@astryxdesign/core 0.6.1 → 0.6.2-canary.176dfa6

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 (104) 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/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  27. package/dist/PowerSearch/PowerSearchEditPopover.js +46 -30
  28. package/dist/Slider/Slider.d.ts.map +1 -1
  29. package/dist/Slider/Slider.js +5 -2
  30. package/dist/Spinner/Spinner.d.ts +1 -1
  31. package/dist/Spinner/Spinner.d.ts.map +1 -1
  32. package/dist/Spinner/Spinner.js +23 -15
  33. package/dist/ToggleButton/ToggleButton.d.ts +2 -1
  34. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  35. package/dist/ToggleButton/ToggleButton.js +7 -1
  36. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  37. package/dist/Typeahead/BaseTypeahead.js +15 -6
  38. package/dist/astryx.css +5 -2
  39. package/dist/hooks/scrollKeyboardDelegation.d.ts +3 -0
  40. package/dist/hooks/scrollKeyboardDelegation.d.ts.map +1 -0
  41. package/dist/hooks/scrollKeyboardDelegation.js +146 -0
  42. package/dist/hooks/useScrollableArea.d.ts +6 -2
  43. package/dist/hooks/useScrollableArea.d.ts.map +1 -1
  44. package/dist/hooks/useScrollableArea.js +17 -5
  45. package/locales/en.json +16 -0
  46. package/locales/pseudo.json +12 -0
  47. package/package.json +7 -5
  48. package/scripts/agent-doc-state.mjs +1 -1
  49. package/src/BottomSheet/BottomSheet.doc.mjs +8 -1
  50. package/src/BottomSheet/BottomSheet.spec.md +46 -20
  51. package/src/BottomSheet/BottomSheet.test.tsx +6 -3
  52. package/src/BottomSheet/BottomSheet.tsx +3 -1
  53. package/src/BottomSheet/BottomSheetKeyboard.test.tsx +195 -0
  54. package/src/BottomSheet/BottomSheetPanel.test.tsx +11 -1
  55. package/src/BottomSheet/BottomSheetPanel.tsx +49 -16
  56. package/src/BottomSheet/__tests__/BottomSheetKeyboard.a11y.browser.spec.ts +344 -0
  57. package/src/DateRangeInput/DateRangeInput.doc.mjs +35 -7
  58. package/src/DateRangeInput/DateRangeInput.spec.md +203 -0
  59. package/src/DateRangeInput/DateRangeInput.test.tsx +100 -4
  60. package/src/DateRangeInput/DateRangeInput.tsx +29 -20
  61. package/src/Dialog/Dialog.doc.mjs +3 -0
  62. package/src/Dialog/Dialog.spec.md +1 -1
  63. package/src/Dialog/DialogHeader.doc.mjs +38 -0
  64. package/src/Dialog/DialogHeader.test.tsx +49 -0
  65. package/src/Dialog/DialogHeader.tsx +23 -4
  66. package/src/Dialog/modules/DialogHeader.spec.md +152 -0
  67. package/src/FileInput/FileInput.doc.mjs +2 -0
  68. package/src/FileInput/FileInput.spec.md +199 -0
  69. package/src/FileInput/FileInput.test.tsx +14 -0
  70. package/src/FileInput/FileInput.tsx +13 -3
  71. package/src/Markdown/Markdown.doc.mjs +167 -42
  72. package/src/Markdown/Markdown.public.test.ts +157 -0
  73. package/src/Markdown/Markdown.spec.md +149 -70
  74. package/src/Markdown/Markdown.test.tsx +107 -3
  75. package/src/Markdown/Markdown.tsx +116 -35
  76. package/src/Markdown/incremental.test.ts +175 -7
  77. package/src/Markdown/index.ts +6 -0
  78. package/src/Markdown/parser.perf.test.ts +3 -1
  79. package/src/Markdown/parser.test.ts +122 -0
  80. package/src/Markdown/parser.ts +609 -81
  81. package/src/Markdown/utils.ts +6 -0
  82. package/src/PowerSearch/PowerSearchEditPopover.test.tsx +150 -1
  83. package/src/PowerSearch/PowerSearchEditPopover.tsx +51 -28
  84. package/src/ScrollableArea/modules/useScrollableArea.spec.md +50 -22
  85. package/src/Slider/Slider.doc.mjs +16 -0
  86. package/src/Slider/Slider.spec.md +61 -47
  87. package/src/Slider/Slider.test.tsx +18 -0
  88. package/src/Slider/Slider.tsx +12 -6
  89. package/src/Spinner/Spinner.doc.mjs +6 -3
  90. package/src/Spinner/Spinner.test.tsx +37 -0
  91. package/src/Spinner/Spinner.tsx +31 -14
  92. package/src/ToggleButton/ToggleButton.test.tsx +133 -0
  93. package/src/ToggleButton/ToggleButton.tsx +9 -2
  94. package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +209 -0
  95. package/src/Tokenizer/Tokenizer.spec.md +142 -75
  96. package/src/Typeahead/BaseTypeahead.spec.md +4 -3
  97. package/src/Typeahead/BaseTypeahead.tsx +15 -6
  98. package/src/Typeahead/Typeahead.test.tsx +53 -0
  99. package/src/hooks/scrollKeyboardDelegation.test.ts +155 -0
  100. package/src/hooks/scrollKeyboardDelegation.ts +233 -0
  101. package/src/hooks/useScrollableArea.doc.mjs +15 -3
  102. package/src/hooks/useScrollableArea.test.tsx +59 -1
  103. package/src/hooks/useScrollableArea.ts +34 -10
  104. package/src/theme/derivedVarRegistry.test.ts +6 -4
