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
+14 -9
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 * The sheet's panel is `x-show`n, and Alpine shows an element on the animation frame after
* Alpine holds `$nextTick` until then. Its focus trap starts on a timer of its own and keeps a * `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 * 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. * the drag handle the trap would otherwise pick.
*/ */
@@ -329,15 +332,17 @@ const menu = () => ({
this.control()?.setAttribute('aria-expanded', 'true') this.control()?.setAttribute('aria-expanded', 'true')
this.label() this.label()
this.$nextTick(() => { this.$nextTick(() =>
if (this.field()) { requestAnimationFrame(() => {
this.lookUp() if (this.field()) {
this.lookUp()
return return
} }
this.focusItem(focus) this.focusItem(focus)
}) }),
)
}, },
/** Focus in the field, its text selected, and the first row it leaves highlighted. */ /** 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() this.closedAt = performance.now()
if (refocus) { if (refocus) {
// Back to whatever opened the view: the icon button, or the field itself. A tick // Back to whatever opened the view: the icon button, or the field itself. A frame
// later, because the icon button is only on screen again once the view has closed. // 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 const back = this.$refs.trigger ?? this.$refs.input
this.$nextTick(() => back.focus()) this.$nextTick(() => requestAnimationFrame(() => back.focus()))
} }
}, },