@astryxdesign/core 0.1.7 → 0.1.8-canary.c5fa19b

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 (190) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +30 -19
  3. package/dist/Avatar/Avatar.d.ts +10 -5
  4. package/dist/Avatar/Avatar.d.ts.map +1 -1
  5. package/dist/Avatar/Avatar.js +12 -8
  6. package/dist/Avatar/AvatarSizeContext.d.ts +1 -1
  7. package/dist/Avatar/AvatarSizeContext.js +1 -1
  8. package/dist/Avatar/AvatarStatusDot.d.ts +2 -2
  9. package/dist/Avatar/AvatarStatusDot.js +2 -2
  10. package/dist/AvatarGroup/AvatarGroup.d.ts +2 -2
  11. package/dist/AvatarGroup/AvatarGroup.d.ts.map +1 -1
  12. package/dist/AvatarGroup/AvatarGroup.js +2 -2
  13. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  14. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  15. package/dist/AvatarGroup/AvatarGroupOverflow.js +16 -14
  16. package/dist/Carousel/Carousel.d.ts +3 -2
  17. package/dist/Carousel/Carousel.d.ts.map +1 -1
  18. package/dist/Carousel/Carousel.js +32 -2
  19. package/dist/Chat/ChatMessage.d.ts +1 -1
  20. package/dist/Chat/ChatMessage.d.ts.map +1 -1
  21. package/dist/Chat/ChatMessage.js +1 -1
  22. package/dist/Citation/Citation.d.ts.map +1 -1
  23. package/dist/Citation/Citation.js +10 -4
  24. package/dist/Collapsible/Collapsible.d.ts +11 -1
  25. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  26. package/dist/Collapsible/Collapsible.js +36 -4
  27. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  28. package/dist/ContextMenu/ContextMenu.js +4 -3
  29. package/dist/ContextMenu/index.d.ts +3 -0
  30. package/dist/ContextMenu/index.d.ts.map +1 -1
  31. package/dist/ContextMenu/index.js +7 -1
  32. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  33. package/dist/DropdownMenu/DropdownMenu.js +8 -4
  34. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +89 -0
  35. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts.map +1 -0
  36. package/dist/DropdownMenu/DropdownMenuCheckboxItem.js +170 -0
  37. package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -1
  38. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  39. package/dist/DropdownMenu/DropdownMenuContext.js +18 -0
  40. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +69 -0
  41. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -0
  42. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +70 -0
  43. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +69 -0
  44. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts.map +1 -0
  45. package/dist/DropdownMenu/DropdownMenuRadioItem.js +178 -0
  46. package/dist/DropdownMenu/index.d.ts +5 -1
  47. package/dist/DropdownMenu/index.d.ts.map +1 -1
  48. package/dist/DropdownMenu/index.js +10 -1
  49. package/dist/DropdownMenu/menuItemRoles.d.ts +13 -0
  50. package/dist/DropdownMenu/menuItemRoles.d.ts.map +1 -0
  51. package/dist/DropdownMenu/menuItemRoles.js +15 -0
  52. package/dist/Icon/Icon.d.ts +43 -13
  53. package/dist/Icon/Icon.d.ts.map +1 -1
  54. package/dist/Icon/Icon.js +62 -27
  55. package/dist/Outline/Outline.d.ts +69 -3
  56. package/dist/Outline/Outline.d.ts.map +1 -1
  57. package/dist/Outline/Outline.js +129 -26
  58. package/dist/Outline/useScrollSpy.d.ts +27 -7
  59. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  60. package/dist/Outline/useScrollSpy.js +202 -70
  61. package/dist/TabList/Tab.d.ts.map +1 -1
  62. package/dist/TabList/Tab.js +2 -2
  63. package/dist/TabList/TabList.d.ts.map +1 -1
  64. package/dist/TabList/TabList.js +2 -0
  65. package/dist/TabList/TabMenu.d.ts.map +1 -1
  66. package/dist/TabList/TabMenu.js +1 -1
  67. package/dist/Table/Table.d.ts +1 -1
  68. package/dist/Table/Table.js +1 -1
  69. package/dist/Table/index.d.ts +2 -0
  70. package/dist/Table/index.d.ts.map +1 -1
  71. package/dist/Table/index.js +1 -0
  72. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  73. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +24 -2
  74. package/dist/Table/plugins/sortable/useTableSortable.d.ts.map +1 -1
  75. package/dist/Table/plugins/sortable/useTableSortable.js +18 -7
  76. package/dist/Table/plugins/tree/index.d.ts +5 -0
  77. package/dist/Table/plugins/tree/index.d.ts.map +1 -0
  78. package/dist/Table/plugins/tree/index.js +4 -0
  79. package/dist/Table/plugins/tree/useTableTreeData.d.ts +38 -0
  80. package/dist/Table/plugins/tree/useTableTreeData.d.ts.map +1 -0
  81. package/dist/Table/plugins/tree/useTableTreeData.js +358 -0
  82. package/dist/Table/plugins/tree/useTableTreeState.d.ts +51 -0
  83. package/dist/Table/plugins/tree/useTableTreeState.d.ts.map +1 -0
  84. package/dist/Table/plugins/tree/useTableTreeState.js +175 -0
  85. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  86. package/dist/Table/useBaseTablePlugins.js +6 -3
  87. package/dist/Toolbar/Toolbar.d.ts.map +1 -1
  88. package/dist/Toolbar/Toolbar.js +8 -1
  89. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  90. package/dist/TreeList/TreeListItem.js +5 -14
  91. package/dist/astryx.css +24 -4
  92. package/dist/astryx.umd.js +54 -54
  93. package/dist/astryx.umd.js.map +4 -4
  94. package/dist/theme/Theme.js +1 -1
  95. package/dist/theme/defineTheme.d.ts +1 -1
  96. package/dist/theme/defineTheme.js +1 -1
  97. package/dist/theme/hct.d.ts +0 -1
  98. package/dist/theme/hct.d.ts.map +1 -1
  99. package/dist/theme/hct.js +0 -3
  100. package/docs.mjs +32 -461
  101. package/locales/en.json +200 -172
  102. package/locales/pseudo.json +21 -0
  103. package/package.json +3 -1
  104. package/scripts/postinstall.mjs +93 -0
  105. package/src/Avatar/Avatar.doc.mjs +6 -6
  106. package/src/Avatar/Avatar.tsx +16 -11
  107. package/src/Avatar/AvatarSizeContext.ts +1 -1
  108. package/src/Avatar/AvatarStatusDot.tsx +2 -2
  109. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  110. package/src/AvatarGroup/AvatarGroup.test.tsx +17 -3
  111. package/src/AvatarGroup/AvatarGroup.tsx +3 -3
  112. package/src/AvatarGroup/AvatarGroupOverflow.doc.mjs +4 -4
  113. package/src/AvatarGroup/AvatarGroupOverflow.tsx +21 -5
  114. package/src/Carousel/Carousel.doc.mjs +1 -0
  115. package/src/Carousel/Carousel.test.tsx +96 -1
  116. package/src/Carousel/Carousel.tsx +29 -2
  117. package/src/Chat/Chat.doc.mjs +1 -1
  118. package/src/Chat/ChatMessage.tsx +4 -2
  119. package/src/CheckboxInput/CheckboxInput.doc.mjs +1 -1
  120. package/src/Citation/Citation.test.tsx +51 -0
  121. package/src/Citation/Citation.tsx +8 -2
  122. package/src/Collapsible/Collapsible.doc.mjs +7 -0
  123. package/src/Collapsible/Collapsible.test.tsx +80 -0
  124. package/src/Collapsible/Collapsible.tsx +58 -5
  125. package/src/ContextMenu/ContextMenu.test.tsx +75 -1
  126. package/src/ContextMenu/ContextMenu.tsx +10 -5
  127. package/src/ContextMenu/index.ts +15 -0
  128. package/src/DropdownMenu/DropdownMenu.doc.mjs +23 -0
  129. package/src/DropdownMenu/DropdownMenu.tsx +11 -6
  130. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +95 -0
  131. package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +237 -0
  132. package/src/DropdownMenu/DropdownMenuContext.tsx +29 -1
  133. package/src/DropdownMenu/DropdownMenuItem.doc.mjs +7 -1
  134. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +80 -0
  135. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +119 -0
  136. package/src/DropdownMenu/DropdownMenuRadioItem.doc.mjs +80 -0
  137. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +232 -0
  138. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +163 -0
  139. package/src/DropdownMenu/index.ts +23 -3
  140. package/src/DropdownMenu/menuItemRoles.ts +22 -0
  141. package/src/Field/Field.doc.mjs +1 -1
  142. package/src/Field/FieldLabel.doc.mjs +1 -1
  143. package/src/Icon/Icon.doc.mjs +12 -0
  144. package/src/Icon/Icon.test.tsx +142 -0
  145. package/src/Icon/Icon.tsx +98 -30
  146. package/src/Item/Item.doc.mjs +1 -1
  147. package/src/NumberInput/NumberInput.doc.mjs +2 -2
  148. package/src/Outline/Outline.doc.mjs +123 -3
  149. package/src/Outline/Outline.test.tsx +735 -0
  150. package/src/Outline/Outline.tsx +184 -33
  151. package/src/Outline/useScrollSpy.ts +248 -72
  152. package/src/Selector/SelectorOption.doc.mjs +1 -1
  153. package/src/SideNav/SideNavItem.doc.mjs +2 -2
  154. package/src/Switch/Switch.doc.mjs +1 -1
  155. package/src/TabList/Tab.tsx +5 -1
  156. package/src/TabList/TabList.doc.mjs +3 -3
  157. package/src/TabList/TabList.test.tsx +66 -0
  158. package/src/TabList/TabList.tsx +8 -0
  159. package/src/TabList/TabMenu.tsx +4 -1
  160. package/src/Table/Table.doc.mjs +2 -0
  161. package/src/Table/Table.tsx +1 -1
  162. package/src/Table/index.ts +7 -0
  163. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +90 -1
  164. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +37 -5
  165. package/src/Table/plugins/sortable/useTableSortable.test.tsx +95 -9
  166. package/src/Table/plugins/sortable/useTableSortable.tsx +23 -7
  167. package/src/Table/plugins/tree/index.ts +12 -0
  168. package/src/Table/plugins/tree/useTableTreeData-perf.test.tsx +163 -0
  169. package/src/Table/plugins/tree/useTableTreeData.test.tsx +594 -0
  170. package/src/Table/plugins/tree/useTableTreeData.tsx +495 -0
  171. package/src/Table/plugins/tree/useTableTreeState.test.tsx +636 -0
  172. package/src/Table/plugins/tree/useTableTreeState.tsx +253 -0
  173. package/src/Table/useBaseTablePlugins.ts +6 -2
  174. package/src/Table/useTableTreeData.doc.mjs +75 -0
  175. package/src/Table/useTableTreeState.doc.mjs +98 -0
  176. package/src/TextArea/TextArea.doc.mjs +1 -1
  177. package/src/TextInput/TextInput.doc.mjs +1 -1
  178. package/src/Toolbar/Toolbar.tsx +10 -3
  179. package/src/TreeList/TreeList.test.tsx +32 -1
  180. package/src/TreeList/TreeListItem.tsx +19 -10
  181. package/src/docs-script.test.mjs +18 -13
  182. package/src/postinstall.test.mjs +71 -0
  183. package/src/theme/Theme.doc.mjs +4 -4
  184. package/src/theme/Theme.tsx +1 -1
  185. package/src/theme/defineTheme.ts +1 -1
  186. package/src/theme/hct.ts +0 -4
  187. package/dist/TreeList/treeListItem.markers.stylex.d.ts +0 -16
  188. package/dist/TreeList/treeListItem.markers.stylex.d.ts.map +0 -1
  189. package/dist/TreeList/treeListItem.markers.stylex.js +0 -22
  190. package/src/TreeList/treeListItem.markers.stylex.ts +0 -20
