lostcoords-ui 0.5.0 → 0.6.0

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +102 -48
  3. data/app/assets/tailwind/lostcoords/ui/card.css +5 -2
  4. data/app/assets/tailwind/lostcoords/ui/{disclosure.css → collapsible.css} +1 -1
  5. data/app/assets/tailwind/lostcoords/ui/components/code-block.css +5 -0
  6. data/app/assets/tailwind/lostcoords/ui/components/collapsible.css +5 -0
  7. data/app/assets/tailwind/lostcoords/ui/components/dialog.css +9 -0
  8. data/app/assets/tailwind/lostcoords/ui/components/emoji-picker.css +5 -0
  9. data/app/assets/tailwind/lostcoords/ui/components/menu.css +5 -0
  10. data/app/assets/tailwind/lostcoords/ui/components/prose.css +3 -0
  11. data/app/assets/tailwind/lostcoords/ui/components/table.css +3 -0
  12. data/app/assets/tailwind/lostcoords/ui/components/toast.css +5 -0
  13. data/app/assets/tailwind/lostcoords/ui/components/tooltip.css +4 -0
  14. data/app/assets/tailwind/lostcoords/ui/components.css +12 -25
  15. data/app/assets/tailwind/lostcoords/ui/core.css +21 -0
  16. data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +45 -47
  17. data/app/assets/tailwind/lostcoords/ui/{reading.css → prose.css} +6 -0
  18. data/app/assets/tailwind/lostcoords/ui/sheet.css +3 -4
  19. data/app/assets/tailwind/lostcoords/ui/theme.css +16 -1
  20. data/app/assets/tailwind/lostcoords/ui/toast.css +23 -0
  21. data/app/assets/tailwind/lostcoords/ui/tooltip.css +12 -11
  22. data/app/assets/tailwind/lostcoords/ui/typography.css +1 -7
  23. data/app/helpers/lostcoords/ui/badge_helper.rb +2 -2
  24. data/app/helpers/lostcoords/ui/button_helper.rb +12 -6
  25. data/app/helpers/lostcoords/ui/choice_helper.rb +11 -7
  26. data/app/helpers/lostcoords/ui/collapsible_helper.rb +9 -0
  27. data/app/helpers/lostcoords/ui/dialog_helper.rb +10 -5
  28. data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +6 -0
  29. data/app/helpers/lostcoords/ui/feedback_helper.rb +2 -2
  30. data/app/helpers/lostcoords/ui/form_helper.rb +1 -1
  31. data/app/helpers/lostcoords/ui/glass_helper.rb +1 -1
  32. data/app/helpers/lostcoords/ui/menu_helper.rb +37 -2
  33. data/app/helpers/lostcoords/ui/navigation_helper.rb +17 -4
  34. data/app/helpers/lostcoords/ui/render_attributes_helper.rb +17 -1
  35. data/app/helpers/lostcoords/ui/sheet_helper.rb +4 -0
  36. data/app/helpers/lostcoords/ui/structures_helper.rb +2 -92
  37. data/app/helpers/lostcoords/ui/toast_helper.rb +22 -0
  38. data/app/helpers/lostcoords/ui/{overlay_helper.rb → tooltip_helper.rb} +3 -3
  39. data/app/helpers/lostcoords/ui/typography_helper.rb +30 -0
  40. data/app/javascript/lostcoords/ui/controllers/lc_collapsible_controller.js +1 -1
  41. data/app/javascript/lostcoords/ui/controllers/lc_emoji_picker_controller.js +34 -6
  42. data/app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js +12 -0
  43. data/app/javascript/lostcoords/ui/controllers/lc_menu_controller.js +23 -1
  44. data/app/javascript/lostcoords/ui/controllers/lc_selection_controller.js +6 -14
  45. data/app/javascript/lostcoords/ui/controllers/lc_sheet_controller.js +2 -2
  46. data/app/javascript/lostcoords/ui/controllers/lc_shortcut_controller.js +45 -0
  47. data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +18 -15
  48. data/app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js +62 -0
  49. data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +46 -6
  50. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +72 -20
  51. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +23 -0
  52. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +6 -7
  53. data/app/javascript/lostcoords/ui/helpers/menu_keys.js +19 -18
  54. data/app/javascript/lostcoords/ui/helpers/roving_focus.js +175 -0
  55. data/app/javascript/lostcoords/ui/helpers/shortcuts.js +162 -0
  56. data/app/javascript/lostcoords/ui/helpers/top_layer.js +48 -10
  57. data/app/javascript/lostcoords/ui/index.js +6 -0
  58. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +27 -27
  59. data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +3 -3
  60. data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +1 -1
  61. data/lib/lostcoords/ui/action_view.rb +1 -0
  62. data/lib/lostcoords/ui/builders.rb +2 -2
  63. data/lib/lostcoords/ui/form_builder.rb +23 -10
  64. data/lib/lostcoords/ui/stylesheets.rb +25 -0
  65. data/lib/lostcoords/ui/version.rb +1 -1
  66. data/lib/tasks/lostcoords_ui.rake +11 -0
  67. metadata +23 -6
  68. data/config/helper_renames.json +0 -111
  69. data/script/migrate_helpers.rb +0 -136
