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,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"