@astryxdesign/core 0.1.2-canary.bfcbf64 → 0.1.2-canary.c395fca

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 (132) hide show
  1. package/dist/Breadcrumbs/BreadcrumbItem.d.ts +1 -1
  2. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  3. package/dist/Breadcrumbs/BreadcrumbItem.js +4 -1
  4. package/dist/Button/Button.d.ts.map +1 -1
  5. package/dist/Button/Button.js +2 -4
  6. package/dist/Calendar/Calendar.d.ts.map +1 -1
  7. package/dist/Calendar/Calendar.js +33 -14
  8. package/dist/Calendar/hooks/index.d.ts +0 -2
  9. package/dist/Calendar/hooks/index.d.ts.map +1 -1
  10. package/dist/Calendar/hooks/index.js +1 -2
  11. package/dist/Calendar/index.d.ts +2 -2
  12. package/dist/Calendar/index.d.ts.map +1 -1
  13. package/dist/Calendar/index.js +1 -1
  14. package/dist/CheckboxList/CheckboxList.js +3 -3
  15. package/dist/Citation/Citation.d.ts.map +1 -1
  16. package/dist/Citation/Citation.js +3 -3
  17. package/dist/ContextMenu/ContextMenu.d.ts +1 -7
  18. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  19. package/dist/ContextMenu/ContextMenu.js +7 -13
  20. package/dist/DropdownMenu/DropdownMenu.d.ts +1 -8
  21. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  22. package/dist/DropdownMenu/DropdownMenu.js +4 -9
  23. package/dist/Field/Field.d.ts +4 -4
  24. package/dist/Field/Field.d.ts.map +1 -1
  25. package/dist/Field/Field.js +2 -2
  26. package/dist/Field/FieldLabel.d.ts +4 -4
  27. package/dist/Field/FieldLabel.d.ts.map +1 -1
  28. package/dist/Field/FieldLabel.js +2 -2
  29. package/dist/InputGroup/InputGroup.js +3 -3
  30. package/dist/Link/Link.d.ts.map +1 -1
  31. package/dist/Link/Link.js +2 -4
  32. package/dist/MoreMenu/MoreMenu.d.ts +1 -7
  33. package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
  34. package/dist/MoreMenu/MoreMenu.js +0 -2
  35. package/dist/MultiSelector/MultiSelector.d.ts +25 -2
  36. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  37. package/dist/MultiSelector/MultiSelector.js +31 -6
  38. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  39. package/dist/ProgressBar/ProgressBar.js +2 -4
  40. package/dist/RadioList/RadioList.js +3 -3
  41. package/dist/SegmentedControl/SegmentedControl.d.ts +1 -1
  42. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  43. package/dist/SegmentedControl/SegmentedControl.js +51 -61
  44. package/dist/Selector/Selector.d.ts +22 -1
  45. package/dist/Selector/Selector.d.ts.map +1 -1
  46. package/dist/Selector/Selector.js +31 -6
  47. package/dist/Switch/Switch.d.ts.map +1 -1
  48. package/dist/Switch/Switch.js +2 -4
  49. package/dist/TabList/TabList.d.ts +3 -2
  50. package/dist/TabList/TabList.d.ts.map +1 -1
  51. package/dist/TabList/TabList.js +55 -33
  52. package/dist/Table/tableContextMenu.d.ts.map +1 -1
  53. package/dist/Table/tableContextMenu.js +0 -3
  54. package/dist/TextArea/TextArea.d.ts.map +1 -1
  55. package/dist/TextArea/TextArea.js +2 -4
  56. package/dist/Toolbar/Toolbar.d.ts +3 -3
  57. package/dist/Toolbar/Toolbar.d.ts.map +1 -1
  58. package/dist/Toolbar/Toolbar.js +46 -6
  59. package/dist/TreeList/TreeList.d.ts.map +1 -1
  60. package/dist/TreeList/TreeList.js +16 -22
  61. package/dist/TreeList/TreeListItem.d.ts +3 -2
  62. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  63. package/dist/astryx.css +3 -0
  64. package/dist/astryx.umd.js +47 -47
  65. package/dist/astryx.umd.js.map +4 -4
  66. package/dist/hooks/index.d.ts +4 -2
  67. package/dist/hooks/index.d.ts.map +1 -1
  68. package/dist/hooks/index.js +1 -0
  69. package/dist/hooks/useGridFocus.d.ts +56 -0
  70. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  71. package/dist/hooks/useGridFocus.js +138 -24
  72. package/dist/hooks/useKeyboardHint.d.ts +82 -0
  73. package/dist/hooks/useKeyboardHint.d.ts.map +1 -0
  74. package/dist/hooks/useKeyboardHint.js +220 -0
  75. package/dist/hooks/useTreeFocus.d.ts +27 -3
  76. package/dist/hooks/useTreeFocus.d.ts.map +1 -1
  77. package/dist/hooks/useTreeFocus.js +69 -6
  78. package/package.json +1 -1
  79. package/src/Breadcrumbs/BreadcrumbItem.tsx +5 -2
  80. package/src/Button/Button.tsx +3 -16
  81. package/src/Calendar/Calendar.tsx +52 -22
  82. package/src/Calendar/hooks/index.ts +0 -6
  83. package/src/Calendar/index.ts +0 -3
  84. package/src/CheckboxList/CheckboxList.tsx +3 -3
  85. package/src/Citation/Citation.doc.mjs +10 -0
  86. package/src/Citation/Citation.test.tsx +117 -0
  87. package/src/Citation/Citation.tsx +9 -1
  88. package/src/ContextMenu/ContextMenu.doc.mjs +0 -6
  89. package/src/ContextMenu/ContextMenu.test.tsx +4 -6
  90. package/src/ContextMenu/ContextMenu.tsx +7 -19
  91. package/src/DropdownMenu/DropdownMenu.doc.mjs +1 -8
  92. package/src/DropdownMenu/DropdownMenu.test.tsx +0 -23
  93. package/src/DropdownMenu/DropdownMenu.tsx +4 -16
  94. package/src/Field/Field.test.tsx +2 -2
  95. package/src/Field/Field.tsx +5 -5
  96. package/src/Field/FieldLabel.tsx +5 -5
  97. package/src/InputGroup/InputGroup.tsx +3 -3
  98. package/src/Link/Link.tsx +2 -13
  99. package/src/MoreMenu/MoreMenu.doc.mjs +2 -17
  100. package/src/MoreMenu/MoreMenu.tsx +0 -9
  101. package/src/MultiSelector/MultiSelector.doc.mjs +25 -0
  102. package/src/MultiSelector/MultiSelector.test.tsx +150 -1
  103. package/src/MultiSelector/MultiSelector.tsx +55 -3
  104. package/src/ProgressBar/ProgressBar.tsx +2 -3
  105. package/src/RadioList/RadioList.tsx +3 -3
  106. package/src/SegmentedControl/SegmentedControl.tsx +51 -77
  107. package/src/Selector/Selector.doc.mjs +21 -0
  108. package/src/Selector/Selector.test.tsx +123 -1
  109. package/src/Selector/Selector.tsx +53 -3
  110. package/src/Switch/Switch.tsx +2 -16
  111. package/src/TabList/TabList.test.tsx +41 -0
  112. package/src/TabList/TabList.tsx +66 -39
  113. package/src/Table/tableContextMenu.tsx +1 -5
  114. package/src/TextArea/TextArea.tsx +3 -13
  115. package/src/Toolbar/Toolbar.test.tsx +64 -6
  116. package/src/Toolbar/Toolbar.tsx +55 -4
  117. package/src/TreeList/TreeList.tsx +17 -28
  118. package/src/TreeList/TreeListItem.tsx +3 -2
  119. package/src/VisuallyHidden/VisuallyHidden.doc.mjs +7 -7
  120. package/src/hooks/index.ts +12 -5
  121. package/src/hooks/useGridFocus.doc.mjs +40 -2
  122. package/src/hooks/useGridFocus.test.tsx +132 -0
  123. package/src/hooks/useGridFocus.ts +179 -23
  124. package/src/hooks/useKeyboardHint.doc.mjs +101 -0
  125. package/src/hooks/useKeyboardHint.test.tsx +70 -0
  126. package/src/hooks/useKeyboardHint.tsx +332 -0
  127. package/src/hooks/useTreeFocus.doc.mjs +15 -1
  128. package/src/hooks/useTreeFocus.ts +101 -6
  129. package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts +0 -57
  130. package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts.map +0 -1
  131. package/dist/Calendar/hooks/useCalendarRovingTabindex.js +0 -96
  132. package/src/Calendar/hooks/useCalendarRovingTabindex.ts +0 -118
