UNPKG

@neo4j-ndl/react

Version:

React implementation of Neo4j Design System

455 lines 22.1 kB
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