Workspace Tab Bar
Surface-connected tabs with pinning, closing, drag and keyboard reordering, and host-owned window actions.
Preview
"use client";
import { FileText, LayoutDashboard, Settings2 } from "lucide-react";
import { useId, useRef, useState } from "react";
import { WorkspaceShell } from "@/components/workspace/workspace-shell";
import { WorkspaceTabBar, type WorkspaceTab } from "@/components/workspace/workspace-tab-bar";
export function WorkspaceTabBarPreview() {
const instance = useId();
const serial = useRef(0);
const [tabs, setTabs] = useState<WorkspaceTab[]>([{ id: "home", title: "概览", pinned: true, icon: <LayoutDashboard /> }, { id: "draft", title: "草稿", dirty: true, icon: <FileText /> }, { id: "settings", title: "设置", icon: <Settings2 /> }]);
const [active, setActive] = useState("home");
return <WorkspaceShell className="h-64 rounded-xl" tabBar={<WorkspaceTabBar tabs={tabs.map((tab) => ({ ...tab, tabId: `${instance}-tab-${tab.id}`, panelId: `${instance}-panel-${tab.id}` }))} activeTabId={active} onSelect={setActive} onNewTab={() => { const id = `tab-${++serial.current}`; setTabs((items) => [...items, { id, title: `新标签 ${serial.current}` }]); setActive(id); }} onClose={(id) => { const next = tabs.filter((tab) => tab.id !== id); setTabs(next); if (id === active)
setActive(next[0].id); }} onPinnedChange={(id, pinned) => setTabs((items) => items.map((tab) => tab.id === id ? { ...tab, pinned } : tab).sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned))))} onReorder={(a, b) => setTabs((items) => { const result = [...items]; const from = result.findIndex((tab) => tab.id === a); const to = result.findIndex((tab) => tab.id === b); if (from < 0 || to < 0)
return items; result.splice(to, 0, result.splice(from, 1)[0]); return result; })}/>}>
{tabs.map((tab) => (
// biome-ignore lint/a11y/noNoninteractiveTabindex: ARIA tabpanels need a keyboard entry point for their content.
<div key={tab.id} role="tabpanel" id={`${instance}-panel-${tab.id}`} aria-labelledby={`${instance}-tab-${tab.id}`} hidden={active !== tab.id} tabIndex={0} className="p-6 text-sm outline-none">{tab.title}</div>))}
</WorkspaceShell>;
}
"use client";
// asharca.github.io/ui/components/blocks/workspace-tab-bar
import { ArrowLeft, ArrowRight, ExternalLink, MoreHorizontal, Pin, PinOff, Plus, X } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { Button } from "@/components/motion/button/base";
import { MorphPopover, MorphPopoverTrigger, MorphPopoverContent } from "@/components/motion/popover-morph";
import { SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type MenuPoint = { x: number; y: number };
const LONG_PRESS_DELAY = 520;
const LONG_PRESS_TOLERANCE = 10;
export interface WorkspaceTab {
id: string;
title: string;
icon?: ReactNode;
pinned?: boolean;
dirty?: boolean;
closable?: boolean;
detachable?: boolean;
tabId?: string;
panelId?: string;
}
export interface WorkspaceTabBarProps {
tabs: WorkspaceTab[];
activeTabId: string;
onSelect: (id: string) => void;
onClose?: (id: string) => void;
onReorder?: (sourceId: string, targetId: string) => void;
onPinnedChange?: (id: string, pinned: boolean) => void;
onOpenInNewWindow?: (id: string) => void;
onNewTab?: () => void;
actions?: ReactNode;
closeOnMiddleClick?: boolean;
closeOnDoubleClick?: boolean;
className?: string;
}
interface TabAction { key: string; label: string; icon: ReactNode; disabled?: boolean; run: () => void }
/** Per-tab action menu. One beUI MorphPopover serves every invocation: the ⋯
* button, right-click, long-press and Shift+F10 all open this same panel.
* `openAt` positions without moving focus; a real trigger click just toggles. */
function TabActions({ title, items, restore, openRequest, onOpenHandled, anchorTabId }: {
title: string;
items: TabAction[];
restore: () => void;
openRequest: { point: MenuPoint; invocation: number } | null;
onOpenHandled: () => void;
anchorTabId: string;
}) {
const [open, setOpen] = useState(false);
const panel = useRef<HTMLDivElement>(null);
const popover = useRef<HTMLDivElement>(null);
const anchor = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
if (!open) return;
// beUI portals mount in an effect. Wait one frame for the actual actions.
const frame = requestAnimationFrame(() => panel.current?.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus());
return () => cancelAnimationFrame(frame);
}, [open]);
useLayoutEffect(() => {
if (!openRequest) return;
anchor.current = popover.current?.querySelector<HTMLElement>(`[data-tab-menu-anchor="${anchorTabId}"]`) ?? null;
setOpen(false);
setOpen(true);
onOpenHandled();
}, [openRequest, anchorTabId, onOpenHandled]);
return <span ref={popover} className="contents">
<MorphPopover open={open} onOpenChange={(next) => { setOpen(next); if (!next) restore(); }}>
{!openRequest && <MorphPopoverTrigger><Button variant="ghost" size="icon" className="size-6 rounded-md" aria-label={`${title}操作`}><MoreHorizontal className="size-3.5" /></Button></MorphPopoverTrigger>}
<MorphPopoverContent role="menu" align="end" radius={10} className="w-48 p-1">
<div ref={panel} inert={!open}>
{items.map((item) => <Button key={item.key} role="menuitem" variant="ghost" size="sm" disabled={item.disabled} className="w-full justify-start rounded-md px-2 text-xs"
onClick={() => { item.run(); setOpen(false); restore(); }}>{item.icon}{item.label}</Button>)}
</div>
</MorphPopoverContent>
</MorphPopover>
</span>;
}
/** Browser-like tabs joined to the content surface. Only the selected surface
* moves; controls stay in place. Selection/order/dirty confirmation are host-owned.
*/
interface TabMenuHostProps {
tab: WorkspaceTab;
index: number;
selected: boolean;
items: TabAction[];
instance: string;
tabs: WorkspaceTab[];
refs: React.MutableRefObject<Map<string, HTMLButtonElement>>;
onSelect: (id: string) => void;
close: (tab: WorkspaceTab) => void;
closeOnMiddleClick: boolean;
closeOnDoubleClick: boolean;
onReorder: boolean;
move: (tab: WorkspaceTab, target?: WorkspaceTab) => void;
selectAt: (index: number) => void;
restore: (id: string) => void;
canClose: (tab: WorkspaceTab) => boolean;
onClose: boolean;
}
/** One tab plus its sibling controls. Right-click, touch long-press and
* Shift+F10/CopyAll open the same MorphPopover as the ⋯ button — the
* ContextMenu copy is gone, so there is a single menu to maintain. */
function TabMenuHost({ tab, index, selected, items, instance, tabs, refs, onSelect, close, closeOnMiddleClick, closeOnDoubleClick, onReorder, move, selectAt, restore, canClose, onClose }: TabMenuHostProps) {
const reduce = useReducedMotion();
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const touchOrigin = useRef<MenuPoint | null>(null);
const [openRequest, setOpenRequest] = useState<{ point: MenuPoint; invocation: number } | null>(null);
const cancelLongPress = useCallback(() => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
touchOrigin.current = null;
}, []);
useEffect(() => cancelLongPress, [cancelLongPress]);
const openMenuAt = useCallback((point: MenuPoint) => {
setOpenRequest((current) => ({ point, invocation: (current?.invocation ?? 0) + 1 }));
}, []);
const onContextMenu = (event: ReactMouseEvent<HTMLElement>) => {
if (!items.length) return;
event.preventDefault();
cancelLongPress();
openMenuAt({ x: event.clientX, y: event.clientY });
};
// Touch/pen: hold to open, like the platform context menu. A drag cancels.
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if (!items.length) return;
const pressToOpen = event.pointerType === "touch" || event.pointerType === "pen";
if (pressToOpen) {
const origin = { x: event.clientX, y: event.clientY };
touchOrigin.current = origin;
longPressTimer.current = setTimeout(() => {
longPressTimer.current = null;
touchOrigin.current = null;
openMenuAt(origin);
}, LONG_PRESS_DELAY);
}
};
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
const origin = touchOrigin.current;
if (origin && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > LONG_PRESS_TOLERANCE) cancelLongPress();
};
// Keyboard: Menu key or Shift+F10 opens at the tab, matching ContextMenu's contract.
const onKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
if (event.key === "Delete") { event.preventDefault(); close(tab); }
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
if (!items.length) return;
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
openMenuAt({ x: rect.left + Math.min(24, rect.width / 2), y: rect.top + rect.height / 2 });
}
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
const step = (event.key === "ArrowLeft" ? -1 : 1) * (getComputedStyle(event.currentTarget).direction === "rtl" ? -1 : 1);
if (event.altKey) move(tab, tabs[index + step]); else selectAt(index + step);
}
if (event.key === "Home" || event.key === "End") { event.preventDefault(); selectAt(event.key === "Home" ? 0 : tabs.length - 1); }
};
return (
/* biome-ignore lint/a11y/useSemanticElements: This groups a tab and sibling actions, not form fields. */
<div role="group" aria-label={`${tab.title}标签及操作`} data-slot="workspace-tab" data-tab-key={tab.id} data-active={selected} tabIndex={-1}
draggable={onReorder}
onContextMenu={onContextMenu}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={cancelLongPress}
onPointerCancel={cancelLongPress}
onKeyDown={onKeyDown}
onDragStart={(event) => { event.dataTransfer.setData("text/plain", `${instance}:${tab.id}`); event.dataTransfer.effectAllowed = "move"; }}
onDragOver={(event) => { if (onReorder) event.preventDefault(); }}
onDrop={(event) => { event.preventDefault(); const key = event.dataTransfer.getData("text/plain"); const source = tabs.find((item) => `${instance}:${item.id}` === key); if (source) move(source, tab); }}
className="group relative isolate flex h-10 shrink-0 items-center rounded-t-[var(--workspace-radius,0.75rem)] px-1 pb-0.5 outline-none">
{selected && <motion.span aria-hidden="true" data-slot="workspace-tab-selection" initial={false}
layoutId={reduce ? undefined : `${instance}-surface`} transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn("pointer-events-none absolute inset-0 -z-10 rounded-t-[var(--workspace-radius,0.75rem)] bg-[var(--workspace-surface,var(--background))] after:absolute after:-right-2 after:bottom-0 after:size-2 after:bg-[radial-gradient(circle_at_top_right,transparent_70%,var(--workspace-surface,var(--background))_72%)]", index > 0 && "before:absolute before:-left-2 before:bottom-0 before:size-2 before:bg-[radial-gradient(circle_at_top_left,transparent_70%,var(--workspace-surface,var(--background))_72%)]")} />}
<button ref={(node) => { if (node) refs.current.set(tab.id, node); else refs.current.delete(tab.id); }} type="button" role="tab"
id={tab.tabId} aria-controls={tab.panelId} aria-selected={selected} tabIndex={selected ? 0 : -1}
onClick={() => onSelect(tab.id)}
onAuxClick={(event) => { if (event.button === 1 && closeOnMiddleClick) { event.preventDefault(); close(tab); } }}
onDoubleClick={() => { if (closeOnDoubleClick) close(tab); }}
className="inline-flex h-8 max-w-48 items-center gap-2 rounded-md px-2.5 text-xs text-muted-foreground outline-none aria-selected:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
{tab.icon && <span aria-hidden="true" className="inline-flex shrink-0 [&_svg]:size-3.5">{tab.icon}</span>}
{tab.pinned && <Pin aria-hidden="true" className="size-3 shrink-0" />}
<span className="truncate" title={tab.title}>{tab.title}</span>
{tab.dirty && <><span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-current" /><span className="sr-only">(未保存)</span></>}
</button>
{items.length > 0 && <TabActions title={tab.title} items={items} restore={() => restore(tab.id)} openRequest={openRequest} onOpenHandled={() => setOpenRequest(null)} anchorTabId={tab.id} />}
{onClose && !tab.pinned && <Button variant="ghost" size="icon" className="mr-1 size-6 rounded-md" aria-label={`关闭 ${tab.title}`} disabled={!canClose(tab)} onClick={() => close(tab)}><X aria-hidden="true" className="size-3.5" /></Button>}
</div>
);
}
export function WorkspaceTabBar({ tabs, activeTabId, onSelect, onClose, onReorder, onPinnedChange, onOpenInNewWindow, onNewTab, actions, closeOnMiddleClick = true, closeOnDoubleClick = false, className }: WorkspaceTabBarProps) {
const instance = useId();
const refs = useRef(new Map<string, HTMLButtonElement>());
const scroller = useRef<HTMLDivElement>(null);
const closing = useRef<string | null>(null);
const currentId = tabs.some((tab) => tab.id === activeTabId) ? activeTabId : tabs[0]?.id;
const canClose = (tab: WorkspaceTab) => Boolean(onClose && !tab.pinned && tab.closable !== false && tabs.length > 1);
const restore = (id: string) => (refs.current.get(id) ?? refs.current.get(currentId) ?? refs.current.get(tabs[0]?.id))?.focus({ preventScroll: true });
useLayoutEffect(() => {
if (closing.current && !tabs.some((tab) => tab.id === closing.current)) {
closing.current = null;
(refs.current.get(currentId) ?? refs.current.get(tabs[0]?.id))?.focus({ preventScroll: true });
}
const node = scroller.current;
const active = refs.current.get(currentId)?.closest<HTMLElement>("[data-slot='workspace-tab']");
if (!node || !active) return;
const bounds = node.getBoundingClientRect();
const rect = active.getBoundingClientRect();
if (rect.left < bounds.left) node.scrollLeft += rect.left - bounds.left;
else if (rect.right > bounds.right) node.scrollLeft += rect.right - bounds.right;
}, [currentId, tabs]);
const close = (tab: WorkspaceTab) => { if (canClose(tab)) { closing.current = tab.id; onClose?.(tab.id); } };
const move = (tab: WorkspaceTab, target?: WorkspaceTab) => {
if (!target || tab.id === target.id || Boolean(tab.pinned) !== Boolean(target.pinned)) return;
onReorder?.(tab.id, target.id);
restore(tab.id);
};
const selectAt = (index: number) => {
const target = tabs[(index + tabs.length) % tabs.length];
if (target) { onSelect(target.id); restore(target.id); }
};
const menuItems = (tab: WorkspaceTab, index: number) => {
const items: TabAction[] = [];
if (onOpenInNewWindow) items.push({ key: "detach", label: "在独立窗口打开", icon: <ExternalLink className="size-3.5" />, disabled: tab.detachable === false, run: () => onOpenInNewWindow(tab.id) });
if (onPinnedChange) items.push({ key: "pin", label: tab.pinned ? "取消固定" : "固定标签", icon: tab.pinned ? <PinOff className="size-3.5" /> : <Pin className="size-3.5" />, run: () => onPinnedChange(tab.id, !tab.pinned) });
if (onReorder) {
items.push({ key: "left", label: "向左移动", icon: <ArrowLeft className="size-3.5" />, disabled: !tabs[index - 1] || Boolean(tabs[index - 1].pinned) !== Boolean(tab.pinned), run: () => move(tab, tabs[index - 1]) });
items.push({ key: "right", label: "向右移动", icon: <ArrowRight className="size-3.5" />, disabled: !tabs[index + 1] || Boolean(tabs[index + 1].pinned) !== Boolean(tab.pinned), run: () => move(tab, tabs[index + 1]) });
}
if (onClose) items.push({ key: "close", label: "关闭标签", icon: <X className="size-3.5" />, disabled: !canClose(tab), run: () => close(tab) });
return items;
};
return <div data-slot="workspace-tab-strip" data-leading-active={tabs[0]?.id === currentId} className={cn("flex h-11 max-w-full min-w-0 shrink-0 items-end", className)}>
<motion.div layoutScroll ref={scroller} data-slot="workspace-tab-bar" className="min-w-0 flex-1 overflow-x-auto overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{/* Each tab owns sibling action buttons; never put controls inside a tab button. */}
<div role="tablist" aria-label="工作区标签" className="flex h-11 min-w-max items-end gap-1 pt-1">
{tabs.map((tab, index) => {
const selected = tab.id === currentId;
const items = menuItems(tab, index);
return <TabMenuHost key={tab.id} tab={tab} index={index} selected={selected} items={items} instance={instance} tabs={tabs}
refs={refs} onSelect={onSelect} close={close} closeOnMiddleClick={closeOnMiddleClick}
closeOnDoubleClick={closeOnDoubleClick} onReorder={Boolean(onReorder)} move={move} selectAt={selectAt}
restore={restore} canClose={canClose} onClose={Boolean(onClose)} />;
})}
</div>
</motion.div>
{(onNewTab || actions) && <div data-slot="workspace-tab-actions" className="flex h-11 shrink-0 items-center gap-1 pl-1">
{onNewTab && <Button variant="ghost" size="icon" aria-label="新建标签" onClick={onNewTab}><Plus className="size-4" /></Button>}{actions}
</div>}
</div>;
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
"use client";
// asharca.github.io/ui/components/blocks/workspace-tab-bar
import { ArrowLeft, ArrowRight, ExternalLink, MoreHorizontal, Pin, PinOff, Plus, X } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { Button } from "@/components/motion/button/base";
import { MorphPopover, MorphPopoverTrigger, MorphPopoverContent } from "@/components/motion/popover-morph";
import { SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type MenuPoint = { x: number; y: number };
const LONG_PRESS_DELAY = 520;
const LONG_PRESS_TOLERANCE = 10;
export interface WorkspaceTab {
id: string;
title: string;
icon?: ReactNode;
pinned?: boolean;
dirty?: boolean;
closable?: boolean;
detachable?: boolean;
tabId?: string;
panelId?: string;
}
export interface WorkspaceTabBarProps {
tabs: WorkspaceTab[];
activeTabId: string;
onSelect: (id: string) => void;
onClose?: (id: string) => void;
onReorder?: (sourceId: string, targetId: string) => void;
onPinnedChange?: (id: string, pinned: boolean) => void;
onOpenInNewWindow?: (id: string) => void;
onNewTab?: () => void;
actions?: ReactNode;
closeOnMiddleClick?: boolean;
closeOnDoubleClick?: boolean;
className?: string;
}
interface TabAction { key: string; label: string; icon: ReactNode; disabled?: boolean; run: () => void }
/** Per-tab action menu. One beUI MorphPopover serves every invocation: the ⋯
* button, right-click, long-press and Shift+F10 all open this same panel.
* `openAt` positions without moving focus; a real trigger click just toggles. */
function TabActions({ title, items, restore, openRequest, onOpenHandled, anchorTabId }: {
title: string;
items: TabAction[];
restore: () => void;
openRequest: { point: MenuPoint; invocation: number } | null;
onOpenHandled: () => void;
anchorTabId: string;
}) {
const [open, setOpen] = useState(false);
const panel = useRef<HTMLDivElement>(null);
const popover = useRef<HTMLDivElement>(null);
const anchor = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
if (!open) return;
// beUI portals mount in an effect. Wait one frame for the actual actions.
const frame = requestAnimationFrame(() => panel.current?.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus());
return () => cancelAnimationFrame(frame);
}, [open]);
useLayoutEffect(() => {
if (!openRequest) return;
anchor.current = popover.current?.querySelector<HTMLElement>(`[data-tab-menu-anchor="${anchorTabId}"]`) ?? null;
setOpen(false);
setOpen(true);
onOpenHandled();
}, [openRequest, anchorTabId, onOpenHandled]);
return <span ref={popover} className="contents">
<MorphPopover open={open} onOpenChange={(next) => { setOpen(next); if (!next) restore(); }}>
{!openRequest && <MorphPopoverTrigger><Button variant="ghost" size="icon" className="size-6 rounded-md" aria-label={`${title}操作`}><MoreHorizontal className="size-3.5" /></Button></MorphPopoverTrigger>}
<MorphPopoverContent role="menu" align="end" radius={10} className="w-48 p-1">
<div ref={panel} inert={!open}>
{items.map((item) => <Button key={item.key} role="menuitem" variant="ghost" size="sm" disabled={item.disabled} className="w-full justify-start rounded-md px-2 text-xs"
onClick={() => { item.run(); setOpen(false); restore(); }}>{item.icon}{item.label}</Button>)}
</div>
</MorphPopoverContent>
</MorphPopover>
</span>;
}
/** Browser-like tabs joined to the content surface. Only the selected surface
* moves; controls stay in place. Selection/order/dirty confirmation are host-owned.
*/
interface TabMenuHostProps {
tab: WorkspaceTab;
index: number;
selected: boolean;
items: TabAction[];
instance: string;
tabs: WorkspaceTab[];
refs: React.MutableRefObject<Map<string, HTMLButtonElement>>;
onSelect: (id: string) => void;
close: (tab: WorkspaceTab) => void;
closeOnMiddleClick: boolean;
closeOnDoubleClick: boolean;
onReorder: boolean;
move: (tab: WorkspaceTab, target?: WorkspaceTab) => void;
selectAt: (index: number) => void;
restore: (id: string) => void;
canClose: (tab: WorkspaceTab) => boolean;
onClose: boolean;
}
/** One tab plus its sibling controls. Right-click, touch long-press and
* Shift+F10/CopyAll open the same MorphPopover as the ⋯ button — the
* ContextMenu copy is gone, so there is a single menu to maintain. */
function TabMenuHost({ tab, index, selected, items, instance, tabs, refs, onSelect, close, closeOnMiddleClick, closeOnDoubleClick, onReorder, move, selectAt, restore, canClose, onClose }: TabMenuHostProps) {
const reduce = useReducedMotion();
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const touchOrigin = useRef<MenuPoint | null>(null);
const [openRequest, setOpenRequest] = useState<{ point: MenuPoint; invocation: number } | null>(null);
const cancelLongPress = useCallback(() => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
touchOrigin.current = null;
}, []);
useEffect(() => cancelLongPress, [cancelLongPress]);
const openMenuAt = useCallback((point: MenuPoint) => {
setOpenRequest((current) => ({ point, invocation: (current?.invocation ?? 0) + 1 }));
}, []);
const onContextMenu = (event: ReactMouseEvent<HTMLElement>) => {
if (!items.length) return;
event.preventDefault();
cancelLongPress();
openMenuAt({ x: event.clientX, y: event.clientY });
};
// Touch/pen: hold to open, like the platform context menu. A drag cancels.
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if (!items.length) return;
const pressToOpen = event.pointerType === "touch" || event.pointerType === "pen";
if (pressToOpen) {
const origin = { x: event.clientX, y: event.clientY };
touchOrigin.current = origin;
longPressTimer.current = setTimeout(() => {
longPressTimer.current = null;
touchOrigin.current = null;
openMenuAt(origin);
}, LONG_PRESS_DELAY);
}
};
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
const origin = touchOrigin.current;
if (origin && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > LONG_PRESS_TOLERANCE) cancelLongPress();
};
// Keyboard: Menu key or Shift+F10 opens at the tab, matching ContextMenu's contract.
const onKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
if (event.key === "Delete") { event.preventDefault(); close(tab); }
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
if (!items.length) return;
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
openMenuAt({ x: rect.left + Math.min(24, rect.width / 2), y: rect.top + rect.height / 2 });
}
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
const step = (event.key === "ArrowLeft" ? -1 : 1) * (getComputedStyle(event.currentTarget).direction === "rtl" ? -1 : 1);
if (event.altKey) move(tab, tabs[index + step]); else selectAt(index + step);
}
if (event.key === "Home" || event.key === "End") { event.preventDefault(); selectAt(event.key === "Home" ? 0 : tabs.length - 1); }
};
return (
/* biome-ignore lint/a11y/useSemanticElements: This groups a tab and sibling actions, not form fields. */
<div role="group" aria-label={`${tab.title}标签及操作`} data-slot="workspace-tab" data-tab-key={tab.id} data-active={selected} tabIndex={-1}
draggable={onReorder}
onContextMenu={onContextMenu}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={cancelLongPress}
onPointerCancel={cancelLongPress}
onKeyDown={onKeyDown}
onDragStart={(event) => { event.dataTransfer.setData("text/plain", `${instance}:${tab.id}`); event.dataTransfer.effectAllowed = "move"; }}
onDragOver={(event) => { if (onReorder) event.preventDefault(); }}
onDrop={(event) => { event.preventDefault(); const key = event.dataTransfer.getData("text/plain"); const source = tabs.find((item) => `${instance}:${item.id}` === key); if (source) move(source, tab); }}
className="group relative isolate flex h-10 shrink-0 items-center rounded-t-[var(--workspace-radius,0.75rem)] px-1 pb-0.5 outline-none">
{selected && <motion.span aria-hidden="true" data-slot="workspace-tab-selection" initial={false}
layoutId={reduce ? undefined : `${instance}-surface`} transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn("pointer-events-none absolute inset-0 -z-10 rounded-t-[var(--workspace-radius,0.75rem)] bg-[var(--workspace-surface,var(--background))] after:absolute after:-right-2 after:bottom-0 after:size-2 after:bg-[radial-gradient(circle_at_top_right,transparent_70%,var(--workspace-surface,var(--background))_72%)]", index > 0 && "before:absolute before:-left-2 before:bottom-0 before:size-2 before:bg-[radial-gradient(circle_at_top_left,transparent_70%,var(--workspace-surface,var(--background))_72%)]")} />}
<button ref={(node) => { if (node) refs.current.set(tab.id, node); else refs.current.delete(tab.id); }} type="button" role="tab"
id={tab.tabId} aria-controls={tab.panelId} aria-selected={selected} tabIndex={selected ? 0 : -1}
onClick={() => onSelect(tab.id)}
onAuxClick={(event) => { if (event.button === 1 && closeOnMiddleClick) { event.preventDefault(); close(tab); } }}
onDoubleClick={() => { if (closeOnDoubleClick) close(tab); }}
className="inline-flex h-8 max-w-48 items-center gap-2 rounded-md px-2.5 text-xs text-muted-foreground outline-none aria-selected:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
{tab.icon && <span aria-hidden="true" className="inline-flex shrink-0 [&_svg]:size-3.5">{tab.icon}</span>}
{tab.pinned && <Pin aria-hidden="true" className="size-3 shrink-0" />}
<span className="truncate" title={tab.title}>{tab.title}</span>
{tab.dirty && <><span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-current" /><span className="sr-only">(未保存)</span></>}
</button>
{items.length > 0 && <TabActions title={tab.title} items={items} restore={() => restore(tab.id)} openRequest={openRequest} onOpenHandled={() => setOpenRequest(null)} anchorTabId={tab.id} />}
{onClose && !tab.pinned && <Button variant="ghost" size="icon" className="mr-1 size-6 rounded-md" aria-label={`关闭 ${tab.title}`} disabled={!canClose(tab)} onClick={() => close(tab)}><X aria-hidden="true" className="size-3.5" /></Button>}
</div>
);
}
export function WorkspaceTabBar({ tabs, activeTabId, onSelect, onClose, onReorder, onPinnedChange, onOpenInNewWindow, onNewTab, actions, closeOnMiddleClick = true, closeOnDoubleClick = false, className }: WorkspaceTabBarProps) {
const instance = useId();
const refs = useRef(new Map<string, HTMLButtonElement>());
const scroller = useRef<HTMLDivElement>(null);
const closing = useRef<string | null>(null);
const currentId = tabs.some((tab) => tab.id === activeTabId) ? activeTabId : tabs[0]?.id;
const canClose = (tab: WorkspaceTab) => Boolean(onClose && !tab.pinned && tab.closable !== false && tabs.length > 1);
const restore = (id: string) => (refs.current.get(id) ?? refs.current.get(currentId) ?? refs.current.get(tabs[0]?.id))?.focus({ preventScroll: true });
useLayoutEffect(() => {
if (closing.current && !tabs.some((tab) => tab.id === closing.current)) {
closing.current = null;
(refs.current.get(currentId) ?? refs.current.get(tabs[0]?.id))?.focus({ preventScroll: true });
}
const node = scroller.current;
const active = refs.current.get(currentId)?.closest<HTMLElement>("[data-slot='workspace-tab']");
if (!node || !active) return;
const bounds = node.getBoundingClientRect();
const rect = active.getBoundingClientRect();
if (rect.left < bounds.left) node.scrollLeft += rect.left - bounds.left;
else if (rect.right > bounds.right) node.scrollLeft += rect.right - bounds.right;
}, [currentId, tabs]);
const close = (tab: WorkspaceTab) => { if (canClose(tab)) { closing.current = tab.id; onClose?.(tab.id); } };
const move = (tab: WorkspaceTab, target?: WorkspaceTab) => {
if (!target || tab.id === target.id || Boolean(tab.pinned) !== Boolean(target.pinned)) return;
onReorder?.(tab.id, target.id);
restore(tab.id);
};
const selectAt = (index: number) => {
const target = tabs[(index + tabs.length) % tabs.length];
if (target) { onSelect(target.id); restore(target.id); }
};
const menuItems = (tab: WorkspaceTab, index: number) => {
const items: TabAction[] = [];
if (onOpenInNewWindow) items.push({ key: "detach", label: "在独立窗口打开", icon: <ExternalLink className="size-3.5" />, disabled: tab.detachable === false, run: () => onOpenInNewWindow(tab.id) });
if (onPinnedChange) items.push({ key: "pin", label: tab.pinned ? "取消固定" : "固定标签", icon: tab.pinned ? <PinOff className="size-3.5" /> : <Pin className="size-3.5" />, run: () => onPinnedChange(tab.id, !tab.pinned) });
if (onReorder) {
items.push({ key: "left", label: "向左移动", icon: <ArrowLeft className="size-3.5" />, disabled: !tabs[index - 1] || Boolean(tabs[index - 1].pinned) !== Boolean(tab.pinned), run: () => move(tab, tabs[index - 1]) });
items.push({ key: "right", label: "向右移动", icon: <ArrowRight className="size-3.5" />, disabled: !tabs[index + 1] || Boolean(tabs[index + 1].pinned) !== Boolean(tab.pinned), run: () => move(tab, tabs[index + 1]) });
}
if (onClose) items.push({ key: "close", label: "关闭标签", icon: <X className="size-3.5" />, disabled: !canClose(tab), run: () => close(tab) });
return items;
};
return <div data-slot="workspace-tab-strip" data-leading-active={tabs[0]?.id === currentId} className={cn("flex h-11 max-w-full min-w-0 shrink-0 items-end", className)}>
<motion.div layoutScroll ref={scroller} data-slot="workspace-tab-bar" className="min-w-0 flex-1 overflow-x-auto overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{/* Each tab owns sibling action buttons; never put controls inside a tab button. */}
<div role="tablist" aria-label="工作区标签" className="flex h-11 min-w-max items-end gap-1 pt-1">
{tabs.map((tab, index) => {
const selected = tab.id === currentId;
const items = menuItems(tab, index);
return <TabMenuHost key={tab.id} tab={tab} index={index} selected={selected} items={items} instance={instance} tabs={tabs}
refs={refs} onSelect={onSelect} close={close} closeOnMiddleClick={closeOnMiddleClick}
closeOnDoubleClick={closeOnDoubleClick} onReorder={Boolean(onReorder)} move={move} selectAt={selectAt}
restore={restore} canClose={canClose} onClose={Boolean(onClose)} />;
})}
</div>
</motion.div>
{(onNewTab || actions) && <div data-slot="workspace-tab-actions" className="flex h-11 shrink-0 items-center gap-1 pl-1">
{onNewTab && <Button variant="ghost" size="icon" aria-label="新建标签" onClick={onNewTab}><Plus className="size-4" /></Button>}{actions}
</div>}
</div>;
}
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
export interface ButtonLinkProps extends Omit<
HTMLMotionProps<"a">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-muted/60",
outline:
"border border-border bg-transparent text-foreground hover:bg-muted/60",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0.05, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
variant = "primary",
size = "md",
pressScale = 0.93,
className,
children,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
return (
<motion.a
ref={ref}
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{children}
</motion.a>
);
},
);
"use client";
import {
AnimatePresence,
motion,
animate,
useMotionValue,
usePresence,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
/** The element the panel measures against — see `registerTrigger`. */
triggerRef: React.MutableRefObject<HTMLElement | null>;
registerTrigger: (node: HTMLElement | null) => void;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
// A trigger normally registers itself through MorphPopoverTrigger. It can't
// when something else already clones the element — a Tooltip wrapping the
// button, say — and an unregistered trigger leaves the panel with nothing to
// measure against, so it renders permanently invisible. The root boxes the
// trigger exactly (the content portals out of it), so it stands in until a
// real trigger registers, and stands in again if that one unmounts. Both are
// state, so a trigger arriving while the panel is open re-anchors it.
const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(
() => ({ current: trigger ?? root }),
[root, trigger],
);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: a dismissal hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded. When no trigger has
// registered, the root anchor stands in only if it can actually hold focus;
// there is nowhere better than where the keyboard already is, so leave it.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (!inPanel) return;
const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);
restore?.focus();
}, [root, setOpen, trigger]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
root &&
!root.contains(target) &&
!contentRef.current?.contains(target)
)
close();
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, root, close]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef: anchorRef,
registerTrigger: setTrigger,
contentRef,
}),
[open, setOpen, toggle, baseId, anchorRef],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={setRoot} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object")
(ref as React.MutableRefObject<T | null>).current = node;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child?.props?.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child?.props as { ref?: Ref<HTMLElement> } | undefined)
?.ref;
// Register once per actual ref change, not once per open-state render.
const mergedRef = useMemo(
() => mergeRefs(childRef, ctx.registerTrigger),
[childRef, ctx.registerTrigger],
);
if (!isValidElement(children)) return children;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergedRef,
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipAt(side: Side, align: Align, radius: number, inset: number) {
const top = side === "bottom" ? "0%" : `${inset}%`;
const bottom = side === "bottom" ? `${inset}%` : "0%";
const right = align === "end" ? "0%" : `${inset}%`;
const left = align === "end" ? `${inset}%` : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
/** Draw the surface shadow. Default true. */
shadow?: boolean;
/** Runs after the portalled surface is positioned and visible. */
onOpenAutoFocus?: (content: HTMLDivElement) => void;
/** Override the panel landmark. Menus pass "menu" instead of the dialog default. */
role?: "dialog" | "menu";
className?: string;
}
export function MorphPopoverContent(props: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const [portalReady, setPortalReady] = useState(false);
useEffect(() => setPortalReady(true), []);
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open && <MorphPopoverSurface {...props} />}
</AnimatePresence>,
document.body,
);
}
// Measurement belongs to the mounted portal session: reopening must not start
// an entrance at the previous session's coordinates before measuring this one.
function MorphPopoverSurface({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
shadow = true,
onOpenAutoFocus,
role = "dialog",
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [isPresent, safeToRemove] = usePresence();
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
isPresent,
);
const preferredLeft = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
// Larger compositions, such as calendars, use the roomier side if needed.
const gutter = 8;
const below = layout
? Math.max(0, window.innerHeight - layout.trigger.top - layout.trigger.height - sideOffset - gutter)
: 0;
const above = layout ? Math.max(0, layout.trigger.top - sideOffset - gutter) : 0;
const requestedSpace = side === "bottom" ? below : above;
const otherSpace = side === "bottom" ? above : below;
const resolvedSide = layout && layout.content.height > requestedSpace && otherSpace > requestedSpace
? side === "bottom" ? "top" : "bottom"
: side;
const availableHeight = resolvedSide === "bottom" ? below : above;
const left = layout
? Math.max(gutter, Math.min(preferredLeft, window.innerWidth - layout.content.width - gutter))
: 0;
const top = layout
? resolvedSide === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: Math.max(gutter, layout.trigger.top - layout.content.height - sideOffset)
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { scale: 0.96, transition: SPRING_PANEL },
show: { scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipAt(resolvedSide, align, radius, 92),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipAt(resolvedSide, align, radius, 0),
transition: MORPH_CLIP_TRANSITION,
},
};
// Animate the value directly so opacity stays in the inline style throughout
// the entrance. A native opacity animation can expose the initial inline 0
// for a frame when it finishes, before Motion writes the final value.
const opacity = useMotionValue(0);
const ready = layout !== null;
const focusedOnOpen = useRef(false);
useEffect(() => {
if (!ready || !isPresent || focusedOnOpen.current || !ctx.contentRef.current) return;
focusedOnOpen.current = true;
onOpenAutoFocus?.(ctx.contentRef.current);
}, [ready, isPresent, ctx.contentRef, onOpenAutoFocus]);
useEffect(() => {
if (!ready) {
if (!isPresent) safeToRemove?.();
return;
}
const animation = animate(opacity, isPresent ? 1 : 0, {
...(reduce ? { duration: 0.12 } : SPRING_PANEL),
onComplete: () => {
if (!isPresent) safeToRemove?.();
},
});
return () => animation.stop();
}, [opacity, ready, isPresent, reduce, safeToRemove]);
return (
<motion.div
data-morph-popover-portal=""
inert={!isPresent}
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial="hidden"
animate={layout ? "show" : "hidden"}
exit="hidden"
style={{
left,
top,
opacity,
pointerEvents: isPresent ? "auto" : "none",
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(resolvedSide, align),
}}
className={cn("fixed z-[9999]", shadow && "[filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]")}
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role={role}
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius, maxHeight: layout ? availableHeight : undefined, overflowY: "auto" }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
);
}
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
"use client";
import type { ComponentPropsWithRef, ReactNode } from "react";
import {
AnimatedSidebarProvider,
type AnimatedSidebarProviderProps,
} from "@/components/motion/animated-sidebar";
import { cn } from "@/lib/utils";
export interface WorkspaceShellProps extends AnimatedSidebarProviderProps {
sidebar?: ReactNode;
tabBar?: ReactNode;
mobileHeader?: ReactNode;
header?: ReactNode;
footer?: ReactNode;
/** content: this pane scrolls; none: the editor/chat child owns scrolling. */
scroll?: "content" | "none";
contentProps?: Omit<ComponentPropsWithRef<"div">, "children">;
}
const hasSlot = (node: ReactNode) => node != null && typeof node !== "boolean";
/** The beUI sidebar provider owns navigation state; the host owns tabs and data.
* Set a height on the shell. Tabs and content share one alignment line, and the
* first active tab removes only the adjoining top-left corner of the surface.
* data-workspace-shell identifies this composition without replacing beUI's
* own data-slot="sidebar-wrapper" or introducing a second layout wrapper.
*/
export function WorkspaceShell({
sidebar, tabBar, mobileHeader, header, footer, children, className, style,
scroll = "content", contentProps, ...props
}: WorkspaceShellProps) {
const sidebarInset = hasSlot(sidebar) && "md:ml-[var(--workspace-gap,0px)]";
return (
<AnimatedSidebarProvider
{...props}
data-workspace-shell=""
style={{ "--sidebar-width": "14rem", "--sidebar-width-icon": "4rem", ...style }}
className={cn("h-full min-h-0 w-full overflow-hidden bg-[var(--workspace-shell-background,var(--muted))] text-foreground", className)}
>
{sidebar}
<div data-slot="workspace-body" className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{hasSlot(mobileHeader) && <div data-slot="workspace-mobile-header" className="flex h-12 shrink-0 items-center gap-2 px-2 md:hidden">{mobileHeader}</div>}
{hasSlot(tabBar) && <div data-slot="workspace-shell-tabs" className={cn("peer/workspace-tabs mx-[var(--workspace-gap,0.5rem)] min-w-0 shrink-0", sidebarInset)}>{tabBar}</div>}
<div data-slot="workspace-surface" className={cn(
"m-[var(--workspace-gap,0.5rem)] flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-[var(--workspace-radius,0.75rem)] bg-[var(--workspace-surface,var(--background))]",
sidebarInset,
hasSlot(tabBar) && "mt-0 peer-has-[[data-leading-active=true]]/workspace-tabs:rounded-tl-none",
)}>
{hasSlot(header) && <div data-slot="workspace-header" className="min-w-0 shrink-0">{header}</div>}
<div {...contentProps} data-slot="workspace-content" data-scroll={scroll} className={cn("min-h-0 min-w-0 flex-1", scroll === "content" ? "overflow-auto overscroll-contain" : "flex flex-col overflow-hidden", contentProps?.className)}>{children}</div>
{hasSlot(footer) && <div data-slot="workspace-footer" className="min-w-0 shrink-0">{footer}</div>}
</div>
</div>
</AnimatedSidebarProvider>
);
}
/** Invoke synchronously from a user action. null means the browser blocked it.
* Only the host decides whether to remove a tab and what state may be copied.
*/
export function openWorkspaceWindow(href: string | URL, prepare?: (popup: Window) => void): Window | null {
const url = new URL(href, window.location.href);
if (!["http:", "https:"].includes(url.protocol) || url.origin !== window.location.origin || url.username || url.password) {
throw new Error("工作区窗口必须使用不含凭据的同源 HTTP(S) 地址。");
}
const popup = window.open("about:blank", "_blank", "popup,width=1100,height=760");
if (!popup) return null;
try {
popup.opener = null;
prepare?.(popup);
popup.location.replace(url.href);
return popup;
} catch (error) {
popup.close();
throw error;
}
}
"use client";
import { ChevronRight } from "lucide-react";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
type ButtonHTMLAttributes,
type CSSProperties,
createContext,
forwardRef,
type HTMLAttributes,
type ReactNode,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { createPortal } from "react-dom";
import { SharedLayoutBg } from "@/components/motion/shared-layout-bg";
import {
EASE_DRAWER,
EASE_OUT,
SPRING_LAYOUT,
SPRING_PRESS,
} from "@/lib/ease";
import { cn } from "@/lib/utils";
type SidebarState = "expanded" | "collapsed";
type SidebarSide = "left" | "right";
type SidebarVariant = "sidebar" | "floating" | "inset";
type SidebarCollapsible = "offcanvas" | "icon" | "none";
const MOBILE_QUERY = "(max-width: 767px)";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
const PANEL_TRANSITION = {
duration: 0.36,
ease: EASE_DRAWER,
} as const;
// The desktop rail settles at a hard zero-width boundary. Keep the spring
// critically damped so it cannot overshoot, pause against that boundary, and
// then snap back during the final frame.
const SIDEBAR_MORPH_TRANSITION = {
type: "spring",
stiffness: 380,
damping: 35,
mass: 0.75,
} as const;
const LABEL_ENTER_TRANSITION = {
duration: 0.2,
delay: 0.08,
ease: EASE_OUT,
} as const;
const LABEL_EXIT_TRANSITION = {
duration: 0.12,
ease: EASE_OUT,
} as const;
const SUBMENU_TRANSITION = {
duration: 0.18,
ease: EASE_OUT,
} as const;
const SUBMENU_VARIANTS: Variants = {
closed: {
opacity: 0,
clipPath: "inset(0 0 100% 0 round 8px)",
transition: {
duration: 0.14,
ease: EASE_OUT,
staggerChildren: 0.025,
staggerDirection: -1,
},
},
open: {
opacity: 1,
clipPath: "inset(0 0 0% 0 round 8px)",
transition: {
duration: 0.2,
delayChildren: 0.035,
ease: EASE_OUT,
staggerChildren: 0.045,
},
},
};
const SUBMENU_ITEM_VARIANTS: Variants = {
closed: {
opacity: 0,
y: -6,
filter: "blur(3px)",
},
open: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: SUBMENU_TRANSITION,
},
};
const REDUCED_TRANSITION = {
duration: 0.16,
ease: EASE_OUT,
} as const;
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
function subscribeToMobileQuery(callback: () => void) {
const query = window.matchMedia(MOBILE_QUERY);
query.addEventListener("change", callback);
return () => query.removeEventListener("change", callback);
}
function getMobileSnapshot() {
return window.matchMedia(MOBILE_QUERY).matches;
}
function getServerMobileSnapshot() {
return false;
}
function useIsMobile() {
return useSyncExternalStore(
subscribeToMobileQuery,
getMobileSnapshot,
getServerMobileSnapshot,
);
}
interface AnimatedSidebarContextValue {
isMobile: boolean;
layoutId: string;
open: boolean;
openMobile: boolean;
reduce: boolean;
setOpen: (open: boolean) => void;
setOpenMobile: (open: boolean) => void;
state: SidebarState;
toggleSidebar: () => void;
triggerRef: React.RefObject<HTMLButtonElement | null>;
}
const AnimatedSidebarContext =
createContext<AnimatedSidebarContextValue | null>(null);
interface AnimatedSidebarPanelContextValue {
collapsed: boolean;
collapsible: SidebarCollapsible;
side: SidebarSide;
}
const AnimatedSidebarPanelContext =
createContext<AnimatedSidebarPanelContextValue | null>(null);
export function useAnimatedSidebar() {
const context = useContext(AnimatedSidebarContext);
if (!context) {
throw new Error(
"useAnimatedSidebar must be used inside AnimatedSidebarProvider.",
);
}
return context;
}
function useAnimatedSidebarPanel() {
const context = useContext(AnimatedSidebarPanelContext);
if (!context) {
throw new Error(
"Animated Sidebar parts must be used inside AnimatedSidebar.",
);
}
return context;
}
type SidebarProviderStyle = CSSProperties & {
"--sidebar-width"?: string;
"--sidebar-width-icon"?: string;
"--sidebar-width-mobile"?: string;
};
export interface AnimatedSidebarProviderProps
extends HTMLAttributes<HTMLDivElement> {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
openMobile?: boolean;
defaultOpenMobile?: boolean;
onOpenMobileChange?: (open: boolean) => void;
style?: SidebarProviderStyle;
}
export function AnimatedSidebarProvider({
children,
open,
defaultOpen = true,
onOpenChange,
openMobile,
defaultOpenMobile = false,
onOpenMobileChange,
className,
style,
...props
}: AnimatedSidebarProviderProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [internalOpenMobile, setInternalOpenMobile] =
useState(defaultOpenMobile);
const isMobile = useIsMobile();
const reduce = useReducedMotion() ?? false;
const generatedId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const desktopOpen = open ?? internalOpen;
const mobileOpen = openMobile ?? internalOpenMobile;
const setOpen = useCallback(
(nextOpen: boolean) => {
if (open === undefined) setInternalOpen(nextOpen);
onOpenChange?.(nextOpen);
},
[onOpenChange, open],
);
const setOpenMobile = useCallback(
(nextOpen: boolean) => {
if (openMobile === undefined) setInternalOpenMobile(nextOpen);
onOpenMobileChange?.(nextOpen);
},
[onOpenMobileChange, openMobile],
);
const toggleSidebar = useCallback(() => {
if (isMobile) setOpenMobile(!mobileOpen);
else setOpen(!desktopOpen);
}, [desktopOpen, isMobile, mobileOpen, setOpen, setOpenMobile]);
useEffect(() => {
const handleShortcut = (event: KeyboardEvent) => {
if (
event.key.toLowerCase() === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleShortcut);
return () => window.removeEventListener("keydown", handleShortcut);
}, [toggleSidebar]);
return (
<AnimatedSidebarContext.Provider
value={{
isMobile,
layoutId: `${generatedId}-active`,
open: desktopOpen,
openMobile: mobileOpen,
reduce,
setOpen,
setOpenMobile,
state: desktopOpen ? "expanded" : "collapsed",
toggleSidebar,
triggerRef,
}}
>
<div
{...props}
data-slot="sidebar-wrapper"
data-state={desktopOpen ? "expanded" : "collapsed"}
style={{
"--sidebar-width": "16rem",
"--sidebar-width-icon": "4.25rem",
"--sidebar-width-mobile": "18rem",
...style,
}}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full min-w-0",
className,
)}
>
{children}
</div>
</AnimatedSidebarContext.Provider>
);
}
function MobileSidebar({
ariaLabel,
children,
className,
side,
}: {
ariaLabel: string;
children: ReactNode;
className?: string;
side: SidebarSide;
}) {
const context = useAnimatedSidebar();
const panelRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
// The sheet is mounted for as long as the viewport is mobile, so it hides
// itself while closed rather than sitting there transparent and interactive.
// Opening shows it in the same commit that starts the slide — a delayed show
// would run the focus effect below against a still-hidden panel, and focus()
// on a hidden element is ignored. Closing waits for the slide to finish, and
// the panel's own exit tells us when that is: no duration to keep in sync.
const [hidden, setHidden] = useState(!context.openMobile);
// The completion callback fires for the open slide too, and it reads state
// from whenever motion settles: a ref keeps it on the current one.
const openMobileRef = useRef(context.openMobile);
useEffect(() => setMounted(true), []);
useEffect(() => {
openMobileRef.current = context.openMobile;
if (context.openMobile) setHidden(false);
}, [context.openMobile]);
useEffect(() => {
if (!context.openMobile) return;
const body = document.body;
const scrollY = window.scrollY;
const previousBodyStyles = {
left: body.style.left,
overflow: body.style.overflow,
position: body.style.position,
right: body.style.right,
top: body.style.top,
};
body.style.position = "fixed";
body.style.top = `-${scrollY}px`;
body.style.left = "0";
body.style.right = "0";
body.style.overflow = "hidden";
const focusFrame = requestAnimationFrame(() => {
const firstFocusable =
panelRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
(firstFocusable ?? panelRef.current)?.focus({ preventScroll: true });
});
return () => {
cancelAnimationFrame(focusFrame);
body.style.position = previousBodyStyles.position;
body.style.top = previousBodyStyles.top;
body.style.left = previousBodyStyles.left;
body.style.right = previousBodyStyles.right;
body.style.overflow = previousBodyStyles.overflow;
window.scrollTo(0, scrollY);
context.triggerRef.current?.focus({ preventScroll: true });
};
}, [context.openMobile, context.triggerRef]);
if (!mounted) return null;
// This container groups the sheet for hiding and the z-index and carries no
// box: both children are `fixed` and resolve against the viewport themselves.
// The scrim spans the viewport edges but paints a colour, and the panel is
// inset off one side and paints its own surface, so no layer here is a
// transparent edge-spanning one. See tests/fixed-overlay-edge-sampling.test.tsx.
return createPortal(
<div
className={cn(
"pointer-events-none fixed left-0 top-0 z-50 size-0 md:hidden",
hidden && !context.openMobile ? "invisible" : "visible",
)}
>
<motion.button
type="button"
aria-label="Close sidebar"
tabIndex={context.openMobile ? 0 : -1}
initial={false}
animate={{ opacity: context.openMobile ? 1 : 0 }}
transition={
context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION
}
onClick={() => context.setOpenMobile(false)}
className={cn(
"fixed inset-0 bg-black/40",
context.openMobile
? "pointer-events-auto"
: "pointer-events-none",
)}
/>
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-hidden={!context.openMobile}
inert={!context.openMobile}
tabIndex={-1}
data-mobile="true"
data-state={context.openMobile ? "expanded" : "collapsed"}
data-side={side}
initial={false}
animate={{
opacity: context.reduce
? context.openMobile
? 1
: 0
: 1,
x: context.reduce
? 0
: context.openMobile
? "0%"
: side === "left"
? "-100%"
: "100%",
}}
transition={
context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION
}
onAnimationComplete={() => {
if (!openMobileRef.current) setHidden(true);
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
context.setOpenMobile(false);
return;
}
if (event.key !== "Tab") return;
const focusable = panelRef.current
? Array.from(
panelRef.current.querySelectorAll<HTMLElement>(
FOCUSABLE_SELECTOR,
),
)
: [];
if (focusable.length === 0) {
event.preventDefault();
panelRef.current?.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}}
className={cn(
"pointer-events-auto fixed inset-y-0 flex h-dvh w-(--sidebar-width-mobile) max-w-[88vw] flex-col overflow-hidden",
"border-border bg-background shadow-2xl will-change-transform",
side === "left" ? "left-0 border-r" : "right-0 border-l",
!context.openMobile && "pointer-events-none",
className,
)}
>
<AnimatedSidebarPanelContext.Provider
value={{ collapsed: false, collapsible: "none", side }}
>
{children}
</AnimatedSidebarPanelContext.Provider>
</motion.div>
</div>,
document.body,
);
}
export interface AnimatedSidebarProps
extends Omit<HTMLMotionProps<"aside">, "children"> {
children?: ReactNode;
side?: SidebarSide;
variant?: SidebarVariant;
collapsible?: SidebarCollapsible;
ariaLabel?: string;
panelClassName?: string;
}
export const AnimatedSidebar = forwardRef<HTMLElement, AnimatedSidebarProps>(
function AnimatedSidebar(
{
side = "left",
variant = "sidebar",
collapsible = "icon",
ariaLabel = "Sidebar",
children,
className,
panelClassName,
style,
...props
},
forwardedRef,
) {
const context = useAnimatedSidebar();
const collapsed = collapsible !== "none" && !context.open;
const offcanvas = collapsed && collapsible === "offcanvas";
const width = offcanvas
? "0px"
: collapsed
? "var(--sidebar-width-icon)"
: "var(--sidebar-width)";
if (context.isMobile) {
return (
<MobileSidebar
ariaLabel={ariaLabel}
className={className}
side={side}
>
{children}
</MobileSidebar>
);
}
return (
<motion.aside
{...props}
ref={forwardedRef}
initial={false}
aria-label={ariaLabel}
data-slot="sidebar"
data-state={collapsed ? "collapsed" : "expanded"}
data-collapsible={collapsible}
data-variant={variant}
data-side={side}
animate={{ width }}
transition={
context.reduce ? { duration: 0 } : SIDEBAR_MORPH_TRANSITION
}
style={style}
className={cn(
"group/sidebar relative hidden h-auto shrink-0 md:block will-change-[width]",
"peer",
side === "right" && "order-last",
className,
)}
>
<motion.div
initial={false}
animate={{
opacity: offcanvas ? 0 : 1,
x: offcanvas ? (side === "left" ? "-100%" : "100%") : "0%",
}}
transition={
context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION
}
className={cn(
"sticky top-0 flex h-svh w-full flex-col overflow-hidden bg-background",
collapsible === "offcanvas" && "w-[var(--sidebar-width)]",
variant === "sidebar" &&
(side === "left" ? "border-border border-r" : "border-border border-l"),
variant === "floating" &&
"m-2 h-[calc(100svh-1rem)] rounded-2xl border border-border shadow-sm",
variant === "inset" && "m-2 h-[calc(100svh-1rem)] rounded-2xl",
panelClassName,
)}
>
<AnimatedSidebarPanelContext.Provider
value={{ collapsed, collapsible, side }}
>
{children}
</AnimatedSidebarPanelContext.Provider>
</motion.div>
</motion.aside>
);
},
);
export interface AnimatedSidebarTriggerProps
extends ButtonHTMLAttributes<HTMLButtonElement> {}
export const AnimatedSidebarTrigger = forwardRef<
HTMLButtonElement,
AnimatedSidebarTriggerProps
>(function AnimatedSidebarTrigger(
{ className, onClick, type = "button", ...props },
forwardedRef,
) {
const context = useAnimatedSidebar();
const expanded = context.isMobile ? context.openMobile : context.open;
return (
<button
{...props}
ref={(node) => {
context.triggerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
type={type}
aria-label={props["aria-label"] ?? "Toggle sidebar"}
aria-expanded={expanded}
data-slot="sidebar-trigger"
data-state={expanded ? "expanded" : "collapsed"}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) context.toggleSidebar();
}}
className={cn(
"inline-flex size-10 shrink-0 items-center justify-center rounded-xl outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
/>
);
});
export interface AnimatedSidebarCloseProps
extends ButtonHTMLAttributes<HTMLButtonElement> {}
export const AnimatedSidebarClose = forwardRef<
HTMLButtonElement,
AnimatedSidebarCloseProps
>(function AnimatedSidebarClose(
{ className, onClick, type = "button", ...props },
forwardedRef,
) {
const context = useAnimatedSidebar();
return (
<button
{...props}
ref={forwardedRef}
type={type}
aria-label={props["aria-label"] ?? "Close sidebar"}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (context.isMobile) context.setOpenMobile(false);
else context.setOpen(false);
}}
className={cn(
"inline-flex size-10 shrink-0 items-center justify-center rounded-xl outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
/>
);
});
export interface AnimatedSidebarRailProps
extends ButtonHTMLAttributes<HTMLButtonElement> {}
export const AnimatedSidebarRail = forwardRef<
HTMLButtonElement,
AnimatedSidebarRailProps
>(function AnimatedSidebarRail(
{ className, onClick, type = "button", ...props },
forwardedRef,
) {
const context = useAnimatedSidebar();
const panel = useAnimatedSidebarPanel();
return (
<button
{...props}
ref={forwardedRef}
type={type}
data-side={panel.side}
aria-label={props["aria-label"] ?? "Toggle sidebar"}
title="Toggle sidebar"
tabIndex={-1}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) context.toggleSidebar();
}}
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 outline-none md:block",
"after:absolute after:inset-y-0 after:left-1/2 after:w-px after:bg-transparent after:transition-colors hover:after:bg-border",
"data-[side=right]:right-0 data-[side=right]:translate-x-1/2 data-[side=left]:left-full",
className,
)}
/>
);
});
export interface AnimatedSidebarInsetProps
extends HTMLMotionProps<"main"> {}
export const AnimatedSidebarInset = forwardRef<
HTMLElement,
AnimatedSidebarInsetProps
>(function AnimatedSidebarInset({ className, ...props }, forwardedRef) {
return (
<motion.main
{...props}
ref={forwardedRef}
data-slot="sidebar-inset"
className={cn(
"relative flex min-h-svh min-w-0 flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-2xl md:peer-data-[variant=inset]:shadow-sm",
className,
)}
/>
);
});
export const AnimatedSidebarHeader = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function AnimatedSidebarHeader({ className, ...props }, forwardedRef) {
return (
<div
{...props}
ref={forwardedRef}
data-slot="sidebar-header"
className={cn("flex shrink-0 flex-col gap-2 p-3", className)}
/>
);
});
export const AnimatedSidebarContent = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function AnimatedSidebarContent({ className, ...props }, forwardedRef) {
return (
<div
{...props}
ref={forwardedRef}
data-slot="sidebar-content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden overscroll-contain px-2 py-2",
className,
)}
/>
);
});
export const AnimatedSidebarFooter = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function AnimatedSidebarFooter({ className, ...props }, forwardedRef) {
return (
<div
{...props}
ref={forwardedRef}
data-slot="sidebar-footer"
className={cn(
"flex shrink-0 flex-col gap-2 border-border border-t p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]",
className,
)}
/>
);
});
export const AnimatedSidebarGroup = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function AnimatedSidebarGroup({ className, ...props }, forwardedRef) {
return (
<div
{...props}
ref={forwardedRef}
data-slot="sidebar-group"
className={cn("flex w-full min-w-0 flex-col px-1 py-1.5", className)}
/>
);
});
export const AnimatedSidebarGroupLabel = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function AnimatedSidebarGroupLabel(
{ children, className, ...props },
forwardedRef,
) {
const { collapsed } = useAnimatedSidebarPanel();
return (
<div
{...props}
ref={forwardedRef}
aria-hidden={collapsed}
data-slot="sidebar-group-label"
className={cn(
"mb-1 h-7 overflow-hidden px-2 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground transition-opacity",
collapsed ? "opacity-0" : "opacity-100",
className,
)}
>
{children}
</div>
);
});
export const AnimatedSidebarGroupContent = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function AnimatedSidebarGroupContent(
{ className, ...props },
forwardedRef,
) {
return (
<div
{...props}
ref={forwardedRef}
data-slot="sidebar-group-content"
className={cn("w-full min-w-0", className)}
/>
);
});
export const AnimatedSidebarMenu = forwardRef<
HTMLUListElement,
HTMLAttributes<HTMLUListElement>
>(function AnimatedSidebarMenu(
{ children, className, ...props },
forwardedRef,
) {
return (
<SharedLayoutBg
{...props}
ref={forwardedRef as React.Ref<HTMLElement>}
as="ul"
inset={0}
pillClassName="rounded-xl bg-muted/70"
pillContainerClassName="inset-y-auto top-0 h-9"
data-slot="sidebar-menu"
className={cn("flex w-full min-w-0 list-none flex-col gap-0.5", className)}
>
{children}
</SharedLayoutBg>
);
});
export const AnimatedSidebarMenuItem = forwardRef<
HTMLLIElement,
HTMLMotionProps<"li">
>(function AnimatedSidebarMenuItem({ className, ...props }, forwardedRef) {
return (
<motion.li
{...props}
ref={forwardedRef}
layout="position"
transition={SPRING_LAYOUT}
data-slot="sidebar-menu-item"
className={cn("relative", className)}
/>
);
});
export interface AnimatedSidebarMenuSubProps
extends Omit<HTMLMotionProps<"ul">, "children"> {
open: boolean;
children?: ReactNode;
}
export const AnimatedSidebarMenuSub = forwardRef<
HTMLUListElement,
AnimatedSidebarMenuSubProps
>(function AnimatedSidebarMenuSub(
{ open, children, className, ...props },
forwardedRef,
) {
const context = useAnimatedSidebar();
const panel = useAnimatedSidebarPanel();
return (
<AnimatePresence initial={false} mode="popLayout">
{open && !panel.collapsed ? (
<motion.ul
{...props}
ref={forwardedRef}
key="sidebar-submenu"
variants={context.reduce ? undefined : SUBMENU_VARIANTS}
initial={context.reduce ? false : "closed"}
animate={context.reduce ? { opacity: 1 } : "open"}
exit={context.reduce ? { opacity: 0 } : "closed"}
transition={context.reduce ? { duration: 0.12 } : undefined}
data-slot="sidebar-menu-sub"
className={cn(
"relative mt-1 ml-5 flex min-w-0 flex-col gap-0.5 border-border border-l pl-3",
className,
)}
>
{children}
</motion.ul>
) : null}
</AnimatePresence>
);
});
export const AnimatedSidebarMenuSubItem = forwardRef<
HTMLLIElement,
HTMLMotionProps<"li">
>(function AnimatedSidebarMenuSubItem(
{ className, ...props },
forwardedRef,
) {
return (
<motion.li
{...props}
ref={forwardedRef}
variants={SUBMENU_ITEM_VARIANTS}
data-slot="sidebar-menu-sub-item"
className={cn("relative min-w-0", className)}
/>
);
});
export interface AnimatedSidebarMenuSubButtonProps {
children: ReactNode;
icon?: ReactNode;
href?: string;
isActive?: boolean;
disabled?: boolean;
closeOnSelect?: boolean;
target?: "_blank" | "_self" | "_parent" | "_top";
rel?: string;
onSelect?: () => void;
className?: string;
}
export function AnimatedSidebarMenuSubButton({
children,
icon,
href,
isActive = false,
disabled = false,
closeOnSelect = true,
target,
rel,
onSelect,
className,
}: AnimatedSidebarMenuSubButtonProps) {
const context = useAnimatedSidebar();
const select = (
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>,
) => {
if (disabled) {
event.preventDefault();
return;
}
onSelect?.();
if (context.isMobile && closeOnSelect) context.setOpenMobile(false);
};
const content = (
<>
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center"
>
{icon ?? <span className="size-1 rounded-full bg-current" />}
</span>
<span className="min-w-0 flex-1 truncate">{children}</span>
</>
);
const interactiveClassName = cn(
"flex min-h-8 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-left text-xs outline-none",
"text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground",
"focus-visible:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring",
isActive && "bg-muted/70 text-foreground",
disabled && "cursor-not-allowed opacity-40",
className,
);
return href ? (
<motion.a
href={href}
target={target}
rel={
rel ??
(target === "_blank" ? "noreferrer noopener" : undefined)
}
aria-current={isActive ? "page" : undefined}
aria-disabled={disabled || undefined}
tabIndex={disabled ? -1 : undefined}
onClick={select}
whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={interactiveClassName}
>
{content}
</motion.a>
) : (
<motion.button
type="button"
disabled={disabled}
aria-current={isActive ? "page" : undefined}
onClick={select}
whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={interactiveClassName}
>
{content}
</motion.button>
);
}
export interface AnimatedSidebarMenuButtonProps {
children: ReactNode;
icon?: ReactNode;
badge?: ReactNode;
href?: string;
isActive?: boolean;
ariaExpanded?: boolean;
disabled?: boolean;
closeOnSelect?: boolean;
target?: "_blank" | "_self" | "_parent" | "_top";
rel?: string;
onSelect?: () => void;
className?: string;
}
export function AnimatedSidebarMenuButton({
children,
icon,
badge,
href,
isActive = false,
ariaExpanded,
disabled = false,
closeOnSelect,
target,
rel,
onSelect,
className,
}: AnimatedSidebarMenuButtonProps) {
const context = useAnimatedSidebar();
const panel = useAnimatedSidebarPanel();
const textLabel = typeof children === "string" ? children : undefined;
const select = (
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>,
) => {
if (disabled) {
event.preventDefault();
return;
}
onSelect?.();
const shouldCloseOnSelect =
closeOnSelect ?? ariaExpanded === undefined;
if (context.isMobile && shouldCloseOnSelect) {
context.setOpenMobile(false);
}
// A submenu cannot render in the icon rail, so opening one from there
// leaves its children unreachable — a pointer can still fall back to the
// rail or the shortcut, a finger has nothing. Selecting a group unfolds
// the panel that is about to hold it.
if (ariaExpanded !== undefined && panel.collapsed && !context.isMobile) {
context.setOpen(true);
}
};
const content = (
<>
{isActive ? (
<motion.span
layoutId={context.layoutId}
transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}
className="absolute inset-0 rounded-xl bg-muted"
/>
) : null}
{icon ? (
<span
aria-hidden="true"
className="relative z-10 grid size-5 shrink-0 place-items-center"
>
{icon}
</span>
) : null}
<motion.span
initial={false}
animate={{
opacity: panel.collapsed ? 0 : 1,
x: panel.collapsed ? -4 : 0,
}}
transition={
context.reduce
? REDUCED_TRANSITION
: panel.collapsed
? LABEL_EXIT_TRANSITION
: LABEL_ENTER_TRANSITION
}
aria-hidden={panel.collapsed}
className={cn(
"relative z-10 min-w-0 flex-1 truncate",
panel.collapsed && "pointer-events-none",
)}
>
{children}
</motion.span>
{badge && !panel.collapsed ? (
<span className="relative z-10 shrink-0 text-xs text-muted-foreground">
{badge}
</span>
) : null}
{ariaExpanded !== undefined ? (
<motion.span
aria-hidden="true"
initial={false}
animate={{
opacity: panel.collapsed ? 0 : 1,
rotate: ariaExpanded ? 90 : 0,
x: panel.collapsed ? 4 : 0,
}}
transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}
className="relative z-10 grid size-4 shrink-0 place-items-center text-muted-foreground"
>
<ChevronRight className="size-3.5" />
</motion.span>
) : null}
</>
);
const interactiveClassName = cn(
"relative flex min-h-9 w-full min-w-0 items-center gap-2.5 overflow-hidden rounded-xl px-3 text-left text-sm font-medium outline-none",
"text-muted-foreground transition-colors hover:text-foreground",
"focus-visible:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring",
isActive && "text-foreground",
disabled && "cursor-not-allowed opacity-40",
className,
);
return href ? (
<motion.a
href={href}
target={target}
rel={
rel ??
(target === "_blank" ? "noreferrer noopener" : undefined)
}
aria-current={isActive ? "page" : undefined}
aria-expanded={ariaExpanded}
aria-disabled={disabled || undefined}
aria-label={panel.collapsed ? textLabel : undefined}
title={panel.collapsed ? textLabel : undefined}
tabIndex={disabled ? -1 : undefined}
onClick={select}
whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={interactiveClassName}
>
{content}
</motion.a>
) : (
<motion.button
type="button"
disabled={disabled}
aria-current={isActive ? "page" : undefined}
aria-expanded={ariaExpanded}
aria-label={panel.collapsed ? textLabel : undefined}
title={panel.collapsed ? textLabel : undefined}
onClick={select}
whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={interactiveClassName}
>
{content}
</motion.button>
);
}
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
Children,
cloneElement,
forwardRef,
type HTMLAttributes,
isValidElement,
type MouseEvent,
type ReactElement,
type ReactNode,
type Ref,
useId,
useState,
} from "react";
import { SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface SharedLayoutBgProps
extends Omit<HTMLAttributes<HTMLElement>, "children"> {
children: ReactNode;
/** Semantic container used for the children. */
as?: "div" | "ul";
/** Tailwind class applied to the moving pill. Defaults to a subtle foreground tint. */
pillClassName?: string;
/** Horizontal inset of the pill relative to each row (px). Default 20. */
inset?: number;
/** Optional positioning override for the pill wrapper inside each item. */
pillContainerClassName?: string;
}
const variants: Variants = {
initial: { opacity: 0, filter: "blur(6px)" },
animate: { opacity: 1, filter: "blur(0px)" },
exit: (isActive: boolean) =>
!isActive ? { opacity: 0, filter: "blur(6px)" } : {},
};
const reducedVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: (isActive: boolean) => (!isActive ? { opacity: 0 } : {}),
};
export const SharedLayoutBg = forwardRef<HTMLElement, SharedLayoutBgProps>(
function SharedLayoutBg(
{
children,
as = "div",
className,
onMouseLeave,
pillClassName,
pillContainerClassName,
inset = 20,
...props
},
forwardedRef,
) {
const [activeId, setActiveId] = useState<string | null>(null);
const uid = useId();
const reduce = useReducedMotion();
const renderedChildren = Children.toArray(children)
.filter(isValidElement)
.map((child, index) => {
const el = child as ReactElement<{
className?: string;
onMouseEnter?: () => void;
children?: ReactNode;
}>;
const childKey = el.key ? String(el.key) : `item-${index}`;
return cloneElement(
el,
{
key: childKey,
className: cn("relative", el.props.className),
onMouseEnter: () => {
el.props.onMouseEnter?.();
setActiveId(childKey);
},
},
<>
<AnimatePresence custom={activeId !== null}>
{activeId !== null ? (
<motion.div
variants={reduce ? reducedVariants : variants}
initial="initial"
animate="animate"
exit="exit"
custom={activeId !== null}
className={cn(
"pointer-events-none absolute inset-y-0",
pillContainerClassName,
)}
style={{ left: -inset, right: -inset }}
>
{activeId === childKey ? (
<motion.div
layoutId={`shared-bg-${uid}`}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"pointer-events-none h-full w-full rounded-2xl bg-muted/80",
pillClassName,
)}
/>
) : null}
</motion.div>
) : null}
</AnimatePresence>
<div className="relative z-10">{el.props.children}</div>
</>,
);
});
const handleMouseLeave = (event: MouseEvent<HTMLElement>) => {
setActiveId(null);
onMouseLeave?.(event);
};
// layoutRoot scopes the pill's layout projection to this list, so fixed or
// scrolled ancestors can't smear scroll offsets into its movement.
return as === "ul" ? (
<motion.ul
{...(props as HTMLMotionProps<"ul">)}
ref={forwardedRef as Ref<HTMLUListElement>}
layoutRoot
onMouseLeave={handleMouseLeave}
className={cn("flex w-full flex-col", className)}
>
{renderedChildren}
</motion.ul>
) : (
<motion.div
{...(props as HTMLMotionProps<"div">)}
ref={forwardedRef as Ref<HTMLDivElement>}
layoutRoot
onMouseLeave={handleMouseLeave}
className={cn("flex w-full flex-col", className)}
>
{renderedChildren}
</motion.div>
);
},
);
API Reference
tabsWorkspaceTab[]—activeTabIdstring—onSelect(id: string) => void—onClose?((id: string) => void)—onReorder?((sourceId: string, targetId: string) => void)—onPinnedChange?((id: string, pinned: boolean) => void)—onOpenInNewWindow?((id: string) => void)—onNewTab?(() => void)—actions?ReactNode—closeOnMiddleClick?booleantruecloseOnDoubleClick?booleanfalseclassName?string—Related components
Morphing Tabs
Reorderable tabs whose selected item grows into a white content surface, with the active shape gliding as tabs move and a shared morph between rooms.
Selector
A searchable selector for models, people, workspaces and visual resources. Single or multiple selection, grouped lists or thumbnail grids, optional details, removable chips and keyboard navigation share one morphing surface.
Expandable Tabs
Icon tab bar where the active tab expands to a labelled pill, with a panel above that morphs height and slides content direction-aware on switch.
Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated