UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

657 lines (639 loc) • 21.9 kB
import React__default from 'react'; import { createGlobalStyle } from 'styled-components'; import { useId } from '../hooks/useId.js'; import { useRefObjectAsForwardedRef } from '../hooks/useRefObjectAsForwardedRef.js'; import { isResponsiveValue, useResponsiveValue } from '../hooks/useResponsiveValue.js'; import { useSlots } from '../hooks/useSlots.js'; import '../sx.js'; import { canUseDOM } from '../utils/environment.js'; import { useOverflow } from '../internal/hooks/useOverflow.js'; import { warning } from '../utils/warning.js'; import VisuallyHidden from '../_VisuallyHidden.js'; import { useStickyPaneHeight } from './useStickyPaneHeight.js'; import Box from '../Box/Box.js'; import merge from 'deepmerge'; function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } const REGION_ORDER = { header: 0, paneStart: 1, content: 2, paneEnd: 3, footer: 4 }; const SPACING_MAP = { none: 0, condensed: 3, normal: [3, null, null, 4] }; const PageLayoutContext = /*#__PURE__*/React__default.createContext({ padding: 'normal', rowGap: 'normal', columnGap: 'normal' }); // ---------------------------------------------------------------------------- // PageLayout const containerWidths = { full: '100%', medium: '768px', large: '1012px', xlarge: '1280px' }; // TODO: refs const Root = ({ containerWidth = 'xlarge', padding = 'normal', rowGap = 'normal', columnGap = 'normal', children, sx = {}, _slotsConfig: slotsConfig }) => { const { rootRef, enableStickyPane, disableStickyPane, contentTopRef, contentBottomRef, stickyPaneHeight } = useStickyPaneHeight(); const [slots, rest] = useSlots(children, slotsConfig !== null && slotsConfig !== void 0 ? slotsConfig : { header: Header, footer: Footer }); return /*#__PURE__*/React__default.createElement(PageLayoutContext.Provider, { value: { padding, rowGap, columnGap, enableStickyPane, disableStickyPane, contentTopRef, contentBottomRef } }, /*#__PURE__*/React__default.createElement(Box, { ref: rootRef, style: { // @ts-ignore TypeScript doesn't know about CSS custom properties '--sticky-pane-height': stickyPaneHeight }, sx: merge({ padding: SPACING_MAP[padding] }, sx) }, /*#__PURE__*/React__default.createElement(Box, { sx: { maxWidth: containerWidths[containerWidth], marginX: 'auto', display: 'flex', flexWrap: 'wrap' } }, slots.header, /*#__PURE__*/React__default.createElement(Box, { sx: { display: 'flex', flex: '1 1 100%', flexWrap: 'wrap', maxWidth: '100%' } }, rest), slots.footer))); }; Root.displayName = "Root"; Root.displayName = 'PageLayout'; // ---------------------------------------------------------------------------- // Divider (internal) const horizontalDividerVariants = { none: { display: 'none' }, line: { display: 'block', height: 1, backgroundColor: 'border.default' }, filled: { display: 'block', height: 8, backgroundColor: 'canvas.inset', // eslint-disable-next-line @typescript-eslint/no-explicit-any boxShadow: theme => `inset 0 -1px 0 0 ${theme.colors.border.default}, inset 0 1px 0 0 ${theme.colors.border.default}` } }; function negateSpacingValue(value) { if (Array.isArray(value)) { // Not using recursion to avoid deeply nested arrays return value.map(v => v === null ? null : -v); } return value === null ? null : -value; } const HorizontalDivider = ({ variant = 'none', sx = {} }) => { const { padding } = React__default.useContext(PageLayoutContext); const responsiveVariant = useResponsiveValue(variant, 'none'); return /*#__PURE__*/React__default.createElement(Box // eslint-disable-next-line @typescript-eslint/no-explicit-any , { sx: theme => merge({ // Stretch divider to viewport edges on narrow screens marginX: negateSpacingValue(SPACING_MAP[padding]), ...horizontalDividerVariants[responsiveVariant], [`@media screen and (min-width: ${theme.breakpoints[1]})`]: { marginX: '0 !important' } }, sx) }); }; HorizontalDivider.displayName = "HorizontalDivider"; const verticalDividerVariants = { none: { display: 'none' }, line: { display: 'block', width: 1, backgroundColor: 'border.default' }, filled: { display: 'block', width: 8, backgroundColor: 'canvas.inset', // eslint-disable-next-line @typescript-eslint/no-explicit-any boxShadow: theme => `inset -1px 0 0 0 ${theme.colors.border.default}, inset 1px 0 0 0 ${theme.colors.border.default}` } }; const DraggingGlobalStyles = createGlobalStyle(["body[data-page-layout-dragging=\"true\"]{cursor:col-resize;}body[data-page-layout-dragging=\"true\"] *{user-select:none;}"]); const VerticalDivider = ({ variant = 'none', draggable = false, onDragStart, onDrag, onDragEnd, onDoubleClick, sx = {} }) => { const [isDragging, setIsDragging] = React__default.useState(false); const responsiveVariant = useResponsiveValue(variant, 'none'); const stableOnDrag = React__default.useRef(onDrag); const stableOnDragEnd = React__default.useRef(onDragEnd); React__default.useEffect(() => { stableOnDrag.current = onDrag; }, [onDrag]); React__default.useEffect(() => { stableOnDragEnd.current = onDragEnd; }, [onDragEnd]); React__default.useEffect(() => { function handleDrag(event) { var _stableOnDrag$current; (_stableOnDrag$current = stableOnDrag.current) === null || _stableOnDrag$current === void 0 ? void 0 : _stableOnDrag$current.call(stableOnDrag, event.movementX); event.preventDefault(); } function handleDragEnd(event) { var _stableOnDragEnd$curr; setIsDragging(false); (_stableOnDragEnd$curr = stableOnDragEnd.current) === null || _stableOnDragEnd$curr === void 0 ? void 0 : _stableOnDragEnd$curr.call(stableOnDragEnd); event.preventDefault(); } // TODO: Support touch events if (isDragging) { window.addEventListener('mousemove', handleDrag); window.addEventListener('mouseup', handleDragEnd); document.body.setAttribute('data-page-layout-dragging', 'true'); } else { window.removeEventListener('mousemove', handleDrag); window.removeEventListener('mouseup', handleDragEnd); document.body.removeAttribute('data-page-layout-dragging'); } return () => { window.removeEventListener('mousemove', handleDrag); window.removeEventListener('mouseup', handleDragEnd); document.body.removeAttribute('data-page-layout-dragging'); }; }, [isDragging]); return /*#__PURE__*/React__default.createElement(Box, { sx: merge({ height: '100%', position: 'relative', ...verticalDividerVariants[responsiveVariant] }, sx) }, draggable ? /*#__PURE__*/ // Drag handle React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(Box, { sx: { position: 'absolute', inset: '0 -2px', cursor: 'col-resize', bg: isDragging ? 'accent.fg' : 'transparent', transitionDelay: '0.1s', '&:hover': { bg: isDragging ? 'accent.fg' : 'neutral.muted' } }, role: "separator", onMouseDown: () => { setIsDragging(true); onDragStart === null || onDragStart === void 0 ? void 0 : onDragStart(); }, onDoubleClick: onDoubleClick }), /*#__PURE__*/React__default.createElement(DraggingGlobalStyles, null)) : null); }; VerticalDivider.displayName = "VerticalDivider"; const Header = ({ 'aria-label': label, 'aria-labelledby': labelledBy, padding = 'none', divider = 'none', dividerWhenNarrow = 'inherit', hidden = false, children, sx = {} }) => { // Combine divider and dividerWhenNarrow for backwards compatibility const dividerProp = !isResponsiveValue(divider) && dividerWhenNarrow !== 'inherit' ? { regular: divider, narrow: dividerWhenNarrow } : divider; const dividerVariant = useResponsiveValue(dividerProp, 'none'); const isHidden = useResponsiveValue(hidden, false); const { rowGap } = React__default.useContext(PageLayoutContext); return /*#__PURE__*/React__default.createElement(Box, { as: "header", "aria-label": label, "aria-labelledby": labelledBy, hidden: isHidden, sx: merge({ width: '100%', marginBottom: SPACING_MAP[rowGap] }, sx) }, /*#__PURE__*/React__default.createElement(Box, { sx: { padding: SPACING_MAP[padding] } }, children), /*#__PURE__*/React__default.createElement(HorizontalDivider, { variant: dividerVariant, sx: { marginTop: SPACING_MAP[rowGap] } })); }; Header.displayName = "Header"; Header.displayName = 'PageLayout.Header'; // ---------------------------------------------------------------------------- // PageLayout.Content // TODO: Account for pane width when centering content const contentWidths = { full: '100%', medium: '768px', large: '1012px', xlarge: '1280px' }; const Content = ({ 'aria-label': label, 'aria-labelledby': labelledBy, width = 'full', padding = 'none', hidden = false, children, sx = {} }) => { const isHidden = useResponsiveValue(hidden, false); const { contentTopRef, contentBottomRef } = React__default.useContext(PageLayoutContext); return /*#__PURE__*/React__default.createElement(Box, { as: "main", "aria-label": label, "aria-labelledby": labelledBy, sx: merge({ display: isHidden ? 'none' : 'flex', flexDirection: 'column', order: REGION_ORDER.content, // Set flex-basis to 0% to allow flex-grow to control the width of the content region. // Without this, the content region could wrap onto a different line // than the pane region on wide viewports if its contents are too wide. flexBasis: 0, flexGrow: 1, flexShrink: 1, minWidth: 1 // Hack to prevent overflowing content from pushing the pane region to the next line }, sx) }, /*#__PURE__*/React__default.createElement(Box, { ref: contentTopRef }), /*#__PURE__*/React__default.createElement(Box, { sx: { width: '100%', maxWidth: contentWidths[width], marginX: 'auto', flexGrow: 1, padding: SPACING_MAP[padding] } }, children), /*#__PURE__*/React__default.createElement(Box, { ref: contentBottomRef })); }; Content.displayName = "Content"; Content.displayName = 'PageLayout.Content'; // ---------------------------------------------------------------------------- // PageLayout.Pane const panePositions = { start: REGION_ORDER.paneStart, end: REGION_ORDER.paneEnd }; const paneWidths = { small: ['100%', null, '240px', '256px'], medium: ['100%', null, '256px', '296px'], large: ['100%', null, '256px', '320px', '336px'] }; const defaultPaneWidth = { small: 256, medium: 296, large: 320 }; const Pane = /*#__PURE__*/React__default.forwardRef(({ 'aria-label': label, 'aria-labelledby': labelledBy, position: responsivePosition = 'end', positionWhenNarrow = 'inherit', width = 'medium', padding = 'none', resizable = false, widthStorageKey = 'paneWidth', divider: responsiveDivider = 'none', dividerWhenNarrow = 'inherit', sticky = false, offsetHeader = 0, hidden: responsiveHidden = false, children, id, sx = {} }, forwardRef) => { // Combine position and positionWhenNarrow for backwards compatibility const positionProp = !isResponsiveValue(responsivePosition) && positionWhenNarrow !== 'inherit' ? { regular: responsivePosition, narrow: positionWhenNarrow } : responsivePosition; const position = useResponsiveValue(positionProp, 'end'); // Combine divider and dividerWhenNarrow for backwards compatibility const dividerProp = !isResponsiveValue(responsiveDivider) && dividerWhenNarrow !== 'inherit' ? { regular: responsiveDivider, narrow: dividerWhenNarrow } : responsiveDivider; const dividerVariant = useResponsiveValue(dividerProp, 'none'); const isHidden = useResponsiveValue(responsiveHidden, false); const { rowGap, columnGap, enableStickyPane, disableStickyPane } = React__default.useContext(PageLayoutContext); React__default.useEffect(() => { if (sticky) { enableStickyPane === null || enableStickyPane === void 0 ? void 0 : enableStickyPane(offsetHeader); } else { disableStickyPane === null || disableStickyPane === void 0 ? void 0 : disableStickyPane(); } }, [sticky, enableStickyPane, disableStickyPane, offsetHeader]); const [paneWidth, setPaneWidth] = React__default.useState(() => { if (!canUseDOM) { return defaultPaneWidth[width]; } let storedWidth; try { storedWidth = localStorage.getItem(widthStorageKey); } catch (error) { storedWidth = null; } return storedWidth && !isNaN(Number(storedWidth)) ? Number(storedWidth) : defaultPaneWidth[width]; }); const updatePaneWidth = width => { setPaneWidth(width); try { localStorage.setItem(widthStorageKey, width.toString()); } catch (error) { // Ignore errors } }; const paneRef = React__default.useRef(null); useRefObjectAsForwardedRef(forwardRef, paneRef); const MIN_PANE_WIDTH = 256; // 256px, related to `--pane-min-width CSS var. const [minPercent, setMinPercent] = React__default.useState(0); const [maxPercent, setMaxPercent] = React__default.useState(0); const hasOverflow = useOverflow(paneRef); const measuredRef = React__default.useCallback(() => { if (paneRef.current !== null) { const maxPaneWidthDiffPixels = getComputedStyle(paneRef.current).getPropertyValue('--pane-max-width-diff'); const paneWidth = paneRef.current.getBoundingClientRect().width; const maxPaneWidthDiff = Number(maxPaneWidthDiffPixels.split('px')[0]); const viewportWidth = window.innerWidth; const maxPaneWidth = viewportWidth > maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth; const minPercent = Math.round(100 * MIN_PANE_WIDTH / viewportWidth); setMinPercent(minPercent); const maxPercent = Math.round(100 * maxPaneWidth / viewportWidth); setMaxPercent(maxPercent); const widthPercent = Math.round(100 * paneWidth / viewportWidth); setWidthPercent(widthPercent.toString()); } }, [paneRef]); const [widthPercent, setWidthPercent] = React__default.useState(''); const [prevPercent, setPrevPercent] = React__default.useState(''); const handleWidthFormSubmit = event => { event.preventDefault(); let percent = Number(widthPercent); if (Number.isNaN(percent)) { percent = Number(prevPercent) || minPercent; } else if (percent > maxPercent) { percent = maxPercent; } else if (percent < minPercent) { percent = minPercent; } setWidthPercent(percent.toString()); // Cache previous valid percent. setPrevPercent(percent.toString()); updatePaneWidth(percent / 100 * window.innerWidth); }; const paneId = useId(id); const labelProp = {}; if (hasOverflow) { process.env.NODE_ENV !== "production" ? warning(label === undefined && labelledBy === undefined, 'The <PageLayout.Pane> has overflow and `aria-label` or `aria-labelledby` has not been set. ' + 'Please provide `aria-label` or `aria-labelledby` to <PageLayout.Pane> in order to label this ' + 'region.') : void 0; if (labelledBy) { labelProp['aria-labelledby'] = labelledBy; } else if (label) { labelProp['aria-label'] = label; } } return /*#__PURE__*/React__default.createElement(Box, { ref: measuredRef // eslint-disable-next-line @typescript-eslint/no-explicit-any , sx: theme => merge({ // Narrow viewports display: isHidden ? 'none' : 'flex', order: panePositions[position], width: '100%', marginX: 0, ...(position === 'end' ? { flexDirection: 'column', marginTop: SPACING_MAP[rowGap] } : { flexDirection: 'column-reverse', marginBottom: SPACING_MAP[rowGap] }), // Regular and wide viewports [`@media screen and (min-width: ${theme.breakpoints[1]})`]: { width: 'auto', marginY: '0 !important', ...(sticky ? { position: 'sticky', // If offsetHeader has value, it will stick the pane to the position where the sticky top ends // else top will be 0 as the default value of offsetHeader top: typeof offsetHeader === 'number' ? `${offsetHeader}px` : offsetHeader, maxHeight: 'var(--sticky-pane-height)' } : {}), ...(position === 'end' ? { flexDirection: 'row', marginLeft: SPACING_MAP[columnGap] } : { flexDirection: 'row-reverse', marginRight: SPACING_MAP[columnGap] }) } }, sx) }, /*#__PURE__*/React__default.createElement(HorizontalDivider, { variant: { narrow: dividerVariant, regular: 'none' }, sx: { [position === 'end' ? 'marginBottom' : 'marginTop']: SPACING_MAP[rowGap] } }), /*#__PURE__*/React__default.createElement(VerticalDivider, { variant: { narrow: 'none', // If pane is resizable, always show a vertical divider on regular viewports regular: resizable ? 'line' : dividerVariant } // If pane is resizable, the divider should be draggable , draggable: resizable, sx: { [position === 'end' ? 'marginRight' : 'marginLeft']: SPACING_MAP[columnGap] }, onDrag: delta => { // Get the number of pixels the divider was dragged const deltaWithDirection = position === 'end' ? -delta : delta; updatePaneWidth(paneWidth + deltaWithDirection); } // Ensure `paneWidth` state and actual pane width are in sync when the drag ends , onDragEnd: () => { var _paneRef$current; const paneRect = (_paneRef$current = paneRef.current) === null || _paneRef$current === void 0 ? void 0 : _paneRef$current.getBoundingClientRect(); if (!paneRect) return; updatePaneWidth(paneRect.width); } // Reset pane width on double click , onDoubleClick: () => updatePaneWidth(defaultPaneWidth[width]) }), /*#__PURE__*/React__default.createElement(Box, _extends({ ref: paneRef, style: { // @ts-ignore CSS custom properties are not supported by TypeScript '--pane-width': `${paneWidth}px` }, sx: theme => ({ '--pane-min-width': `256px`, '--pane-max-width-diff': '511px', '--pane-max-width': `calc(100vw - var(--pane-max-width-diff))`, width: resizable ? ['100%', null, 'clamp(var(--pane-min-width), var(--pane-width), var(--pane-max-width))'] : paneWidths[width], padding: SPACING_MAP[padding], overflow: [null, null, 'auto'], [`@media screen and (min-width: ${theme.breakpoints[3]})`]: { '--pane-max-width-diff': '959px' } }) }, hasOverflow && { tabIndex: 0, role: 'region' }, labelProp, id && { id: paneId }), resizable && /*#__PURE__*/React__default.createElement(VisuallyHidden, null, /*#__PURE__*/React__default.createElement("form", { onSubmit: handleWidthFormSubmit }, /*#__PURE__*/React__default.createElement("label", { htmlFor: `${paneId}-width-input` }, "Pane width"), /*#__PURE__*/React__default.createElement("p", { id: `${paneId}-input-hint` }, "Use a value between ", minPercent, "% and ", maxPercent, "%"), /*#__PURE__*/React__default.createElement("input", { id: `${paneId}-width-input`, "aria-describedby": `${paneId}-input-hint`, name: "pane-width", inputMode: "numeric", pattern: "[0-9]*", value: widthPercent, autoCorrect: "off", autoComplete: "off", type: "text", onChange: event => { setWidthPercent(event.target.value); } }), /*#__PURE__*/React__default.createElement("button", { type: "submit" }, "Change width"))), children)); }); Pane.displayName = 'PageLayout.Pane'; // ---------------------------------------------------------------------------- // PageLayout.Footer const Footer = ({ 'aria-label': label, 'aria-labelledby': labelledBy, padding = 'none', divider = 'none', dividerWhenNarrow = 'inherit', hidden = false, children, sx = {} }) => { // Combine divider and dividerWhenNarrow for backwards compatibility const dividerProp = !isResponsiveValue(divider) && dividerWhenNarrow !== 'inherit' ? { regular: divider, narrow: dividerWhenNarrow } : divider; const dividerVariant = useResponsiveValue(dividerProp, 'none'); const isHidden = useResponsiveValue(hidden, false); const { rowGap } = React__default.useContext(PageLayoutContext); return /*#__PURE__*/React__default.createElement(Box, { as: "footer", "aria-label": label, "aria-labelledby": labelledBy, hidden: isHidden, sx: merge({ order: REGION_ORDER.footer, width: '100%', marginTop: SPACING_MAP[rowGap] }, sx) }, /*#__PURE__*/React__default.createElement(HorizontalDivider, { variant: dividerVariant, sx: { marginBottom: SPACING_MAP[rowGap] } }), /*#__PURE__*/React__default.createElement(Box, { sx: { padding: SPACING_MAP[padding] } }, children)); }; Footer.displayName = "Footer"; Footer.displayName = 'PageLayout.Footer'; // ---------------------------------------------------------------------------- // Export const PageLayout = Object.assign(Root, { Header, Content, Pane, Footer }); export { PageLayout };