@happyvertical/smrt-ui 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/AGENTS.md +11 -1
  2. package/README.md +300 -2
  3. package/dist/components/data/DataTable.svelte +60 -15
  4. package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
  5. package/dist/components/data/DataTableResponsive.d.ts +4 -0
  6. package/dist/components/data/DataTableResponsive.d.ts.map +1 -0
  7. package/dist/components/data/DataTableResponsive.js +22 -0
  8. package/dist/components/data/__tests__/DataTableResponsive.test.js +80 -0
  9. package/dist/components/data/__tests__/responsive-table.fixture.svelte +29 -0
  10. package/dist/components/data/__tests__/responsive-table.fixture.svelte.d.ts +7 -0
  11. package/dist/components/data/__tests__/responsive-table.fixture.svelte.d.ts.map +1 -0
  12. package/dist/components/data/types.d.ts +8 -0
  13. package/dist/components/data/types.d.ts.map +1 -1
  14. package/dist/components/display/DateDisplay.svelte +76 -14
  15. package/dist/components/display/DateDisplay.svelte.d.ts +2 -0
  16. package/dist/components/display/DateDisplay.svelte.d.ts.map +1 -1
  17. package/dist/components/display/Icon.svelte +3 -16
  18. package/dist/components/display/Icon.svelte.d.ts +0 -9
  19. package/dist/components/display/Icon.svelte.d.ts.map +1 -1
  20. package/dist/components/display/StatusBadge.svelte +29 -2
  21. package/dist/components/display/StatusBadge.svelte.d.ts +4 -2
  22. package/dist/components/display/StatusBadge.svelte.d.ts.map +1 -1
  23. package/dist/components/display/__tests__/DateDisplay.test.js +82 -1
  24. package/dist/components/display/__tests__/Icon.test.js +132 -2
  25. package/dist/components/display/__tests__/StatusBadge.test.js +78 -0
  26. package/dist/components/display/icons.svelte.d.ts +11 -0
  27. package/dist/components/display/icons.svelte.d.ts.map +1 -0
  28. package/dist/components/display/icons.svelte.js +60 -0
  29. package/dist/components/display/index.d.ts +3 -1
  30. package/dist/components/display/index.d.ts.map +1 -1
  31. package/dist/components/display/index.js +1 -0
  32. package/dist/components/display/types.d.ts +2 -0
  33. package/dist/components/display/types.d.ts.map +1 -1
  34. package/dist/components/feedback/ConfirmDialog.svelte +59 -48
  35. package/dist/components/feedback/ConfirmDialog.svelte.d.ts +14 -2
  36. package/dist/components/feedback/ConfirmDialog.svelte.d.ts.map +1 -1
  37. package/dist/components/feedback/ToastViewport.svelte +156 -8
  38. package/dist/components/feedback/ToastViewport.svelte.d.ts +5 -1
  39. package/dist/components/feedback/ToastViewport.svelte.d.ts.map +1 -1
  40. package/dist/components/feedback/__tests__/ConfirmDialog.test.js +38 -20
  41. package/dist/components/feedback/__tests__/confirm-message.fixture.svelte +9 -0
  42. package/dist/components/feedback/__tests__/confirm-message.fixture.svelte.d.ts +19 -0
  43. package/dist/components/feedback/__tests__/confirm-message.fixture.svelte.d.ts.map +1 -0
  44. package/dist/components/feedback/__tests__/toast.test.js +142 -1
  45. package/dist/components/feedback/index.d.ts +1 -1
  46. package/dist/components/feedback/index.d.ts.map +1 -1
  47. package/dist/components/feedback/toast.d.ts +13 -1
  48. package/dist/components/feedback/toast.d.ts.map +1 -1
  49. package/dist/components/feedback/toast.js +51 -10
  50. package/dist/components/forms/CameraCapture.svelte +619 -0
  51. package/dist/components/forms/CameraCapture.svelte.d.ts +42 -0
  52. package/dist/components/forms/CameraCapture.svelte.d.ts.map +1 -0
  53. package/dist/components/forms/Checkbox.svelte +8 -1
  54. package/dist/components/forms/Checkbox.svelte.d.ts +2 -0
  55. package/dist/components/forms/Checkbox.svelte.d.ts.map +1 -1
  56. package/dist/components/forms/Combobox.svelte +40 -0
  57. package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
  58. package/dist/components/forms/Form.svelte +5 -0
  59. package/dist/components/forms/Form.svelte.d.ts +1 -0
  60. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  61. package/dist/components/forms/Input.svelte +8 -1
  62. package/dist/components/forms/Input.svelte.d.ts +2 -0
  63. package/dist/components/forms/Input.svelte.d.ts.map +1 -1
  64. package/dist/components/forms/Listbox.svelte +39 -1
  65. package/dist/components/forms/Listbox.svelte.d.ts.map +1 -1
  66. package/dist/components/forms/MultiSelect.svelte +41 -1
  67. package/dist/components/forms/MultiSelect.svelte.d.ts.map +1 -1
  68. package/dist/components/forms/Radio.svelte +1 -0
  69. package/dist/components/forms/RadioGroup.svelte +7 -1
  70. package/dist/components/forms/RadioGroup.svelte.d.ts +2 -0
  71. package/dist/components/forms/RadioGroup.svelte.d.ts.map +1 -1
  72. package/dist/components/forms/SegmentedControl.svelte +152 -11
  73. package/dist/components/forms/SegmentedControl.svelte.d.ts +4 -0
  74. package/dist/components/forms/SegmentedControl.svelte.d.ts.map +1 -1
  75. package/dist/components/forms/Select.svelte +8 -1
  76. package/dist/components/forms/Select.svelte.d.ts +2 -0
  77. package/dist/components/forms/Select.svelte.d.ts.map +1 -1
  78. package/dist/components/forms/SignaturePad.svelte +436 -0
  79. package/dist/components/forms/SignaturePad.svelte.d.ts +32 -0
  80. package/dist/components/forms/SignaturePad.svelte.d.ts.map +1 -0
  81. package/dist/components/forms/Switch.svelte +8 -1
  82. package/dist/components/forms/Switch.svelte.d.ts +2 -0
  83. package/dist/components/forms/Switch.svelte.d.ts.map +1 -1
  84. package/dist/components/forms/Textarea.svelte +8 -1
  85. package/dist/components/forms/Textarea.svelte.d.ts +2 -0
  86. package/dist/components/forms/Textarea.svelte.d.ts.map +1 -1
  87. package/dist/components/forms/__tests__/CameraCapture.test.js +504 -0
  88. package/dist/components/forms/__tests__/Combobox.test.js +279 -12
  89. package/dist/components/forms/__tests__/Form.test.js +46 -0
  90. package/dist/components/forms/__tests__/Listbox.test.js +303 -0
  91. package/dist/components/forms/__tests__/MultiSelect.test.js +281 -0
  92. package/dist/components/forms/__tests__/SignaturePad.test.js +338 -0
  93. package/dist/components/forms/__tests__/camera-capture-session.test.js +207 -0
  94. package/dist/components/forms/__tests__/capture-form-retry.test.js +166 -0
  95. package/dist/components/forms/__tests__/capture-form.fixture.svelte +38 -0
  96. package/dist/components/forms/__tests__/capture-form.fixture.svelte.d.ts +10 -0
  97. package/dist/components/forms/__tests__/capture-form.fixture.svelte.d.ts.map +1 -0
  98. package/dist/components/forms/__tests__/capture-test-env.d.ts +70 -0
  99. package/dist/components/forms/__tests__/capture-test-env.d.ts.map +1 -0
  100. package/dist/components/forms/__tests__/capture-test-env.js +122 -0
  101. package/dist/components/forms/__tests__/capture-touch.fixture.svelte +15 -0
  102. package/dist/components/forms/__tests__/capture-touch.fixture.svelte.d.ts +8 -0
  103. package/dist/components/forms/__tests__/capture-touch.fixture.svelte.d.ts.map +1 -0
  104. package/dist/components/forms/__tests__/combobox-form.fixture.svelte +29 -0
  105. package/dist/components/forms/__tests__/combobox-form.fixture.svelte.d.ts +14 -0
  106. package/dist/components/forms/__tests__/combobox-form.fixture.svelte.d.ts.map +1 -0
  107. package/dist/components/forms/__tests__/form-attachment.fixture.svelte +28 -0
  108. package/dist/components/forms/__tests__/form-attachment.fixture.svelte.d.ts +11 -0
  109. package/dist/components/forms/__tests__/form-attachment.fixture.svelte.d.ts.map +1 -0
  110. package/dist/components/forms/__tests__/form-retry-form.fixture.svelte +31 -0
  111. package/dist/components/forms/__tests__/form-retry-form.fixture.svelte.d.ts +13 -0
  112. package/dist/components/forms/__tests__/form-retry-form.fixture.svelte.d.ts.map +1 -0
  113. package/dist/components/forms/__tests__/form-retry-form.test.js +107 -0
  114. package/dist/components/forms/__tests__/hidden-controls-form-retry.test.js +69 -0
  115. package/dist/components/forms/__tests__/hidden-controls-form.fixture.svelte +29 -0
  116. package/dist/components/forms/__tests__/hidden-controls-form.fixture.svelte.d.ts +9 -0
  117. package/dist/components/forms/__tests__/hidden-controls-form.fixture.svelte.d.ts.map +1 -0
  118. package/dist/components/forms/__tests__/listbox-form.fixture.svelte +27 -0
  119. package/dist/components/forms/__tests__/listbox-form.fixture.svelte.d.ts +13 -0
  120. package/dist/components/forms/__tests__/listbox-form.fixture.svelte.d.ts.map +1 -0
  121. package/dist/components/forms/__tests__/multiselect-form.fixture.svelte +27 -0
  122. package/dist/components/forms/__tests__/multiselect-form.fixture.svelte.d.ts +13 -0
  123. package/dist/components/forms/__tests__/multiselect-form.fixture.svelte.d.ts.map +1 -0
  124. package/dist/components/forms/__tests__/native-file-field.test.js +177 -0
  125. package/dist/components/forms/__tests__/segmented-posting.fixture.svelte +28 -0
  126. package/dist/components/forms/__tests__/segmented-posting.fixture.svelte.d.ts +12 -0
  127. package/dist/components/forms/__tests__/segmented-posting.fixture.svelte.d.ts.map +1 -0
  128. package/dist/components/forms/__tests__/segmented-posting.test.js +92 -0
  129. package/dist/components/forms/__tests__/signature-pad-logic.test.js +54 -0
  130. package/dist/components/forms/__tests__/touch-density.fixture.svelte +34 -0
  131. package/dist/components/forms/__tests__/touch-density.fixture.svelte.d.ts +8 -0
  132. package/dist/components/forms/__tests__/touch-density.fixture.svelte.d.ts.map +1 -0
  133. package/dist/components/forms/__tests__/touch-density.test.js +66 -0
  134. package/dist/components/forms/camera-capture-session.d.ts +81 -0
  135. package/dist/components/forms/camera-capture-session.d.ts.map +1 -0
  136. package/dist/components/forms/camera-capture-session.js +130 -0
  137. package/dist/components/forms/form-retry/__tests__/boundaries.test.js +23 -0
  138. package/dist/components/forms/form-retry/__tests__/content-digest.test.js +70 -0
  139. package/dist/components/forms/form-retry/__tests__/controller.test.js +802 -0
  140. package/dist/components/forms/form-retry/__tests__/fake-kit.d.ts +82 -0
  141. package/dist/components/forms/form-retry/__tests__/fake-kit.d.ts.map +1 -0
  142. package/dist/components/forms/form-retry/__tests__/fake-kit.js +180 -0
  143. package/dist/components/forms/form-retry/__tests__/form-draft.test.js +169 -0
  144. package/dist/components/forms/form-retry/__tests__/ssr.test.js +37 -0
  145. package/dist/components/forms/form-retry/__tests__/storage.d.ts +26 -0
  146. package/dist/components/forms/form-retry/__tests__/storage.d.ts.map +1 -0
  147. package/dist/components/forms/form-retry/__tests__/storage.js +63 -0
  148. package/dist/components/forms/form-retry/__tests__/submission-key.test.js +236 -0
  149. package/dist/components/forms/form-retry/__tests__/submitted-draft.test.js +334 -0
  150. package/dist/components/forms/form-retry/content-digest.d.ts +54 -0
  151. package/dist/components/forms/form-retry/content-digest.d.ts.map +1 -0
  152. package/dist/components/forms/form-retry/content-digest.js +169 -0
  153. package/dist/components/forms/form-retry/controller.d.ts +214 -0
  154. package/dist/components/forms/form-retry/controller.d.ts.map +1 -0
  155. package/dist/components/forms/form-retry/controller.js +404 -0
  156. package/dist/components/forms/form-retry/form-draft.d.ts +68 -0
  157. package/dist/components/forms/form-retry/form-draft.d.ts.map +1 -0
  158. package/dist/components/forms/form-retry/form-draft.js +161 -0
  159. package/dist/components/forms/form-retry/index.d.ts +19 -0
  160. package/dist/components/forms/form-retry/index.d.ts.map +1 -0
  161. package/dist/components/forms/form-retry/index.js +16 -0
  162. package/dist/components/forms/form-retry/submission-key.d.ts +244 -0
  163. package/dist/components/forms/form-retry/submission-key.d.ts.map +1 -0
  164. package/dist/components/forms/form-retry/submission-key.js +342 -0
  165. package/dist/components/forms/form-retry/submitted-draft.d.ts +112 -0
  166. package/dist/components/forms/form-retry/submitted-draft.d.ts.map +1 -0
  167. package/dist/components/forms/form-retry/submitted-draft.js +270 -0
  168. package/dist/components/forms/form-retry/types.d.ts +84 -0
  169. package/dist/components/forms/form-retry/types.d.ts.map +1 -0
  170. package/dist/components/forms/form-retry/types.js +13 -0
  171. package/dist/components/forms/index.d.ts +7 -1
  172. package/dist/components/forms/index.d.ts.map +1 -1
  173. package/dist/components/forms/index.js +8 -0
  174. package/dist/components/forms/native-file-field.d.ts +94 -0
  175. package/dist/components/forms/native-file-field.d.ts.map +1 -0
  176. package/dist/components/forms/native-file-field.js +147 -0
  177. package/dist/components/forms/signature-pad-logic.d.ts +50 -0
  178. package/dist/components/forms/signature-pad-logic.d.ts.map +1 -0
  179. package/dist/components/forms/signature-pad-logic.js +48 -0
  180. package/dist/components/forms/types.d.ts +41 -0
  181. package/dist/components/forms/types.d.ts.map +1 -1
  182. package/dist/components/nav/FilterChips.svelte +8 -1
  183. package/dist/components/nav/FilterChips.svelte.d.ts +2 -0
  184. package/dist/components/nav/FilterChips.svelte.d.ts.map +1 -1
  185. package/dist/components/ui/Button.svelte +9 -2
  186. package/dist/components/ui/Button.svelte.d.ts +2 -0
  187. package/dist/components/ui/Button.svelte.d.ts.map +1 -1
  188. package/dist/i18n/strings.ui.d.ts +24 -0
  189. package/dist/i18n/strings.ui.d.ts.map +1 -1
  190. package/dist/i18n/strings.ui.js +26 -0
  191. package/dist/styles/tokens.css +2 -0
  192. package/dist/svelte/__tests__/playground.test.js +2 -1
  193. package/dist/svelte/playground/CapturePreview.svelte +56 -0
  194. package/dist/svelte/playground/CapturePreview.svelte.d.ts +4 -0
  195. package/dist/svelte/playground/CapturePreview.svelte.d.ts.map +1 -0
  196. package/dist/svelte/playground.d.ts.map +1 -1
  197. package/dist/svelte/playground.js +10 -0
  198. package/dist/themes/ThemeProvider.svelte +9 -0
  199. package/dist/themes/ThemeProvider.svelte.d.ts +2 -0
  200. package/dist/themes/ThemeProvider.svelte.d.ts.map +1 -1
  201. package/dist/themes/css-generator.d.ts.map +1 -1
  202. package/dist/themes/css-generator.js +3 -0
  203. package/dist/themes/styles/glass.css +4 -0
  204. package/dist/themes/styles/happyvertical.css +4 -0
  205. package/dist/themes/styles/material.css +4 -0
  206. package/dist/themes/styles/smrt.css +4 -0
  207. package/dist/themes/styles/studio.css +4 -0
  208. package/package.json +8 -2