@@ -0,0 +1,117 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Citation.test.tsx
5
+ * @input Uses React Testing Library, Citation, theme tokens
6
+ * @output Tests for Citation component
7
+ */
8
+
9
+ import {render, screen} from '@testing-library/react';
10
+ import {describe, it, expect} from 'vitest';
11
+ import * as stylex from '@stylexjs/stylex';
12
+ import {colorVars} from '../theme/tokens.stylex';
13
+ import {Citation} from './Citation';
14
+
15
+ // StyleX emits one deterministic atomic class per property/value pair, so an
16
+ // element carries a probe's class exactly when it has the same declaration.
17
+ // The dev-mode debug class (contains "__") varies by source location and is
18
+ // excluded from the comparison.
19
+ const probe = stylex.create({
20
+ secondaryText: {color: colorVars['--color-text-secondary']},
21
+ accentText: {color: colorVars['--color-text-accent']},
22
+ badgeBackground: {backgroundColor: colorVars['--color-accent-muted']},
23
+ });
24
+
25
+ function atomicClasses(style: (typeof probe)[keyof typeof probe]): string[] {
26
+ const {className = ''} = stylex.props(style);
27
+ return className.split(' ').filter(c => c !== '' && !c.includes('__'));
28
+ }
29
+
30
+ describe('Citation', () => {
31
+ const source = {title: 'Example Source', url: 'https://example.com'};
32
+
33
+ it('renders the source title as a link in the label variant', () => {
34
+ render(<Citation source={source} number={1} data-testid="citation" />);
35
+ const el = screen.getByTestId('citation');
36
+ expect(el.tagName).toBe('A');
37
+ expect(el).toHaveAttribute('href', 'https://example.com');
38
+ expect(el).toHaveTextContent('Example Source');
39
+ });
40
+
41
+ it('renders the index as a badge in the number variant', () => {
42
+ render(
43
+ <Citation
44
+ source={source}
45
+ number={3}
46
+ variant="number"
47
+ data-testid="citation"
48
+ />,
49
+ );
50
+ const el = screen.getByTestId('citation');
51
+ expect(el).toHaveTextContent('3');
52
+ expect(el).toHaveAttribute('role', 'doc-noteref');
53
+ expect(el).toHaveAttribute('aria-label', 'Citation 3: Example Source');
54
+ });
55
+
56
+ it('renders as a span when the source has no url', () => {
57
+ render(
58
+ <Citation
59
+ source={{title: 'No link'}}
60
+ number={1}
61
+ data-testid="citation"
62
+ />,
63
+ );
64
+ expect(screen.getByTestId('citation').tagName).toBe('SPAN');
65
+ });
66
+
67
+ it('renders astryx-* class names for theme targeting', () => {
68
+ render(<Citation source={source} number={1} data-testid="citation" />);
69
+ expect(screen.getByTestId('citation').className).toContain(
70
+ 'astryx-citation',
71
+ );
72
+ });
73
+
74
+ it('uses the secondary text color in the label variant', () => {
75
+ render(<Citation source={source} number={1} data-testid="citation" />);
76
+ const el = screen.getByTestId('citation');
77
+ for (const cls of atomicClasses(probe.secondaryText)) {
78
+ expect(el.classList.contains(cls)).toBe(true);
79
+ }
80
+ });
81
+
82
+ it('uses the secondary text color, not accent, in the number variant', () => {
83
+ render(
84
+ <Citation
85
+ source={source}
86
+ number={1}
87
+ variant="number"
88
+ data-testid="citation"
89
+ />,
90
+ );
91
+ const el = screen.getByTestId('citation');
92
+ for (const cls of atomicClasses(probe.secondaryText)) {
93
+ expect(el.classList.contains(cls)).toBe(true);
94
+ }
95
+ for (const cls of atomicClasses(probe.accentText)) {
96
+ expect(el.classList.contains(cls)).toBe(false);
97
+ }
98
+ });
99
+
100
+ it('keeps the accent-muted badge background when the source has a url', () => {
101
+ // `numberHover` must not clobber the base background: a hover-only
102
+ // conditional without a default replaces the whole property on merge,
103
+ // leaving linked badges with a transparent pill.
104
+ render(
105
+ <Citation
106
+ source={source}
107
+ number={1}
108
+ variant="number"
109
+ data-testid="citation"
110
+ />,
111
+ );
112
+ const el = screen.getByTestId('citation');
113
+ for (const cls of atomicClasses(probe.badgeBackground)) {
114
+ expect(el.classList.contains(cls)).toBe(true);
115
+ }
116
+ });
117
+ });
@@ -83,6 +83,10 @@ const styles = stylex.create({
83
83
  },
84
84
  },
