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,69 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["option", "input"]
5
+ static values = { value: String }
6
+
7
+ connect() {
8
+ const selected = this.options.find((option) =>
9
+ this.hasValueValue
10
+ ? option.dataset.value === this.valueValue
11
+ : option.getAttribute("aria-pressed") === "true",
12
+ )
13
+ this.initialOption = selected || this.options[0]
14
+ this.apply(this.initialOption)
15
+ this.form = this.element.closest("form")
16
+ this.onReset = () => queueMicrotask(() => this.apply(this.initialOption))
17
+ this.form?.addEventListener("reset", this.onReset)
18
+ }
19
+
20
+ disconnect() {
21
+ this.form?.removeEventListener("reset", this.onReset)
22
+ }
23
+
24
+ select(event) {
25
+ const option = event.target.closest('[data-ui-selection-target~="option"]')
26
+ if (!this.options.includes(option)) return
27
+ this.apply(option)
28
+ this.changed(option)
29
+ }
30
+
31
+ navigate(event) {
32
+ const current = this.options.indexOf(event.target)
33
+ if (current < 0) return
34
+ const next = {
35
+ ArrowRight: (current + 1) % this.options.length,
36
+ ArrowDown: (current + 1) % this.options.length,
37
+ ArrowLeft: (current - 1 + this.options.length) % this.options.length,
38
+ ArrowUp: (current - 1 + this.options.length) % this.options.length,
39
+ Home: 0,
40
+ End: this.options.length - 1,
41
+ }[event.key]
42
+ if (next === undefined) return
43
+ event.preventDefault()
44
+ const option = this.options[next]
45
+ option.focus()
46
+ this.apply(option)
47
+ this.changed(option)
48
+ }
49
+
50
+ apply(selected) {
51
+ for (const option of this.optionTargets) {
52
+ option.setAttribute("aria-pressed", String(option === selected))
53
+ option.tabIndex = option === selected ? 0 : -1
54
+ }
55
+ if (this.hasInputTarget) this.inputTarget.value = selected?.dataset.value || ""
56
+ }
57
+
58
+ changed(option) {
59
+ if (this.hasInputTarget) {
60
+ this.inputTarget.dispatchEvent(new Event("input", { bubbles: true }))
61
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
62
+ }
63
+ this.dispatch("change", { detail: { value: option.dataset.value } })
64
+ }
65
+
66
+ get options() {
67
+ return this.optionTargets.filter((option) => !option.disabled)
68
+ }
69
+ }
@@ -0,0 +1,13 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ toggle() {
5
+ this.element.setAttribute(
6
+ "aria-pressed",
7
+ String(this.element.getAttribute("aria-pressed") !== "true"),
8
+ )
9
+ this.dispatch("change", {
10
+ detail: { pressed: this.element.getAttribute("aria-pressed") === "true" },
11
+ })
12
+ }
13
+ }
@@ -0,0 +1,455 @@
1
+ import { holdDocumentScroll } from "helpers/dismiss_stack"
2
+ import { FloatingPanel } from "helpers/floating_panel"
3
+ import { SHEET_TIMING, sheetDuration } from "helpers/sheet_motion"
4
+ import { isSheetViewport } from "helpers/viewport"
5
+
6
+ const catalogs = new Map()
7
+ const MAX_RESULTS = 250
8
+
9
+ export class CatalogPicker extends FloatingPanel {
10
+ static targets = [
11
+ "body",
12
+ "searchInput",
13
+ "empty",
14
+ "tabs",
15
+ "tab",
16
+ "results",
17
+ "resultsGrid",
18
+ "resultsCount",
19
+ "preview",
20
+ ]
21
+ static values = { catalogUrl: String }
22
+
23
+ static optionKey = "value"
24
+ static previewTargets = []
25
+
26
+ connect() {
27
+ super.connect()
28
+ this.entries = []
29
+ this.onBodyScroll = this.onBodyScroll.bind(this)
30
+ this.defaultPreview = this.previewFields.map((field) => field?.textContent.trim() ?? "")
31
+ }
32
+
33
+ disconnect() {
34
+ this.releaseScroll?.()
35
+ this.removeSheetGuard()
36
+ this.disconnectObserver()
37
+ super.disconnect()
38
+ }
39
+
40
+ show() {
41
+ this.popoverTarget.hidden = false
42
+ super.show()
43
+ this.syncOptions()
44
+ this.resetPreview()
45
+ this.loadPopover()
46
+
47
+ if (this.isSheet) {
48
+ this.showSheet()
49
+ this.releaseScroll ??= holdDocumentScroll()
50
+ this.addSheetGuard()
51
+ } else {
52
+ this.resetPopoverPosition()
53
+ this.positionPopover()
54
+ this.focusSearch()
55
+ }
56
+ }
57
+
58
+ close() {
59
+ if (!this.isOpen || this.sheetClosing) return
60
+ this.sheetClosing = true
61
+ this.slideSheetOut(() => {
62
+ this.sheetClosing = false
63
+ this.closeNow()
64
+ })
65
+ }
66
+
67
+ hide() {
68
+ this.popoverTarget.style.transition = ""
69
+ this.popoverTarget.style.transform = ""
70
+ super.hide()
71
+ }
72
+
73
+ afterClose() {
74
+ this.hideSheet()
75
+ this.removeSheetGuard()
76
+ this.releaseScroll?.()
77
+ this.releaseScroll = null
78
+ this.resetPopoverPosition()
79
+ this.resetSearch()
80
+ this.resetPreview()
81
+ this.disconnectObserver()
82
+ }
83
+
84
+ // Slid out from JS so the close animates on every path (backdrop tap, Escape,
85
+ // selection) and in browsers that don't honor the CSS `allow-discrete` close.
86
+ slideSheetOut(done) {
87
+ const pop = this.popoverTarget
88
+ const { duration, transition, offscreen } = this.sheetMotion
89
+
90
+ if (
91
+ !this.isSheet ||
92
+ !duration ||
93
+ !this.supportsPopover ||
94
+ !pop.matches(":popover-open") ||
95
+ this.alreadySlidOff(pop)
96
+ ) {
97
+ done()
98
+ return
99
+ }
100
+
101
+ let settled = false
102
+ const finish = () => {
103
+ if (settled) return
104
+ settled = true
105
+ pop.removeEventListener("transitionend", onEnd)
106
+ done()
107
+ }
108
+ const onEnd = (event) => {
109
+ if (event.target === pop && event.propertyName === "transform") finish()
110
+ }
111
+
112
+ pop.addEventListener("transitionend", onEnd)
113
+ pop.style.transition = transition
114
+ window.requestAnimationFrame(() => {
115
+ pop.style.transform = offscreen
116
+ })
117
+ window.setTimeout(finish, duration + 100)
118
+ }
119
+
120
+ runAfterSheetClose(fn) {
121
+ const delay = this.isSheet && this.supportsPopover ? this.sheetMotion.settle : 0
122
+ delay ? window.setTimeout(fn, delay) : fn()
123
+ }
124
+
125
+ alreadySlidOff(pop) {
126
+ const transform = window.getComputedStyle(pop).transform
127
+ if (transform === "none") return false
128
+ try {
129
+ return new DOMMatrixReadOnly(transform).m42 > 8
130
+ } catch {
131
+ return false
132
+ }
133
+ }
134
+
135
+ showSheet() {
136
+ if (!this.supportsPopover) return
137
+
138
+ if (this.popoverTarget.getAttribute("popover") !== "manual") {
139
+ this.popoverTarget.setAttribute("popover", "manual")
140
+ }
141
+ try {
142
+ if (!this.popoverTarget.matches(":popover-open")) this.popoverTarget.showPopover()
143
+ } catch {}
144
+ }
145
+
146
+ hideSheet() {
147
+ if (this.supportsPopover && this.popoverTarget.matches(":popover-open")) {
148
+ try {
149
+ this.popoverTarget.hidePopover()
150
+ } catch {}
151
+ } else {
152
+ this.popoverTarget.hidden = true
153
+ }
154
+ }
155
+
156
+ // A non-modal popover's ::backdrop doesn't block input, so a tap in the dimmed
157
+ // area would activate whatever sits behind the sheet. Swallow it and close.
158
+ addSheetGuard() {
159
+ this.sheetGuard = (event) => {
160
+ if (this.popoverTarget.contains(event.target)) return
161
+ event.stopPropagation()
162
+ if (event.type === "click") {
163
+ event.preventDefault()
164
+ this.close()
165
+ }
166
+ }
167
+ document.addEventListener("pointerdown", this.sheetGuard, true)
168
+ document.addEventListener("click", this.sheetGuard, true)
169
+ }
170
+
171
+ removeSheetGuard() {
172
+ if (!this.sheetGuard) return
173
+ document.removeEventListener("pointerdown", this.sheetGuard, true)
174
+ document.removeEventListener("click", this.sheetGuard, true)
175
+ this.sheetGuard = null
176
+ }
177
+
178
+ filter() {
179
+ this.applyFilter(this.searchQuery)
180
+ }
181
+
182
+ onSearchKeydown(event) {
183
+ if (event.key !== "Escape" || !this.searchQuery) return
184
+
185
+ event.preventDefault()
186
+ event.stopPropagation()
187
+ this.searchInputTarget.value = ""
188
+ this.applyFilter("")
189
+ }
190
+
191
+ applyFilter(query) {
192
+ const normalizedQuery = query.toString().trim().toLocaleLowerCase()
193
+ this.popoverTarget.toggleAttribute("data-searching", !!normalizedQuery)
194
+
195
+ if (!normalizedQuery) {
196
+ if (this.hasResultsTarget) this.resultsTarget.hidden = true
197
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = true
198
+ return
199
+ }
200
+
201
+ const matches = []
202
+ for (const entry of this.entries) {
203
+ const score = this.score(entry, normalizedQuery)
204
+ if (score > 0) matches.push({ entry, score })
205
+ }
206
+ matches.sort(
207
+ (a, b) =>
208
+ b.score - a.score ||
209
+ Number(b.entry.popular) - Number(a.entry.popular) ||
210
+ a.entry.name.length - b.entry.name.length,
211
+ )
212
+
213
+ const buttons = matches.slice(0, MAX_RESULTS).map(({ entry }) => this.resultButton(entry))
214
+ this.resultsGridTarget.replaceChildren(...buttons)
215
+ this.resultsCountTarget.textContent = matches.length
216
+ this.resultsTarget.hidden = matches.length === 0
217
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = matches.length > 0
218
+ this.bodyTarget.scrollTop = 0
219
+ }
220
+
221
+ score(entry, query) {
222
+ let score = 0
223
+ if (entry.name === query || entry.labels.includes(query)) score = 100
224
+ else if (entry.name.startsWith(query)) score = 80
225
+ else if (entry.labels.some((label) => label.startsWith(query))) score = 65
226
+ else if (entry.words.some((word) => word.startsWith(query))) score = 50
227
+ else if (entry.search.includes(query)) score = 20
228
+
229
+ if (score > 0 && entry.popular) score += 30
230
+ return score
231
+ }
232
+
233
+ focusSearch() {
234
+ if (!this.hasSearchInputTarget) return
235
+
236
+ window.requestAnimationFrame(() => {
237
+ if (this.isOpen) this.searchInputTarget.focus({ preventScroll: true })
238
+ })
239
+ }
240
+
241
+ resetSearch() {
242
+ if (this.hasSearchInputTarget) this.searchInputTarget.value = ""
243
+ this.popoverTarget.removeAttribute("data-searching")
244
+ if (this.hasResultsTarget) this.resultsTarget.hidden = true
245
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = true
246
+ }
247
+
248
+ async loadPopover() {
249
+ const existing = this.catalogElement
250
+ if (existing) {
251
+ this.activateCatalog(existing)
252
+ return
253
+ }
254
+ if (this.loadingCatalog || !this.hasCatalogUrlValue) return
255
+
256
+ this.loadingCatalog = true
257
+ try {
258
+ await this.fetchCatalog()
259
+ const template = catalogs.get(this.catalogUrlValue)?.template
260
+ if (!template || !this.isOpen) return
261
+
262
+ const catalog = document.createElement("div")
263
+ catalog.className = this.constructor.catalogClass
264
+ catalog.dataset.pickerCatalog = ""
265
+ catalog.appendChild(template.content.cloneNode(true))
266
+
267
+ if (this.catalogAnchor) {
268
+ this.catalogAnchor.insertAdjacentElement("afterend", catalog)
269
+ } else {
270
+ this.bodyTarget.prepend(catalog)
271
+ }
272
+
273
+ this.activateCatalog(catalog)
274
+ } finally {
275
+ this.loadingCatalog = false
276
+ }
277
+ }
278
+
279
+ activateCatalog(catalog) {
280
+ if (this.entries.length === 0) this.indexCatalog(catalog)
281
+ this.syncOptions()
282
+ this.observeSections()
283
+ this.applyFilter(this.searchQuery)
284
+ }
285
+
286
+ indexCatalog(catalog) {
287
+ const buttons = catalog.querySelectorAll(this.optionSelector)
288
+ const index = catalogs.get(this.catalogUrlValue)?.index || []
289
+ this.entries = index.map((row, i) => ({ ...row, button: buttons[i] }))
290
+ }
291
+
292
+ indexOption(button) {
293
+ const key = button.dataset[this.constructor.optionKey]
294
+ const search = (button.dataset.search || key).toLocaleLowerCase()
295
+ return {
296
+ key,
297
+ name: key.toLocaleLowerCase(),
298
+ labels: (button.dataset.searchLabels || "").toLocaleLowerCase().split("|").filter(Boolean),
299
+ search,
300
+ words: search.split(/\s+/).filter(Boolean),
301
+ popular: button.dataset.popular === "true",
302
+ }
303
+ }
304
+
305
+ preloadCatalog() {
306
+ if (!this.hasCatalogUrlValue || catalogs.has(this.catalogUrlValue)) return
307
+
308
+ catalogs.set(this.catalogUrlValue, {})
309
+ const preload = () => this.fetchCatalog()
310
+
311
+ if ("requestIdleCallback" in window) {
312
+ window.requestIdleCallback(preload, { timeout: 2500 })
313
+ } else {
314
+ window.setTimeout(preload, 1200)
315
+ }
316
+ }
317
+
318
+ fetchCatalog() {
319
+ const url = this.catalogUrlValue
320
+ const catalog = catalogs.get(url) || {}
321
+ catalogs.set(url, catalog)
322
+
323
+ catalog.request ||= fetch(url, {
324
+ headers: { Accept: "text/html" },
325
+ credentials: "same-origin",
326
+ })
327
+ .then((response) => (response.ok ? response.text() : null))
328
+ .then((html) => {
329
+ if (!html) return
330
+ const template = document.createElement("template")
331
+ template.innerHTML = html
332
+ catalog.index = Array.from(
333
+ template.content.querySelectorAll(this.optionSelector),
334
+ (button) => this.indexOption(button),
335
+ )
336
+ catalog.template = template
337
+ })
338
+ return catalog.request
339
+ }
340
+
341
+ jumpTo(event) {
342
+ const key = event.currentTarget.dataset.targetSection
343
+ const section = this.bodyTarget.querySelector(`[data-section="${key}"]`)
344
+ if (!section) return
345
+
346
+ this.bodyTarget.scrollTop = section.offsetTop
347
+ this.setActiveTab(key)
348
+ }
349
+
350
+ observeSections() {
351
+ this.disconnectObserver()
352
+ this.bodyTarget.addEventListener("scroll", this.onBodyScroll, { passive: true })
353
+ this.updateActiveSectionFromScroll()
354
+ }
355
+
356
+ disconnectObserver() {
357
+ if (this.hasBodyTarget) this.bodyTarget.removeEventListener("scroll", this.onBodyScroll)
358
+ if (this.scrollFrame) window.cancelAnimationFrame(this.scrollFrame)
359
+ this.scrollFrame = null
360
+ }
361
+
362
+ onBodyScroll() {
363
+ if (this.scrollFrame) return
364
+
365
+ this.scrollFrame = window.requestAnimationFrame(() => {
366
+ this.scrollFrame = null
367
+ this.updateActiveSectionFromScroll()
368
+ })
369
+ }
370
+
371
+ updateActiveSectionFromScroll() {
372
+ if (!this.hasBodyTarget || this.popoverTarget.hasAttribute("data-searching")) return
373
+
374
+ const scrollTop = this.bodyTarget.scrollTop
375
+ const sections = Array.from(this.bodyTarget.querySelectorAll("[data-section]")).filter(
376
+ (section) => section.offsetParent !== null,
377
+ )
378
+ if (sections.length === 0) return
379
+
380
+ const current =
381
+ sections.findLast((section) => section.offsetTop <= scrollTop + 8) || sections[0]
382
+ this.setActiveTab(current.dataset.section)
383
+ }
384
+
385
+ setActiveTab(key) {
386
+ if (!this.hasTabTarget) return
387
+ this.tabTargets.forEach((tab) => {
388
+ const active = tab.dataset.targetSection === key
389
+ if (active) tab.setAttribute("aria-current", "true")
390
+ else tab.removeAttribute("aria-current")
391
+ if (active) tab.scrollIntoView({ block: "nearest", inline: "nearest" })
392
+ })
393
+ }
394
+
395
+ preview(event) {
396
+ const button = event.target.closest(this.optionSelector)
397
+ if (!button || !this.popoverTarget.contains(button)) return
398
+
399
+ this.setPreview(this.previewFor(button))
400
+ }
401
+
402
+ setPreview(texts) {
403
+ this.previewFields.forEach((field, i) => {
404
+ if (field) field.textContent = texts[i]
405
+ })
406
+ }
407
+
408
+ resetPreview() {
409
+ this.setPreview(this.defaultPreview)
410
+ }
411
+
412
+ syncOptions() {}
413
+
414
+ get catalogAnchor() {
415
+ return null
416
+ }
417
+
418
+ get catalogElement() {
419
+ return this.bodyTarget.querySelector("[data-picker-catalog]")
420
+ }
421
+
422
+ get optionSelector() {
423
+ return `button[data-${this.constructor.optionKey}]`
424
+ }
425
+
426
+ get previewFields() {
427
+ return this.constructor.previewTargets.map((name) => this.targets.find(name))
428
+ }
429
+
430
+ get sheetMotion() {
431
+ const duration = sheetDuration()
432
+ return {
433
+ duration,
434
+ settle: duration,
435
+ transition: `transform ${SHEET_TIMING}`,
436
+ offscreen: "translateY(var(--sheet-offscreen))",
437
+ }
438
+ }
439
+
440
+ get isSheet() {
441
+ return isSheetViewport()
442
+ }
443
+
444
+ get lifts() {
445
+ return this.liftValue && !this.isSheet
446
+ }
447
+
448
+ get supportsPopover() {
449
+ return typeof this.popoverTarget.showPopover === "function"
450
+ }
451
+
452
+ get searchQuery() {
453
+ return this.hasSearchInputTarget ? this.searchInputTarget.value : ""
454
+ }
455
+ }
@@ -0,0 +1,29 @@
1
+ import { isSheetViewport } from "helpers/viewport"
2
+
3
+ const EDGE = 16
4
+
5
+ export function showContextDialog(dialog, menu) {
6
+ delete dialog.dataset.anchored
7
+ if (!dialog.open) dialog.showModal()
8
+
9
+ const rect = menu?.getBoundingClientRect()
10
+ const corner = rect?.height
11
+ ? { top: Math.max(EDGE, rect.bottom - dialog.offsetHeight), right: rect.right }
12
+ : null
13
+ if (anchorDialog(dialog, corner)) dialog.style.setProperty("--dialog-origin", "bottom right")
14
+ }
15
+
16
+ export function anchorDialog(dialog, corner) {
17
+ delete dialog.dataset.anchored
18
+ dialog.style.removeProperty("--dialog-top")
19
+ dialog.style.removeProperty("--dialog-right")
20
+ if (!corner || isSheetViewport()) return false
21
+
22
+ dialog.dataset.anchored = ""
23
+ dialog.style.setProperty("--dialog-top", `${corner.top}px`)
24
+ dialog.style.setProperty(
25
+ "--dialog-right",
26
+ `${Math.max(EDGE, window.innerWidth - corner.right)}px`,
27
+ )
28
+ return true
29
+ }
@@ -0,0 +1,31 @@
1
+ import { registerDismissable } from "helpers/dismiss_stack"
2
+
3
+ const registrations = new WeakMap()
4
+
5
+ export function registerDialog(dialog, { close = () => dialog.close(), lockScroll } = {}) {
6
+ const existing = registrations.get(dialog)
7
+ if (existing) return existing
8
+
9
+ const unregisterDismissable = registerDismissable({
10
+ element: dialog,
11
+ type: "modal",
12
+ lockScroll,
13
+ close,
14
+ isOpen: () => dialog.open,
15
+ })
16
+ const closeOnBackdrop = (event) => {
17
+ if (event.target === dialog) close(event)
18
+ }
19
+ const unregister = () => {
20
+ if (registrations.get(dialog) !== unregister) return
21
+ registrations.delete(dialog)
22
+ dialog.removeEventListener("close", unregister)
23
+ dialog.removeEventListener("click", closeOnBackdrop)
24
+ unregisterDismissable()
25
+ }
26
+
27
+ registrations.set(dialog, unregister)
28
+ dialog.addEventListener("close", unregister)
29
+ dialog.addEventListener("click", closeOnBackdrop)
30
+ return unregister
31
+ }