@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,456 +1,456 @@
1
- <svelte:options runes />
2
-
3
- <script
4
- lang="ts"
5
- generics="T extends object, IdType extends Primitive"
6
- >
7
- import Action from '../../../Action.svelte';
8
- import Checkbox from '../../../Checkbox.svelte';
9
- import ThreeStateRadio from '../../../ThreeStateRadio.svelte';
10
- import { TableContext } from '../../Types/Context/TableContext.svelte.js';
11
- import type { Primitive } from '../../Types/Context/TableInitOptions.js';
12
- import { Size } from '../../../../Types/Internal/Size.js';
13
- import { Ternary } from '../../../../Types/Internal/Ternary.js';
14
- import { getContext } from 'svelte';
15
- import type { ColumnListRowProps } from './ColumnListRowProps.js';
16
- const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
17
-
18
- const { columnDef }: ColumnListRowProps<T> = $props();
19
-
20
- /**
21
- * Position of this column inside the sorted column list. Drives the
22
- * disabled state of the "move up" / "move down" buttons and supplies
23
- * the target index to `moveColumnTo`. Sort by `index` (the stable
24
- * layout identity) rather than relying on `columnDefs` iteration order.
25
- */
26
- const sortedColumns = $derived(tableContext.columnDefs.slice().sort((a, b) => a.index - b.index));
27
- const currentIndex = $derived(
28
- columnDef == null ? -1 : sortedColumns.findIndex((it) => it.id === columnDef.id)
29
- );
30
- const isAtTop = $derived(currentIndex <= 0);
31
- const isAtBottom = $derived(currentIndex === -1 || currentIndex >= sortedColumns.length - 1);
32
-
33
- function moveUp(): void {
34
- if (columnDef == null || isAtTop) {
35
- return;
36
- }
37
- preserveListScroll(() => {
38
- tableContext.moveColumnTo(currentIndex, currentIndex - 1);
39
- });
40
- }
41
-
42
- function moveDown(): void {
43
- if (columnDef == null || isAtBottom) {
44
- return;
45
- }
46
- preserveListScroll(() => {
47
- tableContext.moveColumnTo(currentIndex, currentIndex + 1);
48
- });
49
- }
50
-
51
- let isBeingTargeted = $state(false);
52
- /** Bound `<li>` element used by `preserveListScroll` to find the
53
- * enclosing `.column-list` even when the event target is the
54
- * visibility checkbox's hidden `<input>` (which is
55
- * `position: absolute` with no positioned ancestor and therefore
56
- * drifts away from the row in the layout tree). */
57
- let rowEl: HTMLLIElement | undefined = $state();
58
-
59
- function disableDragVisual() {
60
- isBeingTargeted = false;
61
- }
62
-
63
- function makeKVPairs(item: string, index: number): readonly [string, number] {
64
- return [item, index];
65
- }
66
-
67
- /**
68
- * Pre-click scroll snapshot.
69
- *
70
- * When the user clicks a checkbox label, the browser fires a synthetic
71
- * click on the hidden `<input>` BEFORE `onchange` fires. That synthetic
72
- * click focuses the input, and the focus side effect scrolls the
73
- * column-list (and sometimes the window) to bring the 1x1px,
74
- * `clip-path`-ed input's bounding box into view. Reading `scrollTop`
75
- * inside `onchange` therefore captures the post-jump position and
76
- * "restoring" to it freezes the list at the wrong spot.
77
- *
78
- * Snapshotting on `mousedown` (which fires before the focus step)
79
- * captures the genuine pre-click position. The snapshot is re-applied
80
- * at multiple time points after the mutation so any later focus /
81
- * layout shift gets undone.
82
- */
83
- let preClickListScroll = 0;
84
- let preClickWindowScrollX = 0;
85
- let preClickWindowScrollY = 0;
86
- /** Milliseconds `Date.now()` stamp of the last `snapshotScrollPosition`
87
- * call. Lets `preserveListScroll` tell apart "click triggered toggle
88
- * whose mousedown just snapshotted real pre-click state" from
89
- * "keyboard or other path where the mousedown snapshot is stale". */
90
- let lastSnapshotAt = 0;
91
-
92
- /** Stale snapshots more than this many ms old are ignored. Tuned
93
- * loose enough to survive any focus / click latency the browser
94
- * might insert, but tight enough that an old click from minutes
95
- * ago can't poison a later keyboard toggle. */
96
- const SNAPSHOT_FRESHNESS_MS = 500;
97
-
98
- function snapshotScrollPosition(): void {
99
- const list = rowEl?.closest('.column-list') as HTMLElement | null;
100
- preClickListScroll = list?.scrollTop ?? 0;
101
- preClickWindowScrollX = typeof window !== 'undefined' ? window.scrollX : 0;
102
- preClickWindowScrollY = typeof window !== 'undefined' ? window.scrollY : 0;
103
- lastSnapshotAt = Date.now();
104
- }
105
-
106
- function restoreScrollPosition(): void {
107
- const list = rowEl?.closest('.column-list') as HTMLElement | null;
108
- if (list != null) {
109
- list.scrollTop = preClickListScroll;
110
- }
111
- if (typeof window !== 'undefined') {
112
- window.scrollTo(preClickWindowScrollX, preClickWindowScrollY);
113
- }
114
- }
115
-
116
- /**
117
- * Run `mutate` and then re-apply the pre-click scroll snapshot
118
- * aggressively across the next ~400ms so any deferred focus / layout
119
- * work that fights us at the tail of the event loop still ends up at
120
- * the user's original scroll position.
121
- *
122
- * The snapshot is taken in capture phase from the row's `{@attach}`
123
- * listener below, before the focus side effect can scroll. If invoked
124
- * without a recent mousedown (keyboard toggle, programmatic call) a
125
- * fresh snapshot is taken here.
126
- *
127
- * A temporary scroll listener on list + window forcibly reverts any
128
- * scroll event during the lock window, complementing the discrete
129
- * `rAF` / `setTimeout` restores. The listener no-ops when already at
130
- * the snapshot, so the restore call inside it does not re-trigger.
131
- *
132
- * The active hidden input is blurred immediately after the mutation
133
- * so the browser stops treating it as the "currently focused element
134
- * to keep visible" on subsequent layout passes.
135
- */
136
- function preserveListScroll(mutate: () => void): void {
137
- if (Date.now() - lastSnapshotAt > SNAPSHOT_FRESHNESS_MS) {
138
- snapshotScrollPosition();
139
- }
140
- const focused =
141
- typeof document !== 'undefined' && document.activeElement instanceof HTMLElement
142
- ? document.activeElement
143
- : null;
144
- const list = rowEl?.closest('.column-list') as HTMLElement | null;
145
- mutate();
146
- if (focused != null && rowEl?.contains(focused) === true) {
147
- focused.blur();
148
- }
149
- if (typeof requestAnimationFrame !== 'undefined') {
150
- requestAnimationFrame(restoreScrollPosition);
151
- }
152
- setTimeout(restoreScrollPosition, 0);
153
- setTimeout(restoreScrollPosition, 60);
154
- setTimeout(restoreScrollPosition, 120);
155
- setTimeout(restoreScrollPosition, 240);
156
-
157
- // Scroll-lock: forcibly revert any rogue scroll for the next ~400ms.
158
- // Anything in this window that scrolls the list or page (focus
159
- // auto-scroll, scrollIntoView from a deferred reactive effect,
160
- // etc.) is snapped back to the pre-click snapshot. The handler
161
- // no-ops when already at the snapshot, so the restore inside it
162
- // does not re-trigger.
163
- if (list != null && typeof window !== 'undefined') {
164
- const lockListHandler = (): void => {
165
- if (list.scrollTop !== preClickListScroll) {
166
- list.scrollTop = preClickListScroll;
167
- }
168
- };
169
- const lockWindowHandler = (): void => {
170
- if (window.scrollX !== preClickWindowScrollX || window.scrollY !== preClickWindowScrollY) {
171
- window.scrollTo(preClickWindowScrollX, preClickWindowScrollY);
172
- }
173
- };
174
- list.addEventListener('scroll', lockListHandler, { passive: true });
175
- window.addEventListener('scroll', lockWindowHandler, { passive: true });
176
- setTimeout(() => {
177
- list.removeEventListener('scroll', lockListHandler);
178
- window.removeEventListener('scroll', lockWindowHandler);
179
- }, 400);
180
- }
181
- }
182
-
183
- /**
184
- * Current pin state of this column expressed as a `Ternary` so the
185
- * three states (`pinned-left` / `unpinned` / `pinned-right`) map
186
- * cleanly onto `ThreeStateRadio`:
187
- *
188
- * - `Ternary.False` → pinned to the LEFT side (leftmost segment)
189
- * - `Ternary.Neutral` → unpinned (middle segment)
190
- * - `Ternary.True` → pinned to the RIGHT side (rightmost segment)
191
- */
192
- const pinSide: Ternary = $derived(
193
- columnDef == null
194
- ? Ternary.Neutral
195
- : tableContext.columnPinning.left?.has(columnDef.id)
196
- ? Ternary.False
197
- : tableContext.columnPinning.right?.has(columnDef.id)
198
- ? Ternary.True
199
- : Ternary.Neutral
200
- );
201
-
202
- /**
203
- * Set this column's pin state from a `Ternary`. The previous side (if
204
- * any) is removed first so the column is never pinned to both sides.
205
- * Wrapped in `preserveListScroll` so the column-list scroll position
206
- * survives any focus side effect inside the ThreeStateRadio's hidden
207
- * inputs.
208
- */
209
- function setPin(next: Ternary): void {
210
- if (columnDef == null) {
211
- return;
212
- }
213
- preserveListScroll(() => {
214
- tableContext.setColumnPinning((pinning) => {
215
- const leftKeys = [...(pinning.left?.keys() ?? [])].filter((id) => id !== columnDef.id);
216
- const rightKeys = [...(pinning.right?.keys() ?? [])].filter((id) => id !== columnDef.id);
217
- if (next === Ternary.False) {
218
- leftKeys.push(columnDef.id);
219
- } else if (next === Ternary.True) {
220
- rightKeys.push(columnDef.id);
221
- }
222
- return {
223
- ...pinning,
224
- left: new Map(leftKeys.map(makeKVPairs)),
225
- right: new Map(rightKeys.map(makeKVPairs)),
226
- };
227
- });
228
- });
229
- }
230
-
231
- const onVisibilityChange = (e: Event) => {
232
- if (!columnDef) {
233
- console.warn('no def found', e);
234
-
235
- return;
236
- }
237
- const checked = (e.target as HTMLInputElement).checked;
238
- preserveListScroll(() => {
239
- tableContext.updateVisibility((defs) => {
240
- defs[columnDef.id] = checked;
241
- return defs;
242
- });
243
- });
244
- };
245
-
246
- /**
247
- * Keyboard reorder support. Alt+ArrowUp/Down nudges the column,
248
- * Alt+Home/End jumps to the ends. Bound on the row so any focusable
249
- * child (eye, pin selector, move buttons) can trigger it.
250
- *
251
- * `Alt` is required so plain arrow keys still navigate radio groups
252
- * (ThreeStateRadio) and the row's own scroll container.
253
- */
254
- function onRowKeydown(e: KeyboardEvent) {
255
- if (!columnDef || !tableContext.allowColumnReordering) {
256
- return;
257
- }
258
- if (!e.altKey) {
259
- return;
260
- }
261
- if (currentIndex === -1) {
262
- return;
263
- }
264
-
265
- const lastIndex = sortedColumns.length - 1;
266
- let targetIndex: number;
267
-
268
- switch (e.key) {
269
- case 'ArrowUp':
270
- targetIndex = Math.max(0, currentIndex - 1);
271
- break;
272
- case 'ArrowDown':
273
- targetIndex = Math.min(lastIndex, currentIndex + 1);
274
- break;
275
- case 'Home':
276
- targetIndex = 0;
277
- break;
278
- case 'End':
279
- targetIndex = lastIndex;
280
- break;
281
- default:
282
- return;
283
- }
284
-
285
- if (targetIndex === currentIndex) {
286
- e.preventDefault();
287
- return;
288
- }
289
-
290
- e.preventDefault();
291
- preserveListScroll(() => {
292
- tableContext.moveColumnTo(currentIndex, targetIndex);
293
- });
294
- }
295
- </script>
296
-
297
- {#if columnDef}
298
- {@const reorderLabel = typeof columnDef.header === 'string' ? columnDef.header : columnDef.id}
299
- <!--
300
- The `<li>` is a structural container for the eye, pin selector,
301
- label, and reorder buttons - none activated by clicking the `<li>`
302
- itself. The `onkeydown` here only captures Alt+Arrow / Alt+Home /
303
- Alt+End bubbling up from whichever inner control has focus to
304
- drive the reorder shortcut; the `<li>` is not an interactive
305
- widget. The structure is a free-form list of cards (not a tree
306
- or listbox), so the right call is to silence the warning rather
307
- than fake `role="treeitem"` / `role="option"`.
308
- -->
309
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
310
- <li
311
- bind:this={rowEl}
312
- class="single-column"
313
- class:targeted={isBeingTargeted}
314
- class:draggable={tableContext.allowColumnReordering}
315
- id="{tableContext.tableName}-column-settings-{columnDef.id}"
316
- draggable={tableContext.allowColumnReordering}
317
- onkeydown={onRowKeydown}
318
- {@attach (node: HTMLLIElement) => {
319
- // Capture-phase `mousedown` and `pointerdown` listeners. Both
320
- // must fire BEFORE the synthetic click on the visibility /
321
- // pin checkbox focuses its hidden input, because that focus
322
- // triggers an auto-scroll to bring the 1x1px clip-pathed
323
- // input into view. Capture phase runs before target / bubble,
324
- // so this listener reliably snapshots the user's true
325
- // pre-click scroll position.
326
- //
327
- // Both event types are wired because browsers differ on which
328
- // fires first; the second is a cheap no-op snapshot.
329
- const snapshot = (): void => {
330
- snapshotScrollPosition();
331
- };
332
- node.addEventListener('pointerdown', snapshot, { capture: true });
333
- node.addEventListener('mousedown', snapshot, { capture: true });
334
- return () => {
335
- node.removeEventListener('pointerdown', snapshot, { capture: true });
336
- node.removeEventListener('mousedown', snapshot, { capture: true });
337
- };
338
- }}
339
- ondragstart={() => {
340
- tableContext.dragTarget = columnDef;
341
- }}
342
- ondragend={disableDragVisual}
343
- ondragover={(e) => {
344
- e.preventDefault();
345
- if (tableContext.dragTarget?.id !== columnDef.id) {
346
- isBeingTargeted = true;
347
- }
348
- }}
349
- ondragleave={disableDragVisual}
350
- ondrop={disableDragVisual}
351
- >
352
- <!--
353
- `.row-main` stacks the column label on top and the visibility /
354
- pin controls underneath so the label gets the full card width
355
- to wrap into. `.reorder-buttons` is a sibling outside
356
- `.row-main` so the up / down arrows stay vertically centred on
357
- the right edge of the card.
358
- -->
359
- <div class="row-main">
360
- <span
361
- class="column-label"
362
- title={typeof columnDef.header === 'string' ? columnDef.header : columnDef.id}
363
- >
364
- {#if typeof columnDef.header === 'function'}
365
- <!-- FIXME this needs testing -->
366
- {@render columnDef.header()}
367
- {:else}
368
- {columnDef.header}
369
- {/if}
370
- </span>
371
-
372
- <div class="row-controls">
373
- <div class="visibility-wrapper">
374
- <Checkbox
375
- icon={{ name: 'assessment', size: Size.MediumSmall }}
376
- size={Size.FillWidth}
377
- id={`is-visible-${tableContext.tableName}-${columnDef.id}`}
378
- value={tableContext.columnVisibility[columnDef.id] !== false}
379
- onchange={onVisibilityChange}
380
- />
381
- </div>
382
-
383
- {#if tableContext.allowColumnReordering}
384
- <!--
385
- Tri-state pin selector. The three `Ternary` slots map to:
386
- False -> pinned LEFT (pin-left icon, leftmost)
387
- Neutral -> unpinned (circle-cross icon, middle)
388
- True -> pinned RIGHT (pin-right icon, rightmost)
389
- Expand columns can never be pinned and disable the group.
390
- -->
391
- <div class="pin-selector">
392
- <ThreeStateRadio
393
- value={pinSide}
394
- noLabel="Pin to left"
395
- neutralLabel="Unpinned"
396
- yesLabel="Pin to right"
397
- noIcon={{ name: 'pin-left', size: Size.MediumSmall }}
398
- neutralIcon={{ name: 'circle-cross', size: Size.MediumSmall }}
399
- yesIcon={{ name: 'pin-right', size: Size.MediumSmall }}
400
- onchange={(next: Ternary) => {
401
- setPin(next);
402
- }}
403
- />
404
- </div>
405
- {/if}
406
- </div>
407
- </div>
408
-
409
- {#if tableContext.allowColumnReordering}
410
- <!--
411
- Up / down "move" buttons make column reordering
412
- discoverable without relying on the user noticing a
413
- drag-cursor. The whole row remains draggable for power
414
- users. Buttons disable at the list boundaries so a no-op
415
- `moveColumnTo` is never dispatched.
416
- -->
417
- <div class="reorder-buttons">
418
- <Action
419
- icon={{ name: 'chevron-up', size: Size.Medium }}
420
- size={Size.Medium}
421
- paddingSize={Size.Small}
422
- label="Move {reorderLabel} up"
423
- disabled={isAtTop}
424
- onclick={moveUp}
425
- />
426
- <Action
427
- icon={{ name: 'chevron-down', size: Size.Medium }}
428
- size={Size.Medium}
429
- paddingSize={Size.Small}
430
- label="Move {reorderLabel} down"
431
- disabled={isAtBottom}
432
- onclick={moveDown}
433
- />
434
- </div>
435
- {/if}
436
- </li>
437
- {:else}
438
- <li
439
- id="{tableContext.tableName}-column-settings-top-anchor"
440
- class="single-column anchor"
441
- class:targeted={isBeingTargeted}
442
- ondragover={(e) => {
443
- e.preventDefault();
444
-
445
- if (tableContext.dragTarget?.id !== undefined) {
446
- isBeingTargeted = true;
447
- }
448
- }}
449
- ondragleave={disableDragVisual}
450
- ondrop={disableDragVisual}
451
- ></li>
452
- {/if}
453
-
1
+ <svelte:options runes />
2
+
3
+ <script
4
+ lang="ts"
5
+ generics="T extends object, IdType extends Primitive"
6
+ >
7
+ import Action from '../../../Action.svelte';
8
+ import Checkbox from '../../../Checkbox.svelte';
9
+ import ThreeStateRadio from '../../../ThreeStateRadio.svelte';
10
+ import { TableContext } from '../../Types/Context/TableContext.svelte.js';
11
+ import type { Primitive } from '../../Types/Context/TableInitOptions.js';
12
+ import { Size } from '../../../../Types/Internal/Size.js';
13
+ import { Ternary } from '../../../../Types/Internal/Ternary.js';
14
+ import { getContext } from 'svelte';
15
+ import type { ColumnListRowProps } from './ColumnListRowProps.js';
16
+ const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
17
+
18
+ const { columnDef }: ColumnListRowProps<T> = $props();
19
+
20
+ /**
21
+ * Position of this column inside the sorted column list. Drives the
22
+ * disabled state of the "move up" / "move down" buttons and supplies
23
+ * the target index to `moveColumnTo`. Sort by `index` (the stable
24
+ * layout identity) rather than relying on `columnDefs` iteration order.
25
+ */
26
+ const sortedColumns = $derived(tableContext.columnDefs.slice().sort((a, b) => a.index - b.index));
27
+ const currentIndex = $derived(
28
+ columnDef == null ? -1 : sortedColumns.findIndex((it) => it.id === columnDef.id)
29
+ );
30
+ const isAtTop = $derived(currentIndex <= 0);
31
+ const isAtBottom = $derived(currentIndex === -1 || currentIndex >= sortedColumns.length - 1);
32
+
33
+ function moveUp(): void {
34
+ if (columnDef == null || isAtTop) {
35
+ return;
36
+ }
37
+ preserveListScroll(() => {
38
+ tableContext.moveColumnTo(currentIndex, currentIndex - 1);
39
+ });
40
+ }
41
+
42
+ function moveDown(): void {
43
+ if (columnDef == null || isAtBottom) {
44
+ return;
45
+ }
46
+ preserveListScroll(() => {
47
+ tableContext.moveColumnTo(currentIndex, currentIndex + 1);
48
+ });
49
+ }
50
+
51
+ let isBeingTargeted = $state(false);
52
+ /** Bound `<li>` element used by `preserveListScroll` to find the
53
+ * enclosing `.column-list` even when the event target is the
54
+ * visibility checkbox's hidden `<input>` (which is
55
+ * `position: absolute` with no positioned ancestor and therefore
56
+ * drifts away from the row in the layout tree). */
57
+ let rowEl: HTMLLIElement | undefined = $state();
58
+
59
+ function disableDragVisual() {
60
+ isBeingTargeted = false;
61
+ }
62
+
63
+ function makeKVPairs(item: string, index: number): readonly [string, number] {
64
+ return [item, index];
65
+ }
66
+
67
+ /**
68
+ * Pre-click scroll snapshot.
69
+ *
70
+ * When the user clicks a checkbox label, the browser fires a synthetic
71
+ * click on the hidden `<input>` BEFORE `onchange` fires. That synthetic
72
+ * click focuses the input, and the focus side effect scrolls the
73
+ * column-list (and sometimes the window) to bring the 1x1px,
74
+ * `clip-path`-ed input's bounding box into view. Reading `scrollTop`
75
+ * inside `onchange` therefore captures the post-jump position and
76
+ * "restoring" to it freezes the list at the wrong spot.
77
+ *
78
+ * Snapshotting on `mousedown` (which fires before the focus step)
79
+ * captures the genuine pre-click position. The snapshot is re-applied
80
+ * at multiple time points after the mutation so any later focus /
81
+ * layout shift gets undone.
82
+ */
83
+ let preClickListScroll = 0;
84
+ let preClickWindowScrollX = 0;
85
+ let preClickWindowScrollY = 0;
86
+ /** Milliseconds `Date.now()` stamp of the last `snapshotScrollPosition`
87
+ * call. Lets `preserveListScroll` tell apart "click triggered toggle
88
+ * whose mousedown just snapshotted real pre-click state" from
89
+ * "keyboard or other path where the mousedown snapshot is stale". */
90
+ let lastSnapshotAt = 0;
91
+
92
+ /** Stale snapshots more than this many ms old are ignored. Tuned
93
+ * loose enough to survive any focus / click latency the browser
94
+ * might insert, but tight enough that an old click from minutes
95
+ * ago can't poison a later keyboard toggle. */
96
+ const SNAPSHOT_FRESHNESS_MS = 500;
97
+
98
+ function snapshotScrollPosition(): void {
99
+ const list = rowEl?.closest('.column-list') as HTMLElement | null;
100
+ preClickListScroll = list?.scrollTop ?? 0;
101
+ preClickWindowScrollX = typeof window !== 'undefined' ? window.scrollX : 0;
102
+ preClickWindowScrollY = typeof window !== 'undefined' ? window.scrollY : 0;
103
+ lastSnapshotAt = Date.now();
104
+ }
105
+
106
+ function restoreScrollPosition(): void {
107
+ const list = rowEl?.closest('.column-list') as HTMLElement | null;
108
+ if (list != null) {
109
+ list.scrollTop = preClickListScroll;
110
+ }
111
+ if (typeof window !== 'undefined') {
112
+ window.scrollTo(preClickWindowScrollX, preClickWindowScrollY);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Run `mutate` and then re-apply the pre-click scroll snapshot
118
+ * aggressively across the next ~400ms so any deferred focus / layout
119
+ * work that fights us at the tail of the event loop still ends up at
120
+ * the user's original scroll position.
121
+ *
122
+ * The snapshot is taken in capture phase from the row's `{@attach}`
123
+ * listener below, before the focus side effect can scroll. If invoked
124
+ * without a recent mousedown (keyboard toggle, programmatic call) a
125
+ * fresh snapshot is taken here.
126
+ *
127
+ * A temporary scroll listener on list + window forcibly reverts any
128
+ * scroll event during the lock window, complementing the discrete
129
+ * `rAF` / `setTimeout` restores. The listener no-ops when already at
130
+ * the snapshot, so the restore call inside it does not re-trigger.
131
+ *
132
+ * The active hidden input is blurred immediately after the mutation
133
+ * so the browser stops treating it as the "currently focused element
134
+ * to keep visible" on subsequent layout passes.
135
+ */
136
+ function preserveListScroll(mutate: () => void): void {
137
+ if (Date.now() - lastSnapshotAt > SNAPSHOT_FRESHNESS_MS) {
138
+ snapshotScrollPosition();
139
+ }
140
+ const focused =
141
+ typeof document !== 'undefined' && document.activeElement instanceof HTMLElement
142
+ ? document.activeElement
143
+ : null;
144
+ const list = rowEl?.closest('.column-list') as HTMLElement | null;
145
+ mutate();
146
+ if (focused != null && rowEl?.contains(focused) === true) {
147
+ focused.blur();
148
+ }
149
+ if (typeof requestAnimationFrame !== 'undefined') {
150
+ requestAnimationFrame(restoreScrollPosition);
151
+ }
152
+ setTimeout(restoreScrollPosition, 0);
153
+ setTimeout(restoreScrollPosition, 60);
154
+ setTimeout(restoreScrollPosition, 120);
155
+ setTimeout(restoreScrollPosition, 240);
156
+
157
+ // Scroll-lock: forcibly revert any rogue scroll for the next ~400ms.
158
+ // Anything in this window that scrolls the list or page (focus
159
+ // auto-scroll, scrollIntoView from a deferred reactive effect,
160
+ // etc.) is snapped back to the pre-click snapshot. The handler
161
+ // no-ops when already at the snapshot, so the restore inside it
162
+ // does not re-trigger.
163
+ if (list != null && typeof window !== 'undefined') {
164
+ const lockListHandler = (): void => {
165
+ if (list.scrollTop !== preClickListScroll) {
166
+ list.scrollTop = preClickListScroll;
167
+ }
168
+ };
169
+ const lockWindowHandler = (): void => {
170
+ if (window.scrollX !== preClickWindowScrollX || window.scrollY !== preClickWindowScrollY) {
171
+ window.scrollTo(preClickWindowScrollX, preClickWindowScrollY);
172
+ }
173
+ };
174
+ list.addEventListener('scroll', lockListHandler, { passive: true });
175
+ window.addEventListener('scroll', lockWindowHandler, { passive: true });
176
+ setTimeout(() => {
177
+ list.removeEventListener('scroll', lockListHandler);
178
+ window.removeEventListener('scroll', lockWindowHandler);
179
+ }, 400);
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Current pin state of this column expressed as a `Ternary` so the
185
+ * three states (`pinned-left` / `unpinned` / `pinned-right`) map
186
+ * cleanly onto `ThreeStateRadio`:
187
+ *
188
+ * - `Ternary.False` → pinned to the LEFT side (leftmost segment)
189
+ * - `Ternary.Neutral` → unpinned (middle segment)
190
+ * - `Ternary.True` → pinned to the RIGHT side (rightmost segment)
191
+ */
192
+ const pinSide: Ternary = $derived(
193
+ columnDef == null
194
+ ? Ternary.Neutral
195
+ : tableContext.columnPinning.left?.has(columnDef.id)
196
+ ? Ternary.False
197
+ : tableContext.columnPinning.right?.has(columnDef.id)
198
+ ? Ternary.True
199
+ : Ternary.Neutral
200
+ );
201
+
202
+ /**
203
+ * Set this column's pin state from a `Ternary`. The previous side (if
204
+ * any) is removed first so the column is never pinned to both sides.
205
+ * Wrapped in `preserveListScroll` so the column-list scroll position
206
+ * survives any focus side effect inside the ThreeStateRadio's hidden
207
+ * inputs.
208
+ */
209
+ function setPin(next: Ternary): void {
210
+ if (columnDef == null) {
211
+ return;
212
+ }
213
+ preserveListScroll(() => {
214
+ tableContext.setColumnPinning((pinning) => {
215
+ const leftKeys = [...(pinning.left?.keys() ?? [])].filter((id) => id !== columnDef.id);
216
+ const rightKeys = [...(pinning.right?.keys() ?? [])].filter((id) => id !== columnDef.id);
217
+ if (next === Ternary.False) {
218
+ leftKeys.push(columnDef.id);
219
+ } else if (next === Ternary.True) {
220
+ rightKeys.push(columnDef.id);
221
+ }
222
+ return {
223
+ ...pinning,
224
+ left: new Map(leftKeys.map(makeKVPairs)),
225
+ right: new Map(rightKeys.map(makeKVPairs)),
226
+ };
227
+ });
228
+ });
229
+ }
230
+
231
+ const onVisibilityChange = (e: Event) => {
232
+ if (!columnDef) {
233
+ console.warn('no def found', e);
234
+
235
+ return;
236
+ }
237
+ const checked = (e.target as HTMLInputElement).checked;
238
+ preserveListScroll(() => {
239
+ tableContext.updateVisibility((defs) => {
240
+ defs[columnDef.id] = checked;
241
+ return defs;
242
+ });
243
+ });
244
+ };
245
+
246
+ /**
247
+ * Keyboard reorder support. Alt+ArrowUp/Down nudges the column,
248
+ * Alt+Home/End jumps to the ends. Bound on the row so any focusable
249
+ * child (eye, pin selector, move buttons) can trigger it.
250
+ *
251
+ * `Alt` is required so plain arrow keys still navigate radio groups
252
+ * (ThreeStateRadio) and the row's own scroll container.
253
+ */
254
+ function onRowKeydown(e: KeyboardEvent) {
255
+ if (!columnDef || !tableContext.allowColumnReordering) {
256
+ return;
257
+ }
258
+ if (!e.altKey) {
259
+ return;
260
+ }
261
+ if (currentIndex === -1) {
262
+ return;
263
+ }
264
+
265
+ const lastIndex = sortedColumns.length - 1;
266
+ let targetIndex: number;
267
+
268
+ switch (e.key) {
269
+ case 'ArrowUp':
270
+ targetIndex = Math.max(0, currentIndex - 1);
271
+ break;
272
+ case 'ArrowDown':
273
+ targetIndex = Math.min(lastIndex, currentIndex + 1);
274
+ break;
275
+ case 'Home':
276
+ targetIndex = 0;
277
+ break;
278
+ case 'End':
279
+ targetIndex = lastIndex;
280
+ break;
281
+ default:
282
+ return;
283
+ }
284
+
285
+ if (targetIndex === currentIndex) {
286
+ e.preventDefault();
287
+ return;
288
+ }
289
+
290
+ e.preventDefault();
291
+ preserveListScroll(() => {
292
+ tableContext.moveColumnTo(currentIndex, targetIndex);
293
+ });
294
+ }
295
+ </script>
296
+
297
+ {#if columnDef}
298
+ {@const reorderLabel = typeof columnDef.header === 'string' ? columnDef.header : columnDef.id}
299
+ <!--
300
+ The `<li>` is a structural container for the eye, pin selector,
301
+ label, and reorder buttons - none activated by clicking the `<li>`
302
+ itself. The `onkeydown` here only captures Alt+Arrow / Alt+Home /
303
+ Alt+End bubbling up from whichever inner control has focus to
304
+ drive the reorder shortcut; the `<li>` is not an interactive
305
+ widget. The structure is a free-form list of cards (not a tree
306
+ or listbox), so the right call is to silence the warning rather
307
+ than fake `role="treeitem"` / `role="option"`.
308
+ -->
309
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
310
+ <li
311
+ bind:this={rowEl}
312
+ class="single-column"
313
+ class:targeted={isBeingTargeted}
314
+ class:draggable={tableContext.allowColumnReordering}
315
+ id="{tableContext.tableName}-column-settings-{columnDef.id}"
316
+ draggable={tableContext.allowColumnReordering}
317
+ onkeydown={onRowKeydown}
318
+ {@attach (node: HTMLLIElement) => {
319
+ // Capture-phase `mousedown` and `pointerdown` listeners. Both
320
+ // must fire BEFORE the synthetic click on the visibility /
321
+ // pin checkbox focuses its hidden input, because that focus
322
+ // triggers an auto-scroll to bring the 1x1px clip-pathed
323
+ // input into view. Capture phase runs before target / bubble,
324
+ // so this listener reliably snapshots the user's true
325
+ // pre-click scroll position.
326
+ //
327
+ // Both event types are wired because browsers differ on which
328
+ // fires first; the second is a cheap no-op snapshot.
329
+ const snapshot = (): void => {
330
+ snapshotScrollPosition();
331
+ };
332
+ node.addEventListener('pointerdown', snapshot, { capture: true });
333
+ node.addEventListener('mousedown', snapshot, { capture: true });
334
+ return () => {
335
+ node.removeEventListener('pointerdown', snapshot, { capture: true });
336
+ node.removeEventListener('mousedown', snapshot, { capture: true });
337
+ };
338
+ }}
339
+ ondragstart={() => {
340
+ tableContext.dragTarget = columnDef;
341
+ }}
342
+ ondragend={disableDragVisual}
343
+ ondragover={(e) => {
344
+ e.preventDefault();
345
+ if (tableContext.dragTarget?.id !== columnDef.id) {
346
+ isBeingTargeted = true;
347
+ }
348
+ }}
349
+ ondragleave={disableDragVisual}
350
+ ondrop={disableDragVisual}
351
+ >
352
+ <!--
353
+ `.row-main` stacks the column label on top and the visibility /
354
+ pin controls underneath so the label gets the full card width
355
+ to wrap into. `.reorder-buttons` is a sibling outside
356
+ `.row-main` so the up / down arrows stay vertically centred on
357
+ the right edge of the card.
358
+ -->
359
+ <div class="row-main">
360
+ <span
361
+ class="column-label"
362
+ title={typeof columnDef.header === 'string' ? columnDef.header : columnDef.id}
363
+ >
364
+ {#if typeof columnDef.header === 'function'}
365
+ <!-- FIXME this needs testing -->
366
+ {@render columnDef.header()}
367
+ {:else}
368
+ {columnDef.header}
369
+ {/if}
370
+ </span>
371
+
372
+ <div class="row-controls">
373
+ <div class="visibility-wrapper">
374
+ <Checkbox
375
+ icon={{ name: 'assessment', size: Size.MediumSmall }}
376
+ size={Size.FillWidth}
377
+ id={`is-visible-${tableContext.tableName}-${columnDef.id}`}
378
+ value={tableContext.columnVisibility[columnDef.id] !== false}
379
+ onchange={onVisibilityChange}
380
+ />
381
+ </div>
382
+
383
+ {#if tableContext.allowColumnReordering}
384
+ <!--
385
+ Tri-state pin selector. The three `Ternary` slots map to:
386
+ False -> pinned LEFT (pin-left icon, leftmost)
387
+ Neutral -> unpinned (circle-cross icon, middle)
388
+ True -> pinned RIGHT (pin-right icon, rightmost)
389
+ Expand columns can never be pinned and disable the group.
390
+ -->
391
+ <div class="pin-selector">
392
+ <ThreeStateRadio
393
+ value={pinSide}
394
+ noLabel="Pin to left"
395
+ neutralLabel="Unpinned"
396
+ yesLabel="Pin to right"
397
+ noIcon={{ name: 'pin-left', size: Size.MediumSmall }}
398
+ neutralIcon={{ name: 'circle-cross', size: Size.MediumSmall }}
399
+ yesIcon={{ name: 'pin-right', size: Size.MediumSmall }}
400
+ onchange={(next: Ternary) => {
401
+ setPin(next);
402
+ }}
403
+ />
404
+ </div>
405
+ {/if}
406
+ </div>
407
+ </div>
408
+
409
+ {#if tableContext.allowColumnReordering}
410
+ <!--
411
+ Up / down "move" buttons make column reordering
412
+ discoverable without relying on the user noticing a
413
+ drag-cursor. The whole row remains draggable for power
414
+ users. Buttons disable at the list boundaries so a no-op
415
+ `moveColumnTo` is never dispatched.
416
+ -->
417
+ <div class="reorder-buttons">
418
+ <Action
419
+ icon={{ name: 'chevron-up', size: Size.Medium }}
420
+ size={Size.Medium}
421
+ paddingSize={Size.Small}
422
+ label="Move {reorderLabel} up"
423
+ disabled={isAtTop}
424
+ onclick={moveUp}
425
+ />
426
+ <Action
427
+ icon={{ name: 'chevron-down', size: Size.Medium }}
428
+ size={Size.Medium}
429
+ paddingSize={Size.Small}
430
+ label="Move {reorderLabel} down"
431
+ disabled={isAtBottom}
432
+ onclick={moveDown}
433
+ />
434
+ </div>
435
+ {/if}
436
+ </li>
437
+ {:else}
438
+ <li
439
+ id="{tableContext.tableName}-column-settings-top-anchor"
440
+ class="single-column anchor"
441
+ class:targeted={isBeingTargeted}
442
+ ondragover={(e) => {
443
+ e.preventDefault();
444
+
445
+ if (tableContext.dragTarget?.id !== undefined) {
446
+ isBeingTargeted = true;
447
+ }
448
+ }}
449
+ ondragleave={disableDragVisual}
450
+ ondrop={disableDragVisual}
451
+ ></li>
452
+ {/if}
453
+
454
454
  <style>li {
455
455
  box-sizing: border-box;
456
456
  }
@@ -561,4 +561,4 @@ li.single-column:not(.anchor):last-of-type {
561
561
  --colour: #1f81a0;
562
562
  color: #1f81a0;
563
563
  background-color: transparent;
564
- }</style>
564
+ }</style>