@astryxdesign/core 0.5.2-canary.c9c8564 → 0.5.2-canary.e4f8e4e

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 (146) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +3 -1
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +4 -3
  4. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +2 -2
  5. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetEdgeTint.js +7 -10
  7. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  8. package/dist/BottomSheet/BottomSheetSwitcher.js +1 -1
  9. package/dist/Button/Button.d.ts +2 -1
  10. package/dist/Button/Button.d.ts.map +1 -1
  11. package/dist/Button/Button.js +11 -4
  12. package/dist/Carousel/Carousel.d.ts.map +1 -1
  13. package/dist/Carousel/Carousel.js +2 -2
  14. package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
  15. package/dist/Chat/ChatToolCalls.js +34 -17
  16. package/dist/ContextMenu/ContextMenu.d.ts +14 -3
  17. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  18. package/dist/ContextMenu/ContextMenu.js +149 -26
  19. package/dist/ContextMenu/index.d.ts +1 -0
  20. package/dist/ContextMenu/index.d.ts.map +1 -1
  21. package/dist/DropdownMenu/DropdownMenu.d.ts +24 -5
  22. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  23. package/dist/DropdownMenu/DropdownMenu.js +340 -26
  24. package/dist/DropdownMenu/DropdownMenuContext.d.ts +1 -1
  25. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  26. package/dist/DropdownMenu/DropdownMenuItem.d.ts.map +1 -1
  27. package/dist/DropdownMenu/DropdownMenuItem.js +1 -1
  28. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +6 -2
  29. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  30. package/dist/DropdownMenu/DropdownMenuSubMenu.js +43 -14
  31. package/dist/DropdownMenu/MenuBottomSheet.d.ts +20 -0
  32. package/dist/DropdownMenu/MenuBottomSheet.d.ts.map +1 -0
  33. package/dist/DropdownMenu/MenuBottomSheet.js +36 -0
  34. package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts +10 -0
  35. package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts.map +1 -0
  36. package/dist/DropdownMenu/MenuBottomSheetActionList.js +111 -0
  37. package/dist/DropdownMenu/index.d.ts +2 -1
  38. package/dist/DropdownMenu/index.d.ts.map +1 -1
  39. package/dist/DropdownMenu/menuWidth.d.ts +14 -0
  40. package/dist/DropdownMenu/menuWidth.d.ts.map +1 -0
  41. package/dist/DropdownMenu/menuWidth.js +35 -0
  42. package/dist/DropdownMenu/useMenuOverflow.d.ts +10 -0
  43. package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -0
  44. package/dist/DropdownMenu/useMenuOverflow.js +60 -0
  45. package/dist/MoreMenu/MoreMenu.d.ts +8 -2
  46. package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
  47. package/dist/MoreMenu/MoreMenu.js +2 -0
  48. package/dist/MultiSelector/MultiSelector.d.ts +11 -1
  49. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  50. package/dist/MultiSelector/MultiSelector.js +97 -68
  51. package/dist/MultiSelector/index.d.ts +1 -1
  52. package/dist/MultiSelector/index.d.ts.map +1 -1
  53. package/dist/Popover/Popover.d.ts +10 -6
  54. package/dist/Popover/Popover.d.ts.map +1 -1
  55. package/dist/Popover/Popover.js +141 -16
  56. package/dist/Popover/usePopover.d.ts +4 -0
  57. package/dist/Popover/usePopover.d.ts.map +1 -1
  58. package/dist/Popover/usePopover.js +52 -5
  59. package/dist/Selector/Selector.d.ts +10 -0
  60. package/dist/Selector/Selector.d.ts.map +1 -1
  61. package/dist/Selector/Selector.js +106 -80
  62. package/dist/Selector/SelectorBottomSheet.d.ts +21 -0
  63. package/dist/Selector/SelectorBottomSheet.d.ts.map +1 -0
  64. package/dist/Selector/SelectorBottomSheet.js +83 -0
  65. package/dist/Selector/index.d.ts +1 -1
  66. package/dist/Selector/index.d.ts.map +1 -1
  67. package/dist/Selector/selectorPresentation.stylex.d.ts +16 -0
  68. package/dist/Selector/selectorPresentation.stylex.d.ts.map +1 -0
  69. package/dist/Selector/selectorPresentation.stylex.js +21 -0
  70. package/dist/Selector/useSelectorPresentation.d.ts +32 -0
  71. package/dist/Selector/useSelectorPresentation.d.ts.map +1 -0
  72. package/dist/Selector/useSelectorPresentation.js +90 -0
  73. package/dist/Toast/useToastGesture.d.ts.map +1 -1
  74. package/dist/Toast/useToastGesture.js +46 -9
  75. package/dist/astryx.css +19 -1
  76. package/dist/hooks/useAdaptivePresentation.d.ts +5 -0
  77. package/dist/hooks/useAdaptivePresentation.d.ts.map +1 -0
  78. package/dist/hooks/useAdaptivePresentation.js +19 -0
  79. package/dist/hooks/useFocusReturnVisibility.d.ts +7 -0
  80. package/dist/hooks/useFocusReturnVisibility.d.ts.map +1 -0
  81. package/dist/hooks/useFocusReturnVisibility.js +35 -0
  82. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  83. package/dist/hooks/useFocusTrap.js +12 -0
  84. package/locales/en.json +20 -0
  85. package/locales/pseudo.json +15 -0
  86. package/package.json +2 -2
  87. package/src/BottomSheet/BottomSheet.doc.mjs +6 -0
  88. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  89. package/src/BottomSheet/BottomSheet.tsx +7 -4
  90. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -6
  91. package/src/BottomSheet/BottomSheetEdgeTint.tsx +7 -10
  92. package/src/BottomSheet/BottomSheetSwitcher.tsx +1 -2
  93. package/src/Button/Button.doc.mjs +56 -0
  94. package/src/Button/Button.test.tsx +10 -0
  95. package/src/Button/Button.tsx +13 -5
  96. package/src/ButtonGroup/ButtonGroup.doc.mjs +47 -0
  97. package/src/Carousel/Carousel.test.tsx +83 -0
  98. package/src/Carousel/Carousel.tsx +8 -2
  99. package/src/Chat/ChatToolCalls.test.tsx +44 -0
  100. package/src/Chat/ChatToolCalls.tsx +36 -15
  101. package/src/ContextMenu/ContextMenu.doc.mjs +13 -1
  102. package/src/ContextMenu/ContextMenu.test.tsx +131 -1
  103. package/src/ContextMenu/ContextMenu.tsx +199 -32
  104. package/src/ContextMenu/index.ts +1 -0
  105. package/src/DropdownMenu/DropdownMenu.doc.mjs +201 -40
  106. package/src/DropdownMenu/DropdownMenu.test.tsx +518 -7
  107. package/src/DropdownMenu/DropdownMenu.tsx +459 -28
  108. package/src/DropdownMenu/DropdownMenuContext.tsx +1 -1
  109. package/src/DropdownMenu/DropdownMenuItem.tsx +2 -0
  110. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +4 -2
  111. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +124 -0
  112. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +72 -17
  113. package/src/DropdownMenu/MenuBottomSheet.tsx +46 -0
  114. package/src/DropdownMenu/MenuBottomSheetActionList.tsx +141 -0
  115. package/src/DropdownMenu/index.ts +2 -0
  116. package/src/DropdownMenu/menuWidth.ts +51 -0
  117. package/src/DropdownMenu/useMenuOverflow.ts +77 -0
  118. package/src/IconButton/IconButton.doc.mjs +38 -0
  119. package/src/MoreMenu/MoreMenu.doc.mjs +17 -0
  120. package/src/MoreMenu/MoreMenu.test.tsx +47 -0
  121. package/src/MoreMenu/MoreMenu.tsx +13 -1
  122. package/src/MultiSelector/MultiSelector.doc.mjs +16 -0
  123. package/src/MultiSelector/MultiSelector.test.tsx +127 -0
  124. package/src/MultiSelector/MultiSelector.tsx +121 -73
  125. package/src/MultiSelector/index.ts +1 -0
  126. package/src/Popover/Popover.doc.mjs +11 -11
  127. package/src/Popover/Popover.test.tsx +397 -2
  128. package/src/Popover/Popover.tsx +228 -21
  129. package/src/Popover/usePopover.tsx +92 -15
  130. package/src/ProgressBar/ProgressBar.spec.md +208 -0
  131. package/src/SegmentedControl/SegmentedControl.doc.mjs +56 -0
  132. package/src/Selector/Selector.doc.mjs +12 -0
  133. package/src/Selector/Selector.test.tsx +135 -0
  134. package/src/Selector/Selector.tsx +141 -82
  135. package/src/Selector/SelectorBottomSheet.tsx +97 -0
  136. package/src/Selector/index.ts +1 -0
  137. package/src/Selector/selectorPresentation.stylex.ts +20 -0
  138. package/src/Selector/useSelectorPresentation.ts +147 -0
  139. package/src/Toast/ToastViewport.test.tsx +118 -4
  140. package/src/Toast/useToastGesture.ts +61 -8
  141. package/src/ToggleButton/ToggleButton.doc.mjs +56 -0
  142. package/src/hooks/useAdaptivePresentation.ts +35 -0
  143. package/src/hooks/useFocusReturnVisibility.ts +45 -0
  144. package/src/hooks/useFocusTrap.test.tsx +40 -0
  145. package/src/hooks/useFocusTrap.ts +13 -0
  146. package/src/theme/themingTargets.test.ts +91 -25
