Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Created January 17, 2019 21:17
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save ryanflorence/ddc04ce18924eb2a7f3458cd280893c2 to your computer and use it in GitHub Desktop.
Save ryanflorence/ddc04ce18924eb2a7f3458cd280893c2 to your computer and use it in GitHub Desktop.
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