@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
@@ -38,6 +38,7 @@ import {TypeaheadItem} from './TypeaheadItem';
38
38
  import {Icon} from '../Icon';
39
39
  import {Spinner} from '../Spinner';
40
40
  import {
41
+ borderVars,
41
42
  colorVars,
42
43
  spacingVars,
43
44
  radiusVars,
@@ -45,7 +46,13 @@ import {
45
46
  fontWeightVars,
46
47
  typeScaleVars,
47
48
  } from '../theme/tokens.stylex';
48
- import {getKey, groupItems, mergeProps} from '../utils';
49
+ import {
50
+ characterCount,
51
+ composeEventHandlers,
52
+ getKey,
53
+ groupItems,
54
+ mergeProps,
55
+ } from '../utils';
49
56
  import type {BaseProps} from '../BaseProps';
50
57
  import type {SearchableItem, SearchSource} from './types';
51
58
  import {themeProps} from '../utils/themeProps';
@@ -98,7 +105,7 @@ export interface BaseTypeaheadProps<T extends SearchableItem> extends Omit<
98
105
  */
99
106
  maxMenuItems?: number;
100
107
 
101
- /** Fixed dropdown width in pixels. Never shrinks below the anchor width. */
108
+ /** Requested dropdown width in pixels before viewport clamping. */
102
109
  menuWidth?: number;
103
110
 
104
111
  /**
@@ -107,8 +114,8 @@ export interface BaseTypeaheadProps<T extends SearchableItem> extends Omit<
107
114
  * for a result set that cannot be meaningful yet — and the user does not
108
115
  * see "no results" for a query that was never searched.
109
116
  *
110
- * Measured with `String.length` (UTF-16 code units), like every other
111
- * length check in the library.
117
+ * Measured by grapheme cluster, so one visible character counts once even
118
+ * when JavaScript represents it with multiple UTF-16 code units.
112
119
  *
113
120
  * @default 1 — every non-empty query is searched.
114
121
  */
@@ -130,8 +137,10 @@ export interface BaseTypeaheadProps<T extends SearchableItem> extends Omit<
130
137
  * When disabled with a reason, keeps the input focusable via `aria-disabled`
131
138
  * (instead of the native `disabled` attribute) and `readOnly` so an
132
139
  * associated disabled-reason tooltip stays discoverable by keyboard and
133
- * assistive technology. Value mutation is still blocked by the `isDisabled`
134
- * guards. Consumers (Typeahead) own the tooltip and wrapper.
140
+ * assistive technology. Query and text mutation are blocked, but an
141
+ * already-open highlighted option can still be selected with Enter after a
142
+ * transition into this state. Consumers (Typeahead) own the tooltip and
143
+ * wrapper.
135
144
  * @default false
136
145
  */
137
146
  isFocusableDisabled?: boolean;
@@ -182,17 +191,20 @@ export interface BaseTypeaheadProps<T extends SearchableItem> extends Omit<
182
191
  debounceMs?: number;
183
192
 
184
193
  /**
185
- * ID for the input element (for label association).
194
+ * Legacy input-specific alias for the native `id` prop. When provided, this
195
+ * alias takes precedence; otherwise the native prop is preserved.
186
196
  */
187
197
  inputId?: string;
188
198
 
189
199
  /**
190
- * Additional aria-describedby IDs.
200
+ * Legacy input-specific alias for native `aria-describedby`. When provided,
201
+ * this alias takes precedence; otherwise the native prop is preserved.
191
202
  */
192
203
  ariaDescribedBy?: string;
193
204
 
194
205
  /**
195
- * Additional aria-labelledby IDs.
206
+ * Legacy input-specific alias for native `aria-labelledby`. When provided,
207
+ * this alias takes precedence; otherwise the native prop is preserved.
196
208
  */
197
209
  ariaLabelledBy?: string;
198
210
 
@@ -202,12 +214,13 @@ export interface BaseTypeaheadProps<T extends SearchableItem> extends Omit<
202
214
  inputXStyle?: StyleXStyles;
203
215
 
204
216
  /**
205
- * Tab-order override for the input element. Typeahead passes `-1` while
206
- * its selected-value token is shown: the input is visually collapsed
207
- * (width 0 / opacity 0) but must stay programmatically focusable for
208
- * token edit/clear interactions, so removing it from the Tab order is
209
- * what prevents an invisible tab stop (WCAG 2.4.3 / 2.4.7). The input
210
- * remains focusable via `.focus()` regardless of this value.
217
+ * Legacy input-specific alias for native `tabIndex`. When provided, this
218
+ * alias takes precedence; otherwise the native prop is preserved. Typeahead
219
+ * passes `-1` while its selected-value token is shown: the input is visually
220
+ * collapsed (width 0 / opacity 0) but must stay programmatically focusable
221
+ * for token edit/clear interactions, so removing it from the Tab order is
222
+ * what prevents an invisible tab stop (WCAG 2.4.3 / 2.4.7). The input remains
223
+ * focusable via `.focus()` regardless of this value.
211
224
  */
212
225
  inputTabIndex?: number;
213
226
 
@@ -236,6 +249,10 @@ export interface BaseTypeaheadProps<T extends SearchableItem> extends Omit<
236
249
  // Styles
237
250
  // =============================================================================
238
251
 
252
+ const TYPEAHEAD_VIEWPORT_GUTTER = spacingVars['--spacing-4'];
253
+ const TYPEAHEAD_POSITION_AREA_MAX_INLINE_SIZE = `calc(100% - max(${TYPEAHEAD_VIEWPORT_GUTTER}, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px)))`;
254
+ const TYPEAHEAD_POSITION_AREA_MAX_INLINE_SIZE_FALLBACK = `calc(100% - ${TYPEAHEAD_VIEWPORT_GUTTER})`;
255
+
239
256
  const styles = stylex.create({
240
257
  input: {
241
258
  display: 'block',
@@ -267,7 +284,12 @@ const styles = stylex.create({
267
284
  padding: spacingVars['--spacing-1'],
268
285
  },
269
286
  popover: {
287
+ boxSizing: 'border-box',
270
288
  minWidth: 'anchor-size(width)',
289
+ maxInlineSize: stylex.firstThatWorks(
290
+ TYPEAHEAD_POSITION_AREA_MAX_INLINE_SIZE,
291
+ TYPEAHEAD_POSITION_AREA_MAX_INLINE_SIZE_FALLBACK,
292
+ ),
271
293
  },
272
294
  popoverCustomWidth: (width: number) => ({
273
295
  width: `${width}px`,
@@ -299,6 +321,18 @@ const styles = stylex.create({
299
321
  },
300
322
  itemHighlighted: {
301
323
  backgroundColor: colorVars['--color-overlay-hover'],
324
+ outlineColor: {
325
+ default: null,
326
+ '@media (forced-colors: active)': 'Highlight',
327
+ },
328
+ outlineStyle: {
329
+ default: null,
330
+ '@media (forced-colors: active)': 'solid',
331
+ },
332
+ outlineWidth: {
333
+ default: null,
334
+ '@media (forced-colors: active)': borderVars['--border-width'],
335
+ },
302
336
  },
303
337
  itemSelected: {
304
338
  fontWeight: fontWeightVars['--font-weight-medium'],
@@ -307,6 +341,11 @@ const styles = stylex.create({
307
341
  display: 'flex',
308
342
  flex: 1,
309
343
  minWidth: 0,
344
+ overflow: 'hidden',
345
+ },
346
+ defaultItem: {
347
+ minWidth: 0,
348
+ width: '100%',
310
349
  },
311
350
  emptyState: {
312
351
  padding: spacingVars['--spacing-3'],
@@ -355,7 +394,8 @@ const itemSizeStyles = stylex.create({
355
394
  * untouched state, and `hasEntriesOnFocus` owns what happens there.
356
395
  */
357
396
  function isBelowMinQueryLength(query: string, minQueryLength: number): boolean {
358
- return query.length > 0 && query.length < minQueryLength;
397
+ const length = characterCount(query);
398
+ return length > 0 && length < minQueryLength;
359
399
  }
360
400
 
361
401
  // =============================================================================
@@ -376,6 +416,7 @@ function isBelowMinQueryLength(query: string, minQueryLength: number): boolean {
376
416
  * searchSource={source}
377
417
  * value={selected}
378
418
  * onChange={setSelected}
419
+ * aria-label="Search frameworks"
379
420
  * anchorRef={wrapperRef}
380
421
  * placeholder="Search..."
381
422
  * />
@@ -407,7 +448,18 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
407
448
  onKeyDown: externalOnKeyDown,
408
449
  debounceMs = 150,
409
450
  size = 'md',
451
+ xstyle,
452
+ className,
453
+ style,
454
+ onPointerDown: onPointerDownProp,
455
+ onFocus: onFocusProp,
456
+ onBlur: onBlurProp,
457
+ id: nativeInputId,
458
+ 'aria-describedby': nativeAriaDescribedBy,
459
+ 'aria-labelledby': nativeAriaLabelledBy,
460
+ tabIndex: nativeInputTabIndex,
410
461
  ref,
462
+ ...rest
411
463
  }: BaseTypeaheadProps<T>) {
412
464
  const t = useTranslator();
413
465
  const placeholder =
@@ -416,7 +468,12 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
416
468
  emptySearchResultsTextFromProps ??
417
469
  t('@astryx.typeahead.emptySearchResults');
418
470
  const generatedId = useId();
419
- const inputId = externalInputId ?? generatedId;
471
+ // Keep the released input-specific aliases authoritative when a caller uses
472
+ // them, but do not let an omitted alias erase the equivalent native BaseProp.
473
+ const inputId = externalInputId ?? nativeInputId ?? generatedId;
474
+ const inputAriaDescribedBy = ariaDescribedBy ?? nativeAriaDescribedBy;
475
+ const inputAriaLabelledBy = ariaLabelledBy ?? nativeAriaLabelledBy;
476
+ const resolvedInputTabIndex = inputTabIndex ?? nativeInputTabIndex;
420
477
  const listboxId = useId();
421
478
 
422
479
  const inputRef = useRef<HTMLInputElement>(null);
@@ -943,6 +1000,7 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
943
1000
  return (
944
1001
  <>
945
1002
  <input
1003
+ {...rest}
946
1004
  ref={useMergedRefs(ref, inputRef, fallbackAnchorRef)}
947
1005
  id={inputId}
948
1006
  type="text"
@@ -958,13 +1016,13 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
958
1016
  }
959
1017
  aria-autocomplete="list"
960
1018
  aria-busy={isLoading || undefined}
961
- aria-describedby={ariaDescribedBy}
962
- aria-labelledby={ariaLabelledBy}
1019
+ aria-describedby={inputAriaDescribedBy}
1020
+ aria-labelledby={inputAriaLabelledBy}
963
1021
  aria-disabled={isFocusableDisabled ? 'true' : undefined}
964
- tabIndex={inputTabIndex}
1022
+ tabIndex={resolvedInputTabIndex}
965
1023
  value={query}
966
1024
  onChange={handleInputChange}
967
- onPointerDown={() => {
1025
+ onPointerDown={composeEventHandlers(() => {
968
1026
  pointerActiveRef.current = true;
969
1027
  document.addEventListener(
970
1028
  'click',
@@ -973,23 +1031,29 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
973
1031
  },
974
1032
  {once: true},
975
1033
  );
976
- }}
977
- onFocus={handleFocus}
978
- onBlur={handleBlur}
1034
+ }, onPointerDownProp)}
1035
+ onFocus={composeEventHandlers(handleFocus, onFocusProp)}
1036
+ onBlur={composeEventHandlers(handleBlur, onBlurProp)}
979
1037
  onKeyDown={handleKeyDown}
980
1038
  placeholder={placeholder}
981
1039
  // When a disabled-reason tooltip is shown the input keeps focusability
982
1040
  // via aria-disabled + readOnly instead of the native disabled
983
- // attribute; value mutation stays blocked by the isDisabled guards.
1041
+ // attribute. Query and text mutation are blocked, but an already-open
1042
+ // highlighted option can still be selected with Enter after transition.
984
1043
  disabled={isDisabled && !isFocusableDisabled}
985
1044
  readOnly={isFocusableDisabled || undefined}
986
1045
  autoFocus={hasAutoFocus}
987
1046
  data-autofocus={hasAutoFocus || undefined}
988
1047
  autoComplete="off"
989
- {...stylex.props(
990
- styles.input,
991
- isDisabled && styles.inputDisabled,
992
- inputXStyle,
1048
+ {...mergeProps(
1049
+ stylex.props(
1050
+ styles.input,
1051
+ isDisabled && styles.inputDisabled,
1052
+ inputXStyle,
1053
+ xstyle,
1054
+ ),
1055
+ className,
1056
+ style,
993
1057
  )}
994
1058
  />
995
1059
  {isLoading && busyLane == null && (
@@ -1008,6 +1072,8 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
1008
1072
  )}>
1009
1073
  {results.length === 0 && hasSearched ? (
1010
1074
  <div
1075
+ role="option"
1076
+ aria-disabled="true"
1011
1077
  {...mergeProps(
1012
1078
  themeProps('typeahead-empty-state'),
1013
1079
  stylex.props(styles.emptyState),
@@ -1040,7 +1106,10 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
1040
1106
  {renderItem ? (
1041
1107
  renderItem(item)
1042
1108
  ) : (
1043
- <TypeaheadItem item={item} />
1109
+ <TypeaheadItem
1110
+ item={item}
1111
+ xstyle={styles.defaultItem}
1112
+ />
1044
1113
  )}
1045
1114
  </span>
1046
1115
  {isSelected && (
@@ -56,6 +56,19 @@ export type {UseOverflowOptions, UseOverflowReturn} from './useOverflow';
56
56
  export {useScrollOverflow} from './useScrollOverflow';
57
57
  export type {ScrollOverflowState} from './useScrollOverflow';
58
58
 
59
+ export {useScrollableArea} from './useScrollableArea';
60
+ export type {
61
+ ScrollAxis,
62
+ ScrollAxisState,
63
+ ScrollOverscroll,
64
+ ScrollStickyContainment,
65
+ ScrollKeyboardAccess,
66
+ ScrollableAreaState,
67
+ ScrollableElementProps,
68
+ UseScrollableAreaOptions,
69
+ UseScrollableAreaResult,
70
+ } from './useScrollableArea';
71
+
59
72
  export {useScrollLock} from './useScrollLock';
60
73
 
61
74
  export {useEntryAnimation} from './useEntryAnimation';
@@ -0,0 +1,155 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** Shared geometry primitives for logical-axis scroll ownership. */
4
+
5
+ export const SCROLL_OVERFLOW_TOLERANCE = 1;
6
+
7
+ export type LogicalScrollAxis = 'inline' | 'block';
8
+ export type PhysicalScrollAxis = 'x' | 'y';
9
+
10
+ export interface LogicalAxisMapping {
11
+ inline: PhysicalScrollAxis;
12
+ block: PhysicalScrollAxis;
13
+ inlineReversed: boolean;
14
+ blockReversed: boolean;
15
+ }
16
+
17
+ export interface MeasuredScrollAxisState {
18
+ isScrollable: boolean;
19
+ atStart: boolean;
20
+ atEnd: boolean;
21
+ }
22
+
23
+ export interface LogicalOverflowGeometry {
24
+ inline: boolean;
25
+ block: boolean;
26
+ }
27
+
28
+ const INACTIVE_AXIS_STATE: MeasuredScrollAxisState = {
29
+ isScrollable: false,
30
+ atStart: true,
31
+ atEnd: true,
32
+ };
33
+
34
+ export function getLogicalAxisMapping(
35
+ writingMode: string,
36
+ direction: string,
37
+ ): LogicalAxisMapping {
38
+ const mode = writingMode.toLowerCase();
39
+ const isVertical = mode.startsWith('vertical') || mode.startsWith('sideways');
40
+ const isRtl = direction === 'rtl';
41
+
42
+ if (!isVertical) {
43
+ return {
44
+ inline: 'x',
45
+ block: 'y',
46
+ inlineReversed: isRtl,
47
+ blockReversed: false,
48
+ };
49
+ }
50
+
51
+ // sideways-lr runs its natural inline direction from bottom to top. The
52
+ // other vertical/sideways modes run top to bottom; `direction` reverses each.
53
+ const inlineReversed = mode === 'sideways-lr' ? !isRtl : isRtl;
54
+ const blockReversed = mode.endsWith('-rl');
55
+
56
+ return {
57
+ inline: 'y',
58
+ block: 'x',
59
+ inlineReversed,
60
+ blockReversed,
61
+ };
62
+ }
63
+
64
+ export function isScrollCapableOverflow(value: string): boolean {
65
+ return value === 'auto' || value === 'scroll' || value === 'overlay';
66
+ }
67
+
68
+ function physicalMetrics(
69
+ element: HTMLElement,
70
+ physicalAxis: PhysicalScrollAxis,
71
+ ): {
72
+ clientExtent: number;
73
+ contentExtent: number;
74
+ offset: number;
75
+ overflow: string;
76
+ } {
77
+ const computedStyle = getComputedStyle(element);
78
+ if (physicalAxis === 'x') {
79
+ return {
80
+ clientExtent: element.clientWidth,
81
+ contentExtent: element.scrollWidth,
82
+ offset: element.scrollLeft,
83
+ overflow: computedStyle.overflowX,
84
+ };
85
+ }
86
+ return {
87
+ clientExtent: element.clientHeight,
88
+ contentExtent: element.scrollHeight,
89
+ offset: element.scrollTop,
90
+ overflow: computedStyle.overflowY,
91
+ };
92
+ }
93
+
94
+ function isMeasurable(
95
+ element: HTMLElement,
96
+ computedStyle: CSSStyleDeclaration,
97
+ ) {
98
+ return (
99
+ element.isConnected &&
100
+ computedStyle.display !== 'none' &&
101
+ element.clientWidth > 0 &&
102
+ element.clientHeight > 0
103
+ );
104
+ }
105
+
106
+ export function measureLogicalOverflowGeometry(
107
+ element: HTMLElement,
108
+ logicalAxis: LogicalScrollAxis,
109
+ mapping: LogicalAxisMapping,
110
+ ): boolean | null {
111
+ const computedStyle = getComputedStyle(element);
112
+ if (!isMeasurable(element, computedStyle)) {
113
+ return null;
114
+ }
115
+
116
+ const physicalAxis = mapping[logicalAxis];
117
+ const clientExtent =
118
+ physicalAxis === 'x' ? element.clientWidth : element.clientHeight;
119
+ const contentExtent =
120
+ physicalAxis === 'x' ? element.scrollWidth : element.scrollHeight;
121
+ return contentExtent - clientExtent > SCROLL_OVERFLOW_TOLERANCE;
122
+ }
123
+
124
+ export function measureLogicalScrollAxis(
125
+ element: HTMLElement,
126
+ logicalAxis: LogicalScrollAxis,
127
+ mapping: LogicalAxisMapping,
128
+ ): MeasuredScrollAxisState | null {
129
+ const computedStyle = getComputedStyle(element);
130
+ if (!isMeasurable(element, computedStyle)) {
131
+ return null;
132
+ }
133
+
134
+ const physicalAxis = mapping[logicalAxis];
135
+ const metrics = physicalMetrics(element, physicalAxis);
136
+ const maxOffset = metrics.contentExtent - metrics.clientExtent;
137
+ if (
138
+ !isScrollCapableOverflow(metrics.overflow) ||
139
+ maxOffset <= SCROLL_OVERFLOW_TOLERANCE
140
+ ) {
141
+ return INACTIVE_AXIS_STATE;
142
+ }
143
+
144
+ const isReversed = mapping[`${logicalAxis}Reversed`];
145
+ const logicalOffset = Math.min(
146
+ maxOffset,
147
+ Math.max(0, isReversed ? Math.abs(metrics.offset) : metrics.offset),
148
+ );
149
+
150
+ return {
151
+ isScrollable: true,
152
+ atStart: logicalOffset <= SCROLL_OVERFLOW_TOLERANCE,
153
+ atEnd: logicalOffset >= maxOffset - SCROLL_OVERFLOW_TOLERANCE,
154
+ };
155
+ }
@@ -0,0 +1,47 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** Private DOM registry used by future axis-aware Sticky consumers. */
4
+
5
+ export interface RegisteredScrollAxisState {
6
+ isScrollable: boolean;
7
+ atStart: boolean;
8
+ atEnd: boolean;
9
+ }
10
+
11
+ export interface RegisteredScrollOwnerState {
12
+ inline: RegisteredScrollAxisState;
13
+ block: RegisteredScrollAxisState;
14
+ }
15
+
16
+ const owners = new WeakMap<HTMLElement, RegisteredScrollOwnerState>();
17
+
18
+ export function registerScrollOwner(
19
+ element: HTMLElement,
20
+ state: RegisteredScrollOwnerState,
21
+ ): void {
22
+ owners.set(element, state);
23
+ }
24
+
25
+ export function unregisterScrollOwner(element: HTMLElement): void {
26
+ owners.delete(element);
27
+ }
28
+
29
+ export function getRegisteredScrollOwnerState(
30
+ element: HTMLElement,
31
+ ): RegisteredScrollOwnerState | undefined {
32
+ return owners.get(element);
33
+ }
34
+
35
+ export function findNearestScrollOwner(
36
+ element: Element,
37
+ axis: 'inline' | 'block',
38
+ ): HTMLElement | null {
39
+ let candidate = element.parentElement;
40
+ while (candidate != null) {
41
+ if (owners.get(candidate)?.[axis].isScrollable === true) {
42
+ return candidate;
43
+ }
44
+ candidate = candidate.parentElement;
45
+ }
46
+ return null;
47
+ }
@@ -5,8 +5,8 @@
5
5
  /**
6
6
  * @file useFocusTrap.ts
7
7
  * @input Uses React useCallback, useEffect, useRef
8
- * @output Exports useFocusTrap hook for trapping focus within a container and
9
- * restoring focus to the previously-focused element on deactivation
8
+ * @output Exports useFocusTrap, the deprecated Escape-state shim, and an
9
+ * internal compatibility-signal hook
10
10
  * @position Core hook; used by dialogs, modals, date pickers
11
11
  *
12
12
  * Based on WAI-ARIA dialog pattern:
@@ -41,6 +41,25 @@ export function hasActiveFocusTrapEscape(): boolean {
41
41
  return activeEscapeTrapCount > 0;
42
42
  }
43
43
 
44
+ /**
45
+ * Keep the deprecated `hasActiveFocusTrapEscape` signal accurate for a focus
46
+ * trap whose Escape is coordinated by another registration in the shared
47
+ * stack. This hook is intentionally internal (not re-exported from `hooks`): it
48
+ * preserves the released read-only compatibility signal without creating a
49
+ * second dismissal owner.
50
+ */
51
+ export function useFocusTrapEscapeCompatibilitySignal(isActive: boolean): void {
52
+ useEffect(() => {
53
+ if (!isActive) {
54
+ return;
55
+ }
56
+ activeEscapeTrapCount += 1;
57
+ return () => {
58
+ activeEscapeTrapCount -= 1;
59
+ };
60
+ }, [isActive]);
61
+ }
62
+
44
63
  /**
45
64
  * Whether an element is currently perceivable/focusable — excludes ones hidden
46
65
  * via `display:none`/`visibility:hidden` or inside an `inert`/`hidden` subtree,
@@ -213,15 +232,7 @@ export function useFocusTrap<T extends HTMLElement = HTMLElement>(
213
232
  getContainer: () => containerRef.current,
214
233
  });
215
234
 
216
- useEffect(() => {
217
- if (!isEscapeTrap) {
218
- return;
219
- }
220
- activeEscapeTrapCount += 1;
221
- return () => {
222
- activeEscapeTrapCount -= 1;
223
- };
224
- }, [isEscapeTrap]);
235
+ useFocusTrapEscapeCompatibilitySignal(isEscapeTrap);
225
236
 
226
237
  /**
227
238
  * Focus the first focusable element.
@@ -9,9 +9,10 @@
9
9
  * @position Testing; guards the deprecated public shim in useFocusTrap.ts
10
10
  *
11
11
  * The shim is public API. Its answer must not change now that Escape moved to
12
- * the shared stack: `BottomSheetSwitcher` gates its own dismissal on it, so a
13
- * shim that also counts tooltips, hover cards and dialogs tells the sheet a
14
- * trap is above it when none is, and the sheet stops closing.
12
+ * the shared stack: existing consumers still observe whether an
13
+ * Escape-participating focus trap is active. Families that never trapped focus
14
+ * stay out of this count; BottomSheetSwitcher preserves its historical modal
15
+ * signal without registering a second dismissal owner.
15
16
  *
16
17
  * SYNC: When useFocusTrap.ts changes, update tests to match new behavior
17
18
  */
@@ -0,0 +1,108 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').HookDoc} */
4
+ export const docs = {
5
+ name: 'useScrollableArea',
6
+ displayName: 'useScrollableArea',
7
+ keywords: [
8
+ 'scroll',
9
+ 'overflow',
10
+ 'logical axis',
11
+ 'keyboard',
12
+ 'overscroll',
13
+ 'sticky',
14
+ 'resize',
15
+ ],
16
+ params: [
17
+ {
18
+ name: 'options',
19
+ type: 'UseScrollableAreaOptions',
20
+ description:
21
+ 'Logical scroll intent, keyboard owner, overscroll policy, and fitting Sticky containment.',
22
+ required: true,
23
+ },
24
+ ],
25
+ returns: [
26
+ {
27
+ name: 'getViewportProps',
28
+ type: '<E extends HTMLElement>(props?: ScrollableElementProps<E>) => ScrollableElementProps<E>',
29
+ description:
30
+ 'Consumes caller viewport props, xstyle, and refs; composes fitting/active overflow, Sticky containment, accessibility, chaining, and owner registration.',
31
+ },
32
+ {
33
+ name: 'getContentProps',
34
+ type: '<E extends HTMLElement>(props?: ScrollableElementProps<E>) => ScrollableElementProps<E>',
35
+ description:
36
+ 'Composes caller content-box props and refs with content observation.',
37
+ },
38
+ {
39
+ name: 'state',
40
+ type: 'ScrollableAreaState',
41
+ description:
42
+ 'Stable inline and block effective-scroll and logical-edge state.',
43
+ },
44
+ ],
45
+ usage: {
46
+ description:
47
+ 'Adds canonical axis-aware scroll behavior to structure owned by the caller. An axis is effective only when its computed overflow is scroll-capable and geometry exceeds the shared 1px tolerance. Both viewport and content boxes are observed.',
48
+ bestPractices: [
49
+ {
50
+ guidance: true,
51
+ description:
52
+ 'Pass already-resolved props and refs through both prop getters, then spread each returned object once.',
53
+ },
54
+ {
55
+ guidance: true,
56
+ description:
57
+ 'Use viewport keyboard ownership only when the viewport itself should enter the tab order; provide a concise accessible label.',
58
+ },
59
+ {
60
+ guidance: true,
61
+ description:
62
+ 'Use content keyboard ownership when an existing focusable descendant gives keyboard users access to all overflowed content.',
63
+ },
64
+ {
65
+ guidance: true,
66
+ description:
67
+ 'Pass caller `xstyle` through `getViewportProps`; the getter composes it with fitting clip, active overflow, and Sticky containment.',
68
+ },
69
+ {
70
+ guidance: false,
71
+ description:
72
+ 'Attach only the viewport getter. A real observed content box is required for live overflow changes.',
73
+ },
74
+ ],
75
+ },
76
+ relatedComponents: ['ScrollableArea'],
77
+ relatedHooks: ['useScrollOverflow'],
78
+ importPath: '@astryxdesign/core/hooks',
79
+ category: 'layout',
80
+ };
81
+
82
+ /** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */
83
+ export const docsDense = {
84
+ description:
85
+ 'Composes logical-axis scrolling into caller-owned viewport/content elements with stable effective-axis and edge state.',
86
+ paramDescriptions: {
87
+ options:
88
+ 'axis, keyboard owner, allow/contain overscroll policy, and fitting Sticky containment.',
89
+ },
90
+ returnDescriptions: {
91
+ getViewportProps:
92
+ 'safe viewport prop/ref/xstyle composition with behavior-owned overflow, accessibility, and chaining.',
93
+ getContentProps: 'safe observed content-box prop/ref composition.',
94
+ state: 'inline/block isScrollable, atStart, and atEnd state.',
95
+ },
96
+ usage: {
97
+ description:
98
+ 'Use for existing structures that need ScrollableArea behavior without another wrapper.',
99
+ bestPractices: [
100
+ {
101
+ guidance: true,
102
+ description:
103
+ 'Spread each prop getter result once on its owner element.',
104
+ },
105
+ {guidance: false, description: 'Skip the real content-box getter.'},
106
+ ],
107
+ },
108
+ };