@westpac/ui 1.18.0 → 1.19.0

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 (64) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/component-type.json +1 -1
  3. package/dist/components/button-group/button-group.component.d.ts +2 -1
  4. package/dist/components/button-group/button-group.component.js +6 -3
  5. package/dist/components/checkbox-group/checkbox-group.component.d.ts +11 -2
  6. package/dist/components/checkbox-group/checkbox-group.component.js +10 -3
  7. package/dist/components/checkbox-group/components/checkbox-group-checkbox/checkbox-group-checkbox.component.d.ts +1 -1
  8. package/dist/components/checkbox-group/components/checkbox-group-checkbox/checkbox-group-checkbox.component.js +5 -6
  9. package/dist/components/date-picker/components/date-field/components/date-segment/date-segment.component.d.ts +6 -5
  10. package/dist/components/date-picker/components/date-field/components/date-segment/date-segment.component.js +6 -4
  11. package/dist/components/date-picker/components/date-field/date-field.component.d.ts +5 -5
  12. package/dist/components/date-picker/components/date-field/date-field.component.js +7 -3
  13. package/dist/components/date-picker/date-picker.component.d.ts +19 -2
  14. package/dist/components/date-picker/date-picker.component.js +10 -5
  15. package/dist/components/date-picker/date-picker.styles.js +5 -5
  16. package/dist/components/multi-select/components/multi-select-list-box/components/multi-select-option/multi-select-option.component.js +3 -7
  17. package/dist/components/multi-select/components/multi-select-list-box/components/multi-select-select-all-option/multi-select-select-all-option.component.js +3 -3
  18. package/dist/components/multi-select/components/multi-select-popover/multi-select-popover.component.js +5 -2
  19. package/dist/components/multi-select/components/multi-select-searchbar/multi-select-searchbar.component.js +4 -7
  20. package/dist/components/multi-select/multi-select.component.d.ts +14 -3
  21. package/dist/components/multi-select/multi-select.component.js +10 -16
  22. package/dist/components/radio-group/components/radio-group-radio/radio-group-radio.component.d.ts +1 -1
  23. package/dist/components/radio-group/components/radio-group-radio/radio-group-radio.component.js +5 -6
  24. package/dist/components/radio-group/radio-group.component.d.ts +10 -2
  25. package/dist/components/radio-group/radio-group.component.js +10 -3
  26. package/dist/components/selector/components/selector-button-group/selector-button-group.component.d.ts +9 -2
  27. package/dist/components/selector/components/selector-button-group/selector-button-group.component.js +8 -3
  28. package/dist/components/selector/components/selector-checkbox-group/selector-checkbox-group.component.d.ts +6 -2
  29. package/dist/components/selector/components/selector-checkbox-group/selector-checkbox-group.component.js +8 -3
  30. package/dist/components/selector/components/selector-radio-group/selector-radio-group.component.d.ts +6 -2
  31. package/dist/components/selector/components/selector-radio-group/selector-radio-group.component.js +8 -3
  32. package/dist/components/selector/selector.component.d.ts +3 -1
  33. package/dist/components/selector/selector.component.js +16 -5
  34. package/dist/components/switch/switch.component.d.ts +8 -2
  35. package/dist/components/switch/switch.component.js +6 -4
  36. package/dist/css/westpac-ui.css +12 -12
  37. package/dist/css/westpac-ui.min.css +12 -12
  38. package/dist/hook/focus-manager-ref.hook.d.ts +29 -0
  39. package/dist/hook/focus-manager-ref.hook.js +23 -0
  40. package/dist/hook/index.d.ts +1 -0
  41. package/dist/index.d.ts +1 -0
  42. package/package.json +5 -4
  43. package/src/components/button-group/button-group.component.tsx +26 -16
  44. package/src/components/checkbox-group/checkbox-group.component.tsx +24 -14
  45. package/src/components/checkbox-group/components/checkbox-group-checkbox/checkbox-group-checkbox.component.tsx +7 -7
  46. package/src/components/date-picker/components/date-field/components/date-segment/date-segment.component.tsx +10 -4
  47. package/src/components/date-picker/components/date-field/date-field.component.tsx +17 -3
  48. package/src/components/date-picker/date-picker.component.tsx +32 -21
  49. package/src/components/date-picker/date-picker.styles.ts +5 -5
  50. package/src/components/multi-select/components/multi-select-list-box/components/multi-select-option/multi-select-option.component.tsx +3 -5
  51. package/src/components/multi-select/components/multi-select-list-box/components/multi-select-select-all-option/multi-select-select-all-option.component.tsx +4 -3
  52. package/src/components/multi-select/components/multi-select-popover/multi-select-popover.component.tsx +8 -2
  53. package/src/components/multi-select/components/multi-select-searchbar/multi-select-searchbar.component.tsx +3 -6
  54. package/src/components/multi-select/multi-select.component.tsx +33 -28
  55. package/src/components/radio-group/components/radio-group-radio/radio-group-radio.component.tsx +10 -7
  56. package/src/components/radio-group/radio-group.component.tsx +24 -14
  57. package/src/components/selector/components/selector-button-group/selector-button-group.component.tsx +23 -14
  58. package/src/components/selector/components/selector-checkbox-group/selector-checkbox-group.component.tsx +12 -3
  59. package/src/components/selector/components/selector-radio-group/selector-radio-group.component.tsx +21 -11
  60. package/src/components/selector/selector.component.tsx +16 -5
  61. package/src/components/switch/switch.component.tsx +10 -12
  62. package/src/hook/focus-manager-ref.hook.ts +56 -0
  63. package/src/hook/index.ts +1 -0
  64. package/src/index.ts +1 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { DateValue, getDayOfWeek, isWeekend } from '@internationalized/date';
