@westpac/ui 1.16.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 (79) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/component-type.json +1 -1
  3. package/dist/components/button/button.component.d.ts +2 -17
  4. package/dist/components/button/button.component.js +9 -4
  5. package/dist/components/button/button.types.d.ts +16 -6
  6. package/dist/components/button-group/button-group.component.d.ts +2 -1
  7. package/dist/components/button-group/button-group.component.js +6 -3
  8. package/dist/components/checkbox-group/checkbox-group.component.d.ts +11 -2
  9. package/dist/components/checkbox-group/checkbox-group.component.js +10 -3
  10. package/dist/components/checkbox-group/components/checkbox-group-checkbox/checkbox-group-checkbox.component.d.ts +1 -1
  11. package/dist/components/checkbox-group/components/checkbox-group-checkbox/checkbox-group-checkbox.component.js +5 -6
  12. package/dist/components/date-picker/components/date-field/components/date-segment/date-segment.component.d.ts +6 -5
  13. package/dist/components/date-picker/components/date-field/components/date-segment/date-segment.component.js +6 -4
  14. package/dist/components/date-picker/components/date-field/date-field.component.d.ts +5 -5
  15. package/dist/components/date-picker/components/date-field/date-field.component.js +7 -3
  16. package/dist/components/date-picker/date-picker.component.d.ts +19 -2
  17. package/dist/components/date-picker/date-picker.component.js +10 -5
  18. package/dist/components/date-picker/date-picker.styles.js +5 -5
  19. package/dist/components/link/link.component.d.ts +4 -12
  20. package/dist/components/link/link.component.js +30 -7
  21. package/dist/components/link/link.types.d.ts +26 -3
  22. package/dist/components/modal/components/modal-dialog/components/modal-dialog-footer/modal-dialog-footer.component.d.ts +1 -1
  23. package/dist/components/modal/components/modal-dialog/components/modal-dialog-footer/modal-dialog-footer.component.js +14 -7
  24. package/dist/components/modal/components/modal-dialog/components/modal-dialog-footer/modal-dialog-footer.types.d.ts +11 -0
  25. package/dist/components/multi-select/components/multi-select-list-box/components/multi-select-option/multi-select-option.component.js +3 -7
  26. 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
  27. package/dist/components/multi-select/components/multi-select-popover/multi-select-popover.component.js +5 -2
  28. package/dist/components/multi-select/components/multi-select-searchbar/multi-select-searchbar.component.js +4 -7
  29. package/dist/components/multi-select/multi-select.component.d.ts +14 -3
  30. package/dist/components/multi-select/multi-select.component.js +10 -16
  31. package/dist/components/radio-group/components/radio-group-radio/radio-group-radio.component.d.ts +1 -1
  32. package/dist/components/radio-group/components/radio-group-radio/radio-group-radio.component.js +5 -6
  33. package/dist/components/radio-group/radio-group.component.d.ts +10 -2
  34. package/dist/components/radio-group/radio-group.component.js +10 -3
  35. package/dist/components/selector/components/selector-button-group/selector-button-group.component.d.ts +9 -2
  36. package/dist/components/selector/components/selector-button-group/selector-button-group.component.js +8 -3
  37. package/dist/components/selector/components/selector-checkbox-group/selector-checkbox-group.component.d.ts +6 -2
  38. package/dist/components/selector/components/selector-checkbox-group/selector-checkbox-group.component.js +8 -3
  39. package/dist/components/selector/components/selector-radio-group/selector-radio-group.component.d.ts +6 -2
  40. package/dist/components/selector/components/selector-radio-group/selector-radio-group.component.js +8 -3
  41. package/dist/components/selector/selector.component.d.ts +3 -1
  42. package/dist/components/selector/selector.component.js +16 -5
  43. package/dist/components/switch/switch.component.d.ts +8 -2
  44. package/dist/components/switch/switch.component.js +6 -4
  45. package/dist/css/westpac-ui.css +12 -12
  46. package/dist/css/westpac-ui.min.css +12 -12
  47. package/dist/hook/focus-manager-ref.hook.d.ts +29 -0
  48. package/dist/hook/focus-manager-ref.hook.js +23 -0
  49. package/dist/hook/index.d.ts +1 -0
  50. package/dist/index.d.ts +1 -0
  51. package/package.json +5 -3
  52. package/src/components/button/button.component.tsx +10 -9
  53. package/src/components/button/button.types.ts +21 -8
  54. package/src/components/button-group/button-group.component.tsx +26 -16
  55. package/src/components/checkbox-group/checkbox-group.component.tsx +24 -14
  56. package/src/components/checkbox-group/components/checkbox-group-checkbox/checkbox-group-checkbox.component.tsx +7 -7
  57. package/src/components/date-picker/components/date-field/components/date-segment/date-segment.component.tsx +10 -4
  58. package/src/components/date-picker/components/date-field/date-field.component.tsx +17 -3
  59. package/src/components/date-picker/date-picker.component.tsx +32 -21
  60. package/src/components/date-picker/date-picker.styles.ts +5 -5
  61. package/src/components/link/link.component.tsx +60 -12
  62. package/src/components/link/link.types.ts +31 -4
  63. package/src/components/modal/components/modal-dialog/components/modal-dialog-footer/modal-dialog-footer.component.tsx +16 -2
  64. package/src/components/modal/components/modal-dialog/components/modal-dialog-footer/modal-dialog-footer.types.ts +12 -0
  65. package/src/components/multi-select/components/multi-select-list-box/components/multi-select-option/multi-select-option.component.tsx +3 -5
  66. 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
  67. package/src/components/multi-select/components/multi-select-popover/multi-select-popover.component.tsx +8 -2
  68. package/src/components/multi-select/components/multi-select-searchbar/multi-select-searchbar.component.tsx +3 -6
  69. package/src/components/multi-select/multi-select.component.tsx +33 -28
  70. package/src/components/radio-group/components/radio-group-radio/radio-group-radio.component.tsx +10 -7
  71. package/src/components/radio-group/radio-group.component.tsx +24 -14
  72. package/src/components/selector/components/selector-button-group/selector-button-group.component.tsx +23 -14
  73. package/src/components/selector/components/selector-checkbox-group/selector-checkbox-group.component.tsx +12 -3
  74. package/src/components/selector/components/selector-radio-group/selector-radio-group.component.tsx +21 -11
  75. package/src/components/selector/selector.component.tsx +16 -5
  76. package/src/components/switch/switch.component.tsx +10 -12
  77. package/src/hook/focus-manager-ref.hook.ts +56 -0
  78. package/src/hook/index.ts +1 -0
  79. package/src/index.ts +1 -0
