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