@lavalogic/scoria 0.39.2 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +58 -58
  2. package/dist/Components/AccordionGroup.svelte +46 -46
  3. package/dist/Components/AccordionGroupButton.svelte +76 -76
  4. package/dist/Components/Action.svelte +136 -136
  5. package/dist/Components/AlertModal.svelte +195 -195
  6. package/dist/Components/AppointmentCalendar/AppointmentCalendar.svelte +729 -729
  7. package/dist/Components/Base/BaseModal.svelte +125 -125
  8. package/dist/Components/Base/ContextWrapper.svelte +72 -72
  9. package/dist/Components/Base/Pagination.svelte +198 -198
  10. package/dist/Components/Base/Snippets.svelte +36 -36
  11. package/dist/Components/BigRadioSet.svelte +38 -38
  12. package/dist/Components/Bubble.svelte +101 -101
  13. package/dist/Components/Button.svelte +209 -209
  14. package/dist/Components/Checkbox.svelte +86 -86
  15. package/dist/Components/CollapsibleCard.svelte +78 -78
  16. package/dist/Components/ConditionBuilder/ConditionBuilder.svelte +212 -0
  17. package/dist/Components/ConditionBuilder/ConditionBuilder.svelte.d.ts +25 -0
  18. package/dist/Components/ConditionBuilder/ConditionBuilderProps.d.ts +45 -0
  19. package/dist/Components/ConditionBuilder/ConditionBuilderProps.js +1 -0
  20. package/dist/Components/ConditionBuilder/ConditionTree.d.ts +27 -0
  21. package/dist/Components/ConditionBuilder/ConditionTree.js +9 -0
  22. package/dist/Components/Contexts/ContextMenu.svelte +164 -164
  23. package/dist/Components/Contexts/ContextMenuDivider.svelte +15 -15
  24. package/dist/Components/Contexts/ContextMenuOption.svelte +60 -60
  25. package/dist/Components/Contexts/Toast.svelte +86 -86
  26. package/dist/Components/Contexts/ToastContainer.svelte +65 -65
  27. package/dist/Components/Contexts/Tooltip.svelte +37 -37
  28. package/dist/Components/DataMatrixIcon.svelte +112 -112
  29. package/dist/Components/DateInput.svelte +148 -148
  30. package/dist/Components/DatePicker.svelte +300 -300
  31. package/dist/Components/DesktopModal.svelte +90 -90
  32. package/dist/Components/Dial.svelte +204 -204
  33. package/dist/Components/DimensionInput.svelte +170 -170
  34. package/dist/Components/DragMenu/DragMenuHolder.svelte +119 -119
  35. package/dist/Components/DragMenu/DraggableCard.svelte +77 -77
  36. package/dist/Components/FileCard.svelte +24 -24
  37. package/dist/Components/FileUpload.svelte +236 -236
  38. package/dist/Components/GridInput.svelte +196 -196
  39. package/dist/Components/GridInputSet.svelte +29 -29
  40. package/dist/Components/HorizontalTabGroup.svelte +82 -82
  41. package/dist/Components/HorizontalTabGroupButton.svelte +44 -44
  42. package/dist/Components/Icon.svelte +128 -128
  43. package/dist/Components/Icons.js +357 -357
  44. package/dist/Components/InfoAlert.svelte +35 -35
  45. package/dist/Components/LoadingAnimation.svelte +29 -29
  46. package/dist/Components/LoadingModal.svelte +45 -45
  47. package/dist/Components/LoadingOverlay.svelte +25 -25
  48. package/dist/Components/Modal.svelte +258 -258
  49. package/dist/Components/MultiSelect.svelte +220 -220
  50. package/dist/Components/Nav/Nav.svelte +110 -110
  51. package/dist/Components/Nav/NavServiceMenuList.svelte +43 -43
  52. package/dist/Components/Nav/NavTab.svelte +62 -62
  53. package/dist/Components/Nav/ServiceMenuItem.svelte +112 -112
  54. package/dist/Components/Nav/ServicesNav.svelte +80 -80
  55. package/dist/Components/NumberInput.svelte +157 -157
  56. package/dist/Components/PageHeading.svelte +25 -25
  57. package/dist/Components/PasswordInput.svelte +88 -88
  58. package/dist/Components/Portal.svelte +36 -36
  59. package/dist/Components/ProgressBubble.svelte +107 -107
  60. package/dist/Components/QuerySelect.svelte +366 -366
  61. package/dist/Components/SingleSelect.svelte +250 -250
  62. package/dist/Components/StepButton.svelte +70 -70
  63. package/dist/Components/StepForm.svelte +140 -140
  64. package/dist/Components/Switch.svelte +69 -69
  65. package/dist/Components/Table/ARCHITECTURE.md +298 -298
  66. package/dist/Components/Table/Body/QuickSearchCell.svelte +679 -679
  67. package/dist/Components/Table/Body/Rows/ColumnHighlight.svelte +51 -51
  68. package/dist/Components/Table/Body/Rows/ColumnList.svelte +59 -59
  69. package/dist/Components/Table/Body/Rows/ColumnListRow.svelte +454 -454
  70. package/dist/Components/Table/Body/Rows/Columns/AccessorComponent.svelte +79 -79
  71. package/dist/Components/Table/Body/Rows/Columns/ActionsCell.svelte +185 -185
  72. package/dist/Components/Table/Body/Rows/Columns/BubbleCell.svelte +124 -124
  73. package/dist/Components/Table/Body/Rows/Columns/ExpandCell.svelte +120 -120
  74. package/dist/Components/Table/Body/Rows/Columns/FocusableImmutableCell.svelte +234 -234
  75. package/dist/Components/Table/Body/Rows/Columns/ProgressCell.svelte +105 -105
  76. package/dist/Components/Table/Body/Rows/Columns/TableAction.svelte +87 -87
  77. package/dist/Components/Table/Body/Rows/Columns/TableCheckbox.svelte +126 -126
  78. package/dist/Components/Table/Body/Rows/Columns/TableColumn.svelte +159 -159
  79. package/dist/Components/Table/Body/Rows/Columns/TableDatePicker.svelte +246 -246
  80. package/dist/Components/Table/Body/Rows/Columns/TableNumberInput.svelte +216 -216
  81. package/dist/Components/Table/Body/Rows/Columns/TableQuerySelect.svelte +252 -252
  82. package/dist/Components/Table/Body/Rows/Columns/TableRowSelectionCheckbox.svelte +179 -179
  83. package/dist/Components/Table/Body/Rows/Columns/TableSelect.svelte +221 -221
  84. package/dist/Components/Table/Body/Rows/Columns/TableTextInput.svelte +197 -197
  85. package/dist/Components/Table/Body/Rows/Columns/ValidityCell.svelte +190 -190
  86. package/dist/Components/Table/Body/Rows/TableRow.svelte +290 -290
  87. package/dist/Components/Table/Body/TableBody.svelte +155 -155
  88. package/dist/Components/Table/Headers/HighlightAllHeader.svelte +41 -41
  89. package/dist/Components/Table/Headers/SelectAllHeader.svelte +30 -30
  90. package/dist/Components/Table/Headers/TableHead.svelte +415 -415
  91. package/dist/Components/Table/Misc/ColumnPanel.svelte +39 -39
  92. package/dist/Components/Table/Misc/ColumnPanelModal.svelte +39 -39
  93. package/dist/Components/Table/Misc/FilterInput.svelte +641 -641
  94. package/dist/Components/Table/Misc/FilterPanel.svelte +190 -190
  95. package/dist/Components/Table/Misc/FilterPanelModal.svelte +31 -31
  96. package/dist/Components/Table/Misc/ShareViewModal.svelte +291 -291
  97. package/dist/Components/Table/Misc/SidePanel.svelte +35 -35
  98. package/dist/Components/Table/Misc/TableConfigurationModal.svelte +644 -644
  99. package/dist/Components/Table/Misc/TableFooter.svelte +340 -340
  100. package/dist/Components/Table/Misc/TableHorizontalBar.svelte +140 -140
  101. package/dist/Components/Table/Misc/TableSidebar.svelte +200 -200
  102. package/dist/Components/Table/Misc/TableViewDropdown.svelte +105 -105
  103. package/dist/Components/Table/Misc/ToolboxPanel.svelte +100 -100
  104. package/dist/Components/Table/Misc/ToolboxPanelModal.svelte +107 -107
  105. package/dist/Components/Table/Misc/UpdateViewOptionsModal.svelte +192 -192
  106. package/dist/Components/Table/NestedTable.svelte +130 -130
  107. package/dist/Components/Table/Table.svelte +692 -692
  108. package/dist/Components/Test/TestContextConsumer.svelte +13 -13
  109. package/dist/Components/TextArea.svelte +130 -130
  110. package/dist/Components/TextInput.svelte +176 -176
  111. package/dist/Components/ThreeStateRadio.svelte +131 -131
  112. package/dist/Components/Touch/BoolCard.svelte +45 -45
  113. package/dist/Components/Touch/DateModal.svelte +209 -209
  114. package/dist/Components/Touch/EditCard.svelte +59 -59
  115. package/dist/Components/Touch/InfoCard.svelte +31 -31
  116. package/dist/Components/Touch/InfoTextCard.svelte +28 -28
  117. package/dist/Components/Touch/ModalExplanation.svelte +17 -17
  118. package/dist/Components/Touch/NumberKeyboard.svelte +225 -225
  119. package/dist/Components/Touch/NumberModal.svelte +79 -79
  120. package/dist/Components/Touch/PlusMinusCard.svelte +46 -46
  121. package/dist/Components/Touch/SelectModal.svelte +127 -127
  122. package/dist/Components/Touch/SummaryCard.svelte +93 -93
  123. package/dist/Components/Touch/TextAreaModal.svelte +95 -95
  124. package/dist/Components/Touch/TextModal.svelte +106 -106
  125. package/dist/Components/Touch/TouchCard.svelte +33 -33
  126. package/dist/Components/Touch/TouchModal.svelte +274 -274
  127. package/dist/Components/Touch/UtilityButton.svelte +109 -109
  128. package/dist/Components/VerticalTabGroup.svelte +79 -79
  129. package/dist/Components/VerticalTabGroupButton.svelte +58 -58
  130. package/dist/Helpers/Datamatrix.d.ts +19 -19
  131. package/dist/Helpers/Datamatrix.js +291 -291
  132. package/dist/Types/Examples/ExampleModal.svelte +35 -35
  133. package/dist/index.d.ts +3 -0
  134. package/dist/index.js +2 -0
  135. package/dist/scss/_basics.scss +12 -12
  136. package/dist/scss/_colours.scss +134 -134
  137. package/dist/scss/_mixins.scss +3392 -3392
  138. package/dist/scss/_motion.scss +57 -57
  139. package/dist/scss/_sizing.scss +85 -85
  140. package/dist/scss/_transitions.scss +8 -8
  141. package/package.json +1 -1
@@ -1,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>