UNPKG

@accessible/tabs

Version:

🅰 An accessible and versatile tabs component for React with keyboard navigation and labeling features taught in w3.org's WAI-ARIA tabs example

1 lines • 15.4 kB
{"version":3,"file":"index.mjs","sources":["../../src/index.tsx"],"sourcesContent":["import * as React from 'react'\nimport {Button} from '@accessible/button'\nimport useKey from '@accessible/use-key'\nimport useConditionalFocus from '@accessible/use-conditional-focus'\nimport useId from '@accessible/use-id'\nimport useMergedRef from '@react-hook/merged-ref'\nimport useLayoutEffect from '@react-hook/passive-layout-effect'\nimport useChange from '@react-hook/change'\nimport clsx from 'clsx'\n\n// An optimized function for adding an `index` prop to elements of a Tab or\n// Panel type. All tabs must be on the same child depth level as other tabs,\n// same with panels. Once one tab or panel is found, it will not traverse\n// deeper into the tree. Using this in favor of something more generalized\n// in order to not hurt render performance on large trees.\nconst cloneChildrenWithIndex = (\n elements: React.ReactNode | React.ReactNode[],\n type: typeof Panel | typeof Tab\n): React.ReactNode[] | React.ReactNode => {\n let index = 0\n let didUpdate = false\n const children = React.Children.map(elements, (child) => {\n // bails out if not an element object\n if (!React.isValidElement(child)) return child\n // bails out if certainly the wrong type\n if (\n (type === Panel && (child.type === TabList || child.type === Tab)) ||\n (type === Tab && child.type === Panel)\n )\n return child\n // found a match\n if (child.type === type) {\n // bail out if the indexes are user-provided\n if (child.props.index !== void 0) {\n index = child.props.index + 1\n return child\n } else {\n didUpdate = true\n return React.cloneElement(child, {index: index++})\n }\n }\n // only checks the children if we're not on a depth with tabs/panels\n if (index === 0) {\n const nextChildren = cloneChildrenWithIndex(child.props.children, type)\n if (nextChildren === child.props.children) return child\n else {\n didUpdate = true\n return React.cloneElement(child, void 0, nextChildren)\n }\n }\n\n return child\n })\n\n return !didUpdate ? elements : children?.length === 1 ? children[0] : children\n}\n\nexport interface TabsContextValue {\n tabs: TabState[]\n registerTab: (\n index: number,\n element: HTMLElement,\n id?: string,\n disabled?: boolean\n ) => () => void\n active: number | undefined\n activate: (index: number | undefined) => void\n manualActivation: boolean\n preventScroll: boolean\n}\n\nfunction noop() {}\n\nexport const TabsContext = React.createContext<TabsContextValue>({\n tabs: [],\n registerTab: () => noop,\n active: void 0,\n activate: noop,\n manualActivation: false,\n preventScroll: false,\n }),\n {Consumer: TabsConsumer} = TabsContext,\n useTabs = () => React.useContext<TabsContextValue>(TabsContext)\n\nexport interface TabsProps {\n active?: number\n defaultActive?: number\n manualActivation?: boolean\n preventScroll?: boolean\n onChange?: (active: number | undefined) => void\n children: React.ReactNode | React.ReactNode[] | JSX.Element | JSX.Element[]\n}\n\nexport type TabState =\n | {\n element?: HTMLElement\n id?: string\n disabled?: boolean\n }\n | undefined\n\ntype TabAction =\n | {\n type: 'register'\n index: number\n element: HTMLElement\n id?: string\n disabled?: boolean\n }\n | {\n type: 'unregister'\n index: number\n }\n\nexport function Tabs({\n active,\n defaultActive = 0,\n manualActivation = false,\n preventScroll = false,\n onChange = noop,\n children,\n}: TabsProps) {\n const [tabs, dispatchTabs] = React.useReducer(\n (state: TabState[], action: TabAction) => {\n const {index} = action\n\n if (action.type === 'register') {\n state = state.slice(0)\n state[index] = {\n element: action.element,\n id: action.id,\n disabled: action.disabled,\n }\n } else if (action.type === 'unregister') {\n state = state.slice(0)\n state[index] = void 0\n }\n\n return state\n },\n []\n )\n const [userActive, setActive] = React.useState<number | undefined>(\n defaultActive\n )\n useChange(userActive, onChange)\n const nextActive = active ?? userActive\n\n const context = React.useMemo<TabsContextValue>(\n () => ({\n tabs,\n registerTab: (\n index: number,\n element: HTMLElement,\n id?: string,\n disabled?: boolean\n ) => {\n dispatchTabs({type: 'register', index, element, id, disabled})\n return () => dispatchTabs({type: 'unregister', index})\n },\n active: nextActive,\n activate: (index) => {\n if (tabs[index || -1]?.disabled) return\n setActive(index)\n },\n preventScroll,\n manualActivation,\n }),\n [tabs, nextActive, manualActivation, preventScroll]\n )\n\n return (\n <TabsContext.Provider value={context}>\n {cloneChildrenWithIndex(cloneChildrenWithIndex(children, Tab), Panel)}\n </TabsContext.Provider>\n )\n}\n\nexport interface TabControls {\n activate: () => void\n}\n\ninterface TabContextValue {\n id?: string\n tabRef?: HTMLElement\n index: number\n activate: () => void\n isActive: boolean\n disabled: boolean\n}\n\nexport function useTab(index: number): TabContextValue {\n const {tabs, activate, active} = React.useContext(TabsContext)\n return React.useMemo(\n () => ({\n id: tabs[index]?.id,\n tabRef: tabs[index]?.element,\n index: index as number,\n activate: () => !tabs[index]?.disabled && activate(index),\n isActive: index === active,\n disabled: tabs[index]?.disabled || false,\n }),\n [tabs, index, active, activate]\n )\n}\n\nexport interface TabProps {\n id?: string\n index?: number\n disabled?: boolean\n activeClass?: string\n inactiveClass?: string\n activeStyle?: React.CSSProperties\n inactiveStyle?: React.CSSProperties\n onDelete?: (event: KeyboardEvent) => void\n children: React.ReactElement | JSX.Element\n}\n\nexport function Tab({\n id,\n index,\n disabled = false,\n activeClass,\n inactiveClass,\n activeStyle,\n inactiveStyle,\n onDelete = noop,\n children,\n}: TabProps) {\n id = useId(id)\n const {registerTab} = useTabs()\n const triggerRef = React.useRef<HTMLElement>(null)\n const {tabs, manualActivation} = useTabs()\n const {isActive, activate} = useTab(index as number)\n const ref = useMergedRef(\n // @ts-ignore\n children.ref,\n triggerRef\n )\n\n useKey(triggerRef, {\n // right arrow\n ArrowRight: () => focusNext(tabs, index as number),\n // left arrow\n ArrowLeft: () => focusPrev(tabs, index as number),\n // home\n Home: () => tabs[0]?.element?.focus(),\n // end\n End: () => tabs[tabs.length - 1]?.element?.focus(),\n // delete\n Delete: onDelete,\n })\n\n React.useEffect(\n () =>\n registerTab(\n index as number,\n triggerRef.current as HTMLElement,\n id,\n disabled\n ),\n\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [id, disabled, index]\n )\n\n return (\n <Button>\n {React.cloneElement(children, {\n 'aria-controls': id,\n 'aria-selected': '' + isActive,\n 'aria-disabled': '' + (isActive || disabled),\n role: 'tab',\n className:\n clsx(\n children.props.className,\n isActive ? activeClass : inactiveClass\n ) || void 0,\n style: Object.assign(\n {},\n children.props.style,\n isActive ? activeStyle : inactiveStyle\n ),\n tabIndex: children.props.hasOwnProperty('tabIndex')\n ? children.props.tabIndex\n : isActive\n ? 0\n : -1,\n onFocus: (e: React.MouseEvent<HTMLElement>) => {\n if (!manualActivation) activate()\n children.props.onFocus?.(e)\n },\n onClick: (e: React.MouseEvent<HTMLElement>) => {\n activate()\n children.props.onClick?.(e)\n },\n ref,\n })}\n </Button>\n )\n}\n\nfunction focusNext(tabs: TabState[], currentIndex: number) {\n if (currentIndex === tabs.length - 1) tabs[0]?.element?.focus()\n else tabs[currentIndex + 1]?.element?.focus()\n}\n\nfunction focusPrev(tabs: TabState[], currentIndex: number) {\n if (currentIndex === 0) tabs[tabs.length - 1]?.element?.focus()\n else tabs[currentIndex - 1]?.element?.focus()\n}\n\nexport interface TabListProps {\n children: React.ReactElement | JSX.Element\n}\n\nexport function TabList({children}: TabListProps) {\n return React.cloneElement(children, {\n role: 'tablist',\n })\n}\n\nexport interface PanelProps {\n index?: number\n activeClass?: string\n inactiveClass?: string\n activeStyle?: React.CSSProperties\n inactiveStyle?: React.CSSProperties\n children: React.ReactElement | JSX.Element\n}\n\nexport function Panel({\n index,\n activeClass,\n inactiveClass,\n activeStyle,\n inactiveStyle,\n children,\n}: PanelProps) {\n const {isActive, id} = useTab(index as number)\n const {manualActivation, preventScroll} = useTabs()\n const prevActive = React.useRef<boolean>(isActive)\n const panelRef = React.useRef<HTMLElement>(null)\n const ref = useMergedRef(\n // @ts-ignore\n children.ref,\n panelRef\n )\n\n useConditionalFocus(\n panelRef,\n manualActivation && !prevActive.current && isActive,\n {\n includeRoot: true,\n preventScroll,\n }\n )\n\n // ensures the tab panel won't be granted the window's focus\n // by default, but receives focus when the visual state changes to\n // active\n useLayoutEffect(() => {\n prevActive.current = isActive\n }, [isActive, index])\n\n return React.cloneElement(children, {\n 'aria-hidden': `${!isActive}`,\n id,\n className:\n clsx(children.props.className, isActive ? activeClass : inactiveClass) ||\n void 0,\n style: Object.assign(\n {visibility: isActive ? 'visible' : 'hidden'},\n children.props.style,\n isActive ? activeStyle : inactiveStyle\n ),\n tabIndex: children.props.hasOwnProperty('tabIndex')\n ? children.props.tabIndex\n : isActive\n ? 0\n : -1,\n ref,\n })\n}\n\n/* istanbul ignore next */\nif (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {\n Tabs.displayName = 'Tabs'\n TabList.displayName = 'TabList'\n Tab.displayName = 'Tab'\n Panel.displayName = 'Panel'\n}\n"],"names":["noop","state","action","index","type","slice","element","id","disabled","Tabs","dispatchTabs","tabs","_tabs","setActive","active","defaultActive","manualActivation","preventScroll","onChange","children","React","userActive","useChange","nextActive","context","registerTab","activate","__reactCreateElement__","TabsContext","Provider","value","cloneChildrenWithIndex","Tab","Panel","useTab","_tabs$index3","_tabs$index","tabRef","_tabs$index2","isActive","activeClass","inactiveClass","activeStyle","inactiveStyle","onDelete","useId","useTabs","triggerRef","ref","useMergedRef","useKey","ArrowRight","currentIndex","length","focus","focusNext","ArrowLeft","focusPrev","Home","_tabs$","_tabs$$element","End","_tabs2","_tabs2$element","Delete","current","Button","role","className","clsx","props","style","Object","assign","tabIndex","hasOwnProperty","onFocus","e","onClick","TabList","prevActive","panelRef","useConditionalFocus","includeRoot","useLayoutEffect","visibility","elements","didUpdate","map","child","nextChildren","Consumer","TabsConsumer"],"mappings":"AAuEA,SAASA,KAoDL,WAACC,EAAmBC,OACZC,MAACA,GAASD,QAEI,aAAhBA,EAAOE,MACTH,EAAQA,EAAMI,MAAM,IACdF,GAAS,CACbG,QAASJ,EAAOI,QAChBC,GAAIL,EAAOK,GACXC,SAAUN,EAAOM,UAEM,eAAhBN,EAAOE,QAChBH,EAAQA,EAAMI,MAAM,IACdF,QAAS,GAGVF,EAxBN,SAASQ,gBAsCRN,EACAG,EACAC,EACAC,UAEAE,EAAa,CAACN,KAAM,WAAYD,MAAAA,EAAOG,QAAAA,EAASC,GAAAA,EAAIC,SAAAA,IAC7C,IAAME,EAAa,CAACN,KAAM,aAAcD,MAAAA,eAGtCA,oBACLQ,EAAKR,IAAU,uBAAfS,EAAmBJ,WACvBK,EAAUV,OAjDGW,OACnBA,EADmBC,cAEnBA,EAAgB,EAFGC,iBAGnBA,EAAmB,EAHAC,cAInBA,EAAgB,EAJGC,SAKnBA,EAAWlB,EALQmB,SAMnBA,MAEOR,EAAMD,GAAgBU,IAkB3B,KAEKC,EAAYR,GAAaO,EAC9BL,GAEFO,EAAUD,EAAYH,OAChBK,EAAaT,MAAAA,EAAAA,EAAUO,EAEvBG,EAAUJ,EACd,MACET,KAAAA,EACAc,cASAX,OAAQS,EACRG,WAIAT,cAAAA,EACAD,iBAAAA,IAEF,CAACL,EAAMY,EAAYP,EAAkBC,WAIrCU,EAACC,EAAYC,UAASC,MAAON,GAC1BO,EAAuBA,EAAuBZ,EAAUa,GAAMC,IAkB9D,SAASC,EAAO/B,wCAOAQ,EAAKR,uBAALgC,EAAa3B,WAAYkB,EAASvB,OANjDQ,KAACA,EAAMe,SAAAA,EAAPZ,OAAiBA,GAAUM,EAAiBQ,UAC3CR,EACL,qBAAO,CACLb,aAAII,EAAKR,uBAALiC,EAAa7B,GACjB8B,iBAAQ1B,EAAKR,uBAALmC,EAAahC,QACrBH,MAAOA,EACPuB,WACAa,SAAUpC,IAAUW,EACpBN,oBAAUG,EAAKR,yBAAQK,WAAY,IAErC,CAACG,EAAMR,EAAOW,EAAQY,IAgBnB,SAASM,SAAIzB,GAClBA,EADkBJ,MAElBA,EAFkBK,SAGlBA,EAAW,EAHOgC,YAIlBA,EAJkBC,cAKlBA,EALkBC,YAMlBA,EANkBC,cAOlBA,EAPkBC,SAQlBA,EAAW5C,EAROmB,SASlBA,KAEAZ,EAAKsC,EAAMtC,OACLkB,YAACA,GAAeqB,IAChBC,EAAa3B,EAA0B,OACvCT,KAACA,EAADK,iBAAOA,GAAoB8B,KAC3BP,SAACA,EAADb,SAAWA,GAAYQ,EAAO/B,GAC9B6C,EAAMC,EAEV9B,EAAS6B,IACTD,UAGFG,EAAOH,EAAY,CAEjBI,WAAY,IA4DhB,SAAmBxC,EAAkByC,eAC/BA,IAAiBzC,EAAK0C,OAAS,YAAG1C,EAAK,6BAAIL,wBAASgD,kBACnD3C,EAAKyC,EAAe,6BAAI9C,wBAASgD,QA9DlBC,CAAU5C,EAAMR,GAElCqD,UAAW,IA+Df,SAAmB7C,EAAkByC,eACd,IAAjBA,YAAoBzC,EAAKA,EAAK0C,OAAS,6BAAI/C,wBAASgD,kBACnD3C,EAAKyC,EAAe,6BAAI9C,wBAASgD,QAjEnBG,CAAU9C,EAAMR,GAEjCuD,KAAM,8BAAM/C,EAAK,2BAALgD,EAASrD,4BAATsD,EAAkBN,SAE9BO,IAAK,8BAAMlD,EAAKA,EAAK0C,OAAS,2BAAnBS,EAAuBxD,4BAAvByD,EAAgCT,SAE3CU,OAAQpB,IAGVxB,EACE,IACEK,EACEtB,EACA4C,EAAWkB,QACX1D,EACAC,IAIHD,EAAIC,EAAUL,IAIfwB,EAACuC,OACE9C,EAAmBD,EAAU,iBACXZ,kBACA,GAAKgC,kBACL,IAAMA,GAAY/B,GACnC2D,KAAM,MACNC,UACEC,EACElD,EAASmD,MAAMF,UACf7B,EAAWC,EAAcC,SACtB,EACP8B,MAAOC,OAAOC,OACZ,GACAtD,EAASmD,MAAMC,MACfhC,EAAWG,EAAcC,GAE3B+B,SAAUvD,EAASmD,MAAMK,eAAe,YACpCxD,EAASmD,MAAMI,SACfnC,EACA,GACC,EACLqC,QAAUC,YACH7D,GAAkBU,iBACvBP,EAASmD,OAAMM,+BAAUC,IAE3BC,QAAUD,YACRnD,iBACAP,EAASmD,OAAMQ,+BAAUD,IAE3B7B,IAAAA,KAoBD,SAAS+B,SAAQ5D,SAACA,YAChBC,EAAmBD,EAAU,CAClCgD,KAAM,YAaH,SAASlC,SAAM9B,MACpBA,EADoBqC,YAEpBA,EAFoBC,cAGpBA,EAHoBC,YAIpBA,EAJoBC,cAKpBA,EALoBxB,SAMpBA,MAEMoB,SAACA,EAADhC,GAAWA,GAAM2B,EAAO/B,IACxBa,iBAACA,EAADC,cAAmBA,GAAiB6B,IACpCkC,EAAa5D,EAAsBmB,GACnC0C,EAAW7D,EAA0B,MACrC4B,EAAMC,EAEV9B,EAAS6B,IACTiC,UAGFC,EACED,EACAjE,IAAqBgE,EAAWf,SAAW1B,EAC3C,CACE4C,YAAa,EACblE,cAAAA,IAOJmE,EAAgB,KACdJ,EAAWf,QAAU1B,GACpB,CAACA,EAAUpC,IAEPiB,EAAmBD,EAAU,mBACfoB,EACnBhC,GAAAA,EACA6D,UACEC,EAAKlD,EAASmD,MAAMF,UAAW7B,EAAWC,EAAcC,SACxD,EACF8B,MAAOC,OAAOC,OACZ,CAACY,WAAY9C,EAAW,UAAY,UACpCpB,EAASmD,MAAMC,MACfhC,EAAWG,EAAcC,GAE3B+B,SAAUvD,EAASmD,MAAMK,eAAe,YACpCxD,EAASmD,MAAMI,SACfnC,EACA,GACC,EACLS,IAAAA,8fA9WEjB,EAAyB,CAC7BuD,EACAlF,SAEID,EAAQ,EACRoF,EAAY,EACVpE,EAAWC,EAAeoE,IAAIF,EAAWG,QAExCrE,EAAqBqE,GAAQ,OAAOA,KAGtCrF,IAAS6B,IAAUwD,EAAMrF,OAAS2E,GAAWU,EAAMrF,OAAS4B,IAC5D5B,IAAS4B,GAAOyD,EAAMrF,OAAS6B,EAEhC,OAAOwD,KAELA,EAAMrF,OAASA,cAES,IAAtBqF,EAAMnB,MAAMnE,OACdA,EAAQsF,EAAMnB,MAAMnE,MAAQ,EACrBsF,IAEPF,EAAY,EACLnE,EAAmBqE,EAAO,CAACtF,MAAOA,UAI/B,IAAVA,EAAa,KACTuF,EAAe3D,EAAuB0D,EAAMnB,MAAMnD,SAAUf,UAC9DsF,IAAiBD,EAAMnB,MAAMnD,SAAiBsE,GAEhDF,EAAY,EACLnE,EAAmBqE,OAAO,EAAQC,WAItCD,WAGDF,EAA4C,KAArBpE,MAAAA,SAAAA,EAAUkC,QAAelC,EAAS,GAAKA,EAAlDmE,GAmBT1D,EAAcR,EAAsC,CAC7DT,KAAM,GACNc,YAAa,IAAMzB,EACnBc,YAAQ,EACRY,SAAU1B,EACVgB,iBAAkB,EAClBC,cAAe,KAEhB0E,SAAUC,GAAgBhE,EAC3BkB,EAAU,IAAM1B,EAAmCQ"}