Скилл переносится из ~/.claude/skills/aura-design в репозиторий обвязки: SKILL.md, справочники reference/ и набор kit/ (токены, base.css, компоненты React, оболочка AppShell, тест токенов, test-setup, примеры входа). install.sh ставит его в ~/.claude/skills/aura-design, --check его видит. tests/test-aura-design.sh проверяет структуру и инварианты набора. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
6.3 KiB
TypeScript
121 lines
6.3 KiB
TypeScript
import { Fragment, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||
import { IconButton, IconClose, IconMenu, IconSidebar, Menu, ThemeSwitch } from "../ui/index.ts";
|
||
import "./shell.css";
|
||
|
||
// Оболочка AURA: закреплённая боковая панель (240 px, свёртка до 64 px) и страница справа; прокрутка — у окна.
|
||
// Узкий экран (<640 px): панели в потоке нет — закреплённая строка 56 px с кнопкой «Меню», панель выезжает поверх.
|
||
export type NavItem = { href: string; label: string; icon: ReactNode };
|
||
// ссылка по умолчанию — <a>; с роутером: link={({ href, ...p }) => <Link to={href} {...p} />}
|
||
export type ShellLink = (p: {
|
||
href: string; className: string; "aria-current"?: "page"; title?: string; onClick?: () => void; children: ReactNode;
|
||
}) => ReactNode;
|
||
const plainLink: ShellLink = ({ href, ...rest }) => <a href={href} {...rest} />;
|
||
|
||
type Props = {
|
||
brand: string; // название приложения, Bold 16/24
|
||
nav: NavItem[];
|
||
current: string | null; // href активного раздела; null — активного нет
|
||
link?: ShellLink;
|
||
top?: (collapsed: boolean) => ReactNode; // под названием: выбор проекта/аккаунта и т. п.
|
||
bottom?: (collapsed: boolean) => ReactNode; // над подвалом: состояние данных, кнопка синхронизации
|
||
topbarEnd?: ReactNode; // правый край строки на телефоне (компактное состояние)
|
||
menu?: (close: () => void) => ReactNode; // пункты меню «Ещё» в подвале (MenuItem …)
|
||
storageKey?: string; // ключ свёртки панели в localStorage
|
||
wide?: boolean; // страница во всю ширину (доски, графики), иначе до 1440 px
|
||
children: ReactNode;
|
||
};
|
||
|
||
const NARROW = "(max-width: 639px)";
|
||
const subscribe = (cb: () => void) => {
|
||
if (typeof matchMedia !== "function") return () => {};
|
||
const m = matchMedia(NARROW);
|
||
m.addEventListener?.("change", cb);
|
||
return () => m.removeEventListener?.("change", cb);
|
||
};
|
||
const isNarrow = () => typeof matchMedia === "function" && matchMedia(NARROW).matches;
|
||
|
||
export function AppShell({ wide, children, ...p }: Props) {
|
||
const narrow = useSyncExternalStore(subscribe, isNarrow);
|
||
return (
|
||
<div className="shell">
|
||
{narrow ? <NarrowBar {...p} /> : <Sidebar {...p} />}
|
||
<main className={"page" + (wide ? " page--wide" : "")}>{children}</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type PanelProps = Omit<Props, "wide" | "children">;
|
||
|
||
function NarrowBar(p: PanelProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const menuBtn = useRef<HTMLButtonElement>(null);
|
||
// объект стабилен, пока панель открыта: эффект фокуса и Escape не перезапускается на каждой отрисовке
|
||
const drawer = useMemo(() => ({ close: () => { setOpen(false); menuBtn.current?.focus(); }, navigated: () => setOpen(false) }), []);
|
||
return (
|
||
<>
|
||
<header className="topbar" aria-label="Строка приложения">
|
||
<button ref={menuBtn} type="button" className="ui-icon-btn" aria-label="Меню" title="Меню" aria-expanded={open}
|
||
onClick={() => setOpen(true)}><IconMenu /></button>
|
||
<span className="sidebar__brand">{p.brand}</span>
|
||
<span className="sidebar__spacer" />
|
||
{p.topbarEnd}
|
||
</header>
|
||
{open && <>
|
||
<div className="sidebar-backdrop" onClick={drawer.close} />
|
||
<Sidebar {...p} drawer={drawer} />
|
||
</>}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function readFold(key: string): boolean {
|
||
try { return localStorage.getItem(key) === "collapsed"; } catch { return false; }
|
||
}
|
||
function saveFold(key: string, collapsed: boolean): void {
|
||
try { if (collapsed) localStorage.setItem(key, "collapsed"); else localStorage.removeItem(key); } catch { /* только в памяти */ }
|
||
}
|
||
|
||
function Sidebar({ brand, nav, current, link = plainLink, top, bottom, menu, storageKey = "app-sidebar", drawer }:
|
||
PanelProps & { drawer?: { close(): void; navigated(): void } }) {
|
||
const [stored, setStored] = useState(() => readFold(storageKey));
|
||
const collapsed = !drawer && stored;
|
||
const fold = (c: boolean) => { setStored(c); saveFold(storageKey, c); };
|
||
const aside = useRef<HTMLElement>(null);
|
||
useEffect(() => {
|
||
if (!drawer) return;
|
||
aside.current?.querySelector<HTMLElement>(".sidebar__link")?.focus();
|
||
const esc = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) drawer.close(); };
|
||
document.addEventListener("keydown", esc);
|
||
return () => document.removeEventListener("keydown", esc);
|
||
}, [drawer]);
|
||
return (
|
||
<aside ref={aside} className={"sidebar" + (collapsed ? " is-collapsed" : "") + (drawer ? " is-drawer" : "")} aria-label="Панель приложения">
|
||
<div className="sidebar__head">
|
||
{!collapsed && <span className="sidebar__brand">{brand}</span>}
|
||
{drawer
|
||
? <IconButton label="Закрыть меню" icon={<IconClose />} onClick={drawer.close} />
|
||
: <IconButton label={collapsed ? "Развернуть панель" : "Свернуть панель"} icon={<IconSidebar />}
|
||
aria-expanded={!collapsed} onClick={() => fold(!collapsed)} />}
|
||
</div>
|
||
{top?.(collapsed)}
|
||
<nav className="sidebar__nav" aria-label="Разделы">
|
||
{nav.map((s) => (
|
||
<Fragment key={s.href}>
|
||
{link({
|
||
href: s.href, className: "sidebar__link", "aria-current": current === s.href ? "page" : undefined,
|
||
title: collapsed ? s.label : undefined, onClick: drawer?.navigated,
|
||
children: <>{s.icon}<span className={collapsed ? "visually-hidden" : undefined}>{s.label}</span></>,
|
||
})}
|
||
</Fragment>
|
||
))}
|
||
</nav>
|
||
<span className="sidebar__spacer" />
|
||
{bottom?.(collapsed)}
|
||
<div className="sidebar__foot">
|
||
<ThemeSwitch />
|
||
{menu && <Menu label="Ещё" up align={collapsed ? "start" : "end"}>{menu}</Menu>}
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|