Files
AgentHub/client/src/hooks/useDialog.ts
T
nntrivi2001 8546748759 chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage
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.
2026-10-26 14:22:41 +07:00

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]);
}