feat: compose Jyotisha app sidebar
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { Popover } from "@base-ui/react/popover";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronRight,
|
||||
Gift,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
MessageSquareText,
|
||||
Plus,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Ref } from "react";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export type SidebarSession = {
|
||||
id: string;
|
||||
title: string;
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
export type SidebarAccount = {
|
||||
name: string;
|
||||
email: string;
|
||||
credits: number;
|
||||
isAdmin: boolean;
|
||||
initial: string;
|
||||
};
|
||||
|
||||
export type AppSidebarProps = {
|
||||
sessions: readonly SidebarSession[];
|
||||
activeSessionId: string | null;
|
||||
account: SidebarAccount;
|
||||
accountMenuOpen: boolean;
|
||||
accountTriggerRef: Ref<HTMLButtonElement>;
|
||||
newChatDisabled: boolean;
|
||||
creatingSession: boolean;
|
||||
onAccountMenuOpenChange: (open: boolean) => void;
|
||||
onNewChat: () => void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onOpenProfile: () => void;
|
||||
onOpenRedeem: () => void;
|
||||
onOpenLogout: () => void;
|
||||
};
|
||||
|
||||
export function AppSidebar({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
account,
|
||||
accountMenuOpen,
|
||||
accountTriggerRef,
|
||||
newChatDisabled,
|
||||
creatingSession,
|
||||
onAccountMenuOpenChange,
|
||||
onNewChat,
|
||||
onSelectSession,
|
||||
onOpenProfile,
|
||||
onOpenRedeem,
|
||||
onOpenLogout,
|
||||
}: AppSidebarProps) {
|
||||
const { isMobile, setOpen, setOpenMobile, state } = useSidebar();
|
||||
const firstSessionRef = useRef<HTMLButtonElement>(null);
|
||||
const historyHeadingRef = useRef<HTMLHeadingElement>(null);
|
||||
const isCollapsedDesktop = state === "collapsed" && !isMobile;
|
||||
const showExpandedContent = !isCollapsedDesktop;
|
||||
const popoverPlacement = `${isMobile}:${state}`;
|
||||
const previousPopoverPlacement = useRef(popoverPlacement);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousPopoverPlacement.current !== popoverPlacement && accountMenuOpen) {
|
||||
onAccountMenuOpenChange(false);
|
||||
}
|
||||
previousPopoverPlacement.current = popoverPlacement;
|
||||
}, [accountMenuOpen, onAccountMenuOpenChange, popoverPlacement]);
|
||||
|
||||
function handleNewChat() {
|
||||
onNewChat();
|
||||
if (isMobile) setOpenMobile(false);
|
||||
}
|
||||
|
||||
function handleExpandHistory() {
|
||||
setOpen(true);
|
||||
window.requestAnimationFrame(() => {
|
||||
(firstSessionRef.current ?? historyHeadingRef.current)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function handleAccountAction(action: () => void) {
|
||||
onAccountMenuOpenChange(false);
|
||||
action();
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar className="sidebar" aria-label="对话导航">
|
||||
<SidebarHeader className="sidebar-header">
|
||||
<div className="brand-row">
|
||||
<span className="brand-mark" aria-hidden="true" />
|
||||
{showExpandedContent ? <strong>Jyotisha</strong> : null}
|
||||
</div>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className="new-chat"
|
||||
type="button"
|
||||
tooltip="新对话"
|
||||
disabled={newChatDisabled}
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus aria-hidden="true" />
|
||||
{showExpandedContent ? <span>{creatingSession ? "正在创建" : "新对话"}</span> : null}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
{showExpandedContent ? (
|
||||
<SidebarGroup className="session-nav" aria-label="聊天记录">
|
||||
<SidebarGroupLabel className="sidebar-label" ref={historyHeadingRef} tabIndex={-1}>聊天记录</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
{sessions.length === 0 ? <p className="sidebar-empty">暂无对话</p> : (
|
||||
<SidebarMenu className="session-list">
|
||||
{sessions.map((session, index) => (
|
||||
<SidebarMenuItem key={session.id}>
|
||||
<SidebarMenuButton
|
||||
ref={index === 0 ? firstSessionRef : undefined}
|
||||
className="session-row"
|
||||
type="button"
|
||||
isActive={session.id === activeSessionId}
|
||||
aria-current={session.id === activeSessionId ? "page" : undefined}
|
||||
onClick={() => {
|
||||
onSelectSession(session.id);
|
||||
if (isMobile) setOpenMobile(false);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{session.title}</span>
|
||||
{session.messageCount > 0 ? <small>{session.messageCount} 条消息</small> : null}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
type="button"
|
||||
tooltip="聊天记录"
|
||||
aria-label="聊天记录"
|
||||
onClick={handleExpandHistory}
|
||||
>
|
||||
<MessageSquareText aria-hidden="true" />
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="sidebar-footer">
|
||||
<Popover.Root open={accountMenuOpen} onOpenChange={onAccountMenuOpenChange}>
|
||||
<Popover.Trigger
|
||||
className="profile-trigger"
|
||||
ref={accountTriggerRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span className="profile-initial" aria-hidden="true">{account.initial}</span>
|
||||
{showExpandedContent ? <span><b>{account.name}</b></span> : null}
|
||||
{showExpandedContent ? <ChevronRight className={accountMenuOpen ? "chevron is-open" : "chevron"} aria-hidden="true" /> : null}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Positioner
|
||||
side={isMobile || state === "expanded" ? "top" : "right"}
|
||||
align={isMobile || state === "expanded" ? "end" : "center"}
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
>
|
||||
<Popover.Popup className="account-menu" role="menu" aria-label="账户菜单">
|
||||
<div className="account-menu-identity">
|
||||
<span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>
|
||||
<span><b>{account.name}</b><small>{account.email}</small></span>
|
||||
</div>
|
||||
<button className="account-menu-item" role="menuitem" type="button" onClick={() => handleAccountAction(onOpenProfile)}>
|
||||
<UserRound aria-hidden="true" /><span>个人资料</span><ChevronRight aria-hidden="true" />
|
||||
</button>
|
||||
<button className="account-menu-item" role="menuitem" type="button" onClick={() => handleAccountAction(onOpenRedeem)}>
|
||||
<Gift aria-hidden="true" /><span>兑换点数</span><small>{account.credits} 点</small>
|
||||
</button>
|
||||
{account.isAdmin && <Link className="account-menu-item" href="/admin/codes" role="menuitem" onClick={() => onAccountMenuOpenChange(false)}>
|
||||
<KeyRound aria-hidden="true" /><span>管理兑换码</span><ChevronRight aria-hidden="true" />
|
||||
</Link>}
|
||||
<div className="account-menu-separator" role="separator" />
|
||||
<button className="account-menu-item account-menu-danger" role="menuitem" type="button" onClick={() => handleAccountAction(onOpenLogout)}>
|
||||
<LogOut aria-hidden="true" /><span>退出登录</span>
|
||||
</button>
|
||||
</Popover.Popup>
|
||||
</Popover.Positioner>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -85,3 +85,46 @@ test("documents the sidebar shell design contract", () => {
|
||||
assert.match(design, /Scroll ownership/);
|
||||
assert.match(design, /session-local/);
|
||||
});
|
||||
|
||||
test("composes the Jyotisha app sidebar from the generic shell", () => {
|
||||
assert.equal(existsSync(projectFile("src/components/app-sidebar.tsx")), true);
|
||||
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
|
||||
for (const component of ["SidebarHeader", "SidebarContent", "SidebarFooter", "SidebarRail"]) {
|
||||
assert.match(appSidebar, new RegExp(`<${component}\\b`));
|
||||
}
|
||||
});
|
||||
|
||||
test("uses one collapsed history action instead of icon-only session rows", () => {
|
||||
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
assert.match(appSidebar, /MessageSquareText/);
|
||||
assert.match(appSidebar, /state === "collapsed" && !isMobile/);
|
||||
assert.match(appSidebar, /sessions\.map/);
|
||||
assert.doesNotMatch(appSidebar, /sessions\.map\([^)]*\)\s*=>\s*<[^>]+aria-label=/);
|
||||
});
|
||||
|
||||
test("keeps session navigation independent of request state", () => {
|
||||
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
assert.match(appSidebar, /onSelectSession\(session\.id\)/);
|
||||
assert.doesNotMatch(appSidebar, /pendingSession|isLoading|cancellationPending|requestPending/);
|
||||
});
|
||||
|
||||
test("uses a portaled Base UI account popover with safe collision padding", () => {
|
||||
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
assert.match(appSidebar, /import \{ Popover \} from "@base-ui\/react\/popover"/);
|
||||
assert.match(appSidebar, /<Popover\.Portal>/);
|
||||
assert.match(appSidebar, /collisionPadding=\{12\}/);
|
||||
});
|
||||
|
||||
test("closes an open account menu only when its sidebar placement changes", () => {
|
||||
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
assert.match(appSidebar, /previousPopoverPlacement/);
|
||||
assert.match(appSidebar, /previousPopoverPlacement\.current !== popoverPlacement && accountMenuOpen/);
|
||||
});
|
||||
|
||||
test("keeps app sidebar props as product data and callbacks", () => {
|
||||
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
assert.match(appSidebar, /export type AppSidebarProps/);
|
||||
assert.match(appSidebar, /onSelectSession: \(sessionId: string\) => void/);
|
||||
assert.doesNotMatch(appSidebar, /supabase|fetch\(|\/api\//i);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const appSidebarSource = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
|
||||
|
||||
function sourceBetween(source: string, startMarker: string, endMarker: string) {
|
||||
const start = source.indexOf(startMarker);
|
||||
@@ -79,10 +80,11 @@ test("routes account actions through a popover and focused dialogs", () => {
|
||||
// Then: each account task has a focused destination instead of one combined sheet.
|
||||
assert.match(pageSource, /const \[accountMenuOpen, setAccountMenuOpen\] = useState\(false\)/);
|
||||
assert.match(pageSource, /const \[activeAccountDialog, setActiveAccountDialog\] = useState<AccountDialog \| null>\(null\)/);
|
||||
assert.match(pageSource, /className="account-menu"/);
|
||||
assert.match(pageSource, /openAccountDialog\("profile"/);
|
||||
assert.match(pageSource, /openAccountDialog\("redeem"/);
|
||||
assert.match(pageSource, /openAccountDialog\("logout"/);
|
||||
assert.match(appSidebarSource, /className="account-menu"/);
|
||||
assert.match(appSidebarSource, /<Popover\.Root open=\{accountMenuOpen\} onOpenChange=\{onAccountMenuOpenChange\}>/);
|
||||
});
|
||||
|
||||
test("removes the monolithic account sheet", () => {
|
||||
@@ -97,5 +99,5 @@ test("keeps admin navigation separate from account task dialogs", () => {
|
||||
// Given: the administrator-only route and the new account menu.
|
||||
// When: their source relationship is inspected.
|
||||
// Then: code management stays a guarded navigation action rather than a modal.
|
||||
assert.match(pageSource, /account\?\.isAdmin\s*&&\s*<Link[^>]+href="\/admin\/codes"[^>]+role="menuitem"/);
|
||||
assert.match(appSidebarSource, /account\.isAdmin\s*&&\s*<Link[^>]+href="\/admin\/codes"[^>]+role="menuitem"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user