unmagic-components 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/README.md +493 -1
  4. data/app/assets/javascripts/unmagic/components/autogrow.js +74 -0
  5. data/app/assets/javascripts/unmagic/components/clipboard.js +63 -0
  6. data/app/assets/javascripts/unmagic/components/confirm.js +102 -0
  7. data/app/assets/javascripts/unmagic/components/dialog.js +53 -0
  8. data/app/assets/javascripts/unmagic/components/menu.js +139 -0
  9. data/app/assets/javascripts/unmagic/components/modal.js +182 -0
  10. data/app/assets/javascripts/unmagic/components/tabs.js +94 -0
  11. data/app/assets/javascripts/unmagic/components/time.js +169 -0
  12. data/app/assets/javascripts/unmagic/components/toasts.js +171 -0
  13. data/app/assets/javascripts/unmagic/components/tooltip.js +133 -0
  14. data/app/assets/javascripts/unmagic/components/uuid_input.js +70 -0
  15. data/app/assets/javascripts/unmagic/components.js +19 -0
  16. data/app/assets/stylesheets/unmagic/components.css +927 -0
  17. data/config/importmap.rb +5 -1
  18. data/lib/unmagic/components/action_view_helpers.rb +373 -0
  19. data/lib/unmagic/components/autogrow.rb +13 -0
  20. data/lib/unmagic/components/badge.rb +21 -0
  21. data/lib/unmagic/components/button.rb +28 -0
  22. data/lib/unmagic/components/callout.rb +60 -0
  23. data/lib/unmagic/components/card.rb +67 -0
  24. data/lib/unmagic/components/configuration.rb +19 -1
  25. data/lib/unmagic/components/confirm_template.rb +46 -0
  26. data/lib/unmagic/components/copy_button.rb +50 -0
  27. data/lib/unmagic/components/detail_list.rb +13 -3
  28. data/lib/unmagic/components/dialog.rb +79 -0
  29. data/lib/unmagic/components/dialog_responder.rb +46 -0
  30. data/lib/unmagic/components/engine.rb +13 -4
  31. data/lib/unmagic/components/form_builder.rb +24 -0
  32. data/lib/unmagic/components/icons.rb +40 -0
  33. data/lib/unmagic/components/local_time.rb +64 -0
  34. data/lib/unmagic/components/menu.rb +82 -0
  35. data/lib/unmagic/components/modal.rb +75 -0
  36. data/lib/unmagic/components/page_header.rb +96 -0
  37. data/lib/unmagic/components/skeleton.rb +97 -0
  38. data/lib/unmagic/components/tabs.rb +95 -0
  39. data/lib/unmagic/components/toast.rb +54 -0
  40. data/lib/unmagic/components/toasts.rb +46 -0
  41. data/lib/unmagic/components/tooltip.rb +32 -0
  42. data/lib/unmagic/components/turbo_stream_actions.rb +19 -0
  43. data/lib/unmagic/components/uuid_input.rb +24 -0
  44. data/lib/unmagic/components/version.rb +1 -1
  45. data/lib/unmagic/components.rb +21 -0
  46. metadata +42 -7