@@ -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.
@@ -370,10 +370,15 @@ describe('DateRangeInput', () => {
370
370
  expect(
371
371
  screen.queryByRole('listbox', {hidden: true}),
372
372
  ).not.toBeInTheDocument();
373
- expect(
374
- screen.getByRole('group', {name: 'Preset date ranges', hidden: true}),
375
- ).toBeInTheDocument();
376
- expect(getButton('Last 7 days')).toBeInTheDocument();
373
+ const group = screen.getByRole('group', {
374
+ name: 'Preset date ranges',
375
+ hidden: true,
376
+ });
377
+ expect(group).toBeInTheDocument();
378
+ expect(group).toHaveClass('astryx-date-range-input-presets');
379
+ expect(getButton('Last 7 days')).toHaveClass(
380
+ 'astryx-date-range-input-preset',
381
+ );
377
382
  });
378
383
 
379
384
  it('marks the applied preset with aria-current, not aria-selected', () => {
@@ -386,11 +391,99 @@ describe('DateRangeInput', () => {
386
391
  />,
387
392
  );
388
393
  const active = getButton('Last 7 days');
394
+ expect(active).toHaveClass('astryx-date-range-input-preset');
395
+ expect(active).toHaveAttribute('data-selected', 'selected');
389
396
  expect(active).toHaveAttribute('aria-current', 'true');
390
397
  expect(active).not.toHaveAttribute('aria-selected');
391
398
  const inactive = getButton('This month');
399
+ expect(inactive).toHaveClass('astryx-date-range-input-preset');
400
+ expect(inactive).not.toHaveAttribute('data-selected');
401
+ expect(inactive).not.toHaveAttribute('data-disabled');
392
402
  expect(inactive).not.toHaveAttribute('aria-current');
393
403
  });
404
+
405
+ it('disables a preset when its start is before min', () => {
406
+ const handleChange = vi.fn();
407
+ render(
408
+ <DateRangeInput
409
+ label="Range"
410
+ value={null}
411
+ onChange={handleChange}
412
+ min="2026-03-02"
413
+ presets={[presets[0]]}
414
+ />,
415
+ );
416
+
417
+ const preset = getButton('Last 7 days');
418
+ expect(preset).toBeDisabled();
419
+ fireEvent.click(preset);
420
+ expect(handleChange).not.toHaveBeenCalled();
421
+ });
422
+
423
+ it('disables a preset when its end is after max', () => {
424
+ const handleChange = vi.fn();
425
+ render(
426
+ <DateRangeInput
427
+ label="Range"
428
+ value={null}
429
+ onChange={handleChange}
430
+ max="2026-03-06"
431
+ presets={[presets[0]]}
432
+ />,
433
+ );
434
+
435
+ const preset = getButton('Last 7 days');
436
+ expect(preset).toBeDisabled();
437
+ fireEvent.click(preset);
438
+ expect(handleChange).not.toHaveBeenCalled();
439
+ });
440
+
441
+ it('disables a preset when either endpoint fails dateConstraints', () => {
442
+ const handleChange = vi.fn();
443
+ render(
444
+ <DateRangeInput
445
+ label="Range"
446
+ value={null}
447
+ onChange={handleChange}
448
+ dateConstraints={[date => date.getDate() !== 7]}
449
+ presets={[presets[0]]}
450
+ />,
451
+ );
452
+
453
+ const preset = getButton('Last 7 days');
454
+ expect(preset).toBeDisabled();
455
+ fireEvent.click(preset);
456
+ expect(handleChange).not.toHaveBeenCalled();
457
+ });
458
+
459
+ it('commits an enabled preset using its already-resolved range', () => {
460
+ const range = {start: '2026-03-01', end: '2026-03-07'} as const;
461
+ const getRange = vi.fn(() => range);
462
+ let getRangeCallsAtChange = 0;
463
+ const handleChange = vi.fn(() => {
464
+ getRangeCallsAtChange = getRange.mock.calls.length;
465
+ });
466
+ render(
467
+ <DateRangeInput
468
+ label="Range"
469
+ value={null}
470
+ onChange={handleChange}
471
+ min="2026-03-01"
472
+ max="2026-03-31"
473
+ dateConstraints={[date => date.getDate() !== 13]}
474
+ minRangeSpan={2}
475
+ maxRangeSpan={7}
476
+ presets={[{label: 'Allowed range', getRange}]}
477
+ />,
478
+ );
479
+
480
+ const getRangeCallsBeforeClick = getRange.mock.calls.length;
481
+ const preset = getButton('Allowed range');
482
+ expect(preset).not.toBeDisabled();
483
+ fireEvent.click(preset);
484
+ expect(handleChange).toHaveBeenCalledWith(range);
485
+ expect(getRangeCallsAtChange).toBe(getRangeCallsBeforeClick);
486
+ });
394
487
  });
