lostcoords-ui 0.4.1 → 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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +364 -96
  3. data/app/assets/tailwind/lostcoords/ui/avatar.css +6 -6
  4. data/app/assets/tailwind/lostcoords/ui/base.css +45 -0
  5. data/app/assets/tailwind/lostcoords/ui/card.css +23 -22
  6. data/app/assets/tailwind/lostcoords/ui/choice.css +39 -0
  7. data/app/assets/tailwind/lostcoords/ui/code-block.css +10 -0
  8. data/app/assets/tailwind/lostcoords/ui/{disclosure.css → collapsible.css} +6 -6
  9. data/app/assets/tailwind/lostcoords/ui/components/code-block.css +5 -0
  10. data/app/assets/tailwind/lostcoords/ui/components/collapsible.css +5 -0
  11. data/app/assets/tailwind/lostcoords/ui/components/dialog.css +9 -0
  12. data/app/assets/tailwind/lostcoords/ui/components/emoji-picker.css +5 -0
  13. data/app/assets/tailwind/lostcoords/ui/components/menu.css +5 -0
  14. data/app/assets/tailwind/lostcoords/ui/components/prose.css +3 -0
  15. data/app/assets/tailwind/lostcoords/ui/components/table.css +3 -0
  16. data/app/assets/tailwind/lostcoords/ui/components/toast.css +5 -0
  17. data/app/assets/tailwind/lostcoords/ui/components/tooltip.css +4 -0
  18. data/app/assets/tailwind/lostcoords/ui/components.css +12 -0
  19. data/app/assets/tailwind/lostcoords/ui/copy-button.css +9 -9
  20. data/app/assets/tailwind/lostcoords/ui/core.css +21 -0
  21. data/app/assets/tailwind/lostcoords/ui/dialog.css +17 -17
  22. data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +86 -88
  23. data/app/assets/tailwind/lostcoords/ui/field.css +2 -2
  24. data/app/assets/tailwind/lostcoords/ui/glass.css +9 -8
  25. data/app/assets/tailwind/lostcoords/ui/layout.css +2 -2
  26. data/app/assets/tailwind/lostcoords/ui/popover.css +14 -14
  27. data/app/assets/tailwind/lostcoords/ui/prose.css +66 -0
  28. data/app/assets/tailwind/lostcoords/ui/sheet.css +62 -63
  29. data/app/assets/tailwind/lostcoords/ui/squircle.css +7 -27
  30. data/app/assets/tailwind/lostcoords/ui/table.css +51 -0
  31. data/app/assets/tailwind/lostcoords/ui/theme.css +209 -225
  32. data/app/assets/tailwind/lostcoords/ui/toast.css +23 -0
  33. data/app/assets/tailwind/lostcoords/ui/tooltip.css +51 -0
  34. data/app/assets/tailwind/lostcoords/ui/typography.css +31 -61
  35. data/app/assets/tailwind/lostcoords/ui/ui.css +2 -21
  36. data/app/assets/tailwind/lostcoords/ui/utilities.css +14 -22
  37. data/app/helpers/lostcoords/ui/badge_helper.rb +67 -110
  38. data/app/helpers/lostcoords/ui/button_helper.rb +44 -35
  39. data/app/helpers/lostcoords/ui/card_helper.rb +9 -52
  40. data/app/helpers/lostcoords/ui/choice_helper.rb +87 -0
  41. data/app/helpers/lostcoords/ui/code_block_helper.rb +22 -0
  42. data/app/helpers/lostcoords/ui/collapsible_helper.rb +9 -0
  43. data/app/helpers/lostcoords/ui/copy_button_helper.rb +7 -7
  44. data/app/helpers/lostcoords/ui/description_list_helper.rb +12 -0
  45. data/app/helpers/lostcoords/ui/dialog_helper.rb +15 -17
  46. data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +6 -0
  47. data/app/helpers/lostcoords/ui/feedback_helper.rb +25 -6
  48. data/app/helpers/lostcoords/ui/form_helper.rb +36 -59
  49. data/app/helpers/lostcoords/ui/glass_helper.rb +7 -7
  50. data/app/helpers/lostcoords/ui/layout_helper.rb +13 -11
  51. data/app/helpers/lostcoords/ui/menu_helper.rb +45 -10
  52. data/app/helpers/lostcoords/ui/navigation_helper.rb +144 -0
  53. data/app/helpers/lostcoords/ui/primitives_helper.rb +39 -61
  54. data/app/helpers/lostcoords/ui/reading_helper.rb +2 -2
  55. data/app/helpers/lostcoords/ui/render_attributes_helper.rb +17 -1
  56. data/app/helpers/lostcoords/ui/sheet_helper.rb +7 -45
  57. data/app/helpers/lostcoords/ui/structures_helper.rb +26 -125
  58. data/app/helpers/lostcoords/ui/table_helper.rb +11 -0
  59. data/app/helpers/lostcoords/ui/tailwind_helper.rb +13 -4
  60. data/app/helpers/lostcoords/ui/toast_helper.rb +22 -0
  61. data/app/helpers/lostcoords/ui/tooltip_helper.rb +17 -0
  62. data/app/helpers/lostcoords/ui/typography_helper.rb +41 -11
  63. data/app/javascript/lostcoords/ui/controllers/lc_code_block_controller.js +23 -0
  64. data/app/javascript/lostcoords/ui/controllers/{disclosure_controller.js → lc_collapsible_controller.js} +4 -4
  65. data/app/javascript/lostcoords/ui/controllers/{confirm_dialog_controller.js → lc_confirm_dialog_controller.js} +4 -4
  66. data/app/javascript/lostcoords/ui/controllers/{modal_controller.js → lc_dialog_controller.js} +1 -1
  67. data/app/javascript/lostcoords/ui/controllers/{emoji_picker_controller.js → lc_emoji_picker_controller.js} +38 -10
  68. data/app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js +12 -0
  69. data/app/javascript/lostcoords/ui/controllers/{row_menu_controller.js → lc_menu_controller.js} +25 -3
  70. data/app/javascript/lostcoords/ui/controllers/{ui_popover_controller.js → lc_popover_controller.js} +1 -1
  71. data/app/javascript/lostcoords/ui/controllers/{ui_selection_controller.js → lc_selection_controller.js} +7 -15
  72. data/app/javascript/lostcoords/ui/controllers/{sheet_controller.js → lc_sheet_controller.js} +17 -17
  73. data/app/javascript/lostcoords/ui/controllers/lc_shortcut_controller.js +45 -0
  74. data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +42 -0
  75. data/app/javascript/lostcoords/ui/controllers/{toast_controller.js → lc_toast_controller.js} +2 -2
  76. data/app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js +62 -0
  77. data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +54 -0
  78. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +77 -25
  79. data/app/javascript/lostcoords/ui/helpers/context_dialog.js +6 -6
  80. data/app/javascript/lostcoords/ui/helpers/dialog.js +2 -2
  81. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +30 -7
  82. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +7 -8
  83. data/app/javascript/lostcoords/ui/helpers/menu_keys.js +19 -18
  84. data/app/javascript/lostcoords/ui/helpers/roving_focus.js +175 -0
  85. data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +4 -4
  86. data/app/javascript/lostcoords/ui/helpers/shortcuts.js +162 -0
  87. data/app/javascript/lostcoords/ui/helpers/top_layer.js +48 -10
  88. data/app/javascript/lostcoords/ui/index.js +57 -0
  89. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +46 -46
  90. data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +3 -3
  91. data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +18 -0
  92. data/app/views/lostcoords/ui/components/_lc_collapsible.html.erb +5 -0
  93. data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +11 -8
  94. data/app/views/lostcoords/ui/components/_lc_floating_panel.html.erb +8 -0
  95. data/config/importmap.rb +10 -2
  96. data/config/locales/en.yml +23 -0
  97. data/config/locales/es.yml +23 -0
  98. data/config/locales/ja.yml +23 -0
  99. data/lib/lostcoords/ui/action_view.rb +6 -3
  100. data/lib/lostcoords/ui/builders.rb +2 -2
  101. data/lib/lostcoords/ui/form_builder.rb +126 -0
  102. data/lib/lostcoords/ui/parts.rb +154 -0
  103. data/lib/lostcoords/ui/stylesheets.rb +25 -0
  104. data/lib/lostcoords/ui/tokens.rb +24 -1
  105. data/lib/lostcoords/ui/version.rb +1 -1
  106. data/lib/tasks/lostcoords_ui.rake +11 -0
  107. metadata +54 -21
  108. data/app/assets/tailwind/lostcoords/ui/reading.css +0 -60
  109. data/app/views/lostcoords/ui/components/_form_alert.html.erb +0 -4
  110. data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +0 -5
  111. data/app/views/lostcoords/ui/components/_skeleton.html.erb +0 -4
  112. data/app/views/lostcoords/ui/components/_spinner.html.erb +0 -10
  113. data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +0 -8
  114. data/config/helper_renames.json +0 -113
  115. data/script/migrate_helpers.rb +0 -136
  116. /data/app/javascript/lostcoords/ui/controllers/{ui_copy_controller.js → lc_copy_controller.js} +0 -0
  117. /data/app/javascript/lostcoords/ui/controllers/{ui_toggle_controller.js → lc_toggle_controller.js} +0 -0
