@happyvertical/smrt-ui 0.43.4 → 0.43.6

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 (108) hide show
  1. package/AGENTS.md +33 -0
  2. package/README.md +78 -4
  3. package/dist/components/display/CurrencyDisplay.contract.d.ts +7 -0
  4. package/dist/components/display/CurrencyDisplay.contract.d.ts.map +1 -0
  5. package/dist/components/display/CurrencyDisplay.contract.js +1 -0
  6. package/dist/components/display/CurrencyDisplay.svelte +148 -17
  7. package/dist/components/display/CurrencyDisplay.svelte.d.ts +8 -5
  8. package/dist/components/display/CurrencyDisplay.svelte.d.ts.map +1 -1
  9. package/dist/components/display/__tests__/CurrencyDisplay.test.js +193 -3
  10. package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte +20 -0
  11. package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte.d.ts +19 -0
  12. package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte.d.ts.map +1 -0
  13. package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte +7 -0
  14. package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte.d.ts +19 -0
  15. package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte.d.ts.map +1 -0
  16. package/dist/components/display/currency-metadata.d.ts +2 -0
  17. package/dist/components/display/currency-metadata.d.ts.map +1 -0
  18. package/dist/components/display/currency-metadata.js +14 -0
  19. package/dist/components/forms/Checkbox.svelte +15 -6
  20. package/dist/components/forms/Checkbox.svelte.d.ts.map +1 -1
  21. package/dist/components/forms/Combobox.svelte +45 -10
  22. package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
  23. package/dist/components/forms/FilePicker.svelte +5 -2
  24. package/dist/components/forms/Form.svelte +57 -0
  25. package/dist/components/forms/Form.svelte.d.ts +2 -0
  26. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  27. package/dist/components/forms/Input.svelte +59 -13
  28. package/dist/components/forms/Input.svelte.d.ts.map +1 -1
  29. package/dist/components/forms/Listbox.svelte +33 -6
  30. package/dist/components/forms/Listbox.svelte.d.ts.map +1 -1
  31. package/dist/components/forms/MultiSelect.svelte +41 -19
  32. package/dist/components/forms/MultiSelect.svelte.d.ts.map +1 -1
  33. package/dist/components/forms/RadioGroup.svelte +14 -3
  34. package/dist/components/forms/RadioGroup.svelte.d.ts.map +1 -1
  35. package/dist/components/forms/RangeSlider.svelte +61 -6
  36. package/dist/components/forms/RangeSlider.svelte.d.ts.map +1 -1
  37. package/dist/components/forms/SegmentedControl.svelte +33 -6
  38. package/dist/components/forms/SegmentedControl.svelte.d.ts.map +1 -1
  39. package/dist/components/forms/Select.svelte +32 -9
  40. package/dist/components/forms/Select.svelte.d.ts.map +1 -1
  41. package/dist/components/forms/Slider.svelte +26 -6
  42. package/dist/components/forms/Slider.svelte.d.ts.map +1 -1
  43. package/dist/components/forms/StagedControlReview.svelte +637 -0
  44. package/dist/components/forms/StagedControlReview.svelte.d.ts +13 -0
  45. package/dist/components/forms/StagedControlReview.svelte.d.ts.map +1 -0
  46. package/dist/components/forms/Switch.svelte +16 -7
  47. package/dist/components/forms/Switch.svelte.d.ts.map +1 -1
  48. package/dist/components/forms/TagsInput.svelte +36 -8
  49. package/dist/components/forms/TagsInput.svelte.d.ts.map +1 -1
  50. package/dist/components/forms/Textarea.svelte +20 -8
  51. package/dist/components/forms/Textarea.svelte.d.ts.map +1 -1
  52. package/dist/components/forms/ToggleButton.svelte +20 -3
  53. package/dist/components/forms/ToggleButton.svelte.d.ts.map +1 -1
  54. package/dist/components/forms/__tests__/Form.test.js +106 -0
  55. package/dist/components/forms/__tests__/StagedControlReview.test.js +668 -0
  56. package/dist/components/forms/__tests__/composite-controls.fixture.svelte +22 -4
  57. package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts +6 -2
  58. package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts.map +1 -1
  59. package/dist/components/forms/__tests__/composite-controls.test.js +96 -1
  60. package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte +41 -0
  61. package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts +13 -0
  62. package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts.map +1 -0
  63. package/dist/components/forms/__tests__/control-interaction.test.js +3323 -15
  64. package/dist/components/forms/__tests__/core-controls.fixture.svelte +16 -5
  65. package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts +1 -0
  66. package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts.map +1 -1
  67. package/dist/components/forms/__tests__/core-controls.test.js +223 -2
  68. package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte +25 -0
  69. package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts +8 -0
  70. package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts.map +1 -0
  71. package/dist/components/forms/__tests__/select-interaction.fixture.svelte +25 -0
  72. package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts +9 -0
  73. package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts.map +1 -0
  74. package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte +21 -0
  75. package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts +8 -0
  76. package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts.map +1 -0
  77. package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte +23 -0
  78. package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts +8 -0
  79. package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts.map +1 -0
  80. package/dist/components/forms/__tests__/staged-review.fixture.svelte +56 -0
  81. package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts +12 -0
  82. package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts.map +1 -0
  83. package/dist/components/forms/control-interaction-context.d.ts +9 -1
  84. package/dist/components/forms/control-interaction-context.d.ts.map +1 -1
  85. package/dist/components/forms/control-interaction-context.js +16 -0
  86. package/dist/components/forms/control-interaction.d.ts +97 -7
  87. package/dist/components/forms/control-interaction.d.ts.map +1 -1
  88. package/dist/components/forms/control-interaction.js +1019 -92
  89. package/dist/components/forms/control-value-validation.d.ts +19 -0
  90. package/dist/components/forms/control-value-validation.d.ts.map +1 -0
  91. package/dist/components/forms/control-value-validation.js +152 -0
  92. package/dist/components/forms/index.d.ts +3 -1
  93. package/dist/components/forms/index.d.ts.map +1 -1
  94. package/dist/components/forms/index.js +2 -1
  95. package/dist/components/forms/staged-control-review.d.ts +21 -0
  96. package/dist/components/forms/staged-control-review.d.ts.map +1 -0
  97. package/dist/components/forms/staged-control-review.js +1 -0
  98. package/dist/components/forms/use-control-registration.svelte.d.ts.map +1 -1
  99. package/dist/components/forms/use-control-registration.svelte.js +23 -4
  100. package/dist/i18n/strings.d.ts +3 -0
  101. package/dist/i18n/strings.d.ts.map +1 -1
  102. package/dist/i18n/strings.js +3 -0
  103. package/dist/i18n/strings.ui.d.ts +18 -0
  104. package/dist/i18n/strings.ui.d.ts.map +1 -1
  105. package/dist/i18n/strings.ui.js +19 -0
  106. package/dist/svelte/playground/InteractiveControlsPreview.svelte +3 -4
  107. package/dist/svelte/playground/InteractiveControlsPreview.svelte.d.ts.map +1 -1
  108. package/package.json +2 -2
