Skip to content
Clone
f0a1552
108 lines 5.6 KB
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { ChevronDown, Menu, PanelLeftClose, PanelLeftOpen, X } from 'lucide-react'
/** Shared, responsive workspace frame. Content panes own their scroll position. */
export interface AppShellProps {
rail?: ReactNode
sideHeader?: ReactNode
/** Pinned rows above the "工作区" caption (e.g. the personal inbox entry). */
navPinned?: ReactNode
nav?: ReactNode
sideFooter?: ReactNode
children: ReactNode
}
export function AppShell({ rail, sideHeader, navPinned, nav, sideFooter, children }: AppShellProps) {
const [navigationOpen, setNavigationOpen] = useState(false)
const [collapsed, setCollapsed] = useState(false)
const navigation = useRef<HTMLDivElement>(null)
const menu = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!navigationOpen) return
const panel = navigation.current
const previous = document.activeElement as HTMLElement | null
panel?.querySelector<HTMLElement>('button, a[href]')?.focus()
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') setNavigationOpen(false)
if (event.key !== 'Tab' || !panel) return
const items = [...panel.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input:not([disabled])')].filter(item => item.getClientRects().length)
const first = items[0]
const last = items[items.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
}
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
if (previous?.isConnected) previous.focus()
}
}, [navigationOpen])
useEffect(() => {
const viewport = window.matchMedia('(min-width: 761px)')
const closeDrawer = () => { if (viewport.matches) setNavigationOpen(false) }
viewport.addEventListener('change', closeDrawer)
return () => viewport.removeEventListener('change', closeDrawer)
}, [])
return (
<div className={`workspace-shell ${collapsed ? 'navigation-collapsed' : ''} ${navigationOpen ? 'navigation-open' : ''}`}>
<button
type="button"
className="workspace-skip"
onClick={() => document.getElementById('workspace-main')?.focus()}
>
跳转到主要内容
</button>
<div className="workspace-mobile-bar">
<button ref={menu} type="button" className="workspace-icon-button" aria-label="打开工作区导航" aria-expanded={navigationOpen} aria-controls="workspace-navigation" onClick={() => setNavigationOpen(true)}><Menu size={20} /></button>
<span className="font-semibold">GitData<span className="ml-2 text-xs font-normal text-shell-ink-muted">工作空间</span></span>
</div>
{navigationOpen ? <button type="button" className="workspace-scrim" aria-label="收起工作区导航" tabIndex={-1} onClick={() => setNavigationOpen(false)} /> : null}
<div id="workspace-navigation" ref={navigation} className="workspace-navigation" role={navigationOpen ? 'dialog' : undefined} aria-modal={navigationOpen || undefined} aria-label="工作区导航" onClick={event => {
if ((event.target as HTMLElement).closest('a[href]')) setNavigationOpen(false)
}}>
<aside className="workspace-rail" aria-label="空间列表">{rail}</aside>
<aside className="workspace-sidebar" aria-label="当前空间">
<div className="workspace-sidebar-header">
{sideHeader ?? <><span className="min-w-0 flex-1 truncate font-semibold">个人空间</span><ChevronDown size={16} className="text-shell-ink-muted" /></>}
<button type="button" className="workspace-drawer-close workspace-icon-button" aria-label="收起导航" onClick={() => setNavigationOpen(false)}><X size={18} /></button>
</div>
{navPinned ? (
<div className="workspace-nav-pinned" aria-label="快捷入口">{navPinned}</div>
) : null}
<div className="workspace-nav-caption"><span>工作区</span><button type="button" className="workspace-collapse workspace-icon-button" aria-label="折叠侧栏" title="折叠侧栏" onClick={() => setCollapsed(true)}><PanelLeftClose size={16} /></button></div>
<nav className="workspace-nav" aria-label="频道、看板与仓库">{nav}</nav>
<div className="workspace-sidebar-footer">{sideFooter}</div>
</aside>
</div>
<main id="workspace-main" tabIndex={-1} className="workspace-main" inert={navigationOpen || undefined}>
{/* Always mounted: `visibility` hides it when the sidebar is open, which keeps
it out of the tab order while still letting it fade in and out. */}
<button type="button" className="workspace-expand workspace-icon-button" aria-label="展开侧栏" title="展开侧栏" onClick={() => setCollapsed(false)}><PanelLeftOpen size={18} /></button>
{children}
</main>
</div>
)
}
export interface ShellTopBarProps {
icon?: ReactNode
title: ReactNode
topic?: ReactNode
actions?: ReactNode
}
export function ShellTopBar({ icon, title, topic, actions }: ShellTopBarProps) {
return (
<header className="shell-top-bar">
{icon ? <span className="shell-top-icon">{icon}</span> : null}
<div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold">{title}</h1>
{topic ? <p className="mt-0.5 truncate text-xs text-shell-ink-muted">{topic}</p> : null}
</div>
{actions ? <div className="shell-top-actions">{actions}</div> : null}
</header>
)
}