@r2digisolutions/components 0.1.6 → 0.1.7

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 (72) hide show
  1. package/README.md +1 -1
  2. package/dist/components/atoms/Checkbox/Checkbox.svelte +10 -3
  3. package/dist/components/atoms/Checkbox/Checkbox.svelte.d.ts +3 -0
  4. package/dist/components/atoms/Input/Input.svelte +17 -2
  5. package/dist/components/atoms/Input/Input.svelte.d.ts +6 -1
  6. package/dist/components/atoms/MarqueeRect/MarqueeRect.svelte +21 -0
  7. package/dist/components/atoms/MarqueeRect/MarqueeRect.svelte.d.ts +8 -0
  8. package/dist/components/atoms/Textarea/Textarea.svelte +4 -1
  9. package/dist/components/atoms/Textarea/Textarea.svelte.d.ts +1 -0
  10. package/dist/components/atoms/Toggle/Toggle.svelte +1 -1
  11. package/dist/components/molecules/BulkActionBar/BulkActionBar.svelte +19 -5
  12. package/dist/components/molecules/CodeEditor/CodeEditor.svelte +13 -10
  13. package/dist/components/molecules/ColorPicker/ColorPicker.svelte +166 -96
  14. package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.stories.d.ts +9 -0
  15. package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.stories.js +8 -0
  16. package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.svelte +412 -0
  17. package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.svelte.d.ts +14 -0
  18. package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanelStory.svelte +27 -0
  19. package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanelStory.svelte.d.ts +3 -0
  20. package/dist/components/molecules/ContextMenu/ContextMenu.svelte +73 -21
  21. package/dist/components/molecules/ContextMenu/ContextMenu.svelte.d.ts +8 -1
  22. package/dist/components/molecules/DateTimePicker/DateTimePicker.svelte.d.ts +1 -1
  23. package/dist/components/molecules/FormDateTimePicker/FormDateTimePicker.svelte.d.ts +1 -1
  24. package/dist/components/molecules/Gauge/Gauge.stories.d.ts +1 -1
  25. package/dist/components/molecules/InfiniteScroll/InfiniteScroll.stories.d.ts +1 -1
  26. package/dist/components/organisms/AssetPickerPage/AssetPickerPage.svelte.d.ts +1 -1
  27. package/dist/components/organisms/BacklogPage/BacklogPage.svelte.d.ts +1 -1
  28. package/dist/components/organisms/CalendarApp/CalendarApp.svelte.d.ts +1 -1
  29. package/dist/components/organisms/DataGrid/DataGrid.stories.d.ts +65 -0
  30. package/dist/components/organisms/DataGrid/DataGrid.stories.js +95 -0
  31. package/dist/components/organisms/DataGrid/DataGrid.svelte +2264 -0
  32. package/dist/components/organisms/DataGrid/DataGrid.svelte.d.ts +99 -0
  33. package/dist/components/organisms/DataGrid/DataGridStory.svelte +659 -0
  34. package/dist/components/organisms/DataGrid/DataGridStory.svelte.d.ts +11 -0
  35. package/dist/components/organisms/DataGrid/breakpoints.d.ts +10 -0
  36. package/dist/components/organisms/DataGrid/breakpoints.js +20 -0
  37. package/dist/components/organisms/DataGrid/cellEditors.d.ts +8 -0
  38. package/dist/components/organisms/DataGrid/cellEditors.js +59 -0
  39. package/dist/components/organisms/DataGrid/conditionalFormat.d.ts +11 -0
  40. package/dist/components/organisms/DataGrid/conditionalFormat.js +98 -0
  41. package/dist/components/organisms/DataGrid/filterRows.d.ts +5 -0
  42. package/dist/components/organisms/DataGrid/filterRows.js +60 -0
  43. package/dist/components/organisms/DataGrid/marks.d.ts +9 -0
  44. package/dist/components/organisms/DataGrid/marks.js +91 -0
  45. package/dist/components/organisms/DataGrid/types.d.ts +126 -0
  46. package/dist/components/organisms/DataGrid/types.js +21 -0
  47. package/dist/components/organisms/ResourceStudio/ResourceStudio.stories.d.ts +33 -0
  48. package/dist/components/organisms/ResourceStudio/ResourceStudio.stories.js +56 -0
  49. package/dist/components/organisms/ResourceStudio/ResourceStudio.svelte +830 -0
  50. package/dist/components/organisms/ResourceStudio/ResourceStudio.svelte.d.ts +68 -0
  51. package/dist/components/organisms/ResourceStudio/ResourceStudioStory.svelte +600 -0
  52. package/dist/components/organisms/ResourceStudio/ResourceStudioStory.svelte.d.ts +7 -0
  53. package/dist/components/organisms/ResourceStudio/fieldsToColumns.d.ts +4 -0
  54. package/dist/components/organisms/ResourceStudio/fieldsToColumns.js +20 -0
  55. package/dist/components/organisms/ResourceStudio/fieldsToSchema.d.ts +6 -0
  56. package/dist/components/organisms/ResourceStudio/fieldsToSchema.js +53 -0
  57. package/dist/components/organisms/ResourceStudio/types.d.ts +69 -0
  58. package/dist/components/organisms/ResourceStudio/types.js +15 -0
  59. package/dist/components/organisms/Scheduler/Scheduler.svelte.d.ts +1 -1
  60. package/dist/components/organisms/SchemaForm/SchemaForm.svelte +10 -3
  61. package/dist/components/organisms/SchemaForm/SchemaForm.svelte.d.ts +1 -0
  62. package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.stories.d.ts +25 -0
  63. package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.stories.js +18 -0
  64. package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.svelte +192 -0
  65. package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.svelte.d.ts +62 -0
  66. package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplateStory.svelte +129 -0
  67. package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplateStory.svelte.d.ts +7 -0
  68. package/dist/index.d.ts +11 -0
  69. package/dist/index.js +8 -0
  70. package/dist/utils/marqueeSelect.d.ts +48 -0
  71. package/dist/utils/marqueeSelect.js +189 -0
  72. package/package.json +1 -1
