@astryxdesign/core 0.7.2 → 0.7.3-canary.06872b4
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/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +19 -24
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +52 -10
- package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
- package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
- package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
- package/dist/DropdownMenu/menuRegionStops.js +2 -2
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +14 -12
- package/dist/Selector/SelectorOption.d.ts.map +1 -1
- package/dist/Selector/SelectorOption.js +1 -0
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +7 -14
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +12 -14
- package/dist/Switch/switch.markers.stylex.d.ts +2 -0
- package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
- package/dist/Switch/switch.markers.stylex.js +6 -0
- package/dist/TimeInput/TimeInput.d.ts +2 -1
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +36 -14
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +11 -2
- package/dist/TopNav/TopNav.d.ts.map +1 -1
- package/dist/TopNav/TopNav.js +2 -2
- package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
- package/dist/TopNav/TopNavHeading.js +1 -0
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +14 -2
- package/dist/astryx.css +4 -2
- package/dist/hooks/useMenuPress.d.ts +11 -1
- package/dist/hooks/useMenuPress.d.ts.map +1 -1
- package/dist/hooks/useMenuPress.js +43 -5
- package/dist/utils/timeParser.d.ts +4 -0
- package/dist/utils/timeParser.d.ts.map +1 -1
- package/dist/utils/timeParser.js +9 -2
- package/package.json +4 -4
- package/scripts/agent-doc-state.mjs +35 -8
- package/src/BottomSheet/BottomSheet.spec.md +13 -6
- package/src/BottomSheet/BottomSheetSwitcher.spec.md +121 -51
- package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
- package/src/CheckboxInput/CheckboxInput.tsx +16 -26
- package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
- package/src/CheckboxList/CheckboxList.test.tsx +9 -6
- package/src/ContextMenu/ContextMenu.test.tsx +41 -0
- package/src/DateRangeInput/DateRangeInput.spec.md +13 -13
- package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
- package/src/DropdownMenu/DropdownMenu.tsx +79 -8
- package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
- package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
- package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
- package/src/DropdownMenu/menuRegionStops.ts +2 -2
- package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
- package/src/FileInput/FileInput.test.tsx +30 -7
- package/src/FileInput/FileInput.tsx +21 -11
- package/src/Selector/Selector.spec.md +11 -11
- package/src/Selector/Selector.test.tsx +16 -0
- package/src/Selector/SelectorOption.tsx +4 -0
- package/src/SideNav/SideNav.test.tsx +35 -0
- package/src/Slider/Slider.spec.md +11 -11
- package/src/Slider/Slider.test.tsx +3 -3
- package/src/Slider/Slider.tsx +8 -15
- package/src/Switch/Switch.test.tsx +29 -8
- package/src/Switch/Switch.tsx +13 -14
- package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
- package/src/Switch/switch.markers.stylex.ts +4 -0
- package/src/Table/Table.spec.md +2 -0
- package/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.spec.md +244 -0
- package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
- package/src/Table/plugins/virtualization/useTableVirtualization.spec.md +266 -0
- package/src/TimeInput/TimeInput.doc.mjs +5 -3
- package/src/TimeInput/TimeInput.test.tsx +20 -0
- package/src/TimeInput/TimeInput.tsx +2 -1
- package/src/Tokenizer/Tokenizer.spec.md +28 -28
- package/src/Tooltip/Tooltip.test.tsx +51 -6
- package/src/Tooltip/Tooltip.tsx +41 -18
- package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
- package/src/Tooltip/__tests__/TooltipHoverBridge.a11y.chromium.spec.ts +74 -0
- package/src/Tooltip/useTooltip.test.tsx +23 -0
- package/src/Tooltip/useTooltip.tsx +22 -9
- package/src/TopNav/TopNav.tsx +11 -1
- package/src/TopNav/TopNavHeading.tsx +1 -0
- package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
- package/src/Typeahead/Typeahead.test.tsx +89 -0
- package/src/Typeahead/Typeahead.tsx +15 -2
- package/src/hooks/useMenuPress.ts +45 -7
- package/src/utils/timeParser.test.ts +11 -0
- package/src/utils/timeParser.ts +9 -2
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* Spinner, and DropdownMenu context + item roles
|
|
10
10
|
* @output Exports DropdownMenuSubMenu — a single menu row that reveals a nested
|
|
11
11
|
* menu of its own children/items: a flyout beside the row, or, on a phone,
|
|
12
|
-
* a drilled-in view in place of the parent rows.
|
|
12
|
+
* a drilled-in view in place of the parent rows. With a header or footer,
|
|
13
|
+
* that surface is a modal dialog rendered into the root's surface host,
|
|
14
|
+
* beside every menu element.
|
|
13
15
|
* @position Sub-component; place inside a DropdownMenu (or ContextMenu)
|
|
14
16
|
* alongside plain items.
|
|
15
17
|
*
|
|
@@ -65,12 +67,17 @@ import * as stylex from '@stylexjs/stylex';
|
|
|
65
67
|
import {Icon, renderIconSlot, type IconType} from '../Icon';
|
|
66
68
|
import {Spinner} from '../Spinner';
|
|
67
69
|
import {MenuRegionsContext} from './menuRegionsContext';
|
|
70
|
+
import {moveRegionsTab} from './menuRegionStops';
|
|
68
71
|
import {Item} from '../Item';
|
|
69
72
|
import {useLayer} from '../Layer/useLayer';
|
|
70
73
|
import {layerAnimations} from '../Layer/layerAnimations.stylex';
|
|
71
74
|
import {useListFocus} from '../hooks/useListFocus';
|
|
72
75
|
import {useMenuHover} from '../hooks/useMenuHover';
|
|
73
|
-
import {
|
|
76
|
+
import {
|
|
77
|
+
isMenuPressActivation,
|
|
78
|
+
MENU_PRESS_SURFACE_ATTR,
|
|
79
|
+
useMenuPress,
|
|
80
|
+
} from '../hooks/useMenuPress';
|
|
74
81
|
import {useTypeahead} from '../hooks/useTypeahead';
|
|
75
82
|
import {useMenuOverflow} from './useMenuOverflow';
|
|
76
83
|
import {resolveMenuWidth} from './menuWidth';
|
|
@@ -85,8 +92,17 @@ import {
|
|
|
85
92
|
typographyVars,
|
|
86
93
|
typeScaleVars,
|
|
87
94
|
} from '../theme/tokens.stylex';
|
|
88
|
-
import {
|
|
95
|
+
import {
|
|
96
|
+
composeEventHandlers,
|
|
97
|
+
isImeKeyEvent,
|
|
98
|
+
isRenderable,
|
|
99
|
+
mergeProps,
|
|
100
|
+
rtlStyles,
|
|
101
|
+
} from '../utils';
|
|
102
|
+
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
103
|
+
import {getInteractionModality} from '../utils/interactionModality';
|
|
89
104
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
105
|
+
import {useDevWarning} from '../hooks/useDevWarning';
|
|
90
106
|
import {themeProps} from '../utils/themeProps';
|
|
91
107
|
import type {BaseProps} from '../BaseProps';
|
|
92
108
|
import {
|
|
@@ -98,6 +114,7 @@ import {
|
|
|
98
114
|
import {
|
|
99
115
|
DropdownMenuContext,
|
|
100
116
|
DropdownMenuRootOpenContext,
|
|
117
|
+
DropdownMenuSurfaceContext,
|
|
101
118
|
useDropdownMenuContext,
|
|
102
119
|
useDropdownMenuRootOpen,
|
|
103
120
|
type DropdownMenuContextValue,
|
|
@@ -244,6 +261,70 @@ const drillInStyles = stylex.create({
|
|
|
244
261
|
},
|
|
245
262
|
});
|
|
246
263
|
|
|
264
|
+
// With a header or footer the sub-menu's surface is a frame holding the
|
|
265
|
+
// header, the sub-menu's own role="menu" element and the footer, in one column
|
|
266
|
+
// under the flyout's block cap: the regions keep their height and the rows
|
|
267
|
+
// shrink and scroll between them.
|
|
268
|
+
// A drilled-in view with a header or footer takes the root menu's place, so
|
|
269
|
+
// it caps where the root does when the root's `menuMaxHeight` lifts the 300px
|
|
270
|
+
// cap; the viewport still bounds it.
|
|
271
|
+
const dynamicRegionStyles = stylex.create({
|
|
272
|
+
viewMaxHeight: (maxHeight: string) => ({
|
|
273
|
+
maxHeight: `min(${maxHeight}, ${layerViewportInset.maxBlockSize})`,
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const regionStyles = stylex.create({
|
|
278
|
+
frame: {
|
|
279
|
+
boxSizing: 'border-box',
|
|
280
|
+
display: 'flex',
|
|
281
|
+
flexDirection: 'column',
|
|
282
|
+
maxHeight: stylex.firstThatWorks(
|
|
283
|
+
MENU_MAX_BLOCK_SIZE,
|
|
284
|
+
MENU_MAX_BLOCK_SIZE_FALLBACK,
|
|
285
|
+
),
|
|
286
|
+
'--_dropdown-menu-radius': radiusVars['--radius-container'],
|
|
287
|
+
'--_dropdown-menu-padding': spacingVars['--spacing-1'],
|
|
288
|
+
borderRadius: 'var(--_dropdown-menu-radius)',
|
|
289
|
+
},
|
|
290
|
+
// The flyout paints its own surface; a drilled-in view sits inside the
|
|
291
|
+
// root's.
|
|
292
|
+
flyoutSurface: {
|
|
293
|
+
// Each constant inside calc(), as for the plain flyout: a bare pair would
|
|
294
|
+
// ship as var(--<hash>, <fallback>) with the hash defined nowhere.
|
|
295
|
+
maxInlineSize: stylex.firstThatWorks(
|
|
296
|
+
`calc(${layerViewportInset.maxInlineSize})`,
|
|
297
|
+
`calc(${layerViewportInset.maxInlineSizeFallback})`,
|
|
298
|
+
),
|
|
299
|
+
backgroundColor: colorVars['--color-background-popover'],
|
|
300
|
+
boxShadow: shadowVars['--shadow-low'],
|
|
301
|
+
opacity: 1,
|
|
302
|
+
transitionProperty: 'opacity',
|
|
303
|
+
transitionDuration: durationVars['--duration-fast'],
|
|
304
|
+
transitionTimingFunction: easeVars['--ease-standard'],
|
|
305
|
+
},
|
|
306
|
+
menu: {
|
|
307
|
+
boxSizing: 'border-box',
|
|
308
|
+
display: 'flex',
|
|
309
|
+
flexDirection: 'column',
|
|
310
|
+
gap: spacingVars['--spacing-0-5'],
|
|
311
|
+
flexGrow: 1,
|
|
312
|
+
flexShrink: 1,
|
|
313
|
+
minBlockSize: 0,
|
|
314
|
+
padding: spacingVars['--spacing-1'],
|
|
315
|
+
outline: 'none',
|
|
316
|
+
WebkitTouchCallout: 'none',
|
|
317
|
+
userSelect: 'none',
|
|
318
|
+
},
|
|
319
|
+
region: {
|
|
320
|
+
flexShrink: 0,
|
|
321
|
+
padding: spacingVars['--spacing-1'],
|
|
322
|
+
},
|
|
323
|
+
hidden: {
|
|
324
|
+
display: 'none',
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
247
328
|
export type DropdownMenuSubMenuPresentation =
|
|
248
329
|
'flyout' | 'drill-in' | 'adaptive';
|
|
249
330
|
|
|
@@ -285,13 +366,30 @@ interface DropdownMenuSubMenuBaseProps extends Omit<
|
|
|
285
366
|
* 'adaptive'
|
|
286
367
|
*/
|
|
287
368
|
presentation?: DropdownMenuSubMenuPresentation;
|
|
369
|
+
/**
|
|
370
|
+
* Content above the sub-menu's rows: a field that filters them, say. Like
|
|
371
|
+
* the root menu's `header`, it sits beside the sub-menu's `role="menu"`
|
|
372
|
+
* element, never inside it, and keeps its own keys and presses. With a
|
|
373
|
+
* header or footer the sub-menu's surface (the flyout, or the drilled-in
|
|
374
|
+
* view on a phone) is a modal dialog named by the row: the row says
|
|
375
|
+
* `aria-haspopup="dialog"`, Tab and Shift+Tab walk the header's controls,
|
|
376
|
+
* the rows as one stop and the footer's controls and wrap, and Escape
|
|
377
|
+
* returns to the row. A pointer leaving the flyout does not close it while
|
|
378
|
+
* focus is inside. A `ContextMenu` does not show it.
|
|
379
|
+
*/
|
|
380
|
+
header?: ReactNode;
|
|
381
|
+
/**
|
|
382
|
+
* Content below the sub-menu's rows: a door to where the options are
|
|
383
|
+
* managed, say. Same rules as `header`.
|
|
384
|
+
*/
|
|
385
|
+
footer?: ReactNode;
|
|
288
386
|
/** Test id for the trigger row. */
|
|
289
387
|
'data-testid'?: string;
|
|
290
388
|
/** Test id for the flyout menu. */
|
|
291
389
|
menuDataTestId?: string;
|
|
292
390
|
/**
|
|
293
391
|
* Ref forwarded to the trigger row — the element carrying `role="menuitem"`
|
|
294
|
-
* and `aria-haspopup="menu"
|
|
392
|
+
* and `aria-haspopup="menu"` (`"dialog"` with a header or footer).
|
|
295
393
|
*/
|
|
296
394
|
ref?: React.Ref<HTMLElement>;
|
|
297
395
|
}
|
|
@@ -309,6 +407,49 @@ export interface DropdownMenuSubMenuProps extends DropdownMenuSubMenuBaseProps {
|
|
|
309
407
|
children: ReactNode;
|
|
310
408
|
}
|
|
311
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Close every open flyout with a header or footer whose row sits in `menu`.
|
|
412
|
+
* Such a flyout renders beside the root's menu, not inside the flyout that
|
|
413
|
+
* holds its row, so hiding that flyout does not hide it. Each closes through
|
|
414
|
+
* the popover API, as DropdownMenu closes its flyouts when it hides, so its
|
|
415
|
+
* layer's toggle handling resets its state and reports the close; its own
|
|
416
|
+
* nested flyouts close the same way, innermost first.
|
|
417
|
+
*/
|
|
418
|
+
function hideRegionedFlyoutsIn(menu: HTMLElement | null): void {
|
|
419
|
+
if (menu == null) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const doc = menu.ownerDocument;
|
|
423
|
+
const rows = menu.querySelectorAll<HTMLElement>(
|
|
424
|
+
'[aria-expanded="true"][aria-controls]',
|
|
425
|
+
);
|
|
426
|
+
for (const row of [...rows].reverse()) {
|
|
427
|
+
const frame = doc.getElementById(row.getAttribute('aria-controls') ?? '');
|
|
428
|
+
if (frame?.getAttribute('data-astryx-submenu-regions') !== 'flyout') {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const flyout = frame.closest<HTMLElement>('[popover]');
|
|
432
|
+
if (flyout == null) {
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (typeof flyout.hidePopover === 'function') {
|
|
436
|
+
if (flyout.matches(':popover-open')) {
|
|
437
|
+
flyout.hidePopover();
|
|
438
|
+
}
|
|
439
|
+
} else if (flyout.style.display === 'block') {
|
|
440
|
+
// Without the popover API the layer toggles `display`; mirror it and
|
|
441
|
+
// tell its reconciler, as DropdownMenu's own sweep does.
|
|
442
|
+
flyout.style.display = 'none';
|
|
443
|
+
const toggle = new Event('toggle', {bubbles: false});
|
|
444
|
+
Object.defineProperty(toggle, 'newState', {
|
|
445
|
+
value: 'closed',
|
|
446
|
+
configurable: true,
|
|
447
|
+
});
|
|
448
|
+
flyout.dispatchEvent(toggle);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
312
453
|
/**
|
|
313
454
|
* A single menu row that reveals a nested flyout of its own items. The row
|
|
314
455
|
* adopts DropdownMenuItem semantics (label / icon / description / isDisabled);
|
|
@@ -341,6 +482,8 @@ export function DropdownMenuSubMenu(
|
|
|
341
482
|
menuWidth,
|
|
342
483
|
onOpenChange,
|
|
343
484
|
presentation = 'adaptive',
|
|
485
|
+
header,
|
|
486
|
+
footer,
|
|
344
487
|
children,
|
|
345
488
|
xstyle,
|
|
346
489
|
className,
|
|
@@ -360,9 +503,29 @@ export function DropdownMenuSubMenu(
|
|
|
360
503
|
|
|
361
504
|
const t = useTranslator();
|
|
362
505
|
const menuCtx = useDropdownMenuContext();
|
|
506
|
+
const rootSurface = use(DropdownMenuSurfaceContext);
|
|
363
507
|
const rootHasRegions = use(MenuRegionsContext);
|
|
364
508
|
const menuSize = menuCtx?.menuSize ?? 'md';
|
|
365
509
|
const canOpen = !isDisabled;
|
|
510
|
+
// With a header or footer the sub-menu's surface holds controls that are not
|
|
511
|
+
// menu items, so it is a modal dialog around the sub-menu's menu, rendered
|
|
512
|
+
// into the root's surface host: outside every menu element. A root that
|
|
513
|
+
// renders no host (no surface context, as in ContextMenu) has nowhere
|
|
514
|
+
// outside its menu to put them, so the sub-menu shows its rows alone there.
|
|
515
|
+
const wantsRegions = isRenderable(header) || isRenderable(footer);
|
|
516
|
+
const rootHostsSurfaces = rootSurface != null;
|
|
517
|
+
const hasRegions = wantsRegions && rootHostsSurfaces;
|
|
518
|
+
const surfaceHost = rootSurface?.host.getElement() ?? null;
|
|
519
|
+
useDevWarning(
|
|
520
|
+
'DropdownMenuSubMenu',
|
|
521
|
+
'header and footer show only inside a DropdownMenu; this menu has no place outside its rows for them, so they are not rendered.',
|
|
522
|
+
wantsRegions && !rootHostsSurfaces,
|
|
523
|
+
);
|
|
524
|
+
const frameId = useId();
|
|
525
|
+
const frameRef = useRef<HTMLDivElement | null>(null);
|
|
526
|
+
const headerRef = useRef<HTMLDivElement | null>(null);
|
|
527
|
+
const footerRef = useRef<HTMLDivElement | null>(null);
|
|
528
|
+
const [isMenuKeyboardFocused, setIsMenuKeyboardFocused] = useState(false);
|
|
366
529
|
|
|
367
530
|
const contentId = useId();
|
|
368
531
|
const generatedTriggerId = useId();
|
|
@@ -394,17 +557,32 @@ export function DropdownMenuSubMenu(
|
|
|
394
557
|
const [isFlyoutOpen, setIsFlyoutOpen] = useState(false);
|
|
395
558
|
const isOpen = isDrillIn ? isOnStack : isFlyoutOpen;
|
|
396
559
|
|
|
560
|
+
// Whether focus is anywhere in this flyout's frame, a level nested in it
|
|
561
|
+
// included: React brings a nested level's focus events back here through
|
|
562
|
+
// its portal, where the DOM would not.
|
|
563
|
+
const isFocusInFrameRef = useRef(false);
|
|
564
|
+
// A pointer that left while focus was inside: the flyout closes once focus
|
|
565
|
+
// leaves too.
|
|
566
|
+
const closeWhenFocusLeavesRef = useRef(false);
|
|
397
567
|
const layer = useLayer({
|
|
398
568
|
mode: 'context',
|
|
399
569
|
lightDismiss: false,
|
|
400
570
|
onShow: useCallback(() => {
|
|
571
|
+
closeWhenFocusLeavesRef.current = false;
|
|
401
572
|
setIsFlyoutOpen(true);
|
|
402
573
|
onOpenChange?.(true);
|
|
403
574
|
}, [onOpenChange]),
|
|
404
575
|
onHide: useCallback(() => {
|
|
576
|
+
closeWhenFocusLeavesRef.current = false;
|
|
577
|
+
// Whatever closed this flyout, a flyout with a header or footer opened
|
|
578
|
+
// from one of its rows closes with it: that flyout renders beside the
|
|
579
|
+
// root's menu, not inside this one, so hiding this one leaves it shown.
|
|
580
|
+
hideRegionedFlyoutsIn(
|
|
581
|
+
triggerRef.current?.ownerDocument.getElementById(contentId) ?? null,
|
|
582
|
+
);
|
|
405
583
|
setIsFlyoutOpen(false);
|
|
406
584
|
onOpenChange?.(false);
|
|
407
|
-
}, [onOpenChange]),
|
|
585
|
+
}, [onOpenChange, contentId]),
|
|
408
586
|
});
|
|
409
587
|
|
|
410
588
|
const showLayer = useCallback(() => {
|
|
@@ -413,8 +591,49 @@ export function DropdownMenuSubMenu(
|
|
|
413
591
|
}
|
|
414
592
|
}, [canOpen, layer]);
|
|
415
593
|
const hideLayer = useCallback(() => {
|
|
594
|
+
// A pointer leaving a flyout with a header or footer does not close it
|
|
595
|
+
// while focus is inside: what was typed into its field stays.
|
|
596
|
+
if (hasRegions && isFocusInFrameRef.current) {
|
|
597
|
+
closeWhenFocusLeavesRef.current = true;
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
416
600
|
layer.hide();
|
|
417
|
-
}, [layer]);
|
|
601
|
+
}, [layer, hasRegions]);
|
|
602
|
+
const handleFrameFocus = useCallback(() => {
|
|
603
|
+
isFocusInFrameRef.current = true;
|
|
604
|
+
}, []);
|
|
605
|
+
const handleFrameBlur = useCallback(
|
|
606
|
+
(event: React.FocusEvent<HTMLElement>) => {
|
|
607
|
+
isFocusInFrameRef.current = false;
|
|
608
|
+
if (!closeWhenFocusLeavesRef.current) {
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
// Focus moving to another control in the frame keeps it open.
|
|
612
|
+
const next = event.relatedTarget;
|
|
613
|
+
if (next instanceof Node && event.currentTarget.contains(next)) {
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
// Focus moving into a level nested in this one (React brings that
|
|
617
|
+
// focus event back through its portal), or moved by the browser itself
|
|
618
|
+
// (an arrow key in a native radio group, whose focus event follows a
|
|
619
|
+
// microtask checkpoint), arrives after this blur: decide in a task of
|
|
620
|
+
// its own, once the move has completed.
|
|
621
|
+
setTimeout(() => {
|
|
622
|
+
if (
|
|
623
|
+
!isFocusInFrameRef.current &&
|
|
624
|
+
closeWhenFocusLeavesRef.current &&
|
|
625
|
+
triggerRef.current?.isConnected === true
|
|
626
|
+
) {
|
|
627
|
+
closeWhenFocusLeavesRef.current = false;
|
|
628
|
+
layer.hide();
|
|
629
|
+
}
|
|
630
|
+
}, 0);
|
|
631
|
+
},
|
|
632
|
+
[layer],
|
|
633
|
+
);
|
|
634
|
+
const handleFramePointerEnter = useCallback(() => {
|
|
635
|
+
closeWhenFocusLeavesRef.current = false;
|
|
636
|
+
}, []);
|
|
418
637
|
|
|
419
638
|
// Dedicated roving-focus + typeahead for this flyout level. The boundary
|
|
420
639
|
// selector scopes item collection and key handling to this flyout's own
|
|
@@ -435,8 +654,13 @@ export function DropdownMenuSubMenu(
|
|
|
435
654
|
hasPaging: true,
|
|
436
655
|
onEscape: () => close({focusTrigger: true}),
|
|
437
656
|
});
|
|
438
|
-
// A drilled-in list scrolls with the root menu, not on its own
|
|
439
|
-
|
|
657
|
+
// A drilled-in list scrolls with the root menu, not on its own, unless it
|
|
658
|
+
// has a header or footer: then its rows scroll between them.
|
|
659
|
+
const hasOverflow = useMenuOverflow(
|
|
660
|
+
menuRef,
|
|
661
|
+
children,
|
|
662
|
+
isOpen && (!isDrillIn || hasRegions),
|
|
663
|
+
);
|
|
440
664
|
|
|
441
665
|
const typeahead = useTypeahead({
|
|
442
666
|
getItemLabels: () => getItems().map(el => el.textContent),
|
|
@@ -465,10 +689,10 @@ export function DropdownMenuSubMenu(
|
|
|
465
689
|
isOpen: isFlyoutOpen,
|
|
466
690
|
isEnabled: canOpen && !isDrillIn && isRootMenuOpen,
|
|
467
691
|
// The safe triangle toward the flyout's near edge is built from the
|
|
468
|
-
// flyout this component renders, not the hook's own list ref
|
|
469
|
-
|
|
692
|
+
// flyout this component renders, not the hook's own list ref: the
|
|
693
|
+
// frame, with a header or footer.
|
|
694
|
+
flyoutRef: hasRegions ? frameRef : menuRef,
|
|
470
695
|
});
|
|
471
|
-
|
|
472
696
|
// Where focus lands once a pushed view shows: the first row after Back for
|
|
473
697
|
// a keyboard open, the list itself for a pointer open; and the row itself
|
|
474
698
|
// once its view is popped.
|
|
@@ -664,12 +888,29 @@ export function DropdownMenuSubMenu(
|
|
|
664
888
|
// stopPropagation needed — each level self-filters.
|
|
665
889
|
const handleContentKeyDown = useCallback(
|
|
666
890
|
(e: React.KeyboardEvent) => {
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
891
|
+
if (e.key === 'Tab') {
|
|
892
|
+
// With its own header or footer this level is a dialog: Tab walks
|
|
893
|
+
// its regions and its rows as one stop, and wraps. A deeper level
|
|
894
|
+
// has closed itself as the key bubbled up, and the walk continues
|
|
895
|
+
// from this level's rows.
|
|
896
|
+
if (hasRegions) {
|
|
897
|
+
moveRegionsTab(
|
|
898
|
+
e,
|
|
899
|
+
menuRef.current,
|
|
900
|
+
headerRef.current,
|
|
901
|
+
footerRef.current,
|
|
902
|
+
!ownsEvent(e),
|
|
903
|
+
);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
// In a menu with a header or footer above this level, Tab leaves the
|
|
907
|
+
// flyout stack: every open level closes as the key bubbles past it,
|
|
908
|
+
// this one's own rows or a deeper level's, and the level with the
|
|
909
|
+
// regions moves focus on.
|
|
910
|
+
if (rootHasRegions) {
|
|
911
|
+
close({focusTrigger: false});
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
673
914
|
}
|
|
674
915
|
if (!ownsEvent(e)) {
|
|
675
916
|
return;
|
|
@@ -718,7 +959,46 @@ export function DropdownMenuSubMenu(
|
|
|
718
959
|
}
|
|
719
960
|
listNavKeyDown(e);
|
|
720
961
|
},
|
|
721
|
-
[
|
|
962
|
+
[
|
|
963
|
+
ownsEvent,
|
|
964
|
+
close,
|
|
965
|
+
listNavKeyDown,
|
|
966
|
+
typeahead,
|
|
967
|
+
menuRef,
|
|
968
|
+
rootHasRegions,
|
|
969
|
+
hasRegions,
|
|
970
|
+
],
|
|
971
|
+
);
|
|
972
|
+
|
|
973
|
+
// A region keeps its own keys: arrows move a field's caret and letters type.
|
|
974
|
+
// Tab moves on through the dialog's stops, and Escape returns to the row as
|
|
975
|
+
// it does from the rows.
|
|
976
|
+
const handleRegionKeyDown = useCallback(
|
|
977
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
978
|
+
if (e.key === 'Tab') {
|
|
979
|
+
moveRegionsTab(
|
|
980
|
+
e,
|
|
981
|
+
menuRef.current,
|
|
982
|
+
headerRef.current,
|
|
983
|
+
footerRef.current,
|
|
984
|
+
);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
// An Escape that ends an IME composition belongs to the field, and one a
|
|
988
|
+
// control in the region already handled (a menu it opened closing, a
|
|
989
|
+
// field clearing itself) belongs to that control: either closes nothing
|
|
990
|
+
// here, so one Escape affects one surface.
|
|
991
|
+
if (
|
|
992
|
+
e.key === 'Escape' &&
|
|
993
|
+
!e.defaultPrevented &&
|
|
994
|
+
!isImeKeyEvent(e.nativeEvent)
|
|
995
|
+
) {
|
|
996
|
+
e.preventDefault();
|
|
997
|
+
e.stopPropagation();
|
|
998
|
+
close({focusTrigger: true});
|
|
999
|
+
}
|
|
1000
|
+
},
|
|
1001
|
+
[menuRef, close],
|
|
722
1002
|
);
|
|
723
1003
|
|
|
724
1004
|
// Re-provide the menu context so nested items behave exactly like top-level
|
|
@@ -776,35 +1056,133 @@ export function DropdownMenuSubMenu(
|
|
|
776
1056
|
: flyoutStyles.popoverCustomWidth(resolvedMenuWidth.value)
|
|
777
1057
|
: flyoutStyles.popover;
|
|
778
1058
|
|
|
1059
|
+
// The flyout is a layer of its own, where layer-scoped contexts reset:
|
|
1060
|
+
// nested rows get this level's menu context and the root's surface again.
|
|
779
1061
|
const nestedRows = (
|
|
780
1062
|
<DropdownMenuContext value={nestedMenuContext}>
|
|
781
|
-
{
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1063
|
+
<DropdownMenuSurfaceContext value={rootSurface}>
|
|
1064
|
+
{isDrillIn && (
|
|
1065
|
+
<DropdownMenuItem
|
|
1066
|
+
icon={
|
|
1067
|
+
<Icon
|
|
1068
|
+
icon="chevronLeft"
|
|
1069
|
+
size="sm"
|
|
1070
|
+
color="secondary"
|
|
1071
|
+
xstyle={rtlStyles.mirror}
|
|
1072
|
+
/>
|
|
1073
|
+
}
|
|
1074
|
+
label={backLabel}
|
|
1075
|
+
hasCloseOnSelect={false}
|
|
1076
|
+
onClick={() => close({focusTrigger: true})}
|
|
1077
|
+
/>
|
|
1078
|
+
)}
|
|
1079
|
+
{children}
|
|
1080
|
+
</DropdownMenuSurfaceContext>
|
|
797
1081
|
</DropdownMenuContext>
|
|
798
1082
|
);
|
|
1083
|
+
// A level with its own header or footer tells the levels below it, so Tab
|
|
1084
|
+
// from a deeper flyout closes that flyout and this level walks on.
|
|
1085
|
+
//
|
|
799
1086
|
// Nested rows live in this level's flyout, a layer of their own where the
|
|
800
|
-
// layer-scoped signal resets; re-provide it
|
|
801
|
-
// dies with the root menu
|
|
1087
|
+
// layer-scoped signal resets; re-provide it, as whether every level above
|
|
1088
|
+
// them is open, so their pending hover intent dies with the root menu or
|
|
1089
|
+
// with this level: a hover-open that fired after this level closed would
|
|
1090
|
+
// leave a flyout with a header or footer shown beside the root's menu.
|
|
802
1091
|
const nestedContent = (
|
|
803
|
-
<DropdownMenuRootOpenContext value={isRootMenuOpen}>
|
|
804
|
-
{
|
|
1092
|
+
<DropdownMenuRootOpenContext value={isRootMenuOpen && isOpen}>
|
|
1093
|
+
{hasRegions ? (
|
|
1094
|
+
<MenuRegionsContext value={true}>{nestedRows}</MenuRegionsContext>
|
|
1095
|
+
) : (
|
|
1096
|
+
nestedRows
|
|
1097
|
+
)}
|
|
805
1098
|
</DropdownMenuRootOpenContext>
|
|
806
1099
|
);
|
|
807
1100
|
|
|
1101
|
+
// The menu of a sub-menu with a header or footer renders beside the root's
|
|
1102
|
+
// menu, outside its DOM: it names the root so the press model counts it as
|
|
1103
|
+
// nested there, as a flyout inside the root's menu is. Only this menu does:
|
|
1104
|
+
// a picker placed in its header or footer stays its own press root.
|
|
1105
|
+
const surfaceOwnerProps =
|
|
1106
|
+
hasRegions && rootSurface != null
|
|
1107
|
+
? {[MENU_PRESS_SURFACE_ATTR]: rootSurface.ownerId}
|
|
1108
|
+
: undefined;
|
|
1109
|
+
|
|
1110
|
+
// With a header or footer, the dialog that holds them and the sub-menu's
|
|
1111
|
+
// menu. A drilled-in view is named as its list is, by the row's label; the
|
|
1112
|
+
// flyout by the row.
|
|
1113
|
+
const renderRegionsFrame = (
|
|
1114
|
+
menuElement: ReactNode,
|
|
1115
|
+
frameProps: React.HTMLAttributes<HTMLDivElement>,
|
|
1116
|
+
surface: 'flyout' | 'view',
|
|
1117
|
+
): ReactNode => (
|
|
1118
|
+
<div
|
|
1119
|
+
ref={frameRef}
|
|
1120
|
+
id={frameId}
|
|
1121
|
+
role="dialog"
|
|
1122
|
+
aria-modal="true"
|
|
1123
|
+
// Read by the root's step-aside rule (a shown view) and by the flyout
|
|
1124
|
+
// that holds this row when it closes (an open flyout).
|
|
1125
|
+
data-astryx-submenu-regions={surface}
|
|
1126
|
+
{...frameProps}
|
|
1127
|
+
{...(surface === 'flyout'
|
|
1128
|
+
? {
|
|
1129
|
+
onFocus: handleFrameFocus,
|
|
1130
|
+
onBlur: handleFrameBlur,
|
|
1131
|
+
onPointerEnter: handleFramePointerEnter,
|
|
1132
|
+
}
|
|
1133
|
+
: undefined)}
|
|
1134
|
+
{...(surface === 'flyout'
|
|
1135
|
+
? // The flyout's frame paints its surface, so the menu target rides
|
|
1136
|
+
// on it; a drilled-in view sits inside the root's surface.
|
|
1137
|
+
mergeProps(
|
|
1138
|
+
themeProps('dropdown-menu'),
|
|
1139
|
+
stylex.props(regionStyles.frame, regionStyles.flyoutSurface),
|
|
1140
|
+
)
|
|
1141
|
+
: stylex.props(
|
|
1142
|
+
regionStyles.frame,
|
|
1143
|
+
rootSurface?.menuMaxHeight != null &&
|
|
1144
|
+
dynamicRegionStyles.viewMaxHeight(rootSurface.menuMaxHeight),
|
|
1145
|
+
frameProps.hidden === true && regionStyles.hidden,
|
|
1146
|
+
))}>
|
|
1147
|
+
{isRenderable(header) && (
|
|
1148
|
+
<div
|
|
1149
|
+
ref={headerRef}
|
|
1150
|
+
onKeyDown={handleRegionKeyDown}
|
|
1151
|
+
{...stylex.props(regionStyles.region)}>
|
|
1152
|
+
{header}
|
|
1153
|
+
</div>
|
|
1154
|
+
)}
|
|
1155
|
+
{menuElement}
|
|
1156
|
+
{isRenderable(footer) && (
|
|
1157
|
+
<div
|
|
1158
|
+
ref={footerRef}
|
|
1159
|
+
onKeyDown={handleRegionKeyDown}
|
|
1160
|
+
{...stylex.props(regionStyles.region)}>
|
|
1161
|
+
{footer}
|
|
1162
|
+
</div>
|
|
1163
|
+
)}
|
|
1164
|
+
</div>
|
|
1165
|
+
);
|
|
1166
|
+
// A flyout with a header or footer renders into the root's surface host,
|
|
1167
|
+
// beside the root's menu rather than inside it; any other flyout renders
|
|
1168
|
+
// in place, inside the menu that holds its row.
|
|
1169
|
+
const renderFlyout = (flyout: ReactNode): ReactNode =>
|
|
1170
|
+
hasRegions && surfaceHost != null
|
|
1171
|
+
? createPortal(flyout, surfaceHost)
|
|
1172
|
+
: flyout;
|
|
1173
|
+
// Between a header and a footer the rows are one Tab stop, so keyboard focus
|
|
1174
|
+
// on the menu element shows the ring the base style otherwise removes.
|
|
1175
|
+
const handleMenuFocus = (event: React.FocusEvent<HTMLDivElement>) => {
|
|
1176
|
+
if (hasRegions && event.target === event.currentTarget) {
|
|
1177
|
+
setIsMenuKeyboardFocused(getInteractionModality() === 'keyboard');
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
const handleMenuBlur = (event: React.FocusEvent<HTMLDivElement>) => {
|
|
1181
|
+
if (hasRegions && event.target === event.currentTarget) {
|
|
1182
|
+
setIsMenuKeyboardFocused(false);
|
|
1183
|
+
}
|
|
1184
|
+
};
|
|
1185
|
+
|
|
808
1186
|
return (
|
|
809
1187
|
<>
|
|
810
1188
|
<Item
|
|
@@ -816,9 +1194,9 @@ export function DropdownMenuSubMenu(
|
|
|
816
1194
|
id={triggerId}
|
|
817
1195
|
role="menuitem"
|
|
818
1196
|
tabIndex={isDisabled ? undefined : -1}
|
|
819
|
-
aria-haspopup=
|
|
1197
|
+
aria-haspopup={hasRegions ? 'dialog' : 'menu'}
|
|
820
1198
|
aria-expanded={isOpen}
|
|
821
|
-
aria-controls={isOpen ? contentId : undefined}
|
|
1199
|
+
aria-controls={isOpen ? (hasRegions ? frameId : contentId) : undefined}
|
|
822
1200
|
aria-disabled={isDisabled || undefined}
|
|
823
1201
|
data-testid={testId}
|
|
824
1202
|
onMouseEnter={composeEventHandlers(
|
|
@@ -856,66 +1234,145 @@ export function DropdownMenuSubMenu(
|
|
|
856
1234
|
{activeDrillIn != null
|
|
857
1235
|
? isOnStack &&
|
|
858
1236
|
activeDrillIn.host != null &&
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
themeProps('dropdown-menu'),
|
|
899
|
-
stylex.props(
|
|
900
|
-
flyoutStyles.menu,
|
|
901
|
-
hasOverflow
|
|
902
|
-
? flyoutStyles.touchPanYPinchZoom
|
|
903
|
-
: flyoutStyles.touchPinchZoom,
|
|
904
|
-
hasOverflow && flyoutStyles.scrollable,
|
|
1237
|
+
(hasRegions
|
|
1238
|
+
? // A view with a header or footer renders beside the root's menu,
|
|
1239
|
+
// in its surface host, while the root steps its menu aside.
|
|
1240
|
+
createPortal(
|
|
1241
|
+
renderRegionsFrame(
|
|
1242
|
+
<div
|
|
1243
|
+
ref={menuRef}
|
|
1244
|
+
id={contentId}
|
|
1245
|
+
role="menu"
|
|
1246
|
+
tabIndex={0}
|
|
1247
|
+
aria-label={stringLabel}
|
|
1248
|
+
aria-labelledby={
|
|
1249
|
+
stringLabel == null ? triggerId : undefined
|
|
1250
|
+
}
|
|
1251
|
+
onKeyDown={handleContentKeyDown}
|
|
1252
|
+
onFocus={handleMenuFocus}
|
|
1253
|
+
onBlur={handleMenuBlur}
|
|
1254
|
+
data-testid={menuDataTestId}
|
|
1255
|
+
{...menuPress.menuProps}
|
|
1256
|
+
{...surfaceOwnerProps}
|
|
1257
|
+
{...stylex.props(
|
|
1258
|
+
regionStyles.menu,
|
|
1259
|
+
hasOverflow && flyoutStyles.scrollable,
|
|
1260
|
+
hasOverflow
|
|
1261
|
+
? flyoutStyles.touchPanYPinchZoom
|
|
1262
|
+
: flyoutStyles.touchPinchZoom,
|
|
1263
|
+
isMenuKeyboardFocused && focusOutlineStyles.focusVisible,
|
|
1264
|
+
)}>
|
|
1265
|
+
{nestedContent}
|
|
1266
|
+
</div>,
|
|
1267
|
+
{
|
|
1268
|
+
'aria-label': stringLabel,
|
|
1269
|
+
'aria-labelledby':
|
|
1270
|
+
stringLabel == null ? triggerId : undefined,
|
|
1271
|
+
// A deeper view shows in its place; this one waits, hidden.
|
|
1272
|
+
hidden: !isTopView,
|
|
1273
|
+
inert: !isTopView,
|
|
1274
|
+
},
|
|
1275
|
+
'view',
|
|
905
1276
|
),
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1277
|
+
surfaceHost ?? activeDrillIn.host,
|
|
1278
|
+
)
|
|
1279
|
+
: createPortal(
|
|
1280
|
+
<div
|
|
1281
|
+
ref={menuRef}
|
|
1282
|
+
id={contentId}
|
|
1283
|
+
role="menu"
|
|
1284
|
+
tabIndex={-1}
|
|
1285
|
+
// The drilled-in list is named by its row's label.
|
|
1286
|
+
aria-label={stringLabel}
|
|
1287
|
+
aria-labelledby={stringLabel == null ? triggerId : undefined}
|
|
1288
|
+
// A deeper view shows in its place; this one waits, hidden.
|
|
1289
|
+
hidden={!isTopView}
|
|
1290
|
+
inert={!isTopView}
|
|
1291
|
+
onKeyDown={handleContentKeyDown}
|
|
1292
|
+
data-testid={menuDataTestId}
|
|
1293
|
+
{...menuPress.menuProps}
|
|
1294
|
+
{...stylex.props(
|
|
1295
|
+
drillInStyles.list,
|
|
1296
|
+
!isTopView && drillInStyles.hidden,
|
|
1297
|
+
)}>
|
|
1298
|
+
{nestedContent}
|
|
1299
|
+
</div>,
|
|
1300
|
+
activeDrillIn.host,
|
|
1301
|
+
))
|
|
1302
|
+
: renderFlyout(
|
|
1303
|
+
layer.render(
|
|
1304
|
+
hasRegions ? (
|
|
1305
|
+
renderRegionsFrame(
|
|
1306
|
+
<div
|
|
1307
|
+
ref={menuRef}
|
|
1308
|
+
id={contentId}
|
|
1309
|
+
role="menu"
|
|
1310
|
+
// One Tab stop between the regions.
|
|
1311
|
+
tabIndex={0}
|
|
1312
|
+
aria-labelledby={triggerId}
|
|
1313
|
+
onKeyDown={handleContentKeyDown}
|
|
1314
|
+
onFocus={handleMenuFocus}
|
|
1315
|
+
onBlur={handleMenuBlur}
|
|
1316
|
+
data-testid={menuDataTestId}
|
|
1317
|
+
{...menuPress.menuProps}
|
|
1318
|
+
{...surfaceOwnerProps}
|
|
1319
|
+
{...stylex.props(
|
|
1320
|
+
regionStyles.menu,
|
|
1321
|
+
hasOverflow
|
|
1322
|
+
? flyoutStyles.touchPanYPinchZoom
|
|
1323
|
+
: flyoutStyles.touchPinchZoom,
|
|
1324
|
+
hasOverflow && flyoutStyles.scrollable,
|
|
1325
|
+
isMenuKeyboardFocused && focusOutlineStyles.focusVisible,
|
|
1326
|
+
)}>
|
|
1327
|
+
{nestedContent}
|
|
1328
|
+
</div>,
|
|
1329
|
+
{
|
|
1330
|
+
'aria-labelledby': triggerId,
|
|
1331
|
+
// Hovering the regions is hovering the flyout.
|
|
1332
|
+
onMouseEnter: contentProps.onMouseEnter,
|
|
1333
|
+
onMouseLeave: contentProps.onMouseLeave,
|
|
1334
|
+
},
|
|
1335
|
+
'flyout',
|
|
1336
|
+
)
|
|
1337
|
+
) : (
|
|
1338
|
+
<div
|
|
1339
|
+
ref={menuRef}
|
|
1340
|
+
id={contentId}
|
|
1341
|
+
role="menu"
|
|
1342
|
+
// Focusable as a fallback target so an empty/loading flyout can own
|
|
1343
|
+
// arrow/Escape keys. An overflowing flyout joins the Tab order so its
|
|
1344
|
+
// scrollable region is keyboard-accessible.
|
|
1345
|
+
tabIndex={hasOverflow ? 0 : -1}
|
|
1346
|
+
aria-labelledby={triggerId}
|
|
1347
|
+
onKeyDown={handleContentKeyDown}
|
|
1348
|
+
onMouseEnter={contentProps.onMouseEnter}
|
|
1349
|
+
onMouseLeave={contentProps.onMouseLeave}
|
|
1350
|
+
data-testid={menuDataTestId}
|
|
1351
|
+
{...menuPress.menuProps}
|
|
1352
|
+
{...mergeProps(
|
|
1353
|
+
themeProps('dropdown-menu'),
|
|
1354
|
+
stylex.props(
|
|
1355
|
+
flyoutStyles.menu,
|
|
1356
|
+
hasOverflow
|
|
1357
|
+
? flyoutStyles.touchPanYPinchZoom
|
|
1358
|
+
: flyoutStyles.touchPinchZoom,
|
|
1359
|
+
hasOverflow && flyoutStyles.scrollable,
|
|
1360
|
+
),
|
|
1361
|
+
)}>
|
|
1362
|
+
{nestedContent}
|
|
1363
|
+
</div>
|
|
1364
|
+
),
|
|
1365
|
+
{
|
|
1366
|
+
placement: 'end',
|
|
1367
|
+
alignment: 'start',
|
|
1368
|
+
offset: spacingVars['--spacing-1'],
|
|
1369
|
+
xstyle: [
|
|
1370
|
+
flyoutStyles.popoverViewport,
|
|
1371
|
+
popoverXstyle,
|
|
1372
|
+
layerAnimations.end,
|
|
1373
|
+
],
|
|
1374
|
+
},
|
|
1375
|
+
),
|
|
919
1376
|
)}
|
|
920
1377
|
</>
|
|
921
1378
|
);
|