From 754e9ad87040de26c14e2f9521cfbfc600f29941 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Thu, 15 Aug 2024 13:32:52 -0700 Subject: [PATCH] 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 <44729383+yihuiliao@users.noreply.github.com> Co-authored-by: Robert Snow --- packages/@react-spectrum/s2/src/Tabs.tsx | 352 +++++++++++++++++- packages/@react-spectrum/s2/src/index.ts | 2 + .../s2/stories/Tabs.stories.tsx | 57 ++- 3 files changed, 403 insertions(+), 8 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Tabs.tsx b/packages/@react-spectrum/s2/src/Tabs.tsx index b77bb0b2270..b7a3c2feb87 100644 --- a/packages/@react-spectrum/s2/src/Tabs.tsx +++ b/packages/@react-spectrum/s2/src/Tabs.tsx @@ -10,9 +10,355 @@ * governing permissions and limitations under the License. */ -import {Tabs as RACTabs, TabsProps} from 'react-aria-components'; +import { + TabListProps as AriaTabListProps, + TabPanel as AriaTabPanel, + TabPanelProps as AriaTabPanelProps, + TabProps as AriaTabProps, + TabsProps as AriaTabsProps, + Provider, + Tab as RACTab, + TabList as RACTabList, + Tabs as RACTabs, + TabListStateContext, + ContextValue, + useSlottedContext} from 'react-aria-components'; +import {centerBaseline} from './CenterBaseline'; +import {Collection, DOMRef, DOMRefValue, Key, Node, Orientation} from '@react-types/shared'; +import {createContext, forwardRef, ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react'; +import {focusRing, getAllowedOverrides, UnsafeStyles, StylesPropWithHeight, StyleProps} from './style-utils' with {type: 'macro'}; +import {IconContext} from './Icon'; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import {Text, TextContext} from './Content'; +import {useDOMRef} from '@react-spectrum/utils'; +import {useLayoutEffect} from '@react-aria/utils'; +import {useLocale} from '@react-aria/i18n'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; +export interface TabsProps extends Omit, UnsafeStyles { + /** Spectrum-defined styles, returned by the `style()` macro. */ + styles?: StylesPropWithHeight, + /** The content to display in the tabs. */ + children?: ReactNode, + /** The amount of space between the tabs. */ + density?: 'compact' | 'regular' +} + +export interface TabProps extends Omit, StyleProps { + /** The content to display in the tab. */ + children?: ReactNode +} + +export interface TabListProps extends Omit, 'children' | 'style' | 'className'>, StyleProps { + /** The content to display in the tablist. */ + children?: ReactNode +} + +export interface TabPanelProps extends Omit, UnsafeStyles { + /** Spectrum-defined styles, returned by the `style()` macro. */ + styles?: StylesPropWithHeight, + /** The content to display in the tab panels. */ + children?: ReactNode +} + +export const TabsContext = createContext>>(null); + +const tabPanel = style({ + marginTop: 4, + color: 'gray-800', + flexGrow: 1, + flexBasis: '[0%]', + minHeight: 0, + minWidth: 0 +}, getAllowedOverrides({height: true})); + +export function TabPanel(props: TabPanelProps) { + return ( + + ); +} -export function Tabs(props: TabsProps) { - return ; +const tab = style({ + ...focusRing(), + display: 'flex', + color: { + default: 'neutral-subdued', + isSelected: 'neutral', + isHovered: 'neutral-subdued', + isDisabled: 'disabled', + forcedColors: { + isSelected: 'Highlight', + isDisabled: 'GrayText' + } + }, + borderRadius: 'sm', + gap: 'text-to-visual', + height: { + density: { + compact: 32, + regular: 48 + } + }, + alignItems: 'center', + position: 'relative', + cursor: 'default', + flexShrink: 0, + transition: 'default' +}, getAllowedOverrides()); + +const icon = style({ + flexShrink: 0, + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + } +}); + +export function Tab(props: TabProps) { + let {density} = useSlottedContext(TabsContext); + + return ( + (props.UNSAFE_className || '') + tab({...renderProps, density}, props.styles)}> + + {typeof props.children === 'string' ? {props.children} : props.children} + + + ); } + +const tablist = style({ + display: 'flex', + gap: { + orientation: { + horizontal: { + density: { + compact: 24, + regular: 32 + } + } + } + }, + flexDirection: { + orientation: { + vertical: 'column' + } + }, + paddingEnd: { + orientation: { + vertical: 20 + } + }, + paddingStart: { + orientation: { + vertical: 12 + } + }, + flexShrink: 0, + flexBasis: '[0%]' +}); + +export function TabList(props: TabListProps) { + let {density, isDisabled, disabledKeys, orientation} = useSlottedContext(TabsContext); + let state = useContext(TabListStateContext); + let [selectedTab, setSelectedTab] = useState(undefined); + let tablistRef = useRef(null); + + useLayoutEffect(() => { + if (tablistRef?.current) { + let tab: HTMLElement | null = tablistRef.current.querySelector('[role=tab][data-selected=true]'); + + if (tab != null) { + setSelectedTab(tab); + } + } + }, [tablistRef, state?.selectedItem?.key]); + + return ( +
+ {orientation === 'vertical' && + } + tablist({...renderProps, density})} /> + {orientation === 'horizontal' && + } +
+ ); +} + +function isAllTabsDisabled(collection: Collection> | null, disabledKeys: Set) { + let testKey: Key | null = null; + if (collection && collection.size > 0) { + testKey = collection.getFirstKey(); + + let index = 0; + while (testKey && index < collection.size) { + // We have to check if the item in the collection has a key in disabledKeys or has the isDisabled prop set directly on it + if (!disabledKeys.has(testKey) && !collection.getItem(testKey)?.props?.isDisabled) { + return false; + } + + testKey = collection.getKeyAfter(testKey) + index++; + } + return true; + } + return false; +} + +interface TabLineProps { + disabledKeys: Iterable | undefined, + isDisabled: boolean | undefined, + selectedTab: HTMLElement | undefined, + orientation?: Orientation, + density?: 'compact' | 'regular' +} + +const selectedIndicator = style({ + position: 'absolute', + backgroundColor: { + default: 'neutral', + isDisabled: 'disabled', + forcedColors: { + default: 'Highlight', + isDisabled: 'GrayText' + } + }, + height: { + orientation: { + horizontal: '[2px]' + } + }, + width: { + orientation: { + vertical: '[2px]' + } + }, + bottom: { + orientation: { + horizontal: 0 + } + }, + borderStyle: 'none', + borderRadius: 'full', + transitionDuration: 130, + transitionTimingFunction: 'in-out', +}); + +function TabLine(props: TabLineProps) { + let { + disabledKeys, + isDisabled: isTabsDisabled, + selectedTab, + orientation, + density + } = props; + let {direction} = useLocale(); + let state = useContext(TabListStateContext); + + // We want to add disabled styling to the selection indicator only if all the Tabs are disabled + let [isDisabled, setIsDisabled] = useState(false); + useEffect(() => { + let isDisabled = isTabsDisabled || isAllTabsDisabled(state?.collection || null, disabledKeys ? new Set(disabledKeys) : new Set(null)); + setIsDisabled(isDisabled); + }, [state?.collection, disabledKeys, isTabsDisabled, setIsDisabled]); + + let [style, setStyle] = useState<{transform: string | undefined, width: string | undefined, height: string | undefined}>({ + transform: undefined, + width: undefined, + height: undefined + }); + + let onResize = useCallback(() => { + if (selectedTab) { + let styleObj: { transform: string | undefined, width: string | undefined, height: string | undefined } = { + transform: undefined, + width: undefined, + height: undefined + }; + + // 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 + let offset = direction === 'rtl' ? -1 * ((selectedTab.offsetParent as HTMLElement)?.offsetWidth - selectedTab.offsetWidth - selectedTab.offsetLeft) : selectedTab.offsetLeft; + styleObj.transform = orientation === 'vertical' + ? `translateY(${selectedTab.offsetTop}px)` + : `translateX(${offset}px)`; + + if (orientation === 'horizontal') { + styleObj.width = `${selectedTab.offsetWidth}px`; + } else { + styleObj.height = `${selectedTab.offsetHeight}px`; + } + setStyle(styleObj); + } + }, [direction, setStyle, selectedTab, orientation]); + + useLayoutEffect(() => { + onResize(); + }, [onResize, state?.selectedItem?.key, direction, orientation, density]); + + return ( +
+ ); +} + +const tabs = style({ + display: 'flex', + flexShrink: 0, + fontFamily: 'sans', + fontWeight: 'normal', + flexDirection: { + orientation: { + horizontal: 'column' + } + } +}, getAllowedOverrides({height: true})); + +const TabsInternalContext = createContext({}); + +function Tabs(props: TabsProps, ref: DOMRef) { + [props, ref] = useSpectrumContextProps(props, ref, TabsContext); + let { + density = 'regular', + isDisabled, + disabledKeys, + orientation = 'horizontal' + } = props + let domRef = useDOMRef(ref); + + return ( + (props.UNSAFE_className || '') + tabs({...renderProps}, props.styles)}> + + {props.children} + + + ); +} + +/** + * 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. + */ +const _Tabs = forwardRef(Tabs); +export {_Tabs as Tabs}; diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index 26063efa566..6c6dce1308a 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -52,6 +52,7 @@ export {SearchField, SearchFieldContext} from './SearchField'; export {Slider, SliderContext} from './Slider'; export {StatusLight, StatusLightContext} from './StatusLight'; export {Switch, SwitchContext} from './Switch'; +export {Tabs, TabList, Tab, TabPanel, TabsContext} from './Tabs'; export {TagGroup, Tag, TagGroupContext} from './TagGroup'; export {TextArea, TextField, TextAreaContext, TextFieldContext} from './TextField'; export {ToggleButton, ToggleButtonContext} from './ToggleButton'; @@ -98,6 +99,7 @@ export type {SliderProps} from './Slider'; export type {RangeSliderProps} from './RangeSlider'; export type {StatusLightProps} from './StatusLight'; export type {SwitchProps} from './Switch'; +export type {TabsProps, TabProps, TabListProps, TabPanelProps} from './Tabs' export type {TagGroupProps, TagProps} from './TagGroup'; export type {TextFieldProps, TextAreaProps} from './TextField'; export type {ToggleButtonProps} from './ToggleButton'; diff --git a/packages/@react-spectrum/s2/stories/Tabs.stories.tsx b/packages/@react-spectrum/s2/stories/Tabs.stories.tsx index 376e458ca74..54e60ce2495 100644 --- a/packages/@react-spectrum/s2/stories/Tabs.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Tabs.stories.tsx @@ -10,10 +10,12 @@ * governing permissions and limitations under the License. */ +import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; +import Bell from '../s2wf-icons/S2_Icon_Bell_20_N.svg'; +import Heart from '../s2wf-icons/S2_Icon_Heart_20_N.svg'; import type {Meta} from '@storybook/react'; -import {Tab, TabList, TabPanel} from 'react-aria-components'; - -import {Tabs} from '../src/Tabs'; +import {style} from '../style/spectrum-theme' with { type: 'macro' }; +import {Tab, TabList, TabPanel, Tabs} from '../src/Tabs'; const meta: Meta = { component: Tabs, @@ -25,12 +27,57 @@ const meta: Meta = { export default meta; export const Example = (args: any) => ( - + - Founding of Rome + Founding of Rome Monarchy and Republic Empire + +
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum non rutrum augue, a dictum est. Sed ultricies vel orci in blandit. Morbi sed tempor leo. Phasellus et sollicitudin nunc, a volutpat est. In volutpat molestie velit, nec rhoncus felis vulputate porttitor. In efficitur nibh tortor, maximus imperdiet libero sollicitudin sed. Pellentesque dictum, quam id scelerisque rutrum, lorem augue suscipit est, nec ultricies ligula lorem id dui. Cras lacus tortor, fringilla nec ligula quis, semper imperdiet ex.

+
+
+ +
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ut vulputate justo. Suspendisse potenti. Nunc id fringilla leo, at luctus quam. Maecenas et ipsum nisi. Curabitur in porta purus, a pretium est. Fusce eu urna diam. Sed nunc neque, consectetur ut purus nec, consequat elementum libero. Sed ut diam in quam maximus condimentum at non erat. Vestibulum sagittis rutrum velit, vitae suscipit arcu. Nulla ac feugiat ante, vitae laoreet ligula. Maecenas sed molestie ligula. Nulla sed fringilla ex. Nulla viverra tortor at enim condimentum egestas. Nulla sed tristique sapien. Integer ligula quam, vulputate eget mollis eu, interdum sit amet justo.

+

Vivamus dignissim tortor ut sapien congue tristique. Sed ac aliquet mauris. Nulla metus dui, elementum sit amet luctus eu, condimentum id elit. Praesent id nibh sed ligula congue venenatis. Pellentesque urna turpis, eleifend id pellentesque a, auctor nec neque. Vestibulum ipsum mauris, rutrum sit amet magna et, aliquet mollis tellus. Pellentesque nec ultricies nibh, at tempus massa. Phasellus dictum turpis et interdum scelerisque. Aliquam fermentum tincidunt ipsum sit amet suscipit. Fusce non dui sed diam lacinia mattis fermentum eu urna. Cras pretium id nunc in elementum. Mauris laoreet odio vitae laoreet dictum. In non justo nec nunc vehicula posuere non non ligula. Nullam eleifend scelerisque nibh, in sollicitudin tortor ullamcorper vel. Praesent sagittis risus in erat dignissim, non lacinia elit efficitur. Quisque maximus nulla vel luctus pharetra.

+
+
+ +
+

Alea jacta est.

+
+
+
+); + +export const Disabled = (args: any) => ( + + + Founding of Rome + Monarchy and Republic + Empire + + + Arma virumque cano, Troiae qui primus ab oris. + + + Senatus Populusque Romanus. + + + Alea jacta est. + + +); + +export const Icons = (args: any) => ( + + + + + + Arma virumque cano, Troiae qui primus ab oris.