85
85
  color: {
86
+ // Explicit default: without it, this hover-only conditional replaces
87
+ // the base secondary color from `label` (last-wins property merge),
88
+ // leaving linked citations to inherit the surrounding text color.
89
+ default: colorVars['--color-text-secondary'],
86
90
  ':hover': {
87
91
  '@media (hover: hover)': colorVars['--color-text-primary'],
88
92
  },
@@ -96,7 +100,7 @@ const styles = stylex.create({
96
100
  fontSize: typeScaleVars['--text-supporting-size'],
97
101
  fontWeight: fontWeightVars['--font-weight-semibold'],
98
102
  lineHeight: typeScaleVars['--text-supporting-leading'],
99
- color: colorVars['--color-text-accent'],
103
+ color: colorVars['--color-text-secondary'],
100
104
  backgroundColor: colorVars['--color-accent-muted'],
101
105
  borderRadius: radiusVars['--radius-full'],
102
106
  minWidth: spacingVars['--spacing-5'],
@@ -110,6 +114,10 @@ const styles = stylex.create({
110
114
  },
111
115
  numberHover: {
112
116
  backgroundColor: {
117
+ // Explicit default for the same reason as labelHover's color: a
118
+ // hover-only conditional replaces the base accent-muted pill from
119
+ // `number` on merge, leaving linked badges transparent.
120
+ default: colorVars['--color-accent-muted'],
113
121
  ':hover': {
114
122
  '@media (hover: hover)': colorVars['--color-overlay-hover'],
115
123
  },
@@ -58,12 +58,6 @@ export const docs = {
58
58
  description: 'Accessible name for the menu surface, announced when it opens.',
59
59
  default: "'Context menu'",
60
60
  },
61
- {
62
- name: 'hasAutoFocus',
63
- type: 'boolean',
64
- description: 'Whether to auto-focus the first menu item when the menu opens. Set to false for inline showcases.',
65
- default: 'true',
66
- },
67
61
  {
68
62
  name: 'isDisabled',
69
63
  type: 'boolean',
@@ -62,9 +62,7 @@ describe('ContextMenu', () => {
62
62
 
63
63
  it('typeahead focuses the matching menu item (menus-11)', () => {
64
64
  render(
65
- <ContextMenu
66
- items={[{label: 'Cut'}, {label: 'Copy'}, {label: 'Paste'}]}
67
- hasAutoFocus={false}>
65
+ <ContextMenu items={[{label: 'Cut'}, {label: 'Copy'}, {label: 'Paste'}]}>
68
66
  <div>Right-click me</div>
69
67
  </ContextMenu>,
70
68
  );
@@ -121,7 +119,7 @@ describe('ContextMenu', () => {
121
119
 
122
120
  it('closes on Escape even when opened without auto-focus', () => {
123
121
  render(
124
- <ContextMenu items={[{label: 'Item 1'}]} hasAutoFocus={false}>
122
+ <ContextMenu items={[{label: 'Item 1'}]}>
125
123
  <div>Right-click me</div>
126
124
  </ContextMenu>,
127
125
  );
@@ -135,7 +133,7 @@ describe('ContextMenu', () => {
135
133
 
136
134
  it('ignores Escape during IME composition', () => {
137
135
  render(
138
- <ContextMenu items={[{label: 'Item 1'}]} hasAutoFocus={false}>
136
+ <ContextMenu items={[{label: 'Item 1'}]}>
139
137
  <div>Right-click me</div>
140
138
  </ContextMenu>,
141
139
  );
@@ -147,7 +145,7 @@ describe('ContextMenu', () => {
147
145
 
148
146
  it('restores focus to the trigger on close', () => {
149
147
  render(
150
- <ContextMenu items={[{label: 'Item 1'}]} hasAutoFocus={false}>
148
+ <ContextMenu items={[{label: 'Item 1'}]}>
151
149
  <button type="button">Right-click me</button>
152
150
  </ContextMenu>,
153
151
  );
@@ -125,12 +125,6 @@ interface ContextMenuBaseProps extends BaseProps {
125
125
  * @default 'Context menu'
126
126
  */
127
127
  label?: string;
128
- /**
129
- * Whether to auto-focus the first menu item when the menu opens.
130
- * Set to `false` for inline showcases or documentation previews.
131
- * @default true
132
- */
133
- hasAutoFocus?: boolean;
134
128
  /** When true, right-click shows the native browser context menu instead. */
135
129
  isDisabled?: boolean;
136
130
  /** Called when the menu opens or closes. */
@@ -184,7 +178,6 @@ export function ContextMenu({
184
178
  menuWidth,
185
179
  size = 'md',
186
180
  label = 'Context menu',
187
- hasAutoFocus = true,
188
181
  isDisabled = false,
189
182
  onOpenChange,
190
183
  ref,
@@ -282,10 +275,9 @@ export function ContextMenu({
282
275
  }, [isOpen, closeMenu, listRef]);
283
276
 
284
277
  // Dismiss on Escape from anywhere while open. The menu div's own onKeyDown
285
- // only fires when focus is inside the menu, which never happens when the
286
- // menu is opened with hasAutoFocus={false} (e.g. table context menus), so a
287
- // document-level listener is required for a reliable Escape path. Guards
288
- // against IME composition-cancel.
278
+ // only fires when focus is inside the menu; a document-level listener is
279
+ // kept as a reliable fallback Escape path (e.g. if focus has moved out of
280
+ // the menu). Guards against IME composition-cancel.
289
281
  useEffect(() => {
290
282
  if (!isOpen) {
291
283
  return;
@@ -350,11 +342,9 @@ export function ContextMenu({
350
342
  ? document.activeElement
351
343
  : (e.currentTarget as HTMLElement);
352
344
  layer.show();
353
- if (hasAutoFocus) {
354
- requestAnimationFrame(() => focusFirst());
355
- }
345
+ requestAnimationFrame(() => focusFirst());
356
346
  },
357
- [isDisabled, layer, hasAutoFocus, focusFirst],
347
+ [isDisabled, layer, focusFirst],
358
348
  );
359
349
 
360
350
  // Touch long-press invocation (menus-8). iOS Safari never synthesizes a
@@ -367,11 +357,9 @@ export function ContextMenu({
367
357
  (point: {x: number; y: number}) => {
368
358
  positionRef.current = {x: point.x, y: point.y};
369
359
  layer.show();
370
- if (hasAutoFocus) {
371
- requestAnimationFrame(() => focusFirst());
372
- }
360
+ requestAnimationFrame(() => focusFirst());
373
361
  },
374
- [layer, hasAutoFocus, focusFirst],
362
+ [layer, focusFirst],
375
363
  ),
376
364
  });
377
365
 
@@ -61,14 +61,7 @@ export const docs = {
61
61
  type: 'boolean',
62
62
  description: 'Whether to show a chevron icon on the trigger button. Set to false for icon-only triggers.',
63
63
  default: 'true',
64
- },
65
- {
66
- name: 'hasAutoFocus',
67
- type: 'boolean',
68
- description: 'Whether to auto-focus the first menu item when the menu opens. Set to false for inline showcases or documentation previews.',
69
- default: 'true',
70
- },
71
- {
64
+ }, {
72
65
  name: 'children',
73
66
  type: '(item: DropdownMenuItemData) => ReactNode',
74
67
  description: 'Custom render function for each item in the list.',
@@ -249,29 +249,6 @@ describe('DropdownMenu controlled mode', () => {
249
249
  });
250
250
  });
251
251
 
252
- describe('DropdownMenu hasAutoFocus', () => {
253
- it('does not focus menu items when hasAutoFocus is false and isMenuOpen is true', () => {
254
- const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus');
255
- render(
256
- <DropdownMenu
257
- button={{label: 'Actions'}}
258
- items={[{label: 'Edit'}, {label: 'Delete'}]}
259
- isMenuOpen={true}
260
- hasAutoFocus={false}
261
- onOpenChange={() => {}}
262
- />,
263
- );
264
-
265
- const menuItems = screen.getAllByRole('menuitem', {hidden: true});
266
- const menuItemFocusCalls = focusSpy.mock.calls.filter((_, i) => {
267
- const ctx = focusSpy.mock.contexts[i];
268
- return menuItems.includes(ctx as HTMLElement);
269
- });
270
- expect(menuItemFocusCalls).toHaveLength(0);
271
- focusSpy.mockRestore();
272
- });
273
- });
274
-
275
252
  describe('DropdownMenu items', () => {
276
253
  it('renders items with labels', () => {
277
254
  render(
@@ -135,13 +135,6 @@ interface DropdownMenuBaseProps extends BaseProps {
135
135
  */
136
136
  placement?: LayerPlacement;
137
137
 
138
- /**
139
- * Whether to auto-focus the first menu item when the menu opens.
140
- * Set to `false` for inline showcases or documentation previews
141
- * where stealing focus is undesirable.
142
- * @default true
143
- */
144
- hasAutoFocus?: boolean;
145
138
  'data-testid'?: string;
146
139
  }
147
140
 
@@ -193,7 +186,6 @@ export function DropdownMenu({
193
186
  onClick,
194
187
  hasChevron = true,
195
188
  placement = 'below',
196
- hasAutoFocus = true,
197
189
  className,
198
190
  style,
199
191
  xstyle,
@@ -299,14 +291,12 @@ export function DropdownMenu({
299
291
  if (isControlled) {
300
292
  if (controlledIsOpen && !popover.isOpen) {
301
293
  popover.show();
302
- if (hasAutoFocus) {
303
- requestAnimationFrame(() => focusFirst());
304
- }
294
+ requestAnimationFrame(() => focusFirst());
305
295
  } else if (!controlledIsOpen && popover.isOpen) {
306
296
  popover.hide();
307
297
  }
308
298
  }
309
- }, [controlledIsOpen, isControlled, popover, hasAutoFocus, focusFirst]);
299
+ }, [controlledIsOpen, isControlled, popover, focusFirst]);
310
300
 
311
301
  // Extend useListFocus with Enter/Space activation + typeahead
312
302
  const listKeyDown = useCallback(
@@ -340,10 +330,8 @@ export function DropdownMenu({
340
330
 
341
331
  const openAndFocus = useCallback(() => {
342
332
  popover.show();
343
- if (hasAutoFocus) {
344
- requestAnimationFrame(() => focusFirst());
345
- }
346
- }, [popover, hasAutoFocus, focusFirst]);
333
+ requestAnimationFrame(() => focusFirst());
334
+ }, [popover, focusFirst]);
347
335
 
348
336
  const handleButtonClick = useCallback(() => {
349
337
  // If the menu was just closed by light dismiss (e.g. iOS Safari fires
@@ -109,7 +109,7 @@ describe('Field', () => {
109
109
  <Field
110
110
  label="Plan"
111
111
  inputID="plan-group"
112
- labelElementID="plan-label"
112
+ labelID="plan-label"
113
113
  isGroupLabel>
114
114
  <div role="radiogroup" aria-labelledby="plan-label" />
115
115
  </Field>,
@@ -119,7 +119,7 @@ describe('Field', () => {
119
119
  expect(labelEl.tagName).toBe('SPAN');
120
120
  expect(labelEl.closest('label')).toBeNull();
121
121
  expect(labelEl).not.toHaveAttribute('for');
122
- // labelElementID is applied to the label element and referenced by the group.
122
+ // labelID is applied to the label element and referenced by the group.
123
123
  expect(labelEl).toHaveAttribute('id', 'plan-label');
124
124
  const group = screen.getByRole('radiogroup', {name: 'Plan'});
125
125
  expect(group.getAttribute('aria-labelledby')).toBe(labelEl.id);
@@ -101,7 +101,7 @@ export interface FieldProps extends Omit<
101
101
  /**
102
102
  * ID of the input element this label points AT (used as the label's
103
103
  * `htmlFor`). This is the id of the *control*, not of the label element —
104
- * see `labelElementID` for the latter.
104
+ * see `labelID` for the latter.
105
105
  */
106
106
  inputID: string;
107
107
  /**
@@ -110,12 +110,12 @@ export interface FieldProps extends Omit<
110
110
  * (radiogroup, checkbox group) references this via `aria-labelledby` to take
111
111
  * the label as its accessible name. Pair with `isGroupLabel`.
112
112
  */
113
- labelElementID?: string;
113
+ labelID?: string;
114
114
  /**
115
115
  * When the field wraps a group of controls rather than a single input, set
116
116
  * this so the label renders as a non-`<label>` element (a `<span>`): a
117
117
  * `<label>` semantically names one control and can't be associated with a
118
- * group. Pair with `labelElementID` + `aria-labelledby` on the group.
118
+ * group. Pair with `labelID` + `aria-labelledby` on the group.
119
119
  * @default false
120
120
  */
121
121
  isGroupLabel?: boolean;
@@ -189,7 +189,7 @@ export function Field({
189
189
  isLabelHidden = false,
190
190
  description,
191
191
  inputID,
192
- labelElementID,
192
+ labelID,
193
193
  isGroupLabel = false,
194
194
  descriptionID,
195
195
  isOptional = false,
@@ -225,7 +225,7 @@ export function Field({
225
225
  <FieldLabel
226
226
  label={label}
227
227
  inputID={inputID}
228
- labelElementID={labelElementID}
228
+ labelID={labelID}
229
229
  isGroupLabel={isGroupLabel}
230
230
  isLabelHidden={isLabelHidden}
231
231
  isDisabled={isDisabled}
@@ -84,7 +84,7 @@ export interface FieldLabelProps extends BaseProps<HTMLLabelElement> {
84
84
  /**
85
85
  * ID of the input element this label points AT (rendered as `htmlFor` on the
86
86
  * label). This is *not* the id of the label element itself — see
87
- * `labelElementID` for that.
87
+ * `labelID` for that.
88
88
  */
89
89
  inputID: string;
90
90
  /**
@@ -93,14 +93,14 @@ export interface FieldLabelProps extends BaseProps<HTMLLabelElement> {
93
93
  * reference this via `aria-labelledby` to take the label as its accessible
94
94
  * name.
95
95
  */
96
- labelElementID?: string;
96
+ labelID?: string;
97
97
  /**
98
98
  * When true, the field wraps a *group* of controls (e.g. a radiogroup)
99
99
  * rather than a single input. In that case the label is rendered as a
100
100
  * `<span>` instead of a `<label>` — a `<label>` semantically names one form
101
101
  * control and can't be associated with a group, so it must not be a literal
102
102
  * label element. The group takes the label as its name via
103
- * `labelElementID` + `aria-labelledby`.
103
+ * `labelID` + `aria-labelledby`.
104
104
  * @default false
105
105
  */
106
106
  isGroupLabel?: boolean;
@@ -161,7 +161,7 @@ export interface FieldLabelProps extends BaseProps<HTMLLabelElement> {
161
161
  export function FieldLabel({
162
162
  label,
163
163
  inputID,
164
- labelElementID,
164
+ labelID,
165
165
  isGroupLabel = false,
166
166
  isLabelHidden = false,
167
167
  isDisabled = false,
@@ -203,7 +203,7 @@ export function FieldLabel({
203
203
  <>
204
204
  <LabelElement
205
205
  ref={ref}
206
- id={labelElementID}
206
+ id={labelID}
207
207
  // `htmlFor` only applies to a real `<label>` associating with a single
208
208
  // control; a group label (span) has no `htmlFor`.
209
209
  htmlFor={isGroupLabel ? undefined : inputID}
@@ -156,7 +156,7 @@ export function InputGroup({
156
156
  }: InputGroupProps) {
157
157
  const size = useSize(sizeProp, 'md');
158
158
  const inputId = useId();
159
- const labelElementId = useId();
159
+ const labelID = useId();
160
160
  const statusMessageId = useId();
161
161
 
162
162
  const contextValue = useMemo(() => ({isInGroup: true as const}), []);
@@ -169,7 +169,7 @@ export function InputGroup({
169
169
  isLabelHidden={isLabelHidden}
170
170
  description={description}
171
171
  inputID={inputId}
172
- labelElementID={labelElementId}
172
+ labelID={labelID}
173
173
  isGroupLabel
174
174
  isOptional={isOptional}
175
175
  isRequired={isRequired}
@@ -188,7 +188,7 @@ export function InputGroup({
188
188
  <div
189
189
  ref={ref}
190
190
  role="group"
191
- aria-labelledby={labelElementId}
191
+ aria-labelledby={labelID}
192
192
  data-testid={testId}
193
193
  {...rest}
194
194
  {...mergeProps(
package/src/Link/Link.tsx CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  import {Icon} from '../Icon';
31
31
  import {Tooltip} from '../Tooltip';
32
32
  import {Text} from '../Text';
33
+ import {VisuallyHidden} from '../VisuallyHidden';
33
34
  import type {
34
35
  TextType,
35
36
  TextSize,
@@ -98,18 +99,6 @@ const styles = stylex.create({
98
99
  fontSize: typeScaleVars['--text-body-size'],
99
100
  lineHeight: typeScaleVars['--text-body-leading'],
100
101
  },
101
- // Screen-reader-only text (announces the new-tab context change).
102
- visuallyHidden: {
103
- position: 'absolute',
104
- width: 1,
105
- height: 1,
106
- margin: -1,
107
- padding: 0,
108
- overflow: 'hidden',
109
- clip: 'rect(0, 0, 0, 0)',
110
- whiteSpace: 'nowrap',
111
- borderStyle: 'none',
112
- },
113
102
  });
114
103
 
115
104
  /**
@@ -345,7 +334,7 @@ export function Link({
345
334
  {isExternalLink && !renderAsButton && (
346
335
  <>
347
336
  <Icon icon="externalLink" size="xsm" color="inherit" />
348
- <span {...stylex.props(styles.visuallyHidden)}>{newTabLabel}</span>
337
+ <VisuallyHidden>{newTabLabel}</VisuallyHidden>
349
338
  </>
350
339
  )}
351
340
  </>
@@ -46,14 +46,7 @@ export const docs = {
46
46
  type: 'boolean',
47
47
  description: 'Whether the menu trigger is disabled.',
48
48
  default: 'false',
49
- },
50
- {
51
- name: 'hasAutoFocus',
52
- type: 'boolean',
53
- description: 'Whether to auto-focus the first menu item when the menu opens. Set to false for inline showcases or documentation previews.',
54
- default: 'true',
55
- },
56
- {
49
+ }, {
57
50
  name: 'xstyle',
58
51
  type: 'StyleXStyles',
59
52
  description:
@@ -126,14 +119,7 @@ export const docsZh = {
126
119
  type: 'boolean',
127
120
  description: '菜单触发器是否禁用。',
128
121
  default: 'false',
129
- },
130
- {
131
- name: 'hasAutoFocus',
132
- type: 'boolean',
133
- description: '菜单打开时是否自动聚焦第一个菜单项。内联展示或文档预览设为 false。',
134
- default: 'true',
135
- },
136
- {
122
+ }, {
137
123
  name: 'xstyle',
138
124
  type: 'StyleXStyles',
139
125
  description:
@@ -176,7 +162,6 @@ export const docsDense = {
176
162
  size: 'Trigger button size.',
177
163
  icon: 'Override default three-dot icon. Accepts any ReactNode.',
178
164
  isDisabled: 'Whether menu trigger disabled.',
179
- hasAutoFocus: 'Auto-focus first item on open; false for showcases.',
180
165
  xstyle:
181
166
  'StyleX styles for layout customization (margins, positioning, sizing). Must be stylex.create() value.',
182
167
  },
@@ -81,13 +81,6 @@ export interface MoreMenuProps extends Pick<
81
81
  */
82
82
  onOpenChange?: (isOpen: boolean) => void;
83
83
 
84
- /**
85
- * Whether to auto-focus the first menu item when the menu opens.
86
- * Set to `false` for inline showcases or documentation previews.
87
- * @default true
88
- */
89
- hasAutoFocus?: boolean;
90
-
91
84
  /** Test ID for testing frameworks. */
92
85
  'data-testid'?: string;
93
86
  }
@@ -116,7 +109,6 @@ export function MoreMenu({
116
109
  isDisabled = false,
117
110
  isMenuOpen,
118
111
  onOpenChange,
119
- hasAutoFocus,
120
112
  xstyle,
121
113
  className: classNameProp,
122
114
  style,
@@ -149,7 +141,6 @@ export function MoreMenu({
149
141
  }}
150
142
  items={items}
151
143
  hasChevron={false}
152
- hasAutoFocus={hasAutoFocus}
153
144
  data-testid={testId}
154
145
  />
155
146
  );
@@ -112,6 +112,12 @@ export const docs = {
112
112
  type: 'boolean',
113
113
  description: 'Disables the selector.',
114
114
  },
115
+ {
116
+ name: 'disabledMessage',
117
+ type: 'string',
118
+ description:
119
+ 'Explains why the selector is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and keeps the trigger focusable via aria-disabled (activation stays blocked). Use this instead of wrapping a disabled MultiSelector in Tooltip — disabled controls swallow the hover events an external Tooltip needs.',
120
+ },
115
121
  {
116
122
  name: 'isLabelHidden',
117
123
  type: 'boolean',
@@ -189,6 +195,11 @@ export const docs = {
189
195
  guidance: false,
190
196
  description: 'Show more than ~20 options without enabling search.',
191
197
  },
198
+ {
199
+ guidance: false,
200
+ description:
201
+ 'Wrap a disabled MultiSelector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
202
+ },
192
203
  ],
193
204
  },
194
205
  };
@@ -217,6 +228,8 @@ export const docsZh = {
217
228
  hasSearch: '是否显示用于过滤选项的搜索输入。',
218
229
  searchPlaceholder: '搜索输入的占位文本。',
219
230
  isDisabled: '禁用选择器。',
231
+ disabledMessage:
232
+ '解释选择器被禁用的原因。与 isDisabled 一起使用时,悬停/键盘聚焦时显示工具提示,并通过 aria-disabled 保持触发器可聚焦(仍无法激活)。请使用此属性,而不是用 Tooltip 包裹被禁用的选择器。',
220
233
  isLabelHidden: '视觉上隐藏标签同时保持其可访问性。',
221
234
  description: '标签下方显示的辅助文本。',
222
235
  isOptional: '将字段标记为可选。',
@@ -261,6 +274,11 @@ export const docsZh = {
261
274
  guidance: false,
262
275
  description: 'Show more than ~20 options without enabling search.',
263
276
  },
277
+ {
278
+ guidance: false,
279
+ description:
280
+ 'Wrap a disabled MultiSelector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
281
+ },
264
282
  ],
265
283
  },
266
284
  };
@@ -301,6 +319,11 @@ export const docsDense = {
301
319
  guidance: false,
302
320
  description: 'Show more than ~20 options without enabling search.',
303
321
  },
322
+ {
323
+ guidance: false,
324
+ description:
325
+ 'Wrap a disabled MultiSelector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
326
+ },
304
327
  ],
305
328
  },
306
329
  components: [
@@ -324,6 +347,8 @@ export const docsDense = {
324
347
  hasSearch: 'show search input',
325
348
  searchPlaceholder: 'search placeholder',
326
349
  isDisabled: 'disables selector',
350
+ disabledMessage:
351
+ 'why disabled; w/ isDisabled shows tooltip on hover/focus, trigger stays focusable via aria-disabled; use instead of Tooltip wrapper',
327
352
  isLabelHidden: 'visually hides label',
328
353
  description: 'helper text below label',
329
354
  isOptional: 'marks optional',