From fb29d16757d5822ea36f0867a6797a538721e6b4 Mon Sep 17 00:00:00 2001 From: "truph01 (via MelvinBot)" Date: Fri, 5 Jun 2026 03:47:49 +0000 Subject: [PATCH 1/6] Prevent panel auto-close on selection for Pronouns and Cash Expense Default pages Selecting a row in these single-select lists applied the change and immediately navigated back, an automatic change of context that violates WCAG 3.2.2 (On Input). Keep the optimistic update but let the user close the page via the header back button. Also remove the now-harmful isOptionSelected guard on the Pronouns page, which would otherwise block re-selecting after the first pick once auto-navigation is gone. Co-authored-by: truph01 --- src/pages/settings/Profile/PronounsPage.tsx | 8 +------- .../workspace/rules/RulesReimbursableDefaultPage.tsx | 1 - 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/pages/settings/Profile/PronounsPage.tsx b/src/pages/settings/Profile/PronounsPage.tsx index 705a83e3ac46..b810a2540f84 100644 --- a/src/pages/settings/Profile/PronounsPage.tsx +++ b/src/pages/settings/Profile/PronounsPage.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -31,7 +31,6 @@ function PronounsPage({currentUserPersonalDetails}: PronounsPageProps) { const currentPronouns = currentUserPersonalDetails?.pronouns ?? ''; const currentPronounsKey = currentPronouns.substring(CONST.PRONOUNS.PREFIX.length); const [searchValue, setSearchValue] = useState(''); - const isOptionSelected = useRef(false); const currentUserAccountID = currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID; useEffect(() => { @@ -68,12 +67,7 @@ function PronounsPage({currentUserPersonalDetails}: PronounsPageProps) { }, [searchValue, currentPronouns, translate, localeCompare]); const updatePronouns = (selectedPronouns: PronounEntry) => { - if (isOptionSelected.current) { - return; - } - isOptionSelected.current = true; updatePronounsPersonalDetails(selectedPronouns.keyForList === currentPronounsKey ? '' : (selectedPronouns?.value ?? ''), currentUserAccountID); - Navigation.goBack(); }; const textInputOptions = useMemo( diff --git a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx index 065a4dcc0a56..75fc498c6c66 100644 --- a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx +++ b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx @@ -59,7 +59,6 @@ function RulesReimbursableDefaultPage({ ListItem={SingleSelectListItem} onSelectRow={(item) => { setPolicyReimbursableMode(policyID, item.value, policy?.defaultReimbursable, policy?.disabledFields?.reimbursable); - Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); }} shouldSingleExecuteRowSelect style={{containerStyle: styles.pt3}} From 2886269e2e75a233f257e22a10168ed6beefc510 Mon Sep 17 00:00:00 2001 From: "truph01 (via MelvinBot)" Date: Fri, 5 Jun 2026 04:22:09 +0000 Subject: [PATCH 2/6] Add Save button instead of persisting selection on tap Per design feedback, selecting a pronoun or cash expense default mode now only updates local draft state. The change is persisted (and the page closed) when the user taps the Save button, matching the Status RHP flow. Co-authored-by: truph01 --- src/pages/settings/Profile/PronounsPage.tsx | 30 ++++++++++++++----- .../rules/RulesReimbursableDefaultPage.tsx | 25 ++++++++++++++-- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/pages/settings/Profile/PronounsPage.tsx b/src/pages/settings/Profile/PronounsPage.tsx index b810a2540f84..d56291dbc139 100644 --- a/src/pages/settings/Profile/PronounsPage.tsx +++ b/src/pages/settings/Profile/PronounsPage.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -31,6 +31,7 @@ function PronounsPage({currentUserPersonalDetails}: PronounsPageProps) { const currentPronouns = currentUserPersonalDetails?.pronouns ?? ''; const currentPronounsKey = currentPronouns.substring(CONST.PRONOUNS.PREFIX.length); const [searchValue, setSearchValue] = useState(''); + const [selectedPronouns, setSelectedPronouns] = useState(currentPronouns); const currentUserAccountID = currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID; useEffect(() => { @@ -40,6 +41,7 @@ function PronounsPage({currentUserPersonalDetails}: PronounsPageProps) { const currentPronounsText = CONST.PRONOUNS_LIST.find((value) => value === currentPronounsKey); setSearchValue(currentPronounsText ? translate(`pronouns.${currentPronounsText}`) : ''); + setSelectedPronouns(currentPronouns); // Only need to update search value when the first time the data is loaded // eslint-disable-next-line react-hooks/exhaustive-deps @@ -48,13 +50,12 @@ function PronounsPage({currentUserPersonalDetails}: PronounsPageProps) { const filteredPronounsList = useMemo((): PronounEntry[] => { const pronouns = CONST.PRONOUNS_LIST.map((value) => { const fullPronounKey = `${CONST.PRONOUNS.PREFIX}${value}`; - const isCurrentPronouns = fullPronounKey === currentPronouns; return { text: translate(`pronouns.${value}`), value: fullPronounKey, keyForList: value, - isSelected: isCurrentPronouns, + isSelected: fullPronounKey === selectedPronouns, }; }).sort((a, b) => localeCompare(a.text.toLowerCase(), b.text.toLowerCase())); @@ -64,12 +65,26 @@ function PronounsPage({currentUserPersonalDetails}: PronounsPageProps) { return []; } return pronouns.filter((pronoun) => pronoun.text.toLowerCase().indexOf(trimmedSearch.toLowerCase()) >= 0); - }, [searchValue, currentPronouns, translate, localeCompare]); + }, [searchValue, selectedPronouns, translate, localeCompare]); - const updatePronouns = (selectedPronouns: PronounEntry) => { - updatePronounsPersonalDetails(selectedPronouns.keyForList === currentPronounsKey ? '' : (selectedPronouns?.value ?? ''), currentUserAccountID); + const selectPronoun = (selectedPronoun: PronounEntry) => { + setSelectedPronouns(selectedPronoun.value === selectedPronouns ? '' : (selectedPronoun?.value ?? '')); }; + const savePronouns = useCallback(() => { + updatePronounsPersonalDetails(selectedPronouns, currentUserAccountID); + Navigation.goBack(); + }, [selectedPronouns, currentUserAccountID]); + + const confirmButtonOptions = useMemo( + () => ({ + showButton: true, + text: translate('common.save'), + onConfirm: savePronouns, + }), + [savePronouns, translate], + ); + const textInputOptions = useMemo( () => ({ label: translate('pronounsPage.pronouns'), @@ -101,9 +116,10 @@ function PronounsPage({currentUserPersonalDetails}: PronounsPageProps) { diff --git a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx index 75fc498c6c66..9fff33a4c767 100644 --- a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx +++ b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; @@ -27,15 +27,33 @@ function RulesReimbursableDefaultPage({ const policy = usePolicy(policyID); const reimbursableMode = getCashExpenseReimbursableMode(policy); + const [selectedMode, setSelectedMode] = useState(reimbursableMode); const reimbursableModes = Object.values(CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES).map((mode) => ({ text: translate(`workspace.rules.individualExpenseRules.${mode}`), alternateText: translate(`workspace.rules.individualExpenseRules.${mode}Description`), value: mode, - isSelected: reimbursableMode === mode, + isSelected: selectedMode === mode, keyForList: mode, })); + const saveAndGoBack = useCallback(() => { + if (!selectedMode) { + return; + } + setPolicyReimbursableMode(policyID, selectedMode, policy?.defaultReimbursable, policy?.disabledFields?.reimbursable); + Navigation.goBack(); + }, [policyID, selectedMode, policy?.defaultReimbursable, policy?.disabledFields?.reimbursable]); + + const confirmButtonOptions = useMemo( + () => ({ + showButton: true, + text: translate('common.save'), + onConfirm: saveAndGoBack, + }), + [saveAndGoBack, translate], + ); + return ( { - setPolicyReimbursableMode(policyID, item.value, policy?.defaultReimbursable, policy?.disabledFields?.reimbursable); + setSelectedMode(item.value); }} + confirmButtonOptions={confirmButtonOptions} shouldSingleExecuteRowSelect style={{containerStyle: styles.pt3}} initiallyFocusedItemKey={reimbursableMode} From dcf9380dddac066cc7a05cb1d0795d42d45cb033 Mon Sep 17 00:00:00 2001 From: "truph01 (via MelvinBot)" Date: Wed, 10 Jun 2026 02:51:30 +0000 Subject: [PATCH 3/6] Sync reimbursable draft mode once policy data loads Co-authored-by: truph01 --- .../workspace/rules/RulesReimbursableDefaultPage.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx index 9fff33a4c767..c65eb9125b17 100644 --- a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx +++ b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; @@ -29,6 +29,15 @@ function RulesReimbursableDefaultPage({ const reimbursableMode = getCashExpenseReimbursableMode(policy); const [selectedMode, setSelectedMode] = useState(reimbursableMode); + // When the page renders before the policy is in Onyx, reimbursableMode is undefined. Sync the draft once it becomes + // available, without overwriting a selection the user has already made. + useEffect(() => { + if (!reimbursableMode) { + return; + } + setSelectedMode((prevMode) => prevMode ?? reimbursableMode); + }, [reimbursableMode]); + const reimbursableModes = Object.values(CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES).map((mode) => ({ text: translate(`workspace.rules.individualExpenseRules.${mode}`), alternateText: translate(`workspace.rules.individualExpenseRules.${mode}Description`), From 900c3a3d559a6462bb7fcf71e3777756cf4f4539 Mon Sep 17 00:00:00 2001 From: "truph01 (via MelvinBot)" Date: Wed, 10 Jun 2026 03:04:23 +0000 Subject: [PATCH 4/6] Early return when reimbursable draft mode is already set Co-authored-by: truph01 --- src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx index c65eb9125b17..c03606a0e133 100644 --- a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx +++ b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx @@ -32,11 +32,11 @@ function RulesReimbursableDefaultPage({ // When the page renders before the policy is in Onyx, reimbursableMode is undefined. Sync the draft once it becomes // available, without overwriting a selection the user has already made. useEffect(() => { - if (!reimbursableMode) { + if (!reimbursableMode || selectedMode) { return; } - setSelectedMode((prevMode) => prevMode ?? reimbursableMode); - }, [reimbursableMode]); + setSelectedMode(reimbursableMode); + }, [reimbursableMode, selectedMode]); const reimbursableModes = Object.values(CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES).map((mode) => ({ text: translate(`workspace.rules.individualExpenseRules.${mode}`), From 2b5ad4c7e6d63de5af0b0d889adcece214c124e4 Mon Sep 17 00:00:00 2001 From: "truph01 (via MelvinBot)" Date: Wed, 10 Jun 2026 03:34:57 +0000 Subject: [PATCH 5/6] Fix: derive selected reimbursable mode instead of syncing via effect to satisfy ESLint Co-authored-by: truph01 --- .../rules/RulesReimbursableDefaultPage.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx index c03606a0e133..33a3c3741760 100644 --- a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx +++ b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; @@ -27,16 +27,10 @@ function RulesReimbursableDefaultPage({ const policy = usePolicy(policyID); const reimbursableMode = getCashExpenseReimbursableMode(policy); - const [selectedMode, setSelectedMode] = useState(reimbursableMode); - - // When the page renders before the policy is in Onyx, reimbursableMode is undefined. Sync the draft once it becomes - // available, without overwriting a selection the user has already made. - useEffect(() => { - if (!reimbursableMode || selectedMode) { - return; - } - setSelectedMode(reimbursableMode); - }, [reimbursableMode, selectedMode]); + // The draft holds the user's in-page selection. Until they pick a row it stays undefined and we fall back to the + // persisted reimbursableMode, which also covers the page rendering before the policy is in Onyx. + const [draftMode, setDraftMode] = useState(); + const selectedMode = draftMode ?? reimbursableMode; const reimbursableModes = Object.values(CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES).map((mode) => ({ text: translate(`workspace.rules.individualExpenseRules.${mode}`), @@ -85,7 +79,7 @@ function RulesReimbursableDefaultPage({ data={reimbursableModes} ListItem={SingleSelectListItem} onSelectRow={(item) => { - setSelectedMode(item.value); + setDraftMode(item.value); }} confirmButtonOptions={confirmButtonOptions} shouldSingleExecuteRowSelect From fe15e4f7cb738fc3a07272fd272afec797b54f9a Mon Sep 17 00:00:00 2001 From: "truph01 (via MelvinBot)" Date: Thu, 11 Jun 2026 08:37:54 +0000 Subject: [PATCH 6/6] Add empty line before draft comment per review suggestion Co-authored-by: truph01 --- src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx index 33a3c3741760..0cd55451553a 100644 --- a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx +++ b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx @@ -27,6 +27,7 @@ function RulesReimbursableDefaultPage({ const policy = usePolicy(policyID); const reimbursableMode = getCashExpenseReimbursableMode(policy); + // The draft holds the user's in-page selection. Until they pick a row it stays undefined and we fall back to the // persisted reimbursableMode, which also covers the page rendering before the policy is in Onyx. const [draftMode, setDraftMode] = useState();