@@ -1,7 +1,9 @@
1
- import { holdDocumentScroll } from "helpers/dismiss_stack"
2
- import { FloatingPanel } from "helpers/floating_panel"
3
- import { SHEET_TIMING, sheetDuration } from "helpers/sheet_motion"
4
- import { isSheetViewport } from "helpers/viewport"
1
+ import { holdDocumentScroll } from "lostcoords/ui/helpers/dismiss_stack"
2
+ import { FloatingPanel } from "lostcoords/ui/helpers/floating_panel"
3
+ import { navigableItems, navigate, rove } from "lostcoords/ui/helpers/roving_focus"
4
+ import { SHEET_TIMING, sheetDuration } from "lostcoords/ui/helpers/sheet_motion"
5
+ import { hideTopLayer, showTopLayer, supportsTopLayer } from "lostcoords/ui/helpers/top_layer"
6
+ import { isSheetViewport } from "lostcoords/ui/helpers/viewport"
5
7
 
6
8
  const catalogs = new Map()
7
9
  const MAX_RESULTS = 250
@@ -133,24 +135,14 @@ export class CatalogPicker extends FloatingPanel {
133
135
  }
134
136
 
135
137
  showSheet() {
136
- if (!this.supportsPopover) return
137
-
138
- if (this.popoverTarget.getAttribute("popover") !== "manual") {
138
+ if (this.popoverTarget.getAttribute("popover") !== "manual")
139
139
  this.popoverTarget.setAttribute("popover", "manual")
140
- }
141
- try {
142
- if (!this.popoverTarget.matches(":popover-open")) this.popoverTarget.showPopover()
143
- } catch {}
140
+ showTopLayer(this.popoverTarget)
144
141
  }
