@primer/react
Version:
An implementation of GitHub's Primer Design System using React
266 lines (257 loc) • 9.48 kB
JavaScript
import { c } from 'react-compiler-runtime';
import React, { useState, useRef, useMemo, Children, isValidElement, cloneElement, createContext, useContext } from 'react';
import { TabContainerElement } from '@github/tab-container-element';
import { createComponent } from '../../utils/create-component.js';
import { UnderlineWrapper, UnderlineItemList, UnderlineItem } from '../../internal/components/UnderlineTabbedInterface.js';
import { invariant } from '../../utils/invariant.js';
import { useResizeObserver } from '../../hooks/useResizeObserver.js';
import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect.js';
import classes from './UnderlinePanels.module.css.js';
import { clsx } from 'clsx';
import { isSlot } from '../../utils/is-slot.js';
import { jsx, jsxs } from 'react/jsx-runtime';
import { useId } from '../../hooks/useId.js';
const TabContainerComponent = createComponent(TabContainerElement, 'tab-container');
// Carries flags that affect every Tab's rendering but that don't belong on the
// consumer-facing Tab API. Passing them via context (instead of cloneElement)
// keeps each Tab element's props referentially stable across UnderlinePanels
// re-renders, so React.memo(Tab) can skip work when an unrelated piece of
// state changes.
const UnderlinePanelsContext = /*#__PURE__*/createContext({
iconsVisible: true,
loadingCounters: undefined
});
const UnderlinePanels = ({
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
children,
loadingCounters,
className,
...props
}) => {
const [iconsVisible, setIconsVisible] = useState(true);
const wrapperRef = useRef(null);
const listRef = useRef(null);
// We need to always call useId() because React Hooks must be
// called in the exact same order in every component render
const parentId = useId(props.id);
const [tabs_0, tabPanels_0, tabsHaveIcons_0] = useMemo(() => {
// Walk children, clone each Tab with a generated id, and each Panel with a
// matching aria-labelledby. Derive in render so we never ship a
// "before-the-effect-ran" empty-tablist frame and so that re-renders of
// UnderlinePanels don't churn through an extra commit cycle.
//
// iconsVisible / loadingCounters are NOT baked into the cloned Tab
// elements — they flow through UnderlinePanelsContext, so this memo's deps
// can stay tight ([children, parentId]) and Tab elements stay
// referentially stable across resize-driven iconsVisible toggles.
let tabIndex = 0;
let panelIndex = 0;
const childrenWithProps = Children.map(children, child => {
if (/*#__PURE__*/isValidElement(child) && (child.type === Tab || isSlot(child, Tab))) {
return /*#__PURE__*/cloneElement(child, {
id: `${parentId}-tab-${tabIndex++}`
});
}
if (/*#__PURE__*/isValidElement(child) && (child.type === Panel || isSlot(child, Panel))) {
const childPanel = child;
return /*#__PURE__*/cloneElement(childPanel, {
'aria-labelledby': `${parentId}-tab-${panelIndex++}`
});
}
return child;
});
const tabs = [];
const tabPanels = [];
for (const child_0 of Children.toArray(childrenWithProps)) {
if (! /*#__PURE__*/isValidElement(child_0)) continue;
if (child_0.type === Tab || isSlot(child_0, Tab)) tabs.push(child_0);else if (child_0.type === Panel || isSlot(child_0, Panel)) tabPanels.push(child_0);
}
const tabsHaveIcons = tabs.some(tab => /*#__PURE__*/React.isValidElement(tab) && tab.props.icon);
return [tabs, tabPanels, tabsHaveIcons];
}, [children, parentId]);
const contextValue = useMemo(() => ({
iconsVisible,
loadingCounters
}), [iconsVisible, loadingCounters]);
// Mirror iconsVisible into a ref so the list observer below can read it
// without being re-created on every toggle (re-creating the observer
// would re-trigger its initial callback and churn extra work).
const iconsVisibleRef = useRef(iconsVisible);
useIsomorphicLayoutEffect(() => {
iconsVisibleRef.current = iconsVisible;
}, [iconsVisible]);
// The list's natural width (icons + labels), kept in sync via a
// ResizeObserver on the list — never read in render, so updates don't
// cause commits. Only refreshed while icons are visible: when icons are
// hidden the list is at its compressed width, which is not the value we
// want to compare against. The ResizeObserver fires synchronously on
// observe, which seeds the ref on mount for free.
const listWidthRef = useRef(0);
useResizeObserver(entries => {
if (!tabsHaveIcons_0) return;
if (!iconsVisibleRef.current) return;
listWidthRef.current = entries[0].contentRect.width;
}, listRef, []);
// when the wrapper resizes, check if the icons should be visible
// by comparing the wrapper width to the list width
useResizeObserver(resizeObserverEntries => {
if (!tabsHaveIcons_0) {
return;
}
const wrapperWidth = resizeObserverEntries[0].contentRect.width;
setIconsVisible(wrapperWidth > listWidthRef.current);
}, wrapperRef, []);
if (process.env.NODE_ENV !== "production") {
const selectedTabs = tabs_0.filter(tab_0 => {
const ariaSelected = /*#__PURE__*/React.isValidElement(tab_0) && tab_0.props['aria-selected'];
return ariaSelected === true || ariaSelected === 'true';
});
!(selectedTabs.length <= 1) ? process.env.NODE_ENV !== "production" ? invariant(false, 'Only one tab can be selected at a time.') : invariant(false) : void 0;
!(tabs_0.length === tabPanels_0.length) ? process.env.NODE_ENV !== "production" ? invariant(false, `The number of tabs and panels must be equal. Counted ${tabs_0.length} tabs and ${tabPanels_0.length} panels.`) : invariant(false) : void 0;
}
return /*#__PURE__*/jsx(UnderlinePanelsContext.Provider, {
value: contextValue,
children: /*#__PURE__*/jsxs(TabContainerComponent, {
children: [/*#__PURE__*/jsx(UnderlineWrapper, {
ref: wrapperRef,
slot: "tablist-wrapper",
"data-icons-visible": iconsVisible,
className: clsx(className, classes.StyledUnderlineWrapper),
...props,
children: /*#__PURE__*/jsx(UnderlineItemList, {
ref: listRef,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
role: "tablist",
children: tabs_0
})
}), tabPanels_0]
})
});
};
UnderlinePanels.displayName = "UnderlinePanels";
const TabImpl = t0 => {
const $ = c(16);
let ariaSelected;
let onSelect;
let props;
if ($[0] !== t0) {
({
"aria-selected": ariaSelected,
onSelect,
...props
} = t0);
$[0] = t0;
$[1] = ariaSelected;
$[2] = onSelect;
$[3] = props;
} else {
ariaSelected = $[1];
onSelect = $[2];
props = $[3];
}
const {
iconsVisible,
loadingCounters
} = useContext(UnderlinePanelsContext);
let t1;
if ($[4] !== onSelect) {
t1 = event => {
if (!event.defaultPrevented && typeof onSelect === "function") {
onSelect(event);
}
};
$[4] = onSelect;
$[5] = t1;
} else {
t1 = $[5];
}
const clickHandler = t1;
let t2;
if ($[6] !== onSelect) {
t2 = event_0 => {
if ((event_0.key === " " || event_0.key === "Enter") && !event_0.defaultPrevented && typeof onSelect === "function") {
onSelect(event_0);
}
};
$[6] = onSelect;
$[7] = t2;
} else {
t2 = $[7];
}
const keyDownHandler = t2;
const t3 = ariaSelected ? 0 : -1;
let t4;
if ($[8] !== ariaSelected || $[9] !== clickHandler || $[10] !== iconsVisible || $[11] !== keyDownHandler || $[12] !== loadingCounters || $[13] !== props || $[14] !== t3) {
t4 = /*#__PURE__*/jsx(UnderlineItem, {
as: "button",
role: "tab",
tabIndex: t3,
"aria-selected": ariaSelected,
type: "button",
onClick: clickHandler,
onKeyDown: keyDownHandler,
iconsVisible: iconsVisible,
loadingCounters: loadingCounters,
...props
});
$[8] = ariaSelected;
$[9] = clickHandler;
$[10] = iconsVisible;
$[11] = keyDownHandler;
$[12] = loadingCounters;
$[13] = props;
$[14] = t3;
$[15] = t4;
} else {
t4 = $[15];
}
return t4;
};
// Memoized so that UnderlinePanels re-rendering (e.g. when iconsVisible flips)
// only re-renders Tabs whose own props actually changed. iconsVisible and
// loadingCounters reach Tab via UnderlinePanelsContext, so Tabs still react
// to those changes through context propagation.
TabImpl.displayName = 'UnderlinePanels.Tab';
const Tab = /*#__PURE__*/React.memo(TabImpl);
Tab.displayName = 'UnderlinePanels.Tab';
const Panel = t0 => {
const $ = c(6);
let children;
let rest;
if ($[0] !== t0) {
({
children,
...rest
} = t0);
$[0] = t0;
$[1] = children;
$[2] = rest;
} else {
children = $[1];
rest = $[2];
}
let t1;
if ($[3] !== children || $[4] !== rest) {
t1 = /*#__PURE__*/jsx("div", {
role: "tabpanel",
...rest,
children: children
});
$[3] = children;
$[4] = rest;
$[5] = t1;
} else {
t1 = $[5];
}
return t1;
};
Panel.displayName = 'UnderlinePanels.Panel';
var UnderlinePanels_default = Object.assign(UnderlinePanels, {
Panel,
Tab
});
Tab.__SLOT__ = Symbol('UnderlinePanels.Tab');
Panel.__SLOT__ = Symbol('UnderlinePanels.Panel');
export { UnderlinePanels_default as default };