@@ -0,0 +1,74 @@
1
+ // <unmagic-autogrow> — a textarea that grows to fit what's typed, rendered by
2
+ // `autogrow_text_area`.
3
+ //
4
+ // It grows from the height its rows give it up to its CSS max-height, and only
5
+ // then shows a scrollbar. CSS `field-sizing: content` would do the growing on its
6
+ // own, but it ignores rows, so an empty box would collapse to a single line;
7
+ // measuring keeps rows as the floor.
8
+ //
9
+ // It re-measures as you type, when the form is reset, and when the box changes
10
+ // width (a narrower box wraps onto more lines). Call resize() after setting the
11
+ // value from script.
12
+
13
+ class UnmagicAutogrow extends HTMLElement {
14
+ #form = null
15
+ #width = 0
16
+ #observer = new ResizeObserver(([entry]) => {
17
+ const width = Math.round(entry.contentRect.width)
18
+ if (width === this.#width) return
19
+ this.#width = width
20
+ this.resize()
21
+ })
22
+
23
+ constructor() {
24
+ super()
25
+ this.addEventListener("input", (event) => {
26
+ if (event.target === this.textarea) this.resize()
27
+ })
28
+ }
29
+
30
+ connectedCallback() {
31
+ this.resize()
32
+ this.#observer.observe(this)
33
+ this.#form = this.textarea?.form ?? null
34
+ this.#form?.addEventListener("reset", this.#reset)
35
+ }
36
+
37
+ disconnectedCallback() {
38
+ this.#observer.disconnect()
39
+ this.#form?.removeEventListener("reset", this.#reset)
40
+ this.#form = null
41
+ }
42
+
43
+ get textarea() {
44
+ return this.querySelector("textarea")
45
+ }
46
+
47
+ resize() {
48
+ const textarea = this.textarea
49
+ if (!textarea) return
50
+
51
+ // Collapse to the rows-given height first, so the measurement can shrink too.
52
+ textarea.style.height = "auto"
53
+
54
+ const style = getComputedStyle(textarea)
55
+ const sum = (a, b) => parseFloat(style[a]) + parseFloat(style[b])
56
+
57
+ // scrollHeight is the content plus padding. The height to set is in the box's
58
+ // own sizing model, which max-height is in too.
59
+ const full = style.boxSizing === "border-box"
60
+ ? textarea.scrollHeight + sum("borderTopWidth", "borderBottomWidth")
61
+ : textarea.scrollHeight - sum("paddingTop", "paddingBottom")
62
+ const max = parseFloat(style.maxHeight) || Infinity
63
+
64
+ textarea.style.height = `${Math.min(full, max)}px`
65
+ textarea.style.overflowY = full > max ? "auto" : "hidden"
66
+ }
67
+
68
+ // reset fires before the form clears its fields, so measure on the next frame.
69
+ #reset = () => {
70
+ requestAnimationFrame(() => this.resize())
71
+ }
72
+ }
73
+
74
+ customElements.get("unmagic-autogrow") || customElements.define("unmagic-autogrow", UnmagicAutogrow)
@@ -0,0 +1,63 @@
1
+ // <unmagic-clipboard> — copies text when its button is clicked, rendered by
2
+ // `copy_button`.
3
+ //
4
+ // value="…" the text to copy
5
+ // for="ID" or copy, at the moment of the click, the value of that input or the
6
+ // text of that element
7
+ //
8
+ // After a copy the element carries data-copied for a moment, which the CSS uses to
9
+ // swap the copy icon for a check, and its live region says "Copied" for a screen
10
+ // reader. It fires unmagic-clipboard:copy, or unmagic-clipboard:error when the
11
+ // browser refuses the write (no permission, or an insecure page).
12
+
13
+ const CONFIRM_FOR = 1500
14
+
15
+ class UnmagicClipboard extends HTMLElement {
16
+ #timer = null
17
+
18
+ constructor() {
19
+ super()
20
+ this.addEventListener("click", (event) => {
21
+ if (event.target instanceof Element && event.target.closest("button")) this.copy()
22
+ })
23
+ }
24
+
25
+ disconnectedCallback() {
26
+ clearTimeout(this.#timer)
27
+ }
28
+
29
+ get text() {
30
+ const id = this.getAttribute("for")
31
+ if (!id) return this.getAttribute("value") ?? ""
32
+
33
+ const source = document.getElementById(id)
34
+ if (!source) return ""
35
+
36
+ const control = source instanceof HTMLInputElement || source instanceof HTMLTextAreaElement || source instanceof HTMLSelectElement
37
+ return control ? source.value : source.textContent
38
+ }
39
+
40
+ async copy() {
41
+ const text = this.text
42
+
43
+ try {
44
+ await navigator.clipboard.writeText(text)
45
+ } catch (error) {
46
+ this.dispatchEvent(new CustomEvent("unmagic-clipboard:error", { bubbles: true, detail: { error } }))
47
+ return
48
+ }
49
+
50
+ const live = this.querySelector(":scope > [aria-live]")
51
+ this.setAttribute("data-copied", "")
52
+ if (live) live.textContent = this.dataset.copiedLabel || "Copied"
53
+ this.dispatchEvent(new CustomEvent("unmagic-clipboard:copy", { bubbles: true, detail: { text } }))
54
+
55
+ clearTimeout(this.#timer)
56
+ this.#timer = setTimeout(() => {
57
+ this.removeAttribute("data-copied")
58
+ if (live) live.textContent = ""
59
+ }, CONFIRM_FOR)
60
+ }
61
+ }
62
+
63
+ customElements.get("unmagic-clipboard") || customElements.define("unmagic-clipboard", UnmagicClipboard)
@@ -0,0 +1,102 @@
1
+ // Replaces window.confirm for data-turbo-confirm with a dialog in the components'
2
+ // own chrome. Importing this module installs it.
3
+ //
4
+ // <%= button_to "Delete", label_path(label), method: :delete,
5
+ // form: { data: { turbo_confirm: "Delete this label?",
6
+ // turbo_confirm_accept: "Delete",
7
+ // turbo_confirm_variant: "danger" } } %>
8
+ //
9
+ // Optional attributes, read from the submitter first and then the form:
10
+ // data-turbo-confirm-title the heading (default "Are you sure?")
11
+ // data-turbo-confirm-accept the confirming button's label (default "Confirm")
12
+ // data-turbo-confirm-variant "danger" styles that button as destructive and
13
+ // focuses Cancel instead
14
+ //
15
+ // When a link carries data-turbo-method, Turbo builds a form for it and copies only
16
+ // data-turbo-confirm onto that form. Use button_to when you need the other
17
+ // attributes.
18
+ //
19
+ // The words come from the page's `confirm_dialog_template`, so they can be
20
+ // translated. Without one they fall back to the English defaults below.
21
+
22
+ import { Turbo } from "@hotwired/turbo-rails"
23
+ import "unmagic/components/dialog"
24
+
25
+ const FALLBACK = `
26
+ <dialog class="UnmagicDialogBox" role="alertdialog" data-unmagic-dialog>
27
+ <form method="dialog" class="UnmagicDialog">
28
+ <header class="UnmagicDialog__header">
29
+ <h2 class="UnmagicDialog__title" data-unmagic-confirm-title>Are you sure?</h2>
30
+ </header>
31
+ <div class="UnmagicDialog__body"><p data-unmagic-confirm-message></p></div>
32
+ <div class="UnmagicDialog__footer">
33
+ <button value="cancel" class="UnmagicButton" data-unmagic-confirm-cancel>Cancel</button>
34
+ <button value="confirm" class="UnmagicButton UnmagicButton--primary" data-unmagic-confirm-accept>Confirm</button>
35
+ </div>
36
+ </form>
37
+ </dialog>`
38
+
39
+ let sequence = 0
40
+
41
+ export function confirm(message, form, submitter) {
42
+ const option = (name) =>
43
+ submitter?.getAttribute?.(`data-turbo-confirm-${name}`) ?? form?.getAttribute?.(`data-turbo-confirm-${name}`)
44
+
45
+ const dialog = build()
46
+ const id = `unmagic_confirm_${++sequence}`
47
+ const title = dialog.querySelector("[data-unmagic-confirm-title]")
48
+ const body = dialog.querySelector("[data-unmagic-confirm-message]")
49
+ const accept = dialog.querySelector("[data-unmagic-confirm-accept]")
50
+ const cancel = dialog.querySelector("[data-unmagic-confirm-cancel]")
51
+
52
+ title.id = `${id}_title`
53
+ body.id = `${id}_message`
54
+ dialog.setAttribute("aria-labelledby", title.id)
55
+ dialog.setAttribute("aria-describedby", body.id)
56
+
57
+ body.textContent = message
58
+ if (option("title")) title.textContent = option("title")
59
+ if (option("accept")) accept.textContent = option("accept")
60
+
61
+ // A destructive action shouldn't be one Enter away.
62
+ if (option("variant") === "danger") {
63
+ accept.classList.replace("UnmagicButton--primary", "UnmagicButton--danger")
64
+ cancel.autofocus = true
65
+ } else {
66
+ accept.autofocus = true
67
+ }
68
+
69
+ document.body.append(dialog)
70
+ dialog.showModal()
71
+
72
+ return new Promise((resolve) => {
73
+ dialog.addEventListener(
74
+ "close",
75
+ () => {
76
+ resolve(dialog.returnValue === "confirm")
77
+ dialog.remove()
78
+ },
79
+ { once: true },
80
+ )
81
+ })
82
+ }
83
+
84
+ function build() {
85
+ const template = document.querySelector("template[data-unmagic-confirm]") ?? fallbackTemplate()
86
+ return template.content.firstElementChild.cloneNode(true)
87
+ }
88
+
89
+ let fallback
90
+ function fallbackTemplate() {
91
+ if (!fallback) {
92
+ fallback = document.createElement("template")
93
+ fallback.innerHTML = FALLBACK.trim()
94
+ }
95
+ return fallback
96
+ }
97
+
98
+ if (Turbo.config?.forms) {
99
+ Turbo.config.forms.confirm = confirm
100
+ } else {
101
+ Turbo.setConfirmMethod(confirm)
102
+ }
@@ -0,0 +1,53 @@
1
+ // Behaviour every native <dialog> the components render shares. It is wired once,
2
+ // by delegation from the document, so a dialog that is streamed or morphed in later
3
+ // needs no setup of its own:
4
+ //
5
+ // [data-unmagic-dialog-open="ID"] opens that dialog modally
6
+ // [data-unmagic-dialog-close] closes the dialog it sits in
7
+ // the backdrop of a dialog[data-unmagic-dialog] closes it on click
8
+ //
9
+ // A backdrop click only counts when the press also started on the backdrop.
10
+ // Selecting text in an input and letting go past the panel's edge fires a click on
11
+ // the dialog too, and that shouldn't throw the form away.
12
+ //
13
+ // Escape needs nothing here; a modal <dialog> closes on it natively.
14
+
15
+ const installed = Symbol.for("unmagic-components.dialog")
16
+
17
+ if (!globalThis[installed]) {
18
+ globalThis[installed] = true
19
+
20
+ let pressed = null
21
+ document.addEventListener("pointerdown", (event) => (pressed = event.target), true)
22
+
23
+ document.addEventListener("click", (event) => {
24
+ const target = event.target
25
+ if (!(target instanceof Element)) return
26
+
27
+ const opener = target.closest("[data-unmagic-dialog-open]")
28
+ if (opener) {
29
+ const dialog = document.getElementById(opener.getAttribute("data-unmagic-dialog-open"))
30
+ if (dialog instanceof HTMLDialogElement) {
31
+ event.preventDefault()
32
+ if (!dialog.open) dialog.showModal()
33
+ }
34
+ return
35
+ }
36
+
37
+ const closer = target.closest("[data-unmagic-dialog-close]")
38
+ if (closer) {
39
+ closer.closest("dialog")?.close()
40
+ return
41
+ }
42
+
43
+ if (target instanceof HTMLDialogElement && target.matches("[data-unmagic-dialog]") && pressed === target) {
44
+ target.close()
45
+ }
46
+ })
47
+
48
+ // An open dialog must not be in the snapshot Turbo restores on Back: it would
49
+ // come back open but not modal, sitting in the page with no backdrop.
50
+ document.addEventListener("turbo:before-cache", () => {
51
+ document.querySelectorAll("dialog[data-unmagic-dialog][open]").forEach((dialog) => dialog.close())
52
+ })
53
+ }
@@ -0,0 +1,139 @@
1
+ // <unmagic-menu> — a dropdown of actions, rendered by `menu`.
2
+ //
3
+ // The markup is a native <details>, so the trigger works before this script
4
+ // upgrades it. The element adds what <details> lacks:
5
+ // - closing on an outside press, on Escape, on choosing an item, and before
6
+ // Turbo caches the page (a restored snapshot would otherwise come back open
7
+ // and with none of this wiring)
8
+ // - arrow keys, Home and End between items
9
+ // - focusing the first item when the menu is opened from the keyboard
10
+
11
+ const ITEMS = "[role=menuitem]:not([disabled]):not([aria-disabled=true])"
12
+
13
+ class UnmagicMenu extends HTMLElement {
14
+ #openedFromKeyboard = false
15
+
16
+ constructor() {
17
+ super()
18
+ // toggle doesn't bubble, so listen on the way down.
19
+ this.addEventListener("toggle", this.#toggled, true)
20
+ this.addEventListener("keydown", this.#keydown)
21
+ this.addEventListener("click", this.#clicked)
22
+ }
23
+
24
+ connectedCallback() {
25
+ this.close()
26
+ document.addEventListener("turbo:before-cache", this.#beforeCache)
27
+ }
28
+
29
+ disconnectedCallback() {
30
+ document.removeEventListener("turbo:before-cache", this.#beforeCache)
31
+ this.#unbind()
32
+ }
33
+
34
+ get details() {
35
+ return this.querySelector(":scope > details")
36
+ }
37
+
38
+ get summary() {
39
+ return this.details?.querySelector(":scope > summary")
40
+ }
41
+
42
+ get items() {
43
+ return [...(this.details?.querySelectorAll(ITEMS) ?? [])]
44
+ }
45
+
46
+ close({ focus = false } = {}) {
47
+ const details = this.details
48
+ if (!details?.open) return
49
+
50
+ details.open = false
51
+ if (focus) this.summary?.focus()
52
+ }
53
+
54
+ #toggled = (event) => {
55
+ if (event.target !== this.details) return
56
+
57
+ if (this.details.open) {
58
+ document.addEventListener("pointerdown", this.#outside, true)
59
+ document.addEventListener("keydown", this.#escape)
60
+ if (this.#openedFromKeyboard) this.items[0]?.focus()
61
+ } else {
62
+ this.#unbind()
63
+ }
64
+ this.#openedFromKeyboard = false
65
+ }
66
+
67
+ #keydown = (event) => {
68
+ const details = this.details
69
+ if (!details) return
70
+
71
+ if (event.target === this.summary && !details.open) {
72
+ if (event.key === "Enter" || event.key === " ") this.#openedFromKeyboard = true
73
+ if (event.key === "ArrowDown") {
74
+ event.preventDefault()
75
+ this.#openedFromKeyboard = true
76
+ details.open = true
77
+ }
78
+ return
79
+ }
80
+
81
+ if (!details.open) return
82
+
83
+ switch (event.key) {
84
+ case "ArrowDown":
85
+ event.preventDefault()
86
+ this.#move(1)
87
+ break
88
+ case "ArrowUp":
89
+ event.preventDefault()
90
+ this.#move(-1)
91
+ break
92
+ case "Home":
93
+ event.preventDefault()
94
+ this.items[0]?.focus()
95
+ break
96
+ case "End":
97
+ event.preventDefault()
98
+ this.items.at(-1)?.focus()
99
+ break
100
+ case "Tab":
101
+ this.close()
102
+ break
103
+ }
104
+ }
105
+
106
+ #move(step) {
107
+ const items = this.items
108
+ if (items.length === 0) return
109
+
110
+ const index = items.indexOf(document.activeElement)
111
+ const next = index === -1 ? (step > 0 ? 0 : items.length - 1) : (index + step + items.length) % items.length
112
+ items[next].focus()
113
+ }
114
+
115
+ // Close after the click has done its job: closing during the click would hide a
116
+ // button_to's form before the browser submits it.
117
+ #clicked = (event) => {
118
+ if (event.target instanceof Element && event.target.closest(ITEMS)) setTimeout(() => this.close())
119
+ }
120
+
121
+ #outside = (event) => {
122
+ if (!this.contains(event.target)) this.close()
123
+ }
124
+
125
+ #escape = (event) => {
126
+ if (event.key === "Escape") this.close({ focus: true })
127
+ }
128
+
129
+ #beforeCache = () => {
130
+ this.close()
131
+ }
132
+
133
+ #unbind() {
134
+ document.removeEventListener("pointerdown", this.#outside, true)
135
+ document.removeEventListener("keydown", this.#escape)
136
+ }
137
+ }
138
+
139
+ customElements.get("unmagic-menu") || customElements.define("unmagic-menu", UnmagicMenu)
@@ -0,0 +1,182 @@
1
+ // <unmagic-modal> — the shared modal a layout mounts with `modal_frame`. It wraps a
2
+ // <dialog> holding the turbo frame that modal links load into.
3
+ //
4
+ // The dialog opens when the frame starts fetching, not when the response lands, so a
5
+ // slow form shows a skeleton instead of nothing. The GET check keeps the form's own
6
+ // submit from flashing the skeleton again. The prefetch check ignores Turbo's hover
7
+ // prefetch, which is also a GET on the frame; without it, merely pointing at a modal
8
+ // link would open the dialog.
9
+ //
10
+ // A failed load swaps in the error template rather than leaving the skeleton
11
+ // stranded or letting Turbo print "Content missing". Three failures are caught:
12
+ // - a network error (turbo:fetch-request-error)
13
+ // - an error status whose body lacks the frame (turbo:frame-missing)
14
+ // - an error status with an empty body, which Turbo otherwise ignores
15
+ // (turbo:before-fetch-response)
16
+ // The error's retry button reloads the frame, which shows the skeleton again.
17
+ //
18
+ // A successful submit doesn't close the dialog straight away. The save usually
19
+ // answers with a turbo_stream.refresh (see DialogResponder), and closing at once
20
+ // would show the stale page for as long as that refresh takes. Instead the dialog
21
+ // stays over the page and closes at turbo:before-render, the same render in which
22
+ // the morph replaces the page, so the page repaints once. Until then the submit
23
+ // button stays disabled and keeps its "Saving…" label. Other responses:
24
+ // - a stream that doesn't refresh: the dialog closes as soon as it is read
25
+ // - a redirect to a page without the frame: the modal visits that page and
26
+ // closes as it renders
27
+ // - a response that renders into the frame (the next step of a wizard): the
28
+ // dialog stays open
29
+
30
+ import "unmagic/components/dialog"
31
+
32
+ class UnmagicModal extends HTMLElement {
33
+ #pendingClose = false
34
+ #loadingFrame = false
35
+
36
+ connectedCallback() {
37
+ this.addEventListener("turbo:before-fetch-request", this.#loading)
38
+ this.addEventListener("turbo:before-fetch-response", this.#response)
39
+ this.addEventListener("turbo:frame-load", this.#loaded)
40
+ this.addEventListener("turbo:frame-missing", this.#missing)
41
+ this.addEventListener("turbo:fetch-request-error", this.#failed)
42
+ this.addEventListener("turbo:submit-end", this.#submitted)
43
+ this.addEventListener("click", this.#retry)
44
+ // close doesn't bubble, so listen on the way down.
45
+ this.addEventListener("close", this.#reset, true)
46
+ document.addEventListener("turbo:before-render", this.#beforeRender)
47
+ }
48
+
49
+ disconnectedCallback() {
50
+ this.removeEventListener("turbo:before-fetch-request", this.#loading)
51
+ this.removeEventListener("turbo:before-fetch-response", this.#response)
52
+ this.removeEventListener("turbo:frame-load", this.#loaded)
53
+ this.removeEventListener("turbo:frame-missing", this.#missing)
54
+ this.removeEventListener("turbo:fetch-request-error", this.#failed)
55
+ this.removeEventListener("turbo:submit-end", this.#submitted)
56
+ this.removeEventListener("click", this.#retry)
57
+ this.removeEventListener("close", this.#reset, true)
58
+ document.removeEventListener("turbo:before-render", this.#beforeRender)
59
+ }
60
+
61
+ get dialog() {
62
+ return this.querySelector(":scope > dialog")
63
+ }
64
+
65
+ get frame() {
66
+ return this.dialog?.querySelector(":scope > turbo-frame")
67
+ }
68
+
69
+ close() {
70
+ this.dialog?.close()
71
+ }
72
+
73
+ #loading = (event) => {
74
+ // Only the frame's own navigation. A GET form inside the dialog (a filter) is
75
+ // targeted at the form and shouldn't blank the dialog with a skeleton.
76
+ if (event.target !== this.frame) return
77
+
78
+ const fetchOptions = event.detail?.fetchOptions
79
+ if (fetchOptions?.method?.toLowerCase() !== "get") return
80
+ if (fetchOptions.headers?.["X-Sec-Purpose"] === "prefetch") return
81
+
82
+ this.#loadingFrame = true
83
+ this.#fill("skeleton")
84
+ if (!this.dialog.open) this.dialog.showModal()
85
+ }
86
+
87
+ #response = (event) => {
88
+ if (this.#loadingFrame && !event.detail?.fetchResponse?.succeeded) this.#fail()
89
+ }
90
+
91
+ #loaded = () => {
92
+ this.#loadingFrame = false
93
+ this.#label()
94
+ }
95
+
96
+ #missing = (event) => {
97
+ if (!this.dialog.open) return
98
+ event.preventDefault()
99
+
100
+ const { response, visit } = event.detail
101
+ if (response.ok) {
102
+ this.#pendingClose = true
103
+ visit(response)
104
+ } else {
105
+ this.#fail()
106
+ }
107
+ }
108
+
109
+ #failed = () => {
110
+ if (this.dialog.open) this.#fail()
111
+ }
112
+
113
+ #fail() {
114
+ this.#loadingFrame = false
115
+ this.#fill("error")
116
+ }
117
+
118
+ #submitted = async (event) => {
119
+ const { success, fetchResponse, formSubmission } = event.detail
120
+ if (!success || !fetchResponse?.contentType?.includes("turbo-stream")) return
121
+
122
+ this.#pendingClose = true
123
+ this.#holdSubmitter(formSubmission?.submitter)
124
+
125
+ const html = await fetchResponse.responseHTML
126
+ if (this.#pendingClose && !/<turbo-stream[^>]*action="refresh"/.test(html ?? "")) this.close()
127
+ }
128
+
129
+ // Turbo has just re-enabled the submitter and restored its idle label. Keep it
130
+ // disabled, still saying "Saving…", until the dialog actually closes.
131
+ #holdSubmitter(submitter) {
132
+ if (!submitter) return
133
+
134
+ submitter.setAttribute("disabled", "")
135
+ const submitting = submitter.getAttribute("data-turbo-submits-with")
136
+ if (!submitting) return
137
+
138
+ if (submitter.matches("input")) submitter.value = submitting
139
+ else submitter.textContent = submitting
140
+ }
141
+
142
+ #beforeRender = () => {
143
+ if (this.#pendingClose) this.close()
144
+ }
145
+
146
+ #retry = (event) => {
147
+ if (!(event.target instanceof Element) || !event.target.closest("[data-unmagic-modal-retry]")) return
148
+ if (this.frame.getAttribute("src")) this.frame.reload()
149
+ }
150
+
151
+ #reset = (event) => {
152
+ if (event.target !== this.dialog) return
153
+
154
+ // close is dispatched a task after dialog.close(). If a modal link reopened
155
+ // the dialog in between, its load is already under way; resetting now would
156
+ // abort that fetch and leave the dialog open and blank.
157
+ if (this.dialog.open) return
158
+
159
+ this.#pendingClose = false
160
+ this.#loadingFrame = false
161
+ this.frame.removeAttribute("src")
162
+ this.frame.replaceChildren()
163
+ this.dialog.removeAttribute("aria-labelledby")
164
+ }
165
+
166
+ #fill(name) {
167
+ const template = this.dialog.querySelector(`:scope > template[data-unmagic-modal-${name}]`)
168
+ if (!template) return
169
+
170
+ this.frame.replaceChildren(template.content.cloneNode(true))
171
+ this.#label()
172
+ }
173
+
174
+ // Name the dialog after whatever panel title it is showing.
175
+ #label() {
176
+ const title = this.frame.querySelector(".UnmagicDialog__title[id]")
177
+ if (title) this.dialog.setAttribute("aria-labelledby", title.id)
178
+ else this.dialog.removeAttribute("aria-labelledby")
179
+ }
180
+ }
181
+
182
+ customElements.get("unmagic-modal") || customElements.define("unmagic-modal", UnmagicModal)