Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './segmented_control.module.css';
3 : import { classNames } from '../../utils/classnames.js';
4 : import { Tooltip } from './tooltip.js';
5 :
6 : // ==================== Types ====================
7 :
8 1 : export enum SegmentedControlSize {
9 1 : Tiny,
10 1 : Small,
11 1 : Medium,
12 1 : XSmall,
13 1 : XXSmall,
14 1 : }
15 :
16 1 : export enum SegmentedControlVariant {
17 : /** The default variant with an outer border and dividers between segments */
18 1 : Default,
19 : /** A borderless variant (no outer border, no dividers) — like the Button's invisible variant */
20 1 : Invisible,
21 1 : }
22 :
23 : export interface SegmentedControlProps {
24 : 'aria-label'?: string;
25 : 'aria-labelledby'?: string;
26 : 'aria-describedby'?: string;
27 : /** A stable hook for tests */
28 : 'data-testid'?: string;
29 : /** Whether the control fills the width of its parent */
30 : fullWidth?: boolean;
31 : /** The handler that gets called when a segment is selected */
32 : onChange?: (selectedIndex: number) => void;
33 : /** The size of the buttons */
34 : size?: SegmentedControlSize;
35 : /** The visual variant of the control */
36 : variant?: SegmentedControlVariant;
37 : className?: string;
38 : children?: React.ReactNode;
39 : }
40 :
41 : export interface SegmentedControlButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
42 : /** The visible label rendered in the button */
43 : children: string;
44 : /** Whether the segment is selected (for controlled components) */
45 : selected?: boolean;
46 : /** Whether the segment is selected initially (for uncontrolled components) */
47 : defaultSelected?: boolean;
48 : /** The leading visual icon */
49 : leadingVisual?: React.ElementType;
50 : /** Optional counter to display */
51 : count?: number | string;
52 : }
53 :
54 : export interface SegmentedControlIconButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label'> {
55 : 'aria-label': string;
56 : /** The icon element */
57 : icon: React.ElementType | React.ReactElement;
58 : /** Whether the segment is selected (for controlled components) */
59 : selected?: boolean;
60 : /** Whether the segment is selected initially (for uncontrolled components) */
61 : defaultSelected?: boolean;
62 : /** Supplementary description for tooltip */
63 : description?: string;
64 : }
65 :
66 : // ==================== Button Component ====================
67 :
68 1 : const SegmentedControlButton: React.FC<SegmentedControlButtonProps> = ({
69 1 : children,
70 1 : leadingVisual: LeadingVisual,
71 1 : selected,
72 1 : defaultSelected: _defaultSelected,
73 1 : count,
74 1 : className,
75 1 : disabled,
76 1 : ...props
77 1 : }) => {
78 1 : return (
79 1 : <li className={classNames(styles.item, { [styles.selected]: selected })} data-selected={selected ? '' : undefined}>
80 1 : <button
81 1 : className={classNames(styles.button, className)}
82 1 : type="button"
83 1 : aria-current={selected}
84 1 : aria-disabled={disabled || undefined}
85 1 : disabled={disabled}
86 1 : {...props}
87 : >
88 1 : <span className={styles.content}>
89 1 : {LeadingVisual && (
90 1 : <span className={styles.leading_visual}>
91 1 : <LeadingVisual />
92 1 : </span>
93 : )}
94 1 : <span className={styles.text} data-text={children}>
95 1 : {children}
96 1 : </span>
97 1 : {count !== undefined && (
98 1 : <span className={styles.counter}>{count}</span>
99 : )}
100 1 : </span>
101 1 : </button>
102 1 : </li>
103 : );
104 1 : };
105 :
106 : // ==================== IconButton Component ====================
107 :
108 1 : const SegmentedControlIconButton: React.FC<SegmentedControlIconButtonProps> = ({
109 1 : 'aria-label': ariaLabel,
110 1 : icon: Icon,
111 1 : selected,
112 1 : defaultSelected: _defaultSelected,
113 1 : description,
114 1 : className,
115 1 : disabled,
116 1 : ...props
117 1 : }) => {
118 1 : return (
119 1 : <li className={classNames(styles.item, styles.icon_item, { [styles.selected]: selected })} data-selected={selected ? '' : undefined}>
120 1 : <Tooltip
121 1 : text={description ?? ariaLabel}
122 1 : type={description ? undefined : 'label'}
123 : >
124 1 : <button
125 1 : className={classNames(styles.button, styles.icon_button, className)}
126 1 : type="button"
127 1 : aria-current={selected}
128 1 : aria-label={description ? ariaLabel : undefined}
129 1 : aria-disabled={disabled || undefined}
130 1 : disabled={disabled}
131 1 : {...props}
132 : >
133 1 : <span className={styles.content}>
134 1 : {React.isValidElement(Icon) ? Icon : <Icon />}
135 1 : </span>
136 1 : </button>
137 1 : </Tooltip>
138 1 : </li>
139 : );
140 1 : };
141 :
142 : // ==================== Root Component ====================
143 :
144 1 : const Root: React.FC<SegmentedControlProps> = ({
145 1 : 'aria-label': ariaLabel,
146 1 : 'aria-labelledby': ariaLabelledby,
147 1 : 'aria-describedby': ariaDescribedby,
148 1 : 'data-testid': testId,
149 1 : children,
150 1 : fullWidth,
151 1 : onChange,
152 1 : size,
153 1 : variant,
154 1 : className,
155 1 : }) => {
156 1 : const isUncontrolled = onChange === undefined ||
157 1 : React.Children.toArray(children).some(
158 1 : child => React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(child) &&
159 1 : child.props.defaultSelected !== undefined
160 1 : );
161 :
162 1 : const selectedSegments = React.Children.toArray(children).map(
163 1 : child =>
164 1 : React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(child) &&
165 1 : (child.props.defaultSelected || child.props.selected)
166 1 : );
167 :
168 1 : const hasSelectedButton = selectedSegments.some(isSelected => isSelected);
169 1 : const selectedIndexExternal = hasSelectedButton ? selectedSegments.indexOf(true) : 0;
170 1 : const [selectedIndexInternalState, setSelectedIndexInternalState] = React.useState<number>(selectedIndexExternal);
171 1 : const selectedIndex = isUncontrolled ? selectedIndexInternalState : selectedIndexExternal;
172 :
173 1 : if (!ariaLabel && !ariaLabelledby) {
174 1 : console.warn(
175 1 : 'Use the `aria-label` or `aria-labelledby` prop to provide an accessible label for assistive technologies'
176 1 : );
177 : }
178 :
179 1 : return (
180 1 : <ul
181 1 : aria-label={ariaLabel}
182 1 : aria-labelledby={ariaLabelledby}
183 1 : aria-describedby={ariaDescribedby}
184 1 : data-testid={testId}
185 1 : className={classNames(
186 1 : styles.segmented_control,
187 1 : {
188 1 : [styles.full_width]: fullWidth,
189 1 : [styles.size_tiny]: size === SegmentedControlSize.Tiny,
190 1 : [styles.size_small]: size === SegmentedControlSize.Small,
191 1 : [styles.size_xsmall]: size === SegmentedControlSize.XSmall,
192 1 : [styles.size_xxsmall]: size === SegmentedControlSize.XXSmall,
193 1 : [styles.variant_invisible]: variant === SegmentedControlVariant.Invisible,
194 1 : },
195 1 : className
196 1 : )}
197 : >
198 1 : {React.Children.map(children, (child, index) => {
199 1 : if (!React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(child)) {
200 1 : return null;
201 : }
202 :
203 1 : const sharedChildProps = {
204 1 : onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
205 : const isDisabled =
206 1 : child.props.disabled === true ||
207 1 : child.props['aria-disabled'] === 'true' ||
208 1 : child.props['aria-disabled'] === true;
209 :
210 1 : if (!isDisabled) {
211 1 : onChange?.(index);
212 1 : isUncontrolled && setSelectedIndexInternalState(index);
213 1 : child.props.onClick?.(event);
214 1 : }
215 1 : },
216 1 : selected: index === selectedIndex,
217 1 : };
218 :
219 1 : return React.cloneElement(child, sharedChildProps);
220 1 : })}
221 1 : </ul>
222 : );
223 1 : };
224 :
225 : // ==================== Export ====================
226 :
227 1 : export const SegmentedControl = Object.assign(Root, {
228 1 : Button: SegmentedControlButton,
229 1 : IconButton: SegmentedControlIconButton,
230 1 : });
|