From c4b937fe9a1ac094b0ae3bc3e3b3af4e984cb86f Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Wed, 2 Sep 2026 13:26:05 +0430 Subject: [PATCH 1/6] Move the Categories and Tags settings into the header menu --- .../ButtonWithDropdownMenu/types.ts | 11 ++++- .../categories/WorkspaceCategoriesPage.tsx | 40 +++++++++++++-- .../workspace/tags/WorkspaceTagsPage.tsx | 49 ++++++++++++++++++- 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 58197b6fe639..2dffb51dffcb 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -8,7 +8,7 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; -import type {RefObject} from 'react'; +import type {ReactNode, RefObject} from 'react'; import type {GestureResponderEvent, StyleProp, TextStyle, View, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -56,6 +56,15 @@ type DropdownOption = WithSentryLabel & { shouldShowLoadingSpinnerIcon?: boolean; /** Whether to render a divider before this option */ addSeparatorBefore?: boolean; + + /** A component rendered on the right side of the option, e.g. a Switch for an inline toggle row */ + rightComponent?: ReactNode; + + /** Whether to show the rightComponent */ + shouldShowRightComponent?: boolean; + + /** Whether to show the default right chevron icon (e.g. for a row that opens another page) */ + shouldShowRightIcon?: boolean; /** The type of brick road indicator to show */ brickRoadIndicator?: ValueOf; /** Whether selecting this option should bypass the delete confirmation modal */ diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 27e2964a2f71..741ab8672bfb 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -9,6 +9,7 @@ import ImportedFromAccountingSoftware from '@components/ImportedFromAccountingSo import {ModalActions} from '@components/Modal/Global/ModalContext'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; +import Switch from '@components/Switch'; import type {WorkspaceCategoryTableRowData} from '@components/Tables/WorkspaceCategoriesTable'; import WorkspaceCategoriesTable from '@components/Tables/WorkspaceCategoriesTable'; import Text from '@components/Text'; @@ -47,7 +48,14 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import {getCurrentAccountingIntegrationName} from '@pages/workspace/accounting/utils'; import {close} from '@userActions/Modal'; -import {clearCategoryErrors, deleteWorkspaceCategories, downloadCategoriesCSV, openPolicyCategoriesPage, setWorkspaceCategoryEnabled} from '@userActions/Policy/Category'; +import { + clearCategoryErrors, + deleteWorkspaceCategories, + downloadCategoriesCSV, + openPolicyCategoriesPage, + setPolicyShowCategoryGLCodes, + setWorkspaceCategoryEnabled, +} from '@userActions/Policy/Category'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -365,8 +373,29 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const secondaryActions = useMemo(() => { const menuItems = []; - // Under the revamp the other settings moved to Rules, so this is only worth showing for the GL codes toggle. - if (canWriteCategories && (!isRulesRevampEnabled || !!policy?.glCodes)) { + // Under the revamp the other settings moved to Rules, so the only remaining setting is the GL codes toggle, + // which we surface directly in this menu instead of behind a dedicated Settings page. + if (isRulesRevampEnabled) { + if (canWriteCategories && !!policy?.glCodes) { + menuItems.push({ + text: translate('workspace.categories.showCategoryGLCodes'), + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + // The row itself is inert; only the Switch handles the toggle so the menu stays open. + interactive: false, + shouldShowRightComponent: true, + shouldCloseModalOnSelect: false, + pendingAction: policy?.pendingFields?.showCategoryGLCodes, + rightComponent: ( + setPolicyShowCategoryGLCodes(policyId, value)} + disabled={!policy?.areCategoriesEnabled} + /> + ), + }); + } + } else if (canWriteCategories) { menuItems.push({ icon: icons.Gear, text: translate('common.settings'), @@ -380,6 +409,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { text: translate('spreadsheet.importSpreadsheet'), onSelected: navigateToImportSpreadsheet, value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, + // Group the GL codes toggle apart from the spreadsheet actions under the revamp. + addSeparatorBefore: isRulesRevampEnabled, }); } if (hasVisibleCategories) { @@ -416,6 +447,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { canWriteCategories, isRulesRevampEnabled, policy?.glCodes, + policy?.showCategoryGLCodes, + policy?.areCategoriesEnabled, + policy?.pendingFields?.showCategoryGLCodes, policyHasAccountingConnections, hasVisibleCategories, navigateToImportSpreadsheet, diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 53d75d1cf861..811ff56690ff 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -9,6 +9,7 @@ import ImportedFromAccountingSoftware from '@components/ImportedFromAccountingSo import {ModalActions} from '@components/Modal/Global/ModalContext'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; +import Switch from '@components/Switch'; import type {WorkspaceTagTableRowData} from '@components/Tables/WorkspaceTagsTable'; import WorkspaceTagsTable from '@components/Tables/WorkspaceTagsTable'; import Text from '@components/Text'; @@ -39,6 +40,7 @@ import { downloadMultiLevelTagsCSV, downloadTagsCSV, openPolicyTagsPage, + setPolicyShowTagGLCodes, setPolicyTagsRequired, setWorkspaceTagEnabled, setWorkspaceTagRequired, @@ -460,6 +462,11 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { Navigation.navigate(createDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_TAGS_SETTINGS.path : DYNAMIC_ROUTES.WORKSPACE_TAGS_SETTINGS.path)); }, [isQuickSettingsFlow]); + const navigateToCustomTagName = useCallback(() => { + const orderWeight = policyTagLists.at(0)?.orderWeight ?? 0; + Navigation.navigate(createDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_TAGS_EDIT.getRoute(orderWeight) : DYNAMIC_ROUTES.WORKSPACE_EDIT_TAGS.getRoute(orderWeight))); + }, [isQuickSettingsFlow, policyTagLists]); + const navigateToCreateTagPage = () => { Navigation.navigate(isQuickSettingsFlow ? createDynamicRoute(DYNAMIC_ROUTES.SETTINGS_TAG_CREATE.path) : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_TAG_CREATE.path)); }; @@ -497,7 +504,38 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { const hasAccountingConnections = hasAccountingConnectionsPolicyUtils(policy); const secondaryActions = useMemo(() => { const menuItems = []; - if (shouldShowTagsSettings) { + // Under the revamp the Settings page is gone, so its remaining rows (custom tag name and the GL codes toggle) + // are surfaced directly in this menu using the same visibility rules the Settings page used. + if (isRulesRevampEnabled) { + if (canWriteTags && !isMultiLevelTags) { + menuItems.push({ + text: translate('workspace.tags.customTagName'), + description: policyTagLists.at(0)?.name ?? '', + onSelected: navigateToCustomTagName, + shouldShowRightIcon: true, + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + }); + } + if (canWriteTags && !!policy?.glCodes) { + menuItems.push({ + text: translate('workspace.tags.showTagGLCodes'), + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + // The row itself is inert; only the Switch handles the toggle so the menu stays open. + interactive: false, + shouldShowRightComponent: true, + shouldCloseModalOnSelect: false, + pendingAction: policy?.pendingFields?.showTagGLCodes, + rightComponent: ( + setPolicyShowTagGLCodes(policyID, value, policy?.showTagGLCodes)} + disabled={!policy?.areTagsEnabled} + /> + ), + }); + } + } else if (shouldShowTagsSettings) { menuItems.push({ icon: expensifyIcons.Gear, text: translate('common.settings'), @@ -512,6 +550,8 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { text: translate('spreadsheet.importSpreadsheet'), onSelected: navigateToImportSpreadsheet, value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, + // Group the settings rows apart from the spreadsheet actions under the revamp. + addSeparatorBefore: isRulesRevampEnabled, }); } @@ -570,6 +610,13 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { expensifyIcons, showConfirmModal, canWriteTags, + isRulesRevampEnabled, + policyTagLists, + navigateToCustomTagName, + policy?.glCodes, + policy?.showTagGLCodes, + policy?.areTagsEnabled, + policy?.pendingFields?.showTagGLCodes, ]); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); From 0d0c549cdbf249ed10b1043913fa8975d1f2d0c6 Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Thu, 3 Sep 2026 16:55:40 +0430 Subject: [PATCH 2/6] Update routing and component styles for workspace categories and tags pages --- src/ROUTES.ts | 2 +- src/components/ButtonWithDropdownMenu/types.ts | 3 +++ .../workspace/categories/WorkspaceCategoriesPage.tsx | 10 ++++++++-- src/pages/workspace/tags/WorkspaceTagsPage.tsx | 7 +++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 21ad50efbad8..0e7b28c2ab9d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1094,7 +1094,7 @@ const DYNAMIC_ROUTES = { }, WORKSPACE_EDIT_TAGS: { path: 'workspace-edit-tags/:orderWeight', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_TAGS_SETTINGS, SCREENS.WORKSPACE.DYNAMIC_TAG_LIST_VIEW], + entryScreens: [SCREENS.WORKSPACE.TAGS, SCREENS.WORKSPACE.DYNAMIC_TAGS_SETTINGS, SCREENS.WORKSPACE.DYNAMIC_TAG_LIST_VIEW], getRoute: (orderWeight: number) => `workspace-edit-tags/${orderWeight}`, }, WORKSPACE_CATEGORY_CREATE: { diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 2dffb51dffcb..9a25d058d293 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -65,6 +65,9 @@ type DropdownOption = WithSentryLabel & { /** Whether to show the default right chevron icon (e.g. for a row that opens another page) */ shouldShowRightIcon?: boolean; + + /** Style for the row that holds the title and the rightComponent (e.g. to vertically center a toggle against wrapped text) */ + innerContainerStyle?: StyleProp; /** The type of brick road indicator to show */ brickRoadIndicator?: ValueOf; /** Whether selecting this option should bypass the delete confirmation modal */ diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 741ab8672bfb..8440f517d313 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -373,8 +373,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const secondaryActions = useMemo(() => { const menuItems = []; - // Under the revamp the other settings moved to Rules, so the only remaining setting is the GL codes toggle, - // which we surface directly in this menu instead of behind a dedicated Settings page. + // Under the revamp the Settings page is gone, so its remaining GL codes toggle is surfaced directly in this + // menu instead of behind a dedicated Settings page. if (isRulesRevampEnabled) { if (canWriteCategories && !!policy?.glCodes) { menuItems.push({ @@ -384,6 +384,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { interactive: false, shouldShowRightComponent: true, shouldCloseModalOnSelect: false, + numberOfLinesTitle: 0, + innerContainerStyle: styles.alignItemsStart, + titleStyle: [styles.textLabel, styles.fontWeightNormal], pendingAction: policy?.pendingFields?.showCategoryGLCodes, rightComponent: ( Date: Sat, 5 Sep 2026 09:27:12 +0430 Subject: [PATCH 3/6] Add Switch component support to ButtonWithDropdownMenu and update WorkspaceCategoriesPage and WorkspaceTagsPage --- .../ButtonWithDropdownMenu/index.tsx | 3 +++ src/components/ButtonWithDropdownMenu/types.ts | 12 +++++------- src/components/Switch.tsx | 1 + .../categories/WorkspaceCategoriesPage.tsx | 18 +++++++----------- src/pages/workspace/tags/WorkspaceTagsPage.tsx | 18 +++++++----------- 5 files changed, 23 insertions(+), 29 deletions(-) diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 4809570b9277..88031907647a 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -2,6 +2,7 @@ import Button from '@components/ButtonComposed'; import Icon from '@components/Icon'; import InlineIcon from '@components/Icon/InlineIcon'; import PopoverMenu from '@components/PopoverMenu'; +import Switch from '@components/Switch'; import Text from '@components/Text'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -360,6 +361,8 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM containerStyles={containerStyles} menuItems={options.map((item, index) => ({ ...item, + // Build the Switch here from typed data so callers pass `switchProps` instead of threading JSX through the option config. + ...(item.switchProps ? {shouldShowRightComponent: true, rightComponent: } : {}), onSelected: item.onSelected ? () => { item.onSelected?.(); diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 57dcd47e0f80..6b0b6e033557 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -1,4 +1,5 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; +import type {SwitchProps} from '@components/Switch'; import type {ButtonVariant} from '@styles/utils/types'; @@ -8,7 +9,7 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; -import type {ReactNode, RefObject} from 'react'; +import type {RefObject} from 'react'; import type {GestureResponderEvent, StyleProp, TextStyle, View, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -57,16 +58,13 @@ type DropdownOption = WithSentryLabel & { /** Whether to render a divider before this option */ addSeparatorBefore?: boolean; - /** A component rendered on the right side of the option, e.g. a Switch for an inline toggle row */ - rightComponent?: ReactNode; - - /** Whether to show the rightComponent */ - shouldShowRightComponent?: boolean; + /** When set, renders a Switch on the right of the option as an inline toggle row. The menu builds the Switch from this data so no JSX is threaded through the option config. */ + switchProps?: SwitchProps; /** Whether to show the default right chevron icon (e.g. for a row that opens another page) */ shouldShowRightIcon?: boolean; - /** Style for the row that holds the title and the rightComponent (e.g. to vertically center a toggle against wrapped text) */ + /** Style for the row that holds the title and the Switch (e.g. to vertically center a toggle against wrapped text) */ innerContainerStyle?: StyleProp; /** The type of brick road indicator to show */ brickRoadIndicator?: ValueOf; diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx index 57724f18675d..5a85a6d343f7 100644 --- a/src/components/Switch.tsx +++ b/src/components/Switch.tsx @@ -160,3 +160,4 @@ function Switch({isOn, onToggle, accessibilityLabel, disabled, pending = false, } export default Switch; +export type {SwitchProps}; diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 8440f517d313..62618fa2a1ce 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -9,7 +9,6 @@ import ImportedFromAccountingSoftware from '@components/ImportedFromAccountingSo import {ModalActions} from '@components/Modal/Global/ModalContext'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; -import Switch from '@components/Switch'; import type {WorkspaceCategoryTableRowData} from '@components/Tables/WorkspaceCategoriesTable'; import WorkspaceCategoriesTable from '@components/Tables/WorkspaceCategoriesTable'; import Text from '@components/Text'; @@ -380,22 +379,19 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { menuItems.push({ text: translate('workspace.categories.showCategoryGLCodes'), value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, - // The row itself is inert; only the Switch handles the toggle so the menu stays open. + // The row itself is inert. Only the Switch handles the toggle so the menu stays open. interactive: false, - shouldShowRightComponent: true, shouldCloseModalOnSelect: false, numberOfLinesTitle: 0, innerContainerStyle: styles.alignItemsStart, titleStyle: [styles.textLabel, styles.fontWeightNormal], pendingAction: policy?.pendingFields?.showCategoryGLCodes, - rightComponent: ( - setPolicyShowCategoryGLCodes(policyId, value)} - disabled={!policy?.areCategoriesEnabled} - /> - ), + switchProps: { + isOn: policy?.showCategoryGLCodes ?? false, + accessibilityLabel: translate('workspace.categories.showCategoryGLCodes'), + onToggle: (value: boolean) => setPolicyShowCategoryGLCodes(policyId, value), + disabled: !policy?.areCategoriesEnabled, + }, }); } } else if (canWriteCategories) { diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 730ccc28b21a..9b6c6c065883 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -9,7 +9,6 @@ import ImportedFromAccountingSoftware from '@components/ImportedFromAccountingSo import {ModalActions} from '@components/Modal/Global/ModalContext'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; -import Switch from '@components/Switch'; import type {WorkspaceTagTableRowData} from '@components/Tables/WorkspaceTagsTable'; import WorkspaceTagsTable from '@components/Tables/WorkspaceTagsTable'; import Text from '@components/Text'; @@ -520,23 +519,20 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { menuItems.push({ text: translate('workspace.tags.showTagGLCodes'), value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, - // The row itself is inert; only the Switch handles the toggle so the menu stays open. + // The row itself is inert. Only the Switch handles the toggle so the menu stays open. interactive: false, - shouldShowRightComponent: true, shouldCloseModalOnSelect: false, // Let the label wrap fully and keep the Switch centered against it on narrow screens. numberOfLinesTitle: 0, innerContainerStyle: styles.alignItemsCenter, titleStyle: [styles.textLabel, styles.fontWeightNormal], pendingAction: policy?.pendingFields?.showTagGLCodes, - rightComponent: ( - setPolicyShowTagGLCodes(policyID, value, policy?.showTagGLCodes)} - disabled={!policy?.areTagsEnabled} - /> - ), + switchProps: { + isOn: policy?.showTagGLCodes ?? false, + accessibilityLabel: translate('workspace.tags.showTagGLCodes'), + onToggle: (value: boolean) => setPolicyShowTagGLCodes(policyID, value, policy?.showTagGLCodes), + disabled: !policy?.areTagsEnabled, + }, }); } } else if (shouldShowTagsSettings) { From d8da5b78a66f5e7587640070daafc2ad6e048091 Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Sat, 5 Sep 2026 09:35:44 +0430 Subject: [PATCH 4/6] Prevent inert rows from being highlighted in BasePopoverMenu --- src/components/PopoverMenu/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/PopoverMenu/index.tsx b/src/components/PopoverMenu/index.tsx index f6b51c0c7f5b..5d8c3b7a4c44 100644 --- a/src/components/PopoverMenu/index.tsx +++ b/src/components/PopoverMenu/index.tsx @@ -525,7 +525,9 @@ function BasePopoverMenu({ shouldShowRightIcon={!!item.rightIcon} brickRoadIndicator={item.brickRoadIndicator} onFocus={() => { - if (!shouldUpdateFocusedIndex) { + // Inert rows (e.g. an inline toggle row whose only control is a Switch) shouldn't become the + // focused/highlighted item. Otherwise focus bubbling from the inner control leaves the row highlighted. + if (!shouldUpdateFocusedIndex || item.interactive === false) { return; } setFocusedIndex(menuIndex); From bfaf5338aea98eee2663d3aada8dd2bb163d6d6c Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Mon, 7 Sep 2026 18:34:47 +0430 Subject: [PATCH 5/6] Address C+ review: keyboard, error display, and switch alignment - Show the save error inside the menu row on failure instead of reverting silently: PopoverMenu now forwards each item's errors/onCloseError to its OfflineWithFeedback, and the GL-code rows pass errorFields + clearPolicyErrorField. - Make the inline toggle rows keyboard-accessible: the row is interactive and toggles on select (click or Enter), and the Switch is rendered display-only (pointerEvents="none") so the mouse path doesn't double-toggle. - Center the Switch against the (possibly wrapped) label via alignItemsCenter. - Add errors/onCloseError to DropdownOption/PopoverMenuItem so a menu row can --- .../ButtonWithDropdownMenu/index.tsx | 13 +++++++++++-- .../ButtonWithDropdownMenu/types.ts | 7 +++++++ src/components/PopoverMenu/index.tsx | 19 ++++++++++++++----- .../categories/WorkspaceCategoriesPage.tsx | 12 ++++++++---- .../workspace/tags/WorkspaceTagsPage.tsx | 10 ++++++---- 5 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 7e5da4fa9f9b..3c0a33751148 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -349,8 +349,17 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM containerStyles={containerStyles} menuItems={options.map((item, index) => ({ ...item, - // Build the Switch here from typed data so callers pass `switchProps` instead of threading JSX through the option config. - ...(item.switchProps ? {shouldShowRightComponent: true, rightComponent: } : {}), + ...(item.switchProps + ? { + shouldShowRightComponent: true, + // The Switch is display-only; the interactive row handles the toggle (mouse and keyboard) via onSelected, so there's no double-fire. + rightComponent: ( + + + + ), + } + : {}), onSelected: item.onSelected ? () => { item.onSelected?.(); diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 8df7cb8115a3..1491da796ce9 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -4,6 +4,7 @@ import type {SwitchProps} from '@components/Switch'; import type {ButtonVariant} from '@styles/utils/types'; import type CONST from '@src/CONST'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -61,6 +62,12 @@ type DropdownOption = WithSentryLabel & { /** When set, renders a Switch on the right of the option as an inline toggle row. The menu builds the Switch from this data so no JSX is threaded through the option config. */ switchProps?: SwitchProps; + /** Errors to display under the option (e.g. when an inline toggle's save fails) */ + errors?: Errors | null; + + /** Callback to dismiss the option's errors */ + onCloseError?: () => void; + /** Whether to show the default right chevron icon (e.g. for a row that opens another page) */ shouldShowRightIcon?: boolean; diff --git a/src/components/PopoverMenu/index.tsx b/src/components/PopoverMenu/index.tsx index 5d8c3b7a4c44..46e37ad1ab4d 100644 --- a/src/components/PopoverMenu/index.tsx +++ b/src/components/PopoverMenu/index.tsx @@ -33,7 +33,7 @@ import {close} from '@userActions/Modal'; import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -75,6 +75,12 @@ type PopoverMenuItem = MenuItemProps & { pendingAction?: PendingAction; + /** Errors to display under the menu item (e.g. when an inline toggle's save fails) */ + errors?: Errors | null; + + /** Callback to dismiss the item's errors */ + onCloseError?: () => void; + rightIcon?: IconAsset; key?: string; @@ -513,7 +519,12 @@ function BasePopoverMenu({ {/* Compact popovers need tighter divider spacing than full-page sections. */} {addSeparatorBefore === true && menuIndex > 0 && } - + { - // Inert rows (e.g. an inline toggle row whose only control is a Switch) shouldn't become the - // focused/highlighted item. Otherwise focus bubbling from the inner control leaves the row highlighted. - if (!shouldUpdateFocusedIndex || item.interactive === false) { + if (!shouldUpdateFocusedIndex) { return; } setFocusedIndex(menuIndex); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 62618fa2a1ce..2dbdb987627e 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -55,6 +55,7 @@ import { setPolicyShowCategoryGLCodes, setWorkspaceCategoryEnabled, } from '@userActions/Policy/Category'; +import {clearPolicyErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -379,13 +380,15 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { menuItems.push({ text: translate('workspace.categories.showCategoryGLCodes'), value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, - // The row itself is inert. Only the Switch handles the toggle so the menu stays open. - interactive: false, + // Selecting the row (click or Enter) toggles it; the Switch is a display-only indicator. Keep the menu open on select. shouldCloseModalOnSelect: false, + onSelected: () => setPolicyShowCategoryGLCodes(policyId, !(policy?.showCategoryGLCodes ?? false)), numberOfLinesTitle: 0, - innerContainerStyle: styles.alignItemsStart, + innerContainerStyle: styles.alignItemsCenter, titleStyle: [styles.textLabel, styles.fontWeightNormal], pendingAction: policy?.pendingFields?.showCategoryGLCodes, + errors: policy?.errorFields?.showCategoryGLCodes, + onCloseError: () => clearPolicyErrorField(policyId, 'showCategoryGLCodes'), switchProps: { isOn: policy?.showCategoryGLCodes ?? false, accessibilityLabel: translate('workspace.categories.showCategoryGLCodes'), @@ -449,12 +452,13 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { policy?.showCategoryGLCodes, policy?.areCategoriesEnabled, policy?.pendingFields?.showCategoryGLCodes, + policy?.errorFields?.showCategoryGLCodes, policyHasAccountingConnections, hasVisibleCategories, navigateToImportSpreadsheet, isOffline, policyId, - styles.alignItemsStart, + styles.alignItemsCenter, styles.textLabel, styles.fontWeightNormal, ]); diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 9b6c6c065883..95f8ef06d541 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -68,6 +68,7 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import {getCurrentAccountingIntegrationName} from '@pages/workspace/accounting/utils'; import {close} from '@userActions/Modal'; +import {clearPolicyErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -503,8 +504,6 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { const hasAccountingConnections = hasAccountingConnectionsPolicyUtils(policy); const secondaryActions = useMemo(() => { const menuItems = []; - // Under the revamp the Settings page is gone, so its remaining rows (custom tag name and the GL codes toggle) - // are surfaced directly in this menu using the same visibility rules the Settings page used. if (isRulesRevampEnabled) { if (canWriteTags && !isMultiLevelTags) { menuItems.push({ @@ -519,14 +518,16 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { menuItems.push({ text: translate('workspace.tags.showTagGLCodes'), value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, - // The row itself is inert. Only the Switch handles the toggle so the menu stays open. - interactive: false, + // Selecting the row (click or Enter) toggles it; the Switch is a display-only indicator. Keep the menu open on select. shouldCloseModalOnSelect: false, + onSelected: () => setPolicyShowTagGLCodes(policyID, !(policy?.showTagGLCodes ?? false), policy?.showTagGLCodes), // Let the label wrap fully and keep the Switch centered against it on narrow screens. numberOfLinesTitle: 0, innerContainerStyle: styles.alignItemsCenter, titleStyle: [styles.textLabel, styles.fontWeightNormal], pendingAction: policy?.pendingFields?.showTagGLCodes, + errors: policy?.errorFields?.showTagGLCodes, + onCloseError: () => clearPolicyErrorField(policyID, 'showTagGLCodes'), switchProps: { isOn: policy?.showTagGLCodes ?? false, accessibilityLabel: translate('workspace.tags.showTagGLCodes'), @@ -617,6 +618,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { policy?.showTagGLCodes, policy?.areTagsEnabled, policy?.pendingFields?.showTagGLCodes, + policy?.errorFields?.showTagGLCodes, styles.alignItemsCenter, styles.textLabel, styles.fontWeightNormal, From b94eb96f7277f2f128dc5f8808bdd0a3899fc9dd Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Tue, 8 Sep 2026 16:33:44 +0430 Subject: [PATCH 6/6] Enhance PopoverMenu behavior to retain focus on stay-open items and update WorkspaceTagsPage to include pendingAction for policy tags --- src/components/PopoverMenu/index.tsx | 5 ++++- src/pages/workspace/tags/WorkspaceTagsPage.tsx | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/PopoverMenu/index.tsx b/src/components/PopoverMenu/index.tsx index 46e37ad1ab4d..7a443907a34a 100644 --- a/src/components/PopoverMenu/index.tsx +++ b/src/components/PopoverMenu/index.tsx @@ -588,8 +588,11 @@ function BasePopoverMenu({ if (focusedIndex === -1) { return; } + const staysOpenOnSelect = currentMenuItems.at(focusedIndex)?.shouldCloseModalOnSelect === false; + selectItem(focusedIndex); - setFocusedIndex(-1); // Reset the focusedIndex on selecting any menu + // Keep focus on a stay-open item (e.g. an inline toggle) so the next arrow key continues from it; otherwise reset. + setFocusedIndex(staysOpenOnSelect ? focusedIndex : -1); }, {isActive: isVisible}, ); diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 95f8ef06d541..3f25808894c2 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -512,6 +512,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { onSelected: navigateToCustomTagName, shouldShowRightIcon: true, value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + pendingAction: policyTags?.[policyTagLists.at(0)?.name ?? '']?.pendingAction, }); } if (canWriteTags && !!policy?.glCodes) { @@ -613,6 +614,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { canWriteTags, isRulesRevampEnabled, policyTagLists, + policyTags, navigateToCustomTagName, policy?.glCodes, policy?.showTagGLCodes,