@happyvertical/smrt-ui 0.42.6 → 0.43.0

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 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`, `/layout`,
43
- `/themes`) to keep imports explicit. The package root remains a compatibility
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 { Snippet } from 'svelte';
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
- view = String(next) as typeof view;
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 class="toolbar {className}" role="search">
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} bind:value={search} oninput={(event) => onsearchchange?.(event.currentTarget.value)} />
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 { Snippet } from 'svelte';
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,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAKtC,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,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAsDD,QAAA,MAAM,iBAAiB,0DAAwC,CAAC;AAChE,KAAK,iBAAiB,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAC9D,eAAe,iBAAiB,CAAC"}
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"}