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,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,5 @@
1
+ <%# locals: (title:, attributes:, content:) %>
2
+ <%= tag.details(**attributes) do %>
3
+ <summary class="cursor-pointer font-semibold"><%= title %></summary>
4
+ <div class="pt-4"><%= content %></div>
5
+ <% end %>
@@ -0,0 +1,4 @@
1
+ <%# Decorative: the surrounding loading region conveys state. Pass sizing via :class. %>
2
+ <%= tag.span class: lc_skeleton_class(extra: local_assigns.fetch(:class, "h-4 w-full")),
3
+ "aria-hidden": "true",
4
+ **local_assigns.fetch(:attributes, {}) %>
@@ -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 %>