@astryxdesign/core 0.6.0 → 0.6.1

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 (203) hide show
  1. package/CHANGELOG.md +41 -3
  2. package/dist/AppShell/AppShell.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +12 -1
  4. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  5. package/dist/BottomSheet/BottomSheetSwitcher.js +44 -15
  6. package/dist/Breadcrumbs/BreadcrumbItem.d.ts +3 -2
  7. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  8. package/dist/Breadcrumbs/BreadcrumbItem.js +3 -7
  9. package/dist/Center/Center.d.ts +23 -16
  10. package/dist/Center/Center.d.ts.map +1 -1
  11. package/dist/Center/Center.js +7 -5
  12. package/dist/CodeBlock/CodeBlock.js +2 -2
  13. package/dist/DateInput/DateInput.d.ts.map +1 -1
  14. package/dist/DateInput/DateInput.js +12 -2
  15. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  16. package/dist/DateTimeInput/DateTimeInput.js +12 -2
  17. package/dist/Field/Field.d.ts.map +1 -1
  18. package/dist/Field/Field.js +1 -0
  19. package/dist/Field/InputClearButton.d.ts +2 -2
  20. package/dist/Field/InputClearButton.d.ts.map +1 -1
  21. package/dist/Field/InputClearButton.js +5 -1
  22. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  23. package/dist/Field/PanelSearchInput.js +16 -4
  24. package/dist/FileInput/FileInput.d.ts.map +1 -1
  25. package/dist/FileInput/FileInput.js +12 -1
  26. package/dist/HoverCard/useHoverCard.js +2 -2
  27. package/dist/Indicator/CheckboxIndicator.js +2 -2
  28. package/dist/Indicator/RadioIndicator.js +2 -2
  29. package/dist/Layer/layerStack.d.ts +10 -0
  30. package/dist/Layer/layerStack.d.ts.map +1 -1
  31. package/dist/Layer/layerStack.js +21 -9
  32. package/dist/Layer/useLayerDismissal.d.ts +2 -3
  33. package/dist/Layer/useLayerDismissal.d.ts.map +1 -1
  34. package/dist/Layer/useLayerDismissal.js +2 -3
  35. package/dist/NavIcon/NavIcon.js +2 -2
  36. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  37. package/dist/NumberInput/NumberInput.js +12 -2
  38. package/dist/Popover/usePopover.d.ts +3 -2
  39. package/dist/Popover/usePopover.d.ts.map +1 -1
  40. package/dist/Popover/usePopover.js +4 -2
  41. package/dist/ProgressBar/ProgressBar.js +2 -2
  42. package/dist/ScrollableArea/ScrollableArea.d.ts +79 -0
  43. package/dist/ScrollableArea/ScrollableArea.d.ts.map +1 -0
  44. package/dist/ScrollableArea/ScrollableArea.js +144 -0
  45. package/dist/ScrollableArea/index.d.ts +11 -0
  46. package/dist/ScrollableArea/index.d.ts.map +1 -0
  47. package/dist/ScrollableArea/index.js +11 -0
  48. package/dist/StatusDot/StatusDot.js +2 -2
  49. package/dist/TextArea/TextArea.js +2 -2
  50. package/dist/TextInput/TextInput.d.ts.map +1 -1
  51. package/dist/TextInput/TextInput.js +19 -4
  52. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  53. package/dist/TimeInput/TimeInput.js +12 -2
  54. package/dist/Typeahead/BaseTypeahead.d.ts +21 -14
  55. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  56. package/dist/Typeahead/BaseTypeahead.js +56 -20
  57. package/dist/astryx.css +15 -0
  58. package/dist/hooks/index.d.ts +2 -0
  59. package/dist/hooks/index.d.ts.map +1 -1
  60. package/dist/hooks/index.js +1 -0
  61. package/dist/hooks/scrollGeometry.d.ts +24 -0
  62. package/dist/hooks/scrollGeometry.d.ts.map +1 -0
  63. package/dist/hooks/scrollGeometry.js +86 -0
  64. package/dist/hooks/scrollOwnerRegistry.d.ts +15 -0
  65. package/dist/hooks/scrollOwnerRegistry.d.ts.map +1 -0
  66. package/dist/hooks/scrollOwnerRegistry.js +24 -0
  67. package/dist/hooks/useFocusTrap.d.ts +8 -0
  68. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  69. package/dist/hooks/useFocusTrap.js +22 -11
  70. package/dist/hooks/useScrollableArea.d.ts +51 -0
  71. package/dist/hooks/useScrollableArea.d.ts.map +1 -0
  72. package/dist/hooks/useScrollableArea.js +287 -0
  73. package/dist/index.d.ts +1 -0
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +1 -0
  76. package/dist/theme/defineTheme.d.ts +2 -6
  77. package/dist/theme/defineTheme.d.ts.map +1 -1
  78. package/dist/theme/defineTheme.js +1 -1
  79. package/dist/theme/derivedVarRegistry.js +1 -1
  80. package/dist/theme/localTokens.d.ts +8 -11
  81. package/dist/theme/localTokens.d.ts.map +1 -1
  82. package/dist/theme/localTokens.js +17 -71
  83. package/dist/theme/themeAdaptations.d.ts.map +1 -1
  84. package/dist/theme/themeAdaptations.js +4 -4
  85. package/dist/utils/themeProps.d.ts +10 -10
  86. package/dist/utils/themeProps.d.ts.map +1 -1
  87. package/dist/utils/themeProps.js +27 -10
  88. package/locales/en.json +16 -0
  89. package/locales/pseudo.json +12 -0
  90. package/package.json +7 -2
  91. package/src/AppShell/AppShell.test.tsx +36 -0
  92. package/src/AppShell/AppShell.tsx +4 -1
  93. package/src/AspectRatio/AspectRatio.doc.mjs +3 -3
  94. package/src/Banner/Banner.test.tsx +3 -1
  95. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +56 -1
  96. package/src/BottomSheet/BottomSheetSwitcher.spec.md +211 -0
  97. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +134 -2
  98. package/src/BottomSheet/BottomSheetSwitcher.tsx +43 -20
  99. package/src/Breadcrumbs/BreadcrumbItem.doc.mjs +10 -5
  100. package/src/Breadcrumbs/BreadcrumbItem.spec.md +225 -0
  101. package/src/Breadcrumbs/BreadcrumbItem.tsx +8 -13
  102. package/src/Breadcrumbs/Breadcrumbs.doc.mjs +2 -2
  103. package/src/Breadcrumbs/Breadcrumbs.test.tsx +49 -2
  104. package/src/Center/Center.doc.mjs +32 -28
  105. package/src/Center/Center.spec.md +225 -0
  106. package/src/Center/Center.test.tsx +42 -4
  107. package/src/Center/Center.tsx +24 -17
  108. package/src/Chat/ChatSystemMessage.test.tsx +2 -9
  109. package/src/CodeBlock/CodeBlock.doc.mjs +2 -2
  110. package/src/CodeBlock/CodeBlock.tsx +2 -2
  111. package/src/DateInput/DateInput.test.tsx +4 -4
  112. package/src/DateInput/DateInput.tsx +15 -4
  113. package/src/DateRangeInput/DateRangeInput.test.tsx +2 -2
  114. package/src/DateTimeInput/DateTimeInput.test.tsx +6 -4
  115. package/src/DateTimeInput/DateTimeInput.tsx +18 -7
  116. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +4 -77
  117. package/src/Field/Field.test.tsx +42 -0
  118. package/src/Field/Field.tsx +6 -0
  119. package/src/Field/InputClearButton.test.tsx +35 -1
  120. package/src/Field/InputClearButton.tsx +7 -3
  121. package/src/Field/PanelSearchInput.tsx +21 -8
  122. package/src/FieldStatus/FieldStatus.spec.md +27 -17
  123. package/src/FieldStatus/FieldStatus.test.tsx +7 -5
  124. package/src/FieldStatus/__tests__/StatusMessage.a11y.chromium.spec.ts +198 -0
  125. package/src/FieldStatus/__tests__/StatusMessage.a11y.known-failures.ts +13 -0
  126. package/src/FieldStatus/__tests__/StatusMessage.a11y.renders.tsx +305 -0
  127. package/src/FieldStatus/__tests__/StatusMessage.a11y.states.ts +317 -0
  128. package/src/FieldStatus/__tests__/StatusMessage.a11y.test.tsx +155 -0
  129. package/src/FileInput/FileInput.tsx +10 -1
  130. package/src/FormLayout/__snapshots__/FormLayout.test.tsx.snap +3 -3
  131. package/src/HoverCard/HoverCard.doc.mjs +4 -4
  132. package/src/HoverCard/useHoverCard.tsx +2 -2
  133. package/src/Indicator/CheckboxIndicator.tsx +2 -2
  134. package/src/Indicator/Indicator.doc.mjs +2 -2
  135. package/src/Indicator/Indicator.test.tsx +1 -1
  136. package/src/Indicator/RadioIndicator.tsx +2 -2
  137. package/src/Layer/layerStack.ts +20 -9
  138. package/src/Layer/useLayerDismissal.ts +2 -3
  139. package/src/MultiSelector/MultiSelector.test.tsx +4 -4
  140. package/src/NavIcon/NavIcon.doc.mjs +4 -4
  141. package/src/NavIcon/NavIcon.tsx +2 -2
  142. package/src/NumberInput/NumberInput.tsx +18 -7
  143. package/src/Popover/Popover.doc.mjs +10 -10
  144. package/src/Popover/Popover.spec.md +55 -65
  145. package/src/Popover/Popover.test.tsx +29 -0
  146. package/src/Popover/usePopover.doc.mjs +4 -4
  147. package/src/Popover/usePopover.tsx +7 -4
  148. package/src/ProgressBar/ProgressBar.doc.mjs +4 -4
  149. package/src/ProgressBar/ProgressBar.test.tsx +1 -31
  150. package/src/ProgressBar/ProgressBar.tsx +2 -2
  151. package/src/RadioList/RadioList.test.tsx +5 -144
  152. package/src/RadioList/__tests__/RadioGroup.a11y.chromium.spec.ts +255 -0
  153. package/src/RadioList/__tests__/RadioGroup.a11y.known-failures.ts +12 -0
  154. package/src/RadioList/__tests__/RadioGroup.a11y.renders.tsx +232 -0
  155. package/src/RadioList/__tests__/RadioGroup.a11y.states.ts +503 -0
  156. package/src/RadioList/__tests__/RadioGroup.a11y.test.tsx +217 -0
  157. package/src/ScrollableArea/ScrollableArea.doc.mjs +100 -0
  158. package/src/ScrollableArea/ScrollableArea.spec.md +189 -0
  159. package/src/ScrollableArea/ScrollableArea.test.tsx +299 -0
  160. package/src/ScrollableArea/ScrollableArea.tsx +259 -0
  161. package/src/ScrollableArea/index.ts +26 -0
  162. package/src/ScrollableArea/modules/useScrollableArea.spec.md +121 -0
  163. package/src/SegmentedControl/SegmentedControl.test.tsx +5 -172
  164. package/src/Selector/Selector.test.tsx +4 -4
  165. package/src/Spinner/Spinner.test.tsx +0 -18
  166. package/src/StatusDot/StatusDot.doc.mjs +4 -4
  167. package/src/StatusDot/StatusDot.tsx +2 -2
  168. package/src/TabList/TabList.test.tsx +5 -9
  169. package/src/TabList/__tests__/Tabs.a11y.chromium.spec.ts +191 -0
  170. package/src/TabList/__tests__/Tabs.a11y.known-failures.ts +45 -0
  171. package/src/TabList/__tests__/Tabs.a11y.renders.tsx +92 -0
  172. package/src/TabList/__tests__/Tabs.a11y.states.ts +247 -0
  173. package/src/TabList/__tests__/Tabs.a11y.test.tsx +153 -0
  174. package/src/Table/Table.doc.mjs +2 -2
  175. package/src/TextArea/TextArea.doc.mjs +4 -4
  176. package/src/TextArea/TextArea.tsx +2 -2
  177. package/src/TextInput/TextInput.doc.mjs +2 -1
  178. package/src/TextInput/TextInput.test.tsx +94 -0
  179. package/src/TextInput/TextInput.tsx +22 -6
  180. package/src/TimeInput/TimeInput.tsx +18 -7
  181. package/src/Toast/ToastViewport.test.tsx +1 -39
  182. package/src/Typeahead/BaseTypeahead.doc.mjs +229 -33
  183. package/src/Typeahead/BaseTypeahead.spec.md +269 -0
  184. package/src/Typeahead/BaseTypeahead.test.tsx +200 -0
  185. package/src/Typeahead/BaseTypeahead.tsx +99 -30
  186. package/src/hooks/index.ts +13 -0
  187. package/src/hooks/scrollGeometry.ts +155 -0
  188. package/src/hooks/scrollOwnerRegistry.ts +47 -0
  189. package/src/hooks/useFocusTrap.ts +22 -11
  190. package/src/hooks/useFocusTrapEscapeShim.test.tsx +4 -3
  191. package/src/hooks/useScrollableArea.doc.mjs +108 -0
  192. package/src/hooks/useScrollableArea.test.tsx +437 -0
  193. package/src/hooks/useScrollableArea.ts +469 -0
  194. package/src/index.ts +1 -0
  195. package/src/theme/defineTheme.test.ts +65 -105
  196. package/src/theme/defineTheme.ts +3 -9
  197. package/src/theme/derivedVarRegistry.ts +1 -1
  198. package/src/theme/localTokens.ts +25 -96
  199. package/src/theme/publicThemeHelperContract.test.ts +2 -2
  200. package/src/theme/themeAdaptations.test.ts +16 -42
  201. package/src/theme/themeAdaptations.ts +6 -9
  202. package/src/utils/themeProps.test.ts +29 -10
  203. package/src/utils/themeProps.ts +36 -17
