@happyvertical/smrt-ui 0.42.6 → 0.42.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.
- package/README.md +55 -2
- package/dist/components/data/CollectionToolbar.svelte +204 -4
- package/dist/components/data/CollectionToolbar.svelte.d.ts +7 -1
- package/dist/components/data/CollectionToolbar.svelte.d.ts.map +1 -1
- package/dist/components/data/DataTable.svelte +338 -2
- package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
- package/dist/components/data/DataTableController.d.ts +2 -0
- package/dist/components/data/DataTableController.d.ts.map +1 -1
- package/dist/components/data/DataTableController.js +4 -0
- package/dist/components/data/__tests__/data-surface.test.js +75 -4
- package/dist/components/data/__tests__/mounted-data-surface.test.js +794 -0
- package/dist/components/data/data-surface.d.ts +36 -0
- package/dist/components/data/data-surface.d.ts.map +1 -1
- package/dist/components/data/data-surface.js +189 -30
- package/dist/components/data/data-table-surface.d.ts +9 -0
- package/dist/components/data/data-table-surface.d.ts.map +1 -0
- package/dist/components/data/data-table-surface.js +195 -0
- package/dist/components/data/index.d.ts +1 -0
- package/dist/components/data/index.d.ts.map +1 -1
- package/dist/components/data/index.js +1 -0
- package/dist/components/data/types.d.ts +22 -0
- package/dist/components/data/types.d.ts.map +1 -1
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -39,8 +39,10 @@ pnpm add @happyvertical/smrt-ui
|
|
|
39
39
|
| Collections | `CollectionToolbar`, `CollectionList`/`ContentList`, `DataTable`, `Pagination` |
|
|
40
40
|
| Layout and navigation | `Container`, `Grid`, `Header`, `Footer`, `PageHeader`, `EmptyState`, `Tabs`, `FilterChips` |
|
|
41
41
|
|
|
42
|
-
Use the focused subpaths (`/forms`, `/ui`, `/feedback`, `/data`,
|
|
43
|
-
`/themes`) to keep imports explicit. The
|
|
42
|
+
Use the focused subpaths (`/forms`, `/ui`, `/feedback`, `/data`,
|
|
43
|
+
`/data-surface`, `/layout`, `/themes`) to keep imports explicit. The
|
|
44
|
+
Svelte-free `/data-surface` entry exposes the registry contracts and shared
|
|
45
|
+
protocol limits for server adapters. The package root remains a compatibility
|
|
44
46
|
barrel.
|
|
45
47
|
|
|
46
48
|
## Component standard
|
|
@@ -376,6 +378,57 @@ adapter, and authentication, tenancy, confirmation-token verification, and
|
|
|
376
378
|
durable actions remain server-side. URL state and saved views also remain
|
|
377
379
|
application-owned persistence adapters.
|
|
378
380
|
|
|
381
|
+
### DataTable and CollectionToolbar integration
|
|
382
|
+
|
|
383
|
+
Registration is opt-in. Pass `dataSurface` with an explicit descriptor and a
|
|
384
|
+
registry; existing `DataTable` and `CollectionToolbar` consumers do not
|
|
385
|
+
register or change behavior. Registration follows reactive `dataSurface` and
|
|
386
|
+
controller prop replacement, so registry commands never retain a prior mounted
|
|
387
|
+
instance. A DataTable descriptor must only name effective, visible columns,
|
|
388
|
+
except for its stable `rowKey`, which may remain non-rendered. Mounted tables
|
|
389
|
+
always require that `rowKey` to be an explicit string field; the index fallback
|
|
390
|
+
and functional key callbacks are never addressable across pages or refreshes.
|
|
391
|
+
|
|
392
|
+
```svelte
|
|
393
|
+
<script lang="ts">
|
|
394
|
+
import { DataTable, createDataSurfaceRegistry } from '@happyvertical/smrt-ui/data';
|
|
395
|
+
|
|
396
|
+
const registry = createDataSurfaceRegistry();
|
|
397
|
+
const dataSurface = {
|
|
398
|
+
registry,
|
|
399
|
+
descriptor: {
|
|
400
|
+
// descriptor omitted: give this mounted instance a stable identity,
|
|
401
|
+
// policy-visible columns, controls, query limits, and action descriptors
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
</script>
|
|
405
|
+
|
|
406
|
+
<DataTable {dataSurface} data={rows} {columns} rowKey="id" />
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Declared controller controls include search, filters, multi-sort, page/page
|
|
410
|
+
size, column layout, selection, expansion, reset, focus/reveal/highlight, and
|
|
411
|
+
optional refresh/retry callbacks. The component maps controller controls to the
|
|
412
|
+
same `DataTableController.dispatch()` path used by buttons and checkboxes. A
|
|
413
|
+
controlled table supplies `applyControlledState(candidate, command)`; the
|
|
414
|
+
registry acknowledges only after that callback settles the candidate state.
|
|
415
|
+
|
|
416
|
+
`CollectionToolbar` accepts the same opt-in registration and an optional
|
|
417
|
+
`controller`. Its `set-search` control shares that table controller; `set-view`
|
|
418
|
+
remains toolbar-local. Descriptors may advertise row/bulk action contracts, but
|
|
419
|
+
smrt-ui does not execute durable actions—the later authenticated action adapter
|
|
420
|
+
owns preview, confirmation, authorization, and persistence.
|
|
421
|
+
|
|
422
|
+
Toolbar snapshots also advance their revision when the host updates exposed
|
|
423
|
+
uncontrolled `search` or `view` props, so a command based on an earlier view is
|
|
424
|
+
rejected as stale instead of overwriting host state.
|
|
425
|
+
|
|
426
|
+
DataSurface columns may also carry domain-neutral policy metadata (`fieldName`,
|
|
427
|
+
`visibility`, `order`, `role`, `responsivePriority`, `readable`, and per-column
|
|
428
|
+
operator allowlists). Domain packages such as `@happyvertical/smrt-fields`
|
|
429
|
+
apply their effective policy above this package; `smrt-ui` validates and
|
|
430
|
+
serializes the metadata without owning field authorization or policy rules.
|
|
431
|
+
|
|
379
432
|
## Themes
|
|
380
433
|
|
|
381
434
|
`@happyvertical/smrt-ui/themes` is the canonical theme API and includes the
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type
|
|
2
|
+
import { onDestroy, type Snippet, untrack } from 'svelte';
|
|
3
3
|
import Input from '../forms/Input.svelte';
|
|
4
4
|
import SegmentedControl from '../forms/SegmentedControl.svelte';
|
|
5
|
+
import type {
|
|
6
|
+
DataTableController,
|
|
7
|
+
DataTableViewState,
|
|
8
|
+
} from './DataTableController.js';
|
|
9
|
+
import {
|
|
10
|
+
type DataSurfaceJsonValue,
|
|
11
|
+
type DataSurfaceRegistry,
|
|
12
|
+
normalizeDataSurfaceDescriptor,
|
|
13
|
+
} from './data-surface.js';
|
|
14
|
+
import type { CollectionToolbarDataSurfaceOptions } from './types.js';
|
|
5
15
|
|
|
6
16
|
export interface Props {
|
|
7
17
|
search?: string;
|
|
@@ -16,6 +26,10 @@ export interface Props {
|
|
|
16
26
|
bulkActions?: Snippet;
|
|
17
27
|
onsearchchange?: (value: string) => void;
|
|
18
28
|
onviewchange?: (view: 'list' | 'grid' | 'table') => void;
|
|
29
|
+
/** Shares search state with a DataTable controller when supplied. */
|
|
30
|
+
controller?: DataTableController;
|
|
31
|
+
/** Registers this toolbar only when explicitly supplied. */
|
|
32
|
+
dataSurface?: CollectionToolbarDataSurfaceOptions;
|
|
19
33
|
class?: string;
|
|
20
34
|
}
|
|
21
35
|
|
|
@@ -32,9 +46,36 @@ let {
|
|
|
32
46
|
bulkActions,
|
|
33
47
|
onsearchchange,
|
|
34
48
|
onviewchange,
|
|
49
|
+
controller,
|
|
50
|
+
dataSurface,
|
|
35
51
|
class: className = '',
|
|
36
52
|
}: Props = $props();
|
|
37
53
|
|
|
54
|
+
let controllerState = $state<DataTableViewState | undefined>(undefined);
|
|
55
|
+
let toolbarElement = $state<HTMLDivElement>();
|
|
56
|
+
let surfaceHighlighted = $state(false);
|
|
57
|
+
let surfaceRegistration:
|
|
58
|
+
| {
|
|
59
|
+
surface: CollectionToolbarDataSurfaceOptions;
|
|
60
|
+
registry: DataSurfaceRegistry;
|
|
61
|
+
descriptorSignature: string;
|
|
62
|
+
controller: DataTableController | undefined;
|
|
63
|
+
cleanup: () => void;
|
|
64
|
+
}
|
|
65
|
+
| undefined;
|
|
66
|
+
|
|
67
|
+
$effect(() => {
|
|
68
|
+
if (!controller) {
|
|
69
|
+
controllerState = undefined;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
controllerState = controller.getState();
|
|
73
|
+
return controller.subscribe((transition) => {
|
|
74
|
+
if (!transition.changed) return;
|
|
75
|
+
controllerState = transition.next.state;
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
38
79
|
const viewOptions = $derived(
|
|
39
80
|
views.map((value) => ({
|
|
40
81
|
value,
|
|
@@ -43,14 +84,172 @@ const viewOptions = $derived(
|
|
|
43
84
|
);
|
|
44
85
|
|
|
45
86
|
function changeView(next: string | number) {
|
|
46
|
-
|
|
87
|
+
const candidate = String(next);
|
|
88
|
+
if (!views.includes(candidate as typeof view)) return;
|
|
89
|
+
view = candidate as typeof view;
|
|
47
90
|
onviewchange?.(view);
|
|
48
91
|
}
|
|
92
|
+
|
|
93
|
+
function changeSearch(next: string) {
|
|
94
|
+
if (controller) {
|
|
95
|
+
const transition = controller.dispatch({ type: 'setSearch', search: next });
|
|
96
|
+
if (!transition.changed) return;
|
|
97
|
+
} else {
|
|
98
|
+
if (search === next) return;
|
|
99
|
+
search = next;
|
|
100
|
+
}
|
|
101
|
+
onsearchchange?.(next);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function payloadObject(
|
|
105
|
+
value: DataSurfaceJsonValue | undefined,
|
|
106
|
+
): Record<string, DataSurfaceJsonValue> | undefined {
|
|
107
|
+
return value && !Array.isArray(value) && typeof value === 'object'
|
|
108
|
+
? value
|
|
109
|
+
: undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
$effect(() => {
|
|
113
|
+
const surface = dataSurface;
|
|
114
|
+
const surfaceController = controller;
|
|
115
|
+
if (!surface) {
|
|
116
|
+
surfaceRegistration?.cleanup();
|
|
117
|
+
surfaceRegistration = undefined;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const descriptorSignature = JSON.stringify(
|
|
121
|
+
normalizeDataSurfaceDescriptor(surface.descriptor),
|
|
122
|
+
);
|
|
123
|
+
if (
|
|
124
|
+
surfaceRegistration?.registry === surface.registry &&
|
|
125
|
+
surfaceRegistration?.descriptorSignature === descriptorSignature &&
|
|
126
|
+
surfaceRegistration?.controller === surfaceController
|
|
127
|
+
) {
|
|
128
|
+
surfaceRegistration.surface = surface;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
surfaceRegistration?.cleanup();
|
|
132
|
+
surfaceRegistration = undefined;
|
|
133
|
+
const registration = {
|
|
134
|
+
surface,
|
|
135
|
+
registry: surface.registry,
|
|
136
|
+
descriptorSignature,
|
|
137
|
+
controller: surfaceController,
|
|
138
|
+
cleanup: () => {},
|
|
139
|
+
};
|
|
140
|
+
const cleanup = untrack(() => {
|
|
141
|
+
let revision = 0;
|
|
142
|
+
let previousStateSignature: string | undefined;
|
|
143
|
+
const readState = () => {
|
|
144
|
+
const state = {
|
|
145
|
+
search: surfaceController?.getState().search ?? search,
|
|
146
|
+
view,
|
|
147
|
+
};
|
|
148
|
+
const signature = JSON.stringify(state);
|
|
149
|
+
if (
|
|
150
|
+
previousStateSignature !== undefined &&
|
|
151
|
+
previousStateSignature !== signature
|
|
152
|
+
) {
|
|
153
|
+
revision += 1;
|
|
154
|
+
}
|
|
155
|
+
previousStateSignature = signature;
|
|
156
|
+
return state;
|
|
157
|
+
};
|
|
158
|
+
let highlightTimer: ReturnType<typeof setTimeout> | undefined;
|
|
159
|
+
const unregister = surface.registry.register({
|
|
160
|
+
descriptor: surface.descriptor,
|
|
161
|
+
getSnapshot: () => {
|
|
162
|
+
const state = readState();
|
|
163
|
+
return { revision, state, selection: null };
|
|
164
|
+
},
|
|
165
|
+
execute: async (command) => {
|
|
166
|
+
const payload = payloadObject(command.payload);
|
|
167
|
+
if (
|
|
168
|
+
command.controlId === 'set-search' &&
|
|
169
|
+
typeof payload?.search === 'string'
|
|
170
|
+
) {
|
|
171
|
+
if (surfaceController) {
|
|
172
|
+
const transition = surfaceController.dispatch({
|
|
173
|
+
type: 'setSearch',
|
|
174
|
+
search: payload.search,
|
|
175
|
+
});
|
|
176
|
+
if (surfaceController.isControlled() && transition.changed) {
|
|
177
|
+
const settled = await registration.surface.applyControlledState?.(
|
|
178
|
+
transition.next.state,
|
|
179
|
+
{ type: 'setSearch', search: payload.search },
|
|
180
|
+
);
|
|
181
|
+
if (settled) surfaceController.replaceState(settled);
|
|
182
|
+
if (
|
|
183
|
+
JSON.stringify(surfaceController.getState()) !==
|
|
184
|
+
JSON.stringify(transition.next.state)
|
|
185
|
+
) {
|
|
186
|
+
return { ok: false };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
onsearchchange?.(payload.search);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
changeSearch(payload.search);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (
|
|
196
|
+
command.controlId === 'set-view' &&
|
|
197
|
+
typeof payload?.view === 'string'
|
|
198
|
+
) {
|
|
199
|
+
const prior = view;
|
|
200
|
+
changeView(payload.view);
|
|
201
|
+
return view === prior && payload.view !== prior
|
|
202
|
+
? { ok: false }
|
|
203
|
+
: undefined;
|
|
204
|
+
}
|
|
205
|
+
switch (command.controlId) {
|
|
206
|
+
case 'focus':
|
|
207
|
+
toolbarElement?.focus();
|
|
208
|
+
return;
|
|
209
|
+
case 'reveal':
|
|
210
|
+
toolbarElement?.scrollIntoView({ block: 'nearest' });
|
|
211
|
+
return;
|
|
212
|
+
case 'highlight':
|
|
213
|
+
surfaceHighlighted = true;
|
|
214
|
+
if (highlightTimer) clearTimeout(highlightTimer);
|
|
215
|
+
highlightTimer = setTimeout(() => {
|
|
216
|
+
surfaceHighlighted = false;
|
|
217
|
+
}, 1_000);
|
|
218
|
+
return;
|
|
219
|
+
case 'refresh':
|
|
220
|
+
if (!registration.surface.onRefresh) return { ok: false };
|
|
221
|
+
await registration.surface.onRefresh();
|
|
222
|
+
return;
|
|
223
|
+
case 'retry':
|
|
224
|
+
if (!registration.surface.onRetry) return { ok: false };
|
|
225
|
+
await registration.surface.onRetry();
|
|
226
|
+
return;
|
|
227
|
+
default:
|
|
228
|
+
return { ok: false };
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
return () => {
|
|
233
|
+
if (highlightTimer) clearTimeout(highlightTimer);
|
|
234
|
+
unregister();
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
registration.cleanup = cleanup;
|
|
238
|
+
surfaceRegistration = registration;
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
onDestroy(() => surfaceRegistration?.cleanup());
|
|
49
242
|
</script>
|
|
50
243
|
|
|
51
|
-
<div
|
|
244
|
+
<div
|
|
245
|
+
bind:this={toolbarElement}
|
|
246
|
+
class="toolbar {className}"
|
|
247
|
+
class:toolbar--highlighted={surfaceHighlighted}
|
|
248
|
+
role="search"
|
|
249
|
+
tabindex="-1"
|
|
250
|
+
>
|
|
52
251
|
<div class="search">
|
|
53
|
-
<Input type="search" name="collection-search" aria-label={searchLabel} placeholder={searchPlaceholder}
|
|
252
|
+
<Input type="search" name="collection-search" aria-label={searchLabel} placeholder={searchPlaceholder} value={controllerState?.search ?? search} oninput={(event) => changeSearch(event.currentTarget.value)} />
|
|
54
253
|
</div>
|
|
55
254
|
{#if filters}<div class="filters">{@render filters()}</div>{/if}
|
|
56
255
|
{#if resultCount !== undefined}<span class="count" aria-live="polite">{resultCount} {resultCount === 1 ? 'result' : 'results'}</span>{/if}
|
|
@@ -64,6 +263,7 @@ function changeView(next: string | number) {
|
|
|
64
263
|
|
|
65
264
|
<style>
|
|
66
265
|
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--smrt-spacing-2); padding: var(--smrt-spacing-2) 0; color: var(--smrt-color-on-surface); }
|
|
266
|
+
.toolbar--highlighted { outline: 2px solid var(--smrt-color-primary, #2563eb); outline-offset: 3px; }
|
|
67
267
|
.search { flex: 1 1 14rem; max-width: 24rem; }
|
|
68
268
|
.filters, .actions, .bulk { display: flex; align-items: center; gap: var(--smrt-spacing-2); }
|
|
69
269
|
.bulk { padding: var(--smrt-spacing-1) var(--smrt-spacing-2); border-radius: var(--smrt-radius-small); background: var(--smrt-color-secondary-container); color: var(--smrt-color-on-secondary-container); }
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
import type { DataTableController } from './DataTableController.js';
|
|
3
|
+
import type { CollectionToolbarDataSurfaceOptions } from './types.js';
|
|
2
4
|
export interface Props {
|
|
3
5
|
search?: string;
|
|
4
6
|
searchLabel?: string;
|
|
@@ -12,6 +14,10 @@ export interface Props {
|
|
|
12
14
|
bulkActions?: Snippet;
|
|
13
15
|
onsearchchange?: (value: string) => void;
|
|
14
16
|
onviewchange?: (view: 'list' | 'grid' | 'table') => void;
|
|
17
|
+
/** Shares search state with a DataTable controller when supplied. */
|
|
18
|
+
controller?: DataTableController;
|
|
19
|
+
/** Registers this toolbar only when explicitly supplied. */
|
|
20
|
+
dataSurface?: CollectionToolbarDataSurfaceOptions;
|
|
15
21
|
class?: string;
|
|
16
22
|
}
|
|
17
23
|
declare const CollectionToolbar: import("svelte").Component<Props, {}, "view" | "search">;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CollectionToolbar.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/data/CollectionToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"CollectionToolbar.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/data/CollectionToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAAa,KAAK,OAAO,EAAW,MAAM,QAAQ,CAAC;AAG1D,OAAO,KAAK,EACV,mBAAmB,EAEpB,MAAM,0BAA0B,CAAC;AAMlC,OAAO,KAAK,EAAE,mCAAmC,EAAE,MAAM,YAAY,CAAC;AAGtE,MAAM,WAAW,KAAK;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;IACjC,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC;IACzC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,KAAK,IAAI,CAAC;IACzD,qEAAqE;IACrE,UAAU,CAAC,EAAE,mBAAmB,CAAC;IACjC,4DAA4D;IAC5D,WAAW,CAAC,EAAE,mCAAmC,CAAC;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA4OD,QAAA,MAAM,iBAAiB,0DAAwC,CAAC;AAChE,KAAK,iBAAiB,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAC9D,eAAe,iBAAiB,CAAC"}
|