@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
@@ -4,8 +4,8 @@
4
4
 
5
5
  /**
6
6
  * @file Popover.tsx
7
- * @input Uses React, usePopover hook
8
- * @output Exports Popover component for click-triggered popovers
7
+ * @input Uses React layout measurement and the usePopover hook
8
+ * @output Exports Popover with viewport fitting and conditional overflow
9
9
  * @position Layer component; declarative wrapper around usePopover hook
10
10
  *
11
11
  * For hover-triggered overlays, use HoverCard instead.
@@ -20,6 +20,7 @@
20
20
  import React, {
21
21
  useCallback,
22
22
  useRef,
23
+ useState,
23
24
  type ReactElement,
24
25
  type ReactNode,
25
26
  } from 'react';
@@ -27,7 +28,7 @@ import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
27
28
  import * as stylex from '@stylexjs/stylex';
28
29
  import {devWarn} from '../utils/devWarning';
29
30
  import type {BaseProps} from '../BaseProps';
30
- import {usePopover} from './usePopover';
31
+ import {usePopoverInternal} from './usePopover';
31
32
  import type {LayerAlignment, LayerPlacement} from '../Layer/useLayer';
32
33
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
33
34
  import {spacingVars} from '../theme/tokens.stylex';
@@ -38,6 +39,14 @@ import {InteractiveRoleContext} from '../InteractiveRoleContext/InteractiveRoleC
38
39
  // =============================================================================
39
40
 
40
41
  const BUTTON_SELECTOR = 'button, [role="button"]';
42
+ const POPOVER_VIEWPORT_GUTTER = spacingVars['--spacing-4'];
43
+ const POPOVER_MAX_INLINE_SIZE = `calc(100vi - max(${POPOVER_VIEWPORT_GUTTER}, env(safe-area-inset-left, 0px)) - max(${POPOVER_VIEWPORT_GUTTER}, env(safe-area-inset-right, 0px)))`;
44
+ const POPOVER_MAX_INLINE_SIZE_FALLBACK = `calc(100vw - ${POPOVER_VIEWPORT_GUTTER} - ${POPOVER_VIEWPORT_GUTTER})`;
45
+ const POPOVER_MAX_BLOCK_SIZE = `calc(100dvb - max(${POPOVER_VIEWPORT_GUTTER}, env(safe-area-inset-top, 0px)) - max(${POPOVER_VIEWPORT_GUTTER}, env(safe-area-inset-bottom, 0px)))`;
46
+ const POPOVER_MAX_BLOCK_SIZE_FALLBACK = `calc(100vh - ${POPOVER_VIEWPORT_GUTTER} - ${POPOVER_VIEWPORT_GUTTER})`;
47
+ const POPOVER_POSITION_AREA_MAX_INLINE_SIZE = `calc(100% - max(${POPOVER_VIEWPORT_GUTTER}, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px)))`;
48
+ const POPOVER_POSITION_AREA_MAX_INLINE_SIZE_FALLBACK = `calc(100% - ${POPOVER_VIEWPORT_GUTTER})`;
49
+ const POPOVER_INLINE_EDGE_GUTTER = `max(${POPOVER_VIEWPORT_GUTTER}, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))`;
41
50
 
42
51
  /**
43
52
  * Find the trigger button inside a container element.
@@ -61,9 +70,9 @@ function findTriggerButton(el: HTMLElement): HTMLElement | null {
61
70
  export interface PopoverTriggerRenderProps {
62
71
  /** Ref callback — attach to the trigger element for anchor positioning. */
63
72
  ref: (el: HTMLElement | null) => void;
64
- /** Toggle the popover open/closed. */
65
- onClick: () => void;
66
- /** ARIA attribute: indicates the element triggers a dialog. */
73
+ /** Toggle the popover open/closed. Pass the click event through so pointer and keyboard focus behavior can differ. */
74
+ onClick: (event?: {detail: number}) => void;
75
+ /** ARIA attribute: indicates the trigger opens a dialog-style popover. */
67
76
  'aria-haspopup': 'dialog';
68
77
  /** ARIA attribute: whether the popover is currently open. */
69
78
  'aria-expanded': boolean;
@@ -198,7 +207,9 @@ export interface PopoverProps extends Pick<
198
207
  closeButtonLabel?: string;
199
208
 
