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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -1
- data/README.md +493 -1
- data/app/assets/javascripts/unmagic/components/autogrow.js +74 -0
- data/app/assets/javascripts/unmagic/components/clipboard.js +63 -0
- data/app/assets/javascripts/unmagic/components/confirm.js +102 -0
- data/app/assets/javascripts/unmagic/components/dialog.js +53 -0
- data/app/assets/javascripts/unmagic/components/menu.js +139 -0
- data/app/assets/javascripts/unmagic/components/modal.js +182 -0
- data/app/assets/javascripts/unmagic/components/tabs.js +94 -0
- data/app/assets/javascripts/unmagic/components/time.js +169 -0
- data/app/assets/javascripts/unmagic/components/toasts.js +171 -0
- data/app/assets/javascripts/unmagic/components/tooltip.js +133 -0
- data/app/assets/javascripts/unmagic/components/uuid_input.js +70 -0
- data/app/assets/javascripts/unmagic/components.js +19 -0
- data/app/assets/stylesheets/unmagic/components.css +927 -0
- data/config/importmap.rb +5 -1
- data/lib/unmagic/components/action_view_helpers.rb +373 -0
- data/lib/unmagic/components/autogrow.rb +13 -0
- data/lib/unmagic/components/badge.rb +21 -0
- data/lib/unmagic/components/button.rb +28 -0
- data/lib/unmagic/components/callout.rb +60 -0
- data/lib/unmagic/components/card.rb +67 -0
- data/lib/unmagic/components/configuration.rb +19 -1
- data/lib/unmagic/components/confirm_template.rb +46 -0
- data/lib/unmagic/components/copy_button.rb +50 -0
- data/lib/unmagic/components/detail_list.rb +13 -3
- data/lib/unmagic/components/dialog.rb +79 -0
- data/lib/unmagic/components/dialog_responder.rb +46 -0
- data/lib/unmagic/components/engine.rb +13 -4
- data/lib/unmagic/components/form_builder.rb +24 -0
- data/lib/unmagic/components/icons.rb +40 -0
- data/lib/unmagic/components/local_time.rb +64 -0
- data/lib/unmagic/components/menu.rb +82 -0
- data/lib/unmagic/components/modal.rb +75 -0
- data/lib/unmagic/components/page_header.rb +96 -0
- data/lib/unmagic/components/skeleton.rb +97 -0
- data/lib/unmagic/components/tabs.rb +95 -0
- data/lib/unmagic/components/toast.rb +54 -0
- data/lib/unmagic/components/toasts.rb +46 -0
- data/lib/unmagic/components/tooltip.rb +32 -0
- data/lib/unmagic/components/turbo_stream_actions.rb +19 -0
- data/lib/unmagic/components/uuid_input.rb +24 -0
- data/lib/unmagic/components/version.rb +1 -1
- data/lib/unmagic/components.rb +21 -0
- 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)
|