feat(web): 工作区布局 - 侧栏分组导航支持折叠
- 新增 NavGroup 组件:有标题的分组(工作台管理)可点击折叠/展开,Chevron 旋转指示,grid-rows 平滑动画;激活项所在分组自动展开\n- 折叠按钮借鉴 SelectItem 交互:紧凑 28px 高度、hover/focus-visible 浅背景反馈、标签文字与图标随 hover 加深(36% -> 55% / 35% -> 70%)\n- 无标题主分组(核心导航入口)保持常驻;分组标签 padding 移交折叠按钮,样式单一来源
This commit is contained in:
@@ -266,6 +266,62 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 }
|
||||
);
|
||||
}
|
||||
|
||||
function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout }: {
|
||||
group: WorkspaceNavGroup;
|
||||
activeId: string;
|
||||
onNavigate: () => void;
|
||||
onOpenSearch: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const hasActive = group.items.some((item) => item.id === activeId || (item.id === "settings" && activeId.startsWith("settings:")));
|
||||
|
||||
// 激活项所在分组自动展开,保证当前位置可见。
|
||||
useEffect(() => {
|
||||
if (hasActive) setIsOpen(true);
|
||||
}, [hasActive]);
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
activeId={activeId}
|
||||
onSelect={onNavigate}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 无标题分组(核心导航入口)常驻展示,不做折叠。
|
||||
if (!group.heading) {
|
||||
return <div className="flex shrink-0 flex-col">{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
aria-expanded={isOpen}
|
||||
className="app-workspace-nav-group-toggle select-none"
|
||||
>
|
||||
<span className="app-workspace-nav-group-label">{group.heading}</span>
|
||||
<ChevronRight
|
||||
className={cn("size-3.5 shrink-0 text-foreground/35 transition-transform duration-200", isOpen && "rotate-90")}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
<div className={cn("grid transition-[grid-template-rows,opacity] duration-300 ease-in-out", isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0")}>
|
||||
<div className="min-h-0 overflow-hidden pt-0.5">{content}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate: () => void; onOpenSearch: () => void }) {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -325,21 +381,14 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate:
|
||||
)}
|
||||
>
|
||||
{groups.map((group, index) => (
|
||||
<div key={index} className="flex shrink-0 flex-col">
|
||||
{group.heading ? <span className="app-workspace-nav-group-label">{group.heading}</span> : null}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
activeId={activeId}
|
||||
onSelect={onNavigate}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onLogout={() => void handleLogout()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<NavGroup
|
||||
key={index}
|
||||
group={group}
|
||||
activeId={activeId}
|
||||
onNavigate={onNavigate}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onLogout={() => void handleLogout()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -941,15 +941,44 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 分组标题:小号大写标签,弱化但可读 */
|
||||
/* 分组标题:小号大写标签,弱化但可读;有标题的分组支持折叠,padding/间距由折叠按钮接管 */
|
||||
.app-workspace-nav-group-label {
|
||||
display: block;
|
||||
padding: 0 10px 6px;
|
||||
font-size: var(--fs-label);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--foreground) 36%, transparent);
|
||||
transition: color var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.app-workspace-nav-group-toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--r-sm);
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
transition: background-color var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.app-workspace-nav-group-toggle:hover,
|
||||
.app-workspace-nav-group-toggle:focus-visible {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.app-workspace-nav-group-toggle:hover .app-workspace-nav-group-label,
|
||||
.app-workspace-nav-group-toggle:focus-visible .app-workspace-nav-group-label {
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
}
|
||||
|
||||
.app-workspace-nav-group-toggle:hover svg,
|
||||
.app-workspace-nav-group-toggle:focus-visible svg {
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
}
|
||||
|
||||
/* 工作区切换器下拉浮层 */
|
||||
|
||||
Reference in New Issue
Block a user