@primer/react
Version:
An implementation of GitHub's Primer Design System using React
560 lines (535 loc) • 22.9 kB
JavaScript
import { ChevronDownIcon, ChevronRightIcon, FileDirectoryOpenFillIcon, FileDirectoryFillIcon } from '@primer/octicons-react';
import classnames from 'classnames';
import React__default from 'react';
import styled, { keyframes } from 'styled-components';
import { ConfirmationDialog } from '../Dialog/ConfirmationDialog.js';
import VisuallyHidden from '../_VisuallyHidden.js';
import { get } from '../constants.js';
import { useControllableState } from '../hooks/useControllableState.js';
import { useId } from '../hooks/useId.js';
import useSafeTimeout from '../hooks/useSafeTimeout.js';
import { useSlots } from '../hooks/useSlots.js';
import sx from '../sx.js';
import { getAccessibleName } from './shared.js';
import { useRovingTabIndex, getFirstChildElement } from './useRovingTabIndex.js';
import { useTypeahead } from './useTypeahead.js';
import Spinner from '../Spinner/Spinner.js';
import Text from '../Text/Text.js';
// ----------------------------------------------------------------------------
// Context
const RootContext = /*#__PURE__*/React__default.createContext({
announceUpdate: () => {},
expandedStateCache: {
current: new Map()
}
});
const ItemContext = /*#__PURE__*/React__default.createContext({
itemId: '',
level: 1,
isSubTreeEmpty: false,
setIsSubTreeEmpty: () => {},
isExpanded: false,
setIsExpanded: () => {},
leadingVisualId: '',
trailingVisualId: ''
});
// ----------------------------------------------------------------------------
// TreeView
const UlBox = styled.ul.withConfig({
displayName: "TreeView__UlBox",
componentId: "sc-4ex6b6-0"
})(["list-style:none;padding:0;margin:0;.PRIVATE_TreeView-item{outline:none;&:focus-visible > div,&.focus-visible > div{box-shadow:inset 0 0 0 2px ", ";@media (forced-colors:active){outline:2px solid HighlightText;outline-offset:-2;}}}.PRIVATE_TreeView-item-container{--level:1;--toggle-width:1rem;position:relative;display:grid;grid-template-columns:calc(calc(var(--level) - 1) * (var(--toggle-width) / 2)) var(--toggle-width) 1fr;grid-template-areas:'spacer toggle content';width:100%;min-height:2rem;font-size:", ";color:", ";border-radius:", ";cursor:pointer;&:hover{background-color:", ";@media (forced-colors:active){outline:2px solid transparent;outline-offset:-2px;}}@media (pointer:coarse){--toggle-width:1.5rem;min-height:2.75rem;}&:has(.PRIVATE_TreeView-item-skeleton):hover{background-color:transparent;cursor:default;@media (forced-colors:active){outline:none;}}}&[data-omit-spacer='true'] .PRIVATE_TreeView-item-container{grid-template-columns:0 0 1fr;}.PRIVATE_TreeView-item[aria-current='true'] > .PRIVATE_TreeView-item-container{background-color:", ";&::after{content:'';position:absolute;top:calc(50% - 0.75rem);left:-", ";width:0.25rem;height:1.5rem;background-color:", ";border-radius:", ";@media (forced-colors:active){background-color:HighlightText;}}}.PRIVATE_TreeView-item-toggle{grid-area:toggle;display:flex;align-items:center;justify-content:center;height:100%;color:", ";}.PRIVATE_TreeView-item-toggle--hover:hover{background-color:", ";}.PRIVATE_TreeView-item-toggle--end{border-top-left-radius:", ";border-bottom-left-radius:", ";}.PRIVATE_TreeView-item-content{grid-area:content;display:flex;align-items:center;height:100%;padding:0 ", ";gap:", ";}.PRIVATE_TreeView-item-content-text{flex:1 1 auto;width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;}.PRIVATE_TreeView-item-visual{display:flex;color:", ";}.PRIVATE_TreeView-item-level-line{width:100%;height:100%;border-right:1px solid;border-color:", ";}@media (hover:hover){.PRIVATE_TreeView-item-level-line{border-color:transparent;}&:hover .PRIVATE_TreeView-item-level-line,&:focus-within .PRIVATE_TreeView-item-level-line{border-color:", ";}}.PRIVATE_TreeView-directory-icon{display:grid;color:", ";}.PRIVATE_VisuallyHidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0;}", ""], get(`colors.accent.fg`), get('fontSizes.1'), get('colors.fg.default'), get('radii.2'), get('colors.actionListItem.default.hoverBg'), get('colors.actionListItem.default.selectedBg'), get('space.2'), get('colors.accent.fg'), get('radii.2'), get('colors.fg.muted'), get('colors.treeViewItem.chevron.hoverBg'), get('radii.2'), get('radii.2'), get('space.2'), get('space.2'), get('colors.fg.muted'), get('colors.border.subtle'), get('colors.border.subtle'), get('colors.treeViewItem.directory.fill'), sx);
const Root = ({
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
children,
flat
}) => {
const containerRef = React__default.useRef(null);
const [ariaLiveMessage, setAriaLiveMessage] = React__default.useState('');
const announceUpdate = React__default.useCallback(message => {
setAriaLiveMessage(message);
}, []);
useRovingTabIndex({
containerRef
});
useTypeahead({
containerRef,
onFocusChange: element => {
if (element instanceof HTMLElement) {
element.focus();
}
}
});
const expandedStateCache = React__default.useRef(null);
if (expandedStateCache.current === null) {
expandedStateCache.current = new Map();
}
return /*#__PURE__*/React__default.createElement(RootContext.Provider, {
value: {
announceUpdate,
expandedStateCache
}
}, /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(VisuallyHidden, {
role: "status",
"aria-live": "polite",
"aria-atomic": "true"
}, ariaLiveMessage), /*#__PURE__*/React__default.createElement(UlBox, {
ref: containerRef,
role: "tree",
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
"data-omit-spacer": flat
}, children)));
};
Root.displayName = "Root";
Root.displayName = 'TreeView';
// ----------------------------------------------------------------------------
// TreeView.Item
const Item = /*#__PURE__*/React__default.forwardRef(({
id: itemId,
containIntrinsicSize,
current: isCurrentItem = false,
defaultExpanded,
expanded,
onExpandedChange,
onSelect,
children
}, ref) => {
const [slots, rest] = useSlots(children, {
leadingVisual: LeadingVisual,
trailingVisual: TrailingVisual
});
const {
expandedStateCache
} = React__default.useContext(RootContext);
const labelId = useId();
const leadingVisualId = useId();
const trailingVisualId = useId();
const [isExpanded, setIsExpanded] = useControllableState({
name: itemId,
// If the item was previously mounted, it's expanded state might be cached.
// We check the cache first, and then fall back to the defaultExpanded prop.
// If defaultExpanded is not provided, we default to false unless the item
// is the current item, in which case we default to true.
defaultValue: () => {
var _ref, _expandedStateCache$c, _expandedStateCache$c2;
return (_ref = (_expandedStateCache$c = (_expandedStateCache$c2 = expandedStateCache.current) === null || _expandedStateCache$c2 === void 0 ? void 0 : _expandedStateCache$c2.get(itemId)) !== null && _expandedStateCache$c !== void 0 ? _expandedStateCache$c : defaultExpanded) !== null && _ref !== void 0 ? _ref : isCurrentItem;
},
value: expanded,
onChange: onExpandedChange
});
const {
level
} = React__default.useContext(ItemContext);
const {
hasSubTree,
subTree,
childrenWithoutSubTree
} = useSubTree(rest);
const [isSubTreeEmpty, setIsSubTreeEmpty] = React__default.useState(!hasSubTree);
const [isFocused, setIsFocused] = React__default.useState(false);
// Set the expanded state and cache it
const setIsExpandedWithCache = React__default.useCallback(newIsExpanded => {
var _expandedStateCache$c3;
setIsExpanded(newIsExpanded);
(_expandedStateCache$c3 = expandedStateCache.current) === null || _expandedStateCache$c3 === void 0 ? void 0 : _expandedStateCache$c3.set(itemId, newIsExpanded);
}, [itemId, setIsExpanded, expandedStateCache]);
// Expand or collapse the subtree
const toggle = React__default.useCallback(event => {
setIsExpandedWithCache(!isExpanded);
event === null || event === void 0 ? void 0 : event.stopPropagation();
}, [isExpanded, setIsExpandedWithCache]);
const handleKeyDown = React__default.useCallback(event => {
switch (event.key) {
case 'Enter':
if (onSelect) {
onSelect(event);
} else {
toggle(event);
}
break;
case 'ArrowRight':
event.preventDefault();
event.stopPropagation();
setIsExpandedWithCache(true);
break;
case 'ArrowLeft':
event.preventDefault();
event.stopPropagation();
setIsExpandedWithCache(false);
break;
}
}, [onSelect, setIsExpandedWithCache, toggle]);
return /*#__PURE__*/React__default.createElement(ItemContext.Provider, {
value: {
itemId,
level: level + 1,
isSubTreeEmpty,
setIsSubTreeEmpty,
isExpanded,
setIsExpanded: setIsExpandedWithCache,
leadingVisualId,
trailingVisualId
}
}, /*#__PURE__*/React__default.createElement("li", {
className: "PRIVATE_TreeView-item",
ref: ref,
tabIndex: 0,
id: itemId,
role: "treeitem",
"aria-labelledby": labelId,
"aria-describedby": `${leadingVisualId} ${trailingVisualId}`,
"aria-level": level,
"aria-expanded": isSubTreeEmpty ? undefined : isExpanded,
"aria-current": isCurrentItem ? 'true' : undefined,
"aria-selected": isFocused ? 'true' : 'false',
onKeyDown: handleKeyDown,
onFocus: event => {
var _event$currentTarget$;
// Scroll the first child into view when the item receives focus
(_event$currentTarget$ = event.currentTarget.firstElementChild) === null || _event$currentTarget$ === void 0 ? void 0 : _event$currentTarget$.scrollIntoView({
block: 'nearest',
inline: 'nearest'
});
// Set the focused state
setIsFocused(true);
// Prevent focus event from bubbling up to parent items
event.stopPropagation();
},
onBlur: () => setIsFocused(false)
}, /*#__PURE__*/React__default.createElement("div", {
className: "PRIVATE_TreeView-item-container",
style: {
// @ts-ignore CSS custom property
'--level': level,
contentVisibility: containIntrinsicSize ? 'auto' : undefined,
containIntrinsicSize
},
onClick: event => {
if (onSelect) {
onSelect(event);
} else {
toggle(event);
}
},
onAuxClick: event => {
if (onSelect && event.button === 1) {
onSelect(event);
}
}
}, /*#__PURE__*/React__default.createElement("div", {
style: {
gridArea: 'spacer',
display: 'flex'
}
}, /*#__PURE__*/React__default.createElement(LevelIndicatorLines, {
level: level
})), hasSubTree ?
/*#__PURE__*/
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
React__default.createElement("div", {
className: classnames('PRIVATE_TreeView-item-toggle', onSelect && 'PRIVATE_TreeView-item-toggle--hover', level === 1 && 'PRIVATE_TreeView-item-toggle--end'),
onClick: event => {
if (onSelect) {
toggle(event);
}
}
}, isExpanded ? /*#__PURE__*/React__default.createElement(ChevronDownIcon, {
size: 12
}) : /*#__PURE__*/React__default.createElement(ChevronRightIcon, {
size: 12
})) : null, /*#__PURE__*/React__default.createElement("div", {
id: labelId,
className: "PRIVATE_TreeView-item-content"
}, slots.leadingVisual, /*#__PURE__*/React__default.createElement("span", {
className: "PRIVATE_TreeView-item-content-text"
}, childrenWithoutSubTree), slots.trailingVisual)), subTree));
});
/** Lines to indicate the depth of an item in a TreeView */
const LevelIndicatorLines = ({
level
}) => {
return /*#__PURE__*/React__default.createElement("div", {
style: {
width: '100%',
display: 'flex'
}
}, Array.from({
length: level - 1
}).map((_, index) => /*#__PURE__*/React__default.createElement("div", {
key: index,
className: "PRIVATE_TreeView-item-level-line"
})));
};
LevelIndicatorLines.displayName = "LevelIndicatorLines";
Item.displayName = 'TreeView.Item';
// ----------------------------------------------------------------------------
// TreeView.SubTree
const SubTree = ({
count,
state,
children
}) => {
const {
announceUpdate
} = React__default.useContext(RootContext);
const {
itemId,
isExpanded,
isSubTreeEmpty,
setIsSubTreeEmpty
} = React__default.useContext(ItemContext);
const loadingItemRef = React__default.useRef(null);
const ref = React__default.useRef(null);
const [loadingFocused, setLoadingFocused] = React__default.useState(false);
const previousState = usePreviousValue(state);
const {
safeSetTimeout
} = useSafeTimeout();
React__default.useEffect(() => {
// If `state` is undefined, we're working in a synchronous context and need
// to detect if the sub-tree has content. If `state === 'done` then we're
// working in an asynchronous context and need to see if there is content
// that has been loaded in.
if (state === undefined || state === 'done') {
if (!isSubTreeEmpty && !children) {
setIsSubTreeEmpty(true);
} else if (isSubTreeEmpty && children) {
setIsSubTreeEmpty(false);
}
}
}, [state, isSubTreeEmpty, setIsSubTreeEmpty, children]);
// Handle transition from loading to done state
React__default.useEffect(() => {
if (previousState === 'loading' && state === 'done') {
var _ref$current;
const parentElement = document.getElementById(itemId);
if (!parentElement) return;
// Announce update to screen readers
const parentName = getAccessibleName(parentElement);
if ((_ref$current = ref.current) !== null && _ref$current !== void 0 && _ref$current.childElementCount) {
announceUpdate(`${parentName} content loaded`);
} else {
announceUpdate(`${parentName} is empty`);
}
// Move focus to the first child if the loading indicator
// was focused when the async items finished loading
if (loadingFocused) {
const firstChild = getFirstChildElement(parentElement);
if (firstChild) {
safeSetTimeout(() => {
firstChild.focus();
});
} else {
safeSetTimeout(() => {
parentElement.focus();
});
}
setLoadingFocused(false);
}
}
}, [loadingFocused, previousState, state, itemId, announceUpdate, ref, safeSetTimeout]);
// Track focus on the loading indicator
React__default.useEffect(() => {
function handleFocus() {
setLoadingFocused(true);
}
function handleBlur(event) {
// Skip blur events that are caused by the element being removed from the DOM.
// This can happen when the loading indicator is focused when async items are
// done loading and the loading indicator is removed from the DOM.
// If `loadingFocused` is `true` when `state` is `"done"` then the loading indicator
// was focused when the async items finished loading and we need to move focus to the
// first child.
if (!event.relatedTarget) return;
setLoadingFocused(false);
}
const loadingElement = loadingItemRef.current;
if (!loadingElement) return;
loadingElement.addEventListener('focus', handleFocus);
loadingElement.addEventListener('blur', handleBlur);
return () => {
loadingElement.removeEventListener('focus', handleFocus);
loadingElement.removeEventListener('blur', handleBlur);
};
}, [loadingItemRef, state]);
if (!isExpanded) {
return null;
}
return /*#__PURE__*/React__default.createElement("ul", {
role: "group",
style: {
listStyle: 'none',
padding: 0,
margin: 0
}
// @ts-ignore Box doesn't have type support for `ref` used in combination with `as`
,
ref: ref
}, state === 'loading' ? /*#__PURE__*/React__default.createElement(LoadingItem, {
ref: loadingItemRef,
count: count
}) : children);
};
SubTree.displayName = "SubTree";
SubTree.displayName = 'TreeView.SubTree';
function usePreviousValue(value) {
const ref = React__default.useRef(value);
React__default.useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
const shimmer = keyframes(["from{mask-position:200%;}to{mask-position:0%;}"]);
const SkeletonItem = styled.span.attrs({
className: 'PRIVATE_TreeView-item-skeleton'
}).withConfig({
displayName: "TreeView__SkeletonItem",
componentId: "sc-4ex6b6-1"
})(["display:flex;align-items:center;column-gap:0.5rem;height:2rem;@media (pointer:coarse){height:2.75rem;}@media (prefers-reduced-motion:no-preference){mask-image:linear-gradient(75deg,#000 30%,rgba(0,0,0,0.65) 80%);mask-size:200%;animation:", ";animation-duration:1s;animation-iteration-count:infinite;}&::before{content:'';display:block;width:1rem;height:1rem;background-color:", ";border-radius:3px;@media (forced-colors:active){outline:1px solid transparent;outline-offset:-1px;}}&::after{content:'';display:block;width:var(--tree-item-loading-width,67%);height:1rem;background-color:", ";border-radius:3px;@media (forced-colors:active){outline:1px solid transparent;outline-offset:-1px;}}&:nth-of-type(5n + 1){--tree-item-loading-width:67%;}&:nth-of-type(5n + 2){--tree-item-loading-width:47%;}&:nth-of-type(5n + 3){--tree-item-loading-width:73%;}&:nth-of-type(5n + 4){--tree-item-loading-width:64%;}&:nth-of-type(5n + 5){--tree-item-loading-width:50%;}"], shimmer, get('colors.neutral.subtle'), get('colors.neutral.subtle'));
const LoadingItem = /*#__PURE__*/React__default.forwardRef(({
count
}, ref) => {
const itemId = useId();
if (count) {
return /*#__PURE__*/React__default.createElement(Item, {
id: itemId,
ref: ref
}, Array.from({
length: count
}).map((_, i) => {
return /*#__PURE__*/React__default.createElement(SkeletonItem, {
"aria-hidden": true,
key: i
});
}), /*#__PURE__*/React__default.createElement("div", {
className: "PRIVATE_VisuallyHidden"
}, "Loading ", count, " items"));
}
return /*#__PURE__*/React__default.createElement(Item, {
id: itemId,
ref: ref
}, /*#__PURE__*/React__default.createElement(LeadingVisual, null, /*#__PURE__*/React__default.createElement(Spinner, {
size: "small"
})), /*#__PURE__*/React__default.createElement(Text, {
sx: {
color: 'fg.muted'
}
}, "Loading..."));
});
function useSubTree(children) {
return React__default.useMemo(() => {
const subTree = React__default.Children.toArray(children).find(child => /*#__PURE__*/React__default.isValidElement(child) && child.type === SubTree);
const childrenWithoutSubTree = React__default.Children.toArray(children).filter(child => !( /*#__PURE__*/React__default.isValidElement(child) && child.type === SubTree));
return {
subTree,
childrenWithoutSubTree,
hasSubTree: Boolean(subTree)
};
}, [children]);
}
// ----------------------------------------------------------------------------
// TreeView.LeadingVisual and TreeView.TrailingVisual
const LeadingVisual = props => {
const {
isExpanded,
leadingVisualId
} = React__default.useContext(ItemContext);
const children = typeof props.children === 'function' ? props.children({
isExpanded
}) : props.children;
return /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement("div", {
className: "PRIVATE_VisuallyHidden",
"aria-hidden": true,
id: leadingVisualId
}, props.label), /*#__PURE__*/React__default.createElement("div", {
className: "PRIVATE_TreeView-item-visual",
"aria-hidden": true
}, children));
};
LeadingVisual.displayName = 'TreeView.LeadingVisual';
const TrailingVisual = props => {
const {
isExpanded,
trailingVisualId
} = React__default.useContext(ItemContext);
const children = typeof props.children === 'function' ? props.children({
isExpanded
}) : props.children;
return /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement("div", {
className: "PRIVATE_VisuallyHidden",
"aria-hidden": true,
id: trailingVisualId
}, props.label), /*#__PURE__*/React__default.createElement("div", {
className: "PRIVATE_TreeView-item-visual",
"aria-hidden": true
}, children));
};
TrailingVisual.displayName = 'TreeView.TrailingVisual';
// ----------------------------------------------------------------------------
// TreeView.DirectoryIcon
const DirectoryIcon = () => {
const {
isExpanded
} = React__default.useContext(ItemContext);
const Icon = isExpanded ? FileDirectoryOpenFillIcon : FileDirectoryFillIcon;
return /*#__PURE__*/React__default.createElement("div", {
className: "PRIVATE_TreeView-directory-icon"
}, /*#__PURE__*/React__default.createElement(Icon, null));
};
DirectoryIcon.displayName = "DirectoryIcon";
const ErrorDialog = ({
title = 'Error',
children,
onRetry,
onDismiss
}) => {
const {
itemId,
setIsExpanded
} = React__default.useContext(ItemContext);
return (
/*#__PURE__*/
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
React__default.createElement("div", {
onKeyDown: event => {
if (['Backspace', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter'].includes(event.key)) {
// Prevent keyboard events from bubbling up to the TreeView
// and interfering with keyboard navigation
event.stopPropagation();
}
}
}, /*#__PURE__*/React__default.createElement(ConfirmationDialog, {
title: title,
onClose: gesture => {
// Focus parent item after the dialog is closed
setTimeout(() => {
const parentElement = document.getElementById(itemId);
parentElement === null || parentElement === void 0 ? void 0 : parentElement.focus();
});
if (gesture === 'confirm') {
onRetry === null || onRetry === void 0 ? void 0 : onRetry();
} else {
setIsExpanded(false);
onDismiss === null || onDismiss === void 0 ? void 0 : onDismiss();
}
},
confirmButtonContent: "Retry",
cancelButtonContent: "Dismiss"
}, children))
);
};
ErrorDialog.displayName = "ErrorDialog";
ErrorDialog.displayName = 'TreeView.ErrorDialog';
// ----------------------------------------------------------------------------
// Export
const TreeView = Object.assign(Root, {
Item,
SubTree,
LeadingVisual,
TrailingVisual,
DirectoryIcon,
ErrorDialog
});
export { TreeView };