145
142
 
146
143
  hideSheet() {
147
- if (this.supportsPopover && this.popoverTarget.matches(":popover-open")) {
148
- try {
149
- this.popoverTarget.hidePopover()
150
- } catch {}
151
- } else {
152
- this.popoverTarget.hidden = true
153
- }
144
+ if (this.supportsPopover) hideTopLayer(this.popoverTarget)
145
+ else this.popoverTarget.hidden = true
154
146
  }
155
147
 
156
148
  // A non-modal popover's ::backdrop doesn't block input, so a tap in the dimmed
@@ -179,13 +171,71 @@ export class CatalogPicker extends FloatingPanel {
179
171
  this.applyFilter(this.searchQuery)
180
172
  }
181
173
 
174
+ // Arrow Down leaves the search for the first option in view; Enter picks
175
+ // the best match.
182
176
  onSearchKeydown(event) {
183
- if (event.key !== "Escape" || !this.searchQuery) return
177
+ if (event.isComposing) return
178
+
179
+ if (event.key === "ArrowDown") {
180
+ const top = this.bodyTarget.getBoundingClientRect().top
181
+ const option = this.gridOptions().find((item) => item.getBoundingClientRect().bottom > top)
182
+ if (!option) return
183
+ event.preventDefault()
184
+ rove(this.allOptions(), option)
185
+ option.focus()
186
+ } else if (event.key === "Enter" && this.searchQuery) {
187
+ const first = this.gridOptions()[0]
188
+ if (!first) return
189
+ event.preventDefault()
190
+ first.click()
191
+ }
192
+ }
193
+
194
+ // The options are one Tab stop. Arrows move through them as a grid that
195
+ // flows in rows, across section breaks; Arrow Up from the top row returns
196
+ // to the search field.
197
+ navigateGrid(event) {
198
+ if (!event.target.matches(this.optionSelector)) return
199
+
200
+ const options = this.gridOptions()
201
+ const option = navigate(event, options, { orientation: "grid", flow: "row" })
202
+ if (option) {
203
+ rove(this.allOptions(), option)
204
+ return
205
+ }
206
+ if (event.key === "ArrowUp" && this.hasSearchInputTarget && !event.defaultPrevented) {
207
+ event.preventDefault()
208
+ this.searchInputTarget.focus()
209
+ }
210
+ }
211
+
212
+ // Keeps the Tab stop on the option focus last landed on.
213
+ trackGridFocus(event) {
214
+ if (event.target.matches?.(this.optionSelector)) rove(this.allOptions(), event.target)
215
+ }
216
+
217
+ syncGridStop() {
218
+ const options = this.allOptions()
219
+ const stop = this.gridStop(options)
220
+ rove(options, stop)
221
+ }
222
+
223
+ gridStop(options = this.allOptions()) {
224
+ const visible = (option) => option.getClientRects().length > 0
225
+ return (
226
+ options.find((option) => option.getAttribute("tabindex") === "0" && visible(option)) ||
227
+ options.find((option) => option.getAttribute("aria-current") === "true" && visible(option)) ||
228
+ options.find(visible) ||
229
+ null
230
+ )
231
+ }
232
+
233
+ gridOptions() {
234
+ return navigableItems(this.allOptions())
235
+ }
184
236
 
