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.
- checksums.yaml +4 -4
- data/README.md +111 -49
- data/app/assets/tailwind/lostcoords/ui/card.css +5 -2
- data/app/assets/tailwind/lostcoords/ui/{disclosure.css → collapsible.css} +1 -1
- data/app/assets/tailwind/lostcoords/ui/components/code-block.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/collapsible.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/dialog.css +9 -0
- data/app/assets/tailwind/lostcoords/ui/components/emoji-picker.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/menu.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/prose.css +3 -0
- data/app/assets/tailwind/lostcoords/ui/components/table.css +3 -0
- data/app/assets/tailwind/lostcoords/ui/components/toast.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/tooltip.css +4 -0
- data/app/assets/tailwind/lostcoords/ui/components.css +12 -25
- data/app/assets/tailwind/lostcoords/ui/core.css +21 -0
- data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +45 -47
- data/app/assets/tailwind/lostcoords/ui/popover.css +0 -52
- data/app/assets/tailwind/lostcoords/ui/{reading.css → prose.css} +6 -0
- data/app/assets/tailwind/lostcoords/ui/sheet.css +3 -64
- data/app/assets/tailwind/lostcoords/ui/theme.css +16 -1
- data/app/assets/tailwind/lostcoords/ui/toast.css +23 -0
- data/app/assets/tailwind/lostcoords/ui/tooltip.css +12 -11
- data/app/assets/tailwind/lostcoords/ui/typography.css +1 -7
- data/app/helpers/lostcoords/ui/badge_helper.rb +2 -2
- data/app/helpers/lostcoords/ui/button_helper.rb +12 -6
- data/app/helpers/lostcoords/ui/choice_helper.rb +11 -7
- data/app/helpers/lostcoords/ui/collapsible_helper.rb +9 -0
- data/app/helpers/lostcoords/ui/dialog_helper.rb +10 -5
- data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +6 -0
- data/app/helpers/lostcoords/ui/feedback_helper.rb +2 -2
- data/app/helpers/lostcoords/ui/form_helper.rb +4 -3
- data/app/helpers/lostcoords/ui/glass_helper.rb +1 -1
- data/app/helpers/lostcoords/ui/menu_helper.rb +37 -2
- data/app/helpers/lostcoords/ui/navigation_helper.rb +17 -4
- data/app/helpers/lostcoords/ui/render_attributes_helper.rb +17 -1
- data/app/helpers/lostcoords/ui/sheet_helper.rb +4 -0
- data/app/helpers/lostcoords/ui/structures_helper.rb +7 -95
- data/app/helpers/lostcoords/ui/toast_helper.rb +22 -0
- data/app/helpers/lostcoords/ui/{overlay_helper.rb → tooltip_helper.rb} +3 -3
- data/app/helpers/lostcoords/ui/typography_helper.rb +30 -0
- data/app/javascript/lostcoords/ui/controllers/lc_collapsible_controller.js +1 -1
- data/app/javascript/lostcoords/ui/controllers/lc_emoji_picker_controller.js +34 -6
- data/app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js +12 -0
- data/app/javascript/lostcoords/ui/controllers/lc_menu_controller.js +23 -1
- data/app/javascript/lostcoords/ui/controllers/lc_selection_controller.js +8 -16
- data/app/javascript/lostcoords/ui/controllers/lc_sheet_controller.js +15 -3
- data/app/javascript/lostcoords/ui/controllers/lc_shortcut_controller.js +45 -0
- data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +18 -15
- data/app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js +62 -0
- data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +46 -6
- data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +72 -20
- data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +23 -0
- data/app/javascript/lostcoords/ui/helpers/floating_panel.js +6 -7
- data/app/javascript/lostcoords/ui/helpers/menu_keys.js +19 -18
- data/app/javascript/lostcoords/ui/helpers/roving_focus.js +175 -0
- data/app/javascript/lostcoords/ui/helpers/shortcuts.js +162 -0
- data/app/javascript/lostcoords/ui/helpers/top_layer.js +48 -10
- data/app/javascript/lostcoords/ui/index.js +6 -0
- data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +27 -27
- data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +3 -3
- data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +1 -1
- data/lib/lostcoords/ui/action_view.rb +1 -0
- data/lib/lostcoords/ui/builders.rb +2 -2
- data/lib/lostcoords/ui/form_builder.rb +55 -10
- data/lib/lostcoords/ui/stylesheets.rb +25 -0
- data/lib/lostcoords/ui/version.rb +1 -1
- data/lib/tasks/lostcoords_ui.rake +11 -0
- metadata +23 -6
- data/config/helper_renames.json +0 -111
- data/script/migrate_helpers.rb +0 -136
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { FloatingPanel } from "lostcoords/ui/helpers/floating_panel"
|
|
2
|
-
import {
|
|
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-
|
|
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
|
|
33
|
-
if (
|
|
34
|
-
|
|
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-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
148
|
-
|
|
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.
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
window.removeEventListener("pagehide", this.closeForPageLeave)
|
|
33
|
+
this.stopPageLeave?.()
|
|
35
34
|
this.release({ immediate: true })
|
|
36
35
|
}
|
|
37
36
|
|
|
@@ -1,27 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
import { navigableItems, navigate } from "lostcoords/ui/helpers/roving_focus"
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
10
|
+
menuItems(menu)[0]?.focus()
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (list.length === 0) return
|
|
13
|
+
export function focusLastMenuItem(menu) {
|
|
14
|
+
menuItems(menu).at(-1)?.focus()
|
|
15
|
+
}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
26
|
-
list[next].focus()
|
|
27
|
+
navigate(event, menuItems(menu), { orientation, wrap: true, typeahead: true })
|
|
27
28
|
}
|