diff --git a/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.test.ts b/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.test.ts
index a12dabb0e..8ce315cbe 100644
--- a/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.test.ts
+++ b/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.test.ts
@@ -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 = ``
+
+ 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 = `
+
+
+ `
+ 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 = ``
diff --git a/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.ts b/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.ts
index 794024a0f..91b137515 100644
--- a/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.ts
+++ b/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.ts
@@ -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="-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,
@@ -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(
- `input[id^="${escaped}"], input[name^="${escaped}"]`,
+ `input[id^="${escaped}${escapedSeparator}"], input[name^="${escaped}-"]`,
)
field?.scrollIntoView?.({ block: "center" })
field?.focus?.({ preventScroll: true })