UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

592 lines (574 loc) • 21.3 kB
import React, { useState, useEffect } from 'react'; import { AlertIcon, SearchIcon, XCircleFillIcon, ArrowLeftIcon, FilterRemoveIcon, XIcon } from '@primer/octicons-react'; import { ActionListContainerContext } from '../../ActionList/ActionListContainerContext.js'; import { useSlots } from '../../hooks/useSlots.js'; import { BaseOverlay, heightMap } from '../../Overlay/Overlay.js'; import { InputLabel } from '../../internal/components/InputLabel.js'; import { invariant } from '../../utils/invariant.js'; import { useResponsiveValue } from '../../hooks/useResponsiveValue.js'; import { clsx } from 'clsx'; import classes from './SelectPanel.module.css.js'; import { isSlot } from '../../utils/is-slot.js'; import { jsxs, Fragment, jsx } from 'react/jsx-runtime'; import { useProvidedRefOrCreate } from '../../hooks/useProvidedRefOrCreate.js'; import { useId } from '../../hooks/useId.js'; import { useAnchoredPosition } from '../../hooks/useAnchoredPosition.js'; import { AriaStatus } from '../../live-region/AriaStatus.js'; import Octicon from '../../Octicon/Octicon.js'; import Spinner from '../../Spinner/Spinner.js'; import { ButtonComponent } from '../../Button/Button.js'; import TextInput from '../../TextInput/TextInput.js'; import { IconButton } from '../../Button/IconButton.js'; import Heading from '../../Heading/Heading.js'; import { useFormControlForwardedProps } from '../../FormControl/_FormControlContext.js'; import Link from '../../Link/Link.js'; import Checkbox from '../../Checkbox/Checkbox.js'; const SelectPanelContext = /*#__PURE__*/React.createContext({ title: '', description: undefined, panelId: '', onCancel: () => {}, onClearSelection: undefined, searchQuery: '', setSearchQuery: () => {}, selectionVariant: 'multiple', moveFocusToList: () => {} }); const responsiveButtonSizes = { narrow: 'medium', regular: 'small' }; const Panel = ({ title, description, variant: propsVariant, selectionVariant = 'multiple', id, defaultOpen = false, open: propsOpen, anchorRef: providedAnchorRef, anchoredPositionSettings, onCancel: propsOnCancel, onClearSelection: propsOnClearSelection, onSubmit: propsOnSubmit, width = 'medium', maxHeight = 'large', className, ...props }) => { var _position$top, _position$left, _slots$header; const [internalOpen, setInternalOpen] = React.useState(defaultOpen); const responsiveVariants = Object.assign({ regular: 'anchored', narrow: 'full-screen' }, // defaults typeof propsVariant === 'string' ? { regular: propsVariant } : propsVariant); const currentVariant = useResponsiveValue(responsiveVariants, 'anchored'); // sync open state with props if (propsOpen !== undefined && internalOpen !== propsOpen) setInternalOpen(propsOpen); // TODO: replace this hack with clone element? // 🚨 Hack for good API! // we strip out Anchor from children and wire it up to Dialog // with additional props for accessibility // eslint-disable-next-line @typescript-eslint/no-explicit-any let Anchor; const anchorRef = useProvidedRefOrCreate(providedAnchorRef); const onAnchorClick = () => { if (!internalOpen) setInternalOpen(true);else onInternalCancel(); }; const contents = React.Children.map(props.children, child => { if (/*#__PURE__*/React.isValidElement(child) && (child.type === SelectPanelButton || isSlot(child, SelectPanelButton))) { // eslint-disable-next-line react-hooks/immutability Anchor = /*#__PURE__*/React.cloneElement(child, { // @ts-ignore TODO ref: anchorRef, onClick: child.props.onClick || onAnchorClick, 'aria-haspopup': true, 'aria-expanded': internalOpen }); return null; } return child; }); const onInternalClose = React.useCallback(() => { if (internalOpen === false) return; // nothing to do here if (propsOpen === undefined) setInternalOpen(false); }, [internalOpen, propsOpen]); const onInternalCancel = React.useCallback(() => { onInternalClose(); if (typeof propsOnCancel === 'function') propsOnCancel(); }, [onInternalClose, propsOnCancel]); const onInternalSubmit = event => { event === null || event === void 0 ? void 0 : event.preventDefault(); // there is no event with selectionVariant=instant onInternalClose(); if (typeof propsOnSubmit === 'function') propsOnSubmit(event); }; const onInternalClearSelection = () => { if (typeof propsOnClearSelection === 'function') propsOnClearSelection(); }; const internalAfterSelect = event => { if (selectionVariant === 'instant') onInternalSubmit(); if (event.type === 'keypress') { if (event.key === 'Enter') onInternalSubmit(); } }; /* Search/Filter */ const [searchQuery, setSearchQuery] = React.useState(''); /* Panel plumbing */ const panelId = useId(id); const [slots, childrenInBody] = useSlots(contents, { header: SelectPanelHeader, footer: SelectPanelFooter }); // used in SelectPanel.SearchInput const moveFocusToList = () => { var _dialogRef$current; const selector = 'ul[role=listbox] li:not([role=none])'; // being specific about roles because there can be another ul (tabs in header) and an ActionList.Group (li[role=none]) const firstListElement = (_dialogRef$current = dialogRef.current) === null || _dialogRef$current === void 0 ? void 0 : _dialogRef$current.querySelector(selector); firstListElement === null || firstListElement === void 0 ? void 0 : firstListElement.focus(); }; /* Dialog */ const dialogRef = React.useRef(null); // sync dialog open state (imperative) with internal component state React.useEffect(() => { var _dialogRef$current2, _dialogRef$current3; if (internalOpen) (_dialogRef$current2 = dialogRef.current) === null || _dialogRef$current2 === void 0 ? void 0 : _dialogRef$current2.showModal();else if ((_dialogRef$current3 = dialogRef.current) !== null && _dialogRef$current3 !== void 0 && _dialogRef$current3.open) dialogRef.current.close(); }, [internalOpen]); // dialog handles Esc automatically, so we have to sync internal state // but it doesn't call onCancel, so have another effect for that! React.useEffect(() => { const dialogEl = dialogRef.current; dialogEl === null || dialogEl === void 0 ? void 0 : dialogEl.addEventListener('close', onInternalClose); return () => dialogEl === null || dialogEl === void 0 ? void 0 : dialogEl.removeEventListener('close', onInternalClose); }, [onInternalClose]); // Esc handler React.useEffect(() => { const dialogEl = dialogRef.current; const handler = event => { if (event.key === 'Escape') onInternalCancel(); }; dialogEl === null || dialogEl === void 0 ? void 0 : dialogEl.addEventListener('keydown', handler); return () => dialogEl === null || dialogEl === void 0 ? void 0 : dialogEl.removeEventListener('keydown', handler); }, [onInternalCancel]); // Autofocus hack: React doesn't support autoFocus for dialog: https://github.com/facebook/react/issues/23301 // tl;dr: react takes over autofocus instead of letting the browser handle it, // but not for dialogs, so we have to do it React.useEffect(function initialFocus() { if (internalOpen) { const searchInput = document.querySelector('dialog[open] input'); if (searchInput) searchInput.focus();else moveFocusToList(); } }, [internalOpen]); /* Anchored */ const { position } = useAnchoredPosition({ anchorElementRef: anchorRef, floatingElementRef: dialogRef, side: 'outside-bottom', align: 'start', ...anchoredPositionSettings }, // eslint-disable-next-line react-hooks/refs [internalOpen, anchorRef.current, dialogRef.current]); /* We want to cancel and close the panel when user clicks outside. See decision log: https://github.com/github/primer/discussions/2614#discussioncomment-8544561 */ const onClickOutside = onInternalCancel; let maxHeightValue = heightMap[maxHeight]; if (currentVariant === 'bottom-sheet') { maxHeightValue = 'calc(100vh - 64px)'; } else if (currentVariant === 'full-screen') { maxHeightValue = '100vh'; } return /*#__PURE__*/jsxs(Fragment, { children: [Anchor, /*#__PURE__*/jsx(BaseOverlay, { as: "dialog", ref: dialogRef, "aria-labelledby": `${panelId}--title`, "aria-describedby": description ? `${panelId}--description` : undefined, width: width, height: "fit-content", maxHeight: maxHeight, "data-variant": currentVariant, style: { '--max-height': maxHeightValue, '--position-top': `${(_position$top = position === null || position === void 0 ? void 0 : position.top) !== null && _position$top !== void 0 ? _position$top : 0}px`, '--position-left': `${(_position$left = position === null || position === void 0 ? void 0 : position.left) !== null && _position$left !== void 0 ? _position$left : 0}px`, visibility: internalOpen ? 'visible' : 'hidden', display: 'flex' }, className: clsx(classes.Overlay, className), ...props, onClick: event => { if (event.target === event.currentTarget) onClickOutside(); }, children: internalOpen && /*#__PURE__*/jsx(Fragment, { children: /*#__PURE__*/jsx(SelectPanelContext.Provider, { value: { panelId, title, description, onCancel: onInternalCancel, onClearSelection: propsOnClearSelection ? onInternalClearSelection : undefined, searchQuery, setSearchQuery, selectionVariant, moveFocusToList }, children: /*#__PURE__*/jsxs("form", { method: "dialog", onSubmit: onInternalSubmit, className: classes.Form, children: [(_slots$header = slots.header) !== null && _slots$header !== void 0 ? _slots$header : /*#__PURE__*/ /* render default header as fallback */ jsx(SelectPanelHeader, {}), /*#__PURE__*/jsx("div", { className: classes.Container, children: /*#__PURE__*/jsx(ActionListContainerContext.Provider, { value: { container: 'SelectPanel', listRole: 'listbox', selectionAttribute: 'aria-selected', selectionVariant: selectionVariant === 'instant' ? 'single' : selectionVariant, afterSelect: internalAfterSelect, listLabelledBy: `${panelId}--title`, enableFocusZone: true // Arrow keys navigation for list items }, children: childrenInBody }) }), slots.footer] }) }) }) })] }); }; const SelectPanelButton = /*#__PURE__*/React.forwardRef((props, anchorRef) => { const inputProps = useFormControlForwardedProps(props); const [labelText, setLabelText] = useState(''); useEffect(() => { const label = document.querySelector(`[for='${inputProps.id}']`); if (label !== null && label !== void 0 && label.textContent) { // eslint-disable-next-line react-hooks/set-state-in-effect setLabelText(label.textContent); } }, [inputProps.id]); if (labelText) { return /*#__PURE__*/jsx(ButtonComponent, { ref: anchorRef // eslint-disable-next-line react-hooks/refs , "aria-label": `${anchorRef.current.textContent}, ${labelText}`, ...inputProps }); } else { return /*#__PURE__*/jsx(ButtonComponent, { ref: anchorRef, ...props }); } }); SelectPanelButton.__SLOT__ = Symbol('SelectPanel.Button'); const SelectPanelHeader = ({ children, onBack, className, ...props }) => { const [slots, childrenWithoutSlots] = useSlots(children, { searchInput: SelectPanelSearchInput }); const { title, description, panelId, onCancel, onClearSelection } = React.useContext(SelectPanelContext); return /*#__PURE__*/jsxs("div", { className: clsx(classes.Header, className), ...props, children: [/*#__PURE__*/jsxs("div", { className: classes.HeaderContent, "data-description": description ? true : undefined, "data-search-input": slots.searchInput ? true : undefined, children: [/*#__PURE__*/jsxs("div", { className: classes.FlexBox, children: [onBack ? /*#__PURE__*/jsx(IconButton, { type: "button", variant: "invisible", icon: ArrowLeftIcon, "aria-label": "Back", onClick: () => onBack() }) : null, /*#__PURE__*/jsxs("div", { className: classes.TitleWrapper, "data-description": description ? true : undefined, "data-on-back": onBack ? true : undefined, children: [/*#__PURE__*/jsx(Heading, { as: "h1", id: `${panelId}--title`, className: classes.Title, children: title }), description ? /*#__PURE__*/jsx("span", { id: `${panelId}--description`, className: classes.Description, children: description }) : null] })] }), /*#__PURE__*/jsxs("div", { children: [onClearSelection ? /*#__PURE__*/jsx(IconButton, { type: "button", variant: "invisible", icon: FilterRemoveIcon, "aria-label": "Clear selection", onClick: onClearSelection }) : null, /*#__PURE__*/jsx(IconButton, { type: "button", variant: "invisible", icon: XIcon, "aria-label": "Close", onClick: () => onCancel() })] })] }), slots.searchInput, childrenWithoutSlots] }); }; SelectPanelHeader.displayName = "SelectPanelHeader"; SelectPanelHeader.__SLOT__ = Symbol('SelectPanel.Header'); const SelectPanelSearchInput = ({ onChange: propsOnChange, onKeyDown: propsOnKeyDown, className, ...props }) => { // TODO: use forwardedRef const inputRef = /*#__PURE__*/React.createRef(); const { setSearchQuery, moveFocusToList } = React.useContext(SelectPanelContext); const internalOnChange = event => { // If props.onChange is given, the application controls search, // otherwise the component does if (typeof propsOnChange === 'function') propsOnChange(event);else setSearchQuery(event.target.value); }; const internalKeyDown = event => { if (event.key === 'ArrowDown') { event.preventDefault(); // prevent scroll moveFocusToList(); } if (typeof propsOnKeyDown === 'function') propsOnKeyDown(event); }; return /*#__PURE__*/jsx(TextInput, { ref: inputRef, block: true, leadingVisual: SearchIcon, placeholder: "Search", trailingAction: /*#__PURE__*/jsx(TextInput.Action, { icon: XCircleFillIcon, "aria-label": "Clear", tooltipDirection: "w", className: classes.ClearAction, onClick: () => { if (inputRef.current) inputRef.current.value = ''; if (typeof propsOnChange === 'function') { // @ts-ignore TODO this is a hacky solution to clear propsOnChange({ target: inputRef.current, currentTarget: inputRef.current }); } } }), className: clsx(classes.TextInput, className), onChange: internalOnChange, onKeyDown: internalKeyDown, ...props }); }; SelectPanelSearchInput.displayName = "SelectPanelSearchInput"; SelectPanelSearchInput.__SLOT__ = Symbol('SelectPanel.SearchInput'); const FooterContext = /*#__PURE__*/React.createContext(false); const SelectPanelFooter = ({ ...props }) => { const { onCancel, selectionVariant } = React.useContext(SelectPanelContext); const hidePrimaryActions = selectionVariant === 'instant'; const buttonSize = useResponsiveValue(responsiveButtonSizes, 'small'); if (hidePrimaryActions && !props.children) { // nothing to render // todo: we can inform them the developer footer will render nothing return null; } return /*#__PURE__*/jsx(FooterContext.Provider, { value: true, children: /*#__PURE__*/jsxs("div", { className: classes.Footer, "data-hide-primary-actions": hidePrimaryActions || undefined, children: [/*#__PURE__*/jsx("div", { className: classes.FooterContent, "data-hide-primary-actions": hidePrimaryActions || undefined, children: props.children }), hidePrimaryActions ? null : /*#__PURE__*/jsxs("div", { className: classes.FooterActions, children: [/*#__PURE__*/jsx(ButtonComponent, { type: "button", size: buttonSize, onClick: () => onCancel(), children: "Cancel" }), /*#__PURE__*/jsx(ButtonComponent, { type: "submit", size: buttonSize, variant: "primary", children: "Save" })] })] }) }); }; SelectPanelFooter.displayName = "SelectPanelFooter"; SelectPanelFooter.__SLOT__ = Symbol('SelectPanel.Footer'); const SecondaryButton = props => { const size = useResponsiveValue(responsiveButtonSizes, 'small'); return /*#__PURE__*/jsx(ButtonComponent, { type: "button", size: size, block: true, ...props }); }; SecondaryButton.displayName = "SecondaryButton"; const SecondaryLink = ({ className, ...props }) => { const size = useResponsiveValue(responsiveButtonSizes, 'small'); return ( /*#__PURE__*/ // @ts-ignore TODO: is as prop is not recognised by button? jsx(ButtonComponent, { as: Link, size: size, variant: "invisible", block: true, ...props, className: clsx(classes.SmallText, className), children: props.children }) ); }; SecondaryLink.displayName = "SecondaryLink"; const SecondaryCheckbox = ({ id, children, className, ...props }) => { const checkboxId = useId(id); const { selectionVariant } = React.useContext(SelectPanelContext); // Checkbox should not be used with instant selection !(selectionVariant !== 'instant') ? process.env.NODE_ENV !== "production" ? invariant(false, 'Sorry! SelectPanel.SecondaryAction with variant="checkbox" is not allowed inside selectionVariant="instant"') : invariant(false) : void 0; return /*#__PURE__*/jsxs("div", { className: classes.SecondaryCheckbox, children: [/*#__PURE__*/jsx(Checkbox, { id: checkboxId, className: clsx(classes.Checkbox, className), ...props }), /*#__PURE__*/jsx(InputLabel, { htmlFor: checkboxId, className: classes.SmallText, children: children })] }); }; SecondaryCheckbox.displayName = "SecondaryCheckbox"; const SelectPanelSecondaryAction = ({ variant, ...props }) => { const insideFooter = React.useContext(FooterContext); !insideFooter ? process.env.NODE_ENV !== "production" ? invariant(false, 'SelectPanel.SecondaryAction is only allowed inside SelectPanel.Footer') : invariant(false) : void 0; // @ts-ignore TODO if (variant === 'button') return /*#__PURE__*/jsx(SecondaryButton, { ...props }); // @ts-ignore TODO else if (variant === 'link') return /*#__PURE__*/jsx(SecondaryLink, { ...props }); // @ts-ignore TODO // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition else if (variant === 'checkbox') return /*#__PURE__*/jsx(SecondaryCheckbox, { ...props }); }; const SelectPanelLoading = ({ children = 'Fetching items...' }) => { return /*#__PURE__*/jsxs(AriaStatus, { announceOnShow: true, className: classes.SelectPanelLoading, children: [/*#__PURE__*/jsx(Spinner, { size: "medium", srText: null }), /*#__PURE__*/jsx("span", { className: classes.LoadingText, children: children })] }); }; SelectPanelLoading.displayName = "SelectPanelLoading"; const SelectPanelMessage = ({ variant = 'warning', size = variant === 'empty' ? 'full' : 'inline', title, children }) => { const MessageWrapper = variant === 'empty' ? 'div' : AriaStatus; if (size === 'full') { return /*#__PURE__*/jsxs(MessageWrapper, { className: classes.MessageFull, children: [variant !== 'empty' ? /*#__PURE__*/jsx(Octicon, { icon: AlertIcon, className: clsx(classes.Octicon, variant === 'error' ? classes.Error : undefined, variant === 'warning' ? classes.Warning : undefined) }) : null, /*#__PURE__*/jsx("span", { className: classes.MessageTitle, children: title }), /*#__PURE__*/jsx("span", { className: classes.MessageContent, children: children })] }); } else { return /*#__PURE__*/jsxs(MessageWrapper, { className: classes.MessageInline, "data-variant": variant, children: [/*#__PURE__*/jsx(AlertIcon, { size: 16 }), /*#__PURE__*/jsx("div", { children: children })] }); } }; const SelectPanel = Object.assign(Panel, { Button: SelectPanelButton, Header: SelectPanelHeader, SearchInput: SelectPanelSearchInput, Footer: SelectPanelFooter, Loading: SelectPanelLoading, Message: SelectPanelMessage, SecondaryAction: SelectPanelSecondaryAction }); export { SelectPanel };