Files
m3e-canvas/web/vendor/components/Menus.tsx
T
pastilhasandClaude Fable 5 240d63940d vendor m3e-canvas 5c83ac6, with the import rewrite scripted
upstream imports through a @/ alias, and this platform already binds
@/* to src/apps/officer-web and @/components/* to its own shared
components — so an unrewritten import resolves to the WRONG module
rather than failing. the rewrite is mechanical and lives in
scripts/vendor.sh so a re-vendor produces a readable diff.

lib/ai.ts and AiPanel are vendored despite being unwanted: deleting them
meant editing vendored code, and Canvas.tsx references them throughout.
a faithful copy carrying an unused module beats a doctored one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 12:00:17 +01:00

121 lines
3.9 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Palette } from "../lib/tokens";
import { LANGS, Lang, t, useLang } from "../lib/i18n";
import { IconBtn } from "./ui";
import { Icon } from "./M3Node";
const EASE = [0.2, 0, 0, 1] as const;
/** An icon button with a small menu anchored to it. `side` is where the menu opens. */
export function Popover({
p,
icon,
title,
side = "down",
size = 40,
children,
}: {
p: Palette;
icon: string;
title: string;
side?: "down" | "right";
size?: number;
children: (close: () => void) => React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
window.addEventListener("pointerdown", onDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", onDown);
window.removeEventListener("keydown", onKey);
};
}, [open]);
const anchor: React.CSSProperties =
side === "down"
? { top: size + 8, right: 0, transformOrigin: "top right" }
: { left: size + 8, bottom: 0, transformOrigin: "bottom left" };
const hidden = side === "down" ? { opacity: 0, y: -6, scale: 0.96 } : { opacity: 0, x: -6, scale: 0.96 };
return (
<div ref={ref} style={{ position: "relative" }}>
<IconBtn icon={icon} p={p} on={open} onClick={() => setOpen((o) => !o)} title={title} size={size} />
<AnimatePresence>
{open && (
<motion.div
role="menu"
initial={hidden}
animate={{ opacity: 1, x: 0, y: 0, scale: 1 }}
exit={hidden}
transition={{ duration: 0.16, ease: EASE }}
style={{
position: "absolute",
padding: 6,
borderRadius: 18,
background: p.surfaceContainerLow,
boxShadow: "0 6px 20px rgba(0,0,0,0.16), 0 0 0 1px rgba(0,0,0,0.04)",
zIndex: 60,
...anchor,
}}
>
{children(() => setOpen(false))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export function LangMenu({ p, onLang, side, size }: { p: Palette; onLang: (l: Lang) => void; side?: "down" | "right"; size?: number }) {
const lang = useLang();
return (
<Popover p={p} icon="translate" title={t("language", lang)} side={side} size={size}>
{(close) => (
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 140 }}>
{LANGS.map((l) => {
const on = l.key === lang;
return (
<button
key={l.key}
role="menuitemradio"
aria-checked={on}
onClick={() => {
onLang(l.key);
close();
}}
className="m3-press"
style={{
height: 40,
padding: "0 14px 0 10px",
borderRadius: 12,
border: "none",
background: on ? p.secondaryContainer : "transparent",
color: on ? p.onSecondaryContainer : p.onSurface,
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 10,
whiteSpace: "nowrap",
}}
>
<span style={{ width: 18, display: "inline-flex" }}>{on && <Icon name="check" size={18} />}</span>
{l.label}
</button>
);
})}
</div>
)}
</Popover>
);
}