@@ -29,7 +29,6 @@ import React, {
29
29
  type ReactNode,
30
30
  } from 'react';
31
31
  import * as stylex from '@stylexjs/stylex';
32
- import {usePopoverInternal} from '../Popover/usePopover';
33
32
  import {useTooltip} from '../Tooltip';
34
33
  import {Icon, renderIconSlot, type IconType} from '../Icon';
35
34
  import {useIndicator} from '../Indicator';
@@ -86,6 +85,10 @@ import {groupStyles} from '../InputGroup/groupStyles';
86
85
  import {useInputGroup} from '../InputGroup/InputGroupContext';
87
86
  import {VisuallyHidden} from '../VisuallyHidden';
88
87
  import {useTranslator} from '../i18n';
88
+ import type {AdaptivePresentation} from '../hooks/useAdaptivePresentation';
89
+ import {SelectorBottomSheet} from './SelectorBottomSheet';
90
+ import {useSelectorPresentation} from './useSelectorPresentation';
91
+ import {selectorPresentationStyles} from './selectorPresentation.stylex';
89
92
 
90
93
  const styles = stylex.create({
91
94
  // Trigger container — the enhanced click target wrapping the combobox button and clear button as siblings
@@ -265,6 +268,7 @@ const styles = stylex.create({
265
268
  boxSizing: 'border-box',
266
269
  maxHeight: '300px',
267
270
  overflowY: 'auto',
271
+ outline: 'none',
268
272
  paddingBlock: spacingVars['--spacing-1'],
269
273
  paddingInline: spacingVars['--spacing-1'],
270
274
  opacity: 1,
@@ -450,6 +454,8 @@ export type SelectorSize = 'sm' | 'md' | 'lg';
450
454
 
451
455
  export type SelectorVariant = 'input' | 'ghost';
452
456
 
457
+ export type SelectorPresentation = AdaptivePresentation;
458
+
453
459
  export type SelectorStatusType = 'warning' | 'error' | 'success';
454
460
 
455
461
  export interface SelectorStatus {
@@ -672,6 +678,15 @@ interface SelectorPropsBase<
672
678
  */
673
679
  placement?: LayerPlacement;
674
680
 
681
+ /**
682
+ * How the option list is presented.
683
+ * - 'popover': anchored to the trigger
684
+ * - 'bottom-sheet': modal sheet suited to compact touch screens
685
+ * - 'adaptive': bottom sheet on compact coarse-pointer screens, otherwise popover
686
+ * @default 'popover'
687
+ */
688
+ presentation?: SelectorPresentation;
689
+
675
690
  /**
676
691
  * Whether the dropdown starts open on mount.
677
692
  * Useful for showcases and previews.
@@ -811,12 +826,14 @@ export function Selector<T extends SelectorOptionType>(
811
826
  emptyText: emptyTextFromProps,
812
827
  emptySearchText: emptySearchTextFromProps,
813
828
  placement,
829
+ presentation = 'popover',
814
830
  isDefaultOpen = false,
815
831
  'data-testid': testId,
816
832
  width,
817
833
  xstyle,
818
834
  className,
819
835
  style,
836
+ onFocus,
820
837
  hasClear: hasClearProp,
821
838
  id,
822
839
  ...rest
@@ -949,27 +966,31 @@ export function Selector<T extends SelectorOptionType>(
949
966
  // Clear any lingering result count when the popover closes so stale status
950
967
  // text does not linger in the a11y tree.
951
968
  announce('');
952
- triggerRef.current?.focus();
953
969
  }, [announce]);
954
970
 
955
- const popover = usePopoverInternal({
971
+ const surface = useSelectorPresentation({
972
+ presentation,
956
973
  onHide: handleLayerHide,
957
- hasLightDismiss: true,
958
- hasCloseButton: false,
959
- hasAutoFocus: false,
960
- // The popup's own role="listbox" is the exposed semantics; the trigger
961
- // keeps DOM focus, so wrapping it in a modal dialog would misrepresent it.
962
- role: 'none',
963
- // The theme target belongs on the SURFACE that paints the popup, which
964
- // `usePopover` owns — not on the scrolling list inside it.
965
- surfaceTarget: 'selector-popup',
974
+ triggerRef,
975
+ popoverOptions: {
976
+ hasLightDismiss: true,
977
+ hasCloseButton: false,
978
+ hasAutoFocus: false,
979
+ // The popup's own role="listbox" is the exposed semantics; the trigger
980
+ // keeps DOM focus, so wrapping it in a modal dialog would misrepresent it.
981
+ role: 'none',
982
+ // The theme target belongs on the SURFACE that paints the popup, which
983
+ // `usePopover` owns — not on the scrolling list inside it.
984
+ surfaceTarget: 'selector-popup',
985
+ },
966
986
  });
987
+ const {popover} = surface;
967
988
  const keepOpenProps = useKeepLayerOpenProps(popover.id, popover.isOpen);
968
989
 
969
990
  // Open dropdown on mount when isDefaultOpen is true
970
991
  useEffect(() => {
971
992
  if (isDefaultOpen) {
972
- popover.show();
993
+ surface.show();
973
994
  }
974
995
  // eslint-disable-next-line @eslint-react/exhaustive-deps -- mount-only: isDefaultOpen is not reactive
975
996
  }, []);
@@ -1022,7 +1043,7 @@ export function Selector<T extends SelectorOptionType>(
1022
1043
  const announcedEmptyRef = useRef<string | null>(null);
1023
1044
  useEffect(() => {
1024
1045
  const isPanelEmpty =
1025
- popover.isOpen &&
1046
+ surface.isOpen &&
1026
1047
  !isLoading &&
1027
1048
  searchQuery === '' &&
1028
1049
  selectableItems.length === 0;
@@ -1036,7 +1057,7 @@ export function Selector<T extends SelectorOptionType>(
1036
1057
  announcedEmptyRef.current = emptyAnnouncement;
1037
1058
  announce(emptyAnnouncement);
1038
1059
  }, [
1039
- popover.isOpen,
1060
+ surface.isOpen,
1040
1061
  isLoading,
1041
1062
  searchQuery,
1042
1063
  selectableItems.length,
@@ -1118,17 +1139,17 @@ export function Selector<T extends SelectorOptionType>(
1118
1139
  onItemMouseEnter,
1119
1140
  } = useCombobox({
1120
1141
  selectableItems: filteredItems,
1121
- wasJustDismissed: popover.wasJustDismissed,
1142
+ wasJustDismissed: surface.wasJustDismissed,
1122
1143
  // The optimistic value, not the raw prop: with a pending changeAction the
1123
1144
  // prop still holds the old selection, so the popup would open with the
1124
1145
  // highlight on it and Delete/Backspace could clear a value the action has
1125
1146
  // already replaced.
1126
1147
  value: optimisticValue,
1127
1148
  isDisabled,
1128
- isOpen: popover.isOpen,
1149
+ isOpen: surface.isOpen,
1129
1150
  hasSearch,
1130
1151
  onOpen: useCallback(() => {
1131
- popover.show();
1152
+ surface.show();
1132
1153
  if (hasSearch) {
1133
1154
  requestAnimationFrame(() => {
1134
1155
  const input = searchRef.current;
@@ -1140,8 +1161,8 @@ export function Selector<T extends SelectorOptionType>(
1140
1161
  }
1141
1162
  });
1142
1163
  }
1143
- }, [popover, hasSearch]),
1144
- onClose: popover.hide,
1164
+ }, [surface, hasSearch]),
1165
+ onClose: surface.hide,
1145
1166
  onSelect: commitValue,
1146
1167
  onClear: hasClear ? clearValue : undefined,
1147
1168
  onSearchSeed: appendSearchQuery,
@@ -1161,10 +1182,10 @@ export function Selector<T extends SelectorOptionType>(
1161
1182
  // cycling on the first match. -1 means nothing is selected or highlighted,
1162
1183
  // which the hook reads as "search from the top".
1163
1184
  getCurrentIndex: () =>
1164
- popover.isOpen ? highlightedIndex : selectedItemIndex,
1185
+ surface.isOpen ? highlightedIndex : selectedItemIndex,
1165
1186
  onMatch: index => {
1166
1187
  const item = selectableItems[index];
1167
- if (popover.isOpen) {
1188
+ if (surface.isOpen) {
1168
1189
  setHighlightedIndex(index);
1169
1190
  } else if (item.value !== optimisticValue) {
1170
1191
  commitValue(item.value);
@@ -1191,13 +1212,13 @@ export function Selector<T extends SelectorOptionType>(
1191
1212
  // cursor walks off-screen once navigation passes the visible window. Mirrors
1192
1213
  // CommandPaletteItem's scrollIntoView({block: 'nearest'}) behavior.
1193
1214
  useEffect(() => {
1194
- if (!popover.isOpen || highlightedIndex < 0) {
1215
+ if (!surface.isOpen || highlightedIndex < 0) {
1195
1216
  return;
1196
1217
  }
1197
1218
  document
1198
1219
  .getElementById(getItemId(highlightedIndex))
1199
1220
  ?.scrollIntoView?.({block: 'nearest'});
1200
- }, [popover.isOpen, highlightedIndex, getItemId]);
1221
+ }, [surface.isOpen, highlightedIndex, getItemId]);
1201
1222
 
1202
1223
  // Handle clear button click
1203
1224
  const handleClear = useCallback(
@@ -1229,17 +1250,21 @@ export function Selector<T extends SelectorOptionType>(
1229
1250
  label: t('@astryx.selector.searchOptions'),
1230
1251
  })}
1231
1252
  {...themeProps('selector-search')}
1232
- xstyle={variant !== 'ghost' && styles.searchRowInput}
1253
+ xstyle={
1254
+ surface.activePresentation === 'popover' &&
1255
+ variant !== 'ghost' &&
1256
+ styles.searchRowInput
1257
+ }
1233
1258
  // When hasSearch is set, focus moves into this input on open, so it —
1234
1259
  // not the trigger — must be the combobox that reports the highlighted
1235
1260
  // option via aria-activedescendant (comboboxes-4). A bare searchbox
1236
1261
  // left the highlight silent to screen readers.
1237
1262
  role="combobox"
1238
- aria-expanded={popover.isOpen}
1263
+ aria-expanded={surface.isOpen}
1239
1264
  aria-controls={listboxId}
1240
1265
  aria-autocomplete="list"
1241
1266
  aria-activedescendant={
1242
- popover.isOpen && highlightedIndex >= 0
1267
+ surface.isOpen && highlightedIndex >= 0
1243
1268
  ? getItemId(highlightedIndex)
1244
1269
  : undefined
1245
1270
  }
@@ -1302,7 +1327,8 @@ export function Selector<T extends SelectorOptionType>(
1302
1327
  searchPlaceholder,
1303
1328
  handleSearchChange,
1304
1329
  onKeyDown,
1305
- popover.isOpen,
1330
+ surface.isOpen,
1331
+ surface.activePresentation,
1306
1332
  highlightedIndex,
1307
1333
  getItemId,
1308
1334
  variant,
@@ -1554,6 +1580,79 @@ export function Selector<T extends SelectorOptionType>(
1554
1580
  </>
1555
1581
  );
1556
1582
 
1583
+ const panelContent = hasSearch ? (
1584
+ <div>
1585
+ {renderSearch()}
1586
+ <Divider />
1587
+ <div
1588
+ ref={listboxRef}
1589
+ id={listboxId}
1590
+ role="listbox"
1591
+ aria-labelledby={triggerId}
1592
+ {...stylex.props(
1593
+ styles.dropdown,
1594
+ surface.activePresentation === 'popover' &&
1595
+ variant !== 'ghost' &&
1596
+ styles.dropdownInput,
1597
+ )}>
1598
+ {renderOptions()}
1599
+ </div>
1600
+ </div>
1601
+ ) : (
1602
+ <div
1603
+ ref={listboxRef}
1604
+ id={listboxId}
1605
+ role="listbox"
1606
+ aria-labelledby={triggerId}
1607
+ aria-activedescendant={
1608
+ surface.isOpen && highlightedIndex >= 0
1609
+ ? getItemId(highlightedIndex)
1610
+ : undefined
1611
+ }
1612
+ tabIndex={surface.activePresentation === 'bottom-sheet' ? 0 : undefined}
1613
+ onKeyDown={
1614
+ surface.activePresentation === 'bottom-sheet'
1615
+ ? handleTriggerKeyDown
1616
+ : undefined
1617
+ }
1618
+ {...stylex.props(
1619
+ styles.dropdown,
1620
+ surface.activePresentation === 'popover' &&
1621
+ variant !== 'ghost' &&
1622
+ styles.dropdownInput,
1623
+ surface.activePresentation === 'popover' &&
1624
+ !isPositioned &&
1625
+ styles.dropdownHidden,
1626
+ )}>
1627
+ {renderOptions()}
1628
+ </div>
1629
+ );
1630
+
1631
+ const selectionSurface =
1632
+ surface.activePresentation === 'bottom-sheet' ? (
1633
+ <SelectorBottomSheet
1634
+ isOpen={surface.isSheetOpen}
1635
+ onOpenChange={surface.onSheetOpenChange}
1636
+ finalFocusRef={triggerRef}
1637
+ initialFocusRef={hasSearch ? searchRef : listboxRef}
1638
+ label={label}>
1639
+ {panelContent}
1640
+ </SelectorBottomSheet>
1641
+ ) : (
1642
+ popover.render(panelContent, {
1643
+ placement: popoverPlacement,
1644
+ alignment: 'start',
1645
+ // The system's standard menu clearance, except in overlay mode:
1646
+ // there the measured negative margin owns the block geometry and
1647
+ // the menu is meant to sit on the trigger, not clear it.
1648
+ offset: shouldOverlaySelectedItem
1649
+ ? undefined
1650
+ : spacingVars['--spacing-1'],
1651
+ xstyle: [styles.popover, layerAnimations[popoverPlacement]],
1652
+ style: popoverOffsetStyle,
1653
+ })
1654
+ );
1655
+
1557
1656
  const selectorContent = (
1558
1657
  <>
1559
1658
  <div
@@ -1581,6 +1680,8 @@ export function Selector<T extends SelectorOptionType>(
1581
1680
  variant === 'ghost' && styles.triggerGhost,
1582
1681
  variant === 'ghost' && interactionOverlayStyles.backgroundImage,
1583
1682
  variant === 'ghost' && focusOutlineStyles.focusWithin,
1683
+ surface.isTriggerFocusRingSuppressed &&
1684
+ selectorPresentationStyles.pointerRestoredFocus,
1584
1685
  isDisabled && inputWrapperStyles.disabled,
1585
1686
  variant === 'ghost' && isDisabled && styles.triggerGhostDisabled,
1586
1687
  !selectedItem && styles.triggerPlaceholder,
@@ -1612,11 +1713,13 @@ export function Selector<T extends SelectorOptionType>(
1612
1713
  // plain button that opens the listbox — not a second combobox.
1613
1714
  role={hasSearch ? undefined : 'combobox'}
1614
1715
  {...rest}
1615
- aria-haspopup="listbox"
1616
- aria-expanded={popover.isOpen}
1716
+ aria-haspopup={
1717
+ surface.activePresentation === 'bottom-sheet' ? 'dialog' : 'listbox'
1718
+ }
1719
+ aria-expanded={surface.isOpen}
1617
1720
  aria-controls={listboxId}
1618
1721
  aria-activedescendant={
1619
- !hasSearch && popover.isOpen && highlightedIndex >= 0
1722
+ !hasSearch && surface.isOpen && highlightedIndex >= 0
1620
1723
  ? getItemId(highlightedIndex)
1621
1724
  : undefined
1622
1725
  }
@@ -1631,6 +1734,10 @@ export function Selector<T extends SelectorOptionType>(
1631
1734
  disabled={isDisabled && !showsDisabledMessage}
1632
1735
  aria-disabled={showsDisabledMessage ? 'true' : undefined}
1633
1736
  onKeyDown={handleTriggerKeyDown}
1737
+ onFocus={event => {
1738
+ onFocus?.(event);
1739
+ surface.onTriggerFocus(event);
1740
+ }}
1634
1741
  tabIndex={isDisabled && !showsDisabledMessage ? -1 : 0}
1635
1742
  {...stylex.props(
1636
1743
  styles.trigger,
@@ -1702,7 +1809,7 @@ export function Selector<T extends SelectorOptionType>(
1702
1809
  xstyle={[
1703
1810
  styles.triggerIcon,
1704
1811
  styles.triggerIconRotation,
1705
- popover.isOpen && styles.triggerIconOpen,
1812
+ surface.isOpen && styles.triggerIconOpen,
1706
1813
  ]}
1707
1814
  // Stable theme target on the chevron glyph itself, so a theme can
1708
1815
  // restyle just this icon (color, size, hover) — and its
@@ -1710,61 +1817,13 @@ export function Selector<T extends SelectorOptionType>(
1710
1817
  // @layer astryx-theme win over the icon's own base color/size,
1711
1818
  // which a button-level target could not reach.
1712
1819
  {...themeProps('selector-indicator-icon', {
1713
- state: popover.isOpen ? 'expanded' : 'collapsed',
1820
+ state: surface.isOpen ? 'expanded' : 'collapsed',
1714
1821
  })}
1715
1822
  />
1716
1823
  )}
1717
1824
  </div>
1718
1825
 
1719
- {popover.render(
1720
- hasSearch ? (
1721
- <div>
1722
- {renderSearch()}
1723
- {/*
1724
- Separates the header from the options and spans the panel: the
1725
- search row and the listbox each hold their own inline padding,
1726
- the line does not, so it reads as the panel's own edge.
1727
- */}
1728
- <Divider />
1729
- <div
1730
- ref={listboxRef}
1731
- id={listboxId}
1732
- role="listbox"
1733
- aria-labelledby={triggerId}
1734
- {...stylex.props(
1735
- styles.dropdown,
1736
- variant !== 'ghost' && styles.dropdownInput,
1737
- )}>
1738
- {renderOptions()}
1739
- </div>
1740
- </div>
1741
- ) : (
1742
- <div
1743
- ref={listboxRef}
1744
- id={listboxId}
1745
- role="listbox"
1746
- aria-labelledby={triggerId}
1747
- {...stylex.props(
1748
- styles.dropdown,
1749
- variant !== 'ghost' && styles.dropdownInput,
1750
- !isPositioned && styles.dropdownHidden,
1751
- )}>
1752
- {renderOptions()}
1753
- </div>
1754
- ),
1755
- {
1756
- placement: popoverPlacement,
1757
- alignment: 'start',
1758
- // The system's standard menu clearance, except in overlay mode:
1759
- // there the measured negative margin owns the block geometry and
1760
- // the menu is meant to sit on the trigger, not clear it.
1761
- offset: shouldOverlaySelectedItem
1762
- ? undefined
1763
- : spacingVars['--spacing-1'],
1764
- xstyle: [styles.popover, layerAnimations[popoverPlacement]],
1765
- style: popoverOffsetStyle,
1766
- },
1767
- )}
1826
+ {selectionSurface}
1768
1827
 
1769
1828
  {showStatusTooltip && statusTooltip.renderTooltip(status?.message ?? '')}
1770
1829
 
@@ -0,0 +1,97 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file SelectorBottomSheet.tsx
7
+ * @input Uses BottomSheet, Section, and Heading
8
+ * @output Shared mobile listbox sheet for Selector and MultiSelector
9
+ * @position Internal presentation primitive for selection controls
10
+ */
11
+
12
+ import {lazy, Suspense, useEffect, type ReactNode, type RefObject} from 'react';
13
+ import * as stylex from '@stylexjs/stylex';
14
+ import {Heading} from '../Heading';
15
+ import {Section} from '../Section';
16
+ import {spacingVars} from '../theme/tokens.stylex';
17
+
18
+ const LazyBottomSheet = lazy(async () =>
19
+ import('../BottomSheet/BottomSheet').then(module => ({
20
+ default: module.BottomSheet,
21
+ })),
22
+ );
23
+
24
+ const styles = stylex.create({
25
+ content: {
26
+ width: '100%',
27
+ },
28
+ heading: {
29
+ marginBlockEnd: spacingVars['--spacing-2'],
30
+ marginInline: spacingVars['--spacing-3'],
31
+ },
32
+ });
33
+
34
+ interface SelectorBottomSheetProps {
35
+ children: ReactNode;
36
+ finalFocusRef: RefObject<HTMLElement | null>;
37
+ initialFocusRef: RefObject<HTMLElement | null>;
38
+ isOpen: boolean;
39
+ label: string;
40
+ onOpenChange: (isOpen: boolean) => void;
41
+ }
42
+
43
+ function SelectorBottomSheetInitialFocus({
44
+ isOpen,
45
+ targetRef,
46
+ }: {
47
+ isOpen: boolean;
48
+ targetRef: RefObject<HTMLElement | null>;
49
+ }) {
50
+ useEffect(() => {
51
+ if (!isOpen) {
52
+ return;
53
+ }
54
+ const frame = requestAnimationFrame(() => {
55
+ targetRef.current?.focus({preventScroll: true});
56
+ });
57
+ return () => cancelAnimationFrame(frame);
58
+ }, [isOpen, targetRef]);
59
+
60
+ return null;
61
+ }
62
+
63
+ export function SelectorBottomSheet({
64
+ children,
65
+ finalFocusRef,
66
+ initialFocusRef,
67
+ isOpen,
68
+ label,
69
+ onOpenChange,
70
+ }: SelectorBottomSheetProps) {
71
+ return (
72
+ <Suspense fallback={null}>
73
+ <LazyBottomSheet
74
+ isOpen={isOpen}
75
+ onOpenChange={onOpenChange}
76
+ finalFocusRef={finalFocusRef}
77
+ label={label}
78
+ height="hug"
79
+ purpose="info">
80
+ <Section paddingBlockStart={4} paddingBlockEnd={0} paddingInline={1}>
81
+ <div {...stylex.props(styles.content)}>
82
+ <Heading level={3} xstyle={styles.heading}>
83
+ {label}
84
+ </Heading>
85
+ {children}
86
+ <SelectorBottomSheetInitialFocus
87
+ isOpen={isOpen}
88
+ targetRef={initialFocusRef}
89
+ />
90
+ </div>
91
+ </Section>
92
+ </LazyBottomSheet>
93
+ </Suspense>
94
+ );
95
+ }
96
+
97
+ SelectorBottomSheet.displayName = 'SelectorBottomSheet';
@@ -11,6 +11,7 @@
11
11
  export {
12
12
  Selector,
13
13
  type SelectorProps,
14
+ type SelectorPresentation,
14
15
  type SelectorSize,
15
16
  type SelectorStatus,
16
17
  type SelectorStatusType,
@@ -0,0 +1,20 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file selectorPresentation.stylex.ts
5
+ * @input Uses field border tokens
6
+ * @output Shared focus-return styles for Selector and MultiSelector
7
+ * @position Internal visual policy for selector presentation changes
8
+ */
9
+
10
+ import * as stylex from '@stylexjs/stylex';
11
+ import {colorVars} from '../theme/tokens.stylex';
12
+
13
+ export const selectorPresentationStyles = stylex.create({
14
+ pointerRestoredFocus: {
15
+ borderColor: colorVars['--color-border-emphasized'],
16
+ boxShadow: 'none',
17
+ outlineWidth: '0',
18
+ outlineStyle: 'none',
19
+ },
20
+ });
@@ -0,0 +1,147 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useSelectorPresentation.ts
7
+ * @input Uses the shared adaptive presentation policy and usePopover
8
+ * @output Coordinates popover and bottom-sheet disclosure state
9
+ * @position Internal presentation controller shared by Selector and MultiSelector
10
+ */
11
+
12
+ import {
13
+ useCallback,
14
+ useRef,
15
+ useState,
16
+ type FocusEvent,
17
+ type RefObject,
18
+ } from 'react';
19
+ import {
20
+ usePopoverInternal,
21
+ type UsePopoverOptions,
22
+ type UsePopoverReturn,
23
+ } from '../Popover/usePopover';
24
+ import {
25
+ useAdaptivePresentation,
26
+ type AdaptivePresentation,
27
+ type ResolvedAdaptivePresentation,
28
+ } from '../hooks/useAdaptivePresentation';
29
+ import {useFocusReturnVisibility} from '../hooks/useFocusReturnVisibility';
30
+
31
+ interface UseSelectorPresentationOptions {
32
+ presentation: AdaptivePresentation;
33
+ onHide: () => void;
34
+ popoverOptions: Omit<UsePopoverOptions, 'onHide'>;
35
+ triggerRef: RefObject<HTMLElement | null>;
36
+ }
37
+
38
+ interface SelectorPresentationController {
39
+ activePresentation: ResolvedAdaptivePresentation;
40
+ hide: () => void;
41
+ isOpen: boolean;
42
+ isSheetOpen: boolean;
43
+ isTriggerFocusRingSuppressed: boolean;
44
+ onSheetOpenChange: (isOpen: boolean) => void;
45
+ onTriggerFocus: (event: FocusEvent<HTMLElement>) => void;
46
+ popover: UsePopoverReturn & {wasJustDismissed: () => boolean};
47
+ show: () => void;
48
+ wasJustDismissed: () => boolean;
49
+ }
50
+
51
+ export function useSelectorPresentation({
52
+ presentation,
53
+ onHide,
54
+ popoverOptions,
55
+ triggerRef,
56
+ }: UseSelectorPresentationOptions): SelectorPresentationController {
57
+ const resolvedPresentation = useAdaptivePresentation(presentation);
58
+ const activePresentationRef =
59
+ useRef<ResolvedAdaptivePresentation>(resolvedPresentation);
60
+ const [isSheetOpen, setIsSheetOpen] = useState(false);
61
+ const isSheetOpenRef = useRef(false);
62
+ const onHideRef = useRef(onHide);
63
+ const {
64
+ isFocusRingSuppressed,
65
+ onFocusReturnTargetFocus,
66
+ prepareFocusReturn,
67
+ resetFocusReturn,
68
+ } = useFocusReturnVisibility();
69
+ onHideRef.current = onHide;
70
+
71
+ const handlePopoverHide = useCallback(() => {
72
+ prepareFocusReturn();
73
+ onHideRef.current();
74
+ triggerRef.current?.focus();
75
+ }, [prepareFocusReturn, triggerRef]);
76
+ const popover = usePopoverInternal({
77
+ ...popoverOptions,
78
+ onHide: handlePopoverHide,
79
+ });
80
+ const {
81
+ hide: hidePopover,
82
+ show: showPopover,
83
+ wasJustDismissed: wasPopoverJustDismissed,
84
+ } = popover;
85
+
86
+ const show = useCallback(() => {
87
+ resetFocusReturn();
88
+ activePresentationRef.current = resolvedPresentation;
89
+ if (resolvedPresentation === 'bottom-sheet') {
90
+ isSheetOpenRef.current = true;
91
+ setIsSheetOpen(true);
92
+ } else {
93
+ showPopover();
94
+ }
95
+ }, [resetFocusReturn, resolvedPresentation, showPopover]);
96
+
97
+ const hide = useCallback(() => {
98
+ if (isSheetOpenRef.current) {
99
+ prepareFocusReturn();
100
+ isSheetOpenRef.current = false;
101
+ setIsSheetOpen(false);
102
+ onHideRef.current();
103
+ } else {
104
+ hidePopover();
105
+ }
106
+ }, [hidePopover, prepareFocusReturn]);
107
+
108
+ const handleTriggerFocus = useCallback(
109
+ (_event: FocusEvent<HTMLElement>) => onFocusReturnTargetFocus(),
110
+ [onFocusReturnTargetFocus],
111
+ );
112
+
113
+ const handleSheetOpenChange = useCallback(
114
+ (nextIsOpen: boolean) => {
115
+ if (nextIsOpen) {
116
+ show();
117
+ } else {
118
+ hide();
119
+ }
120
+ },
121
+ [hide, show],
122
+ );
123
+
124
+ const isOpen = popover.isOpen || isSheetOpen;
125
+ const activePresentation = isOpen
126
+ ? activePresentationRef.current
127
+ : resolvedPresentation;
128
+
129
+ const wasJustDismissed = useCallback(
130
+ () =>
131
+ activePresentationRef.current === 'popover' && wasPopoverJustDismissed(),
132
+ [wasPopoverJustDismissed],
133
+ );
134
+
135
+ return {
136
+ activePresentation,
137
+ hide,
138
+ isOpen,
139
+ isSheetOpen,
140
+ isTriggerFocusRingSuppressed: isFocusRingSuppressed,
141
+ onSheetOpenChange: handleSheetOpenChange,
142
+ onTriggerFocus: handleTriggerFocus,
143
+ popover,
144
+ show,
145
+ wasJustDismissed,
146
+ };
147
+ }