@@ -0,0 +1,830 @@
1
+ <script module lang="ts">
2
+ export type {
3
+ FieldType,
4
+ ResourceField,
5
+ ResourceCapabilities,
6
+ ResourceDefinition,
7
+ ResourceStudioMode,
8
+ ResourceStudioLayout,
9
+ ResourceStudioLabels,
10
+ ResourceStudioView
11
+ } from './types.js';
12
+ export { DEFAULT_CAPABILITIES, resolveCapabilities } from './types.js';
13
+ export { fieldsToColumns } from './fieldsToColumns.js';
14
+ export { fieldsToSchema } from './fieldsToSchema.js';
15
+ </script>
16
+
17
+ <script lang="ts">
18
+ import type { Snippet } from 'svelte';
19
+ import DataGrid from '../DataGrid/DataGrid.svelte';
20
+ import type {
21
+ CellNote,
22
+ ConditionalFormatRule,
23
+ DataGridColumn,
24
+ GridSelection
25
+ } from '../DataGrid/types.js';
26
+ import { EMPTY_SELECTION } from '../DataGrid/types.js';
27
+ import ConditionalFormatPanel from '../../molecules/ConditionalFormatPanel/ConditionalFormatPanel.svelte';
28
+ import SearchInput from '../../molecules/SearchInput/SearchInput.svelte';
29
+ import Pagination from '../../molecules/Pagination/Pagination.svelte';
30
+ import EmptyState from '../../molecules/EmptyState/EmptyState.svelte';
31
+ import ConfirmDelete from '../../molecules/ConfirmDelete/ConfirmDelete.svelte';
32
+ import Drawer from '../Drawer/Drawer.svelte';
33
+ import SchemaForm from '../SchemaForm/SchemaForm.svelte';
34
+ import type { SchemaFormValues } from '../SchemaForm/SchemaForm.svelte';
35
+ import Badge from '../../atoms/Badge/Badge.svelte';
36
+ import Button from '../../atoms/Button/Button.svelte';
37
+ import Skeleton from '../../atoms/Skeleton/Skeleton.svelte';
38
+ import DropZone from '../../molecules/DropZone/DropZone.svelte';
39
+ import type { BulkAction } from '../../molecules/BulkActionBar/BulkActionBar.svelte';
40
+ import {
41
+ type ResourceDefinition,
42
+ type ResourceStudioMode,
43
+ type ResourceStudioLayout,
44
+ type ResourceStudioLabels,
45
+ type ResourceStudioView,
46
+ resolveCapabilities
47
+ } from './types.js';
48
+ import { fieldsToColumns } from './fieldsToColumns.js';
49
+ import { fieldsToSchema } from './fieldsToSchema.js';
50
+
51
+ interface ResourceStudioProps {
52
+ resources?: ResourceDefinition[];
53
+ activeResourceId?: string;
54
+ records?: Record<string, unknown>[];
55
+ totalRecords?: number;
56
+ loading?: boolean;
57
+ page?: number;
58
+ pageSize?: number;
59
+ query?: string;
60
+ selection?: GridSelection;
61
+ notes?: CellNote[];
62
+ formatRules?: ConditionalFormatRule[];
63
+ modes?: ResourceStudioMode[];
64
+ activeMode?: ResourceStudioMode;
65
+ layout?: ResourceStudioLayout;
66
+ labels?: ResourceStudioLabels;
67
+ views?: ResourceStudioView[];
68
+ activeViewId?: string;
69
+ /** Filter `records` locally by the search box. Set false for server-side search. */
70
+ clientSearch?: boolean;
71
+ /** Open / bind the conditional format rules panel. */
72
+ formatPanelOpen?: boolean;
73
+ /** Override bulk dock actions (defaults from capabilities). */
74
+ actions?: BulkAction[];
75
+ class?: string;
76
+ toolbar?: Snippet;
77
+ sidebarItem?: Snippet<[ResourceDefinition, boolean]>;
78
+ sidebarFooter?: Snippet;
79
+ detail?: Snippet<[record: Record<string, unknown> | null]>;
80
+ schemaPanel?: Snippet<[ResourceDefinition]>;
81
+ queryPanel?: Snippet;
82
+ activityPanel?: Snippet;
83
+ recordForm?: Snippet<[mode: 'create' | 'edit', record: Record<string, unknown> | null]>;
84
+ empty?: Snippet;
85
+ dockExtra?: Snippet;
86
+ cell?: Snippet<[Record<string, unknown>, DataGridColumn]>;
87
+ onresourcechange?: (id: string) => void;
88
+ onsearch?: (q: string) => void;
89
+ onpagechange?: (page: number, pageSize: number) => void;
90
+ onsort?: (columnId: string, dir: 'asc' | 'desc') => void;
91
+ oncreate?: () => void;
92
+ oninsert?: (record: Record<string, unknown>) => void;
93
+ onupdate?: (id: string, patch: Record<string, unknown>) => void;
94
+ ondelete?: (ids: string[]) => void;
95
+ onduplicate?: (ids: string[]) => void;
96
+ onexport?: (ids: string[] | 'all') => void;
97
+ onimport?: (file: File) => void;
98
+ oncelledit?: (id: string, fieldId: string, value: unknown) => void;
99
+ /** Fired for dock actions not handled built-in (e.g. custom bulk workflow). */
100
+ onaction?: (id: string, rowIds: string[]) => void;
101
+ onselectionchange?: (s: GridSelection) => void;
102
+ onnoteschange?: (notes: CellNote[]) => void;
103
+ onformatruleschange?: (rules: ConditionalFormatRule[]) => void;
104
+ onmodechange?: (mode: ResourceStudioMode) => void;
105
+ onviewchange?: (viewId: string) => void;
106
+ onrefresh?: () => void;
107
+ }
108
+
109
+ let {
110
+ resources = [],
111
+ activeResourceId = $bindable(''),
112
+ records = [],
113
+ totalRecords,
114
+ loading = false,
115
+ page = $bindable(1),
116
+ pageSize = 25,
117
+ query = $bindable(''),
118
+ selection = $bindable<GridSelection>(EMPTY_SELECTION),
119
+ notes = $bindable<CellNote[]>([]),
120
+ formatRules = $bindable<ConditionalFormatRule[]>([]),
121
+ modes = ['browse', 'detail', 'schema'] as ResourceStudioMode[],
122
+ activeMode = $bindable<ResourceStudioMode>('browse'),
123
+ layout = 'sidebar-main-detail',
124
+ labels = {},
125
+ views = [],
126
+ activeViewId = $bindable(''),
127
+ clientSearch = true,
128
+ formatPanelOpen = $bindable(false),
129
+ actions,
130
+ class: className = '',
131
+ toolbar,
132
+ sidebarItem,
133
+ sidebarFooter,
134
+ detail,
135
+ schemaPanel,
136
+ queryPanel,
137
+ activityPanel,
138
+ recordForm,
139
+ empty,
140
+ dockExtra,
141
+ cell,
142
+ onresourcechange,
143
+ onsearch,
144
+ onpagechange,
145
+ onsort,
146
+ oncreate,
147
+ oninsert,
148
+ onupdate,
149
+ ondelete,
150
+ onduplicate,
151
+ onexport,
152
+ onimport,
153
+ oncelledit,
154
+ onaction,
155
+ onselectionchange,
156
+ onnoteschange,
157
+ onformatruleschange,
158
+ onmodechange,
159
+ onviewchange,
160
+ onrefresh
161
+ }: ResourceStudioProps = $props();
162
+
163
+ let sidebarQuery = $state('');
164
+ let formOpen = $state(false);
165
+ let formMode = $state<'create' | 'edit'>('create');
166
+ let formRecord = $state<Record<string, unknown> | null>(null);
167
+ let deleteOpen = $state(false);
168
+ let pendingDeleteIds = $state<string[]>([]);
169
+ let importOpen = $state(false);
170
+ let focusedRecord = $state<Record<string, unknown> | null>(null);
171
+ let draftForm = $state<SchemaFormValues>({});
172
+
173
+ const formSchema = $derived(
174
+ activeResource ? fieldsToSchema(activeResource.fields, { mode: formMode }) : []
175
+ );
176
+
177
+ const L = $derived({
178
+ resources: labels.resources ?? 'Resources',
179
+ records: labels.records ?? 'records',
180
+ addRecord: labels.addRecord ?? 'Add record',
181
+ schema: labels.schema ?? 'Schema',
182
+ runQuery: labels.runQuery ?? 'Run query',
183
+ searchPlaceholder: labels.searchPlaceholder ?? 'Search records…',
184
+ detail: labels.detail ?? 'Detail',
185
+ activity: labels.activity ?? 'Activity',
186
+ query: labels.query ?? 'Query'
187
+ });
188
+
189
+ $effect(() => {
190
+ if (!activeResourceId && resources[0]) {
191
+ activeResourceId = resources[0].id;
192
+ }
193
+ });
194
+
195
+ const activeResource = $derived(
196
+ resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
197
+ );
198
+
199
+ const caps = $derived(resolveCapabilities(activeResource?.capabilities));
200
+
201
+ const primaryKey = $derived(activeResource?.primaryKey ?? 'id');
202
+
203
+ const columns = $derived(activeResource ? fieldsToColumns(activeResource.fields) : []);
204
+
205
+ const groups = $derived.by(() => {
206
+ const map = new Map<string, ResourceDefinition[]>();
207
+ const q = sidebarQuery.trim().toLowerCase();
208
+ for (const r of resources) {
209
+ if (q && !r.name.toLowerCase().includes(q) && !r.id.toLowerCase().includes(q)) continue;
210
+ const g = r.group ?? 'General';
211
+ if (!map.has(g)) map.set(g, []);
212
+ map.get(g)!.push(r);
213
+ }
214
+ return [...map.entries()];
215
+ });
216
+
217
+ const filteredRecords = $derived.by(() => {
218
+ const q = query.trim().toLowerCase();
219
+ if (!clientSearch || !q) return records;
220
+ const fieldIds =
221
+ activeResource?.fields.filter((f) => !f.hidden).map((f) => f.id) ??
222
+ (records[0] ? Object.keys(records[0]) : []);
223
+ return records.filter((row) =>
224
+ fieldIds.some((id) => String(row[id] ?? '').toLowerCase().includes(q))
225
+ );
226
+ });
227
+
228
+ const visibleCount = $derived(
229
+ clientSearch ? filteredRecords.length : (totalRecords ?? records.length)
230
+ );
231
+
232
+ const totalPages = $derived(
233
+ Math.max(
234
+ 1,
235
+ Math.ceil(
236
+ (clientSearch ? filteredRecords.length : (totalRecords ?? records.length)) /
237
+ Math.max(pageSize, 1)
238
+ )
239
+ )
240
+ );
241
+
242
+ const showSidebar = $derived(layout !== 'main-only');
243
+ /** Companion inspector while browsing (large screens / stacked on small). */
244
+ const showInspector = $derived(
245
+ layout === 'sidebar-main-detail' && activeMode === 'browse' && modes.includes('detail')
246
+ );
247
+
248
+ function selectResource(id: string) {
249
+ if (activeResourceId === id) return;
250
+ activeResourceId = id;
251
+ selection = EMPTY_SELECTION;
252
+ focusedRecord = null;
253
+ page = 1;
254
+ query = '';
255
+ if (activeMode !== 'browse' && modes.includes('browse')) activeMode = 'browse';
256
+ onresourcechange?.(id);
257
+ const res = resources.find((r) => r.id === id);
258
+ if (res?.defaultFormatRules) {
259
+ formatRules = res.defaultFormatRules.map((r) => ({ ...r }));
260
+ onformatruleschange?.(formatRules);
261
+ } else {
262
+ formatRules = [];
263
+ onformatruleschange?.([]);
264
+ }
265
+ }
266
+
267
+ function setMode(mode: ResourceStudioMode) {
268
+ if (!modes.includes(mode)) return;
269
+ activeMode = mode;
270
+ onmodechange?.(mode);
271
+ }
272
+
273
+ /** Keep inspector in sync when exactly one row is selected via checkbox/dock. */
274
+ $effect(() => {
275
+ const ids =
276
+ selection.type === 'rows'
277
+ ? selection.ids
278
+ : selection.type === 'cells'
279
+ ? [...new Set(selection.cells.map((c) => c.rowId))]
280
+ : [];
281
+ if (ids.length !== 1) return;
282
+ const row = records.find((r) => String(r[primaryKey]) === ids[0]);
283
+ if (row) focusedRecord = { ...row };
284
+ });
285
+
286
+ function openCreate() {
287
+ formMode = 'create';
288
+ formRecord = null;
289
+ draftForm = Object.fromEntries(
290
+ fieldsToSchema(activeResource?.fields ?? [], { mode: 'create' }).map((f) => [
291
+ f.name,
292
+ f.defaultValue ?? (f.type === 'toggle' || f.type === 'checkbox' ? false : '')
293
+ ])
294
+ );
295
+ formOpen = true;
296
+ oncreate?.();
297
+ }
298
+
299
+ function openEdit(record: Record<string, unknown>) {
300
+ formMode = 'edit';
301
+ formRecord = record;
302
+ const schema = fieldsToSchema(activeResource?.fields ?? [], { mode: 'edit' });
303
+ draftForm = Object.fromEntries(
304
+ schema.map((f) => {
305
+ const raw = record[f.name];
306
+ if (f.type === 'toggle' || f.type === 'checkbox') return [f.name, Boolean(raw)];
307
+ if (f.type === 'number') return [f.name, raw == null || raw === '' ? '' : Number(raw)];
308
+ return [f.name, raw == null ? '' : String(raw)];
309
+ })
310
+ );
311
+ formOpen = true;
312
+ }
313
+
314
+ function submitForm(values: SchemaFormValues = draftForm) {
315
+ const record: Record<string, unknown> = { ...values };
316
+ for (const f of activeResource?.fields ?? []) {
317
+ if (f.type === 'number' && record[f.id] != null && record[f.id] !== '') {
318
+ record[f.id] = Number(record[f.id]);
319
+ }
320
+ if (f.type === 'boolean') {
321
+ record[f.id] = record[f.id] === true || record[f.id] === 'true';
322
+ }
323
+ }
324
+ if (formMode === 'create') {
325
+ if (!record[primaryKey]) record[primaryKey] = crypto.randomUUID?.() ?? String(Date.now());
326
+ oninsert?.(record);
327
+ } else if (formRecord) {
328
+ const id = String(formRecord[primaryKey]);
329
+ onupdate?.(id, record);
330
+ }
331
+ formOpen = false;
332
+ }
333
+
334
+ function selectedRowIds(): string[] {
335
+ if (selection.type === 'rows') return selection.ids;
336
+ if (selection.type === 'cells') return [...new Set(selection.cells.map((c) => c.rowId))];
337
+ return [];
338
+ }
339
+
340
+ function askDelete(ids: string[]) {
341
+ pendingDeleteIds = ids;
342
+ deleteOpen = true;
343
+ }
344
+
345
+ function confirmDelete() {
346
+ ondelete?.(pendingDeleteIds);
347
+ deleteOpen = false;
348
+ pendingDeleteIds = [];
349
+ selection = EMPTY_SELECTION;
350
+ }
351
+
352
+ function handleDockAction(id: string, sel: GridSelection) {
353
+ const ids = selectedRowIds();
354
+ if (id === 'delete' && caps.delete) {
355
+ askDelete(ids);
356
+ return;
357
+ }
358
+ if (id === 'duplicate' && caps.duplicate) {
359
+ onduplicate?.(ids);
360
+ return;
361
+ }
362
+ if (id === 'export' && caps.export) {
363
+ onexport?.(ids.length ? ids : 'all');
364
+ return;
365
+ }
366
+ if (id === 'format' && caps.conditionalFormat) {
367
+ formatPanelOpen = true;
368
+ return;
369
+ }
370
+ if (id === 'edit' && ids[0]) {
371
+ const row = records.find((r) => String(r[primaryKey]) === ids[0]);
372
+ if (row) openEdit(row);
373
+ return;
374
+ }
375
+ onaction?.(id, ids);
376
+ void sel;
377
+ }
378
+
379
+ const dockActions = $derived.by((): BulkAction[] => {
380
+ if (actions) return actions;
381
+ const list: BulkAction[] = [];
382
+ if (caps.notes) list.push({ id: 'note', label: 'Note', variant: 'secondary' });
383
+ if (caps.update) list.push({ id: 'edit', label: 'Edit', variant: 'secondary' });
384
+ if (caps.duplicate) list.push({ id: 'duplicate', label: 'Duplicate', variant: 'secondary' });
385
+ if (caps.export) list.push({ id: 'export', label: 'Export', variant: 'secondary' });
386
+ if (caps.conditionalFormat) list.push({ id: 'format', label: 'Rules', variant: 'secondary' });
387
+ list.push({ id: 'copy', label: 'Copy', variant: 'ghost' });
388
+ if (caps.delete)
389
+ list.push({ id: 'delete', label: 'Delete', variant: 'destructive', confirm: true });
390
+ return list;
391
+ });
392
+
393
+ function onRowClick(row: Record<string, unknown>) {
394
+ focusedRecord = { ...row };
395
+ }
396
+
397
+ function onRowDblClick(row: Record<string, unknown>) {
398
+ // Focus only — Detail mode is opened from the mode tab, never from row clicks.
399
+ focusedRecord = { ...row };
400
+ }
401
+
402
+ function modeLabel(m: ResourceStudioMode): string {
403
+ if (m === 'browse') return 'Browse';
404
+ if (m === 'detail') return L.detail;
405
+ if (m === 'schema') return L.schema;
406
+ if (m === 'query') return L.query;
407
+ return L.activity;
408
+ }
409
+ </script>
410
+
411
+ {#snippet schemaBody(resource: ResourceDefinition)}
412
+ {#if schemaPanel}
413
+ {@render schemaPanel(resource)}
414
+ {:else}
415
+ <ul class="space-y-2">
416
+ {#each resource.fields as field (field.id)}
417
+ <li class="rounded-lg border border-border px-2.5 py-2">
418
+ <div class="flex items-center justify-between gap-2">
419
+ <span class="text-xs font-medium">{field.label}</span>
420
+ <Badge size="sm" variant="secondary">{field.type}</Badge>
421
+ </div>
422
+ <p class="mt-0.5 font-mono text-[10px] text-muted">{field.id}</p>
423
+ {#if field.required || field.unique || field.readonly}
424
+ <div class="mt-1.5 flex flex-wrap gap-1">
425
+ {#if field.required}<Badge size="sm" variant="warning">required</Badge>{/if}
426
+ {#if field.unique}<Badge size="sm" variant="info">unique</Badge>{/if}
427
+ {#if field.readonly}<Badge size="sm" variant="default">readonly</Badge>{/if}
428
+ </div>
429
+ {/if}
430
+ {#if field.relation}
431
+ <p class="mt-1 text-[10px] text-secondary">
432
+ → {field.relation.resourceId}.{field.relation.fieldId}
433
+ </p>
434
+ {/if}
435
+ </li>
436
+ {/each}
437
+ </ul>
438
+ {/if}
439
+ {/snippet}
440
+
441
+ {#snippet detailBody(record: Record<string, unknown> | null)}
442
+ {#if detail}
443
+ {@render detail(record)}
444
+ {:else if record && activeResource}
445
+ <div class="space-y-3">
446
+ <div class="flex items-center justify-between gap-2">
447
+ <p class="text-xs font-semibold">{L.detail}</p>
448
+ {#if caps.update}
449
+ <Button size="xs" variant="secondary" onclick={() => openEdit(record)}>Edit</Button>
450
+ {/if}
451
+ </div>
452
+ <dl class="space-y-2">
453
+ {#each activeResource.fields.filter((f) => !f.hidden) as field (field.id)}
454
+ <div>
455
+ <dt class="text-[10px] font-semibold tracking-wide text-muted uppercase">
456
+ {field.label}
457
+ </dt>
458
+ <dd class="mt-0.5 break-all text-sm">
459
+ {record[field.id] == null ? '—' : String(record[field.id])}
460
+ </dd>
461
+ </div>
462
+ {/each}
463
+ </dl>
464
+ </div>
465
+ {:else}
466
+ <p class="py-8 text-center text-xs text-muted">Select a row to inspect.</p>
467
+ {/if}
468
+ {/snippet}
469
+
470
+ <div
471
+ class={[
472
+ 'flex h-full min-h-0 w-full overflow-hidden rounded-xl border border-border bg-surface text-primary',
473
+ className
474
+ ]}
475
+ >
476
+ {#if showSidebar}
477
+ <aside class="flex w-56 shrink-0 flex-col border-r border-border bg-surface-elevated">
478
+ <div class="space-y-2 border-b border-border p-3">
479
+ <p class="text-[11px] font-semibold tracking-wide text-secondary uppercase">{L.resources}</p>
480
+ <SearchInput
481
+ size="sm"
482
+ bind:value={sidebarQuery}
483
+ placeholder="Filter…"
484
+ class="w-full"
485
+ />
486
+ </div>
487
+ <nav class="flex-1 overflow-y-auto p-2" aria-label={L.resources}>
488
+ {#each groups as [group, items] (group)}
489
+ <div class="mb-3">
490
+ <p class="px-2 py-1 text-[10px] font-semibold tracking-wide text-muted uppercase">
491
+ {group}
492
+ </p>
493
+ <ul class="space-y-0.5">
494
+ {#each items as resource (resource.id)}
495
+ {@const active = resource.id === activeResource?.id}
496
+ <li>
497
+ {#if sidebarItem}
498
+ <button
499
+ type="button"
500
+ class="w-full text-left"
501
+ onclick={() => selectResource(resource.id)}
502
+ >
503
+ {@render sidebarItem(resource, active)}
504
+ </button>
505
+ {:else}
506
+ <button
507
+ type="button"
508
+ class={[
509
+ 'flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm transition-colors',
510
+ active
511
+ ? 'bg-brand-500/10 font-medium text-brand-700 dark:text-brand-300'
512
+ : 'text-secondary hover:bg-surface-overlay hover:text-primary'
513
+ ]}
514
+ onclick={() => selectResource(resource.id)}
515
+ >
516
+ <span class="truncate">{resource.name}</span>
517
+ {#if resource.recordCount != null}
518
+ <span class="text-[10px] tabular-nums text-muted"
519
+ >{resource.recordCount}</span
520
+ >
521
+ {/if}
522
+ </button>
523
+ {/if}
524
+ </li>
525
+ {/each}
526
+ </ul>
527
+ </div>
528
+ {:else}
529
+ <p class="px-2 py-6 text-center text-xs text-muted">No resources</p>
530
+ {/each}
531
+ </nav>
532
+ {#if sidebarFooter}
533
+ <div class="border-t border-border p-2">
534
+ {@render sidebarFooter()}
535
+ </div>
536
+ {/if}
537
+ </aside>
538
+ {/if}
539
+
540
+ <div class="flex min-h-0 min-w-0 flex-1 flex-col">
541
+ <header class="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2">
542
+ <div class="min-w-0 flex-1">
543
+ <div class="flex flex-wrap items-center gap-2">
544
+ <h2 class="truncate text-sm font-semibold">
545
+ {activeResource?.name ?? 'Select a resource'}
546
+ </h2>
547
+ {#if activeResource?.description}
548
+ <p class="truncate text-xs text-muted">{activeResource.description}</p>
549
+ {/if}
550
+ </div>
551
+ </div>
552
+
553
+ <div class="flex flex-wrap items-center gap-1 rounded-lg bg-surface-overlay/60 p-0.5">
554
+ {#each modes as mode (mode)}
555
+ <button
556
+ type="button"
557
+ class={[
558
+ 'rounded-md px-2 py-1 text-[11px] font-medium transition-colors',
559
+ activeMode === mode
560
+ ? 'bg-surface-elevated text-primary shadow-sm'
561
+ : 'text-secondary hover:text-primary'
562
+ ]}
563
+ onclick={() => setMode(mode)}
564
+ >
565
+ {modeLabel(mode)}
566
+ </button>
567
+ {/each}
568
+ </div>
569
+
570
+ {#if caps.create}
571
+ <Button size="sm" variant="primary" onclick={openCreate}>{L.addRecord}</Button>
572
+ {/if}
573
+ {#if caps.conditionalFormat}
574
+ <Button
575
+ size="sm"
576
+ variant={formatPanelOpen ? 'secondary' : 'ghost'}
577
+ onclick={() => (formatPanelOpen = !formatPanelOpen)}
578
+ >
579
+ Rules{formatRules.length ? ` (${formatRules.length})` : ''}
580
+ </Button>
581
+ {/if}
582
+ {#if caps.import}
583
+ <Button size="sm" variant="secondary" onclick={() => (importOpen = true)}>Import</Button>
584
+ {/if}
585
+ {#if caps.export}
586
+ <Button size="sm" variant="ghost" onclick={() => onexport?.('all')}>Export</Button>
587
+ {/if}
588
+ {#if onrefresh}
589
+ <Button size="sm" variant="ghost" onclick={() => onrefresh?.()}>Refresh</Button>
590
+ {/if}
591
+ </header>
592
+
593
+ {#if views.length}
594
+ <div class="flex shrink-0 flex-wrap gap-1.5 border-b border-border px-3 py-2">
595
+ {#each views as view (view.id)}
596
+ <button
597
+ type="button"
598
+ class={[
599
+ 'rounded-full px-2.5 py-0.5 text-[11px] font-medium ring-1 transition-colors',
600
+ activeViewId === view.id
601
+ ? 'bg-brand-500/10 text-brand-700 ring-brand-500/30 dark:text-brand-300'
602
+ : 'text-secondary ring-border hover:bg-surface-overlay'
603
+ ]}
604
+ onclick={() => {
605
+ activeViewId = view.id;
606
+ onviewchange?.(view.id);
607
+ }}
608
+ >
609
+ {view.label}
610
+ </button>
611
+ {/each}
612
+ </div>
613
+ {/if}
614
+
615
+ <div class="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2">
616
+ {#if toolbar}
617
+ {@render toolbar()}
618
+ {:else}
619
+ <SearchInput
620
+ size="sm"
621
+ bind:value={query}
622
+ placeholder={L.searchPlaceholder}
623
+ class="min-w-[12rem] flex-1"
624
+ onsubmit={(q) => onsearch?.(q)}
625
+ oninput={() => {
626
+ page = 1;
627
+ onsearch?.(query);
628
+ }}
629
+ />
630
+ <span class="text-[11px] text-muted tabular-nums">
631
+ {visibleCount}
632
+ {L.records}
633
+ </span>
634
+ {/if}
635
+ </div>
636
+
637
+ <div class="flex min-h-0 flex-1 flex-col lg:flex-row">
638
+ <div class="flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-hidden p-3">
639
+ {#if activeMode === 'query' && queryPanel}
640
+ <div
641
+ class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-surface-elevated p-3"
642
+ >
643
+ {@render queryPanel()}
644
+ </div>
645
+ {:else if activeMode === 'activity' && activityPanel}
646
+ <div class="min-h-0 flex-1 overflow-auto">
647
+ {@render activityPanel()}
648
+ </div>
649
+ {:else if activeMode === 'schema' && activeResource}
650
+ <div class="min-h-0 flex-1 overflow-auto rounded-xl border border-border bg-surface-elevated p-3">
651
+ <p class="mb-3 text-xs font-semibold text-secondary">{L.schema}</p>
652
+ {@render schemaBody(activeResource)}
653
+ </div>
654
+ {:else if activeMode === 'detail'}
655
+ <div class="min-h-0 flex-1 overflow-auto rounded-xl border border-border bg-surface-elevated p-3">
656
+ {#key focusedRecord ? String(focusedRecord[primaryKey] ?? '') : 'none'}
657
+ {@render detailBody(focusedRecord)}
658
+ {/key}
659
+ </div>
660
+ {:else if !activeResource}
661
+ <EmptyState title="No resource selected" description="Pick one from the sidebar." />
662
+ {:else if loading}
663
+ <div class="space-y-2">
664
+ {#each Array.from({ length: 6 }) as _, i (i)}
665
+ <Skeleton class="h-9 w-full" />
666
+ {/each}
667
+ </div>
668
+ {:else if filteredRecords.length === 0}
669
+ {#if empty}
670
+ {@render empty()}
671
+ {:else}
672
+ <EmptyState
673
+ title={`No ${L.records}`}
674
+ description={query.trim()
675
+ ? 'No matches for your search.'
676
+ : 'Create a record or adjust your search.'}
677
+ >
678
+ {#snippet action()}
679
+ {#if caps.create}
680
+ <Button size="sm" variant="primary" onclick={openCreate}>{L.addRecord}</Button>
681
+ {/if}
682
+ {/snippet}
683
+ </EmptyState>
684
+ {/if}
685
+ {:else}
686
+ <div class="flex min-h-0 flex-1 flex-col gap-3">
687
+ <DataGrid
688
+ class="min-h-0 flex-1"
689
+ {columns}
690
+ rows={filteredRecords}
691
+ rowKey={primaryKey}
692
+ bind:selection
693
+ bind:notes
694
+ bind:formatRules
695
+ editable={caps.editable && caps.update}
696
+ selectable={
697
+ caps.delete ||
698
+ caps.export ||
699
+ caps.notes ||
700
+ caps.duplicate ||
701
+ caps.update ||
702
+ caps.editable
703
+ }
704
+ selectCells={false}
705
+ selectColumns={false}
706
+ selectOnClick={false}
707
+ marqueeSelect
708
+ contextMenu
709
+ actions={dockActions}
710
+ compact
711
+ {cell}
712
+ {dockExtra}
713
+ onselectionchange={onselectionchange}
714
+ onnoteschange={onnoteschange}
715
+ onformatruleschange={onformatruleschange}
716
+ oncelledit={(rowId, columnId, value) => oncelledit?.(rowId, columnId, value)}
717
+ onaction={handleDockAction}
718
+ onsort={(id, dir) => onsort?.(id, dir)}
719
+ onrowclick={onRowClick}
720
+ onrowdblclick={onRowDblClick}
721
+ />
722
+
723
+ {#if visibleCount > pageSize || totalPages > 1}
724
+ <div class="flex shrink-0 justify-end">
725
+ <Pagination
726
+ bind:page
727
+ {totalPages}
728
+ onchange={(p) => onpagechange?.(p, pageSize)}
729
+ />
730
+ </div>
731
+ {/if}
732
+ </div>
733
+ {/if}
734
+
735
+ {#if formatPanelOpen && caps.conditionalFormat && activeMode === 'browse'}
736
+ <div class="shrink-0 space-y-2">
737
+ <ConditionalFormatPanel
738
+ bind:rules={formatRules}
739
+ columns={columns.map((c) => ({ id: c.id, label: c.header }))}
740
+ onchange={(r) => onformatruleschange?.(r)}
741
+ />
742
+ <div class="flex justify-end">
743
+ <Button size="sm" variant="ghost" onclick={() => (formatPanelOpen = false)}
744
+ >Hide rules</Button
745
+ >
746
+ </div>
747
+ </div>
748
+ {/if}
749
+ </div>
750
+
751
+ {#if showInspector && activeResource}
752
+ <aside
753
+ class="flex max-h-72 min-h-0 w-full shrink-0 flex-col border-t border-border bg-surface-elevated lg:max-h-none lg:w-72 lg:border-t-0 lg:border-l"
754
+ >
755
+ <div class="flex items-center gap-1 border-b border-border p-2">
756
+ {#each (['detail', 'schema'] as ResourceStudioMode[]).filter((m) => modes.includes(m)) as m (m)}
757
+ <button
758
+ type="button"
759
+ class={[
760
+ 'flex-1 rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors',
761
+ (m === 'detail' && activeMode === 'browse') || activeMode === m
762
+ ? 'bg-surface-overlay text-primary'
763
+ : 'text-secondary hover:bg-surface-overlay/70 hover:text-primary'
764
+ ]}
765
+ onclick={() => setMode(m === 'detail' ? 'browse' : m)}
766
+ >
767
+ {modeLabel(m)}
768
+ </button>
769
+ {/each}
770
+ {#if focusedRecord && caps.update}
771
+ <Button size="xs" variant="secondary" onclick={() => openEdit(focusedRecord!)}
772
+ >Edit</Button
773
+ >
774
+ {/if}
775
+ </div>
776
+ <div class="min-h-0 flex-1 overflow-y-auto p-3">
777
+ {#if activeMode === 'browse'}
778
+ {#key focusedRecord ? String(focusedRecord[primaryKey] ?? '') : 'none'}
779
+ {@render detailBody(focusedRecord)}
780
+ {/key}
781
+ {/if}
782
+ </div>
783
+ </aside>
784
+ {/if}
785
+ </div>
786
+ </div>
787
+ </div>
788
+
789
+ <Drawer
790
+ bind:open={formOpen}
791
+ title={formMode === 'create' ? L.addRecord : 'Edit record'}
792
+ side="right"
793
+ size="md"
794
+ >
795
+ {#if recordForm}
796
+ {@render recordForm(formMode, formRecord)}
797
+ {:else if activeResource}
798
+ <SchemaForm
799
+ schema={formSchema}
800
+ bind:values={draftForm}
801
+ showCancel
802
+ submitLabel="Save"
803
+ cancelLabel="Cancel"
804
+ onsubmit={submitForm}
805
+ oncancel={() => (formOpen = false)}
806
+ />
807
+ {/if}
808
+ </Drawer>
809
+
810
+ <Drawer bind:open={importOpen} title="Import" side="right" size="sm">
811
+ <DropZone
812
+ label="Drop a CSV or JSON file"
813
+ hint="Your app handles parsing via onimport"
814
+ multiple={false}
815
+ ondropfiles={(files) => {
816
+ const file = files?.[0];
817
+ if (file) {
818
+ onimport?.(file);
819
+ importOpen = false;
820
+ }
821
+ }}
822
+ />
823
+ </Drawer>
824
+
825
+ <ConfirmDelete
826
+ bind:open={deleteOpen}
827
+ entity={L.records}
828
+ itemName={`${pendingDeleteIds.length} selected`}
829
+ onconfirm={confirmDelete}
830
+ />