Skip to content

Commit 754e9ad

Browse files
devongovettyihuiliaosnowystinger
authored
S2 tabs (#6779)
* Add Spectrum 2 docs to storybook * initialize tabs * fix lint * support vertical orientation, add TabLine * fix lint * add comment * lint * add height style prop, add hcm * add different icons to stories, explicit flex shrink * fix ts error * remove raw animation * fix ts error * small fixes * remove height from tablist * add style props * fix style props on tab panel * update icon styling, fix vertical selection indicator * small fixes * update some css * fix stories * update gap to use token * remove unused prop * fix width so white space is clickable * revert rsp tab story * Tabs layout (#6867) * Tabs layout A few things I found while reviewing the styles * tab should never shrink or grow, but be the size it contains * update tab panel props * update effect dependencies * export types and props, small fixes * add context * remove mergeStyles --------- Co-authored-by: Yihui Liao <[email protected]> Co-authored-by: Robert Snow <[email protected]>
1 parent 10df503 commit 754e9ad

File tree

3 files changed

+403
-8
lines changed

3 files changed

+403
-8
lines changed

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

Lines changed: 349 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,355 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {Tabs as RACTabs, TabsProps} from 'react-aria-components';
13+
import {
14+
TabListProps as AriaTabListProps,
15+
TabPanel as AriaTabPanel,
16+
TabPanelProps as AriaTabPanelProps,
17+
TabProps as AriaTabProps,
18+
TabsProps as AriaTabsProps,
19+
Provider,
20+
Tab as RACTab,
21+
TabList as RACTabList,
22+
Tabs as RACTabs,
23+
TabListStateContext,
24+
ContextValue,
25+
useSlottedContext} from 'react-aria-components';
26+
import {centerBaseline} from './CenterBaseline';
27+
import {Collection, DOMRef, DOMRefValue, Key, Node, Orientation} from '@react-types/shared';
28+
import {createContext, forwardRef, ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react';
29+
import {focusRing, getAllowedOverrides, UnsafeStyles, StylesPropWithHeight, StyleProps} from './style-utils' with {type: 'macro'};
30+
import {IconContext} from './Icon';
31+
import {style} from '../style/spectrum-theme' with {type: 'macro'};
32+
import {Text, TextContext} from './Content';
33+
import {useDOMRef} from '@react-spectrum/utils';
34+
import {useLayoutEffect} from '@react-aria/utils';
35+
import {useLocale} from '@react-aria/i18n';
36+
import {useSpectrumContextProps} from './useSpectrumContextProps';
1437

38+
export interface TabsProps extends Omit<AriaTabsProps, 'className' | 'style' | 'children'>, UnsafeStyles {
39+
/** Spectrum-defined styles, returned by the `style()` macro. */
40+
styles?: StylesPropWithHeight,
41+
/** The content to display in the tabs. */
42+
children?: ReactNode,
43+
/** The amount of space between the tabs. */
44+
density?: 'compact' | 'regular'
45+
}
46+
47+
export interface TabProps extends Omit<AriaTabProps, 'children' | 'style' | 'className'>, StyleProps {
48+
/** The content to display in the tab. */
49+
children?: ReactNode
50+
}
51+
52+
export interface TabListProps<T> extends Omit<AriaTabListProps<T>, 'children' | 'style' | 'className'>, StyleProps {
53+
/** The content to display in the tablist. */
54+
children?: ReactNode
55+
}
56+
57+
export interface TabPanelProps extends Omit<AriaTabPanelProps, 'children' | 'style' | 'className'>, UnsafeStyles {
58+
/** Spectrum-defined styles, returned by the `style()` macro. */
59+
styles?: StylesPropWithHeight,
60+
/** The content to display in the tab panels. */
61+
children?: ReactNode
62+
}
63+
64+
export const TabsContext = createContext<ContextValue<TabsProps, DOMRefValue<HTMLDivElement>>>(null);
65+
66+
const tabPanel = style({
67+
marginTop: 4,
68+
color: 'gray-800',
69+
flexGrow: 1,
70+
flexBasis: '[0%]',
71+
minHeight: 0,
72+
minWidth: 0
73+
}, getAllowedOverrides({height: true}));
74+
75+
export function TabPanel(props: TabPanelProps) {
76+
return (
77+
<AriaTabPanel
78+
{...props}
79+
style={props.UNSAFE_style}
80+
className={(props.UNSAFE_className || '') + tabPanel(null , props.styles)} />
81+
);
82+
}
1583

16-
export function Tabs(props: TabsProps) {
17-
return <RACTabs {...props} />;
84+
const tab = style({
85+
...focusRing(),
86+
display: 'flex',
87+
color: {
88+
default: 'neutral-subdued',
89+
isSelected: 'neutral',
90+
isHovered: 'neutral-subdued',
91+
isDisabled: 'disabled',
92+
forcedColors: {
93+
isSelected: 'Highlight',
94+
isDisabled: 'GrayText'
95+
}
96+
},
97+
borderRadius: 'sm',
98+
gap: 'text-to-visual',
99+
height: {
100+
density: {
101+
compact: 32,
102+
regular: 48
103+
}
104+
},
105+
alignItems: 'center',
106+
position: 'relative',
107+
cursor: 'default',
108+
flexShrink: 0,
109+
transition: 'default'
110+
}, getAllowedOverrides());
111+
112+
const icon = style({
113+
flexShrink: 0,
114+
'--iconPrimary': {
115+
type: 'fill',
116+
value: 'currentColor'
117+
}
118+
});
119+
120+
export function Tab(props: TabProps) {
121+
let {density} = useSlottedContext(TabsContext);
122+
123+
return (
124+
<RACTab
125+
{...props}
126+
style={props.UNSAFE_style}
127+
className={renderProps => (props.UNSAFE_className || '') + tab({...renderProps, density}, props.styles)}>
128+
<Provider
129+
values={[
130+
[TextContext, {styles: style({order: 1})}],
131+
[IconContext, {
132+
render: centerBaseline({slot: 'icon', styles: style({order: 0})}),
133+
styles: icon
134+
}]
135+
]}>
136+
{typeof props.children === 'string' ? <Text>{props.children}</Text> : props.children}
137+
</Provider>
138+
</RACTab>
139+
);
18140
}
141+
142+
const tablist = style({
143+
display: 'flex',
144+
gap: {
145+
orientation: {
146+
horizontal: {
147+
density: {
148+
compact: 24,
149+
regular: 32
150+
}
151+
}
152+
}
153+
},
154+
flexDirection: {
155+
orientation: {
156+
vertical: 'column'
157+
}
158+
},
159+
paddingEnd: {
160+
orientation: {
161+
vertical: 20
162+
}
163+
},
164+
paddingStart: {
165+
orientation: {
166+
vertical: 12
167+
}
168+
},
169+
flexShrink: 0,
170+
flexBasis: '[0%]'
171+
});
172+
173+
export function TabList<T extends object>(props: TabListProps<T>) {
174+
let {density, isDisabled, disabledKeys, orientation} = useSlottedContext(TabsContext);
175+
let state = useContext(TabListStateContext);
176+
let [selectedTab, setSelectedTab] = useState<HTMLElement | undefined>(undefined);
177+
let tablistRef = useRef<HTMLDivElement>(null);
178+
179+
useLayoutEffect(() => {
180+
if (tablistRef?.current) {
181+
let tab: HTMLElement | null = tablistRef.current.querySelector('[role=tab][data-selected=true]');
182+
183+
if (tab != null) {
184+
setSelectedTab(tab);
185+
}
186+
}
187+
}, [tablistRef, state?.selectedItem?.key]);
188+
189+
return (
190+
<div
191+
style={props.UNSAFE_style}
192+
className={(props.UNSAFE_className || '') + style({position: 'relative'}, getAllowedOverrides())(null, props.styles)}>
193+
{orientation === 'vertical' &&
194+
<TabLine disabledKeys={disabledKeys} isDisabled={isDisabled} selectedTab={selectedTab} orientation={orientation} density={density}/>}
195+
<RACTabList
196+
{...props}
197+
ref={tablistRef}
198+
className={renderProps => tablist({...renderProps, density})} />
199+
{orientation === 'horizontal' &&
200+
<TabLine disabledKeys={disabledKeys} isDisabled={isDisabled} selectedTab={selectedTab} orientation={orientation} density={density}/>}
201+
</div>
202+
);
203+
}
204+
205+
function isAllTabsDisabled<T>(collection: Collection<Node<T>> | null, disabledKeys: Set<Key>) {
206+
let testKey: Key | null = null;
207+
if (collection && collection.size > 0) {
208+
testKey = collection.getFirstKey();
209+
210+
let index = 0;
211+
while (testKey && index < collection.size) {
212+
// We have to check if the item in the collection has a key in disabledKeys or has the isDisabled prop set directly on it
213+
if (!disabledKeys.has(testKey) && !collection.getItem(testKey)?.props?.isDisabled) {
214+
return false;
215+
}
216+
217+
testKey = collection.getKeyAfter(testKey)
218+
index++;
219+
}
220+
return true;
221+
}
222+
return false;
223+
}
224+
225+
interface TabLineProps {
226+
disabledKeys: Iterable<Key> | undefined,
227+
isDisabled: boolean | undefined,
228+
selectedTab: HTMLElement | undefined,
229+
orientation?: Orientation,
230+
density?: 'compact' | 'regular'
231+
}
232+
233+
const selectedIndicator = style({
234+
position: 'absolute',
235+
backgroundColor: {
236+
default: 'neutral',
237+
isDisabled: 'disabled',
238+
forcedColors: {
239+
default: 'Highlight',
240+
isDisabled: 'GrayText'
241+
}
242+
},
243+
height: {
244+
orientation: {
245+
horizontal: '[2px]'
246+
}
247+
},
248+
width: {
249+
orientation: {
250+
vertical: '[2px]'
251+
}
252+
},
253+
bottom: {
254+
orientation: {
255+
horizontal: 0
256+
}
257+
},
258+
borderStyle: 'none',
259+
borderRadius: 'full',
260+
transitionDuration: 130,
261+
transitionTimingFunction: 'in-out',
262+
});
263+
264+
function TabLine(props: TabLineProps) {
265+
let {
266+
disabledKeys,
267+
isDisabled: isTabsDisabled,
268+
selectedTab,
269+
orientation,
270+
density
271+
} = props;
272+
let {direction} = useLocale();
273+
let state = useContext(TabListStateContext);
274+
275+
// We want to add disabled styling to the selection indicator only if all the Tabs are disabled
276+
let [isDisabled, setIsDisabled] = useState<boolean>(false);
277+
useEffect(() => {
278+
let isDisabled = isTabsDisabled || isAllTabsDisabled(state?.collection || null, disabledKeys ? new Set(disabledKeys) : new Set(null));
279+
setIsDisabled(isDisabled);
280+
}, [state?.collection, disabledKeys, isTabsDisabled, setIsDisabled]);
281+
282+
let [style, setStyle] = useState<{transform: string | undefined, width: string | undefined, height: string | undefined}>({
283+
transform: undefined,
284+
width: undefined,
285+
height: undefined
286+
});
287+
288+
let onResize = useCallback(() => {
289+
if (selectedTab) {
290+
let styleObj: { transform: string | undefined, width: string | undefined, height: string | undefined } = {
291+
transform: undefined,
292+
width: undefined,
293+
height: undefined
294+
};
295+
296+
// In RTL, calculate the transform from the right edge of the tablist so that resizing the window doesn't break the Tabline position due to offsetLeft changes
297+
let offset = direction === 'rtl' ? -1 * ((selectedTab.offsetParent as HTMLElement)?.offsetWidth - selectedTab.offsetWidth - selectedTab.offsetLeft) : selectedTab.offsetLeft;
298+
styleObj.transform = orientation === 'vertical'
299+
? `translateY(${selectedTab.offsetTop}px)`
300+
: `translateX(${offset}px)`;
301+
302+
if (orientation === 'horizontal') {
303+
styleObj.width = `${selectedTab.offsetWidth}px`;
304+
} else {
305+
styleObj.height = `${selectedTab.offsetHeight}px`;
306+
}
307+
setStyle(styleObj);
308+
}
309+
}, [direction, setStyle, selectedTab, orientation]);
310+
311+
useLayoutEffect(() => {
312+
onResize();
313+
}, [onResize, state?.selectedItem?.key, direction, orientation, density]);
314+
315+
return (
316+
<div style={{...style}} className={selectedIndicator({isDisabled, orientation})} />
317+
);
318+
}
319+
320+
const tabs = style({
321+
display: 'flex',
322+
flexShrink: 0,
323+
fontFamily: 'sans',
324+
fontWeight: 'normal',
325+
flexDirection: {
326+
orientation: {
327+
horizontal: 'column'
328+
}
329+
}
330+
}, getAllowedOverrides({height: true}));
331+
332+
const TabsInternalContext = createContext<TabsProps>({});
333+
334+
function Tabs(props: TabsProps, ref: DOMRef<HTMLDivElement>) {
335+
[props, ref] = useSpectrumContextProps(props, ref, TabsContext);
336+
let {
337+
density = 'regular',
338+
isDisabled,
339+
disabledKeys,
340+
orientation = 'horizontal'
341+
} = props
342+
let domRef = useDOMRef(ref);
343+
344+
return (
345+
<RACTabs
346+
{...props}
347+
ref={domRef}
348+
style={props.UNSAFE_style}
349+
className={renderProps => (props.UNSAFE_className || '') + tabs({...renderProps}, props.styles)}>
350+
<Provider
351+
values={[
352+
[TabsContext, {density, isDisabled, disabledKeys, orientation}]
353+
]}>
354+
{props.children}
355+
</Provider>
356+
</RACTabs>
357+
);
358+
}
359+
360+
/**
361+
* Tabs organize content into multiple sections and allow users to navigate between them. The content under the set of tabs should be related and form a coherent unit.
362+
*/
363+
const _Tabs = forwardRef(Tabs);
364+
export {_Tabs as Tabs};

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export {SearchField, SearchFieldContext} from './SearchField';
5252
export {Slider, SliderContext} from './Slider';
5353
export {StatusLight, StatusLightContext} from './StatusLight';
5454
export {Switch, SwitchContext} from './Switch';
55+
export {Tabs, TabList, Tab, TabPanel, TabsContext} from './Tabs';
5556
export {TagGroup, Tag, TagGroupContext} from './TagGroup';
5657
export {TextArea, TextField, TextAreaContext, TextFieldContext} from './TextField';
5758
export {ToggleButton, ToggleButtonContext} from './ToggleButton';
@@ -98,6 +99,7 @@ export type {SliderProps} from './Slider';
9899
export type {RangeSliderProps} from './RangeSlider';
99100
export type {StatusLightProps} from './StatusLight';
100101
export type {SwitchProps} from './Switch';
102+
export type {TabsProps, TabProps, TabListProps, TabPanelProps} from './Tabs'
101103
export type {TagGroupProps, TagProps} from './TagGroup';
102104
export type {TextFieldProps, TextAreaProps} from './TextField';
103105
export type {ToggleButtonProps} from './ToggleButton';

0 commit comments

Comments
 (0)