UNPKG

@neovici/cosmoz-treenode-navigator

Version:

A Pion component that lets you navigate and search through hierarchically structured data-nodes and select one of them.

206 lines (205 loc) 6.86 kB
import { component, lift, useEffect, useMemo, useProperty, useRef, useState, } from '@pionjs/pion'; import { html } from 'lit-html'; import { ref } from 'lit/directives/ref.js'; import style from './cosmoz-treenode-button-view.styles'; import '@neovici/cosmoz-button/cosmoz-button'; import { xIcon } from '@neovici/cosmoz-icons/untitled'; import '@neovici/cosmoz-tooltip'; import { t } from 'i18next'; import { when } from 'lit-html/directives/when.js'; import './cosmoz-treenode-navigator'; import { useKeyDown } from './hooks/useKeyDown'; import { getTreePathParts } from './util/helpers'; const defaultIcon = html `<svg class="default-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" > <circle cx="7" cy="7" r="2" /> <circle cx="17" cy="17" r="2" /> <path d="M7 9v3c0 1.66 1.34 3 3 3h7" /> </svg>`; const CosmozNodeButtonView = ({ tree, showReset = false, searchMinLength = 3, searchDebounceTimeout = 500, variant = 'secondary', }) => { const dialogRef = useRef(null); const tooltipTimeoutRef = useRef(); // nodePath is the single source of truth - external two-way binding const [nodePath, setNodePath] = useProperty('nodePath', ''); // opened is controllable externally const [opened, setOpened] = useProperty('opened', false); // Track highlighted node path from navigator for Select button const [highlightedNodePath, setHighlightedNodePath] = useState(''); // Temporarily suppress tooltip after dialog close to prevent // it from showing when focus returns to the button const [tooltipDisabled, setTooltipDisabled] = useState(false); // nodesOnNodePath derived from nodePath + tree const nodesOnNodePath = useMemo(() => getTreePathParts(nodePath, tree), [nodePath, tree]); // buttonLabel derived from nodesOnNodePath const buttonLabel = useMemo(() => { if (!Array.isArray(nodesOnNodePath) || nodesOnNodePath.length === 0) { return t('Select a node'); } return nodesOnNodePath .filter((n) => n) .map((part) => part[tree.searchProperty]) .join(' / '); }, [nodesOnNodePath, tree]); // Sync dialog DOM state with opened property useEffect(() => { if (opened) { dialogRef.current?.showModal(); } else { dialogRef.current?.close(); } }, [opened]); const reset = () => { setNodePath(''); }; const onOpen = () => setOpened(true); const onClose = () => { setOpened(false); setTooltipDisabled(true); clearTimeout(tooltipTimeoutRef.current); tooltipTimeoutRef.current = setTimeout(() => setTooltipDisabled(false), 500); }; // Only register Escape handler when dialog is open // When closed, pass undefined to prevent useKeyDown from calling preventDefault() // and blocking the native dialog's Escape close behavior useKeyDown('Escape', opened ? onClose : undefined); // Dialog dragging support const onHeaderMouseDown = (e) => { const dialog = dialogRef.current; if (!dialog) return; const startX = e.clientX; const startY = e.clientY; const rect = dialog.getBoundingClientRect(); const startLeft = rect.left; const startTop = rect.top; const onMouseMove = (moveEvent) => { const dx = moveEvent.clientX - startX; const dy = moveEvent.clientY - startY; dialog.style.left = `${startLeft + dx}px`; dialog.style.top = `${startTop + dy}px`; dialog.style.margin = '0'; }; const onMouseUp = () => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }; // Handle node-path-changed from navigator - close dialog on selection const onNodePathChanged = (e) => { e.preventDefault(); const newPath = e.detail.value; if (newPath) { setNodePath(newPath); onClose(); } }; // Handle highlighted-node-path-changed from navigator const onHighlightedNodePathChanged = lift(setHighlightedNodePath); // Handle Select button click const onSelect = () => { if (highlightedNodePath) { setNodePath(highlightedNodePath); onClose(); } }; return html ` <nav part="actions"> <cosmoz-tooltip placement="right" .description=${buttonLabel} .delay=${1000} .disabled=${tooltipDisabled} > <cosmoz-button variant=${variant} full-width data-testid="open-button" @click=${onOpen} part="action-open" > <slot name="prefix" slot="prefix">${defaultIcon}</slot> <div class="path-text"> <span>${buttonLabel}</span> </div> <slot name="suffix" slot="suffix"></slot> </cosmoz-button> </cosmoz-tooltip> ${when(showReset && !!nodePath, () => html `<cosmoz-button variant="tertiary" @click=${reset} data-testid="reset-button" part="action-reset" > ${xIcon({ slot: 'prefix' })} </cosmoz-button>`)} </nav> <dialog part="dialog" data-testid="dialog" ${ref((el) => { dialogRef.current = el; })} > <header part="header" @mousedown=${onHeaderMouseDown}> <h1 part="heading">${t('Search or navigate to chosen destination')}</h1> </header> <main part="main"> <cosmoz-treenode-navigator id="treeNavigator" .nodePath=${nodePath} @node-path-changed=${onNodePathChanged} @highlighted-node-path-changed=${onHighlightedNodePathChanged} .searchMinLength=${searchMinLength} .searchDebounceTimeout=${searchDebounceTimeout} .tree=${tree} .opened=${opened} > <slot></slot> </cosmoz-treenode-navigator> </main> <footer part="footer"> <div> <cosmoz-button variant="primary" ?disabled=${!highlightedNodePath} @click=${onSelect} data-testid="select-button" part="select-button" > ${t('Select')} </cosmoz-button> <cosmoz-button variant="secondary" @click=${onClose} data-testid="cancel-button" part="cancel-button" > ${t('Cancel')} </cosmoz-button> </div> </footer> </dialog> `; }; CosmozNodeButtonView.observedAttributes = [ 'show-reset', 'search-min-length', 'variant', ]; customElements.define('cosmoz-treenode-button-view', component(CosmozNodeButtonView, { styleSheets: [style], })); //# sourceMappingURL=cosmoz-treenode-button-view.js.map