Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,7 @@ const CONST = {
INSIGHTS_PAGE: 'insightsPage',
INSIGHTS_COMPARE: 'insightsCompare',
PAYMENT_HISTORY: 'paymentHistory',
ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns',
},
BUTTON_STATES: {
DEFAULT: 'default',
Expand Down
45 changes: 37 additions & 8 deletions src/components/CategoryPicker/CategoryPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,25 @@ import type PopoverWithMeasuredContentProps from '@components/PopoverWithMeasure
import type {ListItem} from '@components/SelectionList/types';

import useKeyboardState from '@hooks/useKeyboardState';
import useOnyx from '@hooks/useOnyx';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';

import {getEnabledCategoriesCount} from '@libs/CategoryUtils';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import getSelectionListPopoverContentHeight from '@libs/getSelectionListPopoverContentHeight';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

import type {ComponentRef} from 'react';

import React, {useRef} from 'react';
import React, {useRef, useState} from 'react';
import {View} from 'react-native';

import CategoryPicker from '.';

const popoverDimensions = {
width: CONST.POPOVER_DROPDOWN_WIDTH,
height: CONST.POPOVER_DROPDOWN_MAX_HEIGHT,
};

const DEFAULT_ANCHOR_ALIGNMENT = {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
Expand All @@ -36,6 +37,18 @@ type CategoryPickerModalProps = {

/** Called when the user confirms a category selection */
onSelected?: (item: ListItem) => void;

/** Width of the pop-over. Defaults to the standard dropdown width, a field row passes its own width instead */
popoverWidth?: number;

/** Height of the pop-over. Defaults to the standard dropdown height, a caller short on room passes a smaller one */
popoverHeight?: number;

/** Whether the pop-over shrinks to the height its list needs, treating `popoverHeight` as a ceiling. */
shouldFitContentHeight?: boolean;

/** Whether the pop-over may flip to the other side of the anchor when it overflows. It shifts by a whole pop-over height, so a caller that already picked the side turns this off. */
shouldSwitchPositionIfOverflow?: boolean;
} & Omit<PopoverWithMeasuredContentProps, 'anchorRef' | 'children' | 'onClose'>;

function CategoryPickerModal({
Expand All @@ -47,6 +60,10 @@ function CategoryPickerModal({
onSelected,
anchorAlignment = DEFAULT_ANCHOR_ALIGNMENT,
shouldMeasureAnchorPositionFromTop = false,
popoverWidth = CONST.POPOVER_DROPDOWN_WIDTH,
popoverHeight = CONST.POPOVER_DROPDOWN_MAX_HEIGHT,
shouldFitContentHeight = false,
shouldSwitchPositionIfOverflow = true,
}: CategoryPickerModalProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
Expand All @@ -55,6 +72,17 @@ function CategoryPickerModal({
const {isKeyboardActive} = useKeyboardState();
const anchorRef = useRef<ComponentRef<typeof View>>(null);

const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`, {selector: getEnabledCategoriesCount});
const [renderedRowCount, setRenderedRowCount] = useState<number>();

const categoriesCount = policyCategories ?? 0;
const isSearchable = categoriesCount >= CONST.STANDARD_LIST_ITEM_LIMIT;
const estimatedContentHeight = getSelectionListPopoverContentHeight({optionCount: Math.max(renderedRowCount ?? categoriesCount, 1), isSearchable});

// A bottom sheet is sized by the screen, so the content estimate only applies to the pop-over.
const resolvedHeight = shouldFitContentHeight && !isSmallScreenWidth ? Math.min(popoverHeight, estimatedContentHeight) : popoverHeight;
const popoverDimensions = {width: popoverWidth, height: resolvedHeight};

const handleCategorySelect = (item: ListItem) => {
// If clicking the same category that's already selected, treat it as deselection
if (item.keyForList === selectedCategory) {
Expand All @@ -73,9 +101,9 @@ function CategoryPickerModal({
anchorPosition={anchorPosition}
popoverDimensions={popoverDimensions}
anchorAlignment={anchorAlignment}
innerContainerStyle={StyleUtils.getWidthStyle(popoverDimensions.width)}
innerContainerStyle={isSmallScreenWidth ? undefined : StyleUtils.getWidthStyle(popoverDimensions.width)}
restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE}
shouldSwitchPositionIfOverflow
shouldSwitchPositionIfOverflow={shouldSwitchPositionIfOverflow}
shouldEnableNewFocusManagement
shouldMeasureAnchorPositionFromTop={shouldMeasureAnchorPositionFromTop}
shouldSkipRemeasurement
Expand All @@ -84,6 +112,7 @@ function CategoryPickerModal({
>
<View style={[StyleUtils.getHeight(popoverDimensions.height), styles.flexColumn, styles.pt4]}>
<CategoryPicker
onRenderedRowCountChange={setRenderedRowCount}
selectedCategory={selectedCategory}
policyID={policyID}
onSubmit={handleCategorySelect}
Expand Down
28 changes: 26 additions & 2 deletions src/components/CategoryPicker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

import React from 'react';
import React, {useEffect} from 'react';
// eslint-disable-next-line no-restricted-imports -- Need original useOnyx to avoid reading partial Search snapshot policy data (GL code flags are trimmed from the snapshot).
import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx';

Expand All @@ -39,6 +39,13 @@ type CategoryPickerProps = {

/** Whether the search input should auto-focus when the picker mounts. Only opted into by the inline-edit popover wrapper. */
shouldAutoFocusSearchInput?: boolean;

/**
* Reports how many rows the unfiltered list renders, for a pop-over wrapper to size itself from. The category
* count does not match: nested names add a row per parent, a selected out-of-policy category adds one, and
* the `Recent` and `All` headings add one each. Only reported unfiltered, so searching never resizes it.
*/
onRenderedRowCountChange?: (rowCount: number) => void;
};

const getSelectedOptions = (selectedCategory?: string): Category[] => {
Expand All @@ -55,7 +62,15 @@ const getSelectedOptions = (selectedCategory?: string): Category[] => {
];
};

function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOption = false, addBottomSafeAreaPadding = false, shouldAutoFocusSearchInput = false}: CategoryPickerProps) {
function CategoryPicker({
selectedCategory,
policyID,
onSubmit,
shouldShowNoneOption = false,
addBottomSafeAreaPadding = false,
shouldAutoFocusSearchInput = false,
onRenderedRowCountChange,
}: CategoryPickerProps) {
const styles = useThemeStyles();
const {inputCallbackRef} = useAutoFocusInput();
const [shouldShowGLCode] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(policyID)}`, {
Expand Down Expand Up @@ -107,6 +122,15 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt

const sectionsWithTitleStyles = sectionsWithNoneOption.map((section) => ({...section, data: section.data.map((category) => ({...category, titleStyles: styles.w100}))}));
const categoryData = sectionsWithNoneOption.flatMap((section) => section.data);
const renderedRowCount = sectionsWithNoneOption.reduce((total, section) => total + section.data.length + (section.title ? 1 : 0), 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 src/components/CategoryPicker/index.tsx:126

renderedRowCount is built from the search-filtered sections (getCategoryListSections gets debouncedSearchValue), so the pop-over now resizes while the user types. That happens with 12 or more categories, which is when search shows, as soon as results drop below 4 rows.

When the container opens above the row, its bottom edge is pinned to the row. As the results shrink, the search input at the top slides down under the user mid-typing, then jumps back up when they clear it.

The count also stays in CategoryPickerModal state after close. If you search, pick, and reopen, the pop-over opens at the filtered size for a frame before the remounted picker reports the full count.

Current With Suggested Fix
Screen.Recording.2026-10-01.at.13.53.41.mov
Screen.Recording.2026-10-01.at.13.58.24.mov

Could we report the count only for the full list, so searching never resizes the container ?

useEffect(() => {
    if (debouncedSearchValue) {
        return;
    }
    onRenderedRowCountChange?.(renderedRowCount);
}, [renderedRowCount, debouncedSearchValue, onRenderedRowCountChange]);

cc @Expensify/design for a decision on whether to keep current or go with suggested fix 🙌

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the container opens above the row, its bottom edge is pinned to the row. As the results shrink, the search input at the top slides down under the user mid-typing, then jumps back up when they clear it.

I think we have this shrinking/growing behavior already with the exposed filters and inline edit on Spend, right? So I think it's fine as-is, but let's get confirmation from DesignTeam first.


useEffect(() => {
if (debouncedSearchValue) {
return;
}
onRenderedRowCountChange?.(renderedRowCount);
}, [renderedRowCount, debouncedSearchValue, onRenderedRowCountChange]);

const categoriesCount = getEnabledCategoriesCount(categories);
const selectedOptionKey = categoryData.find((category) => category.searchText === selectedCategory)?.keyForList;

Expand Down
8 changes: 6 additions & 2 deletions src/components/MenuItem/layout/MenuItemRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type WithSentryLabel from '@src/types/utils/SentryLabel';
import type WithTestID from '@src/types/utils/TestID';

import type {ComponentRef, PropsWithChildren} from 'react';
import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
import type {AccessibilityState, GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';

import React, {useRef} from 'react';
import {View} from 'react-native';
Expand All @@ -46,9 +46,12 @@ type MenuItemRootProps = PropsWithChildren &
* before the hover/press background so the row keeps its interaction feedback.
*/
style?: StyleProp<ViewStyle>;

/** Accessibility state for the row, e.g. `{expanded}`. Lands on the pressable, which is what a screen reader focuses. */
accessibilityState?: AccessibilityState;
};

function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel, style}: MenuItemRootProps) {
function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel, style, accessibilityState}: MenuItemRootProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const pressableRef = useRef<ComponentRef<typeof View>>(null);
Expand Down Expand Up @@ -110,6 +113,7 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI
role={isInteractive ? CONST.ROLE.BUTTON : undefined}
accessibilityLabel={accessibilityLabel ?? derivedAccessibilityLabel}
accessibilityHint={accessibilityHint}
accessibilityState={accessibilityState}
accessible
tabIndex={isInteractive ? 0 : -1}
sentryLabel={sentryLabel}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context';

import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import usePermissions from '@hooks/usePermissions';
import useThemeStyles from '@hooks/useThemeStyles';

import {getDecodedLeafCategoryName} from '@libs/CategoryUtils';
import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
import {hasEnabledOptions} from '@libs/OptionsListUtils';

import CONST from '@src/CONST';
import type {IOUAction, IOUType} from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES';
import type * as OnyxTypes from '@src/types/onyx';

import type {OnyxEntry} from 'react-native-onyx';

import React from 'react';

import ExpenseFieldRow from './ExpenseFieldRow';
import CategoryFieldDropdown from './CategoryFieldDropdown';
import ExpenseFieldDropdown from './ExpenseFieldDropdown';
import {useExpenseFormLayout} from './ExpenseFormLayoutContext';
import {categoryStateSelector} from './selectors';
import useTransactionSelector from './useTransactionSelector';

const hasEnabledCategoriesSelector = (policyCategories: OnyxEntry<OnyxTypes.PolicyCategories>) => hasEnabledOptions(Object.values(policyCategories ?? {}));

type CategoryFieldProps = {
isCategoryRequired: boolean;
didConfirm: boolean;
Expand Down Expand Up @@ -53,17 +62,22 @@ function CategoryField({
shouldSelectPolicy,
}: CategoryFieldProps) {
const {shouldUseDropdownRows} = useExpenseFormLayout();
const {isEditingSplitBill} = useConfirmationFields();
const {isBetaEnabled} = usePermissions();
const styles = useThemeStyles();
const {translate} = useLocalize();
const icons = useMemoizedLazyExpensifyIcons(['Sparkles']);

const categoryState = useTransactionSelector(transactionID, categoryStateSelector);
const [hasEnabledCategories = false] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policy?.id)}`, {selector: hasEnabledCategoriesSelector});

const shouldDisplayCategoryError = formError === 'violations.categoryOutOfPolicy';
const iouCategory = categoryState?.category ?? '';
const willAutoFill = categoryState?.willAutoFill ?? false;
const isAutoFillFromReceipt = categoryState?.isAutoFillFromReceipt ?? false;
const decodedCategoryName = getDecodedLeafCategoryName(iouCategory);
// The list marks and clears by the stored category name, not by the leaf name the row shows.
const selectedCategory = isCategoryMissing(iouCategory) ? '' : iouCategory;

// The workspace setting defaults to on. Turning it off means nothing will pick a category, so the row must not promise one.
const isAutoCategorizationEnabled = policy?.autoCategorizeNewExpenses !== false;
Expand Down Expand Up @@ -135,9 +149,14 @@ function CategoryField({
}
};

const canSaveFromThisForm = action !== CONST.IOU.ACTION.EDIT || isEditingSplitBill;
const canUseAnchoredFieldDropdowns = isBetaEnabled(CONST.BETAS.ANCHORED_FIELD_DROPDOWNS);
const shouldOpenInDropdown =
canUseAnchoredFieldDropdowns && !!transactionID && !!policy && !shouldNavigateToUpgradePath && !shouldSelectPolicy && hasEnabledCategories && canSaveFromThisForm;

if (shouldUseDropdownRows) {
return (
<ExpenseFieldRow
<ExpenseFieldDropdown
name={translate('common.category')}
value={decodedCategoryName}
numberOfLinesValue={2}
Expand All @@ -149,6 +168,17 @@ function CategoryField({
shouldKeepRightLabelWhenFilled={shouldPromiseAutomaticCategory && isAutoFillFromReceipt}
errorText={shouldDisplayCategoryError ? translate(formError as TranslationPaths) : ''}
onPress={openCategoryPage}
shouldOpenInDropdown={shouldOpenInDropdown && !isReadOnly && !didConfirm}
renderDropdown={(dropdownProps) =>
!!transactionID && (
<CategoryFieldDropdown
{...dropdownProps}
transactionID={transactionID}
policy={policy}
selectedCategory={selectedCategory}
/>
)
}
isDisabled={didConfirm}
isInteractive={!isReadOnly}
sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.CATEGORY_FIELD}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import CategoryPickerModal from '@components/CategoryPicker/CategoryPickerModal';
import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context';
import type {ListItem} from '@components/SelectionList/types';

import useOnyx from '@hooks/useOnyx';
import useUpdateTransactionCategory from '@hooks/useUpdateTransactionCategory';

import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';

import type {OnyxEntry} from 'react-native-onyx';

import React from 'react';

import type {ExpenseFieldDropdownRenderProps} from './ExpenseFieldDropdown';

type CategoryFieldDropdownProps = ExpenseFieldDropdownRenderProps & {
/** ID of the expense the picked category is written to */
transactionID: string;

/** Policy the categories belong to */
policy: OnyxEntry<OnyxTypes.Policy>;

/** Category the expense already holds, so the list can mark it */
selectedCategory: string;
};

/**
* The category list, in the container anchored to the expense form's category row. Mounted only once the row is
* opened, keeping its Onyx subscriptions off the form's first render, and saves through the same hook the
* full-page selector uses so both leave the expense in the same state.
*/
function CategoryFieldDropdown({transactionID, policy, selectedCategory, onClose, ...popoverProps}: CategoryFieldDropdownProps) {
const {reportID, isEditingSplitBill, action} = useConfirmationFields();

const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`);
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policy?.id)}`);

const {updateCategory} = useUpdateTransactionCategory({
transactionID,
transaction: draftTransaction ?? transaction,
report,
policy,
policyCategories,
isEditing: action === CONST.IOU.ACTION.EDIT,
isEditingSplit: isEditingSplitBill,
});

const handleSelected = (item: ListItem) => {
// `CategoryPickerModal` hands back an empty item when the selected category is tapped again, clearing it.
updateCategory(item.searchText ?? '');
};

return (
<CategoryPickerModal
{...popoverProps}
onClose={onClose}
shouldFitContentHeight
policyID={policy?.id}
selectedCategory={selectedCategory}
onSelected={handleSelected}
/>
);
}

export default CategoryFieldDropdown;
Loading
Loading