Скилл переносится из ~/.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>
50 lines
2.5 KiB
TypeScript
50 lines
2.5 KiB
TypeScript
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||
import { IconButton } from "./Button.tsx";
|
||
import { IconMore } from "./icons.tsx";
|
||
|
||
// выпадающий список: кнопка открывает, щелчок вне или Escape закрывает
|
||
export function Dropdown({ button, className = "", menu = false, children, onOpen }: {
|
||
button: (p: { open: boolean; toggle(): void }) => ReactNode; className?: string; menu?: boolean; // menu — меню пунктов («Ещё»), не панель
|
||
children: (close: () => void) => ReactNode; onOpen?: () => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const away = (e: PointerEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); };
|
||
document.addEventListener("pointerdown", away);
|
||
return () => document.removeEventListener("pointerdown", away);
|
||
}, [open]);
|
||
const toggle = () => { if (!open) onOpen?.(); setOpen(!open); }; // побочное действие — вне updater setState
|
||
return (
|
||
<div className={"dd " + className} ref={ref} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}>
|
||
{button({ open, toggle })}
|
||
{open && <div className={"dd-menu" + (menu ? " menu" : "")}>{children(() => setOpen(false))}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// меню «Ещё»: Dropdown с кнопкой-иконкой; пункты — кнопки (aria-pressed для переключателей)
|
||
export function Menu({ label, children, align = "end", up = false }: {
|
||
label: string; children: (close: () => void) => ReactNode; align?: "start" | "end"; up?: boolean; // up — открывается вверх (низ боковой панели)
|
||
}) {
|
||
return (
|
||
<Dropdown menu className={"more" + (align === "start" ? " dd--start" : "") + (up ? " dd--up" : "")}
|
||
button={({ open, toggle }) => <IconButton label={label} icon={<IconMore />} aria-haspopup="menu" aria-expanded={open} onClick={toggle} />}>
|
||
{children}
|
||
</Dropdown>
|
||
);
|
||
}
|
||
|
||
export function MenuItem({ onSelect, pressed, danger, icon, title, children }: {
|
||
onSelect(): void; title?: string; pressed?: boolean; danger?: boolean; icon?: ReactNode; children: ReactNode;
|
||
}) {
|
||
return (
|
||
<button type="button" className={danger ? "danger" : undefined} aria-pressed={pressed === undefined ? undefined : pressed} title={title} onClick={onSelect}>
|
||
<span className="menu__label">{icon}{children}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function MenuSeparator() { return <hr />; }
|