@ui-organized/svelte-table 0.0.0-stage → 6.0.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 (208) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +99 -2
  3. package/dist/DataTable/DataTable.svelte +62 -0
  4. package/dist/DataTable/DataTable.svelte.d.ts +29 -0
  5. package/dist/DataTable/DataTable.svelte.d.ts.map +1 -0
  6. package/dist/DataTable/DataTable.test.svelte.d.ts +2 -0
  7. package/dist/DataTable/DataTable.test.svelte.d.ts.map +1 -0
  8. package/dist/DataTable/DataTable.test.svelte.js +360 -0
  9. package/dist/DataTable/index.d.ts +2 -0
  10. package/dist/DataTable/index.d.ts.map +1 -0
  11. package/dist/DataTable/index.js +1 -0
  12. package/dist/components/Table/CellEditor.svelte +29 -0
  13. package/dist/components/Table/CellEditor.svelte.d.ts +8 -0
  14. package/dist/components/Table/CellEditor.svelte.d.ts.map +1 -0
  15. package/dist/components/Table/MoveButtons.svelte +60 -0
  16. package/dist/components/Table/MoveButtons.svelte.d.ts +8 -0
  17. package/dist/components/Table/MoveButtons.svelte.d.ts.map +1 -0
  18. package/dist/components/Table/ResizeHandle.svelte +82 -0
  19. package/dist/components/Table/ResizeHandle.svelte.d.ts +28 -0
  20. package/dist/components/Table/ResizeHandle.svelte.d.ts.map +1 -0
  21. package/dist/components/Table/Table.svelte +17 -0
  22. package/dist/components/Table/Table.svelte.d.ts +9 -0
  23. package/dist/components/Table/Table.svelte.d.ts.map +1 -0
  24. package/dist/components/Table/TableBody.svelte +78 -0
  25. package/dist/components/Table/TableBody.svelte.d.ts +30 -0
  26. package/dist/components/Table/TableBody.svelte.d.ts.map +1 -0
  27. package/dist/components/Table/TableCell.svelte +106 -0
  28. package/dist/components/Table/TableCell.svelte.d.ts +34 -0
  29. package/dist/components/Table/TableCell.svelte.d.ts.map +1 -0
  30. package/dist/components/Table/TableFooter.svelte +34 -0
  31. package/dist/components/Table/TableFooter.svelte.d.ts +9 -0
  32. package/dist/components/Table/TableFooter.svelte.d.ts.map +1 -0
  33. package/dist/components/Table/TableHeadCell.svelte +157 -0
  34. package/dist/components/Table/TableHeadCell.svelte.d.ts +29 -0
  35. package/dist/components/Table/TableHeadCell.svelte.d.ts.map +1 -0
  36. package/dist/components/Table/TableHeader.svelte +22 -0
  37. package/dist/components/Table/TableHeader.svelte.d.ts +7 -0
  38. package/dist/components/Table/TableHeader.svelte.d.ts.map +1 -0
  39. package/dist/components/Table/TableRow.svelte +84 -0
  40. package/dist/components/Table/TableRow.svelte.d.ts +32 -0
  41. package/dist/components/Table/TableRow.svelte.d.ts.map +1 -0
  42. package/dist/components/Table/TableViewport.svelte +69 -0
  43. package/dist/components/Table/TableViewport.svelte.d.ts +10 -0
  44. package/dist/components/Table/TableViewport.svelte.d.ts.map +1 -0
  45. package/dist/components/Table/index.d.ts +23 -0
  46. package/dist/components/Table/index.d.ts.map +1 -0
  47. package/dist/components/Table/index.js +22 -0
  48. package/dist/components/TableCards/TableCard.svelte +96 -0
  49. package/dist/components/TableCards/TableCard.svelte.d.ts +31 -0
  50. package/dist/components/TableCards/TableCard.svelte.d.ts.map +1 -0
  51. package/dist/components/TableCards/TableCards.svelte +61 -0
  52. package/dist/components/TableCards/TableCards.svelte.d.ts +7 -0
  53. package/dist/components/TableCards/TableCards.svelte.d.ts.map +1 -0
  54. package/dist/components/TableCards/index.d.ts +3 -0
  55. package/dist/components/TableCards/index.d.ts.map +1 -0
  56. package/dist/components/TableCards/index.js +2 -0
  57. package/dist/components/TableDetailSheet/TableDetailSheet.svelte +130 -0
  58. package/dist/components/TableDetailSheet/TableDetailSheet.svelte.d.ts +8 -0
  59. package/dist/components/TableDetailSheet/TableDetailSheet.svelte.d.ts.map +1 -0
  60. package/dist/components/TableDetailSheet/index.d.ts +2 -0
  61. package/dist/components/TableDetailSheet/index.d.ts.map +1 -0
  62. package/dist/components/TableDetailSheet/index.js +1 -0
  63. package/dist/components/TableFilters/TableFilterAdd.svelte +89 -0
  64. package/dist/components/TableFilters/TableFilterAdd.svelte.d.ts +15 -0
  65. package/dist/components/TableFilters/TableFilterAdd.svelte.d.ts.map +1 -0
  66. package/dist/components/TableFilters/TableFilterChip.svelte +164 -0
  67. package/dist/components/TableFilters/TableFilterChip.svelte.d.ts +9 -0
  68. package/dist/components/TableFilters/TableFilterChip.svelte.d.ts.map +1 -0
  69. package/dist/components/TableFilters/TableFilterEditor.svelte +40 -0
  70. package/dist/components/TableFilters/TableFilterEditor.svelte.d.ts +9 -0
  71. package/dist/components/TableFilters/TableFilterEditor.svelte.d.ts.map +1 -0
  72. package/dist/components/TableFilters/TableFilterValueEditor.svelte +209 -0
  73. package/dist/components/TableFilters/TableFilterValueEditor.svelte.d.ts +10 -0
  74. package/dist/components/TableFilters/TableFilterValueEditor.svelte.d.ts.map +1 -0
  75. package/dist/components/TableFilters/TableFilters.svelte +51 -0
  76. package/dist/components/TableFilters/TableFilters.svelte.d.ts +7 -0
  77. package/dist/components/TableFilters/TableFilters.svelte.d.ts.map +1 -0
  78. package/dist/components/TableFilters/index.d.ts +7 -0
  79. package/dist/components/TableFilters/index.d.ts.map +1 -0
  80. package/dist/components/TableFilters/index.js +6 -0
  81. package/dist/components/TableFilters/useAutoFocusField.d.ts +30 -0
  82. package/dist/components/TableFilters/useAutoFocusField.d.ts.map +1 -0
  83. package/dist/components/TableFilters/useAutoFocusField.js +59 -0
  84. package/dist/components/TablePagination/TablePagination.svelte +61 -0
  85. package/dist/components/TablePagination/TablePagination.svelte.d.ts +9 -0
  86. package/dist/components/TablePagination/TablePagination.svelte.d.ts.map +1 -0
  87. package/dist/components/TablePagination/index.d.ts +2 -0
  88. package/dist/components/TablePagination/index.d.ts.map +1 -0
  89. package/dist/components/TablePagination/index.js +1 -0
  90. package/dist/components/TableRowActions/TableRowActions.svelte +68 -0
  91. package/dist/components/TableRowActions/TableRowActions.svelte.d.ts +37 -0
  92. package/dist/components/TableRowActions/TableRowActions.svelte.d.ts.map +1 -0
  93. package/dist/components/TableRowActions/index.d.ts +2 -0
  94. package/dist/components/TableRowActions/index.d.ts.map +1 -0
  95. package/dist/components/TableRowActions/index.js +1 -0
  96. package/dist/components/TableSelectionBar/TableSelectionBar.svelte +88 -0
  97. package/dist/components/TableSelectionBar/TableSelectionBar.svelte.d.ts +30 -0
  98. package/dist/components/TableSelectionBar/TableSelectionBar.svelte.d.ts.map +1 -0
  99. package/dist/components/TableSelectionBar/index.d.ts +2 -0
  100. package/dist/components/TableSelectionBar/index.d.ts.map +1 -0
  101. package/dist/components/TableSelectionBar/index.js +1 -0
  102. package/dist/components/TableSelectionFooter/TableSelectionFooter.svelte +38 -0
  103. package/dist/components/TableSelectionFooter/TableSelectionFooter.svelte.d.ts +7 -0
  104. package/dist/components/TableSelectionFooter/TableSelectionFooter.svelte.d.ts.map +1 -0
  105. package/dist/components/TableSelectionFooter/index.d.ts +2 -0
  106. package/dist/components/TableSelectionFooter/index.d.ts.map +1 -0
  107. package/dist/components/TableSelectionFooter/index.js +1 -0
  108. package/dist/components/TableSheets/TableFilterSheet.svelte +150 -0
  109. package/dist/components/TableSheets/TableFilterSheet.svelte.d.ts +8 -0
  110. package/dist/components/TableSheets/TableFilterSheet.svelte.d.ts.map +1 -0
  111. package/dist/components/TableSheets/TableFilterSheetCondition.svelte +53 -0
  112. package/dist/components/TableSheets/TableFilterSheetCondition.svelte.d.ts +9 -0
  113. package/dist/components/TableSheets/TableFilterSheetCondition.svelte.d.ts.map +1 -0
  114. package/dist/components/TableSheets/TableSheetButton.svelte +44 -0
  115. package/dist/components/TableSheets/TableSheetButton.svelte.d.ts +15 -0
  116. package/dist/components/TableSheets/TableSheetButton.svelte.d.ts.map +1 -0
  117. package/dist/components/TableSheets/TableSortSheet.svelte +100 -0
  118. package/dist/components/TableSheets/TableSortSheet.svelte.d.ts +8 -0
  119. package/dist/components/TableSheets/TableSortSheet.svelte.d.ts.map +1 -0
  120. package/dist/components/TableSheets/index.d.ts +4 -0
  121. package/dist/components/TableSheets/index.d.ts.map +1 -0
  122. package/dist/components/TableSheets/index.js +3 -0
  123. package/dist/components/TableStates/TableEmpty.svelte +35 -0
  124. package/dist/components/TableStates/TableEmpty.svelte.d.ts +11 -0
  125. package/dist/components/TableStates/TableEmpty.svelte.d.ts.map +1 -0
  126. package/dist/components/TableStates/TableError.svelte +26 -0
  127. package/dist/components/TableStates/TableError.svelte.d.ts +10 -0
  128. package/dist/components/TableStates/TableError.svelte.d.ts.map +1 -0
  129. package/dist/components/TableStates/TableLoading.svelte +33 -0
  130. package/dist/components/TableStates/TableLoading.svelte.d.ts +8 -0
  131. package/dist/components/TableStates/TableLoading.svelte.d.ts.map +1 -0
  132. package/dist/components/TableStates/index.d.ts +4 -0
  133. package/dist/components/TableStates/index.d.ts.map +1 -0
  134. package/dist/components/TableStates/index.js +3 -0
  135. package/dist/components/TableSubBar/TableSubBar.svelte +34 -0
  136. package/dist/components/TableSubBar/TableSubBar.svelte.d.ts +7 -0
  137. package/dist/components/TableSubBar/TableSubBar.svelte.d.ts.map +1 -0
  138. package/dist/components/TableSubBar/index.d.ts +2 -0
  139. package/dist/components/TableSubBar/index.d.ts.map +1 -0
  140. package/dist/components/TableSubBar/index.js +1 -0
  141. package/dist/components/TableToolbar/TableActions.svelte +40 -0
  142. package/dist/components/TableToolbar/TableActions.svelte.d.ts +9 -0
  143. package/dist/components/TableToolbar/TableActions.svelte.d.ts.map +1 -0
  144. package/dist/components/TableToolbar/TableExportMenu.svelte +60 -0
  145. package/dist/components/TableToolbar/TableExportMenu.svelte.d.ts +8 -0
  146. package/dist/components/TableToolbar/TableExportMenu.svelte.d.ts.map +1 -0
  147. package/dist/components/TableToolbar/TableReorderToggle.svelte +27 -0
  148. package/dist/components/TableToolbar/TableReorderToggle.svelte.d.ts +7 -0
  149. package/dist/components/TableToolbar/TableReorderToggle.svelte.d.ts.map +1 -0
  150. package/dist/components/TableToolbar/TableScrollButtons.svelte +54 -0
  151. package/dist/components/TableToolbar/TableScrollButtons.svelte.d.ts +9 -0
  152. package/dist/components/TableToolbar/TableScrollButtons.svelte.d.ts.map +1 -0
  153. package/dist/components/TableToolbar/TableSearch.svelte +24 -0
  154. package/dist/components/TableToolbar/TableSearch.svelte.d.ts +9 -0
  155. package/dist/components/TableToolbar/TableSearch.svelte.d.ts.map +1 -0
  156. package/dist/components/TableToolbar/TableSortMenu.svelte +97 -0
  157. package/dist/components/TableToolbar/TableSortMenu.svelte.d.ts +8 -0
  158. package/dist/components/TableToolbar/TableSortMenu.svelte.d.ts.map +1 -0
  159. package/dist/components/TableToolbar/TableToolbar.svelte +113 -0
  160. package/dist/components/TableToolbar/TableToolbar.svelte.d.ts +9 -0
  161. package/dist/components/TableToolbar/TableToolbar.svelte.d.ts.map +1 -0
  162. package/dist/components/TableToolbar/TableViewOptions.svelte +69 -0
  163. package/dist/components/TableToolbar/TableViewOptions.svelte.d.ts +8 -0
  164. package/dist/components/TableToolbar/TableViewOptions.svelte.d.ts.map +1 -0
  165. package/dist/components/TableToolbar/index.d.ts +9 -0
  166. package/dist/components/TableToolbar/index.d.ts.map +1 -0
  167. package/dist/components/TableToolbar/index.js +8 -0
  168. package/dist/core/ActionsHeader.svelte +8 -0
  169. package/dist/core/ActionsHeader.svelte.d.ts +27 -0
  170. package/dist/core/ActionsHeader.svelte.d.ts.map +1 -0
  171. package/dist/core/SelectAllCell.svelte +21 -0
  172. package/dist/core/SelectAllCell.svelte.d.ts +19 -0
  173. package/dist/core/SelectAllCell.svelte.d.ts.map +1 -0
  174. package/dist/core/SelectCell.svelte +37 -0
  175. package/dist/core/SelectCell.svelte.d.ts +8 -0
  176. package/dist/core/SelectCell.svelte.d.ts.map +1 -0
  177. package/dist/core/createDataTable.svelte.d.ts +4 -0
  178. package/dist/core/createDataTable.svelte.d.ts.map +1 -0
  179. package/dist/core/createDataTable.svelte.js +1152 -0
  180. package/dist/core/createDataTable.test.svelte.d.ts +2 -0
  181. package/dist/core/createDataTable.test.svelte.d.ts.map +1 -0
  182. package/dist/core/createDataTable.test.svelte.js +156 -0
  183. package/dist/core/createDeferred.svelte.d.ts +38 -0
  184. package/dist/core/createDeferred.svelte.d.ts.map +1 -0
  185. package/dist/core/createDeferred.svelte.js +81 -0
  186. package/dist/core/elementRef.d.ts +17 -0
  187. package/dist/core/elementRef.d.ts.map +1 -0
  188. package/dist/core/elementRef.js +20 -0
  189. package/dist/core/props.d.ts +3 -0
  190. package/dist/core/props.d.ts.map +1 -0
  191. package/dist/core/props.js +46 -0
  192. package/dist/core/systemColumns.d.ts +16 -0
  193. package/dist/core/systemColumns.d.ts.map +1 -0
  194. package/dist/core/systemColumns.js +53 -0
  195. package/dist/core/tableContext.d.ts +7 -0
  196. package/dist/core/tableContext.d.ts.map +1 -0
  197. package/dist/core/tableContext.js +30 -0
  198. package/dist/core/trackStore.svelte.d.ts +24 -0
  199. package/dist/core/trackStore.svelte.d.ts.map +1 -0
  200. package/dist/core/trackStore.svelte.js +28 -0
  201. package/dist/core/types.d.ts +397 -0
  202. package/dist/core/types.d.ts.map +1 -0
  203. package/dist/core/types.js +1 -0
  204. package/dist/index.d.ts +31 -0
  205. package/dist/index.d.ts.map +1 -0
  206. package/dist/index.js +31 -0
  207. package/dist/styles.css +895 -0
  208. package/package.json +77 -3
