lostcoords-ui 0.5.0 → 0.7.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +111 -49
  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/popover.css +0 -52
  18. data/app/assets/tailwind/lostcoords/ui/{reading.css → prose.css} +6 -0
  19. data/app/assets/tailwind/lostcoords/ui/sheet.css +3 -64
  20. data/app/assets/tailwind/lostcoords/ui/theme.css +16 -1
  21. data/app/assets/tailwind/lostcoords/ui/toast.css +23 -0
  22. data/app/assets/tailwind/lostcoords/ui/tooltip.css +12 -11
  23. data/app/assets/tailwind/lostcoords/ui/typography.css +1 -7
  24. data/app/helpers/lostcoords/ui/badge_helper.rb +2 -2
  25. data/app/helpers/lostcoords/ui/button_helper.rb +12 -6
  26. data/app/helpers/lostcoords/ui/choice_helper.rb +11 -7
  27. data/app/helpers/lostcoords/ui/collapsible_helper.rb +9 -0
  28. data/app/helpers/lostcoords/ui/dialog_helper.rb +10 -5
  29. data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +6 -0
  30. data/app/helpers/lostcoords/ui/feedback_helper.rb +2 -2
  31. data/app/helpers/lostcoords/ui/form_helper.rb +4 -3
  32. data/app/helpers/lostcoords/ui/glass_helper.rb +1 -1
  33. data/app/helpers/lostcoords/ui/menu_helper.rb +37 -2
  34. data/app/helpers/lostcoords/ui/navigation_helper.rb +17 -4
  35. data/app/helpers/lostcoords/ui/render_attributes_helper.rb +17 -1
  36. data/app/helpers/lostcoords/ui/sheet_helper.rb +4 -0
  37. data/app/helpers/lostcoords/ui/structures_helper.rb +7 -95
  38. data/app/helpers/lostcoords/ui/toast_helper.rb +22 -0
  39. data/app/helpers/lostcoords/ui/{overlay_helper.rb → tooltip_helper.rb} +3 -3
  40. data/app/helpers/lostcoords/ui/typography_helper.rb +30 -0
  41. data/app/javascript/lostcoords/ui/controllers/lc_collapsible_controller.js +1 -1
  42. data/app/javascript/lostcoords/ui/controllers/lc_emoji_picker_controller.js +34 -6
  43. data/app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js +12 -0
  44. data/app/javascript/lostcoords/ui/controllers/lc_menu_controller.js +23 -1
  45. data/app/javascript/lostcoords/ui/controllers/lc_selection_controller.js +8 -16
  46. data/app/javascript/lostcoords/ui/controllers/lc_sheet_controller.js +15 -3
  47. data/app/javascript/lostcoords/ui/controllers/lc_shortcut_controller.js +45 -0
  48. data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +18 -15
  49. data/app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js +62 -0
  50. data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +46 -6
  51. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +72 -20
  52. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +23 -0
  53. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +6 -7
  54. data/app/javascript/lostcoords/ui/helpers/menu_keys.js +19 -18
  55. data/app/javascript/lostcoords/ui/helpers/roving_focus.js +175 -0
  56. data/app/javascript/lostcoords/ui/helpers/shortcuts.js +162 -0
  57. data/app/javascript/lostcoords/ui/helpers/top_layer.js +48 -10
  58. data/app/javascript/lostcoords/ui/index.js +6 -0
  59. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +27 -27
  60. data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +3 -3
  61. data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +1 -1
  62. data/lib/lostcoords/ui/action_view.rb +1 -0
  63. data/lib/lostcoords/ui/builders.rb +2 -2
  64. data/lib/lostcoords/ui/form_builder.rb +55 -10
  65. data/lib/lostcoords/ui/stylesheets.rb +25 -0
  66. data/lib/lostcoords/ui/version.rb +1 -1
  67. data/lib/tasks/lostcoords_ui.rake +11 -0
  68. metadata +23 -6
  69. data/config/helper_renames.json +0 -111
  70. data/script/migrate_helpers.rb +0 -136
@@ -1,5 +1,9 @@
1
1
  import { FloatingPanel } from "lostcoords/ui/helpers/floating_panel"
