@astryxdesign/core 0.7.0-canary.fe57f54 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +186 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +5 -4
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetPanel.d.ts +1 -1
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetPanel.js +1 -1
  7. package/dist/Calendar/styles.d.ts +6 -6
  8. package/dist/Chat/ChatComposerInput.d.ts +0 -11
  9. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  10. package/dist/Chat/ChatComposerInput.js +0 -11
  11. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  12. package/dist/Chat/useTriggerMenu.js +1 -1
  13. package/dist/CommandPalette/CommandPaletteList.d.ts +3 -2
  14. package/dist/CommandPalette/CommandPaletteList.d.ts.map +1 -1
  15. package/dist/CommandPalette/CommandPaletteList.js +3 -2
  16. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  17. package/dist/DateInput/TouchDateField.js +4 -1
  18. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  19. package/dist/DateTimeInput/TouchDateTimeField.js +29 -13
  20. package/dist/DropdownMenu/DropdownMenu.d.ts +11 -0
  21. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  22. package/dist/DropdownMenu/DropdownMenu.js +173 -11
  23. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  24. package/dist/DropdownMenu/DropdownMenuSubMenu.js +13 -2
  25. package/dist/DropdownMenu/menuRegionsContext.d.ts +2 -0
  26. package/dist/DropdownMenu/menuRegionsContext.d.ts.map +1 -0
  27. package/dist/DropdownMenu/menuRegionsContext.js +17 -0
  28. package/dist/Field/inputStyles.stylex.d.ts +3 -3
  29. package/dist/Layout/container.stylex.d.ts +18 -20
  30. package/dist/Layout/container.stylex.d.ts.map +1 -1
  31. package/dist/Layout/container.stylex.js +15 -25
  32. package/dist/Layout/padding.stylex.d.ts +231 -231
  33. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  34. package/dist/Lightbox/Lightbox.js +15 -6
  35. package/dist/Markdown/plugins/protocol.d.ts.map +1 -1
  36. package/dist/Markdown/plugins/protocol.js +33 -8
  37. package/dist/NavItem/navItemStyles.stylex.d.ts +5 -5
  38. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  39. package/dist/PowerSearch/PowerSearch.js +21 -1
  40. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  41. package/dist/PowerSearch/PowerSearchEditPopover.js +30 -15
  42. package/dist/Stack/stack.stylex.d.ts +22 -22
  43. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  44. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +89 -14
  45. package/dist/Table/table.stylex.d.ts +6 -6
  46. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  47. package/dist/TimeInput/TouchTimeField.js +4 -1
  48. package/dist/Tokenizer/Tokenizer.d.ts +1 -12
  49. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  50. package/dist/Tokenizer/Tokenizer.js +1 -13
  51. package/dist/Typeahead/BaseTypeahead.d.ts +0 -11
  52. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  53. package/dist/Typeahead/BaseTypeahead.js +1 -9
  54. package/dist/Typeahead/Typeahead.d.ts +1 -12
  55. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  56. package/dist/Typeahead/Typeahead.js +1 -13
  57. package/dist/astryx.css +17 -12
  58. package/dist/hooks/useFocusTrap.d.ts +8 -0
  59. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  60. package/dist/hooks/useFocusTrap.js +5 -1
  61. package/dist/hooks/useRenamedProp.d.ts +6 -6
  62. package/dist/hooks/useRenamedProp.js +6 -6
  63. package/dist/reset.a11y.chromium.spec.d.ts +2 -0
  64. package/dist/reset.a11y.chromium.spec.d.ts.map +1 -0
  65. package/dist/reset.a11y.chromium.spec.js +176 -0
  66. package/dist/theme/tokens.stylex.d.ts +30 -30
  67. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  68. package/dist/theme/tokens.stylex.js +18 -15
  69. package/dist/theme/useTheme.d.ts +1 -1
  70. package/package.json +4 -4
  71. package/src/BottomSheet/BottomSheet.doc.mjs +4 -4
  72. package/src/BottomSheet/BottomSheet.tsx +5 -4
  73. package/src/BottomSheet/BottomSheetPanel.test.tsx +6 -9
  74. package/src/BottomSheet/BottomSheetPanel.tsx +2 -3
  75. package/src/Chat/ChatComposerInput.doc.mjs +3 -3
  76. package/src/Chat/ChatComposerInput.test.tsx +7 -55
  77. package/src/Chat/ChatComposerInput.tsx +0 -36
  78. package/src/Chat/useTriggerMenu.tsx +1 -4
  79. package/src/CommandPalette/CommandPalette.spec.md +2 -0
  80. package/src/CommandPalette/CommandPaletteList.spec.md +189 -0
  81. package/src/CommandPalette/CommandPaletteList.test.tsx +47 -0
  82. package/src/CommandPalette/CommandPaletteList.tsx +3 -2
  83. package/src/DateInput/TouchDateField.tsx +3 -1
  84. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +60 -1
  85. package/src/DateTimeInput/TouchDateTimeField.tsx +31 -14
  86. package/src/DropdownMenu/DropdownMenu.doc.mjs +12 -0
  87. package/src/DropdownMenu/DropdownMenu.spec.md +65 -40
  88. package/src/DropdownMenu/DropdownMenu.test.tsx +457 -0
  89. package/src/DropdownMenu/DropdownMenu.tsx +278 -49
  90. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +6 -3
  91. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +2 -1
  92. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +11 -1
  93. package/src/DropdownMenu/__tests__/MenuHeaderFooter.a11y.chromium.spec.ts +195 -0
  94. package/src/DropdownMenu/menuRegionsContext.ts +17 -0
  95. package/src/Layout/container.stylex.ts +11 -21
  96. package/src/Lightbox/Lightbox.test.tsx +58 -0
  97. package/src/Lightbox/Lightbox.tsx +12 -6
  98. package/src/Markdown/plugins/protocol.ts +45 -12
  99. package/src/Markdown/plugins/sourceLines.test.tsx +121 -0
  100. package/src/PowerSearch/PowerSearch.tsx +28 -1
  101. package/src/PowerSearch/PowerSearchEditPopover.test.tsx +284 -0
  102. package/src/PowerSearch/PowerSearchEditPopover.tsx +41 -13
  103. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +33 -9
  104. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +81 -18
  105. package/src/TimeInput/TouchTimeField.tsx +3 -1
  106. package/src/Tokenizer/Tokenizer.doc.mjs +0 -16
  107. package/src/Tokenizer/Tokenizer.test.tsx +12 -63
  108. package/src/Tokenizer/Tokenizer.tsx +1 -25
  109. package/src/Toolbar/Toolbar.test.tsx +29 -0
  110. package/src/Typeahead/BaseTypeahead.doc.mjs +0 -16
  111. package/src/Typeahead/BaseTypeahead.test.tsx +1 -1
  112. package/src/Typeahead/BaseTypeahead.tsx +1 -21
  113. package/src/Typeahead/Typeahead.doc.mjs +0 -7
  114. package/src/Typeahead/Typeahead.test.tsx +24 -42
  115. package/src/Typeahead/Typeahead.tsx +1 -25
  116. package/src/hooks/useFocusTrap.ts +5 -1
  117. package/src/hooks/useRenamedProp.ts +6 -6
  118. package/src/reset.a11y.chromium.spec.ts +178 -0
  119. package/src/reset.css +8 -8
  120. package/src/reset.test.ts +112 -0
  121. package/src/theme/__tests__/SpacingRootFontSize.a11y.chromium.spec.ts +198 -0
  122. package/src/theme/tokens.stylex.ts +18 -15
  123. package/src/theme/tokens.test.ts +1 -1
  124. package/src/theme/useTheme.test.tsx +4 -4
  125. package/src/theme/useTheme.ts +1 -1
