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/** Destructive confirmations focus back; fields with autofocus take precedence. */
45export const showConfirmDialog = (options: DialogOptions) => open(options);
46
47/** Like showConfirmDialog with a text field; Enter submits once `validateInput` passes. */
48export const showTextDialog = (options: TextDialogOptions) =>
49 open({ ...options, onConfirm: (form) => options.onConfirm(String(form.get("value")), form) }, options);
50
51function open(options: DialogOptions, field?: TextDialogOptions) {
52 const host = document.body.appendChild(document.createElement("div"));
53 const id = `dialog-${++dialogs}`;
54 const dispose = render(() => {
55 const [value, setValue] = createSignal(field?.initialValue ?? "");
56 const [pending, setPending] = createSignal(false);
57 const [error, setError] = createSignal("");
58 const valid = () => (!field || (field.validateInput ?? Boolean)(value())) && (options.canConfirm?.() ?? true);
59 let dialog!: HTMLDialogElement;
60 let pressedOutside = false;
61
62 const close = () => {
63 if (pending()) return;
64 dialog.classList.add("closing");
65 setTimeout(() => dialog.close(), reduced.matches ? 0 : 140);
66 };
67
68 const submit = async (event: SubmitEvent) => {
69 event.preventDefault();
70 if (pending() || !valid()) return;
71 const form = new FormData(event.currentTarget as HTMLFormElement);
72 setPending(true);
73 setError("");
74 try {
75 await options.onConfirm(form);
76 setPending(false);
77 close();
78 } catch (failure) {
79 setPending(false);
80 setError(reason(failure));
81 if (!reduced.matches) dialog.animate(SHAKE, { duration: 380, easing: "cubic-bezier(0.36, 0.07, 0.19, 0.97)" });
82 }
83 };
84
85 onMount(() => dialog.showModal());
86 const body = build(options.body);
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 <Show when={options.body}><fieldset disabled={pending()}>{body}</fieldset></Show>
118 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
119 <div class="actions">
120 <button type="button" class="button" disabled={pending()} autofocus={options.destructive} 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 && !options.destructive} 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}