@happyvertical/smrt-ui 0.52.0 → 0.53.1
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 +11 -1
- package/README.md +300 -2
- package/dist/components/data/DataTable.svelte +60 -15
- package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
- package/dist/components/data/DataTableResponsive.d.ts +4 -0
- package/dist/components/data/DataTableResponsive.d.ts.map +1 -0
- package/dist/components/data/DataTableResponsive.js +22 -0
- package/dist/components/data/__tests__/DataTableResponsive.test.js +80 -0
- package/dist/components/data/__tests__/responsive-table.fixture.svelte +29 -0
- package/dist/components/data/__tests__/responsive-table.fixture.svelte.d.ts +7 -0
- package/dist/components/data/__tests__/responsive-table.fixture.svelte.d.ts.map +1 -0
- package/dist/components/data/types.d.ts +8 -0
- package/dist/components/data/types.d.ts.map +1 -1
- package/dist/components/display/DateDisplay.svelte +76 -14
- package/dist/components/display/DateDisplay.svelte.d.ts +2 -0
- package/dist/components/display/DateDisplay.svelte.d.ts.map +1 -1
- package/dist/components/display/Icon.svelte +3 -16
- package/dist/components/display/Icon.svelte.d.ts +0 -9
- package/dist/components/display/Icon.svelte.d.ts.map +1 -1
- package/dist/components/display/StatusBadge.svelte +29 -2
- package/dist/components/display/StatusBadge.svelte.d.ts +4 -2
- package/dist/components/display/StatusBadge.svelte.d.ts.map +1 -1
- package/dist/components/display/__tests__/DateDisplay.test.js +82 -1
- package/dist/components/display/__tests__/Icon.test.js +132 -2
- package/dist/components/display/__tests__/StatusBadge.test.js +78 -0
- package/dist/components/display/icons.svelte.d.ts +11 -0
- package/dist/components/display/icons.svelte.d.ts.map +1 -0
- package/dist/components/display/icons.svelte.js +60 -0
- package/dist/components/display/index.d.ts +3 -1
- package/dist/components/display/index.d.ts.map +1 -1
- package/dist/components/display/index.js +1 -0
- package/dist/components/display/types.d.ts +2 -0
- package/dist/components/display/types.d.ts.map +1 -1
- package/dist/components/feedback/ConfirmDialog.svelte +59 -48
- package/dist/components/feedback/ConfirmDialog.svelte.d.ts +14 -2
- package/dist/components/feedback/ConfirmDialog.svelte.d.ts.map +1 -1
- package/dist/components/feedback/ToastViewport.svelte +156 -8
- package/dist/components/feedback/ToastViewport.svelte.d.ts +5 -1
- package/dist/components/feedback/ToastViewport.svelte.d.ts.map +1 -1
- package/dist/components/feedback/__tests__/ConfirmDialog.test.js +38 -20
- package/dist/components/feedback/__tests__/confirm-message.fixture.svelte +9 -0
- package/dist/components/feedback/__tests__/confirm-message.fixture.svelte.d.ts +19 -0
- package/dist/components/feedback/__tests__/confirm-message.fixture.svelte.d.ts.map +1 -0
- package/dist/components/feedback/__tests__/toast.test.js +142 -1
- package/dist/components/feedback/index.d.ts +1 -1
- package/dist/components/feedback/index.d.ts.map +1 -1
- package/dist/components/feedback/toast.d.ts +13 -1
- package/dist/components/feedback/toast.d.ts.map +1 -1
- package/dist/components/feedback/toast.js +51 -10
- package/dist/components/forms/CameraCapture.svelte +619 -0
- package/dist/components/forms/CameraCapture.svelte.d.ts +42 -0
- package/dist/components/forms/CameraCapture.svelte.d.ts.map +1 -0
- package/dist/components/forms/Checkbox.svelte +8 -1
- package/dist/components/forms/Checkbox.svelte.d.ts +2 -0
- package/dist/components/forms/Checkbox.svelte.d.ts.map +1 -1
- package/dist/components/forms/Combobox.svelte +40 -0
- package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
- package/dist/components/forms/Form.svelte +5 -0
- package/dist/components/forms/Form.svelte.d.ts +1 -0
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/forms/Input.svelte +8 -1
- package/dist/components/forms/Input.svelte.d.ts +2 -0
- package/dist/components/forms/Input.svelte.d.ts.map +1 -1
- package/dist/components/forms/Listbox.svelte +39 -1
- package/dist/components/forms/Listbox.svelte.d.ts.map +1 -1
- package/dist/components/forms/MultiSelect.svelte +41 -1
- package/dist/components/forms/MultiSelect.svelte.d.ts.map +1 -1
- package/dist/components/forms/Radio.svelte +1 -0
- package/dist/components/forms/RadioGroup.svelte +7 -1
- package/dist/components/forms/RadioGroup.svelte.d.ts +2 -0
- package/dist/components/forms/RadioGroup.svelte.d.ts.map +1 -1
- package/dist/components/forms/SegmentedControl.svelte +152 -11
- package/dist/components/forms/SegmentedControl.svelte.d.ts +4 -0
- package/dist/components/forms/SegmentedControl.svelte.d.ts.map +1 -1
- package/dist/components/forms/Select.svelte +8 -1
- package/dist/components/forms/Select.svelte.d.ts +2 -0
- package/dist/components/forms/Select.svelte.d.ts.map +1 -1
- package/dist/components/forms/SignaturePad.svelte +436 -0
- package/dist/components/forms/SignaturePad.svelte.d.ts +32 -0
- package/dist/components/forms/SignaturePad.svelte.d.ts.map +1 -0
- package/dist/components/forms/Switch.svelte +8 -1
- package/dist/components/forms/Switch.svelte.d.ts +2 -0
- package/dist/components/forms/Switch.svelte.d.ts.map +1 -1
- package/dist/components/forms/Textarea.svelte +8 -1
- package/dist/components/forms/Textarea.svelte.d.ts +2 -0
- package/dist/components/forms/Textarea.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/CameraCapture.test.js +504 -0
- package/dist/components/forms/__tests__/Combobox.test.js +279 -12
- package/dist/components/forms/__tests__/Form.test.js +46 -0
- package/dist/components/forms/__tests__/Listbox.test.js +303 -0
- package/dist/components/forms/__tests__/MultiSelect.test.js +281 -0
- package/dist/components/forms/__tests__/SignaturePad.test.js +338 -0
- package/dist/components/forms/__tests__/camera-capture-session.test.js +207 -0
- package/dist/components/forms/__tests__/capture-form-retry.test.js +166 -0
- package/dist/components/forms/__tests__/capture-form.fixture.svelte +38 -0
- package/dist/components/forms/__tests__/capture-form.fixture.svelte.d.ts +10 -0
- package/dist/components/forms/__tests__/capture-form.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/capture-test-env.d.ts +70 -0
- package/dist/components/forms/__tests__/capture-test-env.d.ts.map +1 -0
- package/dist/components/forms/__tests__/capture-test-env.js +122 -0
- package/dist/components/forms/__tests__/capture-touch.fixture.svelte +15 -0
- package/dist/components/forms/__tests__/capture-touch.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/capture-touch.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/combobox-form.fixture.svelte +29 -0
- package/dist/components/forms/__tests__/combobox-form.fixture.svelte.d.ts +14 -0
- package/dist/components/forms/__tests__/combobox-form.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/form-attachment.fixture.svelte +28 -0
- package/dist/components/forms/__tests__/form-attachment.fixture.svelte.d.ts +11 -0
- package/dist/components/forms/__tests__/form-attachment.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/form-retry-form.fixture.svelte +31 -0
- package/dist/components/forms/__tests__/form-retry-form.fixture.svelte.d.ts +13 -0
- package/dist/components/forms/__tests__/form-retry-form.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/form-retry-form.test.js +107 -0
- package/dist/components/forms/__tests__/hidden-controls-form-retry.test.js +69 -0
- package/dist/components/forms/__tests__/hidden-controls-form.fixture.svelte +29 -0
- package/dist/components/forms/__tests__/hidden-controls-form.fixture.svelte.d.ts +9 -0
- package/dist/components/forms/__tests__/hidden-controls-form.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/listbox-form.fixture.svelte +27 -0
- package/dist/components/forms/__tests__/listbox-form.fixture.svelte.d.ts +13 -0
- package/dist/components/forms/__tests__/listbox-form.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/multiselect-form.fixture.svelte +27 -0
- package/dist/components/forms/__tests__/multiselect-form.fixture.svelte.d.ts +13 -0
- package/dist/components/forms/__tests__/multiselect-form.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/native-file-field.test.js +177 -0
- package/dist/components/forms/__tests__/segmented-posting.fixture.svelte +28 -0
- package/dist/components/forms/__tests__/segmented-posting.fixture.svelte.d.ts +12 -0
- package/dist/components/forms/__tests__/segmented-posting.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/segmented-posting.test.js +92 -0
- package/dist/components/forms/__tests__/signature-pad-logic.test.js +54 -0
- package/dist/components/forms/__tests__/touch-density.fixture.svelte +34 -0
- package/dist/components/forms/__tests__/touch-density.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/touch-density.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/touch-density.test.js +66 -0
- package/dist/components/forms/camera-capture-session.d.ts +81 -0
- package/dist/components/forms/camera-capture-session.d.ts.map +1 -0
- package/dist/components/forms/camera-capture-session.js +130 -0
- package/dist/components/forms/form-retry/__tests__/boundaries.test.js +23 -0
- package/dist/components/forms/form-retry/__tests__/content-digest.test.js +70 -0
- package/dist/components/forms/form-retry/__tests__/controller.test.js +802 -0
- package/dist/components/forms/form-retry/__tests__/fake-kit.d.ts +82 -0
- package/dist/components/forms/form-retry/__tests__/fake-kit.d.ts.map +1 -0
- package/dist/components/forms/form-retry/__tests__/fake-kit.js +180 -0
- package/dist/components/forms/form-retry/__tests__/form-draft.test.js +169 -0
- package/dist/components/forms/form-retry/__tests__/ssr.test.js +37 -0
- package/dist/components/forms/form-retry/__tests__/storage.d.ts +26 -0
- package/dist/components/forms/form-retry/__tests__/storage.d.ts.map +1 -0
- package/dist/components/forms/form-retry/__tests__/storage.js +63 -0
- package/dist/components/forms/form-retry/__tests__/submission-key.test.js +236 -0
- package/dist/components/forms/form-retry/__tests__/submitted-draft.test.js +334 -0
- package/dist/components/forms/form-retry/content-digest.d.ts +54 -0
- package/dist/components/forms/form-retry/content-digest.d.ts.map +1 -0
- package/dist/components/forms/form-retry/content-digest.js +169 -0
- package/dist/components/forms/form-retry/controller.d.ts +214 -0
- package/dist/components/forms/form-retry/controller.d.ts.map +1 -0
- package/dist/components/forms/form-retry/controller.js +404 -0
- package/dist/components/forms/form-retry/form-draft.d.ts +68 -0
- package/dist/components/forms/form-retry/form-draft.d.ts.map +1 -0
- package/dist/components/forms/form-retry/form-draft.js +161 -0
- package/dist/components/forms/form-retry/index.d.ts +19 -0
- package/dist/components/forms/form-retry/index.d.ts.map +1 -0
- package/dist/components/forms/form-retry/index.js +16 -0
- package/dist/components/forms/form-retry/submission-key.d.ts +244 -0
- package/dist/components/forms/form-retry/submission-key.d.ts.map +1 -0
- package/dist/components/forms/form-retry/submission-key.js +342 -0
- package/dist/components/forms/form-retry/submitted-draft.d.ts +112 -0
- package/dist/components/forms/form-retry/submitted-draft.d.ts.map +1 -0
- package/dist/components/forms/form-retry/submitted-draft.js +270 -0
- package/dist/components/forms/form-retry/types.d.ts +84 -0
- package/dist/components/forms/form-retry/types.d.ts.map +1 -0
- package/dist/components/forms/form-retry/types.js +13 -0
- package/dist/components/forms/index.d.ts +7 -1
- package/dist/components/forms/index.d.ts.map +1 -1
- package/dist/components/forms/index.js +8 -0
- package/dist/components/forms/native-file-field.d.ts +94 -0
- package/dist/components/forms/native-file-field.d.ts.map +1 -0
- package/dist/components/forms/native-file-field.js +147 -0
- package/dist/components/forms/signature-pad-logic.d.ts +50 -0
- package/dist/components/forms/signature-pad-logic.d.ts.map +1 -0
- package/dist/components/forms/signature-pad-logic.js +48 -0
- package/dist/components/forms/types.d.ts +41 -0
- package/dist/components/forms/types.d.ts.map +1 -1
- package/dist/components/nav/FilterChips.svelte +8 -1
- package/dist/components/nav/FilterChips.svelte.d.ts +2 -0
- package/dist/components/nav/FilterChips.svelte.d.ts.map +1 -1
- package/dist/components/ui/Button.svelte +9 -2
- package/dist/components/ui/Button.svelte.d.ts +2 -0
- package/dist/components/ui/Button.svelte.d.ts.map +1 -1
- package/dist/i18n/strings.ui.d.ts +24 -0
- package/dist/i18n/strings.ui.d.ts.map +1 -1
- package/dist/i18n/strings.ui.js +26 -0
- package/dist/styles/tokens.css +2 -0
- package/dist/svelte/__tests__/playground.test.js +2 -1
- package/dist/svelte/playground/CapturePreview.svelte +56 -0
- package/dist/svelte/playground/CapturePreview.svelte.d.ts +4 -0
- package/dist/svelte/playground/CapturePreview.svelte.d.ts.map +1 -0
- package/dist/svelte/playground.d.ts.map +1 -1
- package/dist/svelte/playground.js +10 -0
- package/dist/themes/ThemeProvider.svelte +9 -0
- package/dist/themes/ThemeProvider.svelte.d.ts +2 -0
- package/dist/themes/ThemeProvider.svelte.d.ts.map +1 -1
- package/dist/themes/css-generator.d.ts.map +1 -1
- package/dist/themes/css-generator.js +3 -0
- package/dist/themes/styles/glass.css +4 -0
- package/dist/themes/styles/happyvertical.css +4 -0
- package/dist/themes/styles/material.css +4 -0
- package/dist/themes/styles/smrt.css +4 -0
- package/dist/themes/styles/studio.css +4 -0
- package/package.json +8 -2
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/svelte';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { tick } from 'svelte';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createControlInteractionRegistry, executeLocalControlCommand, } from '../control-interaction.js';
|
|
6
|
+
import Listbox from '../Listbox.svelte';
|
|
7
|
+
import Fixture from './listbox-form.fixture.svelte';
|
|
8
|
+
function form() {
|
|
9
|
+
return screen.getByRole('form', { name: 'Vendor form' });
|
|
10
|
+
}
|
|
11
|
+
function data() {
|
|
12
|
+
return new FormData(form());
|
|
13
|
+
}
|
|
14
|
+
describe('Listbox native form value', () => {
|
|
15
|
+
it('posts one option value after mouse or keyboard selection', async () => {
|
|
16
|
+
render(Fixture);
|
|
17
|
+
await userEvent.click(screen.getByRole('option', { name: 'Plate supplier' }));
|
|
18
|
+
expect(data().getAll('vendor')).toEqual(['42']);
|
|
19
|
+
await userEvent.keyboard('{ArrowDown}{Enter}');
|
|
20
|
+
expect(data().getAll('vendor')).toEqual(['steel']);
|
|
21
|
+
await userEvent.click(screen.getByRole('option', { name: 'Disabled supplier' }));
|
|
22
|
+
expect(data().getAll('vendor')).toEqual(['steel']);
|
|
23
|
+
});
|
|
24
|
+
it('posts an empty named selection and omits missing or empty names', async () => {
|
|
25
|
+
const { rerender } = render(Fixture);
|
|
26
|
+
expect(data().getAll('vendor')).toEqual(['']);
|
|
27
|
+
await rerender({ name: '', value: 42 });
|
|
28
|
+
expect([...data()]).toEqual([]);
|
|
29
|
+
await rerender({ unnamed: true, value: 42 });
|
|
30
|
+
expect([...data()]).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
it.each([
|
|
33
|
+
{ disabled: true },
|
|
34
|
+
{ fieldsetDisabled: true },
|
|
35
|
+
])('omits disabled values %j', (props) => {
|
|
36
|
+
render(Fixture, { props: { ...props, value: 42 } });
|
|
37
|
+
expect([...data()]).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
it.each([
|
|
40
|
+
undefined,
|
|
41
|
+
42,
|
|
42
|
+
])('restores initial value %j and selection on reset', async (value) => {
|
|
43
|
+
render(Fixture, { props: { value } });
|
|
44
|
+
await userEvent.click(screen.getByRole('option', { name: 'Steel supplier' }));
|
|
45
|
+
form().reset();
|
|
46
|
+
await settleReset();
|
|
47
|
+
expect(data().getAll('vendor')).toEqual([value === undefined ? '' : '42']);
|
|
48
|
+
expect(screen.getByRole('option', { name: 'Plate supplier' })).toHaveAttribute('aria-selected', String(value === 42));
|
|
49
|
+
expect(screen.getByRole('option', { name: 'Steel supplier' })).toHaveAttribute('aria-selected', 'false');
|
|
50
|
+
});
|
|
51
|
+
it('retains the selected value when reset is canceled', async () => {
|
|
52
|
+
render(Fixture);
|
|
53
|
+
await userEvent.click(screen.getByRole('option', { name: 'Plate supplier' }));
|
|
54
|
+
form().addEventListener('reset', (event) => event.preventDefault());
|
|
55
|
+
form().reset();
|
|
56
|
+
await settleReset();
|
|
57
|
+
expect(data().getAll('vendor')).toEqual(['42']);
|
|
58
|
+
});
|
|
59
|
+
it('preserves registry identity and posts only applied values', async () => {
|
|
60
|
+
const registry = createControlInteractionRegistry({
|
|
61
|
+
isLocalGesture: () => true,
|
|
62
|
+
});
|
|
63
|
+
render(Fixture, { props: { registry } });
|
|
64
|
+
const identity = { formId: 'vendor-form', controlId: 'vendor' };
|
|
65
|
+
await registry.execute({ action: 'stage', identity, value: '42' }, { source: 'agent' });
|
|
66
|
+
expect(data().getAll('vendor')).toEqual(['']);
|
|
67
|
+
await userEvent.click(screen.getByRole('button', { name: 'Apply valid changes' }));
|
|
68
|
+
expect(data().getAll('vendor')).toEqual(['42']);
|
|
69
|
+
expect(registry.get(identity)?.state.value).toBe(42);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
async function settleReset() {
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
74
|
+
await tick();
|
|
75
|
+
}
|
|
76
|
+
describe('Listbox deferred native reset', () => {
|
|
77
|
+
it('preserves a newer programmatic value before reset completion', async () => {
|
|
78
|
+
const target = document.createElement('form');
|
|
79
|
+
document.body.append(target);
|
|
80
|
+
const view = render(Listbox, {
|
|
81
|
+
target,
|
|
82
|
+
props: {
|
|
83
|
+
options: [
|
|
84
|
+
{ value: 42, label: 'Initial' },
|
|
85
|
+
{ value: 'other', label: 'Other' },
|
|
86
|
+
{ value: 'changed', label: 'Changed' },
|
|
87
|
+
],
|
|
88
|
+
label: 'Choice',
|
|
89
|
+
name: 'choice',
|
|
90
|
+
value: 42,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
await view.rerender({ value: 'changed' });
|
|
94
|
+
target.reset();
|
|
95
|
+
await view.rerender({ value: 'other' });
|
|
96
|
+
await settleReset();
|
|
97
|
+
expect(new FormData(target).getAll('choice')).toEqual(['other']);
|
|
98
|
+
await view.unmount();
|
|
99
|
+
target.remove();
|
|
100
|
+
});
|
|
101
|
+
it('cancels pending completion when unmounted', async () => {
|
|
102
|
+
const target = document.createElement('form');
|
|
103
|
+
document.body.append(target);
|
|
104
|
+
const onChange = vi.fn();
|
|
105
|
+
const view = render(Listbox, {
|
|
106
|
+
target,
|
|
107
|
+
props: {
|
|
108
|
+
options: [
|
|
109
|
+
{ value: 42, label: 'Initial' },
|
|
110
|
+
{ value: 'other', label: 'Other' },
|
|
111
|
+
{ value: 'changed', label: 'Changed' },
|
|
112
|
+
],
|
|
113
|
+
label: 'Choice',
|
|
114
|
+
name: 'choice',
|
|
115
|
+
value: 42,
|
|
116
|
+
onvaluechange: onChange,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
target.reset();
|
|
120
|
+
await view.unmount();
|
|
121
|
+
await settleReset();
|
|
122
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
123
|
+
target.remove();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe('Listbox selected disabled option posting', () => {
|
|
127
|
+
it('omits initially disabled selections and responds to dynamic disable without losing state', async () => {
|
|
128
|
+
const target = document.createElement('form');
|
|
129
|
+
document.body.append(target);
|
|
130
|
+
const options = [
|
|
131
|
+
{ value: 42, label: 'Selected', disabled: true },
|
|
132
|
+
{ value: 'other', label: 'Other' },
|
|
133
|
+
];
|
|
134
|
+
const view = render(Listbox, {
|
|
135
|
+
target,
|
|
136
|
+
props: { options, label: 'Choice', name: 'choice', value: 42 },
|
|
137
|
+
});
|
|
138
|
+
expect(new FormData(target).getAll('choice')).toEqual([]);
|
|
139
|
+
await view.rerender({
|
|
140
|
+
options: options.map((option) => ({ ...option, disabled: false })),
|
|
141
|
+
});
|
|
142
|
+
expect(new FormData(target).getAll('choice')).toEqual(['42']);
|
|
143
|
+
await view.rerender({ options });
|
|
144
|
+
expect(new FormData(target).getAll('choice')).toEqual([]);
|
|
145
|
+
await view.rerender({
|
|
146
|
+
options: options.map((option) => ({ ...option, disabled: false })),
|
|
147
|
+
});
|
|
148
|
+
expect(new FormData(target).getAll('choice')).toEqual(['42']);
|
|
149
|
+
await view.unmount();
|
|
150
|
+
target.remove();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe('Listbox reset listener ordering', () => {
|
|
154
|
+
it('honors cancellation from a later listener microtask', async () => {
|
|
155
|
+
const target = document.createElement('form');
|
|
156
|
+
document.body.append(target);
|
|
157
|
+
const onChange = vi.fn();
|
|
158
|
+
const view = render(Listbox, {
|
|
159
|
+
target,
|
|
160
|
+
props: {
|
|
161
|
+
options: [
|
|
162
|
+
{ value: 42, label: 'Initial' },
|
|
163
|
+
{ value: 'changed', label: 'Changed' },
|
|
164
|
+
],
|
|
165
|
+
label: 'Choice',
|
|
166
|
+
name: 'choice',
|
|
167
|
+
value: 42,
|
|
168
|
+
onvaluechange: onChange,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
await view.rerender({ value: 'changed' });
|
|
172
|
+
target.addEventListener('reset', (event) => {
|
|
173
|
+
queueMicrotask(() => event.preventDefault());
|
|
174
|
+
});
|
|
175
|
+
target.reset();
|
|
176
|
+
await settleReset();
|
|
177
|
+
expect(new FormData(target).getAll('choice')).toEqual(['changed']);
|
|
178
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
179
|
+
await view.unmount();
|
|
180
|
+
target.remove();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
function dispatchLocalCommand(registry, command) {
|
|
184
|
+
const target = new EventTarget();
|
|
185
|
+
let pending;
|
|
186
|
+
target.addEventListener('click', (event) => {
|
|
187
|
+
pending = executeLocalControlCommand(registry, command, event);
|
|
188
|
+
}, { once: true });
|
|
189
|
+
target.dispatchEvent(new Event('click'));
|
|
190
|
+
if (!pending)
|
|
191
|
+
throw new Error('local command handler did not run');
|
|
192
|
+
return pending;
|
|
193
|
+
}
|
|
194
|
+
describe('Listbox reset registry currency', () => {
|
|
195
|
+
it.each([
|
|
196
|
+
false,
|
|
197
|
+
true,
|
|
198
|
+
])('keeps staged proposal currency correct when cancellation is %s', async (cancel) => {
|
|
199
|
+
const registry = createControlInteractionRegistry({
|
|
200
|
+
isLocalGesture: () => true,
|
|
201
|
+
});
|
|
202
|
+
render(Fixture, { props: { ...{ value: 42 }, registry } });
|
|
203
|
+
const identity = { formId: 'vendor-form', controlId: 'vendor' };
|
|
204
|
+
const target = form();
|
|
205
|
+
await userEvent.click(screen.getByRole('option', { name: 'Steel supplier' }));
|
|
206
|
+
expect((await registry.execute({ action: 'stage', identity, value: 42 }, { source: 'agent' })).ok).toBe(true);
|
|
207
|
+
if (cancel)
|
|
208
|
+
target.addEventListener('reset', (event) => event.preventDefault());
|
|
209
|
+
target.reset();
|
|
210
|
+
await settleReset();
|
|
211
|
+
expect(registry.get(identity)?.state.staged?.stale).toBe(!cancel);
|
|
212
|
+
const applied = await dispatchLocalCommand(registry, {
|
|
213
|
+
action: 'apply',
|
|
214
|
+
identity,
|
|
215
|
+
});
|
|
216
|
+
expect(applied.ok).toBe(cancel);
|
|
217
|
+
if (!cancel) {
|
|
218
|
+
expect(applied.reason).toBe('staged_value_stale');
|
|
219
|
+
expect(new FormData(target).getAll('vendor')).toEqual(['42']);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
it.each([
|
|
223
|
+
false,
|
|
224
|
+
true,
|
|
225
|
+
])('preserves reset over an async local clear when cancellation is %s', async (cancel) => {
|
|
226
|
+
let releasePolicy;
|
|
227
|
+
let policyStarted;
|
|
228
|
+
const blocked = new Promise((resolve) => {
|
|
229
|
+
releasePolicy = resolve;
|
|
230
|
+
});
|
|
231
|
+
const started = new Promise((resolve) => {
|
|
232
|
+
policyStarted = resolve;
|
|
233
|
+
});
|
|
234
|
+
const registry = createControlInteractionRegistry({
|
|
235
|
+
isLocalGesture: () => true,
|
|
236
|
+
policy: async (command) => {
|
|
237
|
+
if (command.action === 'clear') {
|
|
238
|
+
policyStarted();
|
|
239
|
+
await blocked;
|
|
240
|
+
}
|
|
241
|
+
return { allowed: true };
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
render(Fixture, { props: { ...{ value: 42 }, registry } });
|
|
245
|
+
const identity = { formId: 'vendor-form', controlId: 'vendor' };
|
|
246
|
+
const target = form();
|
|
247
|
+
await userEvent.click(screen.getByRole('option', { name: 'Steel supplier' }));
|
|
248
|
+
const clearing = dispatchLocalCommand(registry, {
|
|
249
|
+
action: 'clear',
|
|
250
|
+
identity,
|
|
251
|
+
});
|
|
252
|
+
await started;
|
|
253
|
+
if (cancel)
|
|
254
|
+
target.addEventListener('reset', (event) => event.preventDefault());
|
|
255
|
+
target.reset();
|
|
256
|
+
await settleReset();
|
|
257
|
+
releasePolicy();
|
|
258
|
+
const result = await clearing;
|
|
259
|
+
await tick();
|
|
260
|
+
expect(result.ok).toBe(cancel);
|
|
261
|
+
if (!cancel)
|
|
262
|
+
expect(result.reason).toBe('staged_value_stale');
|
|
263
|
+
expect(new FormData(target).getAll('vendor')).toEqual(cancel ? [''] : ['42']);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
describe('Listbox exact option identity posting', () => {
|
|
267
|
+
it.each([
|
|
268
|
+
false,
|
|
269
|
+
true,
|
|
270
|
+
])('posts an enabled numeric option beside a disabled string collision in reversed order %s', async (reverse) => {
|
|
271
|
+
const target = document.createElement('form');
|
|
272
|
+
document.body.append(target);
|
|
273
|
+
const options = [
|
|
274
|
+
{ value: 42, label: 'Numeric' },
|
|
275
|
+
{ value: '42', label: 'Text', disabled: true },
|
|
276
|
+
];
|
|
277
|
+
if (reverse)
|
|
278
|
+
options.reverse();
|
|
279
|
+
const view = render(Listbox, {
|
|
280
|
+
target,
|
|
281
|
+
props: { options, label: 'Choice', name: 'choice', value: 42 },
|
|
282
|
+
});
|
|
283
|
+
expect(new FormData(target).getAll('choice')).toEqual(['42']);
|
|
284
|
+
expect(screen.getByRole('option', { name: 'Numeric' })).toHaveAttribute('aria-selected', 'true');
|
|
285
|
+
await view.rerender({ value: undefined });
|
|
286
|
+
await userEvent.click(screen.getByRole('option', { name: 'Numeric' }));
|
|
287
|
+
expect(new FormData(target).getAll('choice')).toEqual(['42']);
|
|
288
|
+
expect(screen.getByRole('option', { name: 'Numeric' })).toHaveAttribute('aria-selected', 'true');
|
|
289
|
+
await view.rerender({
|
|
290
|
+
value: 42,
|
|
291
|
+
options: options.map((option) => ({
|
|
292
|
+
...option,
|
|
293
|
+
disabled: option.value === 42,
|
|
294
|
+
})),
|
|
295
|
+
});
|
|
296
|
+
expect(new FormData(target).getAll('choice')).toEqual([]);
|
|
297
|
+
await userEvent.click(screen.getByRole('option', { name: 'Text' }));
|
|
298
|
+
expect(new FormData(target).getAll('choice')).toEqual(['42']);
|
|
299
|
+
expect(screen.getByRole('option', { name: 'Text' })).toHaveAttribute('aria-selected', 'true');
|
|
300
|
+
await view.unmount();
|
|
301
|
+
target.remove();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/svelte';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { tick } from 'svelte';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createControlInteractionRegistry, executeLocalControlCommand, } from '../control-interaction.js';
|
|
6
|
+
import MultiSelect from '../MultiSelect.svelte';
|
|
7
|
+
import Fixture from './multiselect-form.fixture.svelte';
|
|
8
|
+
function form() {
|
|
9
|
+
return screen.getByRole('form', { name: 'Roles form' });
|
|
10
|
+
}
|
|
11
|
+
function data() {
|
|
12
|
+
return new FormData(form());
|
|
13
|
+
}
|
|
14
|
+
async function open() {
|
|
15
|
+
await userEvent.click(screen.getByRole('button', { name: /Roles/ }));
|
|
16
|
+
}
|
|
17
|
+
describe('MultiSelect native form values', () => {
|
|
18
|
+
it('posts repeated values in selection order and removes deselected inputs', async () => {
|
|
19
|
+
render(Fixture);
|
|
20
|
+
await open();
|
|
21
|
+
await userEvent.click(screen.getByRole('option', { name: 'Administrator' }));
|
|
22
|
+
await userEvent.click(screen.getByRole('option', { name: 'Operator' }));
|
|
23
|
+
expect(data().getAll('roles')).toEqual(['admin', '42']);
|
|
24
|
+
await userEvent.click(screen.getByRole('option', { name: 'Administrator' }));
|
|
25
|
+
expect(data().getAll('roles')).toEqual(['42']);
|
|
26
|
+
await userEvent.click(screen.getByRole('option', { name: 'Disabled role' }));
|
|
27
|
+
expect(data().getAll('roles')).toEqual(['42']);
|
|
28
|
+
});
|
|
29
|
+
it('posts canonical numeric selections from legacy strings', () => {
|
|
30
|
+
render(Fixture, { props: { values: ['42'] } });
|
|
31
|
+
expect(data().getAll('roles')).toEqual(['42']);
|
|
32
|
+
});
|
|
33
|
+
it('omits empty selections, empty names and unnamed controls', async () => {
|
|
34
|
+
const { rerender } = render(Fixture);
|
|
35
|
+
expect([...data()]).toEqual([]);
|
|
36
|
+
await rerender({ values: [42], name: '' });
|
|
37
|
+
expect([...data()]).toEqual([]);
|
|
38
|
+
await rerender({ values: [42], unnamed: true });
|
|
39
|
+
expect([...data()]).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
it.each([
|
|
42
|
+
{ disabled: true },
|
|
43
|
+
{ fieldsetDisabled: true },
|
|
44
|
+
])('omits disabled selections %j', (props) => {
|
|
45
|
+
render(Fixture, { props: { ...props, values: [42, 'admin'] } });
|
|
46
|
+
expect([...data()]).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
it('restores initial selected values on native reset', async () => {
|
|
49
|
+
render(Fixture, { props: { values: [42] } });
|
|
50
|
+
await open();
|
|
51
|
+
await userEvent.click(screen.getByRole('option', { name: 'Administrator' }));
|
|
52
|
+
form().reset();
|
|
53
|
+
await settleReset();
|
|
54
|
+
expect(data().getAll('roles')).toEqual(['42']);
|
|
55
|
+
await open();
|
|
56
|
+
expect(screen.getByRole('option', { name: 'Operator' })).toHaveAttribute('aria-selected', 'true');
|
|
57
|
+
expect(screen.getByRole('option', { name: 'Administrator' })).toHaveAttribute('aria-selected', 'false');
|
|
58
|
+
});
|
|
59
|
+
it('preserves changed values when reset is canceled', async () => {
|
|
60
|
+
render(Fixture);
|
|
61
|
+
await open();
|
|
62
|
+
await userEvent.click(screen.getByRole('option', { name: 'Operator' }));
|
|
63
|
+
form().addEventListener('reset', (event) => event.preventDefault());
|
|
64
|
+
form().reset();
|
|
65
|
+
await settleReset();
|
|
66
|
+
expect(data().getAll('roles')).toEqual(['42']);
|
|
67
|
+
});
|
|
68
|
+
it('preserves registry identity and posts only applied canonical values', async () => {
|
|
69
|
+
const registry = createControlInteractionRegistry({
|
|
70
|
+
isLocalGesture: () => true,
|
|
71
|
+
});
|
|
72
|
+
render(Fixture, { props: { registry } });
|
|
73
|
+
const identity = { formId: 'roles-form', controlId: 'roles' };
|
|
74
|
+
await registry.execute({ action: 'stage', identity, value: ['42', 'admin'] }, { source: 'agent' });
|
|
75
|
+
expect([...data()]).toEqual([]);
|
|
76
|
+
await userEvent.click(screen.getByRole('button', { name: 'Apply valid changes' }));
|
|
77
|
+
expect(data().getAll('roles')).toEqual(['42', 'admin']);
|
|
78
|
+
expect(registry.get(identity)?.state.value).toEqual([42, 'admin']);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
async function settleReset() {
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
83
|
+
await tick();
|
|
84
|
+
}
|
|
85
|
+
describe('MultiSelect deferred native reset', () => {
|
|
86
|
+
it('preserves a newer programmatic value before reset completion', async () => {
|
|
87
|
+
const target = document.createElement('form');
|
|
88
|
+
document.body.append(target);
|
|
89
|
+
const view = render(MultiSelect, {
|
|
90
|
+
target,
|
|
91
|
+
props: {
|
|
92
|
+
options: [
|
|
93
|
+
{ value: 42, label: 'Initial' },
|
|
94
|
+
{ value: 'other', label: 'Other' },
|
|
95
|
+
{ value: 'changed', label: 'Changed' },
|
|
96
|
+
],
|
|
97
|
+
label: 'Choice',
|
|
98
|
+
name: 'choice',
|
|
99
|
+
values: [42],
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
await view.rerender({ values: ['changed'] });
|
|
103
|
+
target.reset();
|
|
104
|
+
await view.rerender({ values: ['other'] });
|
|
105
|
+
await settleReset();
|
|
106
|
+
expect(new FormData(target).getAll('choice')).toEqual(['other']);
|
|
107
|
+
await view.unmount();
|
|
108
|
+
target.remove();
|
|
109
|
+
});
|
|
110
|
+
it('cancels pending completion when unmounted', async () => {
|
|
111
|
+
const target = document.createElement('form');
|
|
112
|
+
document.body.append(target);
|
|
113
|
+
const onChange = vi.fn();
|
|
114
|
+
const view = render(MultiSelect, {
|
|
115
|
+
target,
|
|
116
|
+
props: {
|
|
117
|
+
options: [
|
|
118
|
+
{ value: 42, label: 'Initial' },
|
|
119
|
+
{ value: 'other', label: 'Other' },
|
|
120
|
+
{ value: 'changed', label: 'Changed' },
|
|
121
|
+
],
|
|
122
|
+
label: 'Choice',
|
|
123
|
+
name: 'choice',
|
|
124
|
+
values: [42],
|
|
125
|
+
onvalueschange: onChange,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
target.reset();
|
|
129
|
+
await view.unmount();
|
|
130
|
+
await settleReset();
|
|
131
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
132
|
+
target.remove();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe('MultiSelect selected disabled option posting', () => {
|
|
136
|
+
it('omits initially disabled selections and responds to dynamic disable without losing state', async () => {
|
|
137
|
+
const target = document.createElement('form');
|
|
138
|
+
document.body.append(target);
|
|
139
|
+
const options = [
|
|
140
|
+
{ value: 42, label: 'Selected', disabled: true },
|
|
141
|
+
{ value: 'other', label: 'Other' },
|
|
142
|
+
];
|
|
143
|
+
const view = render(MultiSelect, {
|
|
144
|
+
target,
|
|
145
|
+
props: {
|
|
146
|
+
options,
|
|
147
|
+
label: 'Choice',
|
|
148
|
+
name: 'choice',
|
|
149
|
+
values: [42, 'other'],
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
expect(new FormData(target).getAll('choice')).toEqual(['other']);
|
|
153
|
+
await view.rerender({
|
|
154
|
+
options: options.map((option) => ({ ...option, disabled: false })),
|
|
155
|
+
});
|
|
156
|
+
expect(new FormData(target).getAll('choice')).toEqual(['42', 'other']);
|
|
157
|
+
await view.rerender({ options });
|
|
158
|
+
expect(new FormData(target).getAll('choice')).toEqual(['other']);
|
|
159
|
+
await view.rerender({
|
|
160
|
+
options: options.map((option) => ({ ...option, disabled: false })),
|
|
161
|
+
});
|
|
162
|
+
expect(new FormData(target).getAll('choice')).toEqual(['42', 'other']);
|
|
163
|
+
await view.unmount();
|
|
164
|
+
target.remove();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
describe('MultiSelect reset listener ordering', () => {
|
|
168
|
+
it('honors cancellation from a later listener microtask', async () => {
|
|
169
|
+
const target = document.createElement('form');
|
|
170
|
+
document.body.append(target);
|
|
171
|
+
const onChange = vi.fn();
|
|
172
|
+
const view = render(MultiSelect, {
|
|
173
|
+
target,
|
|
174
|
+
props: {
|
|
175
|
+
options: [
|
|
176
|
+
{ value: 42, label: 'Initial' },
|
|
177
|
+
{ value: 'changed', label: 'Changed' },
|
|
178
|
+
],
|
|
179
|
+
label: 'Choice',
|
|
180
|
+
name: 'choice',
|
|
181
|
+
values: [42],
|
|
182
|
+
onvalueschange: onChange,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
await view.rerender({ values: ['changed'] });
|
|
186
|
+
target.addEventListener('reset', (event) => {
|
|
187
|
+
queueMicrotask(() => event.preventDefault());
|
|
188
|
+
});
|
|
189
|
+
target.reset();
|
|
190
|
+
await settleReset();
|
|
191
|
+
expect(new FormData(target).getAll('choice')).toEqual(['changed']);
|
|
192
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
193
|
+
await view.unmount();
|
|
194
|
+
target.remove();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
function dispatchLocalCommand(registry, command) {
|
|
198
|
+
const target = new EventTarget();
|
|
199
|
+
let pending;
|
|
200
|
+
target.addEventListener('click', (event) => {
|
|
201
|
+
pending = executeLocalControlCommand(registry, command, event);
|
|
202
|
+
}, { once: true });
|
|
203
|
+
target.dispatchEvent(new Event('click'));
|
|
204
|
+
if (!pending)
|
|
205
|
+
throw new Error('local command handler did not run');
|
|
206
|
+
return pending;
|
|
207
|
+
}
|
|
208
|
+
describe('MultiSelect reset registry currency', () => {
|
|
209
|
+
it.each([
|
|
210
|
+
false,
|
|
211
|
+
true,
|
|
212
|
+
])('keeps staged proposal currency correct when cancellation is %s', async (cancel) => {
|
|
213
|
+
const registry = createControlInteractionRegistry({
|
|
214
|
+
isLocalGesture: () => true,
|
|
215
|
+
});
|
|
216
|
+
render(Fixture, { props: { ...{ values: [42] }, registry } });
|
|
217
|
+
const identity = { formId: 'roles-form', controlId: 'roles' };
|
|
218
|
+
const target = form();
|
|
219
|
+
await open();
|
|
220
|
+
await userEvent.click(screen.getByRole('option', { name: 'Administrator' }));
|
|
221
|
+
expect((await registry.execute({ action: 'stage', identity, value: ['admin'] }, { source: 'agent' })).ok).toBe(true);
|
|
222
|
+
if (cancel)
|
|
223
|
+
target.addEventListener('reset', (event) => event.preventDefault());
|
|
224
|
+
target.reset();
|
|
225
|
+
await settleReset();
|
|
226
|
+
expect(registry.get(identity)?.state.staged?.stale).toBe(!cancel);
|
|
227
|
+
const applied = await dispatchLocalCommand(registry, {
|
|
228
|
+
action: 'apply',
|
|
229
|
+
identity,
|
|
230
|
+
});
|
|
231
|
+
expect(applied.ok).toBe(cancel);
|
|
232
|
+
if (!cancel) {
|
|
233
|
+
expect(applied.reason).toBe('staged_value_stale');
|
|
234
|
+
expect(new FormData(target).getAll('roles')).toEqual(['42']);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
it.each([
|
|
238
|
+
false,
|
|
239
|
+
true,
|
|
240
|
+
])('preserves reset over an async local clear when cancellation is %s', async (cancel) => {
|
|
241
|
+
let releasePolicy;
|
|
242
|
+
let policyStarted;
|
|
243
|
+
const blocked = new Promise((resolve) => {
|
|
244
|
+
releasePolicy = resolve;
|
|
245
|
+
});
|
|
246
|
+
const started = new Promise((resolve) => {
|
|
247
|
+
policyStarted = resolve;
|
|
248
|
+
});
|
|
249
|
+
const registry = createControlInteractionRegistry({
|
|
250
|
+
isLocalGesture: () => true,
|
|
251
|
+
policy: async (command) => {
|
|
252
|
+
if (command.action === 'clear') {
|
|
253
|
+
policyStarted();
|
|
254
|
+
await blocked;
|
|
255
|
+
}
|
|
256
|
+
return { allowed: true };
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
render(Fixture, { props: { ...{ values: [42] }, registry } });
|
|
260
|
+
const identity = { formId: 'roles-form', controlId: 'roles' };
|
|
261
|
+
const target = form();
|
|
262
|
+
await open();
|
|
263
|
+
await userEvent.click(screen.getByRole('option', { name: 'Administrator' }));
|
|
264
|
+
const clearing = dispatchLocalCommand(registry, {
|
|
265
|
+
action: 'clear',
|
|
266
|
+
identity,
|
|
267
|
+
});
|
|
268
|
+
await started;
|
|
269
|
+
if (cancel)
|
|
270
|
+
target.addEventListener('reset', (event) => event.preventDefault());
|
|
271
|
+
target.reset();
|
|
272
|
+
await settleReset();
|
|
273
|
+
releasePolicy();
|
|
274
|
+
const result = await clearing;
|
|
275
|
+
await tick();
|
|
276
|
+
expect(result.ok).toBe(cancel);
|
|
277
|
+
if (!cancel)
|
|
278
|
+
expect(result.reason).toBe('staged_value_stale');
|
|
279
|
+
expect(new FormData(target).getAll('roles')).toEqual(cancel ? [] : ['42']);
|
|
280
|
+
});
|
|
281
|
+
});
|