@@ -0,0 +1,637 @@
1
+ <script lang="ts">
2
+ import { tick, untrack } from 'svelte';
3
+ import { M, useI18n } from '../../i18n/index.js';
4
+ import type {
5
+ ControlCommand,
6
+ ControlInteractionRegistry,
7
+ ControlSnapshot,
8
+ } from './control-interaction.js';
9
+ import {
10
+ executeLocalControlBatch,
11
+ executeLocalControlCommand,
12
+ } from './control-interaction.js';
13
+ import type { StagedControlReviewLabels } from './staged-control-review.js';
14
+
15
+ const { t } = useI18n();
16
+
17
+ export interface Props {
18
+ registry: ControlInteractionRegistry;
19
+ formId: string;
20
+ formElement?: HTMLFormElement | null;
21
+ summary?: boolean;
22
+ labels?: Partial<StagedControlReviewLabels>;
23
+ }
24
+
25
+ let {
26
+ registry,
27
+ formId,
28
+ formElement = null,
29
+ summary = true,
30
+ labels,
31
+ }: Props = $props();
32
+ const text = $derived({
33
+ region: t(M['ui.staged_control_review.region']),
34
+ heading: t(M['ui.staged_control_review.heading']),
35
+ description: t(M['ui.staged_control_review.description']),
36
+ proposedBy: t(M['ui.staged_control_review.proposed_by']),
37
+ stagedAt: t(M['ui.staged_control_review.staged_at']),
38
+ before: t(M['ui.staged_control_review.before']),
39
+ after: t(M['ui.staged_control_review.after']),
40
+ redacted: t(M['ui.staged_control_review.redacted']),
41
+ stale: t(M['ui.staged_control_review.stale']),
42
+ invalid: t(M['ui.staged_control_review.invalid']),
43
+ apply: t(M['ui.staged_control_review.apply']),
44
+ discard: t(M['ui.staged_control_review.discard']),
45
+ applyAll: t(M['ui.staged_control_review.apply_all']),
46
+ discardAll: t(M['ui.staged_control_review.discard_all']),
47
+ edit: t(M['ui.staged_control_review.edit']),
48
+ appliedStatus: t(M['ui.staged_control_review.applied_status']),
49
+ discardedStatus: t(M['ui.staged_control_review.discarded_status']),
50
+ batchStatus: t(M['ui.staged_control_review.batch_status']),
51
+ ...labels,
52
+ });
53
+ let snapshots = $state<ControlSnapshot[]>([]);
54
+ let drafts = $state<Record<string, string>>({});
55
+ let feedback = $state<Record<string, string>>({});
56
+ let status = $state('');
57
+ let reviewActivated = $state(false);
58
+ let reviewElement = $state<HTMLElement | null>(null);
59
+
60
+ const keyOf = (snapshot: ControlSnapshot) =>
61
+ JSON.stringify([
62
+ snapshot.identity.formId,
63
+ snapshot.identity.controlId,
64
+ snapshot.identity.subject?.type ?? null,
65
+ snapshot.identity.subject?.id ?? null,
66
+ ]);
67
+
68
+ const draftKeyOf = (snapshot: ControlSnapshot) =>
69
+ JSON.stringify([keyOf(snapshot), snapshot.state.staged?.revision ?? null]);
70
+
71
+ function formatValue(value: unknown): string {
72
+ if (value === undefined) return '';
73
+ if (typeof value === 'string') return value;
74
+ try {
75
+ return JSON.stringify(value);
76
+ } catch {
77
+ return String(value);
78
+ }
79
+ }
80
+
81
+ function formatIdentity(snapshot: ControlSnapshot): string {
82
+ const { formId, controlId, subject } = snapshot.identity;
83
+ return subject
84
+ ? `${formId}/${controlId} · ${subject.type}:${subject.id}`
85
+ : `${formId}/${controlId}`;
86
+ }
87
+
88
+ function actionIdentity(snapshot: ControlSnapshot): string {
89
+ const label = snapshot.metadata.label ?? snapshot.identity.controlId;
90
+ const sameLabel = snapshots.filter(
91
+ (candidate) =>
92
+ (candidate.metadata.label ?? candidate.identity.controlId) === label,
93
+ );
94
+ if (sameLabel.length < 2) return label;
95
+
96
+ const subject = snapshot.identity.subject;
97
+ const humanIdentity = subject?.label
98
+ ? subject.label
99
+ : subject
100
+ ? `${subject.type}:${subject.id}`
101
+ : snapshot.identity.controlId;
102
+ // The human-readable portion makes repeated field labels understandable, but
103
+ // cannot authorize a control: labels are mutable and user-provided. Append
104
+ // the immutable registry identity in a tagged, structurally delimited form
105
+ // so every review editor and command name remains collision-free.
106
+ return `${label} ${humanIdentity} [identity:${keyOf(snapshot)}]`;
107
+ }
108
+
109
+ function refresh(): void {
110
+ const nextSnapshots = registry
111
+ .list(formId)
112
+ .filter((snapshot) => snapshot.state.staged !== undefined);
113
+ if (nextSnapshots.length > 0) reviewActivated = true;
114
+ const next = { ...untrack(() => drafts) };
115
+ for (const snapshot of nextSnapshots) {
116
+ const key = draftKeyOf(snapshot);
117
+ if (!(key in next) && !snapshot.state.staged?.valueRedacted) {
118
+ next[key] = formatValue(snapshot.state.staged?.value);
119
+ }
120
+ }
121
+ const live = new Set(nextSnapshots.map(draftKeyOf));
122
+ for (const key of Object.keys(next)) {
123
+ if (!live.has(key)) delete next[key];
124
+ }
125
+ const nextFeedback = { ...untrack(() => feedback) };
126
+ for (const key of Object.keys(nextFeedback)) {
127
+ if (!live.has(key)) delete nextFeedback[key];
128
+ }
129
+ snapshots = nextSnapshots;
130
+ drafts = next;
131
+ feedback = nextFeedback;
132
+ }
133
+
134
+ $effect(() => {
135
+ refresh();
136
+ return registry.subscribe(() => refresh());
137
+ });
138
+
139
+ $effect(() => {
140
+ if (!formElement) return;
141
+ const element = formElement;
142
+ const refreshAfterFieldEdit = () => refresh();
143
+ const refreshAfterReset = () => queueMicrotask(refresh);
144
+ const discardAfterResetGesture = (event: MouseEvent) => {
145
+ const target = event.target;
146
+ if (!(target instanceof Element)) return;
147
+ const resetControl = target.closest<HTMLElement>(
148
+ 'button[type="reset"], input[type="reset"]',
149
+ );
150
+ if (!resetControl || !element.contains(resetControl)) return;
151
+ const commands = registry
152
+ .list(formId)
153
+ .filter((snapshot) => snapshot.state.staged)
154
+ .map((snapshot) => ({
155
+ action: 'discard' as const,
156
+ identity: snapshot.identity,
157
+ revision: snapshot.state.staged?.revision,
158
+ }));
159
+ void executeLocalControlBatch(registry, commands, event);
160
+ };
161
+ element.addEventListener('input', refreshAfterFieldEdit);
162
+ element.addEventListener('change', refreshAfterFieldEdit);
163
+ element.addEventListener('reset', refreshAfterReset);
164
+ element.addEventListener('click', discardAfterResetGesture, true);
165
+ return () => {
166
+ element.removeEventListener('input', refreshAfterFieldEdit);
167
+ element.removeEventListener('change', refreshAfterFieldEdit);
168
+ element.removeEventListener('reset', refreshAfterReset);
169
+ element.removeEventListener('click', discardAfterResetGesture, true);
170
+ };
171
+ });
172
+
173
+ // A control can become effectively disabled when an ancestor fieldset changes,
174
+ // even though the control's own attributes do not. Refresh the shared registry
175
+ // from the shared review component so base and Provider-backed Forms agree on
176
+ // the live DOM state. The review surface changes its own action-button state,
177
+ // so ignore its mutations to avoid observing our own rendering feedback.
178
+ $effect(() => {
179
+ const element = formElement;
180
+ const MutationObserverConstructor =
181
+ element?.ownerDocument.defaultView?.MutationObserver;
182
+ if (!element || !MutationObserverConstructor) return;
183
+ const observer = new MutationObserverConstructor((records) => {
184
+ if (
185
+ records.some(
186
+ ({ target }) =>
187
+ target instanceof Element &&
188
+ !target.closest('[data-staged-control-review]'),
189
+ )
190
+ ) {
191
+ registry.refresh?.(formId);
192
+ }
193
+ });
194
+ observer.observe(element, {
195
+ attributes: true,
196
+ subtree: true,
197
+ attributeFilter: ['disabled', 'readonly'],
198
+ });
199
+ for (
200
+ let ancestor = element.parentElement;
201
+ ancestor;
202
+ ancestor = ancestor.parentElement
203
+ ) {
204
+ if (ancestor instanceof HTMLFieldSetElement) {
205
+ observer.observe(ancestor, {
206
+ attributes: true,
207
+ attributeFilter: ['disabled'],
208
+ });
209
+ }
210
+ }
211
+ return () => observer.disconnect();
212
+ });
213
+
214
+ $effect(() => {
215
+ if (!formElement) return;
216
+ const stagedKeys = new Set(
217
+ snapshots.map((snapshot) =>
218
+ JSON.stringify([
219
+ snapshot.identity.controlId,
220
+ snapshot.identity.subject?.type ?? null,
221
+ snapshot.identity.subject?.id ?? null,
222
+ ]),
223
+ ),
224
+ );
225
+ const controls = formElement.querySelectorAll<HTMLElement>(
226
+ '[data-smrt-control]',
227
+ );
228
+ controls.forEach((control) => {
229
+ const controlKey = JSON.stringify([
230
+ control.dataset.smrtControl ?? '',
231
+ control.dataset.smrtSubjectType ?? null,
232
+ control.dataset.smrtSubjectId ?? null,
233
+ ]);
234
+ if (stagedKeys.has(controlKey)) {
235
+ control.dataset.smrtStaged = 'true';
236
+ } else {
237
+ delete control.dataset.smrtStaged;
238
+ }
239
+ });
240
+ return () => {
241
+ controls.forEach((control) => {
242
+ delete control.dataset.smrtStaged;
243
+ });
244
+ };
245
+ });
246
+
247
+ function editedValue(snapshot: ControlSnapshot): unknown {
248
+ const staged = snapshot.state.staged;
249
+ if (!staged || staged.valueRedacted) return staged?.value;
250
+ const draft = drafts[draftKeyOf(snapshot)] ?? '';
251
+ const original = staged.value;
252
+ if (typeof original === 'number') {
253
+ if (draft.trim().length === 0) throw new Error('invalid_number');
254
+ const number = Number(draft);
255
+ if (!Number.isFinite(number)) throw new Error('invalid_number');
256
+ return number;
257
+ }
258
+ if (typeof original === 'boolean') {
259
+ if (draft !== 'true' && draft !== 'false') {
260
+ throw new Error('invalid_boolean');
261
+ }
262
+ return draft === 'true';
263
+ }
264
+ if (original === null) return JSON.parse(draft);
265
+ if (original !== null && typeof original === 'object')
266
+ return JSON.parse(draft);
267
+ return draft;
268
+ }
269
+
270
+ function failureStatus(reason?: string): string {
271
+ return reason === 'staged_value_stale' || reason === 'stale_revision'
272
+ ? text.stale
273
+ : text.invalid;
274
+ }
275
+
276
+ function setFeedback(snapshot: ControlSnapshot, message: string): void {
277
+ feedback = { ...feedback, [draftKeyOf(snapshot)]: message };
278
+ }
279
+
280
+ function clearFeedback(snapshot: ControlSnapshot): void {
281
+ const key = draftKeyOf(snapshot);
282
+ if (!(key in feedback)) return;
283
+ const next = { ...feedback };
284
+ delete next[key];
285
+ feedback = next;
286
+ }
287
+
288
+ function commandFor(
289
+ snapshot: ControlSnapshot,
290
+ action: 'apply' | 'discard',
291
+ ): ControlCommand {
292
+ const revision = snapshot.state.staged?.revision;
293
+ if (action === 'discard')
294
+ return { action, identity: snapshot.identity, revision };
295
+ return {
296
+ action,
297
+ identity: snapshot.identity,
298
+ revision,
299
+ value: editedValue(snapshot),
300
+ reviewedValueIsCanonical: true,
301
+ };
302
+ }
303
+
304
+ function controlFor(snapshot: ControlSnapshot): HTMLElement | undefined {
305
+ return Array.from(
306
+ formElement?.querySelectorAll<HTMLElement>('[data-smrt-control]') ?? [],
307
+ ).find(
308
+ (control) =>
309
+ control.dataset.smrtControl === snapshot.identity.controlId &&
310
+ (control.dataset.smrtSubjectType ?? undefined) ===
311
+ snapshot.identity.subject?.type &&
312
+ (control.dataset.smrtSubjectId ?? undefined) ===
313
+ snapshot.identity.subject?.id,
314
+ );
315
+ }
316
+
317
+ async function restoreReviewFocus(
318
+ removed: ControlSnapshot,
319
+ removedIndex: number,
320
+ ): Promise<void> {
321
+ const originalControl = controlFor(removed);
322
+ await tick();
323
+ const focusOriginalControl = async () => {
324
+ const focused = await registry.execute(
325
+ { action: 'focus', identity: removed.identity },
326
+ { source: 'user' },
327
+ );
328
+ const registryFocusedOriginal =
329
+ focused.ok &&
330
+ document.activeElement instanceof HTMLElement &&
331
+ document.activeElement !== document.body &&
332
+ formElement?.contains(document.activeElement) === true &&
333
+ reviewElement?.contains(document.activeElement) !== true;
334
+ if (registryFocusedOriginal) return;
335
+ const directControl =
336
+ originalControl &&
337
+ !originalControl.matches(':disabled') &&
338
+ originalControl.matches(
339
+ 'button, input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])',
340
+ )
341
+ ? originalControl
342
+ : undefined;
343
+ const focusableSelector =
344
+ 'button:not(:disabled), input:not([type="hidden"]):not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])';
345
+ const candidates = [
346
+ ...(directControl ? [directControl] : []),
347
+ ...Array.from(
348
+ originalControl?.querySelectorAll<HTMLElement>(focusableSelector) ?? [],
349
+ ),
350
+ ...Array.from(
351
+ formElement?.querySelectorAll<HTMLElement>(focusableSelector) ?? [],
352
+ ),
353
+ ];
354
+ for (const candidate of candidates) {
355
+ candidate.focus();
356
+ if (
357
+ document.activeElement === candidate ||
358
+ candidate.contains(document.activeElement)
359
+ ) {
360
+ return;
361
+ }
362
+ }
363
+ };
364
+ if (
365
+ !registry
366
+ .list(formId)
367
+ .some((snapshot) => snapshot.state.staged !== undefined)
368
+ ) {
369
+ await focusOriginalControl();
370
+ return;
371
+ }
372
+ const items = reviewElement?.querySelectorAll<HTMLElement>(
373
+ '[data-staged-review-item]',
374
+ );
375
+ const target = items?.[Math.min(removedIndex, Math.max(0, items.length - 1))];
376
+ const nextAction = target?.querySelector<HTMLButtonElement>(
377
+ 'button:not(:disabled)',
378
+ );
379
+ const fallback = reviewElement?.querySelector<HTMLButtonElement>(
380
+ '.batch-actions button:not(:disabled)',
381
+ );
382
+ if (nextAction ?? fallback) {
383
+ (nextAction ?? fallback)?.focus();
384
+ return;
385
+ }
386
+ await focusOriginalControl();
387
+ }
388
+
389
+ async function applyOne(
390
+ snapshot: ControlSnapshot,
391
+ event: MouseEvent,
392
+ ): Promise<void> {
393
+ const removedIndex = snapshots.findIndex(
394
+ (candidate) => keyOf(candidate) === keyOf(snapshot),
395
+ );
396
+ try {
397
+ const result = await executeLocalControlCommand(
398
+ registry,
399
+ commandFor(snapshot, 'apply'),
400
+ event,
401
+ );
402
+ status = result.ok ? text.appliedStatus : failureStatus(result.reason);
403
+ if (result.ok) {
404
+ clearFeedback(snapshot);
405
+ await restoreReviewFocus(snapshot, removedIndex);
406
+ } else {
407
+ setFeedback(snapshot, failureStatus(result.reason));
408
+ }
409
+ } catch {
410
+ status = text.invalid;
411
+ setFeedback(snapshot, text.invalid);
412
+ }
413
+ }
414
+
415
+ async function discardOne(
416
+ snapshot: ControlSnapshot,
417
+ event: MouseEvent,
418
+ ): Promise<void> {
419
+ const removedIndex = snapshots.findIndex(
420
+ (candidate) => keyOf(candidate) === keyOf(snapshot),
421
+ );
422
+ const result = await executeLocalControlCommand(
423
+ registry,
424
+ commandFor(snapshot, 'discard'),
425
+ event,
426
+ );
427
+ status = result.ok ? text.discardedStatus : failureStatus(result.reason);
428
+ if (result.ok) {
429
+ clearFeedback(snapshot);
430
+ await restoreReviewFocus(snapshot, removedIndex);
431
+ } else {
432
+ setFeedback(snapshot, failureStatus(result.reason));
433
+ }
434
+ }
435
+
436
+ async function applyAll(event: MouseEvent): Promise<void> {
437
+ const total = snapshots.length;
438
+ const commands: ControlCommand[] = [];
439
+ const commandSnapshots: ControlSnapshot[] = [];
440
+ for (const snapshot of snapshots) {
441
+ if (
442
+ snapshot.state.disabled ||
443
+ snapshot.state.readonly ||
444
+ snapshot.state.staged?.stale
445
+ ) {
446
+ setFeedback(
447
+ snapshot,
448
+ snapshot.state.staged?.stale ? text.stale : text.invalid,
449
+ );
450
+ continue;
451
+ }
452
+ if (snapshot.state.staged?.valid === false) {
453
+ setFeedback(snapshot, text.invalid);
454
+ continue;
455
+ }
456
+ try {
457
+ commands.push(commandFor(snapshot, 'apply'));
458
+ commandSnapshots.push(snapshot);
459
+ } catch {
460
+ setFeedback(snapshot, text.invalid);
461
+ }
462
+ }
463
+ const batch = await executeLocalControlBatch(registry, commands, event);
464
+ const completedSnapshots: ControlSnapshot[] = [];
465
+ for (const [index, result] of batch.results.entries()) {
466
+ const snapshot = commandSnapshots[index];
467
+ if (!snapshot) continue;
468
+ if (result.ok) {
469
+ clearFeedback(snapshot);
470
+ completedSnapshots.push(snapshot);
471
+ } else {
472
+ setFeedback(snapshot, failureStatus(result.reason));
473
+ }
474
+ }
475
+ const completed = batch.results.filter((result) => result.ok).length;
476
+ status = text.batchStatus
477
+ .replace('{completed}', String(completed))
478
+ .replace('{total}', String(total));
479
+ if (completedSnapshots[0]) await restoreReviewFocus(completedSnapshots[0], 0);
480
+ }
481
+
482
+ async function discardAll(event: MouseEvent): Promise<void> {
483
+ const total = snapshots.length;
484
+ const eligible: ControlSnapshot[] = [];
485
+ for (const snapshot of snapshots) {
486
+ if (snapshot.state.staged?.stale) {
487
+ setFeedback(snapshot, text.stale);
488
+ } else if (snapshot.state.staged?.valid === false) {
489
+ setFeedback(snapshot, text.invalid);
490
+ } else {
491
+ eligible.push(snapshot);
492
+ }
493
+ }
494
+ const batch = await executeLocalControlBatch(
495
+ registry,
496
+ eligible.map((snapshot) => commandFor(snapshot, 'discard')),
497
+ event,
498
+ );
499
+ const completedSnapshots: ControlSnapshot[] = [];
500
+ for (const [index, result] of batch.results.entries()) {
501
+ const snapshot = eligible[index];
502
+ if (!snapshot) continue;
503
+ if (result.ok) {
504
+ clearFeedback(snapshot);
505
+ completedSnapshots.push(snapshot);
506
+ } else {
507
+ setFeedback(snapshot, failureStatus(result.reason));
508
+ }
509
+ }
510
+ const completed = batch.results.filter((result) => result.ok).length;
511
+ status = text.batchStatus
512
+ .replace('{completed}', String(completed))
513
+ .replace('{total}', String(total));
514
+ if (completedSnapshots[0]) await restoreReviewFocus(completedSnapshots[0], 0);
515
+ }
516
+ </script>
517
+
518
+ {#if summary && snapshots.length > 0}
519
+ <section
520
+ bind:this={reviewElement}
521
+ class="staged-review"
522
+ data-staged-control-review
523
+ aria-label={text.region}
524
+ >
525
+ <header>
526
+ <div>
527
+ <h2>{text.heading}</h2>
528
+ <p>{text.description}</p>
529
+ </div>
530
+ <div class="batch-actions">
531
+ <button type="button" onclick={applyAll}>{text.applyAll}</button>
532
+ <button type="button" class="secondary" onclick={discardAll}>{text.discardAll}</button>
533
+ </div>
534
+ </header>
535
+
536
+ <ul>
537
+ {#each snapshots as snapshot (keyOf(snapshot))}
538
+ {@const staged = snapshot.state.staged}
539
+ {@const label = snapshot.metadata.label ?? snapshot.identity.controlId}
540
+ {@const actionName = actionIdentity(snapshot)}
541
+ <li data-staged-review-item class:stale={staged?.stale} class:invalid={staged?.valid === false}>
542
+ <div class="proposal-heading">
543
+ <div><strong>{label}</strong><code>{formatIdentity(snapshot)}</code></div>
544
+ <span>{text.proposedBy} {staged?.provenance.actorId ?? staged?.provenance.source} · {text.stagedAt} {staged ? new Date(staged.stagedAt).toLocaleString() : ''}</span>
545
+ </div>
546
+ <dl>
547
+ <div><dt>{text.before}</dt><dd>{snapshot.state.valueRedacted ? text.redacted : formatValue(snapshot.state.value)}</dd></div>
548
+ <div>
549
+ <dt>{text.after}</dt>
550
+ <dd>
551
+ {#if staged?.valueRedacted}
552
+ {text.redacted}
553
+ {:else if typeof staged?.value === 'boolean'}
554
+ <input
555
+ type="checkbox"
556
+ aria-label={`${text.edit} ${actionName}`}
557
+ checked={(drafts[draftKeyOf(snapshot)] ?? 'false') === 'true'}
558
+ onchange={(event) => {
559
+ drafts = {
560
+ ...drafts,
561
+ [draftKeyOf(snapshot)]: String(event.currentTarget.checked),
562
+ };
563
+ }}
564
+ onkeydown={(event) => {
565
+ if (event.key === 'Enter') event.preventDefault();
566
+ }}
567
+ />
568
+ {:else if typeof staged?.value === 'string' && staged.value.includes('\n')}
569
+ <textarea
570
+ aria-label={`${text.edit} ${actionName}`}
571
+ value={drafts[draftKeyOf(snapshot)] ?? ''}
572
+ oninput={(event) => {
573
+ drafts = { ...drafts, [draftKeyOf(snapshot)]: event.currentTarget.value };
574
+ }}
575
+ ></textarea>
576
+ {:else}
577
+ <input
578
+ aria-label={`${text.edit} ${actionName}`}
579
+ value={drafts[draftKeyOf(snapshot)] ?? ''}
580
+ oninput={(event) => {
581
+ drafts = { ...drafts, [draftKeyOf(snapshot)]: event.currentTarget.value };
582
+ }}
583
+ onkeydown={(event) => {
584
+ if (event.key === 'Enter') event.preventDefault();
585
+ }}
586
+ />
587
+ {/if}
588
+ </dd>
589
+ </div>
590
+ </dl>
591
+ {#if staged?.stale}<p class="problem" role="alert">{text.stale}</p>{/if}
592
+ {#if staged?.valid === false}<p class="problem" role="alert">{text.invalid}</p>{/if}
593
+ {#if feedback[draftKeyOf(snapshot)]}<p class="problem" role="alert">{feedback[draftKeyOf(snapshot)]}</p>{/if}
594
+ <div class="item-actions">
595
+ <button
596
+ type="button"
597
+ aria-label={`${text.apply} ${actionName}`}
598
+ disabled={staged?.stale || snapshot.state.disabled || snapshot.state.readonly}
599
+ onclick={(event) => applyOne(snapshot, event)}
600
+ >{text.apply}</button>
601
+ <button type="button" class="secondary" aria-label={`${text.discard} ${actionName}`} onclick={(event) => discardOne(snapshot, event)}>{text.discard}</button>
602
+ </div>
603
+ </li>
604
+ {/each}
605
+ </ul>
606
+ </section>
607
+ {/if}
608
+
609
+ {#if summary && reviewActivated}
610
+ <p class="status visually-hidden" role="status" aria-live="polite" aria-atomic="true">{status}</p>
611
+ {/if}
612
+
613
+ <style>
614
+ .staged-review { margin-block-start: var(--smrt-spacing-5, 1.25rem); padding: var(--smrt-spacing-4, 1rem); color: var(--smrt-color-on-surface, #1f2937); background: var(--smrt-color-surface-container-low, #f8fafc); border: 1px solid var(--smrt-color-outline-variant, #cbd5e1); border-inline-start: 4px solid var(--smrt-color-tertiary, #7d5260); border-radius: var(--smrt-radius-medium, .5rem); }
615
+ header, .proposal-heading, .batch-actions, .item-actions { display: flex; align-items: center; gap: var(--smrt-spacing-2, .5rem); }
616
+ header { justify-content: space-between; align-items: flex-start; gap: var(--smrt-spacing-4, 1rem); }
617
+ h2, p { margin: 0; } h2 { font: var(--smrt-typography-title-medium-font, 600 1rem/1.4 system-ui); }
618
+ header p, .proposal-heading span, .proposal-heading code, dt { color: var(--smrt-color-on-surface-variant, #475569); font-size: .875rem; }
619
+ .proposal-heading strong, .proposal-heading code { display: block; }
620
+ ul { display: grid; gap: var(--smrt-spacing-3, .75rem); padding: 0; list-style: none; }
621
+ li { padding: var(--smrt-spacing-3, .75rem); background: var(--smrt-color-surface, #fff); border: 1px solid var(--smrt-color-outline-variant, #cbd5e1); border-radius: var(--smrt-radius-small, .375rem); }
622
+ li.stale, li.invalid { border-color: var(--smrt-color-error, #b3261e); }
623
+ .proposal-heading { justify-content: space-between; }
624
+ dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--smrt-spacing-3, .75rem); }
625
+ dt { font-weight: 600; } dd { margin: .25rem 0 0; overflow-wrap: anywhere; }
626
+ input, textarea { width: 100%; box-sizing: border-box; padding: .4rem .5rem; color: inherit; background: var(--smrt-color-surface, #fff); border: 1px solid var(--smrt-color-outline, #64748b); border-radius: var(--smrt-radius-small, .375rem); }
627
+ input[type="checkbox"] { width: auto; }
628
+ button { min-height: 2.25rem; padding: .4rem .75rem; color: var(--smrt-color-on-primary, #fff); background: var(--smrt-color-primary, #005ac1); border: 1px solid transparent; border-radius: var(--smrt-radius-small, .375rem); cursor: pointer; }
629
+ button.secondary { color: var(--smrt-color-primary, #005ac1); background: transparent; border-color: var(--smrt-color-outline, #64748b); }
630
+ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--smrt-color-primary, #005ac1); outline-offset: 2px; }
631
+ button:disabled { cursor: not-allowed; opacity: .55; }
632
+ .item-actions { margin-block-start: var(--smrt-spacing-3, .75rem); }
633
+ .problem { margin-block-start: var(--smrt-spacing-2, .5rem); color: var(--smrt-color-error, #b3261e); }
634
+ .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
635
+ :global([data-smrt-staged='true']) { outline: 3px solid var(--smrt-color-tertiary, #7d5260); outline-offset: 3px; }
636
+ @media (max-width: 40rem) { header, .proposal-heading { align-items: stretch; flex-direction: column; } dl { grid-template-columns: 1fr; } }
637
+ </style>
@@ -0,0 +1,13 @@
1
+ import type { ControlInteractionRegistry } from './control-interaction.js';
2
+ import type { StagedControlReviewLabels } from './staged-control-review.js';
3
+ export interface Props {
4
+ registry: ControlInteractionRegistry;
5
+ formId: string;
6
+ formElement?: HTMLFormElement | null;
7
+ summary?: boolean;
8
+ labels?: Partial<StagedControlReviewLabels>;
9
+ }
10
+ declare const StagedControlReview: import("svelte").Component<Props, {}, "">;
11
+ type StagedControlReview = ReturnType<typeof StagedControlReview>;
12
+ export default StagedControlReview;
13
+ //# sourceMappingURL=StagedControlReview.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"StagedControlReview.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/StagedControlReview.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAEV,0BAA0B,EAE3B,MAAM,0BAA0B,CAAC;AAKlC,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAG5E,MAAM,WAAW,KAAK;IACpB,QAAQ,EAAE,0BAA0B,CAAC;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;IACrC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC,yBAAyB,CAAC,CAAC;CAC7C;AAkkBD,QAAA,MAAM,mBAAmB,2CAAwC,CAAC;AAClE,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAClE,eAAe,mBAAmB,CAAC"}