@@ -0,0 +1,269 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 4
4
+ kind: component
5
+ id: component:BaseTypeahead
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: [public-api, behavior, layout, theming, accessibility]
13
+ verified_by:
14
+ [
15
+ packages/core/src/Typeahead/BaseTypeahead.test.tsx,
16
+ packages/core/src/Typeahead/Typeahead.test.tsx,
17
+ packages/core/src/Tokenizer/Tokenizer.test.tsx,
18
+ packages/core/src/theme/themingTargets.test.ts,
19
+ scripts/check-knowledge.mjs,
20
+ ]
21
+ modules: []
22
+ families: [family:overlay-dismissal]
23
+ design_specs: []
24
+ architecture:
25
+ [
26
+ architecture:public-component-api,
27
+ architecture:component-theming-surface,
28
+ architecture:layer-runtime,
29
+ ]
30
+ contributing: []
31
+ system_specs: []
32
+ ---
33
+
34
+ # BaseTypeahead component contract
35
+
36
+ ## Intent
37
+
38
+ BaseTypeahead is the exported combobox engine beneath Typeahead and Tokenizer. It
39
+ renders a bare input, search and bootstrap behavior, keyboard navigation, a
40
+ styled result listbox, loading feedback, and selection callbacks. A direct caller
41
+ supplies the visible input wrapper, accessible name, and selected-value
42
+ presentation.
43
+
44
+ This draft records verified shipped or audit-remediated behavior. It does not
45
+ approve a new prop, default, compatibility promise, visual treatment, or
46
+ ownership boundary.
47
+
48
+ ## Compatibility and migration
49
+
50
+ - Released default preserved: `yes`
51
+ - Compatibility class: patch corrections preserve the existing public surface
52
+ - Controlled/uncontrolled behavior: controlled `value` remains unchanged
53
+ - Migration decision: none; unresolved public-surface cleanup requires a
54
+ separate compatibility decision
55
+
56
+ Consumer migration instructions belong in consumer docs and release notes.
57
+
58
+ ## Ownership boundary
59
+
60
+ **Owns**
61
+
62
+ - Query text, search/bootstrap scheduling, stale-response rejection, result
63
+ ordering, highlight, and selection callbacks.
64
+ - Combobox, listbox, option, busy, selected, and empty-result semantics.
65
+ - The anchored result popup and current dropdown/empty-state visual treatment.
66
+ - Direct-caller loading feedback when a composed wrapper does not take over the
67
+ busy indicator lane.
68
+
69
+ **Does not own / non-goals**
70
+
71
+ - The visible input wrapper, label presentation, field border, or focus ring.
72
+ - Selected-value or token presentation in Typeahead and Tokenizer.
73
+ - Caller-rendered result content.
74
+ - Spinner presentation, owned by `component:Spinner`.
75
+ - Shared top-layer hosting, positioning, and dismissal behavior, owned by
76
+ `architecture:layer-runtime` and `family:overlay-dismissal`.
77
+
78
+ ## Public concepts
79
+
80
+ | Concept | Closed values or states | Meaning | Availability by state | Default | Owner | Stability | Invalid-value behavior |
81
+ | --------------- | ---------------------------------------- | ------------------------------------------------------------ | -------------------------- | -------------------------------- | ---------------------------- | --------- | ---------------------------------------------------------------------------------------- |
82
+ | selection | `T` or `null` | Caller-controlled selected result | all states | required | `component:BaseTypeahead` | released | caller retains control |
83
+ | search source | `search`, `bootstrap`, optional `cancel` | Supplies query and focus results | enabled input | required | `component:BaseTypeahead` | released | rejected work clears current results |
84
+ | focus bootstrap | on or off | Offers bootstrap results before query input | empty focused input | off | `component:BaseTypeahead` | released | off keeps the menu closed |
85
+ | query threshold | non-negative number | Minimum visible-character count before search | non-empty query | `1` | `component:BaseTypeahead` | released | below threshold cancels work and closes results |
86
+ | debounce | milliseconds | Delays query search | typed query | `150` | `component:BaseTypeahead` | released | non-positive runs immediately |
87
+ | result cap | number | Limits fetched results shown | completed search/bootstrap | `10` | `component:BaseTypeahead` | released | source order is preserved |
88
+ | result content | default, `renderItem`, or `item.element` | Chooses content inside the stable option row | result present | default TypeaheadItem | caller and component | released | `item.element` takes precedence |
89
+ | disabled state | native or focusable-disabled | Blocks query mutation; native disabled blocks all activation | disabled | native disabled | component and caller wrapper | released | an already-open focusable-disabled list can still select with Enter (retained violation) |
90
+ | popup width | intrinsic or fixed pixels | Sets result popup width before viewport clamping | popup present | intrinsic, at least anchor width | `component:BaseTypeahead` | released | viewport fit wins |
91
+ | size | `sm`, `md`, `lg` | Selects option-row padding | popup options | `md` | `component:BaseTypeahead` | released | TypeScript rejects other values |
92
+
93
+ ## Behavioral and layout contract
94
+
95
+ Draft requirements identify their observational or current-authority basis.
96
+
97
+ | ID | Candidate invariant | Basis | Draft review state |
98
+ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------- |
99
+ | FR1 | The input is controlled by internal query state while `value` is caller-controlled; selecting a result calls `onChange(item)`, clears query/results, closes the popup, and returns focus to the input. | Current source and tests | verified shipped behavior |
100
+ | FR2 | Search starts only at the grapheme-count threshold, uses the configured debounce, and never presents an empty result for a query that was not searched. | Public API, current i18n character utility, tests | verified audit remediation |
101
+ | FR3 | A newer query, selection, clear, or unmount invalidates stale asynchronous work. Escape hides the current popup but does not invalidate pending work, so a late response currently reopens it (retained violation). | Current source, focused retained-red probe, and tests | verified shipped behavior |
102
+ | FR4 | The popup is a named listbox whose result rows are options. A completed empty search renders one disabled option so the listbox retains a valid owned child. | APG combobox pattern, axe, tests | verified audit remediation |
103
+ | FR5 | Arrow keys wrap the highlight; Home/End move to the first/last option; Enter selects; Escape and Tab hide the current popup; IME-owned key events do not activate combobox commands. Escape dismissal is not durable while source work remains pending (FR3). | Current source and tests | verified shipped behavior; Escape gap retained |
104
+ | FR6 | Pending asynchronous source work sets `aria-busy` and renders one named Spinner unless the composed wrapper owns the busy indicator lane. | Current source, input-family FR7, tests | verified shipped behavior |
105
+ | FR7 | Supported BaseProps DOM, ARIA, data, class, style, event, and `xstyle` inputs reach the combobox input while component-owned role, state, value, and behavior remain authoritative. | `architecture:public-component-api/INV5–INV7`, tests | verified audit remediation |
106
+ | FR8 | The popup remains within the inline viewport at 320 CSS px, including long default results and a requested width larger than the available viewport. | WCAG 1.4.10, real Chromium | verified audit remediation |
107
+ | FR9 | Native disabled removes ordinary focus and activation. Focusable-disabled uses `aria-disabled` plus `readOnly` and blocks text/query mutation, but applying it after the popup opens does not currently block Enter selection (retained violation). | Current source, tests, and focused retained-red probe | verified shipped behavior |
108
+
109
+ ### Allowed variation
110
+
111
+ - Search and bootstrap may complete synchronously or asynchronously.
112
+ - Results may be grouped or ungrouped and may use default or caller-rendered
113
+ content.
114
+ - A direct caller may anchor the popup to the input or to its own wrapper.
115
+ - Typeahead and Tokenizer may own the visible wrapper and busy-indicator lane.
116
+
117
+ ### Representative states
118
+
119
+ | State | Required invariant | Allowed variation |
120
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
121
+ | rest | named combobox is closed and not busy | caller-owned wrapper and label |
122
+ | focused bootstrap | popup opens only when enabled and results exist | synchronous or asynchronous source |
123
+ | query pending | combobox is busy and duplicate stale work is rejected | direct or wrapper-owned Spinner |
124
+ | results | one highlighted option and valid active descendant | grouped/default/custom content |
125
+ | completed empty | one disabled empty option; no active descendant | caller-supplied empty text |
126
+ | selected result | matching option exposes `aria-selected=true` | generic check presentation |
127
+ | disabled | native disabled blocks activation; focusable-disabled blocks query mutation but can still select an already-open highlight with Enter | native or focusable-disabled semantics |
128
+ | dismissed pending | Escape hides the popup; a late pending response currently reopens it | source completion timing |
129
+ | narrow viewport | popup and result content remain inside two viewport gutters | intrinsic or requested width |
130
+
131
+ ### Transformation and precedence order
132
+
133
+ - **ORD1 — Result content.** `item.element` → caller `renderItem` →
134
+ TypeaheadItem.
135
+ - **ORD2 — Input props.** Consumer rest props → defined legacy aliases (`inputId`,
136
+ `ariaDescribedBy`, `ariaLabelledBy`, `inputTabIndex`) → component-owned
137
+ semantics and handlers → component styles → consumer class/style escape
138
+ hatches. An omitted legacy alias preserves its equivalent native BaseProp.
139
+
140
+ ### Performance and resources
141
+
142
+ - **PR1 — Search lifetime.** One generation counter rejects stale responses;
143
+ debounce timers and optional source cancellation are cleared on replacement and
144
+ unmount.
145
+
146
+ ## Accessibility contract
147
+
148
+ - **AR1 — Combobox pattern.** DOM focus stays on the input while
149
+ `aria-activedescendant` identifies the highlighted option in the named listbox.
150
+ - **AR2 — Accessible name.** A direct caller supplies `aria-label` or a valid
151
+ `aria-labelledby` relationship; composed owners supply their visible label ID.
152
+ - **AR3 — State.** Expanded, busy, disabled, active-descendant, and selected
153
+ states are exposed programmatically.
154
+ - **AR4 — Result feedback.** Active queries announce the localized result count
155
+ or empty-result message through the shared announcer.
156
+ - **AR5 — Input method.** IME composition commands remain with the candidate
157
+ window; ordinary keyboard and pointer selection remain equivalent.
158
+
159
+ ## Design relationships
160
+
161
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
162
+ | -------------------- | ----------------------------------------------- | -------------------------------- | ----------------- | ------------------ |
163
+ | Input | caller-owned visible chrome and focus treatment | unsettled ownership for bare use | prominent | AR2 |
164
+ | Loading status | shared Spinner | `component:Spinner` | supporting | FR6 |
165
+ | Dropdown | Popover surface with bounded listbox | current source and layer runtime | prominent | FR4, FR8 |
166
+ | Highlighted result | overlay plus forced-color outline | objective accessibility standard | prominent | FR5 |
167
+ | Empty state | disabled option message | APG/axe | supporting | FR4 |
168
+ | Default item content | TypeaheadItem | `component:Typeahead` | prominent | ORD1 |
169
+ | Caller item content | caller-owned | caller | context-dependent | ORD1 |
170
+
171
+ ### Theming anatomy
172
+
173
+ <!-- anatomy-theming:v1 -->
174
+
175
+ ```json
176
+ {
177
+ "Input": {
178
+ "none": {
179
+ "reason": "intentional: The direct caller owns the bare input chrome and styles it through supported input styling props."
180
+ }
181
+ },
182
+ "Loading status": {
183
+ "delegatesTo": {"owner": "component:Spinner", "target": "spinner"}
184
+ },
185
+ "Dropdown": {
186
+ "delegatesTo": {
187
+ "owner": "component:Typeahead",
188
+ "target": "typeahead-dropdown"
189
+ }
190
+ },
191
+ "Empty state": {
192
+ "delegatesTo": {
193
+ "owner": "component:Typeahead",
194
+ "target": "typeahead-empty-state"
195
+ }
196
+ },
197
+ "Result group heading": {
198
+ "none": {
199
+ "reason": "reachability-gap: No current public target reaches the visible group heading."
200
+ }
201
+ },
202
+ "Result row": {
203
+ "none": {
204
+ "reason": "reachability-gap: The stable option row owns highlight and selection but has no current target."
205
+ }
206
+ },
207
+ "Default item content": {
208
+ "delegatesTo": {
209
+ "owner": "component:Typeahead",
210
+ "target": "typeahead-item"
211
+ }
212
+ },
213
+ "Caller-rendered item content": {
214
+ "none": {
215
+ "reason": "intentional: Caller-rendered result content remains caller-owned."
216
+ }
217
+ },
218
+ "Selected result state": {
219
+ "none": {
220
+ "reason": "reachability-gap: No current Typeahead target or state reaches the outer selected option."
221
+ }
222
+ }
223
+ }
224
+ ```
225
+
226
+ ## Family and system relationships
227
+
228
+ - `family:overlay-dismissal` owns Escape and platform-close ordering while the
229
+ popup is present.
230
+ - `architecture:layer-runtime` owns top-layer hosting, anchoring, viewport
231
+ positioning, and native light dismissal.
232
+ - `architecture:public-component-api` owns reachable exports and BaseProps
233
+ passthrough semantics.
234
+ - `architecture:component-theming-surface` owns anatomy qualification and target
235
+ disposition.
236
+
237
+ ## Verification map
238
+
239
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
240
+ | --------------- | ------------------------------------------------------ | ------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------- |
241
+ | FR1–FR2 | BaseTypeahead and Typeahead tests | query, overlap, select, clear | current work replaces newer results or selection does not close | `audit:BaseTypeahead/behavior` |
242
+ | FR3 | shared stale-response tests plus retained-red probe | newer query, clear, unmount, Escape while pending | late work replaces current results or reopens after explicit dismissal | `audit:BaseTypeahead/a11y` |
243
+ | FR4, AR1–AR4 | focused tests, component-scoped axe, live-region tests | results, empty, busy, selected | invalid listbox ownership or state/announcement disappears | `audit:BaseTypeahead/a11y` |
244
+ | FR5, AR5 | keyboard, focus-out, IME, and retained-red tests | arrows, Home/End, Enter, Escape, Tab, composition | command selects or dismisses at the wrong time | `audit:BaseTypeahead/a11y` |
245
+ | FR6 | busy-lane tests | direct and composed pending source | duplicate/missing Spinner or stale busy state | `audit:BaseTypeahead/a11y` |
246
+ | FR7 | focused passthrough tests and strict lint | native-only, alias collisions, DOM/style/events | supported consumer input is dropped or owned semantics are replaced | `audit:BaseTypeahead/api` |
247
+ | FR8 | real-Chromium 320px sensor receipt | long result, wide request, LTR/RTL | popup or its content crosses either viewport gutter | `audit:BaseTypeahead/responsive` |
248
+ | FR9 | shared disabled tests plus retained-red probe | native disabled, focusable-disabled after open | disabled mode mutates query or accepts an already-open selection | `audit:BaseTypeahead/a11y` |
249
+ | Theming anatomy | knowledge and theming-target checks | all mapped parts | target inventory or disposition drifts | `audit:BaseTypeahead/theming` |
250
+
251
+ ## Decision log
252
+
253
+ None. This draft records current or objectively remediated behavior and makes no
254
+ component-local API, design, compatibility, or ownership decision.
255
+
256
+ ## Open questions
257
+
258
+ - **OQ1 — How should the released public props type remove package-internal
259
+ composition knobs such as `__queryEntries`, `isFocusableDisabled`, and
260
+ `inputTabIndex`?** (`human-api`)
261
+ - **OQ2 — Should `inputXStyle` be deprecated now that the inherited `xstyle`
262
+ contract correctly reaches the same input?** (`human-api`)
263
+ - **OQ3 — Does bare BaseTypeahead own a default focus-visible ring, or must every
264
+ direct caller provide the ring on its wrapper?** (`human-design`)
265
+
266
+ ## Content boundary
267
+
268
+ This file does not duplicate the prop reference, examples, current audit score,
269
+ shared layer mechanics, or shared accessibility and theming rules.
@@ -0,0 +1,200 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file BaseTypeahead.test.tsx
5
+ * @input BaseTypeahead public props and a synchronous SearchSource
6
+ * @output Focused contract tests for the public combobox engine
7
+ * @position Colocated verification for BaseTypeahead
8
+ */
9
+
10
+ import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest';
11
+ import {fireEvent, render, screen, waitFor} from '@testing-library/react';
12
+ import * as stylex from '@stylexjs/stylex';
13
+ import {BaseTypeahead} from './BaseTypeahead';
14
+ import type {SearchSource, SearchableItem} from './types';
15
+
16
+ const popoverOpenState = new WeakMap<HTMLElement, boolean>();
17
+ const originalMatchesDescriptor = Object.getOwnPropertyDescriptor(
18
+ HTMLElement.prototype,
19
+ 'matches',
20
+ );
21
+ const originalMatches = HTMLElement.prototype.matches;
22
+
23
+ beforeAll(() => {
24
+ HTMLElement.prototype.showPopover = function () {
25
+ popoverOpenState.set(this, true);
26
+ const event = new Event('toggle');
27
+ Object.defineProperty(event, 'newState', {value: 'open'});
28
+ this.dispatchEvent(event);
29
+ };
30
+ HTMLElement.prototype.hidePopover = function () {
31
+ popoverOpenState.set(this, false);
32
+ const event = new Event('toggle');
33
+ Object.defineProperty(event, 'newState', {value: 'closed'});
34
+ this.dispatchEvent(event);
35
+ };
36
+ Object.defineProperty(HTMLElement.prototype, 'matches', {
37
+ ...originalMatchesDescriptor,
38
+ value(this: HTMLElement, selector: string) {
39
+ if (selector === ':popover-open') {
40
+ return popoverOpenState.get(this) ?? false;
41
+ }
42
+ return originalMatches.call(this, selector);
43
+ },
44
+ });
45
+ });
46
+
47
+ afterAll(() => {
48
+ if (originalMatchesDescriptor) {
49
+ Object.defineProperty(
50
+ HTMLElement.prototype,
51
+ 'matches',
52
+ originalMatchesDescriptor,
53
+ );
54
+ }
55
+ });
56
+
57
+ const emptySource: SearchSource<SearchableItem> = {
58
+ search: () => [],
59
+ bootstrap: () => [],
60
+ };
61
+
62
+ const resultItem: SearchableItem = {id: '1', label: 'Result'};
63
+
64
+ const testStyles = stylex.create({
65
+ input: {textTransform: 'uppercase'},
66
+ });
67
+
68
+ describe('BaseTypeahead', () => {
69
+ it('forwards supported DOM, styling, and event props to the combobox input', () => {
70
+ const onBlur = vi.fn();
71
+ const onFocus = vi.fn();
72
+ const onPointerDown = vi.fn();
73
+
74
+ render(
75
+ <BaseTypeahead
76
+ searchSource={emptySource}
77
+ value={null}
78
+ onChange={() => {}}
79
+ aria-label="Find a framework"
80
+ aria-expanded="true"
81
+ className="consumer-input"
82
+ data-audit-state="forwarded"
83
+ onBlur={onBlur}
84
+ onFocus={onFocus}
85
+ onPointerDown={onPointerDown}
86
+ style={{letterSpacing: '0.08em'}}
87
+ xstyle={testStyles.input}
88
+ />,
89
+ );
90
+
91
+ const input = screen.getByRole('combobox', {name: 'Find a framework'});
92
+ expect(input).toHaveAttribute('aria-expanded', 'false');
93
+ expect(input).toHaveAttribute('data-audit-state', 'forwarded');
94
+ expect(input).toHaveClass('consumer-input');
95
+ expect(input).toHaveStyle({letterSpacing: '0.08em'});
96
+ expect(getComputedStyle(input).textTransform).toBe('uppercase');
97
+
98
+ fireEvent.pointerDown(input);
99
+ fireEvent.focus(input);
100
+ fireEvent.blur(input);
101
+ expect(onPointerDown).toHaveBeenCalledOnce();
102
+ expect(onFocus).toHaveBeenCalledOnce();
103
+ expect(onBlur).toHaveBeenCalledOnce();
104
+ });
105
+
106
+ it('preserves native input attributes when legacy aliases are undefined', () => {
107
+ render(
108
+ <BaseTypeahead
109
+ searchSource={emptySource}
110
+ value={null}
111
+ onChange={() => {}}
112
+ id="native-input"
113
+ aria-describedby="native-description"
114
+ aria-labelledby="native-label"
115
+ tabIndex={3}
116
+ inputId={undefined}
117
+ ariaDescribedBy={undefined}
118
+ ariaLabelledBy={undefined}
119
+ inputTabIndex={undefined}
120
+ />,
121
+ );
122
+
123
+ const input = screen.getByRole('combobox');
124
+ expect(input).toHaveAttribute('id', 'native-input');
125
+ expect(input).toHaveAttribute('aria-describedby', 'native-description');
126
+ expect(input).toHaveAttribute('aria-labelledby', 'native-label');
127
+ expect(input).toHaveAttribute('tabindex', '3');
128
+ });
129
+
130
+ it('lets defined legacy aliases override their native equivalents', () => {
131
+ render(
132
+ <BaseTypeahead
133
+ searchSource={emptySource}
134
+ value={null}
135
+ onChange={() => {}}
136
+ id="native-input"
137
+ aria-describedby="native-description"
138
+ aria-labelledby="native-label"
139
+ tabIndex={3}
140
+ inputId="legacy-input"
141
+ ariaDescribedBy="legacy-description"
142
+ ariaLabelledBy="legacy-label"
143
+ inputTabIndex={-1}
144
+ />,
145
+ );
146
+
147
+ const input = screen.getByRole('combobox');
148
+ expect(input).toHaveAttribute('id', 'legacy-input');
149
+ expect(input).toHaveAttribute('aria-describedby', 'legacy-description');
150
+ expect(input).toHaveAttribute('aria-labelledby', 'legacy-label');
151
+ expect(input).toHaveAttribute('tabindex', '-1');
152
+ });
153
+
154
+ it('counts grapheme clusters when enforcing minQueryLength', async () => {
155
+ const search = vi.fn(() => [resultItem]);
156
+ render(
157
+ <BaseTypeahead
158
+ searchSource={{search, bootstrap: () => []}}
159
+ value={null}
160
+ onChange={() => {}}
161
+ debounceMs={0}
162
+ minQueryLength={2}
163
+ />,
164
+ );
165
+
166
+ const input = screen.getByRole('combobox');
167
+ fireEvent.change(input, {target: {value: '😀'}});
168
+ await Promise.resolve();
169
+ expect(search).not.toHaveBeenCalled();
170
+ expect(input).toHaveAttribute('aria-expanded', 'false');
171
+
172
+ fireEvent.change(input, {target: {value: '😀a'}});
173
+ await waitFor(() => expect(search).toHaveBeenCalledExactlyOnceWith('😀a'));
174
+ });
175
+
176
+ it('exposes a completed empty search as a disabled listbox option', async () => {
177
+ render(
178
+ <BaseTypeahead
179
+ searchSource={emptySource}
180
+ value={null}
181
+ onChange={() => {}}
182
+ debounceMs={0}
183
+ emptySearchResultsText="No matching frameworks"
184
+ />,
185
+ );
186
+
187
+ fireEvent.change(screen.getByRole('combobox'), {
188
+ target: {value: 'none'},
189
+ });
190
+
191
+ await waitFor(() => {
192
+ expect(
193
+ screen.getByRole('option', {
194
+ hidden: true,
195
+ name: 'No matching frameworks',
196
+ }),
197
+ ).toHaveAttribute('aria-disabled', 'true');
198
+ });
199
+ });
200
+ });