@astryxdesign/core 0.7.2-canary.caec7ca → 0.7.2-canary.cbb7fa3

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 (125) hide show
  1. package/dist/AspectRatio/AspectRatio.d.ts +6 -8
  2. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  3. package/dist/AspectRatio/AspectRatio.js +44 -3
  4. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  5. package/dist/Breadcrumbs/BreadcrumbItem.js +5 -2
  6. package/dist/CheckboxInput/CheckboxInput.d.ts +1 -1
  7. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  8. package/dist/CheckboxInput/CheckboxInput.js +3 -1
  9. package/dist/CommandPalette/CommandPaletteInput.d.ts +1 -1
  10. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  11. package/dist/CommandPalette/CommandPaletteInput.js +37 -5
  12. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  13. package/dist/ContextMenu/ContextMenu.js +5 -2
  14. package/dist/Dialog/Dialog.d.ts.map +1 -1
  15. package/dist/Dialog/Dialog.js +20 -8
  16. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  17. package/dist/DropdownMenu/DropdownMenu.js +46 -3
  18. package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -0
  19. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  20. package/dist/DropdownMenu/DropdownMenuContext.js +21 -0
  21. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  22. package/dist/DropdownMenu/DropdownMenuSubMenu.js +15 -4
  23. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  24. package/dist/EmptyState/EmptyState.js +4 -10
  25. package/dist/Field/Field.d.ts +1 -1
  26. package/dist/Field/Field.d.ts.map +1 -1
  27. package/dist/Field/Field.js +8 -4
  28. package/dist/Grid/GridSpan.d.ts.map +1 -1
  29. package/dist/Grid/GridSpan.js +1 -0
  30. package/dist/List/List.d.ts +46 -19
  31. package/dist/List/List.d.ts.map +1 -1
  32. package/dist/List/List.js +28 -10
  33. package/dist/List/ListContext.d.ts +8 -3
  34. package/dist/List/ListContext.d.ts.map +1 -1
  35. package/dist/List/ListContext.js +7 -2
  36. package/dist/List/ListItem.d.ts +4 -3
  37. package/dist/List/ListItem.d.ts.map +1 -1
  38. package/dist/List/ListItem.js +19 -8
  39. package/dist/List/index.d.ts +1 -1
  40. package/dist/List/index.d.ts.map +1 -1
  41. package/dist/Markdown/Markdown.d.ts.map +1 -1
  42. package/dist/Markdown/Markdown.js +8 -1
  43. package/dist/MetadataList/MetadataList.d.ts.map +1 -1
  44. package/dist/MetadataList/MetadataList.js +5 -1
  45. package/dist/MetadataList/MetadataListContext.d.ts +1 -1
  46. package/dist/MetadataList/MetadataListContext.d.ts.map +1 -1
  47. package/dist/RadioList/RadioList.d.ts +1 -1
  48. package/dist/RadioList/RadioList.d.ts.map +1 -1
  49. package/dist/RadioList/RadioList.js +6 -2
  50. package/dist/ToggleButton/ToggleButton.d.ts +3 -2
  51. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  52. package/dist/ToggleButton/ToggleButton.js +12 -1
  53. package/dist/TreeList/TreeList.d.ts.map +1 -1
  54. package/dist/TreeList/TreeList.js +1 -0
  55. package/dist/TreeList/TreeListBranches.d.ts.map +1 -1
  56. package/dist/TreeList/TreeListBranches.js +2 -2
  57. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  58. package/dist/TreeList/TreeListItem.js +57 -9
  59. package/dist/astryx.css +16 -3
  60. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  61. package/dist/hooks/useMenuHover.js +18 -13
  62. package/dist/utils/plainDate.d.ts.map +1 -1
  63. package/dist/utils/plainDate.js +15 -9
  64. package/package.json +3 -3
  65. package/src/AspectRatio/AspectRatio.test.tsx +44 -43
  66. package/src/AspectRatio/AspectRatio.tsx +38 -18
  67. package/src/AspectRatio/__tests__/AspectRatioFitLayers.a11y.browser.spec.ts +157 -0
  68. package/src/Avatar/Avatar.doc.mjs +5 -3
  69. package/src/AvatarGroup/AvatarGroup.doc.mjs +3 -2
  70. package/src/Breadcrumbs/BreadcrumbItem.tsx +4 -1
  71. package/src/Chat/__tests__/ChatMessageMetadata.a11y.chromium.spec.ts +3 -1
  72. package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
  73. package/src/CheckboxInput/CheckboxInput.tsx +3 -1
  74. package/src/CommandPalette/CommandPaletteInput.test.tsx +53 -4
  75. package/src/CommandPalette/CommandPaletteInput.tsx +33 -3
  76. package/src/CommandPalette/__tests__/CommandPaletteInputAudit.a11y.chromium.spec.ts +335 -0
  77. package/src/ContextMenu/ContextMenu.tsx +4 -1
  78. package/src/Dialog/Dialog.test.tsx +46 -0
  79. package/src/Dialog/Dialog.tsx +20 -7
  80. package/src/Dialog/__tests__/Dialog.a11y.known-failures.ts +0 -14
  81. package/src/DropdownMenu/DropdownMenu.tsx +45 -2
  82. package/src/DropdownMenu/DropdownMenuContext.tsx +24 -0
  83. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +185 -0
  84. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +17 -3
  85. package/src/EmptyState/EmptyState.doc.mjs +3 -2
  86. package/src/EmptyState/EmptyState.spec.md +189 -0
  87. package/src/EmptyState/EmptyState.test.tsx +74 -3
  88. package/src/EmptyState/EmptyState.tsx +4 -8
  89. package/src/Field/Field.doc.mjs +14 -8
  90. package/src/Field/Field.spec.md +217 -0
  91. package/src/Field/Field.test.tsx +60 -2
  92. package/src/Field/Field.tsx +17 -8
  93. package/src/Grid/GridSpan.tsx +3 -0
  94. package/src/Item/Item.doc.mjs +1 -1
  95. package/src/Item/Item.tsx +1 -1
  96. package/src/List/List.doc.mjs +14 -1
  97. package/src/List/List.spec.md +129 -77
  98. package/src/List/List.test.tsx +144 -94
  99. package/src/List/List.tsx +74 -18
  100. package/src/List/ListContext.tsx +9 -3
  101. package/src/List/ListItem.tsx +46 -26
  102. package/src/List/index.ts +1 -1
  103. package/src/Markdown/Markdown.tsx +14 -1
  104. package/src/MetadataList/MetadataList.doc.mjs +2 -2
  105. package/src/MetadataList/MetadataList.test.tsx +38 -0
  106. package/src/MetadataList/MetadataList.tsx +8 -1
  107. package/src/MetadataList/MetadataListContext.tsx +1 -1
  108. package/src/RadioList/RadioList.fr10.test.tsx +45 -2
  109. package/src/RadioList/RadioList.tsx +4 -1
  110. package/src/Slider/Slider.spec.md +39 -18
  111. package/src/TabList/TabList.spec.md +57 -44
  112. package/src/TimeInput/TimeInput.fr10.test.tsx +15 -0
  113. package/src/ToggleButton/ToggleButton.test.tsx +95 -0
  114. package/src/ToggleButton/ToggleButton.tsx +12 -1
  115. package/src/TreeList/TreeList.doc.mjs +1 -0
  116. package/src/TreeList/TreeList.test.tsx +327 -6
  117. package/src/TreeList/TreeList.tsx +5 -1
  118. package/src/TreeList/TreeListBranches.tsx +6 -0
  119. package/src/TreeList/TreeListItem.tsx +88 -13
  120. package/src/docPropLiterals.test.ts +36 -10
  121. package/src/hooks/useMenuHover.ts +18 -13
  122. package/src/reset.css +0 -29
  123. package/src/theme/derivedVarRegistry.test.ts +7 -0
  124. package/src/utils/plainDate.test.ts +37 -1
  125. package/src/utils/plainDate.ts +15 -5
