@astryxdesign/core 0.6.1 → 0.6.2-canary.2b2113d

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 (88) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +1 -1
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheet.js +3 -1
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts +7 -5
  6. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  7. package/dist/BottomSheet/BottomSheetPanel.js +56 -19
  8. package/dist/DateRangeInput/DateRangeInput.d.ts +3 -0
  9. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  10. package/dist/DateRangeInput/DateRangeInput.js +16 -9
  11. package/dist/Dialog/DialogHeader.d.ts +1 -1
  12. package/dist/Dialog/DialogHeader.d.ts.map +1 -1
  13. package/dist/Dialog/DialogHeader.js +10 -7
  14. package/dist/FileInput/FileInput.d.ts.map +1 -1
  15. package/dist/FileInput/FileInput.js +9 -3
  16. package/dist/Markdown/Markdown.d.ts +10 -2
  17. package/dist/Markdown/Markdown.d.ts.map +1 -1
  18. package/dist/Markdown/Markdown.js +58 -14
  19. package/dist/Markdown/index.d.ts +1 -1
  20. package/dist/Markdown/index.d.ts.map +1 -1
  21. package/dist/Markdown/parser.d.ts +126 -12
  22. package/dist/Markdown/parser.d.ts.map +1 -1
  23. package/dist/Markdown/parser.js +369 -34
  24. package/dist/Markdown/utils.d.ts +1 -1
  25. package/dist/Markdown/utils.d.ts.map +1 -1
  26. package/dist/Slider/Slider.d.ts.map +1 -1
  27. package/dist/Slider/Slider.js +5 -2
  28. package/dist/Spinner/Spinner.d.ts +1 -1
  29. package/dist/Spinner/Spinner.d.ts.map +1 -1
  30. package/dist/Spinner/Spinner.js +23 -15
  31. package/dist/astryx.css +5 -2
  32. package/dist/hooks/scrollKeyboardDelegation.d.ts +3 -0
  33. package/dist/hooks/scrollKeyboardDelegation.d.ts.map +1 -0
  34. package/dist/hooks/scrollKeyboardDelegation.js +146 -0
  35. package/dist/hooks/useScrollableArea.d.ts +6 -2
  36. package/dist/hooks/useScrollableArea.d.ts.map +1 -1
  37. package/dist/hooks/useScrollableArea.js +17 -5
  38. package/locales/en.json +16 -0
  39. package/locales/pseudo.json +12 -0
  40. package/package.json +7 -5
  41. package/scripts/agent-doc-state.mjs +1 -1
  42. package/src/BottomSheet/BottomSheet.doc.mjs +8 -1
  43. package/src/BottomSheet/BottomSheet.spec.md +46 -20
  44. package/src/BottomSheet/BottomSheet.test.tsx +6 -3
  45. package/src/BottomSheet/BottomSheet.tsx +3 -1
  46. package/src/BottomSheet/BottomSheetKeyboard.test.tsx +195 -0
  47. package/src/BottomSheet/BottomSheetPanel.test.tsx +11 -1
  48. package/src/BottomSheet/BottomSheetPanel.tsx +49 -16
  49. package/src/BottomSheet/__tests__/BottomSheetKeyboard.a11y.browser.spec.ts +344 -0
  50. package/src/DateRangeInput/DateRangeInput.doc.mjs +35 -7
  51. package/src/DateRangeInput/DateRangeInput.spec.md +203 -0
  52. package/src/DateRangeInput/DateRangeInput.test.tsx +100 -4
  53. package/src/DateRangeInput/DateRangeInput.tsx +29 -20
  54. package/src/Dialog/Dialog.doc.mjs +3 -0
  55. package/src/Dialog/Dialog.spec.md +1 -1
  56. package/src/Dialog/DialogHeader.doc.mjs +38 -0
  57. package/src/Dialog/DialogHeader.test.tsx +49 -0
  58. package/src/Dialog/DialogHeader.tsx +23 -4
  59. package/src/Dialog/modules/DialogHeader.spec.md +152 -0
  60. package/src/FileInput/FileInput.doc.mjs +2 -0
  61. package/src/FileInput/FileInput.spec.md +199 -0
  62. package/src/FileInput/FileInput.test.tsx +14 -0
  63. package/src/FileInput/FileInput.tsx +13 -3
  64. package/src/Markdown/Markdown.doc.mjs +167 -42
  65. package/src/Markdown/Markdown.public.test.ts +157 -0
  66. package/src/Markdown/Markdown.spec.md +149 -70
  67. package/src/Markdown/Markdown.test.tsx +107 -3
  68. package/src/Markdown/Markdown.tsx +116 -35
  69. package/src/Markdown/incremental.test.ts +175 -7
  70. package/src/Markdown/index.ts +6 -0
  71. package/src/Markdown/parser.perf.test.ts +3 -1
  72. package/src/Markdown/parser.test.ts +122 -0
  73. package/src/Markdown/parser.ts +609 -81
  74. package/src/Markdown/utils.ts +6 -0
  75. package/src/ScrollableArea/modules/useScrollableArea.spec.md +50 -22
  76. package/src/Slider/Slider.doc.mjs +16 -0
  77. package/src/Slider/Slider.spec.md +61 -47
  78. package/src/Slider/Slider.test.tsx +18 -0
  79. package/src/Slider/Slider.tsx +12 -6
  80. package/src/Spinner/Spinner.doc.mjs +6 -3
  81. package/src/Spinner/Spinner.test.tsx +37 -0
  82. package/src/Spinner/Spinner.tsx +31 -14
  83. package/src/hooks/scrollKeyboardDelegation.test.ts +155 -0
  84. package/src/hooks/scrollKeyboardDelegation.ts +233 -0
  85. package/src/hooks/useScrollableArea.doc.mjs +15 -3
  86. package/src/hooks/useScrollableArea.test.tsx +59 -1
  87. package/src/hooks/useScrollableArea.ts +34 -10
  88. package/src/theme/derivedVarRegistry.test.ts +6 -4
