@medalsocial/meda
Version:
Shared Meda UI shell and runtime package.
92 lines (91 loc) • 7.52 kB
JavaScript
'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 }))] }) }));
}