Files
ihasmail/web/src/ui/dialog.tsx
T
jcoffey-dev 0fdcbbc778 Fix two things live testing on 0.15.5 turned up
**About said "not detected".** Generation was only worked out from the reply to
a registry method, which we never send to a server that does not advertise
urn:stalwart:jmap — every 0.16 build does, and nothing older knows the
capability at all, so its absence is already the answer. Say so, instead of
shrugging. A session with no capabilities at all stays unknown, which is a
different thing from old.

**The caret jumped out of the OTP field after one digit.** Dialog's autofocus
effect listed onClose in its dependencies, and every caller passes an inline
arrow, so each keystroke in a dialog holding state tore the effect down, set it
up again, and refocused the first field — which in the disable-2FA dialog is
the password. Keep the handler in a ref so the effect depends only on `open`.
This was a bug in the shared dialog rather than in one screen; every dialog
with more than one field had it.

The test for it fails against the old dependency array, not just passes
against the new one.
2026-08-24 09:04:16 -07:00

163 lines
5.5 KiB
TypeScript

import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { create } from "zustand";
interface DialogProps {
open: boolean;
onClose: () => void;
title?: ReactNode;
children: ReactNode;
footer?: ReactNode;
size?: "sm" | "md" | "lg" | "xl";
closeOnBackdrop?: boolean;
className?: string;
}
export function Dialog({ open, onClose, title, children, footer, size = "md", closeOnBackdrop = true, className }: DialogProps) {
const ref = useRef<HTMLDivElement>(null);
/*
* Callers almost always pass an inline arrow for onClose, so its identity
* changes on every render of the parent. Depending on it here would tear the
* effect down and set it up again on every keystroke in a dialog that holds
* state, and the autofocus below would drag the caret back to the first
* field mid-typing. Keep the latest handler in a ref instead, so the effect
* depends only on `open`.
*/
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!open) return;
const prev = document.activeElement as HTMLElement | null;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onCloseRef.current();
}
if (e.key === "Tab" && ref.current) {
const focusables = ref.current.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]');
if (!focusables.length) return;
const first = focusables[0]!;
const last = focusables[focusables.length - 1]!;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", onKey, true);
// autofocus first input
window.setTimeout(() => {
const el = ref.current?.querySelector<HTMLElement>("[autofocus],input,textarea,select,button.btn-primary");
el?.focus();
}, 10);
return () => {
document.removeEventListener("keydown", onKey, true);
prev?.focus?.();
};
}, [open]);
if (!open) return null;
return createPortal(
<div
className="dialog-backdrop"
onMouseDown={(e) => {
if (closeOnBackdrop && e.target === e.currentTarget) onClose();
}}
>
<div className={`dialog ${size} ${className ?? ""}`} role="dialog" aria-modal="true" ref={ref}>
{title !== undefined && (
<div className="dialog-head">
<h2>{title}</h2>
<button className="icon-btn" onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
)}
<div className="dialog-body">{children}</div>
{footer && <div className="dialog-foot">{footer}</div>}
</div>
</div>,
document.body,
);
}
/* ---------- Imperative confirm / prompt ---------- */
interface ConfirmRequest {
id: number;
kind: "confirm" | "prompt";
title: string;
message?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
defaultValue?: string;
placeholder?: string;
resolve: (v: boolean | string | null) => void;
}
const useConfirmStore = create<{ queue: ConfirmRequest[]; push(r: ConfirmRequest): void; pop(): void }>((set, get) => ({
queue: [],
push: (r) => set({ queue: [...get().queue, r] }),
pop: () => set({ queue: get().queue.slice(1) }),
}));
let reqId = 1;
export function confirmDialog(opts: { title: string; message?: ReactNode; confirmLabel?: string; cancelLabel?: string; danger?: boolean }): Promise<boolean> {
return new Promise((resolve) => {
useConfirmStore.getState().push({ id: reqId++, kind: "confirm", ...opts, resolve: (v) => resolve(Boolean(v)) });
});
}
export function promptDialog(opts: { title: string; message?: ReactNode; defaultValue?: string; placeholder?: string; confirmLabel?: string }): Promise<string | null> {
return new Promise((resolve) => {
useConfirmStore.getState().push({ id: reqId++, kind: "prompt", ...opts, resolve: (v) => resolve(typeof v === "string" ? v : null) });
});
}
export function ConfirmHost() {
const req = useConfirmStore((s) => s.queue[0]);
const pop = useConfirmStore((s) => s.pop);
const [value, setValue] = useState("");
useEffect(() => setValue(req?.defaultValue ?? ""), [req?.id, req?.defaultValue]);
if (!req) return null;
const done = (v: boolean | string | null) => {
req.resolve(v);
pop();
};
return (
<Dialog
open
onClose={() => done(req.kind === "prompt" ? null : false)}
title={req.title}
size="sm"
footer={
<>
<button className="btn" onClick={() => done(req.kind === "prompt" ? null : false)}>
{req.cancelLabel ?? "Cancel"}
</button>
<button className={`btn ${req.danger ? "btn-danger" : "btn-primary"}`} onClick={() => done(req.kind === "prompt" ? value : true)}>
{req.confirmLabel ?? (req.kind === "prompt" ? "OK" : "Confirm")}
</button>
</>
}
>
{req.message && <p style={{ marginTop: 0 }}>{req.message}</p>}
{req.kind === "prompt" && (
<form
onSubmit={(e) => {
e.preventDefault();
done(value);
}}
>
<input className="input" autoFocus value={value} placeholder={req.placeholder} onChange={(e) => setValue(e.target.value)} />
</form>
)}
</Dialog>
);
}