@@ -0,0 +1,344 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file BottomSheetKeyboard.a11y.browser.spec.ts
5
+ * @input Built BottomSheet stories and real Chromium/WebKit keyboard input
6
+ * @output Browser evidence for shared focus delegation, traversal, and native scrolling
7
+ * @position Cross-browser proof for useScrollableArea's contentOrViewport policy
8
+ */
9
+
10
+ import {expect, test, type Page, type Locator} from '@playwright/test';
11
+ import {
12
+ DEFAULT_STORYBOOK_DIR,
13
+ serveStorybook,
14
+ type StaticServer,
15
+ } from '@astryxdesign/a11y-spec/storybook';
16
+
17
+ let storybook: StaticServer;
18
+ test.beforeAll(async () => {
19
+ storybook = await serveStorybook(
20
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
21
+ );
22
+ });
23
+ test.afterAll(async () => {
24
+ await storybook?.close();
25
+ });
26
+
27
+ // macOS WebKit defaults to text-field traversal. Option+Tab selects its native
28
+ // all-controls traversal, equivalent to enabling Safari's keyboard preference.
29
+ function tabKey(browserName: string, reverse = false): string {
30
+ return `${browserName === 'webkit' && process.platform === 'darwin' ? 'Alt+' : ''}${reverse ? 'Shift+' : ''}Tab`;
31
+ }
32
+
33
+ async function mount(page: Page, browserName: string, variant = 'button') {
34
+ await page.goto(
35
+ `${storybook.origin}/iframe.html?id=core-bottomsheet--keyboard-delegation&viewMode=story`,
36
+ );
37
+ const viewport = page.getByRole('group', {
38
+ name: 'Keyboard reading',
39
+ exact: true,
40
+ });
41
+ await expect(viewport).toHaveAttribute('data-scrollable-block', 'true');
42
+ await page.getByLabel('Content case').selectOption(variant);
43
+ await expect(viewport).toHaveAttribute('tabindex', '0');
44
+ // WebKit includes the non-modal dialog host in sequential navigation;
45
+ // Chromium enters its descendants directly. Start immediately before the body.
46
+ const before =
47
+ browserName === 'webkit'
48
+ ? page.getByRole('dialog', {name: 'Keyboard reading', exact: true})
49
+ : page.getByRole('button', {name: 'Toggle modal', exact: true});
50
+ await before.focus();
51
+ return {viewport, before};
52
+ }
53
+
54
+ for (const variant of ['button', 'link']) {
55
+ test(`${variant}: forward entry delegates once and reverse traversal exits`, async ({
56
+ page,
57
+ browserName,
58
+ }) => {
59
+ const {viewport, before} = await mount(page, browserName, variant);
60
+ const first = page.getByRole(variant === 'button' ? 'button' : 'link', {
61
+ name: variant === 'button' ? 'First action' : 'First link',
62
+ exact: true,
63
+ });
64
+ await page.keyboard.press(tabKey(browserName));
65
+ await expect(first).toBeFocused();
66
+ await expect(viewport).toHaveAttribute('tabindex', '0');
67
+ await expect(viewport).toHaveAccessibleName('Keyboard reading');
68
+ await expect(first).toHaveAccessibleName(
69
+ variant === 'button' ? 'First action' : 'First link',
70
+ );
71
+ await page.keyboard.press(tabKey(browserName));
72
+ await expect(page.getByRole('button', {name: 'Last action'})).toBeFocused();
73
+ await page.keyboard.press(tabKey(browserName, true));
74
+ await expect(first).toBeFocused();
75
+ await page.keyboard.press(tabKey(browserName, true));
76
+ await expect(before).toBeFocused();
77
+ await expect(viewport).toHaveAttribute('tabindex', '0');
78
+ await page.keyboard.press(tabKey(browserName));
79
+ await expect(first).toBeFocused();
80
+ });
81
+ }
82
+
83
+ for (const variant of [
84
+ 'text',
85
+ 'input',
86
+ 'disabled',
87
+ 'nested',
88
+ 'native-scroll',
89
+ 'radiogroup',
90
+ 'slider',
91
+ 'combobox',
92
+ 'listbox',
93
+ 'menu',
94
+ 'grid',
95
+ 'tree',
96
+ 'tablist',
97
+ 'toolbar',
98
+ ]) {
99
+ test(`${variant}: retains a named viewport stop`, async ({
100
+ page,
101
+ browserName,
102
+ }) => {
103
+ const {viewport, before} = await mount(page, browserName, variant);
104
+ await page.keyboard.press(tabKey(browserName));
105
+ await expect(viewport).toBeFocused();
106
+ await expect(viewport).toHaveAccessibleName('Keyboard reading');
107
+ await page.keyboard.press(tabKey(browserName, true));
108
+ await expect(before).toBeFocused();
109
+ });
110
+ }
111
+
112
+ async function scrollRange(viewport: Locator) {
113
+ return viewport.evaluate(element => ({
114
+ position: element.scrollTop,
115
+ max: element.scrollHeight - element.clientHeight,
116
+ }));
117
+ }
118
+
119
+ for (const key of ['PageDown', 'ArrowDown']) {
120
+ test(`${key}: reaches both scroll edges after delegation`, async ({
121
+ page,
122
+ browserName,
123
+ }) => {
124
+ const {viewport} = await mount(page, browserName);
125
+ await page.keyboard.press(tabKey(browserName));
126
+ const first = page.getByRole('button', {name: 'First action', exact: true});
127
+ await expect(first).toBeFocused();
128
+ for (let count = 0; count < 300; count++) {
129
+ const {position, max} = await scrollRange(viewport);
130
+ if (position >= max - 1) {
131
+ break;
132
+ }
133
+ await page.keyboard.press(key, {delay: key === 'ArrowDown' ? 50 : 25});
134
+ }
135
+ await expect
136
+ .poll(async () => {
137
+ const {position, max} = await scrollRange(viewport);
138
+ // WebKit can overshoot a native edge while showing rubber-band feedback.
139
+ return max - position;
140
+ })
141
+ .toBeLessThanOrEqual(1);
142
+ for (let count = 0; count < 300; count++) {
143
+ if ((await scrollRange(viewport)).position <= 1) {
144
+ break;
145
+ }
146
+ await page.keyboard.press(key === 'PageDown' ? 'PageUp' : 'ArrowUp', {
147
+ delay: key === 'ArrowDown' ? 50 : 25,
148
+ });
149
+ }
150
+ await expect
151
+ .poll(async () => (await scrollRange(viewport)).position)
152
+ .toBeLessThanOrEqual(1);
153
+ await expect(first).toBeFocused();
154
+ });
155
+ }
156
+
157
+ test('pointer and programmatic focus stay on the viewport, including during keydown', async ({
158
+ page,
159
+ browserName,
160
+ }) => {
161
+ const {viewport, before} = await mount(page, browserName);
162
+ await viewport.focus();
163
+ await expect(viewport).toBeFocused();
164
+ await before.focus();
165
+ await viewport.click({position: {x: 4, y: 40}});
166
+ await expect(viewport).toBeFocused();
167
+ await before.focus();
168
+ await before.evaluate(element => {
169
+ element.addEventListener('keydown', event => {
170
+ if (event instanceof KeyboardEvent && event.key === 'Tab') {
171
+ event.preventDefault();
172
+ document
173
+ .querySelector<HTMLElement>(
174
+ '[role="group"][aria-label="Keyboard reading"]',
175
+ )
176
+ ?.focus();
177
+ }
178
+ });
179
+ });
180
+ await page.keyboard.press(tabKey(browserName));
181
+ await expect(viewport).toBeFocused();
182
+ });
183
+
184
+ test('dynamic eligibility is checked at the next entry without mutation observation', async ({
185
+ page,
186
+ browserName,
187
+ }) => {
188
+ await page.addInitScript(() => {
189
+ const Original = window.MutationObserver;
190
+ const observers: MutationObserver[] = [];
191
+ window.MutationObserver = class extends Original {
192
+ constructor(callback: MutationCallback) {
193
+ super(callback);
194
+ observers.push(this);
195
+ }
196
+ };
197
+ Object.assign(window, {
198
+ stopMutationObservers: () =>
199
+ observers.forEach(observer => observer.disconnect()),
200
+ });
201
+ });
202
+ const {viewport, before} = await mount(page, browserName);
203
+ await page.evaluate(() => {
204
+ (
205
+ window as unknown as {stopMutationObservers: () => void}
206
+ ).stopMutationObservers();
207
+ });
208
+ await viewport.focus();
209
+ const first = page.getByRole('button', {name: 'First action', exact: true});
210
+ await first.evaluate(element => element.setAttribute('disabled', ''));
211
+ await expect(viewport).toBeFocused();
212
+ await before.focus();
213
+ await page.keyboard.press(tabKey(browserName));
214
+ await expect(page.getByRole('button', {name: 'Last action'})).toBeFocused();
215
+ await before.focus();
216
+ await first.evaluate(element => element.removeAttribute('disabled'));
217
+ await page.keyboard.press(tabKey(browserName));
218
+ await expect(first).toBeFocused();
219
+ await before.focus();
220
+ await viewport.locator('[data-scroll-content]').evaluate(element => {
221
+ const input = document.createElement('input');
222
+ input.setAttribute('aria-label', 'Inserted input');
223
+ element.prepend(input);
224
+ });
225
+ await page.keyboard.press(tabKey(browserName));
226
+ await expect(viewport).toBeFocused();
227
+ await page.getByLabel('Inserted input').evaluate(element => element.remove());
228
+ await expect(viewport).toBeFocused();
229
+ await before.focus();
230
+ await page.keyboard.press(tabKey(browserName));
231
+ await expect(first).toBeFocused();
232
+ await before.focus();
233
+ await first.evaluate(element => element.setAttribute('role', 'combobox'));
234
+ await page.keyboard.press(tabKey(browserName));
235
+ await expect(viewport).toBeFocused();
236
+ });
237
+
238
+ test('modal BottomSheet keeps native focus containment without cycling through the viewport', async ({
239
+ page,
240
+ browserName,
241
+ }) => {
242
+ await mount(page, browserName);
243
+ await page.getByRole('button', {name: 'Toggle modal', exact: true}).click();
244
+ const dialog = page.getByRole('dialog', {
245
+ name: 'Keyboard reading',
246
+ exact: true,
247
+ });
248
+ await expect
249
+ .poll(async () => dialog.evaluate(element => element.matches(':modal')))
250
+ .toBe(true);
251
+ const viewport = page.getByRole('group', {
252
+ name: 'Keyboard reading',
253
+ exact: true,
254
+ });
255
+ await expect(viewport).toHaveAttribute('data-scrollable-block', 'true');
256
+ const panel = dialog.locator('.astryx-bottom-sheet');
257
+ await panel.focus();
258
+ await page.keyboard.press(tabKey(browserName));
259
+ const first = page.getByRole('button', {name: 'First action', exact: true});
260
+ await expect(first).toBeFocused();
261
+ await page.keyboard.press(tabKey(browserName, true));
262
+ await expect(viewport).not.toBeFocused();
263
+ await expect(first).not.toBeFocused();
264
+ // Native dialog traversal may wrap to another sheet target or browser chrome;
265
+ // it must never focus the inert background or create a viewport/child loop.
266
+ await expect(
267
+ page.getByRole('button', {name: 'Before sheet', includeHidden: true}),
268
+ ).not.toBeFocused();
269
+ await expect(viewport).toHaveAttribute('tabindex', '0');
270
+ });
271
+
272
+ test('fitting text removes the viewport stop while preserving current focus', async ({
273
+ page,
274
+ browserName,
275
+ }) => {
276
+ const {viewport} = await mount(page, browserName, 'text');
277
+ await viewport.focus();
278
+ await viewport.locator('[data-scroll-content]').evaluate(element => {
279
+ element.textContent = 'Short reading content';
280
+ });
281
+ await expect(viewport).not.toHaveAttribute('data-scrollable-block', 'true');
282
+ await expect(viewport).toHaveAttribute('tabindex', '-1');
283
+ await expect(viewport).toBeFocused();
284
+ });
285
+
286
+ test('horizontal overflow remains keyboard scrollable after delegation', async ({
287
+ page,
288
+ browserName,
289
+ }) => {
290
+ const {viewport, before} = await mount(page, browserName);
291
+ await viewport.evaluate(element => {
292
+ const content = element.querySelector<HTMLElement>('[data-scroll-content]');
293
+ if (content != null) {
294
+ content.style.width = `${element.clientWidth + 320}px`;
295
+ }
296
+ });
297
+ await expect(viewport).toHaveAttribute('data-scrollable-inline', 'true');
298
+ await before.focus();
299
+ await page.keyboard.press(tabKey(browserName));
300
+ await expect(
301
+ page.getByRole('button', {name: 'First action', exact: true}),
302
+ ).toBeFocused();
303
+ for (const key of ['ArrowRight', 'ArrowLeft']) {
304
+ for (let count = 0; count < 100; count++) {
305
+ const atEdge = await viewport.evaluate(
306
+ (element, forward) =>
307
+ forward
308
+ ? element.scrollLeft >=
309
+ element.scrollWidth - element.clientWidth - 1
310
+ : element.scrollLeft <= 1,
311
+ key === 'ArrowRight',
312
+ );
313
+ if (atEdge) {
314
+ break;
315
+ }
316
+ await page.keyboard.press(key, {delay: 50});
317
+ }
318
+ await expect
319
+ .poll(async () =>
320
+ viewport.evaluate(
321
+ (element, forward) =>
322
+ forward
323
+ ? element.scrollWidth - element.clientWidth - element.scrollLeft
324
+ : element.scrollLeft,
325
+ key === 'ArrowRight',
326
+ ),
327
+ )
328
+ .toBeLessThanOrEqual(1);
329
+ }
330
+ });
331
+
332
+ test('does not skip an aria-hidden first tab stop to delegate to a later action', async ({
333
+ page,
334
+ browserName,
335
+ }) => {
336
+ const {viewport} = await mount(page, browserName);
337
+ await page
338
+ .getByRole('button', {name: 'First action', exact: true})
339
+ .evaluate(element => {
340
+ element.setAttribute('aria-hidden', 'true');
341
+ });
342
+ await page.keyboard.press(tabKey(browserName));
343
+ await expect(viewport).toBeFocused();
344
+ });
@@ -114,7 +114,7 @@ export const docs = {
114
114
  name: 'presets',
115
115
  type: 'Array<DateRangePreset>',
116
116
  description:
117
- 'Preset ranges shown as quick-select options beside the calendar.',
117
+ 'Preset ranges shown as quick-select options beside the calendar. A preset is disabled when either endpoint violates min, max, or dateConstraints, or when its span violates minRangeSpan or maxRangeSpan.',
118
118
  },
