@adia-ai/web-components 0.6.32 → 0.6.34

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 (164) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/components/accordion/accordion.css +2 -2
  3. package/components/action-list/action-list.css +2 -2
  4. package/components/agent-artifact/agent-artifact.css +31 -31
  5. package/components/agent-feedback-bar/agent-feedback-bar.css +10 -10
  6. package/components/agent-questions/agent-questions.css +57 -57
  7. package/components/agent-reasoning/agent-reasoning.css +62 -62
  8. package/components/agent-suggestions/agent-suggestions.css +4 -4
  9. package/components/agent-trace/agent-trace.css +53 -53
  10. package/components/alert/alert.css +41 -41
  11. package/components/avatar/avatar.css +27 -27
  12. package/components/badge/badge.css +27 -27
  13. package/components/block/block.css +16 -16
  14. package/components/breadcrumb/breadcrumb.css +23 -23
  15. package/components/button/button.css +101 -91
  16. package/components/calendar-grid/calendar-grid.a2ui.json +136 -0
  17. package/components/calendar-grid/calendar-grid.css +226 -0
  18. package/components/calendar-grid/calendar-grid.d.ts +37 -0
  19. package/components/calendar-grid/calendar-grid.js +17 -0
  20. package/components/calendar-grid/calendar-grid.yaml +116 -0
  21. package/components/calendar-grid/class.js +300 -0
  22. package/components/calendar-picker/calendar-picker.css +139 -139
  23. package/components/canvas/canvas.css +12 -12
  24. package/components/card/card.css +83 -83
  25. package/components/chart/chart.css +224 -224
  26. package/components/chart-legend/chart-legend.css +26 -26
  27. package/components/check/check.css +40 -40
  28. package/components/code/code.css +125 -125
  29. package/components/col/col.css +15 -15
  30. package/components/color-picker/color-picker.css +55 -55
  31. package/components/combobox/class.js +861 -0
  32. package/components/combobox/combobox.a2ui.json +363 -0
  33. package/components/combobox/combobox.css +244 -0
  34. package/components/combobox/combobox.d.ts +113 -0
  35. package/components/combobox/combobox.examples.md +59 -0
  36. package/components/combobox/combobox.js +17 -0
  37. package/components/combobox/combobox.test.js +181 -0
  38. package/components/combobox/combobox.yaml +369 -0
  39. package/components/command/command.css +90 -90
  40. package/components/date-range-picker/class.js +775 -0
  41. package/components/date-range-picker/date-range-picker.a2ui.json +300 -0
  42. package/components/date-range-picker/date-range-picker.css +178 -0
  43. package/components/date-range-picker/date-range-picker.d.ts +82 -0
  44. package/components/date-range-picker/date-range-picker.examples.md +37 -0
  45. package/components/date-range-picker/date-range-picker.js +17 -0
  46. package/components/date-range-picker/date-range-picker.test.js +387 -0
  47. package/components/date-range-picker/date-range-picker.yaml +285 -0
  48. package/components/datetime-picker/class.js +706 -0
  49. package/components/datetime-picker/datetime-picker.a2ui.json +334 -0
  50. package/components/datetime-picker/datetime-picker.css +150 -0
  51. package/components/datetime-picker/datetime-picker.d.ts +86 -0
  52. package/components/datetime-picker/datetime-picker.examples.md +46 -0
  53. package/components/datetime-picker/datetime-picker.js +17 -0
  54. package/components/datetime-picker/datetime-picker.test.js +454 -0
  55. package/components/datetime-picker/datetime-picker.yaml +332 -0
  56. package/components/demo-toggle/demo-toggle.css +27 -27
  57. package/components/description-list/description-list.css +18 -18
  58. package/components/divider/divider.css +24 -24
  59. package/components/embed/embed.css +6 -6
  60. package/components/empty-state/empty-state.css +27 -27
  61. package/components/feed/feed.css +12 -12
  62. package/components/field/field.css +37 -28
  63. package/components/field/field.test.js +32 -0
  64. package/components/fields/fields.css +5 -5
  65. package/components/grid/grid.css +5 -5
  66. package/components/heatmap/heatmap.css +63 -63
  67. package/components/icon/icon.css +12 -12
  68. package/components/image/image.css +14 -14
  69. package/components/index.js +8 -0
  70. package/components/input/input.css +66 -66
  71. package/components/inspector/inspector.css +6 -6
  72. package/components/integration-card/class.js +410 -0
  73. package/components/integration-card/integration-card.a2ui.json +268 -0
  74. package/components/integration-card/integration-card.css +169 -0
  75. package/components/integration-card/integration-card.d.ts +63 -0
  76. package/components/integration-card/integration-card.examples.md +41 -0
  77. package/components/integration-card/integration-card.js +17 -0
  78. package/components/integration-card/integration-card.test.js +306 -0
  79. package/components/integration-card/integration-card.yaml +280 -0
  80. package/components/kbd/kbd.css +32 -32
  81. package/components/link/link.css +12 -12
  82. package/components/list/list.css +8 -8
  83. package/components/list-window/class.js +688 -0
  84. package/components/list-window/list-window.a2ui.json +277 -0
  85. package/components/list-window/list-window.css +124 -0
  86. package/components/list-window/list-window.d.ts +84 -0
  87. package/components/list-window/list-window.examples.md +73 -0
  88. package/components/list-window/list-window.js +17 -0
  89. package/components/list-window/list-window.test.js +303 -0
  90. package/components/list-window/list-window.yaml +270 -0
  91. package/components/menu/menu.css +8 -8
  92. package/components/modal/modal.css +43 -43
  93. package/components/nav/nav.css +40 -40
  94. package/components/nav-group/nav-group.css +52 -52
  95. package/components/nav-item/nav-item.css +44 -44
  96. package/components/noodles/noodles.css +31 -31
  97. package/components/option-card/option-card.css +69 -69
  98. package/components/otp-input/otp-input.css +30 -30
  99. package/components/page/page.css +18 -18
  100. package/components/pagination/pagination.css +61 -61
  101. package/components/pane/pane.css +57 -57
  102. package/components/pipeline-status/pipeline-status.css +65 -65
  103. package/components/popover/popover.css +17 -17
  104. package/components/progress/progress.css +23 -23
  105. package/components/progress-row/progress-row.css +17 -17
  106. package/components/radio/radio.css +39 -39
  107. package/components/range/range.css +55 -55
  108. package/components/rating/rating.css +28 -28
  109. package/components/richtext/richtext.css +133 -133
  110. package/components/row/row.css +19 -19
  111. package/components/search/search.css +5 -5
  112. package/components/segment/segment.css +24 -24
  113. package/components/segmented/segmented.css +25 -25
  114. package/components/select/select.css +84 -84
  115. package/components/skeleton/skeleton.css +14 -14
  116. package/components/slider/slider.css +46 -46
  117. package/components/spinner/class.js +69 -0
  118. package/components/spinner/spinner.a2ui.json +197 -0
  119. package/components/spinner/spinner.css +165 -0
  120. package/components/spinner/spinner.d.ts +26 -0
  121. package/components/spinner/spinner.examples.md +26 -0
  122. package/components/spinner/spinner.js +17 -0
  123. package/components/spinner/spinner.test.js +234 -0
  124. package/components/spinner/spinner.yaml +230 -0
  125. package/components/stack/stack.css +11 -11
  126. package/components/stat/stat.css +25 -25
  127. package/components/step-progress/step-progress.css +20 -20
  128. package/components/stepper/stepper.css +29 -29
  129. package/components/stream/stream.css +12 -12
  130. package/components/swatch/swatch.css +68 -68
  131. package/components/swiper/swiper.css +57 -57
  132. package/components/switch/switch.css +52 -52
  133. package/components/table/class.js +9 -0
  134. package/components/table/table.a2ui.json +1 -1
  135. package/components/table/table.css +162 -162
  136. package/components/table/table.d.ts +1 -1
  137. package/components/table/table.test.js +53 -0
  138. package/components/table/table.yaml +13 -1
  139. package/components/table-toolbar/table-toolbar.css +32 -32
  140. package/components/tabs/tabs.css +51 -51
  141. package/components/tag/tag.css +48 -48
  142. package/components/text/text.css +44 -44
  143. package/components/textarea/textarea.css +46 -46
  144. package/components/time-picker/class.js +693 -0
  145. package/components/time-picker/time-picker.a2ui.json +267 -0
  146. package/components/time-picker/time-picker.css +122 -0
  147. package/components/time-picker/time-picker.d.ts +75 -0
  148. package/components/time-picker/time-picker.examples.md +35 -0
  149. package/components/time-picker/time-picker.js +17 -0
  150. package/components/time-picker/time-picker.test.js +287 -0
  151. package/components/time-picker/time-picker.yaml +256 -0
  152. package/components/timeline/timeline.css +50 -50
  153. package/components/toast/toast.css +58 -58
  154. package/components/toggle-group/toggle-group.css +6 -6
  155. package/components/toggle-scheme/toggle-scheme.css +2 -2
  156. package/components/toolbar/toolbar.css +17 -17
  157. package/components/tooltip/tooltip.css +2 -2
  158. package/components/tree/tree.css +37 -37
  159. package/components/upload/upload.css +49 -49
  160. package/dist/icons-manifest.js +3 -3
  161. package/dist/web-components.min.css +1 -1
  162. package/dist/web-components.min.js +121 -83
  163. package/package.json +1 -1
  164. package/styles/components.css +8 -0
