@astryxdesign/core 0.2.0-canary.f306f13 → 0.2.0-canary.fc53ff7

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 (162) hide show
  1. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  2. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  3. package/dist/AvatarGroup/AvatarGroupOverflow.js +6 -2
  4. package/dist/Button/Button.d.ts +1 -1
  5. package/dist/Button/Button.d.ts.map +1 -1
  6. package/dist/Button/Button.js +4 -2
  7. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  8. package/dist/CheckboxInput/CheckboxInput.js +2 -2
  9. package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
  10. package/dist/CheckboxList/CheckboxListItem.js +19 -4
  11. package/dist/CodeBlock/CodeBlock.js +2 -2
  12. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  13. package/dist/ContextMenu/ContextMenu.js +10 -1
  14. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +8 -11
  15. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  16. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +3 -1
  17. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +1 -1
  18. package/dist/DropdownMenu/DropdownMenuRadioItem.js +1 -1
  19. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  20. package/dist/DropdownMenu/DropdownMenuSubMenu.js +9 -1
  21. package/dist/DropdownMenu/menuItemHover.d.ts +1 -0
  22. package/dist/DropdownMenu/menuItemHover.d.ts.map +1 -1
  23. package/dist/DropdownMenu/menuItemHover.js +1 -0
  24. package/dist/Field/FieldLabel.d.ts +2 -1
  25. package/dist/Field/FieldLabel.d.ts.map +1 -1
  26. package/dist/Field/FieldLabel.js +7 -3
  27. package/dist/FieldStatus/FieldStatus.d.ts.map +1 -1
  28. package/dist/FieldStatus/FieldStatus.js +12 -2
  29. package/dist/Item/Item.d.ts +16 -3
  30. package/dist/Item/Item.d.ts.map +1 -1
  31. package/dist/Item/Item.js +31 -6
  32. package/dist/List/ListItem.d.ts +11 -1
  33. package/dist/List/ListItem.d.ts.map +1 -1
  34. package/dist/List/ListItem.js +2 -0
  35. package/dist/MetadataList/MetadataList.d.ts +1 -1
  36. package/dist/MetadataList/MetadataList.d.ts.map +1 -1
  37. package/dist/MetadataList/MetadataList.js +4 -2
  38. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  39. package/dist/MultiSelector/MultiSelector.js +14 -3
  40. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  41. package/dist/RadioList/RadioListItem.js +1 -1
  42. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  43. package/dist/SegmentedControl/SegmentedControlItem.js +3 -2
  44. package/dist/Selector/Selector.d.ts.map +1 -1
  45. package/dist/Selector/Selector.js +5 -0
  46. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  47. package/dist/Skeleton/Skeleton.js +2 -2
  48. package/dist/Switch/Switch.d.ts.map +1 -1
  49. package/dist/Switch/Switch.js +15 -4
  50. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  51. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +1 -1
  52. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  53. package/dist/Thumbnail/Thumbnail.js +42 -43
  54. package/dist/Toast/ToastViewport.d.ts.map +1 -1
  55. package/dist/Toast/ToastViewport.js +61 -9
  56. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  57. package/dist/ToggleButton/ToggleButton.js +3 -1
  58. package/dist/Token/Token.d.ts +4 -1
  59. package/dist/Token/Token.d.ts.map +1 -1
  60. package/dist/Token/Token.js +68 -43
  61. package/dist/Token/TokenLink.d.ts +62 -0
  62. package/dist/Token/TokenLink.d.ts.map +1 -0
  63. package/dist/Token/TokenLink.js +76 -0
  64. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  65. package/dist/TopNav/TopNavMegaMenu.js +9 -3
  66. package/dist/Typeahead/BaseTypeahead.d.ts +9 -0
  67. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  68. package/dist/Typeahead/BaseTypeahead.js +2 -0
  69. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  70. package/dist/Typeahead/Typeahead.js +8 -1
  71. package/dist/astryx.css +51 -8
  72. package/dist/astryx.umd.js +50 -50
  73. package/dist/astryx.umd.js.map +4 -4
  74. package/dist/hooks/containerReveal.pool.stylex.d.ts +281 -0
  75. package/dist/hooks/containerReveal.pool.stylex.d.ts.map +1 -0
  76. package/dist/hooks/containerReveal.pool.stylex.js +281 -0
  77. package/dist/hooks/index.d.ts +2 -0
  78. package/dist/hooks/index.d.ts.map +1 -1
  79. package/dist/hooks/index.js +2 -1
  80. package/dist/hooks/useContainerReveal.d.ts +83 -0
  81. package/dist/hooks/useContainerReveal.d.ts.map +1 -0
  82. package/dist/hooks/useContainerReveal.js +123 -0
  83. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  84. package/dist/hooks/useInputStatusIcon.js +11 -0
  85. package/dist/hooks/useKeyboardHint.d.ts +1 -1
  86. package/dist/hooks/useKeyboardHint.d.ts.map +1 -1
  87. package/dist/hooks/useKeyboardHint.js +4 -2
  88. package/locales/en.json +32 -0
  89. package/locales/pseudo.json +24 -0
  90. package/package.json +1 -1
  91. package/src/AvatarGroup/AvatarGroup.test.tsx +21 -1
  92. package/src/AvatarGroup/AvatarGroupOverflow.tsx +4 -2
  93. package/src/Breadcrumbs/Breadcrumbs.test.tsx +1 -1
  94. package/src/Button/Button.test.tsx +16 -0
  95. package/src/Button/Button.tsx +4 -2
  96. package/src/CheckboxInput/CheckboxInput.test.tsx +23 -0
  97. package/src/CheckboxInput/CheckboxInput.tsx +17 -2
  98. package/src/CheckboxList/CheckboxList.test.tsx +94 -3
  99. package/src/CheckboxList/CheckboxListItem.tsx +17 -10
  100. package/src/CodeBlock/CodeBlock.test.tsx +17 -0
  101. package/src/CodeBlock/CodeBlock.tsx +2 -2
  102. package/src/ContextMenu/ContextMenu.test.tsx +17 -2
  103. package/src/ContextMenu/ContextMenu.tsx +10 -1
  104. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +4 -12
  105. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +9 -10
  106. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +1 -1
  107. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +20 -2
  108. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +29 -1
  109. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +13 -5
  110. package/src/DropdownMenu/menuItemHover.ts +1 -0
  111. package/src/Field/Field.doc.mjs +4 -0
  112. package/src/Field/FieldLabel.test.tsx +25 -0
  113. package/src/Field/FieldLabel.tsx +11 -2
  114. package/src/FieldStatus/FieldStatus.doc.mjs +1 -0
  115. package/src/FieldStatus/FieldStatus.test.tsx +61 -0
  116. package/src/FieldStatus/FieldStatus.tsx +14 -3
  117. package/src/Item/Item.doc.mjs +5 -0
  118. package/src/Item/Item.test.tsx +88 -0
  119. package/src/Item/Item.tsx +57 -11
  120. package/src/List/ListItem.doc.mjs +12 -0
  121. package/src/List/ListItem.tsx +13 -0
  122. package/src/MetadataList/MetadataList.test.tsx +24 -0
  123. package/src/MetadataList/MetadataList.tsx +6 -2
  124. package/src/MultiSelector/MultiSelector.test.tsx +76 -0
  125. package/src/MultiSelector/MultiSelector.tsx +28 -7
  126. package/src/RadioList/RadioList.test.tsx +26 -4
  127. package/src/RadioList/RadioListItem.tsx +8 -1
  128. package/src/SegmentedControl/SegmentedControl.test.tsx +28 -0
  129. package/src/SegmentedControl/SegmentedControlItem.tsx +20 -2
  130. package/src/Selector/Selector.test.tsx +22 -0
  131. package/src/Selector/Selector.tsx +8 -1
  132. package/src/Skeleton/Skeleton.test.tsx +16 -0
  133. package/src/Skeleton/Skeleton.tsx +13 -1
  134. package/src/Switch/Switch.test.tsx +37 -0
  135. package/src/Switch/Switch.tsx +52 -3
  136. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +19 -0
  137. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +3 -1
  138. package/src/Thumbnail/Thumbnail.tsx +18 -32
  139. package/src/Toast/ToastViewport.test.tsx +218 -8
  140. package/src/Toast/ToastViewport.tsx +71 -12
  141. package/src/ToggleButton/ToggleButton.test.tsx +24 -0
  142. package/src/ToggleButton/ToggleButton.tsx +19 -1
  143. package/src/Token/Token.test.tsx +145 -0
  144. package/src/Token/Token.tsx +73 -33
  145. package/src/Token/TokenLink.tsx +107 -0
  146. package/src/TopNav/TopNavMegaMenu.tsx +26 -2
  147. package/src/Typeahead/BaseTypeahead.tsx +12 -0
  148. package/src/Typeahead/Typeahead.test.tsx +62 -0
  149. package/src/Typeahead/Typeahead.tsx +6 -0
  150. package/src/__tests__/forcedColors.ts +60 -0
  151. package/src/hooks/containerReveal.pool.stylex.ts +464 -0
  152. package/src/hooks/index.ts +7 -0
  153. package/src/hooks/useContainerReveal.test.tsx +116 -0
  154. package/src/hooks/useContainerReveal.ts +180 -0
  155. package/src/hooks/useInputStatusIcon.test.tsx +59 -0
  156. package/src/hooks/useInputStatusIcon.tsx +12 -1
  157. package/src/hooks/useKeyboardHint.test.tsx +22 -0
  158. package/src/hooks/useKeyboardHint.tsx +6 -2
  159. package/dist/Thumbnail/thumbnail.markers.stylex.d.ts +0 -9
  160. package/dist/Thumbnail/thumbnail.markers.stylex.d.ts.map +0 -1
  161. package/dist/Thumbnail/thumbnail.markers.stylex.js +0 -14
  162. package/src/Thumbnail/thumbnail.markers.stylex.ts +0 -12
