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

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 (127) 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 +1 -4
  4. package/dist/Button/Button.d.ts.map +1 -1
  5. package/dist/Button/Button.js +4 -2
  6. package/dist/Calendar/Calendar.d.ts.map +1 -1
  7. package/dist/Calendar/Calendar.js +14 -33
  8. package/dist/Calendar/hooks/index.d.ts +2 -0
  9. package/dist/Calendar/hooks/index.d.ts.map +1 -1
  10. package/dist/Calendar/hooks/index.js +2 -1
  11. package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts +57 -0
  12. package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts.map +1 -0
  13. package/dist/Calendar/hooks/useCalendarRovingTabindex.js +96 -0
  14. package/dist/Calendar/index.d.ts +2 -2
  15. package/dist/Calendar/index.d.ts.map +1 -1
  16. package/dist/Calendar/index.js +1 -1
  17. package/dist/CheckboxList/CheckboxList.js +3 -3
  18. package/dist/ContextMenu/ContextMenu.d.ts +7 -1
  19. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  20. package/dist/ContextMenu/ContextMenu.js +13 -7
  21. package/dist/DropdownMenu/DropdownMenu.d.ts +8 -1
  22. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  23. package/dist/DropdownMenu/DropdownMenu.js +9 -4
  24. package/dist/Field/Field.d.ts +4 -4
  25. package/dist/Field/Field.d.ts.map +1 -1
  26. package/dist/Field/Field.js +2 -2
  27. package/dist/Field/FieldLabel.d.ts +4 -4
  28. package/dist/Field/FieldLabel.d.ts.map +1 -1
  29. package/dist/Field/FieldLabel.js +2 -2
  30. package/dist/InputGroup/InputGroup.js +3 -3
  31. package/dist/Link/Link.d.ts.map +1 -1
  32. package/dist/Link/Link.js +4 -2
  33. package/dist/MoreMenu/MoreMenu.d.ts +7 -1
  34. package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
  35. package/dist/MoreMenu/MoreMenu.js +2 -0
  36. package/dist/MultiSelector/MultiSelector.d.ts +2 -25
  37. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  38. package/dist/MultiSelector/MultiSelector.js +6 -31
  39. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  40. package/dist/ProgressBar/ProgressBar.js +4 -2
  41. package/dist/RadioList/RadioList.js +3 -3
  42. package/dist/SegmentedControl/SegmentedControl.d.ts +1 -1
  43. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  44. package/dist/SegmentedControl/SegmentedControl.js +61 -51
  45. package/dist/Selector/Selector.d.ts +1 -22
  46. package/dist/Selector/Selector.d.ts.map +1 -1
  47. package/dist/Selector/Selector.js +6 -31
  48. package/dist/Switch/Switch.d.ts.map +1 -1
  49. package/dist/Switch/Switch.js +4 -2
  50. package/dist/TabList/TabList.d.ts +2 -3
  51. package/dist/TabList/TabList.d.ts.map +1 -1
  52. package/dist/TabList/TabList.js +33 -55
  53. package/dist/Table/tableContextMenu.d.ts.map +1 -1
  54. package/dist/Table/tableContextMenu.js +3 -0
  55. package/dist/TextArea/TextArea.d.ts.map +1 -1
  56. package/dist/TextArea/TextArea.js +4 -2
  57. package/dist/Toolbar/Toolbar.d.ts +3 -3
  58. package/dist/Toolbar/Toolbar.d.ts.map +1 -1
  59. package/dist/Toolbar/Toolbar.js +6 -46
  60. package/dist/TreeList/TreeList.d.ts.map +1 -1
  61. package/dist/TreeList/TreeList.js +22 -16
  62. package/dist/TreeList/TreeListItem.d.ts +2 -3
  63. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  64. package/dist/astryx.css +0 -3
  65. package/dist/astryx.umd.js +47 -47
  66. package/dist/astryx.umd.js.map +4 -4
  67. package/dist/hooks/index.d.ts +2 -4
  68. package/dist/hooks/index.d.ts.map +1 -1
  69. package/dist/hooks/index.js +0 -1
  70. package/dist/hooks/useGridFocus.d.ts +0 -56
  71. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  72. package/dist/hooks/useGridFocus.js +24 -138
  73. package/dist/hooks/useTreeFocus.d.ts +3 -27
  74. package/dist/hooks/useTreeFocus.d.ts.map +1 -1
  75. package/dist/hooks/useTreeFocus.js +6 -69
  76. package/package.json +1 -1
  77. package/src/Breadcrumbs/BreadcrumbItem.tsx +2 -5
  78. package/src/Button/Button.tsx +16 -3
  79. package/src/Calendar/Calendar.tsx +22 -52
  80. package/src/Calendar/hooks/index.ts +6 -0
  81. package/src/Calendar/hooks/useCalendarRovingTabindex.ts +118 -0
  82. package/src/Calendar/index.ts +3 -0
  83. package/src/CheckboxList/CheckboxList.tsx +3 -3
  84. package/src/ContextMenu/ContextMenu.doc.mjs +6 -0
  85. package/src/ContextMenu/ContextMenu.test.tsx +6 -4
  86. package/src/ContextMenu/ContextMenu.tsx +19 -7
  87. package/src/DropdownMenu/DropdownMenu.doc.mjs +8 -1
  88. package/src/DropdownMenu/DropdownMenu.test.tsx +23 -0
  89. package/src/DropdownMenu/DropdownMenu.tsx +16 -4
  90. package/src/Field/Field.test.tsx +2 -2
  91. package/src/Field/Field.tsx +5 -5
  92. package/src/Field/FieldLabel.tsx +5 -5
  93. package/src/InputGroup/InputGroup.tsx +3 -3
  94. package/src/Link/Link.tsx +13 -2
  95. package/src/MoreMenu/MoreMenu.doc.mjs +17 -2
  96. package/src/MoreMenu/MoreMenu.tsx +9 -0
  97. package/src/MultiSelector/MultiSelector.doc.mjs +0 -25
  98. package/src/MultiSelector/MultiSelector.test.tsx +1 -150
  99. package/src/MultiSelector/MultiSelector.tsx +3 -55
  100. package/src/ProgressBar/ProgressBar.tsx +3 -2
  101. package/src/RadioList/RadioList.tsx +3 -3
  102. package/src/SegmentedControl/SegmentedControl.tsx +77 -51
  103. package/src/Selector/Selector.doc.mjs +0 -21
  104. package/src/Selector/Selector.test.tsx +1 -123
  105. package/src/Selector/Selector.tsx +3 -53
  106. package/src/Switch/Switch.tsx +16 -2
  107. package/src/TabList/TabList.test.tsx +0 -41
  108. package/src/TabList/TabList.tsx +39 -66
  109. package/src/Table/tableContextMenu.tsx +5 -1
  110. package/src/TextArea/TextArea.tsx +13 -3
  111. package/src/Toolbar/Toolbar.test.tsx +6 -64
  112. package/src/Toolbar/Toolbar.tsx +4 -55
  113. package/src/TreeList/TreeList.tsx +28 -17
  114. package/src/TreeList/TreeListItem.tsx +2 -3
  115. package/src/VisuallyHidden/VisuallyHidden.doc.mjs +7 -7
  116. package/src/hooks/index.ts +5 -12
  117. package/src/hooks/useGridFocus.doc.mjs +2 -40
  118. package/src/hooks/useGridFocus.ts +23 -179
  119. package/src/hooks/useTreeFocus.doc.mjs +1 -15
  120. package/src/hooks/useTreeFocus.ts +6 -101
  121. package/dist/hooks/useKeyboardHint.d.ts +0 -82
  122. package/dist/hooks/useKeyboardHint.d.ts.map +0 -1
  123. package/dist/hooks/useKeyboardHint.js +0 -220
  124. package/src/hooks/useGridFocus.test.tsx +0 -132
  125. package/src/hooks/useKeyboardHint.doc.mjs +0 -101
  126. package/src/hooks/useKeyboardHint.test.tsx +0 -70
  127. package/src/hooks/useKeyboardHint.tsx +0 -332