@@ -0,0 +1,189 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 7
4
+ kind: component
5
+ id: component:CommandPaletteList
6
+ authority: draft
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: null
10
+ approved_at: null
11
+ owners: [cixzhang]
12
+ review_triggers: [behavior, accessibility, public-api, theming]
13
+ verified_by:
14
+ [
15
+ packages/core/src/CommandPalette/CommandPaletteList.test.tsx,
16
+ packages/core/src/CommandPalette/CommandPalette.test.tsx,
17
+ packages/core/src/theme/themingTargets.test.ts,
18
+ apps/storybook/stories/CommandPaletteList.stories.tsx,
19
+ apps/storybook/rtl-audit/verified-not-applicable.json,
20
+ scripts/check-knowledge.mjs,
21
+ ]
22
+ modules: []
23
+ families: []
24
+ design_specs: []
25
+ architecture:
26
+ [
27
+ architecture:component-test-sufficiency,
28
+ architecture:component-theming-surface,
29
+ architecture:public-component-api,
30
+ ]
31
+ contributing: []
32
+ system_specs: []
33
+ ---
34
+
35
+ # CommandPaletteList component contract
36
+
37
+ ## Contract at a glance
38
+
39
+ | Area | Contract |
40
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
41
+ | Public contract | None changes. One named listbox container for result options, rendered by CommandPalette or directly for a standalone results list. |
42
+ | Behavior | The root is a `listbox` named by `label` or the translated default; inside CommandPalette it carries the context list id that the query field controls. |
43
+ | End-user impact | Screen-reader users reach a named results list from the query field; overflowing results scroll inside the list instead of growing the palette. |
44
+ | Builder impact | None. CommandPalette renders its own list; standalone compositions render items and groups directly inside this one. |
45
+ | Compatibility/readiness | Observational draft with no runtime change. A standalone caller `id` is overwritten (FR1), an empty list keeps the listbox role (AR4), and the standalone keyboard model remains OQ1. |
46
+ | Review checks | Reject a second list-id owner, a role or name that consumer props can replace, dropped native or styling inputs, or a list target moved off the scrolling root. |
47
+ | Governing rules | `architecture:public-component-api/INV5,INV6,INV8`; `architecture:component-theming-surface`; WAI-ARIA 1.2 `listbox`. |
48
+
49
+ This table is a review projection; the body below is authoritative.
50
+
51
+ ## Intent
52
+
53
+ CommandPaletteList is the scrolling results container of the command palette.
54
+ Inside CommandPalette it is the listbox the query field controls. Standalone, it
55
+ presents a named results listbox around caller-provided option content.
56
+
57
+ ## Compatibility and migration
58
+
59
+ - Released default preserved: `yes`
60
+ - Compatibility class: observational record only; runtime, DOM, styling, target,
61
+ and public API are unchanged
62
+ - Controlled/uncontrolled behavior: not applicable; the list holds no state
63
+ - Migration decision: none
64
+
65
+ Consumer migration instructions belong in consumer docs and release notes.
66
+
67
+ ## Ownership boundary
68
+
69
+ **Owns**
70
+
71
+ - The listbox root and its accessible name from `label`, falling back to the
72
+ translated default.
73
+ - Applying the CommandPalette context list id to the root.
74
+ - Native passthrough, `ref`, and styling-input composition on the root.
75
+ - Block-axis scrolling of overflowing children, with spacing-scale padding and
76
+ gaps.
77
+
78
+ **Does not own / non-goals**
79
+
80
+ - List-id generation, query-field focus, active descendant, arrow-key
81
+ navigation, hover highlight, Enter selection, search, and the choice between
82
+ results and the empty state — owned by `component:CommandPalette`.
83
+ - The aggregate List anatomy entry and its `command-palette-list` target mapping
84
+ — owned by `component:CommandPalette`.
85
+ - Option semantics, state, and activation; group semantics and headings — owned
86
+ by CommandPaletteItem and CommandPaletteGroup.
87
+ - A standalone composite keyboard model; its direction remains OQ1.
88
+
89
+ ## Public concepts
90
+
91
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
92
+ | --------------- | ---------------------------------- | ------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------- | ------------------------------ | --------- | ------------------------------------------------------------------------ |
93
+ | Accessible name | caller `label`; translated default | Name announced for the results listbox. | All uses | `@astryx.commandPalette.list.label` ("Commands" in English) | `component:CommandPaletteList` | Stable | An empty `label` leaves the listbox unnamed; callers supply a real name. |
94
+ | List identity | context list id; absent | DOM id the CommandPalette query field references. | Inside CommandPalette; standalone | Context id inside CommandPalette; absent standalone | `component:CommandPalette` | Stable | A standalone caller `id` is overwritten (FR1 gap). |
95
+
96
+ ## Behavioral and layout contract
97
+
98
+ | ID | Candidate invariant | Basis | Draft review state |
99
+ | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
100
+ | FR1 | Native props and `ref` reach the listbox root, and consumer `className` and `style` combine with the list target and base styles. Inside CommandPalette the root id is the context list id; standalone, an absent id replaces a caller `id`. | Released source; `architecture:public-component-api/INV5,INV6,INV8` | Ref and styling verified by focused tests; the standalone `id` conflicts with INV5 |
101
+ | FR2 | The root exposes `role=listbox` with an accessible name from `label`, falling back to the translated default; consumer `role` and `aria-label` props do not replace them. | Released source and docs; WAI-ARIA 1.2 `listbox` | Role and name verified by focused tests; consumer-prop precedence is source-verified; the role also applies with no option (AR4) |
102
+ | FR3 | Inside CommandPalette, the query field's `aria-controls` names the listbox id. | Released source and component docs | Verified by a focused composition test |
103
+ | FR4 | The root lays out children in one column with spacing-scale padding and gaps, grows within a flex parent, caps its height at the parent's height, and scrolls overflowing children inside itself. | Released source and component docs | Source-verified; the scrolling fixture renders it in a browser, but scroll geometry is not yet measured |
104
+
105
+ ### Allowed variation
106
+
107
+ - **AV1 — Children.** Items, groups, and empty states may vary in count, order,
108
+ and content without changing the listbox root.
109
+ - **AV2 — Layout.** Consumer `xstyle`, class, and style may adjust the root
110
+ without replacing its role, name, id, or target.
111
+
112
+ ### Representative states
113
+
114
+ | State | Required invariant | Allowed variation |
115
+ | --------------------- | --------------------------------------------------------------------- | -------------------------- |
116
+ | Standalone results | The listbox is named by `label` or the translated default. | Item count and grouping. |
117
+ | Inside CommandPalette | The root id equals the query field's `aria-controls` value. | Results count and groups. |
118
+ | Overflowing results | Results scroll inside the list instead of growing past the container. | Container and item height. |
119
+
120
+ ### Transformation and precedence order
121
+
122
+ - **ORD1 — Root attributes.** Consumer native props, then the component-owned
123
+ id, `listbox` role, and accessible name.
124
+ - **ORD2 — Styling.** List target, then base styles with `xstyle`, then
125
+ consumer class, then consumer style.
126
+
127
+ ### Performance and resources
128
+
129
+ - **PR1 — No work beyond render.** The list adds no state, effect, listener, or
130
+ observer; it reads translation and CommandPalette context during render only.
131
+
132
+ ## Accessibility contract
133
+
134
+ - **AR1 — Name.** The listbox takes `label` as its accessible name and falls back
135
+ to the translated default when `label` is omitted.
136
+ - **AR2 — Relationship.** Inside CommandPalette, the query field controls the
137
+ listbox through its id; CommandPalette owns focus and keyboard navigation under
138
+ its combobox model.
139
+ - **AR3 — Standalone gap.** A standalone list exposes a listbox of options with
140
+ no owning keyboard model. OQ1 must settle that contract before runtime
141
+ semantics change.
142
+ - **AR4 — Empty content.** The root keeps `role=listbox` when its children contain
143
+ no option, as in CommandPalette's empty states. WAI-ARIA 1.2 requires a listbox
144
+ to own options, so this is an open conflict, not a contract.
145
+
146
+ ## Design relationships
147
+
148
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
149
+ | ---------------- | ----------------------------------------------------------------------------------- | ------------------------------ | -------------- | ------------------ |
150
+ | List | Presents result options in one scrolling column with spacing-scale padding and gap. | Current source and public docs | Supporting | FR1, FR4 |
151
+
152
+ ## Family and system relationships
153
+
154
+ - `component:CommandPalette` owns list-id generation, keyboard navigation, hover
155
+ highlight, the aggregate anatomy, and the `command-palette-list` target
156
+ mapping; this component applies the id and paints the List.
157
+ - `architecture:public-component-api` owns native passthrough, styling
158
+ composition, and refs.
159
+ - `architecture:component-theming-surface` owns target qualification and the
160
+ aggregate List anatomy map.
161
+ - `architecture:component-test-sufficiency` owns the evidence rules behind the
162
+ verification map below.
163
+
164
+ ## Verification map
165
+
166
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
167
+ | -------- | --------------------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
168
+ | FR1 | `CommandPaletteList.test.tsx` | standalone root with ref, class, and style | Disconnecting `ref`, or dropping the consumer class or the list target, fails focused DOM assertions. | `audit:CommandPaletteList/api` |
169
+ | FR2, AR1 | `CommandPaletteList.test.tsx` | default and caller label | Removing the role or name fails the role and name assertions. | `audit:CommandPaletteList/a11y` |
170
+ | FR3, AR2 | `CommandPaletteList.test.tsx`, `CommandPalette.test.tsx` | inline CommandPalette | Dropping the context id breaks the query-field relationship assertion. | `audit:CommandPaletteList/a11y` |
171
+ | FR4 | Storybook fixtures, `themingTargets.test.ts`, exact-head accessibility and RTL evidence | standalone list; scrolling palette | Losing the target fails the target inventory; any new directional behavior requires a non-empty RTL dimension. | `audit:CommandPaletteList/theming` |
172
+
173
+ ## Decision log
174
+
175
+ None. This draft records current facts and settles no component-local API,
176
+ keyboard, or visual decision.
177
+
178
+ ## Open questions
179
+
180
+ - **OQ1 — What standalone keyboard contract should CommandPaletteList expose?**
181
+ (`human-api`) A standalone list exposes a listbox of options but no owning
182
+ keyboard model. Decide whether a public composite owner supplies that model,
183
+ the standalone composition takes a different role, or standalone use remains
184
+ pointer-only presentation.
185
+
186
+ ## Content boundary
187
+
188
+ This file does not duplicate consumer prop tables/examples, current audit
189
+ results, implementation steps, or family/system rules. It links to their owners.
@@ -7,7 +7,10 @@
7
7
  */
