-
-
Save krvajal/5d3ce8cb2bd127929af2fde6f05a2404 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { | |
cloneElement, | |
useState, | |
useEffect, | |
useRef, | |
HTMLAttributes, | |
ReactElement | |
} from "react"; | |
//////////////////////////////////////////////////////////////////////////////// | |
// rIC Shim (for low-priority updates in suspense, enables the tab to change | |
// style immediately while the content suspends). Should probably depend on some | |
// other shim from npm. No clue how to type this kind of thing, so I just cast | |
// window as `any` | |
interface RICHandlers { | |
[key: string]: () => void; | |
} | |
if (!("requestIdleCallback" in window)) { | |
let c = 0; | |
let handlers: RICHandlers = {}; | |
let k = () => {}; | |
(window as any).requestIdleCallback = (fn: any) => { | |
let id = ++c; | |
handlers[id] = fn; | |
Promise.resolve().then(() => handlers[id]()); | |
}; | |
(window as any).cancelIdleCallback = (id: number) => { | |
handlers[id] = k; | |
}; | |
} | |
let requestIdleCallback = (window as any).requestIdleCallback; | |
let cancelIdleCallback = (window as any).cancelIdleCallback; | |
//////////////////////////////////////////////////////////////////////////////// | |
// Tabs | |
export interface TabsProps { | |
children: React.ReactElement<any>[]; | |
onChange?: (index: number) => void; | |
index?: number; | |
defaultIndex?: number; | |
mode?: TabSelectMode; | |
} | |
// Auto: | |
// - only the active tab is focusable with tab key navigation | |
// - arrow keys navigate the tabs and automatically select | |
// the next tab in the list | |
// Manual: | |
// - all tabs are focusable with tab key navigation | |
// - arrow keys do nothing, user must focus an item and select it | |
export type TabSelectMode = "auto" | "manual"; | |
// doesn't seem like we should need this? Shouldn't @types/react know | |
// what the shape of a ref is? Does it not get it because we're passing | |
// it through cloneElement and it can't figure that out? | |
type UserInteractedRef = { current: boolean }; | |
export function Tabs({ | |
children, | |
onChange, | |
index: controlledIndex = undefined, | |
defaultIndex, | |
mode = "auto", | |
...props | |
}: TabsProps & HTMLAttributes<HTMLDivElement>) { | |
// null checks because index can be 0 😅 | |
let { current: isControlled } = useRef(controlledIndex != null); | |
// we only manage focus if the user caused the update vs. | |
// a new controlled index coming in 👩🏽💻 | |
let userInteractedRef = useRef(false); | |
// allows tab active styles to update before suspending 🕐 | |
let [pendingIndex, setPendingIndex] = useState(-1); | |
// prevents focus from getting out of sync w/ state on | |
// very quick navigation between tabs 💨 | |
let idleCallbackRef = useRef(null); | |
// I feel like this needs an explanation cause everybody else | |
// got one ... this is the state 😂 | |
let [activeIndex, setActiveIndex] = useState(defaultIndex || 0); | |
// seems like the type on cloneElement could be better? | |
let clones = React.Children.map(children, child => | |
cloneElement(child as React.ReactElement<any>, { | |
activeIndex: isControlled ? controlledIndex : activeIndex, | |
pendingIndex, | |
mode, | |
userInteractedRef, | |
onActivateTab: (index: number) => { | |
userInteractedRef.current = true; | |
onChange && onChange(index); | |
if (!isControlled) { | |
setActiveIndex(index); | |
} | |
// This stuff works when suspense is involved, but breaks when its not, | |
// so I commented it all out for now | |
// cancelIdleCallback(idleCallbackRef.current); | |
// userInteractedRef.current = true; | |
// requestIdleCallback(() => (userInteractedRef.current = false)); | |
// setPendingIndex(index); | |
// idleCallbackRef.current = requestIdleCallback(() => { | |
// onChange && onChange(index); | |
// setPendingIndex(-1); | |
// if (!isControlled) { | |
// setActiveIndex(index); | |
// } | |
// }); | |
} | |
}) | |
); | |
return <div data-reach-tabs {...props} children={clones} />; | |
} | |
//////////////////////////////////////////////////////////////////////////////// | |
// TabList | |
export interface TabListProps { | |
children: React.ReactElement<any>[]; | |
} | |
interface TabListClonedProps { | |
mode: TabSelectMode; | |
activeIndex: number; | |
pendingIndex: number; | |
onActivateTab: (index: number) => void; | |
userInteractedRef: UserInteractedRef; | |
} | |
export function TabList({ | |
children, | |
...rest | |
}: TabListProps & HTMLAttributes<HTMLDivElement>) { | |
let { | |
mode, | |
activeIndex, | |
pendingIndex, | |
onActivateTab, | |
userInteractedRef, | |
...htmlProps | |
} = rest as TabListClonedProps; | |
let clones = React.Children.map(children, (child, index) => { | |
return cloneElement( | |
child as React.ReactElement<TabProps & TabClonedProps>, | |
{ | |
mode, | |
userInteractedRef, | |
isActive: index === activeIndex, | |
onActivate: () => onActivateTab(index) | |
} | |
); | |
}); | |
// TODO: wrap in preventable event | |
let handleKeyDown = (event: React.KeyboardEvent) => { | |
switch (event.key) { | |
case "ArrowRight": | |
case "ArrowDown": { | |
onActivateTab((activeIndex + 1) % React.Children.count(children)); | |
break; | |
} | |
case "ArrowLeft": | |
case "ArrowUp": { | |
let count = React.Children.count(children); | |
onActivateTab((activeIndex - 1 + count) % count); | |
break; | |
} | |
case "Home": { | |
onActivateTab(0); | |
break; | |
} | |
case "End": { | |
onActivateTab(React.Children.count(children) - 1); | |
break; | |
} | |
default: { | |
} | |
} | |
}; | |
return ( | |
<div | |
role="tablist" | |
data-reach-tab-list | |
onKeyDown={mode === "auto" ? handleKeyDown : undefined} | |
children={clones} | |
{...htmlProps} | |
/> | |
); | |
} | |
function useUpdateEffect(effect: () => void, deps: any[]) { | |
let mounted = useRef(false); | |
useEffect(() => { | |
if (mounted.current) { | |
effect(); | |
} else { | |
mounted.current = true; | |
} | |
}, deps); | |
} | |
//////////////////////////////////////////////////////////////////////////////// | |
// Tab | |
export interface TabProps { | |
children: React.ReactNode; | |
} | |
export interface TabClonedProps { | |
mode: TabSelectMode; | |
userInteractedRef: UserInteractedRef; | |
onActivate: (event: React.MouseEvent) => void; | |
isActive: boolean; | |
} | |
export function Tab({ children, ...rest }: TabProps) { | |
let { | |
userInteractedRef, | |
mode, | |
onActivate, | |
isActive, | |
...htmlProps | |
} = rest as TabClonedProps; | |
// TODO | |
let controls = undefined; | |
let ref = useRef<HTMLButtonElement>(null); | |
useUpdateEffect( | |
() => { | |
if (isActive && ref.current && userInteractedRef.current) { | |
ref.current.focus(); | |
} | |
}, | |
[isActive] | |
); | |
return ( | |
<button | |
ref={ref} | |
role="tab" | |
data-reach-tab | |
aria-selected={isActive} | |
aria-controls={controls} | |
onClick={onActivate} | |
tabIndex={mode === "manual" ? undefined : isActive ? 0 : -1} | |
children={children} | |
{...htmlProps} | |
/> | |
); | |
} | |
//////////////////////////////////////////////////////////////////////////////// | |
// TabPanels | |
export interface TabPanelsProps { | |
children: React.ReactElement<any>[]; | |
} | |
interface TabPanelsClonedProps { | |
activeIndex: number; | |
} | |
export function TabPanels({ children, ...rest }: TabPanelsProps) { | |
let { activeIndex } = rest as TabPanelsClonedProps; | |
return React.Children.map(children, (child, index) => | |
cloneElement(child, { isActive: index === activeIndex }) | |
) as any; | |
} | |
//////////////////////////////////////////////////////////////////////////////// | |
// TabPanel | |
export interface TabPanelProps { | |
children: React.ReactNode; | |
} | |
interface TabPanelClonedProps { | |
activeIndex: number; | |
} | |
export function TabPanel({ children, ...rest }: TabPanelProps) { | |
let { isActive, ...htmlProps } = rest as TabClonedProps; | |
// TODO: should match aria-controls in Tab | |
let labelledBy = undefined; | |
return ( | |
<div | |
tabIndex={0} | |
role="tabpanel" | |
aria-labelledby={labelledBy} | |
data-reach-tabpanel | |
data-active={isActive ? "true" : undefined} | |
hidden={!isActive} | |
children={children} | |
{...htmlProps} | |
/> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment