@neo4j-ndl/react
Version:
React implementation of Neo4j Design System
455 lines • 22.1 kB
JavaScript
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
/**
*
* Copyright (c) "Neo4j"
* Neo4j Sweden AB [http://neo4j.com]
*
* This file is part of Neo4j.
*
* Neo4j is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useMergeRefs } from '@floating-ui/react';
import classNames from 'classnames';
import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useId, useRef, useState, } from 'react';
import { useSemicontrolledState } from '../../_common/use-semi-controlled-state';
import { Checkbox } from '../../checkbox';
import { CleanIconButton } from '../../clean-icon-button';
import { ConditionalWrap } from '../../conditional-wrap';
import { ChevronRightIconOutline } from '../../icons/generated/heroIcons/ChevronRightIconOutline';
import { EllipsisHorizontalIconOutline } from '../../icons/generated/heroIcons/EllipsisHorizontalIconOutline';
import { Menu } from '../../menu';
import { Skeleton } from '../../skeleton';
import { Tooltip } from '../../tooltip';
const noop = () => undefined;
const TREE_EVENTS = {
action: 'treeitem-action',
collapse: 'treeitem-collapse',
expand: 'treeitem-expand',
select: 'treeitem-select',
};
const TreeViewContext = createContext({
focusedId: null,
level: 1,
selectionMode: 'single',
setFocusedId: noop,
});
function getVisibleTreeItems(root) {
return Array.from(root.querySelectorAll('[role="treeitem"]:not([role="group"][hidden] [role="treeitem"])')).filter((el) => {
let parent = el.parentElement;
while (parent && parent !== root) {
if (parent.getAttribute('role') === 'group' && parent.hidden) {
return false;
}
parent = parent.parentElement;
}
return true;
});
}
function annotateTreeItems(children) {
const items = Children.toArray(children);
const treeItemCount = items.filter((child) => isValidElement(child) && child.type === Item).length;
let posInSet = 0;
return Children.map(children, (child) => {
if (isValidElement(child) && child.type === Item) {
posInSet++;
return cloneElement(child, {
'aria-posinset': posInSet,
'aria-setsize': treeItemCount,
});
}
return child;
});
}
/**
* Root container for a tree view. Renders a `role="tree"` element with full
* keyboard navigation (arrow keys, Home/End, Enter/Space) following the
* WAI-ARIA TreeView pattern.
*
* Direct children should be `TreeView.Item` or `TreeView.SkeletonItem`.
*
* @example
* ```tsx
* <TreeView ariaLabel="Files">
* <TreeView.Item title="README.md" onSelectedChange={setSelected} isSelected={selected} />
* </TreeView>
* ```
*
* @alpha - this component is still in development and may change in the future. It might not have feature parity with the old tree view yet.
*/
const Root = (_a) => {
var { children, selectionMode = 'single', ariaLabel, ariaLabelledby, className, style, htmlAttributes, ref } = _a, restProps = __rest(_a, ["children", "selectionMode", "ariaLabel", "ariaLabelledby", "className", "style", "htmlAttributes", "ref"]);
const treeRef = useRef(null);
const mergedRef = useMergeRefs([treeRef, ref]);
const [focusedId, setFocusedId] = useState(null);
const handleKeyDown = useCallback((e) => {
const root = treeRef.current;
if (!root) {
return;
}
const items = getVisibleTreeItems(root);
if (items.length === 0) {
return;
}
const currentIndex = items.findIndex((el) => el.id === focusedId);
let isHandled = true;
switch (e.key) {
case 'ArrowDown': {
const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : currentIndex;
const nextItem = items[nextIndex];
if (nextItem) {
setFocusedId(nextItem.id);
nextItem.focus();
}
break;
}
case 'ArrowUp': {
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
const prevItem = items[prevIndex];
if (prevItem) {
setFocusedId(prevItem.id);
prevItem.focus();
}
break;
}
case 'ArrowRight': {
const current = currentIndex >= 0 ? items[currentIndex] : null;
if (!current) {
break;
}
const isExpanded = current.getAttribute('aria-expanded');
if (isExpanded === 'false') {
current.dispatchEvent(new CustomEvent(TREE_EVENTS.expand));
}
else if (isExpanded === 'true') {
const nextIndex = currentIndex + 1;
if (nextIndex < items.length) {
const nextItem = items[nextIndex];
setFocusedId(nextItem.id);
nextItem.focus();
}
}
break;
}
case 'ArrowLeft': {
const current = currentIndex >= 0 ? items[currentIndex] : null;
if (!current) {
break;
}
const isExpanded = current.getAttribute('aria-expanded');
if (isExpanded === 'true') {
current.dispatchEvent(new CustomEvent(TREE_EVENTS.collapse));
}
else {
const parentGroup = current.closest('[role="group"]');
if (parentGroup === null || parentGroup === void 0 ? void 0 : parentGroup.id) {
const parentItem = root.querySelector(`[aria-owns="${parentGroup.id}"]`);
if (parentItem) {
setFocusedId(parentItem.id);
parentItem.focus();
}
}
}
break;
}
case 'Home': {
const first = items[0];
if (first) {
setFocusedId(first.id);
first.focus();
}
break;
}
case 'End': {
const last = items[items.length - 1];
if (last) {
setFocusedId(last.id);
last.focus();
}
break;
}
case 'Enter':
case ' ': {
const current = currentIndex >= 0 ? items[currentIndex] : null;
if (current) {
current.dispatchEvent(new CustomEvent(TREE_EVENTS.select));
}
break;
}
case 'F10': {
if (e.shiftKey) {
const current = currentIndex >= 0 ? items[currentIndex] : null;
if (current) {
current.dispatchEvent(new CustomEvent(TREE_EVENTS.action));
}
}
else {
isHandled = false;
}
break;
}
default:
isHandled = false;
}
if (isHandled) {
e.preventDefault();
e.stopPropagation();
}
}, [focusedId]);
const handleFocus = useCallback((e) => {
const root = treeRef.current;
if (!root) {
return;
}
if (e.target === root) {
const items = getVisibleTreeItems(root);
const alreadyFocused = focusedId
? items.find((el) => el.id === focusedId)
: null;
const target = alreadyFocused !== null && alreadyFocused !== void 0 ? alreadyFocused : items[0];
if (target) {
setFocusedId(target.id);
target.focus();
}
}
}, [focusedId]);
const classes = classNames(className, 'ndl-tree-view-new');
return (_jsx(TreeViewContext.Provider, { value: { focusedId, level: 1, selectionMode, setFocusedId }, children: _jsx("div", Object.assign({ ref: mergedRef, role: "tree", "aria-multiselectable": selectionMode === 'multiple' || undefined, tabIndex: 0, className: classes, onKeyDown: handleKeyDown, onFocus: handleFocus, "aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : undefined, "aria-labelledby": ariaLabelledby, style: style }, htmlAttributes, restProps, { children: annotateTreeItems(children) })) }));
};
/**
* A single item within a `TreeView`. Renders a `role="treeitem"` element that
* supports selection, expansion, leading/trailing visuals, and an action menu.
*
* The `title` prop provides the item's label content. Nesting `TreeView.Item`
* elements as `children` creates a hierarchical tree structure.
*
* Supports both controlled (`isExpanded`) and uncontrolled (`defaultExpanded`)
* expansion, as well as lazy loading via `hasChildren` + `isLoading`.
*
* @example
* ```tsx
* <TreeView.Item
* title="src"
* leadingVisual={<FolderIcon />}
* isSelected={selected}
* onSelectedChange={setSelected}
* >
* <TreeView.Item title="index.ts" />
* </TreeView.Item>
* ```
*/
const Item = (_a) => {
var { actionMenuItems, children, defaultExpanded = false, hasChildren: hasChildrenProp, isDisabled, isExpanded: isExpandedProp, isIndeterminate, isLoading, isSelected, leadingVisual, title, tooltipContent, tooltipProps, trailingContent, onExpandedChange, onSelectedChange, actionMenuProps, className, style, htmlAttributes, ref } = _a, restProps = __rest(_a, ["actionMenuItems", "children", "defaultExpanded", "hasChildren", "isDisabled", "isExpanded", "isIndeterminate", "isLoading", "isSelected", "leadingVisual", "title", "tooltipContent", "tooltipProps", "trailingContent", "onExpandedChange", "onSelectedChange", "actionMenuProps", "className", "style", "htmlAttributes", "ref"]);
const generatedId = useId();
const itemId = `ndl-treeitem-${generatedId}`;
const titleId = `ndl-treeitem-title-${generatedId}`;
const groupId = `ndl-treegroup-${generatedId}`;
const itemRef = useRef(null);
const actionButtonRef = useRef(null);
const didOpenViaKeyboard = useRef(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { focusedId, level, selectionMode, setFocusedId } = useContext(TreeViewContext);
const isControlledExpansion = isExpandedProp !== undefined;
const [isExpanded, setIsExpanded] = useSemicontrolledState({
isControlled: isControlledExpansion,
onChange: onExpandedChange,
state: isExpandedProp !== null && isExpandedProp !== void 0 ? isExpandedProp : defaultExpanded,
});
const hasChildren = hasChildrenProp !== null && hasChildrenProp !== void 0 ? hasChildrenProp : Children.count(children) > 0;
const isSelectable = onSelectedChange !== undefined;
const isMultiple = selectionMode === 'multiple';
const toggleExpanded = useCallback(() => {
if (isDisabled) {
return;
}
if (hasChildren) {
setIsExpanded(!isExpanded);
}
}, [isDisabled, hasChildren, setIsExpanded, isExpanded]);
const handleSelect = useCallback(() => {
if (isDisabled) {
return;
}
onSelectedChange === null || onSelectedChange === void 0 ? void 0 : onSelectedChange(!isSelected);
}, [isDisabled, isSelected, onSelectedChange]);
const handleClick = useCallback(() => {
if (isDisabled) {
return;
}
setFocusedId(itemId);
if (isSelectable) {
handleSelect();
}
else {
toggleExpanded();
}
}, [
isDisabled,
setFocusedId,
itemId,
isSelectable,
handleSelect,
toggleExpanded,
]);
const handleChevronClick = useCallback((e) => {
e.stopPropagation();
if (isDisabled) {
return;
}
setFocusedId(itemId);
toggleExpanded();
}, [isDisabled, setFocusedId, itemId, toggleExpanded]);
const handleMenuClose = useCallback(() => {
var _a;
setIsMenuOpen(false);
if (didOpenViaKeyboard.current) {
(_a = itemRef.current) === null || _a === void 0 ? void 0 : _a.focus();
didOpenViaKeyboard.current = false;
}
}, []);
const handleCustomEvents = useCallback((e) => {
if (e.type === TREE_EVENTS.expand) {
e.stopPropagation();
if (hasChildren && !isExpanded && !isDisabled) {
setIsExpanded(true);
}
}
else if (e.type === TREE_EVENTS.collapse) {
e.stopPropagation();
if (hasChildren && isExpanded && !isDisabled) {
setIsExpanded(false);
}
}
else if (e.type === TREE_EVENTS.select) {
e.stopPropagation();
if (isSelectable) {
handleSelect();
}
else {
toggleExpanded();
}
}
else if (e.type === TREE_EVENTS.action) {
e.stopPropagation();
if (actionMenuItems && !isDisabled) {
didOpenViaKeyboard.current = true;
setIsMenuOpen(true);
}
}
}, [
actionMenuItems,
hasChildren,
isExpanded,
isDisabled,
isSelectable,
setIsExpanded,
handleSelect,
toggleExpanded,
]);
const callbackRef = useCallback((node) => {
const prev = itemRef.current;
if (prev) {
prev.removeEventListener(TREE_EVENTS.expand, handleCustomEvents);
prev.removeEventListener(TREE_EVENTS.collapse, handleCustomEvents);
prev.removeEventListener(TREE_EVENTS.select, handleCustomEvents);
prev.removeEventListener(TREE_EVENTS.action, handleCustomEvents);
}
itemRef.current = node;
if (node) {
node.addEventListener(TREE_EVENTS.expand, handleCustomEvents);
node.addEventListener(TREE_EVENTS.collapse, handleCustomEvents);
node.addEventListener(TREE_EVENTS.select, handleCustomEvents);
node.addEventListener(TREE_EVENTS.action, handleCustomEvents);
}
}, [handleCustomEvents]);
const mergedRef = useMergeRefs([callbackRef, ref]);
const isFocused = focusedId === itemId;
const selectionAriaProps = (() => {
if (!isSelectable) {
return {};
}
if (isMultiple) {
return {
'aria-checked': isIndeterminate
? 'mixed'
: (isSelected !== null && isSelected !== void 0 ? isSelected : false),
};
}
return { 'aria-selected': isSelected !== null && isSelected !== void 0 ? isSelected : false };
})();
const itemElement = (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events -- keyboard handled on tree root; aria-selected/aria-checked applied via spread
_jsx("div", Object.assign({ ref: mergedRef, id: itemId,
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props -- aria-selected/aria-checked applied conditionally via spread
role: "treeitem" }, selectionAriaProps, { "aria-expanded": hasChildren ? isExpanded : undefined, "aria-owns": hasChildren ? groupId : undefined, "aria-disabled": isDisabled || undefined, "aria-busy": isLoading || undefined, "aria-keyshortcuts": actionMenuItems ? 'Shift+F10' : undefined, "aria-labelledby": titleId, "aria-level": level, tabIndex: isFocused ? 0 : -1, className: classNames('ndl-tree-view-new-item', className, {
'ndl-tree-view-new-item--disabled': isDisabled,
'ndl-tree-view-new-item--parent': hasChildren,
'ndl-tree-view-new-item--selected': isSelected && isSelectable,
}), style: Object.assign({ '--tree-level': level - 1 }, style), onClick: (e) => {
e.stopPropagation();
handleClick();
} }, htmlAttributes, restProps, { children: _jsxs("div", { className: classNames('ndl-tree-view-new-item-content', {
'ndl-tree-view-new-item-content--parent': hasChildren,
}), children: [hasChildren && (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- keyboard handled on tree root
_jsx("span", { className: "ndl-tree-view-new-chevron", "aria-disabled": isDisabled, onClick: handleChevronClick, children: _jsx(ChevronRightIconOutline, { className: classNames('ndl-tree-view-new-chevron-icon', {
'ndl-tree-view-new-chevron-icon--open': isExpanded,
}) }) })), leadingVisual && (_jsx("div", { className: "ndl-tree-view-new-item-content-leading-visual", children: leadingVisual })), isMultiple && isSelectable && (_jsx("div", { className: "ndl-tree-view-new-checkbox", inert: true, children: _jsx(Checkbox, { style: { borderWidth: '1.5px' }, ariaLabel: "Select", isChecked: isSelected, isIndeterminate: isIndeterminate, isDisabled: isDisabled, onChange: noop }) })), _jsx("div", { id: titleId, className: "ndl-tree-view-new-item-content-title", children: title }), trailingContent && (_jsx("div", { className: "ndl-tree-view-new-item-content-trailing-content", children: trailingContent })), actionMenuItems && (_jsxs("div", { className: classNames('ndl-tree-view-new-action', {
'ndl-tree-view-new-action--visible': isMenuOpen,
}), children: [_jsx(CleanIconButton, { ref: actionButtonRef, size: "small", description: null, isDisabled: isDisabled, style: { height: '24px', width: '24px' }, htmlAttributes: {
'aria-expanded': isMenuOpen || undefined,
'aria-haspopup': 'menu',
'aria-label': 'Actions',
onClick: (e) => {
e.stopPropagation();
didOpenViaKeyboard.current = false;
setIsMenuOpen((prev) => !prev);
},
tabIndex: -1,
}, children: _jsx(EllipsisHorizontalIconOutline, {}) }), _jsx(Menu, Object.assign({ isOpen: isMenuOpen, anchorRef: actionButtonRef, onClose: handleMenuClose }, actionMenuProps, { children: actionMenuItems }))] }))] }) })));
return (_jsxs(_Fragment, { children: [_jsx(ConditionalWrap, { shouldWrap: tooltipContent !== undefined && tooltipContent !== null, wrap: (children) => (_jsxs(Tooltip, Object.assign({ type: "simple", hoverDelay: { close: 0, open: 500 }, isDisabled: isMenuOpen, followCursor: true }, tooltipProps === null || tooltipProps === void 0 ? void 0 : tooltipProps.root, { children: [_jsx(Tooltip.Trigger, Object.assign({ hasButtonWrapper: true }, tooltipProps === null || tooltipProps === void 0 ? void 0 : tooltipProps.trigger, { children: children })), _jsx(Tooltip.Content, Object.assign({}, tooltipProps === null || tooltipProps === void 0 ? void 0 : tooltipProps.content, { children: tooltipContent }))] }))), children: itemElement }), hasChildren && (_jsx(TreeViewContext.Provider, { value: { focusedId, level: level + 1, selectionMode, setFocusedId }, children: _jsx("div", { role: "group", id: groupId, hidden: !isExpanded, style: { '--tree-level': level - 1 }, children: annotateTreeItems(children) }) }))] }));
};
/**
* A skeleton placeholder for tree items that are being loaded. Renders one or
* more `Skeleton` rows at the current nesting depth. The element is
* `aria-hidden` since it is purely a visual indicator and not interactive.
*
* Typically used inside a `TreeView.Item` that has `isLoading` set, to give
* the user a visual hint that child items are being fetched.
*
* @example
* ```tsx
* <TreeView.Item title="Lazy folder" hasChildren isLoading={isLoading} isExpanded>
* {isLoading && <TreeView.SkeletonItem rows={3} />}
* </TreeView.Item>
* ```
*/
const SkeletonItem = (_a) => {
var { rows = 1, className, style, htmlAttributes, ref } = _a, restProps = __rest(_a, ["rows", "className", "style", "htmlAttributes", "ref"]);
const { level } = useContext(TreeViewContext);
return (_jsx("div", Object.assign({ ref: ref, "aria-hidden": "true", className: classNames('ndl-tree-view-new-skeleton', className), style: Object.assign({ '--tree-level': level - 1 }, style) }, htmlAttributes, restProps, { children: Array.from({ length: rows }, (_, i) => (_jsx(Skeleton, { height: "18px", width: "100%" }, i))) })));
};
const TreeView = Object.assign(Root, { Item, SkeletonItem });
export { TreeView };
//# sourceMappingURL=TreeView.js.map