8
8
 
9
9
  import {describe, it, expect} from 'vitest';
10
+ import {createRef} from 'react';
10
11
  import {render, screen} from '@testing-library/react';
12
+ import {createStaticSource} from '@astryxdesign/core/Typeahead';
13
+ import {CommandPalette} from './CommandPalette';
11
14
  import {CommandPaletteList} from './CommandPaletteList';
12
15
 
13
16
  describe('CommandPaletteList', () => {
@@ -54,4 +57,48 @@ describe('CommandPaletteList', () => {
54
57
  'Search results',
55
58
  );
56
59
  });
60
+
61
+ it('is the listbox that the CommandPalette query field controls', async () => {
62
+ render(
63
+ <CommandPalette
64
+ isOpen
65
+ isInline
66
+ onOpenChange={() => {}}
67
+ searchSource={createStaticSource([{id: 'home', label: 'Home'}])}
68
+ />,
69
+ );
70
+
71
+ const listbox = await screen.findByRole('listbox');
72
+ expect(listbox.id).not.toBe('');
73
+ expect(screen.getByRole('combobox')).toHaveAttribute(
74
+ 'aria-controls',
75
+ listbox.id,
76
+ );
77
+ });
78
+
79
+ it('connects ref to the listbox element', () => {
80
+ const ref = createRef<HTMLDivElement>();
81
+ render(
82
+ <CommandPaletteList ref={ref}>
83
+ <div>Item</div>
84
+ </CommandPaletteList>,
85
+ );
86
+
87
+ expect(ref.current).toBe(screen.getByRole('listbox'));
88
+ });
89
+
90
+ it('combines consumer className and style with the list theme target', () => {
91
+ render(
92
+ <CommandPaletteList className="product-results" style={{minHeight: 120}}>
93
+ <div>Item</div>
94
+ </CommandPaletteList>,
95
+ );
96
+
97
+ const listbox = screen.getByRole('listbox');
98
+ expect(listbox).toHaveClass(
99
+ 'astryx-command-palette-list',
100
+ 'product-results',
101
+ );
102
+ expect(listbox).toHaveStyle({minHeight: '120px'});
103
+ });
57
104
  });