@@ -34,7 +34,6 @@ import {
34
34
  } from '../theme/tokens.stylex';
35
35
  import {Tooltip} from '../Tooltip/Tooltip';
36
36
  import {Spinner} from '../Spinner';
37
- import {VisuallyHidden} from '../VisuallyHidden';
38
37
 
39
38
  import {EDGE_COMP_ATTR} from '../Layout/edgeCompensation.stylex';
40
39
  import {useSize} from '../SizeContext/SizeContext';
@@ -125,6 +124,17 @@ const styles = stylex.create({
125
124
  textOverflow: 'ellipsis',
126
125
  minWidth: 0,
127
126
  },
127
+ visuallyHidden: {
128
+ position: 'absolute',
129
+ width: '1px',
130
+ height: '1px',
131
+ padding: 0,
132
+ margin: '-1px',
133
+ overflow: 'hidden',
134
+ clip: 'rect(0, 0, 0, 0)',
135
+ whiteSpace: 'nowrap',
136
+ borderWidth: 0,
137
+ },
128
138
  link: {
129
139
  textDecoration: 'none',
130
140
  },
@@ -680,9 +690,12 @@ export function Button({
680
690
  )}
681
691
  </span>
682
692
  {/* Live region for loading state announcements */}
683
- <VisuallyHidden role="status" aria-live="polite">
693
+ <span
694
+ {...stylex.props(styles.visuallyHidden)}
695
+ role="status"
696
+ aria-live="polite">
684
697
  {isLoadingState ? 'Loading' : ''}
685
- </VisuallyHidden>
698
+ </span>
686
699
  </>
687
700
  );
