UNPKG

@medalsocial/meda

Version:

Shared Meda UI shell and runtime package.

92 lines (91 loc) 7.52 kB
'use client'; import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { ChevronDown, ChevronUp } from 'lucide-react'; import { Fragment, useEffect, useState } from 'react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '../components/ui/tooltip.js'; import { cn } from '../lib/utils.js'; import { useShellViewport } from './use-shell-viewport.js'; // --------------------------------------------------------------------------- // Item styling // --------------------------------------------------------------------------- // Short-viewport scaling: below 850px viewport height items compact (smaller // icon frame, tighter spacing, smaller label); below 700px labels hide so the // rail degrades to icon-only. The rail additionally scrolls (see railClass) as // the hard guarantee that every item stays reachable on any height. function itemClass(isActive, labelVisibility) { if (labelVisibility === 'visible') { return cn('group relative flex min-h-[4rem] w-full shrink-0 flex-col items-center justify-start gap-1 px-1 py-1 text-center transition-colors', '[@media(max-height:850px)]:min-h-[3.25rem] [@media(max-height:850px)]:gap-0.5', '[@media(max-height:700px)]:min-h-0', isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'); } return cn('group relative flex h-11 w-11 shrink-0 items-center justify-center rounded-xl transition-colors', '[@media(max-height:700px)]:h-9 [@media(max-height:700px)]:w-9', isActive ? 'bg-primary text-primary-foreground shadow-sm ring-2 ring-primary/60' : 'text-muted-foreground hover:bg-accent hover:text-foreground'); } function iconFrameClass(isActive) { return cn('relative inline-flex h-11 w-11 items-center justify-center rounded-xl transition-colors', '[@media(max-height:850px)]:h-9 [@media(max-height:850px)]:w-9', isActive ? 'bg-primary text-primary-foreground shadow-sm ring-2 ring-primary/60' : 'group-hover:bg-accent group-hover:text-foreground'); } function railClass(labelVisibility, className) { return cn('flex h-full shrink-0 flex-col items-center bg-shell-rail', // Hard reachability guarantee on short viewports: the rail scrolls when the // compact tiers still can't fit every item. Scrollbar is hidden — wheel, // trackpad, and keyboard focus traversal still scroll the overflow. 'min-h-0 overflow-y-auto overflow-x-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', labelVisibility === 'visible' ? 'w-[var(--shell-rail-label-width)] px-2 py-4 [@media(max-height:850px)]:py-2' : 'w-[var(--shell-rail-width)] py-3.5 [@media(max-height:850px)]:py-2', className); } export function RailDivider({ pinnedBottom, onToggle }) { return (_jsx("button", { type: "button", "data-testid": "rail-divider", onClick: onToggle, "aria-label": pinnedBottom ? 'Pull utility items up' : 'Push utility items down', className: cn('my-3 flex h-6 w-8 shrink-0 items-center justify-center rounded-md', '[@media(max-height:850px)]:my-1', 'text-muted-foreground hover:bg-accent hover:text-foreground transition-colors'), children: pinnedBottom ? (_jsx(ChevronUp, { size: 14, "aria-hidden": "true" })) : (_jsx(ChevronDown, { size: 14, "aria-hidden": "true" })) })); } // Mirrors the [@media(max-height:700px)] CSS tier that hides visible-mode // labels, so those items regain a tooltip fallback exactly when their label // disappears. SSR-safe: initial false, resolves post-mount. function useIsShortViewport() { const [isShort, setIsShort] = useState(false); useEffect(() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; const mql = window.matchMedia('(max-height: 700px)'); const onChange = () => setIsShort(mql.matches); onChange(); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, []); return isShort; } // --------------------------------------------------------------------------- // IconRail // --------------------------------------------------------------------------- export function IconRail({ mainItems, utilityItems = [], footer, activeId, renderLink, labelVisibility = 'tooltip', className, }) { const band = useShellViewport(); const isShortViewport = useIsShortViewport(); const [pinnedBottom, setPinnedBottom] = useState(true); if (band === 'mobile') return null; const showLabels = labelVisibility === 'visible'; // At ≤700px the visible-mode label is hidden by CSS, so items fall back to // tooltip labels like tooltip mode does. const useTooltipFallback = !showLabels || isShortViewport; const renderItem = (item) => { const isActive = item.id === activeId; const klass = itemClass(isActive, labelVisibility); const IconComp = item.icon; const inner = showLabels ? (_jsxs(_Fragment, { children: [_jsxs("span", { "data-slot": "icon-rail-icon-frame", className: iconFrameClass(isActive), children: [_jsx(IconComp, { size: 26, "aria-hidden": "true", className: "[@media(max-height:850px)]:size-5" }), item.badge ? _jsx("span", { className: "absolute right-1 top-1", children: item.badge }) : null] }), _jsx("span", { "data-slot": "icon-rail-label", className: "max-w-full truncate text-[12px] leading-4 [@media(max-height:850px)]:text-[11px] [@media(max-height:700px)]:hidden", children: item.label })] })) : (_jsxs(_Fragment, { children: [_jsx(IconComp, { size: 22, "aria-hidden": "true", className: "[@media(max-height:700px)]:size-[18px]" }), item.badge ? _jsx("span", { className: "absolute right-1 top-1", children: item.badge }) : null] })); const linkProps = { href: item.to, 'aria-label': item.label, 'aria-current': isActive ? 'page' : undefined, className: 'contents', children: inner, }; const linkContent = renderLink ? ( // renderLink consumers receive the className so they can apply it themselves renderLink({ item, isActive, className: klass, children: inner, linkProps })) : (_jsx("a", { ...linkProps })); const trigger = (_jsx("span", { "data-testid": `icon-rail-trigger-${item.id}`, className: klass, children: linkContent })); if (!useTooltipFallback) { return _jsx(Fragment, { children: trigger }, item.id); } return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: trigger }), _jsx(TooltipContent, { side: "right", children: item.label })] }, item.id)); }; return (_jsx(TooltipProvider, { children: _jsxs("nav", { "data-testid": "icon-rail", "aria-label": "Primary", className: railClass(labelVisibility, className), children: [_jsx("div", { className: cn('flex shrink-0 flex-col items-center', showLabels ? 'w-full gap-1' : 'gap-1'), children: mainItems.map(renderItem) }), utilityItems.length > 0 && (_jsxs(_Fragment, { children: [_jsx(RailDivider, { pinnedBottom: pinnedBottom, onToggle: () => setPinnedBottom((prev) => !prev) }), _jsx("div", { "data-testid": "utility-items-wrapper", className: cn('flex shrink-0 flex-col items-center', showLabels ? 'w-full gap-1' : 'gap-1', pinnedBottom && 'mt-auto'), children: utilityItems.map(renderItem) })] })), footer && (_jsx("div", { className: cn('shrink-0 pt-3 [@media(max-height:850px)]:pt-1.5', pinnedBottom || utilityItems.length === 0 ? 'mt-auto' : ''), children: footer }))] }) })); }