@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.
Files changed (56) hide show
  1. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  2. package/dist/Breadcrumbs/BreadcrumbItem.js +5 -2
  3. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  4. package/dist/ContextMenu/ContextMenu.js +5 -2
  5. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  6. package/dist/DropdownMenu/DropdownMenu.js +46 -3
  7. package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -0
  8. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  9. package/dist/DropdownMenu/DropdownMenuContext.js +21 -0
  10. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  11. package/dist/DropdownMenu/DropdownMenuSubMenu.js +15 -4
  12. package/dist/Field/Field.d.ts +1 -1
  13. package/dist/Field/Field.d.ts.map +1 -1
  14. package/dist/Field/Field.js +8 -4
  15. package/dist/Grid/GridSpan.d.ts.map +1 -1
  16. package/dist/Grid/GridSpan.js +1 -0
  17. package/dist/MetadataList/MetadataList.d.ts.map +1 -1
  18. package/dist/MetadataList/MetadataList.js +5 -1
  19. package/dist/MetadataList/MetadataListContext.d.ts +1 -1
  20. package/dist/MetadataList/MetadataListContext.d.ts.map +1 -1
  21. package/dist/RadioList/RadioList.d.ts +1 -1
  22. package/dist/RadioList/RadioList.d.ts.map +1 -1
  23. package/dist/RadioList/RadioList.js +6 -2
  24. package/dist/ToggleButton/ToggleButton.d.ts +3 -2
  25. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  26. package/dist/ToggleButton/ToggleButton.js +12 -1
  27. package/dist/astryx.css +1 -0
  28. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  29. package/dist/hooks/useMenuHover.js +18 -13
  30. package/package.json +3 -3
  31. package/src/Avatar/Avatar.doc.mjs +5 -3
  32. package/src/AvatarGroup/AvatarGroup.doc.mjs +3 -2
  33. package/src/Breadcrumbs/BreadcrumbItem.tsx +4 -1
  34. package/src/Chat/__tests__/ChatMessageMetadata.a11y.chromium.spec.ts +3 -1
  35. package/src/ContextMenu/ContextMenu.tsx +4 -1
  36. package/src/DropdownMenu/DropdownMenu.tsx +45 -2
  37. package/src/DropdownMenu/DropdownMenuContext.tsx +24 -0
  38. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +185 -0
  39. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +17 -3
  40. package/src/Field/Field.doc.mjs +14 -8
  41. package/src/Field/Field.spec.md +217 -0
  42. package/src/Field/Field.test.tsx +60 -2
  43. package/src/Field/Field.tsx +17 -8
  44. package/src/Grid/GridSpan.tsx +3 -0
  45. package/src/MetadataList/MetadataList.doc.mjs +2 -2
  46. package/src/MetadataList/MetadataList.test.tsx +38 -0
  47. package/src/MetadataList/MetadataList.tsx +8 -1
  48. package/src/MetadataList/MetadataListContext.tsx +1 -1
  49. package/src/RadioList/RadioList.fr10.test.tsx +45 -2
  50. package/src/RadioList/RadioList.tsx +4 -1
  51. package/src/Slider/Slider.spec.md +39 -18
  52. package/src/TimeInput/TimeInput.fr10.test.tsx +15 -0
  53. package/src/ToggleButton/ToggleButton.test.tsx +95 -0
  54. package/src/ToggleButton/ToggleButton.tsx +12 -1
  55. package/src/docPropLiterals.test.ts +36 -10
  56. package/src/hooks/useMenuHover.ts +18 -13
@@ -67,8 +67,10 @@ export const docs = {
67
67
  },
68
68
  {
69
69
  name: 'size',
70
- type: "'xsm' | 'sm' | 'md' | 'lg' | 'xl' | number",
71
- description: "Avatar size. Use a named size ('xsm' 20px, 'sm' 24px, 'md' 36px, 'lg' 48px, 'xl' 128px) or a numeric pixel value. Avatar shares Icon's abbreviated scale, but its tiers are larger because avatars align with media rather than glyphs. Inside an AvatarGroup the group's size wins and this prop is ignored.",
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: "avatar size. Named ('xsm' 20px, 'sm' 24px, 'md' 36px, 'lg' 48px, 'xl' 128px) or numeric px. An AvatarGroup's size overrides it.",
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: 'AvatarSize',
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 via context. This wins over each child Avatar's own size prop, including when it is left at the default, so set the size here rather than on the children.",
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
- {menuContent}
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) {throw new Error('Narrow metadata fixture is missing');}
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
- {resolvedMenuContent}
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
- {menuContent}
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 nestedContent = (
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
  <>
@@ -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 the status message renders relative to the input. Attached overlaps the input border; detached floats below.',
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: false, description: 'Use the attached status variant on non-bordered controls such as sliders, switches, or checkboxes; use detached so the message does not overlap the control.' },
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: 'Description', required: false, description: 'Helper text between the label and input explaining what to enter.'},
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 at the end of the label with a tooltip explaining the field.'},
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: false, description: 'Use the attached status variant on non-bordered controls such as sliders, switches, or checkboxes; use detached so the message does not overlap the control.' },
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: false, description: 'Use attached status on sliders/switches/checkboxes; use detached so messages do not overlap.' },
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
  ],