@lavalogic/scoria 0.40.1 → 0.40.2

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 (137) 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/Contexts/ContextMenu.svelte +164 -164
  17. package/dist/Components/Contexts/ContextMenuDivider.svelte +15 -15
  18. package/dist/Components/Contexts/ContextMenuOption.svelte +60 -60
  19. package/dist/Components/Contexts/Toast.svelte +86 -86
  20. package/dist/Components/Contexts/ToastContainer.svelte +65 -65
  21. package/dist/Components/Contexts/Tooltip.svelte +37 -37
  22. package/dist/Components/DataMatrixIcon.svelte +112 -112
  23. package/dist/Components/DateInput.svelte +148 -148
  24. package/dist/Components/DatePicker.svelte +300 -300
  25. package/dist/Components/DesktopModal.svelte +90 -90
  26. package/dist/Components/Dial.svelte +204 -204
  27. package/dist/Components/DimensionInput.svelte +170 -170
  28. package/dist/Components/DragMenu/DragMenuHolder.svelte +119 -119
  29. package/dist/Components/DragMenu/DraggableCard.svelte +77 -77
  30. package/dist/Components/FileCard.svelte +24 -24
  31. package/dist/Components/FileUpload.svelte +236 -236
  32. package/dist/Components/GridInput.svelte +196 -196
  33. package/dist/Components/GridInputSet.svelte +29 -29
  34. package/dist/Components/HorizontalTabGroup.svelte +82 -82
  35. package/dist/Components/HorizontalTabGroupButton.svelte +44 -44
  36. package/dist/Components/Icon.svelte +128 -128
  37. package/dist/Components/Icons.js +357 -357
  38. package/dist/Components/InfoAlert.svelte +35 -35
  39. package/dist/Components/LoadingAnimation.svelte +29 -29
  40. package/dist/Components/LoadingModal.svelte +45 -45
  41. package/dist/Components/LoadingOverlay.svelte +25 -25
  42. package/dist/Components/Modal.svelte +258 -258
  43. package/dist/Components/MultiSelect.svelte +220 -220
  44. package/dist/Components/Nav/Nav.svelte +110 -110
  45. package/dist/Components/Nav/NavServiceMenuList.svelte +43 -43
  46. package/dist/Components/Nav/NavTab.svelte +62 -62
  47. package/dist/Components/Nav/ServiceMenuItem.svelte +112 -112
  48. package/dist/Components/Nav/ServicesNav.svelte +80 -80
  49. package/dist/Components/NumberInput.svelte +157 -157
  50. package/dist/Components/PageHeading.svelte +25 -25
  51. package/dist/Components/PasswordInput.svelte +88 -88
  52. package/dist/Components/Portal.svelte +36 -36
  53. package/dist/Components/ProgressBubble.svelte +107 -107
  54. package/dist/Components/QuerySelect.svelte +366 -366
  55. package/dist/Components/SingleSelect.svelte +250 -250
  56. package/dist/Components/StepButton.svelte +70 -70
  57. package/dist/Components/StepForm.svelte +140 -140
  58. package/dist/Components/Switch.svelte +69 -69
  59. package/dist/Components/Table/ARCHITECTURE.md +298 -298
  60. package/dist/Components/Table/Body/QuickSearchCell.svelte +679 -679
  61. package/dist/Components/Table/Body/Rows/ColumnHighlight.svelte +51 -51
  62. package/dist/Components/Table/Body/Rows/ColumnList.svelte +59 -59
  63. package/dist/Components/Table/Body/Rows/ColumnListRow.svelte +454 -454
  64. package/dist/Components/Table/Body/Rows/Columns/AccessorComponent.svelte +79 -79
  65. package/dist/Components/Table/Body/Rows/Columns/ActionsCell.svelte +185 -185
  66. package/dist/Components/Table/Body/Rows/Columns/BubbleCell.svelte +124 -124
  67. package/dist/Components/Table/Body/Rows/Columns/ExpandCell.svelte +131 -120
  68. package/dist/Components/Table/Body/Rows/Columns/FocusableImmutableCell.svelte +234 -234
  69. package/dist/Components/Table/Body/Rows/Columns/ProgressCell.svelte +105 -105
  70. package/dist/Components/Table/Body/Rows/Columns/TableAction.svelte +87 -87
  71. package/dist/Components/Table/Body/Rows/Columns/TableCheckbox.svelte +126 -126
  72. package/dist/Components/Table/Body/Rows/Columns/TableColumn.svelte +159 -159
  73. package/dist/Components/Table/Body/Rows/Columns/TableDatePicker.svelte +246 -246
  74. package/dist/Components/Table/Body/Rows/Columns/TableNumberInput.svelte +216 -216
  75. package/dist/Components/Table/Body/Rows/Columns/TableQuerySelect.svelte +252 -252
  76. package/dist/Components/Table/Body/Rows/Columns/TableRowSelectionCheckbox.svelte +179 -179
  77. package/dist/Components/Table/Body/Rows/Columns/TableSelect.svelte +221 -221
  78. package/dist/Components/Table/Body/Rows/Columns/TableTextInput.svelte +197 -197
  79. package/dist/Components/Table/Body/Rows/Columns/ValidityCell.svelte +190 -190
  80. package/dist/Components/Table/Body/Rows/TableRow.svelte +290 -290
  81. package/dist/Components/Table/Body/TableBody.svelte +155 -155
  82. package/dist/Components/Table/ColumnFactory.svelte.js +1 -0
  83. package/dist/Components/Table/Headers/HighlightAllHeader.svelte +41 -41
  84. package/dist/Components/Table/Headers/SelectAllHeader.svelte +30 -30
  85. package/dist/Components/Table/Headers/TableHead.svelte +415 -415
  86. package/dist/Components/Table/Misc/ColumnPanel.svelte +39 -39
  87. package/dist/Components/Table/Misc/ColumnPanelModal.svelte +39 -39
  88. package/dist/Components/Table/Misc/FilterInput.svelte +641 -641
  89. package/dist/Components/Table/Misc/FilterPanel.svelte +190 -190
  90. package/dist/Components/Table/Misc/FilterPanelModal.svelte +31 -31
  91. package/dist/Components/Table/Misc/ShareViewModal.svelte +291 -291
  92. package/dist/Components/Table/Misc/SidePanel.svelte +35 -35
  93. package/dist/Components/Table/Misc/TableConfigurationModal.svelte +644 -644
  94. package/dist/Components/Table/Misc/TableFooter.svelte +340 -340
  95. package/dist/Components/Table/Misc/TableHorizontalBar.svelte +140 -140
  96. package/dist/Components/Table/Misc/TableSidebar.svelte +200 -200
  97. package/dist/Components/Table/Misc/TableViewDropdown.svelte +105 -105
  98. package/dist/Components/Table/Misc/ToolboxPanel.svelte +100 -100
  99. package/dist/Components/Table/Misc/ToolboxPanelModal.svelte +107 -107
  100. package/dist/Components/Table/Misc/UpdateViewOptionsModal.svelte +192 -192
  101. package/dist/Components/Table/NestedTable.svelte +130 -130
  102. package/dist/Components/Table/Table.svelte +692 -692
  103. package/dist/Components/Table/Types/Columns/Definitions/Expand/ExpandDef.svelte.d.ts +5 -0
  104. package/dist/Components/Table/Types/Columns/Definitions/Expand/ExpandDef.svelte.js +3 -0
  105. package/dist/Components/Table/Types/Public/ColumnOptions.d.ts +10 -0
  106. package/dist/Components/Test/TestContextConsumer.svelte +13 -13
  107. package/dist/Components/TextArea.svelte +130 -130
  108. package/dist/Components/TextInput.svelte +176 -176
  109. package/dist/Components/ThreeStateRadio.svelte +131 -131
  110. package/dist/Components/Touch/BoolCard.svelte +45 -45
  111. package/dist/Components/Touch/DateModal.svelte +209 -209
  112. package/dist/Components/Touch/EditCard.svelte +59 -59
  113. package/dist/Components/Touch/InfoCard.svelte +31 -31
  114. package/dist/Components/Touch/InfoTextCard.svelte +28 -28
  115. package/dist/Components/Touch/ModalExplanation.svelte +17 -17
  116. package/dist/Components/Touch/NumberKeyboard.svelte +225 -225
  117. package/dist/Components/Touch/NumberModal.svelte +79 -79
  118. package/dist/Components/Touch/PlusMinusCard.svelte +46 -46
  119. package/dist/Components/Touch/SelectModal.svelte +127 -127
  120. package/dist/Components/Touch/SummaryCard.svelte +93 -93
  121. package/dist/Components/Touch/TextAreaModal.svelte +95 -95
  122. package/dist/Components/Touch/TextModal.svelte +106 -106
  123. package/dist/Components/Touch/TouchCard.svelte +33 -33
  124. package/dist/Components/Touch/TouchModal.svelte +274 -274
  125. package/dist/Components/Touch/UtilityButton.svelte +109 -109
  126. package/dist/Components/VerticalTabGroup.svelte +79 -79
  127. package/dist/Components/VerticalTabGroupButton.svelte +58 -58
  128. package/dist/Helpers/Datamatrix.d.ts +19 -19
  129. package/dist/Helpers/Datamatrix.js +291 -291
  130. package/dist/Types/Examples/ExampleModal.svelte +35 -35
  131. package/dist/scss/_basics.scss +12 -12
  132. package/dist/scss/_colours.scss +134 -134
  133. package/dist/scss/_mixins.scss +3392 -3392
  134. package/dist/scss/_motion.scss +57 -57
  135. package/dist/scss/_sizing.scss +85 -85
  136. package/dist/scss/_transitions.scss +8 -8
  137. 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>