Focus what x-show reveals on the frame it shows

A sheet-at-compact menu focused its first item, or its filter field, in
$nextTick, and the icon entry point of <x-search> handed focus back to
its trigger the same way. Both targets are x-show'n, and Alpine shows an
element on the animation frame after its condition turns true, not in the
tick: the focus reached an element still display: none, which Firefox and
WebKit refuse. The sheet's focus trap then put the focus on the drag
handle, and a closed search left it on the page body; in Chrome the
frame or the trap's own return happened to cover for it. The comment in
openSheet() assumed Alpine holds $nextTick until the transition begins,
which it only does with x-transition, and the sheet uses CSS transitions.

Both now wait for that frame after the tick, running after Alpine's own
show in it, as search.js's expand() already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-16 12:23:15 +02:00
co-authored by Claude Opus 5
parent d2245ad122
commit b4a82a4dbe
2 changed files with 19 additions and 12 deletions
+9 -4
View File
@@ -317,8 +317,11 @@ const menu = () => ({
},
/**
* The sheet shows a frame or two after `open` turns true, once its transition has begun, and
* Alpine holds `$nextTick` until then. Its focus trap starts on a timer of its own and keeps a
* The sheet's panel is `x-show`n, and Alpine shows an element on the animation frame after
* `open` turns true, not in the tick: an item focused in `$nextTick` is still `display: none`,
* which Firefox and WebKit refuse to focus (Chrome only got there because its frame came
* first). So the focus waits for that frame too, and runs after Alpine's own show in it, as
* search.js's `expand()` does. The sheet's focus trap starts on a timer of its own and keeps a
* focus already inside it, so the item (or the field) M3 asks to be focused first wins over
* the drag handle the trap would otherwise pick.
*/
@@ -329,7 +332,8 @@ const menu = () => ({
this.control()?.setAttribute('aria-expanded', 'true')
this.label()
this.$nextTick(() => {
this.$nextTick(() =>
requestAnimationFrame(() => {
if (this.field()) {
this.lookUp()
@@ -337,7 +341,8 @@ const menu = () => ({
}
this.focusItem(focus)
})
}),
)
},
/** Focus in the field, its text selected, and the first row it leaves highlighted. */
+5 -3
View File
@@ -123,11 +123,13 @@ document.addEventListener('alpine:init', () => {
this.closedAt = performance.now()
if (refocus) {
// Back to whatever opened the view: the icon button, or the field itself. A tick
// later, because the icon button is only on screen again once the view has closed.
// Back to whatever opened the view: the icon button, or the field itself. A frame
// after the tick, as `expand()` waits: the icon button is `x-show`n, and Alpine only
// shows it on that frame, so a focus in the tick reaches a hidden button, which
// Firefox and WebKit refuse (in Chrome the trap's own return had covered for it).
const back = this.$refs.trigger ?? this.$refs.input
this.$nextTick(() => back.focus())
this.$nextTick(() => requestAnimationFrame(() => back.focus()))
}
},