lostcoords-ui 0.4.1

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 (93) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +352 -0
  4. data/app/assets/tailwind/lostcoords/ui/avatar.css +11 -0
  5. data/app/assets/tailwind/lostcoords/ui/card.css +26 -0
  6. data/app/assets/tailwind/lostcoords/ui/copy-button.css +33 -0
  7. data/app/assets/tailwind/lostcoords/ui/dialog.css +60 -0
  8. data/app/assets/tailwind/lostcoords/ui/disclosure.css +43 -0
  9. data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +462 -0
  10. data/app/assets/tailwind/lostcoords/ui/field.css +6 -0
  11. data/app/assets/tailwind/lostcoords/ui/glass.css +37 -0
  12. data/app/assets/tailwind/lostcoords/ui/layout.css +5 -0
  13. data/app/assets/tailwind/lostcoords/ui/popover.css +75 -0
  14. data/app/assets/tailwind/lostcoords/ui/reading.css +60 -0
  15. data/app/assets/tailwind/lostcoords/ui/sheet.css +170 -0
  16. data/app/assets/tailwind/lostcoords/ui/squircle.css +32 -0
  17. data/app/assets/tailwind/lostcoords/ui/theme.css +255 -0
  18. data/app/assets/tailwind/lostcoords/ui/typography.css +81 -0
  19. data/app/assets/tailwind/lostcoords/ui/ui.css +21 -0
  20. data/app/assets/tailwind/lostcoords/ui/utilities.css +45 -0
  21. data/app/assets/tailwind/lostcoords_ui/engine.css +1 -0
  22. data/app/helpers/lostcoords/ui/badge_helper.rb +162 -0
  23. data/app/helpers/lostcoords/ui/button_helper.rb +70 -0
  24. data/app/helpers/lostcoords/ui/card_helper.rb +60 -0
  25. data/app/helpers/lostcoords/ui/copy_button_helper.rb +40 -0
  26. data/app/helpers/lostcoords/ui/dialog_helper.rb +33 -0
  27. data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +245 -0
  28. data/app/helpers/lostcoords/ui/feedback_helper.rb +30 -0
  29. data/app/helpers/lostcoords/ui/form_helper.rb +93 -0
  30. data/app/helpers/lostcoords/ui/glass_helper.rb +25 -0
  31. data/app/helpers/lostcoords/ui/icon_helper.rb +15 -0
  32. data/app/helpers/lostcoords/ui/layout_helper.rb +60 -0
  33. data/app/helpers/lostcoords/ui/menu_helper.rb +41 -0
  34. data/app/helpers/lostcoords/ui/primitives_helper.rb +152 -0
  35. data/app/helpers/lostcoords/ui/reading_helper.rb +15 -0
  36. data/app/helpers/lostcoords/ui/render_attributes_helper.rb +46 -0
  37. data/app/helpers/lostcoords/ui/sheet_helper.rb +54 -0
  38. data/app/helpers/lostcoords/ui/structures_helper.rb +159 -0
  39. data/app/helpers/lostcoords/ui/tailwind_helper.rb +14 -0
  40. data/app/helpers/lostcoords/ui/translation_helper.rb +13 -0
  41. data/app/helpers/lostcoords/ui/typography_helper.rb +28 -0
  42. data/app/javascript/lostcoords/ui/controllers/confirm_dialog_controller.js +39 -0
  43. data/app/javascript/lostcoords/ui/controllers/disclosure_controller.js +163 -0
  44. data/app/javascript/lostcoords/ui/controllers/emoji_picker_controller.js +366 -0
  45. data/app/javascript/lostcoords/ui/controllers/modal_controller.js +27 -0
  46. data/app/javascript/lostcoords/ui/controllers/row_menu_controller.js +83 -0
  47. data/app/javascript/lostcoords/ui/controllers/sheet_controller.js +422 -0
  48. data/app/javascript/lostcoords/ui/controllers/toast_controller.js +69 -0
  49. data/app/javascript/lostcoords/ui/controllers/ui_copy_controller.js +56 -0
  50. data/app/javascript/lostcoords/ui/controllers/ui_popover_controller.js +17 -0
  51. data/app/javascript/lostcoords/ui/controllers/ui_selection_controller.js +69 -0
  52. data/app/javascript/lostcoords/ui/controllers/ui_toggle_controller.js +13 -0
  53. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +455 -0
  54. data/app/javascript/lostcoords/ui/helpers/context_dialog.js +29 -0
  55. data/app/javascript/lostcoords/ui/helpers/dialog.js +31 -0
  56. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +155 -0
  57. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +108 -0
  58. data/app/javascript/lostcoords/ui/helpers/menu_keys.js +27 -0
  59. data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +13 -0
  60. data/app/javascript/lostcoords/ui/helpers/top_layer.js +102 -0
  61. data/app/javascript/lostcoords/ui/helpers/viewport.js +13 -0
  62. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +176 -0
  63. data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +29 -0
  64. data/app/views/lostcoords/ui/components/_form_alert.html.erb +4 -0
  65. data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +13 -0
  66. data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +5 -0
  67. data/app/views/lostcoords/ui/components/_skeleton.html.erb +4 -0
  68. data/app/views/lostcoords/ui/components/_spinner.html.erb +10 -0
  69. data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +8 -0
  70. data/config/emoji_search_aliases.yml +146 -0
  71. data/config/emoji_search_names.yml +1917 -0
  72. data/config/emoji_search_names_ja.yml +6902 -0
  73. data/config/helper_renames.json +113 -0
  74. data/config/importmap.rb +3 -0
  75. data/config/locales/en.yml +33 -0
  76. data/config/locales/es.yml +33 -0
  77. data/config/locales/ja.yml +33 -0
  78. data/lib/lostcoords/ui/action_view.rb +10 -0
  79. data/lib/lostcoords/ui/builders.rb +42 -0
  80. data/lib/lostcoords/ui/components.rb +13 -0
  81. data/lib/lostcoords/ui/engine.rb +22 -0
  82. data/lib/lostcoords/ui/helpers.rb +11 -0
  83. data/lib/lostcoords/ui/icons.rb +24 -0
  84. data/lib/lostcoords/ui/rails.rb +3 -0
  85. data/lib/lostcoords/ui/resources.rb +17 -0
  86. data/lib/lostcoords/ui/tokens.rb +14 -0
  87. data/lib/lostcoords/ui/version.rb +5 -0
  88. data/lib/lostcoords/ui.rb +2 -0
  89. data/lib/tasks/lostcoords_ui.rake +15 -0
  90. data/script/migrate_helpers.rb +136 -0
  91. data/vendor/heroicons/LICENSE +21 -0
  92. data/vendor/heroicons/icons.json +1303 -0
  93. metadata +203 -0
