@astryxdesign/core 0.7.2-canary.e2909cf → 0.7.2-canary.fbc03f7
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/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
- package/dist/Breadcrumbs/BreadcrumbItem.js +5 -2
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +5 -2
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +46 -3
- package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -0
- package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuContext.js +21 -0
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +15 -4
- package/dist/Field/Field.d.ts +1 -1
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +8 -4
- package/dist/Grid/GridSpan.d.ts.map +1 -1
- package/dist/Grid/GridSpan.js +1 -0
- package/dist/MetadataList/MetadataList.d.ts.map +1 -1
- package/dist/MetadataList/MetadataList.js +5 -1
- package/dist/MetadataList/MetadataListContext.d.ts +1 -1
- package/dist/MetadataList/MetadataListContext.d.ts.map +1 -1
- package/dist/RadioList/RadioList.d.ts +1 -1
- package/dist/RadioList/RadioList.d.ts.map +1 -1
- package/dist/RadioList/RadioList.js +6 -2
- package/dist/ToggleButton/ToggleButton.d.ts +3 -2
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +12 -1
- package/dist/astryx.css +1 -0
- package/dist/hooks/useMenuHover.d.ts.map +1 -1
- package/dist/hooks/useMenuHover.js +18 -13
- package/package.json +3 -3
- package/src/Avatar/Avatar.doc.mjs +5 -3
- package/src/AvatarGroup/AvatarGroup.doc.mjs +3 -2
- package/src/Breadcrumbs/BreadcrumbItem.tsx +4 -1
- package/src/Chat/__tests__/ChatMessageMetadata.a11y.chromium.spec.ts +3 -1
- package/src/ContextMenu/ContextMenu.tsx +4 -1
- package/src/DropdownMenu/DropdownMenu.tsx +45 -2
- package/src/DropdownMenu/DropdownMenuContext.tsx +24 -0
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +185 -0
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +17 -3
- package/src/Field/Field.doc.mjs +14 -8
- package/src/Field/Field.spec.md +217 -0
- package/src/Field/Field.test.tsx +60 -2
- package/src/Field/Field.tsx +17 -8
- package/src/Grid/GridSpan.tsx +3 -0
- package/src/MetadataList/MetadataList.doc.mjs +2 -2
- package/src/MetadataList/MetadataList.test.tsx +38 -0
- package/src/MetadataList/MetadataList.tsx +8 -1
- package/src/MetadataList/MetadataListContext.tsx +1 -1
- package/src/RadioList/RadioList.fr10.test.tsx +45 -2
- package/src/RadioList/RadioList.tsx +4 -1
- package/src/Slider/Slider.spec.md +39 -18
- package/src/TimeInput/TimeInput.fr10.test.tsx +15 -0
- package/src/ToggleButton/ToggleButton.test.tsx +95 -0
- package/src/ToggleButton/ToggleButton.tsx +12 -1
- package/src/docPropLiterals.test.ts +36 -10
- package/src/hooks/useMenuHover.ts +18 -13
|
@@ -67,8 +67,10 @@ export const docs = {
|
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
name: 'size',
|
|
70
|
-
type:
|
|
71
|
-
|
|
70
|
+
type:
|
|
71
|
+
"'xsm' | 'sm' | 'md' | 'lg' | 'xl' | 16 | 20 | 24 | 32 | 36 | 40 | 48 | 60 | 64 | 72 | 96 | 128 | 144 | 180",
|
|
72
|
+
description:
|
|
73
|
+
"Avatar size: named sizes are 'xsm' (20px), 'sm' (24px), 'md' (36px), 'lg' (48px), and 'xl' (128px); numeric sizes must be one of the pixel values listed in the type. Inside an AvatarGroup, the group's size always overrides this prop, including when the group uses its default size.",
|
|
72
74
|
default: "'md'",
|
|
73
75
|
},
|
|
74
76
|
{
|
|
@@ -169,7 +171,7 @@ export const docsDense = {
|
|
|
169
171
|
fallbackSrc: 'fallback image when primary fails',
|
|
170
172
|
name: 'user name for initials and alt text',
|
|
171
173
|
alt: 'alt text; falls back to name',
|
|
172
|
-
size: "
|
|
174
|
+
size: "named sizes: 'xsm' 20px, 'sm' 24px, 'md' 36px, 'lg' 48px, 'xl' 128px; numeric px restricted to values listed in the type. AvatarGroup's size always wins, default included.",
|
|
173
175
|
status:
|
|
174
176
|
'corner content for status indicators; AvatarStatusDot reports its `label`, composed into the avatar accessible name ("Jane Doe, Online"), at any nesting depth',
|
|
175
177
|
tooltip:
|
|
@@ -48,9 +48,10 @@ export const docs = {
|
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
name: 'size',
|
|
51
|
-
type:
|
|
51
|
+
type:
|
|
52
|
+
"'xsm' | 'sm' | 'md' | 'lg' | 'xl' | 16 | 20 | 24 | 32 | 36 | 40 | 48 | 60 | 64 | 72 | 96 | 128 | 144 | 180",
|
|
52
53
|
description:
|
|
53
|
-
"Size applied to all avatars
|
|
54
|
+
"Size applied to all avatars: named sizes are 'xsm' (20px), 'sm' (24px), 'md' (36px), 'lg' (48px), and 'xl' (128px); numeric sizes must be one of the pixel values listed in the type. The group's size always overrides each child Avatar's size, including when the group uses its default size.",
|
|
54
55
|
default: "'md'",
|
|
55
56
|
},
|
|
56
57
|
{
|
|
@@ -56,6 +56,7 @@ import {layerAnimations} from '../Layer/layerAnimations.stylex';
|
|
|
56
56
|
import {renderDropdownItems} from '../DropdownMenu/renderDropdownItems';
|
|
57
57
|
import {
|
|
58
58
|
DropdownMenuContext,
|
|
59
|
+
DropdownMenuRootOpenContext,
|
|
59
60
|
type DropdownMenuContextValue,
|
|
60
61
|
type DropdownMenuSize,
|
|
61
62
|
} from '../DropdownMenu/DropdownMenuContext';
|
|
@@ -673,7 +674,9 @@ function BreadcrumbMenuTrigger({
|
|
|
673
674
|
stylex.props(menuStyles.menu),
|
|
674
675
|
)}>
|
|
675
676
|
<DropdownMenuContext value={contextValue}>
|
|
676
|
-
{
|
|
677
|
+
<DropdownMenuRootOpenContext value={popover.isOpen}>
|
|
678
|
+
{menuContent}
|
|
679
|
+
</DropdownMenuRootOpenContext>
|
|
677
680
|
</DropdownMenuContext>
|
|
678
681
|
</div>,
|
|
679
682
|
{
|
|
@@ -395,7 +395,9 @@ test('captures narrow touch reflow in both modes', async ({browser}) => {
|
|
|
395
395
|
try {
|
|
396
396
|
const page = await context.newPage();
|
|
397
397
|
const scenario = CASES.find(item => item.name === 'narrow-overflow');
|
|
398
|
-
if (!scenario) {
|
|
398
|
+
if (!scenario) {
|
|
399
|
+
throw new Error('Narrow metadata fixture is missing');
|
|
400
|
+
}
|
|
399
401
|
const failures: string[] = [];
|
|
400
402
|
for (const mode of ['light', 'dark'] as const) {
|
|
401
403
|
failures.push(
|
|
@@ -56,6 +56,7 @@ import {MenuBottomSheetActionList} from '../DropdownMenu/MenuBottomSheetActionLi
|
|
|
56
56
|
import {renderDropdownItems} from '../DropdownMenu/renderDropdownItems';
|
|
57
57
|
import {
|
|
58
58
|
DropdownMenuContext,
|
|
59
|
+
DropdownMenuRootOpenContext,
|
|
59
60
|
type DropdownMenuContextValue,
|
|
60
61
|
} from '../DropdownMenu/DropdownMenuContext';
|
|
61
62
|
import {
|
|
@@ -701,7 +702,9 @@ export function ContextMenu({
|
|
|
701
702
|
style,
|
|
702
703
|
)}>
|
|
703
704
|
<DropdownMenuContext value={contextValue}>
|
|
704
|
-
{
|
|
705
|
+
<DropdownMenuRootOpenContext value={isOpen}>
|
|
706
|
+
{resolvedMenuContent}
|
|
707
|
+
</DropdownMenuRootOpenContext>
|
|
705
708
|
</DropdownMenuContext>
|
|
706
709
|
</div>
|
|
707
710
|
);
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
} from './menuItemRoles';
|
|
61
61
|
import {
|
|
62
62
|
DropdownMenuContext,
|
|
63
|
+
DropdownMenuRootOpenContext,
|
|
63
64
|
type DropdownMenuContextValue,
|
|
64
65
|
} from './DropdownMenuContext';
|
|
65
66
|
import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
|
|
@@ -987,6 +988,43 @@ function DropdownMenuPopover({
|
|
|
987
988
|
// above it ends the gesture in flight through this ref.
|
|
988
989
|
const cancelMenuPressRef = useRef<() => void>(() => {});
|
|
989
990
|
|
|
991
|
+
// Submenu flyouts are `popover="manual"` elements nested inside the menu's
|
|
992
|
+
// `popover="auto"` element, so the browser's light dismiss of the menu never
|
|
993
|
+
// reaches them: without a sweep they keep their open state and their
|
|
994
|
+
// top-layer entry while the menu is hidden, and re-anchor to the viewport
|
|
995
|
+
// corner when the menu reopens (#6893). Closing them through the popover
|
|
996
|
+
// API lets useLayer's toggle handling reset their state. Only flyouts that
|
|
997
|
+
// are actually open are touched: `:popover-open` ships wherever
|
|
998
|
+
// `hidePopover` does (they are one API), and the display fallback keeps its
|
|
999
|
+
// own open signal (`display: block`) so it is guarded the same way.
|
|
1000
|
+
const hideOpenFlyouts = useCallback(() => {
|
|
1001
|
+
const menuPopover = contentRef.current;
|
|
1002
|
+
if (!menuPopover) {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
// Innermost first: hiding a flyout may unmount the flyouts nested inside
|
|
1006
|
+
// it, and a detached element no longer has the toggle listener that
|
|
1007
|
+
// resets its open state.
|
|
1008
|
+
const flyouts = [
|
|
1009
|
+
...menuPopover.querySelectorAll<HTMLElement>('[popover]'),
|
|
1010
|
+
].reverse();
|
|
1011
|
+
for (const flyout of flyouts) {
|
|
1012
|
+
if (typeof flyout.hidePopover === 'function') {
|
|
1013
|
+
if (flyout.matches(':popover-open')) {
|
|
1014
|
+
flyout.hidePopover();
|
|
1015
|
+
}
|
|
1016
|
+
} else if (flyout.style.display === 'block') {
|
|
1017
|
+
flyout.style.display = 'none';
|
|
1018
|
+
const toggle = new Event('toggle', {bubbles: false});
|
|
1019
|
+
Object.defineProperty(toggle, 'newState', {
|
|
1020
|
+
value: 'closed',
|
|
1021
|
+
configurable: true,
|
|
1022
|
+
});
|
|
1023
|
+
flyout.dispatchEvent(toggle);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}, []);
|
|
1027
|
+
|
|
990
1028
|
// Focus lands somewhere stated when the menu closes. A press outside that
|
|
991
1029
|
// landed on a focusable control has already moved focus there; leave it.
|
|
992
1030
|
// Otherwise — a row acted, Escape, a press on nothing — focus returns to
|
|
@@ -994,6 +1032,9 @@ function DropdownMenuPopover({
|
|
|
994
1032
|
// keyboard (the modality tracker decides; the suppressed style hides the
|
|
995
1033
|
// pointer case, which Safari would otherwise paint after a touch pick).
|
|
996
1034
|
const handleLayerHide = useCallback(() => {
|
|
1035
|
+
// Whatever closed the menu — light dismiss, the dismissal stack, Escape
|
|
1036
|
+
// or a controlled close — its flyouts close with it.
|
|
1037
|
+
hideOpenFlyouts();
|
|
997
1038
|
if (suppressControlledRollbackHideRef.current) {
|
|
998
1039
|
suppressControlledRollbackHideRef.current = false;
|
|
999
1040
|
return;
|
|
@@ -1024,7 +1065,7 @@ function DropdownMenuPopover({
|
|
|
1024
1065
|
// also reaches a control the caller renders, which the browser would
|
|
1025
1066
|
// otherwise ring (Chromium after a mouse pick, WebKit after a touch).
|
|
1026
1067
|
focusForLastInput(trigger, {preventScroll: true});
|
|
1027
|
-
}, [isControlled, onOpenChange, prepareFocusReturn]);
|
|
1068
|
+
}, [hideOpenFlyouts, isControlled, onOpenChange, prepareFocusReturn]);
|
|
1028
1069
|
|
|
1029
1070
|
// Defer item focus until the layer has committed open, so focus restore
|
|
1030
1071
|
// captures the trigger instead of the first menu item.
|
|
@@ -1694,7 +1735,9 @@ function DropdownMenuPopover({
|
|
|
1694
1735
|
)}>
|
|
1695
1736
|
<MenuRegionsContext value={hasRegions}>
|
|
1696
1737
|
<DropdownMenuContext value={contextValue}>
|
|
1697
|
-
{
|
|
1738
|
+
<DropdownMenuRootOpenContext value={isOpen}>
|
|
1739
|
+
{menuContent}
|
|
1740
|
+
</DropdownMenuRootOpenContext>
|
|
1698
1741
|
</DropdownMenuContext>
|
|
1699
1742
|
</MenuRegionsContext>
|
|
1700
1743
|
</div>,
|
|
@@ -65,6 +65,30 @@ export interface DropdownMenuContextValue {
|
|
|
65
65
|
menuLabel?: string;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Whether the root menu of this layer tree is open; internal companion to
|
|
70
|
+
* `DropdownMenuContext`, on its own unexported context so the public
|
|
71
|
+
* `DropdownMenuContextValue` surface stays unchanged. A sub-menu's hover
|
|
72
|
+
* intent must die with the menu: a hover-open scheduled on its trigger
|
|
73
|
+
* before the menu closed from elsewhere would otherwise fire into the
|
|
74
|
+
* hidden menu and reopen the flyout behind it (#6893). `null` means no
|
|
75
|
+
* signal, treated as open, matching providers that have no state to give.
|
|
76
|
+
* @internal
|
|
77
|
+
*/
|
|
78
|
+
export const DropdownMenuRootOpenContext = createLayerScopedContext<
|
|
79
|
+
boolean | null
|
|
80
|
+
>(null);
|
|
81
|
+
DropdownMenuRootOpenContext.displayName = 'DropdownMenuRootOpenContext';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether the root menu backing these compound items is open. True when no
|
|
85
|
+
* provider gave a signal.
|
|
86
|
+
* @internal
|
|
87
|
+
*/
|
|
88
|
+
export function useDropdownMenuRootOpen(): boolean {
|
|
89
|
+
return use(DropdownMenuRootOpenContext) !== false;
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
export const DropdownMenuContext =
|
|
69
93
|
createLayerScopedContext<DropdownMenuContextValue | null>(null);
|
|
70
94
|
DropdownMenuContext.displayName = 'DropdownMenuContext';
|
|
@@ -1494,3 +1494,188 @@ describe('DropdownMenuSubMenu drill-in on a phone', () => {
|
|
|
1494
1494
|
screen.getAllByRole('menu', {name, ...h}).find(isShown);
|
|
1495
1495
|
});
|
|
1496
1496
|
});
|
|
1497
|
+
|
|
1498
|
+
describe('DropdownMenuSubMenu light dismiss of the root menu (#6893)', () => {
|
|
1499
|
+
// The browser closes the root `popover=\"auto\"` element itself on an outside
|
|
1500
|
+
// tap; that path never reaches the submenu, whose flyout is
|
|
1501
|
+
// `popover=\"manual\"`. Simulate it by hiding the root popover element
|
|
1502
|
+
// directly, exactly what light dismiss does to it.
|
|
1503
|
+
function lightDismissRootMenu() {
|
|
1504
|
+
const menu = screen.getByRole('menu', {name: /Actions/, hidden: true});
|
|
1505
|
+
const rootPopover = menu.closest<HTMLElement>('[popover]');
|
|
1506
|
+
expect(rootPopover).not.toBeNull();
|
|
1507
|
+
act(() => {
|
|
1508
|
+
rootPopover!.hidePopover();
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function getFlyoutPopover(name: RegExp) {
|
|
1513
|
+
const flyout = screen.getByRole('menu', {name, hidden: true});
|
|
1514
|
+
return flyout.closest<HTMLElement>('[popover]');
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
it('closes an open flyout when the root menu is light-dismissed', async () => {
|
|
1518
|
+
const user = userEvent.setup();
|
|
1519
|
+
render(<MoveMenu />);
|
|
1520
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
1521
|
+
const trigger = screen.getByRole('menuitem', {
|
|
1522
|
+
name: /Move to/,
|
|
1523
|
+
hidden: true,
|
|
1524
|
+
});
|
|
1525
|
+
await user.click(trigger);
|
|
1526
|
+
await waitFor(() => {
|
|
1527
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
1528
|
+
});
|
|
1529
|
+
const flyoutPopover = getFlyoutPopover(/Move to/);
|
|
1530
|
+
expect(flyoutPopover).toHaveAttribute('popover-open', '');
|
|
1531
|
+
|
|
1532
|
+
lightDismissRootMenu();
|
|
1533
|
+
|
|
1534
|
+
await waitFor(() => {
|
|
1535
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
1536
|
+
});
|
|
1537
|
+
expect(flyoutPopover).not.toHaveAttribute('popover-open');
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
it('leaves the submenu collapsed when the menu reopens after a light dismiss', async () => {
|
|
1541
|
+
const user = userEvent.setup();
|
|
1542
|
+
render(<MoveMenu />);
|
|
1543
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
1544
|
+
const trigger = screen.getByRole('menuitem', {
|
|
1545
|
+
name: /Move to/,
|
|
1546
|
+
hidden: true,
|
|
1547
|
+
});
|
|
1548
|
+
await user.click(trigger);
|
|
1549
|
+
await waitFor(() => {
|
|
1550
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
lightDismissRootMenu();
|
|
1554
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
1555
|
+
|
|
1556
|
+
await waitFor(() => {
|
|
1557
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
1558
|
+
});
|
|
1559
|
+
expect(getFlyoutPopover(/Move to/)).not.toHaveAttribute('popover-open');
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
it('closes an open flyout when the root menu closes via controlled isMenuOpen', async () => {
|
|
1563
|
+
const user = userEvent.setup();
|
|
1564
|
+
function ControlledMenu() {
|
|
1565
|
+
const [open, setOpen] = useState(true);
|
|
1566
|
+
return (
|
|
1567
|
+
<>
|
|
1568
|
+
<DropdownMenu
|
|
1569
|
+
button={{label: 'Actions'}}
|
|
1570
|
+
isMenuOpen={open}
|
|
1571
|
+
onOpenChange={setOpen}>
|
|
1572
|
+
<DropdownMenuSubMenu label="Move to">
|
|
1573
|
+
<DropdownMenuItem label="Folder A" onClick={() => {}} />
|
|
1574
|
+
</DropdownMenuSubMenu>
|
|
1575
|
+
</DropdownMenu>
|
|
1576
|
+
<button type="button" onClick={() => setOpen(false)}>
|
|
1577
|
+
close it
|
|
1578
|
+
</button>
|
|
1579
|
+
</>
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
render(<ControlledMenu />);
|
|
1583
|
+
const trigger = screen.getByRole('menuitem', {
|
|
1584
|
+
name: /Move to/,
|
|
1585
|
+
hidden: true,
|
|
1586
|
+
});
|
|
1587
|
+
await user.click(trigger);
|
|
1588
|
+
await waitFor(() => {
|
|
1589
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
1590
|
+
});
|
|
1591
|
+
const flyoutPopover = getFlyoutPopover(/Move to/);
|
|
1592
|
+
|
|
1593
|
+
await user.click(screen.getByRole('button', {name: /close it/}));
|
|
1594
|
+
|
|
1595
|
+
await waitFor(() => {
|
|
1596
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
1597
|
+
});
|
|
1598
|
+
expect(flyoutPopover).not.toHaveAttribute('popover-open');
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
it('closes flyouts nested two levels deep on light dismiss', async () => {
|
|
1602
|
+
const user = userEvent.setup();
|
|
1603
|
+
render(
|
|
1604
|
+
<DropdownMenu button={{label: 'Actions'}}>
|
|
1605
|
+
<DropdownMenuSubMenu label="Move to">
|
|
1606
|
+
<DropdownMenuSubMenu label="Deeper">
|
|
1607
|
+
<DropdownMenuItem label="Leaf" onClick={() => {}} />
|
|
1608
|
+
</DropdownMenuSubMenu>
|
|
1609
|
+
</DropdownMenuSubMenu>
|
|
1610
|
+
</DropdownMenu>,
|
|
1611
|
+
);
|
|
1612
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
1613
|
+
const first = screen.getByRole('menuitem', {
|
|
1614
|
+
name: /Move to/,
|
|
1615
|
+
hidden: true,
|
|
1616
|
+
});
|
|
1617
|
+
await user.click(first);
|
|
1618
|
+
await waitFor(() => {
|
|
1619
|
+
expect(first).toHaveAttribute('aria-expanded', 'true');
|
|
1620
|
+
});
|
|
1621
|
+
const deeper = screen.getByRole('menuitem', {
|
|
1622
|
+
name: /Deeper/,
|
|
1623
|
+
hidden: true,
|
|
1624
|
+
});
|
|
1625
|
+
await user.click(deeper);
|
|
1626
|
+
await waitFor(() => {
|
|
1627
|
+
expect(deeper).toHaveAttribute('aria-expanded', 'true');
|
|
1628
|
+
});
|
|
1629
|
+
const firstPopover = getFlyoutPopover(/Move to/);
|
|
1630
|
+
const deeperPopover = getFlyoutPopover(/Deeper/);
|
|
1631
|
+
|
|
1632
|
+
lightDismissRootMenu();
|
|
1633
|
+
|
|
1634
|
+
await waitFor(() => {
|
|
1635
|
+
expect(first).toHaveAttribute('aria-expanded', 'false');
|
|
1636
|
+
});
|
|
1637
|
+
await waitFor(() => {
|
|
1638
|
+
expect(deeper).toHaveAttribute('aria-expanded', 'false');
|
|
1639
|
+
});
|
|
1640
|
+
expect(firstPopover).not.toHaveAttribute('popover-open');
|
|
1641
|
+
expect(deeperPopover).not.toHaveAttribute('popover-open');
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
// Regression for the stale hover timer: a hover over the submenu trigger
|
|
1645
|
+
// schedules an open after the show delay. A press on the same trigger
|
|
1646
|
+
// cancels its own scheduled hover-open (that is the click guard), so the
|
|
1647
|
+
// way a timer is still pending at close time is a close that comes from
|
|
1648
|
+
// elsewhere — here the root menu's light dismiss, the #6893 case. The
|
|
1649
|
+
// close must cancel the scheduled hover-open: once the delay elapses the
|
|
1650
|
+
// flyout stays closed instead of reopening behind the dismissed menu.
|
|
1651
|
+
// Time is driven manually so the dismiss lands inside the show delay;
|
|
1652
|
+
// auto-advancing timers would let the hover-open fire before it.
|
|
1653
|
+
it('cancels a hover-open scheduled before the menu was closed from elsewhere', async () => {
|
|
1654
|
+
vi.useFakeTimers();
|
|
1655
|
+
try {
|
|
1656
|
+
render(<MoveMenu />);
|
|
1657
|
+
fireEvent.click(screen.getByRole('button', {name: /Actions/}));
|
|
1658
|
+
const trigger = screen.getByRole('menuitem', {
|
|
1659
|
+
name: /Move to/,
|
|
1660
|
+
hidden: true,
|
|
1661
|
+
});
|
|
1662
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
1663
|
+
|
|
1664
|
+
// Schedules the hover-open (default show delay 150ms).
|
|
1665
|
+
fireEvent.mouseEnter(trigger);
|
|
1666
|
+
|
|
1667
|
+
// Close from elsewhere while the hover-open is still pending.
|
|
1668
|
+
lightDismissRootMenu();
|
|
1669
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
1670
|
+
|
|
1671
|
+
// The scheduled delay passes; the flyout must stay closed.
|
|
1672
|
+
await act(async () => {
|
|
1673
|
+
vi.advanceTimersByTime(300);
|
|
1674
|
+
});
|
|
1675
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
1676
|
+
expect(getFlyoutPopover(/Move to/)).not.toHaveAttribute('popover-open');
|
|
1677
|
+
} finally {
|
|
1678
|
+
vi.useRealTimers();
|
|
1679
|
+
}
|
|
1680
|
+
});
|
|
1681
|
+
});
|
|
@@ -97,7 +97,9 @@ import {
|
|
|
97
97
|
} from './menuItemRoles';
|
|
98
98
|
import {
|
|
99
99
|
DropdownMenuContext,
|
|
100
|
+
DropdownMenuRootOpenContext,
|
|
100
101
|
useDropdownMenuContext,
|
|
102
|
+
useDropdownMenuRootOpen,
|
|
101
103
|
type DropdownMenuContextValue,
|
|
102
104
|
} from './DropdownMenuContext';
|
|
103
105
|
import {focusMenuItemOnHover} from './menuItemHover';
|
|
@@ -451,13 +453,17 @@ export function DropdownMenuSubMenu(
|
|
|
451
453
|
// Hover intent and the shared hover→click guard only: this level owns its own
|
|
452
454
|
// click handling, roving focus and typeahead. popover="manual", so the
|
|
453
455
|
// invoker wiring other consumers need does not apply. A drill-in row has no
|
|
454
|
-
// hover: a finger drives it, and a press opens it.
|
|
456
|
+
// hover: a finger drives it, and a press opens it. Hover is also gated on
|
|
457
|
+
// the root menu being open, so a hover-open scheduled before the menu
|
|
458
|
+
// closed from elsewhere is cancelled instead of firing into the hidden
|
|
459
|
+
// menu (#6893).
|
|
460
|
+
const isRootMenuOpen = useDropdownMenuRootOpen();
|
|
455
461
|
const {triggerProps, contentProps, confirmHoverOpen} =
|
|
456
462
|
useMenuHover<HTMLDivElement>({
|
|
457
463
|
show: showLayer,
|
|
458
464
|
hide: hideLayer,
|
|
459
465
|
isOpen: isFlyoutOpen,
|
|
460
|
-
isEnabled: canOpen && !isDrillIn,
|
|
466
|
+
isEnabled: canOpen && !isDrillIn && isRootMenuOpen,
|
|
461
467
|
// The safe triangle toward the flyout's near edge is built from the
|
|
462
468
|
// flyout this component renders, not the hook's own list ref.
|
|
463
469
|
flyoutRef: menuRef,
|
|
@@ -770,7 +776,7 @@ export function DropdownMenuSubMenu(
|
|
|
770
776
|
: flyoutStyles.popoverCustomWidth(resolvedMenuWidth.value)
|
|
771
777
|
: flyoutStyles.popover;
|
|
772
778
|
|
|
773
|
-
const
|
|
779
|
+
const nestedRows = (
|
|
774
780
|
<DropdownMenuContext value={nestedMenuContext}>
|
|
775
781
|
{isDrillIn && (
|
|
776
782
|
<DropdownMenuItem
|
|
@@ -790,6 +796,14 @@ export function DropdownMenuSubMenu(
|
|
|
790
796
|
{children}
|
|
791
797
|
</DropdownMenuContext>
|
|
792
798
|
);
|
|
799
|
+
// Nested rows live in this level's flyout, a layer of their own where the
|
|
800
|
+
// layer-scoped signal resets; re-provide it so their pending hover intent
|
|
801
|
+
// dies with the root menu too.
|
|
802
|
+
const nestedContent = (
|
|
803
|
+
<DropdownMenuRootOpenContext value={isRootMenuOpen}>
|
|
804
|
+
{nestedRows}
|
|
805
|
+
</DropdownMenuRootOpenContext>
|
|
806
|
+
);
|
|
793
807
|
|
|
794
808
|
return (
|
|
795
809
|
<>
|
package/src/Field/Field.doc.mjs
CHANGED
|
@@ -114,7 +114,7 @@ export const docs = {
|
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
name: 'labelIcon',
|
|
117
|
-
type: 'IconType',
|
|
117
|
+
type: 'ReactNode | IconType',
|
|
118
118
|
description: 'Icon to display before the label text. See `astryx docs icons` for valid semantic names.',
|
|
119
119
|
},
|
|
120
120
|
{
|
|
@@ -129,8 +129,8 @@ export const docs = {
|
|
|
129
129
|
},
|
|
130
130
|
{
|
|
131
131
|
name: 'statusVariant',
|
|
132
|
-
type: "'attached' | 'detached'",
|
|
133
|
-
description: 'How
|
|
132
|
+
type: "'attached' | 'detached' | 'tooltip'",
|
|
133
|
+
description: 'How status feedback is placed. Attached overlaps an eligible opaque, bordered, fixed-height control; detached renders a message below custom or non-bordered controls; tooltip leaves the visible affordance to the wrapped input and announces its message without a FieldStatus box.',
|
|
134
134
|
default: "'attached'",
|
|
135
135
|
},
|
|
136
136
|
{
|
|
@@ -170,17 +170,23 @@ export const docs = {
|
|
|
170
170
|
{ guidance: true, description: 'Always provide a label for accessibility, even if visually hidden with isLabelHidden.' },
|
|
171
171
|
{ guidance: true, description: 'Use inputID and descriptionID to connect the label and description to the inner control with htmlFor and aria-describedby.' },
|
|
172
172
|
{ guidance: false, description: 'Nest Field around styled inputs such as TextInput, Typeahead, Select, DateInput, or TextArea; those components already render their own Field shell.' },
|
|
173
|
-
{ guidance:
|
|
173
|
+
{ guidance: true, description: 'Use attached status only when the wrapped control is opaque, bordered, and fixed-height; use detached for custom, tall, translucent, or non-bordered controls.' },
|
|
174
174
|
{ guidance: false, description: 'Set both isOptional and isRequired on the same field.' },
|
|
175
175
|
{ guidance: false, description: 'Hide the label without providing an alternative way for the user to understand the field purpose.' },
|
|
176
176
|
],
|
|
177
177
|
anatomy: [
|
|
178
|
+
{name: 'Container', required: true, description: 'Root field box that owns layout, width, and local stacking for the label, control, and status.'},
|
|
178
179
|
{name: 'Label', required: true, description: 'Text identifying the field. Always rendered for accessibility, optionally hidden visually.'},
|
|
179
|
-
{name: '
|
|
180
|
+
{name: 'Ordinary description', required: false, description: 'Helper text under the label in the ordinary field layout.'},
|
|
181
|
+
{name: 'Horizontal-label description', required: false, description: 'Helper text in the control column when FormLayout owns horizontal labels.'},
|
|
180
182
|
{name: 'Control slot', required: true, description: 'A custom, native, or third-party control that does not already render a field shell.'},
|
|
181
183
|
{name: 'Status message', required: false, description: 'Inline validation feedback showing error, warning, or success with a message.'},
|
|
184
|
+
{name: 'Input status icon', required: false, description: 'Shared status affordance used by Field-composed input controls.'},
|
|
185
|
+
{name: 'Input clear button', required: false, description: 'Shared clear action used by Field-composed input controls.'},
|
|
186
|
+
{name: 'Input clear icon', required: false, description: 'Glyph inside the shared input clear action.'},
|
|
182
187
|
{name: 'Optional/Required indicator', required: false, description: 'Badge next to the label showing whether the field is optional or required.'},
|
|
183
|
-
{name: 'Label tooltip', required: false, description: 'Info icon
|
|
188
|
+
{name: 'Label tooltip trigger', required: false, description: 'Info icon beside the label that opens explanatory content.'},
|
|
189
|
+
{name: 'Label tooltip surface', required: false, description: 'Layer-hosted explanatory content for the label tooltip.'},
|
|
184
190
|
],
|
|
185
191
|
},
|
|
186
192
|
examples: [
|
|
@@ -279,7 +285,7 @@ export const docsZh = {
|
|
|
279
285
|
{ guidance: true, description: 'Always provide a label for accessibility, even if visually hidden with isLabelHidden.' },
|
|
280
286
|
{ guidance: true, description: 'Use inputID and descriptionID to connect the label and description to the inner control with htmlFor and aria-describedby.' },
|
|
281
287
|
{ guidance: false, description: 'Nest Field around styled inputs such as TextInput, Typeahead, Select, DateInput, or TextArea; those components already render their own Field shell.' },
|
|
282
|
-
{ guidance:
|
|
288
|
+
{ guidance: true, description: 'Use attached status only when the wrapped control is opaque, bordered, and fixed-height; use detached for custom, tall, translucent, or non-bordered controls.' },
|
|
283
289
|
{ guidance: false, description: 'Set both isOptional and isRequired on the same field.' },
|
|
284
290
|
{ guidance: false, description: 'Hide the label without providing an alternative way for the user to understand the field purpose.' },
|
|
285
291
|
],
|
|
@@ -297,7 +303,7 @@ export const docsDense = {
|
|
|
297
303
|
{ guidance: true, description: 'Always provide a label; visually hide it only when context is clear.' },
|
|
298
304
|
{ guidance: true, description: 'Wire inputID/descriptionID to htmlFor and aria-describedby on the inner control.' },
|
|
299
305
|
{ guidance: false, description: 'Nest Field around styled inputs; it double-renders labels and status UI.' },
|
|
300
|
-
{ guidance:
|
|
306
|
+
{ guidance: true, description: 'Use attached status only for opaque, bordered, fixed-height controls; use detached for custom, tall, translucent, or non-bordered controls.' },
|
|
301
307
|
{ guidance: false, description: 'Set both isOptional and isRequired on the same field.' },
|
|
302
308
|
{ guidance: false, description: 'Hide the label without another way to understand field purpose.' },
|
|
303
309
|
],
|