395
488
  describe('disabledMessage', () => {
396
489
  // jsdom does not implement the Popover API used by the tooltip, so mock
@@ -831,7 +924,10 @@ describe('DateRangeInput range-span forwarding', () => {
831
924
  const withinCap = getButton('Last 3 days');
832
925
  const overCap = getButton('Last 30 days');
833
926
  expect(withinCap).not.toBeDisabled();
927
+ expect(withinCap).not.toHaveAttribute('data-disabled');
834
928
  expect(overCap).toBeDisabled();
929
+ expect(overCap).toHaveClass('astryx-date-range-input-preset');
930
+ expect(overCap).toHaveAttribute('data-disabled', 'disabled');
835
931
 
836
932
  fireEvent.click(overCap);
837
933
  expect(handleChange).not.toHaveBeenCalled();
@@ -54,6 +54,7 @@ import {
54
54
  type DayOfWeek,
55
55
  type DayOfWeekName,
56
56
  } from '../Calendar';
57
+ import {useCalendarConstraints} from '../Calendar/hooks';
57
58
  import {usePopover} from '../Popover';
58
59
  import {useTooltip} from '../Tooltip';
59
60
  import {mergeProps} from '../utils';
@@ -376,6 +377,9 @@ export interface DateRangeInputProps extends Omit<
376
377
 
377
378
  /**
378
379
  * Preset date ranges shown as quick-select options beside the calendar.
380
+ * A preset is disabled when either endpoint violates `min`, `max`, or
381
+ * `dateConstraints`, or when its span violates `minRangeSpan` or
382
+ * `maxRangeSpan`.
379
383
  */
380
384
  presets?: ReadonlyArray<DateRangePreset>;
381
385
 
@@ -542,6 +546,11 @@ export function DateRangeInput({
542
546
  dialogLabel: t('@astryx.dateRangeInput.dialogLabel'),
543
547
  closeButtonLabel: t('@astryx.dateInput.closeCalendar'),
544
548
  });
549
+ const {isDateDisabled} = useCalendarConstraints({
550
+ min,
551
+ max,
552
+ dateConstraints,
553
+ });
545
554
 
546
555
  const fireChange = useCallback(
547
556
  (newValue: DateRange | null) => {
@@ -577,14 +586,6 @@ export function DateRangeInput({
577
586
  [fireChange, popover],
578
587
  );
579
588
 
580
- const handlePresetClick = useCallback(
581
- (preset: DateRangePreset) => {
582
- fireChange(preset.getRange());
583
- popover.hide();
584
- },
585
- [fireChange, popover],
586
- );
587
-
588
589
  const handleClear = useCallback(
589
590
  (e: React.MouseEvent) => {
590
591
  e.stopPropagation();
@@ -713,15 +714,17 @@ export function DateRangeInput({
713
714
  <div
714
715
  role="group"
715
716
  aria-label={t('@astryx.dateRangeInput.presetDateRanges')}
716
- {...stylex.props(styles.presetSidebar)}>
717
+ {...mergeProps(
718
+ themeProps('date-range-input-presets'),
719
+ stylex.props(styles.presetSidebar),
720
+ )}>
717
721
  {presets.map(preset => {
718
722
  const presetRange = preset.getRange();
719
723
  const isActive = isRangeEqual(value, presetRange);
720
- const isPresetDisabled = !isRangeWithinSpan(
721
- presetRange,
722
- maxRangeSpan,
723
- minRangeSpan,
724
- );
724
+ const isPresetDisabled =
725
+ !isRangeWithinSpan(presetRange, maxRangeSpan, minRangeSpan) ||
726
+ isDateDisabled(plainDateFromISO(presetRange.start)) ||
727
+ isDateDisabled(plainDateFromISO(presetRange.end));
725
728
  return (
726
729
  <button
727
730
  key={preset.label}
@@ -733,12 +736,18 @@ export function DateRangeInput({
733
736
  // concept that contradicted the Tab interaction) (forms-5).
734
737
  aria-current={isActive ? 'true' : undefined}
735
738
  disabled={isPresetDisabled}
736
- onClick={() => handlePresetClick(preset)}
737
- {...stylex.props(
738
- focusOutlineStyles.focusVisible,
739
- styles.presetButton,
740
- isActive && styles.presetButtonActive,
741
- isPresetDisabled && styles.presetButtonDisabled,
739
+ onClick={() => handleRangeSelect(presetRange)}
740
+ {...mergeProps(
741
+ themeProps('date-range-input-preset', {
742
+ selected: isActive ? 'selected' : null,
743
+ disabled: isPresetDisabled ? 'disabled' : null,
744
+ }),
745
+ stylex.props(
746
+ focusOutlineStyles.focusVisible,
747
+ styles.presetButton,
748
+ isActive && styles.presetButtonActive,
749
+ isPresetDisabled && styles.presetButtonDisabled,
750
+ ),
742
751
  )}>
743
752
  {preset.label}
744
753
  </button>
@@ -30,6 +30,9 @@ export const docs = {
30
30
  container: true,
31
31
  targets: [
32
32
  {className: 'astryx-dialog', visualProps: ['variant']},
33
+ {className: 'astryx-dialog-header'},
34
+ {className: 'astryx-dialog-header-title-block'},
35
+ {className: 'astryx-dialog-header-close-icon'},
33
36
  ],
34
37
  vars: [
35
38
  {name: '--_dialog-radius', description: 'Border radius of the dialog', default: 'var(--radius-container)', private: true},
@@ -17,7 +17,7 @@ verified_by:
17
17
  packages/core/src/Dialog/__tests__/Dialog.a11y.test.tsx,
18
18
  packages/core/src/Dialog/__tests__/Dialog.a11y.chromium.spec.ts,
19
19
  ]
20
- modules: []
20
+ modules: [module:Dialog/DialogHeader]
21
21
  families: [family:overlay-dismissal]
22
22
  design_specs: []
23
23
  architecture:
@@ -8,6 +8,14 @@ export const docs = {
8
8
  displayName: 'Dialog Header',
9
9
  isHiddenFromOverview: true,
10
10
  description: 'Header for dialogs with a title, optional subtitle, close button, and start/end content slots.',
11
+ usage: {
12
+ description: 'Use DialogHeader to give a dialog a labelled title area and optional close control.',
13
+ anatomy: [
14
+ {name: 'Header row', required: true, description: 'Arranges the title block, optional start/end content, and close control.'},
15
+ {name: 'Title block', required: true, description: 'Groups the title and optional subtitle.'},
16
+ {name: 'Close icon', required: false, description: 'Visual close glyph inside the close button.'},
17
+ ],
18
+ },
11
19
  props: [
12
20
  {
13
21
  name: 'title',
@@ -72,6 +80,13 @@ export const docs = {
72
80
  hasDivider: true,
73
81
  },
74
82
  },
83
+ theming: {
84
+ targets: [
85
+ {className: 'astryx-dialog-header'},
86
+ {className: 'astryx-dialog-header-title-block'},
87
+ {className: 'astryx-dialog-header-close-icon'},
88
+ ],
89
+ },
75
90
  examples: [
76
91
  {
77
92
  label: 'Basic',
@@ -117,6 +132,14 @@ export const docsZh = {
117
132
  isHiddenFromOverview: true,
118
133
  displayName: 'Dialog Header',
119
134
  description: '对话框头部,包含标题、可选副标题、关闭按钮以及首尾内容插槽。',
135
+ usage: {
136
+ description: '使用 DialogHeader 为对话框提供带标签的标题区和可选的关闭控件。',
137
+ anatomy: [
138
+ {name: 'Header row', required: true, description: '排列标题区、可选的首尾内容和关闭控件。'},
139
+ {name: 'Title block', required: true, description: '组合标题和可选副标题。'},
140
+ {name: 'Close icon', required: false, description: '关闭按钮内的关闭图标。'},
141
+ ],
142
+ },
120
143
  props: [
121
144
  {
122
145
  name: 'title',
@@ -150,6 +173,13 @@ export const docsZh = {
150
173
  default: 'true',
151
174
  },
152
175
  ],
176
+ theming: {
177
+ targets: [
178
+ {className: 'astryx-dialog-header'},
179
+ {className: 'astryx-dialog-header-title-block'},
180
+ {className: 'astryx-dialog-header-close-icon'},
181
+ ],
182
+ },
153
183
  };
154
184
 
155
185
  export const docsDense = {
@@ -157,6 +187,14 @@ export const docsDense = {
157
187
  isHiddenFromOverview: true,
158
188
  displayName: 'Dialog Header',
159
189
  description: 'dialog header w/ title, optional subtitle, close button, start/end content slots',
190
+ usage: {
191
+ description: 'labelled dialog title area + optional close control',
192
+ anatomy: [
193
+ {name: 'Header row', required: true, description: 'arranges title block, optional start/end content, close control'},
194
+ {name: 'Title block', required: true, description: 'groups title + optional subtitle'},
195
+ {name: 'Close icon', required: false, description: 'close glyph inside close button'},
196
+ ],
197
+ },
160
198
  propDescriptions: {
161
199
  title: 'dialog title (focused on open; labels dialog via aria-labelledby)',
162
200
  subtitle: 'subtitle below title',
@@ -14,6 +14,13 @@ import {render, screen} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {DialogHeader} from './DialogHeader';
16
16
  import {LayoutDividerContext} from '../Layout/LayoutDividerContext';
17
+ import {defineTheme} from '../theme/defineTheme';
18
+ import {generateThemeCSS} from '../theme/generateThemeRules';
19
+
20
+ function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
21
+ const {prose, component} = generateThemeCSS(theme);
22
+ return [prose, component].filter(Boolean).join('\n\n');
23
+ }
17
24
 
18
25
  describe('DialogHeader', () => {
19
26
  it('renders the title', () => {
@@ -56,6 +63,48 @@ describe('DialogHeader', () => {
56
63
  expect(screen.getByRole('button', {name: /close/i})).toBeInTheDocument();
57
64
  });
58
65
 
66
+ it('exposes theme targets for the header row, title block, and close icon', () => {
67
+ const {container} = render(
68
+ <DialogHeader
69
+ title="Title"
70
+ subtitle="Subtitle"
71
+ onOpenChange={() => {}}
72
+ />,
73
+ );
74
+
75
+ expect(container.querySelector('.astryx-dialog-header')).not.toBeNull();
76
+ expect(screen.getByRole('heading', {level: 2}).parentElement).toHaveClass(
77
+ 'astryx-dialog-header-title-block',
78
+ );
79
+
80
+ const closeIcon = screen
81
+ .getByRole('button', {name: /close/i})
82
+ .querySelector('.astryx-dialog-header-close-icon');
83
+ expect(closeIcon).toHaveClass('astryx-icon');
84
+ });
85
+
86
+ it('lets themes set the two internal gaps and close-icon size', () => {
87
+ const theme = defineTheme({
88
+ name: 'dialog-header-targets-test',
89
+ components: {
90
+ 'dialog-header': {base: {gap: '8px'}},
91
+ 'dialog-header-title-block': {base: {gap: '4px'}},
92
+ 'dialog-header-close-icon': {
93
+ base: {width: '16px', height: '16px', fontSize: '16px'},
94
+ },
95
+ },
96
+ });
97
+ const css = generateThemeTestCSS(theme);
98
+
99
+ expect(css).toContain('.astryx-dialog-header {');
100
+ expect(css).toContain('gap: 8px');
101
+ expect(css).toContain('.astryx-dialog-header-title-block {');
102
+ expect(css).toContain('gap: 4px');
103
+ expect(css).toContain('.astryx-dialog-header-close-icon {');
104
+ expect(css).toContain('width: 16px');
105
+ expect(css).toContain('height: 16px');
106
+ });
107
+
59
108
  it('does not render close button when onOpenChange is not provided', () => {
60
109
  render(<DialogHeader title="Title" />);
61
110
  expect(
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file DialogHeader.tsx
7
- * @input Uses React, useEffect, useRef, LayoutHeader, Button, Icon, Heading, Text, DialogContext
7
+ * @input Uses React, StyleX, LayoutHeader, Button, Icon, Heading, Text, DialogContext, mergeProps, themeProps
8
8
  * @output Exports DialogHeader component and DialogHeaderProps
9
9
  * @position Dialog header component; used with Dialog and Layout
10
10
  *
@@ -25,6 +25,8 @@ import {Icon} from '../Icon';
25
25
  import {Heading} from '../Heading/Heading';
26
26
  import {Text} from '../Text/Text';
27
27
  import type {BaseProps} from '../BaseProps';
28
+ import {mergeProps} from '../utils';
29
+ import {themeProps} from '../utils/themeProps';
28
30
  import {useDialogContext} from './DialogContext';
29
31
  import {useTranslator} from '../i18n';
30
32
 
@@ -41,6 +43,9 @@ const styles = stylex.create({
41
43
  marginInlineEnd: `calc(-1 * ${spacingVars['--spacing-2']})`,
42
44
  },
43
45
  titleWrapper: {
46
+ display: 'flex',
47
+ flexDirection: 'column',
48
+ gap: spacingVars['--spacing-0'],
44
49
  flex: 1,
45
50
  minWidth: 0,
46
51
  // Visual centering: align title center with close button center
@@ -161,11 +166,19 @@ export function DialogHeader({
161
166
  className={className}
162
167
  style={style}
163
168
  {...rest}>
164
- <div {...stylex.props(styles.container)}>
169
+ <div
170
+ {...mergeProps(
171
+ themeProps('dialog-header'),
172
+ stylex.props(styles.container),
173
+ )}>
165
174
  {startContent && (
166
175
  <div {...stylex.props(styles.actions)}>{startContent}</div>
167
176
  )}
168
- <div {...stylex.props(styles.titleWrapper)}>
177
+ <div
178
+ {...mergeProps(
179
+ themeProps('dialog-header-title-block'),
180
+ stylex.props(styles.titleWrapper),
181
+ )}>
169
182
  <Heading
170
183
  ref={titleRef}
171
184
  id={titleId}
@@ -192,7 +205,13 @@ export function DialogHeader({
192
205
  variant="ghost"
193
206
  label={t('@astryx.dialog.close')}
194
207
  tooltip={t('@astryx.dialog.close')}
195
- icon={<Icon icon="close" color="inherit" />}
208
+ icon={
209
+ <Icon
210
+ icon="close"
211
+ color="inherit"
212
+ {...themeProps('dialog-header-close-icon')}
213
+ />
214
+ }
196
215
  onClick={() => {
197
216
  onOpenChange?.(false);
198
217
  }}