@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.
- package/dist/BottomSheet/BottomSheet.d.ts +3 -1
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +4 -3
- package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +2 -2
- package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetEdgeTint.js +7 -10
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcher.js +1 -1
- package/dist/Button/Button.d.ts +2 -1
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +11 -4
- package/dist/Carousel/Carousel.d.ts.map +1 -1
- package/dist/Carousel/Carousel.js +2 -2
- package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
- package/dist/Chat/ChatToolCalls.js +34 -17
- package/dist/ContextMenu/ContextMenu.d.ts +14 -3
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +149 -26
- package/dist/ContextMenu/index.d.ts +1 -0
- package/dist/ContextMenu/index.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.d.ts +24 -5
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +340 -26
- package/dist/DropdownMenu/DropdownMenuContext.d.ts +1 -1
- package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuItem.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuItem.js +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +6 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +43 -14
- package/dist/DropdownMenu/MenuBottomSheet.d.ts +20 -0
- package/dist/DropdownMenu/MenuBottomSheet.d.ts.map +1 -0
- package/dist/DropdownMenu/MenuBottomSheet.js +36 -0
- package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts +10 -0
- package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts.map +1 -0
- package/dist/DropdownMenu/MenuBottomSheetActionList.js +111 -0
- package/dist/DropdownMenu/index.d.ts +2 -1
- package/dist/DropdownMenu/index.d.ts.map +1 -1
- package/dist/DropdownMenu/menuWidth.d.ts +14 -0
- package/dist/DropdownMenu/menuWidth.d.ts.map +1 -0
- package/dist/DropdownMenu/menuWidth.js +35 -0
- package/dist/DropdownMenu/useMenuOverflow.d.ts +10 -0
- package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -0
- package/dist/DropdownMenu/useMenuOverflow.js +60 -0
- package/dist/MoreMenu/MoreMenu.d.ts +8 -2
- package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
- package/dist/MoreMenu/MoreMenu.js +2 -0
- package/dist/MultiSelector/MultiSelector.d.ts +11 -1
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +97 -68
- package/dist/MultiSelector/index.d.ts +1 -1
- package/dist/MultiSelector/index.d.ts.map +1 -1
- package/dist/Popover/Popover.d.ts +10 -6
- package/dist/Popover/Popover.d.ts.map +1 -1
- package/dist/Popover/Popover.js +141 -16
- package/dist/Popover/usePopover.d.ts +4 -0
- package/dist/Popover/usePopover.d.ts.map +1 -1
- package/dist/Popover/usePopover.js +52 -5
- package/dist/Selector/Selector.d.ts +10 -0
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +106 -80
- package/dist/Selector/SelectorBottomSheet.d.ts +21 -0
- package/dist/Selector/SelectorBottomSheet.d.ts.map +1 -0
- package/dist/Selector/SelectorBottomSheet.js +83 -0
- package/dist/Selector/index.d.ts +1 -1
- package/dist/Selector/index.d.ts.map +1 -1
- package/dist/Selector/selectorPresentation.stylex.d.ts +16 -0
- package/dist/Selector/selectorPresentation.stylex.d.ts.map +1 -0
- package/dist/Selector/selectorPresentation.stylex.js +21 -0
- package/dist/Selector/useSelectorPresentation.d.ts +32 -0
- package/dist/Selector/useSelectorPresentation.d.ts.map +1 -0
- package/dist/Selector/useSelectorPresentation.js +90 -0
- package/dist/Toast/useToastGesture.d.ts.map +1 -1
- package/dist/Toast/useToastGesture.js +46 -9
- package/dist/astryx.css +19 -1
- package/dist/hooks/useAdaptivePresentation.d.ts +5 -0
- package/dist/hooks/useAdaptivePresentation.d.ts.map +1 -0
- package/dist/hooks/useAdaptivePresentation.js +19 -0
- package/dist/hooks/useFocusReturnVisibility.d.ts +7 -0
- package/dist/hooks/useFocusReturnVisibility.d.ts.map +1 -0
- package/dist/hooks/useFocusReturnVisibility.js +35 -0
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +12 -0
- package/locales/en.json +20 -0
- package/locales/pseudo.json +15 -0
- package/package.json +2 -2
- package/src/BottomSheet/BottomSheet.doc.mjs +6 -0
- package/src/BottomSheet/BottomSheet.test.tsx +29 -0
- package/src/BottomSheet/BottomSheet.tsx +7 -4
- package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -6
- package/src/BottomSheet/BottomSheetEdgeTint.tsx +7 -10
- package/src/BottomSheet/BottomSheetSwitcher.tsx +1 -2
- package/src/Button/Button.doc.mjs +56 -0
- package/src/Button/Button.test.tsx +10 -0
- package/src/Button/Button.tsx +13 -5
- package/src/ButtonGroup/ButtonGroup.doc.mjs +47 -0
- package/src/Carousel/Carousel.test.tsx +83 -0
- package/src/Carousel/Carousel.tsx +8 -2
- package/src/Chat/ChatToolCalls.test.tsx +44 -0
- package/src/Chat/ChatToolCalls.tsx +36 -15
- package/src/ContextMenu/ContextMenu.doc.mjs +13 -1
- package/src/ContextMenu/ContextMenu.test.tsx +131 -1
- package/src/ContextMenu/ContextMenu.tsx +199 -32
- package/src/ContextMenu/index.ts +1 -0
- package/src/DropdownMenu/DropdownMenu.doc.mjs +201 -40
- package/src/DropdownMenu/DropdownMenu.test.tsx +518 -7
- package/src/DropdownMenu/DropdownMenu.tsx +459 -28
- package/src/DropdownMenu/DropdownMenuContext.tsx +1 -1
- package/src/DropdownMenu/DropdownMenuItem.tsx +2 -0
- package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +4 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +124 -0
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +72 -17
- package/src/DropdownMenu/MenuBottomSheet.tsx +46 -0
- package/src/DropdownMenu/MenuBottomSheetActionList.tsx +141 -0
- package/src/DropdownMenu/index.ts +2 -0
- package/src/DropdownMenu/menuWidth.ts +51 -0
- package/src/DropdownMenu/useMenuOverflow.ts +77 -0
- package/src/IconButton/IconButton.doc.mjs +38 -0
- package/src/MoreMenu/MoreMenu.doc.mjs +17 -0
- package/src/MoreMenu/MoreMenu.test.tsx +47 -0
- package/src/MoreMenu/MoreMenu.tsx +13 -1
- package/src/MultiSelector/MultiSelector.doc.mjs +16 -0
- package/src/MultiSelector/MultiSelector.test.tsx +127 -0
- package/src/MultiSelector/MultiSelector.tsx +121 -73
- package/src/MultiSelector/index.ts +1 -0
- package/src/Popover/Popover.doc.mjs +11 -11
- package/src/Popover/Popover.test.tsx +397 -2
- package/src/Popover/Popover.tsx +228 -21
- package/src/Popover/usePopover.tsx +92 -15
- package/src/ProgressBar/ProgressBar.spec.md +208 -0
- package/src/SegmentedControl/SegmentedControl.doc.mjs +56 -0
- package/src/Selector/Selector.doc.mjs +12 -0
- package/src/Selector/Selector.test.tsx +135 -0
- package/src/Selector/Selector.tsx +141 -82
- package/src/Selector/SelectorBottomSheet.tsx +97 -0
- package/src/Selector/index.ts +1 -0
- package/src/Selector/selectorPresentation.stylex.ts +20 -0
- package/src/Selector/useSelectorPresentation.ts +147 -0
- package/src/Toast/ToastViewport.test.tsx +118 -4
- package/src/Toast/useToastGesture.ts +61 -8
- package/src/ToggleButton/ToggleButton.doc.mjs +56 -0
- package/src/hooks/useAdaptivePresentation.ts +35 -0
- package/src/hooks/useFocusReturnVisibility.ts +45 -0
- package/src/hooks/useFocusTrap.test.tsx +40 -0
- package/src/hooks/useFocusTrap.ts +13 -0
- package/src/theme/themingTargets.test.ts +91 -25
package/src/Popover/Popover.tsx
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file Popover.tsx
|
|
7
|
-
* @input Uses React
|
|
8
|
-
* @output Exports Popover
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
256
|
-
minWidth:
|
|
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 =
|
|
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: [
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
if (!
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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
|
-
|
|
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
|
|
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,
|
|
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?: {
|
|
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
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
layer.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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 {
|
|
602
|
+
const {
|
|
603
|
+
wasJustDismissed: _,
|
|
604
|
+
toggleWithOptions: __,
|
|
605
|
+
...popover
|
|
606
|
+
} = usePopoverImplementation(options);
|
|
530
607
|
return popover;
|
|
531
608
|
}
|
|
532
609
|
|