@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
@@ -150,7 +150,7 @@ const styles = stylex.create({
150
150
  *
151
151
  * @example
152
152
  * ```
153
- * <ChatMessage sender="assistant" name="Navi" avatar={<Avatar name="Navi" size="small" />}>
153
+ * <ChatMessage sender="assistant" name="Navi" avatar={<Avatar name="Navi" size="md" />}>
154
154
  * <ChatMessageBubble>Hello!</ChatMessageBubble>
155
155
  * <ChatMessageMetadata timestamp="2:30 PM" />
156
156
  * </ChatMessage>
@@ -222,7 +222,9 @@ export function ChatMessage({
222
222
  {...rest}
223
223
  ref={ref}
224
224
  data-testid={testId}
225
- aria-label={!hasName ? t('@astryx.chatMessage.messageFrom', {sender}) : undefined}
225
+ aria-label={
226
+ !hasName ? t('@astryx.chatMessage.messageFrom', {sender}) : undefined
227
+ }
226
228
  aria-labelledby={hasName ? nameId : undefined}
227
229
  {...mergeProps(
228
230
  themeProps('chat-message', {sender, density}),
@@ -113,7 +113,7 @@ export const docs = {
113
113
  {
114
114
  name: 'labelIcon',
115
115
  type: 'IconType',
116
- description: 'Icon to display before the label text. See `npx astryx docs icons` for valid semantic names.',
116
+ description: 'Icon to display before the label text. See `astryx docs icons` for valid semantic names.',
117
117
  },
118
118
  {
119
119
  name: 'status',
@@ -20,6 +20,7 @@ const probe = stylex.create({
20
20
  secondaryText: {color: colorVars['--color-text-secondary']},
21
21
  accentText: {color: colorVars['--color-text-accent']},
22
22
  badgeBackground: {backgroundColor: colorVars['--color-accent-muted']},
23
+ pointerCursor: {cursor: 'pointer'},
23
24
  });
24
25
 
25
26
  function atomicClasses(style: (typeof probe)[keyof typeof probe]): string[] {
@@ -118,4 +119,54 @@ describe('Citation', () => {
118
119
  expect(el.classList.contains(cls)).toBe(true);
119
120
  }
120
121
  });
122
+
123
+ // The interactive (pointer) treatment is keyed on `source.url`: a citation
124
+ // with no url is non-interactive and must keep the default cursor.
125
+ const noUrlSource = {title: 'No link'};
126
+
127
+ it('does not use the pointer cursor without a url in the label variant', () => {
128
+ render(<Citation source={noUrlSource} number={1} data-testid="citation" />);
129
+ const el = screen.getByTestId('citation');
130
+ for (const cls of atomicClasses(probe.pointerCursor)) {
131
+ expect(el.classList.contains(cls)).toBe(false);
132
+ }
133
+ });
134
+
135
+ it('uses the pointer cursor with a url in the label variant', () => {
136
+ render(<Citation source={source} number={1} data-testid="citation" />);
137
+ const el = screen.getByTestId('citation');
138
+ for (const cls of atomicClasses(probe.pointerCursor)) {
139
+ expect(el.classList.contains(cls)).toBe(true);
140
+ }
141
+ });
142
+
143
+ it('does not use the pointer cursor without a url in the number variant', () => {
144
+ render(
145
+ <Citation
146
+ source={noUrlSource}
147
+ number={1}
148
+ variant="number"
149
+ data-testid="citation"
150
+ />,
151
+ );
152
+ const el = screen.getByTestId('citation');
153
+ for (const cls of atomicClasses(probe.pointerCursor)) {
154
+ expect(el.classList.contains(cls)).toBe(false);
155
+ }
156
+ });
157
+
158
+ it('uses the pointer cursor with a url in the number variant', () => {
159
+ render(
160
+ <Citation
161
+ source={source}
162
+ number={1}
163
+ variant="number"
164
+ data-testid="citation"
165
+ />,
166
+ );
167
+ const el = screen.getByTestId('citation');
168
+ for (const cls of atomicClasses(probe.pointerCursor)) {
169
+ expect(el.classList.contains(cls)).toBe(true);
170
+ }
171
+ });
121
172
  });
@@ -61,7 +61,6 @@ const styles = stylex.create({
61
61
  paddingInline: spacingVars['--spacing-2'],
62
62
  marginInlineStart: spacingVars['--spacing-0-5'],
63
63
  textDecoration: 'none',
64
- cursor: 'pointer',
65
64
  transitionProperty: 'background-color, border-color, color',
66
65
  transitionDuration: durationVars['--duration-fast-max'],
67
66
  transitionTimingFunction: easeVars['--ease-standard'],
@@ -77,6 +76,9 @@ const styles = stylex.create({
77
76
  whiteSpace: 'nowrap',
78
77
  minWidth: 0,
79
78
  },
79
+ labelInteractive: {
80
+ cursor: 'pointer',
81
+ },
80
82
  labelHover: {
81
83
  backgroundColor: {
82
84
  ':hover': {
@@ -108,11 +110,13 @@ const styles = stylex.create({
108
110
  height: spacingVars['--spacing-5'],
109
111
  paddingInline: spacingVars['--spacing-1'],
110
112
  textDecoration: 'none',
111
- cursor: 'pointer',
112
113
  transitionProperty: 'background-color',
113
114
  transitionDuration: durationVars['--duration-fast-max'],
114
115
  transitionTimingFunction: easeVars['--ease-standard'],
115
116
  },
117
+ numberInteractive: {
118
+ cursor: 'pointer',
119
+ },
116
120
  numberHover: {
117
121
  backgroundColor: {
118
122
  // Explicit default for the same reason as labelHover's color: a
@@ -191,6 +195,7 @@ export function Citation({
191
195
  stylex.props(
192
196
  styles.number,
193
197
  href != null && styles.numberHover,
198
+ href != null && styles.numberInteractive,
194
199
  xstyle,
195
200
  ),
196
201
  className,
@@ -215,6 +220,7 @@ export function Citation({
215
220
  styles.label,
216
221
  icon != null && styles.labelWithIcon,
217
222
  href != null && styles.labelHover,
223
+ href != null && styles.labelInteractive,
218
224
  xstyle,
219
225
  ),
220
226
  className,
@@ -17,6 +17,7 @@ export const docs = {
17
17
  theming: {
18
18
  targets: [
19
19
  {className: 'astryx-collapsible', visualProps: ['density']},
20
+ {className: 'astryx-collapsible-content', visualProps: ['density']},
20
21
  {className: 'astryx-collapsible-group', visualProps: ['density']},
21
22
  ],
22
23
  },
@@ -53,6 +54,12 @@ export const docs = {
53
54
  type: 'boolean',
54
55
  description: 'Controlled open state.',
55
56
  },
57
+ {
58
+ name: 'isDisabled',
59
+ type: 'boolean',
60
+ description: "Disable the item so its trigger can't be toggled (dimmed, aria-disabled, and out of the tab order). Doesn't collapse an already-open item.",
61
+ default: 'false',
62
+ },
56
63
  {
57
64
  name: 'onOpenChange',
58
65
  type: '(isOpen: boolean) => void',
@@ -72,6 +72,12 @@ describe('Collapsible', () => {
72
72
  expect(screen.getByTestId('root')).toHaveClass('astryx-collapsible');
73
73
  });
74
74
 
75
+ it('renders the stable astryx-collapsible-content class on the content area', () => {
76
+ render(<Collapsible trigger="T">c</Collapsible>);
77
+ const content = contentFor(screen.getByRole('button'));
78
+ expect(content).toHaveClass('astryx-collapsible-content');
79
+ });
80
+
75
81
  it('renders a ReactNode trigger, not just a string', () => {
76
82
  render(
77
83
  <Collapsible trigger={<span data-testid="rich">Rich</span>}>
@@ -240,6 +246,80 @@ describe('Collapsible', () => {
240
246
  });
241
247
  });
242
248
 
249
+ describe('disabled state', () => {
250
+ it('marks the trigger aria-disabled and drops it from the tab order', () => {
251
+ render(
252
+ <Collapsible trigger="T" isDisabled>
253
+ Body
254
+ </Collapsible>,
255
+ );
256
+ const button = screen.getByRole('button');
257
+ expect(button).toHaveAttribute('aria-disabled', 'true');
258
+ expect(button).toHaveAttribute('tabindex', '-1');
259
+ // Never the native disabled attribute — it stays focusable/perceivable.
260
+ expect(button).not.toBeDisabled();
261
+ });
262
+
263
+ it('is enabled by default (no aria-disabled, stays in tab order)', () => {
264
+ render(<Collapsible trigger="T">Body</Collapsible>);
265
+ const button = screen.getByRole('button');
266
+ expect(button).not.toHaveAttribute('aria-disabled');
267
+ expect(button).not.toHaveAttribute('tabindex', '-1');
268
+ });
269
+
270
+ it('does not toggle when the trigger is clicked while disabled', async () => {
271
+ const user = userEvent.setup();
272
+ render(
273
+ <Collapsible trigger="T" isDisabled defaultIsOpen>
274
+ Body
275
+ </Collapsible>,
276
+ );
277
+ const button = screen.getByRole('button');
278
+ expect(button).toHaveAttribute('aria-expanded', 'true');
279
+ await user.click(button);
280
+ expect(button).toHaveAttribute('aria-expanded', 'true');
281
+ });
282
+
283
+ it('does not fire onOpenChange while disabled', async () => {
284
+ const user = userEvent.setup();
285
+ const onOpenChange = vi.fn();
286
+ render(
287
+ <Collapsible trigger="T" isDisabled onOpenChange={onOpenChange}>
288
+ Body
289
+ </Collapsible>,
290
+ );
291
+ await user.click(screen.getByRole('button'));
292
+ expect(onOpenChange).not.toHaveBeenCalled();
293
+ });
294
+
295
+ it('does not collapse an already-open item — content stays visible', () => {
296
+ render(
297
+ <Collapsible trigger="T" isDisabled defaultIsOpen>
298
+ Body
299
+ </Collapsible>,
300
+ );
301
+ const content = contentFor(screen.getByRole('button'));
302
+ expect(content).not.toHaveStyle({display: 'none'});
303
+ });
304
+
305
+ it('does not toggle its group item when disabled', async () => {
306
+ const user = userEvent.setup();
307
+ render(
308
+ <CollapsibleGroup type="single">
309
+ <Collapsible trigger="A" value="a" isDisabled>
310
+ Body A
311
+ </Collapsible>
312
+ <Collapsible trigger="B" value="b">
313
+ Body B
314
+ </Collapsible>
315
+ </CollapsibleGroup>,
316
+ );
317
+ const triggerA = screen.getByRole('button', {name: /A/});
318
+ await user.click(triggerA);
319
+ expect(triggerA).toHaveAttribute('aria-expanded', 'false');
320
+ });
321
+ });
322
+
243
323
  describe('prop forwarding', () => {
244
324
  it('forwards a ref to the root element', () => {
245
325
  const ref = vi.fn();
@@ -39,6 +39,7 @@ import {
39
39
  durationVars,
40
40
  easeVars,
41
41
  } from '../theme/tokens.stylex';
42
+
42
43
  import {useCollapsible} from './useCollapsible';
43
44
  import {CollapsibleGroupPresentationContext} from './CollapsibleGroupContext';
44
45
  import {getIcon} from '../Icon/globalIconRegistry';
@@ -81,6 +82,13 @@ const styles = stylex.create({
81
82
  textBoxEdge: 'cap alphabetic',
82
83
  textBoxTrim: 'trim-both',
83
84
  },
85
+ // Disabled trigger — non-interactive, dimmed. Native `disabled` on the
86
+ // button blocks click + keyboard activation; these styles restore the
87
+ // visual affordance that `all: unset` wipes.
88
+ triggerDisabled: {
89
+ cursor: 'not-allowed',
90
+ opacity: 0.5,
91
+ },
84
92
  // Chevron indicator
85
93
  chevron: {
86
94
  display: 'inline-flex',
@@ -102,8 +110,17 @@ const styles = stylex.create({
102
110
  contentHidden: {
103
111
  display: 'none',
104
112
  },
113
+ // Anchors body typography so revealed text renders at the system's body
114
+ // scale (family/size/weight/leading) instead of inheriting from wherever
115
+ // the Collapsible is placed. External themes override via the
116
+ // `astryx-collapsible-content` target.
105
117
  content: {
106
118
  paddingBlockStart: spacingVars['--spacing-1'],
119
+ fontFamily: typographyVars['--font-family-body'],
120
+ fontSize: typeScaleVars['--text-body-size'],
121
+ fontWeight: typeScaleVars['--text-body-weight'],
122
+ lineHeight: typeScaleVars['--text-body-leading'],
123
+ color: colorVars['--color-text-primary'],
107
124
  },
108
125
  // Group divider chrome — a hairline above every item except the first.
109
126
  // The group's wrapper (or 'all' mode) owns the outer edges.
@@ -166,6 +183,17 @@ export interface CollapsibleProps extends BaseProps {
166
183
  */
167
184
  isOpen?: boolean;
168
185
 
186
+ /**
187
+ * Whether the collapsible is disabled. A disabled item can't be toggled —
188
+ * its trigger is non-interactive and dimmed. Following the system-wide
189
+ * disabled convention, the trigger uses `aria-disabled` (not the native
190
+ * `disabled` attribute) and drops out of the tab order, staying perceivable
191
+ * to assistive tech. The content stays in whatever open state it was;
192
+ * disabling doesn't collapse an already-open item.
193
+ * @default false
194
+ */
195
+ isDisabled?: boolean;
196
+
169
197
  /**
170
198
  * Callback when the open state changes.
171
199
  */
@@ -225,6 +253,7 @@ export function Collapsible({
225
253
  children,
226
254
  defaultIsOpen,
227
255
  isOpen: controlledIsOpen,
256
+ isDisabled = false,
228
257
  onOpenChange,
229
258
  value,
230
259
  ref,
@@ -244,6 +273,17 @@ export function Collapsible({
244
273
  value,
245
274
  });
246
275
 
276
+ // Activation is blocked by this guard rather than the native `disabled`
277
+ // attribute, so the trigger keeps `aria-disabled` semantics and stays
278
+ // discoverable. A native `disabled` button would silently swallow events
279
+ // (e.g. a wrapping tooltip's hover) — the system-wide disabled convention.
280
+ const handleToggle = () => {
281
+ if (isDisabled) {
282
+ return;
283
+ }
284
+ toggle();
285
+ };
286
+
247
287
  const presentation = use(CollapsibleGroupPresentationContext);
248
288
  const isDivided = presentation?.hasDividers ?? false;
249
289
  const density = presentation?.density ?? null;
@@ -268,12 +308,20 @@ export function Collapsible({
268
308
  {...props}>
269
309
  <button
270
310
  type="button"
271
- onClick={toggle}
311
+ onClick={handleToggle}
312
+ aria-disabled={isDisabled || undefined}
272
313
  aria-expanded={isOpen}
273
314
  aria-controls={contentId}
315
+ // A disabled trigger drops out of the tab order so it isn't a silently
316
+ // dead tab stop; activation stays blocked by the handleToggle guard,
317
+ // and aria-disabled keeps the state perceivable to assistive tech —
318
+ // the system-wide disabled convention (never native `disabled`, which
319
+ // would swallow events like a wrapping tooltip's hover).
320
+ tabIndex={isDisabled ? -1 : undefined}
274
321
  {...stylex.props(
275
322
  styles.trigger,
276
323
  density != null && triggerDensity[density],
324
+ isDisabled && styles.triggerDisabled,
277
325
  )}>
278
326
  <span {...stylex.props(styles.triggerLabel)}>{trigger}</span>
279
327
  <span
@@ -286,10 +334,15 @@ export function Collapsible({
286
334
  </button>
287
335
  <div
288
336
  id={contentId}
289
- {...stylex.props(
290
- styles.content,
291
- density != null && contentDensity[density],
292
- !isOpen && styles.contentHidden,
337
+ {...mergeProps(
338
+ themeProps('collapsible-content', {
339
+ density: density ?? undefined,
340
+ }),
341
+ stylex.props(
342
+ styles.content,
343
+ density != null && contentDensity[density],
344
+ !isOpen && styles.contentHidden,
345
+ ),
293
346
  )}>
294
347
  {presentation != null ? (
295
348
  <CollapsibleGroupPresentationContext value={null}>
@@ -12,7 +12,12 @@ import {describe, it, expect, vi, beforeEach} from 'vitest';
12
12
  import {render, screen, fireEvent, act} from '@testing-library/react';
13
13
  import userEvent from '@testing-library/user-event';
14
14
  import {ContextMenu} from './ContextMenu';
15
- import {ContextMenuItem} from './index';
15
+ import {
16
+ ContextMenuItem,
17
+ ContextMenuCheckboxItem,
18
+ ContextMenuRadioGroup,
19
+ ContextMenuRadioItem,
20
+ } from './index';
16
21
  import {DropdownMenuItem} from '../DropdownMenu/DropdownMenuItem';
17
22
  import {Divider} from '../Divider';
18
23
 
@@ -537,3 +542,72 @@ describe('ContextMenu compound mode', () => {
537
542
  });
538
543
  });
539
544
  });
545
+
546
+ describe('ContextMenu selectable items', () => {
547
+ it('renders checkbox and radio items with correct roles and state', () => {
548
+ render(
549
+ <ContextMenu
550
+ menuContent={
551
+ <>
552
+ <ContextMenuRadioGroup
553
+ value="name"
554
+ onChange={() => {}}
555
+ aria-label="Sort by">
556
+ <ContextMenuRadioItem value="name" label="Sort by name" />
557
+ <ContextMenuRadioItem value="date" label="Sort by date" />
558
+ </ContextMenuRadioGroup>
559
+ <ContextMenuCheckboxItem label="Show hidden" value={true} />
560
+ </>
561
+ }>
562
+ <div>Right-click me</div>
563
+ </ContextMenu>,
564
+ );
565
+ expect(
566
+ screen.getByRole('menuitemradio', {name: 'Sort by name', hidden: true}),
567
+ ).toHaveAttribute('aria-checked', 'true');
568
+ expect(
569
+ screen.getByRole('menuitemradio', {name: 'Sort by date', hidden: true}),
570
+ ).toHaveAttribute('aria-checked', 'false');
571
+ expect(
572
+ screen.getByRole('menuitemcheckbox', {name: 'Show hidden', hidden: true}),
573
+ ).toHaveAttribute('aria-checked', 'true');
574
+ expect(
575
+ screen.getByRole('group', {name: 'Sort by', hidden: true}),
576
+ ).toBeInTheDocument();
577
+ });
578
+
579
+ it('fires onChange for radio and checkbox items', async () => {
580
+ const user = userEvent.setup();
581
+ const onSort = vi.fn();
582
+ const onToggle = vi.fn();
583
+ render(
584
+ <ContextMenu
585
+ menuContent={
586
+ <>
587
+ <ContextMenuRadioGroup
588
+ value="name"
589
+ onChange={onSort}
590
+ aria-label="Sort by">
591
+ <ContextMenuRadioItem value="name" label="Sort by name" />
592
+ <ContextMenuRadioItem value="date" label="Sort by date" />
593
+ </ContextMenuRadioGroup>
594
+ <ContextMenuCheckboxItem
595
+ label="Show hidden"
596
+ value={false}
597
+ onChange={onToggle}
598
+ />
599
+ </>
600
+ }>
601
+ <div>Right-click me</div>
602
+ </ContextMenu>,
603
+ );
604
+ await user.click(
605
+ screen.getByRole('menuitemradio', {name: 'Sort by date', hidden: true}),
606
+ );
607
+ expect(onSort).toHaveBeenCalledWith('date');
608
+ await user.click(
609
+ screen.getByRole('menuitemcheckbox', {name: 'Show hidden', hidden: true}),
610
+ );
611
+ expect(onToggle).toHaveBeenCalledWith(true);
612
+ });
613
+ });
@@ -46,6 +46,10 @@ import {
46
46
  DropdownMenuContext,
47
47
  type DropdownMenuContextValue,
48
48
  } from '../DropdownMenu/DropdownMenuContext';
49
+ import {
50
+ MENU_ITEM_ROLES,
51
+ MENU_ITEM_SELECTOR,
52
+ } from '../DropdownMenu/menuItemRoles';
49
53
  import {useListFocus} from '../hooks/useListFocus';
50
54
  import {useTypeahead} from '../hooks/useTypeahead';
51
55
  import {useLongPress} from '../hooks/useLongPress';
@@ -265,7 +269,7 @@ export function ContextMenu({
265
269
  focusFirst,
266
270
  focusItem,
267
271
  } = useListFocus<HTMLDivElement>({
268
- itemSelector: '[role="menuitem"]:not([aria-disabled="true"])',
272
+ itemSelector: MENU_ITEM_SELECTOR,
269
273
  wrap: false,
270
274
  onEscape: closeMenu,
271
275
  });
@@ -275,9 +279,7 @@ export function ContextMenu({
275
279
  (): HTMLElement[] =>
276
280
  listRef.current
277
281
  ? Array.from(
278
- listRef.current.querySelectorAll<HTMLElement>(
279
- '[role="menuitem"]:not([aria-disabled="true"])',
280
- ),
282
+ listRef.current.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR),
281
283
  )
282
284
  : [],
283
285
  [listRef],
@@ -341,7 +343,10 @@ export function ContextMenu({
341
343
  if (e.key === 'Enter' || e.key === ' ') {
342
344
  e.preventDefault();
343
345
  const focused = document.activeElement as HTMLElement | null;
344
- if (focused?.getAttribute('role') === 'menuitem') {
346
+ if (
347
+ focused &&
348
+ MENU_ITEM_ROLES.has(focused.getAttribute('role') ?? '')
349
+ ) {
345
350
  focused.click();
346
351
  }
347
352
  return;
@@ -20,3 +20,18 @@ export {
20
20
  DropdownMenuItem as ContextMenuItem,
21
21
  type DropdownMenuItemProps as ContextMenuItemProps,
22
22
  } from '../DropdownMenu/DropdownMenuItem';
23
+
24
+ // Selectable items work inside a ContextMenu too — re-exported under the
25
+ // ContextMenu name for a coherent API.
26
+ export {
27
+ DropdownMenuCheckboxItem as ContextMenuCheckboxItem,
28
+ type DropdownMenuCheckboxItemProps as ContextMenuCheckboxItemProps,
29
+ } from '../DropdownMenu/DropdownMenuCheckboxItem';
30
+ export {
31
+ DropdownMenuRadioGroup as ContextMenuRadioGroup,
32
+ type DropdownMenuRadioGroupProps as ContextMenuRadioGroupProps,
33
+ } from '../DropdownMenu/DropdownMenuRadioGroup';
34
+ export {
35
+ DropdownMenuRadioItem as ContextMenuRadioItem,
36
+ type DropdownMenuRadioItemProps as ContextMenuRadioItemProps,
37
+ } from '../DropdownMenu/DropdownMenuRadioItem';
@@ -8,10 +8,33 @@ export const docs = {
8
8
  group: 'DropdownMenu',
9
9
  category: 'Action',
10
10
  keywords: ["dropdown","menu","popover","select","actions","contextmenu","overflow","kebab","menubutton"],
11
+ playground: {
12
+ // `items` is required; without seeded entries the properties-tab preview
13
+ // renders an empty trigger button. Provide a few actions so the preview
14
+ // is meaningful.
15
+ defaults: {
16
+ button: {label: 'Actions'},
17
+ items: [
18
+ {label: 'Edit'},
19
+ {label: 'Duplicate'},
20
+ {label: 'Delete'},
21
+ ],
22
+ },
23
+ },
11
24
  theming: {
12
25
  targets: [
13
26
  {className: 'astryx-dropdown-menu'},
14
27
  {className: 'astryx-dropdown-menu-item', visualProps: ['size']},
28
+ {
29
+ className: 'astryx-dropdown-menu-checkbox',
30
+ visualProps: ['size'],
31
+ states: ['checked', 'disabled'],
32
+ },
33
+ {
34
+ className: 'astryx-dropdown-menu-radio',
35
+ visualProps: ['size'],
36
+ states: ['checked', 'disabled'],
37
+ },
15
38
  ],
16
39
  vars: [
17
40
  {name: '--_dropdown-menu-radius', description: 'Border radius of the menu popup', default: 'var(--radius-element)', private: true},
@@ -38,6 +38,7 @@ import {Icon} from '../Icon';
38
38
  import type {IconType} from '../Icon';
39
39
 
40
40
  import {renderDropdownItems} from './renderDropdownItems';
41
+ import {MENU_ITEM_ROLES, MENU_ITEM_SELECTOR} from './menuItemRoles';
41
42
  import {
42
43
  DropdownMenuContext,
43
44
  type DropdownMenuContextValue,
@@ -255,14 +256,17 @@ export function DropdownMenu({
255
256
  popover.hide();
256
257
  }, [popover]);
257
258
 
258
- // Single keyboard navigation path for both modes
259
+ // Single keyboard navigation path for both modes.
260
+ // The selector matches plain items plus selectable items
261
+ // (menuitemradio/menuitemcheckbox) so lab checkbox/radio rows are reachable
262
+ // and roved to alongside plain items — not just role="menuitem".
259
263
  const {
260
264
  listRef,
261
265
  handleKeyDown: listNavKeyDown,
262
266
  focusFirst,
263
267
  focusItem,
264
268
  } = useListFocus<HTMLDivElement>({
265
- itemSelector: '[role="menuitem"]:not([aria-disabled="true"])',
269
+ itemSelector: MENU_ITEM_SELECTOR,
266
270
  wrap: false,
267
271
  onEscape: closeMenu,
268
272
  });
@@ -273,9 +277,7 @@ export function DropdownMenu({
273
277
  (): HTMLElement[] =>
274
278
  listRef.current
275
279
  ? Array.from(
276
- listRef.current.querySelectorAll<HTMLElement>(
277
- '[role="menuitem"]:not([aria-disabled="true"])',
278
- ),
280
+ listRef.current.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR),
279
281
  )
280
282
  : [],
281
283
  [listRef],
@@ -308,7 +310,10 @@ export function DropdownMenu({
308
310
  if (e.key === 'Enter' || e.key === ' ') {
309
311
  e.preventDefault();
310
312
  const focused = document.activeElement as HTMLElement | null;
311
- if (focused?.getAttribute('role') === 'menuitem') {
313
+ if (
314
+ focused &&
315
+ MENU_ITEM_ROLES.has(focused.getAttribute('role') ?? '')
316
+ ) {
312
317
  focused.click();
313
318
  }
314
319
  return;