@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,903 +1,731 @@
1
- <script lang="ts">
2
- /* eslint-disable @typescript-eslint/no-explicit-any */
3
- import type { Snippet } from 'svelte';
4
- import { Grid, Willow, WillowDark } from '@svar-ui/svelte-grid';
5
- import type { IApi } from '@svar-ui/svelte-grid';
6
- import SvarkInfiniteLoader from './SvarkInfiniteLoader.svelte';
7
- import SvarkPager from './SvarkPager.svelte';
8
- import { SvarkResolveSpecAdapter } from './SvarkResolveSpecAdapter.js';
9
- import { SvarkRestHeaderSpecAdapter } from './SvarkRestHeaderSpecAdapter.js';
10
- import SvarkTopBar from './SvarkTopBar.svelte';
11
- import { reduceServerFilterState } from './filtering.js';
12
- import SvarkContextMenu from './SvarkContextMenu.svelte';
13
- import type {
14
- SvarkFilterState,
15
- SvarkFilterOptionItem,
16
- SvarkSortState,
17
- SvarkChangeEvent,
18
- SvarkRowInteractionEvent,
19
- SvarkAdapterType,
20
- SvarkInitialQuery,
21
- SvarkPopupFilterDraft,
22
- IColumnConfig,
23
- IConfig,
24
- SvarkContextMenuContext,
25
- SvarkContextMenuItem,
26
- SvarkContextMenuProps
27
- } from './types.js';
28
-
29
- export interface Props {
30
- // Prefer `url`. `baseUrl` is kept for backwards compatibility.
31
- url?: string;
32
- baseUrl?: string;
33
- token?: string;
34
- schema: string;
35
- entity: string;
36
- adapterType?: SvarkAdapterType;
37
- // Backwards compatibility alias for `adapterType`.
38
- clientType?: SvarkAdapterType;
39
- columns: IColumnConfig[];
40
- config?: Omit<IConfig, 'data'>;
41
- query?: SvarkInitialQuery;
42
- pageSize?: number;
43
- serverSide?: boolean;
44
- height?: string | number;
45
- select?: boolean;
46
- multiselect?: boolean;
47
- virtualScroll?: boolean;
48
- infiniteScroll?: boolean;
49
- topBarLeft?: Snippet;
50
- topBarRight?: Snippet;
51
- onchange?: (event: SvarkChangeEvent) => void;
52
- onerror?: (message: string) => void;
53
- onselect?: (ids: (string | number)[]) => void;
54
- onrowclick?: (event: SvarkRowInteractionEvent) => void;
55
- onrowdblclick?: (event: SvarkRowInteractionEvent) => void;
56
- onrowcontextmenu?: (event: SvarkRowInteractionEvent) => void;
57
- contextMenuProps?: SvarkContextMenuProps;
58
- }
59
-
60
- let {
61
- url,
62
- baseUrl,
63
- token,
64
- schema,
65
- entity,
66
- adapterType,
67
- clientType,
68
- columns,
69
- config = {},
70
- query = {},
71
- pageSize = 100,
72
- serverSide = false,
73
- height = '400px',
74
- select = true,
75
- multiselect = false,
76
- virtualScroll = false,
77
- infiniteScroll = false,
78
- topBarLeft,
79
- topBarRight,
80
- onchange,
81
- onerror,
82
- onselect,
83
- onrowclick,
84
- onrowdblclick,
85
- onrowcontextmenu,
86
- contextMenuProps
87
- }: Props = $props();
88
-
89
- function normalizeAdapterType(t: SvarkAdapterType): 'ResolveSpec' | 'RestHeaderSpec' {
90
- return t === 'header' || t === 'RestHeaderSpec' ? 'RestHeaderSpec' : 'ResolveSpec';
91
- }
92
-
93
- const FILTER_OPERATOR_LABELS: Record<string, string> = {
94
- contains: 'Contains',
95
- eq: 'Equals',
96
- neq: 'Not equal',
97
- startswith: 'Starts with',
98
- endswith: 'Ends with',
99
- gt: 'Greater than',
100
- gte: 'Greater or equal',
101
- lt: 'Less than',
102
- lte: 'Less or equal'
1
+ <script lang="ts">import { Grid, Willow, WillowDark } from "@svar-ui/svelte-grid";
2
+ import SvarkInfiniteLoader from "./SvarkInfiniteLoader.svelte";
3
+ import SvarkPager from "./SvarkPager.svelte";
4
+ import { SvarkResolveSpecAdapter } from "./SvarkResolveSpecAdapter.js";
5
+ import { SvarkRestHeaderSpecAdapter } from "./SvarkRestHeaderSpecAdapter.js";
6
+ import SvarkTopBar from "./SvarkTopBar.svelte";
7
+ import { reduceServerFilterState } from "./filtering.js";
8
+ import SvarkContextMenu from "./SvarkContextMenu.svelte";
9
+ let { url, baseUrl, token, schema, entity, adapterType, clientType, columns, config = {}, query = {}, pageSize = 100, serverSide = false, height = "400px", select = true, multiselect = false, virtualScroll = false, infiniteScroll = false, topBarLeft, topBarRight, onchange, onerror, onselect, onrowclick, onrowdblclick, onrowcontextmenu, contextMenuProps } = $props();
10
+ function normalizeAdapterType(t) {
11
+ return t === "header" || t === "RestHeaderSpec" ? "RestHeaderSpec" : "ResolveSpec";
12
+ }
13
+ const FILTER_OPERATOR_LABELS = {
14
+ contains: "Contains",
15
+ eq: "Equals",
16
+ neq: "Not equal",
17
+ startswith: "Starts with",
18
+ endswith: "Ends with",
19
+ gt: "Greater than",
20
+ gte: "Greater or equal",
21
+ lt: "Less than",
22
+ lte: "Less or equal"
23
+ };
24
+ function normalizeOperatorToken(value) {
25
+ return String(value ?? "contains").trim().toLowerCase().replace(/[\s_-]+/g, "");
26
+ }
27
+ function mapPopupOperatorValue(operator, value) {
28
+ const normalized = normalizeOperatorToken(operator);
29
+ if (normalized === "contains") return {
30
+ operator: "ilike",
31
+ value
103
32
  };
104
-
105
- function normalizeOperatorToken(value: string | undefined): string {
106
- return String(value ?? 'contains')
107
- .trim()
108
- .toLowerCase()
109
- .replace(/[\s_-]+/g, '');
110
- }
111
-
112
- function mapPopupOperatorValue(operator: string, value: string): { operator: string; value: string } {
113
- const normalized = normalizeOperatorToken(operator);
114
- if (normalized === 'contains') return { operator: 'ilike', value };
115
- if (normalized === 'startswith') return { operator: 'ilike', value: `${value}%` };
116
- if (normalized === 'endswith') return { operator: 'ilike', value: `%${value}` };
117
- return { operator: normalized, value };
118
- }
119
-
120
- // Enable column sorting by default.
121
- // Opt-out: set `sort: false` on a column.
122
- const gridColumns = $derived(
123
- (columns ?? []).map((col) => {
124
- return {
125
- ...col,
126
- sort: (col as any).sort ?? true
127
- } as IColumnConfig;
128
- })
129
- );
130
-
131
- const filterableColumns = $derived(
132
- gridColumns
133
- .filter((col) => (col as any).id != null && !(col as any).hidden)
134
- .map((col) => ({
135
- id: String((col as any).id),
136
- label:
137
- typeof (col as any).header === 'string'
138
- ? String((col as any).header)
139
- : String((col as any).header?.text ?? (col as any).id),
140
- column: col
141
- }))
142
- );
143
- const canFilter = $derived(serverSide && filterableColumns.length > 0);
144
-
145
- function getColumnDefinition(columnId: string): IColumnConfig | undefined {
146
- return filterableColumns.find((entry) => entry.id === columnId)?.column;
147
- }
148
-
149
- function getColumnOptions(columnId: string): SvarkFilterOptionItem[] {
150
- const column = getColumnDefinition(columnId);
151
- const source = column?.filterOptions;
152
- if (!source?.length) return [];
153
- return source.map((option) =>
154
- typeof option === 'object' && option != null && 'id' in option && 'label' in option
155
- ? { value: String(option.id), label: String(option.label) }
156
- : { value: String(option), label: String(option) }
157
- );
158
- }
159
-
160
- function getSupportedOperators(columnId: string) {
161
- const column = getColumnDefinition(columnId);
162
- const configured = column?.filterOperators?.map((item) => normalizeOperatorToken(item)).filter(Boolean);
163
- if (configured?.length) return configured;
164
- if (column?.filterOptions?.length) return ['eq', 'neq'];
165
- return ['contains', 'eq', 'neq', 'startswith', 'endswith'];
166
- }
167
-
168
- function getDefaultOperator(columnId: string) {
169
- return getSupportedOperators(columnId)[0] ?? 'contains';
170
- }
171
-
172
- let popupFilterId = $state(0);
173
-
174
- function nextPopupFilterId() {
175
- popupFilterId += 1;
176
- return popupFilterId;
177
- }
178
-
179
- function createPopupDraft(columnId?: string): SvarkPopupFilterDraft {
180
- const selectedColumn = columnId ?? filterableColumns[0]?.id ?? '';
181
- return {
182
- id: nextPopupFilterId(),
183
- column: selectedColumn,
184
- operator: getDefaultOperator(selectedColumn),
185
- value: '',
186
- logic: 'AND'
33
+ if (normalized === "startswith") return {
34
+ operator: "ilike",
35
+ value: `${value}%`
36
+ };
37
+ if (normalized === "endswith") return {
38
+ operator: "ilike",
39
+ value: `%${value}`
40
+ };
41
+ return {
42
+ operator: normalized,
43
+ value
44
+ };
45
+ }
46
+ // Enable column sorting by default.
47
+ // Opt-out: set `sort: false` on a column.
48
+ const gridColumns = $derived((columns ?? []).map((col) => {
49
+ return {
50
+ ...col,
51
+ sort: col.sort ?? true
52
+ };
53
+ }));
54
+ const filterableColumns = $derived(gridColumns.filter((col) => col.id != null && !col.hidden).map((col) => ({
55
+ id: String(col.id),
56
+ label: typeof col.header === "string" ? String(col.header) : String(col.header?.text ?? col.id),
57
+ column: col
58
+ })));
59
+ const canFilter = $derived(serverSide && filterableColumns.length > 0);
60
+ function getColumnDefinition(columnId) {
61
+ return filterableColumns.find((entry) => entry.id === columnId)?.column;
62
+ }
63
+ function getColumnOptions(columnId) {
64
+ const column = getColumnDefinition(columnId);
65
+ const source = column?.filterOptions;
66
+ if (!source?.length) return [];
67
+ return source.map((option) => typeof option === "object" && option != null && "id" in option && "label" in option ? {
68
+ value: String(option.id),
69
+ label: String(option.label)
70
+ } : {
71
+ value: String(option),
72
+ label: String(option)
73
+ });
74
+ }
75
+ function getSupportedOperators(columnId) {
76
+ const column = getColumnDefinition(columnId);
77
+ const configured = column?.filterOperators?.map((item) => normalizeOperatorToken(item)).filter(Boolean);
78
+ if (configured?.length) return configured;
79
+ if (column?.filterOptions?.length) return ["eq", "neq"];
80
+ return [
81
+ "contains",
82
+ "eq",
83
+ "neq",
84
+ "startswith",
85
+ "endswith"
86
+ ];
87
+ }
88
+ function getDefaultOperator(columnId) {
89
+ return getSupportedOperators(columnId)[0] ?? "contains";
90
+ }
91
+ let popupFilterId = $state(0);
92
+ function nextPopupFilterId() {
93
+ popupFilterId += 1;
94
+ return popupFilterId;
95
+ }
96
+ function createPopupDraft(columnId) {
97
+ const selectedColumn = columnId ?? filterableColumns[0]?.id ?? "";
98
+ return {
99
+ id: nextPopupFilterId(),
100
+ column: selectedColumn,
101
+ operator: getDefaultOperator(selectedColumn),
102
+ value: "",
103
+ logic: "AND"
104
+ };
105
+ }
106
+ function clonePopupDrafts(filters) {
107
+ return filters.map((filter) => ({ ...filter }));
108
+ }
109
+ function stripPopupFilters(state) {
110
+ return Object.fromEntries(Object.entries(state).filter(([key]) => !key.startsWith("popup:")));
111
+ }
112
+ function buildPopupFilterState(filters) {
113
+ const next = {};
114
+ for (const filter of filters) {
115
+ const value = filter.value.trim();
116
+ if (!filter.column || value === "") continue;
117
+ const mapped = mapPopupOperatorValue(filter.operator, value);
118
+ next[`popup:${filter.id}`] = {
119
+ column: filter.column,
120
+ operator: mapped.operator,
121
+ value: mapped.value,
122
+ logic_operator: filter.logic
187
123
  };
188
124
  }
189
-
190
- function clonePopupDrafts(filters: SvarkPopupFilterDraft[]) {
191
- return filters.map((filter) => ({ ...filter }));
192
- }
193
-
194
- function stripPopupFilters(state: SvarkFilterState): SvarkFilterState {
195
- return Object.fromEntries(
196
- Object.entries(state).filter(([key]) => !key.startsWith('popup:'))
197
- );
198
- }
199
-
200
- function buildPopupFilterState(filters: SvarkPopupFilterDraft[]): SvarkFilterState {
201
- const next: SvarkFilterState = {};
202
- for (const filter of filters) {
203
- const value = filter.value.trim();
204
- if (!filter.column || value === '') continue;
205
- const mapped = mapPopupOperatorValue(filter.operator, value);
206
- next[`popup:${filter.id}`] = {
207
- column: filter.column,
208
- operator: mapped.operator,
209
- value: mapped.value,
210
- logic_operator: filter.logic
211
- };
125
+ return next;
126
+ }
127
+ function syncPopupFilters(filters) {
128
+ popupAppliedFilters = clonePopupDrafts(filters).filter((filter) => filter.column && filter.value.trim() !== "");
129
+ filterState = {
130
+ ...stripPopupFilters(filterState),
131
+ ...buildPopupFilterState(popupAppliedFilters)
132
+ };
133
+ }
134
+ const resolvedUrl = $derived(url ?? baseUrl ?? "");
135
+ const resolvedAdapterType = $derived(normalizeAdapterType(adapterType ?? clientType ?? "body"));
136
+ const heightStyle = $derived(typeof height === "number" ? `${height}px` : height);
137
+ let data = $state([]);
138
+ let loading = $state(false);
139
+ let infiniteLoading = $state(false);
140
+ let error = $state(null);
141
+ let total = $state(0);
142
+ let sortState = $state([]);
143
+ let filterState = $state({});
144
+ let searchFilterState = $state({});
145
+ let page = $state(0);
146
+ let reload = $state(0);
147
+ let gridApi = $state(null);
148
+ let searchOpen = $state(false);
149
+ let searchValue = $state("");
150
+ let filterMenuOpen = $state(false);
151
+ let popupAppliedFilters = $state([]);
152
+ let popupDraftFilters = $state([]);
153
+ // Paging is active when serverSide with no scroll variant
154
+ const showPager = $derived(serverSide && !infiniteScroll && !virtualScroll);
155
+ const totalPages = $derived(pageSize > 0 ? Math.ceil(total / pageSize) : 1);
156
+ const pageStart = $derived(total === 0 ? 0 : page * pageSize + 1);
157
+ const pageEnd = $derived(Math.min((page + 1) * pageSize, total));
158
+ // Debounced mirrors of sortState/filterState used by the main fetch effect.
159
+ // When serverSide is true, changes are held for 300 ms before committing so
160
+ // rapid keystrokes / click-sort chains don't fire a request per event.
161
+ // Page resets to 0 atomically with the debounced update so only one fetch fires.
162
+ let debouncedSort = $state([]);
163
+ let debouncedFilter = $state({});
164
+ let _debounceTimer = null;
165
+ $effect(() => {
166
+ const s = sortState;
167
+ const f = {
168
+ ...filterState,
169
+ ...searchFilterState
170
+ };
171
+ if (!serverSide) {
172
+ debouncedSort = s;
173
+ debouncedFilter = f;
174
+ return;
175
+ }
176
+ if (_debounceTimer !== null) clearTimeout(_debounceTimer);
177
+ _debounceTimer = setTimeout(() => {
178
+ _debounceTimer = null;
179
+ page = 0;
180
+ debouncedSort = s;
181
+ debouncedFilter = f;
182
+ }, 300);
183
+ return () => {
184
+ if (_debounceTimer !== null) {
185
+ clearTimeout(_debounceTimer);
186
+ _debounceTimer = null;
187
+ }
188
+ };
189
+ });
190
+ // Dark mode detection — priority: inline color-scheme style > .dark class > OS media query
191
+ let isDark = $state(false);
192
+ $effect(() => {
193
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
194
+ const html = document.documentElement;
195
+ function update() {
196
+ const explicit = html.style.colorScheme;
197
+ if (explicit === "dark" || explicit === "light") {
198
+ isDark = explicit === "dark";
199
+ } else {
200
+ isDark = html.classList.contains("dark") || mq.matches;
212
201
  }
213
- return next;
214
202
  }
215
-
216
- function syncPopupFilters(filters: SvarkPopupFilterDraft[]) {
217
- popupAppliedFilters = clonePopupDrafts(filters).filter((filter) => filter.column && filter.value.trim() !== '');
218
- filterState = {
219
- ...stripPopupFilters(filterState),
220
- ...buildPopupFilterState(popupAppliedFilters)
203
+ update();
204
+ mq.addEventListener("change", update);
205
+ const observer = new MutationObserver(update);
206
+ observer.observe(html, { attributeFilter: ["class", "style"] });
207
+ return () => {
208
+ mq.removeEventListener("change", update);
209
+ observer.disconnect();
210
+ };
211
+ });
212
+ // Bind to outer container so we can find .wx-scroll for infinite scroll
213
+ let containerRef = $state();
214
+ let scrollContainerRef = $state(null);
215
+ function getLocalSearchColumns() {
216
+ const columnsMap = {};
217
+ for (const col of gridColumns) {
218
+ const id = col.id;
219
+ if (id == null) continue;
220
+ if (col.hidden) continue;
221
+ if (col.searchable === false) continue;
222
+ columnsMap[String(id)] = true;
223
+ }
224
+ return Object.keys(columnsMap).length > 0 ? columnsMap : undefined;
225
+ }
226
+ function applyServerSearchFilters(value) {
227
+ if (!serverSide) return;
228
+ if (!value) {
229
+ searchFilterState = {};
230
+ return;
231
+ }
232
+ const searchableIds = [];
233
+ for (const col of gridColumns) {
234
+ const id = col.id;
235
+ if (id == null) continue;
236
+ if (col.hidden) continue;
237
+ if (col.searchable === true) searchableIds.push(String(id));
238
+ }
239
+ if (searchableIds.length === 0) {
240
+ searchFilterState = {};
241
+ return;
242
+ }
243
+ const next = {};
244
+ for (const id of searchableIds) {
245
+ // Avoid clobbering an existing column filter the user set elsewhere.
246
+ const hasExistingFilter = Object.entries(filterState).some(([key, filter]) => {
247
+ const target = filter.column ?? key;
248
+ return target === id && filter.value != null && filter.value !== "";
249
+ });
250
+ if (hasExistingFilter) continue;
251
+ next[id] = {
252
+ value,
253
+ operator: "ilike",
254
+ logic_operator: "OR"
221
255
  };
222
256
  }
223
-
224
- const resolvedUrl = $derived(url ?? baseUrl ?? '');
225
- const resolvedAdapterType = $derived(normalizeAdapterType(adapterType ?? clientType ?? 'body'));
226
-
227
- const heightStyle = $derived(typeof height === 'number' ? `${height}px` : height);
228
-
229
- let data = $state<any[]>([]);
230
- let loading = $state(false);
231
- let infiniteLoading = $state(false);
232
- let error = $state<string | null>(null);
233
- let total = $state(0);
234
- let sortState = $state<SvarkSortState[]>([]);
235
- let filterState = $state<SvarkFilterState>({});
236
- let searchFilterState = $state<SvarkFilterState>({});
237
- let page = $state(0);
238
- let reload = $state(0);
239
-
240
- let gridApi = $state<IApi | null>(null);
241
- let searchOpen = $state(false);
242
- let searchValue = $state('');
243
- let filterMenuOpen = $state(false);
244
- let popupAppliedFilters = $state<SvarkPopupFilterDraft[]>([]);
245
- let popupDraftFilters = $state<SvarkPopupFilterDraft[]>([]);
246
-
247
- // Paging is active when serverSide with no scroll variant
248
- const showPager = $derived(serverSide && !infiniteScroll && !virtualScroll);
249
- const totalPages = $derived(pageSize > 0 ? Math.ceil(total / pageSize) : 1);
250
- const pageStart = $derived(total === 0 ? 0 : page * pageSize + 1);
251
- const pageEnd = $derived(Math.min((page + 1) * pageSize, total));
252
-
253
- // Debounced mirrors of sortState/filterState used by the main fetch effect.
254
- // When serverSide is true, changes are held for 300 ms before committing so
255
- // rapid keystrokes / click-sort chains don't fire a request per event.
256
- // Page resets to 0 atomically with the debounced update so only one fetch fires.
257
- let debouncedSort = $state<SvarkSortState[]>([]);
258
- let debouncedFilter = $state<SvarkFilterState>({});
259
- let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
260
-
261
- $effect(() => {
262
- const s = sortState;
263
- const f = { ...filterState, ...searchFilterState };
264
-
265
- if (!serverSide) {
266
- debouncedSort = s;
267
- debouncedFilter = f;
268
- return;
269
- }
270
-
271
- if (_debounceTimer !== null) clearTimeout(_debounceTimer);
272
- _debounceTimer = setTimeout(() => {
273
- _debounceTimer = null;
274
- page = 0; // reset page atomically — prevents a double-fetch
275
- debouncedSort = s;
276
- debouncedFilter = f;
277
- }, 300);
278
-
279
- return () => {
280
- if (_debounceTimer !== null) {
281
- clearTimeout(_debounceTimer);
282
- _debounceTimer = null;
283
- }
284
- };
257
+ searchFilterState = next;
258
+ }
259
+ function setSearch(next) {
260
+ searchValue = next;
261
+ gridApi?.exec("search-rows", {
262
+ search: next,
263
+ columns: getLocalSearchColumns()
285
264
  });
286
-
287
- // Dark mode detection — priority: inline color-scheme style > .dark class > OS media query
288
- let isDark = $state(false);
289
-
290
- $effect(() => {
291
- const mq = window.matchMedia('(prefers-color-scheme: dark)');
292
- const html = document.documentElement;
293
-
294
- function update() {
295
- const explicit = html.style.colorScheme;
296
- if (explicit === 'dark' || explicit === 'light') {
297
- isDark = explicit === 'dark';
298
- } else {
299
- isDark = html.classList.contains('dark') || mq.matches;
265
+ applyServerSearchFilters(next.trim());
266
+ }
267
+ function openSearch() {
268
+ searchOpen = true;
269
+ }
270
+ function closeSearch() {
271
+ searchOpen = false;
272
+ }
273
+ function openFilterMenu() {
274
+ popupDraftFilters = popupAppliedFilters.length > 0 ? clonePopupDrafts(popupAppliedFilters) : [createPopupDraft()];
275
+ filterMenuOpen = true;
276
+ }
277
+ function closeFilterMenu() {
278
+ filterMenuOpen = false;
279
+ }
280
+ function toggleFilterMenu() {
281
+ if (filterMenuOpen) {
282
+ closeFilterMenu();
283
+ return;
284
+ }
285
+ openFilterMenu();
286
+ }
287
+ function addPopupDraft() {
288
+ popupDraftFilters = [...popupDraftFilters, createPopupDraft()];
289
+ }
290
+ function removePopupDraft(id) {
291
+ const next = popupDraftFilters.filter((filter) => filter.id !== id);
292
+ popupDraftFilters = next.length > 0 ? next : [createPopupDraft()];
293
+ }
294
+ function updatePopupDraft(id, updates, resetValue = false) {
295
+ popupDraftFilters = popupDraftFilters.map((filter) => {
296
+ if (filter.id !== id) return filter;
297
+ const next = {
298
+ ...filter,
299
+ ...updates
300
+ };
301
+ if (updates.column) {
302
+ const supported = getSupportedOperators(updates.column);
303
+ if (!supported.includes(normalizeOperatorToken(next.operator))) {
304
+ next.operator = getDefaultOperator(updates.column);
300
305
  }
306
+ if (resetValue) next.value = "";
301
307
  }
302
-
303
- update();
304
- mq.addEventListener('change', update);
305
-
306
- const observer = new MutationObserver(update);
307
- observer.observe(html, { attributeFilter: ['class', 'style'] });
308
-
309
- return () => {
310
- mq.removeEventListener('change', update);
311
- observer.disconnect();
312
- };
308
+ return next;
313
309
  });
314
-
315
- // Bind to outer container so we can find .wx-scroll for infinite scroll
316
- let containerRef = $state<HTMLElement | undefined>();
317
- let scrollContainerRef = $state<HTMLElement | null>(null);
318
-
319
- function getLocalSearchColumns() {
320
- const columnsMap: Record<string, boolean> = {};
321
- for (const col of gridColumns) {
322
- const id = (col as any).id;
323
- if (id == null) continue;
324
- if ((col as any).hidden) continue;
325
- if ((col as any).searchable === false) continue;
326
- columnsMap[String(id)] = true;
327
- }
328
- return Object.keys(columnsMap).length > 0 ? columnsMap : undefined;
329
- }
330
-
331
- function applyServerSearchFilters(value: string) {
332
- if (!serverSide) return;
333
-
334
- if (!value) {
335
- searchFilterState = {};
310
+ }
311
+ function applyPopupDrafts() {
312
+ syncPopupFilters(popupDraftFilters);
313
+ filterMenuOpen = false;
314
+ }
315
+ function clearPopupFilters() {
316
+ popupAppliedFilters = [];
317
+ popupDraftFilters = [createPopupDraft()];
318
+ filterState = stripPopupFilters(filterState);
319
+ }
320
+ function removeAppliedPopupFilter(id) {
321
+ syncPopupFilters(popupAppliedFilters.filter((filter) => filter.id !== id));
322
+ }
323
+ $effect(() => {
324
+ if (!containerRef) return;
325
+ const node = containerRef;
326
+ function onKeyDown(ev) {
327
+ const key = ev.key?.toLowerCase?.() ?? "";
328
+ const isFind = (ev.ctrlKey || ev.metaKey) && !ev.altKey && !ev.shiftKey && key === "f";
329
+ if (isFind) {
330
+ ev.preventDefault();
331
+ ev.stopPropagation();
332
+ openSearch();
336
333
  return;
337
334
  }
338
-
339
- const searchableIds: string[] = [];
340
- for (const col of gridColumns) {
341
- const id = (col as any).id;
342
- if (id == null) continue;
343
- if ((col as any).hidden) continue;
344
- if ((col as any).searchable === true) searchableIds.push(String(id));
345
- }
346
-
347
- if (searchableIds.length === 0) {
348
- searchFilterState = {};
349
- return;
335
+ if (searchOpen && ev.key === "Escape") {
336
+ ev.preventDefault();
337
+ ev.stopPropagation();
338
+ closeSearch();
350
339
  }
351
-
352
- const next: SvarkFilterState = {};
353
- for (const id of searchableIds) {
354
- // Avoid clobbering an existing column filter the user set elsewhere.
355
- const hasExistingFilter = Object.entries(filterState).some(([key, filter]) => {
356
- const target = filter.column ?? key;
357
- return target === id && filter.value != null && filter.value !== '';
358
- });
359
- if (hasExistingFilter) continue;
360
- next[id] = { value, operator: 'ilike', logic_operator: 'OR' };
340
+ if (contextMenuOpen && ev.key === "Escape") {
341
+ ev.preventDefault();
342
+ ev.stopPropagation();
343
+ closeContextMenu();
361
344
  }
362
- searchFilterState = next;
363
345
  }
364
-
365
- function setSearch(next: string) {
366
- searchValue = next;
367
- gridApi?.exec('search-rows', { search: next, columns: getLocalSearchColumns() });
368
- applyServerSearchFilters(next.trim());
369
- }
370
-
371
- function openSearch() {
372
- searchOpen = true;
373
- }
374
-
375
- function closeSearch() {
376
- searchOpen = false;
377
- }
378
-
379
- function openFilterMenu() {
380
- popupDraftFilters =
381
- popupAppliedFilters.length > 0 ? clonePopupDrafts(popupAppliedFilters) : [createPopupDraft()];
382
- filterMenuOpen = true;
346
+ node.addEventListener("keydown", onKeyDown, true);
347
+ return () => node.removeEventListener("keydown", onKeyDown, true);
348
+ });
349
+ let selectedIdsInternal = $state([]);
350
+ const selectedRow = $derived.by(() => {
351
+ const id = selectedIdsInternal[0];
352
+ if (id == null) return null;
353
+ return data.find((item) => String(item?.id) === String(id)) ?? null;
354
+ });
355
+ function resolveRowFromTarget(target) {
356
+ if (!containerRef || !(target instanceof Element)) return undefined;
357
+ const rowEl = target.closest("[data-id]");
358
+ if (!rowEl || !containerRef.contains(rowEl)) return undefined;
359
+ const attrId = rowEl.getAttribute("data-id");
360
+ if (attrId == null) return undefined;
361
+ const row = data.find((item) => String(item?.id) === attrId);
362
+ return {
363
+ id: row?.id ?? attrId,
364
+ row
365
+ };
366
+ }
367
+ let contextMenuOpen = $state(false);
368
+ let contextMenuX = $state(0);
369
+ let contextMenuY = $state(0);
370
+ let contextMenuRowId = $state(null);
371
+ let contextMenuRow = $state(null);
372
+ let contextMenuEvent = $state(null);
373
+ function buildContextMenuCtx() {
374
+ if (!contextMenuEvent) return null;
375
+ return {
376
+ api: gridApi ?? undefined,
377
+ row: contextMenuRow,
378
+ rowId: contextMenuRowId,
379
+ selectedIds: selectedIdsInternal,
380
+ data,
381
+ event: contextMenuEvent
382
+ };
383
+ }
384
+ const contextMenuItems = $derived.by(() => {
385
+ if (!contextMenuOpen) return [];
386
+ if (!contextMenuProps) return [];
387
+ const ctx = buildContextMenuCtx();
388
+ if (!ctx) return [];
389
+ const resolved = typeof contextMenuProps.items === "function" ? contextMenuProps.items(ctx) : contextMenuProps.items ?? [];
390
+ return (resolved ?? []).map((it) => ({
391
+ ...it,
392
+ kind: it.kind ?? "item"
393
+ }));
394
+ });
395
+ function closeContextMenu() {
396
+ contextMenuOpen = false;
397
+ contextMenuEvent = null;
398
+ contextMenuRowId = null;
399
+ contextMenuRow = null;
400
+ }
401
+ function handleContextMenuItem(item) {
402
+ if (item.id === "__close__") {
403
+ closeContextMenu();
404
+ return;
383
405
  }
384
-
385
- function closeFilterMenu() {
386
- filterMenuOpen = false;
406
+ if (!("label" in item)) {
407
+ closeContextMenu();
408
+ return;
409
+ }
410
+ const ctx = buildContextMenuCtx();
411
+ closeContextMenu();
412
+ if (!ctx) return;
413
+ item.onselect?.(ctx);
414
+ contextMenuProps?.onselectitem?.(item, ctx);
415
+ }
416
+ $effect(() => {
417
+ if (!containerRef) return;
418
+ const node = containerRef;
419
+ function handleClick(ev) {
420
+ const resolved = resolveRowFromTarget(ev.target);
421
+ if (!resolved) return;
422
+ onrowclick?.({
423
+ ...resolved,
424
+ event: ev
425
+ });
387
426
  }
388
-
389
- function toggleFilterMenu() {
390
- if (filterMenuOpen) {
391
- closeFilterMenu();
427
+ function handleContextMenu(ev) {
428
+ // Only handle right-clicks on the grid surface itself, not on Svark's toolbar/pager.
429
+ if (!containerRef || !(ev.target instanceof Element) || !ev.target.closest(".wx-grid") || !containerRef.contains(ev.target.closest(".wx-grid"))) {
392
430
  return;
393
431
  }
394
- openFilterMenu();
395
- }
396
-
397
- function addPopupDraft() {
398
- popupDraftFilters = [...popupDraftFilters, createPopupDraft()];
399
- }
400
-
401
- function removePopupDraft(id: number) {
402
- const next = popupDraftFilters.filter((filter) => filter.id !== id);
403
- popupDraftFilters = next.length > 0 ? next : [createPopupDraft()];
404
- }
405
-
406
- function updatePopupDraft(
407
- id: number,
408
- updates: Partial<SvarkPopupFilterDraft>,
409
- resetValue = false
410
- ) {
411
- popupDraftFilters = popupDraftFilters.map((filter) => {
412
- if (filter.id !== id) return filter;
413
- const next = { ...filter, ...updates };
414
- if (updates.column) {
415
- const supported = getSupportedOperators(updates.column);
416
- if (!supported.includes(normalizeOperatorToken(next.operator))) {
417
- next.operator = getDefaultOperator(updates.column);
418
- }
419
- if (resetValue) next.value = '';
420
- }
421
- return next;
432
+ const resolved = resolveRowFromTarget(ev.target);
433
+ if (resolved) {
434
+ onrowcontextmenu?.({
435
+ ...resolved,
436
+ event: ev
437
+ });
438
+ }
439
+ if (!contextMenuProps) return;
440
+ // Always prevent the browser context menu when a custom menu is configured.
441
+ ev.preventDefault();
442
+ ev.stopPropagation();
443
+ // Right-clicking a row should behave like "select + open menu".
444
+ if (resolved?.id != null && gridApi && select !== false) {
445
+ gridApi.exec("select-row", {
446
+ id: resolved.id,
447
+ toggle: false,
448
+ range: false
449
+ });
450
+ selectedIdsInternal = [resolved.id];
451
+ }
452
+ contextMenuEvent = ev;
453
+ contextMenuX = ev.clientX;
454
+ contextMenuY = ev.clientY;
455
+ // Prefer the right-clicked row; otherwise fall back to current selection.
456
+ const ctxRowId = resolved?.id ?? selectedIdsInternal[0] ?? null;
457
+ const ctxRow = resolved?.row ?? (ctxRowId != null ? data.find((item) => String(item?.id) === String(ctxRowId)) ?? null : selectedRow) ?? null;
458
+ contextMenuRowId = ctxRowId;
459
+ contextMenuRow = ctxRow;
460
+ contextMenuOpen = true;
461
+ }
462
+ function handleDoubleClick(ev) {
463
+ const resolved = resolveRowFromTarget(ev.target);
464
+ if (!resolved) return;
465
+ onrowdblclick?.({
466
+ ...resolved,
467
+ event: ev
422
468
  });
423
469
  }
424
-
425
- function applyPopupDrafts() {
426
- syncPopupFilters(popupDraftFilters);
427
- filterMenuOpen = false;
428
- }
429
-
430
- function clearPopupFilters() {
431
- popupAppliedFilters = [];
432
- popupDraftFilters = [createPopupDraft()];
433
- filterState = stripPopupFilters(filterState);
434
- }
435
-
436
- function removeAppliedPopupFilter(id: number) {
437
- syncPopupFilters(popupAppliedFilters.filter((filter) => filter.id !== id));
438
- }
439
-
440
- $effect(() => {
441
- if (!containerRef) return;
442
- const node = containerRef;
443
-
444
- function onKeyDown(ev: KeyboardEvent) {
445
- const key = ev.key?.toLowerCase?.() ?? '';
446
- const isFind = (ev.ctrlKey || ev.metaKey) && !ev.altKey && !ev.shiftKey && key === 'f';
447
-
448
- if (isFind) {
449
- ev.preventDefault();
450
- ev.stopPropagation();
451
- openSearch();
452
- return;
453
- }
454
-
455
- if (searchOpen && ev.key === 'Escape') {
456
- ev.preventDefault();
457
- ev.stopPropagation();
458
- closeSearch();
459
- }
460
-
461
- if (contextMenuOpen && ev.key === 'Escape') {
462
- ev.preventDefault();
463
- ev.stopPropagation();
464
- closeContextMenu();
470
+ node.addEventListener("click", handleClick);
471
+ node.addEventListener("dblclick", handleDoubleClick);
472
+ node.addEventListener("contextmenu", handleContextMenu);
473
+ return () => {
474
+ node.removeEventListener("click", handleClick);
475
+ node.removeEventListener("dblclick", handleDoubleClick);
476
+ node.removeEventListener("contextmenu", handleContextMenu);
477
+ };
478
+ });
479
+ const adapter = $derived(resolvedAdapterType === "RestHeaderSpec" ? new SvarkRestHeaderSpecAdapter({
480
+ url: resolvedUrl,
481
+ token,
482
+ schema,
483
+ entity
484
+ }) : new SvarkResolveSpecAdapter({
485
+ url: resolvedUrl,
486
+ token,
487
+ schema,
488
+ entity
489
+ }));
490
+ // For virtual scroll: set dynamic rowCount once total is known
491
+ const gridDynamic = $derived(virtualScroll && total > 0 ? { rowCount: total } : undefined);
492
+ // ── Main data effect (initial load + sort/filter/page/reload triggers) ──────
493
+ $effect(() => {
494
+ const _adapter = adapter;
495
+ const _limit = virtualScroll ? 1 : pageSize;
496
+ const _sort = serverSide ? debouncedSort : [];
497
+ const _filters = serverSide ? debouncedFilter : {};
498
+ // Paging offset: only applied when server-side paging is active
499
+ const _offset = showPager ? page * pageSize : 0;
500
+ void reload;
501
+ let cancelled = false;
502
+ loading = true;
503
+ error = null;
504
+ _adapter.read(_sort, _filters, _limit, _offset, query).then((response) => {
505
+ if (cancelled) return;
506
+ if (response.success) {
507
+ const incoming = response.data ?? [];
508
+ total = response.metadata?.total ?? incoming.length;
509
+ if (virtualScroll) {
510
+ data = [];
511
+ } else {
512
+ data = incoming;
465
513
  }
514
+ } else {
515
+ const msg = response.error?.message ?? "Failed to load data";
516
+ error = msg;
517
+ onerror?.(msg);
466
518
  }
467
-
468
- node.addEventListener('keydown', onKeyDown, true);
469
- return () => node.removeEventListener('keydown', onKeyDown, true);
519
+ }).catch((e) => {
520
+ if (cancelled) return;
521
+ const msg = e instanceof Error ? e.message : "Unknown error";
522
+ error = msg;
523
+ onerror?.(msg);
524
+ }).finally(() => {
525
+ if (!cancelled) loading = false;
470
526
  });
471
-
472
- let selectedIdsInternal = $state<(string | number)[]>([]);
473
- const selectedRow = $derived.by(() => {
474
- const id = selectedIdsInternal[0];
475
- if (id == null) return null;
476
- return data.find((item) => String(item?.id) === String(id)) ?? null;
527
+ return () => {
528
+ cancelled = true;
529
+ };
530
+ });
531
+ // ── Infinite scroll: append pages when the user nears the bottom ───────────
532
+ function maybeLoadNextPage() {
533
+ if (!infiniteScroll || !containerRef || loading || infiniteLoading || data.length >= total) return;
534
+ const scrollEl = scrollContainerRef;
535
+ if (scrollEl && scrollEl.scrollHeight > scrollEl.clientHeight + 1) {
536
+ const { scrollTop, scrollHeight, clientHeight } = scrollEl;
537
+ if (scrollHeight - scrollTop - clientHeight < 150) {
538
+ loadNextPage();
539
+ }
540
+ return;
541
+ }
542
+ const { bottom } = containerRef.getBoundingClientRect();
543
+ if (bottom - window.innerHeight < 150) {
544
+ loadNextPage();
545
+ }
546
+ }
547
+ $effect(() => {
548
+ if (!infiniteScroll || loading || infiniteLoading) return;
549
+ void data.length;
550
+ void total;
551
+ queueMicrotask(() => {
552
+ maybeLoadNextPage();
477
553
  });
478
-
479
- function resolveRowFromTarget(target: EventTarget | null) {
480
- if (!containerRef || !(target instanceof Element)) return undefined;
481
- const rowEl = target.closest('[data-id]') as HTMLElement | null;
482
- if (!rowEl || !containerRef.contains(rowEl)) return undefined;
483
-
484
- const attrId = rowEl.getAttribute('data-id');
485
- if (attrId == null) return undefined;
486
-
487
- const row = data.find((item) => String(item?.id) === attrId);
488
- return {
489
- id: (row?.id ?? attrId) as string | number,
490
- row
491
- };
492
- }
493
-
494
- let contextMenuOpen = $state(false);
495
- let contextMenuX = $state(0);
496
- let contextMenuY = $state(0);
497
- let contextMenuRowId = $state<string | number | null>(null);
498
- let contextMenuRow = $state<any | null>(null);
499
- let contextMenuEvent = $state<MouseEvent | null>(null);
500
-
501
- function buildContextMenuCtx(): SvarkContextMenuContext | null {
502
- if (!contextMenuEvent) return null;
503
- return {
504
- api: gridApi ?? undefined,
505
- row: contextMenuRow,
506
- rowId: contextMenuRowId,
507
- selectedIds: selectedIdsInternal,
508
- data,
509
- event: contextMenuEvent
554
+ });
555
+ $effect(() => {
556
+ if (!infiniteScroll || !containerRef) return;
557
+ let scrollEl = null;
558
+ function attach(el) {
559
+ scrollEl = el;
560
+ scrollContainerRef = el;
561
+ el.addEventListener("scroll", onScroll);
562
+ queueMicrotask(onViewportChange);
563
+ }
564
+ function onScroll() {
565
+ maybeLoadNextPage();
566
+ }
567
+ function onViewportChange() {
568
+ maybeLoadNextPage();
569
+ }
570
+ window.addEventListener("scroll", onViewportChange, true);
571
+ window.addEventListener("resize", onViewportChange);
572
+ // SVAR renders .wx-scroll asynchronously; watch for it with MutationObserver
573
+ const candidate = containerRef.querySelector(".wx-scroll");
574
+ if (candidate) {
575
+ attach(candidate);
576
+ } else {
577
+ const obs = new MutationObserver(() => {
578
+ const el = containerRef?.querySelector(".wx-scroll");
579
+ if (el) {
580
+ attach(el);
581
+ obs.disconnect();
582
+ }
583
+ });
584
+ obs.observe(containerRef, {
585
+ childList: true,
586
+ subtree: true
587
+ });
588
+ return () => {
589
+ obs.disconnect();
590
+ scrollEl?.removeEventListener("scroll", onScroll);
591
+ window.removeEventListener("scroll", onViewportChange, true);
592
+ window.removeEventListener("resize", onViewportChange);
593
+ scrollContainerRef = null;
510
594
  };
511
595
  }
512
-
513
- const contextMenuItems = $derived.by(() => {
514
- if (!contextMenuOpen) return [] as SvarkContextMenuItem[];
515
- if (!contextMenuProps) return [] as SvarkContextMenuItem[];
516
- const ctx = buildContextMenuCtx();
517
- if (!ctx) return [] as SvarkContextMenuItem[];
518
- const resolved =
519
- typeof contextMenuProps.items === 'function'
520
- ? contextMenuProps.items(ctx)
521
- : contextMenuProps.items ?? [];
522
- return (resolved ?? []).map((it): SvarkContextMenuItem => ({
523
- ...it,
524
- kind: it.kind ?? 'item'
525
- }));
526
- });
527
-
528
- function closeContextMenu() {
529
- contextMenuOpen = false;
530
- contextMenuEvent = null;
531
- contextMenuRowId = null;
532
- contextMenuRow = null;
533
- }
534
-
535
- function handleContextMenuItem(item: SvarkContextMenuItem | { id: '__close__' }) {
536
- if (item.id === '__close__') {
537
- closeContextMenu();
538
- return;
539
- }
540
- if (!('label' in item)) {
541
- closeContextMenu();
542
- return;
543
- }
544
- const ctx = buildContextMenuCtx();
545
- closeContextMenu();
546
- if (!ctx) return;
547
- item.onselect?.(ctx);
548
- contextMenuProps?.onselectitem?.(item, ctx);
549
- }
550
-
551
- $effect(() => {
552
- if (!containerRef) return;
553
- const node = containerRef;
554
-
555
- function handleClick(ev: MouseEvent) {
556
- const resolved = resolveRowFromTarget(ev.target);
557
- if (!resolved) return;
558
- onrowclick?.({ ...resolved, event: ev });
559
- }
560
-
561
- function handleContextMenu(ev: MouseEvent) {
562
- // Only handle right-clicks on the grid surface itself, not on Svark's toolbar/pager.
563
- if (
564
- !containerRef ||
565
- !(ev.target instanceof Element) ||
566
- !ev.target.closest('.wx-grid') ||
567
- !containerRef.contains(ev.target.closest('.wx-grid'))
568
- ) {
596
+ return () => {
597
+ scrollEl?.removeEventListener("scroll", onScroll);
598
+ window.removeEventListener("scroll", onViewportChange, true);
599
+ window.removeEventListener("resize", onViewportChange);
600
+ scrollContainerRef = null;
601
+ };
602
+ });
603
+ async function loadNextPage() {
604
+ // Block if main fetch is running (sort/filter change in progress)
605
+ if (loading || infiniteLoading || data.length >= total) return;
606
+ // Snapshot debounced sort+filter so we can discard the response if they
607
+ // change while the request is in-flight (race condition guard).
608
+ const snapshotSort = serverSide ? JSON.stringify(debouncedSort) : "";
609
+ const snapshotFilter = serverSide ? JSON.stringify(debouncedFilter) : "";
610
+ const snapshotOffset = data.length;
611
+ infiniteLoading = true;
612
+ try {
613
+ const response = await adapter.read(serverSide ? debouncedSort : [], serverSide ? debouncedFilter : {}, pageSize, snapshotOffset, query);
614
+ // Discard if sort/filter changed while we were fetching
615
+ if (serverSide) {
616
+ if (JSON.stringify(debouncedSort) !== snapshotSort || JSON.stringify(debouncedFilter) !== snapshotFilter) {
569
617
  return;
570
618
  }
571
-
572
- const resolved = resolveRowFromTarget(ev.target);
573
- if (resolved) {
574
- onrowcontextmenu?.({ ...resolved, event: ev });
575
- }
576
-
577
- if (!contextMenuProps) return;
578
- // Always prevent the browser context menu when a custom menu is configured.
579
- ev.preventDefault();
580
- ev.stopPropagation();
581
-
582
- // Right-clicking a row should behave like "select + open menu".
583
- if (resolved?.id != null && gridApi && select !== false) {
584
- gridApi.exec('select-row', { id: resolved.id, toggle: false, range: false });
585
- selectedIdsInternal = [resolved.id];
586
- }
587
-
588
- contextMenuEvent = ev;
589
- contextMenuX = ev.clientX;
590
- contextMenuY = ev.clientY;
591
-
592
- // Prefer the right-clicked row; otherwise fall back to current selection.
593
- const ctxRowId = (resolved?.id ?? selectedIdsInternal[0] ?? null) as string | number | null;
594
- const ctxRow =
595
- resolved?.row ??
596
- (ctxRowId != null
597
- ? data.find((item) => String(item?.id) === String(ctxRowId)) ?? null
598
- : selectedRow) ??
599
- null;
600
-
601
- contextMenuRowId = ctxRowId;
602
- contextMenuRow = ctxRow;
603
- contextMenuOpen = true;
604
619
  }
605
-
606
- function handleDoubleClick(ev: MouseEvent) {
607
- const resolved = resolveRowFromTarget(ev.target);
608
- if (!resolved) return;
609
- onrowdblclick?.({ ...resolved, event: ev });
620
+ if (response.success) {
621
+ data = [...data, ...response.data ?? []];
610
622
  }
611
-
612
- node.addEventListener('click', handleClick);
613
- node.addEventListener('dblclick', handleDoubleClick);
614
- node.addEventListener('contextmenu', handleContextMenu);
615
-
616
- return () => {
617
- node.removeEventListener('click', handleClick);
618
- node.removeEventListener('dblclick', handleDoubleClick);
619
- node.removeEventListener('contextmenu', handleContextMenu);
620
- };
623
+ } catch {} finally {
624
+ infiniteLoading = false;
625
+ }
626
+ }
627
+ // ── SVAR Grid init ─────────────────────────────────────────────────────────
628
+ function handleInit(api) {
629
+ gridApi = api;
630
+ // Row selection callback
631
+ selectedIdsInternal = api.getState().selectedRows ?? [];
632
+ api.on("select-row", () => {
633
+ const ids = api.getState().selectedRows ?? [];
634
+ selectedIdsInternal = ids;
635
+ onselect?.(ids);
621
636
  });
622
-
623
- const adapter = $derived(
624
- resolvedAdapterType === 'RestHeaderSpec'
625
- ? new SvarkRestHeaderSpecAdapter({ url: resolvedUrl, token, schema, entity })
626
- : new SvarkResolveSpecAdapter({ url: resolvedUrl, token, schema, entity })
627
- );
628
-
629
- // For virtual scroll: set dynamic rowCount once total is known
630
- const gridDynamic = $derived(
631
- virtualScroll && total > 0 ? { rowCount: total } : undefined
632
- );
633
-
634
- // ── Main data effect (initial load + sort/filter/page/reload triggers) ──────
635
-
636
- $effect(() => {
637
- const _adapter = adapter;
638
- const _limit = virtualScroll ? 1 : pageSize;
639
- const _sort = serverSide ? debouncedSort : [];
640
- const _filters = serverSide ? debouncedFilter : {};
641
- // Paging offset: only applied when server-side paging is active
642
- const _offset = showPager ? page * pageSize : 0;
643
- void reload;
644
- let cancelled = false;
645
-
646
- loading = true;
647
- error = null;
648
-
649
- _adapter
650
- .read(_sort, _filters, _limit, _offset, query)
651
- .then((response) => {
652
- if (cancelled) return;
637
+ // Virtual scroll: SVAR fires request-data when the visible row window changes.
638
+ // We fetch only that window from the API and replace data.
639
+ if (virtualScroll) {
640
+ api.on("request-data", async (ev) => {
641
+ const { start, end } = ev.row;
642
+ const limit = end - start;
643
+ if (limit <= 0) return;
644
+ loading = true;
645
+ try {
646
+ const response = await adapter.read(serverSide ? debouncedSort : [], serverSide ? debouncedFilter : {}, limit, start, query);
653
647
  if (response.success) {
654
- const incoming = response.data ?? [];
655
- total = (response as any).metadata?.total ?? incoming.length;
656
-
657
- if (virtualScroll) {
658
- data = [];
659
- } else {
660
- data = incoming;
661
- }
662
- } else {
663
- const msg = (response as any).error?.message ?? 'Failed to load data';
664
- error = msg;
665
- onerror?.(msg);
648
+ data = response.data ?? [];
666
649
  }
667
- })
668
- .catch((e) => {
669
- if (cancelled) return;
670
- const msg = e instanceof Error ? e.message : 'Unknown error';
650
+ } catch (e) {
651
+ const msg = e instanceof Error ? e.message : "Failed to load data";
671
652
  error = msg;
672
653
  onerror?.(msg);
673
- })
674
- .finally(() => {
675
- if (!cancelled) loading = false;
676
- });
677
-
678
- return () => {
679
- cancelled = true;
680
- };
681
- });
682
-
683
- // ── Infinite scroll: append pages when the user nears the bottom ───────────
684
-
685
- function maybeLoadNextPage() {
686
- if (!infiniteScroll || !containerRef || loading || infiniteLoading || data.length >= total) return;
687
-
688
- const scrollEl = scrollContainerRef;
689
- if (scrollEl && scrollEl.scrollHeight > scrollEl.clientHeight + 1) {
690
- const { scrollTop, scrollHeight, clientHeight } = scrollEl;
691
- if (scrollHeight - scrollTop - clientHeight < 150) {
692
- loadNextPage();
654
+ } finally {
655
+ loading = false;
693
656
  }
694
- return;
695
- }
696
-
697
- const { bottom } = containerRef.getBoundingClientRect();
698
- if (bottom - window.innerHeight < 150) {
699
- loadNextPage();
700
- }
701
- }
702
-
703
- $effect(() => {
704
- if (!infiniteScroll || loading || infiniteLoading) return;
705
- void data.length;
706
- void total;
707
-
708
- queueMicrotask(() => {
709
- maybeLoadNextPage();
710
657
  });
711
- });
712
-
713
- $effect(() => {
714
- if (!infiniteScroll || !containerRef) return;
715
-
716
- let scrollEl: Element | null = null;
717
-
718
- function attach(el: Element) {
719
- scrollEl = el;
720
- scrollContainerRef = el as HTMLElement;
721
- el.addEventListener('scroll', onScroll);
722
- queueMicrotask(onViewportChange);
723
- }
724
-
725
- function onScroll() {
726
- maybeLoadNextPage();
727
- }
728
-
729
- function onViewportChange() {
730
- maybeLoadNextPage();
731
- }
732
-
733
- window.addEventListener('scroll', onViewportChange, true);
734
- window.addEventListener('resize', onViewportChange);
735
-
736
- // SVAR renders .wx-scroll asynchronously; watch for it with MutationObserver
737
- const candidate = containerRef.querySelector('.wx-scroll');
738
- if (candidate) {
739
- attach(candidate);
740
- } else {
741
- const obs = new MutationObserver(() => {
742
- const el = containerRef?.querySelector('.wx-scroll');
743
- if (el) {
744
- attach(el);
745
- obs.disconnect();
746
- }
747
- });
748
- obs.observe(containerRef, { childList: true, subtree: true });
749
- return () => {
750
- obs.disconnect();
751
- scrollEl?.removeEventListener('scroll', onScroll);
752
- window.removeEventListener('scroll', onViewportChange, true);
753
- window.removeEventListener('resize', onViewportChange);
754
- scrollContainerRef = null;
755
- };
756
- }
757
-
758
- return () => {
759
- scrollEl?.removeEventListener('scroll', onScroll);
760
- window.removeEventListener('scroll', onViewportChange, true);
761
- window.removeEventListener('resize', onViewportChange);
762
- scrollContainerRef = null;
763
- };
764
- });
765
-
766
- async function loadNextPage() {
767
- // Block if main fetch is running (sort/filter change in progress)
768
- if (loading || infiniteLoading || data.length >= total) return;
769
-
770
- // Snapshot debounced sort+filter so we can discard the response if they
771
- // change while the request is in-flight (race condition guard).
772
- const snapshotSort = serverSide ? JSON.stringify(debouncedSort) : '';
773
- const snapshotFilter = serverSide ? JSON.stringify(debouncedFilter) : '';
774
- const snapshotOffset = data.length;
775
-
776
- infiniteLoading = true;
777
- try {
778
- const response = await adapter.read(
779
- serverSide ? debouncedSort : [],
780
- serverSide ? debouncedFilter : {},
781
- pageSize,
782
- snapshotOffset,
783
- query
784
- );
785
- // Discard if sort/filter changed while we were fetching
786
- if (serverSide) {
787
- if (
788
- JSON.stringify(debouncedSort) !== snapshotSort ||
789
- JSON.stringify(debouncedFilter) !== snapshotFilter
790
- ) {
791
- return;
792
- }
793
- }
794
- if (response.success) {
795
- data = [...data, ...(response.data ?? [])];
796
- }
797
- } catch {
798
- // Silent — don't replace existing rows with an error on pagination
799
- } finally {
800
- infiniteLoading = false;
801
- }
802
658
  }
803
-
804
- // ── SVAR Grid init ─────────────────────────────────────────────────────────
805
-
806
- function handleInit(api: IApi) {
807
- gridApi = api;
808
-
809
- // Row selection callback
810
- selectedIdsInternal = ((api.getState() as any).selectedRows ?? []) as (string | number)[];
811
- api.on('select-row', () => {
812
- const ids = ((api.getState() as any).selectedRows ?? []) as (string | number)[];
813
- selectedIdsInternal = ids;
814
- onselect?.(ids);
815
- });
816
-
817
- // Virtual scroll: SVAR fires request-data when the visible row window changes.
818
- // We fetch only that window from the API and replace data.
819
- if (virtualScroll) {
820
- api.on('request-data', async (ev: any) => {
821
- const { start, end } = ev.row as { start: number; end: number };
822
- const limit = end - start;
823
- if (limit <= 0) return;
824
-
825
- loading = true;
826
- try {
827
- const response = await adapter.read(
828
- serverSide ? debouncedSort : [],
829
- serverSide ? debouncedFilter : {},
830
- limit,
831
- start,
832
- query
833
- );
834
- if (response.success) {
835
- data = response.data ?? [];
836
- }
837
- } catch (e) {
838
- const msg = e instanceof Error ? e.message : 'Failed to load data';
839
- error = msg;
840
- onerror?.(msg);
841
- } finally {
842
- loading = false;
843
- }
844
- });
845
- }
846
-
847
- if (serverSide) {
848
- api.intercept('sort-rows', (ev: any) => {
849
- if (ev.key) {
850
- if (ev.add) {
851
- const idx = sortState.findIndex((s) => s.key === ev.key);
852
- if (idx >= 0) {
853
- sortState[idx] = { key: ev.key, order: ev.order ?? 'asc' };
854
- } else {
855
- sortState = [...sortState, { key: ev.key, order: ev.order ?? 'asc' }];
856
- }
659
+ if (serverSide) {
660
+ api.intercept("sort-rows", (ev) => {
661
+ if (ev.key) {
662
+ if (ev.add) {
663
+ const idx = sortState.findIndex((s) => s.key === ev.key);
664
+ if (idx >= 0) {
665
+ sortState[idx] = {
666
+ key: ev.key,
667
+ order: ev.order ?? "asc"
668
+ };
857
669
  } else {
858
- sortState = [{ key: ev.key, order: ev.order ?? 'asc' }];
670
+ sortState = [...sortState, {
671
+ key: ev.key,
672
+ order: ev.order ?? "asc"
673
+ }];
859
674
  }
860
675
  } else {
861
- sortState = [];
676
+ sortState = [{
677
+ key: ev.key,
678
+ order: ev.order ?? "asc"
679
+ }];
862
680
  }
863
- return false;
864
- });
865
-
866
- api.intercept('filter-rows', (ev: any) => {
867
- filterState = reduceServerFilterState(filterState, ev ?? {});
868
- return false;
869
- });
870
- }
871
-
872
- api.on('add-row', async (ev: any) => {
873
- try {
874
- await adapter.create(ev.row);
875
- onchange?.({ action: 'add', row: ev.row });
876
- reload++;
877
- } catch (e) {
878
- onerror?.(e instanceof Error ? e.message : 'Create failed');
879
- }
880
- });
881
-
882
- api.on('delete-row', async (ev: any) => {
883
- try {
884
- await adapter.delete(ev.id);
885
- onchange?.({ action: 'delete', id: ev.id });
886
- reload++;
887
- } catch (e) {
888
- onerror?.(e instanceof Error ? e.message : 'Delete failed');
681
+ } else {
682
+ sortState = [];
889
683
  }
684
+ return false;
890
685
  });
891
-
892
- api.on('update-cell', async (ev: any) => {
893
- try {
894
- await adapter.update(ev.id, { [String(ev.column)]: ev.value });
895
- onchange?.({ action: 'update', id: ev.id, column: ev.column, value: ev.value });
896
- } catch (e) {
897
- onerror?.(e instanceof Error ? e.message : 'Update failed');
898
- }
686
+ api.intercept("filter-rows", (ev) => {
687
+ filterState = reduceServerFilterState(filterState, ev ?? {});
688
+ return false;
899
689
  });
900
690
  }
691
+ api.on("add-row", async (ev) => {
692
+ try {
693
+ await adapter.create(ev.row);
694
+ onchange?.({
695
+ action: "add",
696
+ row: ev.row
697
+ });
698
+ reload++;
699
+ } catch (e) {
700
+ onerror?.(e instanceof Error ? e.message : "Create failed");
701
+ }
702
+ });
703
+ api.on("delete-row", async (ev) => {
704
+ try {
705
+ await adapter.delete(ev.id);
706
+ onchange?.({
707
+ action: "delete",
708
+ id: ev.id
709
+ });
710
+ reload++;
711
+ } catch (e) {
712
+ onerror?.(e instanceof Error ? e.message : "Delete failed");
713
+ }
714
+ });
715
+ api.on("update-cell", async (ev) => {
716
+ try {
717
+ await adapter.update(ev.id, { [String(ev.column)]: ev.value });
718
+ onchange?.({
719
+ action: "update",
720
+ id: ev.id,
721
+ column: ev.column,
722
+ value: ev.value
723
+ });
724
+ } catch (e) {
725
+ onerror?.(e instanceof Error ? e.message : "Update failed");
726
+ }
727
+ });
728
+ }
901
729
  </script>
902
730
 
903
731
  <div class="svark-container" class:svark-dark={isDark} style:height={heightStyle} bind:this={containerRef}>