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