119
119
  {
120
120
  name: 'hasClear',
@@ -178,9 +178,21 @@ export const docs = {
178
178
  ],
179
179
  theming: {
180
180
  targets: [
181
- {className: 'astryx-date-range-input', visualProps: ['size', 'status'], states: ['disabled']},
181
+ {
182
+ className: 'astryx-date-range-input',
183
+ visualProps: ['size', 'status'],
184
+ states: ['disabled'],
185
+ },
182
186
  {className: 'astryx-date-range-input-toggle-icon', states: ['state']},
183
- {className: 'astryx-date-range-input-clear-icon', deprecatedFor: 'input-clear-icon'},
187
+ {
188
+ className: 'astryx-date-range-input-clear-icon',
189
+ deprecatedFor: 'input-clear-icon',
190
+ },
191
+ {className: 'astryx-date-range-input-presets'},
192
+ {
193
+ className: 'astryx-date-range-input-preset',
194
+ states: ['selected', 'disabled'],
195
+ },
184
196
  ],
185
197
  },
186
198
  usage: {
@@ -230,6 +242,12 @@ export const docs = {
230
242
  description:
231
243
  'Text above the trigger describing what date range is expected.',
232
244
  },
245
+ {
246
+ name: 'Field surface',
247
+ required: true,
248
+ description:
249
+ 'Bordered control containing the calendar toggle, range trigger, and end affordances.',
250
+ },
233
251
  {
234
252
  name: 'Trigger button',
235
253
  required: true,
@@ -252,6 +270,12 @@ export const docs = {
252
270
  required: false,
253
271
  description: 'A list of preset range options beside the calendar.',
254
272
  },
273
+ {
274
+ name: 'Preset button',
275
+ required: false,
276
+ description:
277
+ 'A quick-select action for one preset range, reflecting current and disabled states.',
278
+ },
255
279
  {
256
280
  name: 'Clear button',
257
281
  required: false,
@@ -320,7 +344,8 @@ export const docsDense = {
320
344
  isDisabled: 'disable trigger+picker',
321
345
  disabledMessage:
322
346
  'reason shown in a tooltip on hover/focus when disabled; keeps trigger focusable via aria-disabled',
323
- value: 'selected range {start, end} or null; import DateRange type from @astryxdesign/core/DateRangeInput (do not redeclare)',
347
+ value:
348
+ 'selected range {start, end} or null; import DateRange type from @astryxdesign/core/DateRangeInput (do not redeclare)',
324
349
  onChange: 'callback on range change; null on clear',
325
350
  min: 'min selectable date: ISODateString template literal type (YYYY-MM-DD); use string literal or cast `as ISODateString`',
326
351
  max: 'max selectable date: ISODateString template literal type (YYYY-MM-DD); use string literal or cast `as ISODateString`',
@@ -329,15 +354,18 @@ export const docsDense = {
329
354
  'max days a range may span, both endpoints counted (7 = a 7-day window); caps the window from the picked start. Selection-only; does not rewrite an over-wide value',
330
355
  minRangeSpan:
331
356
  'min days a range must span, both endpoints counted (2 forbids a single-day range); repeated start click commits one day when allowed, otherwise cancels; default 1',
332
- presets: 'preset ranges as quick-select options',
357
+ presets:
358
+ 'preset ranges as quick-select options; disabled when an endpoint or span violates the corresponding constraints',
333
359
  hasClear: 'clear button when range is set (default true)',
334
360
  placeholder: 'placeholder when empty',
335
361
  size: 'trigger size',
336
362
  status: 'error/warning/success status',
337
- statusVariant: 'How status message is placed: attached overlaps below input; detached floats below w/ spacing; tooltip hides the box and shows it on the status icon.',
363
+ statusVariant:
364
+ 'How status message is placed: attached overlaps below input; detached floats below w/ spacing; tooltip hides the box and shows it on the status icon.',
338
365
  labelTooltip: 'tooltip via info icon at label end',
339
366
  numberOfMonths: 'months in calendar (default 2)',
340
- weekStartsOn: 'first day of week in calendar (0=Sunday, or name e.g. "mon")',
367
+ weekStartsOn:
368
+ 'first day of week in calendar (0=Sunday, or name e.g. "mon")',
341
369
  changeAction:
342
370
  'async action fired after onChange; drives optimistic UI updates via useTransition',
343
371
  isLoading: 'loading state; disables interaction + shows a spinner',
@@ -0,0 +1,203 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 4
4
+ kind: component
5
+ id: component:DateRangeInput
6
+ authority: current
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: cixzhang
10
+ approved_at: 2026-09-14
11
+ owners: [cixzhang]
12
+ review_triggers: [theming]
13
+ verified_by:
14
+ [
15
+ packages/core/src/DateRangeInput/DateRangeInput.test.tsx,
16
+ packages/core/src/theme/themingTargets.test.ts,
17
+ scripts/check-knowledge.mjs,
18
+ ]
19
+ modules: []
20
+ families: [family:input-fields, family:overlay-dismissal]
21
+ design_specs: []
22
+ architecture: [architecture:component-theming-surface]
23
+ contributing: []
24
+ system_specs: []
25
+ ---
26
+
27
+ # DateRangeInput component contract
28
+
29
+ ## Intent
30
+
31
+ DateRangeInput presents one controlled date range through a labeled field surface.
32
+ Its trigger opens a Calendar-backed Popover and may include a list of quick-select
33
+ presets beside that calendar.
34
+
35
+ ## Compatibility and migration
36
+
37
+ - Released default preserved: `yes`
38
+ - Compatibility class: additive theme targets plus corrected preset constraint
39
+ enforcement; default appearance, DOM semantics, and public props remain unchanged
40
+ - Controlled/uncontrolled behavior: unchanged; DateRangeInput remains controlled
41
+ - Migration decision: none
42
+
43
+ Consumer migration instructions belong in consumer docs and release notes.
44
+
45
+ ## Ownership boundary
46
+
47
+ **Owns**
48
+
49
+ - The composite date-range field surface and its range-display trigger.
50
+ - The optional preset list, each preset action, and reflection of a preset's current
51
+ and disabled states.
52
+ - Converting a selected Calendar range or preset into `onChange` and
53
+ `changeAction` output.
54
+
55
+ **Does not own / non-goals**
56
+
57
+ - Label, description, and status presentation — owned by `component:Field` and
58
+ `component:FieldStatus`.
59
+ - Calendar-grid rendering and date-cell interaction — owned by
60
+ `component:Calendar`.
61
+ - Layer hosting and dismissal — owned by `component:Popover` and
62
+ `family:overlay-dismissal`.
63
+ - Shared clear-button presentation — owned by `component:Field`.
64
+
65
+ ## Public concepts
66
+
67
+ No public prop or value domain is added. This contract records the existing
68
+ preset-list anatomy and its additive theming surface.
69
+
70
+ ## Behavioral and layout contract
71
+
72
+ | ID | Candidate invariant | Basis | Review state |
73
+ | --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------- |
74
+ | FR1 | DateRangeInput MUST present the controlled `value` and emit range changes without maintaining a competing selected range. | Current source, docs, and focused tests | Verified current behavior |
75
+ | FR2 | When presets are present, each preset remains an independent button in one labeled group; the applied preset reflects current state and a preset whose endpoint violates `min`, `max`, or `dateConstraints`, or whose range violates `minRangeSpan` or `maxRangeSpan`, reflects disabled state. | Current source, accessibility comments, and focused tests | Verified current behavior |
76
+ | FR3 | The preset group and each preset button expose stable theme targets; selected and disabled are states of the preset-button target rather than separate targets. | `architecture:component-theming-surface`; #5417 demand | Approved additive contract |
77
+ | FR4 | Adding theme targets MUST NOT change the Popover, Calendar, button, focus, or selection semantics those elements already own. | Composition boundary and compatibility goal | Approved additive contract |
78
+
79
+ ### Allowed variation
80
+
81
+ - **AV1 — Preset content.** A caller may omit presets or provide any number of
82
+ labeled ranges.
83
+ - **AV2 — Calendar layout.** Calendar month count, date constraints, and range
84
+ bounds may vary without changing preset target identity.
85
+ - **AV3 — Theme output.** Themes may restyle the preset group and buttons while
86
+ the component's button semantics and state remain unchanged.
87
+
88
+ ### Representative states
89
+
90
+ | State | Required invariant | Allowed variation |
91
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
92
+ | No presets | No preset group or preset-button target renders. | Calendar configuration |
93
+ | Presets, no match | Every button carries the preset target with no selected state. | Preset count and labels |
94
+ | Applied preset | The matching button carries `aria-current="true"` and the target's selected state. | Selected range |
95
+ | Disabled preset | A preset that violates an endpoint date constraint or a range-span constraint is natively disabled and carries the target's disabled state. | Constraint source |
96
+
97
+ ### Transformation and precedence order
98
+
99
+ - **ORD1 — Preset state.** Resolve each preset's range once, compare it with the
100
+ controlled value, evaluate `min`, `max`, and `dateConstraints` against both
101
+ endpoints, evaluate `minRangeSpan` and `maxRangeSpan` against the full range,
102
+ then reflect selected and disabled state on the same preset-button target.
103
+ - **ORD2 — Preset activation.** An enabled preset emits the already-resolved range;
104
+ activation does not resolve the preset again.
105
+
106
+ Endpoint constraints match Calendar selection: they apply to the preset's start
107
+ and end, not every date between them.
108
+
109
+ ### Performance and resources
110
+
111
+ No new performance or resource constraint is introduced.
112
+
113
+ ## Accessibility contract
114
+
115
+ - **AR1 — Preset semantics.** Presets remain native buttons in one labeled group,
116
+ navigated independently by Tab.
117
+ - **AR2 — Current state.** The applied preset remains exposed with
118
+ `aria-current="true"`; theme state reflection is additive.
119
+ - **AR3 — Disabled state.** A preset that violates an endpoint date constraint or
120
+ a range-span constraint remains natively disabled; theme state reflection does
121
+ not replace that behavior.
122
+
123
+ ## Design relationships
124
+
125
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
126
+ | ---------------- | --------------------------------------------------------------- | ------------------------------------- | -------------- | ------------------ |
127
+ | Field surface | Presents one coherent input boundary. | Current source and input-field family | Prominent | FR1 |
128
+ | Calendar popover | Provides range selection without changing field ownership. | Popover and Calendar components | Prominent | FR1, FR4 |
129
+ | Preset sidebar | Groups optional shortcuts beside the calendar. | Current source and public docs | Supporting | FR2, FR3 |
130
+ | Preset button | Presents one quick-select range and its current/disabled state. | Current source and public docs | Supporting | FR2–FR4, AR1–AR3 |
131
+
132
+ ### Theming anatomy
133
+
134
+ <!-- anatomy-theming:v1 -->
135
+
136
+ ```json
137
+ {
138
+ "Label": {
139
+ "delegatesTo": {"owner": "component:Field", "target": "field-label"}
140
+ },
141
+ "Field surface": {"target": "date-range-input"},
142
+ "Trigger button": {
143
+ "none": {
144
+ "reason": "unsettled: The composite field surface has a target, but the inner range-display button has no separate current target."
145
+ }
146
+ },
147
+ "Calendar icon": {"target": "date-range-input-toggle-icon"},
148
+ "Calendar popover": {
149
+ "delegatesTo": {"owner": "component:Popover", "target": "popover"}
150
+ },
151
+ "Preset sidebar": {"target": "date-range-input-presets"},
152
+ "Preset button": {"target": "date-range-input-preset"},
153
+ "Clear button": {
154
+ "delegatesTo": {"owner": "component:Field", "target": "input-clear-button"}
155
+ },
156
+ "Status message": {
157
+ "delegatesTo": {"owner": "component:FieldStatus", "target": "field-status"}
158
+ }
159
+ }
160
+ ```
161
+
162
+ The deprecated `date-range-input-clear-icon` alias remains compatibility metadata;
163
+ the shared `input-clear-icon` target owns the current glyph contract.
164
+
165
+ ## Family and system relationships
166
+
167
+ - `family:input-fields` owns field sizing, status placement, loading, disabled
168
+ reasons, and end-control geometry.
169
+ - `family:overlay-dismissal` owns the Popover dismissal stack.
170
+ - `architecture:component-theming-surface` owns target admission, state
171
+ reflection, and anatomy mapping.
172
+
173
+ ## Verification map
174
+
175
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
176
+ | ------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------ |
177
+ | FR1, FR2, AR1–AR3 | `DateRangeInput.test.tsx` | no match, applied preset, endpoint-invalid preset, span-invalid preset | Semantics or state attributes disappear | `audit:DateRangeInput/presets` |
178
+ | FR3, FR4 | `DateRangeInput.test.tsx`, `themingTargets.test.ts`, generated probe theme | group target, selected button, disabled button | Target class/state is missing, undocumented, or placed on the wrong element | `audit:DateRangeInput/theming` |
179
+ | Theming anatomy map | `scripts/check-knowledge.mjs` | all documented anatomy | Target ownership or anatomy names drift | `audit:DateRangeInput/theming` |
180
+
181
+ ## Decision log
182
+
183
+ ### DEC-1 — Preset group and buttons are public theme anatomy
184
+
185
+ **Reference:** `component:DateRangeInput/DEC-1`
186
+ **Decider:** cixzhang, 2026-09-14
187
+
188
+ The optional preset sidebar and each quick-select button are stable,
189
+ consumer-recognizable parts. `date-range-input-presets` belongs on the group
190
+ that owns sidebar presentation; `date-range-input-preset` belongs on each
191
+ button, with selected and disabled reflected as states rather than separate
192
+ targets. The target additions preserve default visuals and button semantics;
193
+ preset constraint enforcement follows FR2 and ORD1–ORD2.
194
+
195
+ ## Open questions
196
+
197
+ - **OQ1 — Should the inner range-display trigger receive its own target, or remain
198
+ represented only by the composite field surface?** (`human-api`)
199
+
200
+ ## Content boundary
201
+
202
+ This file does not duplicate the consumer prop table, usage examples, Calendar or
203
+ Popover contracts, current audit results, or downstream theme implementation.