2
- import { focusFirstMenuItem, navigateMenu } from "lostcoords/ui/helpers/menu_keys"
2
+ import {
3
+ focusFirstMenuItem,
4
+ focusLastMenuItem,
5
+ navigateMenu,
6
+ } from "lostcoords/ui/helpers/menu_keys"
3
7
 
4
8
  // The phone dock's action stack lays itself out over the fixed nav, so it
5
9
  // passes lift: false and stays in place instead of lifting into the top layer.
@@ -9,15 +13,33 @@ export default class extends FloatingPanel {
9
13
  this.focusOutHandler = this.handleFocusOut.bind(this)
10
14
  this.pointerDownHandler = this.handlePointerDown.bind(this)
11
15
  this.keydownHandler = (event) => navigateMenu(event, this.popoverTarget)
16
+ this.triggerKeydownHandler = this.handleTriggerKeydown.bind(this)
12
17
  this.focusOutSuppressed = false
13
18
  this.focusOutSuppressionTimer = null
14
19
  }
15
20
 
21
+ connect() {
22
+ super.connect()
23
+ this.triggerTarget.addEventListener("keydown", this.triggerKeydownHandler)
24
+ }
25
+
16
26
  disconnect() {
27
+ this.triggerTarget.removeEventListener("keydown", this.triggerKeydownHandler)
17
28
  this.clearFocusOutSuppression()
18
29
  super.disconnect()
19
30
  }
20
31
 