200
209
  /**
201
- * Whether to auto-focus the first focusable element when the popover opens.
210
+ * Whether to move focus into the popover when it opens. Keyboard activation
211
+ * focuses the first content control; pointer activation focuses the labeled
212
+ * dialog container so an action does not appear preselected.
202
213
  * Set to `false` for inline showcases or documentation previews.
203
214
  * @default true
204
215
  */
@@ -241,6 +252,62 @@ const styles = stylex.create({
241
252
  anchorWrapper: {
242
253
  display: 'inline-flex',
243
254
  },
255
+ viewportFit: {
256
+ boxSizing: 'border-box',
257
+ maxBlockSize: stylex.firstThatWorks(
258
+ POPOVER_MAX_BLOCK_SIZE,
259
+ POPOVER_MAX_BLOCK_SIZE_FALLBACK,
260
+ ),
261
+ },
262
+ viewportAligned: {
263
+ maxInlineSize: stylex.firstThatWorks(
264
+ POPOVER_POSITION_AREA_MAX_INLINE_SIZE,
265
+ POPOVER_POSITION_AREA_MAX_INLINE_SIZE_FALLBACK,
266
+ ),
267
+ },
268
+ viewportStart: {
269
+ marginInlineEnd: POPOVER_INLINE_EDGE_GUTTER,
270
+ },
271
+ viewportEnd: {
272
+ marginInlineStart: POPOVER_INLINE_EDGE_GUTTER,
273
+ },
274
+ viewportBlockStart: {
275
+ marginBlockEnd: `max(${POPOVER_VIEWPORT_GUTTER}, env(safe-area-inset-bottom, 0px))`,
276
+ },
277
+ viewportBlockEnd: {
278
+ marginBlockStart: `max(${POPOVER_VIEWPORT_GUTTER}, env(safe-area-inset-top, 0px))`,
279
+ },
280
+ viewportCentered: {
281
+ marginInlineStart: POPOVER_INLINE_EDGE_GUTTER,
282
+ marginInlineEnd: POPOVER_INLINE_EDGE_GUTTER,
283
+ maxInlineSize: stylex.firstThatWorks(
284
+ POPOVER_MAX_INLINE_SIZE,
285
+ POPOVER_MAX_INLINE_SIZE_FALLBACK,
286
+ ),
287
+ },
288
+ viewportBlockCentered: {
289
+ marginBlockStart: `max(${POPOVER_VIEWPORT_GUTTER}, env(safe-area-inset-top, 0px))`,
290
+ marginBlockEnd: `max(${POPOVER_VIEWPORT_GUTTER}, env(safe-area-inset-bottom, 0px))`,
291
+ maxInlineSize: stylex.firstThatWorks(
292
+ POPOVER_MAX_INLINE_SIZE,
293
+ POPOVER_MAX_INLINE_SIZE_FALLBACK,
294
+ ),
295
+ },
296
+ surfaceViewportFit: {
297
+ boxSizing: 'border-box',
298
+ maxInlineSize: stylex.firstThatWorks(
299
+ POPOVER_MAX_INLINE_SIZE,
300
+ POPOVER_MAX_INLINE_SIZE_FALLBACK,
301
+ ),
302
+ maxBlockSize: stylex.firstThatWorks(
303
+ POPOVER_MAX_BLOCK_SIZE,
304
+ POPOVER_MAX_BLOCK_SIZE_FALLBACK,
305
+ ),
306
+ },
307
+ surfaceScrollable: {
308
+ overflow: 'auto',
309
+ overscrollBehavior: 'contain',
310
+ },
244
311
  // Content padding, applied to the popup surface so a theme's `padding`
245
312
  // replaces it instead of nesting inside it.
246
313
  contentPadding: {
@@ -252,8 +319,19 @@ const styles = stylex.create({
252
319
  customWidth: (width: string | number) => ({
253
320
  width: typeof width === 'number' ? `${width}px` : width,
254
321
  }),
255
- matchTrigger: {
256
- minWidth: 'anchor-size(width)',
322
+ matchTriggerAligned: {
323
+ minWidth: stylex.firstThatWorks(
324
+ `min(anchor-size(width), ${POPOVER_POSITION_AREA_MAX_INLINE_SIZE})`,
325
+ `min(anchor-size(width), ${POPOVER_POSITION_AREA_MAX_INLINE_SIZE_FALLBACK})`,
326
+ 'anchor-size(width)',
327
+ ),
328
+ },
329
+ matchTriggerCentered: {
330
+ minWidth: stylex.firstThatWorks(
331
+ `min(anchor-size(width), ${POPOVER_MAX_INLINE_SIZE})`,
332
+ `min(anchor-size(width), ${POPOVER_MAX_INLINE_SIZE_FALLBACK})`,
333
+ 'anchor-size(width)',
334
+ ),
257
335
  },
258
336
  });
259
337
 
@@ -320,6 +398,8 @@ export function Popover({
320
398
  'data-testid': testId,
321
399
  }: PopoverProps): ReactElement {
322
400
  const wrapperRef = useRef<HTMLDivElement>(null);
401
+ const measurementFrameRef = useRef<number | null>(null);
402
+ const [hasOverflow, setHasOverflow] = useState(false);
323
403
  const isControlled = isOpen !== undefined;
324
404
 
325
405
  const handlePopoverShow = useCallback(() => {
@@ -330,7 +410,7 @@ export function Popover({
330
410
  onOpenChange?.(false);
331
411
  }, [onOpenChange]);
332
412
 
333
- const popover = usePopover({
413
+ const popover = usePopoverInternal({
334
414
  dialogLabel: label,
335
415
  role,
336
416
  isModal,
@@ -343,21 +423,113 @@ export function Popover({
343
423
  // it is the element the `popover` theme target has to sit on — a target on
344
424
  // the content div inside it styles a box that paints nothing.
345
425
  surfaceTarget: 'popover',
346
- xstyle: [styles.contentPadding, xstyle],
426
+ xstyle: [
427
+ styles.contentPadding,
428
+ styles.surfaceViewportFit,
429
+ hasOverflow && styles.surfaceScrollable,
430
+ xstyle,
431
+ ],
347
432
  className,
348
433
  style,
349
434
  onShow: handlePopoverShow,
350
435
  onHide: handlePopoverHide,
351
436
  });
352
437
 
353
- // Shared handler for click events on the trigger button.
354
- const handleTriggerClick = useCallback(() => {
355
- if (!isEnabled) {
438
+ const measureOverflow = useCallback(() => {
439
+ const surface = popover.contentRef.current;
440
+ if (!surface) {
441
+ return;
442
+ }
443
+ const nextHasOverflow =
444
+ surface.scrollHeight > surface.clientHeight + 1 ||
445
+ surface.scrollWidth > surface.clientWidth + 1;
446
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- DOM overflow measurement controls whether this surface becomes a scroll container
447
+ setHasOverflow(current =>
448
+ current === nextHasOverflow ? current : nextHasOverflow,
449
+ );
450
+ }, [popover.contentRef]);
451
+
452
+ const scheduleOverflowMeasurement = useCallback(() => {
453
+ if (measurementFrameRef.current != null) {
454
+ return;
455
+ }
456
+ measurementFrameRef.current = window.requestAnimationFrame(() => {
457
+ measurementFrameRef.current = null;
458
+ measureOverflow();
459
+ });
460
+ }, [measureOverflow]);
461
+
462
+ useIsomorphicLayoutEffect(() => {
463
+ if (!popover.isOpen) {
464
+ return;
465
+ }
466
+ const surface = popover.contentRef.current;
467
+ if (!surface) {
468
+ return;
469
+ }
470
+ measureOverflow();
471
+ const resizeObserver =
472
+ typeof ResizeObserver === 'undefined'
473
+ ? null
474
+ : new ResizeObserver(scheduleOverflowMeasurement);
475
+ const mutationObserver =
476
+ typeof MutationObserver === 'undefined'
477
+ ? null
478
+ : new MutationObserver(scheduleOverflowMeasurement);
479
+ resizeObserver?.observe(surface);
480
+ mutationObserver?.observe(surface, {
481
+ childList: true,
482
+ characterData: true,
483
+ subtree: true,
484
+ });
485
+ surface.addEventListener('load', scheduleOverflowMeasurement, true);
486
+ window.addEventListener('resize', scheduleOverflowMeasurement);
487
+ window.visualViewport?.addEventListener(
488
+ 'resize',
489
+ scheduleOverflowMeasurement,
490
+ );
491
+ return () => {
492
+ resizeObserver?.disconnect();
493
+ mutationObserver?.disconnect();
494
+ surface.removeEventListener('load', scheduleOverflowMeasurement, true);
495
+ window.removeEventListener('resize', scheduleOverflowMeasurement);
496
+ window.visualViewport?.removeEventListener(
497
+ 'resize',
498
+ scheduleOverflowMeasurement,
499
+ );
500
+ if (measurementFrameRef.current != null) {
501
+ window.cancelAnimationFrame(measurementFrameRef.current);
502
+ measurementFrameRef.current = null;
503
+ }
504
+ };
505
+ }, [measureOverflow, popover.isOpen, scheduleOverflowMeasurement]);
506
+
507
+ useIsomorphicLayoutEffect(() => {
508
+ if (!popover.isOpen) {
356
509
  return;
357
510
  }
358
- // `toggle` absorbs a click that belongs to its own light dismiss.
359
- popover.toggle();
360
- }, [isEnabled, popover]);
511
+ scheduleOverflowMeasurement();
512
+ }, [content, popover.isOpen, scheduleOverflowMeasurement]);
513
+
514
+ // Shared handler for click events on the trigger button.
515
+ const handleTriggerClick = useCallback(
516
+ (event?: {detail: number}) => {
517
+ if (!isEnabled) {
518
+ return;
519
+ }
520
+ // Pointer/touch activation should not make the first action look
521
+ // preselected. Keep focus inside the modal dialog by focusing its
522
+ // labeled container; keyboard and AT activation still focus the first
523
+ // content control and expose the expected focus ring.
524
+ popover.toggleWithOptions({
525
+ focusTarget:
526
+ role === 'dialog' && event != null && event.detail > 0
527
+ ? 'container'
528
+ : 'first',
529
+ });
530
+ },
531
+ [isEnabled, popover, role],
532
+ );
361
533
 
362
534
  // Shared handler for keydown events on role="button" elements.
363
535
  // Native <button> synthesizes click on Enter/Space, but role="button"
@@ -503,7 +675,27 @@ export function Popover({
503
675
  }, [isOpen, isControlled, popover]);
504
676
 
505
677
  // Determine popover xstyle
506
- const popoverXstyle = width ? styles.customWidth(width) : styles.matchTrigger;
678
+ const popoverSizeXstyle = width
679
+ ? styles.customWidth(width)
680
+ : alignment === 'center'
681
+ ? styles.matchTriggerCentered
682
+ : styles.matchTriggerAligned;
683
+ const isSidePlacement = placement === 'start' || placement === 'end';
684
+ const popoverViewportXstyle =
685
+ alignment === 'center'
686
+ ? isSidePlacement
687
+ ? styles.viewportBlockCentered
688
+ : styles.viewportCentered
689
+ : [
690
+ styles.viewportAligned,
691
+ isSidePlacement
692
+ ? alignment === 'start'
693
+ ? styles.viewportBlockStart
694
+ : styles.viewportBlockEnd
695
+ : alignment === 'start'
696
+ ? styles.viewportStart
697
+ : styles.viewportEnd,
698
+ ];
507
699
 
508
700
  // Sibling mode: render only the popover (no wrapper needed)
509
701
  if (anchorRef && children == null) {
@@ -513,7 +705,12 @@ export function Popover({
513
705
  placement,
514
706
  alignment,
515
707
  offset: spacingVars['--spacing-1'],
516
- xstyle: [popoverXstyle, layerAnimations[placement]],
708
+ xstyle: [
709
+ styles.viewportFit,
710
+ popoverViewportXstyle,
711
+ popoverSizeXstyle,
712
+ layerAnimations[placement],
713
+ ],
517
714
  })}
518
715
  </>
519
716
  );
@@ -538,7 +735,12 @@ export function Popover({
538
735
  placement,
539
736
  alignment,
540
737
  offset: spacingVars['--spacing-1'],
541
- xstyle: [popoverXstyle, layerAnimations[placement]],
738
+ xstyle: [
739
+ styles.viewportFit,
740
+ popoverViewportXstyle,
741
+ popoverSizeXstyle,
742
+ layerAnimations[placement],
743
+ ],
542
744
  })}
543
745
  </>
544
746
  );
@@ -556,7 +758,12 @@ export function Popover({
556
758
  placement,
557
759
  alignment,
558
760
  offset: spacingVars['--spacing-1'],
559
- xstyle: [popoverXstyle, layerAnimations[placement]],
761
+ xstyle: [
762
+ styles.viewportFit,
763
+ popoverViewportXstyle,
764
+ popoverSizeXstyle,
765
+ layerAnimations[placement],
766
+ ],
560
767
  })}
561
768
  </>
562
769
  );
@@ -28,6 +28,7 @@ import {
28
28
  shadowVars,
29
29
  } from '../theme/tokens.stylex';
30
30
  import {Button} from '../Button';
31
+ import {FOCUSABLE_SELECTOR} from '../hooks/focusableSelector';
31
32
  import {rtlStyles} from '../utils';
32
33
  import {useTranslator} from '../i18n';
33
34
  import {useDevWarning} from '../hooks/useDevWarning';
@@ -35,6 +36,29 @@ import {mergeProps} from '../utils/mergeProps';
35
36
  import {themeProps} from '../utils/themeProps';
36
37
  import {stableClassName} from '../naming';
37
38
 
39
+ const FALLBACK_CLOSE_SELECTOR = '[data-astryx-popover-fallback-close]';
40
+
41
+ function attemptFocus(element: HTMLElement): boolean {
42
+ try {
43
+ element.focus();
44
+ } catch {
45
+ // Ignore non-focusable elements; the caller will try the next target.
46
+ }
47
+ return document.activeElement === element;
48
+ }
49
+
50
+ function focusFirstContentControl(container: HTMLElement): boolean {
51
+ const focusable = Array.from(
52
+ container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
53
+ ).filter(element => element.closest(FALLBACK_CLOSE_SELECTOR) == null);
54
+ for (const element of focusable) {
55
+ if (attemptFocus(element)) {
56
+ return true;
57
+ }
58
+ }
59
+ return false;
60
+ }
61
+
38
62
  const styles = stylex.create({
39
63
  // Default popover surface — background, radius, shadow.
40
64
  // Applied automatically unless hasSurface is false.
@@ -48,6 +72,11 @@ const styles = stylex.create({
48
72
  // Focus trap container
49
73
  contentWrapper: {
50
74
  position: 'relative',
75
+ // Pointer-open dialog popovers park focus on this wrapper so the first
76
+ // action does not look selected. The wrapper itself is not an action and
77
+ // should not receive the browser's default focus ring; interactive
78
+ // descendants retain their normal focus-visible treatment.
79
+ outline: 'none',
51
80
  },
52
81
  // Hidden close button wrapper - sr-only until focused, then positioned below
53
82
  // popover. Inline-axis centering (+ the translateY(100%) that drops it below
@@ -296,6 +325,10 @@ export interface UsePopoverReturn {
296
325
 
297
326
  interface InternalUsePopoverReturn extends UsePopoverReturn {
298
327
  wasJustDismissed: () => boolean;
328
+ toggleWithOptions: (options?: {
329
+ skipAutoFocus?: boolean;
330
+ focusTarget?: 'first' | 'container';
331
+ }) => void;
299
332
  }
300
333
 
301
334
  /**
@@ -368,6 +401,7 @@ function usePopoverImplementation(
368
401
 
369
402
  // Track whether to skip auto-focus for the current open event
370
403
  const skipAutoFocusRef = useRef(false);
404
+ const focusTargetRef = useRef<'first' | 'container'>('first');
371
405
 
372
406
  // Core layer for popover positioning
373
407
  const layer = useLayerInternal({
@@ -385,19 +419,44 @@ function usePopoverImplementation(
385
419
  onEscape: hasEscapeDismiss || hasLightDismiss ? layer.hide : undefined,
386
420
  });
387
421
 
422
+ const focusInitialTarget = useCallback(() => {
423
+ const container = contentRef.current;
424
+ if (!container) {
425
+ return;
426
+ }
427
+ if (focusFirstContentControl(container)) {
428
+ return;
429
+ }
430
+ if (role === 'dialog') {
431
+ attemptFocus(container);
432
+ return;
433
+ }
434
+ focusFirst();
435
+ }, [contentRef, focusFirst, role]);
436
+
388
437
  // Auto-focus first element when popover opens (unless skipped)
389
438
  useEffect(() => {
390
439
  if (layer.isOpen && hasAutoFocus && !skipAutoFocusRef.current) {
391
440
  // Use requestAnimationFrame to ensure DOM is ready
392
441
  requestAnimationFrame(() => {
393
- focusFirst();
442
+ const container = contentRef.current;
443
+ if (
444
+ focusTargetRef.current === 'container' &&
445
+ role === 'dialog' &&
446
+ container
447
+ ) {
448
+ attemptFocus(container);
449
+ } else {
450
+ focusInitialTarget();
451
+ }
394
452
  });
395
453
  }
396
- // Reset the skip flag after the effect runs
454
+ // Reset per-open focus preferences after the popover closes.
397
455
  if (!layer.isOpen) {
398
456
  skipAutoFocusRef.current = false;
457
+ focusTargetRef.current = 'first';
399
458
  }
400
- }, [layer.isOpen, hasAutoFocus, focusFirst]);
459
+ }, [contentRef, layer.isOpen, hasAutoFocus, focusInitialTarget, role]);
401
460
 
402
461
  // Combined ref for trigger element (layer anchor + our ref)
403
462
  const triggerRef = useCallback(
@@ -410,24 +469,35 @@ function usePopoverImplementation(
410
469
 
411
470
  // Show function with optional skipAutoFocus
412
471
  const show = useCallback(
413
- (showOptions?: {skipAutoFocus?: boolean}) => {
472
+ (showOptions?: {
473
+ skipAutoFocus?: boolean;
474
+ focusTarget?: 'first' | 'container';
475
+ }) => {
414
476
  skipAutoFocusRef.current = showOptions?.skipAutoFocus ?? false;
477
+ focusTargetRef.current = showOptions?.focusTarget ?? 'first';
415
478
  layer.show();
416
479
  },
417
480
  [layer],
418
481
  );
419
482
 
420
483
  // Toggle function
421
- const toggle = useCallback(() => {
422
- if (layer.wasJustDismissed()) {
423
- return;
424
- }
425
- if (layer.isOpen) {
426
- layer.hide();
427
- } else {
428
- show();
429
- }
430
- }, [layer, show]);
484
+ const toggleWithOptions = useCallback(
485
+ (showOptions?: {
486
+ skipAutoFocus?: boolean;
487
+ focusTarget?: 'first' | 'container';
488
+ }) => {
489
+ if (layer.wasJustDismissed()) {
490
+ return;
491
+ }
492
+ if (layer.isOpen) {
493
+ layer.hide();
494
+ } else {
495
+ show(showOptions);
496
+ }
497
+ },
498
+ [layer, show],
499
+ );
500
+ const toggle = useCallback(() => toggleWithOptions(), [toggleWithOptions]);
431
501
 
432
502
  // ARIA attributes for the trigger
433
503
  const triggerProps = {
@@ -465,6 +535,7 @@ function usePopoverImplementation(
465
535
  role={role === 'dialog' ? 'dialog' : undefined}
466
536
  aria-modal={role === 'dialog' && isModal ? true : undefined}
467
537
  aria-label={role === 'dialog' ? dialogLabel : undefined}
538
+ tabIndex={role === 'dialog' ? -1 : undefined}
468
539
  {...mergeProps(
469
540
  {...surfaceProps, className: surfaceClassName},
470
541
  stylex.props(
@@ -478,6 +549,7 @@ function usePopoverImplementation(
478
549
  {children}
479
550
  {hasCloseButton && (
480
551
  <div
552
+ data-astryx-popover-fallback-close=""
481
553
  {...stylex.props(
482
554
  styles.closeButtonWrapper,
483
555
  rtlStyles.centerInline('100%'),
@@ -517,6 +589,7 @@ function usePopoverImplementation(
517
589
  show,
518
590
  hide: layer.hide,
519
591
  toggle,
592
+ toggleWithOptions,
520
593
  wasJustDismissed: layer.wasJustDismissed,
521
594
  isOpen: layer.isOpen,
522
595
  id: layer.id,
@@ -526,7 +599,11 @@ function usePopoverImplementation(
526
599
  }
527
600
 
528
601
  export function usePopover(options: UsePopoverOptions = {}): UsePopoverReturn {
529
- const {wasJustDismissed: _, ...popover} = usePopoverImplementation(options);
602
+ const {
603
+ wasJustDismissed: _,
604
+ toggleWithOptions: __,
605
+ ...popover
606
+ } = usePopoverImplementation(options);
530
607
  return popover;
531
608
  }
532
609