@@ -0,0 +1,83 @@
1
+ import { FloatingPanel } from "helpers/floating_panel"
2
+ import { focusFirstMenuItem, navigateMenu } from "helpers/menu_keys"
3
+
4
+ // The phone dock's action stack lays itself out over the fixed nav, so it
5
+ // passes lift: false and stays in place instead of lifting into the top layer.
6
+ export default class extends FloatingPanel {
7
+ initialize() {
8
+ super.initialize()
9
+ this.focusOutHandler = this.handleFocusOut.bind(this)
10
+ this.pointerDownHandler = this.handlePointerDown.bind(this)
11
+ this.keydownHandler = (event) => navigateMenu(event, this.popoverTarget)
12
+ this.focusOutSuppressed = false
13
+ this.focusOutSuppressionTimer = null
14
+ }
15
+
16
+ disconnect() {
17
+ this.clearFocusOutSuppression()
18
+ super.disconnect()
19
+ }
20
+
21
+ toggle(event) {
22
+ event.preventDefault()
23
+ event.stopPropagation()
24
+ if (this.isOpen) return this.close()
25
+
26
+ this.open()
27
+ if (event.detail === 0) focusFirstMenuItem(this.popoverTarget)
28
+ }
29
+
30
+ toggleFromRow(event) {
31
+ if (this.popoverTarget.contains(event.target)) return
32
+ if (event.target.closest("a, button, input, select, textarea, [data-row-menu-ignore]")) return
33
+
34
+ event.preventDefault()
35
+ this.isOpen ? this.close() : this.open()
36
+ }
37
+
38
+ open() {
39
+ if (this.isOpen) return
40
+
41
+ super.open()
42
+ this.element.addEventListener("focusout", this.focusOutHandler)
43
+ this.element.addEventListener("pointerdown", this.pointerDownHandler)
44
+ this.popoverTarget.addEventListener("keydown", this.keydownHandler)
45
+ }
46
+
47
+ release(options) {
48
+ super.release(options)
49
+ this.element.removeEventListener("focusout", this.focusOutHandler)
50
+ this.element.removeEventListener("pointerdown", this.pointerDownHandler)
51
+ if (this.hasPopoverTarget)
52
+ this.popoverTarget.removeEventListener("keydown", this.keydownHandler)
53
+ }
54
+
55
+ handleFocusOut(event) {
56
+ if (this.element.contains(event.relatedTarget)) return
57
+
58
+ queueMicrotask(() => {
59
+ if (this.focusOutSuppressed) return
60
+ if (!this.element.contains(document.activeElement)) this.close()
61
+ })
62
+ }
63
+
64
+ handlePointerDown(event) {
65
+ if (!this.element.contains(event.target)) return
66
+
67
+ this.clearFocusOutSuppression()
68
+ this.focusOutSuppressed = true
69
+ this.focusOutSuppressionTimer = setTimeout(() => {
70
+ this.focusOutSuppressed = false
71
+ this.focusOutSuppressionTimer = null
72
+ }, 0)
73
+ }
74
+
75
+ clearFocusOutSuppression() {
76
+ if (this.focusOutSuppressionTimer) {
77
+ clearTimeout(this.focusOutSuppressionTimer)
78
+ this.focusOutSuppressionTimer = null
79
+ }
80
+
81
+ this.focusOutSuppressed = false
82
+ }
83
+ }
@@ -0,0 +1,422 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { holdDocumentScroll } from "helpers/dismiss_stack"
3
+ import { SHEET_TIMING, sheetDuration } from "helpers/sheet_motion"
4
+ import { isSheetViewport, prefersReducedMotion, TEXT_ENTRY } from "helpers/viewport"
5
+
6
+ const PROJECTION_MS = 180
7
+ const MEDIUM_FRACTION = 0.5
8
+ const PAGE_SCALE_LOSS = 0.06
9
+ const NO_DRAG = "input[type=range], [data-sheet-no-drag]"
10
+ const HANDLE = "[data-sheet-target~='handle']"
11
+ const GRABBER_ZONE = 28
12
+ const KEYBOARD_FREE = 0.5
13
+
14
+ export default class extends Controller {
15
+ static targets = ["handle"]
16
+ static values = { detents: { type: String, default: "fit" } }
17
+
18
+ connect() {
19
+ this.onTouchStart = this.onTouchStart.bind(this)
20
+ this.onTouchMove = this.onTouchMove.bind(this)
21
+ this.onTouchEnd = this.onTouchEnd.bind(this)
22
+ this.onPointerDown = this.onPointerDown.bind(this)
23
+ this.onPointerMove = this.onPointerMove.bind(this)
24
+ this.onPointerUp = this.onPointerUp.bind(this)
25
+
26
+ this.element.addEventListener("touchstart", this.onTouchStart, { passive: true })
27
+ this.element.addEventListener("touchmove", this.onTouchMove, { passive: false })
28
+ this.element.addEventListener("touchend", this.onTouchEnd)
29
+ this.element.addEventListener("touchcancel", this.onTouchEnd)
30
+ this.element.addEventListener("pointerdown", this.onPointerDown)
31
+
32
+ if (this.isDialog) {
33
+ this.observer = new MutationObserver(() =>
34
+ this.element.open ? this.present() : this.closed(),
35
+ )
36
+ this.observer.observe(this.element, { attributes: true, attributeFilter: ["open"] })
37
+ if (this.element.open) this.present()
38
+ } else {
39
+ this.observer = new MutationObserver(() => this.element.hidden || this.pinFloor())
40
+ this.observer.observe(this.element, { attributes: true, attributeFilter: ["hidden"] })
41
+ }
42
+ }
43
+
44
+ disconnect() {
45
+ this.observer?.disconnect()
46
+ this.element.removeEventListener("touchstart", this.onTouchStart)
47
+ this.element.removeEventListener("touchmove", this.onTouchMove)
48
+ this.element.removeEventListener("touchend", this.onTouchEnd)
49
+ this.element.removeEventListener("touchcancel", this.onTouchEnd)
50
+ this.element.removeEventListener("pointerdown", this.onPointerDown)
51
+ this.stopMouseTracking()
52
+ clearTimeout(this.settleTimer)
53
+ clearTimeout(this.transitionTimer)
54
+ this.releasePage()
55
+ this.resetSurface()
56
+ }
57
+
58
+ present() {
59
+ clearTimeout(this.settleTimer)
60
+ if (!this.isSheet) return
61
+ this.resetSurface()
62
+ this.pinFloor()
63
+ const detents = this.detents()
64
+ this.detent = detents[0]
65
+ if (detents.length > 1 || this.detent.large)
66
+ this.element.style.blockSize = `${this.detent.height}px`
67
+ this.setLargeness(this.detent.large ? 1 : 0)
68
+ if (this.detent.large) this.animatePage(1)
69
+ }
70
+
71
+ // The keyboard rises over the sheet. Safari and in-app browsers shrink the
72
+ // page under it, which would drag a bottom-pinned sheet up and squash it, so
73
+ // the sheet is placed from the top at the height the screen had when it
74
+ // opened (see --sheet-floor in the CSS).
75
+ pinFloor() {
76
+ if (this.isSheet) this.element.style.setProperty("--sheet-floor", `${window.innerHeight}px`)
77
+ }
78
+
79
+ closed() {
80
+ this.gesture = null
81
+ if (this.ownsPage) this.animatePage(0)
82
+ this.later(() => {
83
+ this.releasePage()
84
+ this.resetSurface()
85
+ })
86
+ }
87
+
88
+ // Touch: decide on the first move whether the gesture is the sheet's or the
89
+ // scroller's. A touchmove that isn't cancelled at once lets native scrolling
90
+ // start, and that can't be taken back.
91
+ onTouchStart(event) {
92
+ this.gesture = null
93
+ if (!this.isSheet || event.touches.length !== 1 || event.target.closest(NO_DRAG)) return
94
+ const touch = event.touches[0]
95
+ this.begin(touch.clientX, touch.clientY, event.timeStamp, this.scrollerFor(event.target))
96
+ }
97
+
98
+ onTouchMove(event) {
99
+ const gesture = this.gesture
100
+ if (!gesture) return
101
+ const touch = event.touches[0]
102
+
103
+ if (!gesture.dragging) {
104
+ const dx = touch.clientX - gesture.startX
105
+ const dy = touch.clientY - gesture.startY
106
+ if (dx === 0 && dy === 0) return
107
+ if (!this.claims(dx, dy, gesture.scroller)) {
108
+ this.gesture = null
109
+ return
110
+ }
111
+ this.startDrag(touch.clientY)
112
+ }
113
+
114
+ event.preventDefault()
115
+ this.track(touch.clientY, event.timeStamp)
116
+ }
117
+
118
+ onTouchEnd(event) {
119
+ if (this.gesture?.dragging) this.release()
120
+ else if (this.gesture && event?.type === "touchend") this.focusInPlace(event)
121
+ this.gesture = null
122
+ }
123
+
124
+ // The keyboard rises over the sheet; nothing moves to make room for it. But
125
+ // Safari scrolls the page to "reveal" a tapped field even when the keyboard
126
+ // won't cover it, dragging the sheet up and back. A field in the top half,
127
+ // which no keyboard reaches, is focused while shifted off-screen so there is
128
+ // nothing to reveal (react-aria's usePreventScroll trick). Lower fields keep
129
+ // the native reveal: moving is better than typing blind.
130
+ focusInPlace(event) {
131
+ const field = event.target.closest?.(TEXT_ENTRY)
132
+ if (!event.cancelable || !field || field === document.activeElement) return
133
+ if (field.getBoundingClientRect().bottom > window.innerHeight * KEYBOARD_FREE) return
134
+
135
+ event.preventDefault()
136
+ const transform = field.style.transform
137
+ field.style.transform = "translateY(-2000px)"
138
+ field.focus()
139
+ requestAnimationFrame(() => (field.style.transform = transform))
140
+ }
141
+
142
+ onPointerDown(event) {
143
+ if (!this.isSheet || event.pointerType !== "mouse" || event.button !== 0) return
144
+ const fromTop = event.clientY - this.element.getBoundingClientRect().top
145
+ if (!event.target.closest(HANDLE) && fromTop > GRABBER_ZONE) return
146
+
147
+ this.begin(event.clientX, event.clientY, event.timeStamp, null)
148
+ this.startDrag(event.clientY)
149
+ window.addEventListener("pointermove", this.onPointerMove)
150
+ window.addEventListener("pointerup", this.onPointerUp)
151
+ window.addEventListener("pointercancel", this.onPointerUp)
152
+ }
153
+
154
+ onPointerMove(event) {
155
+ if (this.gesture?.dragging) this.track(event.clientY, event.timeStamp)
156
+ }
157
+
158
+ onPointerUp() {
159
+ this.stopMouseTracking()
160
+ this.onTouchEnd()
161
+ }
162
+
163
+ stopMouseTracking() {
164
+ window.removeEventListener("pointermove", this.onPointerMove)
165
+ window.removeEventListener("pointerup", this.onPointerUp)
166
+ window.removeEventListener("pointercancel", this.onPointerUp)
167
+ }
168
+
169
+ begin(x, y, time, scroller) {
170
+ this.gesture = {
171
+ startX: x,
172
+ startY: y,
173
+ lastY: y,
174
+ lastT: time,
175
+ velocity: 0,
176
+ scroller,
177
+ dragging: false,
178
+ }
179
+ }
180
+
181
+ claims(dx, dy, scroller) {
182
+ if (Math.abs(dx) > Math.abs(dy)) return false
183
+ if (!scroller) return true
184
+ if (dy > 0) return scroller.scrollTop <= 0
185
+ const detents = this.detents()
186
+ return this.visibleHeight() < detents.at(-1).height - 1
187
+ }
188
+
189
+ startDrag(y) {
190
+ clearTimeout(this.settleTimer)
191
+ const gesture = this.gesture
192
+ gesture.dragging = true
193
+ gesture.anchorY = y
194
+ gesture.detents = this.detents()
195
+ gesture.startVisible = this.visibleHeight()
196
+ this.element.style.transition = "none"
197
+ this.element.style.willChange = "transform"
198
+ this.pageTransition("0s")
199
+
200
+ const field = document.activeElement
201
+ if (this.element.contains(field) && field.matches("input, textarea, select")) field.blur()
202
+ }
203
+
204
+ track(y, time) {
205
+ const gesture = this.gesture
206
+ const dt = time - gesture.lastT
207
+ if (dt > 0) gesture.velocity = (y - gesture.lastY) / dt
208
+ gesture.lastY = y
209
+ gesture.lastT = time
210
+
211
+ this.show(
212
+ this.rubberBand(gesture.startVisible - (y - gesture.anchorY), gesture.detents),
213
+ gesture.detents,
214
+ )
215
+ }
216
+
217
+ release() {
218
+ const { detents, velocity } = this.gesture
219
+ const projected = this.visibleHeight() - velocity * PROJECTION_MS
220
+
221
+ if (velocity >= 0 && projected < detents[0].height * 0.6) return this.dismiss()
222
+
223
+ const target = detents.reduce((best, detent) =>
224
+ Math.abs(detent.height - projected) < Math.abs(best.height - projected) ? detent : best,
225
+ )
226
+ this.settle(target, detents)
227
+ }
228
+
229
+ settle(detent, detents = this.detents()) {
230
+ this.detent = detent
231
+ this.transition()
232
+ this.pageTransition()
233
+ this.show(detent.height, detents)
234
+ }
235
+
236
+ dismiss() {
237
+ if (this.isDialog) return this.dismissDialog()
238
+
239
+ this.transition()
240
+ this.element.style.transform = `translateY(${this.element.getBoundingClientRect().height + 48}px)`
241
+ this.later(() => {
242
+ this.dispatch("dismiss", { bubbles: false })
243
+ requestAnimationFrame(() => this.resetSurface())
244
+ })
245
+ }
246
+
247
+ dismissDialog() {
248
+ const cancel = new Event("cancel", { cancelable: true })
249
+ this.element.dispatchEvent(cancel)
250
+ if (!cancel.defaultPrevented) this.element.close()
251
+
252
+ if (this.element.open) return this.settle(this.detent || this.detents()[0])
253
+
254
+ this.transition()
255
+ this.element.style.transform = ""
256
+ }
257
+
258
+ detents() {
259
+ const names = this.detentsValue.split(/\s+/)
260
+ const large = this.largeHeight
261
+ const fit = Math.min(this.fitHeight(), large)
262
+ const medium = Math.round(window.innerHeight * MEDIUM_FRACTION)
263
+
264
+ if (!names.includes("large")) return [{ height: fit, large: false }]
265
+ if (!names.includes("medium")) return [{ height: large, large: true }]
266
+ if (fit <= medium) return [{ height: fit, large: false }]
267
+ return [
268
+ { height: medium, large: false },
269
+ { height: large, large: true },
270
+ ]
271
+ }
272
+
273
+ // Between detents the sheet resizes, so a pinned footer stays on screen;
274
+ // below the smallest it slides down whole; past the largest it stretches
275
+ // against resistance — the UIKit rubber band.
276
+ show(visible, detents) {
277
+ const smallest = detents[0].height
278
+ const largest = detents.at(-1).height
279
+ const resizes = detents.length > 1 || detents[0].large
280
+ const style = this.element.style
281
+
282
+ if (visible < smallest) {
283
+ style.transform = `translateY(${smallest - visible}px)`
284
+ if (resizes) style.blockSize = `${smallest}px`
285
+ } else {
286
+ style.transform = ""
287
+ if (resizes || visible > largest) style.blockSize = `${visible}px`
288
+ }
289
+
290
+ if (!detents.some((detent) => detent.large)) return
291
+ const clamp = (n) => Math.max(0, Math.min(1, n))
292
+ if (detents.length > 1) {
293
+ const p = clamp((visible - smallest) / (largest - smallest))
294
+ this.setLargeness(p)
295
+ this.setPage(p)
296
+ } else {
297
+ this.setPage(clamp(visible / largest))
298
+ }
299
+ }
300
+
301
+ rubberBand(wanted, detents) {
302
+ const largest = detents.at(-1).height
303
+ if (wanted <= largest) return wanted
304
+ const room = window.innerHeight - largest + 24
305
+ return largest + room * (1 - 1 / ((wanted - largest) / room + 1))
306
+ }
307
+
308
+ // The sheet's height less however far it is slid down off its resting place.
309
+ visibleHeight() {
310
+ const transform = getComputedStyle(this.element).transform
311
+ const slid = transform === "none" ? 0 : new DOMMatrixReadOnly(transform).m42
312
+ return this.element.getBoundingClientRect().height - slid
313
+ }
314
+
315
+ // Only a settle's inline transition animates block-size, so only that one is
316
+ // paused. Touching the transition as the sheet opens would cancel its
317
+ // @starting-style slide-up and it would pop into place.
318
+ fitHeight() {
319
+ const style = this.element.style
320
+ const saved = [style.blockSize, style.transition]
321
+ if (saved[1]) style.transition = "none"
322
+ style.blockSize = "auto"
323
+ const height = this.element.getBoundingClientRect().height
324
+ ;[style.blockSize, style.transition] = saved
325
+ return height
326
+ }
327
+
328
+ get largeHeight() {
329
+ return parseFloat(getComputedStyle(this.element).maxBlockSize) || window.innerHeight
330
+ }
331
+
332
+ setLargeness(p) {
333
+ this.element.style.setProperty("--sheet-p", p.toFixed(3))
334
+ }
335
+
336
+ setPage(p) {
337
+ const root = document.documentElement
338
+ if (!this.ownsPage) {
339
+ if (p <= 0) return
340
+ this.releaseScroll = holdDocumentScroll()
341
+ const lockedTop = -parseFloat(document.body.style.top || "0")
342
+ root.style.setProperty("--sheet-page-origin", `${window.scrollY + lockedTop}px`)
343
+ root.dataset.sheetPage = ""
344
+ this.ownsPage = true
345
+ }
346
+ root.style.setProperty("--sheet-page-p", p.toFixed(3))
347
+ root.style.setProperty("--sheet-page-scale", (1 - PAGE_SCALE_LOSS * p).toFixed(4))
348
+ }
349
+
350
+ animatePage(p) {
351
+ this.pageTransition()
352
+ if (p > 0 && !this.ownsPage) {
353
+ this.setPage(0.001)
354
+ requestAnimationFrame(() => requestAnimationFrame(() => this.setPage(p)))
355
+ } else {
356
+ this.setPage(p)
357
+ }
358
+ }
359
+
360
+ releasePage() {
361
+ if (!this.ownsPage) return
362
+ this.ownsPage = false
363
+ const root = document.documentElement
364
+ delete root.dataset.sheetPage
365
+ for (const name of ["p", "scale", "origin", "transition"])
366
+ root.style.removeProperty(`--sheet-page-${name}`)
367
+ this.releaseScroll()
368
+ }
369
+
370
+ pageTransition(value = prefersReducedMotion() ? "0s" : SHEET_TIMING) {
371
+ if (this.ownsPage || value !== "0s")
372
+ document.documentElement.style.setProperty("--sheet-page-transition", value)
373
+ }
374
+
375
+ transition() {
376
+ const timing = prefersReducedMotion() ? "0s" : SHEET_TIMING
377
+ const discrete = this.isDialog
378
+ ? `, display ${timing} allow-discrete, overlay ${timing} allow-discrete`
379
+ : ""
380
+ this.element.style.transition = `transform ${timing}, block-size ${timing}${discrete}`
381
+ clearTimeout(this.transitionTimer)
382
+ this.transitionTimer = setTimeout(() => {
383
+ this.element.style.transition = ""
384
+ this.element.style.willChange = ""
385
+ }, sheetDuration())
386
+ }
387
+
388
+ later(callback) {
389
+ clearTimeout(this.settleTimer)
390
+ this.settleTimer = setTimeout(callback, sheetDuration())
391
+ }
392
+
393
+ resetSurface() {
394
+ const style = this.element.style
395
+ style.transition = ""
396
+ style.transform = ""
397
+ style.willChange = ""
398
+ style.blockSize = ""
399
+ style.removeProperty("--sheet-p")
400
+ style.removeProperty("--sheet-floor")
401
+ }
402
+
403
+ scrollerFor(target) {
404
+ for (let node = target; node; node = node.parentElement) {
405
+ if (
406
+ node.scrollHeight > node.clientHeight + 1 &&
407
+ /auto|scroll/.test(getComputedStyle(node).overflowY)
408
+ )
409
+ return node
410
+ if (node === this.element) return null
411
+ }
412
+ return null
413
+ }
414
+
415
+ get isDialog() {
416
+ return this.element instanceof HTMLDialogElement
417
+ }
418
+
419
+ get isSheet() {
420
+ return isSheetViewport()
421
+ }
422
+ }
@@ -0,0 +1,69 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { prefersReducedMotion } from "helpers/viewport"
3
+
4
+ export default class extends Controller {
5
+ static values = { timeout: { type: Number, default: 4000 } }
6
+
7
+ connect() {
8
+ for (const sibling of this.element.parentElement?.children || []) {
9
+ if (sibling === this.element) continue
10
+ this.application.getControllerForElementAndIdentifier(sibling, "toast")?.dismiss()
11
+ }
12
+
13
+ this.startedAt = performance.now()
14
+ this.remaining = this.timeoutValue
15
+ this.schedule(this.remaining)
16
+
17
+ this.pause = () => this.pauseTimer()
18
+ this.resume = () => this.resumeTimer()
19
+
20
+ this.element.addEventListener("mouseenter", this.pause)
21
+ this.element.addEventListener("mouseleave", this.resume)
22
+ this.element.addEventListener("focusin", this.pause)
23
+ this.element.addEventListener("focusout", this.resume)
24
+ }
25
+
26
+ disconnect() {
27
+ clearTimeout(this.timer)
28
+ clearTimeout(this.removeTimer)
29
+ this.element.removeEventListener("mouseenter", this.pause)
30
+ this.element.removeEventListener("mouseleave", this.resume)
31
+ this.element.removeEventListener("focusin", this.pause)
32
+ this.element.removeEventListener("focusout", this.resume)
33
+ }
34
+
35
+ schedule(ms) {
36
+ if (this.timeoutValue === 0) return
37
+ if (this.timer) clearTimeout(this.timer)
38
+ this.startedAt = performance.now()
39
+ this.timer = setTimeout(() => this.dismiss(), ms)
40
+ }
41
+
42
+ pauseTimer() {
43
+ if (!this.timer) return
44
+ clearTimeout(this.timer)
45
+ this.timer = null
46
+ const elapsed = performance.now() - this.startedAt
47
+ this.remaining = Math.max(0, this.remaining - elapsed)
48
+ }
49
+
50
+ resumeTimer() {
51
+ if (this.timer) return
52
+ this.schedule(this.remaining)
53
+ }
54
+
55
+ dismiss() {
56
+ if (this.element.hasAttribute("data-leaving")) return
57
+ if (this.timer) clearTimeout(this.timer)
58
+ this.element.dataset.leaving = ""
59
+
60
+ const reduced = prefersReducedMotion()
61
+ if (reduced) {
62
+ this.element.remove()
63
+ return
64
+ }
65
+
66
+ this.element.addEventListener("transitionend", () => this.element.remove(), { once: true })
67
+ this.removeTimer = setTimeout(() => this.element.isConnected && this.element.remove(), 400)
68
+ }
69
+ }
@@ -0,0 +1,56 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["button", "status"]
5
+ static values = { value: String, source: String, resetAfter: Number }
6
+
7
+ connect() {
8
+ this.reset()
9
+ }
10
+
11
+ disconnect() {
12
+ clearTimeout(this.resetTimer)
13
+ this.request = null
14
+ }
15
+
16
+ async copy() {
17
+ if (this.request || this.buttonTarget.disabled) return
18
+
19
+ clearTimeout(this.resetTimer)
20
+ this.reset()
21
+ const request = Symbol()
22
+ this.request = request
23
+ this.buttonTarget.setAttribute("aria-busy", "true")
24
+
25
+ try {
26
+ let text = this.valueValue
27
+ if (this.hasSourceValue) {
28
+ const source = document.getElementById(this.sourceValue)
29
+ if (!source) throw new Error("Copy source not found")
30
+ text = source.matches("input, textarea") ? source.value : source.textContent
31
+ }
32
+ await navigator.clipboard.writeText(text)
33
+ if (this.request !== request) return
34
+ this.element.dataset.state = "copied"
35
+ this.statusTarget.textContent = this.statusTarget.dataset.copied
36
+ this.dispatch("copied", { detail: { text } })
37
+ } catch (error) {
38
+ if (this.request !== request) return
39
+ this.element.dataset.state = "error"
40
+ this.statusTarget.textContent = this.statusTarget.dataset.error
41
+ this.dispatch("error", { detail: { error } })
42
+ } finally {
43
+ if (this.request === request) {
44
+ this.request = null
45
+ this.buttonTarget.removeAttribute("aria-busy")
46
+ this.resetTimer = setTimeout(() => this.reset(), this.resetAfterValue)
47
+ }
48
+ }
49
+ }
50
+
51
+ reset() {
52
+ this.element.dataset.state = "idle"
53
+ this.statusTarget.textContent = ""
54
+ this.buttonTarget.removeAttribute("aria-busy")
55
+ }
56
+ }
@@ -0,0 +1,17 @@
1
+ import { FloatingPanel } from "helpers/floating_panel"
2
+
3
+ export default class extends FloatingPanel {
4
+ toggle(event) {
5
+ super.toggle(event)
6
+ if (this.isOpen && event.detail === 0) {
7
+ const selected = this.popoverTarget.querySelector(
8
+ 'input[type="radio"]:checked:not(:disabled)',
9
+ )
10
+ const first = this.popoverTarget.querySelector(
11
+ 'button:not(:disabled), input:not([type="hidden"]):not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex="0"]:not(input[type="hidden"])',
12
+ )
13
+ const target = selected || first || this.popoverTarget
14
+ target.focus({ preventScroll: true })
15
+ }
16
+ }
17
+ }