Workspace Shell
An inset workspace on beUI: shared chrome, a collapsible sidebar and browser-like tabs joined to the content surface.
Preview
工作台
概览
工作台
全部 Agents
工作台
全部服务
"use client";
import { Bot, Boxes, Check, ChevronsUpDown, CircleUserRound, Command, FileText, Folder, LayoutDashboard, LogOut, Menu, MessageSquare, Moon, MoreHorizontal, Play, Plug, Settings2, Sun } from "lucide-react";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useTheme } from "next-themes";
import Image from "next/image";
import { Button } from "@/components/motion/button/base";
import { AnimatedSidebarTrigger, useAnimatedSidebar } from "@/components/motion/animated-sidebar";
import { MorphPopover, MorphPopoverContent, MorphPopoverTrigger } from "@/components/motion/popover-morph";
import { WorkspaceShell, openWorkspaceWindow } from "@/components/workspace/workspace-shell";
import { WorkspaceSidebar, type WorkspaceSidebarGroup } from "@/components/workspace/workspace-sidebar";
import { WorkspaceTabBar, type WorkspaceTab } from "@/components/workspace/workspace-tab-bar";
const destinations = [
{ id: "overview", label: "概览", icon: <LayoutDashboard /> },
{ id: "agent-group", label: "Agents", icon: <Bot />, children: [
{ id: "agents", label: "全部 Agents", icon: <Bot /> },
{ id: "conversations", label: "对话", icon: <MessageSquare />, badge: "12" },
{ id: "runs", label: "运行记录", icon: <Play /> },
] },
{ id: "mcp-group", label: "MCP 服务", icon: <Plug />, children: [
{ id: "mcp", label: "全部服务", icon: <Plug /> },
{ id: "connections", label: "连接配置", icon: <Settings2 /> },
] },
{ id: "file-group", label: "项目文件", icon: <Folder />, children: [
{ id: "files", label: "全部文件", icon: <FileText /> },
{ id: "recent-files", label: "最近打开", icon: <Folder /> },
] },
];
const groups: WorkspaceSidebarGroup[] = [
{ id: "work", title: "工作台", items: destinations },
{ id: "manage", title: "管理", items: [{ id: "settings", label: "工作区设置", icon: <Settings2 /> }] },
];
const pages = groups.flatMap((group) => group.items.flatMap((item) => item.children?.length ? item.children : [item]));
const initial: WorkspaceTab[] = ["overview", "agents", "mcp"].flatMap((id, i) => {
const item = pages.find((page) => page.id === id);
return item ? [{ id, title: item.label, icon: item.icon, pinned: i === 0 }] : [];
});
const defaultNotes = { overview: "整理本周的工作区任务" };
const storagePrefix = "asharca:beui-workspace:";
interface Snapshot {
version: 1;
id: string;
title: string;
note: string;
}
function readSnapshot(key: string): Snapshot | null {
if (!/^[a-f\d-]{36}$/.test(key))
return null;
try {
const raw = sessionStorage.getItem(storagePrefix + key);
if (!raw || raw.length > 30000)
return null;
const value: Partial<Snapshot> = JSON.parse(raw);
if (value.version !== 1 || typeof value.id !== "string" || value.id.length > 128 || typeof value.title !== "string" || value.title.length > 128 || typeof value.note !== "string" || value.note.length > 20000)
return null;
return value as Snapshot;
}
catch {
return null;
}
}
const workspaces = [
{ id: "local", name: "本地工作区", hint: "个人 · 免费", mark: <Command className="size-3.5"/> },
{ id: "acme", name: "Acme 团队", hint: "12 名成员", mark: <Boxes className="size-3.5"/> },
{ id: "solo", name: "独立项目", hint: "个人 · Pro", mark: <FileText className="size-3.5"/> },
];
type WorkspaceOption = (typeof workspaces)[number];
/** Demo-owned footer menus reuse the bordered, shadowed morph surface so they
* stay distinct from the sidebar in both themes. */
function WorkspaceFooter({ compact }: {
compact: boolean;
}) {
const [current, setCurrent] = useState<WorkspaceOption | undefined>(workspaces[0]);
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
if (!current)
return null;
return (<div className="flex w-full flex-col gap-1">
<MorphPopover open={workspaceMenuOpen} onOpenChange={setWorkspaceMenuOpen} className="w-full">
<MorphPopoverTrigger>
<button type="button" aria-label="切换工作区" className="flex h-9 w-full items-center gap-2 overflow-hidden rounded-lg px-1 py-1 text-left text-xs text-muted-foreground outline-none transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
<span className="grid size-7 shrink-0 place-items-center rounded-lg border border-border/60 bg-background text-foreground">{current.mark}</span>
{!compact && <span className="min-w-0 flex-1"><span className="block truncate text-xs font-medium text-foreground">{current.name}</span><span className="block truncate text-[10px] text-muted-foreground">{current.hint}</span></span>}
{!compact && <ChevronsUpDown className="size-3.5 shrink-0"/>}
</button>
</MorphPopoverTrigger>
<WorkspaceSwitcherContent workspaces={workspaces} currentId={current.id} onPick={(workspace) => { setCurrent(workspace); setWorkspaceMenuOpen(false); }}/>
</MorphPopover>
<MorphPopover open={accountMenuOpen} onOpenChange={setAccountMenuOpen} className="w-full">
<MorphPopoverTrigger>
<button type="button" aria-label="账户菜单" className="flex h-9 w-full items-center gap-2 overflow-hidden rounded-lg px-1 py-1 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring">
<span className="grid size-7 shrink-0 place-items-center rounded-full bg-[#d5ff66] text-[11px] font-semibold text-[#172000]">AS</span>
{!compact && <span className="min-w-0 flex-1"><span className="block truncate text-xs leading-4 font-medium text-foreground">Ava Stone</span><span className="block truncate text-[10px] leading-3 text-muted-foreground">ava@solace.app</span></span>}
{!compact && <MoreHorizontal aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground"/>}
</button>
</MorphPopoverTrigger>
<AccountMenuContent onClose={() => setAccountMenuOpen(false)}/>
</MorphPopover>
</div>);
}
function WorkspaceSwitcherContent({ workspaces, currentId, onPick }: {
workspaces: WorkspaceOption[];
currentId: string;
onPick: (workspace: WorkspaceOption) => void;
}) {
return <MorphPopoverContent side="top" align="start" sideOffset={10} className="w-60 border-foreground/15 p-1.5 text-popover-foreground">
<p className="px-2.5 pb-1 pt-2 text-[10px] font-medium text-muted-foreground">工作区</p>
{workspaces.map((workspace) => (<button key={workspace.id} type="button" onClick={() => onPick(workspace)} className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:bg-muted/60">
<span className="grid size-7 shrink-0 place-items-center rounded-full bg-muted text-foreground">{workspace.mark}</span>
<span className="min-w-0 flex-1"><span className="block truncate text-xs font-medium text-foreground">{workspace.name}</span><span className="block truncate text-[10px] text-muted-foreground">{workspace.hint}</span></span>
{currentId === workspace.id && <Check className="size-3.5 shrink-0 text-foreground"/>}
</button>))}
</MorphPopoverContent>;
}
const ACCOUNT_ITEMS = [["个人资料", CircleUserRound], ["偏好设置", Settings2], ["退出登录", LogOut]] as const;
function AccountMenuContent({ onClose }: {
onClose: () => void;
}) {
return <MorphPopoverContent side="top" align="start" sideOffset={10} className="w-56 border-foreground/15 p-1.5 text-popover-foreground">
<p className="px-2.5 pb-1 pt-2 text-xs font-medium text-foreground">Ava Stone</p>
<p className="px-2.5 pb-1.5 text-[10px] text-muted-foreground">ava@solace.app</p>
<div className="my-1 h-px bg-border"/>
{ACCOUNT_ITEMS.map(([label, Icon]) => (<button key={label} type="button" onClick={onClose} className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-xs text-foreground outline-none transition-colors hover:bg-muted/60 focus-visible:bg-muted/60">
<Icon className="size-3.5 shrink-0 text-muted-foreground"/>{label}
</button>))}
</MorphPopoverContent>;
}
/** WorkspaceSidebar renders its footer inside the AnimatedSidebar tree, so the
* bridge can read the collapse state there and hand it to the demo footer. */
function WorkspaceSidebarFooterBridge(props: {
title: string;
logo?: ReactNode;
groups: typeof groups;
activeId: string;
onSelect: (id: string) => void;
}) {
const { open, isMobile } = useAnimatedSidebar();
return <WorkspaceSidebar {...props} footer={<WorkspaceFooter compact={!open && !isMobile}/>}/>;
}
/** This demo owns all sample state; reusable components never invent business data. */
export function WorkspaceDemo({ fullPage = false, detachedKey }: {
fullPage?: boolean;
detachedKey?: string;
}) {
const instance = useId();
const sequence = useRef(0);
const [tabs, setTabs] = useState<WorkspaceTab[]>(initial);
const [active, setActive] = useState("overview");
const [open, setOpen] = useState(true);
const [mobileOpen, setMobileOpen] = useState(false);
const [notes, setNotes] = useState<Record<string, string>>(defaultNotes);
const [saved, setSaved] = useState<Record<string, string>>(defaultNotes);
const [loaded, setLoaded] = useState(!detachedKey);
const [invalid, setInvalid] = useState(false);
const [error, setError] = useState("");
const { resolvedTheme, setTheme } = useTheme();
const [themeReady, setThemeReady] = useState(false);
useEffect(() => setThemeReady(true), []);
useEffect(() => {
if (!detachedKey)
return;
const snapshot = readSnapshot(detachedKey);
if (snapshot) {
setTabs([{ id: snapshot.id, title: snapshot.title, icon: <Boxes /> }]);
setActive(snapshot.id);
setNotes({ [snapshot.id]: snapshot.note });
setSaved({ [snapshot.id]: snapshot.note });
}
else
setInvalid(true);
setLoaded(true);
}, [detachedKey]);
useEffect(() => {
if (!detachedKey || !loaded || invalid)
return;
const current = tabs.find((item) => item.id === active);
if (!current)
return;
try {
sessionStorage.setItem(storagePrefix + detachedKey, JSON.stringify({ version: 1, id: current.id, title: current.title, note: notes[current.id] ?? "" } satisfies Snapshot));
document.title = `${current.title} — Asharca Workspace`;
}
catch {
setError("无法保存窗口状态,请在刷新前复制便签。");
}
}, [active, detachedKey, invalid, loaded, notes, tabs]);
const panelId = (id: string) => `${instance}-panel-${id}`;
const tabId = (id: string) => `${instance}-tab-${id}`;
const isDirty = (id: string) => (notes[id] ?? "") !== (saved[id] ?? "");
function select(id: string) {
const item = pages.find((candidate) => candidate.id === id);
if (!item)
return;
setTabs((current) => current.some((tab) => tab.id === id) ? current : [...current, { id, title: item.label, icon: item.icon }]);
setActive(id);
}
function close(id: string) {
const target = tabs.find((tab) => tab.id === id);
if (!target || target.pinned || target.closable === false || tabs.length === 1)
return;
if (isDirty(id) && !window.confirm(`“${target.title}”有未保存内容,仍要关闭吗?`))
return;
const remaining = tabs.filter((tab) => tab.id !== id);
if (active === id)
setActive(remaining[Math.min(tabs.indexOf(target), remaining.length - 1)].id);
setTabs(remaining);
}
function reorder(sourceId: string, targetId: string) {
setTabs((current) => {
const result = [...current];
const a = result.findIndex((tab) => tab.id === sourceId);
const b = result.findIndex((tab) => tab.id === targetId);
if (a < 0 || b < 0 || Boolean(result[a].pinned) !== Boolean(result[b].pinned))
return current;
result.splice(b, 0, result.splice(a, 1)[0]);
return result;
});
}
function pin(id: string, pinned: boolean) {
setTabs((current) => current.map((tab) => tab.id === id ? { ...tab, pinned } : tab).sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned))));
}
function add() {
const id = `note-${++sequence.current}`;
setTabs((current) => [...current, { id, title: `新标签 ${sequence.current}`, icon: <FileText /> }]);
setActive(id);
}
function openWindow(id: string) {
const tab = tabs.find((item) => item.id === id);
if (!tab)
return;
setError("");
try {
const key = crypto.randomUUID();
const url = new URL("/ui/workspace/", window.location.href);
url.searchParams.set("window", key);
const snapshot: Snapshot = { version: 1, id, title: tab.title, note: notes[id] ?? "" };
const popup = openWorkspaceWindow(url, (child) => { child.sessionStorage.clear(); child.sessionStorage.setItem(storagePrefix + key, JSON.stringify(snapshot)); });
if (!popup)
setError("新窗口被浏览器拦截;原标签和便签已保留。");
}
catch {
setError("无法打开独立窗口;原标签和便签已保留。");
}
}
if (!loaded)
return <p role="status" className="p-6 text-sm">正在读取此窗口的便签…</p>;
if (invalid)
return <div className="grid h-dvh place-content-center gap-3 p-6"><h1 className="text-lg font-semibold">此窗口的本地状态已不可用</h1><p className="text-sm text-muted-foreground">原工作区未受影响。</p><a href="/ui/workspace" className="underline underline-offset-4">返回工作区</a></div>;
return <WorkspaceShell className={fullPage ? "h-dvh" : "h-[640px] rounded-2xl"} open={open} onOpenChange={setOpen} openMobile={mobileOpen} onOpenMobileChange={setMobileOpen} sidebar={detachedKey ? undefined : <WorkspaceSidebarFooterBridge title="Asharca Workspace" logo={<Image src="/ui/beui-mark.png" alt="" aria-hidden="true" width={28} height={28} className="size-7 rounded-lg"/>} groups={groups} activeId={active} onSelect={select}/>} mobileHeader={detachedKey ? undefined : <><AnimatedSidebarTrigger aria-label="打开工作区导航" className="size-8"><Menu className="size-4"/></AnimatedSidebarTrigger><span className="text-xs font-medium">Asharca Workspace</span></>} tabBar={<WorkspaceTabBar tabs={tabs.map((tab) => ({ ...tab, dirty: isDirty(tab.id), tabId: tabId(tab.id), panelId: panelId(tab.id) }))} activeTabId={active} onSelect={setActive} onClose={close} onReorder={reorder} onPinnedChange={pin} onNewTab={add} onOpenInNewWindow={openWindow} actions={<Button variant="ghost" size="icon" aria-label="切换深浅主题" onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}>{themeReady && resolvedTheme === "dark" ? <Sun className="size-4"/> : <Moon className="size-4"/>}</Button>}/>} footer={<div className="flex h-8 items-center justify-between gap-2 border-t border-border/50 px-5 text-[11px] text-muted-foreground"><span>基于 beUI · 本地示例</span><span>{tabs.length} 个标签</span></div>}>
{error && <p role="alert" className="mx-5 mt-3 rounded-lg bg-destructive/10 p-3 text-xs text-destructive">{error}</p>}
{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={panelId(tab.id)} aria-labelledby={tabId(tab.id)} hidden={active !== tab.id} inert={active !== tab.id} tabIndex={0} className="min-h-full p-5 outline-none md:p-7">
<div className="mb-6 flex items-center justify-between gap-3"><div><p className="mb-1 text-[11px] text-muted-foreground">工作台</p><h1 className="text-xl font-semibold tracking-tight">{tab.title}</h1></div><Button variant="outline" size="sm" onClick={() => setSaved((value) => ({ ...value, [tab.id]: notes[tab.id] ?? "" }))}><Check className="size-3.5"/>保存便签</Button></div>
<div className="grid gap-3 sm:grid-cols-3">{["项目管理", "Agent 对话", "资源连接"].map((label, i) => <button key={label} type="button" onClick={() => select(["files", "agents", "mcp"][i])} className="group rounded-xl border border-border/70 p-4 text-left transition-colors hover:bg-muted/40"><span className="mb-4 inline-flex size-8 items-center justify-center rounded-lg bg-muted text-muted-foreground">{i === 0 ? <Boxes className="size-4"/> : i === 1 ? <Bot className="size-4"/> : <Plug className="size-4"/>}</span><span className="block text-sm font-medium">{label}</span><span className="mt-1 block text-xs text-muted-foreground">在工作区标签中打开</span></button>)}</div>
<section className="mt-6 rounded-xl border border-border/70 p-4"><div className="mb-3 flex items-center justify-between"><label htmlFor={`${instance}-note-${tab.id}`} className="text-sm font-medium">工作便签</label><span role="status" className="text-[11px] text-muted-foreground">{isDirty(tab.id) ? "未保存" : "已保存"}</span></div>
<textarea id={`${instance}-note-${tab.id}`} aria-label={`${tab.title}便签`} value={notes[tab.id] ?? ""} maxLength={20000} onChange={(event) => setNotes((current) => ({ ...current, [tab.id]: event.target.value }))} placeholder="写下当前任务,切换标签后内容仍会保留。" className="min-h-36 w-full resize-y rounded-lg bg-muted/35 p-3 text-sm leading-6 outline-none focus-visible:ring-2 focus-visible:ring-ring"/>
</section>
</div>))}
</WorkspaceShell>;
}
export function WorkspaceShellPreview() { return <div className="w-full min-w-0"><WorkspaceDemo /><a href="/ui/workspace" target="_blank" rel="noopener noreferrer" className="mt-3 inline-block text-xs text-muted-foreground underline underline-offset-4">打开独立工作区演示</a></div>; }
"use client";
// asharca.github.io/ui/components/blocks/workspace-shell
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;
}
}
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 next-themes tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 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;
"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;
}
"use client";
import { useState } from "react";
/**
* Runs `start` during the render in which `open` becomes true.
*
* Opening a list starts a fresh session — an empty query, the highlight back at
* the top — and that is a resolution, not a side effect: a passive effect runs
* after the commit, so the new session would carry the last one's state for a
* window in which a key press can land.
*
* `start` may only set state belonging to the calling component. Anything that
* reaches outside it — a consumer's callback, a DOM write, moving focus — is a
* side effect, and React rejects a callback that sets another component's state
* during this one's render. Put those in an effect keyed to `open`.
*/
export function useOnOpen(open: boolean, start: () => void) {
const [wasOpen, setWasOpen] = useState(open);
if (open !== wasOpen) {
setWasOpen(open);
if (open) start();
}
}
Copy the source code
"use client";
// asharca.github.io/ui/components/blocks/workspace-shell
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";
// asharca.github.io/ui/components/blocks/workspace-shell
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";
// asharca.github.io/ui/components/blocks/workspace-shell
import { motion, useReducedMotion } from "motion/react";
import { PanelLeft, X } from "lucide-react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import {
AnimatedSidebar, AnimatedSidebarClose, AnimatedSidebarContent,
AnimatedSidebarFooter, AnimatedSidebarGroup,
AnimatedSidebarHeader, AnimatedSidebarMenu, AnimatedSidebarMenuButton,
AnimatedSidebarMenuItem, AnimatedSidebarMenuSub, AnimatedSidebarMenuSubButton,
AnimatedSidebarMenuSubItem, AnimatedSidebarTrigger, useAnimatedSidebar,
} from "@/components/motion/animated-sidebar";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { useOnOpen } from "@/lib/hooks/use-on-open";
import { cn } from "@/lib/utils";
export interface WorkspaceSidebarItem {
id: string;
label: string;
icon?: ReactNode;
badge?: ReactNode;
href?: string;
disabled?: boolean;
/** Exactly one nested level. Parents toggle their children instead of navigating. */
children?: Omit<WorkspaceSidebarItem, "children">[];
}
export interface WorkspaceSidebarGroup { id: string; title?: string; items: WorkspaceSidebarItem[] }
export interface WorkspaceSidebarProps {
groups: WorkspaceSidebarGroup[];
activeId?: string;
onSelect?: (id: string) => void;
title?: string;
/** Brand mark rendered in the header instead of the plain title text. */
logo?: ReactNode;
footer?: ReactNode;
className?: string;
}
function WorkspaceNavigationItem({ item, activeId, onSelect, compact }: {
item: WorkspaceSidebarItem;
activeId?: string;
onSelect?: (id: string) => void;
compact: boolean;
}) {
const children = item.children?.length ? item.children : undefined;
const activeChild = children?.some((child) => child.id === activeId) ?? false;
const [expanded, setExpanded] = useState(activeChild);
const [previousActive, setPreviousActive] = useState(activeId);
if (previousActive !== activeId) {
setPreviousActive(activeId);
if (activeChild) setExpanded(true);
}
return <AnimatedSidebarMenuItem>
<AnimatedSidebarMenuButton
isActive={activeId === item.id || (compact && activeChild)}
ariaExpanded={children ? !compact && expanded : undefined}
onSelect={() => children ? setExpanded((current) => compact || !current) : onSelect?.(item.id)}
href={children ? undefined : item.href} icon={item.icon} badge={item.badge} disabled={item.disabled}
className={cn("min-h-9 gap-2 overflow-visible rounded-lg px-2 text-xs [&>span:first-child:not([aria-hidden])]:rounded-lg [&>span[aria-hidden=true]]:size-5 [&_svg]:size-[18px] [&>span.absolute]:bg-[var(--workspace-surface,var(--background))]", activeChild && !compact && "bg-foreground/5 text-foreground")}
>
{item.label}
</AnimatedSidebarMenuButton>
{children && <div inert={compact || !expanded || item.disabled} aria-hidden={compact || !expanded || item.disabled}>
<AnimatedSidebarMenuSub open={expanded} className="ml-4 gap-1 border-foreground/10 pl-3">
{children.map((child) => <AnimatedSidebarMenuSubItem key={child.id}>
<AnimatedSidebarMenuSubButton href={child.href} icon={child.icon}
disabled={item.disabled || child.disabled} isActive={activeId === child.id}
onSelect={() => onSelect?.(child.id)}
className={cn("min-h-9 [&_svg]:size-3.5", activeId === child.id && "bg-[var(--workspace-surface,var(--background))] font-medium hover:bg-[var(--workspace-surface,var(--background))]")}
>
<span className="flex min-w-0 items-center gap-2"><span className="truncate">{child.label}</span>{child.badge != null && <span className="ml-auto shrink-0 rounded bg-muted px-1 text-[10px] text-muted-foreground">{child.badge}</span>}</span>
</AnimatedSidebarMenuSubButton>
</AnimatedSidebarMenuSubItem>)}
</AnimatedSidebarMenuSub>
</div>}
</AnimatedSidebarMenuItem>;
}
/** Composition of beUI primitives, not a second copy of the sidebar engine.
* The footer stays mounted and interactive while collapsed: hosts put their
* icon-sized workspace/avatar popovers there and manage their own visibility. */
export function WorkspaceSidebar({ groups, activeId, onSelect, title = "工作区", logo, footer, className }: WorkspaceSidebarProps) {
const { open, isMobile } = useAnimatedSidebar();
const reduced = useReducedMotion() ?? false;
const compact = !open && !isMobile;
const canHover = useHoverCapable();
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
useOnOpen(compact, () => setHovered(false));
const showToggle = !compact || !logo || (canHover && hovered) || focused;
const toggleRef = useRef<HTMLButtonElement>(null);
useLayoutEffect(() => {
if (compact && document.activeElement instanceof HTMLElement) {
const panel = toggleRef.current?.closest('[data-slot="sidebar"]');
if (panel?.getAttribute("data-state") === "collapsed" && panel.contains(document.activeElement)) {
const footer = panel.querySelector('[data-slot="sidebar-footer"]');
if (!(footer instanceof HTMLElement && footer.contains(document.activeElement))) toggleRef.current?.focus();
}
}
}, [compact]);
// Opacity must not inherit spring velocity when hover reverses mid-flight.
const transition = reduced ? { duration: 0 } : {
...SPRING_LAYOUT,
opacity: { type: "tween" as const, duration: 0.14, ease: EASE_OUT },
};
return <AnimatedSidebar ariaLabel={`${title}导航`} variant="inset"
className={cn(!isMobile && "h-full", className)} panelClassName="static m-0 h-full rounded-none bg-transparent">
<AnimatedSidebarHeader className="px-3.5 pt-2 pb-0">
<motion.div
initial={false}
animate={{ height: compact ? 36 : 64 }}
transition={transition}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocusCapture={(event) => setFocused(event.target.matches(":focus-visible"))}
onBlurCapture={() => setFocused(false)}
className="relative flex w-full flex-row items-center"
>
{isMobile ? (
<>
<span className="flex min-w-0 flex-1 items-center gap-2 text-xs font-semibold">
{logo && <span className="grid size-7 shrink-0 place-items-center">{logo}</span>}
<span className="truncate">{title}</span>
</span>
<AnimatedSidebarClose aria-label="关闭工作区导航" className="size-9 shrink-0"><X className="size-4" /></AnimatedSidebarClose>
</>
) : (
<>
{logo && <motion.span
initial={false}
animate={{ y: compact && showToggle && !reduced ? 20 : 0, opacity: compact && showToggle ? 0 : 1 }}
transition={transition}
aria-hidden="true"
className="pointer-events-none absolute left-0 grid size-9 place-items-center"
>
<span className="grid size-7 place-items-center">{logo}</span>
</motion.span>}
{/* The reveal edge and trigger share the sidebar's actual width.
No measured-width spring chasing the sidebar's own spring. */}
<div aria-hidden={compact} className="pointer-events-none absolute inset-y-0 left-0 right-9 overflow-hidden">
<motion.span
initial={false}
animate={{ x: compact && !reduced ? -8 : 0 }}
transition={transition}
className={cn("flex h-full items-center whitespace-nowrap text-xs font-semibold", logo ? "pl-11" : "pl-2")}
>
{title}
</motion.span>
</div>
<AnimatedSidebarTrigger
ref={toggleRef}
aria-label={compact ? "展开工作区侧栏" : "折叠工作区侧栏"}
className="absolute right-0 z-10 size-9 overflow-hidden rounded-lg focus-visible:ring-inset"
>
<motion.span
initial={false}
animate={{ x: showToggle || reduced ? 0 : 36, opacity: showToggle ? 1 : 0 }}
transition={transition}
className="grid size-9 shrink-0 place-items-center"
>
<PanelLeft className="size-[18px]" />
</motion.span>
</AnimatedSidebarTrigger>
</>
)}
</motion.div>
</AnimatedSidebarHeader>
<AnimatedSidebarContent className="gap-6 px-3.5 py-3 [scrollbar-width:none]">
{groups.map((group) => <AnimatedSidebarGroup key={group.id} role="group" aria-label={group.title} className="px-0 py-0">
<AnimatedSidebarMenu className="gap-1">
{group.items.map((item) => <WorkspaceNavigationItem key={item.id} item={item} activeId={activeId} onSelect={onSelect} compact={compact} />)}
</AnimatedSidebarMenu>
</AnimatedSidebarGroup>)}
</AnimatedSidebarContent>
{footer && <AnimatedSidebarFooter className="mx-3.5 w-auto border-t border-border/60 px-0 pt-3 pb-3">{footer}</AnimatedSidebarFooter>}
</AnimatedSidebar>;
}
"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,
} 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 {
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>
);
},
);
"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;
}
API Reference
sidebar?ReactNode—tabBar?ReactNode—mobileHeader?ReactNode—header?ReactNode—footer?ReactNode—scroll?"content" | "none"content: this pane scrolls; none: the editor/chat child owns scrolling.
contentcontentProps?Omit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "children">—open?boolean—defaultOpen?boolean—onOpenChange?((open: boolean) => void)—openMobile?boolean—defaultOpenMobile?boolean—onOpenMobileChange?((open: boolean) => void)—style?SidebarProviderStyle—className?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.
Workspace Tab Bar
Surface-connected tabs with pinning, closing, drag and keyboard reordering, and host-owned window actions.
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.
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