@warkypublic/svelix 0.1.43 → 0.1.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/CardGrid/CardGrid.svelte +1312 -0
- package/dist/components/CardGrid/CardGrid.svelte.d.ts +61 -0
- package/dist/components/CardGrid/CardGridFilterPanel.svelte +299 -0
- package/dist/components/CardGrid/CardGridFilterPanel.svelte.d.ts +11 -0
- package/dist/components/CardGrid/DefaultCard.svelte +303 -0
- package/dist/components/CardGrid/DefaultCard.svelte.d.ts +10 -0
- package/dist/components/CardGrid/ImageCardStory.svelte +257 -0
- package/dist/components/CardGrid/ImageCardStory.svelte.d.ts +18 -0
- package/dist/components/CardGrid/cardGrid.d.ts +33 -0
- package/dist/components/CardGrid/cardGrid.js +21 -0
- package/dist/components/CardGrid/index.d.ts +4 -0
- package/dist/components/CardGrid/index.js +4 -0
- package/dist/components/Gridler/components/GridlerCanvas.svelte +1 -0
- package/dist/components/Gridler/components/GridlerFull.svelte +4 -1
- package/dist/components/Gridler/components/GridlerSearch.svelte +3 -3
- package/dist/components/Gridler/components/GridlerSearch.svelte.d.ts +3 -1
- package/dist/components/Gridler/components/GridlerSearchToggle.svelte +7 -0
- package/dist/components/Gridler/types.d.ts +2 -12
- package/dist/components/Gridler/utils/cellContent.js +2 -1
- package/dist/components/Gridler/utils/filters.js +2 -2
- package/dist/components/Gridler/utils/sort.js +2 -1
- package/dist/components/Types/generic_grid.d.ts +73 -5
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/llm/COMPONENT_GUIDE.md +240 -0
- package/llm/plans/card_grid.plan.md +305 -0
- package/package.json +19 -18
|
@@ -0,0 +1,1312 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
import { createVirtualizer, type SvelteVirtualizer } from '@tanstack/svelte-virtual';
|
|
5
|
+
import BetterMenu from '../BetterMenu/BetterMenu.svelte';
|
|
6
|
+
import type { BetterMenuInstanceItem } from '../BetterMenu/types.js';
|
|
7
|
+
import { GridlerResolveSpecAdapter } from '../Gridler/adapters/GridlerResolveSpecAdapter.js';
|
|
8
|
+
import { GridlerRestHeaderSpecAdapter } from '../Gridler/adapters/GridlerRestHeaderSpecAdapter.js';
|
|
9
|
+
import {
|
|
10
|
+
gridColumnFiltersToFilterOptions,
|
|
11
|
+
buildSearchFilters,
|
|
12
|
+
applyLocalFilter,
|
|
13
|
+
} from '../Gridler/utils/filters.js';
|
|
14
|
+
import { applyLocalSort, sortOrderToSortOptions } from '../Gridler/utils/sort.js';
|
|
15
|
+
import type { GridlerAdapterConfig, GridlerColumn } from '../Gridler/types.js';
|
|
16
|
+
import type { FilterOption } from '@warkypublic/resolvespec-js';
|
|
17
|
+
import CardGridFilterPanel from './CardGridFilterPanel.svelte';
|
|
18
|
+
import DefaultCard from './DefaultCard.svelte';
|
|
19
|
+
import {
|
|
20
|
+
toggleItemInSelection,
|
|
21
|
+
rangeSelect,
|
|
22
|
+
isItemSelected,
|
|
23
|
+
computeColumnsInRow,
|
|
24
|
+
} from './cardGrid.js';
|
|
25
|
+
import type {
|
|
26
|
+
CardGridColumn,
|
|
27
|
+
CardSortOption,
|
|
28
|
+
CardGridContextMenuItem,
|
|
29
|
+
} from './cardGrid.js';
|
|
30
|
+
import type { GridColumnSortOrder, GridColumnFilters } from '../Types/generic_grid.js';
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
// Column / display
|
|
34
|
+
columns: CardGridColumn[];
|
|
35
|
+
displayColumns?: string[];
|
|
36
|
+
cardMinWidth?: number;
|
|
37
|
+
cardGap?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Maximum number of columns shown on the front face.
|
|
40
|
+
* When `resolvedColumns.length` exceeds this, a flip button appears and the
|
|
41
|
+
* remaining columns are shown on the card's back face.
|
|
42
|
+
*/
|
|
43
|
+
cardMaxFrontColumns?: number;
|
|
44
|
+
|
|
45
|
+
// Custom rendering
|
|
46
|
+
/** Front-face snippet: (record, index, selected, focused) */
|
|
47
|
+
card?: Snippet<[Record<string, unknown>, number, boolean, boolean]>;
|
|
48
|
+
/** Back-face snippet for flip mode: (record, index, selected, focused) */
|
|
49
|
+
cardBack?: Snippet<[Record<string, unknown>, number, boolean, boolean]>;
|
|
50
|
+
empty?: Snippet;
|
|
51
|
+
skeleton?: Snippet;
|
|
52
|
+
|
|
53
|
+
// Data source
|
|
54
|
+
dataSource?: 'resolvespec' | 'headerspec' | 'local';
|
|
55
|
+
dataSourceOptions?: {
|
|
56
|
+
url?: string;
|
|
57
|
+
authToken?: string;
|
|
58
|
+
schema?: string;
|
|
59
|
+
entity?: string;
|
|
60
|
+
uniqueID?: string;
|
|
61
|
+
hotfields?: string[];
|
|
62
|
+
extraOptions?: Record<string, unknown>;
|
|
63
|
+
};
|
|
64
|
+
data?: Record<string, unknown>[] | (() => Promise<Record<string, unknown>[]>);
|
|
65
|
+
pageSize?: number;
|
|
66
|
+
uniqueID?: string;
|
|
67
|
+
|
|
68
|
+
// Search
|
|
69
|
+
searchValue?: string;
|
|
70
|
+
onSearchValueChange?: (v: string) => void;
|
|
71
|
+
serverSideSearch?: boolean;
|
|
72
|
+
searchColumns?: string[];
|
|
73
|
+
searchPlaceholder?: string;
|
|
74
|
+
|
|
75
|
+
// Sort
|
|
76
|
+
sortOrder?: GridColumnSortOrder;
|
|
77
|
+
onSortOrderChange?: (order: GridColumnSortOrder) => void;
|
|
78
|
+
sortOptions?: CardSortOption[];
|
|
79
|
+
|
|
80
|
+
// Filters
|
|
81
|
+
filters?: GridColumnFilters;
|
|
82
|
+
onFilterChange?: (filters: GridColumnFilters) => void;
|
|
83
|
+
|
|
84
|
+
// Selection
|
|
85
|
+
selectedItems?: Record<string, unknown>[];
|
|
86
|
+
onSelectedItemsChange?: (items: Record<string, unknown>[]) => void;
|
|
87
|
+
multiSelect?: boolean;
|
|
88
|
+
|
|
89
|
+
// Card events
|
|
90
|
+
onCardClick?: (record: Record<string, unknown>, index: number) => void;
|
|
91
|
+
onCardDblClick?: (record: Record<string, unknown>, index: number) => void;
|
|
92
|
+
onCardContextMenu?: (
|
|
93
|
+
record: Record<string, unknown>,
|
|
94
|
+
index: number,
|
|
95
|
+
x: number,
|
|
96
|
+
y: number,
|
|
97
|
+
) => void;
|
|
98
|
+
|
|
99
|
+
// Context menu
|
|
100
|
+
menuItems?: CardGridContextMenuItem[];
|
|
101
|
+
|
|
102
|
+
// Status (bindable)
|
|
103
|
+
total?: number;
|
|
104
|
+
loading?: boolean;
|
|
105
|
+
onLoadError?: (error: string) => void;
|
|
106
|
+
|
|
107
|
+
// Layout
|
|
108
|
+
class?: string;
|
|
109
|
+
toolbarEnd?: Snippet;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let {
|
|
113
|
+
columns,
|
|
114
|
+
displayColumns,
|
|
115
|
+
cardMinWidth = 280,
|
|
116
|
+
cardGap = 16,
|
|
117
|
+
cardMaxFrontColumns,
|
|
118
|
+
card,
|
|
119
|
+
cardBack,
|
|
120
|
+
empty,
|
|
121
|
+
skeleton,
|
|
122
|
+
dataSource = 'local',
|
|
123
|
+
dataSourceOptions,
|
|
124
|
+
data,
|
|
125
|
+
pageSize = 20,
|
|
126
|
+
uniqueID: uniqueIDProp,
|
|
127
|
+
searchValue,
|
|
128
|
+
onSearchValueChange,
|
|
129
|
+
serverSideSearch = true,
|
|
130
|
+
searchColumns,
|
|
131
|
+
searchPlaceholder = 'Search…',
|
|
132
|
+
sortOrder,
|
|
133
|
+
onSortOrderChange,
|
|
134
|
+
sortOptions,
|
|
135
|
+
filters,
|
|
136
|
+
onFilterChange,
|
|
137
|
+
selectedItems: selectedItemsProp,
|
|
138
|
+
onSelectedItemsChange,
|
|
139
|
+
multiSelect = true,
|
|
140
|
+
onCardClick,
|
|
141
|
+
onCardDblClick,
|
|
142
|
+
onCardContextMenu,
|
|
143
|
+
menuItems,
|
|
144
|
+
total = $bindable(0),
|
|
145
|
+
loading = $bindable(false),
|
|
146
|
+
onLoadError,
|
|
147
|
+
class: cls,
|
|
148
|
+
toolbarEnd,
|
|
149
|
+
}: Props = $props();
|
|
150
|
+
|
|
151
|
+
// ── Derived config ─────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
const resolvedUniqueID = $derived(uniqueIDProp ?? dataSourceOptions?.uniqueID ?? 'id');
|
|
154
|
+
|
|
155
|
+
const resolvedColumns = $derived(
|
|
156
|
+
displayColumns?.length ? columns.filter((c) => displayColumns!.includes(c.id)) : columns,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// Flip mode split
|
|
160
|
+
const frontColumns = $derived(
|
|
161
|
+
cardMaxFrontColumns != null ? resolvedColumns.slice(0, cardMaxFrontColumns) : resolvedColumns,
|
|
162
|
+
);
|
|
163
|
+
const backColumns = $derived(
|
|
164
|
+
cardMaxFrontColumns != null ? resolvedColumns.slice(cardMaxFrontColumns) : [],
|
|
165
|
+
);
|
|
166
|
+
const flipModeEnabled = $derived(backColumns.length > 0);
|
|
167
|
+
|
|
168
|
+
// How many detail rows the skeleton should mimic
|
|
169
|
+
const skeletonDetailRows = $derived(Math.max(0, frontColumns.length - 2));
|
|
170
|
+
|
|
171
|
+
// ── Controlled/uncontrolled search ─────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
let internalSearchValue = $state('');
|
|
174
|
+
const resolvedSearchValue = $derived(searchValue ?? internalSearchValue);
|
|
175
|
+
|
|
176
|
+
function emitSearchValue(v: string) {
|
|
177
|
+
if (searchValue === undefined) internalSearchValue = v;
|
|
178
|
+
onSearchValueChange?.(v);
|
|
179
|
+
}
|
|
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);
|
|
189
|
+
}
|
|
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);
|
|
199
|
+
}
|
|
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
|
+
}
|
|
235
|
+
});
|
|
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
|
+
);
|
|
257
|
+
});
|
|
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];
|
|
265
|
+
}
|
|
266
|
+
return val;
|
|
267
|
+
}
|
|
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);
|
|
298
|
+
});
|
|
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))];
|
|
305
|
+
});
|
|
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; };
|
|
349
|
+
});
|
|
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();
|
|
406
|
+
});
|
|
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();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// ── Selection ──────────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
function handleCardClick(e: MouseEvent, item: Record<string, unknown>, index: number) {
|
|
421
|
+
focusedIndex = index;
|
|
422
|
+
if (!multiSelect) {
|
|
423
|
+
setSelectedItems([item]);
|
|
424
|
+
anchorIndex = index;
|
|
425
|
+
onCardClick?.(item, index);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (e.shiftKey && anchorIndex >= 0) {
|
|
429
|
+
setSelectedItems(rangeSelect(displayData, anchorIndex, index));
|
|
430
|
+
} else if (e.ctrlKey || e.metaKey) {
|
|
431
|
+
setSelectedItems(toggleItemInSelection(resolvedSelectedItems, item, resolvedUniqueID));
|
|
432
|
+
anchorIndex = index;
|
|
433
|
+
} else {
|
|
434
|
+
setSelectedItems([item]);
|
|
435
|
+
anchorIndex = index;
|
|
436
|
+
}
|
|
437
|
+
onCardClick?.(item, index);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function handleCardContextMenu(e: MouseEvent, item: Record<string, unknown>, index: number) {
|
|
441
|
+
e.preventDefault();
|
|
442
|
+
onCardContextMenu?.(item, index, e.clientX, e.clientY);
|
|
443
|
+
menu?.show('cg-context-menu', {
|
|
444
|
+
x: e.clientX, y: e.clientY,
|
|
445
|
+
items: buildContextMenuItems(item),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function handleContainerClick(e: MouseEvent) {
|
|
450
|
+
if (e.target === e.currentTarget) {
|
|
451
|
+
setSelectedItems([]);
|
|
452
|
+
focusedIndex = -1;
|
|
453
|
+
}
|
|
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
|
+
}
|
|
467
|
+
}
|
|
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;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ── Filter panel ───────────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
const activeFilterCount = $derived(Object.keys(resolvedFilters).length);
|
|
483
|
+
|
|
484
|
+
function openFilterPanel() {
|
|
485
|
+
if (!filterBtnEl) return;
|
|
486
|
+
const rect = filterBtnEl.getBoundingClientRect();
|
|
487
|
+
menu?.show('cg-filter-panel', {
|
|
488
|
+
x: rect.left, y: rect.bottom + 4,
|
|
489
|
+
renderer: filterPanelSnippet as unknown as (props: Record<string, unknown>) => unknown,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── Sort pills ─────────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
function toggleSortOption(columnId: string) {
|
|
496
|
+
const current = resolvedSortOrder[columnId] ?? 'none';
|
|
497
|
+
const next = current === 'none' ? 'asc' : current === 'asc' ? 'desc' : ('none' as const);
|
|
498
|
+
const newOrder = { ...resolvedSortOrder };
|
|
499
|
+
if (next === 'none') delete newOrder[columnId]; else newOrder[columnId] = next;
|
|
500
|
+
handleSortOrderChange(newOrder);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── Keyboard navigation ────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
function moveFocus(next: number) {
|
|
506
|
+
focusedIndex = next;
|
|
507
|
+
scrollToCard(next);
|
|
508
|
+
if (!multiSelect) {
|
|
509
|
+
const item = displayData[next];
|
|
510
|
+
if (item) setSelectedItems([item]);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
515
|
+
const count = displayData.length;
|
|
516
|
+
if (count === 0) return;
|
|
517
|
+
switch (e.key) {
|
|
518
|
+
case 'ArrowLeft': e.preventDefault(); moveFocus(Math.max(0, focusedIndex - 1)); break;
|
|
519
|
+
case 'ArrowRight': e.preventDefault(); moveFocus(Math.min(count - 1, focusedIndex + 1)); break;
|
|
520
|
+
case 'ArrowUp': e.preventDefault(); moveFocus(Math.max(0, focusedIndex - columnsInRow)); break;
|
|
521
|
+
case 'ArrowDown': e.preventDefault(); moveFocus(Math.min(count - 1, focusedIndex + columnsInRow)); break;
|
|
522
|
+
case 'Tab':
|
|
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
|
+
}
|
|
548
|
+
}
|
|
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
|
+
}
|
|
558
|
+
}
|
|
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
|
+
}
|
|
572
|
+
}
|
|
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;
|
|
592
|
+
});
|
|
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
|
+
});
|
|
625
|
+
});
|
|
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
|
+
});
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
</script>
|
|
643
|
+
|
|
644
|
+
{#snippet filterPanelSnippet()}
|
|
645
|
+
<CardGridFilterPanel
|
|
646
|
+
columns={columns.filter((c) => !c.disableFilter)}
|
|
647
|
+
filters={resolvedFilters}
|
|
648
|
+
onApply={(next) => { handleFilterChange(next); menu?.hide('cg-filter-panel'); }}
|
|
649
|
+
onCancel={() => menu?.hide('cg-filter-panel')}
|
|
650
|
+
/>
|
|
651
|
+
{/snippet}
|
|
652
|
+
|
|
653
|
+
<BetterMenu bind:this={menu}>
|
|
654
|
+
<div class="cg-root {cls ?? ''}">
|
|
655
|
+
|
|
656
|
+
<!-- ── Toolbar ──────────────────────────────────────────────────────── -->
|
|
657
|
+
<div class="cg-toolbar">
|
|
658
|
+
<div class="cg-search-wrap">
|
|
659
|
+
<span class="cg-search-icon" aria-hidden="true">
|
|
660
|
+
<svg width="14" height="14" viewBox="0 0 20 20" fill="none">
|
|
661
|
+
<circle cx="9" cy="9" r="6.5" stroke="currentColor" stroke-width="1.8"/>
|
|
662
|
+
<path d="M14 14l3.5 3.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
663
|
+
</svg>
|
|
664
|
+
</span>
|
|
665
|
+
<input
|
|
666
|
+
bind:this={searchInputEl}
|
|
667
|
+
class="cg-search"
|
|
668
|
+
type="search"
|
|
669
|
+
placeholder={searchPlaceholder}
|
|
670
|
+
value={resolvedSearchValue}
|
|
671
|
+
oninput={(e) => emitSearchValue((e.target as HTMLInputElement).value)}
|
|
672
|
+
onkeydown={(e) => e.stopPropagation()}
|
|
673
|
+
aria-label="Search"
|
|
674
|
+
/>
|
|
675
|
+
</div>
|
|
676
|
+
|
|
677
|
+
{#if sortOptions?.length}
|
|
678
|
+
<div class="cg-sort-pills" role="group" aria-label="Sort options">
|
|
679
|
+
{#each sortOptions as opt (opt.columnId)}
|
|
680
|
+
{@const dir = resolvedSortOrder[opt.columnId] ?? 'none'}
|
|
681
|
+
<button
|
|
682
|
+
class="cg-sort-pill"
|
|
683
|
+
class:cg-sort-pill-active={dir !== 'none'}
|
|
684
|
+
onclick={() => toggleSortOption(opt.columnId)}
|
|
685
|
+
type="button"
|
|
686
|
+
aria-pressed={dir !== 'none'}
|
|
687
|
+
>
|
|
688
|
+
{opt.label}
|
|
689
|
+
{#if dir === 'asc'}
|
|
690
|
+
<svg class="cg-sort-arrow" width="10" height="10" viewBox="0 0 10 10"><path d="M5 8V2M2 5l3-3 3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
|
|
691
|
+
{:else if dir === 'desc'}
|
|
692
|
+
<svg class="cg-sort-arrow" width="10" height="10" viewBox="0 0 10 10"><path d="M5 2v6M2 5l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
|
|
693
|
+
{/if}
|
|
694
|
+
</button>
|
|
695
|
+
{/each}
|
|
696
|
+
</div>
|
|
697
|
+
{/if}
|
|
698
|
+
|
|
699
|
+
{#if columns.some((c) => !c.disableFilter)}
|
|
700
|
+
<button
|
|
701
|
+
bind:this={filterBtnEl}
|
|
702
|
+
class="cg-btn"
|
|
703
|
+
class:cg-btn-active={activeFilterCount > 0}
|
|
704
|
+
onclick={openFilterPanel}
|
|
705
|
+
type="button"
|
|
706
|
+
aria-label="Open filters"
|
|
707
|
+
>
|
|
708
|
+
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
709
|
+
<path d="M2 4h12M4 8h8M6 12h4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
710
|
+
</svg>
|
|
711
|
+
Filters
|
|
712
|
+
{#if activeFilterCount > 0}
|
|
713
|
+
<span class="cg-badge">{activeFilterCount}</span>
|
|
714
|
+
{/if}
|
|
715
|
+
</button>
|
|
716
|
+
{/if}
|
|
717
|
+
|
|
718
|
+
<button class="cg-btn cg-btn-icon" onclick={() => refresh()} type="button" aria-label="Refresh" title="Refresh">
|
|
719
|
+
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
720
|
+
<path d="M17 10a7 7 0 1 1-1.5-4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
721
|
+
<path d="M15 3v4h-4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
722
|
+
</svg>
|
|
723
|
+
</button>
|
|
724
|
+
|
|
725
|
+
{#if isServerMode && serverTotal > 0}
|
|
726
|
+
<span class="cg-count">{serverTotal.toLocaleString()} items</span>
|
|
727
|
+
{:else if !isServerMode && displayData.length > 0}
|
|
728
|
+
<span class="cg-count">{displayData.length.toLocaleString()} items</span>
|
|
729
|
+
{/if}
|
|
730
|
+
|
|
731
|
+
{#if toolbarEnd}
|
|
732
|
+
<div class="cg-toolbar-end">{@render toolbarEnd()}</div>
|
|
733
|
+
{/if}
|
|
734
|
+
</div>
|
|
735
|
+
|
|
736
|
+
<!-- ── Card grid ────────────────────────────────────────────────────── -->
|
|
737
|
+
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
738
|
+
<div
|
|
739
|
+
bind:this={containerEl}
|
|
740
|
+
class="cg-grid"
|
|
741
|
+
role="listbox"
|
|
742
|
+
aria-multiselectable={multiSelect}
|
|
743
|
+
tabindex="0"
|
|
744
|
+
onkeydown={handleKeyDown}
|
|
745
|
+
onclick={handleContainerClick}
|
|
746
|
+
style="--cg-min-width:{cardMinWidth}px;--cg-gap:{cardGap}px;--cg-cols:{columnsInRow};"
|
|
747
|
+
>
|
|
748
|
+
{#if isFirstLoad}
|
|
749
|
+
<!-- ── Skeleton cards ─────────────────────────────────── -->
|
|
750
|
+
{#if skeleton}
|
|
751
|
+
{@render skeleton()}
|
|
752
|
+
{:else}
|
|
753
|
+
<div class="cg-card-grid">
|
|
754
|
+
{#each { length: skeletonCount } as _, si (si)}
|
|
755
|
+
<div class="cg-card cg-card-skeleton" aria-hidden="true" style="--sk-delay:{si * 80}ms">
|
|
756
|
+
<div class="cg-sk-header">
|
|
757
|
+
<div class="cg-sk-line cg-sk-title"></div>
|
|
758
|
+
<div class="cg-sk-line cg-sk-subtitle"></div>
|
|
759
|
+
</div>
|
|
760
|
+
<div class="cg-sk-divider"></div>
|
|
761
|
+
<div class="cg-sk-body">
|
|
762
|
+
{#each { length: Math.max(2, skeletonDetailRows) } as _, ri (ri)}
|
|
763
|
+
<div class="cg-sk-row" style="--row-i:{ri}">
|
|
764
|
+
<div class="cg-sk-line cg-sk-label"></div>
|
|
765
|
+
<div class="cg-sk-line cg-sk-value"></div>
|
|
766
|
+
</div>
|
|
767
|
+
{/each}
|
|
768
|
+
{#if flipModeEnabled}
|
|
769
|
+
<div class="cg-sk-flip-badge"></div>
|
|
770
|
+
{/if}
|
|
771
|
+
</div>
|
|
772
|
+
</div>
|
|
773
|
+
{/each}
|
|
774
|
+
</div>
|
|
775
|
+
{/if}
|
|
776
|
+
|
|
777
|
+
{:else if isEmpty}
|
|
778
|
+
<!-- ── Empty state ────────────────────────────────────── -->
|
|
779
|
+
{#if empty}
|
|
780
|
+
<div class="cg-empty-wrap">{@render empty()}</div>
|
|
781
|
+
{:else}
|
|
782
|
+
<div class="cg-empty-wrap">
|
|
783
|
+
<div class="cg-empty-icon" aria-hidden="true">
|
|
784
|
+
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
|
|
785
|
+
<rect x="6" y="10" width="28" height="22" rx="3" stroke="currentColor" stroke-width="1.6"/>
|
|
786
|
+
<path d="M13 18h14M13 23h9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
|
787
|
+
<circle cx="30" cy="10" r="5" fill="currentColor" opacity=".15"/>
|
|
788
|
+
<path d="M28 10h4M30 8v4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
|
789
|
+
</svg>
|
|
790
|
+
</div>
|
|
791
|
+
<p class="cg-empty-title">No records found</p>
|
|
792
|
+
{#if resolvedSearchValue || activeFilterCount > 0}
|
|
793
|
+
<p class="cg-empty-hint">
|
|
794
|
+
Try
|
|
795
|
+
{#if resolvedSearchValue}clearing the search{/if}
|
|
796
|
+
{#if resolvedSearchValue && activeFilterCount > 0} or {/if}
|
|
797
|
+
{#if activeFilterCount > 0}removing filters{/if}.
|
|
798
|
+
</p>
|
|
799
|
+
{/if}
|
|
800
|
+
</div>
|
|
801
|
+
{/if}
|
|
802
|
+
|
|
803
|
+
{:else}
|
|
804
|
+
<!-- ── Virtualised rows ───────────────────────────────── -->
|
|
805
|
+
<div style="position:relative; width:100%; height:{totalSize}px;">
|
|
806
|
+
<div style="position:absolute; top:0; left:0; width:100%; transform:translateY({virtualItems[0]?.start ?? 0}px);">
|
|
807
|
+
{#each virtualItems as vRow (vRow.key)}
|
|
808
|
+
<div class="cg-virtual-row" data-index={vRow.index}>
|
|
809
|
+
{#each virtualRows[vRow.index] ?? [] as item, colIdx (item[resolvedUniqueID] ?? (vRow.index * columnsInRow + colIdx))}
|
|
810
|
+
{@const i = vRow.index * columnsInRow + colIdx}
|
|
811
|
+
{@const selected = isItemSelected(resolvedSelectedItems, item, resolvedUniqueID)}
|
|
812
|
+
{@const focused = focusedIndex === i}
|
|
813
|
+
{@const cardId = item[resolvedUniqueID] ?? i}
|
|
814
|
+
{@const flipped = flippedCards.has(cardId)}
|
|
815
|
+
|
|
816
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
817
|
+
<div
|
|
818
|
+
class="cg-card"
|
|
819
|
+
class:cg-card-selected={selected}
|
|
820
|
+
class:cg-card-focused={focused}
|
|
821
|
+
role="option"
|
|
822
|
+
aria-selected={selected}
|
|
823
|
+
tabindex="-1"
|
|
824
|
+
data-card-index={i}
|
|
825
|
+
onclick={(e) => handleCardClick(e, item, i)}
|
|
826
|
+
ondblclick={() => onCardDblClick?.(item, i)}
|
|
827
|
+
oncontextmenu={(e) => handleCardContextMenu(e, item, i)}
|
|
828
|
+
>
|
|
829
|
+
{#if flipModeEnabled}
|
|
830
|
+
<div class="cg-flip-scene">
|
|
831
|
+
<div class="cg-flip-inner" class:cg-flipped={flipped}>
|
|
832
|
+
<!-- Front -->
|
|
833
|
+
<div class="cg-flip-face cg-flip-front">
|
|
834
|
+
{#if card}
|
|
835
|
+
{@render card(item, i, selected, focused)}
|
|
836
|
+
{:else}
|
|
837
|
+
<DefaultCard record={item} columns={frontColumns} {selected} {focused} />
|
|
838
|
+
{/if}
|
|
839
|
+
<button
|
|
840
|
+
class="cg-flip-trigger"
|
|
841
|
+
onclick={(e) => toggleFlip(e, item)}
|
|
842
|
+
type="button"
|
|
843
|
+
aria-label="Show {backColumns.length} more field{backColumns.length === 1 ? '' : 's'}"
|
|
844
|
+
>+{backColumns.length}</button>
|
|
845
|
+
</div>
|
|
846
|
+
<!-- Back -->
|
|
847
|
+
<div class="cg-flip-face cg-flip-back">
|
|
848
|
+
<button
|
|
849
|
+
class="cg-flip-back-btn"
|
|
850
|
+
onclick={(e) => toggleFlip(e, item)}
|
|
851
|
+
type="button"
|
|
852
|
+
aria-label="Flip back"
|
|
853
|
+
>
|
|
854
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
855
|
+
<path d="M8 2L4 6l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
856
|
+
</svg>
|
|
857
|
+
Back
|
|
858
|
+
</button>
|
|
859
|
+
{#if cardBack}
|
|
860
|
+
{@render cardBack(item, i, selected, focused)}
|
|
861
|
+
{:else}
|
|
862
|
+
<DefaultCard record={item} columns={backColumns} {selected} {focused} />
|
|
863
|
+
{/if}
|
|
864
|
+
</div>
|
|
865
|
+
</div>
|
|
866
|
+
</div>
|
|
867
|
+
{:else}
|
|
868
|
+
{#if card}
|
|
869
|
+
{@render card(item, i, selected, focused)}
|
|
870
|
+
{:else}
|
|
871
|
+
<DefaultCard record={item} columns={resolvedColumns} {selected} {focused} />
|
|
872
|
+
{/if}
|
|
873
|
+
{/if}
|
|
874
|
+
</div>
|
|
875
|
+
{/each}
|
|
876
|
+
</div>
|
|
877
|
+
{/each}
|
|
878
|
+
</div>
|
|
879
|
+
</div>
|
|
880
|
+
{/if}
|
|
881
|
+
|
|
882
|
+
{#if isServerMode}
|
|
883
|
+
<div bind:this={sentinelEl} class="cg-sentinel" aria-hidden="true"></div>
|
|
884
|
+
{/if}
|
|
885
|
+
</div>
|
|
886
|
+
|
|
887
|
+
<!-- ── Loading more ─────────────────────────────────────────────────── -->
|
|
888
|
+
{#if isLoadingMore}
|
|
889
|
+
<div class="cg-loading-more" aria-live="polite" aria-busy="true">
|
|
890
|
+
<span class="cg-spinner" aria-hidden="true"></span>
|
|
891
|
+
Loading more…
|
|
892
|
+
</div>
|
|
893
|
+
{/if}
|
|
894
|
+
</div>
|
|
895
|
+
</BetterMenu>
|
|
896
|
+
|
|
897
|
+
<style>
|
|
898
|
+
/* ── Token layer — light defaults, dark overrides ────────────────────── */
|
|
899
|
+
|
|
900
|
+
.cg-root {
|
|
901
|
+
--cg-bg: var(--color-surface-100, #f1f5f9);
|
|
902
|
+
--cg-surface: var(--color-surface-0, white);
|
|
903
|
+
--cg-surface-raised: var(--color-surface-0, white);
|
|
904
|
+
--cg-border: var(--color-surface-200, #e2e8f0);
|
|
905
|
+
--cg-border-subtle: var(--color-surface-100, #f1f5f9);
|
|
906
|
+
--cg-text: var(--color-surface-900, #0f172a);
|
|
907
|
+
--cg-text-muted: var(--color-surface-500, #64748b);
|
|
908
|
+
--cg-accent: var(--color-primary-500, #6366f1);
|
|
909
|
+
--cg-accent-subtle: var(--color-primary-50, #eef2ff);
|
|
910
|
+
--cg-accent-ring: color-mix(in oklab, var(--color-primary-500, #6366f1) 25%, transparent);
|
|
911
|
+
--cg-focus-ring: var(--color-primary-400, #818cf8);
|
|
912
|
+
--cg-shadow-sm: 0 1px 2px rgba(0,0,0,.04), 0 1px 4px rgba(0,0,0,.06);
|
|
913
|
+
--cg-shadow-md: 0 4px 8px rgba(0,0,0,.07), 0 2px 4px rgba(0,0,0,.05);
|
|
914
|
+
--cg-shadow-selected: 0 0 0 3px var(--cg-accent-ring);
|
|
915
|
+
--cg-sk-base: var(--color-surface-100, #f1f5f9);
|
|
916
|
+
--cg-sk-shine: var(--color-surface-200, #e2e8f0);
|
|
917
|
+
|
|
918
|
+
display: flex;
|
|
919
|
+
flex-direction: column;
|
|
920
|
+
gap: 10px;
|
|
921
|
+
height: 100%;
|
|
922
|
+
min-height: 0;
|
|
923
|
+
color: var(--cg-text);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/* Dark mode — requires explicit .dark class on a parent element */
|
|
927
|
+
:global(.dark) .cg-root {
|
|
928
|
+
--cg-bg: var(--color-surface-950, #020617);
|
|
929
|
+
--cg-surface: var(--color-surface-900, #0f172a);
|
|
930
|
+
--cg-surface-raised: var(--color-surface-800, #1e293b);
|
|
931
|
+
--cg-border: var(--color-surface-700, #334155);
|
|
932
|
+
--cg-border-subtle: var(--color-surface-800, #1e293b);
|
|
933
|
+
--cg-text: var(--color-surface-50, #f8fafc);
|
|
934
|
+
--cg-text-muted: var(--color-surface-400, #94a3b8);
|
|
935
|
+
--cg-accent: var(--color-primary-400, #818cf8);
|
|
936
|
+
--cg-accent-subtle: color-mix(in oklab, var(--color-primary-500, #6366f1) 15%, transparent);
|
|
937
|
+
--cg-shadow-sm: 0 1px 2px rgba(0,0,0,.3), 0 1px 3px rgba(0,0,0,.25);
|
|
938
|
+
--cg-shadow-md: 0 4px 6px rgba(0,0,0,.4), 0 2px 4px rgba(0,0,0,.3);
|
|
939
|
+
--cg-sk-base: var(--color-surface-800, #1e293b);
|
|
940
|
+
--cg-sk-shine: var(--color-surface-700, #334155);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/* ── Toolbar ──────────────────────────────────────────────────────────── */
|
|
944
|
+
|
|
945
|
+
.cg-toolbar {
|
|
946
|
+
display: flex;
|
|
947
|
+
align-items: center;
|
|
948
|
+
gap: 6px;
|
|
949
|
+
flex-wrap: wrap;
|
|
950
|
+
padding: 2px 0;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/* Search */
|
|
954
|
+
.cg-search-wrap {
|
|
955
|
+
position: relative;
|
|
956
|
+
flex: 1;
|
|
957
|
+
min-width: 160px;
|
|
958
|
+
max-width: 300px;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
.cg-search-icon {
|
|
962
|
+
position: absolute;
|
|
963
|
+
left: 10px;
|
|
964
|
+
top: 50%;
|
|
965
|
+
transform: translateY(-50%);
|
|
966
|
+
color: var(--cg-text-muted);
|
|
967
|
+
pointer-events: none;
|
|
968
|
+
display: flex;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
.cg-search {
|
|
972
|
+
width: 100%;
|
|
973
|
+
padding: 6px 10px 6px 32px;
|
|
974
|
+
border: 1px solid var(--cg-border);
|
|
975
|
+
border-radius: 7px;
|
|
976
|
+
font-size: 0.8125rem;
|
|
977
|
+
background: var(--cg-surface-raised);
|
|
978
|
+
color: var(--cg-text);
|
|
979
|
+
transition: border-color 120ms, box-shadow 120ms;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
.cg-search::placeholder { color: var(--cg-text-muted); }
|
|
983
|
+
.cg-search:focus {
|
|
984
|
+
outline: none;
|
|
985
|
+
border-color: var(--cg-accent);
|
|
986
|
+
box-shadow: 0 0 0 3px var(--cg-accent-ring);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/* Sort pills */
|
|
990
|
+
.cg-sort-pills { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
991
|
+
|
|
992
|
+
.cg-sort-pill {
|
|
993
|
+
display: inline-flex;
|
|
994
|
+
align-items: center;
|
|
995
|
+
gap: 4px;
|
|
996
|
+
padding: 5px 10px;
|
|
997
|
+
border: 1px solid var(--cg-border);
|
|
998
|
+
border-radius: 999px;
|
|
999
|
+
font-size: 0.78rem;
|
|
1000
|
+
font-weight: 500;
|
|
1001
|
+
cursor: pointer;
|
|
1002
|
+
background: var(--cg-surface-raised);
|
|
1003
|
+
color: var(--cg-text-muted);
|
|
1004
|
+
transition: all 120ms;
|
|
1005
|
+
white-space: nowrap;
|
|
1006
|
+
}
|
|
1007
|
+
.cg-sort-pill:hover { border-color: var(--cg-accent); color: var(--cg-accent); }
|
|
1008
|
+
.cg-sort-pill-active {
|
|
1009
|
+
background: var(--cg-accent-subtle);
|
|
1010
|
+
border-color: var(--cg-accent);
|
|
1011
|
+
color: var(--cg-accent);
|
|
1012
|
+
}
|
|
1013
|
+
.cg-sort-arrow { flex-shrink: 0; }
|
|
1014
|
+
|
|
1015
|
+
/* Generic toolbar button */
|
|
1016
|
+
.cg-btn {
|
|
1017
|
+
display: inline-flex;
|
|
1018
|
+
align-items: center;
|
|
1019
|
+
gap: 5px;
|
|
1020
|
+
padding: 5px 10px;
|
|
1021
|
+
border: 1px solid var(--cg-border);
|
|
1022
|
+
border-radius: 7px;
|
|
1023
|
+
font-size: 0.78rem;
|
|
1024
|
+
font-weight: 500;
|
|
1025
|
+
cursor: pointer;
|
|
1026
|
+
background: var(--cg-surface-raised);
|
|
1027
|
+
color: var(--cg-text-muted);
|
|
1028
|
+
white-space: nowrap;
|
|
1029
|
+
transition: all 120ms;
|
|
1030
|
+
}
|
|
1031
|
+
.cg-btn:hover { border-color: var(--cg-accent); color: var(--cg-accent); }
|
|
1032
|
+
.cg-btn-active {
|
|
1033
|
+
background: var(--cg-accent-subtle);
|
|
1034
|
+
border-color: var(--cg-accent);
|
|
1035
|
+
color: var(--cg-accent);
|
|
1036
|
+
}
|
|
1037
|
+
.cg-btn-icon { padding: 5px 8px; }
|
|
1038
|
+
|
|
1039
|
+
.cg-badge {
|
|
1040
|
+
display: inline-flex;
|
|
1041
|
+
align-items: center;
|
|
1042
|
+
justify-content: center;
|
|
1043
|
+
min-width: 16px;
|
|
1044
|
+
height: 16px;
|
|
1045
|
+
padding: 0 4px;
|
|
1046
|
+
border-radius: 999px;
|
|
1047
|
+
font-size: 0.65rem;
|
|
1048
|
+
font-weight: 700;
|
|
1049
|
+
background: var(--cg-accent);
|
|
1050
|
+
color: #fff;
|
|
1051
|
+
line-height: 1;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
.cg-count {
|
|
1055
|
+
font-size: 0.78rem;
|
|
1056
|
+
color: var(--cg-text-muted);
|
|
1057
|
+
white-space: nowrap;
|
|
1058
|
+
margin-left: 2px;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
.cg-toolbar-end {
|
|
1062
|
+
margin-left: auto;
|
|
1063
|
+
display: flex;
|
|
1064
|
+
align-items: center;
|
|
1065
|
+
gap: 6px;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/* ── Card grid ────────────────────────────────────────────────────────── */
|
|
1069
|
+
|
|
1070
|
+
.cg-grid {
|
|
1071
|
+
overflow-y: auto;
|
|
1072
|
+
flex: 1;
|
|
1073
|
+
min-height: 0;
|
|
1074
|
+
outline: none;
|
|
1075
|
+
padding: 4px 4px 16px;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/* Shared grid column layout (skeleton + virtual rows) */
|
|
1079
|
+
.cg-card-grid {
|
|
1080
|
+
display: grid;
|
|
1081
|
+
grid-template-columns: repeat(auto-fill, minmax(var(--cg-min-width, 280px), 1fr));
|
|
1082
|
+
gap: var(--cg-gap, 16px);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
.cg-virtual-row {
|
|
1086
|
+
display: grid;
|
|
1087
|
+
grid-template-columns: repeat(var(--cg-cols, 1), 1fr);
|
|
1088
|
+
column-gap: var(--cg-gap, 16px);
|
|
1089
|
+
padding-bottom: var(--cg-gap, 16px);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
.cg-card {
|
|
1093
|
+
border: 1.5px solid var(--cg-border);
|
|
1094
|
+
border-radius: 10px;
|
|
1095
|
+
background: var(--cg-surface-raised);
|
|
1096
|
+
box-shadow: var(--cg-shadow-sm);
|
|
1097
|
+
cursor: pointer;
|
|
1098
|
+
transition:
|
|
1099
|
+
transform 150ms ease,
|
|
1100
|
+
box-shadow 150ms ease,
|
|
1101
|
+
border-color 150ms ease,
|
|
1102
|
+
background 150ms ease;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
.cg-card:hover {
|
|
1106
|
+
box-shadow: var(--cg-shadow-md);
|
|
1107
|
+
border-color: color-mix(in oklab, var(--cg-border) 60%, var(--cg-accent) 40%);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
.cg-card-selected {
|
|
1111
|
+
border-color: var(--cg-accent);
|
|
1112
|
+
box-shadow: var(--cg-shadow-selected), var(--cg-shadow-sm);
|
|
1113
|
+
transform: translateY(-1px) scale(1.015);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
.cg-card-focused {
|
|
1117
|
+
outline: 2px solid var(--cg-focus-ring);
|
|
1118
|
+
outline-offset: 2px;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/* ── Skeleton cards ───────────────────────────────────────────────────── */
|
|
1122
|
+
|
|
1123
|
+
@keyframes cg-shimmer {
|
|
1124
|
+
0% { background-position: 200% center; }
|
|
1125
|
+
100% { background-position: -200% center; }
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
.cg-card-skeleton {
|
|
1129
|
+
cursor: default;
|
|
1130
|
+
pointer-events: none;
|
|
1131
|
+
border-color: var(--cg-border-subtle);
|
|
1132
|
+
box-shadow: none;
|
|
1133
|
+
animation: none;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
.cg-sk-header {
|
|
1137
|
+
padding: 14px 16px 10px;
|
|
1138
|
+
display: flex;
|
|
1139
|
+
flex-direction: column;
|
|
1140
|
+
gap: 6px;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
.cg-sk-divider {
|
|
1144
|
+
height: 1px;
|
|
1145
|
+
background: var(--cg-border-subtle);
|
|
1146
|
+
margin: 0 16px;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
.cg-sk-body {
|
|
1150
|
+
padding: 10px 16px 14px;
|
|
1151
|
+
display: flex;
|
|
1152
|
+
flex-direction: column;
|
|
1153
|
+
gap: 8px;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
.cg-sk-row {
|
|
1157
|
+
display: grid;
|
|
1158
|
+
grid-template-columns: 38% 1fr;
|
|
1159
|
+
gap: 8px;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/* All shimmer lines share the gradient animation */
|
|
1163
|
+
.cg-sk-line {
|
|
1164
|
+
border-radius: 4px;
|
|
1165
|
+
background: linear-gradient(
|
|
1166
|
+
90deg,
|
|
1167
|
+
var(--cg-sk-base) 25%,
|
|
1168
|
+
var(--cg-sk-shine) 50%,
|
|
1169
|
+
var(--cg-sk-base) 75%
|
|
1170
|
+
);
|
|
1171
|
+
background-size: 200% 100%;
|
|
1172
|
+
animation: cg-shimmer 1.6s ease-in-out infinite;
|
|
1173
|
+
animation-delay: var(--sk-delay, 0ms);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
.cg-sk-title { height: 14px; width: 65%; }
|
|
1177
|
+
.cg-sk-subtitle { height: 11px; width: 45%; animation-delay: calc(var(--sk-delay, 0ms) + 60ms); }
|
|
1178
|
+
.cg-sk-label { height: 10px; width: 70%; animation-delay: calc(var(--sk-delay, 0ms) + calc(var(--row-i, 0) * 40ms + 80ms)); }
|
|
1179
|
+
.cg-sk-value { height: 10px; width: 80%; animation-delay: calc(var(--sk-delay, 0ms) + calc(var(--row-i, 0) * 40ms + 100ms)); }
|
|
1180
|
+
|
|
1181
|
+
.cg-sk-flip-badge {
|
|
1182
|
+
align-self: flex-end;
|
|
1183
|
+
height: 18px;
|
|
1184
|
+
width: 36px;
|
|
1185
|
+
border-radius: 999px;
|
|
1186
|
+
background: linear-gradient(90deg, var(--cg-sk-base) 25%, var(--cg-sk-shine) 50%, var(--cg-sk-base) 75%);
|
|
1187
|
+
background-size: 200% 100%;
|
|
1188
|
+
animation: cg-shimmer 1.6s ease-in-out infinite;
|
|
1189
|
+
animation-delay: calc(var(--sk-delay, 0ms) + 200ms);
|
|
1190
|
+
margin-top: 2px;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/* ── Flip ─────────────────────────────────────────────────────────────── */
|
|
1194
|
+
|
|
1195
|
+
.cg-flip-scene {
|
|
1196
|
+
border-radius: 8px;
|
|
1197
|
+
perspective: 1000px;
|
|
1198
|
+
overflow: hidden;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
.cg-flip-inner {
|
|
1202
|
+
display: grid;
|
|
1203
|
+
transform-style: preserve-3d;
|
|
1204
|
+
transition: transform 450ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
.cg-flip-inner.cg-flipped { transform: rotateY(180deg); }
|
|
1208
|
+
|
|
1209
|
+
.cg-flip-face {
|
|
1210
|
+
grid-area: 1 / 1;
|
|
1211
|
+
backface-visibility: hidden;
|
|
1212
|
+
-webkit-backface-visibility: hidden;
|
|
1213
|
+
display: flex;
|
|
1214
|
+
flex-direction: column;
|
|
1215
|
+
position: relative;
|
|
1216
|
+
padding-bottom: 36px;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
.cg-flip-back {
|
|
1220
|
+
transform: rotateY(180deg);
|
|
1221
|
+
background: var(--cg-surface-raised);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
.cg-flip-trigger,
|
|
1225
|
+
.cg-flip-back-btn {
|
|
1226
|
+
position: absolute;
|
|
1227
|
+
bottom: 10px;
|
|
1228
|
+
right: 12px;
|
|
1229
|
+
display: inline-flex;
|
|
1230
|
+
align-items: center;
|
|
1231
|
+
gap: 3px;
|
|
1232
|
+
padding: 2px 9px;
|
|
1233
|
+
border-radius: 999px;
|
|
1234
|
+
font-size: 0.7rem;
|
|
1235
|
+
font-weight: 700;
|
|
1236
|
+
cursor: pointer;
|
|
1237
|
+
line-height: 1.5;
|
|
1238
|
+
transition: background 120ms, color 120ms;
|
|
1239
|
+
z-index: 1;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
.cg-flip-trigger {
|
|
1243
|
+
border: 1.5px solid var(--cg-accent);
|
|
1244
|
+
background: var(--cg-accent-subtle);
|
|
1245
|
+
color: var(--cg-accent);
|
|
1246
|
+
}
|
|
1247
|
+
.cg-flip-trigger:hover {
|
|
1248
|
+
background: color-mix(in oklab, var(--cg-accent) 20%, transparent);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
.cg-flip-back-btn {
|
|
1252
|
+
border: 1px solid var(--cg-border);
|
|
1253
|
+
background: none;
|
|
1254
|
+
color: var(--cg-text-muted);
|
|
1255
|
+
}
|
|
1256
|
+
.cg-flip-back-btn:hover { background: var(--cg-border-subtle); color: var(--cg-text); }
|
|
1257
|
+
|
|
1258
|
+
/* ── Empty state ─────────────────────────────────────────────────────── */
|
|
1259
|
+
|
|
1260
|
+
.cg-empty-wrap {
|
|
1261
|
+
grid-column: 1 / -1;
|
|
1262
|
+
display: flex;
|
|
1263
|
+
flex-direction: column;
|
|
1264
|
+
align-items: center;
|
|
1265
|
+
justify-content: center;
|
|
1266
|
+
padding: 56px 16px;
|
|
1267
|
+
gap: 8px;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
.cg-empty-icon { color: var(--cg-border); }
|
|
1271
|
+
.cg-empty-title {
|
|
1272
|
+
font-size: 0.9375rem;
|
|
1273
|
+
font-weight: 600;
|
|
1274
|
+
color: var(--cg-text-muted);
|
|
1275
|
+
margin: 0;
|
|
1276
|
+
}
|
|
1277
|
+
.cg-empty-hint {
|
|
1278
|
+
font-size: 0.8125rem;
|
|
1279
|
+
color: var(--cg-text-muted);
|
|
1280
|
+
margin: 0;
|
|
1281
|
+
opacity: 0.8;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/* ── Sentinel / loading more ─────────────────────────────────────────── */
|
|
1285
|
+
|
|
1286
|
+
.cg-sentinel {
|
|
1287
|
+
grid-column: 1 / -1;
|
|
1288
|
+
height: 1px;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
.cg-loading-more {
|
|
1292
|
+
display: flex;
|
|
1293
|
+
align-items: center;
|
|
1294
|
+
justify-content: center;
|
|
1295
|
+
gap: 8px;
|
|
1296
|
+
padding: 10px;
|
|
1297
|
+
font-size: 0.8125rem;
|
|
1298
|
+
color: var(--cg-text-muted);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
@keyframes cg-spin { to { transform: rotate(360deg); } }
|
|
1302
|
+
|
|
1303
|
+
.cg-spinner {
|
|
1304
|
+
width: 14px;
|
|
1305
|
+
height: 14px;
|
|
1306
|
+
border: 2px solid var(--cg-border);
|
|
1307
|
+
border-top-color: var(--cg-accent);
|
|
1308
|
+
border-radius: 50%;
|
|
1309
|
+
animation: cg-spin 0.7s linear infinite;
|
|
1310
|
+
flex-shrink: 0;
|
|
1311
|
+
}
|
|
1312
|
+
</style>
|