4
4
  import { Breakpoint } from '@westpac/style-config/constants';
5
- import React, { useMemo, useRef } from 'react';
5
+ import React, { ForwardedRef, forwardRef, useMemo, useRef } from 'react';
6
6
  import { useButton, useDatePicker, useLocale } from 'react-aria';
7
7
  import { useDatePickerState } from 'react-stately';
8
8
 
@@ -19,19 +19,24 @@ import { styles as datePickerStyles } from './date-picker.styles.js';
19
19
  import { type DatePickerProps } from './date-picker.types.js';
20
20
 
21
21
  const BREAKPOINTS_DECRECENT = ['xl', 'lg', 'md', 'sm', 'xsl', 'initial'] as const;
22
- export function DatePicker({
23
- size = 'medium',
24
- className,
25
- bottomSheetView = { initial: true, xsl: false },
26
- block,
27
- isDateUnavailable,
28
- disableDaysOfWeek,
29
- disableWeekends,
30
- separator,
31
- portalContainer,
32
- placement = 'bottom left',
33
- ...props
34
- }: DatePickerProps) {
22
+ function BaseDatePicker(
23
+ {
24
+ size = 'medium',
25
+ className,
26
+ bottomSheetView = { initial: true, xsl: false },
27
+ block,
28
+ isDateUnavailable,
29
+ disableDaysOfWeek,
30
+ disableWeekends,
31
+ separator,
32
+ portalContainer,
33
+ placement = 'bottom left',
34
+ ...props
35
+ }: DatePickerProps,
36
+ // Attached to the first editable date segment, so `ref.current.focus()` (e.g. react-hook-form
37
+ // focusing a field with a validation error) lands on a focusable control.
38
+ forwardedRef: ForwardedRef<HTMLSpanElement>,
39
+ ) {
35
40
  const { locale } = useLocale();
36
41
 
37
42
  const enhancedIsDateUnavailable = useMemo(() => {
@@ -60,7 +65,7 @@ export function DatePicker({
60
65
  isReadOnly: props.isReadOnly,
61
66
  isDisabled: props.isDisabled,
62
67
  });
63
- const ref = useRef(null);
68
+ const ref = useRef<HTMLDivElement>(null);
64
69
 
65
70
  const { groupProps, labelProps, fieldProps, buttonProps, dialogProps, calendarProps } = useDatePicker(
66
71
  { isDateUnavailable: enhancedIsDateUnavailable, ...props },
@@ -84,12 +89,15 @@ export function DatePicker({
84
89
 
85
90
  const { buttonProps: newButtonProps } = useButton(buttonProps, buttonRef);
86
91
 
92
+ // This is required so branding applies correctly by default due to portal location, can be overridden with portalContainer prop
87
93
  const brandContainer = useMemo(() => {
88
- return (
89
- document.querySelector('[data-brand]') ||
90
- document.querySelector('[class^="theme-"], [class*=" theme-"]') ||
91
- undefined
92
- );
94
+ if (typeof window !== 'undefined') {
95
+ return (
96
+ document.querySelector('[data-brand]') ||
97
+ document.querySelector('[class^="theme-"], [class*=" theme-"]') ||
98
+ undefined
99
+ );
100
+ }
93
101
  }, []);
94
102
 
95
103
  return (
@@ -107,7 +115,7 @@ export function DatePicker({
107
115
  }}
108
116
  className={styles.input({ className })}
109
117
  >
110
- <DateField className={styles.dateField()} separator={separator} {...fieldProps} />
118
+ <DateField className={styles.dateField()} separator={separator} {...fieldProps} ref={forwardedRef} />
111
119
  <Button
112
120
  look="faint"
113
121
  className={styles.button()}
@@ -134,3 +142,6 @@ export function DatePicker({
134
142
  </>
135
143
  );
136
144
  }
145
+
146
+ export const DatePicker = forwardRef(BaseDatePicker);
147
+ DatePicker.displayName = 'DatePicker';
@@ -3,26 +3,26 @@ import { tv } from 'tailwind-variants';
3
3
  export const styles = tv({
4
4
  slots: {
5
5
  input: 'form-control flex items-center border-0',
6
- dateField: 'rounded rounded-r-none border border-r-0 border-border-muted-strong',
6
+ dateField: 'rounded rounded-r-none border border-r-0 border-border-muted-strong whitespace-nowrap',
7
7
  button: 'flex items-center justify-center rounded-l-none border-border-muted-strong bg-surface-muted-pale',
8
8
  },
9
9
  variants: {
10
10
  size: {
11
11
  small: {
12
12
  input: 'max-h-5',
13
- dateField: 'h-5 w-[120px] form-control-small',
13
+ dateField: 'h-5 min-w-[120px] form-control-small',
14
14
  },
15
15
  medium: {
16
16
  input: 'max-h-6',
17
- dateField: 'h-6 w-[140px] form-control-medium',
17
+ dateField: 'h-6 min-w-[140px] form-control-medium',
18
18
  },
19
19
  large: {
20
20
  input: 'max-h-7',
21
- dateField: 'h-7 w-[145px] form-control-large',
21
+ dateField: 'h-7 min-w-[145px] form-control-large',
22
22
  },
23
23
  xlarge: {
24
24
  input: 'max-h-8',
25
- dateField: 'h-8 w-[165px] form-control-xlarge',
25
+ dateField: 'h-8 min-w-[165px] form-control-xlarge',
26
26
  },
27
27
  },
28
28
  block: {
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { focusWithoutScrolling } from '@react-aria/utils';
3
4
  import React, { useCallback, useContext, useRef, KeyboardEvent } from 'react';
4
5
  import { mergeProps, useFocusRing, useOption } from 'react-aria';
5
6
 
@@ -27,11 +28,8 @@ export function MultiSelectOption<T>({ item }: MultiSelectOptionProps<T>) {
27
28
  (e: KeyboardEvent<HTMLLIElement>) => {
28
29
  if (e.key === 'ArrowUp' && item.index === 0) {
29
30
  e.preventDefault();
30
- if (selectAllRef.current) {
31
- selectAllRef.current?.focus();
32
- } else {
33
- inputRef.current?.focus();
34
- }
31
+ const target = selectAllRef.current || inputRef.current;
32
+ if (target) focusWithoutScrolling(target);
35
33
  }
36
34
  if (e.key === 'Enter' || e.key === ' ') {
37
35
  e.preventDefault();
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { focusWithoutScrolling } from '@react-aria/utils';
3
4
  import React, { useCallback, useContext, useMemo, KeyboardEvent } from 'react';
4
5
  import { useOption, useFocusRing, mergeProps } from 'react-aria';
5
6
 
@@ -54,12 +55,12 @@ export function MultiSelectSelectAllOption() {
54
55
  (e: KeyboardEvent<HTMLDivElement>) => {
55
56
  if (e.key === 'ArrowDown') {
56
57
  e.preventDefault();
57
- const firstItem = listBoxRef.current?.querySelector('[data-key]') as HTMLElement;
58
- firstItem?.focus();
58
+ const firstItem = listBoxRef.current?.querySelector('[data-key]') as HTMLElement | null;
59
+ if (firstItem) focusWithoutScrolling(firstItem);
59
60
  }
60
61
  if (e.key === 'ArrowUp') {
61
62
  e.preventDefault();
62
- inputRef.current?.focus();
63
+ if (inputRef.current) focusWithoutScrolling(inputRef.current);
63
64
  }
64
65
  if (e.key === 'Enter' || e.key === ' ') {
65
66
  e.preventDefault();
@@ -24,6 +24,13 @@ export function MultiSelectPopover({ children, className, ...props }: MultiSelec
24
24
  // eslint-disable-next-line react-hooks/exhaustive-deps
25
25
  }, []);
26
26
 
27
+ // react-aria assumes a non-modal popover never receives DOM focus, so it closes it whenever the page
28
+ // scrolls (useOverlayPosition -> useCloseOnScroll via `state.close`). Focus does move into this popover
29
+ // (filter input / options), and browsers may scroll the page when it does, which closed the dropdown
30
+ // mid keyboard navigation. Dismissal (blur, Escape, Tab, DismissButton) is handled explicitly below, so
31
+ // give react-aria a state it cannot close.
32
+ const positioningState = { ...overlayState, close: () => undefined };
33
+
27
34
  const { popoverProps } = usePopover(
28
35
  {
29
36
  ...props,
@@ -35,7 +42,7 @@ export function MultiSelectPopover({ children, className, ...props }: MultiSelec
35
42
  shouldCloseOnInteractOutside: () => false, // need to manage accessibility manually due to complexity of component
36
43
  offset: 6,
37
44
  },
38
- overlayState,
45
+ positioningState,
39
46
  );
40
47
 
41
48
  // This is required so branding applies correctly by default due to portal location, can be overridden with portalContainer prop
@@ -80,7 +87,6 @@ export function MultiSelectPopover({ children, className, ...props }: MultiSelec
80
87
  }
81
88
  }}
82
89
  role="dialog"
83
- aria-modal="true"
84
90
  aria-label="Options list with filter"
85
91
  >
86
92
  {children}
@@ -1,3 +1,4 @@
1
+ import { focusWithoutScrolling } from '@react-aria/utils';
1
2
  import React, { useContext, useCallback, KeyboardEvent } from 'react';
2
3
 
3
4
  import { Button } from '../../../../components/button/index.js';
@@ -21,12 +22,8 @@ export function MultiSelectSearchbar({
21
22
  (e: KeyboardEvent<HTMLInputElement>) => {
22
23
  if (e.key === 'ArrowDown') {
23
24
  e.preventDefault();
24
- if (selectAllRef.current) {
25
- selectAllRef.current.focus();
26
- } else {
27
- const firstItem = listBoxRef.current?.querySelector('[data-key]') as HTMLElement;
28
- firstItem?.focus();
29
- }
25
+ const target = selectAllRef.current || (listBoxRef.current?.querySelector('[data-key]') as HTMLElement | null);
26
+ if (target) focusWithoutScrolling(target);
30
27
  }
31
28
  if (e.key === 'Escape' && filterText.length > 0) {
32
29
  e.stopPropagation();
@@ -1,8 +1,9 @@
1
1
  'use client';
2
2
 
3
+ import { focusWithoutScrolling } from '@react-aria/utils';
3
4
  import { Node } from '@react-types/shared';
4
- import React, { useRef, useState, memo, createContext } from 'react';
5
- import { useFilter, useOverlayTrigger } from 'react-aria';
5
+ import React, { ForwardedRef, forwardRef, useRef, useState, memo, createContext } from 'react';
6
+ import { useFilter, useObjectRef, useOverlayTrigger } from 'react-aria';
6
7
  import { Item, useListState, useOverlayTriggerState } from 'react-stately';
7
8
 
8
9
  import { MultiSelectDropdown } from './components/multi-select-dropdown/multi-select-dropdown.component.js';
@@ -30,22 +31,25 @@ export const MultiSelectContext = createContext<MultiSelectContextProps>({
30
31
  hideSelectAll: false,
31
32
  });
32
33
 
33
- export function BaseMultiSelect<T extends MultiSelectValue = MultiSelectValue>({
34
- size = 'medium',
35
- listBoxProps,
36
- selectionMode = 'multiple',
37
- selectedKeys,
38
- onSelectionChange,
39
- placeholder = 'Select',
40
- showSingleSectionTitle = false,
41
- placement = 'bottom left',
42
- portalContainer,
43
- id,
44
- hideFilter = false,
45
- hideSelectAll = false,
46
- width = 'full',
47
- ...props
48
- }: MultiSelectProps<T>) {
34
+ export function BaseMultiSelect<T extends MultiSelectValue = MultiSelectValue>(
35
+ {
36
+ size = 'medium',
37
+ listBoxProps,
38
+ selectionMode = 'multiple',
39
+ selectedKeys,
40
+ onSelectionChange,
41
+ placeholder = 'Select',
42
+ showSingleSectionTitle = false,
43
+ placement = 'bottom left',
44
+ portalContainer,
45
+ id,
46
+ hideFilter = false,
47
+ hideSelectAll = false,
48
+ width = 'full',
49
+ ...props
50
+ }: MultiSelectProps<T>,
51
+ forwardedRef: ForwardedRef<HTMLButtonElement>,
52
+ ) {
49
53
  const [filterText, setFilterText] = useState('');
50
54
  const filter = useFilter({ sensitivity: 'base' });
51
55
 
@@ -62,7 +66,7 @@ export function BaseMultiSelect<T extends MultiSelectValue = MultiSelectValue>({
62
66
 
63
67
  // refs
64
68
  const inputRef = useRef<HTMLInputElement>(null);
65
- const buttonRef = useRef<HTMLButtonElement>(null);
69
+ const buttonRef = useObjectRef(forwardedRef);
66
70
  const popoverRef = useRef<HTMLDivElement>(null);
67
71
  const selectAllRef = useRef<HTMLInputElement>(null);
68
72
  const listBoxRef = useRef<HTMLUListElement>(null);
@@ -70,15 +74,14 @@ export function BaseMultiSelect<T extends MultiSelectValue = MultiSelectValue>({
70
74
  const overlayState = useOverlayTriggerState({
71
75
  onOpenChange: isOpen => {
72
76
  if (isOpen) {
77
+ // Focus moves into the popover without scrolling the page (react-aria's own focus helper), so the
78
+ // dropdown does not jump around, and page scroll cannot dismiss it while the user navigates.
73
79
  requestAnimationFrame(() => {
74
- if (!hideFilter) {
75
- inputRef.current?.focus();
76
- } else if (selectionMode === 'multiple' && !hideSelectAll) {
77
- selectAllRef.current?.focus();
78
- } else {
79
- const firstItem = listBoxRef.current?.querySelector('[data-key]') as HTMLElement;
80
- firstItem?.focus();
81
- }
80
+ const target =
81
+ (!hideFilter && inputRef.current) ||
82
+ (selectionMode === 'multiple' && !hideSelectAll && selectAllRef.current) ||
83
+ (listBoxRef.current?.querySelector('[data-key]') as HTMLElement | null);
84
+ if (target) focusWithoutScrolling(target);
82
85
  });
83
86
  }
84
87
  if (!isOpen) {
@@ -124,7 +127,9 @@ export function BaseMultiSelect<T extends MultiSelectValue = MultiSelectValue>({
124
127
  </MultiSelectContext.Provider>
125
128
  );
126
129
  }
127
- export const MultiSelect = memo(BaseMultiSelect);
130
+
131
+ export const MultiSelect = memo(forwardRef(BaseMultiSelect));
132
+ MultiSelect.displayName = 'MultiSelect';
128
133
 
129
134
  // Exporting react-stately's Item with custom props/naming and Section with custom naming to align with other components
130
135
  export const MultiSelectItem = Item as (props: MultiSelectItemProps) => JSX.Element;
@@ -1,24 +1,27 @@
1
1
  'use client';
2
2
 
3
- import React, { Ref, forwardRef, useContext, useRef } from 'react';
4
- import { VisuallyHidden, mergeProps, useFocusRing, useRadio } from 'react-aria';
3
+ import React, { ForwardedRef, forwardRef, useContext } from 'react';
4
+ import { VisuallyHidden, mergeProps, useFocusRing, useObjectRef, useRadio } from 'react-aria';
5
5
 
6
6
  import { RadioGroupContext } from '../../radio-group.component.js';
7
7
 
8
8
  import { styles as radioStyles } from './radio-group-radio.styles.js';
9
9
  import { type RadioGroupRadioProps } from './radio-group-radio.types.js';
10
10
 
11
- function BaseRadioGroupRadio({ className, hint, label, ...props }: RadioGroupRadioProps, ref: Ref<HTMLLabelElement>) {
11
+ function BaseRadioGroupRadio(
12
+ { className, hint, label, ...props }: RadioGroupRadioProps,
13
+ ref: ForwardedRef<HTMLInputElement>,
14
+ ) {
12
15
  const { state, size, orientation } = useContext(RadioGroupContext);
13
- const localRef = useRef(null);
14
- const { inputProps, labelProps, isSelected, isDisabled } = useRadio({ ...props, children: label }, state, localRef);
16
+ const inputRef = useObjectRef(ref);
17
+ const { inputProps, labelProps, isSelected, isDisabled } = useRadio({ ...props, children: label }, state, inputRef);
15
18
  const { isFocusVisible, focusProps } = useFocusRing();
16
19
  const styles = radioStyles({ isDisabled, isSelected, isFocusVisible, size, orientation });
17
20
 
18
21
  return (
19
- <label className={styles.base({ className })} ref={ref} {...labelProps}>
22
+ <label className={styles.base({ className })} {...labelProps}>
20
23
  <VisuallyHidden elementType="span">
21
- <input {...mergeProps(inputProps, focusProps)} ref={localRef} />
24
+ <input {...mergeProps(inputProps, focusProps)} ref={inputRef} />
22
25
  </VisuallyHidden>
23
26
  <span className={styles.selector()} />
24
27
  <span className={styles.textWrapper()}>
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
- import React, { createContext, useEffect, useId, useMemo, useRef, useState } from 'react';
3
+ import React, { ForwardedRef, createContext, forwardRef, useEffect, useId, useMemo, useRef, useState } from 'react';
4
4
  import { useFocusRing, useRadioGroup } from 'react-aria';
5
5
  import { useRadioGroupState } from 'react-stately';
6
6
 
7
7
  import { FUNCTION_NOT_IMPLEMENTED } from '../../constants/message.js';
8
8
  import { useBreakpoint } from '../../hook/breakpoints.hook.js';
9
+ import { FocusHandle, acceptInputs, useFocusManagerRef } from '../../hook/focus-manager-ref.hook.js';
9
10
  import { resolveResponsiveVariant } from '../../utils/breakpoint.util.js';
10
11
  import { Button } from '../button/index.js';
11
12
  import { ExpandMoreIcon } from '../icon/index.js';
@@ -75,17 +76,23 @@ export const RadioGroupContext = createContext<RadioGroupContextState>({
75
76
  },
76
77
  });
77
78
 
78
- export function RadioGroup({
79
- className,
80
- radios,
81
- label,
82
- orientation = 'vertical',
83
- showAmount = 0,
84
- size = 'medium',
85
- errorMessage,
86
- hintMessage,
87
- ...props
88
- }: RadioGroupProps) {
79
+ function BaseRadioGroup(
80
+ {
81
+ className,
82
+ radios,
83
+ label,
84
+ orientation = 'vertical',
85
+ showAmount = 0,
86
+ size = 'medium',
87
+ errorMessage,
88
+ hintMessage,
89
+ ...props
90
+ }: RadioGroupProps,
91
+ // `ref.current.focus()` (e.g. react-hook-form focusing a field with a validation error) moves focus
92
+ // to the group's tab stop: the selected radio, or the first enabled radio when nothing is selected.
93
+ ref: ForwardedRef<FocusHandle>,
94
+ ) {
95
+ const wrapperRef = useFocusManagerRef(ref, { accept: acceptInputs });
89
96
  const breakpoint = useBreakpoint();
90
97
  const resolvedOrientation = resolveResponsiveVariant(orientation, breakpoint);
91
98
  const resolvedSize = resolveResponsiveVariant(size, breakpoint);
@@ -96,7 +103,7 @@ export function RadioGroup({
96
103
  );
97
104
  const { isFocusVisible, focusProps } = useFocusRing();
98
105
  const [hiddenOptions, setHiddenOptions] = useState<boolean>(showAmount > 0);
99
- const firstNewRadioRef = useRef<HTMLLabelElement>(null);
106
+ const firstNewRadioRef = useRef<HTMLInputElement>(null);
100
107
  const revealAmount = radios && radios.length - showAmount;
101
108
  const styles = radioGroupStyles({ orientation: resolvedOrientation, isFocusVisible });
102
109
  const panelId = useId();
@@ -119,7 +126,7 @@ export function RadioGroup({
119
126
  <Label {...labelProps}>{label}</Label>
120
127
  {hintMessage && <Hint {...descriptionProps}>{hintMessage}</Hint>}
121
128
  {errorMessage && state.isInvalid && <ErrorMessage {...errorMessageProps} message={errorMessage} />}
122
- <div className={styles.radioWrapper()} id={panelId}>
129
+ <div className={styles.radioWrapper()} id={panelId} ref={wrapperRef}>
123
130
  <RadioGroupContext.Provider value={{ state, orientation: resolvedOrientation, size: resolvedSize }}>
124
131
  {childrenToRender}
125
132
  </RadioGroupContext.Provider>
@@ -142,3 +149,6 @@ export function RadioGroup({
142
149
  </div>
143
150
  );
144
151
  }
152
+
153
+ export const RadioGroup = forwardRef(BaseRadioGroup);
154
+ RadioGroup.displayName = 'RadioGroup';
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
 
3
- import React, { createContext, useCallback, useMemo, useState } from 'react';
3
+ import React, { ForwardedRef, createContext, forwardRef, useCallback, useMemo, useState } from 'react';
4
4
  import { useField } from 'react-aria';
5
5
 
6
6
  import { useBreakpoint } from '../../../../hook/breakpoints.hook.js';
7
+ import { FocusHandle, useFocusManagerRef } from '../../../../hook/focus-manager-ref.hook.js';
7
8
  import { resolveResponsiveVariant } from '../../../../utils/breakpoint.util.js';
8
9
  import { ErrorMessage, Hint, Label } from '../../../index.js';
9
10
 
@@ -17,18 +18,23 @@ export const SelectorButtonContext = createContext<SelectorButtonGroupContextSta
17
18
  isDisabled: undefined,
18
19
  });
19
20
 
20
- export function SelectorButtonGroup({
21
- className,
22
- children,
23
- label,
24
- orientation = 'vertical',
25
- errorMessage,
26
- description,
27
- value = '',
28
- onChange,
29
- isDisabled,
30
- ...props
31
- }: SelectorButtonGroupProps) {
21
+ function BaseSelectorButtonGroup(
22
+ {
23
+ className,
24
+ children,
25
+ label,
26
+ orientation = 'vertical',
27
+ errorMessage,
28
+ description,
29
+ value = '',
30
+ onChange,
31
+ isDisabled,
32
+ ...props
33
+ }: SelectorButtonGroupProps,
34
+ // `ref.current.focus()` moves focus to the first tabbable option button.
35
+ ref: ForwardedRef<FocusHandle>,
36
+ ) {
37
+ const wrapperRef = useFocusManagerRef(ref);
32
38
  const isControlled = onChange !== undefined;
33
39
  const onChangeCallback = onChange as ((value: string) => void) | undefined;
34
40
  const [selected, setSelected] = useState(value);
@@ -69,9 +75,12 @@ export function SelectorButtonGroup({
69
75
  {label && <Label {...labelProps}>{label}</Label>}
70
76
  {description && <Hint {...descriptionProps}>{description}</Hint>}
71
77
  {errorMessage && <ErrorMessage {...errorMessageProps} message={errorMessage} />}
72
- <div className={styles({ className, orientation: resolvedOrientation })} {...fieldProps}>
78
+ <div className={styles({ className, orientation: resolvedOrientation })} {...fieldProps} ref={wrapperRef}>
73
79
  <SelectorButtonContext.Provider value={state}>{children}</SelectorButtonContext.Provider>
74
80
  </div>
75
81
  </>
76
82
  );
77
83
  }
84
+
85
+ export const SelectorButtonGroup = forwardRef(BaseSelectorButtonGroup);
86
+ SelectorButtonGroup.displayName = 'SelectorButtonGroup';
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
- import React, { createContext } from 'react';
3
+ import React, { ForwardedRef, createContext, forwardRef } from 'react';
4
4
  import { useCheckboxGroup } from 'react-aria';
5
5
  import { useCheckboxGroupState } from 'react-stately';
6
6
 
7
7
  import { FUNCTION_NOT_IMPLEMENTED } from '../../../../constants/message.js';
8
+ import { FocusHandle, useFocusManagerRef } from '../../../../hook/focus-manager-ref.hook.js';
8
9
  import { ErrorMessage, Hint, Label } from '../../../index.js';
9
10
 
10
11
  import { styles } from './selector-checkbox-group.styles.js';
@@ -74,8 +75,13 @@ export const SelectorCheckboxGroupContext = createContext<SelectorCheckboxGroupC
74
75
  },
75
76
  });
76
77
 
77
- export function SelectorCheckboxGroup(props: SelectorCheckboxGroupProps) {
78
+ function BaseSelectorCheckboxGroup(
79
+ props: SelectorCheckboxGroupProps,
80
+ // `ref.current.focus()` moves focus to the first tabbable checkbox input.
81
+ ref: ForwardedRef<FocusHandle>,
82
+ ) {
78
83
  const { children, label, description, errorMessage } = props;
84
+ const wrapperRef = useFocusManagerRef(ref);
79
85
  const state = useCheckboxGroupState(props);
80
86
  const { groupProps, labelProps, descriptionProps, errorMessageProps } = useCheckboxGroup(props, state);
81
87
 
@@ -84,9 +90,12 @@ export function SelectorCheckboxGroup(props: SelectorCheckboxGroupProps) {
84
90
  {label && <Label {...labelProps}>{label}</Label>}
85
91
  {description && <Hint {...descriptionProps}>{description}</Hint>}
86
92
  {errorMessage && state.isInvalid && <ErrorMessage {...errorMessageProps} message={errorMessage} />}
87
- <div {...groupProps} className={styles({ className: groupProps.className })}>
93
+ <div {...groupProps} className={styles({ className: groupProps.className })} ref={wrapperRef}>
88
94
  <SelectorCheckboxGroupContext.Provider value={state}>{children}</SelectorCheckboxGroupContext.Provider>
89
95
  </div>
90
96
  </>
91
97
  );
92
98
  }
99
+
100
+ export const SelectorCheckboxGroup = forwardRef(BaseSelectorCheckboxGroup);
101
+ SelectorCheckboxGroup.displayName = 'SelectorCheckboxGroup';
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
- import React, { createContext } from 'react';
3
+ import React, { ForwardedRef, createContext, forwardRef } from 'react';
4
4
  import { useRadioGroup } from 'react-aria';
5
5
  import { useRadioGroupState } from 'react-stately';
6
6
 
7
7
  import { FUNCTION_NOT_IMPLEMENTED } from '../../../../constants/message.js';
8
8
  import { useBreakpoint } from '../../../../hook/breakpoints.hook.js';
9
+ import { FocusHandle, useFocusManagerRef } from '../../../../hook/focus-manager-ref.hook.js';
9
10
  import { resolveResponsiveVariant } from '../../../../utils/breakpoint.util.js';
10
11
  import { ErrorMessage, Hint, Label } from '../../../index.js';
11
12
 
@@ -73,15 +74,21 @@ export const SelectorRadioGroupContext = createContext<SelectorRadioGroupContext
73
74
  },
74
75
  });
75
76
 
76
- export function SelectorRadioGroup({
77
- className,
78
- children,
79
- label,
80
- orientation = 'vertical',
81
- errorMessage,
82
- description,
83
- ...props
84
- }: SelectorRadioGroupProps) {
77
+ function BaseSelectorRadioGroup(
78
+ {
79
+ className,
80
+ children,
81
+ label,
82
+ orientation = 'vertical',
83
+ errorMessage,
84
+ description,
85
+ ...props
86
+ }: SelectorRadioGroupProps,
87
+ // `ref.current.focus()` moves focus to the group's tab stop: the selected radio, or the first
88
+ // enabled radio when nothing is selected.
89
+ ref: ForwardedRef<FocusHandle>,
90
+ ) {
91
+ const wrapperRef = useFocusManagerRef(ref);
85
92
  const breakpoint = useBreakpoint();
86
93
  const resolvedOrientation = resolveResponsiveVariant(orientation, breakpoint);
87
94
  const state = useRadioGroupState({ ...props, errorMessage, label, orientation: resolvedOrientation });
@@ -95,7 +102,7 @@ export function SelectorRadioGroup({
95
102
  {label && <Label {...labelProps}>{label}</Label>}
96
103
  {description && <Hint {...descriptionProps}>{description}</Hint>}
97
104
  {errorMessage && state.isInvalid && <ErrorMessage {...errorMessageProps} message={errorMessage} />}
98
- <div className={styles({ className, orientation: resolvedOrientation })} {...radioGroupProps}>
105
+ <div className={styles({ className, orientation: resolvedOrientation })} {...radioGroupProps} ref={wrapperRef}>
99
106
  <SelectorRadioGroupContext.Provider value={{ state, orientation: resolvedOrientation }}>
100
107
  {children}
101
108
  </SelectorRadioGroupContext.Provider>
@@ -103,3 +110,6 @@ export function SelectorRadioGroup({
103
110
  </>
104
111
  );
105
112
  }
113
+
114
+ export const SelectorRadioGroup = forwardRef(BaseSelectorRadioGroup);
115
+ SelectorRadioGroup.displayName = 'SelectorRadioGroup';
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
 
3
- import React from 'react';
3
+ import React, { ForwardedRef, forwardRef } from 'react';
4
+
5
+ import { FocusHandle } from '../../hook/focus-manager-ref.hook.js';
4
6
 
5
7
  import {
6
8
  SelectorButtonGroup,
@@ -11,17 +13,26 @@ import {
11
13
  import { type SelectorProps } from './selector.types.js';
12
14
 
13
15
  // TODO: react-aria doesn't consider the click as focus. so the focusRing will appeared just with the tab.
14
- export function Selector(props: SelectorProps) {
16
+ function BaseSelector(
17
+ props: SelectorProps,
18
+ // `ref.current.focus()` (e.g. react-hook-form focusing a field with a validation error) moves
19
+ // focus to the first tabbable option (radio/checkbox input or button).
20
+ // The `link` type is navigation, not a form field, and does not receive the ref.
21
+ ref: ForwardedRef<FocusHandle>,
22
+ ) {
15
23
  if (props.type === 'checkbox') {
16
- return <SelectorCheckboxGroup {...props} />;
24
+ return <SelectorCheckboxGroup {...props} ref={ref} />;
17
25
  }
18
26
  if (props.type === 'radio') {
19
- return <SelectorRadioGroup {...props} />;
27
+ return <SelectorRadioGroup {...props} ref={ref} />;
20
28
  }
21
29
  if (props.type === 'button') {
22
- return <SelectorButtonGroup {...props} />;
30
+ return <SelectorButtonGroup {...props} ref={ref} />;
23
31
  }
24
32
  if (props.type === 'link') {
25
33
  return <SelectorLinkGroup {...props} />;
26
34
  }
27
35
  }
36
+
37
+ export const Selector = forwardRef(BaseSelector);
38
+ Selector.displayName = 'Selector';