**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.
163 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|