@lavalogic/scoria 0.40.0 → 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.
- package/README.md +58 -58
- package/dist/Components/AccordionGroup.svelte +46 -46
- package/dist/Components/AccordionGroupButton.svelte +76 -76
- package/dist/Components/Action.svelte +136 -136
- package/dist/Components/AlertModal.svelte +195 -195
- package/dist/Components/AppointmentCalendar/AppointmentCalendar.svelte +729 -729
- package/dist/Components/Base/BaseModal.svelte +125 -125
- package/dist/Components/Base/ContextWrapper.svelte +72 -72
- package/dist/Components/Base/Pagination.svelte +198 -198
- package/dist/Components/Base/Snippets.svelte +36 -36
- package/dist/Components/BigRadioSet.svelte +38 -38
- package/dist/Components/Bubble.svelte +101 -101
- package/dist/Components/Button.svelte +209 -209
- package/dist/Components/Checkbox.svelte +86 -86
- package/dist/Components/CollapsibleCard.svelte +78 -78
- package/dist/Components/Contexts/ContextMenu.svelte +164 -164
- package/dist/Components/Contexts/ContextMenuDivider.svelte +15 -15
- package/dist/Components/Contexts/ContextMenuOption.svelte +60 -60
- package/dist/Components/Contexts/Toast.svelte +86 -86
- package/dist/Components/Contexts/ToastContainer.svelte +65 -65
- package/dist/Components/Contexts/Tooltip.svelte +37 -37
- package/dist/Components/DataMatrixIcon.svelte +112 -112
- package/dist/Components/DateInput.svelte +148 -148
- package/dist/Components/DatePicker.svelte +300 -300
- package/dist/Components/DesktopModal.svelte +90 -90
- package/dist/Components/Dial.svelte +204 -204
- package/dist/Components/DimensionInput.svelte +170 -170
- package/dist/Components/DragMenu/DragMenuHolder.svelte +119 -119
- package/dist/Components/DragMenu/DraggableCard.svelte +77 -77
- package/dist/Components/FileCard.svelte +24 -24
- package/dist/Components/FileUpload.svelte +236 -236
- package/dist/Components/GridInput.svelte +196 -196
- package/dist/Components/GridInputSet.svelte +29 -29
- package/dist/Components/HorizontalTabGroup.svelte +82 -82
- package/dist/Components/HorizontalTabGroupButton.svelte +44 -44
- package/dist/Components/Icon.svelte +128 -128
- package/dist/Components/Icons.js +357 -357
- package/dist/Components/InfoAlert.svelte +35 -35
- package/dist/Components/LoadingAnimation.svelte +29 -29
- package/dist/Components/LoadingModal.svelte +45 -45
- package/dist/Components/LoadingOverlay.svelte +25 -25
- package/dist/Components/Modal.svelte +258 -258
- package/dist/Components/MultiSelect.svelte +220 -220
- package/dist/Components/Nav/Nav.svelte +110 -110
- package/dist/Components/Nav/NavServiceMenuList.svelte +43 -43
- package/dist/Components/Nav/NavTab.svelte +62 -62
- package/dist/Components/Nav/ServiceMenuItem.svelte +112 -112
- package/dist/Components/Nav/ServicesNav.svelte +80 -80
- package/dist/Components/NumberInput.svelte +157 -157
- package/dist/Components/PageHeading.svelte +25 -25
- package/dist/Components/PasswordInput.svelte +88 -88
- package/dist/Components/Portal.svelte +36 -36
- package/dist/Components/ProgressBubble.svelte +107 -107
- package/dist/Components/QuerySelect.svelte +366 -366
- package/dist/Components/SingleSelect.svelte +250 -250
- package/dist/Components/StepButton.svelte +70 -70
- package/dist/Components/StepForm.svelte +140 -140
- package/dist/Components/Switch.svelte +69 -69
- package/dist/Components/Table/ARCHITECTURE.md +298 -298
- package/dist/Components/Table/Body/QuickSearchCell.svelte +679 -679
- package/dist/Components/Table/Body/Rows/ColumnHighlight.svelte +51 -51
- package/dist/Components/Table/Body/Rows/ColumnList.svelte +59 -59
- package/dist/Components/Table/Body/Rows/ColumnListRow.svelte +454 -454
- package/dist/Components/Table/Body/Rows/Columns/AccessorComponent.svelte +79 -79
- package/dist/Components/Table/Body/Rows/Columns/ActionsCell.svelte +185 -185
- package/dist/Components/Table/Body/Rows/Columns/BubbleCell.svelte +124 -124
- package/dist/Components/Table/Body/Rows/Columns/ExpandCell.svelte +131 -120
- package/dist/Components/Table/Body/Rows/Columns/FocusableImmutableCell.svelte +234 -234
- package/dist/Components/Table/Body/Rows/Columns/ProgressCell.svelte +105 -105
- package/dist/Components/Table/Body/Rows/Columns/TableAction.svelte +87 -87
- package/dist/Components/Table/Body/Rows/Columns/TableCheckbox.svelte +126 -126
- package/dist/Components/Table/Body/Rows/Columns/TableColumn.svelte +159 -159
- package/dist/Components/Table/Body/Rows/Columns/TableDatePicker.svelte +246 -246
- package/dist/Components/Table/Body/Rows/Columns/TableNumberInput.svelte +216 -216
- package/dist/Components/Table/Body/Rows/Columns/TableQuerySelect.svelte +252 -252
- package/dist/Components/Table/Body/Rows/Columns/TableRowSelectionCheckbox.svelte +179 -179
- package/dist/Components/Table/Body/Rows/Columns/TableSelect.svelte +221 -221
- package/dist/Components/Table/Body/Rows/Columns/TableTextInput.svelte +197 -197
- package/dist/Components/Table/Body/Rows/Columns/ValidityCell.svelte +190 -190
- package/dist/Components/Table/Body/Rows/TableRow.svelte +290 -290
- package/dist/Components/Table/Body/TableBody.svelte +155 -155
- package/dist/Components/Table/ColumnFactory.svelte.js +1 -0
- package/dist/Components/Table/Headers/HighlightAllHeader.svelte +41 -41
- package/dist/Components/Table/Headers/SelectAllHeader.svelte +30 -30
- package/dist/Components/Table/Headers/TableHead.svelte +415 -415
- package/dist/Components/Table/Misc/ColumnPanel.svelte +39 -39
- package/dist/Components/Table/Misc/ColumnPanelModal.svelte +39 -39
- package/dist/Components/Table/Misc/FilterInput.svelte +641 -641
- package/dist/Components/Table/Misc/FilterPanel.svelte +190 -190
- package/dist/Components/Table/Misc/FilterPanelModal.svelte +31 -31
- package/dist/Components/Table/Misc/ShareViewModal.svelte +291 -291
- package/dist/Components/Table/Misc/SidePanel.svelte +35 -35
- package/dist/Components/Table/Misc/TableConfigurationModal.svelte +644 -644
- package/dist/Components/Table/Misc/TableFooter.svelte +340 -340
- package/dist/Components/Table/Misc/TableHorizontalBar.svelte +140 -140
- package/dist/Components/Table/Misc/TableSidebar.svelte +200 -200
- package/dist/Components/Table/Misc/TableViewDropdown.svelte +105 -105
- package/dist/Components/Table/Misc/ToolboxPanel.svelte +100 -100
- package/dist/Components/Table/Misc/ToolboxPanelModal.svelte +107 -107
- package/dist/Components/Table/Misc/UpdateViewOptionsModal.svelte +192 -192
- package/dist/Components/Table/NestedTable.svelte +130 -130
- package/dist/Components/Table/Table.svelte +692 -692
- package/dist/Components/Table/Types/Columns/Definitions/Expand/ExpandDef.svelte.d.ts +5 -0
- package/dist/Components/Table/Types/Columns/Definitions/Expand/ExpandDef.svelte.js +3 -0
- package/dist/Components/Table/Types/Public/ColumnOptions.d.ts +10 -0
- package/dist/Components/Test/TestContextConsumer.svelte +13 -13
- package/dist/Components/TextArea.svelte +130 -130
- package/dist/Components/TextInput.svelte +176 -176
- package/dist/Components/ThreeStateRadio.svelte +131 -131
- package/dist/Components/Touch/BoolCard.svelte +45 -45
- package/dist/Components/Touch/DateModal.svelte +209 -209
- package/dist/Components/Touch/EditCard.svelte +59 -59
- package/dist/Components/Touch/InfoCard.svelte +31 -31
- package/dist/Components/Touch/InfoTextCard.svelte +28 -28
- package/dist/Components/Touch/ModalExplanation.svelte +17 -17
- package/dist/Components/Touch/NumberKeyboard.svelte +225 -225
- package/dist/Components/Touch/NumberModal.svelte +79 -79
- package/dist/Components/Touch/PlusMinusCard.svelte +46 -46
- package/dist/Components/Touch/SelectModal.svelte +127 -127
- package/dist/Components/Touch/SummaryCard.svelte +93 -93
- package/dist/Components/Touch/TextAreaModal.svelte +95 -95
- package/dist/Components/Touch/TextModal.svelte +106 -106
- package/dist/Components/Touch/TouchCard.svelte +33 -33
- package/dist/Components/Touch/TouchModal.svelte +274 -274
- package/dist/Components/Touch/UtilityButton.svelte +109 -109
- package/dist/Components/VerticalTabGroup.svelte +79 -79
- package/dist/Components/VerticalTabGroupButton.svelte +58 -58
- package/dist/Helpers/Datamatrix.d.ts +19 -19
- package/dist/Helpers/Datamatrix.js +291 -291
- package/dist/Types/Examples/ExampleModal.svelte +35 -35
- package/dist/scss/_basics.scss +12 -12
- package/dist/scss/_colours.scss +134 -134
- package/dist/scss/_mixins.scss +3392 -3392
- package/dist/scss/_motion.scss +57 -57
- package/dist/scss/_sizing.scss +85 -85
- package/dist/scss/_transitions.scss +8 -8
- 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>
|