@lavalogic/scoria 0.39.2 → 0.40.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 (141) hide show
  1. package/README.md +58 -58
  2. package/dist/Components/AccordionGroup.svelte +46 -46
  3. package/dist/Components/AccordionGroupButton.svelte +76 -76
  4. package/dist/Components/Action.svelte +136 -136
  5. package/dist/Components/AlertModal.svelte +195 -195
  6. package/dist/Components/AppointmentCalendar/AppointmentCalendar.svelte +729 -729
  7. package/dist/Components/Base/BaseModal.svelte +125 -125
  8. package/dist/Components/Base/ContextWrapper.svelte +72 -72
  9. package/dist/Components/Base/Pagination.svelte +198 -198
  10. package/dist/Components/Base/Snippets.svelte +36 -36
  11. package/dist/Components/BigRadioSet.svelte +38 -38
  12. package/dist/Components/Bubble.svelte +101 -101
  13. package/dist/Components/Button.svelte +209 -209
  14. package/dist/Components/Checkbox.svelte +86 -86
  15. package/dist/Components/CollapsibleCard.svelte +78 -78
  16. package/dist/Components/ConditionBuilder/ConditionBuilder.svelte +212 -0
  17. package/dist/Components/ConditionBuilder/ConditionBuilder.svelte.d.ts +25 -0
  18. package/dist/Components/ConditionBuilder/ConditionBuilderProps.d.ts +45 -0
  19. package/dist/Components/ConditionBuilder/ConditionBuilderProps.js +1 -0
  20. package/dist/Components/ConditionBuilder/ConditionTree.d.ts +27 -0
  21. package/dist/Components/ConditionBuilder/ConditionTree.js +9 -0
  22. package/dist/Components/Contexts/ContextMenu.svelte +164 -164
  23. package/dist/Components/Contexts/ContextMenuDivider.svelte +15 -15
  24. package/dist/Components/Contexts/ContextMenuOption.svelte +60 -60
  25. package/dist/Components/Contexts/Toast.svelte +86 -86
  26. package/dist/Components/Contexts/ToastContainer.svelte +65 -65
  27. package/dist/Components/Contexts/Tooltip.svelte +37 -37
  28. package/dist/Components/DataMatrixIcon.svelte +112 -112
  29. package/dist/Components/DateInput.svelte +148 -148
  30. package/dist/Components/DatePicker.svelte +300 -300
  31. package/dist/Components/DesktopModal.svelte +90 -90
  32. package/dist/Components/Dial.svelte +204 -204
  33. package/dist/Components/DimensionInput.svelte +170 -170
  34. package/dist/Components/DragMenu/DragMenuHolder.svelte +119 -119
  35. package/dist/Components/DragMenu/DraggableCard.svelte +77 -77
  36. package/dist/Components/FileCard.svelte +24 -24
  37. package/dist/Components/FileUpload.svelte +236 -236
  38. package/dist/Components/GridInput.svelte +196 -196
  39. package/dist/Components/GridInputSet.svelte +29 -29
  40. package/dist/Components/HorizontalTabGroup.svelte +82 -82
  41. package/dist/Components/HorizontalTabGroupButton.svelte +44 -44
  42. package/dist/Components/Icon.svelte +128 -128
  43. package/dist/Components/Icons.js +357 -357
  44. package/dist/Components/InfoAlert.svelte +35 -35
  45. package/dist/Components/LoadingAnimation.svelte +29 -29
  46. package/dist/Components/LoadingModal.svelte +45 -45
  47. package/dist/Components/LoadingOverlay.svelte +25 -25
  48. package/dist/Components/Modal.svelte +258 -258
  49. package/dist/Components/MultiSelect.svelte +220 -220
  50. package/dist/Components/Nav/Nav.svelte +110 -110
  51. package/dist/Components/Nav/NavServiceMenuList.svelte +43 -43
  52. package/dist/Components/Nav/NavTab.svelte +62 -62
  53. package/dist/Components/Nav/ServiceMenuItem.svelte +112 -112
  54. package/dist/Components/Nav/ServicesNav.svelte +80 -80
  55. package/dist/Components/NumberInput.svelte +157 -157
  56. package/dist/Components/PageHeading.svelte +25 -25
  57. package/dist/Components/PasswordInput.svelte +88 -88
  58. package/dist/Components/Portal.svelte +36 -36
  59. package/dist/Components/ProgressBubble.svelte +107 -107
  60. package/dist/Components/QuerySelect.svelte +366 -366
  61. package/dist/Components/SingleSelect.svelte +250 -250
  62. package/dist/Components/StepButton.svelte +70 -70
  63. package/dist/Components/StepForm.svelte +140 -140
  64. package/dist/Components/Switch.svelte +69 -69
  65. package/dist/Components/Table/ARCHITECTURE.md +298 -298
  66. package/dist/Components/Table/Body/QuickSearchCell.svelte +679 -679
  67. package/dist/Components/Table/Body/Rows/ColumnHighlight.svelte +51 -51
  68. package/dist/Components/Table/Body/Rows/ColumnList.svelte +59 -59
  69. package/dist/Components/Table/Body/Rows/ColumnListRow.svelte +454 -454
  70. package/dist/Components/Table/Body/Rows/Columns/AccessorComponent.svelte +79 -79
  71. package/dist/Components/Table/Body/Rows/Columns/ActionsCell.svelte +185 -185
  72. package/dist/Components/Table/Body/Rows/Columns/BubbleCell.svelte +124 -124
  73. package/dist/Components/Table/Body/Rows/Columns/ExpandCell.svelte +120 -120
  74. package/dist/Components/Table/Body/Rows/Columns/FocusableImmutableCell.svelte +234 -234
  75. package/dist/Components/Table/Body/Rows/Columns/ProgressCell.svelte +105 -105
  76. package/dist/Components/Table/Body/Rows/Columns/TableAction.svelte +87 -87
  77. package/dist/Components/Table/Body/Rows/Columns/TableCheckbox.svelte +126 -126
  78. package/dist/Components/Table/Body/Rows/Columns/TableColumn.svelte +159 -159
  79. package/dist/Components/Table/Body/Rows/Columns/TableDatePicker.svelte +246 -246
  80. package/dist/Components/Table/Body/Rows/Columns/TableNumberInput.svelte +216 -216
  81. package/dist/Components/Table/Body/Rows/Columns/TableQuerySelect.svelte +252 -252
  82. package/dist/Components/Table/Body/Rows/Columns/TableRowSelectionCheckbox.svelte +179 -179
  83. package/dist/Components/Table/Body/Rows/Columns/TableSelect.svelte +221 -221
  84. package/dist/Components/Table/Body/Rows/Columns/TableTextInput.svelte +197 -197
  85. package/dist/Components/Table/Body/Rows/Columns/ValidityCell.svelte +190 -190
  86. package/dist/Components/Table/Body/Rows/TableRow.svelte +290 -290
  87. package/dist/Components/Table/Body/TableBody.svelte +155 -155
  88. package/dist/Components/Table/Headers/HighlightAllHeader.svelte +41 -41
  89. package/dist/Components/Table/Headers/SelectAllHeader.svelte +30 -30
  90. package/dist/Components/Table/Headers/TableHead.svelte +415 -415
  91. package/dist/Components/Table/Misc/ColumnPanel.svelte +39 -39
  92. package/dist/Components/Table/Misc/ColumnPanelModal.svelte +39 -39
  93. package/dist/Components/Table/Misc/FilterInput.svelte +641 -641
  94. package/dist/Components/Table/Misc/FilterPanel.svelte +190 -190
  95. package/dist/Components/Table/Misc/FilterPanelModal.svelte +31 -31
  96. package/dist/Components/Table/Misc/ShareViewModal.svelte +291 -291
  97. package/dist/Components/Table/Misc/SidePanel.svelte +35 -35
  98. package/dist/Components/Table/Misc/TableConfigurationModal.svelte +644 -644
  99. package/dist/Components/Table/Misc/TableFooter.svelte +340 -340
  100. package/dist/Components/Table/Misc/TableHorizontalBar.svelte +140 -140
  101. package/dist/Components/Table/Misc/TableSidebar.svelte +200 -200
  102. package/dist/Components/Table/Misc/TableViewDropdown.svelte +105 -105
  103. package/dist/Components/Table/Misc/ToolboxPanel.svelte +100 -100
  104. package/dist/Components/Table/Misc/ToolboxPanelModal.svelte +107 -107
  105. package/dist/Components/Table/Misc/UpdateViewOptionsModal.svelte +192 -192
  106. package/dist/Components/Table/NestedTable.svelte +130 -130
  107. package/dist/Components/Table/Table.svelte +692 -692
  108. package/dist/Components/Test/TestContextConsumer.svelte +13 -13
  109. package/dist/Components/TextArea.svelte +130 -130
  110. package/dist/Components/TextInput.svelte +176 -176
  111. package/dist/Components/ThreeStateRadio.svelte +131 -131
  112. package/dist/Components/Touch/BoolCard.svelte +45 -45
  113. package/dist/Components/Touch/DateModal.svelte +209 -209
  114. package/dist/Components/Touch/EditCard.svelte +59 -59
  115. package/dist/Components/Touch/InfoCard.svelte +31 -31
  116. package/dist/Components/Touch/InfoTextCard.svelte +28 -28
  117. package/dist/Components/Touch/ModalExplanation.svelte +17 -17
  118. package/dist/Components/Touch/NumberKeyboard.svelte +225 -225
  119. package/dist/Components/Touch/NumberModal.svelte +79 -79
  120. package/dist/Components/Touch/PlusMinusCard.svelte +46 -46
  121. package/dist/Components/Touch/SelectModal.svelte +127 -127
  122. package/dist/Components/Touch/SummaryCard.svelte +93 -93
  123. package/dist/Components/Touch/TextAreaModal.svelte +95 -95
  124. package/dist/Components/Touch/TextModal.svelte +106 -106
  125. package/dist/Components/Touch/TouchCard.svelte +33 -33
  126. package/dist/Components/Touch/TouchModal.svelte +274 -274
  127. package/dist/Components/Touch/UtilityButton.svelte +109 -109
  128. package/dist/Components/VerticalTabGroup.svelte +79 -79
  129. package/dist/Components/VerticalTabGroupButton.svelte +58 -58
  130. package/dist/Helpers/Datamatrix.d.ts +19 -19
  131. package/dist/Helpers/Datamatrix.js +291 -291
  132. package/dist/Types/Examples/ExampleModal.svelte +35 -35
  133. package/dist/index.d.ts +3 -0
  134. package/dist/index.js +2 -0
  135. package/dist/scss/_basics.scss +12 -12
  136. package/dist/scss/_colours.scss +134 -134
  137. package/dist/scss/_mixins.scss +3392 -3392
  138. package/dist/scss/_motion.scss +57 -57
  139. package/dist/scss/_sizing.scss +85 -85
  140. package/dist/scss/_transitions.scss +8 -8
  141. package/package.json +1 -1
