8546748759
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
rule for sendFile/download, alerts ack route without inline regex),
better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
TypeScript 7, i18next/react-i18next/codemirror minors.
Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.
Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
and gates the router by allowlist; /profiles* and /plugins/update-all stay
admin-only. Member writes go through write-as, CLI calls through sudo as the
member (never -y), reads through openSafe; cc_config_changed reaches only
its owner. Not verified live on an S3 install yet.
Other
- Chat: an HTML file is an artifact only when written whole and not a project
page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
86 lines
3.2 KiB
TypeScript
86 lines
3.2 KiB
TypeScript
/**
|
|
* @file useDialog.ts
|
|
* @description The one modal focus contract, shared by every dialog: focus
|
|
* moves in on open (the `initialFocus` element, else the panel's first
|
|
* focusable), Tab and Shift+Tab cycle inside the panel, Escape calls `onClose`,
|
|
* and focus returns to whatever held it before the dialog opened. Extracted
|
|
* from `ConfirmModal` so the other modals stop re-implementing (or skipping) it.
|
|
* Dialogs STACK: only the most recently opened one answers Escape and Tab, so
|
|
* a confirm opened over the sidebar drawer closes alone and traps focus alone.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useRef, type RefObject } from "react";
|
|
|
|
/** Open dialogs, oldest first; only the last one handles keys. */
|
|
const stack: object[] = [];
|
|
|
|
const FOCUSABLE =
|
|
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
|
|
/**
|
|
* Wire focus-in, a Tab trap, Escape and focus restore to a dialog panel.
|
|
* @param open Whether the dialog is shown.
|
|
* @param panelRef The dialog panel (the element carrying `role="dialog"`).
|
|
* @param onClose Called on Escape. Pass `undefined` for a dialog Escape must not close.
|
|
* @param initialFocus Element to focus on open; defaults to the first focusable.
|
|
*/
|
|
export function useDialog(
|
|
open: boolean,
|
|
panelRef: RefObject<HTMLElement | null>,
|
|
onClose?: () => void,
|
|
initialFocus?: RefObject<HTMLElement | null>
|
|
): void {
|
|
// Read through a ref so an inline `onClose` does not re-run the effect, which
|
|
// would steal focus back to the first control on every parent render.
|
|
const closeRef = useRef(onClose);
|
|
closeRef.current = onClose;
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const token = {};
|
|
stack.push(token);
|
|
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
const timer = window.setTimeout(() => {
|
|
const panel = panelRef.current;
|
|
if (!panel || panel.contains(document.activeElement)) return;
|
|
(initialFocus?.current ?? panel.querySelector<HTMLElement>(FOCUSABLE) ?? panel).focus();
|
|
}, 0);
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
const panel = panelRef.current;
|
|
if (!panel || stack[stack.length - 1] !== token) return;
|
|
if (e.key === "Escape" && closeRef.current) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
closeRef.current();
|
|
return;
|
|
}
|
|
if (e.key !== "Tab") return;
|
|
const items = panel.querySelectorAll<HTMLElement>(FOCUSABLE);
|
|
if (items.length === 0) return;
|
|
const first = items[0]!;
|
|
const last = items[items.length - 1]!;
|
|
if (
|
|
e.shiftKey &&
|
|
(document.activeElement === first || !panel.contains(document.activeElement))
|
|
) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
} else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
document.removeEventListener("keydown", onKey);
|
|
stack.splice(stack.indexOf(token), 1);
|
|
previous?.focus?.();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- refs are stable
|
|
}, [open]);
|
|
}
|