| 1 | import { createSignal, type JSX, onMount, Show } from "solid-js"; |
| 2 | import { render } from "solid-js/web"; |
| 3 | import { reason } from "../api.ts"; |
| 4 | |
| 5 | type Content = JSX.Element | (() => JSX.Element); |
| 6 | const build = (content: Content) => (typeof content === "function" ? content() : content); |
| 7 | |
| 8 | interface 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 | |
| 29 | interface 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 | |
| 39 | const SHAKE = [0, -8, 8, -8, 8, -8, 8, 0].map((x) => ({ transform: `translateX(${x}px)` })); |
| 40 | const reduced = matchMedia("(prefers-reduced-motion: reduce)"); |
| 41 | let 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 | */ |
| 47 | export const showConfirmDialog = (options: DialogOptions) => open(options); |
| 48 | |
| 49 | /** Like showConfirmDialog with a text field; Enter submits once `validateInput` passes. */ |
| 50 | export const showTextDialog = (options: TextDialogOptions) => |
| 51 | open({ ...options, onConfirm: (form) => options.onConfirm(String(form.get("value")), form) }, options); |
| 52 | |
| 53 | function 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 | } |