@@ -1,8 +1,98 @@
1
- import { render, screen } from '@testing-library/svelte';
1
+ import { fireEvent, render, screen } from '@testing-library/svelte';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { tick } from 'svelte';
4
- import { describe, expect, it } from 'vitest';
5
- import Fixture from './combobox-labels.fixture.svelte';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import Combobox from '../Combobox.svelte';
6
+ import { createControlInteractionRegistry, executeLocalControlCommand, } from '../control-interaction.js';
7
+ import FormFixture from './combobox-form.fixture.svelte';
8
+ import LabelsFixture from './combobox-labels.fixture.svelte';
9
+ function data() {
10
+ return new FormData(screen.getByRole('form', { name: 'Order' }));
11
+ }
12
+ async function selectPlate() {
13
+ await userEvent.click(screen.getByRole('combobox', { name: 'SKU' }));
14
+ await userEvent.click(screen.getByRole('option', { name: 'Plate 1/4 A36' }));
15
+ }
16
+ describe('Combobox native form value', () => {
17
+ it('posts the selected value once, retaining it while searching', async () => {
18
+ render(FormFixture);
19
+ await selectPlate();
20
+ expect(data().getAll('sku')).toEqual(['42']);
21
+ const input = screen.getByRole('combobox', { name: 'SKU' });
22
+ expect(input).not.toHaveAttribute('name');
23
+ await userEvent.clear(input);
24
+ await userEvent.type(input, 'unselected search');
25
+ expect(data().getAll('sku')).toEqual(['42']);
26
+ });
27
+ it('posts keyboard-selected and custom values', async () => {
28
+ render(FormFixture, { props: { allowCustom: true } });
29
+ const input = screen.getByRole('combobox', { name: 'SKU' });
30
+ await userEvent.type(input, 'Plate');
31
+ await userEvent.keyboard('{Enter}');
32
+ expect(data().getAll('sku')).toEqual(['42']);
33
+ await userEvent.clear(input);
34
+ await userEvent.type(input, 'custom');
35
+ expect(data().getAll('sku')).toEqual(['custom']);
36
+ });
37
+ it('posts an empty named value and omits missing or empty names', async () => {
38
+ const { rerender } = render(FormFixture);
39
+ expect(data().getAll('sku')).toEqual(['']);
40
+ await rerender({ name: '' });
41
+ expect([...data()]).toEqual([]);
42
+ await rerender({ unnamed: true });
43
+ expect([...data()]).toEqual([]);
44
+ });
45
+ it.each([
46
+ { disabled: true },
47
+ { fieldsetDisabled: true },
48
+ ])('omits disabled values: %j', (props) => {
49
+ render(FormFixture, { props: { ...props, value: '42' } });
50
+ expect([...data()]).toEqual([]);
51
+ });
52
+ it('does not select disabled options', async () => {
53
+ render(FormFixture);
54
+ await userEvent.click(screen.getByRole('combobox', { name: 'SKU' }));
55
+ await userEvent.click(screen.getByRole('option', { name: 'Disabled plate' }));
56
+ expect(data().getAll('sku')).toEqual(['']);
57
+ });
58
+ it.each([
59
+ '',
60
+ '42',
61
+ ])('restores initial selection %j on native reset', async (value) => {
62
+ render(FormFixture, { props: { value, allowCustom: true } });
63
+ const input = screen.getByRole('combobox', { name: 'SKU' });
64
+ await userEvent.clear(input);
65
+ await userEvent.type(input, 'changed');
66
+ screen.getByRole('form', { name: 'Order' }).reset();
67
+ await settleReset();
68
+ expect(data().getAll('sku')).toEqual([value]);
69
+ expect(input).toHaveValue(value ? 'Plate 1/4 A36' : '');
70
+ });
71
+ it('preserves selection when reset is canceled', async () => {
72
+ render(FormFixture);
73
+ await selectPlate();
74
+ const form = screen.getByRole('form', { name: 'Order' });
75
+ form.addEventListener('reset', (event) => event.preventDefault());
76
+ await fireEvent.reset(form);
77
+ await settleReset();
78
+ expect(data().getAll('sku')).toEqual(['42']);
79
+ });
80
+ it('posts registry-applied values while preserving identity and staged isolation', async () => {
81
+ const registry = createControlInteractionRegistry({
82
+ isLocalGesture: () => true,
83
+ });
84
+ render(FormFixture, { props: { registry } });
85
+ await registry.execute({
86
+ action: 'stage',
87
+ identity: { formId: 'order', controlId: 'sku' },
88
+ value: '42',
89
+ }, { source: 'agent' });
90
+ expect(data().getAll('sku')).toEqual(['']);
91
+ await userEvent.click(screen.getByRole('button', { name: 'Apply valid changes' }));
92
+ expect(data().getAll('sku')).toEqual(['42']);
93
+ expect(registry.get({ formId: 'order', controlId: 'sku' })?.state.value).toBe('42');
94
+ });
95
+ });
6
96
  const towns = [
7
97
  { value: '0b9c-uuid-lacombe', label: 'Lacombe' },
8
98
  { value: '77aa-uuid-blackfalds', label: 'Blackfalds' },
@@ -10,13 +100,13 @@ const towns = [
10
100
  ];
11
101
  describe('Combobox labels and reopening', () => {
12
102
  it('shows the option label on first render, never the raw id', () => {
13
- render(Fixture, {
103
+ render(LabelsFixture, {
14
104
  props: { options: towns, value: '77aa-uuid-blackfalds' },
15
105
  });
16
106
  expect(screen.getByRole('combobox', { name: 'Town' })).toHaveValue('Blackfalds');
17
107
  });
18
108
  it('shows valueLabel (or nothing) until async options arrive, then the label', async () => {
19
- const view = render(Fixture, {
109
+ const view = render(LabelsFixture, {
20
110
  props: { options: [], value: '12cd-uuid-ponoka', valueLabel: 'Ponoka' },
21
111
  });
22
112
  const input = screen.getByRole('combobox', { name: 'Town' });
@@ -26,15 +116,15 @@ describe('Combobox labels and reopening', () => {
26
116
  value: '12cd-uuid-ponoka',
27
117
  valueLabel: undefined,
28
118
  });
29
- await tick();
119
+ await settleReset();
30
120
  expect(input).toHaveValue('');
31
121
  expect(input).not.toHaveValue('12cd-uuid-ponoka');
32
122
  await view.rerender({ options: towns, value: '12cd-uuid-ponoka' });
33
- await tick();
123
+ await settleReset();
34
124
  expect(input).toHaveValue('Ponoka');
35
125
  });
36
126
  it('reopening after a choice lists every option, not only the current one', async () => {
37
- render(Fixture, { props: { options: towns, value: '' } });
127
+ render(LabelsFixture, { props: { options: towns, value: '' } });
38
128
  const input = screen.getByRole('combobox', { name: 'Town' });
39
129
  await userEvent.type(input, 'lacom');
40
130
  expect(screen.getAllByRole('option')).toHaveLength(1);
@@ -47,7 +137,7 @@ describe('Combobox labels and reopening', () => {
47
137
  expect(screen.getByRole('option', { name: 'Lacombe' })).toHaveAttribute('aria-selected', 'true');
48
138
  });
49
139
  it('puts the chosen label back when the person leaves without choosing', async () => {
50
- render(Fixture, {
140
+ render(LabelsFixture, {
51
141
  props: { options: towns, value: '77aa-uuid-blackfalds' },
52
142
  });
53
143
  const input = screen.getByRole('combobox', { name: 'Town' });
@@ -58,7 +148,7 @@ describe('Combobox labels and reopening', () => {
58
148
  expect(screen.getByTestId('value')).toHaveTextContent('77aa-uuid-blackfalds');
59
149
  });
60
150
  it('posts the committed id under its name, not the visible label', async () => {
61
- const { container } = render(Fixture, {
151
+ const { container } = render(LabelsFixture, {
62
152
  props: { options: towns, value: '77aa-uuid-blackfalds' },
63
153
  });
64
154
  const input = screen.getByRole('combobox', { name: 'Town' });
@@ -71,7 +161,7 @@ describe('Combobox labels and reopening', () => {
71
161
  expect(container.querySelector('input[name="town"]')).toHaveValue('12cd-uuid-ponoka');
72
162
  });
73
163
  it('a required combobox is valid while its value is committed, even before its option loads', async () => {
74
- const view = render(Fixture, {
164
+ const view = render(LabelsFixture, {
75
165
  props: { options: [], value: '12cd-uuid-ponoka', required: true },
76
166
  });
77
167
  const input = screen.getByRole('combobox', {
@@ -81,7 +171,184 @@ describe('Combobox labels and reopening', () => {
81
171
  expect(input.checkValidity()).toBe(true);
82
172
  expect(input).toHaveAttribute('aria-required', 'true');
83
173
  await view.rerender({ options: towns, value: '', required: true });
84
- await tick();
174
+ await settleReset();
85
175
  expect(input.checkValidity()).toBe(false);
86
176
  });
87
177
  });
178
+ async function settleReset() {
179
+ await new Promise((resolve) => setTimeout(resolve));
180
+ await tick();
181
+ }
182
+ describe('Combobox deferred native reset', () => {
183
+ it('preserves a newer programmatic value before reset completion', async () => {
184
+ const target = document.createElement('form');
185
+ document.body.append(target);
186
+ const view = render(Combobox, {
187
+ target,
188
+ props: {
189
+ options: [
190
+ { value: 42, label: 'Initial' },
191
+ { value: 'other', label: 'Other' },
192
+ { value: 'changed', label: 'Changed' },
193
+ ],
194
+ label: 'Choice',
195
+ name: 'choice',
196
+ value: '42',
197
+ },
198
+ });
199
+ await view.rerender({ value: 'changed' });
200
+ target.reset();
201
+ await view.rerender({ value: 'other' });
202
+ await settleReset();
203
+ expect(new FormData(target).getAll('choice')).toEqual(['other']);
204
+ await view.unmount();
205
+ target.remove();
206
+ });
207
+ it('cancels pending completion when unmounted', async () => {
208
+ const target = document.createElement('form');
209
+ document.body.append(target);
210
+ const onChange = vi.fn();
211
+ const view = render(Combobox, {
212
+ target,
213
+ props: {
214
+ options: [
215
+ { value: 42, label: 'Initial' },
216
+ { value: 'other', label: 'Other' },
217
+ { value: 'changed', label: 'Changed' },
218
+ ],
219
+ label: 'Choice',
220
+ name: 'choice',
221
+ value: '42',
222
+ onvaluechange: onChange,
223
+ },
224
+ });
225
+ target.reset();
226
+ await view.unmount();
227
+ await settleReset();
228
+ expect(onChange).not.toHaveBeenCalled();
229
+ target.remove();
230
+ });
231
+ });
232
+ describe('Combobox reset listener ordering', () => {
233
+ it('honors cancellation from a later listener microtask', async () => {
234
+ const target = document.createElement('form');
235
+ document.body.append(target);
236
+ const onChange = vi.fn();
237
+ const view = render(Combobox, {
238
+ target,
239
+ props: {
240
+ options: [
241
+ { value: 42, label: 'Initial' },
242
+ { value: 'changed', label: 'Changed' },
243
+ ],
244
+ label: 'Choice',
245
+ name: 'choice',
246
+ value: '42',
247
+ onvaluechange: onChange,
248
+ },
249
+ });
250
+ await view.rerender({ value: 'changed' });
251
+ target.addEventListener('reset', (event) => {
252
+ queueMicrotask(() => event.preventDefault());
253
+ });
254
+ target.reset();
255
+ await settleReset();
256
+ expect(new FormData(target).getAll('choice')).toEqual(['changed']);
257
+ expect(onChange).not.toHaveBeenCalled();
258
+ await view.unmount();
259
+ target.remove();
260
+ });
261
+ });
262
+ function dispatchLocalCommand(registry, command) {
263
+ const target = new EventTarget();
264
+ let pending;
265
+ target.addEventListener('click', (event) => {
266
+ pending = executeLocalControlCommand(registry, command, event);
267
+ }, { once: true });
268
+ target.dispatchEvent(new Event('click'));
269
+ if (!pending)
270
+ throw new Error('local command handler did not run');
271
+ return pending;
272
+ }
273
+ describe('Combobox reset registry currency', () => {
274
+ it.each([
275
+ false,
276
+ true,
277
+ ])('keeps staged proposal currency correct when cancellation is %s', async (cancel) => {
278
+ const registry = createControlInteractionRegistry({
279
+ isLocalGesture: () => true,
280
+ });
281
+ render(FormFixture, {
282
+ props: { ...{ value: '42', allowCustom: true }, registry },
283
+ });
284
+ const identity = { formId: 'order', controlId: 'sku' };
285
+ const target = screen.getByRole('form', {
286
+ name: 'Order',
287
+ });
288
+ await userEvent.clear(screen.getByRole('combobox', { name: 'SKU' }));
289
+ await userEvent.type(screen.getByRole('combobox', { name: 'SKU' }), 'custom');
290
+ expect((await registry.execute({ action: 'stage', identity, value: '42' }, { source: 'agent' })).ok).toBe(true);
291
+ if (cancel)
292
+ target.addEventListener('reset', (event) => event.preventDefault());
293
+ target.reset();
294
+ await settleReset();
295
+ expect(registry.get(identity)?.state.staged?.stale).toBe(!cancel);
296
+ const applied = await dispatchLocalCommand(registry, {
297
+ action: 'apply',
298
+ identity,
299
+ });
300
+ expect(applied.ok).toBe(cancel);
301
+ if (!cancel) {
302
+ expect(applied.reason).toBe('staged_value_stale');
303
+ expect(new FormData(target).getAll('sku')).toEqual(['42']);
304
+ }
305
+ });
306
+ it.each([
307
+ false,
308
+ true,
309
+ ])('preserves reset over an async local clear when cancellation is %s', async (cancel) => {
310
+ let releasePolicy;
311
+ let policyStarted;
312
+ const blocked = new Promise((resolve) => {
313
+ releasePolicy = resolve;
314
+ });
315
+ const started = new Promise((resolve) => {
316
+ policyStarted = resolve;
317
+ });
318
+ const registry = createControlInteractionRegistry({
319
+ isLocalGesture: () => true,
320
+ policy: async (command) => {
321
+ if (command.action === 'clear') {
322
+ policyStarted();
323
+ await blocked;
324
+ }
325
+ return { allowed: true };
326
+ },
327
+ });
328
+ render(FormFixture, {
329
+ props: { ...{ value: '42', allowCustom: true }, registry },
330
+ });
331
+ const identity = { formId: 'order', controlId: 'sku' };
332
+ const target = screen.getByRole('form', {
333
+ name: 'Order',
334
+ });
335
+ await userEvent.clear(screen.getByRole('combobox', { name: 'SKU' }));
336
+ await userEvent.type(screen.getByRole('combobox', { name: 'SKU' }), 'custom');
337
+ const clearing = dispatchLocalCommand(registry, {
338
+ action: 'clear',
339
+ identity,
340
+ });
341
+ await started;
342
+ if (cancel)
343
+ target.addEventListener('reset', (event) => event.preventDefault());
344
+ target.reset();
345
+ await settleReset();
346
+ releasePolicy();
347
+ const result = await clearing;
348
+ await tick();
349
+ expect(result.ok).toBe(cancel);
350
+ if (!cancel)
351
+ expect(result.reason).toBe('staged_value_stale');
352
+ expect(new FormData(target).getAll('sku')).toEqual(cancel ? [''] : ['42']);
353
+ });
354
+ });
@@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest';
12
12
  import { expectNoA11yViolations } from '../../../test-support/a11y';
13
13
  import { createControlInteractionRegistry, executeLocalControlCommand, } from '../control-interaction.js';
14
14
  import Form from '../Form.svelte';
15
+ import FormAttachmentFixture from './form-attachment.fixture.svelte';
15
16
  const children = createRawSnippet(() => ({
16
17
  render: () => '<button type="submit">Save</button>',
17
18
  }));
@@ -41,6 +42,51 @@ function dispatchLocalGesture(execute) {
41
42
  return pending;
42
43
  }
43
44
  describe('Form', () => {
45
+ it('exposes its native element while mounted and clears it on unmount', async () => {
46
+ const { component, container, unmount } = render(Form, {
47
+ props: { children },
48
+ });
49
+ expect(component.getFormElement()).toBe(container.querySelector('form'));
50
+ await unmount();
51
+ expect(component.getFormElement()).toBeNull();
52
+ });
53
+ it('forwards an action attachment and preserves form posting and control registration', async () => {
54
+ const registry = createControlInteractionRegistry();
55
+ const attached = vi.fn();
56
+ const destroyed = vi.fn();
57
+ const submitted = vi.fn();
58
+ const onsubmit = vi.fn();
59
+ const enhance = (form) => {
60
+ attached(form);
61
+ const submit = (event) => {
62
+ submitted(event.defaultPrevented, new FormData(form));
63
+ event.preventDefault();
64
+ };
65
+ form.addEventListener('submit', submit);
66
+ return {
67
+ destroy() {
68
+ form.removeEventListener('submit', submit);
69
+ destroyed();
70
+ },
71
+ };
72
+ };
73
+ const { container, unmount } = render(FormAttachmentFixture, {
74
+ props: { enhance, registry, onsubmit },
75
+ });
76
+ const form = container.querySelector('form');
77
+ expect(attached).toHaveBeenCalledExactlyOnceWith(form);
78
+ expect(form).toHaveAttribute('method', 'POST');
79
+ expect(form).toHaveAttribute('action', '/profile');
80
+ expect(registry.list()).toHaveLength(1);
81
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
82
+ expect(submitted).toHaveBeenCalledOnce();
83
+ expect(submitted.mock.calls[0][0]).toBe(false);
84
+ expect(submitted.mock.calls[0][1].get('displayName')).toBe('Dana');
85
+ expect(onsubmit).toHaveBeenCalledOnce();
86
+ await unmount();
87
+ expect(destroyed).toHaveBeenCalledOnce();
88
+ expect(registry.list()).toHaveLength(0);
89
+ });
44
90
  it('renders a <form> with forwarded attributes and its children', () => {
45
91
  const { container } = render(Form, {
46
92
  props: { name: 'profile', class: 'profile-form', children },