@@ -0,0 +1,232 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file DropdownMenuRadioItem.tsx
7
+ * @input React, stylex, Item + Icon + DropdownMenu context + tokens from core
8
+ * @output DropdownMenuRadioItem — a single option in a radio group.
9
+ * @position Sub-component; must be used inside a DropdownMenuRadioGroup.
10
+ *
11
+ * A menu item representing one option in a single-select group
12
+ * (role="menuitemradio"). The row owns the role and aria-checked; there is no
13
+ * nested native <input> (per the WAI-ARIA menuitemradio pattern). Selection
14
+ * state + onChange come from DropdownMenuRadioGroupContext. Keyboard nav +
15
+ * Enter/Space activation come from the parent DropdownMenu.
16
+ *
17
+ * The round radio visual (circle + center dot) is decorative (aria-hidden) —
18
+ * the row owns the checked state. Its size is derived from the menu's item size
19
+ * and it swaps to the inline-end of the row on coarse-pointer (touch) devices
20
+ * via CSS `order`. The dot is a shaped element, not an icon (a filled circle
21
+ * has no registry glyph).
22
+ */
23
+
24
+ import {useCallback, type ReactNode} from 'react';
25
+ import * as stylex from '@stylexjs/stylex';
26
+ import {renderIconSlot, type IconType} from '../Icon';
27
+ import {Item} from '../Item';
28
+ import {useDropdownMenuContext} from './DropdownMenuContext';
29
+ import {
30
+ colorVars,
31
+ spacingVars,
32
+ durationVars,
33
+ easeVars,
34
+ borderVars,
35
+ } from '../theme/tokens.stylex';
36
+ import {mergeProps, themeProps} from '../utils';
37
+ import type {BaseProps} from '../BaseProps';
38
+ import {useDropdownMenuRadioGroupContext} from './DropdownMenuContext';
39
+
40
+ const styles = stylex.create({
41
+ root: {
42
+ width: '100%',
43
+ borderRadius: `max(0px, calc(var(--_dropdown-menu-radius, ${spacingVars['--spacing-2']}) - var(--_dropdown-menu-padding, ${spacingVars['--spacing-1']})))`,
44
+ color: colorVars['--color-text-primary'],
45
+ backgroundColor: {
46
+ default: 'transparent',
47
+ ':focus': colorVars['--color-overlay-hover'],
48
+ ':hover': colorVars['--color-overlay-hover'],
49
+ },
50
+ cursor: 'pointer',
51
+ outline: 'none',
52
+ },
53
+ disabled: {
54
+ opacity: 0.5,
55
+ cursor: 'not-allowed',
56
+ },
57
+ // Rendered in Item's `marker` slot as a raw flex child. On touch it moves to
58
+ // the inline-end of the row via `order`.
59
+ circle: {
60
+ display: 'flex',
61
+ alignItems: 'center',
62
+ justifyContent: 'center',
63
+ flexShrink: 0,
64
+ boxSizing: 'border-box',
65
+ borderWidth: borderVars['--border-width'],
66
+ borderStyle: 'solid',
67
+ borderRadius: '50%',
68
+ transitionProperty: 'background-color, border-color',
69
+ transitionDuration: {
70
+ default: durationVars['--duration-fast'],
71
+ '@media (prefers-reduced-motion: reduce)': '0s',
72
+ },
73
+ transitionTimingFunction: easeVars['--ease-standard'],
74
+ order: {
75
+ default: 0,
76
+ '@media (pointer: coarse)': 1,
77
+ },
78
+ marginInlineStart: {
79
+ default: 0,
80
+ '@media (pointer: coarse)': 'auto',
81
+ },
82
+ },
83
+ unchecked: {
84
+ borderColor: colorVars['--color-border-emphasized'],
85
+ backgroundColor: colorVars['--color-background-surface'],
86
+ },
87
+ checked: {
88
+ borderColor: colorVars['--color-accent'],
89
+ backgroundColor: colorVars['--color-accent'],
90
+ },
91
+ dot: {
92
+ borderRadius: '50%',
93
+ backgroundColor: colorVars['--color-on-accent'],
94
+ },
95
+ });
96
+
97
+ const circleSizeStyles = stylex.create({
98
+ sm: {width: 18, height: 18},
99
+ md: {width: 22, height: 22},
100
+ });
101
+
102
+ const dotSizeStyles = stylex.create({
103
+ sm: {width: 6, height: 6},
104
+ md: {width: 8, height: 8},
105
+ });
106
+
107
+ export interface DropdownMenuRadioItemProps extends Omit<
108
+ BaseProps,
109
+ 'role' | 'aria-checked' | 'tabIndex'
110
+ > {
111
+ /**
112
+ * The value this item represents within its group. The group's `value`
113
+ * matches against this to determine the checked state.
114
+ */
115
+ value: string;
116
+ /**
117
+ * Primary label text identifying the option.
118
+ */
119
+ label: ReactNode;
120
+ /**
121
+ * Secondary description text displayed below the label.
122
+ */
123
+ description?: ReactNode;
124
+ /**
125
+ * Icon to display before the label. Accepts a semantic icon name (see
126
+ * `npx astryx docs icons`) or a rendered node.
127
+ */
128
+ icon?: ReactNode | IconType;
129
+ /**
130
+ * Whether this individual radio item is disabled. Disabled items stay
131
+ * focusable (via `aria-disabled`) so they remain discoverable by keyboard
132
+ * and assistive technology, but selection is blocked.
133
+ * @default false
134
+ */
135
+ isDisabled?: boolean;
136
+ /**
137
+ * Content to render after the label and description, such as a badge or
138
+ * metadata.
139
+ */
140
+ endContent?: ReactNode;
141
+ }
142
+
143
+ /**
144
+ * A single option in a DropdownMenuRadioGroup (role="menuitemradio").
145
+ *
146
+ * @example
147
+ * ```
148
+ * <DropdownMenuRadioGroup value={sort} onChange={setSort} aria-label="Sort by">
149
+ * <DropdownMenuRadioItem value="newest" label="Newest" />
150
+ * <DropdownMenuRadioItem value="oldest" label="Oldest" icon="clock" />
151
+ * </DropdownMenuRadioGroup>
152
+ * ```
153
+ */
154
+ export function DropdownMenuRadioItem({
155
+ value,
156
+ label,
157
+ description,
158
+ icon,
159
+ isDisabled = false,
160
+ endContent,
161
+ xstyle,
162
+ className,
163
+ style,
164
+ ...rest
165
+ }: DropdownMenuRadioItemProps) {
166
+ const menuCtx = useDropdownMenuContext();
167
+ const groupCtx = useDropdownMenuRadioGroupContext();
168
+ if (!groupCtx) {
169
+ throw new Error(
170
+ 'DropdownMenuRadioItem must be used within a DropdownMenuRadioGroup',
171
+ );
172
+ }
173
+ const menuSize = menuCtx?.menuSize ?? 'md';
174
+ const controlSize = menuSize === 'sm' ? 'sm' : 'md';
175
+ const isChecked = groupCtx.value === value;
176
+
177
+ const handleClick = useCallback(() => {
178
+ if (isDisabled) {
179
+ return;
180
+ }
181
+ groupCtx.onChange(value);
182
+ if (groupCtx.hasCloseOnSelect) {
183
+ menuCtx?.closeMenu();
184
+ }
185
+ }, [isDisabled, groupCtx, value, menuCtx]);
186
+
187
+ return (
188
+ <Item
189
+ {...rest}
190
+ role="menuitemradio"
191
+ aria-checked={isChecked}
192
+ tabIndex={isDisabled ? undefined : -1}
193
+ marker={
194
+ <span
195
+ aria-hidden="true"
196
+ {...mergeProps(
197
+ themeProps('dropdown-menu-radio', {
198
+ size: controlSize,
199
+ checked: isChecked ? 'checked' : null,
200
+ disabled: isDisabled ? 'disabled' : null,
201
+ }),
202
+ stylex.props(
203
+ styles.circle,
204
+ circleSizeStyles[controlSize],
205
+ isChecked ? styles.checked : styles.unchecked,
206
+ ),
207
+ )}>
208
+ {isChecked && (
209
+ <span {...stylex.props(styles.dot, dotSizeStyles[controlSize])} />
210
+ )}
211
+ </span>
212
+ }
213
+ startContent={
214
+ icon
215
+ ? renderIconSlot(icon, {size: 'sm', color: 'secondary'})
216
+ : undefined
217
+ }
218
+ label={label}
219
+ description={description}
220
+ endContent={endContent}
221
+ onClick={handleClick}
222
+ isDisabled={isDisabled}
223
+ xstyle={[styles.root, isDisabled && styles.disabled, xstyle]}
224
+ {...mergeProps(themeProps('dropdown-menu-item', {size: menuSize}), {
225
+ className,
226
+ style,
227
+ })}
228
+ />
229
+ );
230
+ }
231
+
232
+ DropdownMenuRadioItem.displayName = 'DropdownMenuRadioItem';
@@ -0,0 +1,163 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file DropdownMenuSelectable.test.tsx
5
+ * @input vitest, @testing-library/react, DropdownMenu + selectable items
6
+ * @output Unit tests for DropdownMenuCheckboxItem / RadioGroup / RadioItem (#3829)
7
+ */
8
+
9
+ import {describe, it, expect, vi, beforeEach} from 'vitest';
10
+ import {render, screen} from '@testing-library/react';
11
+ import userEvent from '@testing-library/user-event';
12
+ import {DropdownMenu} from './DropdownMenu';
13
+ import {DropdownMenuCheckboxItem} from './DropdownMenuCheckboxItem';
14
+ import {DropdownMenuRadioGroup} from './DropdownMenuRadioGroup';
15
+ import {DropdownMenuRadioItem} from './DropdownMenuRadioItem';
16
+
17
+ beforeEach(() => {
18
+ HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
19
+ this.setAttribute('popover-open', '');
20
+ const event = new Event('toggle', {bubbles: false});
21
+ Object.defineProperty(event, 'newState', {value: 'open'});
22
+ this.dispatchEvent(event);
23
+ });
24
+ HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) {
25
+ this.removeAttribute('popover-open');
26
+ const event = new Event('toggle', {bubbles: false});
27
+ Object.defineProperty(event, 'newState', {value: 'closed'});
28
+ this.dispatchEvent(event);
29
+ });
30
+ const originalMatches = HTMLElement.prototype.matches;
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ (HTMLElement.prototype as any).matches = function (
33
+ selector: string,
34
+ ): boolean {
35
+ if (selector === ':popover-open') {
36
+ return this.hasAttribute('popover-open');
37
+ }
38
+ return originalMatches.call(this, selector);
39
+ };
40
+ });
41
+
42
+ describe('DropdownMenuCheckboxItem', () => {
43
+ it('renders role menuitemcheckbox and reflects checked state', async () => {
44
+ const user = userEvent.setup();
45
+ render(
46
+ <DropdownMenu button={{label: 'View'}}>
47
+ <DropdownMenuCheckboxItem label="Show archived" value={true} />
48
+ </DropdownMenu>,
49
+ );
50
+ await user.click(screen.getByRole('button', {name: /View/}));
51
+ expect(
52
+ screen.getByRole('menuitemcheckbox', {
53
+ name: /Show archived/,
54
+ hidden: true,
55
+ }),
56
+ ).toHaveAttribute('aria-checked', 'true');
57
+ });
58
+
59
+ it('calls onChange with the toggled value on click', async () => {
60
+ const user = userEvent.setup();
61
+ const onChangeSpy = vi.fn();
62
+ render(
63
+ <DropdownMenu button={{label: 'View'}}>
64
+ <DropdownMenuCheckboxItem
65
+ label="Show archived"
66
+ value={false}
67
+ onChange={onChangeSpy}
68
+ />
69
+ </DropdownMenu>,
70
+ );
71
+ await user.click(screen.getByRole('button', {name: /View/}));
72
+ await user.click(
73
+ screen.getByRole('menuitemcheckbox', {
74
+ name: /Show archived/,
75
+ hidden: true,
76
+ }),
77
+ );
78
+ expect(onChangeSpy).toHaveBeenCalledWith(true);
79
+ });
80
+
81
+ it('does not toggle when disabled', async () => {
82
+ const user = userEvent.setup();
83
+ const onChangeSpy = vi.fn();
84
+ render(
85
+ <DropdownMenu button={{label: 'View'}}>
86
+ <DropdownMenuCheckboxItem
87
+ label="Show archived"
88
+ value={false}
89
+ onChange={onChangeSpy}
90
+ isDisabled
91
+ />
92
+ </DropdownMenu>,
93
+ );
94
+ await user.click(screen.getByRole('button', {name: /View/}));
95
+ const item = screen.getByRole('menuitemcheckbox', {
96
+ name: /Show archived/,
97
+ hidden: true,
98
+ });
99
+ expect(item).toHaveAttribute('aria-disabled', 'true');
100
+ await user.click(item);
101
+ expect(onChangeSpy).not.toHaveBeenCalled();
102
+ });
103
+ });
104
+
105
+ describe('DropdownMenuRadioGroup / RadioItem', () => {
106
+ it('renders a named group with radios reflecting the selected value', async () => {
107
+ const user = userEvent.setup();
108
+ render(
109
+ <DropdownMenu button={{label: 'Sort'}}>
110
+ <DropdownMenuRadioGroup
111
+ value="newest"
112
+ onChange={() => {}}
113
+ aria-label="Sort by">
114
+ <DropdownMenuRadioItem value="newest" label="Newest" />
115
+ <DropdownMenuRadioItem value="oldest" label="Oldest" />
116
+ </DropdownMenuRadioGroup>
117
+ </DropdownMenu>,
118
+ );
119
+ await user.click(screen.getByRole('button', {name: /Sort/}));
120
+ expect(
121
+ screen.getByRole('menuitemradio', {name: 'Newest', hidden: true}),
122
+ ).toHaveAttribute('aria-checked', 'true');
123
+ expect(
124
+ screen.getByRole('menuitemradio', {name: 'Oldest', hidden: true}),
125
+ ).toHaveAttribute('aria-checked', 'false');
126
+ expect(
127
+ screen.getByRole('group', {name: 'Sort by', hidden: true}),
128
+ ).toBeInTheDocument();
129
+ });
130
+
131
+ it('calls onChange with the selected value', async () => {
132
+ const user = userEvent.setup();
133
+ const onChange = vi.fn();
134
+ render(
135
+ <DropdownMenu button={{label: 'Sort'}}>
136
+ <DropdownMenuRadioGroup
137
+ value="newest"
138
+ onChange={onChange}
139
+ aria-label="Sort by">
140
+ <DropdownMenuRadioItem value="newest" label="Newest" />
141
+ <DropdownMenuRadioItem value="oldest" label="Oldest" />
142
+ </DropdownMenuRadioGroup>
143
+ </DropdownMenu>,
144
+ );
145
+ await user.click(screen.getByRole('button', {name: /Sort/}));
146
+ await user.click(
147
+ screen.getByRole('menuitemradio', {name: 'Oldest', hidden: true}),
148
+ );
149
+ expect(onChange).toHaveBeenCalledWith('oldest');
150
+ });
151
+
152
+ it('throws when a radio item is used outside a group', () => {
153
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
154
+ expect(() =>
155
+ render(
156
+ <DropdownMenu button={{label: 'Sort'}}>
157
+ <DropdownMenuRadioItem value="x" label="X" />
158
+ </DropdownMenu>,
159
+ ),
160
+ ).toThrow(/DropdownMenuRadioGroup/);
161
+ spy.mockRestore();
162
+ });
163
+ });
@@ -18,7 +18,27 @@ export {
18
18
  type DropdownMenuOption,
19
19
  } from './DropdownMenu';