185
- event.preventDefault()
186
- event.stopPropagation()
187
- this.searchInputTarget.value = ""
188
- this.applyFilter("")
237
+ allOptions() {
238
+ return [...this.bodyTarget.querySelectorAll(this.optionSelector)]
189
239
  }
190
240
 
191
241
  applyFilter(query) {
@@ -195,6 +245,7 @@ export class CatalogPicker extends FloatingPanel {
195
245
  if (!normalizedQuery) {
196
246
  if (this.hasResultsTarget) this.resultsTarget.hidden = true
197
247
  if (this.hasEmptyTarget) this.emptyTarget.hidden = true
248
+ this.syncGridStop()
198
249
  return
199
250
  }
200
251
 
@@ -216,6 +267,7 @@ export class CatalogPicker extends FloatingPanel {
216
267
  this.resultsTarget.hidden = matches.length === 0
217
268
  if (this.hasEmptyTarget) this.emptyTarget.hidden = matches.length > 0
218
269
  this.bodyTarget.scrollTop = 0
270
+ this.syncGridStop()
219
271
  }
220
272
 
221
273
  score(entry, query) {
@@ -433,7 +485,7 @@ export class CatalogPicker extends FloatingPanel {
433
485
  duration,
434
486
  settle: duration,
435
487
  transition: `transform ${SHEET_TIMING}`,
436
- offscreen: "translateY(var(--sheet-offscreen))",
488
+ offscreen: "translateY(var(--lc-sheet-offscreen))",
437
489
  }
438
490
  }
439
491
 
@@ -446,7 +498,7 @@ export class CatalogPicker extends FloatingPanel {
446
498
  }
447
499
 
448
500
  get supportsPopover() {
449
- return typeof this.popoverTarget.showPopover === "function"
501
+ return supportsTopLayer()
450
502
  }
451
503
 
452
504
  get searchQuery() {
@@ -1,4 +1,4 @@
1
- import { isSheetViewport } from "helpers/viewport"
1
+ import { isSheetViewport } from "lostcoords/ui/helpers/viewport"
2
2
 
3
3
  const EDGE = 16
4
4
 
@@ -10,19 +10,19 @@ export function showContextDialog(dialog, menu) {
10
10
  const corner = rect?.height
11
11
  ? { top: Math.max(EDGE, rect.bottom - dialog.offsetHeight), right: rect.right }
12
12
  : null
13
- if (anchorDialog(dialog, corner)) dialog.style.setProperty("--dialog-origin", "bottom right")
13
+ if (anchorDialog(dialog, corner)) dialog.style.setProperty("--lc-dialog-origin", "bottom right")
14
14
  }
15
15
 
16
16
  export function anchorDialog(dialog, corner) {
17
17
  delete dialog.dataset.anchored
18
- dialog.style.removeProperty("--dialog-top")
19
- dialog.style.removeProperty("--dialog-right")
18
+ dialog.style.removeProperty("--lc-dialog-top")
19
+ dialog.style.removeProperty("--lc-dialog-right")
20
20
  if (!corner || isSheetViewport()) return false
21
21
 
22
22
  dialog.dataset.anchored = ""
23
- dialog.style.setProperty("--dialog-top", `${corner.top}px`)
23
+ dialog.style.setProperty("--lc-dialog-top", `${corner.top}px`)
24
24
  dialog.style.setProperty(
25
- "--dialog-right",
25
+ "--lc-dialog-right",
26
26
  `${Math.max(EDGE, window.innerWidth - corner.right)}px`,
27
27
  )
28
28
  return true
@@ -1,4 +1,4 @@
1
- import { registerDismissable } from "helpers/dismiss_stack"
1
+ import { registerDismissable } from "lostcoords/ui/helpers/dismiss_stack"
2
2
 
3
3
  const registrations = new WeakMap()
4
4
 
@@ -8,7 +8,7 @@ export function registerDialog(dialog, { close = () => dialog.close(), lockScrol
8
8
 
9
9
  const unregisterDismissable = registerDismissable({
10
10
  element: dialog,
11
- type: "modal",
11
+ type: "lc-dialog",
12
12
  lockScroll,
13
13
  close,
14
14
  isOpen: () => dialog.open,
@@ -3,9 +3,8 @@ let nextOrder = 0
3
3
  let lockedScrollY = 0
4
4
  let scrollHolds = 0
5
5
 
6
- document.addEventListener("keydown", dismissOnEscape, true)
7
-
8
6
  export function registerDismissable(options) {
7
+ if (stack.length === 0) document.addEventListener("keydown", dismissOnEscape, true)
9
8
  const entry = {
10
9
  type: "panel",
11
10
  isOpen: defaultIsOpen,
@@ -17,7 +16,7 @@ export function registerDismissable(options) {
17
16
  stack.push(entry)
18
17
  syncModalScrollLock()
19
18
 
20
- if (entry.type === "modal" && entry.element instanceof HTMLDialogElement) {
19
+ if (entry.type === "lc-dialog" && entry.element instanceof HTMLDialogElement) {
21
20
  entry.cancel = (event) => cancelDialog(event, entry)
22
21
  entry.element.addEventListener("cancel", entry.cancel)
23
22
  }
@@ -48,6 +47,7 @@ export function closeTopDismissable(event) {
48
47
 
49
48
  function dismissOnEscape(event) {
50
49
  if (event.key !== "Escape" && event.key !== "Esc") return
50
+ if (clearSearchField(event)) return
51
51
  if (!closeTopDismissable(event)) return
52
52
 
53
53
  event.preventDefault()
@@ -55,6 +55,21 @@ function dismissOnEscape(event) {
55
55
  event.stopImmediatePropagation()
56
56
  }
57
57
 
58
+ // Escape in a search field that has text clears it, as a native search field
59
+ // does, and only the next Escape closes the panel around it.
60
+ function clearSearchField(event) {
61
+ const field = event.target
62
+ if (!(field instanceof HTMLInputElement) || field.type !== "search" || field.value === "")
63
+ return false
64
+ if (!topDismissable()?.element.contains(field)) return false
65
+
66
+ event.preventDefault()
67
+ event.stopImmediatePropagation()
68
+ field.value = ""
69
+ field.dispatchEvent(new Event("input", { bubbles: true }))
70
+ return true
71
+ }
72
+
58
73
  function cancelDialog(event, dialogEntry) {
59
74
  const entry = topDismissable(dialogEntry.element)
60
75
  if (!entry) return
@@ -81,10 +96,16 @@ function topDismissable(scope = topModal()?.element) {
81
96
 
82
97
  function topModal() {
83
98
  return openEntries()
84
- .filter((entry) => entry.type === "modal")
99
+ .filter((entry) => entry.type === "lc-dialog")
85
100
  .at(-1)
86
101
  }
87
102
 
103
+ // The modal the page is limited to, if any: the top open lc-dialog, or a native
104
+ // modal <dialog> opened without one. Shortcuts and toasts stay inside it.
105
+ export function topModalElement() {
106
+ return topModal()?.element ?? [...document.querySelectorAll("dialog:modal")].at(-1) ?? null
107
+ }
108
+
88
109
  function openEntries() {
89
110
  return stack.filter((entry) => entry.element?.isConnected && entry.isOpen.call(entry))
90
111
  }
@@ -92,6 +113,7 @@ function openEntries() {
92
113
  function unregisterDismissable(entry) {
93
114
  const index = stack.indexOf(entry)
94
115
  if (index >= 0) stack.splice(index, 1)
116
+ if (stack.length === 0) document.removeEventListener("keydown", dismissOnEscape, true)
95
117
 
96
118
  if (entry.cancel) {
97
119
  entry.element.removeEventListener("cancel", entry.cancel)
@@ -108,6 +130,7 @@ function defaultIsOpen() {
108
130
  }
109
131
 
110
132
  function priorityFor(type) {
133
+ if (type === "tooltip") return 40
111
134
  if (type === "popover") return 30
112
135
  if (type === "panel") return 20
113
136
  return 10
@@ -116,7 +139,7 @@ function priorityFor(type) {
116
139
  function syncModalScrollLock() {
117
140
  if (
118
141
  scrollHolds > 0 ||
119
- openEntries().some((entry) => entry.type === "modal" && entry.lockScroll !== false)
142
+ openEntries().some((entry) => entry.type === "lc-dialog" && entry.lockScroll !== false)
120
143
  ) {
121
144
  lockDocumentScroll()
122
145
  return
@@ -136,7 +159,7 @@ function lockDocumentScroll() {
136
159
  body.style.top = `-${lockedScrollY}px`
137
160
  // Pinning the body drags the sticky nav off with the scrolled-away page;
138
161
  // the nav offsets itself by this to stay where it was.
139
- body.style.setProperty("--scroll-lock-y", `${lockedScrollY}px`)
162
+ body.style.setProperty("--lc-scroll-lock-y", `${lockedScrollY}px`)
140
163
  body.style.inlineSize = "100%"
141
164
  }
142
165
 
@@ -147,7 +170,7 @@ function unlockDocumentScroll() {
147
170
  body.style.position = ""
148
171
  body.style.inset = ""
149
172
  body.style.top = ""
150
- body.style.removeProperty("--scroll-lock-y")
173
+ body.style.removeProperty("--lc-scroll-lock-y")
151
174
  body.style.inlineSize = ""
152
175
  delete body.dataset.modalScrollLock
153
176
  window.scrollTo({ top: lockedScrollY, behavior: "instant" })
@@ -1,9 +1,8 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { registerDismissable } from "helpers/dismiss_stack"
3
- import { liftToTopLayer } from "helpers/top_layer"
2
+ import { registerDismissable } from "lostcoords/ui/helpers/dismiss_stack"
3
+ import { liftToTopLayer, onPageLeave } from "lostcoords/ui/helpers/top_layer"
4
4
 
5
5
  const BEFORE_OPEN = "overlay:before-open"
6
- const PAGE_LEAVES = ["turbo:before-cache", "turbo:before-visit", "turbo:before-render"]
7
6
 
8
7
  export class FloatingPanel extends Controller {
9
8
  static targets = ["trigger", "popover"]
@@ -14,7 +13,9 @@ export class FloatingPanel extends Controller {
14
13
 
15
14
  initialize() {
16
15
  this.closeForOther = (event) => {
17
- if (event.detail?.source !== this) this.close()
16
+ // A panel opened inside this one (a menu in a popover form) keeps it open.
17
+ const source = event.detail?.source
18
+ if (source !== this && !this.element.contains(source?.element)) this.close()
18
19
  }
19
20
  this.closeOnOutsideClick = (event) => {
20
21
  if (!this.element.contains(event.target)) this.close()
@@ -24,14 +25,12 @@ export class FloatingPanel extends Controller {
24
25
 
25
26
  connect() {
26
27
  document.addEventListener(BEFORE_OPEN, this.closeForOther)
27
- for (const type of PAGE_LEAVES) document.addEventListener(type, this.closeForPageLeave)
28
- window.addEventListener("pagehide", this.closeForPageLeave)
28
+ this.stopPageLeave = onPageLeave(this.closeForPageLeave)
29
29
  }
30
30
 
31
31
  disconnect() {
32
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)
33
+ this.stopPageLeave?.()
35
34
  this.release({ immediate: true })
36
35
  }
37
36
 
@@ -1,27 +1,28 @@
1
- const ITEMS = '[role^="menuitem"]:not([disabled]):not([aria-disabled="true"])'
1
+ import { navigableItems, navigate } from "lostcoords/ui/helpers/roving_focus"
2
2
 
3
- function items(menu) {
4
- return [...menu.querySelectorAll(ITEMS)].filter((item) => item.getClientRects().length > 0)
3
+ const ITEMS = '[role^="menuitem"]'
4
+
5
+ export function menuItems(menu) {
6
+ return navigableItems(menu.querySelectorAll(ITEMS))
5
7
  }
6
8
 
7
9
  export function focusFirstMenuItem(menu) {
8
- items(menu)[0]?.focus()
10
+ menuItems(menu)[0]?.focus()
9
11
  }
10
12
 
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
13
+ export function focusLastMenuItem(menu) {
14
+ menuItems(menu).at(-1)?.focus()
15
+ }
15
16
 
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
17
+ // The ARIA menu pattern: arrows move between items and wrap, Home/End jump,
18
+ // typing a letter jumps to the next item that starts with it, and Space
19
+ // activates a link item as Enter does.
20
+ export function navigateMenu(event, menu, { orientation = "vertical" } = {}) {
21
+ if (event.key === " " && event.target.matches(`a${ITEMS}`)) {
22
+ event.preventDefault()
23
+ event.target.click()
24
+ return
25
+ }
24
26
 
25
- event.preventDefault()
26
- list[next].focus()
27
+ navigate(event, menuItems(menu), { orientation, wrap: true, typeahead: true })
27
28
  }
@@ -0,0 +1,175 @@
1
+ // Arrow-key navigation shared by every list, toolbar and grid in the library.
2
+ //
3
+ // orientation:
4
+ // "vertical" ArrowUp/ArrowDown move through the items in order.
5
+ // "horizontal" ArrowLeft/ArrowRight do, flipped in right-to-left text.
6
+ // "both" All four arrows do, for toolbars that wrap onto two lines.
7
+ // "grid" Items laid out in rows (flow: "row") or columns (flow: "column").
8
+ // Arrows along the flow move in order; arrows across it move to
9
+ // the nearest item in the next row or column, by position on screen.
10
+ // wrap: the ends lead back round to the other end. Grids never wrap.
11
+ // typeahead: typing a letter moves to the next item whose label starts with it.
12
+ //
13
+ // navigate() moves focus and returns the item it moved to, or null when the key
14
+ // is not one it handles. rove() keeps a single Tab stop on the current item.
15
+
16
+ const TYPEAHEAD_RESET = 500
17
+ const typeaheads = new WeakMap()
18
+
19
+ export function navigableItems(items) {
20
+ return [...items].filter(
21
+ (item) =>
22
+ !item.disabled &&
23
+ item.getAttribute("aria-disabled") !== "true" &&
24
+ !item.closest("[inert]") &&
25
+ item.getClientRects().length > 0,
26
+ )
27
+ }
28
+
29
+ export function rove(items, current) {
30
+ for (const item of items) item.tabIndex = item === current ? 0 : -1
31
+ }
32
+
33
+ export function navigate(
34
+ event,
35
+ items,
36
+ {
37
+ orientation = "vertical",
38
+ flow = "row",
39
+ wrap = orientation !== "grid",
40
+ typeahead = false,
41
+ label = defaultLabel,
42
+ focus = true,
43
+ } = {},
44
+ ) {
45
+ if (event.defaultPrevented || event.isComposing || event.altKey || event.metaKey) return null
46
+
47
+ const list = navigableItems(items)
48
+ if (list.length === 0) return null
49
+
50
+ const index = list.findIndex((item) => item === event.target || item.contains(event.target))
51
+ const editing = isEditable(event.target)
52
+ let target = null
53
+
54
+ if (event.key === "Home" || event.key === "End") {
55
+ if (editing) return null
56
+ if (event.ctrlKey && orientation !== "grid") return null
57
+ target = event.key === "Home" ? list[0] : list.at(-1)
58
+ } else if (event.key.startsWith("Arrow")) {
59
+ if (event.ctrlKey) return null
60
+ if (editing && (event.key === "ArrowLeft" || event.key === "ArrowRight")) return null
61
+ target = arrowTarget(event.key, list, index, {
62
+ orientation,
63
+ flow,
64
+ wrap,
65
+ rtl: isRtl(event.target),
66
+ })
67
+ } else if (typeahead && !editing && !event.ctrlKey) {
68
+ target = typeaheadTarget(event.key, list, index, label)
69
+ }
70
+
71
+ if (!target) return null
72
+ event.preventDefault()
73
+ if (focus) target.focus()
74
+ return target
75
+ }
76
+
77
+ function arrowTarget(key, list, index, { orientation, flow, wrap, rtl }) {
78
+ const vertical = key === "ArrowUp" || key === "ArrowDown"
79
+ const forward = key === "ArrowDown" || key === (rtl ? "ArrowLeft" : "ArrowRight")
80
+
81
+ if (orientation === "vertical" && !vertical) return null
82
+ if (orientation === "horizontal" && vertical) return null
83
+ if (orientation === "grid" && vertical !== (flow === "column")) {
84
+ return index < 0 ? list[0] : nearestAcross(list, list[index], key)
85
+ }
86
+
87
+ if (index < 0) return forward ? list[0] : list.at(-1)
88
+ const next = index + (forward ? 1 : -1)
89
+ if (next >= 0 && next < list.length) return list[next]
90
+ if (!wrap) return null
91
+ return list[(next + list.length) % list.length]
92
+ }
93
+
94
+ // The nearest row (or column) in the arrow's direction first, then the item in
95
+ // it closest to the current one, so a short last row still has a landing spot.
96
+ function nearestAcross(list, from, key) {
97
+ const vertical = key === "ArrowUp" || key === "ArrowDown"
98
+ const sign = key === "ArrowDown" || key === "ArrowRight" ? 1 : -1
99
+ const origin = centre(from.getBoundingClientRect())
100
+ const reach = (vertical ? from.offsetHeight : from.offsetWidth) / 2
101
+
102
+ const ahead = []
103
+ for (const item of list) {
104
+ if (item === from) continue
105
+ const point = centre(item.getBoundingClientRect())
106
+ const along = (vertical ? point.y - origin.y : point.x - origin.x) * sign
107
+ if (along < reach) continue
108
+ ahead.push({
109
+ item,
110
+ along,
111
+ across: Math.abs(vertical ? point.x - origin.x : point.y - origin.y),
112
+ })
113
+ }
114
+ if (ahead.length === 0) return null
115
+
116
+ const line = Math.min(...ahead.map((candidate) => candidate.along)) + reach
117
+ const sameLine = ahead.filter((candidate) => candidate.along <= line)
118
+ return sameLine.reduce((best, candidate) => (candidate.across < best.across ? candidate : best))
119
+ .item
120
+ }
121
+
122
+ // Repeating one letter cycles through the items that start with it; typing a
123
+ // word quickly matches the whole prefix.
124
+ function typeaheadTarget(key, list, index, label) {
125
+ if (key.length !== 1) return null
126
+
127
+ const state = typeaheads.get(list[0].parentElement) ?? { text: "", timer: null }
128
+ if (key === " " && state.text === "") return null
129
+
130
+ clearTimeout(state.timer)
131
+ state.text += key.toLocaleLowerCase()
132
+ state.timer = setTimeout(() => {
133
+ state.text = ""
134
+ }, TYPEAHEAD_RESET)
135
+ typeaheads.set(list[0].parentElement, state)
136
+
137
+ const repeated = [...state.text].every((letter) => letter === state.text[0])
138
+ const query = repeated ? state.text[0] : state.text
139
+ const start = repeated ? index + 1 : Math.max(index, 0)
140
+ for (let offset = 0; offset < list.length; offset++) {
141
+ const item = list[(start + offset) % list.length]
142
+ if (label(item).startsWith(query)) return item
143
+ }
144
+ return null
145
+ }
146
+
147
+ function defaultLabel(item) {
148
+ return (item.getAttribute("aria-label") || item.textContent).trim().toLocaleLowerCase()
149
+ }
150
+
151
+ function centre(rect) {
152
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
153
+ }
154
+
155
+ function isRtl(element) {
156
+ return getComputedStyle(element).direction === "rtl"
157
+ }
158
+
159
+ export function isEditable(element) {
160
+ if (!(element instanceof Element)) return false
161
+ if (element.isContentEditable || element.tagName === "TEXTAREA" || element.tagName === "SELECT")
162
+ return true
163
+ if (element.tagName !== "INPUT") return false
164
+ return ![
165
+ "button",
166
+ "checkbox",
167
+ "radio",
168
+ "range",
169
+ "reset",
170
+ "submit",
171
+ "color",
172
+ "file",
173
+ "image",
174
+ ].includes(element.type)
175
+ }
@@ -1,13 +1,13 @@
1
- import { prefersReducedMotion } from "helpers/viewport"
2
- // The sheet beat lives in CSS (--sheet-duration, --ease-sheet) so CSS- and
1
+ import { prefersReducedMotion } from "lostcoords/ui/helpers/viewport"
2
+ // The sheet beat lives in CSS (--lc-sheet-duration, --lc-ease-sheet) so CSS- and
3
3
  // JS-driven slides can't drift apart. Inline transitions use the variables
4
4
  // directly; timers need the duration in milliseconds.
5
- export const SHEET_TIMING = "var(--sheet-duration) var(--ease-sheet)"
5
+ export const SHEET_TIMING = "var(--lc-sheet-duration) var(--lc-ease-sheet)"
6
6
 
7
7
  export function sheetDuration() {
8
8
  if (prefersReducedMotion()) return 0
9
9
  const value = getComputedStyle(document.documentElement)
10
- .getPropertyValue("--sheet-duration")
10
+ .getPropertyValue("--lc-sheet-duration")
11
11
  .trim()
12
12
  return value.endsWith("ms") ? parseFloat(value) : parseFloat(value) * 1000
13
13
  }