@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
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13
- import {render, screen, waitFor} from '@testing-library/react';
13
+ import {render, screen, fireEvent, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {MultiSelector} from './MultiSelector';
16
16
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
@@ -893,4 +893,153 @@ describe('MultiSelector', () => {
893
893
  });
894
894
  });
895
895
  });
896
+
897
+ describe('disabledMessage', () => {
898
+ it('shows the reason tooltip on hover when disabled with a reason', async () => {
899
+ render(
900
+ <MultiSelector
901
+ label="Fruit"
902
+ options={defaultOptions}
903
+ value={[]}
904
+ onChange={() => {}}
905
+ isDisabled
906
+ disabledMessage="Select a table first"
907
+ data-testid="fruit-multi-selector"
908
+ />,
909
+ );
910
+
911
+ const container = screen.getByTestId('fruit-multi-selector');
912
+ const tooltip = screen.getByRole('tooltip', h);
913
+ expect(tooltip).toHaveTextContent('Select a table first');
914
+
915
+ fireEvent.mouseEnter(container);
916
+ await waitFor(() => {
917
+ expect(tooltip).toHaveAttribute('popover-open');
918
+ });
919
+
920
+ fireEvent.mouseLeave(container);
921
+ await waitFor(() => {
922
+ expect(tooltip).not.toHaveAttribute('popover-open');
923
+ });
924
+ });
925
+
926
+ it('shows the reason tooltip on keyboard focus', async () => {
927
+ const user = userEvent.setup();
928
+ render(
929
+ <MultiSelector
930
+ label="Fruit"
931
+ options={defaultOptions}
932
+ value={[]}
933
+ onChange={() => {}}
934
+ isDisabled
935
+ disabledMessage="Select a table first"
936
+ />,
937
+ );
938
+
939
+ const tooltip = screen.getByRole('tooltip', h);
940
+ await user.tab();
941
+ expect(screen.getByRole('combobox')).toHaveFocus();
942
+ await waitFor(() => {
943
+ expect(tooltip).toHaveAttribute('popover-open');
944
+ });
945
+ });
946
+
947
+ it('does not render a tooltip when not disabled', () => {
948
+ render(
949
+ <MultiSelector
950
+ label="Fruit"
951
+ options={defaultOptions}
952
+ value={[]}
953
+ onChange={() => {}}
954
+ disabledMessage="Select a table first"
955
+ />,
956
+ );
957
+ expect(screen.queryByRole('tooltip', h)).not.toBeInTheDocument();
958
+ });
959
+
960
+ it('does not render a tooltip when disabled without a reason', () => {
961
+ render(
962
+ <MultiSelector
963
+ label="Fruit"
964
+ options={defaultOptions}
965
+ value={[]}
966
+ onChange={() => {}}
967
+ isDisabled
968
+ />,
969
+ );
970
+ expect(screen.queryByRole('tooltip', h)).not.toBeInTheDocument();
971
+ });
972
+
973
+ it('keeps the trigger focusable via aria-disabled when a reason is provided', () => {
974
+ render(
975
+ <MultiSelector
976
+ label="Fruit"
977
+ options={defaultOptions}
978
+ value={[]}
979
+ onChange={() => {}}
980
+ isDisabled
981
+ disabledMessage="Select a table first"
982
+ />,
983
+ );
984
+ const trigger = screen.getByRole('combobox');
985
+ expect(trigger).not.toBeDisabled();
986
+ expect(trigger).toHaveAttribute('aria-disabled', 'true');
987
+ expect(trigger).toHaveAttribute('tabIndex', '0');
988
+ });
989
+
990
+ it('links the reason tooltip from the trigger via aria-describedby', () => {
991
+ render(
992
+ <MultiSelector
993
+ label="Fruit"
994
+ options={defaultOptions}
995
+ value={[]}
996
+ onChange={() => {}}
997
+ isDisabled
998
+ disabledMessage="Select a table first"
999
+ />,
1000
+ );
1001
+ const trigger = screen.getByRole('combobox');
1002
+ const tooltip = screen.getByRole('tooltip', h);
1003
+ expect(trigger.getAttribute('aria-describedby')).toContain(tooltip.id);
1004
+ });
1005
+
1006
+ it('blocks activation while focusable-disabled', async () => {
1007
+ const user = userEvent.setup();
1008
+ const onChange = vi.fn();
1009
+ render(
1010
+ <MultiSelector
1011
+ label="Fruit"
1012
+ options={defaultOptions}
1013
+ value={[]}
1014
+ onChange={onChange}
1015
+ isDisabled
1016
+ disabledMessage="Select a table first"
1017
+ />,
1018
+ );
1019
+
1020
+ const trigger = screen.getByRole('combobox');
1021
+ await user.click(trigger);
1022
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
1023
+
1024
+ await user.keyboard('{Enter}');
1025
+ await user.keyboard('{ArrowDown}');
1026
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
1027
+ expect(onChange).not.toHaveBeenCalled();
1028
+ });
1029
+
1030
+ it('remains non-focusable when disabled without a reason', () => {
1031
+ render(
1032
+ <MultiSelector
1033
+ label="Fruit"
1034
+ options={defaultOptions}
1035
+ value={[]}
1036
+ onChange={() => {}}
1037
+ isDisabled
1038
+ />,
1039
+ );
1040
+ const trigger = screen.getByRole('combobox');
1041
+ expect(trigger).toBeDisabled();
1042
+ expect(trigger).toHaveAttribute('tabIndex', '-1');
1043
+ });
1044
+ });
896
1045
  });
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file MultiSelector.tsx
7
- * @input Uses React, StyleX, usePopover, CheckboxInput, Field, Badge, Icon
7
+ * @input Uses React, StyleX, usePopover, useTooltip, CheckboxInput, Field, Badge, Icon
8
8
  * @output Exports MultiSelector 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 {
@@ -398,6 +399,30 @@ export interface MultiSelectorProps<
398
399
  */
399
400
  isDisabled?: boolean;
400
401
 
402
+ /**
403
+ * Explains why the selector is disabled. When set together with
404
+ * `isDisabled`, the selector shows a tooltip with this text on hover and
405
+ * keyboard focus, and the trigger stays focusable (via `aria-disabled`)
406
+ * so the reason is discoverable by keyboard and assistive technology.
407
+ * Activation stays blocked.
408
+ *
409
+ * Use this instead of wrapping a disabled selector in `Tooltip` — disabled
410
+ * controls don't emit the pointer events an external tooltip needs.
411
+ *
412
+ * @example
413
+ * ```
414
+ * <MultiSelector
415
+ * label="Columns"
416
+ * options={columns}
417
+ * value={selected}
418
+ * onChange={setSelected}
419
+ * isDisabled
420
+ * disabledMessage="Select a table first"
421
+ * />
422
+ * ```
423
+ */
424
+ disabledMessage?: string;
425
+
401
426
  /**
402
427
  * The options to display in the selector.
403
428
  * Can be strings, objects, dividers, or sections.
@@ -546,6 +571,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
546
571
  isOptional = false,
547
572
  isRequired = false,
548
573
  isDisabled = false,
574
+ disabledMessage,
549
575
  options,
550
576
  value,
551
577
  onChange,
@@ -593,11 +619,26 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
593
619
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
594
620
  const isBusy = isLoading || optimisticValue !== value;
595
621
 
622
+ // Disabled-reason tooltip. Disabled controls swallow pointer events, so the
623
+ // tooltip listeners attach to the trigger container (which already exists)
624
+ // and the trigger button stays perceivable via aria-disabled instead of the
625
+ // disabled attribute. Activation is blocked by the isDisabled guards in
626
+ // useMultiCombobox (onTriggerClick / onKeyDown).
627
+ const showsDisabledMessage = isDisabled && !!disabledMessage;
628
+ const disabledMessageTooltip = useTooltip({
629
+ placement: 'above',
630
+ // The container div is not naturally focusable; focusin bubbles up from
631
+ // the trigger button, so always attach focus listeners.
632
+ focusTrigger: 'always',
633
+ isEnabled: showsDisabledMessage,
634
+ });
635
+
596
636
  // Build aria-describedby
597
637
  const ariaDescribedBy =
598
638
  [
599
639
  description ? descriptionId : null,
600
640
  status?.message ? statusMessageId : null,
641
+ showsDisabledMessage ? disabledMessageTooltip.describedBy : null,
601
642
  ]
602
643
  .filter(Boolean)
603
644
  .join(' ') || undefined;
@@ -1190,6 +1231,10 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1190
1231
  <div
1191
1232
  ref={el => {
1192
1233
  popover.triggerRef(el);
1234
+ // Anchor + hover/focus listeners for the disabled-message tooltip.
1235
+ // Handlers are gated internally by isEnabled, and anchor names
1236
+ // compose, so attaching unconditionally is safe.
1237
+ disabledMessageTooltip.ref(el);
1193
1238
  }}
1194
1239
  onClick={onTriggerClick}
1195
1240
  data-testid={testId}
@@ -1230,9 +1275,13 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1230
1275
  aria-required={isRequired ? 'true' : undefined}
1231
1276
  aria-invalid={status?.type === 'error' ? 'true' : undefined}
1232
1277
  aria-busy={isBusy || undefined}
1233
- disabled={isDisabled}
1278
+ // With a disabledMessage the trigger keeps focusability via
1279
+ // aria-disabled so the reason is focus-discoverable; activation is
1280
+ // still blocked by the isDisabled guards in useMultiCombobox.
1281
+ disabled={isDisabled && !showsDisabledMessage}
1282
+ aria-disabled={showsDisabledMessage ? 'true' : undefined}
1234
1283
  onKeyDown={onKeyDown}
1235
- tabIndex={isDisabled ? -1 : 0}
1284
+ tabIndex={isDisabled && !showsDisabledMessage ? -1 : 0}
1236
1285
  {...stylex.props(styles.trigger)}>
1237
1286
  <span {...stylex.props(styles.triggerContent)}>
1238
1287
  {renderTriggerContent()}
@@ -1283,6 +1332,9 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1283
1332
  xstyle: styles.popover,
1284
1333
  },
1285
1334
  )}
1335
+
1336
+ {showsDisabledMessage &&
1337
+ disabledMessageTooltip.renderTooltip(disabledMessage)}
1286
1338
  </Field>
1287
1339
  );
1288
1340
  }
@@ -31,6 +31,7 @@ import {
31
31
  import {mergeProps} from '../utils';
32
32
  import type {BaseProps} from '../BaseProps';
33
33
  import {themeProps} from '../utils/themeProps';
34
+ import {VisuallyHidden} from '../VisuallyHidden';
34
35
 
35
36
  /**
36
37
  * Extensible variant map for ProgressBar.
@@ -313,9 +314,7 @@ export function ProgressBar({
313
314
  )}
314
315
  </div>
315
316
  ) : (
316
- <span id={labelId} {...stylex.props(styles.visuallyHidden)}>
317
- {label}
318
- </span>
317
+ <VisuallyHidden id={labelId}>{label}</VisuallyHidden>
319
318
  )}
320
319
 
321
320
  {/* Progress track */}