@@ -0,0 +1,162 @@
1
+ import { topModalElement } from "lostcoords/ui/helpers/dismiss_stack"
2
+ import { isEditable } from "lostcoords/ui/helpers/roving_focus"
3
+
4
+ // Page-wide keyboard shortcuts. A shortcut is written as keys joined by "+",
5
+ // with "mod" for ⌘ on Apple platforms and Ctrl elsewhere: "mod+k", "/",
6
+ // "shift+?", "alt+n". Several shortcuts for one element are separated by spaces.
7
+ //
8
+ // A shortcut without mod, ctrl or alt never fires while focus is in a text
9
+ // field, so typing "/" in a field types it. While a modal dialog is open, only
10
+ // elements inside it respond. When two visible elements claim the same keys,
11
+ // the first in document order wins and the page logs a warning.
12
+
13
+ const bindings = new Set()
14
+ const warned = new Set()
15
+
16
+ export const isApple = /mac|iphone|ipad|ipod/i.test(
17
+ navigator.userAgentData?.platform || navigator.platform || navigator.userAgent,
18
+ )
19
+
20
+ export function parseShortcuts(text) {
21
+ return text
22
+ .trim()
23
+ .split(/\s+/)
24
+ .filter(Boolean)
25
+ .map((combo) => {
26
+ const parts = combo.toLowerCase().split("+")
27
+ const key = parts.pop() || "+"
28
+ const modifiers = new Set(parts)
29
+ return {
30
+ text: combo,
31
+ key,
32
+ mod: modifiers.has("mod"),
33
+ ctrl: modifiers.has("ctrl"),
34
+ meta: modifiers.has("meta"),
35
+ alt: modifiers.has("alt"),
36
+ shift: modifiers.has("shift"),
37
+ }
38
+ })
39
+ }
40
+
41
+ export function matchesShortcut(shortcut, event) {
42
+ const meta = shortcut.meta || (shortcut.mod && isApple)
43
+ const ctrl = shortcut.ctrl || (shortcut.mod && !isApple)
44
+ if (event.metaKey !== meta || event.ctrlKey !== ctrl || event.altKey !== shortcut.alt)
45
+ return false
46
+
47
+ // Option on a Mac turns letters into other characters (Option+N is a dead key),
48
+ // so alt shortcuts on letters and digits match the physical key.
49
+ if (shortcut.alt && /^[a-z0-9]$/.test(shortcut.key))
50
+ return event.shiftKey === shortcut.shift && event.code === physicalKey(shortcut.key)
51
+
52
+ const key = event.key.toLowerCase()
53
+ // Shifted punctuation such as "?" already reports the shifted key.
54
+ if (key.length === 1 && !/[a-z0-9]/.test(key)) return key === shortcut.key
55
+ return event.shiftKey === shortcut.shift && key === shortcut.key
56
+ }
57
+
58
+ const physicalKey = (key) => (/\d/.test(key) ? `Digit${key}` : `Key${key.toUpperCase()}`)
59
+
60
+ // Shift shows unless the key is a symbol it already produces, such as "?".
61
+ const showsShift = (shortcut) => shortcut.shift && !/^[^a-z0-9]$/.test(shortcut.key)
62
+
63
+ const KEY_NAMES = {
64
+ escape: "Esc",
65
+ arrowup: "↑",
66
+ arrowdown: "↓",
67
+ arrowleft: "←",
68
+ arrowright: "→",
69
+ enter: "↵",
70
+ }
71
+
72
+ // "mod+k" reads "⌘ K" on Apple platforms and "Ctrl K" elsewhere.
73
+ export function formatShortcut(text) {
74
+ return parseShortcuts(text)
75
+ .map((shortcut) => {
76
+ const keys = []
77
+ if (shortcut.ctrl || (shortcut.mod && !isApple)) keys.push(isApple ? "⌃" : "Ctrl")
78
+ if (shortcut.alt) keys.push(isApple ? "⌥" : "Alt")
79
+ if (showsShift(shortcut)) keys.push(isApple ? "⇧" : "Shift")
80
+ if (shortcut.meta || (shortcut.mod && isApple)) keys.push(isApple ? "⌘" : "Meta")
81
+ keys.push(
82
+ KEY_NAMES[shortcut.key] ||
83
+ (shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key),
84
+ )
85
+ return keys.join(" ")
86
+ })
87
+ .join(" / ")
88
+ }
89
+
90
+ // The aria-keyshortcuts value: "mod+k" becomes "Meta+K" or "Control+K".
91
+ export function ariaKeyShortcuts(text) {
92
+ return parseShortcuts(text)
93
+ .map((shortcut) => {
94
+ const keys = []
95
+ if (shortcut.ctrl || (shortcut.mod && !isApple)) keys.push("Control")
96
+ if (shortcut.alt) keys.push("Alt")
97
+ if (shortcut.shift) keys.push("Shift")
98
+ if (shortcut.meta || (shortcut.mod && isApple)) keys.push("Meta")
99
+ keys.push(
100
+ shortcut.key.length === 1
101
+ ? shortcut.key.toUpperCase()
102
+ : shortcut.key[0].toUpperCase() + shortcut.key.slice(1),
103
+ )
104
+ return keys.join("+")
105
+ })
106
+ .join(" ")
107
+ }
108
+
109
+ // Registers element for the shortcuts in text; run is called when one fires.
110
+ // Returns a function that removes the binding.
111
+ export function registerShortcut(element, text, run) {
112
+ const binding = { element, shortcuts: parseShortcuts(text), run }
113
+ if (bindings.size === 0) document.addEventListener("keydown", handleKeydown)
114
+ bindings.add(binding)
115
+
116
+ return () => {
117
+ bindings.delete(binding)
118
+ if (bindings.size === 0) document.removeEventListener("keydown", handleKeydown)
119
+ }
120
+ }
121
+
122
+ function handleKeydown(event) {
123
+ if (event.defaultPrevented || event.isComposing || event.repeat) return
124
+
125
+ const typing = isEditable(event.target)
126
+ const matches = [...bindings].filter(
127
+ (binding) =>
128
+ binding.shortcuts.some(
129
+ (shortcut) =>
130
+ matchesShortcut(shortcut, event) &&
131
+ !(typing && !shortcut.mod && !shortcut.ctrl && !shortcut.meta && !shortcut.alt),
132
+ ) && available(binding.element),
133
+ )
134
+ if (matches.length === 0) return
135
+
136
+ matches.sort((a, b) =>
137
+ a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
138
+ )
139
+ if (matches.length > 1) warnConflict(event, matches)
140
+
141
+ event.preventDefault()
142
+ matches[0].run(event)
143
+ }
144
+
145
+ function available(element) {
146
+ if (!element.isConnected || element.disabled || element.closest("[inert]")) return false
147
+ if (element.getClientRects().length === 0) return false
148
+
149
+ const modal = topModalElement()
150
+ return !modal || modal.contains(element)
151
+ }
152
+
153
+ function warnConflict(event, matches) {
154
+ const key = `${event.ctrlKey}${event.metaKey}${event.altKey}${event.shiftKey}${event.key}`
155
+ if (warned.has(key)) return
156
+
157
+ warned.add(key)
158
+ console.warn(
159
+ `lostcoords-ui: ${matches.length} elements share the shortcut ${event.key}; the first one runs.`,
160
+ matches.map((binding) => binding.element),
161
+ )
162
+ }
@@ -6,17 +6,48 @@
6
6
  // The panel's stylesheet still decides where it sits. The caller makes the
