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.
- checksums.yaml +4 -4
- data/README.md +364 -96
- data/app/assets/tailwind/lostcoords/ui/avatar.css +6 -6
- data/app/assets/tailwind/lostcoords/ui/base.css +45 -0
- data/app/assets/tailwind/lostcoords/ui/card.css +23 -22
- data/app/assets/tailwind/lostcoords/ui/choice.css +39 -0
- data/app/assets/tailwind/lostcoords/ui/code-block.css +10 -0
- data/app/assets/tailwind/lostcoords/ui/{disclosure.css → collapsible.css} +6 -6
- 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 -0
- data/app/assets/tailwind/lostcoords/ui/copy-button.css +9 -9
- data/app/assets/tailwind/lostcoords/ui/core.css +21 -0
- data/app/assets/tailwind/lostcoords/ui/dialog.css +17 -17
- data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +86 -88
- data/app/assets/tailwind/lostcoords/ui/field.css +2 -2
- data/app/assets/tailwind/lostcoords/ui/glass.css +9 -8
- data/app/assets/tailwind/lostcoords/ui/layout.css +2 -2
- data/app/assets/tailwind/lostcoords/ui/popover.css +14 -14
- data/app/assets/tailwind/lostcoords/ui/prose.css +66 -0
- data/app/assets/tailwind/lostcoords/ui/sheet.css +62 -63
- data/app/assets/tailwind/lostcoords/ui/squircle.css +7 -27
- data/app/assets/tailwind/lostcoords/ui/table.css +51 -0
- data/app/assets/tailwind/lostcoords/ui/theme.css +209 -225
- data/app/assets/tailwind/lostcoords/ui/toast.css +23 -0
- data/app/assets/tailwind/lostcoords/ui/tooltip.css +51 -0
- data/app/assets/tailwind/lostcoords/ui/typography.css +31 -61
- data/app/assets/tailwind/lostcoords/ui/ui.css +2 -21
- data/app/assets/tailwind/lostcoords/ui/utilities.css +14 -22
- data/app/helpers/lostcoords/ui/badge_helper.rb +67 -110
- data/app/helpers/lostcoords/ui/button_helper.rb +44 -35
- data/app/helpers/lostcoords/ui/card_helper.rb +9 -52
- data/app/helpers/lostcoords/ui/choice_helper.rb +87 -0
- data/app/helpers/lostcoords/ui/code_block_helper.rb +22 -0
- data/app/helpers/lostcoords/ui/collapsible_helper.rb +9 -0
- data/app/helpers/lostcoords/ui/copy_button_helper.rb +7 -7
- data/app/helpers/lostcoords/ui/description_list_helper.rb +12 -0
- data/app/helpers/lostcoords/ui/dialog_helper.rb +15 -17
- data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +6 -0
- data/app/helpers/lostcoords/ui/feedback_helper.rb +25 -6
- data/app/helpers/lostcoords/ui/form_helper.rb +36 -59
- data/app/helpers/lostcoords/ui/glass_helper.rb +7 -7
- data/app/helpers/lostcoords/ui/layout_helper.rb +13 -11
- data/app/helpers/lostcoords/ui/menu_helper.rb +45 -10
- data/app/helpers/lostcoords/ui/navigation_helper.rb +144 -0
- data/app/helpers/lostcoords/ui/primitives_helper.rb +39 -61
- data/app/helpers/lostcoords/ui/reading_helper.rb +2 -2
- data/app/helpers/lostcoords/ui/render_attributes_helper.rb +17 -1
- data/app/helpers/lostcoords/ui/sheet_helper.rb +7 -45
- data/app/helpers/lostcoords/ui/structures_helper.rb +26 -125
- data/app/helpers/lostcoords/ui/table_helper.rb +11 -0
- data/app/helpers/lostcoords/ui/tailwind_helper.rb +13 -4
- data/app/helpers/lostcoords/ui/toast_helper.rb +22 -0
- data/app/helpers/lostcoords/ui/tooltip_helper.rb +17 -0
- data/app/helpers/lostcoords/ui/typography_helper.rb +41 -11
- data/app/javascript/lostcoords/ui/controllers/lc_code_block_controller.js +23 -0
- data/app/javascript/lostcoords/ui/controllers/{disclosure_controller.js → lc_collapsible_controller.js} +4 -4
- data/app/javascript/lostcoords/ui/controllers/{confirm_dialog_controller.js → lc_confirm_dialog_controller.js} +4 -4
- data/app/javascript/lostcoords/ui/controllers/{modal_controller.js → lc_dialog_controller.js} +1 -1
- data/app/javascript/lostcoords/ui/controllers/{emoji_picker_controller.js → lc_emoji_picker_controller.js} +38 -10
- data/app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js +12 -0
- data/app/javascript/lostcoords/ui/controllers/{row_menu_controller.js → lc_menu_controller.js} +25 -3
- data/app/javascript/lostcoords/ui/controllers/{ui_popover_controller.js → lc_popover_controller.js} +1 -1
- data/app/javascript/lostcoords/ui/controllers/{ui_selection_controller.js → lc_selection_controller.js} +7 -15
- data/app/javascript/lostcoords/ui/controllers/{sheet_controller.js → lc_sheet_controller.js} +17 -17
- data/app/javascript/lostcoords/ui/controllers/lc_shortcut_controller.js +45 -0
- data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +42 -0
- data/app/javascript/lostcoords/ui/controllers/{toast_controller.js → lc_toast_controller.js} +2 -2
- data/app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js +62 -0
- data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +54 -0
- data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +77 -25
- data/app/javascript/lostcoords/ui/helpers/context_dialog.js +6 -6
- data/app/javascript/lostcoords/ui/helpers/dialog.js +2 -2
- data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +30 -7
- data/app/javascript/lostcoords/ui/helpers/floating_panel.js +7 -8
- 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/sheet_motion.js +4 -4
- 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 +57 -0
- data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +46 -46
- data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +3 -3
- data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +18 -0
- data/app/views/lostcoords/ui/components/_lc_collapsible.html.erb +5 -0
- data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +11 -8
- data/app/views/lostcoords/ui/components/_lc_floating_panel.html.erb +8 -0
- data/config/importmap.rb +10 -2
- data/config/locales/en.yml +23 -0
- data/config/locales/es.yml +23 -0
- data/config/locales/ja.yml +23 -0
- data/lib/lostcoords/ui/action_view.rb +6 -3
- data/lib/lostcoords/ui/builders.rb +2 -2
- data/lib/lostcoords/ui/form_builder.rb +126 -0
- data/lib/lostcoords/ui/parts.rb +154 -0
- data/lib/lostcoords/ui/stylesheets.rb +25 -0
- data/lib/lostcoords/ui/tokens.rb +24 -1
- data/lib/lostcoords/ui/version.rb +1 -1
- data/lib/tasks/lostcoords_ui.rake +11 -0
- metadata +54 -21
- data/app/assets/tailwind/lostcoords/ui/reading.css +0 -60
- data/app/views/lostcoords/ui/components/_form_alert.html.erb +0 -4
- data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +0 -5
- data/app/views/lostcoords/ui/components/_skeleton.html.erb +0 -4
- data/app/views/lostcoords/ui/components/_spinner.html.erb +0 -10
- data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +0 -8
- data/config/helper_renames.json +0 -113
- data/script/migrate_helpers.rb +0 -136
- /data/app/javascript/lostcoords/ui/controllers/{ui_copy_controller.js → lc_copy_controller.js} +0 -0
- /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 {
|
|
4
|
-
import {
|
|
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 (
|
|
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) {
|
|
@@ -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
|
|
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: "
|
|
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 === "
|
|
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 === "
|
|
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 === "
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
}
|