src/tabs-motion/tabs.tsx (455 lines of code) (raw):

/* Copyright (c) Uber Technologies, Inc. This source code is licensed under the MIT license found in the LICENSE file in the root directory of this source tree. */ /* global window */ import * as React from 'react'; import * as ReactIs from 'react-is'; import { useUID } from 'react-uid'; import { useStyletron } from '../styles'; import { getOverrides } from '../helpers/overrides'; import { isFocusVisible, forkFocus, forkBlur } from '../utils/focusVisible'; import { ORIENTATION, FILL } from './constants'; import { StyledRoot, StyledTabList, StyledTab, StyledArtworkContainer, StyledTabHighlight, StyledTabBorder, StyledTabPanel, StyledEndEnhancerContainer, StyledTabBar, } from './styled-components'; import { getTabId, getTabPanelId, isVertical, isHorizontal, isRTL } from './utils'; import type { TabsProps } from './types'; import type { SyntheticEvent } from 'react'; const KEYBOARD_ACTION = { next: 'next', previous: 'previous', } as const; // @ts-ignore const getLayoutParams = (el, orientation) => { if (!el) { return { length: 0, distance: 0, }; } // Note, we are using clientHeight/Width here, which excludes borders. // This means borders won't be taken into account if someone adds borders // through overrides. In that case you would use getBoundingClientRect // which includes borders, but because it returns a fractional value the // highlight is slightly misaligned every so often. if (isVertical(orientation)) { return { length: el.clientHeight, distance: el.offsetTop, }; } else { return { length: el.clientWidth, distance: el.offsetLeft, }; } }; // @ts-ignore const scrollParentToCentreTarget = (targetNode) => { const { x: parentX, y: parentY, width: parentWidth, height: parentHeight, } = targetNode.parentNode.getBoundingClientRect(); const { x: childX, y: childY, width: childWidth, height: childHeight, } = targetNode.getBoundingClientRect(); // get the position of the child centre, relative to parent const childCentre = { x: childX - parentX + childWidth / 2, y: childY - parentY + childHeight / 2, }; // aim for the centre of the child to be the centre of the parent const { scrollLeft, scrollTop } = targetNode.parentNode; const target = { x: scrollLeft + childCentre.x - parentWidth / 2, y: scrollTop + childCentre.y - parentHeight / 2, }; // ignore out of bounds, the browser will manage this for us targetNode.parentNode.scroll(target.x, target.y); }; // @ts-ignore function RenderEnhancer({ Enhancer }) { if (typeof Enhancer === 'string') { return Enhancer; } if (ReactIs.isValidElementType(Enhancer)) { return <Enhancer />; } return Enhancer; } export function Tabs({ activeKey = '0', disabled = false, children, fill = FILL.intrinsic, activateOnFocus = true, onChange, orientation = ORIENTATION.horizontal, overrides = {}, renderAll = false, // @ts-ignore uid: customUid = null, endEnhancer, }: TabsProps) { // Create unique id prefix for this tabs component const generatedUid = useUID(); const uid = customUid || generatedUid; // Unpack overrides const { Root: RootOverrides, TabList: TabListOverrides, TabHighlight: TabHighlightOverrides, TabBorder: TabBorderOverrides, } = overrides; const [Root, RootProps] = getOverrides(RootOverrides, StyledRoot); const [TabList, TabListProps] = getOverrides(TabListOverrides, StyledTabList); const [TabHighlight, TabHighlightProps] = getOverrides(TabHighlightOverrides, StyledTabHighlight); const [TabBorder, TabBorderProps] = getOverrides(TabBorderOverrides, StyledTabBorder); const [EndEnhancerContainer, endEnhancerContainerProps] = getOverrides( overrides.EndEnhancerContainer, StyledEndEnhancerContainer ); const [TabBar, tabBarProps] = getOverrides(overrides.TabBar, StyledTabBar); // Count key updates // We disable a few things until after first mount: // - the highlight animation, avoiding an initial slide-in // - smooth scrolling active tab into view const [keyUpdated, setKeyUpdated] = React.useState(0); React.useEffect(() => { setKeyUpdated(keyUpdated + 1); }, [activeKey]); // Positioning the highlight. const activeTabRef = React.useRef<HTMLElement>(); const [highlightLayout, setHighlightLayout] = React.useState({ length: 0, distance: 0, }); // Create a shared, memoized callback for tabs to call on resize. const updateHighlight = React.useCallback(() => { if (activeTabRef.current) { setHighlightLayout(getLayoutParams(activeTabRef.current, orientation)); } }, [activeTabRef.current, orientation]); // Update highlight on key, orientation and children changes. React.useEffect(updateHighlight, [activeTabRef.current, orientation, children]); // Scroll active tab into view when the parent has scrollbar on mount and // on key change (smooth scroll). Note, if the active key changes while // the tab is not in view, the page will scroll it into view. // TODO: replace with custom scrolling logic. React.useEffect(() => { // Flow needs this condition pulled out. if (activeTabRef.current) { if ( isHorizontal(orientation) ? // @ts-expect-error todo(flow->ts) maybe parentElement? activeTabRef.current.parentNode.scrollWidth > // @ts-expect-error todo(flow->ts) maybe parentElement? activeTabRef.current.parentNode.clientWidth : // @ts-expect-error todo(flow->ts) maybe parentElement? activeTabRef.current.parentNode.scrollHeight > // @ts-expect-error todo(flow->ts) maybe parentElement? activeTabRef.current.parentNode.clientHeight ) { if (keyUpdated > 1) { activeTabRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest', }); } else { scrollParentToCentreTarget(activeTabRef.current); } } } }, [activeTabRef.current]); // Collect shared styling props const sharedStylingProps = { $orientation: orientation, $fill: fill, }; // Helper for parsing directional keys // TODO(WPT-6473): move to universal keycode aliases const [, theme] = useStyletron(); const parseKeyDown = React.useCallback( (event: { keyCode: number }) => { if (isHorizontal(orientation)) { if (isRTL(theme.direction)) { switch (event.keyCode) { case 39: return KEYBOARD_ACTION.previous; case 37: return KEYBOARD_ACTION.next; default: return null; } } else { switch (event.keyCode) { case 37: return KEYBOARD_ACTION.previous; case 39: return KEYBOARD_ACTION.next; default: return null; } } } else { switch (event.keyCode) { case 38: return KEYBOARD_ACTION.previous; case 40: return KEYBOARD_ACTION.next; default: return null; } } }, [orientation, theme.direction] ); return ( <Root {...sharedStylingProps} {...RootProps}> <TabBar $hasEndEnhancer={Boolean(endEnhancer)} $orientation={orientation} {...tabBarProps}> <TabList data-baseweb="tab-list" role="tablist" aria-orientation={orientation} {...sharedStylingProps} {...TabListProps} > {/* @ts-ignore */} {React.Children.map(children, (child: React.ReactElement, index) => { if (!child) return; return ( <InternalTab childKey={child.key} childIndex={index} activeKey={activeKey} orientation={orientation} activeTabRef={activeTabRef} updateHighlight={updateHighlight} parseKeyDown={parseKeyDown} activateOnFocus={activateOnFocus} uid={uid} disabled={disabled} sharedStylingProps={sharedStylingProps} onChange={onChange} {...child.props} /> ); })} <TabHighlight data-baseweb="tab-highlight" $length={highlightLayout.length} $distance={highlightLayout.distance} // This avoids the tab sliding in from the side on mount $animate={keyUpdated > 1} aria-hidden="true" role="presentation" {...sharedStylingProps} {...TabHighlightProps} /> </TabList> {orientation === ORIENTATION.horizontal && endEnhancer !== null && endEnhancer !== undefined && ( <EndEnhancerContainer {...endEnhancerContainerProps} $orientation={orientation}> <RenderEnhancer Enhancer={endEnhancer} /> </EndEnhancerContainer> )} </TabBar> <TabBorder data-baseweb="tab-border" aria-hidden="true" role="presentation" {...sharedStylingProps} {...TabBorderProps} /> {/* @ts-ignore */} {React.Children.map(children, (child: React.ReactElement, index) => { if (!child) return; return ( <InternalTabPanel childKey={child.key} childIndex={index} activeKey={activeKey} uid={uid} sharedStylingProps={sharedStylingProps} renderAll={renderAll} {...child.props} /> ); })} </Root> ); } function InternalTab({ // @ts-ignore childKey, // @ts-ignore childIndex, // @ts-ignore activeKey, // @ts-ignore orientation, // @ts-ignore activeTabRef, // @ts-ignore updateHighlight, // @ts-ignore parseKeyDown, // @ts-ignore activateOnFocus, // @ts-ignore uid, // @ts-ignore disabled, // @ts-ignore sharedStylingProps, // @ts-ignore onChange, ...props }) { const key = childKey || String(childIndex); const isActive = key == activeKey; const { artwork: Artwork, overrides = {}, tabRef, onClick, title, ...restProps } = props; // A way to share our internal activeTabRef via the "tabRef" prop. const ref = React.useRef(); React.useImperativeHandle(tabRef, () => { return isActive ? activeTabRef.current : ref.current; }); // Track tab dimensions in a ref after each render // This is used to compare params when the resize observer fires const tabLayoutParams = React.useRef({ length: 0, distance: 0 }); React.useEffect(() => { tabLayoutParams.current = getLayoutParams( isActive ? activeTabRef.current : ref.current, orientation ); }); // We need to potentially update the active tab highlight when the width or // placement changes for a tab so we listen for resize updates in each tab. React.useEffect(() => { if (window.ResizeObserver) { const observer = new window.ResizeObserver((entries) => { if (entries[0] && entries[0].target) { const tabLayoutParamsAfterResize = getLayoutParams(entries[0].target, orientation); if ( tabLayoutParamsAfterResize.length !== tabLayoutParams.current.length || tabLayoutParamsAfterResize.distance !== tabLayoutParams.current.distance ) { updateHighlight(); } } }); observer.observe(isActive ? activeTabRef.current : ref.current); return () => { observer.disconnect(); }; } }, [activeKey, orientation]); React.useEffect(updateHighlight, [title]); // Collect overrides const { Tab: TabOverrides, ArtworkContainer: ArtworkContainerOverrides } = overrides; const [Tab, TabProps] = getOverrides(TabOverrides, StyledTab); const [ArtworkContainer, ArtworkContainerProps] = getOverrides( ArtworkContainerOverrides, StyledArtworkContainer ); // Keyboard focus styling const [focusVisible, setFocusVisible] = React.useState(false); const handleFocus = React.useCallback((event: SyntheticEvent) => { if (isFocusVisible(event)) { setFocusVisible(true); } }, []); const handleBlur = React.useCallback( // eslint-disable-next-line @typescript-eslint/no-unused-vars (event: SyntheticEvent) => { if (focusVisible !== false) { setFocusVisible(false); } }, [focusVisible] ); // Keyboard focus management // @ts-expect-error todo(flow->ts): deps are required const handleKeyDown = React.useCallback((event) => { // WAI-ARIA 1.1 // https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel // We use directional keys to iterate focus through Tabs. // Find all tabs eligible for focus const availableTabs = [...event.target.parentNode.childNodes].filter( (node) => !node.disabled && node.getAttribute('role') === 'tab' ); // Exit early if there are no other tabs available if (availableTabs.length === 1) return; // Find tab to focus, looping to start/end of list if necessary const currentTabIndex = availableTabs.indexOf(event.target); const action = parseKeyDown(event); if (action) { let nextTab: HTMLButtonElement | undefined | null; if (action === KEYBOARD_ACTION.previous) { if (availableTabs[currentTabIndex - 1]) { nextTab = availableTabs[currentTabIndex - 1]; } else { nextTab = availableTabs[availableTabs.length - 1]; } } else if (action === KEYBOARD_ACTION.next) { if (availableTabs[currentTabIndex + 1]) { nextTab = availableTabs[currentTabIndex + 1]; } else { nextTab = availableTabs[0]; } } if (nextTab) { // Focus the tab nextTab.focus(); // Optionally activate the tab if (activateOnFocus) { nextTab.click(); } } // Prevent default page scroll when in vertical orientation if (isVertical(orientation)) { event.preventDefault(); } } }); return ( <Tab data-baseweb="tab" key={key} id={getTabId(uid, key)} role="tab" onKeyDown={handleKeyDown} aria-selected={isActive} aria-controls={getTabPanelId(uid, key)} tabIndex={isActive ? '0' : '-1'} ref={isActive ? activeTabRef : ref} disabled={!isActive && disabled} type="button" // so it doesn't trigger a submit when used inside forms $focusVisible={focusVisible} $isActive={isActive} {...sharedStylingProps} {...restProps} {...TabProps} // @ts-ignore onClick={(event) => { if (typeof onChange === 'function') onChange({ activeKey: key }); if (typeof onClick === 'function') onClick(event); }} onFocus={forkFocus({ ...restProps, ...TabProps }, handleFocus)} onBlur={forkBlur({ ...restProps, ...TabProps }, handleBlur)} > {Artwork ? ( <ArtworkContainer data-baseweb="artwork-container" {...sharedStylingProps} {...ArtworkContainerProps} > <Artwork size={20} color="contentPrimary" /> </ArtworkContainer> ) : null} {title ? title : key} </Tab> ); } function InternalTabPanel({ // @ts-ignore childKey, // @ts-ignore childIndex, // @ts-ignore activeKey, // @ts-ignore uid, // @ts-ignore sharedStylingProps, // @ts-ignore renderAll, ...props }) { const key = childKey || String(childIndex); const isActive = key == activeKey; const { overrides = {}, children } = props; const { TabPanel: TabPanelOverrides } = overrides; const [TabPanel, TabPanelProps] = getOverrides(TabPanelOverrides, StyledTabPanel); return ( <TabPanel data-baseweb="tab-panel" key={key} role="tabpanel" id={getTabPanelId(uid, key)} aria-labelledby={getTabId(uid, key)} hidden={!isActive} {...sharedStylingProps} {...TabPanelProps} > {isActive || renderAll ? children : null} </TabPanel> ); }