{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-column-configuration",
  "type": "registry:component",
  "title": "Column configuration",
  "description": "Show, hide, reorder and resize a table's columns",
  "author": "sudo-kw <https://github.com/sudo-kw>",
  "dependencies": [
    "@gpuix/native@0.11.0",
    "@gpuix/react@0.11.0",
    "effect@4.1.2"
  ],
  "registryDependencies": [
    "https://gpuixui.sudokw.com/gpuix-components.json",
    "https://gpuixui.sudokw.com/gpuix-primitives.json"
  ],
  "files": [
    {
      "path": "packages/data/src/columns/logic.ts",
      "target": "@ui/gpuixui/data/columns/logic.ts",
      "type": "registry:lib",
      "content": "import { Match, pipe } from \"effect\";\nimport * as Arr from \"effect/Array\";\nimport * as HashMap from \"effect/HashMap\";\nimport * as Number from \"effect/Number\";\nimport * as Option from \"effect/Option\";\nimport * as P from \"effect/Predicate\";\nimport * as R from \"effect/Record\";\nimport * as Result from \"effect/Result\";\nimport * as Schema from \"effect/Schema\";\n\nexport const DEFAULT_MIN_COLUMN_WIDTH = 80;\n\nexport const DEFAULT_MAX_COLUMN_WIDTH = 640;\n\nexport const COLUMN_KEY_STEP = 16;\n\nexport interface ColumnDef {\n  readonly id: string;\n\n  readonly label: string;\n\n  readonly width: number;\n\n  readonly minWidth?: number;\n\n  readonly maxWidth?: number;\n}\n\nexport interface Column extends ColumnDef {\n  readonly visible: boolean;\n}\n\nexport interface ColumnState {\n  readonly columns: ReadonlyArray<Column>;\n}\n\nexport interface ColumnWidthBounds {\n  readonly minWidth: number;\n  readonly maxWidth: number;\n}\n\nconst readable = (value: number, fallback: number): number =>\n  globalThis.Number.isFinite(value) ? value : fallback;\n\nexport const widthBounds = (\n  minWidth: number = DEFAULT_MIN_COLUMN_WIDTH,\n  maxWidth: number = DEFAULT_MAX_COLUMN_WIDTH,\n): ColumnWidthBounds => {\n  const min = Math.max(1, readable(minWidth, DEFAULT_MIN_COLUMN_WIDTH));\n  return {\n    minWidth: min,\n    maxWidth: Math.max(min, readable(maxWidth, DEFAULT_MAX_COLUMN_WIDTH)),\n  };\n};\n\nexport const columnBounds = (column: ColumnDef): ColumnWidthBounds =>\n  widthBounds(column.minWidth, column.maxWidth);\n\nexport const clampWidth = (width: number, bounds: ColumnWidthBounds): number =>\n  Number.clamp({ minimum: bounds.minWidth, maximum: bounds.maxWidth })(width);\n\nexport const readableWidth = (\n  width: number,\n  bounds: ColumnWidthBounds,\n): number => (globalThis.Number.isNaN(width) ? bounds.minWidth : width);\n\nexport const clampColumnWidth = (width: number, column: ColumnDef): number =>\n  clampWidth(width, columnBounds(column));\n\nexport const initialColumns = (\n  defs: ReadonlyArray<ColumnDef>,\n): ColumnState => ({\n  columns: Arr.map(defs, (column) => ({\n    ...column,\n    width: clampColumnWidth(column.width, column),\n    visible: true,\n  })),\n});\n\nexport const columnResizeKeys = [\"left\", \"right\", \"home\", \"end\"] as const;\n\nexport const ColumnResizeKey = Schema.Literals(columnResizeKeys);\n\nexport type ColumnResizeKey = typeof ColumnResizeKey.Type;\n\nexport const isColumnResizeKey = Schema.is(ColumnResizeKey);\n\nexport const resizeWidthByKey = (\n  width: number,\n  key: ColumnResizeKey,\n  bounds: ColumnWidthBounds,\n  step: number = COLUMN_KEY_STEP,\n): number => {\n  const by = readable(step, COLUMN_KEY_STEP);\n\n  const from = readableWidth(width, bounds);\n  return pipe(\n    Match.value(key),\n    Match.when(\"left\", () => clampWidth(from - by, bounds)),\n    Match.when(\"right\", () => clampWidth(from + by, bounds)),\n    Match.when(\"home\", () => bounds.minWidth),\n    Match.when(\"end\", () => bounds.maxWidth),\n    Match.exhaustive,\n  );\n};\n\nexport const visibleColumns = (state: ColumnState): ReadonlyArray<Column> =>\n  Arr.filter(state.columns, (column) => column.visible);\n\nexport const hiddenColumns = (state: ColumnState): ReadonlyArray<string> =>\n  Arr.filterMap(state.columns, (column) =>\n    column.visible ? Result.failVoid : Result.succeed(column.id),\n  );\n\nexport const findColumn = (\n  state: ColumnState,\n  id: string,\n): Option.Option<Column> =>\n  Arr.findFirst(state.columns, (column) => column.id === id);\n\nexport const columnWidth = (\n  state: ColumnState,\n  id: string,\n): Option.Option<number> =>\n  Option.map(findColumn(state, id), (column) => column.width);\n\nexport const isColumnVisible = (state: ColumnState, id: string): boolean =>\n  pipe(\n    findColumn(state, id),\n    Option.match({ onNone: () => false, onSome: (column) => column.visible }),\n  );\n\nexport const contentWidth = (state: ColumnState): number =>\n  Arr.reduce(visibleColumns(state), 0, (total, column) => total + column.width);\n\nexport const columnOffset = (\n  state: ColumnState,\n  id: string,\n): Option.Option<number> =>\n  pipe(\n    visibleColumns(state),\n    Arr.findFirstIndex((column) => column.id === id),\n    Option.map((at) =>\n      Arr.reduce(\n        Arr.take(visibleColumns(state), at),\n        0,\n        (total, column) => total + column.width,\n      ),\n    ),\n  );\n\nconst withColumn = (\n  state: ColumnState,\n  id: string,\n  update: (column: Column) => Column,\n): ColumnState => ({\n  columns: Arr.map(state.columns, (column) =>\n    column.id === id ? update(column) : column,\n  ),\n});\n\nexport const setColumnVisible = (\n  state: ColumnState,\n  id: string,\n  visible: boolean,\n): ColumnState => {\n  const target = findColumn(state, id);\n  if (Option.isNone(target)) return state;\n  const current = target.value;\n  if (current.visible === visible) return state;\n  if (!visible && visibleColumns(state).length <= 1) return state;\n  return withColumn(state, id, (column) => ({ ...column, visible }));\n};\n\nexport const toggleColumn = (state: ColumnState, id: string): ColumnState =>\n  pipe(\n    findColumn(state, id),\n    Option.match({\n      onNone: () => state,\n      onSome: (column) => setColumnVisible(state, id, !column.visible),\n    }),\n  );\n\nexport const moveColumn = (\n  state: ColumnState,\n  id: string,\n  delta: number,\n): ColumnState => {\n  const visible = visibleColumns(state);\n  const fromVisible = Arr.findFirstIndex(visible, (column) => column.id === id);\n  if (Option.isNone(fromVisible)) return state;\n  const toVisible = Math.max(\n    0,\n    Math.min(visible.length - 1, fromVisible.value + delta),\n  );\n  if (toVisible === fromVisible.value) return state;\n  const nextVisible = Option.flatMap(\n    Arr.get(visible, fromVisible.value),\n    (column) =>\n      Arr.insertAt(Arr.remove(visible, fromVisible.value), toVisible, column),\n  );\n  if (Option.isNone(nextVisible)) return state;\n  const visibleSlots = Arr.reduce(\n    state.columns,\n    Arr.empty<number>(),\n    (slots, column, index) =>\n      column.visible ? Arr.append(slots, index) : slots,\n  );\n  return {\n    columns: Arr.map(state.columns, (column, index) =>\n      Option.match(\n        Option.flatMap(\n          Arr.findFirstIndex(visibleSlots, (slot) => slot === index),\n          (rank) => Arr.get(nextVisible.value, rank),\n        ),\n        { onNone: () => column, onSome: (next) => next },\n      ),\n    ),\n  };\n};\n\nexport const resizeColumn = (\n  state: ColumnState,\n  id: string,\n  width: number,\n): ColumnState => {\n  if (globalThis.Number.isNaN(width)) return state;\n  const target = findColumn(state, id);\n  if (Option.isNone(target)) return state;\n  const next = clampColumnWidth(width, target.value);\n  if (next === target.value.width) return state;\n  return withColumn(state, id, (column) => ({ ...column, width: next }));\n};\n\nexport const resetColumns = (defs: ReadonlyArray<ColumnDef>): ColumnState =>\n  initialColumns(defs);\n\nexport interface ColumnConfig {\n  readonly order: ReadonlyArray<string>;\n\n  readonly hidden: ReadonlyArray<string>;\n\n  readonly widths: Readonly<Record<string, number>>;\n}\n\nexport const columnConfig = (state: ColumnState): ColumnConfig => ({\n  order: Arr.map(state.columns, (column) => column.id),\n  hidden: hiddenColumns(state),\n  widths: R.fromEntries(\n    Arr.map(state.columns, (column) => [column.id, column.width] as const),\n  ),\n});\n\nconst StoredColumnConfig = Schema.Record(Schema.String, Schema.Unknown);\n\nconst decodeStoredColumnConfig = Schema.decodeUnknownOption(StoredColumnConfig);\n\nconst storedField = (stored: unknown, key: string): unknown =>\n  Option.match(decodeStoredColumnConfig(stored), {\n    onNone: () => undefined,\n    onSome: (record) => Option.getOrUndefined(R.get(record, key)),\n  });\n\nconst storedIds = (stored: unknown): ReadonlyArray<string> =>\n  Arr.isArray(stored) ? Arr.filter(stored, P.isString) : [];\n\nconst storedWidth = (stored: unknown, fallback: number): number =>\n  P.isNumber(stored) && globalThis.Number.isFinite(stored) ? stored : fallback;\n\nexport const restoreColumns = (\n  defs: ReadonlyArray<ColumnDef>,\n  stored: unknown,\n): ColumnState => {\n  const byId = HashMap.fromIterable(\n    Arr.map(defs, (column) => [column.id, column] as const),\n  );\n  const savedOrder = Arr.dedupe(\n    Arr.filter(storedIds(storedField(stored, \"order\")), (id) =>\n      HashMap.has(byId, id),\n    ),\n  );\n  const rest = Arr.filter(\n    Arr.map(defs, (column) => column.id),\n    (id) => !Arr.contains(savedOrder, id),\n  );\n  const widths = Option.getOrElse(\n    decodeStoredColumnConfig(storedField(stored, \"widths\")),\n    () => ({}),\n  );\n  const ordered = Arr.getSomes(\n    Arr.map(Arr.appendAll(savedOrder, rest), (id) => HashMap.get(byId, id)),\n  );\n  const withWidths: ColumnState = {\n    columns: Arr.map(ordered, (column) => ({\n      ...column,\n      width: clampColumnWidth(\n        storedWidth(\n          Option.getOrUndefined(R.get(widths, column.id)),\n          column.width,\n        ),\n        column,\n      ),\n      visible: true,\n    })),\n  };\n  return Arr.reduce(\n    storedIds(storedField(stored, \"hidden\")),\n    withWidths,\n    (state, id) => setColumnVisible(state, id, false),\n  );\n};\n"
    },
    {
      "path": "packages/data/src/columns/menu.tsx",
      "target": "@ui/gpuixui/data/columns/menu.tsx",
      "type": "registry:component",
      "content": "/** @jsxImportSource @gpuix/react */\nimport {\n  DropdownMenu,\n  DropdownMenuCheckboxItem,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger,\n} from \"../../components/dropdown-menu.js\";\nimport { spokenText } from \"../../primitives/core/index.js\";\nimport * as Arr from \"effect/Array\";\nimport * as Option from \"effect/Option\";\nimport { columnName } from \"../sort/logic.js\";\nimport {\n  moveColumn,\n  setColumnVisible,\n  visibleColumns,\n  type Column,\n  type ColumnState,\n} from \"./logic.js\";\n\nexport const DEFAULT_COLUMN_MENU_LABEL = \"Columns\";\n\nexport interface ColumnMenuProps {\n  readonly state: ColumnState;\n\n  readonly onChange: (next: ColumnState) => void;\n\n  readonly onReset?: () => void;\n\n  readonly label?: string;\n\n  readonly contentLabel?: string;\n  readonly disabled?: boolean;\n\n  readonly testId?: string;\n}\n\nconst VisibilityRow = ({\n  column,\n  state,\n  onChange,\n  canHide,\n  testId,\n}: {\n  readonly column: Column;\n  readonly state: ColumnState;\n  readonly onChange: (next: ColumnState) => void;\n  readonly canHide: boolean;\n  readonly testId?: string;\n}) => (\n  <DropdownMenuCheckboxItem\n    checked={column.visible}\n    disabled={column.visible && !canHide}\n    onCheckedChange={(visible) =>\n      onChange(setColumnVisible(state, column.id, visible))\n    }\n    testId={testId === undefined ? undefined : `${testId}-toggle-${column.id}`}\n  >\n    {columnName(column.label, column.id)}\n  </DropdownMenuCheckboxItem>\n);\n\nconst MoveRow = ({\n  column,\n  at,\n  count,\n  state,\n  onChange,\n  testId,\n}: {\n  readonly column: Column;\n  readonly at: number;\n  readonly count: number;\n  readonly state: ColumnState;\n  readonly onChange: (next: ColumnState) => void;\n  readonly testId?: string;\n}) => (\n  <DropdownMenuSub>\n    <DropdownMenuSubTrigger\n      testId={testId === undefined ? undefined : `${testId}-move-${column.id}`}\n    >\n      {`Move ${columnName(column.label, column.id)}`}\n    </DropdownMenuSubTrigger>\n    <DropdownMenuSubContent\n      label={`Move ${columnName(column.label, column.id)}`}\n    >\n      <DropdownMenuItem\n        disabled={at === 0}\n        onSelect={() => onChange(moveColumn(state, column.id, -1))}\n        testId={\n          testId === undefined ? undefined : `${testId}-move-${column.id}-left`\n        }\n      >\n        Left\n      </DropdownMenuItem>\n      <DropdownMenuItem\n        disabled={at === count - 1}\n        onSelect={() => onChange(moveColumn(state, column.id, 1))}\n        testId={\n          testId === undefined ? undefined : `${testId}-move-${column.id}-right`\n        }\n      >\n        Right\n      </DropdownMenuItem>\n    </DropdownMenuSubContent>\n  </DropdownMenuSub>\n);\n\nexport const ColumnMenu = ({\n  state,\n  onChange,\n  onReset,\n  label,\n  contentLabel,\n  disabled,\n  testId,\n}: ColumnMenuProps) => {\n  const visible = visibleColumns(state);\n  const canHide = visible.length > 1;\n\n  const menuLabel = spokenText(\n    Option.fromNullishOr(label),\n    DEFAULT_COLUMN_MENU_LABEL,\n  );\n  const surfaceLabel = spokenText(\n    Option.fromNullishOr(contentLabel),\n    menuLabel,\n  );\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger\n        variant=\"secondary\"\n        size=\"sm\"\n        disabled={disabled}\n        testId={testId}\n      >\n        {menuLabel}\n      </DropdownMenuTrigger>\n      <DropdownMenuContent label={surfaceLabel}>\n        {Arr.map(state.columns, (column) => (\n          <VisibilityRow\n            key={`visible-${column.id}`}\n            column={column}\n            state={state}\n            onChange={onChange}\n            canHide={canHide}\n            testId={testId}\n          />\n        ))}\n        <DropdownMenuSeparator />\n        {Arr.map(visible, (column, at) => (\n          <MoveRow\n            key={`move-${column.id}`}\n            column={column}\n            at={at}\n            count={visible.length}\n            state={state}\n            onChange={onChange}\n            testId={testId}\n          />\n        ))}\n        {Option.match(Option.fromNullishOr(onReset), {\n          onNone: () => null,\n          onSome: (reset) => (\n            <>\n              <DropdownMenuSeparator />\n              <DropdownMenuItem\n                onSelect={reset}\n                testId={testId === undefined ? undefined : `${testId}-reset`}\n              >\n                Reset columns\n              </DropdownMenuItem>\n            </>\n          ),\n        })}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n};\n"
    },
    {
      "path": "packages/data/src/sort/logic.ts",
      "target": "@ui/gpuixui/data/sort/logic.ts",
      "type": "registry:lib",
      "content": "import { spokenText } from \"../../primitives/core/index.js\";\nimport * as Arr from \"effect/Array\";\nimport * as Option from \"effect/Option\";\nimport { pipe } from \"effect\";\n\nexport type SortDirection = \"asc\" | \"desc\";\n\nexport interface SortState {\n  readonly field: string;\n  readonly direction: SortDirection;\n}\n\nexport type SortOrder = ReadonlyArray<SortState>;\n\nexport type SortValue = string | number;\n\nconst flip = (direction: SortDirection): SortDirection =>\n  direction === \"asc\" ? \"desc\" : \"asc\";\n\nexport const toggleSort = (\n  current: Option.Option<SortState>,\n  field: string,\n  initialDirection: SortDirection = \"asc\",\n): SortState =>\n  pipe(\n    current,\n    Option.filter((state) => state.field === field),\n    Option.match({\n      onNone: () => ({ field, direction: initialDirection }),\n      onSome: (state) => ({ field, direction: flip(state.direction) }),\n    }),\n  );\n\nexport const pressSortOrder = (\n  order: SortOrder,\n  field: string,\n  initialDirection: SortDirection = \"asc\",\n  additive: boolean = false,\n): SortOrder => {\n  const at = Arr.findFirstIndex(order, (entry) => entry.field === field);\n  if (additive) {\n    return Option.match(at, {\n      onNone: () => [...order, { field, direction: initialDirection }],\n      onSome: (index) =>\n        Arr.map(order, (entry, position) =>\n          position === index\n            ? { ...entry, direction: flip(entry.direction) }\n            : entry,\n        ),\n    });\n  }\n  return Option.match(Arr.head(order), {\n    onNone: () => [{ field, direction: initialDirection }],\n    onSome: (primary) => [\n      {\n        field,\n        direction:\n          primary.field === field ? flip(primary.direction) : initialDirection,\n      },\n    ],\n  });\n};\n\nexport const sortPosition = (\n  order: SortOrder,\n  field: string,\n): Option.Option<number> =>\n  Option.map(\n    Arr.findFirstIndex(order, (entry) => entry.field === field),\n    (index) => index + 1,\n  );\n\nexport const directionOf = (\n  sort: Option.Option<SortState>,\n  field: string,\n): Option.Option<SortDirection> =>\n  Option.flatMap(sort, (state) =>\n    state.field === field ? Option.some(state.direction) : Option.none(),\n  );\n\nexport const directionIn = (\n  order: SortOrder,\n  field: string,\n): Option.Option<SortDirection> =>\n  Option.map(\n    Arr.findFirst(order, (entry) => entry.field === field),\n    (entry) => entry.direction,\n  );\n\nexport const sortLabel = (\n  label: string,\n  sort: Option.Option<SortState>,\n  field: string,\n): string => sortOrderLabel(label, Option.toArray(sort), field);\n\nexport const columnName = (label: string, field: string): string =>\n  spokenText(Option.fromNullishOr(label), field);\n\nexport const sortOrderLabel = (\n  label: string,\n  order: SortOrder,\n  field: string,\n): string => {\n  const name = columnName(label, field);\n  return Option.match(\n    Option.all([directionIn(order, field), sortPosition(order, field)]),\n    {\n      onNone: () => `${name}, sortable`,\n      onSome: ([direction, position]) => {\n        const state = `${name}, sorted ${direction === \"asc\" ? \"ascending\" : \"descending\"}`;\n        return order.length > 1\n          ? `${state}, sort ${position} of ${order.length}`\n          : state;\n      },\n    },\n  );\n};\n\nexport const sortOrderGlyph = (order: SortOrder, field: string): string =>\n  Option.match(\n    Option.all([directionIn(order, field), sortPosition(order, field)]),\n    {\n      onNone: () => \"\",\n      onSome: ([direction, position]) => {\n        const arrow = direction === \"asc\" ? \"↑\" : \"↓\";\n        return order.length > 1 ? `${arrow}${position}` : arrow;\n      },\n    },\n  );\n"
    }
  ],
  "docs": "[Column configuration](https://gpuixui.sudokw.com/data-display/column-configuration) — These are GPUix native components: the files render through GPUix's reconciler against `@gpuix/react`, not the DOM, and need the per-platform `@gpuix/native` binding. The CLI installs the `@gpuixui/*` runtime the components import as registry items, so the copied tree resolves on its own; wrap it in `ThemeProvider` before it renders.",
  "meta": {
    "url": "/data-display/column-configuration",
    "kind": "component",
    "runtime": "gpuix",
    "registry": "gpuixui"
  }
}