fix(ui): open dialogs with their input focused

This commit is contained in:
Dax Raad
2026-10-18 21:16:43 -04:00
Unverified
parent f3a8f0dc40
commit d25bf1f0ec
3 changed files with 31 additions and 10 deletions
@@ -117,7 +117,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await models.getByRole("button", { name: "Connect an integration", exact: true }).click()
await expect(models).toBeHidden()
const providers = page.getByRole("dialog", { name: "Connect an integration", exact: true })
await expect(providers.getByRole("searchbox", { name: "Search providers", exact: true })).toBeVisible()
await expect(providers.getByRole("searchbox", { name: "Search providers", exact: true })).toBeFocused()
await expect(page.getByRole("dialog")).toHaveCount(1)
})
}
+1 -1
View File
@@ -55,7 +55,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
dialog.close()
}
// Focus starts on the dialog's close button, outside the list, so listen at the
// Focus starts on the dialog itself, outside the list, so listen at the
// document level while the dialog is mounted instead of on the list container.
let listEl: HTMLDivElement | undefined
onMount(() => {
+29 -8
View File
@@ -4,6 +4,12 @@ import { useDialogLayer } from "../../context/dialog"
import { useI18n } from "../../context/i18n"
import "./dialog.css"
const TEXT_FIELD = [
'input:not([type="hidden"], [type="checkbox"], [type="radio"], [type="button"], [type="submit"], [type="reset"], [type="range"], [type="file"], [type="color"], [type="image"]):not(:disabled, [readonly])',
"textarea:not(:disabled, [readonly])",
'[contenteditable="true"]',
].join(", ")
export interface DialogProps extends ParentProps {
size?: "normal" | "large" | "x-large"
variant?: "default" | "settings"
@@ -120,15 +126,30 @@ export function Dialog(props: DialogProps) {
[local.class ?? ""]: !!local.class,
}}
onOpenAutoFocus={(e) => {
const autofocusEl =
e.currentTarget instanceof HTMLElement ? e.currentTarget.querySelector<HTMLElement>("[autofocus]") : null
const content = e.currentTarget
if (autofocusEl) {
e.preventDefault()
// Kobalte attaches its focus trap after this event, so focusing now would leave the trap with no
// element to return to. Deferring, as Kobalte's own autofocus does, lets it pull stray focus back.
setTimeout(() => autofocusEl.focus({ preventScroll: true }))
}
if (!(content instanceof HTMLElement)) return
// Kobalte would focus the first tabbable element, usually the close button, which Escape already covers.
e.preventDefault()
// Kobalte attaches its focus trap after this event, so focusing now would leave the trap with no element
// to return to. Deferring, as Kobalte's own autofocus does, also lets children that focus themselves on
// mount keep that focus.
setTimeout(() => {
const autofocus = content.querySelector<HTMLElement>("[autofocus]")
const active = document.activeElement
const kept =
active instanceof HTMLElement &&
active !== content &&
content.contains(active) &&
!active.closest('[data-slot="dialog-close-button"]')
? active
: undefined
const field = Array.from(content.querySelectorAll<HTMLElement>(TEXT_FIELD)).find(
(element) => element.getClientRects().length > 0,
)
const target = autofocus ?? kept ?? field ?? content
target.focus({ preventScroll: true })
})
}}
>
{local.children}