cursedbelt 5.28.0 → 5.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/dist/react/components/ColorField.d.ts.map +1 -1
  2. package/dist/react/components/ColorField.js +2 -1
  3. package/dist/react/components/ColorField.js.map +1 -1
  4. package/dist/react/components/ComboboxField.d.ts.map +1 -1
  5. package/dist/react/components/ComboboxField.js +2 -1
  6. package/dist/react/components/ComboboxField.js.map +1 -1
  7. package/dist/react/components/DataTable/DataTable.d.ts +39 -2
  8. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  9. package/dist/react/components/DataTable/DataTable.js +92 -22
  10. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  11. package/dist/react/components/DataTable/ViewOptionsMenu.d.ts.map +1 -1
  12. package/dist/react/components/DataTable/ViewOptionsMenu.js +20 -7
  13. package/dist/react/components/DataTable/ViewOptionsMenu.js.map +1 -1
  14. package/dist/react/components/DataTable/types.d.ts +11 -1
  15. package/dist/react/components/DataTable/types.d.ts.map +1 -1
  16. package/dist/react/components/DataTable/types.js +6 -0
  17. package/dist/react/components/DataTable/types.js.map +1 -1
  18. package/dist/react/components/DatePicker.d.ts.map +1 -1
  19. package/dist/react/components/DatePicker.js +2 -1
  20. package/dist/react/components/DatePicker.js.map +1 -1
  21. package/dist/react/components/DateTimeField.d.ts.map +1 -1
  22. package/dist/react/components/DateTimeField.js +2 -1
  23. package/dist/react/components/DateTimeField.js.map +1 -1
  24. package/dist/react/components/ExpandableRow.d.ts +56 -0
  25. package/dist/react/components/ExpandableRow.d.ts.map +1 -0
  26. package/dist/react/components/ExpandableRow.js +54 -0
  27. package/dist/react/components/ExpandableRow.js.map +1 -0
  28. package/dist/react/components/Field.d.ts +20 -1
  29. package/dist/react/components/Field.d.ts.map +1 -1
  30. package/dist/react/components/Field.js +24 -11
  31. package/dist/react/components/Field.js.map +1 -1
  32. package/dist/react/components/FieldInput.d.ts +15 -0
  33. package/dist/react/components/FieldInput.d.ts.map +1 -1
  34. package/dist/react/components/FieldInput.js +20 -0
  35. package/dist/react/components/FieldInput.js.map +1 -1
  36. package/dist/react/components/FileDropZone.d.ts +23 -1
  37. package/dist/react/components/FileDropZone.d.ts.map +1 -1
  38. package/dist/react/components/FileDropZone.js +56 -8
  39. package/dist/react/components/FileDropZone.js.map +1 -1
  40. package/dist/react/components/FormPreviewLayout.d.ts +49 -0
  41. package/dist/react/components/FormPreviewLayout.d.ts.map +1 -0
  42. package/dist/react/components/FormPreviewLayout.js +36 -0
  43. package/dist/react/components/FormPreviewLayout.js.map +1 -0
  44. package/dist/react/components/Input.d.ts.map +1 -1
  45. package/dist/react/components/Input.js +3 -1
  46. package/dist/react/components/Input.js.map +1 -1
  47. package/dist/react/components/KeyBadge.d.ts +34 -0
  48. package/dist/react/components/KeyBadge.d.ts.map +1 -0
  49. package/dist/react/components/KeyBadge.js +24 -0
  50. package/dist/react/components/KeyBadge.js.map +1 -0
  51. package/dist/react/components/Modal.d.ts.map +1 -1
  52. package/dist/react/components/Modal.js +35 -9
  53. package/dist/react/components/Modal.js.map +1 -1
  54. package/dist/react/components/NativeSelect.d.ts.map +1 -1
  55. package/dist/react/components/NativeSelect.js +2 -1
  56. package/dist/react/components/NativeSelect.js.map +1 -1
  57. package/dist/react/components/RenderField.d.ts.map +1 -1
  58. package/dist/react/components/RenderField.js +2 -2
  59. package/dist/react/components/RenderField.js.map +1 -1
  60. package/dist/react/components/RevealAction.d.ts +33 -0
  61. package/dist/react/components/RevealAction.d.ts.map +1 -0
  62. package/dist/react/components/RevealAction.js +45 -0
  63. package/dist/react/components/RevealAction.js.map +1 -0
  64. package/dist/react/components/SecretInput.d.ts.map +1 -1
  65. package/dist/react/components/SecretInput.js +2 -1
  66. package/dist/react/components/SecretInput.js.map +1 -1
  67. package/dist/react/components/SelectField.d.ts.map +1 -1
  68. package/dist/react/components/SelectField.js +2 -1
  69. package/dist/react/components/SelectField.js.map +1 -1
  70. package/dist/react/components/SettingsStrip.d.ts +22 -0
  71. package/dist/react/components/SettingsStrip.d.ts.map +1 -0
  72. package/dist/react/components/SettingsStrip.js +18 -0
  73. package/dist/react/components/SettingsStrip.js.map +1 -0
  74. package/dist/react/components/formKit.css +555 -0
  75. package/dist/react/components/formKit.d.ts +12 -0
  76. package/dist/react/components/formKit.d.ts.map +1 -0
  77. package/dist/react/components/formKit.js +14 -0
  78. package/dist/react/components/formKit.js.map +1 -0
  79. package/dist/react/filter-rail/RailSection.d.ts +2 -2
  80. package/dist/react/filter-rail/RailSection.d.ts.map +1 -1
  81. package/dist/react/filter-rail/RailSection.js +6 -61
  82. package/dist/react/filter-rail/RailSection.js.map +1 -1
  83. package/dist/react/index.d.ts +8 -3
  84. package/dist/react/index.d.ts.map +1 -1
  85. package/dist/react/index.js +8 -3
  86. package/dist/react/index.js.map +1 -1
  87. package/dist/react/lib/field.d.ts +43 -0
  88. package/dist/react/lib/field.d.ts.map +1 -1
  89. package/dist/react/lib/field.js +68 -0
  90. package/dist/react/lib/field.js.map +1 -1
  91. package/dist/react/media/PlayerStrip.d.ts +9 -1
  92. package/dist/react/media/PlayerStrip.d.ts.map +1 -1
  93. package/dist/react/media/PlayerStrip.js +29 -2
  94. package/dist/react/media/PlayerStrip.js.map +1 -1
  95. package/dist/react/media-gallery/rememberedPreference.d.ts +2 -0
  96. package/dist/react/media-gallery/rememberedPreference.d.ts.map +1 -0
  97. package/dist/react/media-gallery/rememberedPreference.js +75 -0
  98. package/dist/react/media-gallery/rememberedPreference.js.map +1 -0
  99. package/dist/styles-areas/analytics.css +2 -2
  100. package/dist/styles-areas/core.css +34 -28
  101. package/dist/styles-areas/data-table.css +2 -2
  102. package/dist/styles-areas/fields.css +3 -3
  103. package/dist/styles-areas/layout-engine.css +2 -2
  104. package/dist/styles-areas/nav.css +3 -3
  105. package/dist/styles-areas/wizard.css +2 -2
  106. package/dist/styles-areas/workbench.css +2 -2
  107. package/dist/styles-utilities.css +111 -9
  108. package/package.json +14 -1
  109. package/scripts/styleAreas.ts +2 -0
  110. package/src/react/components/ColorField.tsx +3 -1
  111. package/src/react/components/ComboboxField.tsx +3 -1
  112. package/src/react/components/DataTable/DataTable.spec.tsx +18 -0
  113. package/src/react/components/DataTable/DataTable.tsx +136 -22
  114. package/src/react/components/DataTable/ViewOptionsMenu.tsx +24 -9
  115. package/src/react/components/DataTable/selection.spec.tsx +57 -1
  116. package/src/react/components/DataTable/types.ts +12 -1
  117. package/src/react/components/DataTable/virtualize.spec.tsx +20 -0
  118. package/src/react/components/DatePicker.tsx +3 -1
  119. package/src/react/components/DateTimeField.tsx +3 -1
  120. package/src/react/components/ExpandableRow.tsx +146 -0
  121. package/src/react/components/Field.tsx +89 -13
  122. package/src/react/components/Field.width.spec.tsx +162 -0
  123. package/src/react/components/FieldInput.tsx +30 -0
  124. package/src/react/components/FileDropZone.spec.tsx +65 -0
  125. package/src/react/components/FileDropZone.tsx +83 -5
  126. package/src/react/components/FormPreviewLayout.tsx +94 -0
  127. package/src/react/components/Input.tsx +4 -1
  128. package/src/react/components/KeyBadge.tsx +52 -0
  129. package/src/react/components/Modal.tsx +58 -14
  130. package/src/react/components/NativeSelect.tsx +3 -1
  131. package/src/react/components/RenderField.tsx +2 -1
  132. package/src/react/components/RevealAction.tsx +83 -0
  133. package/src/react/components/SecretInput.tsx +3 -1
  134. package/src/react/components/SelectField.tsx +3 -1
  135. package/src/react/components/SettingsStrip.tsx +37 -0
  136. package/src/react/components/formKit.css +555 -0
  137. package/src/react/components/formKit.spec.tsx +138 -0
  138. package/src/react/components/formKit.ts +19 -0
  139. package/src/react/filter-rail/RailSection.tsx +6 -65
  140. package/src/react/index.ts +20 -2
  141. package/src/react/lib/field.ts +93 -0
  142. package/src/react/media/PlayerStrip.tsx +65 -20
  143. package/src/react/media/playerStrip.spec.tsx +23 -0
  144. package/src/react/media-gallery/rememberedPreference.ts +79 -0
  145. package/src/styles-areas/analytics.css +2 -2
  146. package/src/styles-areas/core.css +34 -28
  147. package/src/styles-areas/data-table.css +2 -2
  148. package/src/styles-areas/fields.css +3 -3
  149. package/src/styles-areas/layout-engine.css +2 -2
  150. package/src/styles-areas/nav.css +3 -3
  151. package/src/styles-areas/wizard.css +2 -2
  152. package/src/styles-areas/workbench.css +2 -2
  153. package/src/styles-utilities.css +111 -9
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  type ColumnDef,
3
3
  type ColumnFiltersState,
