/* Copyright (c) 2018-2020 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. */ // @flow /* global window */ import * as React from 'react'; import {useUID} from 'react-uid'; import {useStyletron} from '../styles/index.js'; import {getOverrides} from '../helpers/overrides.js'; import {isFocusVisible, forkFocus, forkBlur} from '../utils/focusVisible.js'; import {ORIENTATION, FILL} from './constants.js'; import { StyledRoot, StyledTabList, StyledTab, StyledArtworkContainer, StyledTabHighlight, StyledTabBorder, StyledTabPanel, } from './styled-components.js'; import { getTabId, getTabPanelId, isVertical, isHorizontal, isRTL, } from './utils.js'; import type {TabsPropsT} from './types.js'; const KEYBOARD_ACTION = { next: 'next', previous: 'previous', }; 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, }; } }; export function Tabs({ activeKey = '0', disabled = false, children, fill = FILL.intrinsic, activateOnFocus = true, onChange, orientation = ORIENTATION.horizontal, overrides = {}, renderAll = false, }: TabsPropsT) { // Create unique id prefix for this tabs component const uid = useUID(); // 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, ); // 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(); 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 and orientation changes. React.useEffect(updateHighlight, [activeTabRef.current, orientation]); // 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) ? activeTabRef.current.parentNode.scrollWidth > activeTabRef.current.parentNode.clientWidth : activeTabRef.current.parentNode.scrollHeight > activeTabRef.current.parentNode.clientHeight ) { if (keyUpdated > 1) { activeTabRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest', }); } else { activeTabRef.current.scrollIntoView({ block: 'center', inline: 'center', }); } } } }, [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 => { 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 ( {React.Children.map(children, (child, index) => { if (!child) return; return ( ); })} 1} aria-hidden="true" role="presentation" {...sharedStylingProps} {...TabHighlightProps} /> ); } function InternalTab({ childKey, childIndex, activeKey, orientation, activeTabRef, updateHighlight, parseKeyDown, activateOnFocus, uid, disabled, sharedStylingProps, 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]); // 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( (event: SyntheticEvent<>) => { if (focusVisible !== false) { setFocusVisible(false); } }, [focusVisible], ); // Keyboard focus management 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; 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 ( { if (typeof onChange === 'function') onChange({activeKey: key}); if (typeof onClick === 'function') onClick(event); }} onFocus={forkFocus({...restProps, ...TabProps}, handleFocus)} onBlur={forkBlur({...restProps, ...TabProps}, handleBlur)} > {Artwork ? ( ) : null} {title ? title : key} ); } function InternalTabPanel({ childKey, childIndex, activeKey, uid, sharedStylingProps, 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 ( ); } declare var __DEV__: boolean; declare var __NODE__: boolean; declare var __BROWSER__: boolean;