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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +352 -0
- data/app/assets/tailwind/lostcoords/ui/avatar.css +11 -0
- data/app/assets/tailwind/lostcoords/ui/card.css +26 -0
- data/app/assets/tailwind/lostcoords/ui/copy-button.css +33 -0
- data/app/assets/tailwind/lostcoords/ui/dialog.css +60 -0
- data/app/assets/tailwind/lostcoords/ui/disclosure.css +43 -0
- data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +462 -0
- data/app/assets/tailwind/lostcoords/ui/field.css +6 -0
- data/app/assets/tailwind/lostcoords/ui/glass.css +37 -0
- data/app/assets/tailwind/lostcoords/ui/layout.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/popover.css +75 -0
- data/app/assets/tailwind/lostcoords/ui/reading.css +60 -0
- data/app/assets/tailwind/lostcoords/ui/sheet.css +170 -0
- data/app/assets/tailwind/lostcoords/ui/squircle.css +32 -0
- data/app/assets/tailwind/lostcoords/ui/theme.css +255 -0
- data/app/assets/tailwind/lostcoords/ui/typography.css +81 -0
- data/app/assets/tailwind/lostcoords/ui/ui.css +21 -0
- data/app/assets/tailwind/lostcoords/ui/utilities.css +45 -0
- data/app/assets/tailwind/lostcoords_ui/engine.css +1 -0
- data/app/helpers/lostcoords/ui/badge_helper.rb +162 -0
- data/app/helpers/lostcoords/ui/button_helper.rb +70 -0
- data/app/helpers/lostcoords/ui/card_helper.rb +60 -0
- data/app/helpers/lostcoords/ui/copy_button_helper.rb +40 -0
- data/app/helpers/lostcoords/ui/dialog_helper.rb +33 -0
- data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +245 -0
- data/app/helpers/lostcoords/ui/feedback_helper.rb +30 -0
- data/app/helpers/lostcoords/ui/form_helper.rb +93 -0
- data/app/helpers/lostcoords/ui/glass_helper.rb +25 -0
- data/app/helpers/lostcoords/ui/icon_helper.rb +15 -0
- data/app/helpers/lostcoords/ui/layout_helper.rb +60 -0
- data/app/helpers/lostcoords/ui/menu_helper.rb +41 -0
- data/app/helpers/lostcoords/ui/primitives_helper.rb +152 -0
- data/app/helpers/lostcoords/ui/reading_helper.rb +15 -0
- data/app/helpers/lostcoords/ui/render_attributes_helper.rb +46 -0
- data/app/helpers/lostcoords/ui/sheet_helper.rb +54 -0
- data/app/helpers/lostcoords/ui/structures_helper.rb +159 -0
- data/app/helpers/lostcoords/ui/tailwind_helper.rb +14 -0
- data/app/helpers/lostcoords/ui/translation_helper.rb +13 -0
- data/app/helpers/lostcoords/ui/typography_helper.rb +28 -0
- data/app/javascript/lostcoords/ui/controllers/confirm_dialog_controller.js +39 -0
- data/app/javascript/lostcoords/ui/controllers/disclosure_controller.js +163 -0
- data/app/javascript/lostcoords/ui/controllers/emoji_picker_controller.js +366 -0
- data/app/javascript/lostcoords/ui/controllers/modal_controller.js +27 -0
- data/app/javascript/lostcoords/ui/controllers/row_menu_controller.js +83 -0
- data/app/javascript/lostcoords/ui/controllers/sheet_controller.js +422 -0
- data/app/javascript/lostcoords/ui/controllers/toast_controller.js +69 -0
- data/app/javascript/lostcoords/ui/controllers/ui_copy_controller.js +56 -0
- data/app/javascript/lostcoords/ui/controllers/ui_popover_controller.js +17 -0
- data/app/javascript/lostcoords/ui/controllers/ui_selection_controller.js +69 -0
- data/app/javascript/lostcoords/ui/controllers/ui_toggle_controller.js +13 -0
- data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +455 -0
- data/app/javascript/lostcoords/ui/helpers/context_dialog.js +29 -0
- data/app/javascript/lostcoords/ui/helpers/dialog.js +31 -0
- data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +155 -0
- data/app/javascript/lostcoords/ui/helpers/floating_panel.js +108 -0
- data/app/javascript/lostcoords/ui/helpers/menu_keys.js +27 -0
- data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +13 -0
- data/app/javascript/lostcoords/ui/helpers/top_layer.js +102 -0
- data/app/javascript/lostcoords/ui/helpers/viewport.js +13 -0
- data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +176 -0
- data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +29 -0
- data/app/views/lostcoords/ui/components/_form_alert.html.erb +4 -0
- data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +13 -0
- data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +5 -0
- data/app/views/lostcoords/ui/components/_skeleton.html.erb +4 -0
- data/app/views/lostcoords/ui/components/_spinner.html.erb +10 -0
- data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +8 -0
- data/config/emoji_search_aliases.yml +146 -0
- data/config/emoji_search_names.yml +1917 -0
- data/config/emoji_search_names_ja.yml +6902 -0
- data/config/helper_renames.json +113 -0
- data/config/importmap.rb +3 -0
- data/config/locales/en.yml +33 -0
- data/config/locales/es.yml +33 -0
- data/config/locales/ja.yml +33 -0
- data/lib/lostcoords/ui/action_view.rb +10 -0
- data/lib/lostcoords/ui/builders.rb +42 -0
- data/lib/lostcoords/ui/components.rb +13 -0
- data/lib/lostcoords/ui/engine.rb +22 -0
- data/lib/lostcoords/ui/helpers.rb +11 -0
- data/lib/lostcoords/ui/icons.rb +24 -0
- data/lib/lostcoords/ui/rails.rb +3 -0
- data/lib/lostcoords/ui/resources.rb +17 -0
- data/lib/lostcoords/ui/tokens.rb +14 -0
- data/lib/lostcoords/ui/version.rb +5 -0
- data/lib/lostcoords/ui.rb +2 -0
- data/lib/tasks/lostcoords_ui.rake +15 -0
- data/script/migrate_helpers.rb +136 -0
- data/vendor/heroicons/LICENSE +21 -0
- data/vendor/heroicons/icons.json +1303 -0
- 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,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
|
+
}
|