View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0012057 | Taler | other | public | 2026-11-10 23:42 | 2026-11-10 23:43 |
| Reporter | bot-cg [agent] | Assigned To | Resolution deadline | ||
| Implementer | Reviewer | ||||
| Release signed by | Deployed by | ||||
| Verifier | Communicator | CM_7 | |||
| Data protection authority notification required | No | ||||
| CSIRT notification required | No | ||||
| Estimated effort (hours) | Actual effort (hours) | 0 | |||
| Fast-track | No | ||||
| Work package | none | ||||
| Priority | normal | Severity | minor | Reproducibility | have not tried |
| Status | new | Resolution | open | ||
| Summary | 0012057: web-util: Forms: `<FormUI focus>` and the per-field `focus` prop never focus anything | ||||
| Description | What happens: FormUI accepts `focus` and passes `focus: i === 0` to each section's first field, but no field component acted on it: InputLine's `ref={composeRef(saveRef(input), doAutoFocus)}` was commented out in 15f551608 ('auto complete off, fix form focus', Feb 2025) with 'FIXME: enable autofocus when form has focus', and InputSelectOne/Multiple, choices, toggle, file, duration and array never read `focus`. Why it was disabled: before that commit, every InputLine called doAutoFocus unconditionally, so every text field in every form grabbed focus (the last one won), and because the composeRef callback is created inline, Preact called it again on every render, pulling the cursor back on each keystroke. That commit limited the prop to the first field but switched the focus call off instead of making it conditional. 'When form has focus' means 'when the FormUI is flagged `focus`'. Two further problems remained: a double-column form passed `focus` to every section, so honouring it naively would focus the first field of the LAST section; and index 0 is often a caption or a hidden field. Measured in Chromium (form mounted after clicking a button, like a SPA step): AML CreateSession replica `<FormUI focus>` -> activeElement stays BUTTON#mount (the trigger; in the app it is BODY). Same for a direct `<InputLine focus>` (AML UnlockSession pattern), a double-column form, and every field type (text, textArea, secret, integer, phone, amount, absoluteTimeText, isoDateText, durationText, duration, selectOne, selectMultiple, choiceStacked, choiceHorizontal, toggle, file, array). Expected: A FormUI flagged `focus` (or a field given `focus` directly) puts the cursor in the first field the user can fill in, once, when it mounts; never on re-render, never in a later section, and never in a form that did not ask for it. Where: - packages/web-util/src/components/utils.ts:111 - packages/web-util/src/components/utils.ts:128 - packages/web-util/src/forms/forms-ui.tsx:224 - packages/web-util/src/forms/forms-ui.tsx:406 - packages/web-util/src/forms/forms-ui.tsx:427 - packages/web-util/src/forms/fields/InputLine.tsx:216 - packages/web-util/src/forms/fields/InputSelectOne.tsx:30 - packages/web-util/src/forms/fields/InputSelectMultiple.tsx:48 - packages/web-util/src/forms/fields/InputChoiceStacked.tsx:45 - packages/web-util/src/forms/fields/InputChoiceHorizontal.tsx:21 - packages/web-util/src/forms/fields/InputToggle.tsx:38 - packages/web-util/src/forms/fields/InputFile.tsx:30 - packages/web-util/src/forms/fields/InputDuration.tsx:39 - packages/web-util/src/forms/fields/InputArray.tsx:150 - packages/web-util/src/forms/fields/InputDrilldown.tsx:35 - packages/web-util/src/forms/forms-ui.test.tsx:233 Measured in headless Chromium (document.activeElement): before: BUTTON#mount (nothing inside the form; BODY in the real app) for create-session, double-column, inputline and all 18 field-* scenarios after: create-session: INPUT[name=password]; double-column: INPUT[name=s1a] (skips the caption, not section two/three); inputline: INPUT[type=password]; field-*: the field's control (input/textarea/combobox/radio/'Alpha' button/switch/file input/'Add item'); caption-then-text: the text input; nofocus-* (FormUI without focus): unchanged BUTTON#mount; typing in the second field after mount keeps focus there Proposed fix: New `useAutoFocus(focus, getElement)` hook in components/utils.ts runs doAutoFocus once on mount (deps []), so it works with InputLine's inline ref callback without re-focusing per render; doAutoFocus now skips elements detached during its 100 ms delay. Every field type calls it on its primary control (input, combobox, first enabled radio/button, switch, file input, years box, 'Add item'; drilldown forwards to level 1). RenderAllFieldsByUiConfig gives `focus` to the first field that is not a caption/group/link/iframe and not hidden or disabled, and a double-column FormUI passes `focus` only to the first visible section. Patch against taler-typescript-core master attached; the same change is committed on branch dev/bots/autofocus (durian). Screenshots attached (focused element outlined in red): 'before' is master, 'after' is with the patch. Verified: tsc --noEmit -p packages/web-util: no new errors (one pre-existing error in untouched context/exchange-api.ts); eslint clean on touched files; prettier clean; unit tests (bundled from src into scratch, lib/ untouched): forms-ui, AcceptTosForm, Accessibility, InputSelectOne, InputSelectMultiple, InputArray, InputAmount, stories-utils, Footer, NotificationBanner = 25/25 pass, including new test 'a form with focus starts on its first fillable field only once' (fails against an InputLine without the hook and against one that re-focuses on every render). Screenshots: create-session, double-column, inputline, field-<type> before/after under shots/web-util--*. Call sites that depend on this (being added by the AML/KYC agents in this tree, plus the existing AML CreateSession): AML CreateSession, UnlockSession (InputLine focus), Search (GenericForm and others), NewMeasure (focus={addingNew}), decision/Justification, decision/Rules; KYC FillForm. | ||||
| Tags | No tags attached. | ||||
| Attached Files | web-util--form-focus.diff (20,131 bytes)
diff --git a/packages/web-util/src/components/utils.ts b/packages/web-util/src/components/utils.ts
index ca47bb440..8a3f3ef92 100644
--- a/packages/web-util/src/components/utils.ts
+++ b/packages/web-util/src/components/utils.ts
@@ -107,7 +107,37 @@ export function doAutoFocus<T extends HTMLElement>(
) {
if (element) {
setTimeout(() => {
+ // The view may have moved on (step change, dialog closed) meanwhile.
+ if (!element.isConnected) return;
element.focus({ preventScroll: true });
}, 100);
}
}
+
+/**
+ * Focus an element once, when the component mounts with `focus` set.
+ *
+ * Use this instead of `ref={doAutoFocus}` when the element's ref callback is
+ * created inline: a new ref callback on every render would focus the element
+ * again on each render and pull the cursor back while the user is typing
+ * elsewhere.
+ *
+ * @param focus whether the component should start with focus
+ * @param element returns the element to focus, read after mounting
+ */
+export function useAutoFocus(
+ focus: boolean | undefined,
+ element: () => HTMLElement | null | undefined,
+): void {
+ useEffect(() => {
+ if (!focus) return;
+ // Look the element up when the timer fires, not now: a parent may not
+ // have attached its children yet when this effect runs.
+ const timer = setTimeout(() => {
+ const target = element();
+ if (target?.isConnected) target.focus({ preventScroll: true });
+ }, 100);
+ return () => clearTimeout(timer);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- focus is taken on mount only; re-running when `focus` or the getter changes would move the cursor while the user is typing.
+ }, []);
+}
diff --git a/packages/web-util/src/forms/fields/InputArray.tsx b/packages/web-util/src/forms/fields/InputArray.tsx
index e095b0ab2..e8e0090c3 100644
--- a/packages/web-util/src/forms/fields/InputArray.tsx
+++ b/packages/web-util/src/forms/fields/InputArray.tsx
@@ -6,6 +6,7 @@ import {
RecursivePartial,
useForm,
} from "../../hooks/useForm.js";
+import { useAutoFocus } from "../../components/utils.js";
import { useTranslationContext } from "../../context/translation.js";
import { RenderAllFieldsByUiConfig } from "../forms-ui.js";
import { resolveFieldHandler, UIFormProps } from "../FormProvider.js";
@@ -146,6 +147,7 @@ export function InputArray(
const [editor, setEditor] = useState<Editor | undefined>();
const returnFocusRef = useRef<HTMLButtonElement | null>(null);
const addButtonRef = useRef<HTMLButtonElement | null>(null);
+ useAutoFocus(props.focus, () => addButtonRef.current);
const generatedId = useId();
const fieldId = `array-${generatedId}`;
const labelId = `${fieldId}-label`;
diff --git a/packages/web-util/src/forms/fields/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/fields/InputChoiceHorizontal.tsx
index 1b473c3fb..5ea4348d2 100644
--- a/packages/web-util/src/forms/fields/InputChoiceHorizontal.tsx
+++ b/packages/web-util/src/forms/fields/InputChoiceHorizontal.tsx
@@ -1,5 +1,7 @@
import { TranslatedString } from "@gnu-taler/taler-util";
import { Fragment, VNode, h } from "preact";
+import { useRef } from "preact/hooks";
+import { useAutoFocus } from "../../components/utils.js";
import { resolveFieldHandler, UIFormProps } from "../FormProvider.js";
import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
@@ -15,6 +17,15 @@ export function InputChoiceHorizontal<ChoiceVal>(
): VNode {
const { hidden, choices, label, tooltip, help, required, converter } = props;
const { value, onChange } = resolveFieldHandler(props.handler, props.name);
+ const fieldset = useRef<HTMLFieldSetElement>(null);
+ useAutoFocus(
+ props.focus,
+ () =>
+ fieldset.current?.querySelector<HTMLElement>(
+ 'button[aria-pressed="true"]:not([disabled])',
+ ) ??
+ fieldset.current?.querySelector<HTMLElement>("button:not([disabled])"),
+ );
if (hidden) {
return <Fragment />;
}
@@ -28,15 +39,17 @@ export function InputChoiceHorizontal<ChoiceVal>(
tooltip={tooltip}
name={props.name as string}
/>
- <fieldset class="mt-2 min-w-0 max-w-full overflow-x-auto">
+ <fieldset ref={fieldset} class="mt-2 min-w-0 max-w-full overflow-x-auto">
<div class="isolate inline-flex rounded-md shadow-sm">
{choices.map((choice, idx) => {
const convertedValue = converter?.fromStringUI(choice.value as any);
+ const isSelected =
+ convertedValue !== undefined && convertedValue === value;
const isFirst = idx === 0;
const isLast = idx === choices.length - 1;
let clazz =
"relative inline-flex items-center px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 focus:z-10";
- if (convertedValue !== undefined && convertedValue === value) {
+ if (isSelected) {
clazz +=
" text-white bg-indigo-600 hover:bg-indigo-500 ring-2 ring-indigo-600 hover:ring-indigo-500";
} else {
@@ -54,6 +67,7 @@ export function InputChoiceHorizontal<ChoiceVal>(
<button
type="button"
key={idx}
+ aria-pressed={isSelected}
disabled={props.disabled}
class={clazz}
onClick={(e) => {
diff --git a/packages/web-util/src/forms/fields/InputChoiceStacked.tsx b/packages/web-util/src/forms/fields/InputChoiceStacked.tsx
index e469006d0..629da896d 100644
--- a/packages/web-util/src/forms/fields/InputChoiceStacked.tsx
+++ b/packages/web-util/src/forms/fields/InputChoiceStacked.tsx
@@ -1,6 +1,7 @@
import { TranslatedString } from "@gnu-taler/taler-util";
import { Fragment, VNode, h } from "preact";
-import { useEffect, useId } from "preact/hooks";
+import { useEffect, useId, useRef } from "preact/hooks";
+import { useAutoFocus } from "../../components/utils.js";
import { resolveFieldHandler, UIFormProps } from "../FormProvider.js";
import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
@@ -40,6 +41,16 @@ export function InputChoiceStacked<Choices>(
if (value !== undefined) onChange(undefined);
}, [choices, onChange, value]);
+ const fieldset = useRef<HTMLFieldSetElement>(null);
+ useAutoFocus(
+ props.focus,
+ () =>
+ fieldset.current?.querySelector<HTMLElement>(
+ "input:checked:not([disabled])",
+ ) ??
+ fieldset.current?.querySelector<HTMLElement>("input:not([disabled])"),
+ );
+
if (hidden) {
return <Fragment />;
}
@@ -53,7 +64,7 @@ export function InputChoiceStacked<Choices>(
tooltip={tooltip}
name={props.name as string}
/>
- <fieldset class="mt-2">
+ <fieldset ref={fieldset} class="mt-2">
<div class="space-y-4">
{choices.map((choice, idx) => {
const optionId = `${groupName}-${idx}`;
diff --git a/packages/web-util/src/forms/fields/InputDrilldown.tsx b/packages/web-util/src/forms/fields/InputDrilldown.tsx
index 163ede8c0..ff685ac6e 100644
--- a/packages/web-util/src/forms/fields/InputDrilldown.tsx
+++ b/packages/web-util/src/forms/fields/InputDrilldown.tsx
@@ -32,6 +32,7 @@ export function InputDrilldown(
<InputSelectOne
name={props.name}
label={localI18n.str`${props.label} (Classification level ${lvl + 1})`}
+ focus={props.focus && lvl === 0}
required={required}
disabled={props.disabled}
handler={{
diff --git a/packages/web-util/src/forms/fields/InputDuration.tsx b/packages/web-util/src/forms/fields/InputDuration.tsx
index bcc4a8f72..dc7a8786a 100644
--- a/packages/web-util/src/forms/fields/InputDuration.tsx
+++ b/packages/web-util/src/forms/fields/InputDuration.tsx
@@ -1,6 +1,7 @@
import { Duration } from "@gnu-taler/taler-util";
import { Fragment, VNode, h } from "preact";
import { useEffect, useRef } from "preact/hooks";
+import { useAutoFocus } from "../../components/utils.js";
import { useTranslationContext } from "../../context/translation.js";
import { resolveFieldHandler, UIFormProps } from "../FormProvider.js";
import { InputWrapper } from "./InputLine.js";
@@ -35,6 +36,7 @@ export function InputDuration(props: UIFormProps<Duration>): VNode {
const daysRef = useRef<HTMLInputElement>(null);
const monthsRef = useRef<HTMLInputElement>(null);
const yearsRef = useRef<HTMLInputElement>(null);
+ useAutoFocus(props.focus, () => yearsRef.current);
// useEffect(() => {
// onChange(
diff --git a/packages/web-util/src/forms/fields/InputFile.tsx b/packages/web-util/src/forms/fields/InputFile.tsx
index 8b934cb4c..3d1001fc2 100644
--- a/packages/web-util/src/forms/fields/InputFile.tsx
+++ b/packages/web-util/src/forms/fields/InputFile.tsx
@@ -8,7 +8,8 @@
*/
import { Fragment, VNode, h } from "preact";
-import { useState } from "preact/hooks";
+import { useRef, useState } from "preact/hooks";
+import { useAutoFocus } from "../../components/utils.js";
import { useTranslationContext } from "../../context/translation.js";
import { resolveFieldHandler, UIFormProps } from "../FormProvider.js";
import { FileFieldData } from "../forms-types.js";
@@ -25,6 +26,8 @@ export function InputFile(
props.name,
);
const [fileError, setFileError] = useState<string>();
+ const fileInput = useRef<HTMLInputElement>(null);
+ useAutoFocus(props.focus, () => fileInput.current);
if (hidden) return <Fragment />;
@@ -100,6 +103,7 @@ export function InputFile(
>
<span>{i18n.str`Upload a file`}</span>
<input
+ ref={fileInput}
id={String(props.name)}
type="file"
class="sr-only"
diff --git a/packages/web-util/src/forms/fields/InputLine.tsx b/packages/web-util/src/forms/fields/InputLine.tsx
index 17d350762..1bb726fe7 100644
--- a/packages/web-util/src/forms/fields/InputLine.tsx
+++ b/packages/web-util/src/forms/fields/InputLine.tsx
@@ -1,7 +1,7 @@
import { TranslatedString } from "@gnu-taler/taler-util";
import { ComponentChildren, Fragment, VNode, h } from "preact";
import { useEffect, useId, useRef } from "preact/hooks";
-import { composeRef, saveRef } from "../../components/utils.js";
+import { composeRef, saveRef, useAutoFocus } from "../../components/utils.js";
import { Addon, resolveFieldHandler, UIFormProps } from "../FormProvider.js";
//@ts-ignore
@@ -213,6 +213,8 @@ export function InputLine<Value = string>(
value === undefined || value === null ? "" : toString(value);
}, [toString, value]);
+ useAutoFocus(props.focus, () => input.current);
+
// useHiddenHandler(name as string, hidden ?? false, value, onChange);
if (hidden) {
return <Fragment />;
@@ -274,7 +276,6 @@ export function InputLine<Value = string>(
id={inputId}
rows={4}
ref={composeRef(saveRef(input))}
- // ref={composeRef(saveRef(input), doAutoFocus)} FIXME: enable autofocus when form has focus
name={String(name)}
onInput={(e) => {
updateFromString(e.currentTarget.value);
@@ -307,7 +308,6 @@ export function InputLine<Value = string>(
id={inputId}
name={String(name)}
ref={composeRef(saveRef(input))}
- // ref={composeRef(saveRef(input), doAutoFocus)} FIXME: enable autofocus when form has focus
type={type}
onInput={(e) => {
updateFromString(e.currentTarget.value);
diff --git a/packages/web-util/src/forms/fields/InputSelectMultiple.tsx b/packages/web-util/src/forms/fields/InputSelectMultiple.tsx
index 4f0bf0353..c92811cbf 100644
--- a/packages/web-util/src/forms/fields/InputSelectMultiple.tsx
+++ b/packages/web-util/src/forms/fields/InputSelectMultiple.tsx
@@ -10,6 +10,7 @@
import { Fragment, VNode, h } from "preact";
import { useId, useRef, useState } from "preact/hooks";
+import { useAutoFocus } from "../../components/utils.js";
import { useTranslationContext } from "../../context/translation.js";
import { resolveFieldHandler, UIFormProps } from "../FormProvider.js";
import { ChoiceS } from "./InputChoiceStacked.js";
@@ -44,6 +45,7 @@ export function InputSelectMultiple<ChoiceVal>(
const [dirty, setDirty] = useState<boolean>();
const id = `select-many-${useId()}`;
const inputRef = useRef<HTMLInputElement>(null);
+ useAutoFocus(props.focus, () => inputRef.current);
const normalizedFilter = (filter ?? "").toLocaleLowerCase();
const list = (value ?? []) as string[];
const choiceMap = new Map(
diff --git a/packages/web-util/src/forms/fields/InputSelectOne.tsx b/packages/web-util/src/forms/fields/InputSelectOne.tsx
index ff65005d8..61153cd37 100644
--- a/packages/web-util/src/forms/fields/InputSelectOne.tsx
+++ b/packages/web-util/src/forms/fields/InputSelectOne.tsx
@@ -2,6 +2,7 @@
import { i18n } from "@gnu-taler/taler-util";
import { Fragment, VNode, h } from "preact";
import { useId, useRef, useState } from "preact/hooks";
+import { useAutoFocus } from "../../components/utils.js";
import { resolveFieldHandler, UIFormProps } from "../FormProvider.js";
import { ChoiceS } from "./InputChoiceStacked.js";
import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
@@ -26,6 +27,7 @@ export function InputSelectOne<Choices>(
const [dirty, setDirty] = useState<boolean>(); // FIXME: dirty state should come from handler
const id = `select-one-${useId()}`;
const inputRef = useRef<HTMLInputElement>(null);
+ useAutoFocus(props.focus, () => inputRef.current);
const normalizedFilter = (filter ?? "").toLocaleLowerCase();
const choiceMap = choices.reduce(
(prev, curr) => {
diff --git a/packages/web-util/src/forms/fields/InputToggle.tsx b/packages/web-util/src/forms/fields/InputToggle.tsx
index 37a8666f0..02a298669 100644
--- a/packages/web-util/src/forms/fields/InputToggle.tsx
+++ b/packages/web-util/src/forms/fields/InputToggle.tsx
@@ -1,5 +1,6 @@
import { Fragment, VNode, h } from "preact";
-import { useState } from "preact/hooks";
+import { useRef, useState } from "preact/hooks";
+import { useAutoFocus } from "../../components/utils.js";
import { resolveFieldHandler, UIFormProps } from "../FormProvider.js";
import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
@@ -33,6 +34,8 @@ export function InputToggle(
props.name,
);
const [dirty, setDirty] = useState<boolean>();
+ const toggle = useRef<HTMLButtonElement>(null);
+ useAutoFocus(props.focus, () => toggle.current);
const isOn = trueValue === value;
@@ -51,6 +54,7 @@ export function InputToggle(
name={props.name as string}
/>
<button
+ ref={toggle}
type="button"
data-state={isOn ? "on" : value === undefined ? "undefined" : "off"}
class="bg-indigo-600 data-[state=off]:bg-gray-200 data-[state=undefined]:bg-gray-200 relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
diff --git a/packages/web-util/src/forms/forms-ui.test.tsx b/packages/web-util/src/forms/forms-ui.test.tsx
index e0c7aea1e..bac402402 100644
--- a/packages/web-util/src/forms/forms-ui.test.tsx
+++ b/packages/web-util/src/forms/forms-ui.test.tsx
@@ -229,3 +229,66 @@ test("form fields show untranslated technical names below their labels", async (
cleanup();
await window.happyDOM.abort();
});
+
+test("a form with focus starts on its first fillable field only once", async () => {
+ const window = installDom();
+ const { cleanup, render } = await import("@testing-library/preact");
+ const focusDesign: FormDesign = {
+ type: "double-column",
+ sections: [
+ {
+ title: "Account" as TranslatedString,
+ fields: [
+ { type: "caption", label: "Introduction" as TranslatedString },
+ {
+ type: "text",
+ id: "skipped",
+ label: "Hidden field" as TranslatedString,
+ hidden: true,
+ },
+ {
+ type: "text",
+ id: "first",
+ label: "First field" as TranslatedString,
+ },
+ {
+ type: "text",
+ id: "second",
+ label: "Second field" as TranslatedString,
+ },
+ ],
+ },
+ {
+ title: "Details" as TranslatedString,
+ fields: [
+ {
+ type: "text",
+ id: "third",
+ label: "Third field" as TranslatedString,
+ },
+ ],
+ },
+ ],
+ };
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 150));
+
+ function Harness() {
+ const form = useForm(focusDesign, {});
+ return <FormUI design={focusDesign} model={form.model} focus />;
+ }
+
+ const view = render(<Harness />);
+ await settle();
+ assert.equal(document.activeElement, view.getByLabelText("First field"));
+
+ // Typing elsewhere re-renders the form; that must not pull focus back.
+ const second = view.getByLabelText("Second field") as HTMLInputElement;
+ second.focus();
+ second.value = "typed";
+ second.dispatchEvent(new Event("input", { bubbles: true }));
+ await settle();
+ assert.equal(document.activeElement, second);
+
+ cleanup();
+ await window.happyDOM.abort();
+});
diff --git a/packages/web-util/src/forms/forms-ui.tsx b/packages/web-util/src/forms/forms-ui.tsx
index 2d2260760..f61639874 100644
--- a/packages/web-util/src/forms/forms-ui.tsx
+++ b/packages/web-util/src/forms/forms-ui.tsx
@@ -19,6 +19,7 @@ import {
UIFormElementConfig,
} from "./forms-types.js";
import { convertFormConfigToUiField } from "./forms-utils.js";
+import { UIFormProps } from "./FormProvider.js";
import { AcceptTosForm, AcceptTosFormValues } from "./AcceptTosForm.js";
import { Attention } from "../components/Attention.js";
import { FormLanguagePolicy } from "./forms-types.js";
@@ -218,6 +219,13 @@ export function FormUI<T>({
);
}
case "double-column": {
+ // Only one field can start with focus: the first one of the first
+ // section that is shown.
+ const focusSection = !focus
+ ? -1
+ : design.sections.findIndex(
+ (section, i) => !!section && !model.isSectionHidden(`${i}`),
+ );
const ui = design.sections.map((section, i) => {
if (!section) return <Fragment key={i} />;
return (
@@ -227,7 +235,7 @@ export function FormUI<T>({
name={name}
section={section}
model={model}
- focus={focus}
+ focus={i === focusSection}
disabled={disabled}
onSubmit={onSubmit}
/>
@@ -395,6 +403,14 @@ export function SingleColumnFormSectionUI<T>({
);
}
+const NOT_FILLABLE_FIELDS: UIFormField["type"][] = [
+ "caption",
+ "group",
+ "htmlIframe",
+ "external-link",
+ "download-link",
+];
+
export function RenderAllFieldsByUiConfig({
focus,
fields,
@@ -406,6 +422,16 @@ export function RenderAllFieldsByUiConfig({
disabled?: boolean;
hidden?: boolean;
}): VNode {
+ // Give focus to the first field the user can fill in, skipping captions,
+ // groups, links, embedded documents and hidden or disabled fields.
+ const focusIndex =
+ !focus || hidden
+ ? -1
+ : fields.findIndex((field) => {
+ if (NOT_FILLABLE_FIELDS.includes(field.type)) return false;
+ const props = field.properties as UIFormProps<unknown>;
+ return !props.hidden && !(disabled ?? props.disabled);
+ });
return create(
Fragment,
{},
@@ -415,7 +441,7 @@ export function RenderAllFieldsByUiConfig({
] as FieldComponentFunction<any>;
const p: Record<string, any> = {
...field.properties,
- focus: !!focus && i === 0,
+ focus: i === focusIndex,
hidden:
hidden || ("hidden" in field.properties && field.properties.hidden),
};
| ||||
| related to | 0012025 | new | AML: 'Session locked' page does not focus the password field | |
| related to | 0012026 | new | AML: 'Create session' (officer key creation) does not focus the password field | |
| related to | 0012027 | new | AML: 'Find account' does not focus the account identifier field | |
| related to | 0012028 | new | AML decision wizard: 'Case disposition' step does not focus the decision rationale | |
| related to | 0012029 | new | AML decision wizard: 'Create custom measure' does not focus the Name field | |
| related to | 0012030 | new | AML decision wizard: 'Add proposed rule' editor opens without focus | |
| related to | 0012044 | new | Challenger: address entry form (email/phone/postal, initial and change) does not focus its first field | |
| related to | 0012046 | new | KYC: opening a requirement's form does not focus its first field | |
| child of | 0012024 | new | Web UIs (bank, wallet, exchange AML/KYC, challenger, auditor, web-util forms): dialogs and single-purpose forms open without keyboard focus on the obvious field |
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-10 23:42 | bot-cg | New Issue | |
| 2026-11-10 23:42 | bot-cg | Relationship added | related to 0012029 |
| 2026-11-10 23:42 | bot-cg | Relationship added | related to 0012026 |
| 2026-11-10 23:42 | bot-cg | Relationship added | related to 0012027 |
| 2026-11-10 23:42 | bot-cg | Relationship added | related to 0012028 |
| 2026-11-10 23:42 | bot-cg | Relationship added | related to 0012025 |
| 2026-11-10 23:42 | bot-cg | Relationship added | related to 0012030 |
| 2026-11-10 23:42 | bot-cg | Relationship added | related to 0012044 |
| 2026-11-10 23:42 | bot-cg | Relationship added | related to 0012046 |
| 2026-11-10 23:42 | bot-cg | Relationship added | child of 0012024 |
| 2026-11-10 23:43 | bot-cg | File Added: web-util--form-focus.diff | |
| 2026-11-10 23:43 | bot-cg | File Added: web-util--create-session--before.png | |
| 2026-11-10 23:43 | bot-cg | File Added: web-util--create-session--after.png |