20
20
 
21
+ export {DropdownMenuItem, type DropdownMenuItemProps} from './DropdownMenuItem';
22
+
23
+ // Selectable items — checkbox (independent) and radio (single-select group).
24
+ export {
25
+ DropdownMenuCheckboxItem,
26
+ type DropdownMenuCheckboxItemProps,
27
+ } from './DropdownMenuCheckboxItem';
28
+ export {
29
+ DropdownMenuRadioGroup,
30
+ type DropdownMenuRadioGroupProps,
31
+ } from './DropdownMenuRadioGroup';
32
+ export {
33
+ DropdownMenuRadioItem,
34
+ type DropdownMenuRadioItemProps,
35
+ } from './DropdownMenuRadioItem';
36
+
37
+ // Menu-coordination context — public so consumers can build custom menu items
38
+ // that read the menu size / close the menu.
21
39
  export {
22
- DropdownMenuItem,
23
- type DropdownMenuItemProps,
24
- } from './DropdownMenuItem';
40
+ DropdownMenuContext,
41
+ useDropdownMenuContext,
42
+ type DropdownMenuContextValue,
43
+ type DropdownMenuSize,
44
+ } from './DropdownMenuContext';
@@ -0,0 +1,22 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file menuItemRoles.ts
5
+ * @output Shared menu-item role set + focus selector.
6
+ * @position Internal; used by DropdownMenu and ContextMenu so their
7
+ * roving-focus, typeahead, and Enter/Space activation stay in sync.
8
+ *
9
+ * Includes the selectable items (menuitemradio/menuitemcheckbox) alongside
10
+ * plain menuitem so checkbox/radio rows are reachable by arrow keys, typeahead,
11
+ * and Enter/Space — not just role="menuitem".
12
+ */
13
+
14
+ export const MENU_ITEM_ROLES: ReadonlySet<string> = new Set([
15
+ 'menuitem',
16
+ 'menuitemradio',
17
+ 'menuitemcheckbox',
18
+ ]);
19
+
20
+ export const MENU_ITEM_SELECTOR: string = [...MENU_ITEM_ROLES]
21
+ .map(role => `[role="${role}"]:not([aria-disabled="true"])`)
22
+ .join(',');
@@ -95,7 +95,7 @@ export const docs = {
95
95
  {
96
96
  name: 'labelIcon',
97
97
  type: 'IconType',
98
- description: 'Icon to display before the label text. See `npx astryx docs icons` for valid semantic names.',
98
+ description: 'Icon to display before the label text. See `astryx docs icons` for valid semantic names.',
99
99
  },
100
100
  {
101
101
  name: 'labelTooltip',
@@ -48,7 +48,7 @@ export const docs = {
48
48
  {
49
49
  name: 'labelIcon',
50
50
  type: 'IconType',
51
- description: 'Icon before the label text. See `npx astryx docs icons` for valid semantic names.',
51
+ description: 'Icon before the label text. See `astryx docs icons` for valid semantic names.',
52
52
  },
53
53
  {
54
54
  name: 'labelTooltip',
@@ -34,6 +34,11 @@ export const docs = {
34
34
  description: 'Icon size.',
35
35
  default: "'md'",
36
36
  },
37
+ {
38
+ name: 'label',
39
+ type: 'string',
40
+ description: 'Accessible name for a MEANINGFUL, standalone icon (a status glyph or icon-only indicator with no adjacent text). Setting it exposes the icon to screen readers as role="img" with this text as the accessible name (aria-label) and removes the default aria-hidden. Omit it (default) for decorative icons and the icon stays hidden from assistive tech (aria-hidden="true"). This is the accessible-name / alt-text prop for icons — one prop instead of manually setting aria-label + role + aria-hidden. An empty string is treated as decorative. Do not set it when an interactive parent (Button, IconButton, link) already names the control.',
41
+ },
37
42
  ],
38
43
  theming: {
39
44
  targets: [
@@ -45,6 +50,7 @@ export const docs = {
45
50
  bestPractices: [
46
51
  { guidance: true, description: 'Use semantic icon names when available; they adapt to theme changes automatically.' },
47
52
  { guidance: true, description: 'Pair icons with text labels for accessibility; icon-only elements need an accessible label.' },
53
+ { guidance: true, description: 'For a meaningful standalone icon (no adjacent text), give it an accessible name via the `label` prop — it sets role="img" + aria-label and unhides the icon.' },
48
54
  { guidance: true, description: 'Use color tokens for icon colors, not hardcoded hex values.' },
49
55
  { guidance: true, description: 'Be mindful of context; decorative icons in compact components can distract rather than help.' },
50
56
  { guidance: false, description: 'Use icons as the sole means of conveying meaning; always provide a text alternative.' },
@@ -79,6 +85,11 @@ export const docsZh = {
79
85
  description: '图标尺寸。',
80
86
  default: "'md'",
81
87
  },
88
+ {
89
+ name: 'label',
90
+ type: 'string',
91
+ description: '有含义的独立图标的可访问名称(无相邻文字的状态图标或纯图标指示器)。设置后会将图标以 role="img" 暴露给辅助技术,并以该文本作为可访问名称(aria-label),同时移除默认的 aria-hidden。省略(默认)用于装饰性图标,图标对辅助技术保持隐藏(aria-hidden="true")。空字符串按装饰性处理。当交互式父元素(Button、IconButton、链接)已命名该控件时请勿设置。',
92
+ },
82
93
  ],
83
94
  theming: {
84
95
  targets: [
@@ -123,5 +134,6 @@ export const docsDense = {
123
134
  icon: 'Semantic icon name or SVG component. Valid names: close, chevronDown, chevronLeft, chevronRight, check, success, error, warning, info, calendar, clock, externalLink, menu, moreHorizontal, search, arrowUp, arrowDown, arrowsUpDown, funnel, eyeSlash, viewColumns, copy, checkDouble, wrench, stop, microphone. For others, pass an SVG component.',
124
135
  color: 'Color variant mapped to Astryx icon color tokens.',
125
136
  size: 'Icon size.',
137
+ label: 'Accessible name for a meaningful, standalone icon. Sets role="img" + aria-label and drops the default aria-hidden. Omit (default) for decorative icons (stays aria-hidden). Empty string = decorative. The accessible-name/alt-text prop for icons.',
126
138
  },
127
139
  };
@@ -96,6 +96,52 @@ describe('Icon', () => {
96
96
  expect(screen.getByTestId('icon')).toBeInTheDocument();
97
97
  });
98
98
 
99
+ it('sizes component-mode icons in rem so they scale with root font-size', () => {
100
+ const sizes = {
101
+ xsm: '0.75rem',
102
+ sm: '1rem',
103
+ md: '1.25rem',
104
+ lg: '1.5rem',
105
+ } as const;
106
+ for (const [size, expected] of Object.entries(sizes)) {
107
+ const {unmount} = render(
108
+ <Icon
109
+ icon={TestIcon}
110
+ size={size as keyof typeof sizes}
111
+ data-testid="icon"
112
+ />,
113
+ );
114
+ const style = getComputedStyle(screen.getByTestId('icon'));
115
+ expect(style.width).toBe(expected);
116
+ expect(style.height).toBe(expected);
117
+ unmount();
118
+ }
119
+ });
120
+
121
+ it('sizes registry (string-mode) icons in rem, including fontSize', () => {
122
+ const sizes = {
123
+ xsm: '0.75rem',
124
+ sm: '1rem',
125
+ md: '1.25rem',
126
+ lg: '1.5rem',
127
+ } as const;
128
+ for (const [size, expected] of Object.entries(sizes)) {
129
+ const {unmount} = render(
130
+ <Icon
131
+ icon="check"
132
+ size={size as keyof typeof sizes}
133
+ data-testid="icon"
134
+ />,
135
+ );
136
+ const style = getComputedStyle(screen.getByTestId('icon'));
137
+ expect(style.width).toBe(expected);
138
+ expect(style.height).toBe(expected);
139
+ // fontSize is expressed in rem so 1em-based registry icons scale too.
140
+ expect(style.fontSize).toBe(expected);
141
+ unmount();
142
+ }
143
+ });
144
+
99
145
  it('forwards ref correctly', () => {
100
146
  const ref = vi.fn();
101
147
  render(<Icon icon={TestIcon} ref={ref} />);
@@ -137,4 +183,100 @@ describe('Icon', () => {
137
183
  expect(icon).toHaveAttribute('aria-label', 'Done');
138
184
  expect(icon).toHaveAttribute('aria-hidden', 'false');
139
185
  });
186
+
187
+ describe('label (accessible name)', () => {
188
+ it('makes a component-mode icon meaningful: role="img" + aria-label, no aria-hidden', () => {
189
+ render(<Icon icon={TestIcon} label="Completed" data-testid="icon" />);
190
+ const icon = screen.getByTestId('icon');
191
+ expect(icon).toHaveAttribute('role', 'img');
192
+ expect(icon).toHaveAttribute('aria-label', 'Completed');
193
+ expect(icon).not.toHaveAttribute('aria-hidden');
194
+ });
195
+
196
+ it('makes a string-mode (registry) icon meaningful: role="img" + aria-label, no aria-hidden', () => {
197
+ render(<Icon icon="check" label="Completed" data-testid="icon" />);
198
+ const icon = screen.getByTestId('icon');
199
+ expect(icon).toHaveAttribute('role', 'img');
200
+ expect(icon).toHaveAttribute('aria-label', 'Completed');
201
+ expect(icon).not.toHaveAttribute('aria-hidden');
202
+ });
203
+
204
+ it('keeps the decorative default when label is omitted (component mode)', () => {
205
+ render(<Icon icon={TestIcon} data-testid="icon" />);
206
+ const icon = screen.getByTestId('icon');
207
+ expect(icon).toHaveAttribute('aria-hidden', 'true');
208
+ expect(icon).not.toHaveAttribute('role');
209
+ expect(icon).not.toHaveAttribute('aria-label');
210
+ });
211
+
212
+ it('keeps the decorative default when label is omitted (string mode)', () => {
213
+ render(<Icon icon="check" data-testid="icon" />);
214
+ const icon = screen.getByTestId('icon');
215
+ expect(icon).toHaveAttribute('aria-hidden', 'true');
216
+ expect(icon).not.toHaveAttribute('role');
217
+ expect(icon).not.toHaveAttribute('aria-label');
218
+ });
219
+
220
+ it('treats an empty string label as decorative (component mode)', () => {
221
+ render(<Icon icon={TestIcon} label="" data-testid="icon" />);
222
+ const icon = screen.getByTestId('icon');
223
+ expect(icon).toHaveAttribute('aria-hidden', 'true');
224
+ expect(icon).not.toHaveAttribute('role');
225
+ expect(icon).not.toHaveAttribute('aria-label');
226
+ });
227
+
228
+ it('treats an empty string label as decorative (string mode)', () => {
229
+ render(<Icon icon="check" label="" data-testid="icon" />);
230
+ const icon = screen.getByTestId('icon');
231
+ expect(icon).toHaveAttribute('aria-hidden', 'true');
232
+ expect(icon).not.toHaveAttribute('role');
233
+ expect(icon).not.toHaveAttribute('aria-label');
234
+ });
235
+
236
+ it('lets an explicit aria-hidden win over label (component mode)', () => {
237
+ render(
238
+ <Icon icon={TestIcon} label="Close" aria-hidden data-testid="icon" />,
239
+ );
240
+ const icon = screen.getByTestId('icon');
241
+ expect(icon).toHaveAttribute('aria-hidden', 'true');
242
+ });
243
+
244
+ it('lets an explicit aria-hidden win over label (string mode)', () => {
245
+ render(
246
+ <Icon icon="check" label="Close" aria-hidden data-testid="icon" />,
247
+ );
248
+ const icon = screen.getByTestId('icon');
249
+ expect(icon).toHaveAttribute('aria-hidden', 'true');
250
+ });
251
+
252
+ it('lets an explicit aria-label override the label-derived name (component mode)', () => {
253
+ render(
254
+ <Icon
255
+ icon={TestIcon}
256
+ label="Close"
257
+ aria-label="Dismiss"
258
+ data-testid="icon"
259
+ />,
260
+ );
261
+ expect(screen.getByTestId('icon')).toHaveAttribute(
262
+ 'aria-label',
263
+ 'Dismiss',
264
+ );
265
+ });
266
+
267
+ it('lets an explicit role override the label-derived role (string mode)', () => {
268
+ render(
269
+ <Icon
270
+ icon="check"
271
+ label="Close"
272
+ role="presentation"
273
+ data-testid="icon"
274
+ />,
275
+ );
276
+ expect(screen.getByTestId('icon')).toHaveAttribute(
277
+ 'role',
278
+ 'presentation',
279
+ );
280
+ });
281
+ });
140
282
  });