@@ -183,7 +183,7 @@ export function RadioList({
183
183
  }: RadioListProps) {
184
184
  const name = useId();
185
185
  const inputID = useId();
186
- const labelElementID = useId();
186
+ const labelID = useId();
187
187
  const descriptionID = useId();
188
188
  const statusMessageID = useId();
189
189
 
@@ -284,7 +284,7 @@ export function RadioList({
284
284
  isLabelHidden={isLabelHidden}
285
285
  description={description}
286
286
  inputID={inputID}
287
- labelElementID={labelElementID}
287
+ labelID={labelID}
288
288
  isGroupLabel
289
289
  descriptionID={description ? descriptionID : undefined}
290
290
  isOptional={isOptional}
@@ -308,7 +308,7 @@ export function RadioList({
308
308
  <div
309
309
  ref={groupRef}
310
310
  role="radiogroup"
311
- aria-labelledby={labelElementID}
311
+ aria-labelledby={labelID}
312
312
  onFocus={handleFocus}
313
313
  aria-describedby={
314
314
  [
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file SegmentedControl.tsx
7
- * @input Uses React, StyleX, SegmentedControlContext
7
+ * @input Uses React, StyleX, SegmentedControlContext, useListFocus, useKeyboardHint
8
8
  * @output Exports SegmentedControl component and SegmentedControlProps type
9
9
  * @position Container wrapper; provides context to SegmentedControlItem children
10
10
  *
@@ -15,11 +15,12 @@
15
15
  * - /packages/cli/templates/blocks/components/SegmentedControl/ (showcase blocks)
16
16
  */
17
17
 
18
- import React, {useMemo, useRef, useCallback, type ReactNode} from 'react';
18
+ import React, {useMemo, useCallback, type ReactNode} from 'react';
19
19
  import * as stylex from '@stylexjs/stylex';
20
20
  import {colorVars, spacingVars, radiusVars} from '../theme/tokens.stylex';
21
21
  import {SegmentedControlContext} from './SegmentedControlContext';
22
- import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
22
+ import {useListFocus} from '../hooks/useListFocus';
23
+ import {useKeyboardHint} from '../hooks/useKeyboardHint';
23
24
  import type {
24
25
  SegmentedControlSize,
25
26
  SegmentedControlLayout,
@@ -134,63 +135,58 @@ export function SegmentedControl({
134
135
  style,
135
136
  }: SegmentedControlProps) {
136
137
  const size = useSize(sizeProp, 'md');
137
- const containerRef = useRef<HTMLDivElement>(null);
138
138
 
139
- const handleKeyDown = useCallback(
140
- (e: React.KeyboardEvent) => {
141
- if (isDisabled) {
142
- return;
143
- }
139
+ // Roving tabindex + arrow/Home/End navigation is owned by the shared
140
+ // useListFocus primitive: it stamps a single tab stop (tabIndex 0/-1) across
141
+ // the radios, skips disabled ones, wraps at the ends, handles Home/End, and
142
+ // repairs the tab stop on mount and whenever items mount/disable — replacing
143
+ // the component's former inline keyboard handler and tab-stop repair effect.
144
+ const {listRef, handleKeyDown, handleFocus} = useListFocus<HTMLDivElement>({
145
+ itemSelector: '[role="radio"]:not([aria-disabled="true"])',
146
+ hasRovingTabIndex: true,
147
+ wrap: true,
148
+ orientation: 'horizontal',
149
+ });
144
150
 
145
- const container = containerRef.current;
146
- if (!container) {
147
- return;
148
- }
151
+ const hint = useKeyboardHint({
152
+ orientation: 'horizontal',
153
+ isEnabled: !isDisabled,
154
+ });
149
155
 
150
- const items = Array.from(
151
- container.querySelectorAll<HTMLButtonElement>(
152
- '[role="radio"]:not([aria-disabled="true"])',
153
- ),
154
- );
155
- if (items.length === 0) {
156
+ const handleContainerKeyDown = useCallback(
157
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
158
+ hint.onKeyDown(e);
159
+ handleKeyDown(e);
160
+ },
161
+ [hint, handleKeyDown],
162
+ );
163
+
164
+ // Selection-follows-focus (APG radiogroup): useListFocus only *moves* focus,
165
+ // so whenever it lands focus on a new radio (arrow/Home/End, or a click) we
166
+ // select that radio's value. Reading the focused element's data-value here
167
+ // keeps selection in lockstep with focus without duplicating the navigation
168
+ // logic. Disabled radios are ignored (they should never become the value),
169
+ // and the already-selected value is skipped so an initial Tab-in (or a click
170
+ // on the current segment) is a no-op, matching click behavior.
171
+ const handleContainerFocus = useCallback(
172
+ (e: React.FocusEvent) => {
173
+ hint.onFocus(e);
174
+ handleFocus(e);
175
+ if (isDisabled) {
156
176
  return;
157
177
  }
158
-
159
- const currentIndex = items.findIndex(
160
- item => item === document.activeElement,
178
+ const focused = (e.target as HTMLElement | null)?.closest<HTMLElement>(
179
+ '[role="radio"][data-value]',
161
180
  );
162
- let nextIndex: number;
163
-
164
- switch (e.key) {
165
- case 'ArrowRight':
166
- nextIndex =
167
- currentIndex === -1 ? 0 : (currentIndex + 1) % items.length;
168
- break;
169
- case 'ArrowLeft':
170
- nextIndex =
171
- currentIndex === -1
172
- ? items.length - 1
173
- : (currentIndex - 1 + items.length) % items.length;
174
- break;
175
- case 'Home':
176
- nextIndex = 0;
177
- break;
178
- case 'End':
179
- nextIndex = items.length - 1;
180
- break;
181
- default:
182
- return;
181
+ if (!focused || focused.getAttribute('aria-disabled') === 'true') {
182
+ return;
183
183
  }
184
-
185
- e.preventDefault();
186
- const nextItem = items[nextIndex];
187
- nextItem.focus();
188
- const nextValue = nextItem.dataset.value;
189
- if (nextValue != null) {
184
+ const nextValue = focused.dataset.value;
185
+ if (nextValue != null && nextValue !== value) {
190
186
  onChange(nextValue);
191
187
  }
192
188
  },
193
- [isDisabled, onChange],
189
+ [hint, handleFocus, isDisabled, onChange, value],
194
190
  );
195
191
 
196
192
  const contextValue = useMemo(
@@ -198,39 +194,16 @@ export function SegmentedControl({
198
194
  [value, onChange, size, layout, isDisabled],
199
195
  );
200
196
 
201
- // Tab-stop repair (navigation-6): each item sets tabIndex=0 only when its
202
- // value matches the group value, so a stale/unmatched `value` (or a disabled
203
- // selected item) can leave every segment at tabIndex=-1 — making the whole
204
- // radiogroup unreachable by Tab. After render, if no enabled radio is
205
- // tabbable, promote the first enabled radio to tabIndex=0 so the group always
206
- // has exactly one tab stop. Mirrors Base UI's Composite tab-stop repair.
207
- useIsomorphicLayoutEffect(() => {
208
- const container = containerRef.current;
209
- if (!container) {
210
- return;
211
- }
212
- const enabled = Array.from(
213
- container.querySelectorAll<HTMLButtonElement>(
214
- '[role="radio"]:not([aria-disabled="true"])',
215
- ),
216
- );
217
- if (enabled.length === 0) {
218
- return;
219
- }
220
- const hasTabStop = enabled.some(el => el.tabIndex === 0);
221
- if (!hasTabStop) {
222
- enabled[0].tabIndex = 0;
223
- }
224
- });
225
-
226
197
  return (
227
198
  <SegmentedControlContext value={contextValue}>
228
199
  <div
229
- ref={mergeRefs(ref, containerRef)}
200
+ ref={mergeRefs(ref, listRef)}
230
201
  role="radiogroup"
231
202
  aria-label={label}
232
203
  aria-disabled={isDisabled || undefined}
233
- onKeyDown={handleKeyDown}
204
+ onKeyDown={handleContainerKeyDown}
205
+ onFocus={handleContainerFocus}
206
+ onBlur={hint.onBlur}
234
207
  {...mergeProps(
235
208
  themeProps('segmented-control', {size}),
236
209
  stylex.props(
@@ -244,6 +217,7 @@ export function SegmentedControl({
244
217
  style,
245
218
  )}>
246
219
  {children}
220
+ {hint.hintElement}
247
221
  </div>
248
222
  </SegmentedControlContext>
249
223
  );
@@ -86,6 +86,12 @@ export const docs = {
86
86
  description: 'Disables the selector.',
87
87
  default: 'false',
88
88
  },
89
+ {
90
+ name: 'disabledMessage',
91
+ type: 'string',
92
+ description:
93
+ '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 Selector in Tooltip — disabled controls swallow the hover events an external Tooltip needs.',
94
+ },
89
95
  {
90
96
  name: 'isLabelHidden',
91
97
  type: 'boolean',
@@ -176,6 +182,11 @@ export const docs = {
176
182
  description:
177
183
  'Put more than ~20 options without sections; consider Typeahead for large lists.',
178
184
  },
185
+ {
186
+ guidance: false,
187
+ description:
188
+ 'Wrap a disabled Selector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
189
+ },
179
190
  ],
180
191
  anatomy: [
181
192
  {
@@ -263,6 +274,11 @@ export const docsZh = {
263
274
  description:
264
275
  'Put more than ~20 options without sections; consider Typeahead for large lists.',
265
276
  },
277
+ {
278
+ guidance: false,
279
+ description:
280
+ 'Wrap a disabled Selector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
281
+ },
266
282
  ],
267
283
  anatomy: [
268
284
  {
@@ -350,6 +366,11 @@ export const docsDense = {
350
366
  description:
351
367
  'Put more than ~20 options without sections; consider Typeahead for large lists.',
352
368
  },
369
+ {
370
+ guidance: false,
371
+ description:
372
+ 'Wrap a disabled Selector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
373
+ },
353
374
  ],
354
375
  anatomy: [
355
376
  {
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import {describe, it, expect, vi, beforeEach} from 'vitest';
13
- import {render, screen, waitFor} from '@testing-library/react';
13
+ import {render, screen, fireEvent, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {Selector} from './Selector';
16
16
  import {SelectorOption} from './SelectorOption';
@@ -652,4 +652,126 @@ describe('Selector', () => {
652
652
  }
653
653
  });
654
654
  });
655
+
656
+ describe('disabledMessage', () => {
657
+ it('shows the reason tooltip on hover when disabled with a reason', async () => {
658
+ render(
659
+ <Selector
660
+ label="Fruit"
661
+ options={OPTIONS}
662
+ isDisabled
663
+ disabledMessage="You need the Editor role"
664
+ data-testid="fruit-selector"
665
+ />,
666
+ );
667
+
668
+ const container = screen.getByTestId('fruit-selector');
669
+ const tooltip = screen.getByRole('tooltip', h);
670
+ expect(tooltip).toHaveTextContent('You need the Editor role');
671
+
672
+ fireEvent.mouseEnter(container);
673
+ await waitFor(() => {
674
+ expect(tooltip).toHaveAttribute('popover-open');
675
+ });
676
+
677
+ fireEvent.mouseLeave(container);
678
+ await waitFor(() => {
679
+ expect(tooltip).not.toHaveAttribute('popover-open');
680
+ });
681
+ });
682
+
683
+ it('shows the reason tooltip on keyboard focus', async () => {
684
+ const user = userEvent.setup();
685
+ render(
686
+ <Selector
687
+ label="Fruit"
688
+ options={OPTIONS}
689
+ isDisabled
690
+ disabledMessage="You need the Editor role"
691
+ />,
692
+ );
693
+
694
+ const tooltip = screen.getByRole('tooltip', h);
695
+ await user.tab();
696
+ expect(screen.getByRole('combobox')).toHaveFocus();
697
+ await waitFor(() => {
698
+ expect(tooltip).toHaveAttribute('popover-open');
699
+ });
700
+ });
701
+
702
+ it('does not render a tooltip when not disabled', () => {
703
+ render(
704
+ <Selector
705
+ label="Fruit"
706
+ options={OPTIONS}
707
+ disabledMessage="You need the Editor role"
708
+ />,
709
+ );
710
+ expect(screen.queryByRole('tooltip', h)).not.toBeInTheDocument();
711
+ });
712
+
713
+ it('does not render a tooltip when disabled without a reason', () => {
714
+ render(<Selector label="Fruit" options={OPTIONS} isDisabled />);
715
+ expect(screen.queryByRole('tooltip', h)).not.toBeInTheDocument();
716
+ });
717
+
718
+ it('keeps the trigger focusable via aria-disabled when a reason is provided', () => {
719
+ render(
720
+ <Selector
721
+ label="Fruit"
722
+ options={OPTIONS}
723
+ isDisabled
724
+ disabledMessage="You need the Editor role"
725
+ />,
726
+ );
727
+ const trigger = screen.getByRole('combobox');
728
+ expect(trigger).not.toBeDisabled();
729
+ expect(trigger).toHaveAttribute('aria-disabled', 'true');
730
+ expect(trigger).toHaveAttribute('tabIndex', '0');
731
+ });
732
+
733
+ it('links the reason tooltip from the trigger via aria-describedby', () => {
734
+ render(
735
+ <Selector
736
+ label="Fruit"
737
+ options={OPTIONS}
738
+ isDisabled
739
+ disabledMessage="You need the Editor role"
740
+ />,
741
+ );
742
+ const trigger = screen.getByRole('combobox');
743
+ const tooltip = screen.getByRole('tooltip', h);
744
+ expect(trigger.getAttribute('aria-describedby')).toContain(tooltip.id);
745
+ });
746
+
747
+ it('blocks activation while focusable-disabled', async () => {
748
+ const user = userEvent.setup();
749
+ const onChange = vi.fn();
750
+ render(
751
+ <Selector
752
+ label="Fruit"
753
+ options={OPTIONS}
754
+ onChange={onChange}
755
+ isDisabled
756
+ disabledMessage="You need the Editor role"
757
+ />,
758
+ );
759
+
760
+ const trigger = screen.getByRole('combobox');
761
+ await user.click(trigger);
762
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
763
+
764
+ await user.keyboard('{Enter}');
765
+ await user.keyboard('{ArrowDown}');
766
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
767
+ expect(onChange).not.toHaveBeenCalled();
768
+ });
769
+
770
+ it('remains non-focusable when disabled without a reason', () => {
771
+ render(<Selector label="Fruit" options={OPTIONS} isDisabled />);
772
+ const trigger = screen.getByRole('combobox');
773
+ expect(trigger).toBeDisabled();
774
+ expect(trigger).toHaveAttribute('tabIndex', '-1');
775
+ });
776
+ });
655
777
  });