main
import { useT } from '@app/i18n'
import { Menu, PanelLeftClose, PanelLeftOpen, X } from 'lucide-react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
/** Shared, responsive workspace frame. Content panes own their scroll position. */
export interface AppShellProps {
sideHeader?: ReactNode
/** Pinned rows above the "工作区" caption (e.g. the personal inbox entry). */
navPinned?: ReactNode
nav?: ReactNode
sideFooter?: ReactNode
/** Directory-style page (explore): the whole navigation column is dropped. */
noSidebar?: boolean
children: ReactNode
}
export function AppShell({ sideHeader, navPinned, nav, sideFooter, noSidebar = false, children }: AppShellProps) {
const { t } = useT()
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' : ''} ${noSidebar ? 'no-sidebar' : ''}`}
>
<button
type="button"
className="workspace-skip"
onClick={() => document.getElementById('workspace-main')?.focus()}
>
{t('ui.skipToContent')}
</button>
<div className="workspace-mobile-bar">
<button
ref={menu}
type="button"
className="workspace-icon-button"
aria-label={t('ui.openNavigation')}
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">{t('ui.workspaceLabel')}</span>
</span>
</div>
{navigationOpen ? (
<button
type="button"
className="workspace-scrim"
aria-label={t('ui.closeNavigation')}
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={t('ui.navigation')}
onClick={(event) => {
if ((event.target as HTMLElement).closest('a[href]')) setNavigationOpen(false)
}}
>
<div className="workspace-navigation-columns">
<aside className="workspace-sidebar" aria-label={t('ui.currentSpace')}>
<div className="workspace-sidebar-header">
{sideHeader}
<button
type="button"
className="workspace-drawer-close workspace-icon-button"
aria-label={t('ui.collapseNavigation')}
onClick={() => setNavigationOpen(false)}
>
<X size={18} />
</button>
</div>
{navPinned ? (
<div className="workspace-nav-pinned" aria-label={t('ui.shortcuts')}>
{navPinned}
</div>
) : null}
<div className="workspace-nav-caption">
<span>{t('ui.workspace')}</span>
<button
type="button"
className="workspace-collapse workspace-icon-button"
aria-label={t('ui.collapseSidebar')}
title={t('ui.collapseSidebar')}
onClick={() => setCollapsed(true)}
>
<PanelLeftClose size={16} />
</button>
</div>
<nav className="workspace-nav" aria-label={t('ui.navScope')}>
{nav}
</nav>
</aside>
</div>
{sideFooter ? <div className="workspace-navigation-footer">{sideFooter}</div> : null}
</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={t('ui.expandSidebar')}
title={t('ui.expandSidebar')}
onClick={() => setCollapsed(false)}
>
<PanelLeftOpen size={18} />
</button>
{children}
</main>
</div>
)
}
export interface ShellTopBarProps {
icon?: ReactNode
title: ReactNode
/** Replaces the plain title heading with an interactive node (e.g. the asset search menu). */
titleMenu?: ReactNode
topic?: ReactNode
/** Page-level view switch, rendered beside the title. */
tabs?: ReactNode
actions?: ReactNode
}
export function ShellTopBar({ icon, title, titleMenu, topic, tabs, actions }: ShellTopBarProps) {
return (
<header className="shell-top-bar">
{icon ? <span className="shell-top-icon">{icon}</span> : null}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-3">
{titleMenu ?? <h1 className="truncate text-base font-semibold">{title}</h1>}
{tabs ? <div className="flex shrink-0 items-center">{tabs}</div> : null}
</div>
{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>
)
}