UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

266 lines (257 loc) 9.48 kB
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 };