runeforge 0.0.53 → 0.0.55

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 (78) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1342 -1222
  3. package/dist/components/Avatar.svelte +31 -31
  4. package/dist/components/IconRenderer.svelte +22 -22
  5. package/dist/components/Modal.svelte +75 -75
  6. package/dist/components/common/Header.svelte +40 -40
  7. package/dist/components/crud/EmbeddedField.svelte +175 -175
  8. package/dist/components/crud/Field.svelte +376 -376
  9. package/dist/components/crud/GenericCRUD.svelte +426 -435
  10. package/dist/components/crud/GenericCRUD.svelte.d.ts +6 -9
  11. package/dist/components/crud/SearchInput.svelte +53 -53
  12. package/dist/components/crud/columns/Avatar.svelte +15 -15
  13. package/dist/components/crud/columns/Icon.svelte +8 -8
  14. package/dist/components/crud/utils/reorder.d.ts +4 -0
  15. package/dist/components/crud/utils/reorder.js +13 -0
  16. package/dist/components/crud/views/Create.svelte +232 -232
  17. package/dist/components/crud/views/Read.svelte +124 -124
  18. package/dist/components/crud/views/Update.svelte +208 -208
  19. package/dist/components/crud/views/list/List.svelte +291 -0
  20. package/dist/components/crud/views/{List.svelte.d.ts → list/List.svelte.d.ts} +4 -14
  21. package/dist/components/crud/views/list/Modals.svelte +56 -0
  22. package/dist/components/crud/views/list/Modals.svelte.d.ts +36 -0
  23. package/dist/components/crud/views/list/Table.svelte +176 -0
  24. package/dist/components/crud/views/list/Table.svelte.d.ts +59 -0
  25. package/dist/components/crud/views/list/Toolbar.svelte +341 -0
  26. package/dist/components/crud/views/list/Toolbar.svelte.d.ts +46 -0
  27. package/dist/components/form/Button.svelte +27 -27
  28. package/dist/components/form/Label.svelte +37 -37
  29. package/dist/components/form/MultiSelect.svelte +248 -248
  30. package/dist/components/form/PasswordInput.svelte +68 -68
  31. package/dist/components/form/Required.svelte +1 -1
  32. package/dist/components/form/Select.svelte +209 -209
  33. package/dist/components/form/Tree.svelte +62 -62
  34. package/dist/components/form/TreeNode.svelte +66 -66
  35. package/dist/components/navigation/Breadcrumbs.svelte +111 -111
  36. package/dist/components/table/ColumnFilter.svelte +168 -168
  37. package/dist/components/table/PaginatedTable.svelte +536 -215
  38. package/dist/components/table/PaginatedTable.svelte.d.ts +11 -2
  39. package/dist/components/table/Paginator.svelte +113 -113
  40. package/dist/components/table/SortHeader.svelte +43 -43
  41. package/dist/components/table/TableBody.svelte +150 -70
  42. package/dist/components/table/TableBody.svelte.d.ts +14 -1
  43. package/dist/components/table/TableHeader.svelte +88 -83
  44. package/dist/components/table/TableHeader.svelte.d.ts +1 -0
  45. package/dist/components/table/sortable.d.ts +27 -0
  46. package/dist/components/table/sortable.js +55 -0
  47. package/dist/components/table/utils.d.ts +28 -1
  48. package/dist/components/table/utils.js +75 -0
  49. package/dist/i18n/en.js +2 -0
  50. package/dist/i18n/es.js +2 -0
  51. package/dist/i18n/types.d.ts +2 -0
  52. package/dist/icons/defaults/Clear.svelte +6 -6
  53. package/dist/icons/defaults/Create.svelte +6 -6
  54. package/dist/icons/defaults/Delete.svelte +6 -6
  55. package/dist/icons/defaults/Download.svelte +7 -7
  56. package/dist/icons/defaults/Edit.svelte +7 -7
  57. package/dist/icons/defaults/Filter.svelte +6 -6
  58. package/dist/icons/defaults/FilterActive.svelte +6 -6
  59. package/dist/icons/defaults/Folder.svelte +6 -6
  60. package/dist/icons/defaults/Grip.svelte +7 -0
  61. package/dist/icons/defaults/Grip.svelte.d.ts +7 -0
  62. package/dist/icons/defaults/Home.svelte +6 -6
  63. package/dist/icons/defaults/PasswordHide.svelte +9 -9
  64. package/dist/icons/defaults/PasswordShow.svelte +7 -7
  65. package/dist/icons/defaults/SortAsc.svelte +6 -6
  66. package/dist/icons/defaults/SortDesc.svelte +6 -6
  67. package/dist/icons/defaults/SortNone.svelte +6 -6
  68. package/dist/icons/defaults/View.svelte +7 -7
  69. package/dist/icons/sets/bootstrap.js +2 -1
  70. package/dist/icons/sets/default.js +2 -0
  71. package/dist/icons/types.d.ts +3 -0
  72. package/dist/index.d.ts +5 -4
  73. package/dist/index.js +3 -2
  74. package/dist/types/attribute.d.ts +9 -0
  75. package/dist/types/crud.d.ts +72 -1
  76. package/dist/types/table.d.ts +56 -0
  77. package/package.json +3 -1
  78. package/dist/components/crud/views/List.svelte +0 -584
