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,155 @@
|
|
|
1
|
+
const stack = []
|
|
2
|
+
let nextOrder = 0
|
|
3
|
+
let lockedScrollY = 0
|
|
4
|
+
let scrollHolds = 0
|
|
5
|
+
|
|
6
|
+
document.addEventListener("keydown", dismissOnEscape, true)
|
|
7
|
+
|
|
8
|
+
export function registerDismissable(options) {
|
|
9
|
+
const entry = {
|
|
10
|
+
type: "panel",
|
|
11
|
+
isOpen: defaultIsOpen,
|
|
12
|
+
...options,
|
|
13
|
+
}
|
|
14
|
+
entry.priority ??= priorityFor(entry.type)
|
|
15
|
+
entry.order = nextOrder++
|
|
16
|
+
|
|
17
|
+
stack.push(entry)
|
|
18
|
+
syncModalScrollLock()
|
|
19
|
+
|
|
20
|
+
if (entry.type === "modal" && entry.element instanceof HTMLDialogElement) {
|
|
21
|
+
entry.cancel = (event) => cancelDialog(event, entry)
|
|
22
|
+
entry.element.addEventListener("cancel", entry.cancel)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return () => unregisterDismissable(entry)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function holdDocumentScroll() {
|
|
29
|
+
scrollHolds++
|
|
30
|
+
syncModalScrollLock()
|
|
31
|
+
|
|
32
|
+
let held = true
|
|
33
|
+
return () => {
|
|
34
|
+
if (!held) return
|
|
35
|
+
held = false
|
|
36
|
+
scrollHolds--
|
|
37
|
+
syncModalScrollLock()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function closeTopDismissable(event) {
|
|
42
|
+
const entry = topDismissable()
|
|
43
|
+
if (!entry) return false
|
|
44
|
+
|
|
45
|
+
closeEntry(entry, event)
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function dismissOnEscape(event) {
|
|
50
|
+
if (event.key !== "Escape" && event.key !== "Esc") return
|
|
51
|
+
if (!closeTopDismissable(event)) return
|
|
52
|
+
|
|
53
|
+
event.preventDefault()
|
|
54
|
+
event.stopPropagation()
|
|
55
|
+
event.stopImmediatePropagation()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cancelDialog(event, dialogEntry) {
|
|
59
|
+
const entry = topDismissable(dialogEntry.element)
|
|
60
|
+
if (!entry) return
|
|
61
|
+
|
|
62
|
+
event.preventDefault()
|
|
63
|
+
closeEntry(entry, event)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function closeEntry(entry, event) {
|
|
67
|
+
const closed = entry.close?.(event)
|
|
68
|
+
if (closed === false) return
|
|
69
|
+
|
|
70
|
+
entry.restoreFocus?.()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function topDismissable(scope = topModal()?.element) {
|
|
74
|
+
const entries = openEntries()
|
|
75
|
+
const scopedEntries = scope
|
|
76
|
+
? entries.filter((entry) => entry.element === scope || scope.contains(entry.element))
|
|
77
|
+
: entries
|
|
78
|
+
|
|
79
|
+
return scopedEntries.sort((a, b) => a.priority - b.priority || a.order - b.order).at(-1)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function topModal() {
|
|
83
|
+
return openEntries()
|
|
84
|
+
.filter((entry) => entry.type === "modal")
|
|
85
|
+
.at(-1)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function openEntries() {
|
|
89
|
+
return stack.filter((entry) => entry.element?.isConnected && entry.isOpen.call(entry))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function unregisterDismissable(entry) {
|
|
93
|
+
const index = stack.indexOf(entry)
|
|
94
|
+
if (index >= 0) stack.splice(index, 1)
|
|
95
|
+
|
|
96
|
+
if (entry.cancel) {
|
|
97
|
+
entry.element.removeEventListener("cancel", entry.cancel)
|
|
98
|
+
entry.cancel = null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
syncModalScrollLock()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function defaultIsOpen() {
|
|
105
|
+
if (this.element instanceof HTMLDialogElement) return this.element.open
|
|
106
|
+
if (this.element.hidden) return false
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function priorityFor(type) {
|
|
111
|
+
if (type === "popover") return 30
|
|
112
|
+
if (type === "panel") return 20
|
|
113
|
+
return 10
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function syncModalScrollLock() {
|
|
117
|
+
if (
|
|
118
|
+
scrollHolds > 0 ||
|
|
119
|
+
openEntries().some((entry) => entry.type === "modal" && entry.lockScroll !== false)
|
|
120
|
+
) {
|
|
121
|
+
lockDocumentScroll()
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
unlockDocumentScroll()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function lockDocumentScroll() {
|
|
129
|
+
const body = document.body
|
|
130
|
+
if (!body || body.dataset.modalScrollLock === "true") return
|
|
131
|
+
|
|
132
|
+
lockedScrollY = window.scrollY
|
|
133
|
+
body.dataset.modalScrollLock = "true"
|
|
134
|
+
body.style.position = "fixed"
|
|
135
|
+
body.style.inset = "0"
|
|
136
|
+
body.style.top = `-${lockedScrollY}px`
|
|
137
|
+
// Pinning the body drags the sticky nav off with the scrolled-away page;
|
|
138
|
+
// the nav offsets itself by this to stay where it was.
|
|
139
|
+
body.style.setProperty("--scroll-lock-y", `${lockedScrollY}px`)
|
|
140
|
+
body.style.inlineSize = "100%"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function unlockDocumentScroll() {
|
|
144
|
+
const body = document.body
|
|
145
|
+
if (body?.dataset.modalScrollLock !== "true") return
|
|
146
|
+
|
|
147
|
+
body.style.position = ""
|
|
148
|
+
body.style.inset = ""
|
|
149
|
+
body.style.top = ""
|
|
150
|
+
body.style.removeProperty("--scroll-lock-y")
|
|
151
|
+
body.style.inlineSize = ""
|
|
152
|
+
delete body.dataset.modalScrollLock
|
|
153
|
+
window.scrollTo({ top: lockedScrollY, behavior: "instant" })
|
|
154
|
+
lockedScrollY = 0
|
|
155
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { registerDismissable } from "helpers/dismiss_stack"
|
|
3
|
+
import { liftToTopLayer } from "helpers/top_layer"
|
|
4
|
+
|
|
5
|
+
const BEFORE_OPEN = "overlay:before-open"
|
|
6
|
+
const PAGE_LEAVES = ["turbo:before-cache", "turbo:before-visit", "turbo:before-render"]
|
|
7
|
+
|
|
8
|
+
export class FloatingPanel extends Controller {
|
|
9
|
+
static targets = ["trigger", "popover"]
|
|
10
|
+
static values = { lift: { type: Boolean, default: true } }
|
|
11
|
+
|
|
12
|
+
static togglesHidden = false
|
|
13
|
+
static closesOnOutsideClick = true
|
|
14
|
+
|
|
15
|
+
initialize() {
|
|
16
|
+
this.closeForOther = (event) => {
|
|
17
|
+
if (event.detail?.source !== this) this.close()
|
|
18
|
+
}
|
|
19
|
+
this.closeOnOutsideClick = (event) => {
|
|
20
|
+
if (!this.element.contains(event.target)) this.close()
|
|
21
|
+
}
|
|
22
|
+
this.closeForPageLeave = () => this.closeNow({ immediate: true })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
connect() {
|
|
26
|
+
document.addEventListener(BEFORE_OPEN, this.closeForOther)
|
|
27
|
+
for (const type of PAGE_LEAVES) document.addEventListener(type, this.closeForPageLeave)
|
|
28
|
+
window.addEventListener("pagehide", this.closeForPageLeave)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
disconnect() {
|
|
32
|
+
document.removeEventListener(BEFORE_OPEN, this.closeForOther)
|
|
33
|
+
for (const type of PAGE_LEAVES) document.removeEventListener(type, this.closeForPageLeave)
|
|
34
|
+
window.removeEventListener("pagehide", this.closeForPageLeave)
|
|
35
|
+
this.release({ immediate: true })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
toggle(event) {
|
|
39
|
+
event?.preventDefault()
|
|
40
|
+
event?.stopPropagation()
|
|
41
|
+
this.isOpen ? this.close() : this.open()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
open(opener) {
|
|
45
|
+
if (this.isOpen) return
|
|
46
|
+
this.opener = opener instanceof Element ? opener : this.triggerTarget
|
|
47
|
+
|
|
48
|
+
document.dispatchEvent(new CustomEvent(BEFORE_OPEN, { detail: { source: this } }))
|
|
49
|
+
this.show()
|
|
50
|
+
if (this.lifts) this.releaseLift = liftToTopLayer(this.popoverTarget, this.opener)
|
|
51
|
+
this.triggerTarget.setAttribute("aria-expanded", "true")
|
|
52
|
+
|
|
53
|
+
if (this.constructor.closesOnOutsideClick)
|
|
54
|
+
document.addEventListener("click", this.closeOnOutsideClick)
|
|
55
|
+
this.unregisterDismissable = registerDismissable({
|
|
56
|
+
element: this.element,
|
|
57
|
+
type: "popover",
|
|
58
|
+
close: () => this.close(),
|
|
59
|
+
restoreFocus: () => this.focusOnClose?.focus({ preventScroll: true }),
|
|
60
|
+
isOpen: () => this.isOpen,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
close() {
|
|
65
|
+
this.closeNow()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
closeNow({ immediate = false } = {}) {
|
|
69
|
+
if (!this.isOpen) return
|
|
70
|
+
|
|
71
|
+
this.hide()
|
|
72
|
+
this.release({ immediate })
|
|
73
|
+
this.triggerTarget.setAttribute("aria-expanded", "false")
|
|
74
|
+
this.afterClose()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
afterClose() {}
|
|
78
|
+
|
|
79
|
+
release({ immediate = false } = {}) {
|
|
80
|
+
this.releaseLift?.({ immediate })
|
|
81
|
+
this.releaseLift = null
|
|
82
|
+
document.removeEventListener("click", this.closeOnOutsideClick)
|
|
83
|
+
this.unregisterDismissable?.()
|
|
84
|
+
this.unregisterDismissable = null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
show() {
|
|
88
|
+
if (this.constructor.togglesHidden) this.popoverTarget.hidden = false
|
|
89
|
+
this.popoverTarget.toggleAttribute("data-open", true)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
hide() {
|
|
93
|
+
this.popoverTarget.toggleAttribute("data-open", false)
|
|
94
|
+
if (this.constructor.togglesHidden) this.popoverTarget.hidden = true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get isOpen() {
|
|
98
|
+
return this.popoverTarget.hasAttribute("data-open")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get lifts() {
|
|
102
|
+
return this.liftValue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get focusOnClose() {
|
|
106
|
+
return this.opener
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const ITEMS = '[role^="menuitem"]:not([disabled]):not([aria-disabled="true"])'
|
|
2
|
+
|
|
3
|
+
function items(menu) {
|
|
4
|
+
return [...menu.querySelectorAll(ITEMS)].filter((item) => item.getClientRects().length > 0)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function focusFirstMenuItem(menu) {
|
|
8
|
+
items(menu)[0]?.focus()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The ARIA menu pattern: arrows move between items and wrap, Home/End jump.
|
|
12
|
+
export function navigateMenu(event, menu) {
|
|
13
|
+
const list = items(menu)
|
|
14
|
+
if (list.length === 0) return
|
|
15
|
+
|
|
16
|
+
const index = list.indexOf(document.activeElement)
|
|
17
|
+
const next = {
|
|
18
|
+
ArrowDown: (index + 1) % list.length,
|
|
19
|
+
ArrowUp: (index - 1 + list.length) % list.length,
|
|
20
|
+
Home: 0,
|
|
21
|
+
End: list.length - 1,
|
|
22
|
+
}[event.key]
|
|
23
|
+
if (next === undefined) return
|
|
24
|
+
|
|
25
|
+
event.preventDefault()
|
|
26
|
+
list[next].focus()
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { prefersReducedMotion } from "helpers/viewport"
|
|
2
|
+
// The sheet beat lives in CSS (--sheet-duration, --ease-sheet) so CSS- and
|
|
3
|
+
// JS-driven slides can't drift apart. Inline transitions use the variables
|
|
4
|
+
// directly; timers need the duration in milliseconds.
|
|
5
|
+
export const SHEET_TIMING = "var(--sheet-duration) var(--ease-sheet)"
|
|
6
|
+
|
|
7
|
+
export function sheetDuration() {
|
|
8
|
+
if (prefersReducedMotion()) return 0
|
|
9
|
+
const value = getComputedStyle(document.documentElement)
|
|
10
|
+
.getPropertyValue("--sheet-duration")
|
|
11
|
+
.trim()
|
|
12
|
+
return value.endsWith("ms") ? parseFloat(value) : parseFloat(value) * 1000
|
|
13
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Floating panels (menus, pickers) open in the browser's top layer so no
|
|
2
|
+
// stacking context or overflow clip can paint over them: every <section> is
|
|
3
|
+
// its own stacking context, so a z-index alone can never lift a menu above the
|
|
4
|
+
// next section, and a menu opened inside a <dialog> must clear the dialog too.
|
|
5
|
+
//
|
|
6
|
+
// The panel's stylesheet still decides where it sits. The caller makes the
|
|
7
|
+
// panel visible in place, liftToTopLayer measures that designed spot relative
|
|
8
|
+
// to its trigger, then re-pins it there as a fixed top-layer popover, follows
|
|
9
|
+
// the trigger on scroll and resize, and flips above when it would leave the
|
|
10
|
+
// viewport. Browsers without the Popover API keep the in-place panel.
|
|
11
|
+
|
|
12
|
+
const MARGIN = 8
|
|
13
|
+
const PLACEMENT = ["position", "inset", "top", "left", "margin", "translate", "inlineSize"]
|
|
14
|
+
|
|
15
|
+
const lifts = new WeakMap()
|
|
16
|
+
const pinned = new WeakSet()
|
|
17
|
+
|
|
18
|
+
export function liftToTopLayer(panel, trigger) {
|
|
19
|
+
if (typeof panel.showPopover !== "function") return () => {}
|
|
20
|
+
|
|
21
|
+
lifts.get(panel)?.()
|
|
22
|
+
if (pinned.has(panel)) reset(panel)
|
|
23
|
+
|
|
24
|
+
const anchor = trigger.getBoundingClientRect()
|
|
25
|
+
const placed = panel.getBoundingClientRect()
|
|
26
|
+
const offsetX = placed.left - anchor.left
|
|
27
|
+
const offsetY = placed.top - anchor.top
|
|
28
|
+
const gap = placed.top - anchor.bottom
|
|
29
|
+
|
|
30
|
+
panel.setAttribute("popover", "manual")
|
|
31
|
+
panel.showPopover()
|
|
32
|
+
Object.assign(panel.style, {
|
|
33
|
+
position: "fixed",
|
|
34
|
+
inset: "auto",
|
|
35
|
+
margin: "0",
|
|
36
|
+
translate: "none",
|
|
37
|
+
inlineSize: `${placed.width}px`,
|
|
38
|
+
})
|
|
39
|
+
pinned.add(panel)
|
|
40
|
+
|
|
41
|
+
let frame = null
|
|
42
|
+
const place = () => {
|
|
43
|
+
frame = null
|
|
44
|
+
const a = trigger.getBoundingClientRect()
|
|
45
|
+
const width = panel.offsetWidth
|
|
46
|
+
const height = panel.offsetHeight
|
|
47
|
+
const { top: minTop, bottom: maxBottom } = visibleBounds(trigger)
|
|
48
|
+
const right = document.documentElement.clientWidth - MARGIN
|
|
49
|
+
|
|
50
|
+
let top = a.top + offsetY
|
|
51
|
+
const flipped = a.top - gap - height
|
|
52
|
+
if (top + height > maxBottom - MARGIN && flipped >= minTop + MARGIN) top = flipped
|
|
53
|
+
top = Math.max(minTop + MARGIN, Math.min(top, maxBottom - MARGIN - height))
|
|
54
|
+
|
|
55
|
+
panel.style.left = `${Math.max(MARGIN, Math.min(a.left + offsetX, right - width))}px`
|
|
56
|
+
panel.style.top = `${top}px`
|
|
57
|
+
}
|
|
58
|
+
const schedule = () => (frame ??= requestAnimationFrame(place))
|
|
59
|
+
|
|
60
|
+
place()
|
|
61
|
+
const resizes = new ResizeObserver(schedule)
|
|
62
|
+
resizes.observe(panel)
|
|
63
|
+
window.addEventListener("scroll", schedule, { capture: true, passive: true })
|
|
64
|
+
window.addEventListener("resize", schedule, { passive: true })
|
|
65
|
+
|
|
66
|
+
const release = ({ immediate = false } = {}) => {
|
|
67
|
+
if (lifts.get(panel) !== release) return
|
|
68
|
+
lifts.delete(panel)
|
|
69
|
+
if (frame) cancelAnimationFrame(frame)
|
|
70
|
+
resizes.disconnect()
|
|
71
|
+
window.removeEventListener("scroll", schedule, { capture: true })
|
|
72
|
+
window.removeEventListener("resize", schedule)
|
|
73
|
+
if (immediate) return reset(panel)
|
|
74
|
+
|
|
75
|
+
// Keep the pinned spot while the closing transition plays out, then hand
|
|
76
|
+
// the panel back to its stylesheet unless it has been lifted again.
|
|
77
|
+
Promise.allSettled(panel.getAnimations().map((animation) => animation.finished)).then(() => {
|
|
78
|
+
if (!lifts.has(panel)) reset(panel)
|
|
79
|
+
})
|
|
80
|
+
if (panel.matches(":popover-open")) panel.hidePopover()
|
|
81
|
+
}
|
|
82
|
+
lifts.set(panel, release)
|
|
83
|
+
return release
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function reset(panel) {
|
|
87
|
+
pinned.delete(panel)
|
|
88
|
+
if (panel.matches(":popover-open")) panel.hidePopover()
|
|
89
|
+
panel.removeAttribute("popover")
|
|
90
|
+
for (const property of PLACEMENT) panel.style[property] = ""
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Chrome floating over the bottom of the page ([data-top-layer-floor], a phone
|
|
94
|
+
// nav) acts as the floor: a panel that would open under it flips or tucks above.
|
|
95
|
+
function visibleBounds(trigger) {
|
|
96
|
+
let bottom = window.innerHeight
|
|
97
|
+
const floor = document.querySelector("body > [data-top-layer-floor]")
|
|
98
|
+
if (floor?.offsetParent && !floor.contains(trigger))
|
|
99
|
+
bottom = Math.min(bottom, floor.getBoundingClientRect().top)
|
|
100
|
+
|
|
101
|
+
return { top: 0, bottom }
|
|
102
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const SHEET_QUERY = "(width < 40rem)"
|
|
2
|
+
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
|
|
3
|
+
|
|
4
|
+
export const TEXT_ENTRY =
|
|
5
|
+
"textarea, [contenteditable]:not([contenteditable=false]), input:not([type=button], [type=submit], [type=reset], [type=checkbox], [type=radio], [type=range], [type=color], [type=file], [type=image], [type=hidden])"
|
|
6
|
+
|
|
7
|
+
export function isSheetViewport() {
|
|
8
|
+
return window.matchMedia(SHEET_QUERY).matches
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function prefersReducedMotion() {
|
|
12
|
+
return window.matchMedia(REDUCED_MOTION_QUERY).matches
|
|
13
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<%# locals: (form:, field:, value: nil, quick: [], recent: [], recent_scope: nil, label:, catalog_url:, picker_class: nil, trigger_class: nil, popover_class: nil, auto_submit: false, attributes: {}) %>
|
|
2
|
+
<% quick = Array(quick).uniq
|
|
3
|
+
recent = Array(recent).uniq
|
|
4
|
+
selected = value.presence || form.object&.public_send(field).presence || quick.first || lc_emoji_picker_groups.first[:emojis].first
|
|
5
|
+
selected_is_quick = quick.include?(selected)
|
|
6
|
+
trigger_label = selected_is_quick ? "+" : selected
|
|
7
|
+
tone_modifiers = [ "", "🏻", "🏼", "🏽", "🏾", "🏿" ]
|
|
8
|
+
tone_names = %w[default light medium_light medium medium_dark dark] %>
|
|
9
|
+
|
|
10
|
+
<%= tag.div(**lc_attributes({ class: lc_tw("emoji-picker", picker_class), data: { controller: "emoji-picker", emoji_picker_auto_submit_value: auto_submit, emoji_picker_catalog_url_value: catalog_url, emoji_picker_recent_scope_value: recent_scope } }, attributes)) do %>
|
|
11
|
+
<%= form.hidden_field field,
|
|
12
|
+
value: selected,
|
|
13
|
+
data: { emoji_picker_target: "input" } %>
|
|
14
|
+
|
|
15
|
+
<% quick.each do |emoji| %>
|
|
16
|
+
<button
|
|
17
|
+
type="button"
|
|
18
|
+
class="emoji-picker__choice"
|
|
19
|
+
aria-current="<%= emoji == selected %>"
|
|
20
|
+
data-emoji="<%= emoji %>"
|
|
21
|
+
data-action="click->emoji-picker#select"
|
|
22
|
+
>
|
|
23
|
+
<%= emoji %>
|
|
24
|
+
</button>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<div class="emoji-picker__more">
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
class="emoji-picker__trigger <%= trigger_class %>"
|
|
31
|
+
aria-current="<%= !selected_is_quick %>"
|
|
32
|
+
data-emoji-picker-target="trigger"
|
|
33
|
+
data-action="click->emoji-picker#toggle"
|
|
34
|
+
data-default-label="+"
|
|
35
|
+
data-value="<%= selected %>"
|
|
36
|
+
aria-label="<%= label %>"
|
|
37
|
+
aria-expanded="false"
|
|
38
|
+
>
|
|
39
|
+
<%= trigger_label %>
|
|
40
|
+
</button>
|
|
41
|
+
|
|
42
|
+
<div
|
|
43
|
+
class="emoji-picker__popover <%= popover_class %>"
|
|
44
|
+
data-emoji-picker-target="popover"
|
|
45
|
+
data-controller="sheet"
|
|
46
|
+
data-action="click->emoji-picker#select pointerover->emoji-picker#preview focusin->emoji-picker#preview sheet:dismiss->emoji-picker#close"
|
|
47
|
+
role="dialog"
|
|
48
|
+
aria-label="<%= label %>"
|
|
49
|
+
hidden
|
|
50
|
+
>
|
|
51
|
+
<span class="emoji-picker__handle" data-sheet-target="handle" aria-hidden="true"></span>
|
|
52
|
+
|
|
53
|
+
<div class="emoji-picker__search">
|
|
54
|
+
<div class="emoji-picker__search-field">
|
|
55
|
+
<svg class="emoji-picker__search-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
56
|
+
<circle cx="9" cy="9" r="6" stroke="currentColor" stroke-width="1.6" />
|
|
57
|
+
<path d="m14 14 3.5 3.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
|
58
|
+
</svg>
|
|
59
|
+
|
|
60
|
+
<input
|
|
61
|
+
class="emoji-picker__search-input"
|
|
62
|
+
type="search"
|
|
63
|
+
inputmode="search"
|
|
64
|
+
autocomplete="off"
|
|
65
|
+
autocapitalize="off"
|
|
66
|
+
spellcheck="false"
|
|
67
|
+
enterkeyhint="search"
|
|
68
|
+
aria-label="<%= lc_t("lostcoords.ui.emoji_picker.search_label") %>"
|
|
69
|
+
placeholder="<%= lc_t("lostcoords.ui.emoji_picker.search_placeholder") %>"
|
|
70
|
+
data-emoji-picker-target="searchInput"
|
|
71
|
+
data-action="input->emoji-picker#filter keydown->emoji-picker#onSearchKeydown"
|
|
72
|
+
>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="emoji-picker__tones" data-emoji-picker-target="tones">
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
class="emoji-picker__tone-toggle"
|
|
79
|
+
data-emoji-picker-target="toneToggle"
|
|
80
|
+
data-action="click->emoji-picker#toggleTones"
|
|
81
|
+
aria-haspopup="true"
|
|
82
|
+
aria-expanded="false"
|
|
83
|
+
aria-label="<%= lc_t("lostcoords.ui.emoji_picker.skin_tone") %>"
|
|
84
|
+
>✋</button>
|
|
85
|
+
|
|
86
|
+
<div class="emoji-picker__tone-menu <%= popover_class %>" data-emoji-picker-target="toneMenu" role="menu" hidden>
|
|
87
|
+
<% tone_modifiers.each_with_index do |modifier, index| %>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
class="emoji-picker__tone"
|
|
91
|
+
role="menuitemradio"
|
|
92
|
+
data-tone-index="<%= index %>"
|
|
93
|
+
data-action="click->emoji-picker#selectTone"
|
|
94
|
+
aria-label="<%= lc_t("lostcoords.ui.emoji_picker.skin_tones.#{tone_names[index]}") %>"
|
|
95
|
+
>✋<%= modifier %></button>
|
|
96
|
+
<% end %>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<nav class="emoji-picker__tabs" data-emoji-picker-target="tabs" aria-label="<%= lc_t("lostcoords.ui.emoji_picker.categories_nav") %>">
|
|
102
|
+
<% if recent.any? %>
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
class="emoji-picker__tab"
|
|
106
|
+
data-emoji-picker-target="tab"
|
|
107
|
+
data-target-section="recent"
|
|
108
|
+
data-action="click->emoji-picker#jumpTo"
|
|
109
|
+
aria-label="<%= lc_t("lostcoords.ui.emoji_picker.recently_used") %>"
|
|
110
|
+
>🕘</button>
|
|
111
|
+
<% end %>
|
|
112
|
+
|
|
113
|
+
<% lc_emoji_picker_groups.each do |group| %>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
class="emoji-picker__tab"
|
|
117
|
+
data-emoji-picker-target="tab"
|
|
118
|
+
data-target-section="<%= group[:key] %>"
|
|
119
|
+
data-action="click->emoji-picker#jumpTo"
|
|
120
|
+
aria-label="<%= group[:name] %>"
|
|
121
|
+
><%= group[:icon] %></button>
|
|
122
|
+
<% end %>
|
|
123
|
+
</nav>
|
|
124
|
+
|
|
125
|
+
<div class="emoji-picker__body" data-emoji-picker-target="body">
|
|
126
|
+
<% if recent.any? %>
|
|
127
|
+
<section
|
|
128
|
+
class="emoji-picker__recent"
|
|
129
|
+
data-section="recent"
|
|
130
|
+
aria-label="<%= lc_t('lostcoords.ui.emoji_picker.recently_used') %>"
|
|
131
|
+
data-emoji-picker-target="recent"
|
|
132
|
+
>
|
|
133
|
+
<header class="emoji-picker__category-head">
|
|
134
|
+
<h3><%= lc_t("lostcoords.ui.emoji_picker.recently_used") %></h3>
|
|
135
|
+
<span class="count"><%= recent.size %></span>
|
|
136
|
+
</header>
|
|
137
|
+
|
|
138
|
+
<div class="emoji-picker__grid">
|
|
139
|
+
<% recent.each do |emoji| %>
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
aria-current="<%= emoji == selected %>"
|
|
143
|
+
data-emoji="<%= emoji %>"
|
|
144
|
+
data-search="<%= emoji %>"
|
|
145
|
+
data-search-name="<%= lc_emoji_picker_search_name(emoji) %>"
|
|
146
|
+
title="<%= lc_emoji_picker_search_name(emoji) %>"
|
|
147
|
+
aria-label="<%= lc_emoji_picker_search_name(emoji) %>"
|
|
148
|
+
>
|
|
149
|
+
<%= emoji %>
|
|
150
|
+
</button>
|
|
151
|
+
<% end %>
|
|
152
|
+
</div>
|
|
153
|
+
</section>
|
|
154
|
+
<% end %>
|
|
155
|
+
|
|
156
|
+
<section class="emoji-picker__results" data-emoji-picker-target="results" aria-label="<%= lc_t('lostcoords.ui.emoji_picker.search_results') %>" hidden>
|
|
157
|
+
<header class="emoji-picker__category-head">
|
|
158
|
+
<h3><%= lc_t("lostcoords.ui.emoji_picker.search_results") %></h3>
|
|
159
|
+
<span class="count" data-emoji-picker-target="resultsCount">0</span>
|
|
160
|
+
</header>
|
|
161
|
+
|
|
162
|
+
<div class="emoji-picker__grid" data-emoji-picker-target="resultsGrid"></div>
|
|
163
|
+
</section>
|
|
164
|
+
|
|
165
|
+
<p class="emoji-picker__empty" data-emoji-picker-target="empty" hidden>
|
|
166
|
+
<%= lc_t("lostcoords.ui.emoji_picker.no_results") %>
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<footer class="emoji-picker__preview" data-emoji-picker-target="preview">
|
|
171
|
+
<span class="emoji-picker__preview-emoji" data-emoji-picker-target="previewEmoji"><%= selected %></span>
|
|
172
|
+
<span class="emoji-picker__preview-name" data-emoji-picker-target="previewName"><%= lc_emoji_picker_search_name(selected) %></span>
|
|
173
|
+
</footer>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
<% end %>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<% lc_emoji_picker_groups.each do |group| %>
|
|
2
|
+
<section
|
|
3
|
+
class="emoji-picker__category"
|
|
4
|
+
data-section="<%= group[:key] %>"
|
|
5
|
+
aria-label="<%= group[:name] %>"
|
|
6
|
+
>
|
|
7
|
+
<header class="emoji-picker__category-head">
|
|
8
|
+
<h3><%= group[:name] %></h3>
|
|
9
|
+
<span class="count"><%= group[:emojis].size %></span>
|
|
10
|
+
</header>
|
|
11
|
+
|
|
12
|
+
<div class="emoji-picker__grid">
|
|
13
|
+
<% group[:emojis].each do |emoji| %>
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
data-emoji="<%= emoji %>"
|
|
17
|
+
data-tone="<%= "true" if lc_emoji_picker_toneable?(emoji) %>"
|
|
18
|
+
data-search="<%= lc_emoji_picker_search_text(emoji) %>"
|
|
19
|
+
data-search-name="<%= lc_emoji_picker_search_name(emoji) %>"
|
|
20
|
+
data-search-labels="<%= lc_emoji_picker_search_labels(emoji) %>"
|
|
21
|
+
title="<%= lc_emoji_picker_search_name(emoji) %>"
|
|
22
|
+
aria-label="<%= lc_emoji_picker_search_name(emoji) %>"
|
|
23
|
+
>
|
|
24
|
+
<%= emoji %>
|
|
25
|
+
</button>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
28
|
+
</section>
|
|
29
|
+
<% end %>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<%= tag.div class: lc_form_alert_class(extra: local_assigns[:class]), data: { tone: local_assigns.fetch(:tone, :error) }, **local_assigns.fetch(:attributes, {}) do %>
|
|
2
|
+
<%= lc_form_alert_icon(tone: local_assigns.fetch(:tone, :error)) %>
|
|
3
|
+
<p class="m-0 min-w-0 text-pretty [overflow-wrap:anywhere]"><%= local_assigns[:message] %></p>
|
|
4
|
+
<% end %>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%# locals: (id:, title:, trigger:, attributes:, content:) %>
|
|
2
|
+
<div data-controller="modal">
|
|
3
|
+
<button type="button" class="<%= lc_button_class(variant: :secondary) %>" aria-haspopup="dialog" aria-controls="<%= id %>" data-action="modal#open"><%= trigger %></button>
|
|
4
|
+
<%= tag.dialog(**attributes) do %>
|
|
5
|
+
<div class="flex flex-col gap-5 p-5">
|
|
6
|
+
<div class="flex items-center justify-between gap-4">
|
|
7
|
+
<h2 id="<%= id %>-title" class="text-xl font-semibold"><%= title %></h2>
|
|
8
|
+
<button type="button" class="<%= lc_icon_button_class(size: :sm) %>" aria-label="Close <%= title %>" data-action="modal#close"><%= lc_icon "x-mark", options: { "aria-hidden": true } %></button>
|
|
9
|
+
</div>
|
|
10
|
+
<%= content %>
|
|
11
|
+
</div>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%# Inherits currentColor. Pass label: nil inside an already-labelled loading region. %>
|
|
2
|
+
<% label = local_assigns.fetch(:label, "Loading…") %>
|
|
3
|
+
|
|
4
|
+
<%= tag.span class: lc_spinner_class(size: local_assigns.fetch(:size, :base), extra: local_assigns[:class]),
|
|
5
|
+
role: (label.present? ? "status" : nil),
|
|
6
|
+
**local_assigns.fetch(:attributes, {}) do %>
|
|
7
|
+
<% if label.present? %>
|
|
8
|
+
<span class="sr-only"><%= label %></span>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|