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,14 @@
1
+ module Lostcoords::Ui
2
+ module TailwindHelper
3
+ CONTROL_SIZES = {
4
+ sm: "min-h-11 py-2",
5
+ base: "min-h-12 py-2.5",
6
+ lg: "min-h-14 py-3.5"
7
+ }.freeze
8
+
9
+ def lc_tw(*classes)
10
+ @tailwind_merger ||= TailwindMerge::Merger.new
11
+ @tailwind_merger.merge(classes.flatten.compact.join(" "))
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ module Lostcoords::Ui
2
+ module TranslationHelper
3
+ private
4
+
5
+ def lc_t(key, **options)
6
+ I18n.translate(key, **options, locale: lc_locale)
7
+ end
8
+
9
+ def lc_locale
10
+ I18n.locale
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,28 @@
1
+ module Lostcoords::Ui
2
+ module TypographyHelper
3
+ HEADING_SIZES = { page: "ui-heading-page", section: "ui-heading-section", card: "ui-heading-card" }.freeze
4
+ TEXT_SIZES = { body: "ui-text-body", small: "ui-text-small", caption: "ui-text-caption" }.freeze
5
+ TEXT_TONES = { default: "text-ink", muted: "text-ink-2", subtle: "text-ink-3" }.freeze
6
+
7
+ def lc_heading_class(size: :section, extra: nil)
8
+ size = lc_option!(:size, size, HEADING_SIZES.keys)
9
+ lc_tw("ui-heading m-0 text-ink", HEADING_SIZES.fetch(size), extra)
10
+ end
11
+
12
+ def lc_text_class(size: :body, tone: :default, extra: nil)
13
+ size = lc_option!(:size, size, TEXT_SIZES.keys)
14
+ tone = lc_option!(:tone, tone, TEXT_TONES.keys)
15
+ lc_tw("ui-text m-0", TEXT_SIZES.fetch(size), TEXT_TONES.fetch(tone), extra)
16
+ end
17
+
18
+ def lc_heading(content = nil, level: 2, size: :section, **attributes, &block)
19
+ raise ArgumentError, "Invalid level: #{level.inspect}. Expected an integer from 1 to 6" unless (1..6).include?(level) && level.is_a?(Integer)
20
+
21
+ tag.public_send("h#{level}", content, **lc_attributes({ class: lc_heading_class(size: size) }, attributes), &block)
22
+ end
23
+
24
+ def lc_text(content = nil, size: :body, tone: :default, **attributes, &block)
25
+ tag.p(content, **lc_attributes({ class: lc_text_class(size: size, tone: tone) }, attributes), &block)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,39 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { showContextDialog } from "helpers/context_dialog"
3
+ import { registerDialog } from "helpers/dialog"
4
+
5
+ export default class extends Controller {
6
+ static targets = ["dialog"]
7
+
8
+ disconnect() {
9
+ this.unregisterDialog?.()
10
+ }
11
+
12
+ open(event) {
13
+ event.preventDefault()
14
+
15
+ const dialog = this.findDialog(event.params.name)
16
+ if (!dialog || dialog.open) return
17
+
18
+ this.activeDialog = dialog
19
+ if (dialog.matches(".context-dialog")) {
20
+ showContextDialog(dialog, this.element.querySelector("[data-row-menu-target='popover']"))
21
+ } else {
22
+ dialog.showModal()
23
+ }
24
+ this.unregisterDialog?.()
25
+ this.unregisterDialog = registerDialog(dialog)
26
+ }
27
+
28
+ close(event) {
29
+ event?.preventDefault()
30
+ const dialog = event?.target?.closest("dialog") || this.activeDialog
31
+ dialog?.close()
32
+ }
33
+
34
+ findDialog(name) {
35
+ if (!name) return this.dialogTarget
36
+
37
+ return this.dialogTargets.find((dialog) => dialog.dataset.confirmDialogName === name)
38
+ }
39
+ }
@@ -0,0 +1,163 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { prefersReducedMotion } from "helpers/viewport"
3
+
4
+ // Animates a <details> both ways and keeps what it reveals on screen. Only
5
+ // user toggles go through here: programmatic opens (validation) already scroll
6
+ // to the field they care about. Durations live in ui/disclosure.css.
7
+ export default class extends Controller {
8
+ // Where the panel's height can't ease to `auto` in CSS (Safari), ease the
9
+ // <details> itself between measured heights.
10
+ static easesHeightInCss = CSS.supports("interpolate-size: allow-keywords")
11
+
12
+ disconnect() {
13
+ this.stopFollowing()
14
+ this.stopClosing()
15
+ }
16
+
17
+ toggle(event) {
18
+ if (event.target.closest("summary")?.parentElement !== this.element) return
19
+
20
+ if (this.element.hasAttribute("data-closing")) {
21
+ event.preventDefault()
22
+ this.stopClosing()
23
+ } else if (this.element.open) {
24
+ event.preventDefault()
25
+ this.close()
26
+ } else {
27
+ if (!this.constructor.easesHeightInCss) {
28
+ event.preventDefault()
29
+ this.openWithHeight()
30
+ }
31
+ this.reveal()
32
+ }
33
+ }
34
+
35
+ openWithHeight() {
36
+ const from = this.element.offsetHeight
37
+ const duration = this.transitionDurations()[0]
38
+ this.element.open = true
39
+ const animation = this.animateHeight(
40
+ from,
41
+ this.element.offsetHeight,
42
+ duration,
43
+ "--ease-out-expo",
44
+ )
45
+ animation.finished.then(
46
+ () => animation.cancel(),
47
+ () => {},
48
+ )
49
+ }
50
+
51
+ // Removing [open] hides the content at once, so play the exit
52
+ // ([data-closing]) while it's still open, then close.
53
+ close() {
54
+ this.stopFollowing()
55
+ this.element.toggleAttribute("data-closing", true)
56
+ const duration = Math.max(...this.transitionDurations())
57
+ if (!this.constructor.easesHeightInCss) {
58
+ this.animateHeight(this.element.offsetHeight, this.closedHeight(), duration, "--ease-out")
59
+ }
60
+ this.closeTimeout = setTimeout(() => {
61
+ this.element.open = false
62
+ this.stopClosing()
63
+ }, duration)
64
+ }
65
+
66
+ stopClosing() {
67
+ clearTimeout(this.closeTimeout)
68
+ this.element.removeAttribute("data-closing")
69
+ this.heightAnimation?.cancel()
70
+ }
71
+
72
+ animateHeight(from, to, duration, easingProperty) {
73
+ this.heightAnimation?.cancel()
74
+ const easing =
75
+ getComputedStyle(this.element).getPropertyValue(easingProperty).trim() || "ease-out"
76
+ this.heightAnimation = this.element.animate(
77
+ { height: [`${from}px`, `${to}px`], overflow: ["clip", "clip"] },
78
+ { duration, easing, fill: "forwards" },
79
+ )
80
+ return this.heightAnimation
81
+ }
82
+
83
+ closedHeight() {
84
+ const style = getComputedStyle(this.element)
85
+ const chrome = ["paddingTop", "paddingBottom", "borderTopWidth", "borderBottomWidth"].reduce(
86
+ (sum, property) => sum + parseFloat(style[property]),
87
+ 0,
88
+ )
89
+ return this.element.querySelector(":scope > summary").offsetHeight + chrome
90
+ }
91
+
92
+ transitionDurations() {
93
+ return getComputedStyle(this.element, "::details-content")
94
+ .transitionDuration.split(",")
95
+ .map((duration) => parseFloat(duration) * 1000)
96
+ }
97
+
98
+ // Opening near the bottom of a scroller grows content off-screen, so the
99
+ // user sees nothing happen. Scroll just enough to show it, never so far that
100
+ // the summary leaves the top.
101
+ reveal() {
102
+ this.stopFollowing()
103
+ // A native toggle applies [open] after this handler; measure next frame.
104
+ requestAnimationFrame(() => {
105
+ if (!this.element.open) return
106
+
107
+ const scroller = this.scrollParent()
108
+ const distance = this.distanceToReveal(scroller)
109
+ if (distance <= 0) return
110
+
111
+ this.followGrowth(scroller, scroller.scrollTop + distance)
112
+ })
113
+ }
114
+
115
+ // The scroller can only go as far as the panel has grown, so a single
116
+ // smooth scroll would stop short. Follow each frame of growth instead; the
117
+ // scroll then rides the panel's own easing.
118
+ followGrowth(scroller, target) {
119
+ const reduceMotion = prefersReducedMotion()
120
+ if (scroller.scrollHeight - scroller.clientHeight >= target) {
121
+ scroller.scrollTo({ top: target, behavior: reduceMotion ? "instant" : "smooth" })
122
+ return
123
+ }
124
+
125
+ this.follower = new ResizeObserver(() => {
126
+ const reachable = scroller.scrollHeight - scroller.clientHeight
127
+ scroller.scrollTo({ top: Math.min(reachable, target), behavior: "instant" })
128
+ if (reachable >= target) this.stopFollowing()
129
+ })
130
+ this.follower.observe(this.element)
131
+ this.followTimeout = setTimeout(() => this.stopFollowing(), 800)
132
+ }
133
+
134
+ stopFollowing() {
135
+ this.follower?.disconnect()
136
+ this.follower = null
137
+ clearTimeout(this.followTimeout)
138
+ }
139
+
140
+ // The content lays out at full size even while its height animates from 0,
141
+ // so its last child already marks where the open panel will end.
142
+ distanceToReveal(scroller) {
143
+ const content = this.element.querySelector(":scope > :last-child")
144
+ const style = getComputedStyle(scroller)
145
+ const view =
146
+ scroller === document.scrollingElement
147
+ ? { top: 0, bottom: window.innerHeight }
148
+ : scroller.getBoundingClientRect()
149
+ const visibleTop = view.top + (parseFloat(style.scrollPaddingBlockStart) || 0)
150
+ const visibleBottom = view.bottom - (parseFloat(style.scrollPaddingBlockEnd) || 0)
151
+
152
+ const overflow = content.getBoundingClientRect().bottom + 12 - visibleBottom
153
+ const room = this.element.getBoundingClientRect().top - visibleTop
154
+ return Math.min(overflow, room)
155
+ }
156
+
157
+ scrollParent() {
158
+ for (let node = this.element.parentElement; node; node = node.parentElement) {
159
+ if (/(auto|scroll)/.test(getComputedStyle(node).overflowY)) return node
160
+ }
161
+ return document.scrollingElement
162
+ }
163
+ }
@@ -0,0 +1,366 @@
1
+ import { CatalogPicker } from "helpers/catalog_picker"
2
+ import { prefersReducedMotion } from "helpers/viewport"
3
+
4
+ const TONE_MODIFIERS = ["", "\u{1F3FB}", "\u{1F3FC}", "\u{1F3FD}", "\u{1F3FE}", "\u{1F3FF}"]
5
+ const TONE_STORAGE_KEY = "lostcoords_ui:emoji-picker:tone"
6
+
7
+ function markCurrent(element, current) {
8
+ element.setAttribute("aria-current", String(current))
9
+ }
10
+
11
+ export default class extends CatalogPicker {
12
+ static targets = [
13
+ "input",
14
+ "recent",
15
+ "previewEmoji",
16
+ "previewName",
17
+ "tones",
18
+ "toneToggle",
19
+ "toneMenu",
20
+ ]
21
+ static values = {
22
+ autoSubmit: Boolean,
23
+ recentLimit: { type: Number, default: 12 },
24
+ recentScope: String,
25
+ }
26
+
27
+ static optionKey = "emoji"
28
+ static catalogClass = "emoji-picker__catalog"
29
+ static previewTargets = ["previewEmoji", "previewName"]
30
+
31
+ connect() {
32
+ super.connect()
33
+ this.tone = this.readTone()
34
+ this.syncTones()
35
+ this.preloadCatalog()
36
+ }
37
+
38
+ disconnect() {
39
+ this.clearPopover()
40
+ super.disconnect()
41
+ }
42
+
43
+ show() {
44
+ this.refreshRecent()
45
+ super.show()
46
+ }
47
+
48
+ afterClose() {
49
+ super.afterClose()
50
+ this.closeTones()
51
+ }
52
+
53
+ select(event) {
54
+ const button = event.target.closest("button[data-emoji]")
55
+ if (!button || !this.element.contains(button)) return
56
+
57
+ event.preventDefault()
58
+ event.stopPropagation()
59
+
60
+ this.value = this.tonedValue(button)
61
+ this.rememberRecent(this.value)
62
+ this.defaultPreview = [this.value, button.dataset.searchName || this.value]
63
+ this.syncOptions()
64
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
65
+ this.dispatch("changed", { detail: { value: this.value, button } })
66
+ this.close()
67
+
68
+ if (this.autoSubmitValue) {
69
+ this.runAfterSheetClose(() => this.element.closest("form")?.requestSubmit())
70
+ }
71
+ }
72
+
73
+ syncOptions() {
74
+ this.inputTarget.value = this.value
75
+ this.element.querySelectorAll("button[data-emoji]").forEach((button) => {
76
+ markCurrent(button, this.tonedValue(button) === this.value)
77
+ })
78
+
79
+ const quickButton = this.quickButtons.find((button) => button.dataset.emoji === this.value)
80
+ this.triggerTarget.textContent = quickButton
81
+ ? this.triggerTarget.dataset.defaultLabel
82
+ : this.value
83
+ this.triggerTarget.dataset.value = this.value
84
+ markCurrent(this.triggerTarget, !quickButton)
85
+ }
86
+
87
+ resultButton(entry) {
88
+ const button = document.createElement("button")
89
+ button.type = "button"
90
+ button.dataset.emoji = entry.key
91
+ if (entry.tone) button.dataset.tone = "true"
92
+ button.dataset.searchName = entry.button.dataset.searchName || entry.key
93
+ button.textContent = entry.tone ? this.applyTone(entry.key, this.tone) : entry.key
94
+ button.title = button.dataset.searchName
95
+ button.setAttribute("aria-label", button.dataset.searchName)
96
+ markCurrent(button, this.tonedValue(button) === this.value)
97
+ return button
98
+ }
99
+
100
+ activateCatalog(catalog) {
101
+ super.activateCatalog(catalog)
102
+ if (!this.isSheet && this.isOpen) this.positionPopover()
103
+ }
104
+
105
+ indexCatalog(catalog) {
106
+ super.indexCatalog(catalog)
107
+ this.applyToneToCatalog()
108
+ }
109
+
110
+ indexOption(button) {
111
+ return {
112
+ ...super.indexOption(button),
113
+ name: (button.dataset.searchName || "").toLocaleLowerCase(),
114
+ tone: button.dataset.tone === "true",
115
+ }
116
+ }
117
+
118
+ clearPopover() {
119
+ this.catalogElement?.remove()
120
+ this.entries = []
121
+ }
122
+
123
+ observeSections() {
124
+ this.disconnectObserver()
125
+ if (!("IntersectionObserver" in window)) return
126
+
127
+ this.sectionObserver = new IntersectionObserver(
128
+ (entries) => {
129
+ for (const entry of entries) {
130
+ if (entry.isIntersecting) this.activeSection = entry.target.dataset.section
131
+ }
132
+ if (this.activeSection) this.setActiveTab(this.activeSection)
133
+ },
134
+ { root: this.bodyTarget, rootMargin: "0px 0px -80% 0px", threshold: 0 },
135
+ )
136
+
137
+ this.bodyTarget.querySelectorAll("[data-section]").forEach((section) => {
138
+ this.sectionObserver.observe(section)
139
+ })
140
+ }
141
+
142
+ disconnectObserver() {
143
+ this.sectionObserver?.disconnect()
144
+ this.sectionObserver = null
145
+ }
146
+
147
+ setActiveTab(key) {
148
+ if (!this.hasTabTarget) return
149
+ this.tabTargets.forEach((tab) => {
150
+ markCurrent(tab, tab.dataset.targetSection === key)
151
+ })
152
+ }
153
+
154
+ toggleTones(event) {
155
+ event.preventDefault()
156
+ event.stopPropagation()
157
+ const open = this.toneMenuTarget.hidden
158
+ this.toneMenuTarget.hidden = !open
159
+ this.toneToggleTarget.setAttribute("aria-expanded", String(open))
160
+ if (open) this.syncTones()
161
+ }
162
+
163
+ selectTone(event) {
164
+ event.preventDefault()
165
+ event.stopPropagation()
166
+ this.tone = Number(event.currentTarget.dataset.toneIndex) || 0
167
+ this.storeTone(this.tone)
168
+ this.syncTones()
169
+ this.applyToneToCatalog()
170
+ this.retoneResults()
171
+ this.syncOptions()
172
+ this.closeTones()
173
+ }
174
+
175
+ closeTones() {
176
+ if (!this.hasToneMenuTarget) return
177
+ this.toneMenuTarget.hidden = true
178
+ this.toneToggleTarget.setAttribute("aria-expanded", "false")
179
+ }
180
+
181
+ syncTones() {
182
+ if (this.hasToneToggleTarget) {
183
+ this.toneToggleTarget.textContent = `✋${TONE_MODIFIERS[this.tone]}`
184
+ }
185
+ if (this.hasToneMenuTarget) {
186
+ this.toneMenuTarget.querySelectorAll("[data-tone-index]").forEach((button) => {
187
+ markCurrent(button, Number(button.dataset.toneIndex) === this.tone)
188
+ })
189
+ }
190
+ }
191
+
192
+ applyToneToCatalog() {
193
+ for (const entry of this.entries) {
194
+ if (entry.tone) entry.button.textContent = this.applyTone(entry.key, this.tone)
195
+ }
196
+ }
197
+
198
+ retoneResults() {
199
+ if (!this.hasResultsGridTarget) return
200
+ this.resultsGridTarget.querySelectorAll("button[data-tone='true']").forEach((button) => {
201
+ button.textContent = this.applyTone(button.dataset.emoji, this.tone)
202
+ })
203
+ }
204
+
205
+ tonedValue(button) {
206
+ return this.tone && button.dataset.tone === "true"
207
+ ? this.applyTone(button.dataset.emoji, this.tone)
208
+ : button.dataset.emoji
209
+ }
210
+
211
+ applyTone(emoji, tone) {
212
+ if (!tone) return emoji
213
+
214
+ const codepoints = Array.from(emoji)
215
+ const base = codepoints[0]
216
+ const rest = codepoints.slice(1).filter((point) => point !== "️")
217
+ return base + TONE_MODIFIERS[tone] + rest.join("")
218
+ }
219
+
220
+ readTone() {
221
+ try {
222
+ return Number(window.localStorage.getItem(TONE_STORAGE_KEY)) || 0
223
+ } catch {
224
+ return 0
225
+ }
226
+ }
227
+
228
+ storeTone(tone) {
229
+ try {
230
+ window.localStorage.setItem(TONE_STORAGE_KEY, String(tone))
231
+ } catch {}
232
+ }
233
+
234
+ previewFor(button) {
235
+ const emoji = button.textContent.trim()
236
+ return [emoji, button.dataset.searchName || emoji]
237
+ }
238
+
239
+ rememberRecent(emoji) {
240
+ if (!this.recentStorageKey) return
241
+
242
+ const recent = [emoji, ...this.recentEmojis.filter((value) => value !== emoji)].slice(
243
+ 0,
244
+ this.recentLimitValue,
245
+ )
246
+ this.storeRecent(recent)
247
+ this.renderRecent(recent)
248
+ }
249
+
250
+ refreshRecent() {
251
+ if (!this.hasRecentTarget) return
252
+ this.renderRecent(this.recentEmojis)
253
+ }
254
+
255
+ renderRecent(emojis) {
256
+ if (!this.hasRecentTarget) return
257
+
258
+ const grid = this.recentTarget.querySelector(".emoji-picker__grid")
259
+ const count = this.recentTarget.querySelector(".count")
260
+ if (!grid) return
261
+
262
+ grid.replaceChildren(...emojis.map((emoji) => this.recentButton(emoji)))
263
+ if (count) count.textContent = emojis.length
264
+ }
265
+
266
+ recentButton(emoji) {
267
+ const button = document.createElement("button")
268
+ button.type = "button"
269
+ button.dataset.emoji = emoji
270
+ button.dataset.search = emoji
271
+ button.dataset.searchName = emoji
272
+ button.title = emoji
273
+ button.setAttribute("aria-label", emoji)
274
+ button.textContent = emoji
275
+ markCurrent(button, emoji === this.value)
276
+ return button
277
+ }
278
+
279
+ storeRecent(emojis) {
280
+ try {
281
+ window.localStorage.setItem(this.recentStorageKey, JSON.stringify(emojis))
282
+ } catch {}
283
+ }
284
+
285
+ positionPopover() {
286
+ const margin = 16
287
+ const anchorRect = this.popoverTarget.offsetParent?.getBoundingClientRect()
288
+ if (!anchorRect) return
289
+
290
+ const popoverWidth = this.popoverTarget.offsetWidth
291
+ let inlineOffset = 0
292
+ const desiredLeft = anchorRect.left
293
+ const desiredRight = desiredLeft + popoverWidth
294
+
295
+ if (desiredLeft < margin) inlineOffset += margin - desiredLeft
296
+ if (desiredRight > window.innerWidth - margin)
297
+ inlineOffset -= desiredRight - (window.innerWidth - margin)
298
+
299
+ this.popoverTarget.style.insetInlineStart = `${inlineOffset}px`
300
+ this.popoverTarget.style.insetInlineEnd = "auto"
301
+ }
302
+
303
+ resetPopoverPosition() {
304
+ this.popoverTarget.style.insetInlineStart = ""
305
+ this.popoverTarget.style.insetInlineEnd = ""
306
+ }
307
+
308
+ get value() {
309
+ return this.inputTarget.value
310
+ }
311
+
312
+ set value(value) {
313
+ this.inputTarget.value = value
314
+ }
315
+
316
+ get catalogAnchor() {
317
+ return this.hasRecentTarget ? this.recentTarget : null
318
+ }
319
+
320
+ get sheetMotion() {
321
+ const duration = prefersReducedMotion() ? 0 : 260
322
+ return {
323
+ duration,
324
+ settle: duration && 280,
325
+ transition: "transform 260ms var(--ease-out-expo, ease-out)",
326
+ offscreen: "translateY(100%)",
327
+ }
328
+ }
329
+
330
+ get quickButtons() {
331
+ return Array.from(this.element.querySelectorAll(":scope > button[data-emoji]"))
332
+ }
333
+
334
+ get recentEmojis() {
335
+ return [...this.storedRecent, ...this.initialRecent]
336
+ .filter((emoji, index, emojis) => emoji && emojis.indexOf(emoji) === index)
337
+ .slice(0, this.recentLimitValue)
338
+ }
339
+
340
+ get initialRecent() {
341
+ if (!this.initialRecentValue) {
342
+ this.initialRecentValue = this.hasRecentTarget
343
+ ? Array.from(this.recentTarget.querySelectorAll("button[data-emoji]")).map(
344
+ (button) => button.dataset.emoji,
345
+ )
346
+ : []
347
+ }
348
+ return this.initialRecentValue
349
+ }
350
+
351
+ get storedRecent() {
352
+ if (!this.recentStorageKey) return []
353
+
354
+ try {
355
+ const parsed = JSON.parse(window.localStorage.getItem(this.recentStorageKey) || "[]")
356
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : []
357
+ } catch {
358
+ return []
359
+ }
360
+ }
361
+
362
+ get recentStorageKey() {
363
+ if (!this.hasRecentScopeValue || !this.recentScopeValue) return null
364
+ return `lostcoords_ui:emoji-picker:${this.recentScopeValue}`
365
+ }
366
+ }
@@ -0,0 +1,27 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { registerDialog } from "helpers/dialog"
3
+
4
+ // Opens a dialog whose body is a Turbo frame fetched on open. It reloads on every
5
+ // open, so what it shows is never staler than the page that opened it.
6
+ export default class extends Controller {
7
+ static targets = ["dialog", "frame"]
8
+
9
+ disconnect() {
10
+ this.unregisterDialog?.()
11
+ }
12
+
13
+ open(event) {
14
+ event?.preventDefault()
15
+ if (this.hasFrameTarget) {
16
+ if (this.frameTarget.src) this.frameTarget.reload()
17
+ else this.frameTarget.src = this.frameTarget.dataset.src
18
+ }
19
+ if (!this.dialogTarget.open) this.dialogTarget.showModal()
20
+
21
+ this.unregisterDialog = registerDialog(this.dialogTarget)
22
+ }
23
+
24
+ close() {
25
+ if (this.dialogTarget.open) this.dialogTarget.close()
26
+ }
27
+ }