@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.
- package/AGENTS.md +33 -0
- package/README.md +78 -4
- package/dist/components/display/CurrencyDisplay.contract.d.ts +7 -0
- package/dist/components/display/CurrencyDisplay.contract.d.ts.map +1 -0
- package/dist/components/display/CurrencyDisplay.contract.js +1 -0
- package/dist/components/display/CurrencyDisplay.svelte +148 -17
- package/dist/components/display/CurrencyDisplay.svelte.d.ts +8 -5
- package/dist/components/display/CurrencyDisplay.svelte.d.ts.map +1 -1
- package/dist/components/display/__tests__/CurrencyDisplay.test.js +193 -3
- package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte +20 -0
- package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte.d.ts +19 -0
- package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte.d.ts.map +1 -0
- package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte +7 -0
- package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte.d.ts +19 -0
- package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte.d.ts.map +1 -0
- package/dist/components/display/currency-metadata.d.ts +2 -0
- package/dist/components/display/currency-metadata.d.ts.map +1 -0
- package/dist/components/display/currency-metadata.js +14 -0
- package/dist/components/forms/Checkbox.svelte +15 -6
- package/dist/components/forms/Checkbox.svelte.d.ts.map +1 -1
- package/dist/components/forms/Combobox.svelte +45 -10
- package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
- package/dist/components/forms/FilePicker.svelte +5 -2
- package/dist/components/forms/Form.svelte +57 -0
- package/dist/components/forms/Form.svelte.d.ts +2 -0
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/forms/Input.svelte +59 -13
- package/dist/components/forms/Input.svelte.d.ts.map +1 -1
- package/dist/components/forms/Listbox.svelte +33 -6
- package/dist/components/forms/Listbox.svelte.d.ts.map +1 -1
- package/dist/components/forms/MultiSelect.svelte +41 -19
- package/dist/components/forms/MultiSelect.svelte.d.ts.map +1 -1
- package/dist/components/forms/RadioGroup.svelte +14 -3
- package/dist/components/forms/RadioGroup.svelte.d.ts.map +1 -1
- package/dist/components/forms/RangeSlider.svelte +61 -6
- package/dist/components/forms/RangeSlider.svelte.d.ts.map +1 -1
- package/dist/components/forms/SegmentedControl.svelte +33 -6
- package/dist/components/forms/SegmentedControl.svelte.d.ts.map +1 -1
- package/dist/components/forms/Select.svelte +32 -9
- package/dist/components/forms/Select.svelte.d.ts.map +1 -1
- package/dist/components/forms/Slider.svelte +26 -6
- package/dist/components/forms/Slider.svelte.d.ts.map +1 -1
- package/dist/components/forms/StagedControlReview.svelte +637 -0
- package/dist/components/forms/StagedControlReview.svelte.d.ts +13 -0
- package/dist/components/forms/StagedControlReview.svelte.d.ts.map +1 -0
- package/dist/components/forms/Switch.svelte +16 -7
- package/dist/components/forms/Switch.svelte.d.ts.map +1 -1
- package/dist/components/forms/TagsInput.svelte +36 -8
- package/dist/components/forms/TagsInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/Textarea.svelte +20 -8
- package/dist/components/forms/Textarea.svelte.d.ts.map +1 -1
- package/dist/components/forms/ToggleButton.svelte +20 -3
- package/dist/components/forms/ToggleButton.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/Form.test.js +106 -0
- package/dist/components/forms/__tests__/StagedControlReview.test.js +668 -0
- package/dist/components/forms/__tests__/composite-controls.fixture.svelte +22 -4
- package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts +6 -2
- package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/composite-controls.test.js +96 -1
- package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte +41 -0
- package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts +13 -0
- package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/control-interaction.test.js +3323 -15
- package/dist/components/forms/__tests__/core-controls.fixture.svelte +16 -5
- package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts +1 -0
- package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/core-controls.test.js +223 -2
- package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte +25 -0
- package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/select-interaction.fixture.svelte +25 -0
- package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts +9 -0
- package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte +21 -0
- package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte +23 -0
- package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/staged-review.fixture.svelte +56 -0
- package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts +12 -0
- package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/control-interaction-context.d.ts +9 -1
- package/dist/components/forms/control-interaction-context.d.ts.map +1 -1
- package/dist/components/forms/control-interaction-context.js +16 -0
- package/dist/components/forms/control-interaction.d.ts +97 -7
- package/dist/components/forms/control-interaction.d.ts.map +1 -1
- package/dist/components/forms/control-interaction.js +1019 -92
- package/dist/components/forms/control-value-validation.d.ts +19 -0
- package/dist/components/forms/control-value-validation.d.ts.map +1 -0
- package/dist/components/forms/control-value-validation.js +152 -0
- package/dist/components/forms/index.d.ts +3 -1
- package/dist/components/forms/index.d.ts.map +1 -1
- package/dist/components/forms/index.js +2 -1
- package/dist/components/forms/staged-control-review.d.ts +21 -0
- package/dist/components/forms/staged-control-review.d.ts.map +1 -0
- package/dist/components/forms/staged-control-review.js +1 -0
- package/dist/components/forms/use-control-registration.svelte.d.ts.map +1 -1
- package/dist/components/forms/use-control-registration.svelte.js +23 -4
- package/dist/i18n/strings.d.ts +3 -0
- package/dist/i18n/strings.d.ts.map +1 -1
- package/dist/i18n/strings.js +3 -0
- package/dist/i18n/strings.ui.d.ts +18 -0
- package/dist/i18n/strings.ui.d.ts.map +1 -1
- package/dist/i18n/strings.ui.js +19 -0
- package/dist/svelte/playground/InteractiveControlsPreview.svelte +3 -4
- package/dist/svelte/playground/InteractiveControlsPreview.svelte.d.ts.map +1 -1
- 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"}
|