@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
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file Selector.tsx
7
- * @input Uses React, StyleX, usePopover, Icon
7
+ * @input Uses React, StyleX, usePopover, useTooltip, Icon
8
8
  * @output Exports Selector component
9
9
  * @position Core implementation; consumed by index.ts
10
10
  *
@@ -27,6 +27,7 @@ import React, {
27
27
  } from 'react';
28
28
  import * as stylex from '@stylexjs/stylex';
29
29
  import {usePopover} from '../Popover/usePopover';
30
+ import {useTooltip} from '../Tooltip';
30
31
  import {Icon, renderIconSlot, type IconType} from '../Icon';
31
32
  import type {IconName} from '../Icon';
32
33
  import {
@@ -369,6 +370,28 @@ interface SelectorPropsBase<
369
370
  */
370
371
  isDisabled?: boolean;
371
372
 
373
+ /**
374
+ * Explains why the selector is disabled. When set together with
375
+ * `isDisabled`, the selector shows a tooltip with this text on hover and
376
+ * keyboard focus, and the trigger stays focusable (via `aria-disabled`)
377
+ * so the reason is discoverable by keyboard and assistive technology.
378
+ * Activation stays blocked.
379
+ *
380
+ * Use this instead of wrapping a disabled selector in `Tooltip` — disabled
381
+ * controls don't emit the pointer events an external tooltip needs.
382
+ *
383
+ * @example
384
+ * ```
385
+ * <Selector
386
+ * label="Owner"
387
+ * options={owners}
388
+ * isDisabled
389
+ * disabledMessage="You need the Editor role to change this"
390
+ * />
391
+ * ```
392
+ */
393
+ disabledMessage?: string;
394
+
372
395
  /**
373
396
  * The options to display in the selector.
374
397
  * Can be strings, objects, dividers, or sections.
@@ -525,6 +548,7 @@ export function Selector<T extends SelectorOptionType>(
525
548
  isOptional = false,
526
549
  isRequired = false,
527
550
  isDisabled = false,
551
+ disabledMessage,
528
552
  options,
529
553
  value,
530
554
  onChange,
@@ -567,11 +591,26 @@ export function Selector<T extends SelectorOptionType>(
567
591
  const [optimisticValue, setOptimisticValue] = useOptimistic(normalizedValue);
568
592
  const isBusy = isLoading || optimisticValue !== normalizedValue;
569
593
 
594
+ // Disabled-reason tooltip. Disabled controls swallow pointer events, so the
595
+ // tooltip listeners attach to the trigger container (which already exists)
596
+ // and the trigger button stays perceivable via aria-disabled instead of the
597
+ // disabled attribute. Activation is blocked by the isDisabled guards in
598
+ // useCombobox (onTriggerClick / onKeyDown).
599
+ const showsDisabledMessage = isDisabled && !!disabledMessage;
600
+ const disabledMessageTooltip = useTooltip({
601
+ placement: 'above',
602
+ // The container div is not naturally focusable; focusin bubbles up from
603
+ // the trigger button, so always attach focus listeners.
604
+ focusTrigger: 'always',
605
+ isEnabled: showsDisabledMessage,
606
+ });
607
+
570
608
  // Build aria-describedby
571
609
  const ariaDescribedBy =
572
610
  [
573
611
  description ? descriptionId : null,
574
612
  status?.message ? statusMessageId : null,
613
+ showsDisabledMessage ? disabledMessageTooltip.describedBy : null,
575
614
  ]
576
615
  .filter(Boolean)
577
616
  .join(' ') || undefined;
@@ -902,6 +941,10 @@ export function Selector<T extends SelectorOptionType>(
902
941
  <div
903
942
  ref={el => {
904
943
  popover.triggerRef(el);
944
+ // Anchor + hover/focus listeners for the disabled-message tooltip.
945
+ // Handlers are gated internally by isEnabled, and anchor names
946
+ // compose, so attaching unconditionally is safe.
947
+ disabledMessageTooltip.ref(el);
905
948
  }}
906
949
  onClick={onTriggerClick}
907
950
  data-testid={testId}
@@ -943,9 +986,13 @@ export function Selector<T extends SelectorOptionType>(
943
986
  aria-required={isRequired ? 'true' : undefined}
944
987
  aria-invalid={status?.type === 'error' ? 'true' : undefined}
945
988
  aria-busy={isBusy || undefined}
946
- disabled={isDisabled}
989
+ // With a disabledMessage the trigger keeps focusability via
990
+ // aria-disabled so the reason is focus-discoverable; activation is
991
+ // still blocked by the isDisabled guards in useCombobox.
992
+ disabled={isDisabled && !showsDisabledMessage}
993
+ aria-disabled={showsDisabledMessage ? 'true' : undefined}
947
994
  onKeyDown={onKeyDown}
948
- tabIndex={isDisabled ? -1 : 0}
995
+ tabIndex={isDisabled && !showsDisabledMessage ? -1 : 0}
949
996
  {...stylex.props(styles.trigger)}>
950
997
  <span {...stylex.props(styles.triggerLabel)}>
951
998
  {selectedItem?.label ?? placeholder}
@@ -1012,6 +1059,9 @@ export function Selector<T extends SelectorOptionType>(
1012
1059
  style: popoverOffsetStyle,
1013
1060
  },
1014
1061
  )}
1062
+
1063
+ {showsDisabledMessage &&
1064
+ disabledMessageTooltip.renderTooltip(disabledMessage)}
1015
1065
  </Field>
1016
1066
  );
1017
1067
  }
@@ -44,6 +44,7 @@ import {switchScope} from './switch.markers.stylex';
44
44
  import type {BaseProps} from '../BaseProps';
45
45
  import type {SizeValue} from '../utils/types';
46
46
  import {themeProps} from '../utils/themeProps';
47
+ import {VisuallyHidden} from '../VisuallyHidden';
47
48
 
48
49
  // Fixed dimensions: 40px width, 24px height, 16px thumb (off), 20px thumb (on)
49
50
  const SWITCH_WIDTH = 40;
@@ -179,17 +180,6 @@ const styles = stylex.create({
179
180
  fontSize: typeScaleVars['--text-supporting-size'],
180
181
  color: colorVars['--color-text-secondary'],
181
182
  },
182
- srOnly: {
183
- position: 'absolute',
184
- width: 1,
185
- height: 1,
186
- padding: 0,
187
- margin: -1,
188
- overflow: 'hidden',
189
- clip: 'rect(0, 0, 0, 0)',
190
- whiteSpace: 'nowrap',
191
- borderWidth: 0,
192
- },
193
183
  });
194
184
 
195
185
  export type SwitchLabelPosition = 'start' | 'end';
@@ -416,11 +406,7 @@ export function Switch({
416
406
  {isBusy && <Spinner size="sm" />}
417
407
  </div>
418
408
  </div>
419
- {isBusy && (
420
- <span {...stylex.props(styles.srOnly)} role="status">
421
- Loading
422
- </span>
423
- )}
409
+ {isBusy && <VisuallyHidden role="status">Loading</VisuallyHidden>}
424
410
  </div>
425
411
  );
426
412
 
@@ -445,6 +445,47 @@ describe('TabList keyboard navigation (roving tabindex)', () => {
445
445
  await user.keyboard('a');
446
446
  expect(home).toHaveFocus();
447
447
  });
448
+
449
+ it('composes consumer onKeyDown with internal arrow navigation', async () => {
450
+ const user = userEvent.setup();
451
+ const onKeyDown = vi.fn();
452
+
453
+ render(
454
+ <TabList value="home" onChange={() => {}} onKeyDown={onKeyDown}>
455
+ <Tab value="home" label="Home" />
456
+ <Tab value="settings" label="Settings" />
457
+ </TabList>,
458
+ );
459
+
460
+ const home = screen.getByRole('button', {name: 'Home'});
461
+ const settings = screen.getByRole('button', {name: 'Settings'});
462
+
463
+ home.focus();
464
+ await user.keyboard('{ArrowRight}');
465
+
466
+ expect(onKeyDown).toHaveBeenCalled();
467
+ expect(settings).toHaveFocus();
468
+ });
469
+
470
+ it('respects preventDefault from consumer onKeyDown', async () => {
471
+ const user = userEvent.setup();
472
+
473
+ render(
474
+ <TabList
475
+ value="home"
476
+ onChange={() => {}}
477
+ onKeyDown={e => e.preventDefault()}>
478
+ <Tab value="home" label="Home" />
479
+ <Tab value="settings" label="Settings" />
480
+ </TabList>,
481
+ );
482
+
483
+ const home = screen.getByRole('button', {name: 'Home'});
484
+ home.focus();
485
+ await user.keyboard('{ArrowRight}');
486
+
487
+ expect(home).toHaveFocus();
488
+ });
448
489
  });
449
490
 
450
491
  describe('Tab polymorphic link', () => {
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file TabList.tsx
7
- * @input Uses React, StyleX, TabListContext, useListFocus
7
+ * @input Uses React, StyleX, TabListContext, useListFocus, useKeyboardHint
8
8
  * @output Exports TabList component and TabListProps type
9
9
  * @position Nav wrapper; provides TabListContext to Tab and TabMenu children.
10
10
  * Owns roving-tabindex keyboard navigation (Arrow/Home/End) across the tab
@@ -17,7 +17,7 @@
17
17
  * - /packages/cli/templates/blocks/components/TabList/ (showcase blocks)
18
18
  */
19
19
 
20
- import React, {useMemo, type ReactNode} from 'react';
20
+ import React, {useCallback, useMemo, type ReactNode} from 'react';
21
21
  import * as stylex from '@stylexjs/stylex';
22
22
  import {borderVars, colorVars, spacingVars} from '../theme/tokens.stylex';
23
23
  import type {BaseProps} from '../BaseProps';
@@ -26,7 +26,7 @@ import type {TabListOrientation, TabListSize} from './TabListContext';
26
26
  import {useSize} from '../SizeContext/SizeContext';
27
27
  import {mergeProps, mergeRefs} from '../utils';
28
28
  import {useListFocus} from '../hooks/useListFocus';
29
- import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
29
+ import {useKeyboardHint} from '../hooks/useKeyboardHint';
30
30
  import {EDGE_COMP_ATTR} from '../Layout/edgeCompensation.stylex';
31
31
  import {themeProps} from '../utils/themeProps';
32
32
 
@@ -37,13 +37,6 @@ import {themeProps} from '../utils/themeProps';
37
37
  */
38
38
  const TAB_STOP_SELECTOR = '[data-tab-value],[data-tab-menu]';
39
39
 
40
- function isDisabledStop(el: HTMLElement): boolean {
41
- return (
42
- el.getAttribute('aria-disabled') === 'true' ||
43
- (el instanceof HTMLButtonElement && el.disabled)
44
- );
45
- }
46
-
47
40
  export interface TabListProps extends Omit<BaseProps<HTMLElement>, 'onChange'> {
48
41
  ref?: React.Ref<HTMLElement>;
49
42
  /**
@@ -133,6 +126,12 @@ export function TabList({
133
126
  className,
134
127
  style,
135
128
  children,
129
+ onKeyDown: onKeyDownProp,
130
+ onFocus: onFocusProp,
131
+ onBlur: onBlurProp,
132
+ 'aria-label': ariaLabel = 'Tabs',
133
+ 'aria-orientation': _ariaOrientation,
134
+ [EDGE_COMP_ATTR]: _edgeCompAttr,
136
135
  ...restProps
137
136
  }: TabListProps) {
138
137
  const size = useSize(sizeProp, 'md');
@@ -142,51 +141,78 @@ export function TabList({
142
141
  // allowance for tab strips (ArrowRight/ArrowDown advance, ArrowLeft/ArrowUp
143
142
  // retreat) regardless of the component's `orientation` prop, which only
144
143
  // drives the reported `aria-orientation`.
145
- const {listRef, handleKeyDown} = useListFocus<HTMLElement>({
144
+ //
145
+ // `hasRovingTabIndex` makes the hook own the single tab stop: it stamps
146
+ // tabindex 0/-1, repairs the stop on mount and as stops mount/unmount or
147
+ // toggle disabled, and — via `handleFocus` on the nav — keeps the stop in
148
+ // sync after clicks or programmatic focus. Individual Tabs still render
149
+ // `tabIndex={isSelected ? 0 : -1}` (see Tab.tsx) as the initial source of
150
+ // truth; the hook's repair preserves an existing tab stop and only promotes
151
+ // the first enabled stop when none is tabbable.
152
+ const {listRef, handleKeyDown, handleFocus} = useListFocus<HTMLElement>({
146
153
  itemSelector: TAB_STOP_SELECTOR,
147
154
  orientation: 'both',
155
+ hasRovingTabIndex: true,
148
156
  });
149
157
 
158
+ const {
159
+ hintElement,
160
+ onKeyDown: onHintKeyDown,
161
+ onFocus: onHintFocus,
162
+ onBlur: onHintBlur,
163
+ } = useKeyboardHint({orientation});
164
+
150
165
  const contextValue = useMemo(
151
166
  () => ({value, onChange, size, layout}),
152
167
  [value, onChange, size, layout],
153
168
  );
154
169
 
155
- // Roving tabindex: the tab strip is a single Tab stop. Individual Tabs set
156
- // tabIndex={0} on the selected tab and -1 on the rest (see Tab.tsx). If the
157
- // selected value doesn't correspond to any focusable stop (e.g. selection
158
- // lives in a collapsed TabMenu that renders no matching stop, or there is no
159
- // selection at all), no stop would be tabbable — this effect repairs that by
160
- // making the first stop tabbable. useListFocus handles arrow navigation but
161
- // not the initial tabbable stop, so this stays. Same pattern as the
162
- // SegmentedControl fix.
163
- useIsomorphicLayoutEffect(() => {
164
- const nav = listRef.current;
165
- if (nav == null) {
166
- return;
167
- }
168
- const stops = Array.from(
169
- nav.querySelectorAll<HTMLElement>(TAB_STOP_SELECTOR),
170
- );
171
- if (stops.length === 0) {
172
- return;
173
- }
174
- const hasTabbable = stops.some(el => el.tabIndex === 0);
175
- if (!hasTabbable) {
176
- const firstEnabled = stops.find(el => !isDisabledStop(el)) ?? stops[0];
177
- firstEnabled.tabIndex = 0;
178
- }
179
- });
170
+ const handleRootKeyDown = useCallback(
171
+ (e: React.KeyboardEvent<HTMLElement>) => {
172
+ onKeyDownProp?.(e);
173
+ if (e.defaultPrevented) {
174
+ return;
175
+ }
176
+ onHintKeyDown(e);
177
+ handleKeyDown(e);
178
+ },
179
+ [onKeyDownProp, onHintKeyDown, handleKeyDown],
180
+ );
181
+
182
+ const handleRootFocus = useCallback(
183
+ (e: React.FocusEvent<HTMLElement>) => {
184
+ onFocusProp?.(e);
185
+ if (e.defaultPrevented) {
186
+ return;
187
+ }
188
+ onHintFocus(e);
189
+ handleFocus(e);
190
+ },
191
+ [onFocusProp, onHintFocus, handleFocus],
192
+ );
193
+
194
+ const handleRootBlur = useCallback(
195
+ (e: React.FocusEvent<HTMLElement>) => {
196
+ onBlurProp?.(e);
197
+ if (e.defaultPrevented) {
198
+ return;
199
+ }
200
+ onHintBlur(e);
201
+ },
202
+ [onBlurProp, onHintBlur],
203
+ );
180
204
 
181
205
  return (
182
206
  <TabListContext value={contextValue}>
183
207
  <nav
184
208
  ref={mergeRefs(ref, listRef)}
185
- aria-label="Tabs"
209
+ {...restProps}
210
+ aria-label={ariaLabel}
186
211
  aria-orientation={orientation}
187
- onKeyDown={handleKeyDown}
212
+ onKeyDown={handleRootKeyDown}
213
+ onFocus={handleRootFocus}
214
+ onBlur={handleRootBlur}
188
215
  {...{[EDGE_COMP_ATTR]: ''}}
189
- {...restProps}
190
216
  {...mergeProps(
191
217
  themeProps('tab-list', {size}),
192
218
  stylex.props(
@@ -199,6 +225,7 @@ export function TabList({
199
225
  style,
200
226
  )}>
201
227
  {children}
228
+ {hintElement}
202
229
  </nav>
203
230
  </TabListContext>
204
231
  );
@@ -107,7 +107,6 @@ function LazyTableContextMenu({
107
107
  return (
108
108
  <ContextMenu
109
109
  items={options ?? []}
110
- hasAutoFocus={false}
111
110
  onOpenChange={open => {
112
111
  // Resolve actions when opening; clear on close so state derived later
113
112
  // (e.g. current sort direction) is always fresh next open.
@@ -135,12 +134,9 @@ export function wrapInTableContextMenu(
135
134
  if (!actions || actions.length === 0) {
136
135
  return element;
137
136
  }
138
- // hasAutoFocus={false}: a right-click menu shouldn't pre-highlight the first
139
137
  // item (it looked like "ascending" was always selected). Focus moves only
140
138
  // when the user arrow-keys into the menu.
141
139
  return (
142
- <ContextMenu items={toContextMenuOptions(actions)} hasAutoFocus={false}>
143
- {element}
144
- </ContextMenu>
140
+ <ContextMenu items={toContextMenuOptions(actions)}>{element}</ContextMenu>
145
141
  );
146
142
  }
@@ -49,6 +49,7 @@ import type {SizeValue} from '../utils/types';
49
49
  import {useInputContainer} from '../hooks/useInputContainer';
50
50
  import {useSize} from '../SizeContext/SizeContext';
51
51
  import {themeProps} from '../utils/themeProps';
52
+ import {VisuallyHidden} from '../VisuallyHidden';
52
53
 
53
54
  const COUNTER_WARNING_THRESHOLD = 0.8;
54
55
 
@@ -93,17 +94,6 @@ const styles = stylex.create({
93
94
  counterError: {
94
95
  color: colorVars['--color-error'],
95
96
  },
96
- srOnly: {
97
- position: 'absolute',
98
- width: '1px',
99
- height: '1px',
100
- padding: 0,
101
- margin: '-1px',
102
- overflow: 'hidden',
103
- clip: 'rect(0,0,0,0)',
104
- whiteSpace: 'nowrap',
105
- borderWidth: 0,
106
- },
107
97
  statusIcon: {
108
98
  position: 'absolute',
109
99
  top: spacingVars['--spacing-2'],
@@ -451,13 +441,13 @@ export function TextArea({
451
441
  optimisticValue.length > maxLength && styles.counterError,
452
442
  )}>
453
443
  {optimisticValue.length}/{maxLength}
454
- <span aria-live="polite" {...stylex.props(styles.srOnly)}>
444
+ <VisuallyHidden aria-live="polite">
455
445
  {optimisticValue.length >= maxLength * COUNTER_WARNING_THRESHOLD
456
446
  ? optimisticValue.length > maxLength
457
447
  ? `${optimisticValue.length - maxLength} characters over limit`
458
448
  : `${maxLength - optimisticValue.length} characters remaining`
459
449
  : ''}
460
- </span>
450
+ </VisuallyHidden>
461
451
  </div>
462
452
  )}
463
453
  </Field>
@@ -71,9 +71,12 @@ describe('Toolbar', () => {
71
71
  expect(screen.getByTestId('center')).toBeInTheDocument();
72
72
  expect(screen.getByTestId('end')).toBeInTheDocument();
73
73
 
74
- // Three-slot layout produces 3 child divs
74
+ // Three-slot layout produces 3 child divs (plus the aria-hidden
75
+ // keyboard-hint popover, which is excluded here as an implementation detail)
75
76
  const toolbar = screen.getByRole('toolbar');
76
- expect(toolbar.children).toHaveLength(3);
77
+ expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
78
+ 3,
79
+ );
77
80
  });
78
81
 
79
82
  it('renders two-slot layout without centerContent', () => {
@@ -87,9 +90,12 @@ describe('Toolbar', () => {
87
90
  expect(screen.getByTestId('start')).toBeInTheDocument();
88
91
  expect(screen.getByTestId('end')).toBeInTheDocument();
89
92
 
90
- // Two-slot layout produces 2 child divs
93
+ // Two-slot layout produces 2 child divs (plus the aria-hidden
94
+ // keyboard-hint popover, excluded here)
91
95
  const toolbar = screen.getByRole('toolbar');
92
- expect(toolbar.children).toHaveLength(2);
96
+ expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
97
+ 2,
98
+ );
93
99
  });
94
100
 
95
101
  it('renders start-only layout', () => {
@@ -101,7 +107,9 @@ describe('Toolbar', () => {
101
107
  );
102
108
  expect(screen.getByTestId('start')).toBeInTheDocument();
103
109
  const toolbar = screen.getByRole('toolbar');
104
- expect(toolbar.children).toHaveLength(1);
110
+ expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
111
+ 1,
112
+ );
105
113
  });
106
114
 
107
115
  it('renders end-only layout', () => {
@@ -113,7 +121,9 @@ describe('Toolbar', () => {
113
121
  );
114
122
  expect(screen.getByTestId('end')).toBeInTheDocument();
115
123
  const toolbar = screen.getByRole('toolbar');
116
- expect(toolbar.children).toHaveLength(1);
124
+ expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
125
+ 1,
126
+ );
117
127
  });
118
128
 
119
129
  it('sets aria-orientation to horizontal by default', () => {
@@ -301,4 +311,52 @@ describe('Toolbar', () => {
301
311
  // Caret movement stays in the input; focus is not stolen by the toolbar.
302
312
  expect(document.activeElement).toBe(inputEl);
303
313
  });
314
+
315
+ it('composes consumer onKeyDown with internal arrow navigation', async () => {
316
+ const user = userEvent.setup();
317
+ const onKeyDown = vi.fn();
318
+
319
+ render(
320
+ <Toolbar
321
+ label="Actions"
322
+ onKeyDown={onKeyDown}
323
+ startContent={
324
+ <>
325
+ <button type="button">Cut</button>
326
+ <button type="button">Copy</button>
327
+ </>
328
+ }
329
+ />,
330
+ );
331
+
332
+ const buttons = screen.getAllByRole('button');
333
+ buttons[0].focus();
334
+ await user.keyboard('{ArrowRight}');
335
+
336
+ expect(onKeyDown).toHaveBeenCalled();
337
+ expect(buttons[1]).toHaveFocus();
338
+ });
339
+
340
+ it('respects preventDefault from consumer onKeyDown', async () => {
341
+ const user = userEvent.setup();
342
+
343
+ render(
344
+ <Toolbar
345
+ label="Actions"
346
+ onKeyDown={e => e.preventDefault()}
347
+ startContent={
348
+ <>
349
+ <button type="button">Cut</button>
350
+ <button type="button">Copy</button>
351
+ </>
352
+ }
353
+ />,
354
+ );
355
+
356
+ const buttons = screen.getAllByRole('button');
357
+ buttons[0].focus();
358
+ await user.keyboard('{ArrowRight}');
359
+
360
+ expect(buttons[0]).toHaveFocus();
361
+ });
304
362
  });
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file Toolbar.tsx
7
- * @input Uses Section, SizeContext, useListFocus, StyleX, spacingVars, sizeVars
7
+ * @input Uses Section, SizeContext, useListFocus, useKeyboardHint, StyleX, spacingVars, sizeVars
8
8
  * @output Exports Toolbar component and ToolbarProps
9
9
  * @position Core implementation; consumed by index.ts
10
10
  *
@@ -16,7 +16,7 @@
16
16
  * - /packages/cli/templates/blocks/components/Toolbar/ (showcase blocks)
17
17
  */
18
18
 
19
- import type {ReactNode} from 'react';
19
+ import {useCallback, type ReactNode} from 'react';
20
20
  import type {BaseProps} from '../BaseProps';
21
21
  import type {SectionVariant} from '../Section/Section';
22
22
  import type {SpacingStep} from '../utils/types';
@@ -26,6 +26,7 @@ import {spacingVars, sizeVars} from '../theme/tokens.stylex';
26
26
  import {mergeProps} from '../utils';
27
27
  import {Section} from '../Section/Section';
28
28
  import {useListFocus} from '../hooks/useListFocus';
29
+ import {useKeyboardHint} from '../hooks/useKeyboardHint';
29
30
  import {SizeProvider} from '../SizeContext/SizeContext';
30
31
  import {edgeCompSlot} from '../Layout/edgeCompensation.stylex';
31
32
  import {themeProps} from '../utils/themeProps';
@@ -227,6 +228,12 @@ export function Toolbar({
227
228
  className,
228
229
  style,
229
230
  ref,
231
+ onKeyDown: onKeyDownProp,
232
+ onFocus: onFocusProp,
233
+ onBlur: onBlurProp,
234
+ role: _role,
235
+ 'aria-label': _ariaLabel,
236
+ 'aria-orientation': _ariaOrientation,
230
237
  ...props
231
238
  }: ToolbarProps) {
232
239
  const hasCenterContent = centerContent != null;
@@ -242,6 +249,48 @@ export function Toolbar({
242
249
  hasCaretGuard: true,
243
250
  });
244
251
 
252
+ const {
253
+ hintElement,
254
+ onKeyDown: onHintKeyDown,
255
+ onFocus: onHintFocus,
256
+ onBlur: onHintBlur,
257
+ } = useKeyboardHint({orientation});
258
+
259
+ const handleToolbarKeyDown = useCallback(
260
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
261
+ onKeyDownProp?.(e);
262
+ if (e.defaultPrevented) {
263
+ return;
264
+ }
265
+ onHintKeyDown(e);
266
+ handleKeyDown(e);
267
+ },
268
+ [onKeyDownProp, onHintKeyDown, handleKeyDown],
269
+ );
270
+
271
+ const handleToolbarFocus = useCallback(
272
+ (e: React.FocusEvent<HTMLDivElement>) => {
273
+ onFocusProp?.(e);
274
+ if (e.defaultPrevented) {
275
+ return;
276
+ }
277
+ onHintFocus(e);
278
+ handleFocus(e);
279
+ },
280
+ [onFocusProp, onHintFocus, handleFocus],
281
+ );
282
+
283
+ const handleToolbarBlur = useCallback(
284
+ (e: React.FocusEvent<HTMLDivElement>) => {
285
+ onBlurProp?.(e);
286
+ if (e.defaultPrevented) {
287
+ return;
288
+ }
289
+ onHintBlur(e);
290
+ },
291
+ [onBlurProp, onHintBlur],
292
+ );
293
+
245
294
  return (
246
295
  <SizeProvider value={size}>
247
296
  <Section
@@ -257,8 +306,9 @@ export function Toolbar({
257
306
  role="toolbar"
258
307
  aria-label={label}
259
308
  aria-orientation={orientation}
260
- onKeyDown={handleKeyDown}
261
- onFocus={handleFocus}
309
+ onKeyDown={handleToolbarKeyDown}
310
+ onFocus={handleToolbarFocus}
311
+ onBlur={handleToolbarBlur}
262
312
  {...mergeProps(
263
313
  themeProps('toolbar', {size}),
264
314
  stylex.props(
@@ -323,6 +373,7 @@ export function Toolbar({
323
373
  )}
324
374
  </>
325
375
  )}
376
+ {hintElement}
326
377
  </div>
327
378
  </Section>
328
379
  </SizeProvider>