7
7
  // panel visible in place, liftToTopLayer measures that designed spot relative
8
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.
9
+ // the trigger on scroll and resize, and flips to the other side when it would
10
+ // leave the viewport. Browsers without the Popover API keep the in-place panel.
11
+ //
12
+ // Every top-layer call in the library goes through this file: showTopLayer and
13
+ // hideTopLayer for elements that place themselves, liftToTopLayer for panels
14
+ // pinned to a trigger, and onPageLeave to close them before Turbo caches a page.
11
15
 
12
16
  const MARGIN = 8
17
+ const PAGE_LEAVES = ["turbo:before-cache", "turbo:before-visit", "turbo:before-render"]
18
+
19
+ export const supportsTopLayer = () => typeof HTMLElement.prototype.showPopover === "function"
20
+
21
+ // showPopover and hidePopover throw when the element is disconnected or already
22
+ // in that state; both are no-ops here instead.
23
+ export function showTopLayer(element) {
24
+ if (!supportsTopLayer() || !element.isConnected) return false
25
+ if (!element.hasAttribute("popover")) element.setAttribute("popover", "manual")
26
+ if (!element.matches(":popover-open")) element.showPopover()
27
+ return true
28
+ }
29
+
30
+ export function hideTopLayer(element) {
31
+ if (supportsTopLayer() && element.matches(":popover-open")) element.hidePopover()
32
+ }
33
+
34
+ // Calls close before Turbo snapshots or leaves the page, so a cached page never
35
+ // keeps an open overlay. Returns a function that removes the listeners.
36
+ export function onPageLeave(close) {
37
+ for (const type of PAGE_LEAVES) document.addEventListener(type, close)
38
+ window.addEventListener("pagehide", close)
39
+ return () => {
40
+ for (const type of PAGE_LEAVES) document.removeEventListener(type, close)
41
+ window.removeEventListener("pagehide", close)
42
+ }
43
+ }
13
44
  const PLACEMENT = ["position", "inset", "top", "left", "margin", "translate", "inlineSize"]