@@ -1,694 +1,694 @@
1
- <svelte:options runes />
2
-
3
- <script
4
- lang="ts"
5
- module
6
- >
7
- import {
8
- ContextMenuContext,
9
- type IContextMenuOption,
10
- } from '../../Contexts/ContextMenuContext.svelte';
11
- import { cssLength, devCatch, refWrapper } from '../../Helpers/Helpers.svelte';
12
- import { getContext, onMount, setContext, tick, untrack } from 'svelte';
13
- import LoadingOverlay from '../LoadingOverlay.svelte';
14
- import TableBody from './Body/TableBody.svelte';
15
- import TableHead from './Headers/TableHead.svelte';
16
- import TableFooter from './Misc/TableFooter.svelte';
17
- import TableHorizontalBar from './Misc/TableHorizontalBar.svelte';
18
- import TableSidebar from './Misc/TableSidebar.svelte';
19
- import { TableContext } from './Types/Context/TableContext.svelte';
20
- import type { Primitive } from './Types/Context/TableInitOptions.js';
21
- import { PaginationType } from './Types/Pagination/PaginationType.js';
22
- import { ToolbarPosition } from './Types/Toolbar/ToolbarPosition.js';
23
-
24
- declare global {
25
- interface HTMLElementEventMap {
26
- edit: CustomEvent<{
27
- selectAll: boolean;
28
- }>;
29
- }
30
- }
31
-
32
- /**
33
- * Top-level orchestrator props. Accepts either:
34
- *
35
- * - `table`: the structured `Table` instance returned by `createTable(...)`.
36
- * This is the consumer-facing form.
37
- * - `tableContext`: the underlying `TableContext`. Used internally by
38
- * `NestedTable` which constructs a context directly for each
39
- * expansion. Not intended for consumer use.
40
- *
41
- * Exactly one of the two must be supplied; a runtime guard inside the
42
- * component throws if both are missing. The reference passed in is
43
- * captured at mount via `setContext` and should not be reassigned
44
- * afterwards.
45
- */
46
- export interface TableProps<T extends object, IdType extends Primitive> {
47
- readonly table?: { readonly _context: TableContext<T, IdType> };
48
- readonly tableContext?: TableContext<T, IdType>;
49
- nested?: boolean;
50
- }
51
-
52
- /**
53
- * Symbol used to share the currently-open quick-filter select across
54
- * every header in a single Table tree. Stored via `setContext` on
55
- * the same component that hosts the Table tree so multiple
56
- * independent tables on a page each own their own popup state.
57
- */
58
- export const quickFilterContextIdentifier: symbol = Symbol();
59
-
60
- const openedQuickFilterSelect = refWrapper<symbol | null>(null);
61
- </script>
62
-
63
- <script
64
- lang="ts"
65
- generics="T extends object,IdType extends Primitive"
66
- >
67
- const { nested = false, table, tableContext: tableContextProp }: TableProps<T, IdType> = $props();
68
-
69
- // Resolve the underlying TableContext from whichever prop was passed:
70
- // consumers pass `table`; internal `NestedTable` passes `tableContext`
71
- // directly. Exactly one must be present — throw cleanly here rather
72
- // than letting it surface as a downstream null-deref.
73
- const tableContext: TableContext<T, IdType> = (() => {
74
- const ctx = table?._context ?? tableContextProp;
75
- if (!ctx) {
76
- throw new Error(
77
- 'scoria: <Table /> requires either a `table` prop (the structured instance returned by createTable) or a `tableContext` prop (used internally by NestedTable).'
78
- );
79
- }
80
- return ctx;
81
- })();
82
-
83
- // NOTE: known limitation. This is passed as a wrapper, and they should never require reassignment. It is deeply reactive
84
- setContext(TableContext.identifier, tableContext);
85
-
86
- const { openContextMenu: _openContextMenu } = getContext<ContextMenuContext>(
87
- ContextMenuContext.identifier
88
- );
89
-
90
- let ref: HTMLDivElement | undefined = $state();
91
- let unfocusRef: HTMLDivElement | undefined = $state();
92
-
93
- setContext(quickFilterContextIdentifier, openedQuickFilterSelect);
94
-
95
- // Focus-restore on focusStart change. The DOM work runs inside `untrack`
96
- // so a future selector tweak that reads reactive state (e.g.
97
- // `document.activeElement`) does not retrigger on every focus change.
98
- $effect(() => {
99
- void tableContext.focusStart;
100
- void ref;
101
- untrack(() => {
102
- if (tableContext.focusStart && ref) {
103
- const { row, column: index } = tableContext.focusStart;
104
- // `columnDefs[index]` can be undefined when the focus
105
- // coordinate references a slot that no longer exists
106
- // (e.g. an inner expand table remounted with a different
107
- // column count, or a preset hydration raced column init).
108
- // Guard so out-of-range degrades to "no focus restored".
109
- const def = tableContext.columnDefs[index];
110
- if (!def) {
111
- return;
112
- }
113
- let element = ref.querySelector(
114
- `#${tableContext.tableName}-cell-${row}-${def.id}`
115
- ) as HTMLElement | undefined;
116
-
117
- if (element?.tabIndex == -1) {
118
- const td = element;
119
- element = td.querySelector('button') as HTMLElement | undefined;
120
- if (!element) {
121
- element = td.querySelector('input') as HTMLElement | undefined;
122
- }
123
- }
124
-
125
- element?.focus({ preventScroll: true });
126
- // Skip horizontal scroll-into-view for pinned cells (see the
127
- // focusEnd effect for the rationale).
128
- const startCell = element?.closest('[role="cell"]');
129
- const isStartPinned =
130
- startCell?.classList.contains('pinned-left') ||
131
- startCell?.classList.contains('pinned-right');
132
- element?.scrollIntoView({
133
- behavior: 'instant',
134
- block: 'nearest',
135
- inline: isStartPinned ? undefined : 'nearest',
136
- });
137
- }
138
- });
139
- });
140
-
141
- // Scroll-into-view on focusEnd change. DOM work wrapped in `untrack` so
142
- // reading layout properties never re-establishes a reactive dependency.
143
- $effect(() => {
144
- void tableContext.focusEnd;
145
- void ref;
146
- untrack(() => {
147
- if (tableContext.focusEnd && ref) {
148
- const { row, column: index } = tableContext.focusEnd;
149
- const def = tableContext.columnDefs[index];
150
- if (!def) {
151
- return;
152
- }
153
- const cell = ref.querySelector(
154
- `#${tableContext.tableName}-cell-${row}-${def.id}`
155
- ) as HTMLElement | undefined;
156
-
157
- // Pinned cells are visually always on screen, but their true
158
- // layout position is at their real column track — which for
159
- // a right-pinned column in a wider-than-viewport table is
160
- // offscreen. Calling `scrollIntoView({inline:'nearest'})`
161
- // would then scroll the table sideways to surface the cell's
162
- // underlying position, which the user sees as the table
163
- // jumping when they click a pinned Actions button. The
164
- // vertical (block) axis still scrolls normally because the
165
- // row's vertical position is the user's real scroll target.
166
- const isPinned =
167
- cell?.classList.contains('pinned-left') ||
168
- cell?.classList.contains('pinned-right');
169
- cell?.scrollIntoView({
170
- behavior: 'instant',
171
- block: 'nearest',
172
- inline: isPinned ? undefined : 'nearest',
173
- });
174
- }
175
- });
176
- });
177
-
178
- /**
179
- * Routes a right-click into either a "focus the cell that was
180
- * clicked" path (if the click landed on a cell other than the
181
- * current focus) or a "show copy options" context menu (when the
182
- * Table is configured with `allowCopy` and the platform exposes the
183
- * Clipboard API).
184
- */
185
- function openContextMenu(e: MouseEvent) {
186
- e.stopPropagation();
187
-
188
- if (
189
- (e.target as HTMLElement).tagName === 'INPUT' ||
190
- (e.target as HTMLElement).closest('.select-wrapper') != null
191
- ) {
192
- return;
193
- }
194
-
195
- const clickedElement = document.activeElement?.id.split('-cell-')[1]?.split('-');
196
-
197
- if (clickedElement) {
198
- const [rowString, columnId] = clickedElement;
199
- const row = parseInt(rowString);
200
-
201
- if (
202
- !tableContext.focusedColumnIds.has(columnId) ||
203
- !tableContext.focusedRowIndices.has(row)
204
- ) {
205
- const columnIndex = tableContext.columnDefs.find((it) => it.id === columnId)?.index;
206
-
207
- // Use `!= null` rather than a truthy check so a column at
208
- // memory index 0 (the leftmost column) is not silently
209
- // rejected. The previous truthy check broke right-click
210
- // copy on the first column.
211
- // `!= null` (not truthy) so an index of 0 (leftmost column) is
212
- // not silently rejected.
213
- if (columnIndex != null) {
214
- tableContext.moveFocus({
215
- column: columnIndex,
216
- row,
217
- id: columnId,
218
- });
219
- }
220
- }
221
- }
222
-
223
- // `window.Clipboard` may be absent in non-HTTPS contexts.
224
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
225
- if (tableContext.allowCopy && window.Clipboard) {
226
- e.preventDefault();
227
-
228
- const options: Array<IContextMenuOption> = [
229
- {
230
- callback: () => {
231
- tableContext.copyDataToClipboard().catch(devCatch);
232
- },
233
- text: 'Copy Selection',
234
- },
235
- ];
236
-
237
- if (tableContext.hasHighlightedItems) {
238
- options.push({
239
- callback: () => {
240
- tableContext.copyHighlightedRowsToClipboard().catch(devCatch);
241
- },
242
- text: 'Copy Highlighted Rows',
243
- });
244
- }
245
-
246
- _openContextMenu(e, options).catch(devCatch);
247
- }
248
- }
249
-
250
- /**
251
- * Closes the quick-filter popup and the actions-cell popup whenever
252
- * the user clicks somewhere that is not inside either floating UI.
253
- */
254
- function handleClickOutside(e: MouseEvent) {
255
- const target = e.target as HTMLElement;
256
- if (target.isConnected) {
257
- // A click inside an open Svelecte dropdown (marked with
258
- // `data-scoria-portal-dropdown` by `dropdownPortal.ts`) is a
259
- // logical extension of the trigger inside the quick-filter
260
- // popup, so it must not close either floating popover.
261
- const insidePortalDropdown = target.closest('[data-scoria-portal-dropdown]') != null;
262
- if (
263
- !insidePortalDropdown &&
264
- target.closest('div.floating') == null &&
265
- target.closest('div.stick-right') == null
266
- ) {
267
- openedQuickFilterSelect.value = null;
268
- }
269
-
270
- if (!insidePortalDropdown && target.closest('div.slot-wrapper') == null) {
271
- tableContext.shownActionsPopup = undefined;
272
- }
273
- }
274
- }
275
-
276
- // nested table resizing
277
- let isResizing = $state(false);
278
- let remPxRatio = $state(0);
279
- const minNestedHeight = $derived(20 * remPxRatio);
280
-
281
- let nestedHeight = $state(0);
282
- $effect.pre(() => {
283
- if (!isResizing) {
284
- nestedHeight = minNestedHeight;
285
- }
286
- });
287
-
288
- let tableRef: HTMLDivElement | undefined = $state();
289
- let mousedDownEmptySpace = $state(false);
290
-
291
- onMount(() => {
292
- remPxRatio = parseFloat(getComputedStyle(document.documentElement).fontSize);
293
- });
294
-
295
- $effect(() => {
296
- if (!isResizing) {
297
- return;
298
- }
299
- const onResize = (e: MouseEvent) => {
300
- const sliderHeight = tableRef ? e.clientY - tableRef.getBoundingClientRect().top : 0;
301
- nestedHeight = Math.max(sliderHeight, minNestedHeight);
302
- };
303
- window.addEventListener('mousemove', onResize);
304
- return () => {
305
- window.removeEventListener('mousemove', onResize);
306
- };
307
- });
308
-
309
- function addOverrideResizeHandler() {
310
- isResizing = true;
311
- }
312
-
313
- function removeOverrideResizeHandler() {
314
- isResizing = false;
315
- }
316
-
317
- const usesLoading = $derived(
318
- tableContext.paginationRepo &&
319
- tableContext.paginationRepo.paginationType === PaginationType.Remote
320
- );
321
-
322
- const shouldUnfocusRemote = $derived(
323
- usesLoading &&
324
- tableContext.paginationRepo?.isLoading === false &&
325
- tableContext.paginationRepo.paginationState.pageIndex !==
326
- tableContext.paginationRepo.previouslyViewedPage
327
- );
328
-
329
- const shouldUnfocusLocal = $derived(
330
- !usesLoading && tableContext.paginationRepo?.paginationState.pageIndex
331
- );
332
-
333
- $effect(() => {
334
- if ((shouldUnfocusRemote || shouldUnfocusLocal) && unfocusRef) {
335
- const el = unfocusRef;
336
- untrack(() => {
337
- tableContext.unfocus();
338
- el.scrollTop = 0;
339
- });
340
- } else if (usesLoading && tableContext.paginationRepo?.isLoading === false) {
341
- // refocus same element after a reload
342
- // this does not break mutifocus
343
- untrack(() => {
344
- const fs = tableContext.focusStart;
345
- if (!fs) {
346
- return;
347
- }
348
- const idToFocusTo = `#${tableContext.tableName}-cell-${fs.row}-${fs.id}`;
349
-
350
- tick()
351
- .then(() => {
352
- (document.querySelector(idToFocusTo) as HTMLElement | undefined)?.focus();
353
- })
354
- .catch(devCatch);
355
- });
356
- }
357
- });
358
-
359
- const leftColumns = $derived(tableContext.table.getLeftVisibleLeafColumns());
360
- const centreColumns = $derived(tableContext.table.getCenterVisibleLeafColumns());
361
- const rightColumns = $derived(tableContext.table.getRightVisibleLeafColumns());
362
-
363
- // CSS Grid template for the table.
364
- //
365
- // - First track is the 20px highlight column (always present).
366
- // - For each other column, the track depends on its `userResized`
367
- // flag and its declared width/maxWidth:
368
- //
369
- // userResized = true → `${width}px`
370
- // userResized = false, maxWidth set → `minmax(width, maxWidth)`
371
- // userResized = false, widthIsFixed → `${width}px`
372
- // userResized = false, on per-kind default → `minmax(width, 1fr)`
373
- //
374
- // This produces "specify widths for the columns you care about; let
375
- // the rest fill the gap", matching the legacy `<table>` behaviour.
376
- // Pinned and centre columns use the same rule so pinning a column
377
- // does not change its width. Sticky-cell offsets read from
378
- // `_columnRenderedWidths` (populated by TableHead's ResizeObserver).
379
- const colsTemplate = $derived.by(() => {
380
- void tableContext.columnSizing;
381
- const track = (col: {
382
- columnDef: {
383
- width: number;
384
- maxWidth?: number;
385
- userResized: boolean;
386
- widthIsFixed: boolean;
387
- };
388
- }) => {
389
- if (col.columnDef.userResized) {
390
- return `${col.columnDef.width}px`;
391
- }
392
- if (col.columnDef.maxWidth != null) {
393
- return `minmax(${col.columnDef.width}px, ${col.columnDef.maxWidth}px)`;
394
- }
395
- if (col.columnDef.widthIsFixed) {
396
- return `${col.columnDef.width}px`;
397
- }
398
- return `minmax(${col.columnDef.width}px, 1fr)`;
399
- };
400
- const tracks: Array<string> = ['20px'];
401
- for (const col of leftColumns) {
402
- tracks.push(track(col));
403
- }
404
- for (const col of centreColumns) {
405
- tracks.push(track(col));
406
- }
407
- for (const col of rightColumns) {
408
- tracks.push(track(col));
409
- }
410
- return tracks.join(' ');
411
- });
412
-
413
- // Total rendered width of the pinned-left and pinned-right column
414
- // stacks. Driven by `_columnRenderedWidths` so the values track actual
415
- // layout rather than the declared `columnDef.width`. Exposed to CSS
416
- // as `--pinned-left` / `--pinned-right`; the SCSS uses these in
417
- // `scroll-padding-{left,right}` so `scrollIntoView` (called on keyboard
418
- // nav and focus restore) keeps the focused cell out from behind the
419
- // pinned columns. The 20px highlight column is always pinned-left.
420
- const totalPinnedLeftWidth = $derived.by(() => {
421
- void tableContext._columnRenderedWidths.size;
422
- let total = 20;
423
- for (const col of leftColumns) {
424
- total += tableContext._columnRenderedWidths.get(col.id) ?? col.columnDef.width;
425
- }
426
- return total;
427
- });
428
- const totalPinnedRightWidth = $derived.by(() => {
429
- void tableContext._columnRenderedWidths.size;
430
- let total = 0;
431
- for (const col of rightColumns) {
432
- total += tableContext._columnRenderedWidths.get(col.id) ?? col.columnDef.width;
433
- }
434
- return total;
435
- });
436
-
437
- // Single scroll listener for the shared scroll container, which is
438
- // also the host the row virtualiser registers against. The same
439
- // handler updates `hasScrolledPastHeader` / `hasContentBelowFooter`,
440
- // which drive the sticky-row drop shadows — shadows appear only when
441
- // there is actually content scrolled under the sticky strip.
442
- let hasScrolledPastHeader = $state(false);
443
- let hasContentBelowFooter = $state(false);
444
-
445
- // Measured rendered height of the sticky strip at the top of
446
- // `.datatable` (header row + quick-filter row when active). Drives
447
- // `scroll-padding-top` via `--sticky-top-height`, so `scrollIntoView`
448
- // (keyboard nav, focus restore) parks the focused cell below the
449
- // actual strip — a static `calc(var(--height) + Npx)` left a 1-2px
450
- // gap from sub-pixel rounding and borders, clipping the body row.
451
- let stickyTopHeight: number = $state(0);
452
-
453
- function recomputeScrollShadows(): void {
454
- if (!tableRef) {
455
- hasScrolledPastHeader = false;
456
- hasContentBelowFooter = false;
457
- return;
458
- }
459
- // `.datatable` has `overflow-x: auto`. Per the CSS spec, when one
460
- // overflow axis is non-visible the other axis becomes auto too,
461
- // so `.datatable` is itself the vertical scroll container when
462
- // the table is constrained inside a fixed-height parent (the
463
- // typical app shell). Detect internal scroll directly.
464
- const internalScrolledPast = tableRef.scrollTop > 0;
465
- const internalContentBelow =
466
- tableRef.scrollTop + tableRef.clientHeight < tableRef.scrollHeight - 1;
467
- // Fall back to window-relative checks when the table is not
468
- // constrained and the page itself is the scroll container.
469
- const rect = tableRef.getBoundingClientRect();
470
- const windowScrolledPast = rect.top < 0;
471
- const windowContentBelow = rect.bottom > window.innerHeight;
472
- hasScrolledPastHeader = internalScrolledPast || windowScrolledPast;
473
- hasContentBelowFooter = internalContentBelow || windowContentBelow;
474
- }
475
-
476
- $effect(() => {
477
- void tableRef;
478
- void unfocusRef;
479
- const handler = (e: Event): void => {
480
- const el = e.currentTarget as HTMLElement | null;
481
- if (!el) {
482
- return;
483
- }
484
- tableContext._virtualisation.registerScrollPosition(el.scrollTop, el.clientHeight);
485
- // Refresh shadow flags on every internal scroll: when the table
486
- // is the scroll container, body scroll never reaches the window
487
- // listener and the shadow flags would otherwise never flip.
488
- recomputeScrollShadows();
489
- };
490
- const elements: Array<HTMLElement> = [];
491
- if (tableRef) {
492
- elements.push(tableRef);
493
- }
494
- if (unfocusRef && unfocusRef !== tableRef) {
495
- elements.push(unfocusRef);
496
- }
497
- for (const el of elements) {
498
- el.addEventListener('scroll', handler, { passive: true });
499
- }
500
- const primary = tableRef ?? unfocusRef;
501
- if (primary) {
502
- tableContext._virtualisation.registerScrollPosition(primary.scrollTop, primary.clientHeight);
503
- }
504
-
505
- // Window scroll + resize cover the unconstrained case where the
506
- // page itself scrolls; `recomputeScrollShadows` handles both.
507
- const onWindow = (): void => {
508
- recomputeScrollShadows();
509
- };
510
- window.addEventListener('scroll', onWindow, { passive: true });
511
- window.addEventListener('resize', onWindow, { passive: true });
512
-
513
- // Content-height changes (row add/remove, pagination, expand
514
- // toggled) can flip the shadow flags without any user scroll, so
515
- // re-evaluate on any intrinsic-height change of the table.
516
- const ro =
517
- typeof ResizeObserver === 'undefined'
518
- ? null
519
- : new ResizeObserver(() => {
520
- recomputeScrollShadows();
521
- });
522
- if (ro && tableRef) {
523
- ro.observe(tableRef);
524
- }
525
-
526
- // Measure the actual rendered height of the sticky strip (header +
527
- // quick-filter row when active) so `scroll-padding-top` matches
528
- // it to the pixel.
529
- const measureStickyTop = (): void => {
530
- if (!tableRef) {
531
- return;
532
- }
533
- const headerRow = tableRef.querySelector('.datatable-row.header-row') as HTMLElement | null;
534
- const filterRow = tableRef.querySelector(
535
- '.datatable-row.quick-filter-row'
536
- ) as HTMLElement | null;
537
- stickyTopHeight = (headerRow?.offsetHeight ?? 0) + (filterRow?.offsetHeight ?? 0);
538
- };
539
- const stickyRo =
540
- typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measureStickyTop);
541
- if (stickyRo && tableRef) {
542
- // Observe the scroll host so any change inside (mode
543
- // toggle, rows added, etc.) re-measures on the next frame.
544
- stickyRo.observe(tableRef);
545
- }
546
- // Initial measurement so the very first focus / arrow-key
547
- // navigation lands below the strip without waiting for the
548
- // observer's first frame.
549
- measureStickyTop();
550
-
551
- recomputeScrollShadows();
552
-
553
- return () => {
554
- for (const el of elements) {
555
- el.removeEventListener('scroll', handler);
556
- }
557
- window.removeEventListener('scroll', onWindow);
558
- window.removeEventListener('resize', onWindow);
559
- ro?.disconnect();
560
- stickyRo?.disconnect();
561
- };
562
- });
563
- </script>
564
-
565
- <svelte:window
566
- onmouseup={(e: MouseEvent) => {
567
- if (
568
- // if it's not a click on an item in the context menu
569
- !(e.target as HTMLElement | undefined)?.classList.contains('context-menu-item') &&
570
- // if it's outside the table or inside the empty space of the table
571
- (e.target === unfocusRef ||
572
- ((e.target as HTMLElement | undefined)?.closest('.table-wrapper') == null &&
573
- mousedDownEmptySpace))
574
- ) {
575
- tableContext.unfocus();
576
- }
577
- mousedDownEmptySpace = false;
578
-
579
- if (tableContext.isMousingDown) {
580
- tableContext.isMousingDown = false;
581
- }
582
-
583
- removeOverrideResizeHandler();
584
- }}
585
- onclick={(e: MouseEvent) => {
586
- handleClickOutside(e);
587
- }}
588
- onmousedown={(e: MouseEvent) => {
589
- mousedDownEmptySpace =
590
- e.target === unfocusRef || (e.target as HTMLElement).closest('.table-wrapper') == null;
591
- }}
592
- />
593
-
594
- <div
595
- class="grid"
596
- class:nested
597
- style="--show-top-shadow: {hasScrolledPastHeader
598
- ? 1
599
- : 0}; --show-bottom-shadow: {hasContentBelowFooter ? 1 : 0};"
600
- >
601
- {#if tableContext.dataRepo.isLoading}
602
- <LoadingOverlay
603
- absolute
604
- borderRadius={cssLength('4px')}
605
- message={tableContext.dataRepo.loadingMessage}
606
- />
607
- {/if}
608
-
609
- <div
610
- class="horizontal-bar"
611
- class:border={tableContext.toolbarPosition === ToolbarPosition.Top}
612
- >
613
- {#if tableContext.toolbarPosition === ToolbarPosition.Top}
614
- <TableHorizontalBar />
615
- {/if}
616
- </div>
617
-
618
- <div
619
- class="sidebar-wrapper sidebar-wrapper-left"
620
- class:nested
621
- style="--nested-height: {nestedHeight}px;"
622
- >
623
- {#if tableContext.toolbarPosition === ToolbarPosition.Left}
624
- <div class="sidebar toolbar-left">
625
- <TableSidebar />
626
- </div>
627
- {/if}
628
- </div>
629
- <div
630
- class="sidebar-wrapper sidebar-wrapper-right"
631
- class:nested
632
- style="--nested-height: {nestedHeight}px;"
633
- >
634
- {#if tableContext.toolbarPosition === ToolbarPosition.Right}
635
- <div class="sidebar toolbar-right">
636
- <TableSidebar />
637
- </div>
638
- {/if}
639
- </div>
640
-
641
- <div class="footer">
642
- {#if tableContext.displayFooter}
643
- <TableFooter />
644
- {/if}
645
- </div>
646
-
647
- <!-- svelte-ignore a11y_no_static_element_interactions -->
648
- <div
649
- bind:this={tableRef}
650
- class="datatable"
651
- class:nested
652
- class:compact={tableContext.isCompact}
653
- class:quick-filtering={tableContext.showQuickFiltering}
654
- onkeydown={tableContext.handleTdKeydown}
655
- style="--pinned-left: {totalPinnedLeftWidth}px; --pinned-right: {totalPinnedRightWidth}px; --sticky-top-height: {stickyTopHeight}px;"
656
- >
657
- <div
658
- class="flex_table"
659
- bind:this={ref}
660
- class:nested
661
- >
662
- <div
663
- bind:this={unfocusRef}
664
- class="table-wrapper"
665
- oncontextmenu={openContextMenu}
666
- >
667
- <div
668
- role="table"
669
- class="datatable-grid"
670
- style="--cols: {colsTemplate};"
671
- >
672
- <TableHead />
673
- <TableBody />
674
- </div>
675
- </div>
676
- </div>
677
-
678
- {#if nested}
679
- <!-- svelte-ignore a11y_no_static_element_interactions -->
680
- <div
681
- class:isResizing
682
- class="resizer"
683
- onmousedown={(e) => {
684
- e.preventDefault();
685
- addOverrideResizeHandler();
686
- }}
687
- ></div>
688
- {/if}
689
- </div>
690
- </div>
691
-
1
+ <svelte:options runes />
2
+
3
+ <script
4
+ lang="ts"
5
+ module
6
+ >
7
+ import {
8
+ ContextMenuContext,
9
+ type IContextMenuOption,
10
+ } from '../../Contexts/ContextMenuContext.svelte';
11
+ import { cssLength, devCatch, refWrapper } from '../../Helpers/Helpers.svelte';
12
+ import { getContext, onMount, setContext, tick, untrack } from 'svelte';
13
+ import LoadingOverlay from '../LoadingOverlay.svelte';
14
+ import TableBody from './Body/TableBody.svelte';
15
+ import TableHead from './Headers/TableHead.svelte';
16
+ import TableFooter from './Misc/TableFooter.svelte';
17
+ import TableHorizontalBar from './Misc/TableHorizontalBar.svelte';
18
+ import TableSidebar from './Misc/TableSidebar.svelte';
19
+ import { TableContext } from './Types/Context/TableContext.svelte';
20
+ import type { Primitive } from './Types/Context/TableInitOptions.js';
21
+ import { PaginationType } from './Types/Pagination/PaginationType.js';
22
+ import { ToolbarPosition } from './Types/Toolbar/ToolbarPosition.js';
23
+
24
+ declare global {
25
+ interface HTMLElementEventMap {
26
+ edit: CustomEvent<{
27
+ selectAll: boolean;
28
+ }>;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Top-level orchestrator props. Accepts either:
34
+ *
35
+ * - `table`: the structured `Table` instance returned by `createTable(...)`.
36
+ * This is the consumer-facing form.
37
+ * - `tableContext`: the underlying `TableContext`. Used internally by
38
+ * `NestedTable` which constructs a context directly for each
39
+ * expansion. Not intended for consumer use.
40
+ *
41
+ * Exactly one of the two must be supplied; a runtime guard inside the
42
+ * component throws if both are missing. The reference passed in is
43
+ * captured at mount via `setContext` and should not be reassigned
44
+ * afterwards.
45
+ */
46
+ export interface TableProps<T extends object, IdType extends Primitive> {
47
+ readonly table?: { readonly _context: TableContext<T, IdType> };
48
+ readonly tableContext?: TableContext<T, IdType>;
49
+ nested?: boolean;
50
+ }
51
+
52
+ /**
53
+ * Symbol used to share the currently-open quick-filter select across
54
+ * every header in a single Table tree. Stored via `setContext` on
55
+ * the same component that hosts the Table tree so multiple
56
+ * independent tables on a page each own their own popup state.
57
+ */
58
+ export const quickFilterContextIdentifier: symbol = Symbol();
59
+
60
+ const openedQuickFilterSelect = refWrapper<symbol | null>(null);
61
+ </script>
62
+
63
+ <script
64
+ lang="ts"
65
+ generics="T extends object,IdType extends Primitive"
66
+ >
67
+ const { nested = false, table, tableContext: tableContextProp }: TableProps<T, IdType> = $props();
68
+
69
+ // Resolve the underlying TableContext from whichever prop was passed:
70
+ // consumers pass `table`; internal `NestedTable` passes `tableContext`
71
+ // directly. Exactly one must be present — throw cleanly here rather
72
+ // than letting it surface as a downstream null-deref.
73
+ const tableContext: TableContext<T, IdType> = (() => {
74
+ const ctx = table?._context ?? tableContextProp;
75
+ if (!ctx) {
76
+ throw new Error(
77
+ 'scoria: <Table /> requires either a `table` prop (the structured instance returned by createTable) or a `tableContext` prop (used internally by NestedTable).'
78
+ );
79
+ }
80
+ return ctx;
81
+ })();
82
+
83
+ // NOTE: known limitation. This is passed as a wrapper, and they should never require reassignment. It is deeply reactive
84
+ setContext(TableContext.identifier, tableContext);
85
+
86
+ const { openContextMenu: _openContextMenu } = getContext<ContextMenuContext>(
87
+ ContextMenuContext.identifier
88
+ );
89
+
90
+ let ref: HTMLDivElement | undefined = $state();
91
+ let unfocusRef: HTMLDivElement | undefined = $state();
92
+
93
+ setContext(quickFilterContextIdentifier, openedQuickFilterSelect);
94
+
95
+ // Focus-restore on focusStart change. The DOM work runs inside `untrack`
96
+ // so a future selector tweak that reads reactive state (e.g.
97
+ // `document.activeElement`) does not retrigger on every focus change.
98
+ $effect(() => {
99
+ void tableContext.focusStart;
100
+ void ref;
101
+ untrack(() => {
102
+ if (tableContext.focusStart && ref) {
103
+ const { row, column: index } = tableContext.focusStart;
104
+ // `columnDefs[index]` can be undefined when the focus
105
+ // coordinate references a slot that no longer exists
106
+ // (e.g. an inner expand table remounted with a different
107
+ // column count, or a preset hydration raced column init).
108
+ // Guard so out-of-range degrades to "no focus restored".
109
+ const def = tableContext.columnDefs[index];
110
+ if (!def) {
111
+ return;
112
+ }
113
+ let element = ref.querySelector(
114
+ `#${tableContext.tableName}-cell-${row}-${def.id}`
115
+ ) as HTMLElement | undefined;
116
+
117
+ if (element?.tabIndex == -1) {
118
+ const td = element;
119
+ element = td.querySelector('button') as HTMLElement | undefined;
120
+ if (!element) {
121
+ element = td.querySelector('input') as HTMLElement | undefined;
122
+ }
123
+ }
124
+
125
+ element?.focus({ preventScroll: true });
126
+ // Skip horizontal scroll-into-view for pinned cells (see the
127
+ // focusEnd effect for the rationale).
128
+ const startCell = element?.closest('[role="cell"]');
129
+ const isStartPinned =
130
+ startCell?.classList.contains('pinned-left') ||
131
+ startCell?.classList.contains('pinned-right');
132
+ element?.scrollIntoView({
133
+ behavior: 'instant',
134
+ block: 'nearest',
135
+ inline: isStartPinned ? undefined : 'nearest',
136
+ });
137
+ }
138
+ });
139
+ });
140
+
141
+ // Scroll-into-view on focusEnd change. DOM work wrapped in `untrack` so
142
+ // reading layout properties never re-establishes a reactive dependency.
143
+ $effect(() => {
144
+ void tableContext.focusEnd;
145
+ void ref;
146
+ untrack(() => {
147
+ if (tableContext.focusEnd && ref) {
148
+ const { row, column: index } = tableContext.focusEnd;
149
+ const def = tableContext.columnDefs[index];
150
+ if (!def) {
151
+ return;
152
+ }
153
+ const cell = ref.querySelector(
154
+ `#${tableContext.tableName}-cell-${row}-${def.id}`
155
+ ) as HTMLElement | undefined;
156
+
157
+ // Pinned cells are visually always on screen, but their true
158
+ // layout position is at their real column track — which for
159
+ // a right-pinned column in a wider-than-viewport table is
160
+ // offscreen. Calling `scrollIntoView({inline:'nearest'})`
161
+ // would then scroll the table sideways to surface the cell's
162
+ // underlying position, which the user sees as the table
163
+ // jumping when they click a pinned Actions button. The
164
+ // vertical (block) axis still scrolls normally because the
165
+ // row's vertical position is the user's real scroll target.
166
+ const isPinned =
167
+ cell?.classList.contains('pinned-left') ||
168
+ cell?.classList.contains('pinned-right');
169
+ cell?.scrollIntoView({
170
+ behavior: 'instant',
171
+ block: 'nearest',
172
+ inline: isPinned ? undefined : 'nearest',
173
+ });
174
+ }
175
+ });
176
+ });
177
+
178
+ /**
179
+ * Routes a right-click into either a "focus the cell that was
180
+ * clicked" path (if the click landed on a cell other than the
181
+ * current focus) or a "show copy options" context menu (when the
182
+ * Table is configured with `allowCopy` and the platform exposes the
183
+ * Clipboard API).
184
+ */
185
+ function openContextMenu(e: MouseEvent) {
186
+ e.stopPropagation();
187
+
188
+ if (
189
+ (e.target as HTMLElement).tagName === 'INPUT' ||
190
+ (e.target as HTMLElement).closest('.select-wrapper') != null
191
+ ) {
192
+ return;
193
+ }
194
+
195
+ const clickedElement = document.activeElement?.id.split('-cell-')[1]?.split('-');
196
+
197
+ if (clickedElement) {
198
+ const [rowString, columnId] = clickedElement;
199
+ const row = parseInt(rowString);
200
+
201
+ if (
202
+ !tableContext.focusedColumnIds.has(columnId) ||
203
+ !tableContext.focusedRowIndices.has(row)
204
+ ) {
205
+ const columnIndex = tableContext.columnDefs.find((it) => it.id === columnId)?.index;
206
+
207
+ // Use `!= null` rather than a truthy check so a column at
208
+ // memory index 0 (the leftmost column) is not silently
209
+ // rejected. The previous truthy check broke right-click
210
+ // copy on the first column.
211
+ // `!= null` (not truthy) so an index of 0 (leftmost column) is
212
+ // not silently rejected.
213
+ if (columnIndex != null) {
214
+ tableContext.moveFocus({
215
+ column: columnIndex,
216
+ row,
217
+ id: columnId,
218
+ });
219
+ }
220
+ }
221
+ }
222
+
223
+ // `window.Clipboard` may be absent in non-HTTPS contexts.
224
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
225
+ if (tableContext.allowCopy && window.Clipboard) {
226
+ e.preventDefault();
227
+
228
+ const options: Array<IContextMenuOption> = [
229
+ {
230
+ callback: () => {
231
+ tableContext.copyDataToClipboard().catch(devCatch);
232
+ },
233
+ text: 'Copy Selection',
234
+ },
235
+ ];
236
+
237
+ if (tableContext.hasHighlightedItems) {
238
+ options.push({
239
+ callback: () => {
240
+ tableContext.copyHighlightedRowsToClipboard().catch(devCatch);
241
+ },
242
+ text: 'Copy Highlighted Rows',
243
+ });
244
+ }
245
+
246
+ _openContextMenu(e, options).catch(devCatch);
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Closes the quick-filter popup and the actions-cell popup whenever
252
+ * the user clicks somewhere that is not inside either floating UI.
253
+ */
254
+ function handleClickOutside(e: MouseEvent) {
255
+ const target = e.target as HTMLElement;
256
+ if (target.isConnected) {
257
+ // A click inside an open Svelecte dropdown (marked with
258
+ // `data-scoria-portal-dropdown` by `dropdownPortal.ts`) is a
259
+ // logical extension of the trigger inside the quick-filter
260
+ // popup, so it must not close either floating popover.
261
+ const insidePortalDropdown = target.closest('[data-scoria-portal-dropdown]') != null;
262
+ if (
263
+ !insidePortalDropdown &&
264
+ target.closest('div.floating') == null &&
265
+ target.closest('div.stick-right') == null
266
+ ) {
267
+ openedQuickFilterSelect.value = null;
268
+ }
269
+
270
+ if (!insidePortalDropdown && target.closest('div.slot-wrapper') == null) {
271
+ tableContext.shownActionsPopup = undefined;
272
+ }
273
+ }
274
+ }
275
+
276
+ // nested table resizing
277
+ let isResizing = $state(false);
278
+ let remPxRatio = $state(0);
279
+ const minNestedHeight = $derived(20 * remPxRatio);
280
+
281
+ let nestedHeight = $state(0);
282
+ $effect.pre(() => {
283
+ if (!isResizing) {
284
+ nestedHeight = minNestedHeight;
285
+ }
286
+ });
287
+
288
+ let tableRef: HTMLDivElement | undefined = $state();
289
+ let mousedDownEmptySpace = $state(false);
290
+
291
+ onMount(() => {
292
+ remPxRatio = parseFloat(getComputedStyle(document.documentElement).fontSize);
293
+ });
294
+
295
+ $effect(() => {
296
+ if (!isResizing) {
297
+ return;
298
+ }
299
+ const onResize = (e: MouseEvent) => {
300
+ const sliderHeight = tableRef ? e.clientY - tableRef.getBoundingClientRect().top : 0;
301
+ nestedHeight = Math.max(sliderHeight, minNestedHeight);
302
+ };
303
+ window.addEventListener('mousemove', onResize);
304
+ return () => {
305
+ window.removeEventListener('mousemove', onResize);
306
+ };
307
+ });
308
+
309
+ function addOverrideResizeHandler() {
310
+ isResizing = true;
311
+ }
312
+
313
+ function removeOverrideResizeHandler() {
314
+ isResizing = false;
315
+ }
316
+
317
+ const usesLoading = $derived(
318
+ tableContext.paginationRepo &&
319
+ tableContext.paginationRepo.paginationType === PaginationType.Remote
320
+ );
321
+
322
+ const shouldUnfocusRemote = $derived(
323
+ usesLoading &&
324
+ tableContext.paginationRepo?.isLoading === false &&
325
+ tableContext.paginationRepo.paginationState.pageIndex !==
326
+ tableContext.paginationRepo.previouslyViewedPage
327
+ );
328
+
329
+ const shouldUnfocusLocal = $derived(
330
+ !usesLoading && tableContext.paginationRepo?.paginationState.pageIndex
331
+ );
332
+
333
+ $effect(() => {
334
+ if ((shouldUnfocusRemote || shouldUnfocusLocal) && unfocusRef) {
335
+ const el = unfocusRef;
336
+ untrack(() => {
337
+ tableContext.unfocus();
338
+ el.scrollTop = 0;
339
+ });
340
+ } else if (usesLoading && tableContext.paginationRepo?.isLoading === false) {
341
+ // refocus same element after a reload
342
+ // this does not break mutifocus
343
+ untrack(() => {
344
+ const fs = tableContext.focusStart;
345
+ if (!fs) {
346
+ return;
347
+ }
348
+ const idToFocusTo = `#${tableContext.tableName}-cell-${fs.row}-${fs.id}`;
349
+
350
+ tick()
351
+ .then(() => {
352
+ (document.querySelector(idToFocusTo) as HTMLElement | undefined)?.focus();
353
+ })
354
+ .catch(devCatch);
355
+ });
356
+ }
357
+ });
358
+
359
+ const leftColumns = $derived(tableContext.table.getLeftVisibleLeafColumns());
360
+ const centreColumns = $derived(tableContext.table.getCenterVisibleLeafColumns());
361
+ const rightColumns = $derived(tableContext.table.getRightVisibleLeafColumns());
362
+
363
+ // CSS Grid template for the table.
364
+ //
365
+ // - First track is the 20px highlight column (always present).
366
+ // - For each other column, the track depends on its `userResized`
367
+ // flag and its declared width/maxWidth:
368
+ //
369
+ // userResized = true → `${width}px`
370
+ // userResized = false, maxWidth set → `minmax(width, maxWidth)`
371
+ // userResized = false, widthIsFixed → `${width}px`
372
+ // userResized = false, on per-kind default → `minmax(width, 1fr)`
373
+ //
374
+ // This produces "specify widths for the columns you care about; let
375
+ // the rest fill the gap", matching the legacy `<table>` behaviour.
376
+ // Pinned and centre columns use the same rule so pinning a column
377
+ // does not change its width. Sticky-cell offsets read from
378
+ // `_columnRenderedWidths` (populated by TableHead's ResizeObserver).
379
+ const colsTemplate = $derived.by(() => {
380
+ void tableContext.columnSizing;
381
+ const track = (col: {
382
+ columnDef: {
383
+ width: number;
384
+ maxWidth?: number;
385
+ userResized: boolean;
386
+ widthIsFixed: boolean;
387
+ };
388
+ }) => {
389
+ if (col.columnDef.userResized) {
390
+ return `${col.columnDef.width}px`;
391
+ }
392
+ if (col.columnDef.maxWidth != null) {
393
+ return `minmax(${col.columnDef.width}px, ${col.columnDef.maxWidth}px)`;
394
+ }
395
+ if (col.columnDef.widthIsFixed) {
396
+ return `${col.columnDef.width}px`;
397
+ }
398
+ return `minmax(${col.columnDef.width}px, 1fr)`;
399
+ };
400
+ const tracks: Array<string> = ['20px'];
401
+ for (const col of leftColumns) {
402
+ tracks.push(track(col));
403
+ }
404
+ for (const col of centreColumns) {
405
+ tracks.push(track(col));
406
+ }
407
+ for (const col of rightColumns) {
408
+ tracks.push(track(col));
409
+ }
410
+ return tracks.join(' ');
411
+ });
412
+
413
+ // Total rendered width of the pinned-left and pinned-right column
414
+ // stacks. Driven by `_columnRenderedWidths` so the values track actual
415
+ // layout rather than the declared `columnDef.width`. Exposed to CSS
416
+ // as `--pinned-left` / `--pinned-right`; the SCSS uses these in
417
+ // `scroll-padding-{left,right}` so `scrollIntoView` (called on keyboard
418
+ // nav and focus restore) keeps the focused cell out from behind the
419
+ // pinned columns. The 20px highlight column is always pinned-left.
420
+ const totalPinnedLeftWidth = $derived.by(() => {
421
+ void tableContext._columnRenderedWidths.size;
422
+ let total = 20;
423
+ for (const col of leftColumns) {
424
+ total += tableContext._columnRenderedWidths.get(col.id) ?? col.columnDef.width;
425
+ }
426
+ return total;
427
+ });
428
+ const totalPinnedRightWidth = $derived.by(() => {
429
+ void tableContext._columnRenderedWidths.size;
430
+ let total = 0;
431
+ for (const col of rightColumns) {
432
+ total += tableContext._columnRenderedWidths.get(col.id) ?? col.columnDef.width;
433
+ }
434
+ return total;
435
+ });
436
+
437
+ // Single scroll listener for the shared scroll container, which is
438
+ // also the host the row virtualiser registers against. The same
439
+ // handler updates `hasScrolledPastHeader` / `hasContentBelowFooter`,
440
+ // which drive the sticky-row drop shadows — shadows appear only when
441
+ // there is actually content scrolled under the sticky strip.
442
+ let hasScrolledPastHeader = $state(false);
443
+ let hasContentBelowFooter = $state(false);
444
+
445
+ // Measured rendered height of the sticky strip at the top of
446
+ // `.datatable` (header row + quick-filter row when active). Drives
447
+ // `scroll-padding-top` via `--sticky-top-height`, so `scrollIntoView`
448
+ // (keyboard nav, focus restore) parks the focused cell below the
449
+ // actual strip — a static `calc(var(--height) + Npx)` left a 1-2px
450
+ // gap from sub-pixel rounding and borders, clipping the body row.
451
+ let stickyTopHeight: number = $state(0);
452
+
453
+ function recomputeScrollShadows(): void {
454
+ if (!tableRef) {
455
+ hasScrolledPastHeader = false;
456
+ hasContentBelowFooter = false;
457
+ return;
458
+ }
459
+ // `.datatable` has `overflow-x: auto`. Per the CSS spec, when one
460
+ // overflow axis is non-visible the other axis becomes auto too,
461
+ // so `.datatable` is itself the vertical scroll container when
462
+ // the table is constrained inside a fixed-height parent (the
463
+ // typical app shell). Detect internal scroll directly.
464
+ const internalScrolledPast = tableRef.scrollTop > 0;
465
+ const internalContentBelow =
466
+ tableRef.scrollTop + tableRef.clientHeight < tableRef.scrollHeight - 1;
467
+ // Fall back to window-relative checks when the table is not
468
+ // constrained and the page itself is the scroll container.
469
+ const rect = tableRef.getBoundingClientRect();
470
+ const windowScrolledPast = rect.top < 0;
471
+ const windowContentBelow = rect.bottom > window.innerHeight;
472
+ hasScrolledPastHeader = internalScrolledPast || windowScrolledPast;
473
+ hasContentBelowFooter = internalContentBelow || windowContentBelow;
474
+ }
475
+
476
+ $effect(() => {
477
+ void tableRef;
478
+ void unfocusRef;
479
+ const handler = (e: Event): void => {
480
+ const el = e.currentTarget as HTMLElement | null;
481
+ if (!el) {
482
+ return;
483
+ }
484
+ tableContext._virtualisation.registerScrollPosition(el.scrollTop, el.clientHeight);
485
+ // Refresh shadow flags on every internal scroll: when the table
486
+ // is the scroll container, body scroll never reaches the window
487
+ // listener and the shadow flags would otherwise never flip.
488
+ recomputeScrollShadows();
489
+ };
490
+ const elements: Array<HTMLElement> = [];
491
+ if (tableRef) {
492
+ elements.push(tableRef);
493
+ }
494
+ if (unfocusRef && unfocusRef !== tableRef) {
495
+ elements.push(unfocusRef);
496
+ }
497
+ for (const el of elements) {
498
+ el.addEventListener('scroll', handler, { passive: true });
499
+ }
500
+ const primary = tableRef ?? unfocusRef;
501
+ if (primary) {
502
+ tableContext._virtualisation.registerScrollPosition(primary.scrollTop, primary.clientHeight);
503
+ }
504
+
505
+ // Window scroll + resize cover the unconstrained case where the
506
+ // page itself scrolls; `recomputeScrollShadows` handles both.
507
+ const onWindow = (): void => {
508
+ recomputeScrollShadows();
509
+ };
510
+ window.addEventListener('scroll', onWindow, { passive: true });
511
+ window.addEventListener('resize', onWindow, { passive: true });
512
+
513
+ // Content-height changes (row add/remove, pagination, expand
514
+ // toggled) can flip the shadow flags without any user scroll, so
515
+ // re-evaluate on any intrinsic-height change of the table.
516
+ const ro =
517
+ typeof ResizeObserver === 'undefined'
518
+ ? null
519
+ : new ResizeObserver(() => {
520
+ recomputeScrollShadows();
521
+ });
522
+ if (ro && tableRef) {
523
+ ro.observe(tableRef);
524
+ }
525
+
526
+ // Measure the actual rendered height of the sticky strip (header +
527
+ // quick-filter row when active) so `scroll-padding-top` matches
528
+ // it to the pixel.
529
+ const measureStickyTop = (): void => {
530
+ if (!tableRef) {
531
+ return;
532
+ }
533
+ const headerRow = tableRef.querySelector('.datatable-row.header-row') as HTMLElement | null;
534
+ const filterRow = tableRef.querySelector(
535
+ '.datatable-row.quick-filter-row'
536
+ ) as HTMLElement | null;
537
+ stickyTopHeight = (headerRow?.offsetHeight ?? 0) + (filterRow?.offsetHeight ?? 0);
538
+ };
539
+ const stickyRo =
540
+ typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measureStickyTop);
541
+ if (stickyRo && tableRef) {
542
+ // Observe the scroll host so any change inside (mode
543
+ // toggle, rows added, etc.) re-measures on the next frame.
544
+ stickyRo.observe(tableRef);
545
+ }
546
+ // Initial measurement so the very first focus / arrow-key
547
+ // navigation lands below the strip without waiting for the
548
+ // observer's first frame.
549
+ measureStickyTop();
550
+
551
+ recomputeScrollShadows();
552
+
553
+ return () => {
554
+ for (const el of elements) {
555
+ el.removeEventListener('scroll', handler);
556
+ }
557
+ window.removeEventListener('scroll', onWindow);
558
+ window.removeEventListener('resize', onWindow);
559
+ ro?.disconnect();
560
+ stickyRo?.disconnect();
561
+ };
562
+ });
563
+ </script>
564
+
565
+ <svelte:window
566
+ onmouseup={(e: MouseEvent) => {
567
+ if (
568
+ // if it's not a click on an item in the context menu
569
+ !(e.target as HTMLElement | undefined)?.classList.contains('context-menu-item') &&
570
+ // if it's outside the table or inside the empty space of the table
571
+ (e.target === unfocusRef ||
572
+ ((e.target as HTMLElement | undefined)?.closest('.table-wrapper') == null &&
573
+ mousedDownEmptySpace))
574
+ ) {
575
+ tableContext.unfocus();
576
+ }
577
+ mousedDownEmptySpace = false;
578
+
579
+ if (tableContext.isMousingDown) {
580
+ tableContext.isMousingDown = false;
581
+ }
582
+
583
+ removeOverrideResizeHandler();
584
+ }}
585
+ onclick={(e: MouseEvent) => {
586
+ handleClickOutside(e);
587
+ }}
588
+ onmousedown={(e: MouseEvent) => {
589
+ mousedDownEmptySpace =
590
+ e.target === unfocusRef || (e.target as HTMLElement).closest('.table-wrapper') == null;
591
+ }}
592
+ />
593
+
594
+ <div
595
+ class="grid"
596
+ class:nested
597
+ style="--show-top-shadow: {hasScrolledPastHeader
598
+ ? 1
599
+ : 0}; --show-bottom-shadow: {hasContentBelowFooter ? 1 : 0};"
600
+ >
601
+ {#if tableContext.dataRepo.isLoading}
602
+ <LoadingOverlay
603
+ absolute
604
+ borderRadius={cssLength('4px')}
605
+ message={tableContext.dataRepo.loadingMessage}
606
+ />
607
+ {/if}
608
+
609
+ <div
610
+ class="horizontal-bar"
611
+ class:border={tableContext.toolbarPosition === ToolbarPosition.Top}
612
+ >
613
+ {#if tableContext.toolbarPosition === ToolbarPosition.Top}
614
+ <TableHorizontalBar />
615
+ {/if}
616
+ </div>
617
+
618
+ <div
619
+ class="sidebar-wrapper sidebar-wrapper-left"
620
+ class:nested
621
+ style="--nested-height: {nestedHeight}px;"
622
+ >
623
+ {#if tableContext.toolbarPosition === ToolbarPosition.Left}
624
+ <div class="sidebar toolbar-left">
625
+ <TableSidebar />
626
+ </div>
627
+ {/if}
628
+ </div>
629
+ <div
630
+ class="sidebar-wrapper sidebar-wrapper-right"
631
+ class:nested
632
+ style="--nested-height: {nestedHeight}px;"
633
+ >
634
+ {#if tableContext.toolbarPosition === ToolbarPosition.Right}
635
+ <div class="sidebar toolbar-right">
636
+ <TableSidebar />
637
+ </div>
638
+ {/if}
639
+ </div>
640
+
641
+ <div class="footer">
642
+ {#if tableContext.displayFooter}
643
+ <TableFooter />
644
+ {/if}
645
+ </div>
646
+
647
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
648
+ <div
649
+ bind:this={tableRef}
650
+ class="datatable"
651
+ class:nested
652
+ class:compact={tableContext.isCompact}
653
+ class:quick-filtering={tableContext.showQuickFiltering}
654
+ onkeydown={tableContext.handleTdKeydown}
655
+ style="--pinned-left: {totalPinnedLeftWidth}px; --pinned-right: {totalPinnedRightWidth}px; --sticky-top-height: {stickyTopHeight}px;"
656
+ >
657
+ <div
658
+ class="flex_table"
659
+ bind:this={ref}
660
+ class:nested
661
+ >
662
+ <div
663
+ bind:this={unfocusRef}
664
+ class="table-wrapper"
665
+ oncontextmenu={openContextMenu}
666
+ >
667
+ <div
668
+ role="table"
669
+ class="datatable-grid"
670
+ style="--cols: {colsTemplate};"
671
+ >
672
+ <TableHead />
673
+ <TableBody />
674
+ </div>
675
+ </div>
676
+ </div>
677
+
678
+ {#if nested}
679
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
680
+ <div
681
+ class:isResizing
682
+ class="resizer"
683
+ onmousedown={(e) => {
684
+ e.preventDefault();
685
+ addOverrideResizeHandler();
686
+ }}
687
+ ></div>
688
+ {/if}
689
+ </div>
690
+ </div>
691
+
692
692
  <style>div.grid {
693
693
  border-radius: 4px;
694
694
  display: grid;
@@ -840,4 +840,4 @@ div.sidebar-wrapper .sidebar.toolbar-right {
840
840
  text-align: left;
841
841
  min-width: max-content;
842
842
  isolation: isolate;
843
- }</style>
843
+ }</style>