fix(dashboard): anchor the focus fallback to a segment boundary

When no element carries the generated field id, the fallback matched any
input whose id or name merely started with it, so an error on `data`
focused the sibling `database` and scrolled to a field the user was not
asked about. Match on a segment boundary instead: the separator for a
nested input, the dash for the SourceField radios named `<id>-source`,
which are the only names any widget emits.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
This commit is contained in:
Myasnikov Daniil authored and Aleksei Sviridkin committed 2026-10-07 05:04:07 +03:00
1 parent 9a59f1d0f7
commit b4259fee03
2 files changed
+30 -4

No files matched your search

@@ -60,6 +60,29 @@ describe("focusFirstError", () => {
expect(document.activeElement).toBe(first)
})
// A bare prefix match would grab any sibling whose id merely starts with the
// same string, scrolling to a field the user was not asked about.
it("does not fall back to a sibling that merely shares the prefix", () => {
document.body.innerHTML = `<input id="root_database" />`
focusFirstError({ property: ".data" })
expect(document.getElementById("root_data")).toBeNull()
expect(document.activeElement).toBe(document.body)
})
it("falls back to a nested input under the errored object", () => {
document.body.innerHTML = `
<input id="root_database" />
<input id="root_data_host" />
`
const nested = document.getElementById("root_data_host")
focusFirstError({ property: ".data" })
expect(document.activeElement).toBe(nested)
})
it("does nothing when the field is nowhere in the document", () => {
document.body.innerHTML = `<input id="root_other" />`
@@ -18,8 +18,9 @@ function propertySegments(property: string): string[] {
*
* Not every widget puts that id on an element. `SourceField` and
* `SourceWidget` render radios carrying only `name="<id>-source"`, so an exact
* lookup finds nothing and the submit scrolls nowhere. Fall back to the first
* input whose id or name starts with the generated id.
* lookup finds nothing and the submit scrolls nowhere. Fall back to a
* descendant input, matched on a segment boundary: a bare prefix would let an
* error on `data` grab the sibling `database`, scrolling to the wrong field.
*/
export function focusFirstError(
error: Pick<RJSFValidationError, "property">,
@@ -27,11 +28,13 @@ export function focusFirstError(
idSeparator = "_",
) {
const id = [idPrefix, ...propertySegments(error.property ?? "")].join(idSeparator)
const escaped = id.replace(/["\\]/g, "\\$&")
const quote = (v: string) => v.replace(/["\\]/g, "\\$&")
const escaped = quote(id)
const escapedSeparator = quote(idSeparator)
const field =
document.getElementById(id) ??
document.querySelector<HTMLElement>(
`input[id^="${escaped}"], input[name^="${escaped}"]`,
`input[id^="${escaped}${escapedSeparator}"], input[name^="${escaped}-"]`,
)
field?.scrollIntoView?.({ block: "center" })
field?.focus?.({ preventScroll: true })