14
45
 
15
46
  const lifts = new WeakMap()
16
47
  const pinned = new WeakSet()
17
48
 
18
49
  export function liftToTopLayer(panel, trigger) {
19
- if (typeof panel.showPopover !== "function") return () => {}
50
+ if (!supportsTopLayer() || !panel.isConnected) return () => {}
20
51
 
21
52
  lifts.get(panel)?.()
22
53
  if (pinned.has(panel)) reset(panel)
@@ -25,10 +56,12 @@ export function liftToTopLayer(panel, trigger) {
25
56
  const placed = panel.getBoundingClientRect()
26
57
  const offsetX = placed.left - anchor.left
27
58
  const offsetY = placed.top - anchor.top
28
- const gap = placed.top - anchor.bottom
59
+ // A panel above its trigger flips below when it would leave the top edge; any
60
+ // other panel flips above when it would leave the bottom.
61
+ const above = placed.bottom <= anchor.top + 1
62
+ const gap = above ? anchor.top - placed.bottom : placed.top - anchor.bottom
29
63
 
30
- panel.setAttribute("popover", "manual")
31
- panel.showPopover()
64
+ showTopLayer(panel)
32
65
  Object.assign(panel.style, {
33
66
  position: "fixed",
34
67
  inset: "auto",
@@ -48,8 +81,13 @@ export function liftToTopLayer(panel, trigger) {
48
81
  const right = document.documentElement.clientWidth - MARGIN
49
82
 
50
83
  let top = a.top + offsetY
51
- const flipped = a.top - gap - height
52
- if (top + height > maxBottom - MARGIN && flipped >= minTop + MARGIN) top = flipped
84
+ if (above) {
85
+ const flipped = a.bottom + gap
86
+ if (top < minTop + MARGIN && flipped + height <= maxBottom - MARGIN) top = flipped
87
+ } else {
88
+ const flipped = a.top - gap - height
89
+ if (top + height > maxBottom - MARGIN && flipped >= minTop + MARGIN) top = flipped
90
+ }
53
91
  top = Math.max(minTop + MARGIN, Math.min(top, maxBottom - MARGIN - height))
54
92
 
55
93
  panel.style.left = `${Math.max(MARGIN, Math.min(a.left + offsetX, right - width))}px`
@@ -77,7 +115,7 @@ export function liftToTopLayer(panel, trigger) {
77
115
  Promise.allSettled(panel.getAnimations().map((animation) => animation.finished)).then(() => {
78
116
  if (!lifts.has(panel)) reset(panel)
79
117
  })
80
- if (panel.matches(":popover-open")) panel.hidePopover()
118
+ hideTopLayer(panel)
81
119
  }
82
120
  lifts.set(panel, release)
83
121
  return release
@@ -85,7 +123,7 @@ export function liftToTopLayer(panel, trigger) {
85
123
 
86
124
  function reset(panel) {
87
125
  pinned.delete(panel)
88
- if (panel.matches(":popover-open")) panel.hidePopover()
126
+ hideTopLayer(panel)
89
127
  panel.removeAttribute("popover")
90
128
  for (const property of PLACEMENT) panel.style[property] = ""
91
129
  }
@@ -4,12 +4,15 @@ import ConfirmDialog from "lostcoords/ui/controllers/lc_confirm_dialog_controlle
4
4
  import Copy from "lostcoords/ui/controllers/lc_copy_controller"
5
5
  import Dialog from "lostcoords/ui/controllers/lc_dialog_controller"
6
6
  import EmojiPicker from "lostcoords/ui/controllers/lc_emoji_picker_controller"
7
+ import Kbd from "lostcoords/ui/controllers/lc_kbd_controller"
7
8
  import Menu from "lostcoords/ui/controllers/lc_menu_controller"
8
9
  import Popover from "lostcoords/ui/controllers/lc_popover_controller"
9
10
  import Selection from "lostcoords/ui/controllers/lc_selection_controller"
10
11
  import Sheet from "lostcoords/ui/controllers/lc_sheet_controller"
12
+ import Shortcut from "lostcoords/ui/controllers/lc_shortcut_controller"
11
13
  import Tabs from "lostcoords/ui/controllers/lc_tabs_controller"
12
14
  import Toast from "lostcoords/ui/controllers/lc_toast_controller"
15
+ import ToastRegion from "lostcoords/ui/controllers/lc_toast_region_controller"
13
16
  import Toggle from "lostcoords/ui/controllers/lc_toggle_controller"
14
17
  import Tooltip from "lostcoords/ui/controllers/lc_tooltip_controller"
15
18
 
@@ -20,12 +23,15 @@ const controllers = {
20
23
  "lc-copy": Copy,
21
24
  "lc-dialog": Dialog,
22
25
  "lc-emoji-picker": EmojiPicker,
26
+ "lc-kbd": Kbd,
23
27
  "lc-menu": Menu,
24
28
  "lc-popover": Popover,
25
29
  "lc-selection": Selection,
26
30
  "lc-sheet": Sheet,
31
+ "lc-shortcut": Shortcut,
27
32
  "lc-tabs": Tabs,
28
33
  "lc-toast": Toast,
34
+ "lc-toast-region": ToastRegion,
29
35
  "lc-toggle": Toggle,
30
36
  "lc-tooltip": Tooltip,
31
37
  }
@@ -15,7 +15,7 @@
15
15
  <% quick.each do |emoji| %>
16
16
  <button
17
17
  type="button"
18
- class="emoji-picker__choice"
18
+ class="lc-emoji-picker__choice"
19
19
  aria-current="<%= emoji == selected %>"
20
20
  data-emoji="<%= emoji %>"
21
21
  data-action="click->lc-emoji-picker#select"
@@ -24,10 +24,10 @@
24
24
  </button>
25
25
  <% end %>
26
26
 
27
- <div class="emoji-picker__more">
27
+ <div class="lc-emoji-picker__more">
28
28
  <button
29
29
  type="button"
30
- class="emoji-picker__trigger <%= trigger_class %>"
30
+ class="lc-emoji-picker__trigger <%= trigger_class %>"
31
31
  aria-current="<%= !selected_is_quick %>"
32
32
  data-lc-emoji-picker-target="trigger"
33
33
  data-action="click->lc-emoji-picker#toggle"
@@ -40,7 +40,7 @@
40
40
  </button>
41
41
 
42
42
  <div
43
- class="emoji-picker__popover <%= popover_class %>"
43
+ class="lc-emoji-picker__popover <%= popover_class %>"
44
44
  data-lc-emoji-picker-target="popover"
45
45
  data-controller="lc-sheet"
46
46
  data-action="click->lc-emoji-picker#select pointerover->lc-emoji-picker#preview focusin->lc-emoji-picker#preview lc-sheet:dismiss->lc-emoji-picker#close"
@@ -48,17 +48,17 @@
48
48
  aria-label="<%= label %>"
49
49
  hidden
50
50
  >
51
- <span class="emoji-picker__handle" data-lc-sheet-target="handle" aria-hidden="true"></span>
51
+ <span class="lc-emoji-picker__handle" data-lc-sheet-target="handle" aria-hidden="true"></span>
52
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">
53
+ <div class="lc-emoji-picker__search">
54
+ <div class="lc-emoji-picker__search-field">
55
+ <svg class="lc-emoji-picker__search-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
56
56
  <circle cx="9" cy="9" r="6" stroke="currentColor" stroke-width="1.6" />
57
57
  <path d="m14 14 3.5 3.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
58
58
  </svg>
59
59
 
60
60
  <input
61
- class="emoji-picker__search-input"
61
+ class="lc-emoji-picker__search-input"
62
62
  type="search"
63
63
  inputmode="search"
64
64
  autocomplete="off"
@@ -72,10 +72,10 @@
72
72
  >
73
73
  </div>
74
74
 
75
- <div class="emoji-picker__tones" data-lc-emoji-picker-target="tones">
75
+ <div class="lc-emoji-picker__tones" data-lc-emoji-picker-target="tones">
76
76
  <button
77
77
  type="button"
78
- class="emoji-picker__tone-toggle"
78
+ class="lc-emoji-picker__tone-toggle"
79
79
  data-lc-emoji-picker-target="toneToggle"
80
80
  data-action="click->lc-emoji-picker#toggleTones"
81
81
  aria-haspopup="true"
@@ -83,11 +83,11 @@
83
83
  aria-label="<%= lc_t("lostcoords.ui.emoji_picker.skin_tone") %>"
84
84
  >✋</button>
85
85
 
86
- <div class="emoji-picker__tone-menu <%= popover_class %>" data-lc-emoji-picker-target="toneMenu" role="menu" hidden>
86
+ <div class="lc-emoji-picker__tone-menu <%= popover_class %>" data-lc-emoji-picker-target="toneMenu" data-action="keydown->lc-emoji-picker#navigateTones" role="menu" aria-label="<%= lc_t("lostcoords.ui.emoji_picker.skin_tone") %>" hidden>
87
87
  <% tone_modifiers.each_with_index do |modifier, index| %>
88
88
  <button
89
89
  type="button"
90
- class="emoji-picker__tone"
90
+ class="lc-emoji-picker__tone"
91
91
  role="menuitemradio"
92
92
  data-tone-index="<%= index %>"
93
93
  data-action="click->lc-emoji-picker#selectTone"
@@ -98,11 +98,11 @@
98
98
  </div>
99
99
  </div>
100
100
 
101
- <nav class="emoji-picker__tabs" data-lc-emoji-picker-target="tabs" aria-label="<%= lc_t("lostcoords.ui.emoji_picker.categories_nav") %>">
101
+ <nav class="lc-emoji-picker__tabs" data-lc-emoji-picker-target="tabs" data-action="keydown->lc-emoji-picker#navigateTabs" aria-label="<%= lc_t("lostcoords.ui.emoji_picker.categories_nav") %>">
102
102
  <% if recent.any? %>
103
103
  <button
104
104
  type="button"
105
- class="emoji-picker__tab"
105
+ class="lc-emoji-picker__tab"
106
106
  data-lc-emoji-picker-target="tab"
107
107
  data-target-section="recent"
108
108
  data-action="click->lc-emoji-picker#jumpTo"
@@ -113,7 +113,7 @@
113
113
  <% lc_emoji_picker_groups.each do |group| %>
114
114
  <button
115
115
  type="button"
116
- class="emoji-picker__tab"
116
+ class="lc-emoji-picker__tab"
117
117
  data-lc-emoji-picker-target="tab"
118
118
  data-target-section="<%= group[:key] %>"
119
119
  data-action="click->lc-emoji-picker#jumpTo"
@@ -122,20 +122,20 @@
122
122
  <% end %>
123
123
  </nav>
124
124
 
125
- <div class="emoji-picker__body" data-lc-emoji-picker-target="body">
125
+ <div class="lc-emoji-picker__body" data-lc-emoji-picker-target="body" data-action="keydown->lc-emoji-picker#navigateGrid focusin->lc-emoji-picker#trackGridFocus">
126
126
  <% if recent.any? %>
127
127
  <section
128
- class="emoji-picker__recent"
128
+ class="lc-emoji-picker__recent"
129
129
  data-section="recent"
130
130
  aria-label="<%= lc_t('lostcoords.ui.emoji_picker.recently_used') %>"
131
131
  data-lc-emoji-picker-target="recent"
132
132
  >
133
- <header class="emoji-picker__category-head">
133
+ <header class="lc-emoji-picker__category-head">
134
134
  <h3><%= lc_t("lostcoords.ui.emoji_picker.recently_used") %></h3>
135
135
  <span class="count"><%= recent.size %></span>
136
136
  </header>
137
137
 
138
- <div class="emoji-picker__grid">
138
+ <div class="lc-emoji-picker__grid">
139
139
  <% recent.each do |emoji| %>
140
140
  <button
141
141
  type="button"
@@ -153,23 +153,23 @@
153
153
  </section>
154
154
  <% end %>
155
155
 
156
- <section class="emoji-picker__results" data-lc-emoji-picker-target="results" aria-label="<%= lc_t('lostcoords.ui.emoji_picker.search_results') %>" hidden>
157
- <header class="emoji-picker__category-head">
156
+ <section class="lc-emoji-picker__results" data-lc-emoji-picker-target="results" aria-label="<%= lc_t('lostcoords.ui.emoji_picker.search_results') %>" hidden>
157
+ <header class="lc-emoji-picker__category-head">
158
158
  <h3><%= lc_t("lostcoords.ui.emoji_picker.search_results") %></h3>
159
159
  <span class="count" data-lc-emoji-picker-target="resultsCount">0</span>
160
160
  </header>
161
161
 
162
- <div class="emoji-picker__grid" data-lc-emoji-picker-target="resultsGrid"></div>
162
+ <div class="lc-emoji-picker__grid" data-lc-emoji-picker-target="resultsGrid"></div>
163
163
  </section>
164
164
 
165
- <p class="emoji-picker__empty" data-lc-emoji-picker-target="empty" hidden>
165
+ <p class="lc-emoji-picker__empty" data-lc-emoji-picker-target="empty" hidden>
166
166
  <%= lc_t("lostcoords.ui.emoji_picker.no_results") %>
167
167
  </p>
168
168
  </div>
169
169
 
170
- <footer class="emoji-picker__preview" data-lc-emoji-picker-target="preview">
171
- <span class="emoji-picker__preview-emoji" data-lc-emoji-picker-target="previewEmoji"><%= selected %></span>
172
- <span class="emoji-picker__preview-name" data-lc-emoji-picker-target="previewName"><%= lc_emoji_picker_search_name(selected) %></span>
170
+ <footer class="lc-emoji-picker__preview" data-lc-emoji-picker-target="preview">
171
+ <span class="lc-emoji-picker__preview-emoji" data-lc-emoji-picker-target="previewEmoji"><%= selected %></span>
172
+ <span class="lc-emoji-picker__preview-name" data-lc-emoji-picker-target="previewName"><%= lc_emoji_picker_search_name(selected) %></span>
173
173
  </footer>
174
174
  </div>
175
175
  </div>
@@ -1,15 +1,15 @@
1
1
  <% lc_emoji_picker_groups.each do |group| %>
2
2
  <section
3
- class="emoji-picker__category"
3
+ class="lc-emoji-picker__category"
4
4
  data-section="<%= group[:key] %>"
5
5
  aria-label="<%= group[:name] %>"
6
6
  >
7
- <header class="emoji-picker__category-head">
7
+ <header class="lc-emoji-picker__category-head">
8
8
  <h3><%= group[:name] %></h3>
9
9
  <span class="count"><%= group[:emojis].size %></span>
10
10
  </header>
11
11
 
12
- <div class="emoji-picker__grid">
12
+ <div class="lc-emoji-picker__grid">
13
13
  <% group[:emojis].each do |emoji| %>
14
14
  <button
15
15
  type="button"
@@ -5,7 +5,7 @@
5
5
  <span class="truncate font-mono text-xs text-muted-foreground"><%= title %></span>
6
6
  </div>
7
7
  <% end %>
8
- <div class="lc-code-block-copy absolute end-2 top-2 z-10 rounded-full bg-muted">
8
+ <div class="lc-code-block-copy absolute end-2 top-2 z-raised rounded-full bg-muted">
9
9
  <%= lc_copy_button source: "#{id}-content", icon_only: true, variant: :ghost, size: :sm, aria: { label: lc_t("lostcoords.ui.code_block.copy", label: label) } %>
10
10
  </div>
11
11
  <div class="relative">
@@ -8,5 +8,6 @@ require_relative "icons"
8
8
  require_relative "parts"
9
9
  require_relative "form_builder"
10
10
  require_relative "helpers"
11
+ require_relative "stylesheets"
11
12
 
12
13
  Lostcoords::Ui.load_translations!
@@ -10,8 +10,8 @@ module Lostcoords::Ui
10
10
 
11
11
  def component?
12
12
  !name.to_s.end_with?("_class") &&
13
- !%i[lc_tw lc_sheet_data lc_alert_icon].include?(name) &&
14
- helper != "EmojiPickerHelper"
13
+ !%i[lc_tw lc_sheet_data lc_shortcut_data lc_alert_icon].include?(name) &&
14
+ (helper != "EmojiPickerHelper" || name == :lc_emoji_picker)
15
15
  end
16
16
  end
17
17
 
@@ -33,6 +33,11 @@ module Lostcoords::Ui
33
33
  super(method, choices, options, control_options(method, html_options, "select", @template.lc_select_class(size: size)), &block)
34
34
  end
35
35
 
36
+ SELECT_OPTIONS = %i[include_blank prompt selected disabled].freeze
37
+
38
+ alias_method :rails_check_box, :check_box
39
+ private :rails_check_box
40
+
36
41
  def check_box(method, options = {}, checked_value = "1", unchecked_value = "0")
37
42
  super(method, control_options(method, options, "checkbox-input", @template.lc_checkbox_class), checked_value, unchecked_value)
38
43
  end
@@ -44,13 +49,14 @@ module Lostcoords::Ui
44
49
 
45
50
  # A checkbox drawn as a switch, with its label beside it.
46
51
  def switch(method, label: nil, hint: nil, checked_value: "1", unchecked_value: "0", **options)
47
- input = check_box(method, { role: "switch", class: @template.lc_switch_class(extra: options.delete(:class)), data: { slot: "switch-input" } }.merge(options), checked_value, unchecked_value)
52
+ input = rails_check_box(method, control_options(method, { role: "switch" }.merge(options), "switch-input", @template.lc_switch_class, described: true), checked_value, unchecked_value)
48
53
  choice_label(method, label, hint, "switch", input)
49
54
  end
50
55
 
51
56
  # A checkbox with its label beside it.
52
57
  def choice(method, label: nil, hint: nil, checked_value: "1", unchecked_value: "0", **options)
53
- choice_label(method, label, hint, "checkbox", check_box(method, options, checked_value, unchecked_value))
58
+ input = rails_check_box(method, control_options(method, options, "checkbox-input", @template.lc_checkbox_class, described: true), checked_value, unchecked_value)
59
+ choice_label(method, label, hint, "checkbox", input)
54
60
  end
55
61
 
56
62
  def label(method, text = nil, options = {}, &block)
@@ -68,17 +74,22 @@ module Lostcoords::Ui
68
74
  end
69
75
  variant = options.delete(:variant) || :primary
70
76
  size = options.delete(:size) || :md
71
- super(value, options.merge(class: @template.lc_button_class(variant: variant, size: size, extra: options[:class])))
77
+ data = { slot: "button", variant: variant, size: size }.merge(options.delete(:data) || {})
78
+ super(value, options.merge(class: @template.lc_button_class(variant: variant, size: size, extra: options[:class]), data: data))
72
79
  end
73
80
 
74
81
  # Label, control, hint and the attribute's errors in one field. `as:` names the control helper;
75
- # a block renders a custom control and receives its attributes to splat with `**control`.
76
- def field(method, label: nil, hint: nil, as: :text_field, **options, &block)
82
+ # `as: :select` takes `choices:` and Rails' select options such as `include_blank:`. A block renders
83
+ # a custom control and receives its attributes to splat with `**control`.
84
+ def field(method, label: nil, hint: nil, as: :text_field, choices: nil, **options, &block)
77
85
  @template.lc_field(label: label || human_name(method), id: field_id(method), hint: hint, error: errors_for(method)) do |control|
78
- if block
79
- @template.capture(control, &block)
86
+ next @template.capture(control, &block) if block
87
+
88
+ attributes = @template.send(:lc_attributes, options.except(*SELECT_OPTIONS), control.to_hash.except(:id))
89
+ if as.to_sym == :select
90
+ select(method, choices, options.slice(*SELECT_OPTIONS), attributes)
80
91
  else
81
- public_send(as, method, **options, **control.to_hash.except(:id))
92
+ public_send(as, method, attributes.merge(options.slice(*SELECT_OPTIONS)))
82
93
  end
83
94
  end
84
95
  end
@@ -88,16 +99,18 @@ module Lostcoords::Ui
88
99
  options[:size].is_a?(Symbol) || options[:size].is_a?(String) && options[:size].match?(/\A[a-z]+\z/) ? options.delete(:size).to_sym : :md
89
100
  end
90
101
 
91
- def control_options(method, options, slot, classes)
102
+ # described: points aria-describedby at the error a choice label renders, as lc_field does for its control.
103
+ def control_options(method, options, slot, classes, described: false)
92
104
  options = options.dup
93
105
  aria = (options.delete(:aria) || {}).dup
94
106
  aria[:invalid] = true if errors_for(method).any? && !aria.key?(:invalid)
107
+ aria[:describedby] = [ aria[:describedby], "#{field_id(method)}-error" ].compact.join(" ") if described && errors_for(method).any?
95
108
  data = { slot: slot }.merge(options.delete(:data) || {})
96
109
  options.merge(class: @template.lc_tw(classes, options[:class]), aria: aria, data: data)
97
110
  end
98
111
 
99
112
  def choice_label(method, text, hint, slot, input)
100
- @template.send(:lc_choice_label, slot, text || human_name(method), hint, input, for: field_id(method))
113
+ @template.send(:lc_choice_label, slot, text || human_name(method), hint, input, errors: errors_for(method), error_id: "#{field_id(method)}-error", for: field_id(method))
101
114
  end
102
115
 
103
116
  def errors_for(method)
@@ -0,0 +1,25 @@
1
+ require "fileutils"
2
+ require "pathname"
3
+
4
+ module Lostcoords::Ui
5
+ # The Tailwind entries a host can import: the document defaults, core.css and one file per opt-in component.
6
+ module Stylesheets
7
+ ROOT = Pathname(File.expand_path("../../../app/assets/tailwind/lostcoords/ui", __dir__))
8
+
9
+ def self.entries
10
+ [ ROOT.join("base.css"), ROOT.join("core.css"), *ROOT.glob("components/*.css").sort ]
11
+ end
12
+
13
+ # tailwindcss-rails gives an engine one generated import, of engine.css. These proxies give every
14
+ # entry a stable path in the host, such as builds/tailwind/lostcoords_ui/components/dialog.css.
15
+ def self.write(builds)
16
+ directory = Pathname(builds).join("lostcoords_ui")
17
+ FileUtils.rm_rf(directory)
18
+ entries.each do |entry|
19
+ path = directory.join(entry.relative_path_from(ROOT))
20
+ FileUtils.mkdir_p(path.dirname)
21
+ path.write("/* Generated by lostcoords-ui before every Tailwind build; do not edit. */\n@import \"#{entry}\";\n")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,5 +1,5 @@
1
1
  module Lostcoords
2
2
  module Ui
3
- VERSION = "0.5.0"
3
+ VERSION = "0.6.0"
4
4
  end
5
5
  end
@@ -13,3 +13,14 @@ namespace :lostcoords do
13
13
  end
14
14
  end
15
15
  end
16
+
17
+ # Runs with tailwindcss-rails' own engines step, before every tailwindcss:build and tailwindcss:watch.
18
+ # Apps without tailwindcss-rails import the gem's stylesheets by path instead.
19
+ if defined?(Tailwindcss::Engines)
20
+ namespace :tailwindcss do
21
+ task :engines do
22
+ require "lostcoords/ui/stylesheets"
23
+ Lostcoords::Ui::Stylesheets.write(Rails.root.join("app/assets/builds/tailwind"))
24
+ end
25
+ end
26
+ end