@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.
Files changed (129) 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 +492 -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 +489 -614
  13. package/dist/components/CardGrid/CardGrid.svelte.d.ts +4 -0
  14. package/dist/components/CardGrid/CardGridFilterPanel.svelte +111 -90
  15. package/dist/components/CardGrid/DefaultCard.svelte +86 -97
  16. package/dist/components/CardGrid/DefaultCard.svelte.d.ts +1 -0
  17. package/dist/components/CardGrid/ImageCardStory.svelte +87 -36
  18. package/dist/components/CardGrid/cardGrid.d.ts +24 -0
  19. package/dist/components/CardGrid/cardGrid.js +20 -0
  20. package/dist/components/ContentEditor/CollaboraInsertTextDemo.svelte +5 -10
  21. package/dist/components/ContentEditor/ContentEditor.svelte +24 -50
  22. package/dist/components/ContentEditor/subcomponents/AudioPlayer.svelte +17 -17
  23. package/dist/components/ContentEditor/subcomponents/CollaboraEditor.svelte +244 -309
  24. package/dist/components/ContentEditor/subcomponents/EmailViewer.svelte +2 -3
  25. package/dist/components/ContentEditor/subcomponents/ImageViewer.svelte +14 -15
  26. package/dist/components/ContentEditor/subcomponents/MarkdownViewer.svelte +74 -90
  27. package/dist/components/ContentEditor/subcomponents/MonacoEditor.svelte +75 -103
  28. package/dist/components/ContentEditor/subcomponents/Office365Editor.svelte +234 -297
  29. package/dist/components/ContentEditor/subcomponents/PdfViewer.svelte +14 -20
  30. package/dist/components/ContentEditor/subcomponents/TextEditor.svelte +122 -150
  31. package/dist/components/ContentEditor/subcomponents/UnknownFile.svelte +105 -126
  32. package/dist/components/ContentEditor/subcomponents/VideoPlayer.svelte +17 -17
  33. package/dist/components/ContentEditor/subcomponents/ZipViewer.svelte +2 -3
  34. package/dist/components/ErrorBoundary/ErrorBoundary.svelte +41 -58
  35. package/dist/components/ErrorBoundary/ErrorBoundaryPreview.svelte +8 -16
  36. package/dist/components/Former/Former.svelte +306 -424
  37. package/dist/components/Former/FormerButtonArea.svelte +17 -68
  38. package/dist/components/Former/FormerDrawer.svelte +16 -80
  39. package/dist/components/Former/FormerDrawerPreview.svelte +99 -89
  40. package/dist/components/Former/FormerModal.svelte +16 -74
  41. package/dist/components/Former/FormerModalPreview.svelte +99 -89
  42. package/dist/components/Former/FormerPreview.svelte +76 -78
  43. package/dist/components/Former/FormerRestApiPreview.svelte +55 -48
  44. package/dist/components/Former/formerOverlayHelpers.svelte.d.ts +11 -0
  45. package/dist/components/Former/formerOverlayHelpers.svelte.js +38 -0
  46. package/dist/components/Former/index.d.ts +1 -0
  47. package/dist/components/Former/index.js +1 -0
  48. package/dist/components/FormerControllers/ButtonCtrl.svelte +13 -49
  49. package/dist/components/FormerControllers/CtrlFieldShell.svelte +15 -0
  50. package/dist/components/FormerControllers/CtrlFieldShell.svelte.d.ts +12 -0
  51. package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrl.svelte +285 -457
  52. package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrlCalendar.svelte +95 -136
  53. package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrlPickerPanel.svelte +3 -62
  54. package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrlTimeFields.svelte +143 -217
  55. package/dist/components/FormerControllers/IconButtonCtrl.svelte +13 -47
  56. package/dist/components/FormerControllers/InlineWrapper.svelte +12 -41
  57. package/dist/components/FormerControllers/InlineWrapperPreview.svelte +4 -26
  58. package/dist/components/FormerControllers/NativeSelectCtrl.svelte +7 -39
  59. package/dist/components/FormerControllers/NumberInputCtrl.svelte +21 -43
  60. package/dist/components/FormerControllers/PasswordInputCtrl.svelte +8 -33
  61. package/dist/components/FormerControllers/SwitchCtrl.svelte +4 -20
  62. package/dist/components/FormerControllers/TextAreaCtrl.svelte +7 -34
  63. package/dist/components/FormerControllers/TextInputCtrl.svelte +7 -34
  64. package/dist/components/FormerControllers/ctrlLoading.svelte.d.ts +8 -0
  65. package/dist/components/FormerControllers/ctrlLoading.svelte.js +43 -0
  66. package/dist/components/FormerControllers/index.d.ts +2 -0
  67. package/dist/components/FormerControllers/index.js +2 -0
  68. package/dist/components/FormerControllers/utils.d.ts +1 -0
  69. package/dist/components/FormerControllers/utils.js +3 -0
  70. package/dist/components/GlobalStateStore/GlobalStateStoreProvider.svelte +73 -120
  71. package/dist/components/Gridler/components/Gridler.svelte +344 -442
  72. package/dist/components/Gridler/components/GridlerCanvas.svelte +720 -1115
  73. package/dist/components/Gridler/components/GridlerEditor.svelte +2 -15
  74. package/dist/components/Gridler/components/GridlerFull.svelte +521 -841
  75. package/dist/components/Gridler/components/GridlerFull.svelte.d.ts +1 -1
  76. package/dist/components/Gridler/components/GridlerFullWithFormerPreview.svelte +194 -147
  77. package/dist/components/Gridler/components/GridlerSearch.svelte +7 -16
  78. package/dist/components/Gridler/components/GridlerSearchToggle.svelte +7 -19
  79. package/dist/components/Gridler/types.d.ts +1 -1
  80. package/dist/components/Gridler/utils/serverPaging.d.ts +1 -0
  81. package/dist/components/Gridler/utils/serverPaging.js +7 -0
  82. package/dist/components/Icons/IconAdd.svelte +1 -3
  83. package/dist/components/Icons/IconAlertCircle.svelte +1 -3
  84. package/dist/components/Icons/IconAlertTriangle.svelte +1 -3
  85. package/dist/components/Icons/IconCamera.svelte +1 -3
  86. package/dist/components/Icons/IconClose.svelte +1 -3
  87. package/dist/components/Icons/IconEdit.svelte +1 -3
  88. package/dist/components/Icons/IconFilter.svelte +1 -3
  89. package/dist/components/Icons/IconSearch.svelte +1 -3
  90. package/dist/components/Icons/IconSort.svelte +1 -3
  91. package/dist/components/Icons/IconTrash.svelte +1 -3
  92. package/dist/components/Portal/Portal.svelte +14 -38
  93. package/dist/components/Screenshot/Screenshot.svelte +7 -13
  94. package/dist/components/Svark/Svark.svelte +680 -852
  95. package/dist/components/Svark/SvarkContextMenu.svelte +44 -63
  96. package/dist/components/Svark/SvarkInfiniteLoader.svelte +2 -6
  97. package/dist/components/Svark/SvarkPager.svelte +2 -28
  98. package/dist/components/Svark/SvarkSelectionDemo.svelte +4 -8
  99. package/dist/components/Svark/SvarkTopBar.svelte +9 -74
  100. package/dist/components/SvarkGrid/SvarkGrid.svelte +877 -1123
  101. package/dist/components/SvarkGrid/components/SvarkColumnFilterForm.svelte +45 -58
  102. package/dist/components/SvarkGrid/components/SvarkContextMenu.svelte +44 -63
  103. package/dist/components/SvarkGrid/components/SvarkHeaderFilterCell.svelte +41 -75
  104. package/dist/components/SvarkGrid/components/SvarkInfiniteLoader.svelte +2 -6
  105. package/dist/components/SvarkGrid/components/SvarkPager.svelte +2 -28
  106. package/dist/components/SvarkGrid/components/SvarkSearchPopover.svelte +8 -16
  107. package/dist/components/SvarkGrid/components/SvarkTopBar.svelte +6 -62
  108. package/dist/components/SvarkGrid/internal/SvarkGridView.svelte +345 -507
  109. package/dist/components/VTree/VTree.svelte +454 -431
  110. package/dist/components/VTree/VTree.utils.d.ts +23 -0
  111. package/dist/components/VTree/VTree.utils.js +73 -0
  112. package/dist/components/VTree/VTreeContextMenu.svelte +21 -33
  113. package/dist/components/VTree/VTreeControlledSelectionDemo.svelte +20 -0
  114. package/dist/components/VTree/VTreeControlledSelectionDemo.svelte.d.ts +18 -0
  115. package/dist/components/VTree/VTreeCustomizationDemo.svelte +23 -0
  116. package/dist/components/VTree/VTreeCustomizationDemo.svelte.d.ts +18 -0
  117. package/dist/components/VTree/VTreeEventsDemo.svelte +48 -58
  118. package/dist/components/VTree/VTreeInteractionDemo.svelte +29 -0
  119. package/dist/components/VTree/VTreeInteractionDemo.svelte.d.ts +18 -0
  120. package/dist/components/VTree/VTreeMultiSelectDemo.svelte +27 -0
  121. package/dist/components/VTree/VTreeMultiSelectDemo.svelte.d.ts +18 -0
  122. package/dist/components/VTree/VTreeRow.svelte +77 -115
  123. package/dist/components/VTree/VTreeRow.svelte.d.ts +34 -18
  124. package/dist/components/VTree/VTreeSearch.svelte +6 -13
  125. package/dist/components/VTree/VTreeVirtualViewport.svelte +54 -143
  126. package/dist/components/VTree/VTreeVirtualViewport.svelte.d.ts +35 -18
  127. package/dist/components/VTree/types.d.ts +39 -1
  128. package/llm/COMPONENT_GUIDE.md +25 -0
  129. package/package.json +51 -50
