8fb703c6db
- Contrast: --fg-muted reaches 4.5:1 on every surface in all nine themes (dark default was 2.55:1); status chips and accent text clear 4.5:1 on their own tints. New --accent-solid(/-hover) + tailwind `accent-solid` is the fill behind white text (.btn-primary, Switch on, Checkbox tick); --accent is now a text colour. Switch off / unchecked Checkbox get a >=3:1 edge. - <html lang> follows the chosen language; a global prefers-reduced-motion rule; index.html body uses a token instead of text-gray-200. - Layout: the sidebar starts collapsed below lg and becomes an off-canvas drawer below md (menu button, backdrop, Escape, useDialog). - ContextMenu: focus moves in, arrows/Home/End, Escape/Tab close, focus returns, a keyboard-opened menu anchors to its target. Select is a real listbox button (aria-expanded, activedescendant, type-ahead). PageTabs implements APG tabs with a tabpanel. New .btn-secondary/.btn-danger/ .btn-icon and a .select class for native selects, all with focus rings. - StatusBar announces connection and "N need you" politely; Sidebar's connection modal uses useDialog; EmptyState/Skeleton/AiToolIcon a11y. - api.ts request(): a non-JSON 200 throws a translated EBADRESPONSE instead of "Unexpected token '<'".
193 lines
6.6 KiB
TypeScript
193 lines
6.6 KiB
TypeScript
/**
|
|
* @file ContextMenu — the one shell every right-click (and `⋯`) menu opens
|
|
* in: a fixed list at a viewport point, rendered into `document.body` so no
|
|
* transformed or clipped ancestor moves it, nudged back inside the viewport
|
|
* once measured, closed by Escape, a press outside it, or after an item runs.
|
|
* `useContextMenu` keeps "which item, where" for a list of rows; `menuPoint`
|
|
* turns a mouse event into that point (and stops the browser's own menu).
|
|
*
|
|
* Keyboard (WAI-ARIA menu): the first enabled item takes focus on open;
|
|
* Up/Down wrap, Home/End jump, Escape and Tab close, and focus goes back to
|
|
* what held it before the menu opened — unless an item moved it elsewhere
|
|
* (a rename field, a dialog). A menu opened from the keyboard (Shift+F10 or
|
|
* the Menu key reports clientX/Y 0) anchors under the element instead of the
|
|
* viewport's corner.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
|
|
|
|
export type MenuPoint = { x: number; y: number };
|
|
|
|
/** The pointer of a right-click, the browser's own menu suppressed. A
|
|
* keyboard-opened menu has no pointer, so it anchors under the element. */
|
|
export function menuPoint(e: ReactMouseEvent): MenuPoint {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.clientX === 0 && e.clientY === 0 && e.currentTarget instanceof Element) {
|
|
const r = e.currentTarget.getBoundingClientRect();
|
|
return { x: r.left, y: r.bottom };
|
|
}
|
|
return { x: e.clientX, y: e.clientY };
|
|
}
|
|
|
|
/** Menu state for a list of rows: which row's menu is open, and where. */
|
|
export function useContextMenu<T>() {
|
|
const [menu, setMenu] = useState<(MenuPoint & { item: T }) | null>(null);
|
|
const open = useCallback((e: ReactMouseEvent, item: T) => {
|
|
setMenu({ ...menuPoint(e), item });
|
|
}, []);
|
|
const close = useCallback(() => setMenu(null), []);
|
|
return { menu, open, close };
|
|
}
|
|
|
|
const MARGIN = 8;
|
|
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
|
const ITEMS = '[role="menuitem"]:not([disabled]):not([aria-disabled="true"])';
|
|
|
|
export function ContextMenu({
|
|
x,
|
|
y,
|
|
onClose,
|
|
testId,
|
|
className = "w-56",
|
|
children,
|
|
}: MenuPoint & {
|
|
onClose: () => void;
|
|
testId?: string;
|
|
className?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [pos, setPos] = useState({ left: x, top: y });
|
|
|
|
// Focus in on open, back out on close. Runs once per mount: a menu that
|
|
// moves to a new point keeps its focus.
|
|
useEffect(() => {
|
|
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
ref.current?.querySelector<HTMLElement>(ITEMS)?.focus({ preventScroll: true });
|
|
return () => {
|
|
// Only when focus would otherwise be lost (it sat on a removed item).
|
|
const now = document.activeElement;
|
|
if (!now || now === document.body || ref.current?.contains(now)) previous?.focus?.();
|
|
};
|
|
}, []);
|
|
|
|
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
const inField = (e.target as HTMLElement).closest(
|
|
"input, textarea, select, [contenteditable='true']"
|
|
);
|
|
if (e.key === "Escape" || (e.key === "Tab" && !inField)) {
|
|
// Handled here so a dialog behind the menu does not close as well.
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onClose();
|
|
return;
|
|
}
|
|
// A field inside a menu (a size slider, a search box) keeps its own keys.
|
|
if (inField) return;
|
|
const items = [...(ref.current?.querySelectorAll<HTMLElement>(ITEMS) ?? [])];
|
|
if (items.length === 0) return;
|
|
const i = items.indexOf(document.activeElement as HTMLElement);
|
|
const next =
|
|
e.key === "ArrowDown"
|
|
? (i + 1) % items.length
|
|
: e.key === "ArrowUp"
|
|
? (i - 1 + items.length) % items.length
|
|
: e.key === "Home"
|
|
? 0
|
|
: e.key === "End"
|
|
? items.length - 1
|
|
: -1;
|
|
if (next < 0) return;
|
|
e.preventDefault();
|
|
items[next]!.focus();
|
|
};
|
|
|
|
useLayoutEffect(() => {
|
|
const r = ref.current?.getBoundingClientRect();
|
|
if (!r) return;
|
|
setPos({
|
|
left: Math.max(MARGIN, Math.min(x, window.innerWidth - r.width - MARGIN)),
|
|
top: Math.max(MARGIN, Math.min(y, window.innerHeight - r.height - MARGIN)),
|
|
});
|
|
}, [x, y]);
|
|
|
|
useEffect(() => {
|
|
const onDown = (e: MouseEvent) => {
|
|
if (!ref.current?.contains(e.target as Node)) onClose();
|
|
};
|
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
|
document.addEventListener("mousedown", onDown);
|
|
document.addEventListener("keydown", onKey);
|
|
window.addEventListener("blur", onClose);
|
|
window.addEventListener("resize", onClose);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onDown);
|
|
document.removeEventListener("keydown", onKey);
|
|
window.removeEventListener("blur", onClose);
|
|
window.removeEventListener("resize", onClose);
|
|
};
|
|
}, [onClose]);
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={ref}
|
|
role="menu"
|
|
data-testid={testId}
|
|
style={pos}
|
|
// A portal still bubbles React events to the component that rendered
|
|
// it — a menu opened from a clickable card must not click the card too.
|
|
onClick={stop}
|
|
onDoubleClick={stop}
|
|
onMouseDown={stop}
|
|
onPointerDown={stop}
|
|
onKeyDown={onKeyDown}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
className={`fixed z-50 rounded-lg border border-border bg-surface-2 p-1 shadow-lg ${className}`}
|
|
>
|
|
{children}
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
export const MENU_ITEM =
|
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary focus:outline-none focus-visible:bg-surface-4 focus-visible:text-fg-primary focus-visible:ring-1 focus-visible:ring-accent/60 disabled:pointer-events-none disabled:opacity-50";
|
|
|
|
export function MenuItem({
|
|
onSelect,
|
|
danger,
|
|
disabled,
|
|
testId,
|
|
children,
|
|
}: {
|
|
onSelect: () => void;
|
|
danger?: boolean;
|
|
disabled?: boolean;
|
|
testId?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid={testId}
|
|
disabled={disabled}
|
|
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
|
|
onClick={onSelect}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function MenuSeparator() {
|
|
return <div role="separator" className="my-1 border-t border-border" />;
|
|
}
|