feat: compose Jyotisha app sidebar

This commit is contained in:
Jesse_Chen
2026-07-17 17:55:55 +08:00
parent fcaece87df
commit 626524b186
3 changed files with 268 additions and 2 deletions
+221
View File
@@ -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>
);
}
+43
View File
@@ -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 -2
View File
@@ -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"/);
});