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,94 @@
|
|
|
1
|
+
// <unmagic-tabs> — tabs that switch panels in the page, rendered by `tabs`.
|
|
2
|
+
//
|
|
3
|
+
// The server renders the whole ARIA tab pattern (roles, aria-controls, the
|
|
4
|
+
// selected tab and the hidden panels), so the element only moves the selection:
|
|
5
|
+
// on a click, or with the arrow keys, Home and End, which also move focus. Only the
|
|
6
|
+
// selected tab is in the tab order.
|
|
7
|
+
//
|
|
8
|
+
// When the element has an id, the chosen tab is remembered in sessionStorage for
|
|
9
|
+
// that page, and re-applied after a morph refresh (which resets the markup to the
|
|
10
|
+
// server's selection). Each change fires unmagic-tabs:change.
|
|
11
|
+
|
|
12
|
+
class UnmagicTabs extends HTMLElement {
|
|
13
|
+
constructor() {
|
|
14
|
+
super()
|
|
15
|
+
this.addEventListener("click", this.#clicked)
|
|
16
|
+
this.addEventListener("keydown", this.#keydown)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
connectedCallback() {
|
|
20
|
+
this.#restore()
|
|
21
|
+
document.addEventListener("turbo:morph", this.#restore)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
disconnectedCallback() {
|
|
25
|
+
document.removeEventListener("turbo:morph", this.#restore)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get tabs() {
|
|
29
|
+
return [...this.querySelectorAll(":scope > [role=tablist] > [role=tab]")]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
select(tab, { focus = false, remember = true } = {}) {
|
|
33
|
+
for (const each of this.tabs) {
|
|
34
|
+
const selected = each === tab
|
|
35
|
+
each.setAttribute("aria-selected", String(selected))
|
|
36
|
+
each.tabIndex = selected ? 0 : -1
|
|
37
|
+
|
|
38
|
+
const panel = document.getElementById(each.getAttribute("aria-controls"))
|
|
39
|
+
if (panel) panel.hidden = !selected
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (focus) tab.focus()
|
|
43
|
+
if (!remember) return
|
|
44
|
+
|
|
45
|
+
const key = this.#storageKey
|
|
46
|
+
if (key) {
|
|
47
|
+
try {
|
|
48
|
+
sessionStorage.setItem(key, tab.id)
|
|
49
|
+
} catch {
|
|
50
|
+
// Storage can be unavailable (a private window, blocked site data); the
|
|
51
|
+
// selection just isn't remembered.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
this.dispatchEvent(new CustomEvent("unmagic-tabs:change", { bubbles: true, detail: { tab } }))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#clicked = (event) => {
|
|
58
|
+
const tab = event.target instanceof Element && event.target.closest("[role=tab]")
|
|
59
|
+
if (tab && this.tabs.includes(tab)) this.select(tab)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#keydown = (event) => {
|
|
63
|
+
const tabs = this.tabs
|
|
64
|
+
const index = tabs.indexOf(event.target)
|
|
65
|
+
if (index === -1) return
|
|
66
|
+
|
|
67
|
+
const targets = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: tabs.length - 1 }
|
|
68
|
+
if (!(event.key in targets)) return
|
|
69
|
+
|
|
70
|
+
event.preventDefault()
|
|
71
|
+
this.select(tabs[(targets[event.key] + tabs.length) % tabs.length], { focus: true })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#restore = () => {
|
|
75
|
+
const key = this.#storageKey
|
|
76
|
+
if (!key) return
|
|
77
|
+
|
|
78
|
+
let id
|
|
79
|
+
try {
|
|
80
|
+
id = sessionStorage.getItem(key)
|
|
81
|
+
} catch {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const tab = id && this.tabs.find((each) => each.id === id)
|
|
86
|
+
if (tab) this.select(tab, { remember: false })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get #storageKey() {
|
|
90
|
+
return this.id ? `unmagic-tabs:${location.pathname}:${this.id}` : null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
customElements.get("unmagic-tabs") || customElements.define("unmagic-tabs", UnmagicTabs)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// <unmagic-time> — a timestamp in the viewer's own locale and time zone, rendered
|
|
2
|
+
// by `local_time_tag`.
|
|
3
|
+
//
|
|
4
|
+
// <unmagic-time datetime="2026-09-16T04:33:24Z" format="relative">
|
|
5
|
+
// <time datetime="2026-09-16T04:33:24Z">about 3 hours ago</time>
|
|
6
|
+
// </unmagic-time>
|
|
7
|
+
//
|
|
8
|
+
// The server's text is the fallback until this upgrades; after that the element
|
|
9
|
+
// writes into its <time> with Intl, so the words come from the browser's locale
|
|
10
|
+
// rather than from anything hand-written here.
|
|
11
|
+
//
|
|
12
|
+
// Attributes:
|
|
13
|
+
// datetime an ISO 8601 timestamp
|
|
14
|
+
// format short | medium | long | full (a date and a time), date, time, or
|
|
15
|
+
// relative. Defaults to medium.
|
|
16
|
+
// compact on a relative time, "5m" rather than "5 minutes ago"
|
|
17
|
+
//
|
|
18
|
+
// A relative time keeps itself current from one shared, minute-aligned clock, and
|
|
19
|
+
// stops ticking once it has settled into a date (a week out).
|
|
20
|
+
|
|
21
|
+
const ABSOLUTE = {
|
|
22
|
+
short: { dateStyle: "short", timeStyle: "short" },
|
|
23
|
+
medium: { dateStyle: "medium", timeStyle: "short" },
|
|
24
|
+
long: { dateStyle: "long", timeStyle: "short" },
|
|
25
|
+
full: { dateStyle: "full", timeStyle: "long" },
|
|
26
|
+
date: { dateStyle: "medium" },
|
|
27
|
+
time: { timeStyle: "short" },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class UnmagicTime extends HTMLElement {
|
|
31
|
+
static observedAttributes = ["datetime", "format", "compact"]
|
|
32
|
+
|
|
33
|
+
#unsubscribe = null
|
|
34
|
+
|
|
35
|
+
connectedCallback() {
|
|
36
|
+
this.#render()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
disconnectedCallback() {
|
|
40
|
+
this.#stopTicking()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
attributeChangedCallback() {
|
|
44
|
+
if (this.isConnected) this.#render()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get #target() {
|
|
48
|
+
return this.querySelector(":scope > time") ?? this
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#render() {
|
|
52
|
+
const date = new Date(this.getAttribute("datetime"))
|
|
53
|
+
if (Number.isNaN(date.getTime())) return
|
|
54
|
+
|
|
55
|
+
const format = this.getAttribute("format") || "medium"
|
|
56
|
+
if (format === "relative") {
|
|
57
|
+
this.#renderRelative(date)
|
|
58
|
+
} else {
|
|
59
|
+
this.#stopTicking()
|
|
60
|
+
this.#target.textContent = formatter(ABSOLUTE[format] ?? ABSOLUTE.medium).format(date)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#renderRelative(date) {
|
|
65
|
+
const { text, live } = relative(date, new Date(), this.hasAttribute("compact"))
|
|
66
|
+
const target = this.#target
|
|
67
|
+
target.textContent = text
|
|
68
|
+
target.title = formatter({ dateStyle: "long", timeStyle: "short" }).format(date)
|
|
69
|
+
|
|
70
|
+
if (live && !this.#unsubscribe) this.#unsubscribe = subscribe(() => this.#render())
|
|
71
|
+
if (!live) this.#stopTicking()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#stopTicking() {
|
|
75
|
+
this.#unsubscribe?.()
|
|
76
|
+
this.#unsubscribe = null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The ladder: under a minute, minutes, hours while it's still the same day,
|
|
81
|
+
// yesterday/tomorrow, days within the week, then a date that no longer changes.
|
|
82
|
+
// Days are calendar days in the viewer's zone, so "yesterday" turns at midnight.
|
|
83
|
+
//
|
|
84
|
+
// Units round to the nearest, not down: a time rendered two hours ahead is a few
|
|
85
|
+
// seconds under two hours by the time this runs, and should still say "in 2 hours".
|
|
86
|
+
function relative(date, now, compact) {
|
|
87
|
+
const seconds = Math.round((date - now) / 1000)
|
|
88
|
+
const sign = Math.sign(seconds) || -1
|
|
89
|
+
const minutes = Math.round(Math.abs(seconds) / 60)
|
|
90
|
+
const hours = Math.round(Math.abs(seconds) / 3600)
|
|
91
|
+
const days = calendarDays(date, now)
|
|
92
|
+
|
|
93
|
+
if (minutes === 0) return { text: phrase(0, "second", compact), live: true }
|
|
94
|
+
if (minutes < 60) return { text: phrase(sign * minutes, "minute", compact), live: true }
|
|
95
|
+
if (days === 0) return { text: phrase(sign * hours, "hour", compact), live: true }
|
|
96
|
+
if (Math.abs(days) <= 6) return { text: phrase(days, "day", compact), live: true }
|
|
97
|
+
|
|
98
|
+
const options = { day: "numeric", month: compact ? "short" : "long" }
|
|
99
|
+
if (date.getFullYear() !== now.getFullYear()) options.year = "numeric"
|
|
100
|
+
return { text: formatter(options).format(date), live: false }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// "now", "5 minutes ago", "yesterday", "in 3 days" — or, compact, "5m", "3h".
|
|
104
|
+
function phrase(value, unit, compact) {
|
|
105
|
+
if (compact && value !== 0) {
|
|
106
|
+
return formatter({ style: "unit", unit, unitDisplay: "narrow" }, Intl.NumberFormat).format(Math.abs(value))
|
|
107
|
+
}
|
|
108
|
+
return formatter({ numeric: "auto", style: compact ? "narrow" : "long" }, Intl.RelativeTimeFormat).format(value, unit)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function calendarDays(date, now) {
|
|
112
|
+
const midnight = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
|
113
|
+
return Math.round((midnight(date) - midnight(now)) / 86_400_000)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Building an Intl formatter is the only real cost here, so each is made once and
|
|
117
|
+
// shared by every element on the page.
|
|
118
|
+
const formatters = new Map()
|
|
119
|
+
function formatter(options, Formatter = Intl.DateTimeFormat) {
|
|
120
|
+
const key = `${Formatter.name}:${JSON.stringify(options)}`
|
|
121
|
+
if (!formatters.has(key)) formatters.set(key, new Formatter(undefined, options))
|
|
122
|
+
return formatters.get(key)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// One timer for every live element, aligned to the wall-clock minute, so a page of
|
|
126
|
+
// relative times rolls over together. It pauses while the tab is hidden and
|
|
127
|
+
// catches up the moment it's seen again.
|
|
128
|
+
const subscribers = new Set()
|
|
129
|
+
let timer = null
|
|
130
|
+
let watchingVisibility = false
|
|
131
|
+
|
|
132
|
+
function subscribe(callback) {
|
|
133
|
+
if (!watchingVisibility) {
|
|
134
|
+
watchingVisibility = true
|
|
135
|
+
document.addEventListener("visibilitychange", () => {
|
|
136
|
+
if (document.hidden) return stop()
|
|
137
|
+
notify()
|
|
138
|
+
schedule()
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
subscribers.add(callback)
|
|
143
|
+
schedule()
|
|
144
|
+
|
|
145
|
+
return () => {
|
|
146
|
+
subscribers.delete(callback)
|
|
147
|
+
if (subscribers.size === 0) stop()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function schedule() {
|
|
152
|
+
if (timer || subscribers.size === 0 || document.hidden) return
|
|
153
|
+
timer = setTimeout(() => {
|
|
154
|
+
timer = null
|
|
155
|
+
notify()
|
|
156
|
+
schedule()
|
|
157
|
+
}, 60_000 - (Date.now() % 60_000))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function stop() {
|
|
161
|
+
clearTimeout(timer)
|
|
162
|
+
timer = null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function notify() {
|
|
166
|
+
for (const callback of [...subscribers]) callback()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
customElements.get("unmagic-time") || customElements.define("unmagic-time", UnmagicTime)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// <unmagic-toasts> — the toasts a layout mounts with `flash_toasts`.
|
|
2
|
+
//
|
|
3
|
+
// Each toast arrives as an inert <template data-unmagic-toast-template> inside the
|
|
4
|
+
// element: rendered with the page, morphed in by a refresh, or appended by
|
|
5
|
+
// turbo_stream.toast. The element clones each one into its stack, animates it in,
|
|
6
|
+
// and dismisses it after the element's `duration` (milliseconds). Hovering or
|
|
7
|
+
// focusing a toast holds it open.
|
|
8
|
+
//
|
|
9
|
+
// The stack is data-turbo-permanent, so a toast on screen survives a Drive visit
|
|
10
|
+
// or a morph. Its timer lives here, in the module, keyed by the toast rather than
|
|
11
|
+
// by the element, because Turbo moves the stack into a fresh <unmagic-toasts> on
|
|
12
|
+
// every visit.
|
|
13
|
+
//
|
|
14
|
+
// The stack is also a manual popover. Showing it puts it in the top layer, where a
|
|
15
|
+
// toast can appear above an open modal dialog; showing it again after a dialog
|
|
16
|
+
// opens lifts it back above. It is only seen there, not touched: a modal dialog
|
|
17
|
+
// makes everything outside it inert, top-layer popovers included, so a toast over
|
|
18
|
+
// one can't be hovered or dismissed until the dialog closes. It still times out.
|
|
19
|
+
|
|
20
|
+
const DURATION = 5000
|
|
21
|
+
const LEAVE = 200
|
|
22
|
+
const MINIMUM_AFTER_HOLD = 1000
|
|
23
|
+
|
|
24
|
+
const timers = new WeakMap()
|
|
25
|
+
|
|
26
|
+
class UnmagicToasts extends HTMLElement {
|
|
27
|
+
#observer = new MutationObserver(() => this.#consume())
|
|
28
|
+
|
|
29
|
+
connectedCallback() {
|
|
30
|
+
this.#observer.observe(this, { childList: true })
|
|
31
|
+
this.addEventListener("click", this.#dismiss)
|
|
32
|
+
this.addEventListener("pointerover", this.#hold)
|
|
33
|
+
this.addEventListener("focusin", this.#hold)
|
|
34
|
+
this.addEventListener("pointerout", this.#release)
|
|
35
|
+
this.addEventListener("focusout", this.#release)
|
|
36
|
+
document.addEventListener("turbo:render", this.#lift)
|
|
37
|
+
|
|
38
|
+
this.stack?.querySelectorAll("[data-unmagic-toast]").forEach((toast) => schedule(toast, this.duration))
|
|
39
|
+
this.#consume()
|
|
40
|
+
this.#lift()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
disconnectedCallback() {
|
|
44
|
+
this.#observer.disconnect()
|
|
45
|
+
this.removeEventListener("click", this.#dismiss)
|
|
46
|
+
this.removeEventListener("pointerover", this.#hold)
|
|
47
|
+
this.removeEventListener("focusin", this.#hold)
|
|
48
|
+
this.removeEventListener("pointerout", this.#release)
|
|
49
|
+
this.removeEventListener("focusout", this.#release)
|
|
50
|
+
document.removeEventListener("turbo:render", this.#lift)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get stack() {
|
|
54
|
+
return this.querySelector(":scope > .UnmagicToasts__stack")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get duration() {
|
|
58
|
+
const duration = Number(this.getAttribute("duration"))
|
|
59
|
+
return duration > 0 ? duration : DURATION
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#consume() {
|
|
63
|
+
const stack = this.stack
|
|
64
|
+
const templates = this.querySelectorAll(":scope > template[data-unmagic-toast-template]")
|
|
65
|
+
if (!stack || templates.length === 0) return
|
|
66
|
+
|
|
67
|
+
for (const template of templates) {
|
|
68
|
+
// Consume the source first, so a later morph or mutation can't pop it twice.
|
|
69
|
+
template.remove()
|
|
70
|
+
|
|
71
|
+
const toast = template.content.firstElementChild?.cloneNode(true)
|
|
72
|
+
if (!toast) continue
|
|
73
|
+
|
|
74
|
+
stack.append(toast)
|
|
75
|
+
toast.getBoundingClientRect() // commit the starting style so the entrance transitions
|
|
76
|
+
toast.setAttribute("data-open", "")
|
|
77
|
+
schedule(toast, this.duration)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.#lift()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#lift = () => {
|
|
84
|
+
const stack = this.stack
|
|
85
|
+
if (!stack?.showPopover) return
|
|
86
|
+
|
|
87
|
+
const open = stack.matches(":popover-open")
|
|
88
|
+
if (!stack.querySelector("[data-unmagic-toast]")) {
|
|
89
|
+
if (open) stack.hidePopover()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (open) stack.hidePopover()
|
|
94
|
+
stack.showPopover()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#dismiss = (event) => {
|
|
98
|
+
const button = event.target instanceof Element && event.target.closest("[data-unmagic-toast-dismiss]")
|
|
99
|
+
if (button) remove(button.closest("[data-unmagic-toast]"))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#hold = (event) => {
|
|
103
|
+
const toast = event.target instanceof Element && event.target.closest("[data-unmagic-toast]")
|
|
104
|
+
if (toast) pause(toast)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// pointerout and focusout also fire when moving between a toast's own children,
|
|
108
|
+
// so wait a frame and ask whether it is still hovered or focused.
|
|
109
|
+
#release = (event) => {
|
|
110
|
+
const toast = event.target instanceof Element && event.target.closest("[data-unmagic-toast]")
|
|
111
|
+
if (!toast) return
|
|
112
|
+
|
|
113
|
+
requestAnimationFrame(() => {
|
|
114
|
+
if (!toast.matches(":hover, :focus-within")) resume(toast)
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function schedule(toast, duration) {
|
|
120
|
+
if (timers.has(toast)) return
|
|
121
|
+
|
|
122
|
+
const entry = { remaining: duration, started: 0, timer: null }
|
|
123
|
+
timers.set(toast, entry)
|
|
124
|
+
start(toast, entry)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function start(toast, entry) {
|
|
128
|
+
entry.started = Date.now()
|
|
129
|
+
entry.timer = setTimeout(() => remove(toast), entry.remaining)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pause(toast) {
|
|
133
|
+
const entry = timers.get(toast)
|
|
134
|
+
if (!entry || entry.timer === null) return
|
|
135
|
+
|
|
136
|
+
clearTimeout(entry.timer)
|
|
137
|
+
entry.timer = null
|
|
138
|
+
entry.remaining -= Date.now() - entry.started
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Give the reader a moment after letting go, even if the time was nearly up.
|
|
142
|
+
function resume(toast) {
|
|
143
|
+
const entry = timers.get(toast)
|
|
144
|
+
if (!entry || entry.timer !== null) return
|
|
145
|
+
|
|
146
|
+
entry.remaining = Math.max(entry.remaining, MINIMUM_AFTER_HOLD)
|
|
147
|
+
start(toast, entry)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function remove(toast) {
|
|
151
|
+
if (!toast || toast.hasAttribute("data-leaving")) return
|
|
152
|
+
|
|
153
|
+
const entry = timers.get(toast)
|
|
154
|
+
if (entry?.timer) clearTimeout(entry.timer)
|
|
155
|
+
timers.delete(toast)
|
|
156
|
+
|
|
157
|
+
toast.setAttribute("data-leaving", "")
|
|
158
|
+
toast.removeAttribute("data-open")
|
|
159
|
+
|
|
160
|
+
const stack = toast.parentElement
|
|
161
|
+
const leave = matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : LEAVE
|
|
162
|
+
|
|
163
|
+
setTimeout(() => {
|
|
164
|
+
toast.remove()
|
|
165
|
+
if (stack?.hidePopover && stack.matches(":popover-open") && !stack.querySelector("[data-unmagic-toast]")) {
|
|
166
|
+
stack.hidePopover()
|
|
167
|
+
}
|
|
168
|
+
}, leave)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
customElements.get("unmagic-toasts") || customElements.define("unmagic-toasts", UnmagicToasts)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// <unmagic-tooltip text="…"> — a hint shown on hover or focus, rendered by
|
|
2
|
+
// `tooltip`.
|
|
3
|
+
//
|
|
4
|
+
// The hint is a manual popover, so it renders in the top layer and no ancestor's
|
|
5
|
+
// overflow can clip it. It is placed with fixed, viewport coordinates each time it
|
|
6
|
+
// opens: on the preferred side (placement="top" or "bottom"), flipped when there
|
|
7
|
+
// isn't room, and clamped so it never runs off the sides.
|
|
8
|
+
//
|
|
9
|
+
// If the content has its own focusable element (a button, a link) the hint
|
|
10
|
+
// describes that element; otherwise the element itself becomes focusable, so a
|
|
11
|
+
// keyboard user can reach the explanation. Escape dismisses it.
|
|
12
|
+
|
|
13
|
+
const GAP = 8
|
|
14
|
+
const MARGIN = 8
|
|
15
|
+
const DELAY = 150
|
|
16
|
+
const FOCUSABLE = "a[href], button, input, select, textarea, [tabindex]:not([tabindex='-1'])"
|
|
17
|
+
|
|
18
|
+
let sequence = 0
|
|
19
|
+
|
|
20
|
+
class UnmagicTooltip extends HTMLElement {
|
|
21
|
+
static observedAttributes = ["text"]
|
|
22
|
+
|
|
23
|
+
#popup = null
|
|
24
|
+
#pending = null
|
|
25
|
+
#open = false
|
|
26
|
+
|
|
27
|
+
constructor() {
|
|
28
|
+
super()
|
|
29
|
+
// On the element itself, once, so moving it (a Turbo cache restore, a permanent
|
|
30
|
+
// element) never doubles them up.
|
|
31
|
+
this.addEventListener("pointerenter", () => this.#schedule())
|
|
32
|
+
this.addEventListener("pointerleave", () => this.#hide())
|
|
33
|
+
this.addEventListener("focusin", () => this.#schedule(0))
|
|
34
|
+
this.addEventListener("focusout", () => this.#hide())
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
connectedCallback() {
|
|
38
|
+
if (this.#popup) return
|
|
39
|
+
|
|
40
|
+
// A snapshot Turbo restores is a clone that already holds a popup, but not
|
|
41
|
+
// this instance's wiring. Start again from the content.
|
|
42
|
+
this.querySelectorAll(":scope > .UnmagicTooltip__popup").forEach((popup) => popup.remove())
|
|
43
|
+
|
|
44
|
+
this.#popup = document.createElement("span")
|
|
45
|
+
this.#popup.className = "UnmagicTooltip__popup"
|
|
46
|
+
this.#popup.id = `unmagic_tooltip_${++sequence}`
|
|
47
|
+
this.#popup.setAttribute("role", "tooltip")
|
|
48
|
+
this.#popup.setAttribute("popover", "manual")
|
|
49
|
+
this.#popup.textContent = this.getAttribute("text") ?? ""
|
|
50
|
+
this.append(this.#popup)
|
|
51
|
+
|
|
52
|
+
const trigger = this.querySelector(FOCUSABLE)
|
|
53
|
+
if (trigger) {
|
|
54
|
+
trigger.setAttribute("aria-describedby", this.#popup.id)
|
|
55
|
+
} else {
|
|
56
|
+
this.tabIndex = 0
|
|
57
|
+
this.setAttribute("aria-describedby", this.#popup.id)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
disconnectedCallback() {
|
|
62
|
+
this.#hide()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
attributeChangedCallback(_name, _old, text) {
|
|
66
|
+
if (this.#popup) this.#popup.textContent = text ?? ""
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#schedule(delay = DELAY) {
|
|
70
|
+
clearTimeout(this.#pending)
|
|
71
|
+
this.#pending = setTimeout(() => this.#show(), delay)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#show() {
|
|
75
|
+
const popup = this.#popup
|
|
76
|
+
if (this.#open || !popup?.showPopover || !popup.textContent) return
|
|
77
|
+
|
|
78
|
+
this.#open = true
|
|
79
|
+
popup.showPopover()
|
|
80
|
+
this.#position()
|
|
81
|
+
popup.setAttribute("data-open", "")
|
|
82
|
+
|
|
83
|
+
window.addEventListener("scroll", this.#reposition, { passive: true, capture: true })
|
|
84
|
+
window.addEventListener("resize", this.#reposition, { passive: true })
|
|
85
|
+
document.addEventListener("keydown", this.#escape)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#hide() {
|
|
89
|
+
clearTimeout(this.#pending)
|
|
90
|
+
if (!this.#open) return
|
|
91
|
+
|
|
92
|
+
this.#open = false
|
|
93
|
+
this.#popup.removeAttribute("data-open")
|
|
94
|
+
this.#popup.hidePopover()
|
|
95
|
+
|
|
96
|
+
window.removeEventListener("scroll", this.#reposition, { capture: true })
|
|
97
|
+
window.removeEventListener("resize", this.#reposition)
|
|
98
|
+
document.removeEventListener("keydown", this.#escape)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#escape = (event) => {
|
|
102
|
+
if (event.key === "Escape") this.#hide()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#reposition = () => {
|
|
106
|
+
if (this.#open) this.#position()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#position() {
|
|
110
|
+
const anchor = this.getBoundingClientRect()
|
|
111
|
+
const popup = this.#popup.getBoundingClientRect()
|
|
112
|
+
const viewportWidth = document.documentElement.clientWidth
|
|
113
|
+
const viewportHeight = document.documentElement.clientHeight
|
|
114
|
+
|
|
115
|
+
const above = anchor.top - popup.height - GAP
|
|
116
|
+
const below = anchor.bottom + GAP
|
|
117
|
+
const fitsAbove = above >= MARGIN
|
|
118
|
+
const fitsBelow = below + popup.height <= viewportHeight - MARGIN
|
|
119
|
+
|
|
120
|
+
const top = this.getAttribute("placement") === "bottom"
|
|
121
|
+
? (fitsBelow || !fitsAbove ? below : above)
|
|
122
|
+
: (fitsAbove || !fitsBelow ? above : below)
|
|
123
|
+
|
|
124
|
+
const centred = anchor.left + anchor.width / 2 - popup.width / 2
|
|
125
|
+
const left = Math.max(MARGIN, Math.min(centred, viewportWidth - popup.width - MARGIN))
|
|
126
|
+
|
|
127
|
+
this.#popup.style.top = `${top}px`
|
|
128
|
+
this.#popup.style.left = `${left}px`
|
|
129
|
+
this.#popup.dataset.side = top === above ? "top" : "bottom"
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
customElements.get("unmagic-tooltip") || customElements.define("unmagic-tooltip", UnmagicTooltip)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// <unmagic-uuid-input name="message[id]"> — a hidden field holding a fresh UUIDv7,
|
|
2
|
+
// rendered by `uuid_field`.
|
|
3
|
+
//
|
|
4
|
+
// A form can then submit an id the client already knows, for instance to match an
|
|
5
|
+
// optimistically rendered element to the record the server goes on to create
|
|
6
|
+
// under the same id.
|
|
7
|
+
//
|
|
8
|
+
// A new id is minted when the element upgrades, replacing the server's fallback.
|
|
9
|
+
// That also covers a page Turbo restores from its cache, whose id may already have
|
|
10
|
+
// been used. Another is minted every time the form is reset, so a form that clears
|
|
11
|
+
// itself after each submit sends a new one next time.
|
|
12
|
+
|
|
13
|
+
class UnmagicUuidInput extends HTMLElement {
|
|
14
|
+
#form = null
|
|
15
|
+
|
|
16
|
+
connectedCallback() {
|
|
17
|
+
this.#input.value = uuidv7()
|
|
18
|
+
this.#form = this.closest("form")
|
|
19
|
+
this.#form?.addEventListener("reset", this.#reset)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
disconnectedCallback() {
|
|
23
|
+
this.#form?.removeEventListener("reset", this.#reset)
|
|
24
|
+
this.#form = null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get value() {
|
|
28
|
+
return this.#input.value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The server renders the input; one written by hand might not have.
|
|
32
|
+
get #input() {
|
|
33
|
+
let input = this.querySelector(":scope > input[type=hidden]")
|
|
34
|
+
if (!input) {
|
|
35
|
+
input = document.createElement("input")
|
|
36
|
+
input.type = "hidden"
|
|
37
|
+
input.name = this.getAttribute("name") || "id"
|
|
38
|
+
this.append(input)
|
|
39
|
+
}
|
|
40
|
+
return input
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// reset fires before the form resets its fields; mint on the microtask after,
|
|
44
|
+
// once the reset can no longer touch the value.
|
|
45
|
+
#reset = () => {
|
|
46
|
+
queueMicrotask(() => {
|
|
47
|
+
this.#input.value = uuidv7()
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A time-ordered UUIDv7: a 48-bit millisecond timestamp, then random bits. It sorts
|
|
53
|
+
// alongside the server's SecureRandom.uuid_v7 and can be used as a record's id.
|
|
54
|
+
export function uuidv7() {
|
|
55
|
+
const bytes = new Uint8Array(16)
|
|
56
|
+
crypto.getRandomValues(bytes)
|
|
57
|
+
|
|
58
|
+
let time = Date.now()
|
|
59
|
+
for (let i = 5; i >= 0; i--) {
|
|
60
|
+
bytes[i] = time % 256
|
|
61
|
+
time = Math.floor(time / 256)
|
|
62
|
+
}
|
|
63
|
+
bytes[6] = 0x70 | (bytes[6] & 0x0f) // version 7
|
|
64
|
+
bytes[8] = 0x80 | (bytes[8] & 0x3f) // variant 10
|
|
65
|
+
|
|
66
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")
|
|
67
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
customElements.get("unmagic-uuid-input") || customElements.define("unmagic-uuid-input", UnmagicUuidInput)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Every component's behaviour in one import:
|
|
2
|
+
//
|
|
3
|
+
// import "unmagic/components"
|
|
4
|
+
//
|
|
5
|
+
// This also installs the confirm dialog in place of window.confirm, and it needs
|
|
6
|
+
// Turbo. An app that wants only some components, or has no Turbo, imports those
|
|
7
|
+
// modules by name instead (import "unmagic/components/tooltip").
|
|
8
|
+
import "unmagic/components/upsert"
|
|
9
|
+
import "unmagic/components/dialog"
|
|
10
|
+
import "unmagic/components/modal"
|
|
11
|
+
import "unmagic/components/confirm"
|
|
12
|
+
import "unmagic/components/toasts"
|
|
13
|
+
import "unmagic/components/time"
|
|
14
|
+
import "unmagic/components/tooltip"
|
|
15
|
+
import "unmagic/components/menu"
|
|
16
|
+
import "unmagic/components/tabs"
|
|
17
|
+
import "unmagic/components/clipboard"
|
|
18
|
+
import "unmagic/components/autogrow"
|
|
19
|
+
import "unmagic/components/uuid_input"
|