@@ -1,435 +1,426 @@
1
- <script lang="ts" generics="T extends object = Record<string, unknown>">
2
- import { page } from '$app/state';
3
- import { goto } from '$app/navigation';
4
- import List from './views/List.svelte';
5
- import Read from './views/Read.svelte';
6
- import Create from './views/Create.svelte';
7
- import Update from './views/Update.svelte';
8
- import { AUTO_EXCLUDED } from './utils/constants.js';
9
- import {
10
- resolveFormatter,
11
- truncateFormatter,
12
- inferType,
13
- buildFieldDefinitions
14
- } from './utils/resolution.js';
15
- import { defaultItemLabel } from './utils/embedded.js';
16
- import { isFilterable } from '../table/utils.js';
17
- import type { XlsxModule } from '../table/export.js';
18
- import type { AttributeMetadata } from '../../types/attribute.js';
19
- import type {
20
- ActionConfiguration,
21
- ColumnDefinition,
22
- CustomAction,
23
- CustomBulkAction,
24
- FieldDefinition,
25
- SearchConfiguration,
26
- ViewBasedCustomBulkAction
27
- } from '../../types/crud.js';
28
- import type {
29
- FilterSnapshot,
30
- PaginatedEnvelope,
31
- ServerPagination,
32
- SortDirection,
33
- TableQuery
34
- } from '../../types/table.js';
35
-
36
- let {
37
- data = undefined as Record<string, unknown> | undefined,
38
- dataKey = undefined as string | undefined,
39
- idKey = '_id',
40
- labelOne = '',
41
- labelMany = '',
42
- icon,
43
- pageSize = 10,
44
- creation = {} as ActionConfiguration<T>,
45
- update = {} as ActionConfiguration<T>,
46
- read = {} as ActionConfiguration<T>,
47
- deletion = {} as ActionConfiguration<T>,
48
- actions = [] as CustomAction<T>[],
49
- customBulkActions = [] as CustomBulkAction<T>[],
50
- search = undefined as SearchConfiguration | undefined,
51
- columns = undefined as ColumnDefinition<T>[] | undefined,
52
- fields = undefined as FieldDefinition<T>[] | undefined,
53
- meta = undefined as Partial<Record<string, AttributeMetadata>> | undefined,
54
- form = null as { error?: string } | null,
55
- enableExport = false,
56
- onExport = undefined as ((query: TableQuery) => Promise<T[]>) | undefined,
57
- xlsx = undefined as XlsxModule | undefined
58
- }: {
59
- data?: Record<string, unknown>;
60
- dataKey?: string;
61
- idKey?: string;
62
- labelOne?: string;
63
- labelMany?: string;
64
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
- icon?: any;
66
- pageSize?: number;
67
- creation?: ActionConfiguration<T>;
68
- update?: ActionConfiguration<T>;
69
- read?: ActionConfiguration<T>;
70
- deletion?: ActionConfiguration<T>;
71
- actions?: CustomAction<T>[];
72
- customBulkActions?: CustomBulkAction<T>[];
73
- search?: SearchConfiguration;
74
- columns?: ColumnDefinition<T>[];
75
- fields?: FieldDefinition<T>[];
76
- meta?: Partial<Record<string, AttributeMetadata>>;
77
- form?: { error?: string } | null;
78
- enableExport?: boolean;
79
- onExport?: (query: TableQuery) => Promise<T[]>;
80
- xlsx?: XlsxModule;
81
- } = $props();
82
-
83
- function isEnvelope(value: unknown): value is PaginatedEnvelope<T> {
84
- return (
85
- !!value &&
86
- typeof value === 'object' &&
87
- !Array.isArray(value) &&
88
- Array.isArray((value as Record<string, unknown>).results) &&
89
- typeof (value as Record<string, unknown>).count === 'number'
90
- );
91
- }
92
-
93
- const rawSlice = $derived(data && dataKey ? data[dataKey] : undefined);
94
- const envelope = $derived(isEnvelope(rawSlice) ? rawSlice : undefined);
95
- const entityData = $derived<T[]>(
96
- envelope ? envelope.results : Array.isArray(rawSlice) ? (rawSlice as T[]) : []
97
- );
98
-
99
- const serverPagination = $derived<ServerPagination | undefined>(
100
- envelope
101
- ? {
102
- page: envelope.page,
103
- pageSize: envelope.pageSize,
104
- totalPages: Math.max(1, Math.ceil(envelope.count / envelope.pageSize)),
105
- total: envelope.count
106
- }
107
- : undefined
108
- );
109
-
110
- const viewParam = $derived(page.url.searchParams.get('view'));
111
- const idParam = $derived(page.url.searchParams.get('id'));
112
-
113
- const creating = $derived(viewParam === 'create');
114
- const reading = $derived(idParam !== null && viewParam === null);
115
- const editing = $derived(idParam !== null && viewParam === 'edit');
116
-
117
- const excluded = $derived(new Set([...AUTO_EXCLUDED, idKey]));
118
-
119
- const singleInstance = $derived<T | undefined>(
120
- Object.values(page.data as Record<string, unknown>).find(
121
- (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && idKey in (v as object)
122
- ) as T | undefined
123
- );
124
-
125
- let activeAction = $state<{ action: CustomAction<T>; item: T } | null>(null);
126
- let activeBulkAction = $state<{ action: ViewBasedCustomBulkAction<T>; items: T[] } | null>(
127
- null
128
- );
129
-
130
- async function runAction(action: CustomAction<T>, item: T) {
131
- if (!(action.condition?.(item) ?? true)) return;
132
- if (action.href) {
133
- await goto(action.href(item));
134
- return;
135
- }
136
- activeAction = { action, item };
137
- }
138
-
139
- function runBulkAction(action: ViewBasedCustomBulkAction<T>, items: T[]) {
140
- activeBulkAction = { action, items };
141
- }
142
-
143
- let duplicateSeed = $state<Record<string, unknown> | undefined>(undefined);
144
-
145
- async function navList() {
146
- duplicateSeed = undefined;
147
- await goto(lastListState.search || '?');
148
- }
149
- async function navCreate() {
150
- duplicateSeed = undefined;
151
- await goto('?view=create');
152
- }
153
- async function navDuplicate(record: Record<string, unknown>) {
154
- duplicateSeed = record;
155
- await goto('?view=create');
156
- }
157
- async function navRead(item: T) {
158
- await goto(`?id=${encodeURIComponent(String((item as Record<string, unknown>)[idKey] ?? ''))}`);
159
- }
160
- async function navEdit(item: T) {
161
- await goto(
162
- `?id=${encodeURIComponent(String((item as Record<string, unknown>)[idKey] ?? ''))}&view=edit`
163
- );
164
- }
165
-
166
- let lastListState = $state<{ data: T[]; search: string }>({ data: [], search: '' });
167
- $effect(() => {
168
- if (!creating && !reading && !editing) {
169
- lastListState = { data: entityData, search: page.url.search };
170
- }
171
- });
172
-
173
- // "Save and continue" on the Update form: jumps to editing the record that
174
- // follows the current one in the last loaded list, falling back to
175
- // re-editing the same instance when it's the last one (or isn't found).
176
- function nextInstance(current: T): T {
177
- const currentId = (current as Record<string, unknown>)[idKey];
178
- const idx = lastListState.data.findIndex(
179
- (item) => (item as Record<string, unknown>)[idKey] === currentId
180
- );
181
- return (idx !== -1 ? lastListState.data[idx + 1] : undefined) ?? current;
182
- }
183
- async function navContinueEdit() {
184
- if (!singleInstance) return;
185
- await navEdit(nextInstance(singleInstance));
186
- }
187
-
188
- const resolvedColumns: ColumnDefinition<T>[] = $derived(
189
- columns ??
190
- (meta
191
- ? (Object.entries(meta) as [string, AttributeMetadata][])
192
- .filter(([, m]) => !m.excludedFromList)
193
- .map(([k, m]) => {
194
- const embeddedFields =
195
- m.type === 'embedded' && m.fields
196
- ? buildFieldDefinitions(m.fields, data, 'excludedFromList', new Set())
197
- : undefined;
198
- // Arrays of objects have no sensible raw cell value, so an
199
- // embedded column without an explicit formatter falls back to
200
- // joining each item's label (itemLabel, or the same
201
- // sub-field-joining summary the embedded form list uses).
202
- const resolvedFormatter =
203
- resolveFormatter(m, data) ??
204
- (embeddedFields
205
- ? (value: unknown) =>
206
- Array.isArray(value)
207
- ? value
208
- .map((item) =>
209
- m.itemLabel
210
- ? m.itemLabel(item as Record<string, unknown>)
211
- : defaultItemLabel(embeddedFields, item as Record<string, unknown>)
212
- )
213
- .join(', ')
214
- : ''
215
- : undefined);
216
- const formatter =
217
- m.truncateUpTo != null
218
- ? truncateFormatter(resolvedFormatter, m.truncateUpTo)
219
- : resolvedFormatter;
220
- return {
221
- attribute: k as keyof T & string,
222
- title: m.label ?? k,
223
- type: m.type,
224
- formatter,
225
- component: m.component,
226
- sortable: m.sortable,
227
- filterable: m.filterable,
228
- fields: embeddedFields,
229
- itemLabel: m.itemLabel
230
- };
231
- })
232
- : entityData.length > 0
233
- ? (Object.keys(entityData[0]) as (keyof T & string)[])
234
- .filter((k) => !excluded.has(k))
235
- .map((k) => ({ attribute: k, title: k }))
236
- : [])
237
- );
238
-
239
- // Server-pagination mode only: GenericCRUD owns `page`/`ordering`/per-column
240
- // filter query params the same way it already owns `view`/`id`, so pagination
241
- // state survives reloads/direct links and `+page.svelte` never has to change.
242
- const orderingParam = $derived(page.url.searchParams.get('ordering'));
243
- const initialSort = $derived(
244
- orderingParam
245
- ? {
246
- column: orderingParam.startsWith('-') ? orderingParam.slice(1) : orderingParam,
247
- direction: (orderingParam.startsWith('-') ? 'desc' : 'asc') as SortDirection
248
- }
249
- : undefined
250
- );
251
-
252
- const initialFilters = $derived.by<Partial<FilterSnapshot> | undefined>(() => {
253
- if (!envelope) return undefined;
254
- const text: Record<string, string> = {};
255
- const values: Record<string, string[]> = {};
256
- const dateRanges: Record<string, { from: string; to: string }> = {};
257
- for (const col of resolvedColumns) {
258
- if (!isFilterable(col)) continue;
259
- const raw = page.url.searchParams.get(col.attribute);
260
- if (raw != null) {
261
- if (col.type === 'boolean') values[col.attribute] = raw.split(',').filter(Boolean);
262
- else text[col.attribute] = raw;
263
- }
264
- const from = page.url.searchParams.get(`${col.attribute}_from`);
265
- const to = page.url.searchParams.get(`${col.attribute}_to`);
266
- if (from || to) dateRanges[col.attribute] = { from: from ?? '', to: to ?? '' };
267
- }
268
- return { text, values, dateRanges };
269
- });
270
-
271
- async function handlePaginationChange(query: TableQuery) {
272
- // Local, synchronous query-string builder consumed immediately by goto();
273
- // not rendered/reactive state, so plain URLSearchParams is correct here.
274
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
275
- const params = new URLSearchParams(page.url.searchParams);
276
- params.delete('view');
277
- params.delete('id');
278
-
279
- if (query.page > 1) params.set('page', String(query.page));
280
- else params.delete('page');
281
-
282
- if (query.ordering) params.set('ordering', query.ordering);
283
- else params.delete('ordering');
284
-
285
- for (const col of resolvedColumns) {
286
- params.delete(col.attribute);
287
- params.delete(`${col.attribute}_from`);
288
- params.delete(`${col.attribute}_to`);
289
- }
290
- for (const [k, v] of Object.entries(query.filters.text)) {
291
- if (v) params.set(k, v);
292
- }
293
- for (const [k, vs] of Object.entries(query.filters.values)) {
294
- if (vs.length) params.set(k, vs.join(','));
295
- }
296
- for (const [k, r] of Object.entries(query.filters.dateRanges)) {
297
- if (r.from) params.set(`${k}_from`, r.from);
298
- if (r.to) params.set(`${k}_to`, r.to);
299
- }
300
-
301
- const qs = params.toString();
302
- await goto(qs ? `?${qs}` : '?', { keepFocus: true, noScroll: true });
303
- }
304
-
305
- const resolvedFields: FieldDefinition<T>[] = $derived(
306
- fields ??
307
- (meta
308
- ? buildFieldDefinitions<T>(meta, data, 'excludedFromCreate', excluded)
309
- : entityData.length > 0
310
- ? (Object.entries(entityData[0]) as [string, unknown][])
311
- .filter(([k]) => !excluded.has(k))
312
- .map(([k, v]) => ({ attribute: k as keyof T & string, type: inferType(k, v) }))
313
- : [])
314
- );
315
-
316
- const resolvedReadFields: FieldDefinition<T>[] = $derived(
317
- fields ??
318
- (meta
319
- ? buildFieldDefinitions<T>(meta, data, 'excludedFromRead', excluded)
320
- : entityData.length > 0
321
- ? (Object.entries(entityData[0]) as [string, unknown][])
322
- .filter(([k]) => !excluded.has(k))
323
- .map(([k, v]) => ({ attribute: k as keyof T & string, type: inferType(k, v) }))
324
- : [])
325
- );
326
-
327
- const resolvedUpdateFields: FieldDefinition<T>[] = $derived(
328
- fields ??
329
- (meta
330
- ? buildFieldDefinitions<T>(meta, data, 'excludedFromUpdate', excluded)
331
- : entityData.length > 0
332
- ? (Object.entries(entityData[0]) as [string, unknown][])
333
- .filter(([k]) => !excluded.has(k))
334
- .map(([k, v]) => ({ attribute: k as keyof T & string, type: inferType(k, v) }))
335
- : [])
336
- );
337
-
338
- const serverError = $derived(
339
- creating || reading || editing || activeAction !== null || activeBulkAction !== null
340
- ? (form?.error ?? '')
341
- : ''
342
- );
343
- </script>
344
-
345
- {#if creating}
346
- <Create
347
- {labelOne}
348
- {labelMany}
349
- {icon}
350
- fields={resolvedFields}
351
- {creation}
352
- {serverError}
353
- seed={duplicateSeed}
354
- onCancel={navList}
355
- onSuccess={navList}
356
- />
357
- {:else if reading}
358
- <Read
359
- {labelOne}
360
- {labelMany}
361
- {icon}
362
- {idKey}
363
- fields={resolvedReadFields}
364
- instance={singleInstance ?? ({} as T)}
365
- {read}
366
- onCancel={navList}
367
- />
368
- {:else if editing}
369
- <Update
370
- {labelOne}
371
- {labelMany}
372
- {icon}
373
- {idKey}
374
- fields={resolvedUpdateFields}
375
- instance={singleInstance ?? ({} as T)}
376
- {update}
377
- {serverError}
378
- onCancel={navList}
379
- onSuccess={navList}
380
- onContinue={navContinueEdit}
381
- onDuplicate={navDuplicate}
382
- />
383
- {:else}
384
- <List
385
- data={entityData}
386
- {labelOne}
387
- {labelMany}
388
- {icon}
389
- {pageSize}
390
- {idKey}
391
- {creation}
392
- {update}
393
- {read}
394
- {deletion}
395
- {actions}
396
- {customBulkActions}
397
- {search}
398
- columns={resolvedColumns}
399
- pagination={serverPagination}
400
- {initialSort}
401
- {initialFilters}
402
- onPaginationChange={handlePaginationChange}
403
- {enableExport}
404
- {onExport}
405
- {xlsx}
406
- onCreate={navCreate}
407
- onEdit={navEdit}
408
- onView={navRead}
409
- onAction={runAction}
410
- onBulkAction={runBulkAction}
411
- />
412
- {/if}
413
-
414
- {#if activeAction !== null}
415
- {@const ActionView = activeAction.action.view}
416
- <ActionView
417
- instance={activeAction.item}
418
- label={activeAction.action.label}
419
- endpoint={activeAction.action.endpoint}
420
- {serverError}
421
- onCancel={() => (activeAction = null)}
422
- onSuccess={() => (activeAction = null)}
423
- />
424
- {/if}
425
-
426
- {#if activeBulkAction !== null}
427
- {@const BulkActionView = activeBulkAction.action.view}
428
- <BulkActionView
429
- items={activeBulkAction.items}
430
- label={activeBulkAction.action.label}
431
- {serverError}
432
- onCancel={() => (activeBulkAction = null)}
433
- onSuccess={() => (activeBulkAction = null)}
434
- />
435
- {/if}
1
+ <script lang="ts" generics="T extends object = Record<string, unknown>">
2
+ import { page } from '$app/state';
3
+ import { goto } from '$app/navigation';
4
+ import List from './views/list/List.svelte';
5
+ import Read from './views/Read.svelte';
6
+ import Create from './views/Create.svelte';
7
+ import Update from './views/Update.svelte';
8
+ import { AUTO_EXCLUDED } from './utils/constants.js';
9
+ import {
10
+ resolveFormatter,
11
+ truncateFormatter,
12
+ inferType,
13
+ buildFieldDefinitions
14
+ } from './utils/resolution.js';
15
+ import { defaultItemLabel } from './utils/embedded.js';
16
+ import { isFilterable } from '../table/utils.js';
17
+ import type { AttributeMetadata } from '../../types/attribute.js';
18
+ import type {
19
+ ActionConfiguration,
20
+ ColumnDefinition,
21
+ CustomAction,
22
+ FieldDefinition,
23
+ ListActions,
24
+ ListConfig,
25
+ ViewBasedCustomBulkAction
26
+ } from '../../types/crud.js';
27
+ import type {
28
+ FilterSnapshot,
29
+ PaginatedEnvelope,
30
+ ServerPagination,
31
+ SortDirection,
32
+ TableQuery
33
+ } from '../../types/table.js';
34
+
35
+ let {
36
+ data = undefined as Record<string, unknown> | undefined,
37
+ dataKey = undefined as string | undefined,
38
+ idKey = '_id',
39
+ labelOne = '',
40
+ labelMany = '',
41
+ icon,
42
+ pageSize = 10,
43
+ creation = {} as ActionConfiguration<T>,
44
+ update = {} as ActionConfiguration<T>,
45
+ read = {} as ActionConfiguration<T>,
46
+ deletion = {} as ActionConfiguration<T>,
47
+ actions = {} as ListActions<T>,
48
+ config = {} as ListConfig<T>,
49
+ columns = undefined as ColumnDefinition<T>[] | undefined,
50
+ fields = undefined as FieldDefinition<T>[] | undefined,
51
+ meta = undefined as Partial<Record<string, AttributeMetadata>> | undefined,
52
+ form = null as { error?: string } | null
53
+ }: {
54
+ data?: Record<string, unknown>;
55
+ dataKey?: string;
56
+ idKey?: string;
57
+ labelOne?: string;
58
+ labelMany?: string;
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ icon?: any;
61
+ pageSize?: number;
62
+ creation?: ActionConfiguration<T>;
63
+ update?: ActionConfiguration<T>;
64
+ read?: ActionConfiguration<T>;
65
+ deletion?: ActionConfiguration<T>;
66
+ /** Extra per-row (`custom`) and per-selection (`bulk`) actions. */
67
+ actions?: ListActions<T>;
68
+ /** Opt-in list behaviors: free-text search, CSV/Excel export, and
69
+ * drag-to-reorder. */
70
+ config?: ListConfig<T>;
71
+ columns?: ColumnDefinition<T>[];
72
+ fields?: FieldDefinition<T>[];
73
+ meta?: Partial<Record<string, AttributeMetadata>>;
74
+ form?: { error?: string } | null;
75
+ } = $props();
76
+
77
+ function isEnvelope(value: unknown): value is PaginatedEnvelope<T> {
78
+ return (
79
+ !!value &&
80
+ typeof value === 'object' &&
81
+ !Array.isArray(value) &&
82
+ Array.isArray((value as Record<string, unknown>).results) &&
83
+ typeof (value as Record<string, unknown>).count === 'number'
84
+ );
85
+ }
86
+
87
+ const rawSlice = $derived(data && dataKey ? data[dataKey] : undefined);
88
+ const envelope = $derived(isEnvelope(rawSlice) ? rawSlice : undefined);
89
+ const entityData = $derived<T[]>(
90
+ envelope ? envelope.results : Array.isArray(rawSlice) ? (rawSlice as T[]) : []
91
+ );
92
+
93
+ const serverPagination = $derived<ServerPagination | undefined>(
94
+ envelope
95
+ ? {
96
+ page: envelope.page,
97
+ pageSize: envelope.pageSize,
98
+ totalPages: Math.max(1, Math.ceil(envelope.count / envelope.pageSize)),
99
+ total: envelope.count
100
+ }
101
+ : undefined
102
+ );
103
+
104
+ const viewParam = $derived(page.url.searchParams.get('view'));
105
+ const idParam = $derived(page.url.searchParams.get('id'));
106
+
107
+ const creating = $derived(viewParam === 'create');
108
+ const reading = $derived(idParam !== null && viewParam === null);
109
+ const editing = $derived(idParam !== null && viewParam === 'edit');
110
+
111
+ const excluded = $derived(new Set([...AUTO_EXCLUDED, idKey]));
112
+
113
+ const singleInstance = $derived<T | undefined>(
114
+ Object.values(page.data as Record<string, unknown>).find(
115
+ (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && idKey in (v as object)
116
+ ) as T | undefined
117
+ );
118
+
119
+ let activeAction = $state<{ action: CustomAction<T>; item: T } | null>(null);
120
+ let activeBulkAction = $state<{ action: ViewBasedCustomBulkAction<T>; items: T[] } | null>(
121
+ null
122
+ );
123
+
124
+ async function runAction(action: CustomAction<T>, item: T) {
125
+ if (!(action.condition?.(item) ?? true)) return;
126
+ if (action.href) {
127
+ await goto(action.href(item));
128
+ return;
129
+ }
130
+ activeAction = { action, item };
131
+ }
132
+
133
+ function runBulkAction(action: ViewBasedCustomBulkAction<T>, items: T[]) {
134
+ activeBulkAction = { action, items };
135
+ }
136
+
137
+ let duplicateSeed = $state<Record<string, unknown> | undefined>(undefined);
138
+
139
+ async function navList() {
140
+ duplicateSeed = undefined;
141
+ await goto(lastListState.search || '?');
142
+ }
143
+ async function navCreate() {
144
+ duplicateSeed = undefined;
145
+ await goto('?view=create');
146
+ }
147
+ async function navDuplicate(record: Record<string, unknown>) {
148
+ duplicateSeed = record;
149
+ await goto('?view=create');
150
+ }
151
+ async function navRead(item: T) {
152
+ await goto(`?id=${encodeURIComponent(String((item as Record<string, unknown>)[idKey] ?? ''))}`);
153
+ }
154
+ async function navEdit(item: T) {
155
+ await goto(
156
+ `?id=${encodeURIComponent(String((item as Record<string, unknown>)[idKey] ?? ''))}&view=edit`
157
+ );
158
+ }
159
+
160
+ let lastListState = $state<{ data: T[]; search: string }>({ data: [], search: '' });
161
+ $effect(() => {
162
+ if (!creating && !reading && !editing) {
163
+ lastListState = { data: entityData, search: page.url.search };
164
+ }
165
+ });
166
+
167
+ // "Save and continue" on the Update form: jumps to editing the record that
168
+ // follows the current one in the last loaded list, falling back to
169
+ // re-editing the same instance when it's the last one (or isn't found).
170
+ function nextInstance(current: T): T {
171
+ const currentId = (current as Record<string, unknown>)[idKey];
172
+ const idx = lastListState.data.findIndex(
173
+ (item) => (item as Record<string, unknown>)[idKey] === currentId
174
+ );
175
+ return (idx !== -1 ? lastListState.data[idx + 1] : undefined) ?? current;
176
+ }
177
+ async function navContinueEdit() {
178
+ if (!singleInstance) return;
179
+ await navEdit(nextInstance(singleInstance));
180
+ }
181
+
182
+ const resolvedColumns: ColumnDefinition<T>[] = $derived(
183
+ columns ??
184
+ (meta
185
+ ? (Object.entries(meta) as [string, AttributeMetadata][])
186
+ .filter(([, m]) => !m.excludedFromList)
187
+ .map(([k, m]) => {
188
+ const embeddedFields =
189
+ m.type === 'embedded' && m.fields
190
+ ? buildFieldDefinitions(m.fields, data, 'excludedFromList', new Set())
191
+ : undefined;
192
+ // Arrays of objects have no sensible raw cell value, so an
193
+ // embedded column without an explicit formatter falls back to
194
+ // joining each item's label (itemLabel, or the same
195
+ // sub-field-joining summary the embedded form list uses).
196
+ const resolvedFormatter =
197
+ resolveFormatter(m, data) ??
198
+ (embeddedFields
199
+ ? (value: unknown) =>
200
+ Array.isArray(value)
201
+ ? value
202
+ .map((item) =>
203
+ m.itemLabel
204
+ ? m.itemLabel(item as Record<string, unknown>)
205
+ : defaultItemLabel(embeddedFields, item as Record<string, unknown>)
206
+ )
207
+ .join(', ')
208
+ : ''
209
+ : undefined);
210
+ const formatter =
211
+ m.truncateUpTo != null
212
+ ? truncateFormatter(resolvedFormatter, m.truncateUpTo)
213
+ : resolvedFormatter;
214
+ return {
215
+ attribute: k as keyof T & string,
216
+ title: m.label ?? k,
217
+ type: m.type,
218
+ formatter,
219
+ component: m.component,
220
+ sortable: m.sortable,
221
+ filterable: m.filterable,
222
+ filterOptions: m.filterOptions,
223
+ fields: embeddedFields,
224
+ itemLabel: m.itemLabel
225
+ };
226
+ })
227
+ : entityData.length > 0
228
+ ? (Object.keys(entityData[0]) as (keyof T & string)[])
229
+ .filter((k) => !excluded.has(k))
230
+ .map((k) => ({ attribute: k, title: k }))
231
+ : [])
232
+ );
233
+
234
+ // Server-pagination mode only: GenericCRUD owns `page`/`ordering`/per-column
235
+ // filter query params the same way it already owns `view`/`id`, so pagination
236
+ // state survives reloads/direct links and `+page.svelte` never has to change.
237
+ const orderingParam = $derived(page.url.searchParams.get('ordering'));
238
+ const initialSort = $derived(
239
+ orderingParam
240
+ ? {
241
+ column: orderingParam.startsWith('-') ? orderingParam.slice(1) : orderingParam,
242
+ direction: (orderingParam.startsWith('-') ? 'desc' : 'asc') as SortDirection
243
+ }
244
+ : undefined
245
+ );
246
+
247
+ const initialFilters = $derived.by<Partial<FilterSnapshot> | undefined>(() => {
248
+ if (!envelope) return undefined;
249
+ const text: Record<string, string> = {};
250
+ const values: Record<string, string[]> = {};
251
+ const dateRanges: Record<string, { from: string; to: string }> = {};
252
+ for (const col of resolvedColumns) {
253
+ if (!isFilterable(col)) continue;
254
+ const raw = page.url.searchParams.get(col.attribute);
255
+ if (raw != null) {
256
+ if (col.type === 'boolean') values[col.attribute] = raw.split(',').filter(Boolean);
257
+ else text[col.attribute] = raw;
258
+ }
259
+ const from = page.url.searchParams.get(`${col.attribute}_from`);
260
+ const to = page.url.searchParams.get(`${col.attribute}_to`);
261
+ if (from || to) dateRanges[col.attribute] = { from: from ?? '', to: to ?? '' };
262
+ }
263
+ return { text, values, dateRanges };
264
+ });
265
+
266
+ async function handlePaginationChange(query: TableQuery) {
267
+ // Local, synchronous query-string builder consumed immediately by goto();
268
+ // not rendered/reactive state, so plain URLSearchParams is correct here.
269
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
270
+ const params = new URLSearchParams(page.url.searchParams);
271
+ params.delete('view');
272
+ params.delete('id');
273
+
274
+ if (query.page > 1) params.set('page', String(query.page));
275
+ else params.delete('page');
276
+
277
+ if (query.ordering) params.set('ordering', query.ordering);
278
+ else params.delete('ordering');
279
+
280
+ for (const col of resolvedColumns) {
281
+ params.delete(col.attribute);
282
+ params.delete(`${col.attribute}_from`);
283
+ params.delete(`${col.attribute}_to`);
284
+ }
285
+ for (const [k, v] of Object.entries(query.filters.text)) {
286
+ if (v) params.set(k, v);
287
+ }
288
+ for (const [k, vs] of Object.entries(query.filters.values)) {
289
+ if (vs.length) params.set(k, vs.join(','));
290
+ }
291
+ for (const [k, r] of Object.entries(query.filters.dateRanges)) {
292
+ if (r.from) params.set(`${k}_from`, r.from);
293
+ if (r.to) params.set(`${k}_to`, r.to);
294
+ }
295
+
296
+ const qs = params.toString();
297
+ await goto(qs ? `?${qs}` : '?', { keepFocus: true, noScroll: true });
298
+ }
299
+
300
+ const resolvedFields: FieldDefinition<T>[] = $derived(
301
+ fields ??
302
+ (meta
303
+ ? buildFieldDefinitions<T>(meta, data, 'excludedFromCreate', excluded)
304
+ : entityData.length > 0
305
+ ? (Object.entries(entityData[0]) as [string, unknown][])
306
+ .filter(([k]) => !excluded.has(k))
307
+ .map(([k, v]) => ({ attribute: k as keyof T & string, type: inferType(k, v) }))
308
+ : [])
309
+ );
310
+
311
+ const resolvedReadFields: FieldDefinition<T>[] = $derived(
312
+ fields ??
313
+ (meta
314
+ ? buildFieldDefinitions<T>(meta, data, 'excludedFromRead', excluded)
315
+ : entityData.length > 0
316
+ ? (Object.entries(entityData[0]) as [string, unknown][])
317
+ .filter(([k]) => !excluded.has(k))
318
+ .map(([k, v]) => ({ attribute: k as keyof T & string, type: inferType(k, v) }))
319
+ : [])
320
+ );
321
+
322
+ const resolvedUpdateFields: FieldDefinition<T>[] = $derived(
323
+ fields ??
324
+ (meta
325
+ ? buildFieldDefinitions<T>(meta, data, 'excludedFromUpdate', excluded)
326
+ : entityData.length > 0
327
+ ? (Object.entries(entityData[0]) as [string, unknown][])
328
+ .filter(([k]) => !excluded.has(k))
329
+ .map(([k, v]) => ({ attribute: k as keyof T & string, type: inferType(k, v) }))
330
+ : [])
331
+ );
332
+
333
+ const serverError = $derived(
334
+ creating || reading || editing || activeAction !== null || activeBulkAction !== null
335
+ ? (form?.error ?? '')
336
+ : ''
337
+ );
338
+ </script>
339
+
340
+ {#if creating}
341
+ <Create
342
+ {labelOne}
343
+ {labelMany}
344
+ {icon}
345
+ fields={resolvedFields}
346
+ {creation}
347
+ {serverError}
348
+ seed={duplicateSeed}
349
+ onCancel={navList}
350
+ onSuccess={navList}
351
+ />
352
+ {:else if reading}
353
+ <Read
354
+ {labelOne}
355
+ {labelMany}
356
+ {icon}
357
+ {idKey}
358
+ fields={resolvedReadFields}
359
+ instance={singleInstance ?? ({} as T)}
360
+ {read}
361
+ onCancel={navList}
362
+ />
363
+ {:else if editing}
364
+ <Update
365
+ {labelOne}
366
+ {labelMany}
367
+ {icon}
368
+ {idKey}
369
+ fields={resolvedUpdateFields}
370
+ instance={singleInstance ?? ({} as T)}
371
+ {update}
372
+ {serverError}
373
+ onCancel={navList}
374
+ onSuccess={navList}
375
+ onContinue={navContinueEdit}
376
+ onDuplicate={navDuplicate}
377
+ />
378
+ {:else}
379
+ <List
380
+ data={entityData}
381
+ {labelOne}
382
+ {labelMany}
383
+ {icon}
384
+ {pageSize}
385
+ {idKey}
386
+ {creation}
387
+ {update}
388
+ {read}
389
+ {deletion}
390
+ {actions}
391
+ {config}
392
+ columns={resolvedColumns}
393
+ pagination={serverPagination}
394
+ {initialSort}
395
+ {initialFilters}
396
+ onPaginationChange={handlePaginationChange}
397
+ onCreate={navCreate}
398
+ onEdit={navEdit}
399
+ onView={navRead}
400
+ onAction={runAction}
401
+ onBulkAction={runBulkAction}
402
+ />
403
+ {/if}
404
+
405
+ {#if activeAction !== null}
406
+ {@const ActionView = activeAction.action.view}
407
+ <ActionView
408
+ instance={activeAction.item}
409
+ label={activeAction.action.label}
410
+ endpoint={activeAction.action.endpoint}
411
+ {serverError}
412
+ onCancel={() => (activeAction = null)}
413
+ onSuccess={() => (activeAction = null)}
414
+ />
415
+ {/if}
416
+
417
+ {#if activeBulkAction !== null}
418
+ {@const BulkActionView = activeBulkAction.action.view}
419
+ <BulkActionView
420
+ items={activeBulkAction.items}
421
+ label={activeBulkAction.action.label}
422
+ {serverError}
423
+ onCancel={() => (activeBulkAction = null)}
424
+ onSuccess={() => (activeBulkAction = null)}
425
+ />
426
+ {/if}