@warkypublic/svelix 0.1.46 → 0.2.1
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 +93 -33
- package/dist/components/BetterMenu/BetterMenu.svelte +21 -37
- package/dist/components/BetterMenu/BetterMenuAsyncButton.svelte +21 -31
- package/dist/components/BetterMenu/BetterMenuPreview.svelte +27 -29
- package/dist/components/BetterMenu/MenuRenderer.svelte +40 -51
- package/dist/components/Boxer/Boxer.svelte +492 -602
- package/dist/components/Boxer/BoxerTarget.svelte +6 -46
- package/dist/components/Boxer/BoxerTarget.svelte.d.ts +1 -0
- package/dist/components/Boxer/types.d.ts +0 -1
- package/dist/components/Button.svelte +19 -43
- package/dist/components/ButtonPreview.svelte +2 -11
- package/dist/components/CardGrid/CardGrid.svelte +489 -614
- package/dist/components/CardGrid/CardGrid.svelte.d.ts +4 -0
- package/dist/components/CardGrid/CardGridFilterPanel.svelte +111 -90
- package/dist/components/CardGrid/DefaultCard.svelte +86 -97
- package/dist/components/CardGrid/DefaultCard.svelte.d.ts +1 -0
- package/dist/components/CardGrid/ImageCardStory.svelte +87 -36
- package/dist/components/CardGrid/cardGrid.d.ts +24 -0
- package/dist/components/CardGrid/cardGrid.js +20 -0
- package/dist/components/ContentEditor/CollaboraInsertTextDemo.svelte +5 -10
- package/dist/components/ContentEditor/ContentEditor.svelte +24 -50
- package/dist/components/ContentEditor/subcomponents/AudioPlayer.svelte +17 -17
- package/dist/components/ContentEditor/subcomponents/CollaboraEditor.svelte +244 -309
- package/dist/components/ContentEditor/subcomponents/EmailViewer.svelte +2 -3
- package/dist/components/ContentEditor/subcomponents/ImageViewer.svelte +14 -15
- package/dist/components/ContentEditor/subcomponents/MarkdownViewer.svelte +74 -90
- package/dist/components/ContentEditor/subcomponents/MonacoEditor.svelte +75 -103
- package/dist/components/ContentEditor/subcomponents/Office365Editor.svelte +234 -297
- package/dist/components/ContentEditor/subcomponents/PdfViewer.svelte +14 -20
- package/dist/components/ContentEditor/subcomponents/TextEditor.svelte +122 -150
- package/dist/components/ContentEditor/subcomponents/UnknownFile.svelte +105 -126
- package/dist/components/ContentEditor/subcomponents/VideoPlayer.svelte +17 -17
- package/dist/components/ContentEditor/subcomponents/ZipViewer.svelte +2 -3
- package/dist/components/ErrorBoundary/ErrorBoundary.svelte +41 -58
- package/dist/components/ErrorBoundary/ErrorBoundaryPreview.svelte +8 -16
- package/dist/components/Former/Former.svelte +306 -424
- package/dist/components/Former/FormerButtonArea.svelte +17 -68
- package/dist/components/Former/FormerDrawer.svelte +16 -80
- package/dist/components/Former/FormerDrawerPreview.svelte +99 -89
- package/dist/components/Former/FormerModal.svelte +16 -74
- package/dist/components/Former/FormerModalPreview.svelte +99 -89
- package/dist/components/Former/FormerPreview.svelte +76 -78
- package/dist/components/Former/FormerRestApiPreview.svelte +55 -48
- package/dist/components/Former/formerOverlayHelpers.svelte.d.ts +11 -0
- package/dist/components/Former/formerOverlayHelpers.svelte.js +38 -0
- package/dist/components/Former/index.d.ts +1 -0
- package/dist/components/Former/index.js +1 -0
- package/dist/components/FormerControllers/ButtonCtrl.svelte +13 -49
- package/dist/components/FormerControllers/CtrlFieldShell.svelte +15 -0
- package/dist/components/FormerControllers/CtrlFieldShell.svelte.d.ts +12 -0
- package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrl.svelte +285 -457
- package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrlCalendar.svelte +95 -136
- package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrlPickerPanel.svelte +3 -62
- package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrlTimeFields.svelte +143 -217
- package/dist/components/FormerControllers/IconButtonCtrl.svelte +13 -47
- package/dist/components/FormerControllers/InlineWrapper.svelte +12 -41
- package/dist/components/FormerControllers/InlineWrapperPreview.svelte +4 -26
- package/dist/components/FormerControllers/NativeSelectCtrl.svelte +7 -39
- package/dist/components/FormerControllers/NumberInputCtrl.svelte +21 -43
- package/dist/components/FormerControllers/PasswordInputCtrl.svelte +8 -33
- package/dist/components/FormerControllers/SwitchCtrl.svelte +4 -20
- package/dist/components/FormerControllers/TextAreaCtrl.svelte +7 -34
- package/dist/components/FormerControllers/TextInputCtrl.svelte +7 -34
- package/dist/components/FormerControllers/ctrlLoading.svelte.d.ts +8 -0
- package/dist/components/FormerControllers/ctrlLoading.svelte.js +43 -0
- package/dist/components/FormerControllers/index.d.ts +2 -0
- package/dist/components/FormerControllers/index.js +2 -0
- package/dist/components/FormerControllers/utils.d.ts +1 -0
- package/dist/components/FormerControllers/utils.js +3 -0
- package/dist/components/GlobalStateStore/GlobalStateStoreProvider.svelte +73 -120
- package/dist/components/Gridler/components/Gridler.svelte +344 -442
- package/dist/components/Gridler/components/GridlerCanvas.svelte +720 -1115
- package/dist/components/Gridler/components/GridlerEditor.svelte +2 -15
- package/dist/components/Gridler/components/GridlerFull.svelte +521 -841
- package/dist/components/Gridler/components/GridlerFull.svelte.d.ts +1 -1
- package/dist/components/Gridler/components/GridlerFullWithFormerPreview.svelte +194 -147
- package/dist/components/Gridler/components/GridlerSearch.svelte +7 -16
- package/dist/components/Gridler/components/GridlerSearchToggle.svelte +7 -19
- package/dist/components/Gridler/types.d.ts +1 -1
- package/dist/components/Gridler/utils/serverPaging.d.ts +1 -0
- package/dist/components/Gridler/utils/serverPaging.js +7 -0
- package/dist/components/Icons/IconAdd.svelte +1 -3
- package/dist/components/Icons/IconAlertCircle.svelte +1 -3
- package/dist/components/Icons/IconAlertTriangle.svelte +1 -3
- package/dist/components/Icons/IconCamera.svelte +1 -3
- package/dist/components/Icons/IconClose.svelte +1 -3
- package/dist/components/Icons/IconEdit.svelte +1 -3
- package/dist/components/Icons/IconFilter.svelte +1 -3
- package/dist/components/Icons/IconSearch.svelte +1 -3
- package/dist/components/Icons/IconSort.svelte +1 -3
- package/dist/components/Icons/IconTrash.svelte +1 -3
- package/dist/components/Portal/Portal.svelte +14 -38
- package/dist/components/Screenshot/Screenshot.svelte +7 -13
- package/dist/components/Svark/Svark.svelte +680 -852
- package/dist/components/Svark/SvarkContextMenu.svelte +44 -63
- package/dist/components/Svark/SvarkInfiniteLoader.svelte +2 -6
- package/dist/components/Svark/SvarkPager.svelte +2 -28
- package/dist/components/Svark/SvarkSelectionDemo.svelte +4 -8
- package/dist/components/Svark/SvarkTopBar.svelte +9 -74
- package/dist/components/SvarkGrid/SvarkGrid.svelte +877 -1123
- package/dist/components/SvarkGrid/components/SvarkColumnFilterForm.svelte +45 -58
- package/dist/components/SvarkGrid/components/SvarkContextMenu.svelte +44 -63
- package/dist/components/SvarkGrid/components/SvarkHeaderFilterCell.svelte +41 -75
- package/dist/components/SvarkGrid/components/SvarkInfiniteLoader.svelte +2 -6
- package/dist/components/SvarkGrid/components/SvarkPager.svelte +2 -28
- package/dist/components/SvarkGrid/components/SvarkSearchPopover.svelte +8 -16
- package/dist/components/SvarkGrid/components/SvarkTopBar.svelte +6 -62
- package/dist/components/SvarkGrid/internal/SvarkGridView.svelte +345 -507
- package/dist/components/VTree/VTree.svelte +454 -431
- package/dist/components/VTree/VTree.utils.d.ts +23 -0
- package/dist/components/VTree/VTree.utils.js +73 -0
- package/dist/components/VTree/VTreeContextMenu.svelte +21 -33
- package/dist/components/VTree/VTreeControlledSelectionDemo.svelte +20 -0
- package/dist/components/VTree/VTreeControlledSelectionDemo.svelte.d.ts +18 -0
- package/dist/components/VTree/VTreeCustomizationDemo.svelte +23 -0
- package/dist/components/VTree/VTreeCustomizationDemo.svelte.d.ts +18 -0
- package/dist/components/VTree/VTreeEventsDemo.svelte +48 -58
- package/dist/components/VTree/VTreeInteractionDemo.svelte +29 -0
- package/dist/components/VTree/VTreeInteractionDemo.svelte.d.ts +18 -0
- package/dist/components/VTree/VTreeMultiSelectDemo.svelte +27 -0
- package/dist/components/VTree/VTreeMultiSelectDemo.svelte.d.ts +18 -0
- package/dist/components/VTree/VTreeRow.svelte +77 -115
- package/dist/components/VTree/VTreeRow.svelte.d.ts +34 -18
- package/dist/components/VTree/VTreeSearch.svelte +6 -13
- package/dist/components/VTree/VTreeVirtualViewport.svelte +54 -143
- package/dist/components/VTree/VTreeVirtualViewport.svelte.d.ts +35 -18
- package/dist/components/VTree/types.d.ts +39 -1
- package/llm/COMPONENT_GUIDE.md +25 -0
- package/package.json +51 -50
|
@@ -1,644 +1,503 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
// Filters
|
|
81
|
-
filters?: GridColumnFilters;
|
|
82
|
-
onFilterChange?: (filters: GridColumnFilters) => void;
|
|
83
|
-
|
|
84
|
-
// Selection
|
|
85
|
-
selectedItems?: Record<string, unknown>[];
|
|
86
|
-
onSelectedItemsChange?: (items: Record<string, unknown>[]) => void;
|
|
87
|
-
multiSelect?: boolean;
|
|
88
|
-
|
|
89
|
-
// Card events
|
|
90
|
-
onCardClick?: (record: Record<string, unknown>, index: number) => void;
|
|
91
|
-
onCardDblClick?: (record: Record<string, unknown>, index: number) => void;
|
|
92
|
-
onCardContextMenu?: (
|
|
93
|
-
record: Record<string, unknown>,
|
|
94
|
-
index: number,
|
|
95
|
-
x: number,
|
|
96
|
-
y: number,
|
|
97
|
-
) => void;
|
|
98
|
-
|
|
99
|
-
// Context menu
|
|
100
|
-
menuItems?: CardGridContextMenuItem[];
|
|
101
|
-
|
|
102
|
-
// Status (bindable)
|
|
103
|
-
total?: number;
|
|
104
|
-
loading?: boolean;
|
|
105
|
-
onLoadError?: (error: string) => void;
|
|
106
|
-
|
|
107
|
-
// Layout
|
|
108
|
-
class?: string;
|
|
109
|
-
toolbarEnd?: Snippet;
|
|
1
|
+
<script lang="ts">import { untrack } from "svelte";
|
|
2
|
+
import { SvelteSet } from "svelte/reactivity";
|
|
3
|
+
import { createVirtualizer } from "@tanstack/svelte-virtual";
|
|
4
|
+
import BetterMenu from "../BetterMenu/BetterMenu.svelte";
|
|
5
|
+
import { GridlerResolveSpecAdapter } from "../Gridler/adapters/GridlerResolveSpecAdapter.js";
|
|
6
|
+
import { GridlerRestHeaderSpecAdapter } from "../Gridler/adapters/GridlerRestHeaderSpecAdapter.js";
|
|
7
|
+
import { gridColumnFiltersToFilterOptions, buildSearchFilters, applyLocalFilter } from "../Gridler/utils/filters.js";
|
|
8
|
+
import { applyLocalSort, sortOrderToSortOptions } from "../Gridler/utils/sort.js";
|
|
9
|
+
import CardGridFilterPanel from "./CardGridFilterPanel.svelte";
|
|
10
|
+
import DefaultCard from "./DefaultCard.svelte";
|
|
11
|
+
import { toggleItemInSelection, rangeSelect, isItemSelected, computeColumnsInRow } from "./cardGrid.js";
|
|
12
|
+
let { columns, displayColumns, cardMinWidth = 280, cardGap = 16, cardMaxFrontColumns, card, cardBack, empty, skeleton, dataSource = "local", dataSourceOptions, data, pageSize = 20, uniqueID: uniqueIDProp, searchValue, onSearchValueChange, serverSideSearch = true, searchColumns, searchPlaceholder = "Search…", sortOrder, onSortOrderChange, sortOptions, filters, onFilterChange, selectedItems: selectedItemsProp, onSelectedItemsChange, multiSelect = true, onCardClick, onCardDblClick, onCardContextMenu, menuItems, total = $bindable(0), loading = $bindable(false), onLoadError, class: cls, toolbarEnd, animate: animateProp, density: densityProp, expand: expandProp } = $props();
|
|
13
|
+
// ── Animation defaults ──────────────────────────────────────────────
|
|
14
|
+
const animateEnabled = $derived(animateProp ?? true);
|
|
15
|
+
// ── Density/expand defaults ─────────────────────────────────────────────
|
|
16
|
+
const densityMode = $derived(densityProp ?? "default");
|
|
17
|
+
const expandMode = $derived(expandProp ?? "none");
|
|
18
|
+
// ── Derived config ─────────────────────────────────────────────────────────
|
|
19
|
+
const resolvedUniqueID = $derived(uniqueIDProp ?? dataSourceOptions?.uniqueID ?? "id");
|
|
20
|
+
const resolvedColumns = $derived(displayColumns?.length ? columns.filter((c) => displayColumns?.includes(c.id)) : columns);
|
|
21
|
+
// Flip mode split
|
|
22
|
+
const frontColumns = $derived(cardMaxFrontColumns != null ? resolvedColumns.slice(0, cardMaxFrontColumns) : resolvedColumns);
|
|
23
|
+
const backColumns = $derived(cardMaxFrontColumns != null ? resolvedColumns.slice(cardMaxFrontColumns) : []);
|
|
24
|
+
const flipModeEnabled = $derived(backColumns.length > 0);
|
|
25
|
+
// How many detail rows the skeleton should mimic
|
|
26
|
+
const skeletonDetailRows = $derived(Math.max(0, frontColumns.length - 2));
|
|
27
|
+
// ── Controlled/uncontrolled search ─────────────────────────────────────────
|
|
28
|
+
let internalSearchValue = $state("");
|
|
29
|
+
const resolvedSearchValue = $derived(searchValue ?? internalSearchValue);
|
|
30
|
+
function emitSearchValue(v) {
|
|
31
|
+
if (searchValue === undefined) internalSearchValue = v;
|
|
32
|
+
onSearchValueChange?.(v);
|
|
33
|
+
}
|
|
34
|
+
// ── Controlled/uncontrolled filters ───────────────────────────────────────
|
|
35
|
+
let internalFilters = $state({});
|
|
36
|
+
const resolvedFilters = $derived(filters ?? internalFilters);
|
|
37
|
+
function handleFilterChange(next) {
|
|
38
|
+
if (filters === undefined) internalFilters = next;
|
|
39
|
+
onFilterChange?.(next);
|
|
40
|
+
}
|
|
41
|
+
// ── Controlled/uncontrolled sort ──────────────────────────────────────────
|
|
42
|
+
let internalSortOrder = $state({});
|
|
43
|
+
const resolvedSortOrder = $derived(sortOrder ?? internalSortOrder);
|
|
44
|
+
function handleSortOrderChange(order) {
|
|
45
|
+
if (sortOrder === undefined) internalSortOrder = order;
|
|
46
|
+
onSortOrderChange?.(order);
|
|
47
|
+
}
|
|
48
|
+
const resolvedSort = $derived(Object.values(resolvedSortOrder).some((d) => d !== "none") ? sortOrderToSortOptions(resolvedSortOrder) : []);
|
|
49
|
+
// ── Density/expand resolution ───────────────────────────────────────────
|
|
50
|
+
const densityMinWidth = $derived(densityMode === "compact" ? 200 : densityMode === "spacious" ? 400 : densityMode === "comfortable" ? 340 : cardMinWidth);
|
|
51
|
+
const densityGap = $derived(densityMode === "compact" ? 8 : densityMode === "spacious" ? 32 : densityMode === "comfortable" ? 24 : cardGap);
|
|
52
|
+
// Expand multiplies the effective min-width/gap per-card
|
|
53
|
+
const expandFactor = $derived(expandMode === "none" ? 1 : expandMode === "medium" ? 1.5 : 2);
|
|
54
|
+
// ── Controlled/uncontrolled selection ─────────────────────────────────────
|
|
55
|
+
let internalSelectedItems = $state([]);
|
|
56
|
+
const resolvedSelectedItems = $derived(selectedItemsProp ?? internalSelectedItems);
|
|
57
|
+
function setSelectedItems(items) {
|
|
58
|
+
if (selectedItemsProp === undefined) internalSelectedItems = items;
|
|
59
|
+
onSelectedItemsChange?.(items);
|
|
60
|
+
}
|
|
61
|
+
// ── Local data path ────────────────────────────────────────────────────────
|
|
62
|
+
let localData = $state([]);
|
|
63
|
+
let localLoading = $state(false);
|
|
64
|
+
$effect(() => {
|
|
65
|
+
if (dataSource !== "local") return;
|
|
66
|
+
if (typeof data === "function") {
|
|
67
|
+
localLoading = true;
|
|
68
|
+
data().then((d) => {
|
|
69
|
+
localData = d;
|
|
70
|
+
}).catch((e) => {
|
|
71
|
+
onLoadError?.(e instanceof Error ? e.message : "Failed to load data");
|
|
72
|
+
}).finally(() => {
|
|
73
|
+
localLoading = false;
|
|
74
|
+
});
|
|
75
|
+
} else if (Array.isArray(data)) {
|
|
76
|
+
localData = data;
|
|
77
|
+
} else {
|
|
78
|
+
localData = [];
|
|
110
79
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
sortOrder,
|
|
133
|
-
onSortOrderChange,
|
|
134
|
-
sortOptions,
|
|
135
|
-
filters,
|
|
136
|
-
onFilterChange,
|
|
137
|
-
selectedItems: selectedItemsProp,
|
|
138
|
-
onSelectedItemsChange,
|
|
139
|
-
multiSelect = true,
|
|
140
|
-
onCardClick,
|
|
141
|
-
onCardDblClick,
|
|
142
|
-
onCardContextMenu,
|
|
143
|
-
menuItems,
|
|
144
|
-
total = $bindable(0),
|
|
145
|
-
loading = $bindable(false),
|
|
146
|
-
onLoadError,
|
|
147
|
-
class: cls,
|
|
148
|
-
toolbarEnd,
|
|
149
|
-
}: Props = $props();
|
|
150
|
-
|
|
151
|
-
// ── Derived config ─────────────────────────────────────────────────────────
|
|
152
|
-
|
|
153
|
-
const resolvedUniqueID = $derived(uniqueIDProp ?? dataSourceOptions?.uniqueID ?? 'id');
|
|
154
|
-
|
|
155
|
-
const resolvedColumns = $derived(
|
|
156
|
-
displayColumns?.length ? columns.filter((c) => displayColumns!.includes(c.id)) : columns,
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
// Flip mode split
|
|
160
|
-
const frontColumns = $derived(
|
|
161
|
-
cardMaxFrontColumns != null ? resolvedColumns.slice(0, cardMaxFrontColumns) : resolvedColumns,
|
|
162
|
-
);
|
|
163
|
-
const backColumns = $derived(
|
|
164
|
-
cardMaxFrontColumns != null ? resolvedColumns.slice(cardMaxFrontColumns) : [],
|
|
165
|
-
);
|
|
166
|
-
const flipModeEnabled = $derived(backColumns.length > 0);
|
|
167
|
-
|
|
168
|
-
// How many detail rows the skeleton should mimic
|
|
169
|
-
const skeletonDetailRows = $derived(Math.max(0, frontColumns.length - 2));
|
|
170
|
-
|
|
171
|
-
// ── Controlled/uncontrolled search ─────────────────────────────────────────
|
|
172
|
-
|
|
173
|
-
let internalSearchValue = $state('');
|
|
174
|
-
const resolvedSearchValue = $derived(searchValue ?? internalSearchValue);
|
|
175
|
-
|
|
176
|
-
function emitSearchValue(v: string) {
|
|
177
|
-
if (searchValue === undefined) internalSearchValue = v;
|
|
178
|
-
onSearchValueChange?.(v);
|
|
80
|
+
});
|
|
81
|
+
const asCols = (cols) => cols;
|
|
82
|
+
const localSorted = $derived(applyLocalSort(localData, resolvedSort, asCols(columns)));
|
|
83
|
+
const localFiltered = $derived(applyLocalFilter(localSorted, resolvedFilters, asCols(columns)));
|
|
84
|
+
const localSearched = $derived.by(() => {
|
|
85
|
+
if (!resolvedSearchValue.trim() || serverSideSearch && isServerMode) return localFiltered;
|
|
86
|
+
const q = resolvedSearchValue.trim().toLowerCase();
|
|
87
|
+
const searchCols = searchColumns?.length ? searchColumns : columns.filter((c) => !c.disableSearch).map((c) => c.id);
|
|
88
|
+
return localFiltered.filter((row) => searchCols.some((colId) => {
|
|
89
|
+
const col = columns.find((c) => c.id === colId);
|
|
90
|
+
const key = col?.dataKey ?? colId;
|
|
91
|
+
const val = getNestedVal(key, row);
|
|
92
|
+
return val != null && String(val).toLowerCase().includes(q);
|
|
93
|
+
}));
|
|
94
|
+
});
|
|
95
|
+
function getNestedVal(key, row) {
|
|
96
|
+
const parts = key.split(".");
|
|
97
|
+
let val = row;
|
|
98
|
+
for (const part of parts) {
|
|
99
|
+
if (val == null || typeof val !== "object") return undefined;
|
|
100
|
+
val = val[part];
|
|
179
101
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
102
|
+
return val;
|
|
103
|
+
}
|
|
104
|
+
// ── Server state ───────────────────────────────────────────────────────────
|
|
105
|
+
let serverData = $state([]);
|
|
106
|
+
let serverTotal = $state(0);
|
|
107
|
+
let serverCursor = $state(undefined);
|
|
108
|
+
let serverFetchVersion = 0;
|
|
109
|
+
let serverLoading = $state(false);
|
|
110
|
+
let serverAllLoaded = $state(false);
|
|
111
|
+
let refreshTrigger = $state(0);
|
|
112
|
+
$effect(() => {
|
|
113
|
+
total = serverTotal;
|
|
114
|
+
});
|
|
115
|
+
$effect(() => {
|
|
116
|
+
loading = serverLoading || localLoading;
|
|
117
|
+
});
|
|
118
|
+
const isServerMode = $derived(dataSource !== "local");
|
|
119
|
+
const serverAdapter = $derived.by(() => {
|
|
120
|
+
if (!isServerMode) return null;
|
|
121
|
+
const url = dataSourceOptions?.url;
|
|
122
|
+
const schema = dataSourceOptions?.schema;
|
|
123
|
+
const entity = dataSourceOptions?.entity;
|
|
124
|
+
if (!url || !schema || !entity) return null;
|
|
125
|
+
const config = {
|
|
126
|
+
url,
|
|
127
|
+
token: dataSourceOptions?.authToken,
|
|
128
|
+
schema,
|
|
129
|
+
entity,
|
|
130
|
+
uniqueID: resolvedUniqueID,
|
|
131
|
+
extraOptions: dataSourceOptions?.extraOptions
|
|
132
|
+
};
|
|
133
|
+
return dataSource === "headerspec" ? new GridlerRestHeaderSpecAdapter(config) : new GridlerResolveSpecAdapter(config);
|
|
134
|
+
});
|
|
135
|
+
const resolvedFields = $derived.by(() => {
|
|
136
|
+
const colFields = columns.map((c) => c.dataKey ?? c.id);
|
|
137
|
+
const hotfields = dataSourceOptions?.hotfields ?? [];
|
|
138
|
+
const seen = new Set(colFields);
|
|
139
|
+
return [...colFields, ...hotfields.filter((f) => !seen.has(f))];
|
|
140
|
+
});
|
|
141
|
+
function buildAllFilters(search) {
|
|
142
|
+
const base = Object.keys(resolvedFilters).length ? gridColumnFiltersToFilterOptions(resolvedFilters) : [];
|
|
143
|
+
if (!search?.trim()) return base;
|
|
144
|
+
const searchCols = searchColumns?.length ? searchColumns : columns.filter((c) => !c.disableSearch).map((c) => c.id);
|
|
145
|
+
return [...base, ...buildSearchFilters(search, asCols([]), searchCols)];
|
|
146
|
+
}
|
|
147
|
+
$effect(() => {
|
|
148
|
+
const _adapter = serverAdapter;
|
|
149
|
+
const _sort = resolvedSort;
|
|
150
|
+
const _search = serverSideSearch ? resolvedSearchValue : null;
|
|
151
|
+
const _pageSize = pageSize;
|
|
152
|
+
const _filters = resolvedFilters;
|
|
153
|
+
const _refresh = refreshTrigger;
|
|
154
|
+
void _refresh;
|
|
155
|
+
if (!_adapter) return;
|
|
156
|
+
let cancelled = false;
|
|
157
|
+
serverFetchVersion++;
|
|
158
|
+
serverLoading = true;
|
|
159
|
+
serverData = [];
|
|
160
|
+
serverTotal = 0;
|
|
161
|
+
serverCursor = undefined;
|
|
162
|
+
serverAllLoaded = false;
|
|
163
|
+
_adapter.readPage(_pageSize, undefined, _sort, buildAllFilters(_search), untrack(() => resolvedFields)).then((result) => {
|
|
164
|
+
if (cancelled) return;
|
|
165
|
+
serverData = result.data;
|
|
166
|
+
serverTotal = result.total;
|
|
167
|
+
serverCursor = result.nextCursor;
|
|
168
|
+
serverAllLoaded = result.data.length < _pageSize || !result.nextCursor;
|
|
169
|
+
}).catch((e) => {
|
|
170
|
+
if (!cancelled) onLoadError?.(e instanceof Error ? e.message : "Failed to load data");
|
|
171
|
+
}).finally(() => {
|
|
172
|
+
if (!cancelled) serverLoading = false;
|
|
173
|
+
});
|
|
174
|
+
return () => {
|
|
175
|
+
cancelled = true;
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
async function loadNextPage() {
|
|
179
|
+
const _adapter = serverAdapter;
|
|
180
|
+
if (!_adapter || serverLoading || serverAllLoaded || !serverCursor) return;
|
|
181
|
+
const cursorSnapshot = serverCursor;
|
|
182
|
+
const versionSnapshot = serverFetchVersion;
|
|
183
|
+
serverLoading = true;
|
|
184
|
+
try {
|
|
185
|
+
const result = await _adapter.readPage(pageSize, cursorSnapshot, resolvedSort, buildAllFilters(serverSideSearch ? resolvedSearchValue : null), resolvedFields);
|
|
186
|
+
if (serverFetchVersion !== versionSnapshot) return;
|
|
187
|
+
serverData = [...serverData, ...result.data];
|
|
188
|
+
serverCursor = result.nextCursor;
|
|
189
|
+
serverAllLoaded = result.data.length < pageSize || !result.nextCursor || result.nextCursor === cursorSnapshot;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
onLoadError?.(e instanceof Error ? e.message : "Failed to load page");
|
|
192
|
+
} finally {
|
|
193
|
+
serverLoading = false;
|
|
189
194
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
195
|
+
}
|
|
196
|
+
// ── Display data ───────────────────────────────────────────────────────────
|
|
197
|
+
const displayData = $derived(isServerMode ? serverData : localSearched);
|
|
198
|
+
// ── UI state ───────────────────────────────────────────────────────────────
|
|
199
|
+
let focusedIndex = $state(-1);
|
|
200
|
+
let anchorIndex = $state(-1);
|
|
201
|
+
let columnsInRow = $state(1);
|
|
202
|
+
let containerEl = $state(null);
|
|
203
|
+
let sentinelEl = $state(null);
|
|
204
|
+
let searchInputEl = $state(null);
|
|
205
|
+
let filterBtnEl = $state(null);
|
|
206
|
+
let menu = $state(null);
|
|
207
|
+
// Per-card flip state
|
|
208
|
+
let flippedCards = new SvelteSet();
|
|
209
|
+
function toggleFlip(e, item) {
|
|
210
|
+
e.stopPropagation();
|
|
211
|
+
const id = item[resolvedUniqueID];
|
|
212
|
+
if (flippedCards.has(id)) {
|
|
213
|
+
flippedCards.delete(id);
|
|
214
|
+
} else {
|
|
215
|
+
flippedCards.add(id);
|
|
199
216
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
// ── Controlled/uncontrolled selection ─────────────────────────────────────
|
|
208
|
-
|
|
209
|
-
let internalSelectedItems = $state<Record<string, unknown>[]>([]);
|
|
210
|
-
const resolvedSelectedItems = $derived(selectedItemsProp ?? internalSelectedItems);
|
|
211
|
-
|
|
212
|
-
function setSelectedItems(items: Record<string, unknown>[]) {
|
|
213
|
-
if (selectedItemsProp === undefined) internalSelectedItems = items;
|
|
214
|
-
onSelectedItemsChange?.(items);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// ── Local data path ────────────────────────────────────────────────────────
|
|
218
|
-
|
|
219
|
-
let localData = $state<Record<string, unknown>[]>([]);
|
|
220
|
-
let localLoading = $state(false);
|
|
221
|
-
|
|
222
|
-
$effect(() => {
|
|
223
|
-
if (dataSource !== 'local') return;
|
|
224
|
-
if (typeof data === 'function') {
|
|
225
|
-
localLoading = true;
|
|
226
|
-
(data as () => Promise<Record<string, unknown>[]>)()
|
|
227
|
-
.then((d) => { localData = d; })
|
|
228
|
-
.catch((e) => { onLoadError?.(e instanceof Error ? e.message : 'Failed to load data'); })
|
|
229
|
-
.finally(() => { localLoading = false; });
|
|
230
|
-
} else if (Array.isArray(data)) {
|
|
231
|
-
localData = data as Record<string, unknown>[];
|
|
232
|
-
} else {
|
|
233
|
-
localData = [];
|
|
234
|
-
}
|
|
217
|
+
}
|
|
218
|
+
$effect(() => {
|
|
219
|
+
if (!containerEl) return;
|
|
220
|
+
const obs = new ResizeObserver((entries) => {
|
|
221
|
+
columnsInRow = computeColumnsInRow(entries[0]?.contentRect.width ?? 0, densityMinWidth * expandFactor, densityGap * expandFactor);
|
|
235
222
|
});
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
223
|
+
obs.observe(containerEl);
|
|
224
|
+
return () => obs.disconnect();
|
|
225
|
+
});
|
|
226
|
+
$effect(() => {
|
|
227
|
+
if (!sentinelEl || !isServerMode) return;
|
|
228
|
+
const obs = new IntersectionObserver((entries) => {
|
|
229
|
+
if (entries[0]?.isIntersecting) loadNextPage();
|
|
230
|
+
}, { rootMargin: "200px" });
|
|
231
|
+
obs.observe(sentinelEl);
|
|
232
|
+
return () => obs.disconnect();
|
|
233
|
+
});
|
|
234
|
+
// ── Selection ──────────────────────────────────────────────────────────────
|
|
235
|
+
function handleCardClick(e, item, index) {
|
|
236
|
+
focusedIndex = index;
|
|
237
|
+
if (!multiSelect) {
|
|
238
|
+
setSelectedItems([item]);
|
|
239
|
+
anchorIndex = index;
|
|
240
|
+
onCardClick?.(item, index);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (e.shiftKey && anchorIndex >= 0) {
|
|
244
|
+
setSelectedItems(rangeSelect(displayData, anchorIndex, index));
|
|
245
|
+
} else if (e.ctrlKey || e.metaKey) {
|
|
246
|
+
setSelectedItems(toggleItemInSelection(resolvedSelectedItems, item, resolvedUniqueID));
|
|
247
|
+
anchorIndex = index;
|
|
248
|
+
} else {
|
|
249
|
+
setSelectedItems([item]);
|
|
250
|
+
anchorIndex = index;
|
|
251
|
+
}
|
|
252
|
+
onCardClick?.(item, index);
|
|
253
|
+
}
|
|
254
|
+
function handleCardContextMenu(e, item, index) {
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
onCardContextMenu?.(item, index, e.clientX, e.clientY);
|
|
257
|
+
menu?.show("cg-context-menu", {
|
|
258
|
+
x: e.clientX,
|
|
259
|
+
y: e.clientY,
|
|
260
|
+
items: buildContextMenuItems(item)
|
|
257
261
|
});
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
262
|
+
}
|
|
263
|
+
function handleContainerClick(e) {
|
|
264
|
+
if (e.target === e.currentTarget) {
|
|
265
|
+
setSelectedItems([]);
|
|
266
|
+
focusedIndex = -1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// ── Context menu ───────────────────────────────────────────────────────────
|
|
270
|
+
function buildContextMenuItems(item) {
|
|
271
|
+
const items = [];
|
|
272
|
+
if (menuItems?.length) {
|
|
273
|
+
for (const mi of menuItems) {
|
|
274
|
+
if (mi.kind === "separator") {
|
|
275
|
+
items.push({ isDivider: true });
|
|
276
|
+
} else {
|
|
277
|
+
items.push({
|
|
278
|
+
label: mi.label,
|
|
279
|
+
onClick: mi.disabled ? undefined : () => mi.onselect?.(item)
|
|
280
|
+
});
|
|
281
|
+
}
|
|
265
282
|
}
|
|
266
|
-
|
|
283
|
+
items.push({ isDivider: true });
|
|
267
284
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
let serverData = $state<Record<string, unknown>[]>([]);
|
|
272
|
-
let serverTotal = $state(0);
|
|
273
|
-
let serverCursor = $state<string | undefined>(undefined);
|
|
274
|
-
let serverFetchVersion = 0;
|
|
275
|
-
let serverLoading = $state(false);
|
|
276
|
-
let serverAllLoaded = $state(false);
|
|
277
|
-
let refreshTrigger = $state(0);
|
|
278
|
-
|
|
279
|
-
$effect(() => { total = serverTotal; });
|
|
280
|
-
$effect(() => { loading = serverLoading || localLoading; });
|
|
281
|
-
|
|
282
|
-
const isServerMode = $derived(dataSource !== 'local');
|
|
283
|
-
|
|
284
|
-
const serverAdapter = $derived.by(() => {
|
|
285
|
-
if (!isServerMode) return null;
|
|
286
|
-
const url = dataSourceOptions?.url;
|
|
287
|
-
const schema = dataSourceOptions?.schema;
|
|
288
|
-
const entity = dataSourceOptions?.entity;
|
|
289
|
-
if (!url || !schema || !entity) return null;
|
|
290
|
-
const config: GridlerAdapterConfig = {
|
|
291
|
-
url, token: dataSourceOptions?.authToken, schema, entity,
|
|
292
|
-
uniqueID: resolvedUniqueID,
|
|
293
|
-
extraOptions: dataSourceOptions?.extraOptions as GridlerAdapterConfig['extraOptions'],
|
|
294
|
-
};
|
|
295
|
-
return dataSource === 'headerspec'
|
|
296
|
-
? new GridlerRestHeaderSpecAdapter(config)
|
|
297
|
-
: new GridlerResolveSpecAdapter(config);
|
|
285
|
+
items.push({
|
|
286
|
+
label: "Refresh",
|
|
287
|
+
onClick: () => refresh()
|
|
298
288
|
});
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const hotfields = dataSourceOptions?.hotfields ?? [];
|
|
303
|
-
const seen = new Set(colFields);
|
|
304
|
-
return [...colFields, ...hotfields.filter((f) => !seen.has(f))];
|
|
289
|
+
if (resolvedSearchValue) items.push({
|
|
290
|
+
label: "Clear search",
|
|
291
|
+
onClick: () => emitSearchValue("")
|
|
305
292
|
});
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
? gridColumnFiltersToFilterOptions(resolvedFilters) : [];
|
|
310
|
-
if (!search?.trim()) return base;
|
|
311
|
-
const searchCols = searchColumns?.length
|
|
312
|
-
? searchColumns
|
|
313
|
-
: columns.filter((c) => !c.disableSearch).map((c) => c.id);
|
|
314
|
-
return [...base, ...buildSearchFilters(search, asCols([]), searchCols)];
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
$effect(() => {
|
|
318
|
-
const _adapter = serverAdapter;
|
|
319
|
-
const _sort = resolvedSort;
|
|
320
|
-
const _search = serverSideSearch ? resolvedSearchValue : null;
|
|
321
|
-
const _pageSize = pageSize;
|
|
322
|
-
const _filters = resolvedFilters;
|
|
323
|
-
const _refresh = refreshTrigger;
|
|
324
|
-
void _refresh;
|
|
325
|
-
|
|
326
|
-
if (!_adapter) return;
|
|
327
|
-
|
|
328
|
-
let cancelled = false;
|
|
329
|
-
serverFetchVersion++;
|
|
330
|
-
serverLoading = true;
|
|
331
|
-
serverData = [];
|
|
332
|
-
serverTotal = 0;
|
|
333
|
-
serverCursor = undefined;
|
|
334
|
-
serverAllLoaded = false;
|
|
335
|
-
|
|
336
|
-
_adapter
|
|
337
|
-
.readPage(_pageSize, undefined, _sort, buildAllFilters(_search), untrack(() => resolvedFields))
|
|
338
|
-
.then((result) => {
|
|
339
|
-
if (cancelled) return;
|
|
340
|
-
serverData = result.data;
|
|
341
|
-
serverTotal = result.total;
|
|
342
|
-
serverCursor = result.nextCursor;
|
|
343
|
-
serverAllLoaded = result.data.length < _pageSize || !result.nextCursor;
|
|
344
|
-
})
|
|
345
|
-
.catch((e) => { if (!cancelled) onLoadError?.(e instanceof Error ? e.message : 'Failed to load data'); })
|
|
346
|
-
.finally(() => { if (!cancelled) serverLoading = false; });
|
|
347
|
-
|
|
348
|
-
return () => { cancelled = true; };
|
|
293
|
+
if (Object.keys(resolvedFilters).length > 0) items.push({
|
|
294
|
+
label: "Clear filters",
|
|
295
|
+
onClick: () => handleFilterChange({})
|
|
349
296
|
});
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (!_adapter || serverLoading || serverAllLoaded || !serverCursor) return;
|
|
354
|
-
const cursorSnapshot = serverCursor;
|
|
355
|
-
const versionSnapshot = serverFetchVersion;
|
|
356
|
-
serverLoading = true;
|
|
357
|
-
try {
|
|
358
|
-
const result = await _adapter.readPage(
|
|
359
|
-
pageSize, cursorSnapshot, resolvedSort,
|
|
360
|
-
buildAllFilters(serverSideSearch ? resolvedSearchValue : null), resolvedFields,
|
|
361
|
-
);
|
|
362
|
-
if (serverFetchVersion !== versionSnapshot) return;
|
|
363
|
-
serverData = [...serverData, ...result.data];
|
|
364
|
-
serverCursor = result.nextCursor;
|
|
365
|
-
serverAllLoaded = result.data.length < pageSize || !result.nextCursor || result.nextCursor === cursorSnapshot;
|
|
366
|
-
} catch (e) {
|
|
367
|
-
onLoadError?.(e instanceof Error ? e.message : 'Failed to load page');
|
|
368
|
-
} finally {
|
|
369
|
-
serverLoading = false;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// ── Display data ───────────────────────────────────────────────────────────
|
|
374
|
-
|
|
375
|
-
const displayData = $derived(isServerMode ? serverData : localSearched);
|
|
376
|
-
|
|
377
|
-
// ── UI state ───────────────────────────────────────────────────────────────
|
|
378
|
-
|
|
379
|
-
let focusedIndex = $state(-1);
|
|
380
|
-
let anchorIndex = $state(-1);
|
|
381
|
-
let columnsInRow = $state(1);
|
|
382
|
-
let containerEl = $state<HTMLElement | null>(null);
|
|
383
|
-
let sentinelEl = $state<HTMLElement | null>(null);
|
|
384
|
-
let searchInputEl = $state<HTMLInputElement | null>(null);
|
|
385
|
-
let filterBtnEl = $state<HTMLButtonElement | null>(null);
|
|
386
|
-
let menu = $state<BetterMenu | null>(null);
|
|
387
|
-
|
|
388
|
-
// Per-card flip state
|
|
389
|
-
let flippedCards = $state(new Set<unknown>());
|
|
390
|
-
|
|
391
|
-
function toggleFlip(e: MouseEvent, item: Record<string, unknown>) {
|
|
392
|
-
e.stopPropagation();
|
|
393
|
-
const id = item[resolvedUniqueID];
|
|
394
|
-
const next = new Set(flippedCards);
|
|
395
|
-
next.has(id) ? next.delete(id) : next.add(id);
|
|
396
|
-
flippedCards = next;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
$effect(() => {
|
|
400
|
-
if (!containerEl) return;
|
|
401
|
-
const obs = new ResizeObserver((entries) => {
|
|
402
|
-
columnsInRow = computeColumnsInRow(entries[0]?.contentRect.width ?? 0, cardMinWidth, cardGap);
|
|
403
|
-
});
|
|
404
|
-
obs.observe(containerEl);
|
|
405
|
-
return () => obs.disconnect();
|
|
297
|
+
if (Object.values(resolvedSortOrder).some((d) => d !== "none")) items.push({
|
|
298
|
+
label: "Clear sort",
|
|
299
|
+
onClick: () => handleSortOrderChange({})
|
|
406
300
|
});
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
301
|
+
return items;
|
|
302
|
+
}
|
|
303
|
+
// ── Filter panel ───────────────────────────────────────────────────────────
|
|
304
|
+
const activeFilterCount = $derived(Object.keys(resolvedFilters).length);
|
|
305
|
+
function openFilterPanel() {
|
|
306
|
+
if (!filterBtnEl) return;
|
|
307
|
+
const rect = filterBtnEl.getBoundingClientRect();
|
|
308
|
+
menu?.show("cg-filter-panel", {
|
|
309
|
+
x: rect.left,
|
|
310
|
+
y: rect.bottom + 4,
|
|
311
|
+
renderer: filterPanelSnippet
|
|
416
312
|
});
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
313
|
+
}
|
|
314
|
+
// ── Sort pills ─────────────────────────────────────────────────────────────
|
|
315
|
+
function toggleSortOption(columnId) {
|
|
316
|
+
const current = resolvedSortOrder[columnId] ?? "none";
|
|
317
|
+
const next = current === "none" ? "asc" : current === "asc" ? "desc" : "none";
|
|
318
|
+
const newOrder = { ...resolvedSortOrder };
|
|
319
|
+
if (next === "none") delete newOrder[columnId];
|
|
320
|
+
else newOrder[columnId] = next;
|
|
321
|
+
handleSortOrderChange(newOrder);
|
|
322
|
+
}
|
|
323
|
+
// ── Keyboard navigation ────────────────────────────────────────────────────
|
|
324
|
+
function moveFocus(next) {
|
|
325
|
+
focusedIndex = next;
|
|
326
|
+
scrollToCard(next);
|
|
327
|
+
if (!multiSelect) {
|
|
328
|
+
const item = displayData[next];
|
|
329
|
+
if (item) setSelectedItems([item]);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function handleKeyDown(e) {
|
|
333
|
+
const count = displayData.length;
|
|
334
|
+
if (count === 0) return;
|
|
335
|
+
switch (e.key) {
|
|
336
|
+
case "ArrowLeft":
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
moveFocus(Math.max(0, focusedIndex - 1));
|
|
339
|
+
break;
|
|
340
|
+
case "ArrowRight":
|
|
341
|
+
e.preventDefault();
|
|
342
|
+
moveFocus(Math.min(count - 1, focusedIndex + 1));
|
|
343
|
+
break;
|
|
344
|
+
case "ArrowUp":
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
moveFocus(Math.max(0, focusedIndex - columnsInRow));
|
|
347
|
+
break;
|
|
348
|
+
case "ArrowDown":
|
|
349
|
+
e.preventDefault();
|
|
350
|
+
moveFocus(Math.min(count - 1, focusedIndex + columnsInRow));
|
|
351
|
+
break;
|
|
352
|
+
case "Tab":
|
|
353
|
+
e.preventDefault();
|
|
354
|
+
moveFocus(e.shiftKey ? Math.max(0, focusedIndex - 1) : Math.min(count - 1, focusedIndex + 1));
|
|
355
|
+
break;
|
|
356
|
+
case "Enter": {
|
|
357
|
+
if (focusedIndex >= 0) {
|
|
358
|
+
const item = displayData[focusedIndex];
|
|
359
|
+
if (item) onCardClick?.(item, focusedIndex);
|
|
360
|
+
}
|
|
361
|
+
break;
|
|
427
362
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
anchorIndex = index;
|
|
363
|
+
case " ": {
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
if (focusedIndex >= 0) {
|
|
366
|
+
const item = displayData[focusedIndex];
|
|
367
|
+
if (item) setSelectedItems(multiSelect ? toggleItemInSelection(resolvedSelectedItems, item, resolvedUniqueID) : [item]);
|
|
368
|
+
}
|
|
369
|
+
break;
|
|
436
370
|
}
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
function handleCardContextMenu(e: MouseEvent, item: Record<string, unknown>, index: number) {
|
|
441
|
-
e.preventDefault();
|
|
442
|
-
onCardContextMenu?.(item, index, e.clientX, e.clientY);
|
|
443
|
-
menu?.show('cg-context-menu', {
|
|
444
|
-
x: e.clientX, y: e.clientY,
|
|
445
|
-
items: buildContextMenuItems(item),
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function handleContainerClick(e: MouseEvent) {
|
|
450
|
-
if (e.target === e.currentTarget) {
|
|
371
|
+
case "Escape":
|
|
451
372
|
setSelectedItems([]);
|
|
452
373
|
focusedIndex = -1;
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const items: BetterMenuInstanceItem[] = [];
|
|
460
|
-
if (menuItems?.length) {
|
|
461
|
-
for (const mi of menuItems) {
|
|
462
|
-
if (mi.kind === 'separator') {
|
|
463
|
-
items.push({ isDivider: true });
|
|
464
|
-
} else {
|
|
465
|
-
items.push({ label: mi.label, onClick: mi.disabled ? undefined : () => mi.onselect?.(item) });
|
|
466
|
-
}
|
|
374
|
+
break;
|
|
375
|
+
case "a":
|
|
376
|
+
case "A":
|
|
377
|
+
if ((e.ctrlKey || e.metaKey) && multiSelect) {
|
|
378
|
+
e.preventDefault();
|
|
379
|
+
setSelectedItems([...displayData]);
|
|
467
380
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
items.push({ label: 'Clear sort', onClick: () => handleSortOrderChange({}) });
|
|
477
|
-
return items;
|
|
381
|
+
break;
|
|
382
|
+
case "f":
|
|
383
|
+
case "F":
|
|
384
|
+
if (e.ctrlKey || e.metaKey) {
|
|
385
|
+
e.preventDefault();
|
|
386
|
+
searchInputEl?.focus();
|
|
387
|
+
}
|
|
388
|
+
break;
|
|
478
389
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
390
|
+
}
|
|
391
|
+
function scrollToCard(index) {
|
|
392
|
+
if (virtualizerInstance) {
|
|
393
|
+
const rowIndex = Math.floor(index / Math.max(1, columnsInRow));
|
|
394
|
+
virtualizerInstance.scrollToIndex(rowIndex, {
|
|
395
|
+
align: "auto",
|
|
396
|
+
behavior: "smooth"
|
|
397
|
+
});
|
|
398
|
+
} else {
|
|
399
|
+
containerEl?.querySelector(`[data-card-index="${index}"]`)?.scrollIntoView({
|
|
400
|
+
block: "nearest",
|
|
401
|
+
behavior: "smooth"
|
|
490
402
|
});
|
|
491
403
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}
|
|
404
|
+
}
|
|
405
|
+
export function selectItem(uniqueIDValue) {
|
|
406
|
+
const idx = displayData.findIndex((item) => item[resolvedUniqueID] === uniqueIDValue);
|
|
407
|
+
if (idx < 0) return false;
|
|
408
|
+
focusedIndex = idx;
|
|
409
|
+
scrollToCard(idx);
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
export function refresh() {
|
|
413
|
+
if (isServerMode) {
|
|
414
|
+
refreshTrigger++;
|
|
415
|
+
} else if (typeof data === "function") {
|
|
416
|
+
localLoading = true;
|
|
417
|
+
data().then((d) => {
|
|
418
|
+
localData = d;
|
|
419
|
+
}).catch((e) => {
|
|
420
|
+
onLoadError?.(e instanceof Error ? e.message : "Failed to load data");
|
|
421
|
+
}).finally(() => {
|
|
422
|
+
localLoading = false;
|
|
423
|
+
});
|
|
512
424
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
break;
|
|
526
|
-
case 'Enter': {
|
|
527
|
-
if (focusedIndex >= 0) { const item = displayData[focusedIndex]; if (item) onCardClick?.(item, focusedIndex); }
|
|
528
|
-
break;
|
|
529
|
-
}
|
|
530
|
-
case ' ': {
|
|
531
|
-
e.preventDefault();
|
|
532
|
-
if (focusedIndex >= 0) {
|
|
533
|
-
const item = displayData[focusedIndex];
|
|
534
|
-
if (item) setSelectedItems(multiSelect
|
|
535
|
-
? toggleItemInSelection(resolvedSelectedItems, item, resolvedUniqueID)
|
|
536
|
-
: [item]);
|
|
537
|
-
}
|
|
538
|
-
break;
|
|
539
|
-
}
|
|
540
|
-
case 'Escape': setSelectedItems([]); focusedIndex = -1; break;
|
|
541
|
-
case 'a': case 'A':
|
|
542
|
-
if ((e.ctrlKey || e.metaKey) && multiSelect) { e.preventDefault(); setSelectedItems([...displayData]); }
|
|
543
|
-
break;
|
|
544
|
-
case 'f': case 'F':
|
|
545
|
-
if (e.ctrlKey || e.metaKey) { e.preventDefault(); searchInputEl?.focus(); }
|
|
546
|
-
break;
|
|
547
|
-
}
|
|
425
|
+
}
|
|
426
|
+
// Reconcile selection across refresh() — drop items no longer in displayData.
|
|
427
|
+
// `internalSelectedItems` is read via untrack() so this effect doesn't depend on
|
|
428
|
+
// its own write target — .filter() always returns a new array reference, which
|
|
429
|
+
// would otherwise re-trigger this same effect forever (effect_update_depth_exceeded).
|
|
430
|
+
$effect(() => {
|
|
431
|
+
if (displayData.length === 0) return;
|
|
432
|
+
const currentIds = new Set(displayData.map((item) => item[resolvedUniqueID]));
|
|
433
|
+
const current = untrack(() => internalSelectedItems);
|
|
434
|
+
const next = current.filter((item) => currentIds.has(item[resolvedUniqueID]));
|
|
435
|
+
if (next.length !== current.length) {
|
|
436
|
+
internalSelectedItems = next;
|
|
548
437
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
438
|
+
});
|
|
439
|
+
// ── Loading states ─────────────────────────────────────────────────────────
|
|
440
|
+
const isFirstLoad = $derived(isServerMode && serverLoading && serverData.length === 0 || !isServerMode && localLoading && localData.length === 0);
|
|
441
|
+
const isLoadingMore = $derived(isServerMode && serverLoading && serverData.length > 0);
|
|
442
|
+
const isEmpty = $derived(!isFirstLoad && displayData.length === 0);
|
|
443
|
+
// Cap skeleton count so we don't render 200 ghost cards
|
|
444
|
+
const skeletonCount = $derived(Math.min(pageSize, 8));
|
|
445
|
+
// ── Virtualizer ────────────────────────────────────────────────────────────
|
|
446
|
+
// Group flat displayData into rows of columnsInRow items each
|
|
447
|
+
const virtualRows = $derived.by(() => {
|
|
448
|
+
const cols = Math.max(1, columnsInRow);
|
|
449
|
+
const rows = [];
|
|
450
|
+
for (let i = 0; i < displayData.length; i += cols) {
|
|
451
|
+
rows.push(displayData.slice(i, i + cols));
|
|
558
452
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
453
|
+
return rows;
|
|
454
|
+
});
|
|
455
|
+
// createVirtualizer returns a Svelte 4 store that calls subscribe with the
|
|
456
|
+
// same object reference on every scroll update. Svelte 5 $state uses Object.is
|
|
457
|
+
// equality, so assigning the same reference never triggers reactivity.
|
|
458
|
+
// Fix: extract the values we need inside the subscribe callback directly into
|
|
459
|
+
// $state variables — getVirtualItems() returns a new array each call, and
|
|
460
|
+
// getTotalSize() is a primitive, so both reliably trigger Svelte updates.
|
|
461
|
+
let virtualizerInstance = $state(null);
|
|
462
|
+
let virtualItems = $state([]);
|
|
463
|
+
let totalSize = $state(0);
|
|
464
|
+
$effect(() => {
|
|
465
|
+
const el = containerEl;
|
|
466
|
+
if (!el) {
|
|
467
|
+
virtualizerInstance = null;
|
|
468
|
+
virtualItems = [];
|
|
469
|
+
totalSize = 0;
|
|
470
|
+
return;
|
|
572
471
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
const skeletonCount = $derived(Math.min(pageSize, 8));
|
|
581
|
-
|
|
582
|
-
// ── Virtualizer ────────────────────────────────────────────────────────────
|
|
583
|
-
|
|
584
|
-
// Group flat displayData into rows of columnsInRow items each
|
|
585
|
-
const virtualRows = $derived.by(() => {
|
|
586
|
-
const cols = Math.max(1, columnsInRow);
|
|
587
|
-
const rows: Record<string, unknown>[][] = [];
|
|
588
|
-
for (let i = 0; i < displayData.length; i += cols) {
|
|
589
|
-
rows.push(displayData.slice(i, i + cols));
|
|
590
|
-
}
|
|
591
|
-
return rows;
|
|
472
|
+
const rowCount = untrack(() => virtualRows.length);
|
|
473
|
+
const gap = untrack(() => cardGap);
|
|
474
|
+
const virt = createVirtualizer({
|
|
475
|
+
count: rowCount,
|
|
476
|
+
estimateSize: () => 220 + gap,
|
|
477
|
+
overscan: 2,
|
|
478
|
+
getScrollElement: () => el
|
|
592
479
|
});
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
// Fix: extract the values we need inside the subscribe callback directly into
|
|
598
|
-
// $state variables — getVirtualItems() returns a new array each call, and
|
|
599
|
-
// getTotalSize() is a primitive, so both reliably trigger Svelte updates.
|
|
600
|
-
let virtualizerInstance = $state<SvelteVirtualizer<HTMLElement, HTMLElement> | null>(null);
|
|
601
|
-
let virtualItems = $state<ReturnType<SvelteVirtualizer<HTMLElement, HTMLElement>['getVirtualItems']>>([]);
|
|
602
|
-
let totalSize = $state(0);
|
|
603
|
-
|
|
604
|
-
$effect(() => {
|
|
605
|
-
const el = containerEl;
|
|
606
|
-
if (!el) {
|
|
607
|
-
virtualizerInstance = null;
|
|
608
|
-
virtualItems = [];
|
|
609
|
-
totalSize = 0;
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
|
-
const rowCount = untrack(() => virtualRows.length);
|
|
613
|
-
const gap = untrack(() => cardGap);
|
|
614
|
-
const virt = createVirtualizer<HTMLElement, HTMLElement>({
|
|
615
|
-
count: rowCount,
|
|
616
|
-
estimateSize: () => 220 + gap,
|
|
617
|
-
overscan: 2,
|
|
618
|
-
getScrollElement: () => el,
|
|
619
|
-
});
|
|
620
|
-
return virt.subscribe((v) => {
|
|
621
|
-
virtualizerInstance = v;
|
|
622
|
-
virtualItems = v.getVirtualItems();
|
|
623
|
-
totalSize = v.getTotalSize();
|
|
624
|
-
});
|
|
480
|
+
return virt.subscribe((v) => {
|
|
481
|
+
virtualizerInstance = v;
|
|
482
|
+
virtualItems = v.getVirtualItems();
|
|
483
|
+
totalSize = v.getTotalSize();
|
|
625
484
|
});
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
});
|
|
485
|
+
});
|
|
486
|
+
// Update virtualizer when row count or gap changes.
|
|
487
|
+
// Untracked read of virtualizerInstance so scroll events don't re-trigger this.
|
|
488
|
+
$effect(() => {
|
|
489
|
+
const count = virtualRows.length;
|
|
490
|
+
const gap = cardGap;
|
|
491
|
+
untrack(() => {
|
|
492
|
+
if (!virtualizerInstance || !containerEl) return;
|
|
493
|
+
virtualizerInstance.setOptions({
|
|
494
|
+
...virtualizerInstance.options,
|
|
495
|
+
count,
|
|
496
|
+
estimateSize: () => 220 + gap,
|
|
497
|
+
getScrollElement: () => containerEl
|
|
640
498
|
});
|
|
641
499
|
});
|
|
500
|
+
});
|
|
642
501
|
</script>
|
|
643
502
|
|
|
644
503
|
{#snippet filterPanelSnippet()}
|
|
@@ -734,16 +593,16 @@
|
|
|
734
593
|
</div>
|
|
735
594
|
|
|
736
595
|
<!-- ── Card grid ────────────────────────────────────────────────────── -->
|
|
737
|
-
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
738
596
|
<div
|
|
739
597
|
bind:this={containerEl}
|
|
740
598
|
class="cg-grid"
|
|
599
|
+
class:cg-animate={animateEnabled}
|
|
741
600
|
role="listbox"
|
|
742
601
|
aria-multiselectable={multiSelect}
|
|
743
602
|
tabindex="0"
|
|
744
603
|
onkeydown={handleKeyDown}
|
|
745
604
|
onclick={handleContainerClick}
|
|
746
|
-
style="--cg-min-width:{
|
|
605
|
+
style="--cg-min-width:{densityMinWidth * expandFactor}px;--cg-gap:{densityGap * expandFactor}px;--cg-cols:{columnsInRow};"
|
|
747
606
|
>
|
|
748
607
|
{#if isFirstLoad}
|
|
749
608
|
<!-- ── Skeleton cards ─────────────────────────────────── -->
|
|
@@ -1118,6 +977,22 @@
|
|
|
1118
977
|
outline-offset: 2px;
|
|
1119
978
|
}
|
|
1120
979
|
|
|
980
|
+
/* ── Card entry animation (enabled via .cg-animate on the container) ── */
|
|
981
|
+
|
|
982
|
+
@keyframes cg-card-in {
|
|
983
|
+
from { opacity: 0; transform: translateY(6px) scale(0.97); }
|
|
984
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
.cg-animate .cg-card {
|
|
988
|
+
animation: cg-card-in 180ms ease both;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/* Selected cards override the entry animation's transform */
|
|
992
|
+
.cg-animate .cg-card-selected {
|
|
993
|
+
animation: none;
|
|
994
|
+
}
|
|
995
|
+
|
|
1121
996
|
/* ── Skeleton cards ───────────────────────────────────────────────────── */
|
|
1122
997
|
|
|
1123
998
|
@keyframes cg-shimmer {
|