@@ -1,9 +1,9 @@
1
- import { AnchorHTMLAttributes, ReactNode } from 'react';
1
+ import { ReactNode } from 'react';
2
2
  import { AriaLinkOptions } from 'react-aria';
3
3
 
4
4
  import { IconProps } from '../icon/index.js';
5
5
 
6
- export type LinkProps = {
6
+ export type BaseLinkProps = {
7
7
  /**
8
8
  * Link text or component
9
9
  */
@@ -35,5 +35,32 @@ export type LinkProps = {
35
35
  * @default true
36
36
  */
37
37
  underline?: boolean;
38
- } & Omit<AriaLinkOptions, 'isDisabled' | 'elementType'> &
39
- AnchorHTMLAttributes<Element>;
38
+ /**
39
+ * Specifies DOM element rendered by a custom component
40
+ * Required when the custom component does not render an '<a>' element
41
+ * @default 'a'
42
+ */
43
+ elementType?: AriaLinkOptions['elementType'];
44
+ } & Omit<AriaLinkOptions, 'elementType' | 'href' | 'isDisabled'>;
45
+
46
+ export type LinkRef<C extends React.ElementType = 'a'> = React.ComponentRef<C>;
47
+
48
+ export type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>['ref'];
49
+
50
+ export type LinkProps<C extends React.ElementType = 'a'> = BaseLinkProps & {
51
+ /**
52
+ * Type to render
53
+ * @default a
54
+ */
55
+ tag?: C;
56
+ } & Omit<React.ComponentPropsWithoutRef<C>, keyof BaseLinkProps | 'tag'>;
57
+
58
+ export type LinkImplementationProps = BaseLinkProps & {
59
+ className?: string;
60
+ href?: unknown;
61
+ tag?: React.ElementType;
62
+ } & Record<string, unknown>;
63
+
64
+ export type LinkComponent = <C extends React.ElementType = 'a'>(
65
+ props: LinkProps<C> & { ref?: PolymorphicRef<C> },
66
+ ) => React.ReactElement | null;
@@ -12,8 +12,10 @@ import { type ModalDialogFooterProps } from './modal-dialog-footer.types.js';
12
12
 
13
13
  export function ModalDialogFooter({
14
14
  className,
15
+ primaryButtonProps,
15
16
  primaryLabel,
16
17
  primaryOnClick,
18
+ secondaryButtonProps,
17
19
  secondaryLabel,
18
20
  secondaryOnClick,
19
21
  ...props
@@ -29,11 +31,23 @@ export function ModalDialogFooter({
29
31
 
30
32
  return (
31
33
  <div className={styles.base({ className })} {...props}>
32
- <Button look="primary" size="large" className={styles.primaryBtn()} onClick={primaryOnClick}>
34
+ <Button
35
+ {...primaryButtonProps}
36
+ look={primaryButtonProps?.look ?? 'primary'}
37
+ size={primaryButtonProps?.size ?? 'large'}
38
+ className={styles.primaryBtn({ className: primaryButtonProps?.className })}
39
+ onClick={primaryOnClick}
40
+ >
33
41
  {primaryLabel}
34
42
  </Button>
35
43
  {secondaryLabel && (
36
- <Button look="link" size="large" className={styles.secondaryBtn()} onClick={secondaryOnClick}>
44
+ <Button
45
+ {...secondaryButtonProps}
46
+ look={secondaryButtonProps?.look ?? 'link'}
47
+ size={secondaryButtonProps?.size ?? 'large'}
48
+ className={styles.secondaryBtn({ className: secondaryButtonProps?.className })}
49
+ onClick={secondaryOnClick}
50
+ >
37
51
  {secondaryLabel}
38
52
  </Button>
39
53
  )}
@@ -1,10 +1,18 @@
1
1
  import { HTMLAttributes, ReactNode } from 'react';
2
2
 
3
+ import { type ButtonProps } from '../../../../../button/index.js';
4
+
5
+ type ModalFooterButtonProps = Omit<ButtonProps, 'children' | 'onClick'>;
6
+
3
7
  export type ModalDialogFooterProps = {
4
8
  /**
5
9
  * Footer content
6
10
  */
7
11
  children?: ReactNode;
12
+ /**
13
+ * Props passed to the primary button
14
+ */
15
+ primaryButtonProps?: ModalFooterButtonProps;
8
16
  /**
9
17
  * Label for primary button
10
18
  */
@@ -13,6 +21,10 @@ export type ModalDialogFooterProps = {
13
21
  * onClick for primary button
14
22
  */
15
23
  primaryOnClick: () => void;
24
+ /**
25
+ * Props passed to the secondary button
26
+ */
27
+ secondaryButtonProps?: ModalFooterButtonProps;
16
28
  /**
17
29
  * Label for secondary button
18
30
  */
@@ -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';