@@ -1,644 +1,503 @@
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;
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
- 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);
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
- // ── 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);
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
- // ── 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);
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
- 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);
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
- 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
- );
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
- 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];
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
- return val;
283
+ items.push({ isDivider: true });
267
284
  }
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);
285
+ items.push({
286
+ label: "Refresh",
287
+ onClick: () => refresh()
298
288
  });
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))];
289
+ if (resolvedSearchValue) items.push({
290
+ label: "Clear search",
291
+ onClick: () => emitSearchValue("")
305
292
  });
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; };
293
+ if (Object.keys(resolvedFilters).length > 0) items.push({
294
+ label: "Clear filters",
295
+ onClick: () => handleFilterChange({})
349
296
  });
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();
297
+ if (Object.values(resolvedSortOrder).some((d) => d !== "none")) items.push({
298
+ label: "Clear sort",
299
+ onClick: () => handleSortOrderChange({})
406
300
  });
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();
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
- // ── 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;
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
- 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;
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
- 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) {
371
+ case "Escape":
451
372
  setSelectedItems([]);
452
373
  focusedIndex = -1;
453
- }
454
- }
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
- }
374
+ break;
375
+ case "a":
376
+ case "A":
377
+ if ((e.ctrlKey || e.metaKey) && multiSelect) {
378
+ e.preventDefault();
379
+ setSelectedItems([...displayData]);
467
380
  }
468
- items.push({ isDivider: true });
469
- }
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;
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
- // ── 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,
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
- // ── 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]);
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
- 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':
523
- 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;
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
- 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
- }
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
- // ── 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
- }
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
- // ── 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;
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
- // 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
- });
480
+ return virt.subscribe((v) => {
481
+ virtualizerInstance = v;
482
+ virtualItems = v.getVirtualItems();
483
+ totalSize = v.getTotalSize();
625
484
  });
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
- });
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:{cardMinWidth}px;--cg-gap:{cardGap}px;--cg-cols:{columnsInRow};"
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 {