UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

263 lines 11.9 kB
import { createComponent, Shade } from '@furystack/shades'; import { buildTransition, cssVariableTheme } from '../services/css-variable-theme.js'; import { getNavigableKeys } from './menu/menu-types.js'; const cloneNode = (node) => node instanceof Node ? node.cloneNode(true) : node; const renderDropdownItems = (items, onSelect) => { return items.map((item) => { if (item.type === 'divider') { return createComponent("div", { role: "separator", className: "dropdown-divider" }); } if (item.type === 'group') { return (createComponent("div", { role: "group", "aria-label": item.label, className: "dropdown-group" }, createComponent("div", { className: "dropdown-group-label" }, item.label), renderDropdownItems(item.children, onSelect))); } const classNames = ['dropdown-item', item.disabled ? 'disabled' : ''].filter(Boolean).join(' '); return (createComponent("div", { role: "menuitem", className: classNames, "aria-disabled": item.disabled ? 'true' : undefined, "data-key": item.key, onclick: () => { if (!item.disabled) { onSelect(item.key); } } }, item.icon && createComponent("span", { className: "dropdown-item-icon" }, cloneNode(item.icon)), createComponent("span", { className: "dropdown-item-label" }, cloneNode(item.label)))); }); }; export const Dropdown = Shade({ customElementName: 'shade-dropdown', css: { display: 'inline-flex', fontFamily: cssVariableTheme.typography.fontFamily, position: 'relative', '& .dropdown-trigger': { display: 'inline-flex', cursor: 'pointer', }, '& .dropdown-trigger.disabled': { cursor: 'not-allowed', opacity: '0.5', pointerEvents: 'none', }, // Backdrop '& .dropdown-backdrop': { opacity: '0', pointerEvents: 'none', transition: `opacity ${cssVariableTheme.transitions.duration.fast} ease-out`, }, '& .dropdown-backdrop.visible': { opacity: '1', pointerEvents: 'auto', }, // Panel '& .dropdown-panel': { opacity: '0', transform: 'scale(0.95) translateY(-4px)', transition: buildTransition(['opacity', cssVariableTheme.transitions.duration.fast, 'ease-out'], ['transform', cssVariableTheme.transitions.duration.fast, 'ease-out']), transformOrigin: 'top left', }, '& .dropdown-panel.visible': { opacity: '1', transform: 'scale(1) translateY(0)', }, // Dropdown items '& .dropdown-item': { display: 'flex', alignItems: 'center', gap: cssVariableTheme.spacing.sm, padding: `${cssVariableTheme.spacing.sm} ${cssVariableTheme.spacing.md}`, cursor: 'pointer', userSelect: 'none', transition: buildTransition([ 'background-color', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default, ]), whiteSpace: 'nowrap', }, '& .dropdown-item:hover:not(.disabled), & .dropdown-item.focused:not(.disabled)': { backgroundColor: cssVariableTheme.action.hoverBackground, }, '& .dropdown-item.disabled': { opacity: '0.5', cursor: 'not-allowed', }, '& .dropdown-item-icon': { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0', width: '20px', }, '& .dropdown-item-label': { flex: '1', }, // Divider '& .dropdown-divider': { height: '1px', margin: `${cssVariableTheme.spacing.xs} ${cssVariableTheme.spacing.sm}`, backgroundColor: cssVariableTheme.divider, }, // Group '& .dropdown-group-label': { padding: `${cssVariableTheme.spacing.xs} ${cssVariableTheme.spacing.md}`, fontSize: cssVariableTheme.typography.fontSize.xs, fontWeight: cssVariableTheme.typography.fontWeight.bold, color: cssVariableTheme.text.secondary, textTransform: 'uppercase', letterSpacing: cssVariableTheme.typography.letterSpacing.wider, userSelect: 'none', }, }, render: ({ props, children, useState, useDisposable, useRef, useHostProps }) => { const triggerRef = useRef('trigger'); const panelRef = useRef('panel'); const backdropRef = useRef('backdrop'); const [isOpenValue, setIsOpen] = useState('isOpen', false); useHostProps({ 'data-open': isOpenValue ? '' : undefined, }); useDisposable('keydown-handler', () => { const listener = (ev) => { if (!backdropRef.current?.classList.contains('visible')) return; const panel = panelRef.current; if (!panel) return; const allItems = Array.from(panel.querySelectorAll('.dropdown-item:not(.disabled)')); switch (ev.key) { case 'Escape': { ev.preventDefault(); backdropRef.current?.dispatchEvent(new MouseEvent('click', { bubbles: true })); break; } case 'ArrowDown': { if (allItems.length === 0) break; ev.preventDefault(); const focusedItem = panel.querySelector('.dropdown-item.focused'); const currentIndex = focusedItem ? allItems.indexOf(focusedItem) : -1; const nextIndex = currentIndex < allItems.length - 1 ? currentIndex + 1 : 0; allItems.forEach((el) => el.classList.remove('focused')); allItems[nextIndex]?.classList.add('focused'); break; } case 'ArrowUp': { if (allItems.length === 0) break; ev.preventDefault(); const focusedItem = panel.querySelector('.dropdown-item.focused'); const currentIndex = focusedItem ? allItems.indexOf(focusedItem) : allItems.length; const prevIndex = currentIndex > 0 ? currentIndex - 1 : allItems.length - 1; allItems.forEach((el) => el.classList.remove('focused')); allItems[prevIndex]?.classList.add('focused'); break; } case 'Enter': { ev.preventDefault(); const focusedItem = panel.querySelector('.dropdown-item.focused'); focusedItem?.click(); break; } default: break; } }; window.addEventListener('keydown', listener, true); return { [Symbol.dispose]: () => window.removeEventListener('keydown', listener, true) }; }); const { items, placement = 'bottomLeft', disabled, onSelect } = props; const positionAndShowPanel = () => { requestAnimationFrame(() => { const trigger = triggerRef.current; const panel = panelRef.current; const backdrop = backdropRef.current; if (!trigger || !panel || !backdrop) return; const { top: rectTop, bottom: rectBottom, left: rectLeft, right: rectRight } = trigger.getBoundingClientRect(); const panelWidth = panel.offsetWidth; const panelHeight = panel.offsetHeight; let top; let left; switch (placement) { case 'bottomRight': top = rectBottom + 2; left = rectRight - panelWidth; break; case 'topLeft': top = rectTop - panelHeight - 2; left = rectLeft; break; case 'topRight': top = rectTop - panelHeight - 2; left = rectRight - panelWidth; break; case 'bottomLeft': default: top = rectBottom + 2; left = rectLeft; break; } panel.style.top = `${top}px`; panel.style.left = `${left}px`; backdrop.classList.add('visible'); panel.classList.add('visible'); const keys = getNavigableKeys(items); if (keys.length > 0) { panel.querySelector(`[data-key="${keys[0]}"]`)?.classList.add('focused'); } }); }; const openDropdown = () => { if (isOpenValue) return; setIsOpen(true); positionAndShowPanel(); }; const closeDropdown = () => { setIsOpen(false); const backdrop = backdropRef.current; const panel = panelRef.current; backdrop?.classList.remove('visible'); panel?.classList.remove('visible'); panel?.querySelectorAll('.dropdown-item.focused').forEach((el) => el.classList.remove('focused')); }; const handleTriggerClick = () => { if (disabled) return; if (backdropRef.current?.classList.contains('visible')) { closeDropdown(); } else { openDropdown(); } }; const handleSelect = (key) => { onSelect?.(key); closeDropdown(); }; // If re-rendered while open (e.g. parent prop change), restore visual state if (isOpenValue) { positionAndShowPanel(); } return (createComponent(createComponent, null, createComponent("div", { ref: triggerRef, className: `dropdown-trigger${disabled ? ' disabled' : ''}`, onclick: handleTriggerClick }, children), createComponent("div", { ref: backdropRef, className: "dropdown-backdrop", "data-spatial-nav-passthrough": "", style: { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', zIndex: cssVariableTheme.zIndex.dropdown, }, onclick: closeDropdown }, createComponent("div", { ref: panelRef, role: "menu", className: "dropdown-panel", style: { position: 'fixed', minWidth: '160px', background: cssVariableTheme.background.paper, borderRadius: cssVariableTheme.shape.borderRadius.md, boxShadow: cssVariableTheme.shadows.lg, border: `1px solid ${cssVariableTheme.divider}`, padding: `${cssVariableTheme.spacing.xs} 0`, overflow: 'hidden', }, onclick: (ev) => ev.stopPropagation() }, renderDropdownItems(items, handleSelect))))); }, }); //# sourceMappingURL=dropdown.js.map