From 905fd8d06315444f76baf24825b7ceed04e72c7f Mon Sep 17 00:00:00 2001 From: Rute Santos Date: Fri, 25 Sep 2026 13:18:58 -0400 Subject: [PATCH 1/4] Fixed tabbing between fields not working with mixed_text field. Add style for disabled 'Next' button. Co-Authored-By: Claude Opus 5.5 --- .../shared/wizard/RenderMultiField.tsx | 63 +++++++++++++++++-- src/hooks/wizardHooks.ts | 3 + src/styles/components/modals/_footer.scss | 8 +++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/components/shared/wizard/RenderMultiField.tsx b/src/components/shared/wizard/RenderMultiField.tsx index 213b23a46a..037a61cbe6 100644 --- a/src/components/shared/wizard/RenderMultiField.tsx +++ b/src/components/shared/wizard/RenderMultiField.tsx @@ -7,8 +7,6 @@ import { MetadataField } from "../../../slices/eventSlice"; import ButtonLikeAnchor from "../ButtonLikeAnchor"; import { LuCheck, LuSquarePen, LuX } from "react-icons/lu"; -const childRef = React.createRef(); - /** * This component renders an editable field for multiple values depending on the type of the corresponding metadata */ @@ -25,6 +23,8 @@ const RenderMultiField = ({ form: FieldProps["form"] showCheck?: boolean, }) => { + // One ref per rendered field + const childRef = useRef(null); // Indicator if currently edit mode is activated const { editMode, setEditMode } = useClickOutsideField(childRef); // Temporary storage for value user currently types in @@ -45,6 +45,13 @@ const RenderMultiField = ({ submitValue(); } + + // Backspace on an empty input drops the last value. + if (event.key === "Backspace" && inputValue === "" && fieldValue.length > 0) { + event.preventDefault(); + + removeItem(fieldValue.length - 1); + } }; const submitValue = (alternativeInput?: string) => { @@ -93,6 +100,17 @@ const RenderMultiField = ({ form.setFieldValue(field.name, fieldValue); }; + // Always points at the latest submitValue, so late callers (e.g. unmount + // cleanup) don't commit against a stale field value. + const submitValueRef = useRef(submitValue); + submitValueRef.current = submitValue; + + // Commit the typed value and leave edit mode (keyboard exit). + const leaveEditMode = (typedValue: string) => { + submitValueRef.current(typedValue); + setEditMode(false); + }; + return ( // Render editable field for multiple values depending on type of metadata field // (types: see metadata.json retrieved from backend) @@ -100,6 +118,7 @@ const RenderMultiField = ({ <> {fieldInfo.type === "mixed_text" && ( submitValueRef.current(input)} + handleLeave={leaveEditMode} /> )} @@ -128,19 +149,23 @@ const RenderMultiField = ({ // Renders multi select const EditMultiSelect = ({ + containerRef, collection, handleKeyDown, handleChange, handleBlur, + handleLeave, inputValue, removeItem, field, fieldValue, }: { + containerRef: React.RefObject collection: { [key: string]: unknown }[] handleKeyDown: (event: React.KeyboardEvent) => void handleChange: (event: React.ChangeEvent) => void handleBlur: (refCurrent: string) => void + handleLeave: (typedValue: string) => void inputValue: HTMLInputElement["value"] removeItem: (key: number) => void field: FieldProps["field"] @@ -154,14 +179,39 @@ const EditMultiSelect = ({ React.useEffect(() => { textRef.current = inputValue; }, [inputValue]); + // Pending deferred blur handling, see onBlur below. + const blurTimeout = useRef>(undefined); React.useEffect(() => { - return () => handleBlur(textRef.current); + return () => { + clearTimeout(blurTimeout.current); + handleBlur(textRef.current); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <> -
+
{ + // Tabbing out: commit the typed value and leave edit mode. Clicks + // are handled by useClickOutsideField instead. + const leavingField = !!e.relatedTarget && !e.currentTarget.contains(e.relatedTarget); + const typedValue = inputValue; + + // Defer until the browser has finished moving focus; re-rendering + // mid-transfer would otherwise lose focus entirely. + clearTimeout(blurTimeout.current); + blurTimeout.current = setTimeout(() => { + if (leavingField) { + handleLeave(typedValue); + } else { + handleBlur(typedValue); + } + }); + }} + >
{item} removeItem(key)} > diff --git a/src/hooks/wizardHooks.ts b/src/hooks/wizardHooks.ts index 43555a302b..2a007f5cef 100644 --- a/src/hooks/wizardHooks.ts +++ b/src/hooks/wizardHooks.ts @@ -132,6 +132,9 @@ export const useClickOutsideField = ( // childRef.current.addEventListener("blur", handleBlur, true); // capture phase // } + // Keyboard exit is handled by the component's own onBlur (see + // RenderMultiField); a listener here can't tell Tab from a click. + // Adding event listener for detecting click outside window.addEventListener("mousedown", handleClickOutside); diff --git a/src/styles/components/modals/_footer.scss b/src/styles/components/modals/_footer.scss index b06180a25a..d8efc6b438 100644 --- a/src/styles/components/modals/_footer.scss +++ b/src/styles/components/modals/_footer.scss @@ -49,6 +49,14 @@ @include button.btn(red); } + // Grey out disabled wizard buttons. Must stay after the btn() includes + // to override their :hover/:focus rules. + &.inactive, + &:disabled { + opacity: 0.5; + cursor: default; + } + min-width: 100px; margin: 10px 3% 0 15px; display: inline-block; From 63d4b1a07ab42e7cab10cad8a2eb3d0e3fc8af79 Mon Sep 17 00:00:00 2001 From: Rute Santos Date: Mon, 5 Oct 2026 10:53:07 -0400 Subject: [PATCH 2/4] Address PR review comments Make multi-value remove buttons keyboard accessible again Change handler name Remove unrelated css change Simplified focus handling (Claude says still needed for focus-trap 8.2.2) Co-Authored-By: Claude Opus 5.5 --- .../shared/wizard/RenderMultiField.tsx | 54 ++++++++----------- src/hooks/wizardHooks.ts | 3 -- src/styles/components/modals/_footer.scss | 8 --- 3 files changed, 21 insertions(+), 44 deletions(-) diff --git a/src/components/shared/wizard/RenderMultiField.tsx b/src/components/shared/wizard/RenderMultiField.tsx index 037a61cbe6..55f1462f64 100644 --- a/src/components/shared/wizard/RenderMultiField.tsx +++ b/src/components/shared/wizard/RenderMultiField.tsx @@ -45,13 +45,6 @@ const RenderMultiField = ({ submitValue(); } - - // Backspace on an empty input drops the last value. - if (event.key === "Backspace" && inputValue === "" && fieldValue.length > 0) { - event.preventDefault(); - - removeItem(fieldValue.length - 1); - } }; const submitValue = (alternativeInput?: string) => { @@ -128,7 +121,7 @@ const RenderMultiField = ({ handleKeyDown={handleKeyDown} // Route through the ref, not submitValue directly handleBlur={input => submitValueRef.current(input)} - handleLeave={leaveEditMode} + handleFocusLeaveField={leaveEditMode} /> )} @@ -154,7 +147,7 @@ const EditMultiSelect = ({ handleKeyDown, handleChange, handleBlur, - handleLeave, + handleFocusLeaveField, inputValue, removeItem, field, @@ -165,7 +158,7 @@ const EditMultiSelect = ({ handleKeyDown: (event: React.KeyboardEvent) => void handleChange: (event: React.ChangeEvent) => void handleBlur: (refCurrent: string) => void - handleLeave: (typedValue: string) => void + handleFocusLeaveField: (typedValue: string) => void inputValue: HTMLInputElement["value"] removeItem: (key: number) => void field: FieldProps["field"] @@ -179,11 +172,11 @@ const EditMultiSelect = ({ React.useEffect(() => { textRef.current = inputValue; }, [inputValue]); - // Pending deferred blur handling, see onBlur below. - const blurTimeout = useRef>(undefined); + const inputRef = useRef(null); + const leaveTimeout = useRef>(undefined); React.useEffect(() => { return () => { - clearTimeout(blurTimeout.current); + clearTimeout(leaveTimeout.current); handleBlur(textRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -193,27 +186,21 @@ const EditMultiSelect = ({ <>
{ - // Tabbing out: commit the typed value and leave edit mode. Clicks - // are handled by useClickOutsideField instead. - const leavingField = !!e.relatedTarget && !e.currentTarget.contains(e.relatedTarget); - const typedValue = inputValue; - - // Defer until the browser has finished moving focus; re-rendering - // mid-transfer would otherwise lose focus entirely. - clearTimeout(blurTimeout.current); - blurTimeout.current = setTimeout(() => { - if (leavingField) { - handleLeave(typedValue); - } else { - handleBlur(typedValue); - } - }); + if (e.relatedTarget && !e.currentTarget.contains(e.relatedTarget)) { + // Wait until focus has landed on the next field. Unmounting this + // editor mid-transfer makes the modal's focus trap (focus-trap >= 8.2) + // pull focus back to the start of the modal. + const typedValue = inputValue; + leaveTimeout.current = setTimeout(() => handleFocusLeaveField(typedValue)); + } }} >
{item} removeItem(key)} + onClick={() => { + removeItem(key); + // The pressed button is about to unmount; keep focus in the field. + inputRef.current?.focus(); + }} > diff --git a/src/hooks/wizardHooks.ts b/src/hooks/wizardHooks.ts index 2a007f5cef..43555a302b 100644 --- a/src/hooks/wizardHooks.ts +++ b/src/hooks/wizardHooks.ts @@ -132,9 +132,6 @@ export const useClickOutsideField = ( // childRef.current.addEventListener("blur", handleBlur, true); // capture phase // } - // Keyboard exit is handled by the component's own onBlur (see - // RenderMultiField); a listener here can't tell Tab from a click. - // Adding event listener for detecting click outside window.addEventListener("mousedown", handleClickOutside); diff --git a/src/styles/components/modals/_footer.scss b/src/styles/components/modals/_footer.scss index d8efc6b438..b06180a25a 100644 --- a/src/styles/components/modals/_footer.scss +++ b/src/styles/components/modals/_footer.scss @@ -49,14 +49,6 @@ @include button.btn(red); } - // Grey out disabled wizard buttons. Must stay after the btn() includes - // to override their :hover/:focus rules. - &.inactive, - &:disabled { - opacity: 0.5; - cursor: default; - } - min-width: 100px; margin: 10px 3% 0 15px; display: inline-block; From cd0131ded66e8de483be7abc658a1355d52b7884 Mon Sep 17 00:00:00 2001 From: Rute Santos Date: Thu, 8 Oct 2026 17:11:48 -0400 Subject: [PATCH 3/4] Commit mixed_text value only on unmount; tab-out just exits edit mode. --- .../shared/wizard/RenderMultiField.tsx | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/src/components/shared/wizard/RenderMultiField.tsx b/src/components/shared/wizard/RenderMultiField.tsx index 55f1462f64..02cf64832d 100644 --- a/src/components/shared/wizard/RenderMultiField.tsx +++ b/src/components/shared/wizard/RenderMultiField.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useRef, useState } from "react"; +import React, { ReactNode, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import cn from "classnames"; import { useClickOutsideField } from "../../../hooks/wizardHooks"; @@ -93,16 +93,13 @@ const RenderMultiField = ({ form.setFieldValue(field.name, fieldValue); }; - // Always points at the latest submitValue, so late callers (e.g. unmount - // cleanup) don't commit against a stale field value. + // Always points at the latest submitValue, so the unmount cleanup doesn't + // commit against a stale field value. Must be a layout effect: the child's + // unmount cleanup runs before this component's passive effects. const submitValueRef = useRef(submitValue); - submitValueRef.current = submitValue; - - // Commit the typed value and leave edit mode (keyboard exit). - const leaveEditMode = (typedValue: string) => { - submitValueRef.current(typedValue); - setEditMode(false); - }; + useLayoutEffect(() => { + submitValueRef.current = submitValue; + }); return ( // Render editable field for multiple values depending on type of metadata field @@ -120,8 +117,8 @@ const RenderMultiField = ({ handleChange={handleChange} handleKeyDown={handleKeyDown} // Route through the ref, not submitValue directly - handleBlur={input => submitValueRef.current(input)} - handleFocusLeaveField={leaveEditMode} + commitOnUnmount={input => submitValueRef.current(input)} + exitEditMode={() => setEditMode(false)} /> )} @@ -146,8 +143,8 @@ const EditMultiSelect = ({ collection, handleKeyDown, handleChange, - handleBlur, - handleFocusLeaveField, + commitOnUnmount, + exitEditMode, inputValue, removeItem, field, @@ -157,8 +154,8 @@ const EditMultiSelect = ({ collection: { [key: string]: unknown }[] handleKeyDown: (event: React.KeyboardEvent) => void handleChange: (event: React.ChangeEvent) => void - handleBlur: (refCurrent: string) => void - handleFocusLeaveField: (typedValue: string) => void + commitOnUnmount: (typedValue: string) => void + exitEditMode: () => void inputValue: HTMLInputElement["value"] removeItem: (key: number) => void field: FieldProps["field"] @@ -166,8 +163,8 @@ const EditMultiSelect = ({ }) => { const { t } = useTranslation(); - // onBlur does not get called if a component unmounts for some reason - // Instead, we achieve the same effect with useEffect + // Commit the typed value whenever the editor unmounts, which covers every way + // of leaving it (tabbing out, clicking outside, closing the modal/wizard page). const textRef = useRef(inputValue); React.useEffect(() => { textRef.current = inputValue; @@ -177,7 +174,7 @@ const EditMultiSelect = ({ React.useEffect(() => { return () => { clearTimeout(leaveTimeout.current); - handleBlur(textRef.current); + commitOnUnmount(textRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -186,15 +183,14 @@ const EditMultiSelect = ({ <>
{ if (e.relatedTarget && !e.currentTarget.contains(e.relatedTarget)) { // Wait until focus has landed on the next field. Unmounting this // editor mid-transfer makes the modal's focus trap (focus-trap >= 8.2) // pull focus back to the start of the modal. - const typedValue = inputValue; - leaveTimeout.current = setTimeout(() => handleFocusLeaveField(typedValue)); + leaveTimeout.current = setTimeout(exitEditMode); } }} > From e1faac3000d528b7d157d12ba8a640e528d259f8 Mon Sep 17 00:00:00 2001 From: Rute Santos Date: Tue, 13 Oct 2026 15:45:55 -0400 Subject: [PATCH 4/4] Add comment. --- src/components/shared/wizard/RenderMultiField.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/shared/wizard/RenderMultiField.tsx b/src/components/shared/wizard/RenderMultiField.tsx index 02cf64832d..e102868dab 100644 --- a/src/components/shared/wizard/RenderMultiField.tsx +++ b/src/components/shared/wizard/RenderMultiField.tsx @@ -116,6 +116,10 @@ const RenderMultiField = ({ removeItem={removeItem} handleChange={handleChange} handleKeyDown={handleKeyDown} + // Two separate handlers on purpose: exitEditMode is only one of the ways + // the editor unmounts (tab-out). Clicking outside (useClickOutsideField) + // and closing the modal/wizard page unmount it without calling it, so + // the commit lives in the unmount cleanup to cover all of them once. // Route through the ref, not submitValue directly commitOnUnmount={input => submitValueRef.current(input)} exitEditMode={() => setEditMode(false)}