@@ -372,6 +372,67 @@ describe('FieldStatus', () => {
372
372
  });
373
373
  });
374
374
 
375
+ describe('field-status-icon theme target', () => {
376
+ // The stable theme target lands on the detached message box's leading glyph
377
+ // itself, so a theme can restyle (e.g. resize) just this icon via
378
+ // `defineTheme`. It reflects the status type as a data attribute so themes
379
+ // can target per status, mirroring the parent astryx-field-status.
380
+ const getStatusIcon = (root: HTMLElement): HTMLElement => {
381
+ const icon = root.querySelector('.astryx-field-status-icon');
382
+ if (icon == null) {
383
+ throw new Error('status icon not found');
384
+ }
385
+ return icon as HTMLElement;
386
+ };
387
+
388
+ it('renders the target on the detached leading icon', () => {
389
+ render(
390
+ <FieldStatus
391
+ type="error"
392
+ message="msg"
393
+ variant="detached"
394
+ data-testid="fs"
395
+ />,
396
+ );
397
+ const icon = getStatusIcon(screen.getByTestId('fs'));
398
+ expect(icon).toHaveClass('astryx-field-status-icon');
399
+ expect(icon).toHaveClass('astryx-icon');
400
+ expect(icon).toHaveAttribute('data-type', 'error');
401
+ });
402
+
403
+ it('reflects the status type per status', () => {
404
+ for (const type of ['error', 'warning', 'success'] as const) {
405
+ const {unmount} = render(
406
+ <FieldStatus
407
+ type={type}
408
+ message="msg"
409
+ variant="detached"
410
+ data-testid="fs"
411
+ />,
412
+ );
413
+ expect(getStatusIcon(screen.getByTestId('fs'))).toHaveAttribute(
414
+ 'data-type',
415
+ type,
416
+ );
417
+ unmount();
418
+ }
419
+ });
420
+
421
+ it('does not render the target for the attached variant', () => {
422
+ render(
423
+ <FieldStatus
424
+ type="error"
425
+ message="msg"
426
+ variant="attached"
427
+ data-testid="fs"
428
+ />,
429
+ );
430
+ expect(
431
+ screen.getByTestId('fs').querySelector('.astryx-field-status-icon'),
432
+ ).toBeNull();
433
+ });
434
+ });
435
+
375
436
  describe('edge cases', () => {
376
437
  it('renders an empty message without crashing', () => {
377
438
  render(<FieldStatus type="error" message="" data-testid="fs" />);
@@ -56,8 +56,8 @@ const styles = stylex.create({
56
56
  paddingBlockStart: `calc(${spacingVars['--spacing-1-5']} + ${spacingVars['--spacing-2']})`,
57
57
  paddingBlockEnd: spacingVars['--spacing-2'],
58
58
  paddingInline: spacingVars['--spacing-2'],
59
- borderBottomLeftRadius: radiusVars['--radius-element'],
60
- borderBottomRightRadius: radiusVars['--radius-element'],
59
+ borderEndStartRadius: radiusVars['--radius-element'],
60
+ borderEndEndRadius: radiusVars['--radius-element'],
61
61
  },
62
62
  detached: {
63
63
  marginTop: spacingVars['--spacing-1'],
@@ -216,7 +216,18 @@ export function FieldStatus({
216
216
  {variant === 'detached' ? (
217
217
  <span {...stylex.props(styles.detachedContent)}>
218
218
  <span {...stylex.props(styles.detachedIcon)}>
219
- <Icon icon={statusIconMap[type]} size="sm" color="inherit" />
219
+ <Icon
220
+ icon={statusIconMap[type]}
221
+ size="sm"
222
+ color="inherit"
223
+ // Stable theme target on the detached message box's leading glyph
224
+ // itself, so a theme can restyle just this icon (color, size) —
225
+ // and each status — via `defineTheme`. Same-element rules in
226
+ // @layer astryx-theme win over the icon's own base
227
+ // width/height/fontSize, which a field-level target could not
228
+ // reach.
229
+ {...themeProps('field-status-icon', {type})}
230
+ />
220
231
  </span>
221
232
  <span>{message}</span>
222
233
  </span>
@@ -37,6 +37,7 @@ export const docs = {
37
37
  {name: 'labelLines', type: 'number', description: 'Max lines before label truncates with ellipsis.'},
38
38
  {name: 'descriptionLines', type: 'number', description: 'Max lines before description truncates with ellipsis.'},
39
39
  {name: 'onClick', type: '(event: MouseEvent) => void', description: 'Click handler. Makes the item clickable with button semantics.'},
40
+ {name: 'interactiveRef', type: 'RefObject<HTMLElement | null>', description: 'Ref to a nested control (e.g. a checkbox in startContent) that owns the item\'s keyboard access and action. The row becomes an enlarged click/tap target that delegates surface clicks to it (useClickableContainer) and renders no invisible button/anchor, so the row adds no second tab stop (WCAG 4.1.2). Mutually exclusive with onClick/href — those are ignored when set.'},
40
41
  {name: 'href', type: 'string', description: 'Link URL. Makes the item a link via an invisible anchor element.'},
41
42
  {name: 'target', type: "'_blank' | '_self'", description: 'Link target. Only used with href. target="_blank" automatically adds noopener noreferrer.'},
42
43
  {name: 'rel', type: 'string', description: 'Link relationship tokens. noopener noreferrer are merged automatically for target="_blank".'},
@@ -90,6 +91,8 @@ export const docsZh = {
90
91
  labelLines: '标签截断前的最大行数。',
91
92
  descriptionLines: '描述截断前的最大行数。',
92
93
  onClick: '点击处理函数。使项目可点击,具有按钮语义。',
94
+ interactiveRef:
95
+ '指向嵌套控件(如 startContent 中的复选框)的 ref,该控件承载项目的键盘访问和操作。行成为更大的点击/触摸目标,将表面点击委托给该控件(useClickableContainer),且不渲染不可见按钮/锚点,因此行不会增加第二个 Tab 停留点(WCAG 4.1.2)。与 onClick/href 互斥——设置后二者将被忽略。',
93
96
  href: '链接 URL。通过不可见锚点元素使项目成为链接。',
94
97
  target: '链接目标。仅与 href 一起使用。target="_blank" 会自动添加 noopener noreferrer。',
95
98
  rel: '链接关系标记。target="_blank" 会自动合并 noopener noreferrer。',
@@ -157,6 +160,8 @@ export const docsDense = {
157
160
  labelLines: 'Max label lines before truncation.',
158
161
  descriptionLines: 'Max description lines before truncation.',
159
162
  onClick: 'Click handler; enables button semantics.',
163
+ interactiveRef:
164
+ 'Ref to a nested control that owns the item\'s keyboard access/action; row delegates surface clicks to it (useClickableContainer), no invisible button/anchor, no second tab stop (WCAG 4.1.2). Mutually exclusive with onClick/href.',
160
165
  href: 'Link URL; enables anchor semantics.',
161
166
  target:
162
167
  'Link target, only with href. target="_blank" auto-adds noopener noreferrer.',
@@ -9,11 +9,35 @@
9
9
  * SYNC: When Item component changes, update tests to match new behavior
10
10
  */
11
11
 
12
+ import {useRef} from 'react';
12
13
  import {describe, it, expect, vi} from 'vitest';
13
14
  import {render, screen} from '@testing-library/react';
14
15
  import userEvent from '@testing-library/user-event';
15
16
  import {Item} from './Item';
16
17
 
18
+ /**
19
+ * Item in delegation mode: `interactiveRef` points at a nested control that
20
+ * owns the row's keyboard access and action. The row is an enlarged tap target
21
+ * that forwards surface clicks to that control (useClickableContainer).
22
+ */
23
+ function DelegatingItem({onToggle}: {onToggle?: () => void}) {
24
+ const ref = useRef<HTMLInputElement>(null);
25
+ return (
26
+ <Item
27
+ label="Row"
28
+ interactiveRef={ref}
29
+ startContent={
30
+ <input
31
+ ref={ref}
32
+ type="checkbox"
33
+ aria-label="Pick row"
34
+ onChange={onToggle}
35
+ />
36
+ }
37
+ />
38
+ );
39
+ }
40
+
17
41
  describe('Item', () => {
18
42
  // ===========================================================================
19
43
  // Basic rendering
@@ -190,6 +214,70 @@ describe('Item', () => {
190
214
  expect(buttons).toHaveLength(1);
191
215
  });
192
216
 
217
+ // ===========================================================================
218
+ // Interactive — interactiveRef (delegation to a nested control)
219
+ // ===========================================================================
220
+
221
+ it('renders no invisible button in interactiveRef (delegation) mode', () => {
222
+ const {container} = render(<DelegatingItem />);
223
+ // The nested control provides keyboard access — the row must not add a
224
+ // second focusable control for the same action (WCAG 4.1.2).
225
+ expect(container.querySelector('button')).not.toBeInTheDocument();
226
+ });
227
+
228
+ it('keeps the nested control as the only tab stop in interactiveRef mode', async () => {
229
+ const user = userEvent.setup();
230
+ render(<DelegatingItem />);
231
+ await user.tab();
232
+ expect(screen.getByRole('checkbox')).toHaveFocus();
233
+ // Next tab leaves the item entirely — the row itself is not focusable.
234
+ await user.tab();
235
+ expect(screen.getByRole('checkbox')).not.toHaveFocus();
236
+ expect(document.body).toHaveFocus();
237
+ });
238
+
239
+ it('delegates a row-surface click to the interactive control', async () => {
240
+ const user = userEvent.setup();
241
+ const onToggle = vi.fn();
242
+ render(<DelegatingItem onToggle={onToggle} />);
243
+ // Clicking the label (row surface) is forwarded to the checkbox.
244
+ await user.click(screen.getByText('Row'));
245
+ expect(onToggle).toHaveBeenCalledTimes(1);
246
+ });
247
+
248
+ it('does not double-fire when the interactive control itself is clicked', async () => {
249
+ const user = userEvent.setup();
250
+ const onToggle = vi.fn();
251
+ render(<DelegatingItem onToggle={onToggle} />);
252
+ await user.click(screen.getByRole('checkbox'));
253
+ // The row must not re-forward the control's own click back to it.
254
+ expect(onToggle).toHaveBeenCalledTimes(1);
255
+ });
256
+
257
+ it('ignores onClick when interactiveRef is set (delegation wins, single tab stop)', async () => {
258
+ const user = userEvent.setup();
259
+ const onClick = vi.fn();
260
+ function ItemWithBoth() {
261
+ const ref = useRef<HTMLInputElement>(null);
262
+ return (
263
+ <Item
264
+ label="Row"
265
+ onClick={onClick}
266
+ interactiveRef={ref}
267
+ startContent={
268
+ <input ref={ref} type="checkbox" aria-label="Pick row" />
269
+ }
270
+ />
271
+ );
272
+ }
273
+ const {container} = render(<ItemWithBoth />);
274
+ // No invisible button (onClick is ignored in delegation mode)...
275
+ expect(container.querySelector('button')).not.toBeInTheDocument();
276
+ // ...and the checkbox is still the sole tab stop.
277
+ await user.tab();
278
+ expect(screen.getByRole('checkbox')).toHaveFocus();
279
+ });
280
+
193
281
  // ===========================================================================
194
282
  // Interactive — href (invisible anchor pattern)
195
283
  // ===========================================================================
package/src/Item/Item.tsx CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file Item.tsx
7
- * @input Uses React, ReactNode, StyleXStyles, theme tokens
7
+ * @input Uses React, ReactNode, StyleXStyles, theme tokens, useClickableContainer
8
8
  * @output Exports Item component, ItemProps type
9
9
  * @position Core layout primitive; consumed by index.ts, tested by Item.test.tsx
10
10
  *
@@ -16,7 +16,7 @@
16
16
  * - /packages/cli/templates/blocks/components/Item/ (showcase blocks)
17
17
  */
18
18
 
19
- import type {ReactNode} from 'react';
19
+ import {useRef, type ReactNode} from 'react';
20
20
  import * as stylex from '@stylexjs/stylex';
21
21
  import {
22
22
  colorVars,
@@ -27,9 +27,11 @@ import {
27
27
  typeScaleVars,
28
28
  } from '../theme/tokens.stylex';
29
29
  import type {BaseProps} from '../BaseProps';
30
- import {mergeProps} from '../utils';
30
+ import {mergeProps, mergeRefs} from '../utils';
31
31
  import {computeTargetAndRel} from '../Link/computeTargetAndRel';
32
32
  import {useLinkComponent} from '../Link/useLinkComponent';
33
+ import {useClickableContainer} from '../hooks/useClickableContainer';
34
+ import {useDevWarning} from '../hooks/useDevWarning';
33
35
  import {themeProps} from '../utils/themeProps';
34
36
 
35
37
  // =============================================================================
@@ -108,6 +110,20 @@ export interface ItemProps extends BaseProps<HTMLElement> {
108
110
  */
109
111
  onClick?: (event: React.MouseEvent) => void;
110
112
 
113
+ /**
114
+ * Ref to a nested control inside the item (e.g. a checkbox in
115
+ * `startContent`) that already provides the item's keyboard access and
116
+ * action. When set, the item becomes an enlarged click/tap target that
117
+ * delegates surface clicks to that control via the `useClickableContainer`
118
+ * pattern: it renders no invisible button/anchor, so the row adds no second
119
+ * tab stop (WCAG 4.1.2 — one focusable control per option). Clicks on the
120
+ * control itself, and on any other nested interactive element, are left to
121
+ * that element. Mutually exclusive with `onClick`/`href` — when
122
+ * `interactiveRef` is set those are ignored (the nested control is the sole
123
+ * action).
124
+ */
125
+ interactiveRef?: React.RefObject<HTMLElement | null>;
126
+
111
127
  /**
112
128
  * Link URL. Makes the item a link via an invisible anchor element.
113
129
  */
@@ -349,6 +365,7 @@ export function Item({
349
365
  labelLines,
350
366
  descriptionLines,
351
367
  onClick,
368
+ interactiveRef,
352
369
  href,
353
370
  target: targetFromProps,
354
371
  rel: relFromProps,
@@ -363,7 +380,29 @@ export function Item({
363
380
  ...restProps
364
381
  }: ItemProps) {
365
382
  const LinkComponent = useLinkComponent();
366
- const isInteractive = onClick != null || href != null;
383
+
384
+ // Delegation mode: the row is an enlarged click/tap target for a nested
385
+ // control (e.g. a checkbox) that owns the keyboard access and action. The
386
+ // control is the row's only tab stop; the row proxies surface clicks to it.
387
+ const isDelegate = interactiveRef != null;
388
+ const containerRef = useRef<HTMLElement | null>(null);
389
+ // Only onClick is needed: onMouseUp handles middle-click href navigation,
390
+ // which delegation mode never has (href is ignored here).
391
+ const {onClick: delegatedOnClick} = useClickableContainer({
392
+ containerRef,
393
+ interactiveRef: interactiveRef ?? undefined,
394
+ disabled: isDisabled,
395
+ });
396
+
397
+ useDevWarning(
398
+ 'Item',
399
+ '`interactiveRef` is mutually exclusive with `onClick`/`href`. In ' +
400
+ 'delegation mode the row only forwards clicks to the referenced control, ' +
401
+ 'so `onClick`/`href` are ignored. Drop one of them.',
402
+ isDelegate && (onClick != null || href != null),
403
+ );
404
+
405
+ const isInteractive = onClick != null || href != null || isDelegate;
367
406
  const {target, rel} = computeTargetAndRel(targetFromProps, relFromProps);
368
407
  // When a semantic role is provided (e.g. "menuitem"), a parent component
369
408
  // handles keyboard access. Skip the invisible button/anchor and put
@@ -441,7 +480,10 @@ export function Item({
441
480
  <span {...stylex.props(styles.startContent)}>{startContent}</span>
442
481
  )}
443
482
 
444
- {hasParentRole ? (
483
+ {hasParentRole || isDelegate ? (
484
+ // Delegation mode (and parent-role mode) put the label in a plain span:
485
+ // keyboard access lives on the nested control, so no invisible
486
+ // button/anchor is rendered and the row adds no second tab stop.
445
487
  <span
446
488
  {...stylex.props(
447
489
  styles.content,
@@ -497,7 +539,9 @@ export function Item({
497
539
 
498
540
  return (
499
541
  <Component
500
- ref={ref as React.Ref<never>}
542
+ ref={
543
+ (isDelegate ? mergeRefs(ref, containerRef) : ref) as React.Ref<never>
544
+ }
501
545
  {...restProps}
502
546
  aria-selected={(allowsAriaSelected && isSelected) || undefined}
503
547
  // aria-selected is invalid on roles that don't permit it (listitem, a
@@ -527,11 +571,13 @@ export function Item({
527
571
  )}
528
572
  role={role}
529
573
  onClick={
530
- hasParentRole
531
- ? onClick
532
- : isInteractive
533
- ? handleContainerClick
534
- : undefined
574
+ isDelegate
575
+ ? delegatedOnClick
576
+ : hasParentRole
577
+ ? onClick
578
+ : isInteractive
579
+ ? handleContainerClick
580
+ : undefined
535
581
  }>
536
582
  {innerContent}
537
583
  </Component>
@@ -59,6 +59,11 @@ export const docs = {
59
59
  type: '(e: MouseEvent) => void',
60
60
  description: 'Click handler; enables the invisible button pattern.',
61
61
  },
62
+ {
63
+ name: 'interactiveRef',
64
+ type: 'RefObject<HTMLElement | null>',
65
+ description: 'Ref to a nested control (e.g. a checkbox in startContent) that owns the item\'s keyboard access and action. The row becomes an enlarged click/tap target that delegates surface clicks to it (useClickableContainer) and renders no invisible button/anchor, so the row adds no second tab stop (WCAG 4.1.2). Mutually exclusive with onClick/href — those are ignored when set.',
66
+ },
62
67
  {
63
68
  name: 'href',
64
69
  type: 'string',
@@ -121,6 +126,11 @@ export const docsZh = {
121
126
  type: '(e: MouseEvent) => void',
122
127
  description: '点击处理函数;启用隐形按钮模式。',
123
128
  },
129
+ {
130
+ name: 'interactiveRef',
131
+ type: 'RefObject<HTMLElement | null>',
132
+ description: '指向嵌套控件(如 startContent 中的复选框)的 ref,该控件承载项目的键盘访问和操作。行成为更大的点击/触摸目标,将表面点击委托给该控件(useClickableContainer),且不渲染不可见按钮/锚点,因此行不会增加第二个 Tab 停留点(WCAG 4.1.2)。与 onClick/href 互斥——设置后二者将被忽略。',
133
+ },
124
134
  {
125
135
  name: 'href',
126
136
  type: 'string',
@@ -162,6 +172,8 @@ export const docsDense = {
162
172
  startContent: 'Content before label area (e.g. icon, avatar).',
163
173
  endContent: 'Content after label area (e.g. badge, chevron).',
164
174
  onClick: 'Click handler; enables invisible button pattern.',
175
+ interactiveRef:
176
+ 'Ref to a nested control that owns the item\'s keyboard access/action; row delegates surface clicks to it (useClickableContainer), no invisible button/anchor, no second tab stop (WCAG 4.1.2). Mutually exclusive with onClick/href.',
165
177
  href: 'Link URL; enables invisible anchor pattern.',
166
178
  target: 'Link target attribute, only when href provided. target="_blank" auto-adds noopener noreferrer.',
167
179
  rel: 'Link relationship tokens. noopener noreferrer are merged for target="_blank".',
@@ -75,6 +75,17 @@ export interface ListItemProps extends BaseProps<HTMLLIElement> {
75
75
  */
76
76
  onClick?: (e: React.MouseEvent) => void;
77
77
 
78
+ /**
79
+ * Ref to a nested control inside the item (e.g. a checkbox in
80
+ * `startContent`) that already provides the item's keyboard access and
81
+ * action. When set, the item becomes an enlarged click/tap target that
82
+ * delegates surface clicks to that control via the `useClickableContainer`
83
+ * pattern: it renders no invisible button/anchor, so the row adds no second
84
+ * tab stop (WCAG 4.1.2 — one focusable control per option). Mutually
85
+ * exclusive with `onClick`/`href` — when set those are ignored.
86
+ */
87
+ interactiveRef?: React.RefObject<HTMLElement | null>;
88
+
78
89
  /**
79
90
  * URL for link items. Renders an invisible anchor element.
80
91
  * Automatically enables hover/press styles when provided.
@@ -199,6 +210,7 @@ export function ListItem({
199
210
  startContent,
200
211
  endContent,
201
212
  onClick,
213
+ interactiveRef,
202
214
  href,
203
215
  target,
204
216
  rel,
@@ -239,6 +251,7 @@ export function ListItem({
239
251
  description={description}
240
252
  endContent={endContent}
241
253
  onClick={onClick}
254
+ interactiveRef={interactiveRef}
242
255
  href={href}
243
256
  target={target as '_blank' | '_self'}
244
257
  rel={rel}
@@ -5,6 +5,7 @@ import {render, screen} from '@testing-library/react';
5
5
  import userEvent from '@testing-library/user-event';
6
6
  import {MetadataList} from './MetadataList';
7
7
  import {MetadataListItem} from './MetadataListItem';
8
+ import {InternationalizationProvider} from '../i18n';
8
9
 
9
10
  describe('MetadataList', () => {
10
11
  it('renders a description list with items', () => {
@@ -87,6 +88,29 @@ describe('MetadataList', () => {
87
88
  expect(screen.queryByText('B')).not.toBeInTheDocument();
88
89
  });
89
90
 
91
+ it('localizes the show more / show less labels through the i18n catalog', async () => {
92
+ const user = userEvent.setup();
93
+
94
+ render(
95
+ <InternationalizationProvider
96
+ locale="fr"
97
+ overrides={{
98
+ fr: {
99
+ '@astryx.metadataList.showMore': 'Afficher plus',
100
+ '@astryx.metadataList.showLess': 'Afficher moins',
101
+ },
102
+ }}>
103
+ <MetadataList maxNumOfItems={1}>
104
+ <MetadataListItem label="A">1</MetadataListItem>
105
+ <MetadataListItem label="B">2</MetadataListItem>
106
+ </MetadataList>
107
+ </InternationalizationProvider>,
108
+ );
109
+
110
+ await user.click(screen.getByText('Afficher plus'));
111
+ expect(screen.getByText('Afficher moins')).toBeInTheDocument();
112
+ });
113
+
90
114
  it('does not show toggle in horizontal mode even with maxNumOfItems', () => {
91
115
  render(
92
116
  <MetadataList orientation="horizontal" maxNumOfItems={1}>
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file MetadataList.tsx
7
- * @input Uses React, ReactNode, StyleXStyles, theme tokens, MetadataListContext
7
+ * @input Uses React, ReactNode, StyleXStyles, theme tokens, MetadataListContext, i18n (useTranslator)
8
8
  * @output Exports MetadataList component, MetadataListProps, MetadataListColumns types
9
9
  * @position Core implementation; consumed by index.ts, tested by MetadataList.test.tsx
10
10
  *
@@ -31,6 +31,7 @@ import {
31
31
  import type {BaseProps} from '../BaseProps';
32
32
  import {mergeProps} from '../utils';
33
33
  import {themeProps} from '../utils/themeProps';
34
+ import {useTranslator} from '../i18n';
34
35
 
35
36
  // =============================================================================
36
37
  // Types
@@ -197,6 +198,7 @@ export function MetadataList({
197
198
  const labelConfig = label ?? (isMultiColumn ? LABEL_TOP : LABEL_START);
198
199
  const [isShowAll, setIsShowAll] = useState(false);
199
200
  const contentId = useId();
201
+ const t = useTranslator();
200
202
 
201
203
  const contextValue = useMemo(
202
204
  () => ({
@@ -283,7 +285,9 @@ export function MetadataList({
283
285
  aria-expanded={isShowAll}
284
286
  onClick={() => setIsShowAll(prev => !prev)}
285
287
  {...stylex.props(styles.toggleButton)}>
286
- {isShowAll ? 'Show less' : 'Show more'}
288
+ {isShowAll
289
+ ? t('@astryx.metadataList.showLess')
290
+ : t('@astryx.metadataList.showMore')}
287
291
  </button>
288
292
  )}
289
293
  </div>
@@ -510,6 +510,59 @@ describe('MultiSelector', () => {
510
510
  expect(options[0]).toHaveTextContent('Select all');
511
511
  });
512
512
 
513
+ it('select-all accessible name reflects none/partial/all selection', async () => {
514
+ const user = userEvent.setup();
515
+ const options = [
516
+ {value: 'apple', label: 'Apple'},
517
+ {value: 'banana', label: 'Banana'},
518
+ ];
519
+ const {rerender} = render(
520
+ <MultiSelector
521
+ label="Fruit"
522
+ options={options}
523
+ value={[]}
524
+ onChange={() => {}}
525
+ hasSelectAll
526
+ />,
527
+ );
528
+
529
+ await user.click(screen.getByRole('combobox'));
530
+
531
+ // None selected: plain name, not selected
532
+ let selectAll = screen.getAllByRole('option', h)[0];
533
+ expect(selectAll).not.toHaveAccessibleName(/partially selected/);
534
+ expect(selectAll).toHaveAttribute('aria-selected', 'false');
535
+
536
+ // Partial: aria-selected="mixed" is invalid on role="option", so the
537
+ // indeterminate state must be conveyed through the accessible name.
538
+ rerender(
539
+ <MultiSelector
540
+ label="Fruit"
541
+ options={options}
542
+ value={['apple']}
543
+ onChange={() => {}}
544
+ hasSelectAll
545
+ />,
546
+ );
547
+ selectAll = screen.getAllByRole('option', h)[0];
548
+ expect(selectAll).toHaveAccessibleName('Select all, partially selected');
549
+ expect(selectAll).toHaveAttribute('aria-selected', 'false');
550
+
551
+ // All selected: plain name again, selected
552
+ rerender(
553
+ <MultiSelector
554
+ label="Fruit"
555
+ options={options}
556
+ value={['apple', 'banana']}
557
+ onChange={() => {}}
558
+ hasSelectAll
559
+ />,
560
+ );
561
+ selectAll = screen.getAllByRole('option', h)[0];
562
+ expect(selectAll).not.toHaveAccessibleName(/partially selected/);
563
+ expect(selectAll).toHaveAttribute('aria-selected', 'true');
564
+ });
565
+
513
566
  it('select-all toggles via keyboard Enter', async () => {
514
567
  const user = userEvent.setup();
515
568
  const onChange = vi.fn();
@@ -714,6 +767,29 @@ describe('MultiSelector', () => {
714
767
  ).toBeInTheDocument();
715
768
  });
716
769
 
770
+ it('empty-state message is not exposed as a listbox child', async () => {
771
+ const user = userEvent.setup();
772
+ render(
773
+ <MultiSelector
774
+ label="Fruit"
775
+ options={defaultOptions}
776
+ value={[]}
777
+ onChange={() => {}}
778
+ hasSearch
779
+ />,
780
+ );
781
+
782
+ await user.click(screen.getByRole('button', {name: 'Fruit'}));
783
+ await user.type(screen.getByRole('combobox', h), 'xyz');
784
+
785
+ // role="listbox" only permits option/group children — the visual
786
+ // empty-state message must be presentational (it is announced through
787
+ // the result-count live region instead).
788
+ const listbox = screen.getByRole('listbox', h);
789
+ const empty = within(listbox).getByText('No results found');
790
+ expect(empty).toHaveAttribute('role', 'presentation');
791
+ });
792
+
717
793
  describe('result announcements', () => {
718
794
  it('announces the match count politely while searching', async () => {
719
795
  const user = userEvent.setup();
@@ -119,11 +119,12 @@ const styles = stylex.create({
119
119
  lineHeight: 'inherit',
120
120
  color: 'inherit',
121
121
  cursor: 'pointer',
122
- outline: {
123
- default: 'none',
124
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
125
- },
126
- outlineOffset: '0',
122
+ // The wrapper (inputWrapperStyles.base) renders the focus ring via
123
+ // :focus-within when this button is focused, matching
124
+ // TextInput/NumberInput/Selector. The button must not draw its own
125
+ // :focus-visible outline or the two stack into a doubled ring over the
126
+ // trigger.
127
+ outline: 'none',
127
128
  borderRadius: radiusVars['--radius-element'],
128
129
  },
129
130
  triggerPlaceholder: {
@@ -1129,6 +1130,11 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1129
1130
  ? allEnabledSelected
1130
1131
  : optimisticValue.includes(item.value);
1131
1132
  const checkboxValue = isSelectAll ? selectAllState : isSelected;
1133
+ // aria-selected="mixed" is invalid on role="option", and the tri-state
1134
+ // checkbox is inert/decorative, so the indeterminate state must be
1135
+ // conveyed through the option's accessible name (WCAG 4.1.2).
1136
+ const isPartiallySelected =
1137
+ isSelectAll && selectAllState === 'indeterminate';
1132
1138
 
1133
1139
  return (
1134
1140
  <div
@@ -1136,6 +1142,13 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1136
1142
  id={getItemId(flatIndex)}
1137
1143
  role="option"
1138
1144
  aria-selected={isSelected}
1145
+ aria-label={
1146
+ isPartiallySelected
1147
+ ? t('@astryx.multiSelector.selectAllPartiallySelected', {
1148
+ label: selectAllLabel,
1149
+ })
1150
+ : undefined
1151
+ }
1139
1152
  aria-disabled={item.disabled}
1140
1153
  onClick={() => {
1141
1154
  if (!item.disabled) {
@@ -1180,10 +1193,12 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1180
1193
  optimisticValue,
1181
1194
  allEnabledSelected,
1182
1195
  selectAllState,
1196
+ selectAllLabel,
1183
1197
  getItemId,
1184
1198
  handleNavigableToggle,
1185
1199
  onItemMouseEnter,
1186
1200
  size,
1201
+ t,
1187
1202
  ],
1188
1203
  );
1189
1204
 
@@ -1211,10 +1226,16 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1211
1226
  cursor = 1;
1212
1227
  }
1213
1228
 
1214
- // Empty state — no real items to show
1229
+ // Empty state — no real items to show. role="presentation" keeps the
1230
+ // message out of the listbox's accessibility tree (role="listbox" only
1231
+ // permits option/group children); the no-results outcome is announced
1232
+ // via the result-count live region instead.
1215
1233
  if (realItemCount === 0) {
1216
1234
  elements.push(
1217
- <div key="empty" {...stylex.props(styles.emptyState)}>
1235
+ <div
1236
+ key="empty"
1237
+ role="presentation"
1238
+ {...stylex.props(styles.emptyState)}>
1218
1239
  No results found
1219
1240
  </div>,
1220
1241
  );