@warkypublic/svelix 0.1.46 → 0.1.47

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