@@ -59,8 +59,9 @@ export interface CommandPaletteListProps extends BaseProps<HTMLDivElement> {
59
59
  * When used inside CommandPalette, automatically gets the correct
60
60
  * ID for aria-controls linking with the input.
61
61
  *
62
- * @compositionHint Place inside CommandPalette, after CommandPaletteInput.
63
- * Contains CommandPaletteItem and CommandPaletteGroup children.
62
+ * @compositionHint CommandPalette renders its own list after its input; render
63
+ * this list directly only for a standalone results list. Contains
64
+ * CommandPaletteItem and CommandPaletteGroup children.
64
65
  *
65
66
  * @example
66
67
  * ```
@@ -1296,7 +1296,9 @@ export function TouchDateField({
1296
1296
  label={t('@astryx.dateInput.dialogLabel')}
1297
1297
  // The picker is a fixed height by construction, so the sheet should be
1298
1298
  // exactly as tall as it is rather than claiming a viewport budget.
1299
- height="hug">
1299
+ height="hug"
1300
+ // sheetBody owns the inset, including the band under the grab handle.
1301
+ padding={0}>
1300
1302
  <div {...stylex.props(styles.sheetBody)}>{surface}</div>
1301
1303
  </BottomSheet>
1302
1304
  {showsDisabledMessage &&
@@ -25,7 +25,7 @@ import {
25
25
  beforeEach,
26
26
  afterEach,
27
27
  } from 'vitest';
28
- import {useState} from 'react';
28
+ import {Profiler, useState} from 'react';
29
29
  import * as stylex from '@stylexjs/stylex';
30
30
  import {readFileSync} from 'node:fs';
31
31
  import {render, screen, fireEvent, within} from '@testing-library/react';
@@ -986,3 +986,62 @@ describe('DateTimeInput touch surface', () => {
986
986
  expect(field).toHaveAttribute('aria-expanded', 'false');
987
987
  });
988
988
  });
989
+
990
+ describe('DateTimeInput touch surface: no stale commit', () => {
991
+ // Records what each commit painted, so a value that is corrected one render
992
+ // later (by an effect) shows up as a stale intermediate frame.
993
+ function paintedFrames(read: () => string) {
994
+ const frames: string[] = [];
995
+ const onRender = () => frames.push(read());
996
+ return {frames, onRender};
997
+ }
998
+
999
+ it('clears the time display in the same commit as an external clear', () => {
1000
+ const {frames, onRender} = paintedFrames(
1001
+ () => (timeField() as HTMLInputElement).value,
1002
+ );
1003
+ const ui = (value: ISODateTimeString | undefined) => (
1004
+ <Profiler id="field" onRender={onRender}>
1005
+ <DateTimeInput label="Meeting" value={value} onChange={() => {}} />
1006
+ </Profiler>
1007
+ );
1008
+ const {rerender} = render(ui('2026-03-15T10:00' as ISODateTimeString));
1009
+ expect(timeField()).toHaveValue('10:00 AM');
1010
+
1011
+ frames.length = 0;
1012
+ rerender(ui(undefined));
1013
+
1014
+ expect(frames.length).toBeGreaterThan(0);
1015
+ expect(frames).not.toContain('10:00 AM');
1016
+ expect(timeField()).toHaveValue('');
1017
+ });
1018
+
1019
+ it('shows an in-range month in the same commit that narrows max', () => {
1020
+ withMonthLayout(() => {
1021
+ const visibleMonth = () =>
1022
+ ['March', 'April', 'May', 'June']
1023
+ .map(m => `${m} 2026`)
1024
+ .find(label => screen.queryByText(label) != null) ?? '';
1025
+ const {frames, onRender} = paintedFrames(visibleMonth);
1026
+ const ui = (max: ISODateTimeString) => (
1027
+ <Profiler id="field" onRender={onRender}>
1028
+ <DateTimeInput label="Meeting" max={max} onChange={() => {}} />
1029
+ </Profiler>
1030
+ );
1031
+ const {rerender} = render(ui('2026-12-31T23:59' as ISODateTimeString));
1032
+ openDateSheet();
1033
+ const next = screen.getByRole('button', {name: 'Next month'});
1034
+ fireEvent.click(next);
1035
+ fireEvent.click(next);
1036
+ fireEvent.click(next);
1037
+ expect(screen.getByText('June 2026')).toBeInTheDocument();
1038
+
1039
+ frames.length = 0;
1040
+ rerender(ui('2026-04-30T23:59' as ISODateTimeString));
1041
+
1042
+ expect(frames.length).toBeGreaterThan(0);
1043
+ expect(frames).not.toContain('June 2026');
1044
+ expect(screen.getByText('April 2026')).toBeInTheDocument();
1045
+ });
1046
+ });
1047
+ });
@@ -590,15 +590,25 @@ export function TouchDateTimeField({
590
590
  );
591
591
  const weekStartsOn = normalizeDayOfWeek(weekStartsOnProp);
592
592
 
593
- useEffect(() => {
593
+ // The draft follows the controlled time, and an external clear resets it.
594
+ // Adjusted during render when the value changes, so no commit paints the
595
+ // previous draft.
596
+ const [draftSource, setDraftSource] = useState({
597
+ time: normalizedValueTime,
598
+ value: optimisticValue,
599
+ });
600
+ // Object.is, matching the dependency comparison of the effect this replaces.
601
+ if (
602
+ !Object.is(draftSource.time, normalizedValueTime) ||
603
+ !Object.is(draftSource.value, optimisticValue)
604
+ ) {
605
+ setDraftSource({time: normalizedValueTime, value: optimisticValue});
594
606
  if (normalizedValueTime !== undefined) {
595
- // eslint-disable-next-line @eslint-react/set-state-in-effect -- mirrors an externally controlled time into the sheet-local draft
596
607
  setDraftTime(normalizedValueTime);
597
608
  } else if (optimisticValue === undefined) {
598
- // eslint-disable-next-line @eslint-react/set-state-in-effect -- external clear resets the sheet-local draft too
599
609
  setDraftTime(undefined);
600
610
  }
601
- }, [normalizedValueTime, optimisticValue]);
611
+ }
602
612
 
603
613
  const [anchorMonthIndex] = useState(() =>
604
614
  monthIndexOf(selectedDate ?? plainDateToday()),
@@ -614,19 +624,23 @@ export function TouchDateTimeField({
614
624
  const [monthIndex, setMonthIndex] = useState(() =>
615
625
  clampIndex(anchorMonthIndex, minMonthIndex, maxMonthIndex),
616
626
  );
627
+ // min/max can narrow underneath the mounted sheet. Keep the visible month in
628
+ // range during render, so no commit shows an out-of-range month; the month
629
+ // scroller is keyed on the range and remounts at the clamped month.
630
+ const clampedMonthIndex = clampIndex(
631
+ monthIndex,
632
+ minMonthIndex,
633
+ maxMonthIndex,
634
+ );
635
+ // Object.is, matching the dependency comparison of the effect this replaces
636
+ // (a NaN index would otherwise never compare equal and re-render forever).
637
+ if (!Object.is(clampedMonthIndex, monthIndex)) {
638
+ setMonthIndex(clampedMonthIndex);
639
+ }
617
640
  const {year, month} = fromMonthIndex(monthIndex);
618
641
  const canStepBack = monthIndex > minMonthIndex;
619
642
  const canStepForward = monthIndex < maxMonthIndex;
620
643
 
621
- useEffect(() => {
622
- const clamped = clampIndex(monthIndex, minMonthIndex, maxMonthIndex);
623
- if (clamped !== monthIndex) {
624
- // eslint-disable-next-line @eslint-react/set-state-in-effect -- min/max can change underneath the mounted sheet; keep the visible month in range
625
- setMonthIndex(clamped);
626
- scrollerHandleRef.current?.scrollToMonth(clamped, 'auto');
627
- }
628
- }, [monthIndex, minMonthIndex, maxMonthIndex]);
629
-
630
644
  const dayNames = useMemo(
631
645
  () =>
632
646
  Array.from({length: 7}, (_, offset) =>
@@ -1464,7 +1478,10 @@ export function TouchDateTimeField({
1464
1478
  isOpen={isSheetOpen}
1465
1479
  onOpenChange={handleSheetOpenChange}
1466
1480
  label={t('@astryx.dateTimeInput.dialogLabel')}
1467
- height="hug">
1481
+ height="hug"
1482
+ // touchSheetBody owns the inset, including the band under the grab
1483
+ // handle.
1484
+ padding={0}>
1468
1485
  <div {...stylex.props(styles.touchSheetBody)}>{surface}</div>
1469
1486
  </BottomSheet>
1470
1487
  {showsDisabledMessage &&
@@ -227,6 +227,18 @@ export const docs = {
227
227
  description:
228
228
  'Maximum height in pixels for the popover presentation, for a menu that must fit its rows. Lifts the default 300px cap; the viewport still bounds it.',
229
229
  },
230
+ {
231
+ name: 'header',
232
+ type: 'ReactNode',
233
+ description:
234
+ 'Content above the rows and outside the menu itself — a filter field, a toggle. A menu holds only its rows, so a control that is not a row goes here or in `footer`, never among the rows. It keeps its own keys and presses; Tab moves from it into the menu, and Shift+Tab before its first control closes the menu. The bottom sheet shows it above the actions.',
235
+ },
236
+ {
237
+ name: 'footer',
238
+ type: 'ReactNode',
239
+ description:
240
+ 'Content below the rows and outside the menu itself — a "Create" button. Tab from the menu moves into it, and Tab past its last control closes the menu. The bottom sheet shows it below the actions.',
241
+ },
230
242
  {
231
243
  name: 'placement',
232
244
  type: "'above' | 'below' | 'start' | 'end'",