@@ -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
  <>
@@ -12,7 +12,7 @@ export const docs = {
12
12
  name: 'title',
13
13
  type: 'string',
14
14
  description:
15
- 'Primary message rendered as an <h3> heading inside the empty state.',
15
+ 'Primary message rendered as the configured heading level inside the empty state.',
16
16
  required: true,
17
17
  },
18
18
  {
@@ -81,6 +81,7 @@ export const docs = {
81
81
  { guidance: false, description: 'Use an EmptyState for error messages that require immediate action; use a Banner instead.' },
82
82
  ],
83
83
  anatomy: [
84
+ {name: 'Container', required: true, description: 'The centered status region that owns spacing, reading order, and the compact layout variant.'},
84
85
  {name: 'Icon', required: false, description: 'A visual cue above the title that reinforces the context, like a search icon for no results.'},
85
86
  {name: 'Title', required: true, description: 'Primary message explaining what is empty: "No projects yet" not "No data".'},
86
87
  {name: 'Description', required: false, description: 'Additional context explaining why it is empty or what the user can do.'},
@@ -98,7 +99,7 @@ export const docsZh = {
98
99
  name: 'title',
99
100
  type: 'string',
100
101
  description:
101
- '在空状态内部渲染为 <h3> 标题的主要信息。',
102
+ '在空状态内部按配置的标题级别渲染主要信息。',
102
103
  required: true,
103
104
  },
104
105
  {
@@ -0,0 +1,189 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 7
4
+ kind: component
5
+ id: component:EmptyState
6
+ authority: draft
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: null
10
+ approved_at: null
11
+ owners: [cixzhang]
12
+ review_triggers: [public-api, behavior, layout, theming, accessibility, testing]
13
+ verified_by:
14
+ [
15
+ packages/core/src/EmptyState/EmptyState.test.tsx,
16
+ packages/core/src/theme/themingTargets.test.ts,
17
+ packages/core/src/docPropReferences.test.ts,
18
+ ]
19
+ modules: []
20
+ families: []
21
+ design_specs: []
22
+ architecture:
23
+ [
24
+ architecture:public-component-api,
25
+ architecture:component-theming-surface,
26
+ architecture:component-test-sufficiency,
27
+ ]
28
+ contributing: []
29
+ system_specs: [spec:AST-020/FR1, spec:AST-029/FR3]
30
+ ---
31
+
32
+ # EmptyState component contract
33
+
34
+ ## Contract at a glance
35
+
36
+ | Area | Contract |
37
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
38
+ | Public contract | `EmptyState` renders a required title plus optional description, decorative icon, and caller-owned actions in one status region; callers choose heading level and compact layout. |
39
+ | Behavior | Null, undefined, boolean, and empty-string optional values create no slot wrapper. Rendered content follows icon, text, then actions order; compact layout reduces spacing and stacks actions. |
40
+ | End-user impact | A person reaches a named empty-content message and any next action without blank gaps from shallow-empty optional values; the title fits the surrounding document outline. |
41
+ | Builder impact | Callers supply content and actions, select heading semantics, and may compose supported root props and styling. No additional setup is required. |
42
+ | Compatibility/readiness | The released default is preserved. This draft observes shipped behavior plus the narrow scalar-slot remediation; it adds no API, default, or compatibility promise. |
43
+ | Review checks | Reject a missing heading, an informative icon hidden as decorative, a shallow-empty optional scalar that still changes layout, a dropped root prop/ref/style, or a theme target without anatomy. |
44
+ | Governing rules | `architecture:public-component-api` INV1 and INV5–INV9; `architecture:component-theming-surface` INV2–INV6; `architecture:component-test-sufficiency` INV1–INV7, INV10–INV11; WCAG 2.2. |
45
+
46
+ This table is a review projection; the body below is authoritative.
47
+
48
+ ## Intent
49
+
50
+ `EmptyState` gives an otherwise empty content area a semantic heading, supporting
51
+ context, optional decorative visual, and optional caller-owned next actions.
52
+
53
+ ## Compatibility and migration
54
+
55
+ - Released default preserved: yes
56
+ - Compatibility class: observational and additive documentation; runtime remediation removes wrappers for nullish, boolean, and empty-string optional values
57
+ - Controlled/uncontrolled behavior: not applicable
58
+ - Migration decision: none
59
+
60
+ Consumer migration instructions belong in consumer docs and release notes.
61
+
62
+ ## Ownership boundary
63
+
64
+ **Owns**
65
+
66
+ - the status-region root, content order, heading element, and default/compact layout;
67
+ - hiding the icon slot from assistive technology as decorative; and
68
+ - whether optional slots create component-owned wrappers and spacing.
69
+
70
+ **Does not own / non-goals**
71
+
72
+ - the message copy, icon meaning, action controls, or action behavior — those belong to the product callsite and the components it supplies;
73
+ - page-level heading order — the caller chooses `headingLevel` for its document; or
74
+ - error, loading, or populated-content behavior.
75
+
76
+ ## Public concepts
77
+
78
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
79
+ | -------------- | ----------------------- | ------------------------------------------------------------------------- | ----------------------------------------- | -------- | ---------------------- | --------- | ------------------------------------------------------- |
80
+ | title | string | The empty region's heading content. | all | required | `component:EmptyState` | stable | type rejection |
81
+ | description | string or absent | Optional supporting context below the title. | all | absent | `component:EmptyState` | stable | nullish and empty-string values omit the part |
82
+ | icon | React node or absent | Optional caller visual, exposed as decorative beside the textual message. | all | absent | product callsite | stable | nullish, boolean, and empty-string values omit the slot |
83
+ | actions | React node or absent | Optional caller-owned next actions below the message. | all | absent | product callsite | stable | nullish, boolean, and empty-string values omit the slot |
84
+ | heading level | `1` through `6` | The title's HTML heading level in the surrounding document outline. | all | `3` | product callsite | stable | type rejection |
85
+ | layout variant | default, compact | Compact reduces spacing and stacks the action slot vertically. | all | default | `component:EmptyState` | stable | type rejection |
86
+
87
+ ## Behavioral and layout contract
88
+
89
+ Draft requirements identify their basis so observed code is not mistaken for an
90
+ intentional decision. A `current` contract contains no unresolved rows.
91
+
92
+ | ID | Candidate invariant | Basis | Draft review state |
93
+ | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------ |
94
+ | FR1 | The component MUST render one status-region root containing the icon, text group, and actions in that reading order when present. | Current implementation and consumer docs; WCAG 2.2 SC 1.3.2 and SC 4.1.3 | verify |
95
+ | FR2 | The required title MUST render at the caller-selected heading level without changing the component's visual type role. | Current implementation, tests, and consumer docs; WCAG 2.2 SC 1.3.1 and SC 2.4.6 | verify |
96
+ | FR3 | Null, undefined, boolean, and empty-string optional values MUST NOT create a wrapper, layout gap, or assistive-technology node; containers that are shallowly non-empty remain wrapped. | React's direct-child rendering semantics; established public optional-slot interface | verify |
97
+ | FR4 | The decorative icon wrapper MUST be absent when the slot is absent and hidden from assistive technology when rendered. | Current implementation and tests; WCAG 2.2 SC 1.1.1 | verify |
98
+ | FR5 | Root DOM/data/ARIA/event props, ref, `className`, `style`, and `xstyle` MUST reach and compose on the status-region root. | `architecture:public-component-api` INV5–INV8 | verify |
99
+ | FR6 | Compact layout MUST reduce component spacing and stack the actions slot while preserving content order and root semantics. | Current implementation, tests, and consumer docs | verify |
100
+
101
+ ### Allowed variation
102
+
103
+ - **AV1 — Caller content.** Message wording, decorative artwork, and action
104
+ composition may vary at the product callsite.
105
+ - **AV2 — Theme paint.** Theme-controlled paint and typography may vary through
106
+ the documented targets while anatomy and content order remain stable.
107
+
108
+ ### Representative states
109
+
110
+ | State | Required invariant | Allowed variation |
111
+ | ----------------------------- | --------------------------------------------------------- | -------------------------------------- |
112
+ | title only | status root and selected heading level remain present | message wording |
113
+ | description | supporting text follows the title | text length and wrapping |
114
+ | decorative icon | visual precedes text and is hidden from assistive tech | artwork supplied by the caller |
115
+ | actions | caller controls follow the text group | one or more caller-owned controls |
116
+ | compact | reduced spacing and vertical actions preserve semantics | theme paint and caller content |
117
+ | shallow-empty optional scalar | nullish, boolean, and empty-string values emit no wrapper | which optional slot receives the value |
118
+
119
+ ### Transformation and precedence order
120
+
121
+ - **ORD1 — Root composition.** Component semantics and theme metadata combine
122
+ with component styles, then consumer `xstyle`, `className`, and `style` under
123
+ the shared merge contract; component-owned `role="status"` remains authoritative.
124
+
125
+ ### Performance and resources
126
+
127
+ - **PR1 — Render-only surface.** EmptyState owns no retained state, listener,
128
+ observer, timer, or external resource.
129
+
130
+ ## Accessibility contract
131
+
132
+ - **AR1 — Heading semantics.** The title is a heading at the caller-selected level.
133
+ - **AR2 — Decorative visual.** The icon slot is hidden from assistive technology;
134
+ callers keep all necessary meaning in the title or description.
135
+ - **AR3 — Status exposure.** The root exposes the empty message as a status region
136
+ without moving focus; action controls keep their own names, roles, and behavior.
137
+
138
+ ## Design relationships
139
+
140
+ No current design record governs EmptyState-specific representation. Visual
141
+ judgment remains evidence, not product authority.
142
+
143
+ ### Theming anatomy
144
+
145
+ <!-- anatomy-theming:v1 -->
146
+
147
+ ```json
148
+ {
149
+ "Container": {"target": "empty-state"},
150
+ "Icon": {
151
+ "none": {
152
+ "reason": "intentional: caller-supplied decorative content keeps its own styling and has no component-owned paint target"
153
+ }
154
+ },
155
+ "Title": {"target": "empty-state-title"},
156
+ "Description": {"target": "empty-state-description"},
157
+ "Actions": {
158
+ "none": {
159
+ "reason": "intentional: caller-supplied controls keep their own component targets and behavior"
160
+ }
161
+ }
162
+ }
163
+ ```
164
+
165
+ ## Family and system relationships
166
+
167
+ No current family record owns EmptyState-specific behavior.
168
+
169
+ ## Verification map
170
+
171
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
172
+ | -------------- | ------------------------------------------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------- | ------------------------- |
173
+ | FR1–FR4, AR1–3 | focused DOM tests plus required exact-head accessibility evidence | title only, all slots, absent/shallow-empty slots | order, heading, status, or decorative treatment regresses | `audit:EmptyState/a11y` |
174
+ | FR5, ORD1 | focused root composition tests plus shared API guards | DOM/data/event/style inputs, ref, root target | accepted input is dropped or component semantics/styles are replaced | `audit:EmptyState/api` |
175
+ | FR6 | compact target/DOM tests plus required exact-head rendered evidence | default and compact, wide and narrow | compact state, spacing, or action direction no longer applies | `audit:EmptyState/layout` |
176
+ | PR1 | source review | every render path | a retained resource appears without a named owner | `audit:EmptyState/code` |
177
+
178
+ ## Decision log
179
+
180
+ No component-local decision is approved in this draft.
181
+
182
+ ## Open questions
183
+
184
+ None.
185
+
186
+ ## Content boundary
187
+
188
+ This file does not duplicate consumer prop tables/examples, current audit
189
+ results, implementation steps, or family/system rules. It links to their owners.
@@ -1,7 +1,8 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- import {describe, it, expect} from 'vitest';
4
- import {render, screen} from '@testing-library/react';
3
+ import {describe, it, expect, vi} from 'vitest';
4
+ import {fireEvent, render, screen} from '@testing-library/react';
5
+ import * as stylex from '@stylexjs/stylex';
5
6
  import {EmptyState} from './EmptyState';
6
7
  import {defineTheme} from '../theme/defineTheme';
7
8
  import {generateThemeCSS} from '../theme/generateThemeRules';
@@ -11,6 +12,12 @@ function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
11
12
  return [prose, component].filter(Boolean).join('\n\n');
12
13
  }
13
14
 
15
+ const testStyles = stylex.create({
16
+ root: {
17
+ minWidth: '123px',
18
+ },
19
+ });
20
+
14
21
  describe('EmptyState', () => {
15
22
  it('renders with title', () => {
16
23
  render(<EmptyState title="No results found" />);
@@ -52,7 +59,7 @@ describe('EmptyState', () => {
52
59
  />,
53
60
  );
54
61
  expect(screen.getByText('Try adjusting your search.')).toBeInTheDocument();
55
- // Description renders as <div> (never <p>) so block content composes safely.
62
+ // Description keeps its stable target element instead of paragraph semantics.
56
63
  expect(screen.getByText('Try adjusting your search.').tagName).toBe('DIV');
57
64
  });
58
65
 
@@ -132,6 +139,70 @@ describe('EmptyState', () => {
132
139
  expect(screen.getByTestId('empty-state')).toBeInTheDocument();
133
140
  });
134
141
 
142
+ it('does not render empty optional slot wrappers', () => {
143
+ const {container} = render(
144
+ <EmptyState title="No results" description="" icon={false} actions="" />,
145
+ );
146
+ const root = screen.getByRole('status');
147
+ expect(root).toHaveTextContent('No results');
148
+ expect(root).toHaveTextContent(/^No results$/);
149
+ expect(root.children).toHaveLength(1);
150
+ expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
151
+ expect(
152
+ container.querySelector('.astryx-empty-state-description'),
153
+ ).toBeNull();
154
+ });
155
+
156
+ it('composes root props and keeps component semantics authoritative', () => {
157
+ const onClick = vi.fn();
158
+ const expectedXstyleClass = stylex.props(testStyles.root).className;
159
+ if (expectedXstyleClass == null) {
160
+ throw new Error('Expected the test xstyle to produce a class name.');
161
+ }
162
+ render(
163
+ <EmptyState
164
+ title="No results"
165
+ role="region"
166
+ data-owner="consumer"
167
+ className="consumer-class"
168
+ style={{width: '321px'}}
169
+ xstyle={testStyles.root}
170
+ onClick={onClick}
171
+ />,
172
+ );
173
+
174
+ const root = screen.getByRole('status');
175
+ expect(root).toHaveClass('astryx-empty-state', 'consumer-class');
176
+ expect(root).toHaveClass(expectedXstyleClass);
177
+ expect(root).toHaveAttribute('data-owner', 'consumer');
178
+ expect(root).toHaveStyle({width: '321px'});
179
+ fireEvent.click(root);
180
+ expect(onClick).toHaveBeenCalledOnce();
181
+ });
182
+
183
+ it('reflects compact layout on the root and preserves slot order', () => {
184
+ render(
185
+ <EmptyState
186
+ title="No results"
187
+ description="Try again."
188
+ icon={<span data-testid="icon">Icon</span>}
189
+ actions={<button type="button">Retry</button>}
190
+ isCompact
191
+ />,
192
+ );
193
+
194
+ const root = screen.getByRole('status');
195
+ expect(root).toHaveAttribute('data-variant', 'compact');
196
+ expect(root.children).toHaveLength(3);
197
+ expect(root.children[0]).toContainElement(screen.getByTestId('icon'));
198
+ expect(root.children[1]).toContainElement(
199
+ screen.getByRole('heading', {name: 'No results'}),
200
+ );
201
+ expect(root.children[2]).toContainElement(
202
+ screen.getByRole('button', {name: 'Retry'}),
203
+ );
204
+ });
205
+
135
206
  it('renders all slots together', () => {
136
207
  render(
137
208
  <EmptyState
@@ -23,7 +23,7 @@ import {
23
23
  typeScaleVars,
24
24
  } from '../theme/tokens.stylex';
25
25
  import type {BaseProps} from '../BaseProps';
26
- import {mergeProps} from '../utils';
26
+ import {isRenderable, mergeProps} from '../utils';
27
27
  import {themeProps} from '../utils/themeProps';
28
28
 
29
29
  const styles = stylex.create({
@@ -169,7 +169,7 @@ export function EmptyState({
169
169
  className,
170
170
  style,
171
171
  )}>
172
- {icon != null && <div aria-hidden="true">{icon}</div>}
172
+ {isRenderable(icon) && <div aria-hidden="true">{icon}</div>}
173
173
  <div {...stylex.props(styles.textGroup)}>
174
174
  {createElement(
175
175
  HeadingTag,
@@ -181,11 +181,7 @@ export function EmptyState({
181
181
  ),
182
182
  title,
183
183
  )}
184
- {description != null && (
185
- // Rendered as <div> (not <p>): description accepts ReactNode and a
186
- // <p> cannot legally contain block children, which causes hydration
187
- // mismatches. The StyleX style sets margin: 0, so appearance is
188
- // unchanged.
184
+ {isRenderable(description) && (
189
185
  <div
190
186
  {...mergeProps(
191
187
  themeProps('empty-state-description', {
@@ -200,7 +196,7 @@ export function EmptyState({
200
196
  </div>
201
197
  )}
202
198
  </div>
203
- {actions != null && (
199
+ {isRenderable(actions) && (
204
200
  <div
205
201
  {...stylex.props(styles.actions, isCompact && styles.actionsCompact)}>
206
202
  {actions}