32
+ // Arrow Down opens the menu on its first item, Arrow Up on its last.
33
+ handleTriggerKeydown(event) {
34
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
35
+ if (event.altKey || event.ctrlKey || event.metaKey) return
36
+
37
+ event.preventDefault()
38
+ this.open()
39
+ if (event.key === "ArrowDown") focusFirstMenuItem(this.popoverTarget)
40
+ else focusLastMenuItem(this.popoverTarget)
41
+ }
42
+
21
43
  toggle(event) {
22
44
  event.preventDefault()
23
45
  event.stopPropagation()
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { navigate } from "lostcoords/ui/helpers/roving_focus"
2
3
 
3
4
  export default class extends Controller {
4
5
  static targets = ["option", "input"]
@@ -8,7 +9,7 @@ export default class extends Controller {
8
9
  const selected = this.options.find((option) =>
9
10
  this.hasValueValue
10
11
  ? option.dataset.value === this.valueValue
11
- : option.getAttribute("aria-pressed") === "true",
12
+ : option.getAttribute("aria-checked") === "true",
12
13
  )
13
14
  this.initialOption = selected || this.options[0]
14
15
  this.apply(this.initialOption)
@@ -28,28 +29,19 @@ export default class extends Controller {
28
29
  this.changed(option)
29
30
  }
30
31
 
32
+ // All four arrows move and select, wrapping at the ends; Left and Right
33
+ // follow the text direction.
31
34
  navigate(event) {
32
- const current = this.options.indexOf(event.target)
33
- if (current < 0) return
34
- const next = {
35
- ArrowRight: (current + 1) % this.options.length,
36
- ArrowDown: (current + 1) % this.options.length,
37
- ArrowLeft: (current - 1 + this.options.length) % this.options.length,
38
- ArrowUp: (current - 1 + this.options.length) % this.options.length,
39
- Home: 0,
40
- End: this.options.length - 1,
41
- }[event.key]
42
- if (next === undefined) return
43
- event.preventDefault()
44
- const option = this.options[next]
45
- option.focus()
35
+ const option = navigate(event, this.optionTargets, { orientation: "both", wrap: true })
36
+ if (!option) return
37
+
46
38
  this.apply(option)
47
39
  this.changed(option)
48
40
  }
49
41
 
50
42
  apply(selected) {
51
43
  for (const option of this.optionTargets) {
52
- option.setAttribute("aria-pressed", String(option === selected))
44
+ option.setAttribute("aria-checked", String(option === selected))
53
45
  option.tabIndex = option === selected ? 0 : -1
54
46
  }
55
47
  if (this.hasInputTarget) this.inputTarget.value = selected?.dataset.value || ""
@@ -155,8 +155,11 @@ export default class extends Controller {
155
155
  if (this.gesture?.dragging) this.track(event.clientY, event.timeStamp)
156
156
  }
157
157
 
158
- onPointerUp() {
158
+ // A mouse drag that ends off the sheet is followed by a click on the
159
+ // <dialog> itself, which reads as a backdrop click and would close it.
160
+ onPointerUp(event) {
159
161
  this.stopMouseTracking()
162
+ if (this.gesture && Math.abs(event.clientY - this.gesture.startY) > 4) swallowNextClick()
160
163
  this.onTouchEnd()
161
164
  }
162
165
 
@@ -340,7 +343,7 @@ export default class extends Controller {
340
343
  this.releaseScroll = holdDocumentScroll()
341
344
  const lockedTop = -parseFloat(document.body.style.top || "0")
342
345
  root.style.setProperty("--lc-sheet-page-origin", `${window.scrollY + lockedTop}px`)
343
- root.dataset.sheetPage = ""
346
+ root.dataset.lcSheetPage = ""
344
347
  this.ownsPage = true
345
348
  }
346
349
  root.style.setProperty("--lc-sheet-page-p", p.toFixed(3))
@@ -361,7 +364,7 @@ export default class extends Controller {
361
364
  if (!this.ownsPage) return
362
365
  this.ownsPage = false
363
366
  const root = document.documentElement
364
- delete root.dataset.sheetPage
367
+ delete root.dataset.lcSheetPage
365
368
  for (const name of ["p", "scale", "origin", "transition"])
366
369
  root.style.removeProperty(`--lc-sheet-page-${name}`)
367
370
  this.releaseScroll()
@@ -420,3 +423,12 @@ export default class extends Controller {
420
423
  return isSheetViewport()
421
424
  }
422
425
  }
426
+
427
+ function swallowNextClick() {
428
+ const swallow = (event) => {
429
+ event.preventDefault()
430
+ event.stopImmediatePropagation()
431
+ }
432
+ window.addEventListener("click", swallow, { capture: true, once: true })
433
+ setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0)
434
+ }
@@ -0,0 +1,45 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { isEditable } from "lostcoords/ui/helpers/roving_focus"
3
+ import { ariaKeyShortcuts, registerShortcut } from "lostcoords/ui/helpers/shortcuts"
4
+
5
+ // Binds page-wide keys to the element: a field takes focus and selects its
6
+ // text, anything else is clicked. The element announces its keys through
7
+ // aria-keyshortcuts. See helpers/shortcuts.js for the key syntax.
8
+ export default class extends Controller {
9
+ static values = { keys: String }
10
+
11
+ // Stimulus keeps values across a reconnect (a moved or permanent element)
12
+ // without calling keysValueChanged again, so connect registers too.
13
+ connect() {
14
+ this.register()
15
+ }
16
+
17
+ keysValueChanged() {
18
+ if (this.connected) this.register()
19
+ }
20
+
21
+ register() {
22
+ this.connected = true
23
+ this.unregister?.()
24
+ this.unregister = null
25
+ if (!this.keysValue) return
26
+
27
+ this.element.setAttribute("aria-keyshortcuts", ariaKeyShortcuts(this.keysValue))
28
+ this.unregister = registerShortcut(this.element, this.keysValue, () => this.run())
29
+ }
30
+
31
+ disconnect() {
32
+ this.connected = false
33
+ this.unregister?.()
34
+ this.unregister = null
35
+ }
36
+
37
+ run() {
38
+ if (isEditable(this.element)) {
39
+ this.element.focus()
40
+ this.element.select?.()
41
+ } else {
42
+ this.element.click()
43
+ }
44
+ }
45
+ }
@@ -1,29 +1,32 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { navigate } from "lostcoords/ui/helpers/roving_focus"
2
3
 
3
- // Tabs with automatic activation: arrow keys, Home and End move focus and select.
4
+ // The ARIA tabs pattern. Arrow keys follow the tab list's aria-orientation, and
5
+ // Home and End jump to the ends. With automatic activation, moving focus selects
6
+ // the tab; with manual activation, Enter or Space does.
4
7
  export default class extends Controller {
5
8
  static targets = ["tab", "panel"]
9
+ static values = { activation: { type: String, default: "automatic" } }
6
10
 
7
11
  select(event) {
8
12
  this.show(event.currentTarget)
9
13
  }
10
14
 
11
15
  navigate(event) {
12
- const tabs = this.tabTargets.filter((tab) => !tab.disabled)
13
- const index = tabs.indexOf(event.currentTarget)
14
- const step = getComputedStyle(this.element).direction === "rtl" ? -1 : 1
15
- const next = {
16
- ArrowRight: index + step,
17
- ArrowLeft: index - step,
18
- Home: 0,
19
- End: tabs.length - 1,
20
- }[event.key]
21
- if (next === undefined) return
16
+ const list = event.currentTarget.closest('[role="tablist"]')
17
+ const tab = navigate(event, this.tabTargets, {
18
+ orientation:
19
+ list?.getAttribute("aria-orientation") === "vertical" ? "vertical" : "horizontal",
20
+ wrap: true,
21
+ })
22
+ if (!tab) return
22
23
 
23
- event.preventDefault()
24
- const tab = tabs[(next + tabs.length) % tabs.length]
25
- tab.focus()
26
- this.show(tab)
24
+ if (this.activationValue === "manual") this.focusOnly(tab)
25
+ else this.show(tab)
26
+ }
27
+
28
+ focusOnly(tab) {
29
+ for (const candidate of this.tabTargets) candidate.tabIndex = candidate === tab ? 0 : -1
27
30
  }
28
31
 
29
32
  show(tab) {
@@ -0,0 +1,62 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { topModalElement } from "lostcoords/ui/helpers/dismiss_stack"
3
+ import { hideTopLayer, showTopLayer, supportsTopLayer } from "lostcoords/ui/helpers/top_layer"
4
+
5
+ // Shows the region in the top layer while it holds toasts, so no sticky header,
6
+ // sheet or dialog paints over them. Each new toast re-opens the region, which
7
+ // moves it above anything opened since. A modal dialog makes the rest of the
8
+ // page inert, so while one is open the region moves inside it, keeping toasts
9
+ // clickable and announced, and moves back when it closes. Without the Popover
10
+ // API the region stays fixed at --lc-z-toast.
11
+ const homes = new WeakMap()
12
+
13
+ export default class extends Controller {
14
+ connect() {
15
+ if (!supportsTopLayer()) return
16
+
17
+ this.observer = new MutationObserver((records) => {
18
+ if (records.some((record) => record.addedNodes.length > 0)) this.raise()
19
+ else if (this.element.childElementCount === 0) this.lower()
20
+ })
21
+ this.observer.observe(this.element, { childList: true })
22
+ if (this.element.childElementCount > 0) this.raise()
23
+ }
24
+
25
+ // A modal removed right after closing (before its close event) takes the
26
+ // region with it; put the region back in the page.
27
+ disconnect() {
28
+ this.observer?.disconnect()
29
+ hideTopLayer(this.element)
30
+ if (!this.element.isConnected) this.goHome()
31
+ }
32
+
33
+ raise() {
34
+ const modal = topModalElement()
35
+ if (modal && !modal.contains(this.element)) return this.moveInto(modal)
36
+
37
+ hideTopLayer(this.element)
38
+ showTopLayer(this.element)
39
+ }
40
+
41
+ lower() {
42
+ hideTopLayer(this.element)
43
+ this.goHome()
44
+ }
45
+
46
+ // Moving the region reconnects this controller, which raises it again.
47
+ moveInto(modal) {
48
+ if (!homes.has(this.element))
49
+ homes.set(this.element, { parent: this.element.parentNode, next: this.element.nextSibling })
50
+ const region = this.element
51
+ modal.addEventListener("close", () => this.goHome(region), { once: true })
52
+ modal.append(region)
53
+ }
54
+
55
+ goHome(region = this.element) {
56
+ const home = homes.get(region)
57
+ if (!home?.parent.isConnected) return
58
+
59
+ homes.delete(region)
60
+ home.parent.insertBefore(region, home.next?.parentNode === home.parent ? home.next : null)
61
+ }
62
+ }
@@ -1,14 +1,54 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { registerDismissable } from "lostcoords/ui/helpers/dismiss_stack"
3
+ import { liftToTopLayer, onPageLeave } from "lostcoords/ui/helpers/top_layer"
2
4
 
3
- // Escape hides a visible tooltip without moving the pointer or focus. It shows
4
- // again after the pointer leaves or focus moves away.
5
+ // Opens the tooltip in the top layer, so no stacking context or overflow clip
6
+ // covers it. Without the Popover API it stays in place. Escape closes it before
7
+ // any menu or dialog around it, and it stays hidden until the pointer leaves
8
+ // and focus moves away.
5
9
  export default class extends Controller {
6
- dismiss(event) {
7
- if (event.key !== "Escape" || !this.element.matches(":hover, :focus-within")) return
8
- this.element.dataset.dismissed = ""
10
+ static targets = ["content"]
11
+
12
+ connect() {
13
+ this.stopPageLeave = onPageLeave(() => this.close({ immediate: true }))
14
+ }
15
+
16
+ disconnect() {
17
+ this.stopPageLeave?.()
18
+ this.close({ immediate: true })
19
+ }
20
+
21
+ show(event) {
22
+ if (event.type === "focusin" && !event.target.matches(":focus-visible")) return
23
+ if (this.release || this.element.hasAttribute("data-dismissed")) return
24
+
25
+ this.element.toggleAttribute("data-open", true)
26
+ this.release = liftToTopLayer(this.contentTarget, this.element)
27
+ this.unregister = registerDismissable({
28
+ type: "tooltip",
29
+ element: this.element,
30
+ isOpen: () => this.element.hasAttribute("data-open"),
31
+ close: () => this.dismiss(),
32
+ })
9
33
  }
10
34
 
11
- reset() {
35
+ hide() {
36
+ if (this.element.matches(":hover") || this.element.querySelector(":focus-visible")) return
37
+
12
38
  delete this.element.dataset.dismissed
39
+ this.close()
40
+ }
41
+
42
+ dismiss() {
43
+ this.element.dataset.dismissed = ""
44
+ this.close({ immediate: true })
45
+ }
46
+
47
+ close({ immediate = false } = {}) {
48
+ this.element.toggleAttribute("data-open", false)
49
+ this.release?.({ immediate })
50
+ this.release = null
51
+ this.unregister?.()
52
+ this.unregister = null
13
53
  }
14
54
  }
@@ -1,6 +1,8 @@
1
1
  import { holdDocumentScroll } from "lostcoords/ui/helpers/dismiss_stack"
2
2
  import { FloatingPanel } from "lostcoords/ui/helpers/floating_panel"
3
+ import { navigableItems, navigate, rove } from "lostcoords/ui/helpers/roving_focus"
3
4
  import { SHEET_TIMING, sheetDuration } from "lostcoords/ui/helpers/sheet_motion"
5
+ import { hideTopLayer, showTopLayer, supportsTopLayer } from "lostcoords/ui/helpers/top_layer"
4
6
  import { isSheetViewport } from "lostcoords/ui/helpers/viewport"
5
7
 
6
8
  const catalogs = new Map()
@@ -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) {
@@ -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() {
@@ -47,6 +47,7 @@ export function closeTopDismissable(event) {
47
47
 
48
48
  function dismissOnEscape(event) {
49
49
  if (event.key !== "Escape" && event.key !== "Esc") return
50
+ if (clearSearchField(event)) return
50
51
  if (!closeTopDismissable(event)) return
51
52
 
52
53
  event.preventDefault()
@@ -54,6 +55,21 @@ function dismissOnEscape(event) {
54
55
  event.stopImmediatePropagation()
55
56
  }
56
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
+
57
73
  function cancelDialog(event, dialogEntry) {
58
74
  const entry = topDismissable(dialogEntry.element)
59
75
  if (!entry) return
@@ -84,6 +100,12 @@ function topModal() {
84
100
  .at(-1)
85
101
  }
86
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
+
87
109
  function openEntries() {
88
110
  return stack.filter((entry) => entry.element?.isConnected && entry.isOpen.call(entry))
89
111
  }
@@ -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
@@ -1,9 +1,8 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { registerDismissable } from "lostcoords/ui/helpers/dismiss_stack"
3
- import { liftToTopLayer } from "lostcoords/ui/helpers/top_layer"
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
  }