@@ -0,0 +1,1152 @@
1
+ /**
2
+ * The headless layer: core's stores and TanStack's Svelte adapter, bound
3
+ * together.
4
+ *
5
+ * Everything decided here is a *binding* decision — which rune holds what, when
6
+ * a value is deferred, how focus is restored. Every behavioural decision (what
7
+ * Tab does, what a shift-range covers, when to virtualize) lives in
8
+ * `@ui-organized/table-core` and is reached from here, which is what keeps a
9
+ * third framework adapter to this file plus the components.
10
+ *
11
+ * ── The shape, and why it is a class ────────────────────────────────────────
12
+ *
13
+ * React's hook returns values, rebuilt per render. Vue's composable returns
14
+ * refs, read with `.value`. Svelte's returns **an object of getters over
15
+ * `$derived`**, so a component writes `table.rows` and the read is tracked — the
16
+ * same property access React has, with none of Vue's ceremony.
17
+ *
18
+ * A class rather than an object literal because `$state` and `$derived` are
19
+ * legal in class fields and the getters come for free, and because the field
20
+ * order *is* the dependency order: a `$derived` may read a field declared above
21
+ * it and not one below.
22
+ *
23
+ * ── The one thing Svelte does not have ──────────────────────────────────────
24
+ *
25
+ * `useDeferredValue`. See `./createDeferred.svelte.ts`, which is deliberately the
26
+ * same answer `@ui-organized/vue-table` gives — two designs for one problem in
27
+ * one design system would be a defect in itself.
28
+ */
29
+ import { untrack } from "svelte";
30
+ import { createTable, } from "@tanstack/svelte-table";
31
+ import { createVirtualizer } from "@tanstack/svelte-virtual";
32
+ import { CARD_HEIGHT, DEFAULT_PAGE_SIZE, EMPTY_SELECTION, FACET_ROW_LIMIT, columnId as columnIdOf, copyRowsToClipboard, coreTableOptions, createDetailStore, createEditStore, createFilterReducer, createOperatorCatalogue, defaultOperatorFor, describeCondition, estimateRowHeight, exportRowsToCsv, facetOptions, filterAnnouncement, fromColumnFilters, gridKeyDown, headerCheckboxState, horizontalScrollState, horizontalScrollTarget, inferFilterType, INITIAL_FILTER_STATE, isEditing as isEditingCell, isNearEnd, isSelected as isRowSelected, layoutColumns, metaOf, NO_HORIZONTAL_SCROLL, normalizeFilterDef, operatorLabel, operatorsFor, retargetCondition, orderFilterableFields, overscanFor, primaryColumnId, rangeSelect, resolveMode, selectAllMatching as selectAllMatchingCore, selectPage, selectionCount as selectionCountCore, shouldVirtualize, spacerHeights, stepRow, toBulkSelection, toColumnFilters, watchWidth, withDateColumns, canSelectHiddenRows, canStepColumn, currentColumnOrder, dropColumn, isMovableColumn, reorderModeOf, stepColumn, } from "@ui-organized/table-core";
33
+ import { trackStore } from "./trackStore.svelte.js";
34
+ import { createDeferred } from "./createDeferred.svelte.js";
35
+ import { actionsColumn, selectionColumn } from "./systemColumns.js";
36
+ /** Stable identity for the un-virtualized path. */
37
+ const NO_SPACERS = { top: 0, bottom: 0 };
38
+ /**
39
+ * Ids are assigned here rather than accepted, so they are deterministic —
40
+ * which is what keeps visual baselines and test assertions stable across runs.
41
+ */
42
+ function seedFilterState(defaults) {
43
+ if (!defaults?.length)
44
+ return INITIAL_FILTER_STATE;
45
+ const conditions = defaults.map((condition, index) => ({
46
+ ...condition,
47
+ id: `f${index + 1}`,
48
+ }));
49
+ return { ...INITIAL_FILTER_STATE, conditions, nextId: conditions.length + 1 };
50
+ }
51
+ /** Follows the same dot path `accessorKey` does, so nested data is reachable. */
52
+ function readPath(row, path) {
53
+ if (!path.includes("."))
54
+ return row?.[path];
55
+ let value = row;
56
+ for (const key of path.split(".")) {
57
+ value = value?.[key];
58
+ }
59
+ return value;
60
+ }
61
+ /** A few real values from a column, for type inference. */
62
+ function sampleColumn(data, def, id) {
63
+ const key = def.accessorKey ?? id;
64
+ // Twenty, not one: a leading null should not get to decide the type.
65
+ return data.slice(0, 20).map((row) => readPath(row, key));
66
+ }
67
+ /** TanStack's logical pinning positions, in this design system's physical ones. */
68
+ const PINNED_SIDE = {
69
+ start: "left",
70
+ end: "right",
71
+ false: false,
72
+ undefined: false,
73
+ };
74
+ function applyUpdater(updater, previous) {
75
+ return typeof updater === "function" ? updater(previous) : updater;
76
+ }
77
+ /**
78
+ * `meta.sticky` is our vocabulary; `columnPinning` is TanStack's.
79
+ *
80
+ * The two disagree on more than the key name since v9: TanStack pins to
81
+ * `start`/`end`, which is the correct call for a library that has to work in
82
+ * both writing directions, while this design system says `left`/`right` because
83
+ * its sticky offsets are physical CSS properties.
84
+ */
85
+ function pinningFromMeta(columns) {
86
+ const start = [];
87
+ const end = [];
88
+ for (const def of columns) {
89
+ const sticky = metaOf(def)?.sticky;
90
+ if (sticky === "left")
91
+ start.push(columnIdOf(def));
92
+ else if (sticky === "right")
93
+ end.push(columnIdOf(def));
94
+ }
95
+ return { start, end };
96
+ }
97
+ function prefersReducedMotion() {
98
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function")
99
+ return false;
100
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
101
+ }
102
+ export function createDataTable(options) {
103
+ // ── Options, read reactively ──────────────────────────────────────────────
104
+ const label = $derived(options().label);
105
+ const captionVisible = $derived(options().captionVisible ?? false);
106
+ const size = $derived(options().size ?? "md");
107
+ const variant = $derived(options().variant ?? "default");
108
+ const selectionMode = $derived(options().selection ?? "none");
109
+ const sortable = $derived(options().sortable ?? true);
110
+ const filterable = $derived(options().filterable ?? true);
111
+ const paginated = $derived(options().paginated ?? false);
112
+ const resizable = $derived(options().resizable ?? false);
113
+ const virtualOption = $derived(options().virtual ?? true);
114
+ const loading = $derived(options().loading ?? false);
115
+ const manual = $derived(options().manual ?? false);
116
+ // ── DOM handles ───────────────────────────────────────────────────────────
117
+ // `$state` written by a callback ref rather than `bind:this`: a plain variable
118
+ // assignment is not something a `$derived` elsewhere can watch, and the resize
119
+ // and scroll observers below have to re-run when the node arrives.
120
+ let rootEl = $state(null);
121
+ // `HTMLElement`, not `HTMLDivElement`: in card mode the scroll container is a
122
+ // `<ul>`, and the virtualizer attaches to whichever one is rendered.
123
+ let viewportEl = $state(null);
124
+ // ── Table state ───────────────────────────────────────────────────────────
125
+ let sorting = $state(options().defaultSorting ?? []);
126
+ const catalogue = $derived(createOperatorCatalogue(options().filterOperators));
127
+ let filterState = $state(seedFilterState(options().defaultFilters));
128
+ const dispatchFilter = (action) => {
129
+ filterState = createFilterReducer(catalogue)(filterState, action);
130
+ };
131
+ const conditions = $derived(filterState.conditions);
132
+ /**
133
+ * Conditions update **urgently**; only the filtering they drive is deferred.
134
+ * See `./createDeferred.svelte.ts`.
135
+ */
136
+ const deferredConditions = createDeferred(() => conditions);
137
+ const columnFilters = $derived(toColumnFilters(deferredConditions.current));
138
+ let search = $state("");
139
+ const deferredSearch = createDeferred(() => search);
140
+ let pagination = $state({
141
+ pageIndex: 0,
142
+ pageSize: options().pageSize ?? DEFAULT_PAGE_SIZE,
143
+ });
144
+ let columnVisibility = $state(options().defaultColumnVisibility ?? {});
145
+ let columnOrder = $state([]);
146
+ let columnSizing = $state({});
147
+ let selection = $state(EMPTY_SELECTION);
148
+ // ── Measurement ───────────────────────────────────────────────────────────
149
+ // Two widths, because they answer different questions. The root's width picks
150
+ // the responsive mode; the viewport's content width decides how much room the
151
+ // columns actually have.
152
+ let width = $state(null);
153
+ let viewportWidth = $state(null);
154
+ $effect(() => watchWidth(rootEl, (value) => (width = value)));
155
+ $effect(() => watchWidth(viewportEl, (value) => (viewportWidth = value)));
156
+ const mode = $derived(resolveMode(options().responsive, width));
157
+ // ── Horizontal scroll ─────────────────────────────────────────────────────
158
+ let scrollState = $state(NO_HORIZONTAL_SCROLL);
159
+ /**
160
+ * Three signals, because three different things change the answer: the user
161
+ * scrolls, the viewport is resized, and the content is re-laid out underneath
162
+ * it. That last one moves neither the scroll offset nor the viewport's own
163
+ * box, which is why the scrolled content is observed and not just its
164
+ * container.
165
+ *
166
+ * Every update goes through a bail-out compare. The numbers change on every
167
+ * frame of a scroll; the three booleans they answer change only at the ends.
168
+ */
169
+ $effect(() => {
170
+ const element = viewportEl;
171
+ if (!element) {
172
+ scrollState = NO_HORIZONTAL_SCROLL;
173
+ return;
174
+ }
175
+ const read = () => {
176
+ const next = horizontalScrollState(element);
177
+ if (next.overflowing === scrollState.overflowing &&
178
+ next.canScrollLeft === scrollState.canScrollLeft &&
179
+ next.canScrollRight === scrollState.canScrollRight) {
180
+ return;
181
+ }
182
+ scrollState = next;
183
+ };
184
+ read();
185
+ element.addEventListener("scroll", read, { passive: true });
186
+ // Guarded for jsdom and SSR, exactly as `watchWidth` is.
187
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(read);
188
+ observer?.observe(element);
189
+ const content = element.firstElementChild;
190
+ if (content)
191
+ observer?.observe(content);
192
+ return () => {
193
+ element.removeEventListener("scroll", read);
194
+ observer?.disconnect();
195
+ };
196
+ });
197
+ // ── Columns ───────────────────────────────────────────────────────────────
198
+ // Date columns rewritten once, up front: everything below — filter types,
199
+ // chips, sorting, export — then sees ISO days and the column's own format.
200
+ // Same array back when no column declares `meta.date`.
201
+ const userColumns = $derived(withDateColumns(options().columns));
202
+ const allColumns = $derived.by(() => {
203
+ const out = [...userColumns];
204
+ if (selectionMode !== "none")
205
+ out.unshift(selectionColumn());
206
+ if (options().rowActions?.length)
207
+ out.push(actionsColumn());
208
+ return out;
209
+ });
210
+ const primaryId = $derived(primaryColumnId(userColumns));
211
+ const basePinning = $derived(pinningFromMeta(allColumns));
212
+ // Created once, outside any rune: these are stores, not derived values, and
213
+ // rebuilding one would discard an open editor mid-keystroke.
214
+ const stores = { edit: createEditStore(), detail: createDetailStore() };
215
+ const editState = trackStore(stores.edit);
216
+ const detailState = trackStore(stores.detail);
217
+ const editableColumns = $derived(new Set(options()
218
+ .columns.filter((def) => metaOf(def)?.edit)
219
+ .map((def) => columnIdOf(def))));
220
+ const interactive = $derived(selectionMode !== "none" ||
221
+ editableColumns.size > 0 ||
222
+ Boolean(options().onRowClick) ||
223
+ Boolean(options().detail) ||
224
+ Boolean(options().rowActions?.length));
225
+ /**
226
+ * The filter type per filterable column, from its declaration or inferred from
227
+ * the data. Read off the raw rows rather than the table's, because this is an
228
+ * *input* to building the table.
229
+ */
230
+ const filterTypes = $derived.by(() => {
231
+ const types = {};
232
+ for (const def of userColumns) {
233
+ const filter = normalizeFilterDef(metaOf(def)?.filter);
234
+ if (!filter)
235
+ continue;
236
+ const id = columnIdOf(def);
237
+ types[id] =
238
+ filter.type ?? inferFilterType(sampleColumn(options().data, def, id), filter.options);
239
+ }
240
+ return types;
241
+ });
242
+ const tableOptions = $derived(coreTableOptions({
243
+ data: options().data,
244
+ columns: allColumns,
245
+ getRowId: options().getRowId,
246
+ selection: selectionMode,
247
+ sortable,
248
+ filterable,
249
+ paginated,
250
+ resizable,
251
+ manual,
252
+ rowCount: options().rowCount,
253
+ filterTypes,
254
+ catalogue,
255
+ }));
256
+ /**
257
+ * The controlled state, in one place — and handed back on the api, because
258
+ * `table.state` is a *React*-adapter convenience. The Svelte adapter exposes
259
+ * rune-aware `atoms` instead, so the parts would have no equivalent to read.
260
+ */
261
+ const state = $derived({
262
+ sorting,
263
+ columnFilters,
264
+ globalFilter: deferredSearch.current,
265
+ pagination,
266
+ columnVisibility,
267
+ columnOrder,
268
+ columnSizing,
269
+ columnPinning: basePinning,
270
+ rowSelection: selection.rows,
271
+ });
272
+ /**
273
+ * Options as **getters**, which is the Svelte adapter's reactivity contract:
274
+ * it syncs in `$effect.pre`, reading whatever the getters return, so a getter
275
+ * that reads a `$derived` keeps the table in step without a watcher.
276
+ */
277
+ const table = createTable({
278
+ get data() {
279
+ return tableOptions.data;
280
+ },
281
+ get columns() {
282
+ return tableOptions.columns;
283
+ },
284
+ get features() {
285
+ return tableOptions.features;
286
+ },
287
+ get defaultColumn() {
288
+ return tableOptions.defaultColumn;
289
+ },
290
+ get getRowId() {
291
+ return tableOptions.getRowId;
292
+ },
293
+ get enableSorting() {
294
+ return tableOptions.enableSorting;
295
+ },
296
+ get enableFilters() {
297
+ return tableOptions.enableFilters;
298
+ },
299
+ get enableGlobalFilter() {
300
+ return tableOptions.enableGlobalFilter;
301
+ },
302
+ get globalFilterFn() {
303
+ return tableOptions.globalFilterFn;
304
+ },
305
+ get enableRowSelection() {
306
+ return tableOptions.enableRowSelection;
307
+ },
308
+ get enableMultiRowSelection() {
309
+ return tableOptions.enableMultiRowSelection;
310
+ },
311
+ get enableColumnResizing() {
312
+ return tableOptions.enableColumnResizing;
313
+ },
314
+ get columnResizeMode() {
315
+ return tableOptions.columnResizeMode;
316
+ },
317
+ get manualSorting() {
318
+ return tableOptions.manualSorting;
319
+ },
320
+ get manualFiltering() {
321
+ return tableOptions.manualFiltering;
322
+ },
323
+ get manualPagination() {
324
+ return tableOptions.manualPagination;
325
+ },
326
+ get rowCount() {
327
+ return tableOptions.rowCount;
328
+ },
329
+ /**
330
+ * TanStack's own page reset is turned **off**, and replaced below. This is
331
+ * the one behavioural workaround in this package and it is forced by the
332
+ * adapter rather than chosen.
333
+ *
334
+ * ── What goes wrong ─────────────────────────────────────────────────────
335
+ *
336
+ * `@tanstack/svelte-table@9.2.4` backs its options store with `$state`,
337
+ * which **deep-proxies**. Every option sync replaces the options object, so
338
+ * `table.options.data` comes back as a *fresh proxy of the same array* —
339
+ * a new identity every time. The core row model memoizes on
340
+ * `[table.options.data]`, so it rebuilds on every state change, and its
341
+ * `onAfterUpdate` calls `table_autoResetPageIndex`.
342
+ *
343
+ * The result: clicking "next page" set the page to 1, the sync fired, the
344
+ * row model rebuilt, and the page was reset to 0 before anything rendered.
345
+ * Paging simply did nothing, with no error.
346
+ *
347
+ * `@tanstack/vue-table` backs the same store with `shallowRef`, which does
348
+ * not proxy, so identity survives and neither Vue nor React sees this.
349
+ *
350
+ * ── Why this is the workaround ──────────────────────────────────────────
351
+ *
352
+ * `autoResetPageIndex` is the only *behaviour* the spurious rebuild breaks;
353
+ * the rebuilds themselves are wasteful but correct. So the flag goes off and
354
+ * the effect below does the reset explicitly, on the inputs TanStack itself
355
+ * resets for — data, filters, search, sorting. Remove both when the adapter
356
+ * stops proxying its options.
357
+ */
358
+ autoResetPageIndex: false,
359
+ get state() {
360
+ return state;
361
+ },
362
+ onSortingChange: (updater) => {
363
+ sorting = applyUpdater(updater, sorting);
364
+ },
365
+ // The raw TanStack instance is a documented escape hatch, so
366
+ // `table.resetColumnFilters()` still has to work. It lands here and is
367
+ // translated back into conditions — one direction of truth.
368
+ onColumnFiltersChange: (updater) => dispatchFilter({
369
+ type: "replace",
370
+ conditions: fromColumnFilters(applyUpdater(updater, columnFilters)),
371
+ }),
372
+ onGlobalFilterChange: (updater) => {
373
+ search = applyUpdater(updater, search);
374
+ },
375
+ onPaginationChange: (updater) => {
376
+ pagination = applyUpdater(updater, pagination);
377
+ },
378
+ onColumnVisibilityChange: (updater) => {
379
+ columnVisibility = applyUpdater(updater, columnVisibility);
380
+ },
381
+ onColumnOrderChange: (updater) => {
382
+ columnOrder = applyUpdater(updater, columnOrder);
383
+ },
384
+ onColumnSizingChange: (updater) => {
385
+ columnSizing = applyUpdater(updater, columnSizing);
386
+ },
387
+ onRowSelectionChange: (updater) => {
388
+ selection = { ...selection, rows: applyUpdater(updater, selection.rows) };
389
+ },
390
+ });
391
+ /**
392
+ * The page reset TanStack would do for us, done here. See `autoResetPageIndex`
393
+ * above for why it cannot.
394
+ *
395
+ * `skipFirstRun` in spirit: a table seeded with `defaultFilters` must not have
396
+ * its page reset on mount, which is why the first pass only records.
397
+ */
398
+ let resetSeen = false;
399
+ $effect(() => {
400
+ // Read every input TanStack resets on, so this effect depends on them.
401
+ void options().data;
402
+ void columnFilters;
403
+ void deferredSearch.current;
404
+ void sorting;
405
+ if (!resetSeen) {
406
+ resetSeen = true;
407
+ return;
408
+ }
409
+ untrack(() => {
410
+ if (pagination.pageIndex !== 0)
411
+ pagination = { ...pagination, pageIndex: 0 };
412
+ });
413
+ });
414
+ const rows = $derived(table.getRowModel().rows);
415
+ const visibleColumns = $derived(table.getVisibleLeafColumns());
416
+ // A table narrower than its container would otherwise leave a bare strip where
417
+ // the header band stops. The surplus goes to one column rather than being
418
+ // spread across all of them — see `layoutColumns`.
419
+ const layout = $derived(layoutColumns(visibleColumns.map((column) => ({
420
+ id: column.id,
421
+ size: column.getSize(),
422
+ pinned: PINNED_SIDE[String(column.getIsPinned())] ?? false,
423
+ })), mode === "table" ? viewportWidth : null));
424
+ // Post-filter, pre-pagination: what "1–25 of 312" counts.
425
+ const totalMatching = $derived(manual ? (options().rowCount ?? rows.length) : table.getPrePaginatedRowModel().rows.length);
426
+ const rowOffset = $derived(paginated && !manual ? pagination.pageIndex * pagination.pageSize : 0);
427
+ // ── Virtualization ────────────────────────────────────────────────────────
428
+ const virtualizing = $derived(shouldVirtualize(rows.length, virtualOption));
429
+ // Always constructed — tearing a virtualizer down when a filter drops the row
430
+ // count below the threshold would lose the scroll position. An idle one costs
431
+ // one scroll listener.
432
+ const virtualizer = createVirtualizer({
433
+ get count() {
434
+ return virtualizing ? rows.length : 0;
435
+ },
436
+ getScrollElement: () => viewportEl,
437
+ // Cards are much taller than rows and vary far more, so the first guess
438
+ // differs by mode. Both are corrected by `measureElement` on the first frame.
439
+ estimateSize: () => (mode === "cards" ? CARD_HEIGHT : estimateRowHeight(size)),
440
+ get overscan() {
441
+ return overscanFor(virtualOption);
442
+ },
443
+ // Rows are measured rather than trusted: the estimate is derived from
444
+ // tokens, and a re-themed table would otherwise mis-position its content.
445
+ measureElement: (element) => element.getBoundingClientRect().height,
446
+ });
447
+ const spacers = $derived(virtualizing
448
+ ? spacerHeights(virtualizer.current.getVirtualItems(), virtualizer.current.getTotalSize())
449
+ : NO_SPACERS);
450
+ const renderRows = $derived.by(() => {
451
+ const build = (row, index) => ({
452
+ row,
453
+ index,
454
+ // What `aria-rowindex` reports: where it sits in the whole dataset.
455
+ absoluteIndex: index + rowOffset,
456
+ });
457
+ if (!virtualizing)
458
+ return rows.map(build);
459
+ const out = [];
460
+ for (const item of virtualizer.current.getVirtualItems()) {
461
+ const row = rows[item.index];
462
+ if (row)
463
+ out.push(build(row, item.index));
464
+ }
465
+ return out;
466
+ });
467
+ // ── Grid focus ────────────────────────────────────────────────────────────
468
+ let cursorState = $state({ row: 0, col: 0 });
469
+ // A counter, not a boolean: the same cell can be re-focused (after an editor
470
+ // closes) without the cursor changing.
471
+ let focusRequest = $state(0);
472
+ // With no rows there is nothing in the body to hold the tab stop, so it moves
473
+ // to the header — still operable (sorting), still one tab stop.
474
+ const effectiveCursor = $derived(rows.length === 0 ? { row: -1, col: cursorState.col } : cursorState);
475
+ const moveCursor = (next) => {
476
+ if (cursorState.row === next.row && cursorState.col === next.col)
477
+ return;
478
+ cursorState = next;
479
+ };
480
+ /**
481
+ * Follow focus that has already moved — a click, or Tab landing on a cell.
482
+ * Deliberately does NOT take focus.
483
+ *
484
+ * The two used to be one function in the React adapter, and it broke inline
485
+ * editing outright: the editor's input took focus, its cell's `focus` event
486
+ * bubbled, the cursor was "set", and the effect below focused the cell again —
487
+ * pulling focus straight back out of the editor the user had just opened.
488
+ */
489
+ const setCursor = moveCursor;
490
+ /** Move the cursor AND take DOM focus. Keyboard actions only. */
491
+ const focusCell = (next) => {
492
+ moveCursor(next);
493
+ focusRequest += 1;
494
+ };
495
+ $effect(() => {
496
+ const count = focusRequest;
497
+ const viewport = viewportEl;
498
+ if (count === 0 || !viewport)
499
+ return;
500
+ let attempts = 0;
501
+ const tryFocus = () => {
502
+ const { row, col } = effectiveCursor;
503
+ const element = viewport.querySelector(`[data-cell="${row}:${col}"]`);
504
+ if (element) {
505
+ element.focus();
506
+ return;
507
+ }
508
+ // A PageDown into un-rendered territory scrolls first; the row exists one
509
+ // frame later. Two attempts, then give up rather than spin.
510
+ if (attempts++ < 2 && typeof requestAnimationFrame === "function") {
511
+ requestAnimationFrame(tryFocus);
512
+ }
513
+ };
514
+ tryFocus();
515
+ });
516
+ // ── Selection ─────────────────────────────────────────────────────────────
517
+ const pageRowIds = $derived(rows.map((row) => row.id));
518
+ /**
519
+ * The rows the header checkbox governs: what is **on screen**.
520
+ *
521
+ * For a paginated table that is the page. For a virtualized one it is the
522
+ * rendered window, and that distinction is the whole point — `rows` on the
523
+ * 100,000-row table is all 100,000, so a header checkbox scoped to it answers
524
+ * one click by enumerating a hundred thousand ids.
525
+ */
526
+ const visibleRowIds = $derived(renderRows.map((entry) => entry.row.id));
527
+ const headerState = $derived(headerCheckboxState(selection, visibleRowIds));
528
+ const toggleRow = (rowId, checked, shift = false) => {
529
+ selection =
530
+ selectionMode === "single"
531
+ ? { ...EMPTY_SELECTION, rows: checked ? { [rowId]: true } : {}, anchor: rowId }
532
+ : rangeSelect(selection, pageRowIds, rowId, shift, checked);
533
+ };
534
+ const togglePage = (checked) => {
535
+ selection = selectPage(selection, visibleRowIds, checked);
536
+ };
537
+ const selectEverythingMatching = () => {
538
+ const next = selectAllMatchingCore(selection);
539
+ // The loaded rows are materialised as well as the flag, so
540
+ // `getSelectedRowModel()` — and therefore CSV export and any bulk action
541
+ // over real row objects — still works for the rows the client has.
542
+ const rowsState = {};
543
+ for (const row of table.getPrePaginatedRowModel().rows)
544
+ rowsState[row.id] = true;
545
+ selection = { ...next, rows: rowsState };
546
+ };
547
+ const selectedRows = $derived.by(() => {
548
+ // `selection` is read explicitly even though `getSelectedRowModel` depends on
549
+ // it: the table instance is stable and its row-model call is memoized, so
550
+ // without this read the derived would have no dependency at all.
551
+ void selection;
552
+ return table.getSelectedRowModel().rows.map((row) => row.original);
553
+ });
554
+ // ── Inline edit ───────────────────────────────────────────────────────────
555
+ const startEdit = (row, colId) => {
556
+ if (!editableColumns.has(colId))
557
+ return;
558
+ stores.edit.dispatch({
559
+ type: "start",
560
+ target: { rowId: row.id, columnId: colId },
561
+ value: row.getValue(colId),
562
+ });
563
+ };
564
+ const commitEdit = () => {
565
+ const editing = stores.edit.getState();
566
+ const target = editing.target;
567
+ // `pending` is the re-entry guard. A commit is reachable from three places
568
+ // at once — Enter, Tab, and the editor's own blur as focus returns to the
569
+ // cell — and without this an edit is written twice.
570
+ if (!target || editing.invalid || editing.pending)
571
+ return;
572
+ const row = table.getRow(target.rowId);
573
+ const column = allColumns.find((def) => columnIdOf(def) === target.columnId);
574
+ const edit = metaOf(column)?.edit;
575
+ const value = edit?.parse ? edit.parse(editing.draft) : editing.draft;
576
+ const message = edit?.validate?.(value, row.original) ?? null;
577
+ if (message) {
578
+ stores.edit.dispatch({ type: "invalid", message });
579
+ return;
580
+ }
581
+ const patch = {
582
+ rowId: target.rowId,
583
+ columnId: target.columnId,
584
+ row: row.original,
585
+ value,
586
+ previousValue: editing.original,
587
+ };
588
+ stores.edit.dispatch({ type: "commit-start" });
589
+ Promise.resolve(options().onEdit?.(patch)).then(() => stores.edit.dispatch({ type: "commit-done" }), (error) => stores.edit.dispatch({
590
+ type: "commit-failed",
591
+ message: error instanceof Error ? error.message : "Could not save the change",
592
+ }));
593
+ };
594
+ const editContextFor = (row, colId) => {
595
+ if (!isEditingCell(editState.current, row.id, colId))
596
+ return null;
597
+ return {
598
+ row: row.original,
599
+ rowId: row.id,
600
+ columnId: colId,
601
+ value: editState.current.draft,
602
+ size,
603
+ invalid: editState.current.invalid,
604
+ setValue: (value) => {
605
+ const column = allColumns.find((def) => columnIdOf(def) === colId);
606
+ const validate = metaOf(column)?.edit?.validate;
607
+ stores.edit.dispatch({
608
+ type: "change",
609
+ value,
610
+ invalid: validate?.(value, row.original) ?? null,
611
+ });
612
+ },
613
+ commit: commitEdit,
614
+ cancel: () => stores.edit.dispatch({ type: "cancel" }),
615
+ };
616
+ };
617
+ // ── Row detail ────────────────────────────────────────────────────────────
618
+ const openDetail = (rowId) => {
619
+ stores.detail.dispatch({ type: "open", rowId, index: pageRowIds.indexOf(rowId) });
620
+ };
621
+ const stepDetail = (direction) => {
622
+ const detail = stores.detail.getState();
623
+ if (detail.dirty) {
624
+ stores.detail.dispatch({ type: "confirm", intent: direction === -1 ? "prev" : "next" });
625
+ return;
626
+ }
627
+ const next = stepRow(pageRowIds, detail.rowId, direction);
628
+ if (next)
629
+ stores.detail.dispatch({ type: "go", ...next });
630
+ };
631
+ const closeDetail = () => {
632
+ if (stores.detail.getState().dirty) {
633
+ stores.detail.dispatch({ type: "confirm", intent: "close" });
634
+ return;
635
+ }
636
+ stores.detail.dispatch({ type: "close" });
637
+ };
638
+ const resolveDetailConfirm = (discard) => {
639
+ const detail = stores.detail.getState();
640
+ const intent = detail.confirming;
641
+ stores.detail.dispatch({ type: "dismiss" });
642
+ if (!discard || !intent)
643
+ return;
644
+ if (intent === "close") {
645
+ stores.detail.dispatch({ type: "close" });
646
+ return;
647
+ }
648
+ const next = stepRow(pageRowIds, detail.rowId, intent === "prev" ? -1 : 1);
649
+ if (next)
650
+ stores.detail.dispatch({ type: "go", ...next });
651
+ };
652
+ const detailRow = $derived.by(() => {
653
+ const rowId = detailState.current.rowId;
654
+ if (!rowId)
655
+ return null;
656
+ const row = rows.find((candidate) => candidate.id === rowId);
657
+ return row ? row.original : null;
658
+ });
659
+ // ── Export ────────────────────────────────────────────────────────────────
660
+ const exportCsv = (scope = "view") => {
661
+ exportRowsToCsv(table, {
662
+ scope,
663
+ filename: `${label.toLowerCase().replaceAll(/\s+/g, "-")}.csv`,
664
+ });
665
+ };
666
+ const copySelection = (scope = "selected") => copyRowsToClipboard(table, { scope });
667
+ // ── Column reordering ───────────────────────────────────────────────────────
668
+ // `reorderable: true | "always"` keeps headers movable all the time;
669
+ // `"toggle"` only inside a reorder mode the toolbar switches. Card mode has
670
+ // no headers to move, so it is never live there.
671
+ const reorderMode = $derived(reorderModeOf(options().reorderable));
672
+ let reorderOn = $state(false);
673
+ const reorderActive = $derived(mode !== "cards" && (reorderMode === "always" || (reorderMode === "toggle" && reorderOn)));
674
+ let drag = $state(null);
675
+ const isMovable = (id) => {
676
+ const column = table.getColumn(id);
677
+ return Boolean(column?.getIsVisible()) && isMovableColumn(id, column.getIsPinned() !== false);
678
+ };
679
+ const orderNow = () => currentColumnOrder(columnOrder, table.getAllLeafColumns().map((column) => column.id));
680
+ const canMoveColumn = (id, direction) => reorderActive && canStepColumn(orderNow(), id, direction, isMovable);
681
+ const moveColumnBy = (id, direction) => {
682
+ if (!canMoveColumn(id, direction))
683
+ return;
684
+ table.setColumnOrder(stepColumn(orderNow(), id, direction, isMovable));
685
+ };
686
+ const dropColumnOn = (source, target, side) => {
687
+ if (!reorderActive || !isMovable(source) || !isMovable(target))
688
+ return;
689
+ table.setColumnOrder(dropColumn(orderNow(), source, target, side));
690
+ };
691
+ // ── Keyboard ──────────────────────────────────────────────────────────────
692
+ const onGridKeyDown = (event) => {
693
+ if (!interactive)
694
+ return;
695
+ const cursor = effectiveCursor;
696
+ const column = visibleColumns[cursor.col];
697
+ const pageRows = Math.max(1, Math.floor((viewportEl?.clientHeight ?? 0) / estimateRowHeight(size)) - 1);
698
+ const action = gridKeyDown({
699
+ cursor,
700
+ rowCount: rows.length,
701
+ colCount: visibleColumns.length,
702
+ pageRows,
703
+ editing: Boolean(editState.current.target),
704
+ editable: column ? editableColumns.has(column.id) : false,
705
+ selectable: selectionMode !== "none",
706
+ multiSelect: selectionMode === "multiple",
707
+ reorderable: Boolean(column) && cursor.row < 0 && reorderActive && isMovable(column.id),
708
+ }, {
709
+ key: event.key,
710
+ shift: event.shiftKey,
711
+ ctrl: event.ctrlKey,
712
+ meta: event.metaKey,
713
+ alt: event.altKey,
714
+ });
715
+ if (action.type === "none")
716
+ return;
717
+ event.preventDefault();
718
+ switch (action.type) {
719
+ case "move":
720
+ focusCell(action.cursor);
721
+ break;
722
+ case "move-column":
723
+ // Stepping trades places with the next movable visible column, which
724
+ // is the neighbouring header, so the column is now one index over.
725
+ // Focus follows it there once the new order has rendered.
726
+ if (column && canMoveColumn(column.id, action.direction)) {
727
+ moveColumnBy(column.id, action.direction);
728
+ focusCell({ row: -1, col: cursor.col + action.direction });
729
+ }
730
+ break;
731
+ case "page":
732
+ if (virtualizing)
733
+ virtualizer.current.scrollToIndex(action.cursor.row, { align: "center" });
734
+ focusCell(action.cursor);
735
+ break;
736
+ case "extend": {
737
+ const anchorRow = rows[cursor.row];
738
+ const targetRow = rows[action.cursor.row];
739
+ if (anchorRow && targetRow) {
740
+ const seeded = selection.anchor ? selection : { ...selection, anchor: anchorRow.id };
741
+ selection = rangeSelect(seeded, pageRowIds, targetRow.id, true, true);
742
+ }
743
+ focusCell(action.cursor);
744
+ break;
745
+ }
746
+ case "toggle-select": {
747
+ const row = rows[cursor.row];
748
+ if (row)
749
+ toggleRow(row.id, !isRowSelected(selection, row.id));
750
+ break;
751
+ }
752
+ case "select-all":
753
+ togglePage(true);
754
+ break;
755
+ case "copy":
756
+ void copySelection(action.scope);
757
+ break;
758
+ case "edit": {
759
+ const row = rows[cursor.row];
760
+ if (row && column)
761
+ startEdit(row, column.id);
762
+ break;
763
+ }
764
+ case "commit":
765
+ commitEdit();
766
+ // The editor is about to unmount; focus has to be given somewhere
767
+ // deliberate, or it falls to <body> and the grid loses the user.
768
+ focusCell(cursor);
769
+ break;
770
+ case "commit-and-move":
771
+ commitEdit();
772
+ focusCell(action.cursor);
773
+ break;
774
+ case "cancel":
775
+ stores.edit.dispatch({ type: "cancel" });
776
+ focusCell(cursor);
777
+ break;
778
+ case "activate": {
779
+ if (cursor.row < 0) {
780
+ if (column?.getCanSort())
781
+ column.toggleSorting();
782
+ break;
783
+ }
784
+ const row = rows[cursor.row];
785
+ if (!row)
786
+ break;
787
+ if (options().detail)
788
+ openDetail(row.id);
789
+ else
790
+ options().onRowClick?.(row.original);
791
+ break;
792
+ }
793
+ default:
794
+ break;
795
+ }
796
+ };
797
+ // ── Server mode ───────────────────────────────────────────────────────────
798
+ $effect(() => {
799
+ if (!manual)
800
+ return;
801
+ const onQueryChange = options().onQueryChange;
802
+ if (!onQueryChange)
803
+ return;
804
+ onQueryChange({
805
+ sorting: sorting.map((entry) => ({ id: entry.id, desc: entry.desc })),
806
+ filters: conditions,
807
+ search: deferredSearch.current,
808
+ pageIndex: pagination.pageIndex,
809
+ pageSize: pagination.pageSize,
810
+ });
811
+ });
812
+ // Infinite scroll. Attached to the viewport rather than to the virtualizer so
813
+ // it works in the un-virtualized case too.
814
+ $effect(() => {
815
+ const viewport = viewportEl;
816
+ const onLoadMore = options().onLoadMore;
817
+ if (!viewport || !onLoadMore)
818
+ return;
819
+ const rowHeight = estimateRowHeight(size);
820
+ const onScroll = () => {
821
+ if (isNearEnd({
822
+ scrollTop: viewport.scrollTop,
823
+ scrollHeight: viewport.scrollHeight,
824
+ clientHeight: viewport.clientHeight,
825
+ }, rowHeight)) {
826
+ onLoadMore();
827
+ }
828
+ };
829
+ viewport.addEventListener("scroll", onScroll, { passive: true });
830
+ return () => viewport.removeEventListener("scroll", onScroll);
831
+ });
832
+ // ── Filters ───────────────────────────────────────────────────────────────
833
+ let announcement = $state("");
834
+ const filterDefs = $derived.by(() => {
835
+ const defs = new Map();
836
+ for (const def of userColumns) {
837
+ const filter = normalizeFilterDef(metaOf(def)?.filter);
838
+ if (filter)
839
+ defs.set(columnIdOf(def), filter);
840
+ }
841
+ return defs;
842
+ });
843
+ const columnLabel = (id) => {
844
+ const column = table.getColumn(id);
845
+ const header = column?.columnDef.header;
846
+ return filterDefs.get(id)?.label ?? (typeof header === "string" ? header : id);
847
+ };
848
+ const typeOfColumn = (id) => filterTypes[id] ?? "text";
849
+ const fields = $derived.by(() => {
850
+ const counts = new Map();
851
+ for (const condition of conditions) {
852
+ counts.set(condition.columnId, (counts.get(condition.columnId) ?? 0) + 1);
853
+ }
854
+ return orderFilterableFields([...filterDefs.entries()].map(([columnId, def], index) => ({
855
+ columnId,
856
+ label: columnLabel(columnId),
857
+ type: typeOfColumn(columnId),
858
+ count: counts.get(columnId) ?? 0,
859
+ priority: def?.priority,
860
+ index,
861
+ })));
862
+ });
863
+ const describe = (condition) => describeCondition(condition, {
864
+ type: typeOfColumn(condition.columnId),
865
+ columnLabel: columnLabel(condition.columnId),
866
+ catalogue,
867
+ options: filterDefs.get(condition.columnId)?.options,
868
+ formatValue: filterDefs.get(condition.columnId)?.formatValue,
869
+ });
870
+ /** Announce discrete changes only — a per-keystroke live region is a firehose. */
871
+ const announce = (kind, condition) => {
872
+ announcement = filterAnnouncement({ kind, description: condition ? describe(condition).text : undefined }, totalMatching);
873
+ };
874
+ const addFilter = (columnId) => {
875
+ const def = filterDefs.get(columnId);
876
+ const type = typeOfColumn(columnId);
877
+ // The id the reducer is about to assign, returned so the caller can open the
878
+ // new chip's editor without waiting for a render to find it.
879
+ const id = `f${filterState.nextId}`;
880
+ dispatchFilter({
881
+ type: "add",
882
+ columnId,
883
+ operator: defaultOperatorFor(type, catalogue, def),
884
+ single: def?.single,
885
+ });
886
+ return id;
887
+ };
888
+ /** The chip's field segment: move a condition to another column. */
889
+ const changeFilterField = (id, columnId) => {
890
+ const condition = conditions.find((entry) => entry.id === id);
891
+ if (!condition || condition.columnId === columnId)
892
+ return;
893
+ dispatchFilter(retargetCondition(condition, { type: typeOfColumn(condition.columnId) }, { columnId, type: typeOfColumn(columnId), def: filterDefs.get(columnId) }, catalogue));
894
+ };
895
+ const removeFilter = (id) => {
896
+ const condition = conditions.find((entry) => entry.id === id);
897
+ if (condition)
898
+ announce("removed", condition);
899
+ dispatchFilter({ type: "remove", id });
900
+ };
901
+ const clearFilters = () => {
902
+ announce("cleared");
903
+ dispatchFilter({ type: "clear" });
904
+ };
905
+ /**
906
+ * `getFacetedUniqueValues` allocates an array per row, so it is capped: above
907
+ * `FACET_ROW_LIMIT` the options render without counts and nothing is disabled.
908
+ */
909
+ const optionsForCondition = (condition) => {
910
+ const def = filterDefs.get(condition.columnId);
911
+ const column = table.getColumn(condition.columnId);
912
+ const showCounts = def?.counts ?? rows.length <= FACET_ROW_LIMIT;
913
+ return facetOptions(showCounts ? column?.getFacetedUniqueValues() : undefined, def?.options, condition.values, showCounts);
914
+ };
915
+ const operatorsForCondition = (condition) => {
916
+ const type = typeOfColumn(condition.columnId);
917
+ return operatorsFor(type, catalogue, filterDefs.get(condition.columnId)?.operators).map((op) => ({ id: op.id, label: operatorLabel(op, type) }));
918
+ };
919
+ const activate = (row) => {
920
+ if (options().detail)
921
+ openDetail(row.id);
922
+ else
923
+ options().onRowClick?.(row.original);
924
+ };
925
+ const chrome = $derived({
926
+ size,
927
+ variant,
928
+ interactive,
929
+ totalWidth: layout.totalWidth,
930
+ rowCount: totalMatching,
931
+ colCount: visibleColumns.length,
932
+ label,
933
+ multiSelectable: selectionMode === "multiple",
934
+ });
935
+ const scrollBy = (direction) => {
936
+ const element = viewportEl;
937
+ if (!element)
938
+ return;
939
+ const left = horizontalScrollTarget(element, direction);
940
+ // Smooth unless the user asked for less motion — and unless the engine has
941
+ // no options-taking `scrollTo` at all (jsdom), where the assignment is the
942
+ // same scroll without the animation.
943
+ if (typeof element.scrollTo === "function" && !prefersReducedMotion()) {
944
+ element.scrollTo({ left, behavior: "smooth" });
945
+ }
946
+ else {
947
+ element.scrollLeft = left;
948
+ }
949
+ };
950
+ return {
951
+ table,
952
+ get state() {
953
+ return state;
954
+ },
955
+ get rows() {
956
+ return rows;
957
+ },
958
+ get renderRows() {
959
+ return renderRows;
960
+ },
961
+ get chrome() {
962
+ return chrome;
963
+ },
964
+ get options() {
965
+ return options();
966
+ },
967
+ get size() {
968
+ return size;
969
+ },
970
+ get variant() {
971
+ return variant;
972
+ },
973
+ get label() {
974
+ return label;
975
+ },
976
+ get captionVisible() {
977
+ return captionVisible;
978
+ },
979
+ get interactive() {
980
+ return interactive;
981
+ },
982
+ get mode() {
983
+ return mode;
984
+ },
985
+ get loading() {
986
+ return loading;
987
+ },
988
+ get search() {
989
+ return search;
990
+ },
991
+ setSearch: (value) => {
992
+ search = value;
993
+ },
994
+ selection: {
995
+ get mode() {
996
+ return selectionMode;
997
+ },
998
+ get state() {
999
+ return selection;
1000
+ },
1001
+ get count() {
1002
+ return selectionCountCore(selection, totalMatching);
1003
+ },
1004
+ get rows() {
1005
+ return selectedRows;
1006
+ },
1007
+ isSelected: (rowId) => isRowSelected(selection, rowId),
1008
+ toggle: toggleRow,
1009
+ togglePage,
1010
+ selectAllMatching: selectEverythingMatching,
1011
+ clear: () => {
1012
+ selection = EMPTY_SELECTION;
1013
+ },
1014
+ get header() {
1015
+ return headerState;
1016
+ },
1017
+ get canSelectAllMatching() {
1018
+ return canSelectHiddenRows(selection, headerState, totalMatching, selectionMode);
1019
+ },
1020
+ get totalMatching() {
1021
+ return totalMatching;
1022
+ },
1023
+ asBulk: () => toBulkSelection(selection),
1024
+ },
1025
+ filters: {
1026
+ get conditions() {
1027
+ return conditions;
1028
+ },
1029
+ get fields() {
1030
+ return fields;
1031
+ },
1032
+ add: addFilter,
1033
+ update: (id, patch) => dispatchFilter({ type: "update", id, ...patch }),
1034
+ changeField: changeFilterField,
1035
+ restore: (condition) => dispatchFilter({ type: "restore", condition }),
1036
+ remove: removeFilter,
1037
+ clear: clearFilters,
1038
+ describe,
1039
+ operatorsFor: operatorsForCondition,
1040
+ optionsFor: optionsForCondition,
1041
+ typeOf: (condition) => typeOfColumn(condition.columnId),
1042
+ arityOf: (condition) => catalogue.get(condition.operator)?.arity ?? "one",
1043
+ get expanded() {
1044
+ return filterState.expanded;
1045
+ },
1046
+ setExpanded: (expanded) => dispatchFilter({ type: "set-expanded", expanded }),
1047
+ get editing() {
1048
+ return filterState.editingId;
1049
+ },
1050
+ setEditing: (id) => dispatchFilter({ type: "set-editing", id }),
1051
+ get announcement() {
1052
+ return announcement;
1053
+ },
1054
+ },
1055
+ edit: {
1056
+ get enabled() {
1057
+ return editableColumns.size > 0;
1058
+ },
1059
+ get state() {
1060
+ return editState.current;
1061
+ },
1062
+ isEditing: (rowId, colId) => isEditingCell(editState.current, rowId, colId),
1063
+ start: startEdit,
1064
+ setValue: (value) => stores.edit.dispatch({ type: "change", value }),
1065
+ commit: commitEdit,
1066
+ cancel: () => stores.edit.dispatch({ type: "cancel" }),
1067
+ contextFor: editContextFor,
1068
+ },
1069
+ detail: {
1070
+ get enabled() {
1071
+ return Boolean(options().detail);
1072
+ },
1073
+ get state() {
1074
+ return detailState.current;
1075
+ },
1076
+ get row() {
1077
+ return detailRow;
1078
+ },
1079
+ get total() {
1080
+ return totalMatching;
1081
+ },
1082
+ open: openDetail,
1083
+ close: closeDetail,
1084
+ step: stepDetail,
1085
+ canStep: (direction) => stepRow(pageRowIds, detailState.current.rowId, direction) !== null,
1086
+ setDirty: (dirty) => stores.detail.dispatch({ type: "dirty", dirty }),
1087
+ resolveConfirm: resolveDetailConfirm,
1088
+ },
1089
+ focus: {
1090
+ get cursor() {
1091
+ return effectiveCursor;
1092
+ },
1093
+ setCursor,
1094
+ focusCell,
1095
+ isFocused: (rowIndex, colIndex) => effectiveCursor.row === rowIndex && effectiveCursor.col === colIndex,
1096
+ },
1097
+ virtual: {
1098
+ get enabled() {
1099
+ return virtualizing;
1100
+ },
1101
+ get virtualizer() {
1102
+ return virtualizer.current;
1103
+ },
1104
+ get spacers() {
1105
+ return spacers;
1106
+ },
1107
+ },
1108
+ scroll: {
1109
+ get state() {
1110
+ return scrollState;
1111
+ },
1112
+ by: scrollBy,
1113
+ },
1114
+ reorder: {
1115
+ get mode() {
1116
+ return reorderMode;
1117
+ },
1118
+ get active() {
1119
+ return reorderActive;
1120
+ },
1121
+ setActive: (active) => {
1122
+ reorderOn = active;
1123
+ },
1124
+ isMovable,
1125
+ canMove: canMoveColumn,
1126
+ move: moveColumnBy,
1127
+ drop: dropColumnOn,
1128
+ get drag() {
1129
+ return drag;
1130
+ },
1131
+ setDrag: (next) => {
1132
+ drag = next;
1133
+ },
1134
+ },
1135
+ get columnWidths() {
1136
+ return layout.widths;
1137
+ },
1138
+ rootRef: (element) => {
1139
+ rootEl = element;
1140
+ },
1141
+ viewportRef: (element) => {
1142
+ viewportEl = element;
1143
+ },
1144
+ exportCsv,
1145
+ copySelection,
1146
+ onGridKeyDown,
1147
+ activate,
1148
+ get primaryColumnId() {
1149
+ return primaryId;
1150
+ },
1151
+ };
1152
+ }