4
- flexRender,
5
4
  getCoreRowModel,
6
5
  getFilteredRowModel,
7
6
  getPaginationRowModel,
@@ -26,6 +25,7 @@ import type {
26
25
  import { useVirtualizer } from '@tanstack/react-virtual';
27
26
  import { type ComponentType, Fragment, type Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
28
27
  import { useScrollEdgeFade } from '../../hooks/useScrollEdgeFade.js';
28
+ import { useRememberedPreference } from '../../media-gallery/rememberedPreference.js';
29
29
  import { cn } from '../../lib/cn.js';
30
30
  import { TAP_TARGET_HEIGHT } from '../../lib/tapTarget.js';
31
31
  import { CardTapTarget } from '../CardTapTarget.js';
@@ -54,7 +54,7 @@ import type {
54
54
  DataTableServerWindow,
55
55
  TableViewMode,
56
56
  } from './types.js';
57
- import { resolveColumnAlign } from './types.js';
57
+ import { resolveColumnAlign, SELECT_COLUMN_ID } from './types.js';
58
58
  import { lazyPart } from './lazyPart.js';
59
59
  import type { TablePaginationProps } from './Pagination.js';
60
60
  import type { TableToolbarProps } from './Toolbar.js';
@@ -109,12 +109,31 @@ function PaginationPlaceholder() {
109
109
  );
110
110
  }
111
111
 
112
+ /**
113
+ * Render a header or cell template by CALLING it — never `flexRender`.
114
+ *
115
+ * 🔴 `flexRender` mounts a function template as a component (`<Template {...ctx} />`), so its
116
+ * identity IS the element type. Our templates are closures rebuilt whenever `columns` changes
117
+ * identity, and a consumer's `columns` memo commonly depends on something that changes often
118
+ * (music's depended on its player: play, pause, every track). Each rebuild was a new component
119
+ * type for every visible cell — React unmounted and remounted the whole body, every `<img>` was
120
+ * recreated, and on an iPhone the list's thumbnails flickered for as long as a song played
121
+ * (2026-09-29). Called as a function, a template returns `<CellRenderer>` / `<SortHeader>`,
122
+ * whose types never change, so a column rebuild is an ordinary re-render. Every template this
123
+ * file builds is hook-free — the hooks live in the components it returns — which is what
124
+ * makes the call sound; keep it so. `DataTable.spec.tsx` → "a columns rebuild keeps the cells".
125
+ */
126
+ function renderTemplate<TContext>(template: ReactNode | ((context: TContext) => ReactNode) | undefined, context: TContext): ReactNode {
127
+ return typeof template === 'function' ? template(context) : (template ?? null);
128
+ }
129
+
112
130
  // TanStack's header is a render fn (we build a sortable button), so its column has no
113
131
  // string header for the ViewOptionsMenu to label a toggle with. Carry the label on
114
132
  // `meta` instead — augmenting the base interface so it's typed everywhere.
115
133
  declare module '@tanstack/react-table' {
116
134
  interface ColumnMeta<TData extends RowData, TValue> {
117
135
  label?: string;
136
+ menuLabel?: string;
118
137
  align?: 'left' | 'right';
119
138
  /** ColDef.width verbatim (px number or CSS string, e.g. `'15%'`) — TanStack's
120
139
  * own `size` is number-only, so proportional tracks ride meta instead. */
@@ -187,6 +206,8 @@ export interface DataTableControls {
187
206
  labelOf: (id: string) => string;
188
207
  viewMode?: TableViewMode;
189
208
  setViewMode?: (mode: TableViewMode) => void;
209
+ /** The checkbox column, when the table has one — so the Columns menu can hide it too. */
210
+ selectionColumn?: { visible: boolean; setVisible: (visible: boolean) => void };
190
211
  }
191
212
 
192
213
  export interface DataTableProps<TRow extends Record<string, unknown>> {
@@ -482,6 +503,20 @@ export interface DataTableProps<TRow extends Record<string, unknown>> {
482
503
  */
483
504
  searchable?: boolean;
484
505
  searchPlaceholder?: string;
506
+ /**
507
+ * Tune the DEFAULT toolbar without composing one — which of its controls to draw, and any
508
+ * extra actions. It stays lazy (a composed `toolbar={(t) => <TableToolbar …/>}` pulls the
509
+ * toolbar into the caller's first chunk). `searchable={false}` still removes the search box
510
+ * but, with this set, not the rest of the line.
511
+ *
512
+ * 🔴 Why (owner, 2026-09-30, music): *"Use the cb table that comes with filtering, checkboxes,
513
+ * and sorting out of the box."* The track list opted out of the whole toolbar because it has
514
+ * its own server-side search box, and so lost the filter builder and the Columns menu with it.
515
+ */
516
+ toolbarOptions?: Pick<
517
+ TableToolbarProps<TRow>,
518
+ 'enableColumnVisibility' | 'enableFilterBuilder' | 'enableSortStack' | 'extraActions'
519
+ >;
485
520
  /**
486
521
  * Multi-column sort: each header click puts that column at the FRONT of the
487
522
  * stack and keeps the previous keys behind it as tie-breakers, so the owner's
@@ -552,6 +587,25 @@ export interface DataTableProps<TRow extends Record<string, unknown>> {
552
587
  * owns the box. The row boxes are unchanged (`selectedIds` / `onSelectionChange`).
553
588
  */
554
589
  selectAll?: DataTableSelectAll;
590
+ /**
591
+ * Which columns are shown, CONTROLLED — `{ [ColDef.key]: false }` hides one, and
592
+ * `'__select'` is the checkbox column's key. Omit and the table keeps it
593
+ * internally, seeded from `ColDef.hidden`, exactly as before.
594
+ *
595
+ * 🔴 Why a caller would own it (owner, 2026-09-30, music: *"Allow hiding any column, like
596
+ * the checkbox column"*): a column hidden through the Columns menu that comes back on the
597
+ * next reload was never hidden, it was dismissed. Only the app knows where its per-account
598
+ * preferences live, so the app persists this and hands it back.
599
+ */
600
+ columnVisibility?: VisibilityState;
601
+ onColumnVisibilityChange?: (next: VisibilityState) => void;
602
+ /**
603
+ * Remember the Columns menu's choices under this key — `localStorage`, announced on the
604
+ * preference bus (`onPreferenceWrite`) and re-read on `seedPreferences`, exactly like a
605
+ * `RailSection`'s `persistKey`, so an app that already carries its rail to the server carries
606
+ * its columns with no code of its own. A controlled {@link columnVisibility} wins over it.
607
+ */
608
+ columnsPersistKey?: string;
555
609
  /**
556
610
  * Attributes and handlers for one body ROW, merged onto it after the table's own —
557
611
  * `className` joins, `style` spreads, and a handler the table also sets runs AFTER the
@@ -586,6 +640,19 @@ export interface DataTableProps<TRow extends Record<string, unknown>> {
586
640
  selectionSummary?: (selectedRows: TRow[]) => ReactNode;
587
641
  }
588
642
 
643
+ /** A remembered column-visibility value, or `null` for anything that is not one — a stored
644
+ * value from an older shape must fall back to the defaults, never throw a table off the page. */
645
+ function parseVisibility(stored: string | null): VisibilityState | null {
646
+ if (!stored) return null;
647
+ try {
648
+ const value: unknown = JSON.parse(stored);
649
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
650
+ return Object.fromEntries(Object.entries(value).filter(([, shown]) => typeof shown === 'boolean')) as VisibilityState;
651
+ } catch {
652
+ return null;
653
+ }
654
+ }
655
+
589
656
  /** Build an OnChangeFn that updates local state AND notifies an optional listener. */
590
657
  function chainUpdater<T>(
591
658
  set: Dispatch<SetStateAction<T>>,
@@ -850,6 +917,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
850
917
  cardMinWidth,
851
918
  searchable = true,
852
919
  searchPlaceholder,
920
+ toolbarOptions,
853
921
  multiSort = true,
854
922
  filterQuery: filterQueryProp,
855
923
  onFilterQueryChange,
@@ -863,6 +931,9 @@ export function DataTable<TRow extends Record<string, unknown>>({
863
931
  selectionSummary,
864
932
  selectAll,
865
933
  getRowProps,
934
+ columnVisibility: columnVisibilityProp,
935
+ onColumnVisibilityChange,
936
+ columnsPersistKey,
866
937
  }: DataTableProps<TRow>) {
867
938
  // Vertical cell padding for the wide grid tier — the one lever the density knob
868
939
  // pulls (headers + body cells stay aligned by sharing it).
@@ -875,9 +946,30 @@ export function DataTable<TRow extends Record<string, unknown>>({
875
946
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
876
947
  const [globalFilter, setGlobalFilter] = useState('');
877
948
 
878
- const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() =>
949
+ const [ownColumnVisibility, setOwnColumnVisibility] = useState<VisibilityState>(() =>
879
950
  Object.fromEntries(columns.filter((c) => c.hidden).map((c) => [c.key, false])),
880
951
  );
952
+ const [rememberedColumns, setRememberedColumns] = useRememberedPreference(columnsPersistKey);
953
+ const remembered = useMemo(() => parseVisibility(rememberedColumns), [rememberedColumns]);
954
+ const columnVisibility =
955
+ columnVisibilityProp ?? (remembered ? { ...ownColumnVisibility, ...remembered } : ownColumnVisibility);
956
+ const columnVisibilityRef = useRef(columnVisibility);
957
+ columnVisibilityRef.current = columnVisibility;
958
+ /** Every visibility write — TanStack's own, the Columns menu's, the checkbox column's. Computed
959
+ * outside a state updater for the same reason as `applySorting`: under a controlled value
960
+ * there is no state to update, and a notify inside an updater fires twice in StrictMode. */
961
+ const updateColumnVisibility = useCallback<OnChangeFn<VisibilityState>>(
962
+ (updater) => {
963
+ const next = typeof updater === 'function' ? updater(columnVisibilityRef.current) : updater;
964
+ columnVisibilityRef.current = next;
965
+ if (columnVisibilityProp === undefined) {
966
+ setOwnColumnVisibility(next);
967
+ if (columnsPersistKey) setRememberedColumns(JSON.stringify(next));
968
+ }
969
+ onColumnVisibilityChange?.(next);
970
+ },
971
+ [columnVisibilityProp, columnsPersistKey, onColumnVisibilityChange, setRememberedColumns],
972
+ );
881
973
  const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize });
882
974
  // `null` = every column, and it STAYS null as columns come and go — see SearchBox.
883
975
  const [searchScope, setSearchScope] = useState<string[] | null>(null);
@@ -936,10 +1028,12 @@ export function DataTable<TRow extends Record<string, unknown>>({
936
1028
  */
937
1029
  const filteredData = useMemo(
938
1030
  () =>
939
- serverSide
1031
+ // A `serverWindow` holds a hundred rows of thousands: narrowing THEM would answer a
1032
+ // question about the loaded page, not the list. The server applies it — the table reports.
1033
+ serverSide || serverWindow
940
1034
  ? data
941
1035
  : data.filter((row) => evaluateFilterQuery(row, filterQuery, (r, key) => readPath(r, key))),
942
- [data, filterQuery, serverSide],
1036
+ [data, filterQuery, serverSide, serverWindow],
943
1037
  );
944
1038
 
945
1039
  /**
@@ -981,6 +1075,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
981
1075
  // preference — `resolveColumnAlign` derives it; an explicit `align` overrides.
982
1076
  meta: {
983
1077
  label: col.label,
1078
+ menuLabel: col.menuLabel,
984
1079
  align: resolveColumnAlign(col),
985
1080
  width: col.width,
986
1081
  minWidth: col.minWidth,
@@ -1042,7 +1137,9 @@ export function DataTable<TRow extends Record<string, unknown>>({
1042
1137
  setFilterQuery,
1043
1138
  // `alwaysVisible` columns are exempt from HIDING, not from filtering — the
1044
1139
  // builder's universe is every column that carries data.
1045
- filterColumns: liveColumns.map((col) => ({ key: col.key, label: col.label })),
1140
+ filterColumns: liveColumns
1141
+ .filter((col) => col.filterable ?? true)
1142
+ .map((col) => ({ key: col.key, label: col.label })),
1046
1143
  searchScope,
1047
1144
  setSearchScope,
1048
1145
  searchColumns: liveColumns
@@ -1056,6 +1153,14 @@ export function DataTable<TRow extends Record<string, unknown>>({
1056
1153
  labelOf,
1057
1154
  viewMode,
1058
1155
  setViewMode: onViewModeChange,
1156
+ selectionColumn:
1157
+ selectedIds !== undefined
1158
+ ? {
1159
+ visible: columnVisibility[SELECT_COLUMN_ID] !== false,
1160
+ setVisible: (visible) =>
1161
+ updateColumnVisibility((prev) => ({ ...prev, [SELECT_COLUMN_ID]: visible })),
1162
+ }
1163
+ : undefined,
1059
1164
  }),
1060
1165
  [
1061
1166
  applySorting,
@@ -1069,6 +1174,9 @@ export function DataTable<TRow extends Record<string, unknown>>({
1069
1174
  setFilterQuery,
1070
1175
  sorting,
1071
1176
  viewMode,
1177
+ selectedIds,
1178
+ columnVisibility,
1179
+ updateColumnVisibility,
1072
1180
  ],
1073
1181
  );
1074
1182
 
@@ -1095,6 +1203,10 @@ export function DataTable<TRow extends Record<string, unknown>>({
1095
1203
  const paginationHidden = hidePagination || virtualEnabled;
1096
1204
 
1097
1205
  const selectionEnabled = selectedIds !== undefined;
1206
+ /** The checkbox column is ON and not hidden through the Columns menu. Hiding it hides the
1207
+ * boxes, never the selection: a row click that selects still does, and the caller's
1208
+ * selection bar still counts it. */
1209
+ const selectionShown = selectionEnabled && columnVisibility[SELECT_COLUMN_ID] !== false;
1098
1210
  /**
1099
1211
  * Without `getRowId`, TanStack ids a row by its index in the array it was
1100
1212
  * handed — which is `filteredData`, so the builder narrowing the rows would
@@ -1128,7 +1240,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
1128
1240
  const text = typeof next === 'string' ? next : ((next as TableSearchState | undefined)?.text ?? '');
1129
1241
  setSearchText(text);
1130
1242
  },
1131
- onColumnVisibilityChange: chainUpdater(setColumnVisibility),
1243
+ onColumnVisibilityChange: updateColumnVisibility,
1132
1244
  onPaginationChange: chainUpdater(setPagination, onPaginationChange),
1133
1245
  // 🔴 Not `'includesString'` any more. That built-in is `String(value)`, and
1134
1246
  // `String(new Date())` / `String(['rock','80s'])` are not the text the
@@ -1462,15 +1574,17 @@ export function DataTable<TRow extends Record<string, unknown>>({
1462
1574
  // passes its own gets exactly that, and `TableToolbar` reads the same
1463
1575
  // controls off `table.options.meta`, so composing a toolbar is never
1464
1576
  // the thing that costs you the search box.
1465
- (searchable ? (
1577
+ (searchable || toolbarOptions ? (
1466
1578
  <ToolbarPart
1467
1579
  table={table}
1468
1580
  searchPlaceholder={searchPlaceholder}
1581
+ enableSearch={searchable}
1582
+ {...toolbarOptions}
1469
1583
  fallback={<ToolbarPlaceholder />}
1470
1584
  />
1471
1585
  ) : null));
1472
1586
  const visibleColumnCount =
1473
- table.getVisibleLeafColumns().length + (hasActions ? 1 : 0) + (selectionEnabled ? 1 : 0);
1587
+ table.getVisibleLeafColumns().length + (hasActions ? 1 : 0) + (selectionShown ? 1 : 0);
1474
1588
  // Cards track the SAME live visibility state as the grid (ViewOptionsMenu toggles
1475
1589
  // apply to both) — not just the ColDefs' initial `hidden` flags.
1476
1590
  const cardColumns = liveColumns.filter((c) => table.getColumn(c.key)?.getIsVisible() ?? true);
@@ -1485,7 +1599,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
1485
1599
  const tableMinWidth = fixedLayout
1486
1600
  ? Math.max(
1487
1601
  minTableWidth ?? 0,
1488
- declaredTrackFloor(cardColumns, hasActions) + (selectionEnabled ? SELECT_TRACK_PX : 0),
1602
+ declaredTrackFloor(cardColumns, hasActions) + (selectionShown ? SELECT_TRACK_PX : 0),
1489
1603
  )
1490
1604
  : minTableWidth;
1491
1605
 
@@ -1536,7 +1650,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
1536
1650
  ladder inside it would only fight the container query. ── */}
1537
1651
  {/* Select-all for the tiers with no header row to carry it: the phone
1538
1652
  cards, and the card view at every width. */}
1539
- {selectionEnabled && (viewMode === 'card' || mobileCards) ? (
1653
+ {selectionShown && (viewMode === 'card' || mobileCards) ? (
1540
1654
  <div
1541
1655
  data-cb-table-select-all=''
1542
1656
  className={cn('flex items-center px-2 py-1', viewMode !== 'card' && '@xl:hidden')}
@@ -1552,7 +1666,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
1552
1666
  onRowClick={onRowClick}
1553
1667
  dragPropsFor={getRowDrag ? dragPropsFor : undefined}
1554
1668
  selectFor={
1555
- selectionEnabled
1669
+ selectionShown
1556
1670
  ? (_row, index) => {
1557
1671
  const tsRow = rows[index];
1558
1672
  return tsRow ? rowCheckbox(tsRow) : null;
@@ -1575,7 +1689,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
1575
1689
  className={rowClassName?.(row.original)}
1576
1690
  onRowClick={onRowClick}
1577
1691
  dragProps={mergeRowProps(dragPropsFor(row.original), getRowProps?.(row.original, windowStart + row.index))}
1578
- select={selectionEnabled ? rowCheckbox(row) : undefined}
1692
+ select={selectionShown ? rowCheckbox(row) : undefined}
1579
1693
  actions={
1580
1694
  hasActions ? (
1581
1695
  <RowActions
@@ -1621,7 +1735,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
1621
1735
  // absent (the header) and every preceding lane drifts ~32px off
1622
1736
  // its label — the v4 judge's tablet misalignment finding.
1623
1737
  const template = [
1624
- ...(selectionEnabled ? ['44px'] : []),
1738
+ ...(selectionShown ? ['44px'] : []),
1625
1739
  'minmax(0,1fr)',
1626
1740
  ...rest.map(trackOf),
1627
1741
  ...(hasActions ? ['32px'] : []),
@@ -1636,7 +1750,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
1636
1750
  className='grid items-center gap-3 border-b border-border px-4 py-2 text-caption font-bold uppercase tracking-wider text-muted-foreground'
1637
1751
  style={{ gridTemplateColumns: template }}
1638
1752
  >
1639
- {selectionEnabled ? <span className='flex justify-center'>{headerCheckbox()}</span> : null}
1753
+ {selectionShown ? <span className='flex justify-center'>{headerCheckbox()}</span> : null}
1640
1754
  <span>{title?.label ?? ''}</span>
1641
1755
  {rest.map((col) => (
1642
1756
  <span key={col.key} className={cn(resolveColumnAlign(col) === 'right' && 'text-right')}>
@@ -1665,7 +1779,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
1665
1779
  getRowProps?.(row.original, windowStart + row.index),
1666
1780
  )}
1667
1781
  >
1668
- {selectionEnabled ? (
1782
+ {selectionShown ? (
1669
1783
  <div {...selectCellProps(row)} className='-my-3 flex self-stretch items-center justify-center'>
1670
1784
  {rowCheckbox(row)}
1671
1785
  </div>
@@ -1781,7 +1895,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
1781
1895
  // next header slides over the frozen one. It is opaque
1782
1896
  // for the same reason the body cells are.
1783
1897
  frozen &&
1784
- (selectionEnabled
1898
+ (selectionShown
1785
1899
  ? 'sticky left-11 bg-card shadow-[1px_0_0_0_var(--color-border)]'
1786
1900
  : 'sticky left-0 bg-card shadow-[1px_0_0_0_var(--color-border)]'),
1787
1901
  frozen && (stickyHeader ? 'z-30' : 'z-20'),
@@ -1790,7 +1904,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
1790
1904
  >
1791
1905
  {h.isPlaceholder
1792
1906
  ? null
1793
- : flexRender(h.column.columnDef.header, h.getContext())}
1907
+ : renderTemplate(h.column.columnDef.header, h.getContext())}
1794
1908
  </th>
1795
1909
  );
1796
1910
  };
@@ -1798,7 +1912,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
1798
1912
  return (
1799
1913
  <>
1800
1914
  {hg.headers.filter(isLeading).map((h) => renderHeader(h, -1))}
1801
- {selectionEnabled ? (
1915
+ {selectionShown ? (
1802
1916
  <th
1803
1917
  scope='col'
1804
1918
  className={cn(
@@ -1929,7 +2043,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
1929
2043
  'px-4 align-middle',
1930
2044
  cellPadY,
1931
2045
  frozen &&
1932
- (selectionEnabled
2046
+ (selectionShown
1933
2047
  ? 'sticky left-11 z-10 bg-[var(--cb-frozen-bg)] shadow-[1px_0_0_0_var(--color-border)]'
1934
2048
  : 'sticky left-0 z-10 bg-[var(--cb-frozen-bg)] shadow-[1px_0_0_0_var(--color-border)]'),
1935
2049
  // A rigid track cannot widen for its content, so an
@@ -1945,14 +2059,14 @@ export function DataTable<TRow extends Record<string, unknown>>({
1945
2059
  HIDE_BELOW_CLASS[cell.column.columnDef.meta.hideBelow],
1946
2060
  )}
1947
2061
  >
1948
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
2062
+ {renderTemplate(cell.column.columnDef.cell, cell.getContext())}
1949
2063
  </td>
1950
2064
  );
1951
2065
  };
1952
2066
  return (
1953
2067
  <>
1954
2068
  {cells.filter(isLeading).map((cell) => renderCell(cell, -1))}
1955
- {selectionEnabled ? (
2069
+ {selectionShown ? (
1956
2070
  <td
1957
2071
  {...selectCellProps(row)}
1958
2072
  style={
@@ -4,6 +4,7 @@ import { useState } from 'react';
4
4
  import { Button } from '../Button.js';
5
5
  import { Popover } from '../Popover.js';
6
6
  import { ColumnChecklist } from './ColumnChecklist.js';
7
+ import { SELECT_COLUMN_ID } from './types.js';
7
8
 
8
9
  export interface ViewOptionsMenuProps<TRow> {
9
10
  table: Table<TRow>;
@@ -29,8 +30,12 @@ export interface ViewOptionsMenuProps<TRow> {
29
30
  export function ViewOptionsMenu<TRow>({ table, label = 'Columns' }: ViewOptionsMenuProps<TRow>) {
30
31
  const [open, setOpen] = useState(false);
31
32
  const columns = table.getAllColumns().filter((c) => c.getCanHide());
32
- if (!columns.length) return null;
33
- const hiddenCount = columns.filter((c) => !c.getIsVisible()).length;
33
+ // The checkbox column is not a TanStack column, so it comes off the table's controls — and it
34
+ // is listed FIRST, where it sits (owner, 2026-09-30: *"Allow hiding any column, like the
35
+ // checkbox column"*).
36
+ const selection = table.options.meta?.cbControls?.selectionColumn;
37
+ if (!columns.length && !selection) return null;
38
+ const hiddenCount = columns.filter((c) => !c.getIsVisible()).length + (selection && !selection.visible ? 1 : 0);
34
39
 
35
40
  return (
36
41
  <Popover
@@ -60,14 +65,24 @@ export function ViewOptionsMenu<TRow>({ table, label = 'Columns' }: ViewOptionsM
60
65
  aria-label='Visible columns'
61
66
  selectAllLabel='Show all'
62
67
  selectNoneLabel='Hide all'
63
- items={columns.map((col) => ({
64
- key: col.id,
65
- label: col.columnDef.meta?.label ?? col.id,
66
- checked: col.getIsVisible(),
67
- }))}
68
- onToggle={(key) => table.getColumn(key)?.toggleVisibility()}
69
- onSelectAll={() => table.toggleAllColumnsVisible(true)}
68
+ items={[
69
+ ...(selection ? [{ key: SELECT_COLUMN_ID, label: 'Checkboxes', checked: selection.visible }] : []),
70
+ ...columns.map((col) => ({
71
+ key: col.id,
72
+ // A label-less column (a cover, a handle) still needs a name in this list.
73
+ label: col.columnDef.meta?.menuLabel || col.columnDef.meta?.label || col.id,
74
+ checked: col.getIsVisible(),
75
+ })),
76
+ ]}
77
+ onToggle={(key) =>
78
+ key === SELECT_COLUMN_ID ? selection?.setVisible(!selection.visible) : table.getColumn(key)?.toggleVisibility()
79
+ }
80
+ onSelectAll={() => {
81
+ table.toggleAllColumnsVisible(true);
82
+ selection?.setVisible(true);
83
+ }}
70
84
  onSelectNone={() => {
85
+ selection?.setVisible(false);
71
86
  // Not `toggleAllColumnsVisible(false)`: that hides the pinned
72
87
  // (`alwaysVisible`) columns too in TanStack's implementation, and a
73
88
  // table with literally no columns is a blank rectangle the reader
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeAll, describe, expect, test } from 'bun:test';
2
- import { cleanup, fireEvent, render } from '@testing-library/react';
2
+ import { cleanup, fireEvent, render, within } from '@testing-library/react';
3
3
  import { useState } from 'react';
4
4
  import { hoverHint } from '../../../testing/tooltip.js';
5
5
  import { DataTable, type DataTableProps, preloadDataTableParts } from './DataTable.js';
@@ -191,3 +191,59 @@ describe('DataTable row selection', () => {
191
191
  expect(list.querySelectorAll('[role="checkbox"]').length).toBe(GAMES.length + 1);
192
192
  });
193
193
  });
194
+
195
+ describe('the checkbox column can be hidden (owner, 2026-09-30)', () => {
196
+ test('🔴 the Columns menu lists "Checkboxes", and unticking it removes the boxes but keeps the selection', () => {
197
+ const reported: Record<string, boolean>[] = [];
198
+ const changes: string[][] = [];
199
+ const { container, getByRole, getByLabelText } = render(
200
+ <Harness
201
+ initial={['g2']}
202
+ onChange={(next) => changes.push([...next])}
203
+ onColumnVisibilityChange={(next) => reported.push(next)}
204
+ />,
205
+ );
206
+ expect(headerBox(container)).not.toBeNull();
207
+ fireEvent.click(getByRole('button', { name: /^columns$/i }));
208
+ fireEvent.click(within(getByLabelText('Visible columns')).getByRole('menuitemcheckbox', { name: 'Checkboxes' }));
209
+ expect(reported.at(-1)).toEqual({ __select: false });
210
+ expect(grid(container).querySelector('[role="checkbox"]')).toBeNull();
211
+ expect(container.querySelectorAll('thead th').length).toBe(COLUMNS.length);
212
+ // Hidden, not cleared: nothing reported a new selection.
213
+ expect(changes).toEqual([]);
214
+ });
215
+
216
+ test('a CONTROLLED visibility is what the table draws — the app owns where it is kept', () => {
217
+ const { container } = render(<Harness columnVisibility={{ __select: false, sizeMb: false }} />);
218
+ expect(grid(container).querySelector('[role="checkbox"]')).toBeNull();
219
+ expect(container.querySelectorAll('thead th').length).toBe(1);
220
+ });
221
+ });
222
+
223
+ describe('columnsPersistKey', () => {
224
+ test('a hidden column is still hidden after a remount, and the write is announced for the server', async () => {
225
+ const { onPreferenceWrite } = await import('../../media-gallery/preferenceBus.js');
226
+ const heard: [string, string | null][] = [];
227
+ const off = onPreferenceWrite((key, value) => heard.push([key, value]));
228
+ localStorage.removeItem('spec:columns');
229
+ const first = render(<Harness columnsPersistKey='spec:columns' />);
230
+ fireEvent.click(first.getByRole('button', { name: /^columns$/i }));
231
+ fireEvent.click(within(first.getByLabelText('Visible columns')).getByRole('menuitemcheckbox', { name: 'Size' }));
232
+ expect(heard.at(-1)).toEqual(['spec:columns', JSON.stringify({ sizeMb: false })]);
233
+ first.unmount();
234
+ const second = render(<Harness columnsPersistKey='spec:columns' />);
235
+ expect(second.container.querySelectorAll('thead th').length).toBe(COLUMNS.length); // checkbox + Title
236
+ off();
237
+ });
238
+ });
239
+
240
+ describe('toolbarOptions', () => {
241
+ test('searchable={false} with toolbarOptions keeps the Columns menu and the filter builder, without a search box', () => {
242
+ const { queryByRole, getByRole } = render(
243
+ <Harness searchable={false} toolbarOptions={{ enableSortStack: false }} />,
244
+ );
245
+ expect(getByRole('button', { name: /^columns$/i })).not.toBeNull();
246
+ expect(getByRole('button', { name: /filter/i })).not.toBeNull();
247
+ expect(queryByRole('searchbox')).toBeNull();
248
+ });
249
+ });
@@ -20,8 +20,12 @@ export interface ColDef<TRow = Record<string, unknown>> {
20
20
  /** Registry type — dispatched to `CELL_REGISTRY` (display) + `FIELD_REGISTRY` (edit). */
21
21
  type: string;
22
22
  sortable?: boolean;
23
- /** Include this column when the global search filters rows (default: true). */
23
+ /** Include this column when the search box or the filter builder narrows rows (default:
24
+ * true). `false` for a column that draws no data — a cover, a drag handle, a row control. */
24
25
  filterable?: boolean;
26
+ /** The column's name in the Columns menu when its header has no text (a cover, a handle) —
27
+ * a checklist row with no name is a switch nobody can find. Default: `label`, then `key`. */
28
+ menuLabel?: string;
25
29
  /** Start hidden (toggle back on via {@link ViewOptionsMenu}). */
26
30
  hidden?: boolean;
27
31
  /** Column track width. A number is px; a string is any CSS width — use
@@ -206,3 +210,10 @@ export interface DataTableHandle {
206
210
  options?: { align?: 'start' | 'center' | 'end' | 'auto'; behavior?: 'auto' | 'smooth' },
207
211
  ): number | null;
208
212
  }
213
+
214
+ /**
215
+ * The checkbox column's key in `columnVisibility`. It is not a TanStack column — it has no
216
+ * data and no header menu of its own — so it cannot be hidden through `getAllColumns()`; the
217
+ * Columns menu lists it by this key instead, beside the real ones.
218
+ */
219
+ export const SELECT_COLUMN_ID = '__select';
@@ -144,3 +144,23 @@ describe('serverWindow (forge task 2300)', () => {
144
144
  expect(screen.queryByRole('button', { name: /next page|previous page/i })).toBeNull();
145
145
  });
146
146
  });
147
+
148
+ describe('serverWindow + the filter builder', () => {
149
+ test('🔴 the builder is REPORTED, never applied to the loaded window', () => {
150
+ const rows = Array.from({ length: 3 }, (_, i) => ({ id: `r${i}`, name: `Row ${i}` }));
151
+ render(
152
+ <DataTable<Row>
153
+ columns={COLUMNS}
154
+ data={rows}
155
+ getRowId={(r) => r.id}
156
+ serverWindow={{ start: 0, total: 3, rowHeight: 50 }}
157
+ filterQuery={{ groups: [{ id: 'g', conditions: [{ id: 'c', column: 'name', op: 'contains', value: 'Row 1' }] }] }}
158
+ />,
159
+ );
160
+ // A window is a page of what the SERVER matched; narrowing it again would hide rows the
161
+ // server already decided belong, and say nothing about the thousands not loaded.
162
+ // (Each row is drawn once per tier — grid, list, cards — hence `getAllByText`.)
163
+ expect(screen.getAllByText('Row 0').length).toBeGreaterThan(0);
164
+ expect(screen.getAllByText('Row 2').length).toBeGreaterThan(0);
165
+ });
166
+ });
@@ -11,7 +11,7 @@ import {
11
11
  localDateToIso,
12
12
  ThemedDayButton,
13
13
  } from '../lib/daypicker.js';
14
- import { type ControlSize, fieldControlClass } from '../lib/field.js';
14
+ import { type ControlSize, declareFieldWidth, fieldControlClass } from '../lib/field.js';
15
15
  import { Drawer } from './Drawer.js';
16
16
  import { Popover } from './Popover.js';
17
17
 
@@ -147,3 +147,5 @@ export function DatePicker({
147
147
  </Popover>
148
148
  );
149
149
  }
150
+
151
+ declareFieldWidth(DatePicker, 'sm');
@@ -1,5 +1,5 @@
1
1
  import { cn } from '../lib/cn.js';
2
- import { type ControlSize, fieldControlClass } from '../lib/field.js';
2
+ import { type ControlSize, declareFieldWidth, fieldControlClass } from '../lib/field.js';
3
3
  import { DatePicker } from './DatePicker.js';
4
4
 
5
5
  export interface DateTimeFieldProps {
@@ -88,3 +88,5 @@ export function DateTimeField({
88
88
  </div>
89
89
  );
90
90
  }
91
+
92
+ declareFieldWidth(DateTimeField, 'sm');