@@ -0,0 +1,387 @@
1
+ /**
2
+ * date-range-picker-ui — unit tests covering the SPEC-037 contract.
3
+ *
4
+ * Run under happy-dom (per vitest.config.js). showPopover/hidePopover are
5
+ * stubbed to no-ops by `packages/web-components/test-setup.js`, so popover
6
+ * state is tracked via the `open` reflected attribute and the `<popover>`
7
+ * div's presence, not via `:popover-open` selector matching.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach } from 'vitest';
11
+ import '../../core/element.js';
12
+ import '../button/button.js';
13
+ import '../popover/popover.js';
14
+ import '../calendar-picker/calendar-picker.js';
15
+ import '../divider/divider.js';
16
+ import './date-range-picker.js';
17
+
18
+ const tick = () => new Promise((r) => queueMicrotask(r));
19
+
20
+ function mount(html) {
21
+ const wrap = document.createElement('div');
22
+ wrap.innerHTML = html.trim();
23
+ document.body.appendChild(wrap);
24
+ return wrap.firstElementChild;
25
+ }
26
+
27
+ describe('date-range-picker-ui — structure + defaults', () => {
28
+ beforeEach(() => { document.body.innerHTML = ''; });
29
+
30
+ it('registers the custom element', () => {
31
+ expect(customElements.get('date-range-picker-ui')).toBeTruthy();
32
+ });
33
+
34
+ it('stamps a default <button-ui slot="trigger"> when no slot="trigger" is authored', async () => {
35
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
36
+ await tick();
37
+ const trigger = el.querySelector(':scope > [slot="trigger"]');
38
+ expect(trigger).not.toBeNull();
39
+ expect(trigger.localName).toBe('button-ui');
40
+ });
41
+
42
+ it('stamps a popover container with role=dialog', async () => {
43
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
44
+ await tick();
45
+ const popover = el.querySelector(':scope > [slot="popover"]');
46
+ expect(popover).not.toBeNull();
47
+ expect(popover.getAttribute('role')).toBe('dialog');
48
+ });
49
+
50
+ it('stamps TWO <calendar-grid-ui> instances inside the popover', async () => {
51
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
52
+ await tick();
53
+ const calArea = el.querySelector(':scope > [slot="popover"] > [data-calendar-area]');
54
+ expect(calArea).not.toBeNull();
55
+ const cals = calArea.querySelectorAll(':scope > calendar-grid-ui');
56
+ expect(cals.length).toBe(2);
57
+ expect(cals[0].hasAttribute('data-cal-from')).toBe(true);
58
+ expect(cals[1].hasAttribute('data-cal-to')).toBe(true);
59
+ });
60
+
61
+ it('stamps a preset rail with the default 5 presets', async () => {
62
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
63
+ await tick();
64
+ const rail = el.querySelector(':scope > [slot="popover"] > [data-preset-rail]');
65
+ expect(rail).not.toBeNull();
66
+ const buttons = rail.querySelectorAll('button-ui[data-preset-label]');
67
+ expect(buttons.length).toBe(5);
68
+ expect(buttons[0].dataset.presetLabel).toBe('Today');
69
+ });
70
+
71
+ it('host carries role=combobox + aria-haspopup=dialog', async () => {
72
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
73
+ await tick();
74
+ expect(el.getAttribute('role')).toBe('combobox');
75
+ expect(el.getAttribute('aria-haspopup')).toBe('dialog');
76
+ expect(el.getAttribute('aria-expanded')).toBe('false');
77
+ });
78
+ });
79
+
80
+ describe('date-range-picker-ui — props + reflection', () => {
81
+ beforeEach(() => { document.body.innerHTML = ''; });
82
+
83
+ it('reflects [min] and [max] onto both calendar panes', async () => {
84
+ const el = mount('<date-range-picker-ui min="2026-01-01" max="2026-12-31"></date-range-picker-ui>');
85
+ await tick();
86
+ const cals = el.querySelectorAll('calendar-grid-ui');
87
+ expect(cals[0].getAttribute('min')).toBe('2026-01-01');
88
+ expect(cals[0].getAttribute('max')).toBe('2026-12-31');
89
+ expect(cals[1].getAttribute('min')).toBe('2026-01-01');
90
+ expect(cals[1].getAttribute('max')).toBe('2026-12-31');
91
+ });
92
+
93
+ it('parses JSON `value` into rangeValue', async () => {
94
+ const el = mount('<date-range-picker-ui value=\'{"from":"2026-01-01","to":"2026-01-07"}\'></date-range-picker-ui>');
95
+ await tick();
96
+ expect(el.rangeValue).toEqual({ from: '2026-01-01', to: '2026-01-07' });
97
+ });
98
+
99
+ it('renders formatted-range text in the trigger when value is set', async () => {
100
+ const el = mount('<date-range-picker-ui value=\'{"from":"2026-01-01","to":"2026-01-07"}\'></date-range-picker-ui>');
101
+ await tick();
102
+ const trigger = el.querySelector(':scope > [slot="trigger"]');
103
+ expect(trigger.getAttribute('text')).toContain('Jan 1, 2026');
104
+ expect(trigger.getAttribute('text')).toContain('Jan 7, 2026');
105
+ });
106
+
107
+ it('uses [placeholder] in the trigger when value is empty', async () => {
108
+ const el = mount('<date-range-picker-ui placeholder="Choose range"></date-range-picker-ui>');
109
+ await tick();
110
+ const trigger = el.querySelector(':scope > [slot="trigger"]');
111
+ expect(trigger.getAttribute('text')).toBe('Choose range');
112
+ });
113
+
114
+ it('reflects [comparison] as an attribute', async () => {
115
+ const el = mount('<date-range-picker-ui comparison></date-range-picker-ui>');
116
+ await tick();
117
+ expect(el.hasAttribute('comparison')).toBe(true);
118
+ expect(el.comparison).toBe(true);
119
+ });
120
+
121
+ it('[no-presets] removes the preset rail', async () => {
122
+ const el = mount('<date-range-picker-ui no-presets></date-range-picker-ui>');
123
+ await tick();
124
+ expect(el.querySelector(':scope > [slot="popover"] > [data-preset-rail]')).toBeNull();
125
+ });
126
+
127
+ it('custom `presets` property overrides the default list', async () => {
128
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
129
+ await tick();
130
+ el.presets = [
131
+ { label: 'Q1', range: { from: '2026-01-01', to: '2026-03-31' } },
132
+ { label: 'Q2', range: { from: '2026-04-01', to: '2026-06-30' } },
133
+ ];
134
+ await tick();
135
+ const rail = el.querySelector(':scope > [slot="popover"] > [data-preset-rail]');
136
+ const buttons = rail.querySelectorAll('button-ui[data-preset-label]');
137
+ expect(buttons.length).toBe(2);
138
+ expect(buttons[0].dataset.presetLabel).toBe('Q1');
139
+ expect(buttons[1].dataset.presetLabel).toBe('Q2');
140
+ });
141
+ });
142
+
143
+ describe('date-range-picker-ui — open/close behavior', () => {
144
+ beforeEach(() => { document.body.innerHTML = ''; });
145
+
146
+ it('opens on trigger click + emits `open` event', async () => {
147
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
148
+ await tick();
149
+ let openDetail = null;
150
+ el.addEventListener('open', (e) => { openDetail = e.detail; });
151
+ const trigger = el.querySelector(':scope > [slot="trigger"]');
152
+ trigger.click();
153
+ await tick();
154
+ expect(el.open).toBe(true);
155
+ expect(el.hasAttribute('open')).toBe(true);
156
+ expect(el.getAttribute('aria-expanded')).toBe('true');
157
+ expect(openDetail?.trigger).toBe('click');
158
+ });
159
+
160
+ it('closes on second trigger click + emits `close`', async () => {
161
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
162
+ await tick();
163
+ const trigger = el.querySelector(':scope > [slot="trigger"]');
164
+ let closeDetail = null;
165
+ el.addEventListener('close', (e) => { closeDetail = e.detail; });
166
+ trigger.click();
167
+ await tick();
168
+ trigger.click();
169
+ await tick();
170
+ expect(el.open).toBe(false);
171
+ expect(closeDetail).not.toBeNull();
172
+ });
173
+
174
+ it('Escape closes the popover + emits close with reason "escape"', async () => {
175
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
176
+ await tick();
177
+ el.open = true;
178
+ await tick();
179
+ let reason = null;
180
+ el.addEventListener('close', (e) => { reason = e.detail?.reason; });
181
+ const popover = el.querySelector(':scope > [slot="popover"]');
182
+ const ev = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
183
+ popover.dispatchEvent(ev);
184
+ await tick();
185
+ expect(el.open).toBe(false);
186
+ expect(reason).toBe('escape');
187
+ });
188
+
189
+ it('Enter on the trigger opens the popover with trigger=keyboard', async () => {
190
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
191
+ await tick();
192
+ const trigger = el.querySelector(':scope > [slot="trigger"]');
193
+ let openDetail = null;
194
+ el.addEventListener('open', (e) => { openDetail = e.detail; });
195
+ trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
196
+ await tick();
197
+ expect(el.open).toBe(true);
198
+ expect(openDetail?.trigger).toBe('keyboard');
199
+ });
200
+
201
+ it('[disabled] blocks the trigger from opening', async () => {
202
+ const el = mount('<date-range-picker-ui disabled></date-range-picker-ui>');
203
+ await tick();
204
+ const trigger = el.querySelector(':scope > [slot="trigger"]');
205
+ trigger.click();
206
+ await tick();
207
+ expect(el.open).toBe(false);
208
+ });
209
+
210
+ it('[readonly] blocks the popover from opening', async () => {
211
+ const el = mount('<date-range-picker-ui readonly></date-range-picker-ui>');
212
+ await tick();
213
+ const trigger = el.querySelector(':scope > [slot="trigger"]');
214
+ trigger.click();
215
+ await tick();
216
+ expect(el.open).toBe(false);
217
+ });
218
+ });
219
+
220
+ describe('date-range-picker-ui — selection + commit', () => {
221
+ beforeEach(() => { document.body.innerHTML = ''; });
222
+
223
+ it('clicking a preset commits value + closes popover + fires `change` with presetLabel', async () => {
224
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
225
+ await tick();
226
+ el.open = true;
227
+ await tick();
228
+ let detail = null;
229
+ el.addEventListener('change', (e) => { detail = e.detail; });
230
+ const rail = el.querySelector(':scope > [slot="popover"] > [data-preset-rail]');
231
+ const todayBtn = rail.querySelector('button-ui[data-preset-label="Today"]');
232
+ todayBtn.click();
233
+ await tick();
234
+ expect(detail).not.toBeNull();
235
+ expect(detail.presetLabel).toBe('Today');
236
+ expect(detail.value.from).toBe(detail.value.to);
237
+ expect(el.open).toBe(false);
238
+ expect(el.rangeValue).not.toBeNull();
239
+ });
240
+
241
+ it('applyPreset(label) commits the matching preset', async () => {
242
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
243
+ await tick();
244
+ let detail = null;
245
+ el.addEventListener('change', (e) => { detail = e.detail; });
246
+ const result = el.applyPreset('Last 7 days');
247
+ expect(result).toBe(true);
248
+ await tick();
249
+ expect(detail).not.toBeNull();
250
+ expect(detail.presetLabel).toBe('Last 7 days');
251
+ expect(detail.value.from < detail.value.to).toBe(true);
252
+ });
253
+
254
+ it('applyPreset(unknown) returns false and does not commit', async () => {
255
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
256
+ await tick();
257
+ let fired = false;
258
+ el.addEventListener('change', () => { fired = true; });
259
+ expect(el.applyPreset('NonExistent')).toBe(false);
260
+ expect(fired).toBe(false);
261
+ });
262
+
263
+ it('emits `input` (partial state) when only `from` is selected', async () => {
264
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
265
+ await tick();
266
+ let inputDetail = null;
267
+ let changeFired = false;
268
+ el.addEventListener('input', (e) => { inputDetail = e.detail; });
269
+ el.addEventListener('change', () => { changeFired = true; });
270
+ const calFrom = el.querySelector('calendar-grid-ui[data-cal-from]');
271
+ calFrom.value = '2026-03-15';
272
+ calFrom.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: '2026-03-15' } }));
273
+ await tick();
274
+ expect(inputDetail).not.toBeNull();
275
+ expect(inputDetail.value.from).toBe('2026-03-15');
276
+ expect(inputDetail.value.to).toBeNull();
277
+ expect(changeFired).toBe(false);
278
+ });
279
+
280
+ it('commits `change` when both panes have selections in order', async () => {
281
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
282
+ await tick();
283
+ let detail = null;
284
+ el.addEventListener('change', (e) => { detail = e.detail; });
285
+ const calFrom = el.querySelector('calendar-grid-ui[data-cal-from]');
286
+ const calTo = el.querySelector('calendar-grid-ui[data-cal-to]');
287
+ calFrom.value = '2026-03-01';
288
+ calFrom.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: '2026-03-01' } }));
289
+ await tick();
290
+ calTo.value = '2026-03-15';
291
+ calTo.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: '2026-03-15' } }));
292
+ await tick();
293
+ expect(detail).not.toBeNull();
294
+ expect(detail.value).toEqual({ from: '2026-03-01', to: '2026-03-15' });
295
+ });
296
+
297
+ it('reversed range fires `invalid`, not `change`', async () => {
298
+ const el = mount('<date-range-picker-ui></date-range-picker-ui>');
299
+ await tick();
300
+ let invalidDetail = null;
301
+ let changeFired = false;
302
+ el.addEventListener('invalid', (e) => { invalidDetail = e.detail; });
303
+ el.addEventListener('change', () => { changeFired = true; });
304
+ const calFrom = el.querySelector('calendar-grid-ui[data-cal-from]');
305
+ const calTo = el.querySelector('calendar-grid-ui[data-cal-to]');
306
+ // Click `to` first (with a date), then `from` AFTER it (reversed).
307
+ calTo.value = '2026-03-01';
308
+ calTo.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: '2026-03-01' } }));
309
+ await tick();
310
+ calFrom.value = '2026-03-15';
311
+ calFrom.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: '2026-03-15' } }));
312
+ await tick();
313
+ expect(invalidDetail).not.toBeNull();
314
+ expect(invalidDetail.reason).toBe('reversed');
315
+ expect(changeFired).toBe(false);
316
+ });
317
+
318
+ it('clear() resets the value', async () => {
319
+ const el = mount('<date-range-picker-ui value=\'{"from":"2026-01-01","to":"2026-01-07"}\'></date-range-picker-ui>');
320
+ await tick();
321
+ el.clear();
322
+ await tick();
323
+ expect(el.value).toBe('');
324
+ expect(el.rangeValue).toBeNull();
325
+ });
326
+ });
327
+
328
+ describe('date-range-picker-ui — comparison mode', () => {
329
+ beforeEach(() => { document.body.innerHTML = ''; });
330
+
331
+ it('adds a comparison preset cluster when [comparison] is set', async () => {
332
+ const el = mount('<date-range-picker-ui comparison></date-range-picker-ui>');
333
+ await tick();
334
+ const rail = el.querySelector(':scope > [slot="popover"] > [data-preset-rail]');
335
+ const cmpButtons = rail.querySelectorAll('button-ui[data-comparison-preset-label]');
336
+ expect(cmpButtons.length).toBe(3);
337
+ expect(cmpButtons[0].dataset.comparisonPresetLabel).toBe('Previous period');
338
+ });
339
+
340
+ it('parses compare-value into rangeCompareValue', async () => {
341
+ const el = mount('<date-range-picker-ui comparison compare-value=\'{"from":"2026-04-01","to":"2026-04-30"}\'></date-range-picker-ui>');
342
+ await tick();
343
+ expect(el.rangeCompareValue).toEqual({ from: '2026-04-01', to: '2026-04-30' });
344
+ });
345
+
346
+ it('change detail carries compareValue when comparison is set', async () => {
347
+ const el = mount('<date-range-picker-ui comparison compare-value=\'{"from":"2026-04-01","to":"2026-04-30"}\'></date-range-picker-ui>');
348
+ await tick();
349
+ let detail = null;
350
+ el.addEventListener('change', (e) => { detail = e.detail; });
351
+ const calFrom = el.querySelector('calendar-grid-ui[data-cal-from]');
352
+ const calTo = el.querySelector('calendar-grid-ui[data-cal-to]');
353
+ calFrom.value = '2026-05-01';
354
+ calFrom.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: '2026-05-01' } }));
355
+ await tick();
356
+ calTo.value = '2026-05-31';
357
+ calTo.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: '2026-05-31' } }));
358
+ await tick();
359
+ expect(detail).not.toBeNull();
360
+ expect(detail.value).toEqual({ from: '2026-05-01', to: '2026-05-31' });
361
+ expect(detail.compareValue).toEqual({ from: '2026-04-01', to: '2026-04-30' });
362
+ });
363
+ });
364
+
365
+ describe('date-range-picker-ui — form participation', () => {
366
+ beforeEach(() => { document.body.innerHTML = ''; });
367
+
368
+ it('is form-associated', () => {
369
+ expect(customElements.get('date-range-picker-ui').formAssociated).toBe(true);
370
+ });
371
+
372
+ it('exposes ElementInternals via the host', async () => {
373
+ const el = mount('<date-range-picker-ui name="period"></date-range-picker-ui>');
374
+ await tick();
375
+ expect(el.internals).toBeDefined();
376
+ expect(typeof el.internals.setFormValue).toBe('function');
377
+ });
378
+
379
+ it('runs constraint validation on a reversed range (post-commit attempt is rejected)', async () => {
380
+ const el = mount('<date-range-picker-ui name="period" value=\'{"from":"2026-01-01","to":"2026-01-07"}\'></date-range-picker-ui>');
381
+ await tick();
382
+ // Apply a reversed range programmatically via direct value write,
383
+ // then sync — happy-dom's setValidity is a no-op, so we just confirm
384
+ // syncValue doesn't throw.
385
+ expect(() => el.syncValue('{"from":"2026-12-31","to":"2026-01-01"}')).not.toThrow();
386
+ });
387
+ });
@@ -0,0 +1,285 @@
1
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
2
+ name: UIDateRangePicker
3
+ tag: date-range-picker-ui
4
+ status: experimental
5
+ component: DateRangePicker
6
+ category: input
7
+ version: 1
8
+ description: >-
9
+ Compound form primitive for selecting a start + end date pair with
10
+ optional named preset shortcuts ("Today", "Last 7 days", "This month",
11
+ ...). Default presentation is a trigger button showing the formatted
12
+ range; on activation a popover opens with two synchronized calendar
13
+ panes + a preset side rail. Composes <calendar-picker-ui> twice;
14
+ emits {from, to} ISO 8601 dates. Optional comparison-range mode
15
+ emits a secondary range for analytics surfaces.
16
+ # Per ADR-0027 — primitives that programmatically create other primitives
17
+ # do NOT auto-import them. Consumer (or demo shell) must explicitly import.
18
+ composes:
19
+ - calendar-grid-ui # two grids (start + end) — substrate primitive
20
+ - button-ui # trigger + preset rail + footer actions
21
+ - popover-ui # the floating panel
22
+ - text-ui # trigger label + preset labels
23
+ - divider-ui # separator between preset rail and calendars
24
+ props:
25
+ name:
26
+ description: Form field name for form data submission. The selected range serializes as JSON `{"from":"...","to":"..."}` under this key.
27
+ type: string
28
+ default: ''
29
+ reflect: true
30
+ value:
31
+ description: 'Selected range as `{from, to}` ISO 8601 dates. Null when empty. Emitted on commit.'
32
+ type: string
33
+ default: ''
34
+ dynamic: true
35
+ compareValue:
36
+ description: 'Comparison range when `[comparison]` is set. Same `{from, to}` shape as `value`.'
37
+ type: string
38
+ default: ''
39
+ attribute: compare-value
40
+ dynamic: true
41
+ comparison:
42
+ description: Enable comparison-range mode. Renders a second preset cluster and emits a secondary range under `<name>-compare`.
43
+ type: boolean
44
+ default: false
45
+ reflect: true
46
+ min:
47
+ description: Earliest selectable ISO date (inclusive). Days before are disabled in both panes.
48
+ type: string
49
+ default: ''
50
+ reflect: true
51
+ max:
52
+ description: Latest selectable ISO date (inclusive). Days after are disabled in both panes.
53
+ type: string
54
+ default: ''
55
+ reflect: true
56
+ disabled:
57
+ description: Block all interaction. Trigger remains rendered but does not open the popover.
58
+ type: boolean
59
+ default: false
60
+ reflect: true
61
+ readonly:
62
+ description: Block edits; allow keyboard navigation for screen-reader inspection.
63
+ type: boolean
64
+ default: false
65
+ reflect: true
66
+ required:
67
+ description: Required for validation when inside a `<form>`.
68
+ type: boolean
69
+ default: false
70
+ reflect: true
71
+ open:
72
+ description: Whether the popover is currently open.
73
+ type: boolean
74
+ default: false
75
+ reflect: true
76
+ placeholder:
77
+ description: Text shown in the trigger when the value is empty.
78
+ type: string
79
+ default: Select range
80
+ format:
81
+ description: 'Trigger button date format. `short`: `Jan 1, 2026`; `long`: `January 1, 2026`; `iso`: `2026-01-01`.'
82
+ type: string
83
+ default: short
84
+ enum:
85
+ - short
86
+ - long
87
+ - iso
88
+ noPresets:
89
+ description: Hide the preset side rail.
90
+ type: boolean
91
+ default: false
92
+ attribute: no-presets
93
+ reflect: true
94
+ events:
95
+ change:
96
+ description: Fired when the range commits (both `from` AND `to` selected, OR a preset clicked).
97
+ detail:
98
+ value: object
99
+ compareValue: object
100
+ presetLabel: string
101
+ input:
102
+ description: Fired during selection — e.g. user clicked `from` but has not yet picked `to`.
103
+ detail:
104
+ value: object
105
+ open:
106
+ description: Popover opened (user clicked the trigger or pressed Enter / Space).
107
+ detail:
108
+ trigger: string
109
+ close:
110
+ description: Popover closed (without commit, or after commit).
111
+ detail:
112
+ reason: string
113
+ invalid:
114
+ description: A constraint failed (out of range, end < start).
115
+ detail:
116
+ value: object
117
+ reason: string
118
+ slots:
119
+ trigger:
120
+ description: Custom trigger replacement. When omitted, a default `<button-ui>` is stamped.
121
+ presets:
122
+ description: Custom preset content replacing the default preset rail.
123
+ footer:
124
+ description: Optional footer area below the calendars (e.g., "Apply" / "Cancel" buttons for explicit-commit flows).
125
+ states:
126
+ - name: idle
127
+ description: Closed, no pending selection.
128
+ - name: open-pending-from
129
+ description: Popover open, user has not yet clicked a start date.
130
+ attribute: open
131
+ - name: open-pending-to
132
+ description: Start clicked, awaiting end click; hover preview active.
133
+ attribute: open
134
+ - name: open-committed
135
+ description: Both ends clicked, awaiting Apply (only in explicit-commit mode with `footer` slot).
136
+ attribute: open
137
+ - name: disabled
138
+ description: Non-interactive; dimmed.
139
+ attribute: disabled
140
+ - name: readonly
141
+ description: Read-only; trigger keyboard-focusable but popover does not open.
142
+ attribute: readonly
143
+ traits: []
144
+ tokens:
145
+ --date-range-picker-bg:
146
+ description: Host background.
147
+ default: var(--a-bg)
148
+ --date-range-picker-popover-bg:
149
+ description: Popover background.
150
+ default: var(--a-bg-subtle)
151
+ --date-range-picker-popover-border:
152
+ description: Popover border color.
153
+ default: var(--a-border-subtle)
154
+ --date-range-picker-popover-radius:
155
+ description: Popover border radius.
156
+ default: var(--a-radius-lg)
157
+ --date-range-picker-popover-shadow:
158
+ description: Popover box shadow.
159
+ default: var(--a-shadow-lg)
160
+ --date-range-picker-preset-bg:
161
+ description: Preset row background (idle).
162
+ default: transparent
163
+ --date-range-picker-preset-bg-hover:
164
+ description: Preset row background on hover.
165
+ default: var(--a-bg-hover)
166
+ --date-range-picker-selected-bg:
167
+ description: Selected day-cell background (cascaded into the inner calendar via --calendar-picker-day-bg-selected).
168
+ default: var(--a-accent)
169
+ --date-range-picker-selected-fg:
170
+ description: Selected day-cell foreground.
171
+ default: var(--a-accent-fg)
172
+ --date-range-picker-preview-bg:
173
+ description: In-range preview background (between from and pending to).
174
+ default: var(--a-accent-muted)
175
+ --date-range-picker-px:
176
+ description: Horizontal popover padding.
177
+ default: var(--a-space-3)
178
+ --date-range-picker-py:
179
+ description: Vertical popover padding.
180
+ default: var(--a-space-2)
181
+ requiredIcons:
182
+ - calendar
183
+ - caret-down
184
+ - arrow-right
185
+ a2ui:
186
+ rules:
187
+ - rule: 'DateRangePicker.value MUST be `{from, to}` with both ISO 8601 dates, OR null. Either side null is invalid mid-state and the validator should reject it (use `input` event for partial state).'
188
+ reason: 'Range integrity contract.'
189
+ - rule: 'DateRangePicker.value.to MUST be `>=` value.from lexicographically. Reversed ranges trigger `invalid` and do NOT commit.'
190
+ reason: 'Range ordering constraint.'
191
+ - rule: 'When `comparison: true`, both `value` AND `compareValue` MUST be set on commit. If only one is set, the form participation emits the set one and omits the other.'
192
+ reason: 'Comparison-mode pairing.'
193
+ - rule: 'presets array entries each require both `label` (string) and `range` (`{from, to}`). Empty preset arrays are valid (rail renders empty).'
194
+ reason: 'Preset shape contract.'
195
+ - rule: 'Use DateRangePicker for date ranges. Do NOT compose two adjacent `<calendar-picker-ui>` instances + JS synchronization — that is the pattern this primitive replaces.'
196
+ reason: 'Adoption signal: prefer the canonical primitive over two-calendar composition.'
197
+ anti_patterns:
198
+ - wrong: |
199
+ {"component": "DateRangePicker", "value": "2026-01-01 to 2026-01-31"}
200
+ why: |
201
+ `value` is a string, not an object. The contract is `{from, to}` ISO 8601 dates.
202
+ fix: |
203
+ {"component": "DateRangePicker", "value": {"from": "2026-01-01", "to": "2026-01-31"}}
204
+ - wrong: |
205
+ {"component": "DateRangePicker", "value": {"from": "2026-12-31", "to": "2026-01-01"}}
206
+ why: |
207
+ Reversed range — `to < from`. This fires `invalid` and never commits.
208
+ fix: |
209
+ {"component": "DateRangePicker", "value": {"from": "2026-01-01", "to": "2026-12-31"}}
210
+ - wrong: |
211
+ Two adjacent <calendar-picker-ui> instances + JS synchronization.
212
+ why: |
213
+ This is the pattern the primitive replaces. Reinvents linked-pane behavior,
214
+ misses preset rail, has no a11y range-announcement.
215
+ fix: |
216
+ Use <date-range-picker-ui>.
217
+ examples:
218
+ - name: canonical
219
+ description: Form-field-wrapped picker with default presets.
220
+ a2ui: |
221
+ [
222
+ {
223
+ "id": "field",
224
+ "component": "Field",
225
+ "label": "Report period",
226
+ "children": ["dr"]
227
+ },
228
+ {
229
+ "id": "dr",
230
+ "component": "DateRangePicker",
231
+ "name": "report-period",
232
+ "min": "2026-01-01",
233
+ "max": "2026-12-31"
234
+ }
235
+ ]
236
+ - name: comparison
237
+ description: Analytics-surface picker with comparison range enabled.
238
+ a2ui: |
239
+ [
240
+ {
241
+ "id": "dr",
242
+ "component": "DateRangePicker",
243
+ "name": "period",
244
+ "comparison": true,
245
+ "value": {"from": "2026-05-01", "to": "2026-05-31"},
246
+ "compareValue": {"from": "2026-04-01", "to": "2026-04-30"}
247
+ }
248
+ ]
249
+ - name: no-presets
250
+ description: Custom-only picker with the preset rail hidden.
251
+ a2ui: |
252
+ [
253
+ {
254
+ "id": "dr",
255
+ "component": "DateRangePicker",
256
+ "name": "period",
257
+ "noPresets": true
258
+ }
259
+ ]
260
+ keywords:
261
+ - daterangepicker
262
+ - date-range-picker
263
+ - date-range
264
+ - range
265
+ - daterange
266
+ - period
267
+ - reporting-period
268
+ synonyms:
269
+ tags:
270
+ - DateRangePicker
271
+ - DateRange
272
+ - DateRangeInput
273
+ range:
274
+ - date-range
275
+ - period
276
+ - report-period
277
+ period:
278
+ - date-range
279
+ - range
280
+ - reporting-period
281
+ related:
282
+ - calendar-picker
283
+ - popover
284
+ - field
285
+ - button