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( ) { 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(); const returnFocusRef = useRef(null); const addButtonRef = useRef(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( ): VNode { const { hidden, choices, label, tooltip, help, required, converter } = props; const { value, onChange } = resolveFieldHandler(props.handler, props.name); + const fieldset = useRef(null); + useAutoFocus( + props.focus, + () => + fieldset.current?.querySelector( + 'button[aria-pressed="true"]:not([disabled])', + ) ?? + fieldset.current?.querySelector("button:not([disabled])"), + ); if (hidden) { return ; } @@ -28,15 +39,17 @@ export function InputChoiceHorizontal( tooltip={tooltip} name={props.name as string} /> -
+
{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(