1import { createSignal, type JSX, onMount, Show } from "solid-js";
2import { render } from "solid-js/web";
3import { reason } from "../api.ts";
4
5type Content = JSX.Element | (() => JSX.Element);
6const build = (content: Content) => (typeof content === "function" ? content() : content);
7
8interface DialogOptions {
9 title: string;
10 /**
11 * The consequence, read out with the title. Pass a function when it reads signals or uses control flow, so it's
12 * built inside the dialog rather than in the click handler, which has no owner.
13 */
14 description?: Content;
15 /** Form fields and options under the description (and text field), built inside the dialog like `description`. */
16 body?: Content;
17 /** Names the action, like "restart"; never "ok". */
18 confirmLabel: string;
19 destructive?: boolean;
20 canConfirm?: () => boolean;
21 /** Takes focus once the dialog closes, instead of the element that opened it. */
22 returnFocus?: HTMLElement;
23 /**
24 * Gets the body's named fields. The dialog stays open with a spinner until this settles; a rejection shakes it
25 * and shows the reason.
26 */
27 onConfirm: (form: FormData) => unknown;
28}
29
30interface TextDialogOptions extends Omit<DialogOptions, "onConfirm"> {
31 /** Visible label for the field. */
32 label: string;
33 placeholder?: string;
34 initialValue?: string;
35 /** Gates the confirm button; defaults to non-empty. */
36 validateInput?: (value: string) => boolean;
37 onConfirm: (value: string, form: FormData) => unknown;
38}
39
40const SHAKE = [0, -8, 8, -8, 8, -8, 8, 0].map((x) => ({ transform: `translateX(${x}px)` }));
41const reduced = matchMedia("(prefers-reduced-motion: reduce)");
42let dialogs = 0;
43
44/**
45 * Enter confirms and Esc, the backdrop, and "back" cancel. Confirm starts focused unless the body has an
46 * `autofocus` field; the browser checks the body's constraints (`required`, `pattern`…) before `onConfirm` runs.
47 */
48export const showConfirmDialog = (options: DialogOptions) => open(options);
49
50/** Like showConfirmDialog with a text field; Enter submits once `validateInput` passes. */
51export const showTextDialog = (options: TextDialogOptions) =>
52 open({ ...options, onConfirm: (form) => options.onConfirm(String(form.get("value")), form) }, options);
53
54function open(options: DialogOptions, field?: TextDialogOptions) {
55 const host = document.body.appendChild(document.createElement("div"));
56 const id = `dialog-${++dialogs}`;
57 const dispose = render(() => {
58 const [value, setValue] = createSignal(field?.initialValue ?? "");
59 const [pending, setPending] = createSignal(false);
60 const [error, setError] = createSignal("");
61 const valid = () => (!field || (field.validateInput ?? Boolean)(value())) && (options.canConfirm?.() ?? true);
62 let dialog!: HTMLDialogElement;
63 let pressedOutside = false;
64
65 const close = () => {
66 if (pending()) return;
67 dialog.classList.add("closing");
68 setTimeout(() => dialog.close(), reduced.matches ? 0 : 140);
69 };
70
71 const submit = async (event: SubmitEvent) => {
72 event.preventDefault();
73 if (pending() || !valid()) return;
74 const form = new FormData(event.currentTarget as HTMLFormElement);
75 setPending(true);
76 setError("");
77 try {
78 await options.onConfirm(form);
79 setPending(false);
80 close();
81 } catch (failure) {
82 setPending(false);
83 setError(reason(failure));
84 if (!reduced.matches) dialog.animate(SHAKE, { duration: 380, easing: "cubic-bezier(0.36, 0.07, 0.19, 0.97)" });
85 }
86 };
87
88 onMount(() => dialog.showModal());
89 const body = build(options.body);
90
91 return (
92 <dialog ref={dialog} class="dialog" aria-labelledby={`${id}-title`}
93 aria-describedby={options.description ? `${id}-description` : undefined}
94 onCancel={(event) => {
95 event.preventDefault();
96 close();
97 }}
98 onClose={() => {
99 dispose();
100 host.remove();
101 options.returnFocus?.focus();
102 }}
103 onPointerDown={(event) => (pressedOutside = event.target === dialog)}
104 onClick={(event) => pressedOutside && event.target === dialog && close()}>
105 <form onSubmit={submit}>
106 <h2 id={`${id}-title`}>{options.title}</h2>
107 <Show when={options.description}>
108 <div id={`${id}-description`} class="description">{build(options.description)}</div>
109 </Show>
110 <Show when={field}>
111 {(field) => (
112 <label class="field">
113 {field().label}
114 <input name="value" class="search" value={value()} placeholder={field().placeholder} autofocus
115 readOnly={pending()} autocomplete="off" spellcheck={false}
116 onInput={(event) => setValue(event.currentTarget.value)} />
117 </label>
118 )}
119 </Show>
120 <Show when={options.body}><fieldset disabled={pending()}>{body}</fieldset></Show>
121 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
122 <div class="actions">
123 <button type="button" class="button" disabled={pending()} onClick={close}>back<kbd aria-hidden="true">esc</kbd></button>
124 {/* aria-disabled rather than disabled while pending, so focus stays put for a retry. */}
125 <button type="submit" class="button primary" classList={{ danger: options.destructive }}
126 autofocus={!field} disabled={!valid()} aria-disabled={pending()} aria-busy={pending()}>
127 {options.confirmLabel}<kbd aria-hidden="true">enter</kbd>
128 </button>
129 </div>
130 </form>
131 </dialog>
132 );
133 }, host);
134}