688
701
 
@@ -32,6 +32,7 @@ import {useGridFocus} from '../hooks';
32
32
  import {
33
33
  useCalendarDays,
34
34
  useCalendarConstraints,
35
+ useCalendarRovingTabindex,
35
36
  type CalendarDay,
36
37
  } from './hooks';
37
38
  import {
@@ -553,34 +554,14 @@ function MonthGrid({
553
554
  return null;
554
555
  }, [mode, value]);
555
556
 
556
- // Seed the initial roving tab stop for this month. useGridFocus owns the
557
- // live tab stop (see `hasRovingTabIndex` below) — it honors an existing
558
- // `tabindex="0"` and repairs/moves it thereafter — so this only decides
559
- // which day button starts tabbable. Priority: selected date (if visible and
560
- // enabled) > today (if visible and enabled) > first enabled in-month day.
561
- const seedTabbableIso = useMemo((): ISODateString | null => {
562
- if (selectedDateForTabindex) {
563
- const isSelectedInMonth =
564
- selectedDateForTabindex.year === year &&
565
- selectedDateForTabindex.month === month.month;
566
- if (isSelectedInMonth && !isDateDisabled(selectedDateForTabindex)) {
567
- return plainDateToISO(selectedDateForTabindex);
568
- }
569
- }
570
-
571
- const isTodayInMonth = today.year === year && today.month === month.month;
572
- if (isTodayInMonth && !isDateDisabled(today)) {
573
- return plainDateToISO(today);
574
- }
575
-
576
- for (const day of days) {
577
- if (!day.isOutside && !isDateDisabled(day.date)) {
578
- return day.iso;
579
- }
580
- }
581
-
582
- return null;
583
- }, [days, today, year, month.month, isDateDisabled, selectedDateForTabindex]);
557
+ const {isTabbable} = useCalendarRovingTabindex({
558
+ days,
559
+ today,
560
+ year,
561
+ month: month.month,
562
+ isDateDisabled,
563
+ selectedDate: selectedDateForTabindex,
564
+ });
584
565
 
585
566
  // Helper to get the focused date from the currently focused element.
586
567
  // Reads the machine-readable `data-date` (ISO) attribute rather than parsing
@@ -647,22 +628,18 @@ function MonthGrid({
647
628
  // focus (the day button inside the cell). Arrow keys move to the target
648
629
  // row/column and, if that cell is disabled, continue in the same direction to
649
630
  // the next enabled cell.
650
- const {
651
- gridRef,
652
- handleKeyDown: handleGridKeyDown,
653
- handleFocus: handleGridFocus,
654
- } = useGridFocus<HTMLDivElement>({
655
- columns: 7,
656
- cellSelector: '[role="gridcell"]',
657
- isCellFocusable: cell =>
658
- cell.querySelector('button:not([disabled])') !== null,
659
- getFocusTarget: cell => cell.querySelector<HTMLElement>('button'),
660
- hasRovingTabIndex: true,
661
- onNavigateBefore: handleNavigatePrevious,
662
- onNavigateAfter: handleNavigateNext,
663
- onPageUp: handlePageUp,
664
- onPageDown: handlePageDown,
665
- });
631
+ const {gridRef, handleKeyDown: handleGridKeyDown} =
632
+ useGridFocus<HTMLDivElement>({
633
+ columns: 7,
634
+ cellSelector: '[role="gridcell"]',
635
+ isCellFocusable: cell =>
636
+ cell.querySelector('button:not([disabled])') !== null,
637
+ getFocusTarget: cell => cell.querySelector<HTMLElement>('button'),
638
+ onNavigateBefore: handleNavigatePrevious,
639
+ onNavigateAfter: handleNavigateNext,
640
+ onPageUp: handlePageUp,
641
+ onPageDown: handlePageDown,
642
+ });
666
643
 
667
644
  // Handle pending focus after month navigation
668
645
  useEffect(() => {
@@ -742,7 +719,6 @@ function MonthGrid({
742
719
  role="grid"
743
720
  aria-label={monthLabel}
744
721
  onKeyDown={handleGridKeyDown}
745
- onFocus={handleGridFocus}
746
722
  {...stylex.props(
747
723
  monthGridStyles.daysGrid,
748
724
  hasWeekNumbers && monthGridStyles.daysGridWithNumbers,
@@ -798,7 +774,7 @@ function MonthGrid({
798
774
  today={today}
799
775
  hasOutsideDays={hasOutsideDays}
800
776
  isDisabled={isDateDisabled(day.date)}
801
- isTabbable={day.iso === seedTabbableIso}
777
+ isTabbable={isTabbable(day.iso)}
802
778
  onDayClick={onDayClick}
803
779
  onDayHover={onDayHover}
804
780
  />
@@ -827,11 +803,6 @@ interface DayCellProps {
827
803
  today: PlainDate;
828
804
  hasOutsideDays: boolean;
829
805
  isDisabled: boolean;
830
- /**
831
- * Whether this day seeds the initial roving tab stop. useGridFocus
832
- * (`hasRovingTabIndex`) owns the live tab stop thereafter — it honors an
833
- * existing `tabindex="0"` and repairs/moves it on navigation and focus.
834
- */
835
806
  isTabbable: boolean;
836
807
  onDayClick: (date: PlainDate) => void;
837
808
  onDayHover: (date: PlainDate | null) => void;
@@ -923,7 +894,6 @@ function DayCell({
923
894
  aria-selected={state.isSelected || state.isInRange || undefined}
924
895
  aria-disabled={state.effectivelyDisabled || undefined}
925
896
  disabled={isDisabled}
926
- // Initial roving tab-stop seed; useGridFocus owns it after mount.
927
897
  tabIndex={isTabbableDay ? 0 : -1}
928
898
  onClick={() => !state.effectivelyDisabled && onDayClick(date)}
929
899
  onMouseEnter={() => !state.effectivelyDisabled && onDayHover(date)}
@@ -27,3 +27,9 @@ export type {
27
27
  UseCalendarNavigationOptions,
28
28
  UseCalendarNavigationReturn,
29
29
  } from './useCalendarNavigation';
30
+
31
+ export {useCalendarRovingTabindex} from './useCalendarRovingTabindex';
32
+ export type {
33
+ UseCalendarRovingTabindexOptions,
34
+ UseCalendarRovingTabindexReturn,
35
+ } from './useCalendarRovingTabindex';
@@ -0,0 +1,118 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useCalendarRovingTabindex.ts
7
+ * @input Uses React useMemo, PlainDate utilities
8
+ * @output Exports useCalendarRovingTabindex hook for accessible grid navigation
9
+ * @position Calendar-specific hook; used by Calendar
10
+ *
11
+ * SYNC: When modified, update:
12
+ * - /packages/core/src/Calendar/hooks/index.ts
13
+ */
14
+
15
+ import {useMemo} from 'react';
16
+ import type {ISODateString} from '../../utils/dateTypes';
17
+ import {type PlainDate, plainDateToISO} from '../../utils/plainDate';
18
+ import type {CalendarDay} from './useCalendarDays';
19
+
20
+ /**
21
+ * Configuration for calendar roving tabindex
22
+ */
23
+ export interface UseCalendarRovingTabindexOptions {
24
+ /** All days in the grid */
25
+ days: CalendarDay[];
26
+ /** Today's date as a PlainDate */
27
+ today: PlainDate;
28
+ /** The year being displayed */
29
+ year: number;
30
+ /** The month being displayed (1-based: 1 = January, 12 = December) */
31
+ month: number;
32
+ /** Function to check if a date is disabled */
33
+ isDateDisabled: (date: PlainDate) => boolean;
34
+ /** Currently selected date (if any) */
35
+ selectedDate?: PlainDate | null;
36
+ }
37
+
38
+ /**
39
+ * Return type for useCalendarRovingTabindex hook
40
+ */
41
+ export interface UseCalendarRovingTabindexReturn {
42
+ /** The ISO date string of the tabbable element, or null */
43
+ tabbableDate: ISODateString | null;
44
+ /** Check if a specific date is the tabbable one */
45
+ isTabbable: (iso: ISODateString) => boolean;
46
+ }
47
+
48
+ /**
49
+ * Hook for managing roving tabindex in calendar grids.
50
+ *
51
+ * Implements the WAI-ARIA roving tabindex pattern where only one element
52
+ * in a group has tabindex="0" while others have tabindex="-1".
53
+ *
54
+ * Priority for tabbable element:
55
+ * 1. Today if visible in the current month and enabled
56
+ * 2. First enabled day in the month
57
+ *
58
+ * @example
59
+ * ```
60
+ * const {tabbableDate, isTabbable} = useCalendarRovingTabindex({
61
+ * days,
62
+ * today: {year: 2026, month: 1, day: 20},
63
+ * year: 2026,
64
+ * month: 1, // January (1-based)
65
+ * isDateDisabled,
66
+ * });
67
+ *
68
+ * // In day rendering:
69
+ * <button tabIndex={isTabbable(day.iso) ? 0 : -1}>
70
+ * {day.dayNumber}
71
+ * </button>
72
+ * ```
73
+ */
74
+ export function useCalendarRovingTabindex(
75
+ options: UseCalendarRovingTabindexOptions,
76
+ ): UseCalendarRovingTabindexReturn {
77
+ const {days, today, year, month, isDateDisabled, selectedDate} = options;
78
+
79
+ // Determine which day should be tabbable
80
+ const tabbableDate = useMemo(() => {
81
+ // Priority 1: Selected date if visible in this month and enabled
82
+ if (selectedDate) {
83
+ const isSelectedInMonth =
84
+ selectedDate.year === year && selectedDate.month === month;
85
+ if (isSelectedInMonth && !isDateDisabled(selectedDate)) {
86
+ return plainDateToISO(selectedDate);
87
+ }
88
+ }
89
+
90
+ // Priority 2: Today if visible in this month and enabled
91
+ const isTodayInMonth = today.year === year && today.month === month;
92
+
93
+ if (isTodayInMonth && !isDateDisabled(today)) {
94
+ return plainDateToISO(today);
95
+ }
96
+
97
+ // Priority 3: First enabled day in this month
98
+ for (const day of days) {
99
+ if (!day.isOutside && !isDateDisabled(day.date)) {
100
+ return day.iso;
101
+ }
102
+ }
103
+
104
+ return null;
105
+ }, [days, today, year, month, isDateDisabled, selectedDate]);
106
+
107
+ // Check if a specific date is tabbable
108
+ const isTabbable = useMemo(() => {
109
+ return (iso: ISODateString): boolean => {
110
+ return tabbableDate !== null && iso === tabbableDate;
111
+ };
112
+ }, [tabbableDate]);
113
+
114
+ return {
115
+ tabbableDate,
116
+ isTabbable,
117
+ };
118
+ }
@@ -26,6 +26,7 @@ export {
26
26
  useCalendarDays,
27
27
  useCalendarConstraints,
28
28
  useCalendarNavigation,
29
+ useCalendarRovingTabindex,
29
30
  } from './hooks';
30
31
  export type {
31
32
  CalendarDay,
@@ -35,6 +36,8 @@ export type {
35
36
  UseCalendarConstraintsReturn,
36
37
  UseCalendarNavigationOptions,
37
38
  UseCalendarNavigationReturn,
39
+ UseCalendarRovingTabindexOptions,
40
+ UseCalendarRovingTabindexReturn,
38
41
  } from './hooks';
39
42
 
40
43
  // Re-export calendar-specific utilities for advanced usage
@@ -151,7 +151,7 @@ export function CheckboxList({
151
151
  'data-testid': dataTestId,
152
152
  }: CheckboxListProps) {
153
153
  const inputID = useId();
154
- const labelID = useId();
154
+ const labelElementID = useId();
155
155
  const descriptionID = useId();
156
156
  const statusMessageID = useId();
157
157
 
@@ -211,7 +211,7 @@ export function CheckboxList({
211
211
  isLabelHidden={isLabelHidden}
212
212
  description={description}
213
213
  inputID={inputID}
214
- labelID={labelID}
214
+ labelElementID={labelElementID}
215
215
  isGroupLabel
216
216
  descriptionID={description ? descriptionID : undefined}
217
217
  isDisabled={isDisabled}
@@ -231,7 +231,7 @@ export function CheckboxList({
231
231
  <CheckboxListContext value={contextValue}>
232
232
  <div
233
233
  role="group"
234
- aria-labelledby={labelID}
234
+ aria-labelledby={labelElementID}
235
235
  aria-describedby={
236
236
  [
237
237
  description ? descriptionID : null,
@@ -58,6 +58,12 @@ 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
+ },
61
67
  {
62
68
  name: 'isDisabled',
63
69
  type: 'boolean',
@@ -62,7 +62,9 @@ describe('ContextMenu', () => {
62
62
 
63
63
  it('typeahead focuses the matching menu item (menus-11)', () => {
64
64
  render(
65
- <ContextMenu items={[{label: 'Cut'}, {label: 'Copy'}, {label: 'Paste'}]}>
65
+ <ContextMenu
66
+ items={[{label: 'Cut'}, {label: 'Copy'}, {label: 'Paste'}]}
67
+ hasAutoFocus={false}>
66
68
  <div>Right-click me</div>
67
69
  </ContextMenu>,
68
70
  );
@@ -119,7 +121,7 @@ describe('ContextMenu', () => {
119
121
 
120
122
  it('closes on Escape even when opened without auto-focus', () => {
121
123
  render(
122
- <ContextMenu items={[{label: 'Item 1'}]}>
124
+ <ContextMenu items={[{label: 'Item 1'}]} hasAutoFocus={false}>
123
125
  <div>Right-click me</div>
124
126
  </ContextMenu>,
125
127
  );
@@ -133,7 +135,7 @@ describe('ContextMenu', () => {
133
135
 
134
136
  it('ignores Escape during IME composition', () => {
135
137
  render(
136
- <ContextMenu items={[{label: 'Item 1'}]}>
138
+ <ContextMenu items={[{label: 'Item 1'}]} hasAutoFocus={false}>
137
139
  <div>Right-click me</div>
138
140
  </ContextMenu>,
139
141
  );
@@ -145,7 +147,7 @@ describe('ContextMenu', () => {
145
147
 
146
148
  it('restores focus to the trigger on close', () => {
147
149
  render(
148
- <ContextMenu items={[{label: 'Item 1'}]}>
150
+ <ContextMenu items={[{label: 'Item 1'}]} hasAutoFocus={false}>
149
151
  <button type="button">Right-click me</button>
150
152
  </ContextMenu>,
151
153
  );
@@ -125,6 +125,12 @@ 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;
128
134
  /** When true, right-click shows the native browser context menu instead. */
129
135
  isDisabled?: boolean;
130
136
  /** Called when the menu opens or closes. */
@@ -178,6 +184,7 @@ export function ContextMenu({
178
184
  menuWidth,
179
185
  size = 'md',
180
186
  label = 'Context menu',
187
+ hasAutoFocus = true,
181
188
  isDisabled = false,
182
189
  onOpenChange,
183
190
  ref,
@@ -275,9 +282,10 @@ export function ContextMenu({
275
282
  }, [isOpen, closeMenu, listRef]);
276
283
 
277
284
  // Dismiss on Escape from anywhere while open. The menu div's own onKeyDown
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.
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.
281
289
  useEffect(() => {
282
290
  if (!isOpen) {
283
291
  return;
@@ -342,9 +350,11 @@ export function ContextMenu({
342
350
  ? document.activeElement
343
351
  : (e.currentTarget as HTMLElement);
344
352
  layer.show();
345
- requestAnimationFrame(() => focusFirst());
353
+ if (hasAutoFocus) {
354
+ requestAnimationFrame(() => focusFirst());
355
+ }
346
356
  },
347
- [isDisabled, layer, focusFirst],
357
+ [isDisabled, layer, hasAutoFocus, focusFirst],
348
358
  );
349
359
 
350
360
  // Touch long-press invocation (menus-8). iOS Safari never synthesizes a
@@ -357,9 +367,11 @@ export function ContextMenu({
357
367
  (point: {x: number; y: number}) => {
358
368
  positionRef.current = {x: point.x, y: point.y};
359
369
  layer.show();
360
- requestAnimationFrame(() => focusFirst());
370
+ if (hasAutoFocus) {
371
+ requestAnimationFrame(() => focusFirst());
372
+ }
361
373
  },
362
- [layer, focusFirst],
374
+ [layer, hasAutoFocus, focusFirst],
363
375
  ),
364
376
  });
365
377
 
@@ -61,7 +61,14 @@ 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
- }, {
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
+ {
65
72
  name: 'children',
66
73
  type: '(item: DropdownMenuItemData) => ReactNode',
67
74
  description: 'Custom render function for each item in the list.',
@@ -249,6 +249,29 @@ 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
+
252
275
  describe('DropdownMenu items', () => {
253
276
  it('renders items with labels', () => {
254
277
  render(
@@ -135,6 +135,13 @@ 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;
138
145
  'data-testid'?: string;
139
146
  }
140
147
 
@@ -186,6 +193,7 @@ export function DropdownMenu({
186
193
  onClick,
187
194
  hasChevron = true,
188
195
  placement = 'below',
196
+ hasAutoFocus = true,
189
197
  className,
190
198
  style,
191
199
  xstyle,
@@ -291,12 +299,14 @@ export function DropdownMenu({
291
299
  if (isControlled) {
292
300
  if (controlledIsOpen && !popover.isOpen) {
293
301
  popover.show();
294
- requestAnimationFrame(() => focusFirst());
302
+ if (hasAutoFocus) {
303
+ requestAnimationFrame(() => focusFirst());
304
+ }
295
305
  } else if (!controlledIsOpen && popover.isOpen) {
296
306
  popover.hide();
297
307
  }
298
308
  }
299
- }, [controlledIsOpen, isControlled, popover, focusFirst]);
309
+ }, [controlledIsOpen, isControlled, popover, hasAutoFocus, focusFirst]);
300
310
 
301
311
  // Extend useListFocus with Enter/Space activation + typeahead
302
312
  const listKeyDown = useCallback(
@@ -330,8 +340,10 @@ export function DropdownMenu({
330
340
 
331
341
  const openAndFocus = useCallback(() => {
332
342
  popover.show();
333
- requestAnimationFrame(() => focusFirst());
334
- }, [popover, focusFirst]);
343
+ if (hasAutoFocus) {
344
+ requestAnimationFrame(() => focusFirst());
345
+ }
346
+ }, [popover, hasAutoFocus, focusFirst]);
335
347
 
336
348
  const handleButtonClick = useCallback(() => {
337
349
  // 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
- labelID="plan-label"
112
+ labelElementID="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
- // labelID is applied to the label element and referenced by the group.
122
+ // labelElementID 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 `labelID` for the latter.
104
+ * see `labelElementID` 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
- labelID?: string;
113
+ labelElementID?: 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 `labelID` + `aria-labelledby` on the group.
118
+ * group. Pair with `labelElementID` + `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
- labelID,
192
+ labelElementID,
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
- labelID={labelID}
228
+ labelElementID={labelElementID}
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
- * `labelID` for that.
87
+ * `labelElementID` 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
- labelID?: string;
96
+ labelElementID?: 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
- * `labelID` + `aria-labelledby`.
103
+ * `labelElementID` + `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
- labelID,
164
+ labelElementID,
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={labelID}
206
+ id={labelElementID}
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}