Skip to content

Commit c12a3b4

Browse files
authored
feat: Add support for animated selection indicators (#8876)
* feat: Add support for animated selection indicators * Add SelectionIndicator support for all other selectable components
1 parent 8fc37fd commit c12a3b4

40 files changed

+799
-255
lines changed

packages/@react-spectrum/s2/src/SegmentedControl.tsx

Lines changed: 15 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@
1313
import {AriaLabelingProps, DOMRef, DOMRefValue, FocusableRef, Key} from '@react-types/shared';
1414
import {baseColor, focusRing, style} from '../style' with {type: 'macro'};
1515
import {centerBaseline} from './CenterBaseline';
16-
import {ContextValue, DEFAULT_SLOT, Provider, TextContext as RACTextContext, SlotProps, ToggleButton, ToggleButtonGroup, ToggleButtonRenderProps, ToggleGroupStateContext} from 'react-aria-components';
16+
import {ContextValue, DEFAULT_SLOT, Provider, TextContext as RACTextContext, SelectionIndicator, SlotProps, ToggleButton, ToggleButtonGroup, ToggleButtonRenderProps, ToggleGroupStateContext} from 'react-aria-components';
1717
import {control, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'};
18-
import {createContext, forwardRef, ReactNode, RefObject, useCallback, useContext, useRef} from 'react';
18+
import {createContext, forwardRef, ReactNode, useCallback, useContext, useRef} from 'react';
1919
import {IconContext} from './Icon';
2020
import {pressScale} from './pressScale';
2121
import {Text, TextContext} from './Content';
22-
import {useDOMRef, useFocusableRef, useMediaQuery} from '@react-spectrum/utils';
22+
import {useDOMRef, useFocusableRef} from '@react-spectrum/utils';
2323
import {useLayoutEffect} from '@react-aria/utils';
2424
import {useSpectrumContextProps} from './useSpectrumContextProps';
2525

@@ -113,6 +113,13 @@ const slider = style<{isDisabled: boolean}>({
113113
left: 0,
114114
width: 'full',
115115
height: 'full',
116+
contain: 'strict',
117+
transition: {
118+
default: '[translate,width]',
119+
'@media (prefers-reduced-motion: reduce)': 'none'
120+
},
121+
transitionDuration: 200,
122+
transitionTimingFunction: 'out',
116123
position: 'absolute',
117124
boxSizing: 'border-box',
118125
borderStyle: 'solid',
@@ -130,17 +137,13 @@ const slider = style<{isDisabled: boolean}>({
130137

131138
interface InternalSegmentedControlContextProps {
132139
register?: (value: Key, isDisabled?: boolean) => void,
133-
prevRef?: RefObject<DOMRect | null>,
134-
currentSelectedRef?: RefObject<HTMLDivElement | null>,
135140
isJustified?: boolean
136141
}
137142

138143
interface DefaultSelectionTrackProps {
139144
defaultValue?: Key | null,
140145
value?: Key | null,
141146
children: ReactNode,
142-
prevRef: RefObject<DOMRect | null>,
143-
currentSelectedRef: RefObject<HTMLDivElement | null>,
144147
isJustified?: boolean
145148
}
146149

@@ -158,14 +161,7 @@ export const SegmentedControl = /*#__PURE__*/ forwardRef(function SegmentedContr
158161
} = props;
159162
let domRef = useDOMRef(ref);
160163

161-
let prevRef = useRef<DOMRect>(null);
162-
let currentSelectedRef = useRef<HTMLDivElement>(null);
163-
164164
let onChange = (values: Set<Key>) => {
165-
if (currentSelectedRef.current) {
166-
prevRef.current = currentSelectedRef?.current.getBoundingClientRect();
167-
}
168-
169165
if (onSelectionChange) {
170166
let firstKey = values.values().next().value;
171167
if (firstKey != null) {
@@ -186,7 +182,7 @@ export const SegmentedControl = /*#__PURE__*/ forwardRef(function SegmentedContr
186182
onSelectionChange={onChange}
187183
className={(props.UNSAFE_className || '') + segmentedControl(null, props.styles)}
188184
aria-label={props['aria-label']}>
189-
<DefaultSelectionTracker defaultValue={defaultSelectedKey} value={selectedKey} prevRef={prevRef} currentSelectedRef={currentSelectedRef} isJustified={props.isJustified}>
185+
<DefaultSelectionTracker defaultValue={defaultSelectedKey} value={selectedKey} isJustified={props.isJustified}>
190186
{props.children}
191187
</DefaultSelectionTracker>
192188
</ToggleButtonGroup>
@@ -209,7 +205,7 @@ function DefaultSelectionTracker(props: DefaultSelectionTrackProps) {
209205
return (
210206
<Provider
211207
values={[
212-
[InternalSegmentedControlContext, {register: register, prevRef: props.prevRef, currentSelectedRef: props.currentSelectedRef, isJustified: props.isJustified}]
208+
[InternalSegmentedControlContext, {register: register, isJustified: props.isJustified}]
213209
]}>
214210
{props.children}
215211
</Provider>
@@ -222,46 +218,21 @@ function DefaultSelectionTracker(props: DefaultSelectionTrackProps) {
222218
export const SegmentedControlItem = /*#__PURE__*/ forwardRef(function SegmentedControlItem(props: SegmentedControlItemProps, ref: FocusableRef<HTMLButtonElement>) {
223219
let domRef = useFocusableRef(ref);
224220
let divRef = useRef<HTMLDivElement>(null);
225-
let {register, prevRef, currentSelectedRef, isJustified} = useContext(InternalSegmentedControlContext);
226-
let state = useContext(ToggleGroupStateContext);
227-
let isSelected = state?.selectedKeys.has(props.id);
228-
// do not apply animation if a user has the prefers-reduced-motion setting
229-
let reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
221+
let {register, isJustified} = useContext(InternalSegmentedControlContext);
230222

231223
useLayoutEffect(() => {
232224
register?.(props.id);
233225
}, [register, props.id]);
234226

235-
useLayoutEffect(() => {
236-
if (isSelected && prevRef?.current && currentSelectedRef?.current && !reduceMotion) {
237-
let currentItem = currentSelectedRef?.current.getBoundingClientRect();
238-
239-
let deltaX = prevRef?.current.left - currentItem?.left;
240-
241-
currentSelectedRef.current.animate(
242-
[
243-
{transform: `translateX(${deltaX}px)`, width: `${prevRef?.current.width}px`},
244-
{transform: 'translateX(0px)', width: `${currentItem.width}px`}
245-
],
246-
{
247-
duration: 200,
248-
easing: 'ease-out'
249-
}
250-
);
251-
252-
prevRef.current = null;
253-
}
254-
}, [isSelected, reduceMotion, prevRef, currentSelectedRef]);
255-
256227
return (
257228
<ToggleButton
258229
{...props}
259230
ref={domRef}
260231
style={props.UNSAFE_style}
261232
className={renderProps => (props.UNSAFE_className || '') + controlItem({...renderProps, isJustified}, props.styles)} >
262-
{({isSelected, isPressed, isDisabled}) => (
233+
{({isPressed, isDisabled}) => (
263234
<>
264-
{isSelected && <div className={slider({isDisabled})} ref={currentSelectedRef} />}
235+
<SelectionIndicator className={slider({isDisabled})} />
265236
<Provider
266237
values={[
267238
[IconContext, {

packages/@react-spectrum/s2/src/Tabs.tsx

Lines changed: 33 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
Tab as RACTab,
2323
TabList as RACTabList,
2424
Tabs as RACTabs,
25+
SelectionIndicator,
2526
TabListStateContext,
2627
TabRenderProps
2728
} from 'react-aria-components';
@@ -36,7 +37,7 @@ import {inertValue, useEffectEvent, useId, useLabels, useLayoutEffect, useResize
3637
import {Picker, PickerItem} from './TabsPicker';
3738
import {Text, TextContext} from './Content';
3839
import {useControlledState} from '@react-stately/utils';
39-
import {useDOMRef, useMediaQuery} from '@react-spectrum/utils';
40+
import {useDOMRef} from '@react-spectrum/utils';
4041
import {useHasTabbableChild} from '@react-aria/focus';
4142
import {useLocale} from '@react-aria/i18n';
4243
import {useSpectrumContextProps} from './useSpectrumContextProps';
@@ -79,7 +80,6 @@ export interface TabPanelProps extends Omit<AriaTabPanelProps, 'children' | 'sty
7980
export const TabsContext = createContext<ContextValue<Partial<TabsProps>, DOMRefValue<HTMLDivElement>>>(null);
8081
const InternalTabsContext = createContext<Partial<TabsProps> & {
8182
tablistRef?: RefObject<HTMLDivElement | null>,
82-
prevRef?: RefObject<DOMRect | null>,
8383
selectedKey?: Key | null
8484
}>({});
8585

@@ -133,14 +133,6 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef<HTMLD
133133
}
134134

135135
let tablistRef = useRef<HTMLDivElement | null>(null);
136-
let prevRef = useRef<DOMRect | null>(null);
137-
138-
let onChange = useEffectEvent((val: Key) => {
139-
if (tablistRef.current) {
140-
prevRef.current = tablistRef.current.querySelector('[role=tab][data-selected=true]')?.getBoundingClientRect() ?? null;
141-
}
142-
setValue(val);
143-
});
144136

145137
return (
146138
<Provider
@@ -152,8 +144,7 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef<HTMLD
152144
disabledKeys,
153145
selectedKey: value,
154146
tablistRef,
155-
prevRef,
156-
onSelectionChange: onChange,
147+
onSelectionChange: setValue,
157148
labelBehavior,
158149
'aria-label': props['aria-label'],
159150
'aria-labelledby': props['aria-labelledby']
@@ -164,7 +155,7 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef<HTMLD
164155
<CollapsingTabs
165156
{...props}
166157
selectedKey={value}
167-
onSelectionChange={onChange}
158+
onSelectionChange={setValue}
168159
collection={collection}
169160
containerRef={domRef} />
170161
)}
@@ -294,6 +285,13 @@ const selectedIndicator = style<{isDisabled: boolean, orientation?: Orientation}
294285
vertical: '[2px]'
295286
}
296287
},
288+
contain: 'strict',
289+
transition: {
290+
default: '[translate,width,height]',
291+
'@media (prefers-reduced-motion: reduce)': 'none'
292+
},
293+
transitionDuration: 200,
294+
transitionTimingFunction: 'out',
297295
bottom: {
298296
default: 0
299297
},
@@ -374,7 +372,7 @@ const icon = style({
374372
});
375373

376374
export function Tab(props: TabProps): ReactNode {
377-
let {density, orientation, labelBehavior, prevRef} = useContext(InternalTabsContext) ?? {};
375+
let {density, orientation, labelBehavior} = useContext(InternalTabsContext) ?? {};
378376

379377
let contentId = useId();
380378
let ariaLabelledBy = props['aria-labelledby'] || '';
@@ -390,7 +388,6 @@ export function Tab(props: TabProps): ReactNode {
390388
{({
391389
// @ts-ignore
392390
isMenu,
393-
isSelected,
394391
isDisabled
395392
}) => {
396393
if (isMenu) {
@@ -417,10 +414,8 @@ export function Tab(props: TabProps): ReactNode {
417414
}]
418415
]}>
419416
<TabInner
420-
isSelected={isSelected}
421417
orientation={orientation!}
422-
isDisabled={isDisabled}
423-
prevRef={prevRef}>
418+
isDisabled={isDisabled}>
424419
{typeof props.children === 'string' ? <Text>{props.children}</Text> : props.children}
425420
</TabInner>
426421
</Provider>
@@ -431,53 +426,17 @@ export function Tab(props: TabProps): ReactNode {
431426
);
432427
}
433428

434-
function TabInner({isSelected, isDisabled, orientation, children, prevRef}: {
435-
isSelected: boolean,
429+
function TabInner({isDisabled, orientation, children}: {
436430
isDisabled: boolean,
437431
orientation: Orientation,
438-
children: ReactNode,
439-
prevRef?: RefObject<DOMRect | null>
432+
children: ReactNode
440433
}) {
441-
let reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
442434
let ref = useRef<HTMLDivElement | null>(null);
443-
444-
useLayoutEffect(() => {
445-
if (isSelected && prevRef?.current && ref?.current && !reduceMotion) {
446-
let currentItem = ref?.current.getBoundingClientRect();
447-
448-
if (orientation === 'horizontal') {
449-
let deltaX = prevRef.current.left - currentItem.left;
450-
ref.current.animate(
451-
[
452-
{transform: `translateX(${deltaX}px)`, width: `${prevRef.current.width}px`},
453-
{transform: 'translateX(0px)', width: '100%'}
454-
],
455-
{
456-
duration: 200,
457-
easing: 'ease-out'
458-
}
459-
);
460-
} else {
461-
let deltaY = prevRef.current.top - currentItem.top;
462-
ref.current.animate(
463-
[
464-
{transform: `translateY(${deltaY}px)`, height: `${prevRef.current.height}px`},
465-
{transform: 'translateY(0px)', height: '100%'}
466-
],
467-
{
468-
duration: 200,
469-
easing: 'ease-out'
470-
}
471-
);
472-
}
473-
474-
prevRef.current = null;
475-
}
476-
}, [isSelected, reduceMotion, prevRef, orientation]);
435+
let isHidden = useContext(HiddenTabsContext);
477436

478437
return (
479438
<>
480-
{isSelected && <div ref={ref} className={selectedIndicator({isDisabled, orientation})} />}
439+
{!isHidden && <SelectionIndicator ref={ref} className={selectedIndicator({isDisabled, orientation})} />}
481440
{children}
482441
</>
483442
);
@@ -549,6 +508,8 @@ function isEveryTabDisabled<T>(collection: Collection<Node<T>> | undefined, disa
549508
return false;
550509
}
551510

511+
const HiddenTabsContext = createContext(false);
512+
552513
let HiddenTabs = function (props: {
553514
listRef: RefObject<HTMLDivElement | null>,
554515
items: Array<Node<any>>,
@@ -573,18 +534,20 @@ let HiddenTabs = function (props: {
573534
overflow: 'hidden',
574535
opacity: 0
575536
})}>
576-
{items.map((item) => {
577-
// pull off individual props as an allow list, don't want refs or other props getting through
578-
return (
579-
<div
580-
data-hidden-tab
581-
style={item.props.UNSAFE_style}
582-
key={item.key}
583-
className={item.props.className({size, density})}>
584-
{item.props.children({size, density})}
585-
</div>
586-
);
587-
})}
537+
<HiddenTabsContext.Provider value>
538+
{items.map((item) => {
539+
// pull off individual props as an allow list, don't want refs or other props getting through
540+
return (
541+
<div
542+
data-hidden-tab
543+
style={item.props.UNSAFE_style}
544+
key={item.key}
545+
className={item.props.className({size, density})}>
546+
{item.props.children({size, density})}
547+
</div>
548+
);
549+
})}
550+
</HiddenTabsContext.Provider>
588551
</div>
589552
);
590553
};

packages/dev/s2-docs/pages/react-aria/GridList.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,11 +263,11 @@ function Example() {
263263

264264
<Anatomy role="img" aria-label="Anatomy diagram of a list container, consisting of multiple list items. Each list item contains a drag button, a selection checkbox, an icon, a title, and a description." />
265265

266-
```tsx links={{GridList: '#gridlist', GridListItem: '#gridlistitem', GridListLoadMoreItem: '#gridlistloadmoreitem', Button: 'Button.html', Checkbox: 'Checkbox.html'}}
266+
```tsx links={{GridList: '#gridlist', GridListItem: '#gridlistitem', GridListLoadMoreItem: '#gridlistloadmoreitem', Button: 'Button.html', Checkbox: 'Checkbox.html', SelectionIndicator: 'selection.html#animated-selectionindicator'}}
267267
<GridList>
268268
<GridListItem>
269269
<Button slot="drag" />
270-
<Checkbox slot="selection" />
270+
<Checkbox slot="selection" /> or <SelectionIndicator />
271271
</GridListItem>
272272
<GridListLoadMoreItem />
273273
</GridList>

packages/dev/s2-docs/pages/react-aria/ListBox.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,11 +372,12 @@ function Example() {
372372

373373
<Anatomy role="img" aria-label="Anatomy diagram of a list container, consisting of multiple list items. Each list item contains a label and description. The items are grouped into a section with a header." />
374374

375-
```tsx links={{ListBox: '#listbox', ListBoxItem: '#listboxitem', ListBoxSection: '#listboxsection', ListBoxLoadMoreItem: '#listboxloadmoreitem'}}
375+
```tsx links={{ListBox: '#listbox', ListBoxItem: '#listboxitem', ListBoxSection: '#listboxsection', ListBoxLoadMoreItem: '#listboxloadmoreitem', SelectionIndicator: 'selection.html#animated-selectionindicator'}}
376376
<ListBox>
377377
<ListBoxItem>
378378
<Text slot="label" />
379379
<Text slot="description" />
380+
<SelectionIndicator />
380381
</ListBoxItem>
381382
<ListBoxSection>
382383
<Header />

packages/dev/s2-docs/pages/react-aria/Menu.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ import {MenuTrigger, Menu, MenuItem, Button, Popover} from 'react-aria-component
469469

470470
<Anatomy />
471471

472-
```tsx links={{MenuTrigger: '#menutrigger', Button: 'Button.html', Popover: 'Popover.html', Menu: '#menu', MenuItem: '#menuitem', Separator: 'Separator.html', MenuSection: '#menusection', SubmenuTrigger: '#submenutrigger'}}
472+
```tsx links={{MenuTrigger: '#menutrigger', Button: 'Button.html', Popover: 'Popover.html', Menu: '#menu', MenuItem: '#menuitem', Separator: 'Separator.html', MenuSection: '#menusection', SubmenuTrigger: '#submenutrigger', SelectionIndicator: 'selection.html#animated-selectionindicator'}}
473473
<MenuTrigger>
474474
<Button />
475475
<Popover>
@@ -478,6 +478,7 @@ import {MenuTrigger, Menu, MenuItem, Button, Popover} from 'react-aria-component
478478
<Text slot="label" />
479479
<Text slot="description" />
480480
<Keyboard />
481+
<SelectionIndicator />
481482
</MenuItem>
482483
<Separator />
483484
<MenuSection>

packages/dev/s2-docs/pages/react-aria/RadioGroup.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,12 @@ import {Form} from 'react-aria-components';
9393

9494
<Anatomy />
9595

96-
```tsx links={{RadioGroup: '#radiogroup', Radio: '#radio'}}
96+
```tsx links={{RadioGroup: '#radiogroup', Radio: '#radio', SelectionIndicator: 'selection.html#animated-selectionindicator'}}
9797
<RadioGroup>
9898
<Label />
99-
<Radio />
99+
<Radio>
100+
<SelectionIndicator />
101+
</Radio>
100102
<Text slot="description" />
101103
<FieldError />
102104
</RadioGroup>

0 commit comments

Comments
 (0)