Skip to content

Commit 25c9d16

Browse files
Segmented control (#6864)
* initialize segemented file * add some styling * add track styling and hcm * fix hcm * remove unused imports * export properly, add font weight * default select first item, fix hcm * remove trackStyle prop * remove more trackStyle stuff, update css * update stories * fix types, add jsdoc comments * update story again oops * fix typo * fix types * remove space * add translation * update internal context prop name, add transition * remove transition, fix keyboard in rtl * update string formatter * initialize animations (it's very janky) * require aria label, remove translation * fix lint * fix ts * update props * fix spacing * add aria label to stories * update animation using flip, fix selectedValue when radio group is disabled * keep speed the same * fix lint and small other fixes * add icon only spacing * update segmented control item prop name * fix lint * small fixes * fix alignment with text and icons, fix wiggling for ltr * fix wiggling in rtl languages * update icon only buttons * revert useRadioGroupState change * revert changes to registration * fix lint --------- Co-authored-by: Robert Snow <[email protected]>
1 parent 2bd6e20 commit 25c9d16

File tree

3 files changed

+344
-0
lines changed

3 files changed

+344
-0
lines changed
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/*
2+
* Copyright 2024 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {centerBaseline} from './CenterBaseline';
14+
import {ContextValue, DEFAULT_SLOT, Provider, TextContext as RACTextContext, Radio, RadioGroup, RadioGroupProps, RadioGroupStateContext, RadioProps} from 'react-aria-components';
15+
import {createContext, forwardRef, ReactNode, RefObject, useCallback, useContext, useRef} from 'react';
16+
import {DOMRef, DOMRefValue, FocusableRef} from '@react-types/shared';
17+
import {focusRing, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'};
18+
import {IconContext} from './Icon';
19+
import {pressScale} from './pressScale';
20+
import {size, style} from '../style/spectrum-theme' with {type: 'macro'};
21+
import {Text, TextContext} from './Content';
22+
import {useDOMRef, useFocusableRef} from '@react-spectrum/utils';
23+
import {useLayoutEffect} from '@react-aria/utils';
24+
import {useSpectrumContextProps} from './useSpectrumContextProps';
25+
26+
export interface SegmentedControlProps extends Omit<RadioGroupProps, 'isReadOnly' | 'name' | 'isRequired' | 'isInvalid' | 'validate' | 'validationBehavior' | 'children' | 'className' | 'style' | 'aria-label' | 'orientation'>, StyleProps{
27+
/**
28+
* The content to display in the segmented control.
29+
*/
30+
children: ReactNode,
31+
/**
32+
* Whether the segmented control is disabled.
33+
*/
34+
isDisabled?: boolean,
35+
/**
36+
* Defines a string value that labels the current element.
37+
*/
38+
'aria-label': string
39+
}
40+
export interface SegmentedControlItemProps extends Omit<RadioProps, 'children' | 'className' | 'style' | 'onHoverStart' | 'onHoverEnd' | 'onHoverChange'>, StyleProps {
41+
/**
42+
* The content to display in the control item.
43+
*/
44+
children: ReactNode
45+
}
46+
47+
export const SegmentedControlContext = createContext<ContextValue<SegmentedControlProps, DOMRefValue<HTMLDivElement>>>(null);
48+
49+
const segmentedControl = style<{size: string}>({
50+
font: 'control',
51+
display: 'flex',
52+
backgroundColor: 'gray-100',
53+
borderRadius: 'lg',
54+
width: 'full'
55+
}, getAllowedOverrides());
56+
57+
const controlItem = style({
58+
position: 'relative',
59+
display: 'flex',
60+
forcedColorAdjust: 'none',
61+
color: {
62+
default: 'gray-700',
63+
isHovered: 'neutral-subdued',
64+
isSelected: 'neutral',
65+
isDisabled: 'disabled',
66+
forcedColors: {
67+
default: 'ButtonText',
68+
isDisabled: 'GrayText',
69+
isSelected: 'HighlightText'
70+
}
71+
},
72+
// TODO: update this padding for icon-only items when we introduce the non-track style back
73+
paddingX: {
74+
default: 'edge-to-text',
75+
':has([slot=icon]:only-child)': size(6)
76+
},
77+
height: 32,
78+
alignItems: 'center',
79+
flexBasis: 0,
80+
flexGrow: 1,
81+
flexShrink: 0,
82+
justifyContent: 'center',
83+
whiteSpace: 'nowrap',
84+
disableTapHighlight: true,
85+
'--iconPrimary': {
86+
type: 'fill',
87+
value: 'currentColor'
88+
}
89+
}, getAllowedOverrides());
90+
91+
const slider = style({
92+
...focusRing(),
93+
backgroundColor: 'gray-25',
94+
left: 0,
95+
width: 'full',
96+
height: 'full',
97+
position: 'absolute',
98+
boxSizing: 'border-box',
99+
borderStyle: 'solid',
100+
borderWidth: 2,
101+
borderColor: {
102+
default: 'gray-900',
103+
isDisabled: 'disabled'
104+
},
105+
borderRadius: 'lg'
106+
});
107+
108+
interface InternalSegmentedControlContextProps {
109+
register?: (value: string, isDisabled?: boolean) => void,
110+
prevRef?: RefObject<DOMRect | null>,
111+
currentSelectedRef?: RefObject<HTMLDivElement | null>
112+
}
113+
114+
interface DefaultSelectionTrackProps {
115+
defaultValue?: string | null,
116+
value?: string | null,
117+
children?: ReactNode,
118+
prevRef: RefObject<DOMRect | null>,
119+
currentSelectedRef: RefObject<HTMLDivElement | null>
120+
}
121+
122+
const InternalSegmentedControlContext = createContext<InternalSegmentedControlContextProps>({});
123+
124+
function SegmentedControl(props: SegmentedControlProps, ref: DOMRef<HTMLDivElement>) {
125+
[props, ref] = useSpectrumContextProps(props, ref, SegmentedControlContext);
126+
let {
127+
defaultValue,
128+
value
129+
} = props;
130+
let domRef = useDOMRef(ref);
131+
132+
let prevRef = useRef<DOMRect>(null);
133+
let currentSelectedRef = useRef<HTMLDivElement>(null);
134+
135+
let onChange = (value: string) => {
136+
if (currentSelectedRef.current) {
137+
prevRef.current = currentSelectedRef?.current.getBoundingClientRect();
138+
}
139+
140+
if (props.onChange) {
141+
props.onChange(value);
142+
}
143+
};
144+
145+
return (
146+
<RadioGroup
147+
{...props}
148+
ref={domRef}
149+
orientation="horizontal"
150+
style={props.UNSAFE_style}
151+
onChange={onChange}
152+
className={(props.UNSAFE_className || '') + segmentedControl({size: 'M'}, props.styles)}
153+
aria-label={props['aria-label']}>
154+
<DefaultSelectionTracker defaultValue={defaultValue} value={value} prevRef={prevRef} currentSelectedRef={currentSelectedRef}>
155+
{props.children}
156+
</DefaultSelectionTracker>
157+
</RadioGroup>
158+
);
159+
}
160+
161+
function DefaultSelectionTracker(props: DefaultSelectionTrackProps) {
162+
let state = useContext(RadioGroupStateContext);
163+
let isRegistered = useRef(!(props.defaultValue == null && props.value == null));
164+
165+
// default select the first available item
166+
let register = useCallback((value: string) => {
167+
if (state && !isRegistered.current) {
168+
isRegistered.current = true;
169+
state.setSelectedValue(value);
170+
}
171+
}, []);
172+
173+
return (
174+
<Provider
175+
values={[
176+
[InternalSegmentedControlContext, {register: register, prevRef: props.prevRef, currentSelectedRef: props.currentSelectedRef}]
177+
]}>
178+
{props.children}
179+
</Provider>
180+
);
181+
}
182+
183+
function SegmentedControlItem(props: SegmentedControlItemProps, ref: FocusableRef<HTMLLabelElement>) {
184+
let inputRef = useRef<HTMLInputElement>(null);
185+
let domRef = useFocusableRef(ref, inputRef);
186+
let divRef = useRef<HTMLDivElement>(null);
187+
let {register, prevRef, currentSelectedRef} = useContext(InternalSegmentedControlContext);
188+
let state = useContext(RadioGroupStateContext);
189+
let isSelected = props.value === state?.selectedValue;
190+
// do not apply animation if a user has the prefers-reduced-motion setting
191+
let isReduced = false;
192+
if (window?.matchMedia) {
193+
isReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
194+
}
195+
196+
useLayoutEffect(() => {
197+
register?.(props.value);
198+
}, []);
199+
200+
useLayoutEffect(() => {
201+
if (isSelected && prevRef?.current && currentSelectedRef?.current && !isReduced) {
202+
let currentItem = currentSelectedRef?.current.getBoundingClientRect();
203+
204+
let deltaX = prevRef?.current.left - currentItem?.left;
205+
206+
currentSelectedRef.current.animate(
207+
[
208+
{transform: `translateX(${deltaX}px)`, width: `${prevRef?.current.width}px`},
209+
{transform: 'translateX(0px)', width: `${currentItem.width}px`}
210+
],
211+
{
212+
duration: 200,
213+
easing: 'ease-out'
214+
}
215+
);
216+
217+
prevRef.current = null;
218+
}
219+
}, [isSelected]);
220+
221+
return (
222+
<Radio
223+
{...props}
224+
ref={domRef}
225+
inputRef={inputRef}
226+
style={props.UNSAFE_style}
227+
className={renderProps => (props.UNSAFE_className || '') + controlItem({...renderProps}, props.styles)} >
228+
{({isSelected, isFocusVisible, isPressed, isDisabled}) => (
229+
<>
230+
{isSelected && <div className={slider({isFocusVisible, isDisabled})} ref={currentSelectedRef} />}
231+
<Provider
232+
values={[
233+
[IconContext, {
234+
render: centerBaseline({slot: 'icon', styles: style({order: 0, flexShrink: 0})})
235+
}],
236+
[RACTextContext, {slots: {[DEFAULT_SLOT]: {}}}],
237+
[TextContext, {styles: style({order: 1, truncate: true})}]
238+
]}>
239+
<div ref={divRef} style={pressScale(divRef)({isPressed})} className={style({zIndex: 1, display: 'flex', gap: 'text-to-visual', transition: 'default', alignItems: 'center'})}>
240+
{typeof props.children === 'string' ? <Text>{props.children}</Text> : props.children}
241+
</div>
242+
</Provider>
243+
</>
244+
)
245+
}
246+
</Radio>
247+
);
248+
}
249+
250+
/**
251+
* A control items represents an individual control within a segmented control.
252+
*/
253+
const _SegmentedControlItem = /*#__PURE__*/ forwardRef(SegmentedControlItem);
254+
export {_SegmentedControlItem as SegmentedControlItem};
255+
256+
/**
257+
* A segmented control is a mutually exclusive group of buttons, with or without a track.
258+
*/
259+
const _SegmentedControl = /*#__PURE__*/ forwardRef(SegmentedControl);
260+
export {_SegmentedControl as SegmentedControl};

packages/@react-spectrum/s2/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export {Radio} from './Radio';
5555
export {RadioGroup, RadioGroupContext} from './RadioGroup';
5656
export {RangeSlider, RangeSliderContext} from './RangeSlider';
5757
export {SearchField, SearchFieldContext} from './SearchField';
58+
export {SegmentedControl, SegmentedControlItem, SegmentedControlContext} from './SegmentedControl';
5859
export {Slider, SliderContext} from './Slider';
5960
export {Skeleton, useIsSkeleton} from './Skeleton';
6061
export {SkeletonCollection} from './SkeletonCollection';
@@ -109,6 +110,7 @@ export type {ProviderProps} from './Provider';
109110
export type {RadioProps} from './Radio';
110111
export type {RadioGroupProps} from './RadioGroup';
111112
export type {SearchFieldProps} from './SearchField';
113+
export type {SegmentedControlProps, SegmentedControlItemProps} from './SegmentedControl';
112114
export type {SliderProps} from './Slider';
113115
export type {RangeSliderProps} from './RangeSlider';
114116
export type {SkeletonProps} from './Skeleton';
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright 2024 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import AlignBottom from '../s2wf-icons/S2_Icon_AlignBottom_20_N.svg';
14+
import AlignCenter from '../s2wf-icons/S2_Icon_AlignCenter_20_N.svg';
15+
import AlignLeft from '../s2wf-icons/S2_Icon_AlignLeft_20_N.svg';
16+
import ListBulleted from '../s2wf-icons/S2_Icon_ListBulleted_20_N.svg';
17+
import ListMultiSelect from '../s2wf-icons/S2_Icon_ListMultiSelect_20_N.svg';
18+
import ListNumbered from '../s2wf-icons/S2_Icon_ListNumbered_20_N.svg';
19+
import type {Meta} from '@storybook/react';
20+
import {SegmentedControl, SegmentedControlItem, Text} from '../src';
21+
import {style} from '../style/spectrum-theme' with {type: 'macro'};
22+
23+
24+
const meta: Meta<typeof SegmentedControl> = {
25+
component: SegmentedControl,
26+
parameters: {
27+
layout: 'centered'
28+
},
29+
tags: ['autodocs']
30+
};
31+
32+
export default meta;
33+
34+
export const Example = (args: any) => (
35+
<SegmentedControl {...args}>
36+
<SegmentedControlItem value="day">Day</SegmentedControlItem>
37+
<SegmentedControlItem value="week">Week</SegmentedControlItem>
38+
<SegmentedControlItem value="month">Month</SegmentedControlItem>
39+
<SegmentedControlItem value="year">Year</SegmentedControlItem>
40+
</SegmentedControl>
41+
);
42+
43+
Example.args = {
44+
'aria-label': 'Time granularity'
45+
};
46+
47+
export const WithIcons = (args: any) => (
48+
<SegmentedControl {...args}>
49+
<SegmentedControlItem value="unordered"><ListBulleted /><Text>Unordered</Text></SegmentedControlItem>
50+
<SegmentedControlItem value="ordered"><ListNumbered /><Text>Ordered</Text></SegmentedControlItem>
51+
<SegmentedControlItem value="task list"><ListMultiSelect /><Text>Task List</Text></SegmentedControlItem>
52+
</SegmentedControl>
53+
);
54+
55+
WithIcons.args = {
56+
'aria-label': 'List organization'
57+
};
58+
59+
export const OnlyIcons = (args: any) => (
60+
<SegmentedControl {...args}>
61+
<SegmentedControlItem value="align bottom"><AlignBottom /></SegmentedControlItem>
62+
<SegmentedControlItem value="align center"><AlignCenter /></SegmentedControlItem>
63+
<SegmentedControlItem value="align left"><AlignLeft /></SegmentedControlItem>
64+
</SegmentedControl>
65+
);
66+
67+
OnlyIcons.args = {
68+
'aria-label': 'Text alignment'
69+
};
70+
71+
export const CustomWidth = (args: any) => (
72+
<SegmentedControl {...args} styles={style({width: '[400px]'})}>
73+
<SegmentedControlItem value="overview">Overview</SegmentedControlItem>
74+
<SegmentedControlItem value="specs">Specs</SegmentedControlItem>
75+
<SegmentedControlItem value="guidelines">Guidelines</SegmentedControlItem>
76+
<SegmentedControlItem value="accessibility">Accessibility</SegmentedControlItem>
77+
</SegmentedControl>
78+
);
79+
80+
CustomWidth.args = {
81+
'aria-label': 'Getting started'
82+
};

0 commit comments

Comments
 (0)