mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 16:16:49 +00:00
480 lines
20 KiB
TypeScript
480 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { Check, ChevronRight } from "@/components/icons";
|
|
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
|
import { MENU_BAR_ATTRIBUTE, cachedMenuBarPinned, menuKeyText, rememberMenuBarPinned } from "@/lib/desktop/menu-bar";
|
|
import type { DesktopMenuBarEntry, DesktopMenuBarItem, DesktopMenuBarMenu } from "@/lib/desktop/types";
|
|
import { watchTitlebarArea } from "@/lib/window-controls-overlay";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* The desktop app's menu bar where the window's controls sit over the page (Windows and
|
|
* Linux), as Thunderbird has it. Alt shows it as a row at the top that moves the app down
|
|
* under it, and the window's controls shrink into that row; "Always show the menu bar"
|
|
* keeps it. Alt, Escape or a click elsewhere hides it again. Alt and a letter opens that
|
|
* menu, with the letters underlined while the keys are on the bar, and the arrows go
|
|
* between menus and lines. The menus are the shell's own (src/main/menu.ts in the desktop
|
|
* repo), with what's enabled and ticked now, and a pick runs the very item the native
|
|
* menu has. macOS keeps its system bar.
|
|
*
|
|
* A shell from before this gives only the names, and opens each menu natively.
|
|
*/
|
|
|
|
const LINE = "[data-menu-line]";
|
|
const ENABLED_LINE = `${LINE}:not([aria-disabled="true"])`;
|
|
|
|
type Focus = { kind: "name"; index: number } | { kind: "line" | "panel"; sub?: boolean };
|
|
|
|
/** The label with its letter underlined, while the keys are on the bar. */
|
|
function Label({ text, at, cue }: { text: string; at: number | undefined; cue: boolean }) {
|
|
if (!cue || at === undefined || at < 0 || at >= text.length) return <>{text}</>;
|
|
return (
|
|
<>
|
|
{text.slice(0, at)}
|
|
<span className="underline underline-offset-2">{text[at]}</span>
|
|
{text.slice(at + 1)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function nameLetterAt(entry: DesktopMenuBarEntry): number | undefined {
|
|
if (!entry.mnemonic) return undefined;
|
|
const at = entry.label.toLowerCase().indexOf(entry.mnemonic);
|
|
return at >= 0 ? at : undefined;
|
|
}
|
|
|
|
function letterOf(item: DesktopMenuBarItem): string | undefined {
|
|
return item.mnemonic !== undefined ? item.label[item.mnemonic]?.toLowerCase() : undefined;
|
|
}
|
|
|
|
function usable(item: DesktopMenuBarItem | undefined): item is DesktopMenuBarItem {
|
|
return !!item && item.type !== "separator" && item.enabled;
|
|
}
|
|
|
|
const EDGE = 4;
|
|
|
|
/**
|
|
* A menu that would leave the window moves back in. A submenu sits beside its line, on the
|
|
* reading side, or the other side where there's no room, and never below the window.
|
|
*/
|
|
function keepInside(root: HTMLElement | null): void {
|
|
if (!root) return;
|
|
const width = document.documentElement.clientWidth;
|
|
const height = document.documentElement.clientHeight;
|
|
const panel = root.querySelector<HTMLElement>("[data-menu-panel]");
|
|
if (panel) {
|
|
panel.style.translate = "";
|
|
const rect = panel.getBoundingClientRect();
|
|
if (rect.right > width - EDGE) panel.style.translate = `${Math.round(width - EDGE - rect.right)}px 0`;
|
|
else if (rect.left < EDGE) panel.style.translate = `${Math.round(EDGE - rect.left)}px 0`;
|
|
}
|
|
const subpanel = root.querySelector<HTMLElement>("[data-menu-subpanel]");
|
|
const line = panel?.querySelector<HTMLElement>(`${LINE}[aria-expanded="true"]`);
|
|
if (!panel || !subpanel || !line) return;
|
|
const outer = panel.getBoundingClientRect();
|
|
const at = line.getBoundingClientRect();
|
|
const own = subpanel.getBoundingClientRect();
|
|
const rtl = getComputedStyle(root).direction === "rtl";
|
|
const after = outer.right - EDGE;
|
|
const before = outer.left + EDGE - own.width;
|
|
const fitsAfter = after + own.width <= width - EDGE;
|
|
const fitsBefore = before >= EDGE;
|
|
const left = rtl ? (fitsBefore ? before : after) : fitsAfter || !fitsBefore ? Math.min(after, width - EDGE - own.width) : before;
|
|
subpanel.style.left = `${Math.round(Math.max(EDGE, left))}px`;
|
|
subpanel.style.top = `${Math.round(Math.max(EDGE, Math.min(at.top - EDGE, height - EDGE - own.height)))}px`;
|
|
}
|
|
|
|
export function WindowMenuBar() {
|
|
const t = useTranslations("desktopUi.menu");
|
|
const [desktop, setDesktop] = useState(false);
|
|
const [hasOverlay, setHasOverlay] = useState(false);
|
|
const [pinned, setPinned] = useState(false);
|
|
/** The keys are on the bar: Alt was pressed, or a menu was opened. */
|
|
const [active, setActive] = useState(false);
|
|
const [menus, setMenus] = useState<DesktopMenuBarMenu[] | null>(null);
|
|
const [open, setOpen] = useState<number | null>(null);
|
|
/** The line of the open menu whose submenu is open. */
|
|
const [sub, setSub] = useState<number | null>(null);
|
|
/** Underlined letters: the keys brought the bar, not the mouse. */
|
|
const [cue, setCue] = useState(false);
|
|
const barRef = useRef<HTMLDivElement>(null);
|
|
const previousFocus = useRef<HTMLElement | null>(null);
|
|
const pendingFocus = useRef<Focus | null>(null);
|
|
|
|
const shown = desktop && hasOverlay && (pinned || active);
|
|
const drawsMenus = desktop && !!getDesktopBridge()?.menu?.bar?.menus;
|
|
|
|
useEffect(() => {
|
|
const bridge = getDesktopBridge();
|
|
if (!bridge?.menu?.bar || bridge.platform === "darwin") return;
|
|
setDesktop(true);
|
|
setPinned(cachedMenuBarPinned());
|
|
return watchTitlebarArea((area) => setHasOverlay(area !== null));
|
|
}, []);
|
|
|
|
const refresh = useCallback(async (): Promise<DesktopMenuBarMenu[] | null> => {
|
|
const bar = getDesktopBridge()?.menu?.bar;
|
|
if (!bar) return null;
|
|
const list = bar.menus
|
|
? await bar.menus().catch(() => null)
|
|
: await bar.entries().then((entries) => entries.map((entry) => ({ ...entry, items: [] }))).catch(() => null);
|
|
if (list) setMenus(list);
|
|
return list;
|
|
}, []);
|
|
|
|
const rememberFocus = useCallback(() => {
|
|
const now = document.activeElement;
|
|
if (now instanceof HTMLElement && now !== document.body && !barRef.current?.contains(now)) previousFocus.current = now;
|
|
}, []);
|
|
|
|
/** Back to how it was before Alt: the menus shut, the keys back where they were. */
|
|
const deactivate = useCallback(() => {
|
|
setOpen(null);
|
|
setSub(null);
|
|
setActive(false);
|
|
setCue(false);
|
|
const back = previousFocus.current;
|
|
previousFocus.current = null;
|
|
if (barRef.current?.contains(document.activeElement)) {
|
|
if (back?.isConnected) back.focus({ preventScroll: true });
|
|
else (document.activeElement as HTMLElement | null)?.blur();
|
|
}
|
|
}, []);
|
|
|
|
// The shell: Alt, Alt and a letter, the window losing the focus, the setting.
|
|
useEffect(() => {
|
|
if (!desktop) return;
|
|
const bar = getDesktopBridge()?.menu?.bar;
|
|
if (!bar) return;
|
|
const stop = bar.onEvent((event) => {
|
|
if (typeof event.pinned === "boolean") {
|
|
setPinned(event.pinned);
|
|
rememberMenuBarPinned(event.pinned);
|
|
}
|
|
if (!event.visible) {
|
|
deactivate();
|
|
return;
|
|
}
|
|
rememberFocus();
|
|
setActive(true);
|
|
setCue(true);
|
|
setSub(null);
|
|
// Set now: the names may be drawn before the menus arrive, and take it then.
|
|
pendingFocus.current = event.open && bar.menus ? { kind: "line" } : { kind: "name", index: 0 };
|
|
void refresh().then((list) => {
|
|
const index = event.open ? (list ?? []).findIndex((menu) => menu.id === event.open) : -1;
|
|
if (index >= 0 && bar.menus) setOpen(index);
|
|
else setOpen(null);
|
|
});
|
|
});
|
|
// Listening now: the shell answers with whether the bar is always there.
|
|
bar.report?.({ height: 0, active: false });
|
|
return stop;
|
|
}, [desktop, refresh, deactivate, rememberFocus]);
|
|
|
|
// An always-shown bar has its names from the start.
|
|
useEffect(() => {
|
|
if (shown && !menus) void refresh();
|
|
}, [shown, menus, refresh]);
|
|
|
|
// The row it takes: everything else moves down under it, and the controls fit into it.
|
|
useLayoutEffect(() => {
|
|
if (!desktop) return;
|
|
document.documentElement.toggleAttribute(MENU_BAR_ATTRIBUTE, shown);
|
|
const height = shown ? Math.round(barRef.current?.getBoundingClientRect().height ?? 0) : 0;
|
|
getDesktopBridge()?.menu?.bar?.report?.({ height, active });
|
|
}, [desktop, shown, active]);
|
|
|
|
useEffect(() => () => document.documentElement.removeAttribute(MENU_BAR_ATTRIBUTE), []);
|
|
|
|
// Menus stay in the window; the focus goes where the keys asked, once it's drawn.
|
|
useLayoutEffect(() => {
|
|
const root = barRef.current;
|
|
keepInside(root);
|
|
const want = pendingFocus.current;
|
|
if (!want || !root) return;
|
|
let target: HTMLElement | null = null;
|
|
if (want.kind === "name") {
|
|
target = root.querySelectorAll<HTMLElement>("[data-menu-name]")[want.index] ?? null;
|
|
} else {
|
|
const panel = root.querySelector<HTMLElement>(want.sub ? "[data-menu-subpanel]" : "[data-menu-panel]");
|
|
target = want.kind === "panel" ? panel : panel?.querySelector<HTMLElement>(ENABLED_LINE) ?? panel ?? null;
|
|
}
|
|
if (!target) return;
|
|
pendingFocus.current = null;
|
|
target.focus({ preventScroll: true });
|
|
});
|
|
|
|
// A click anywhere else puts it all away.
|
|
useEffect(() => {
|
|
if (!active && open === null) return;
|
|
const onDown = (event: PointerEvent) => {
|
|
if (barRef.current?.contains(event.target as Node)) return;
|
|
deactivate();
|
|
};
|
|
document.addEventListener("pointerdown", onDown, true);
|
|
return () => document.removeEventListener("pointerdown", onDown, true);
|
|
}, [active, open, deactivate]);
|
|
|
|
const openMenu = useCallback((index: number, focus: "line" | "panel") => {
|
|
if (!drawsMenus) {
|
|
const entry = menus?.[index];
|
|
const button = barRef.current?.querySelectorAll<HTMLElement>("[data-menu-name]")[index];
|
|
if (!entry || !button) return;
|
|
const rect = button.getBoundingClientRect();
|
|
getDesktopBridge()?.menu?.bar?.open(entry.id, { x: rect.left, y: rect.bottom });
|
|
return;
|
|
}
|
|
setActive(true);
|
|
setOpen(index);
|
|
setSub(null);
|
|
pendingFocus.current = { kind: focus };
|
|
// What's enabled may have changed since the bar showed.
|
|
void refresh();
|
|
}, [drawsMenus, menus, refresh]);
|
|
|
|
const run = useCallback((item: DesktopMenuBarItem) => {
|
|
if (!usable(item)) return;
|
|
// Cut, Copy and Paste work on what had the focus, so it has it back first.
|
|
deactivate();
|
|
getDesktopBridge()?.menu?.bar?.run?.(item.key, item.label);
|
|
}, [deactivate]);
|
|
|
|
if (!shown) return null;
|
|
|
|
const list = menus ?? [];
|
|
const openItems = open !== null ? list[open]?.items ?? [] : [];
|
|
const subItems = sub !== null ? openItems[sub]?.items ?? [] : [];
|
|
|
|
const names = () => Array.from(barRef.current?.querySelectorAll<HTMLElement>("[data-menu-name]") ?? []);
|
|
const linesIn = (selector: string) => Array.from(barRef.current?.querySelector(selector)?.querySelectorAll<HTMLElement>(ENABLED_LINE) ?? []);
|
|
|
|
const step = (lines: HTMLElement[], delta: number) => {
|
|
if (lines.length === 0) return;
|
|
const at = lines.indexOf(document.activeElement as HTMLElement);
|
|
const next = at < 0 ? (delta > 0 ? 0 : lines.length - 1) : (at + delta + lines.length) % lines.length;
|
|
lines[next]?.focus({ preventScroll: true });
|
|
};
|
|
|
|
const openSub = (index: number) => {
|
|
setSub(index);
|
|
pendingFocus.current = { kind: "line", sub: true };
|
|
};
|
|
|
|
const closeSub = () => {
|
|
const parent = sub;
|
|
setSub(null);
|
|
barRef.current?.querySelector<HTMLElement>(`[data-menu-panel] [data-menu-line="${parent}"]`)?.focus({ preventScroll: true });
|
|
};
|
|
|
|
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
const target = event.target as HTMLElement;
|
|
const rtl = getComputedStyle(barRef.current ?? document.documentElement).direction === "rtl";
|
|
const forward = rtl ? "ArrowLeft" : "ArrowRight";
|
|
const backward = rtl ? "ArrowRight" : "ArrowLeft";
|
|
const inSub = !!target.closest("[data-menu-subpanel]");
|
|
const inMenu = inSub || !!target.closest("[data-menu-panel]");
|
|
const key = event.key;
|
|
const plainLetter = key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
const take = () => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
setCue(true);
|
|
|
|
if (key === "Tab") {
|
|
take();
|
|
deactivate();
|
|
return;
|
|
}
|
|
|
|
if (!inMenu) {
|
|
const at = names().indexOf(target);
|
|
if (key === forward || key === backward) {
|
|
take();
|
|
step(names(), key === forward ? 1 : -1);
|
|
} else if (key === "ArrowDown" || key === "Enter" || key === " ") {
|
|
take();
|
|
if (at >= 0) openMenu(at, "line");
|
|
} else if (key === "Escape") {
|
|
take();
|
|
deactivate();
|
|
} else if (plainLetter) {
|
|
const index = list.findIndex((menu) => menu.mnemonic === key.toLowerCase());
|
|
if (index >= 0) {
|
|
take();
|
|
openMenu(index, "line");
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const items = inSub ? subItems : openItems;
|
|
const lineIndex = Number(target.closest<HTMLElement>(LINE)?.dataset.menuLine);
|
|
const current = Number.isInteger(lineIndex) ? items[lineIndex] : undefined;
|
|
const neighbour = (delta: number) => {
|
|
if (open !== null && list.length) openMenu((open + delta + list.length) % list.length, "line");
|
|
};
|
|
|
|
if (key === "ArrowDown" || key === "ArrowUp") {
|
|
take();
|
|
step(linesIn(inSub ? "[data-menu-subpanel]" : "[data-menu-panel]"), key === "ArrowDown" ? 1 : -1);
|
|
} else if (key === "Home" || key === "End") {
|
|
take();
|
|
const lines = linesIn(inSub ? "[data-menu-subpanel]" : "[data-menu-panel]");
|
|
lines[key === "Home" ? 0 : lines.length - 1]?.focus({ preventScroll: true });
|
|
} else if (key === forward) {
|
|
take();
|
|
if (!inSub && usable(current) && current.type === "submenu") openSub(lineIndex);
|
|
else neighbour(1);
|
|
} else if (key === backward) {
|
|
take();
|
|
if (inSub) closeSub();
|
|
else neighbour(-1);
|
|
} else if (key === "Enter" || key === " ") {
|
|
take();
|
|
if (!usable(current)) return;
|
|
if (current.type === "submenu") openSub(lineIndex);
|
|
else run(current);
|
|
} else if (key === "Escape") {
|
|
take();
|
|
if (inSub) {
|
|
closeSub();
|
|
} else {
|
|
const index = open ?? 0;
|
|
setOpen(null);
|
|
names()[index]?.focus({ preventScroll: true });
|
|
}
|
|
} else if (plainLetter) {
|
|
const index = items.findIndex((item) => usable(item) && letterOf(item) === key.toLowerCase());
|
|
if (index < 0) return;
|
|
take();
|
|
const item = items[index];
|
|
if (item.type === "submenu" && !inSub) openSub(index);
|
|
else run(item);
|
|
}
|
|
};
|
|
|
|
const renderLines = (items: DesktopMenuBarItem[], level: "panel" | "subpanel") =>
|
|
items.map((item, index) => {
|
|
if (item.type === "separator") return <div key={item.key} role="separator" className="my-1 h-px bg-border" />;
|
|
const isSub = item.type === "submenu";
|
|
const checkable = item.type === "checkbox" || item.type === "radio";
|
|
const opened = level === "panel" && isSub && sub === index;
|
|
return (
|
|
<div key={item.key} className="relative">
|
|
<button
|
|
type="button"
|
|
role={item.type === "checkbox" ? "menuitemcheckbox" : item.type === "radio" ? "menuitemradio" : "menuitem"}
|
|
aria-checked={checkable ? !!item.checked : undefined}
|
|
aria-haspopup={isSub ? "menu" : undefined}
|
|
aria-expanded={isSub ? opened : undefined}
|
|
aria-disabled={item.enabled ? undefined : true}
|
|
aria-keyshortcuts={item.accelerator ? menuKeyText(item.accelerator, getDesktopBridge()?.platform) : undefined}
|
|
data-menu-line={index}
|
|
tabIndex={-1}
|
|
onMouseEnter={(event) => {
|
|
if (level === "panel") setSub(isSub && item.enabled ? index : null);
|
|
if (item.enabled) event.currentTarget.focus({ preventScroll: true });
|
|
}}
|
|
onClick={() => {
|
|
if (!item.enabled) return;
|
|
if (isSub) openSub(index);
|
|
else run(item);
|
|
}}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm outline-none",
|
|
item.enabled ? "focus:bg-muted" : "cursor-default text-muted-foreground/60",
|
|
opened && "bg-muted",
|
|
)}
|
|
>
|
|
<span className="flex w-4 shrink-0 justify-center" aria-hidden="true">
|
|
{checkable && item.checked && (item.type === "radio" ? <span className="h-1.5 w-1.5 rounded-full bg-current" /> : <Check className="h-3.5 w-3.5" />)}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate" data-menu-label="">
|
|
<Label text={item.label} at={item.mnemonic} cue={cue} />
|
|
</span>
|
|
{item.accelerator && (
|
|
<span className="shrink-0 ps-6 text-xs text-muted-foreground" data-menu-keys="" aria-hidden="true">
|
|
{menuKeyText(item.accelerator, getDesktopBridge()?.platform)}
|
|
</span>
|
|
)}
|
|
{isSub && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />}
|
|
</button>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
return (
|
|
<div
|
|
ref={barRef}
|
|
role="menubar"
|
|
aria-label={t("bar_label")}
|
|
data-window-menu-bar=""
|
|
data-window-top-row=""
|
|
onKeyDown={onKeyDown}
|
|
className="fixed inset-x-0 top-0 z-[100002] flex h-8 items-center gap-0.5 border-b border-border bg-background px-1 text-sm text-foreground select-none"
|
|
>
|
|
{list.map((menu, index) => (
|
|
<div key={menu.id} className="relative">
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
aria-haspopup="menu"
|
|
aria-expanded={open === index}
|
|
data-menu-name=""
|
|
data-menu-id={menu.id}
|
|
tabIndex={-1}
|
|
onPointerDown={(event) => {
|
|
if (event.button !== 0) return;
|
|
// The focus stays put until a menu takes it: Cut and Paste need to know where it was.
|
|
event.preventDefault();
|
|
rememberFocus();
|
|
setCue(false);
|
|
if (open === index) deactivate();
|
|
else openMenu(index, "panel");
|
|
}}
|
|
onClick={(event) => {
|
|
// Enter and Space; the pointer was handled on the way down.
|
|
if (event.detail === 0) openMenu(index, "line");
|
|
}}
|
|
onMouseEnter={() => {
|
|
if (open !== null && open !== index) openMenu(index, "panel");
|
|
}}
|
|
className={cn(
|
|
"flex h-7 items-center rounded px-2.5 outline-none hover:bg-muted focus-visible:bg-muted",
|
|
open === index && "bg-muted",
|
|
)}
|
|
>
|
|
<Label text={menu.label} at={nameLetterAt(menu)} cue={cue} />
|
|
</button>
|
|
{open === index && drawsMenus && (
|
|
<div
|
|
role="menu"
|
|
aria-label={menu.label}
|
|
data-menu-panel=""
|
|
tabIndex={-1}
|
|
className="absolute start-0 top-full mt-0.5 min-w-56 max-w-[min(24rem,90vw)] max-h-[calc(100dvh-2.75rem)] overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none"
|
|
>
|
|
{renderLines(openItems, "panel")}
|
|
</div>
|
|
)}
|
|
{open === index && drawsMenus && sub !== null && (
|
|
<div
|
|
role="menu"
|
|
aria-label={openItems[sub]?.label}
|
|
data-menu-subpanel=""
|
|
className="fixed z-10 min-w-44 max-w-[min(24rem,90vw)] max-h-[calc(100dvh-0.5rem)] overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
|
|
style={{ top: -9999, left: -9999 }}
|
|
>
|
|
{renderLines(subItems, "subpanel")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|