Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
85331ef
feat: add basic title style
war-in Aug 25, 2026
324e0c5
chore: prepare gallery with the simplest MenuItemWithTopDescription u…
war-in Aug 25, 2026
ae95a97
Merge branch 'war-in/menu-item-decomposition-entity' into war-in/menu…
war-in Aug 25, 2026
8d91c69
Merge branch 'war-in/menu-item-decomposition-entity' into war-in/menu…
war-in Aug 26, 2026
68adeb9
feat: add MenuItemField preset
war-in Aug 26, 2026
97dbdf7
chore: migrate MenuItemWithTopDescription basic usages
war-in Aug 26, 2026
24672e5
chore: update sotrybook `Phase 3`
war-in Aug 26, 2026
a107059
Merge branch 'war-in/menu-item-decomposition-entity' into war-in/menu…
war-in Aug 31, 2026
840d245
Merge branch 'main' into war-in/menu-item-with-top-description-decomp…
war-in Sep 2, 2026
cfa7d51
refactor: split variants into two components
war-in Sep 2, 2026
f8bac59
fix: make `onPress` optional
war-in Sep 2, 2026
7b6fc93
refactor: make `MenuItemField` display empty state
war-in Sep 2, 2026
398e092
refactor: rename title to title strong
war-in Sep 2, 2026
fa85b4b
refactor: add FieldName and FieldValue leaves
war-in Sep 2, 2026
5b477d1
Merge branch 'main' into war-in/menu-item-with-top-description-decomp…
war-in Sep 3, 2026
a6b633a
refactor: move `value` to MenuItemField
war-in Sep 3, 2026
fde619e
refactor: migrate migratable MenuItemWithTopDescription
war-in Sep 3, 2026
931d732
chore: improve MenuItemComparison
war-in Sep 3, 2026
0b8fbe5
chore: improve comments
war-in Sep 3, 2026
9bc9742
Merge branch 'main' into war-in/menu-item-with-top-description-decomp…
war-in Sep 3, 2026
d799fad
fix: don't pass value for empty strings
war-in Sep 3, 2026
bc17dd6
Merge branch 'main' into war-in/menu-item-with-top-description-decomp…
war-in Sep 4, 2026
35d3b89
tests: MenuItemField
war-in Sep 4, 2026
5893c1e
chore: update comments
war-in Sep 4, 2026
0d41cd4
Merge branch 'main' into war-in/menu-item-with-top-description-decomp…
war-in Sep 8, 2026
c07330b
chore: post-merge fixes
war-in Sep 8, 2026
533b678
Merge branch 'main' into war-in/menu-item-with-top-description-decomp…
war-in Sep 9, 2026
91d90b8
chore: post-merge fixes
war-in Sep 9, 2026
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
12 changes: 8 additions & 4 deletions src/components/MenuItem/MenuItemAccessibilityContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import type {TupleToUnion, ValueOf} from 'type-fest';

import {createContext, useContext, useEffect, useState} from 'react';

/** The text slots a `MenuItem` row can contribute to its label, in the order they are announced */
const MENU_ITEM_LABEL_SLOTS = ['title', 'description'] as const;
/**
* Label slots a `MenuItem` row can contribute, in the order they are announced. Keyed by line rather
* than role, so the announced order matches the visual one for both field and navigation rows.
*/
const MENU_ITEM_LABEL_SLOTS = ['top', 'bottom'] as const;

type MenuItemLabelSlot = TupleToUnion<typeof MENU_ITEM_LABEL_SLOTS>;

Expand Down Expand Up @@ -35,7 +38,7 @@ const MenuItemAccessibilityContext = createContext<MenuItemAccessibilityActions

/**
* Contributes text to the label `MenuItem.Root` derives. Registered under a fixed slot key so the
* announced order is deterministic (`title`, then `description`) regardless of mount/render timing
* announced order is deterministic (`top`, then `bottom`) regardless of mount/render timing.
* No-op when `text` is empty or when rendered outside a `MenuItem.Root`.
*/
function useMenuItemAccessibilityLabel(slot: MenuItemLabelSlot, text: string | undefined) {
Expand Down Expand Up @@ -100,7 +103,7 @@ function useKeyedRegistry<TKey, TValue>() {

/** Assembles the row's accessibility label from what its sub-components registered, plus the value for `MenuItemAccessibilityContext.Provider` */
function useMenuItemAccessibility() {
// Text contributed by Title/Description children, keyed by slot
// Text contributed by the text leaves, keyed by the line each one occupies
const {entries: labels, register: registerLabel, unregister: unregisterLabel} = useKeyedRegistry<MenuItemLabelSlot, string>();

// Facts contributed by any child, keyed by the fact
Expand All @@ -119,4 +122,5 @@ function useMenuItemAccessibility() {
}

export default MenuItemAccessibilityContext;
export type {MenuItemLabelSlot};
export {MENU_ITEM_ACCESSIBILITY_ANNOUNCEMENT, useMenuItemAccessibilityLabel, useMenuItemAccessibilityAnnouncement, useMenuItemAccessibility};
16 changes: 10 additions & 6 deletions src/components/MenuItem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* imports keep working), extended with the compound sub-components following the
* composition-over-configuration pattern.
*
* The row's accessibility label is derived from the `Title`/`Description` text, followed by the
* The row's accessibility label is derived from the text leaves, announced top line first and followed by the
* hints trailing leaves register.
*
* @example Simple navigation row
Expand All @@ -32,11 +32,13 @@ import MenuItemLeading from './layout/MenuItemLeading';
import MenuItemRoot from './layout/MenuItemRoot';
import MenuItemRow from './layout/MenuItemRow';
import MenuItemTrailing from './layout/MenuItemTrailing';
import MenuItemDescription from './leaves/content/MenuItemDescription';
import MenuItemFieldName from './leaves/content/MenuItemFieldName';
import MenuItemFieldNamePlaceholder from './leaves/content/MenuItemFieldNamePlaceholder';
import MenuItemFieldValue from './leaves/content/MenuItemFieldValue';
import MenuItemLabel from './leaves/content/MenuItemLabel';
import MenuItemTitle from './leaves/content/MenuItemTitle';
import MenuItemIcon from './leaves/leading/MenuItemIcon';
import MenuItemDescription from './leaves/text/description/MenuItemDescription';
import MenuItemDescriptionPlaceholder from './leaves/text/description/MenuItemDescriptionPlaceholder';
import MenuItemLabel from './leaves/text/MenuItemLabel';
import MenuItemTitle from './leaves/text/MenuItemTitle';
import MenuItemChevron from './leaves/trailing/icons/MenuItemChevron';
import MenuItemNewWindowIcon from './leaves/trailing/icons/MenuItemNewWindowIcon';
import MenuItemRightLabel from './leaves/trailing/MenuItemRightLabel';
Expand All @@ -52,7 +54,9 @@ const MenuItem = Object.assign(LegacyMenuItem, {
Label: MenuItemLabel,
Title: MenuItemTitle,
Description: MenuItemDescription,
DescriptionPlaceholder: MenuItemDescriptionPlaceholder,
FieldName: MenuItemFieldName,
FieldNamePlaceholder: MenuItemFieldNamePlaceholder,
FieldValue: MenuItemFieldValue,
Chevron: MenuItemChevron,
NewWindowIcon: MenuItemNewWindowIcon,
RightLabel: MenuItemRightLabel,
Expand Down
22 changes: 22 additions & 0 deletions src/components/MenuItem/leaves/content/MenuItemDescription.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import useThemeStyles from '@hooks/useThemeStyles';

import React from 'react';

import type {MenuItemSupportingTextProps} from './base/types';

import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText';

/** Supporting text under a `MenuItem.Title`, the bottom line of a `MenuItem.Content` */
function MenuItemDescription(props: MenuItemSupportingTextProps) {
const styles = useThemeStyles();

return (
<BaseMenuItemSupportingText
{...props}
slot="bottom"
style={styles.textLineHeightNormal}
/>
);
}

export default MenuItemDescription;
22 changes: 22 additions & 0 deletions src/components/MenuItem/leaves/content/MenuItemFieldName.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import useThemeStyles from '@hooks/useThemeStyles';

import React from 'react';

import type {MenuItemSupportingTextProps} from './base/types';

import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText';

/** Name of a filled field */
function MenuItemFieldName(props: MenuItemSupportingTextProps) {
const styles = useThemeStyles();

return (
<BaseMenuItemSupportingText
{...props}
slot="top"
style={styles.textLineHeightNormal}
/>
);
}

export default MenuItemFieldName;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import useStyleUtils from '@hooks/useStyleUtils';

import {fontScale, lineHeightScale} from '@styles/typography';

import React from 'react';

import type {MenuItemSupportingTextProps} from './base/types';

import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText';

/** Name of an empty field, standing in for the missing value */
function MenuItemFieldNamePlaceholder(props: MenuItemSupportingTextProps) {
const StyleUtils = useStyleUtils();

return (
<BaseMenuItemSupportingText
{...props}
slot="top"
style={[StyleUtils.getFontSizeStyle(fontScale.text), StyleUtils.getLineHeightStyle(lineHeightScale.text)]}
/>
);
}

export default MenuItemFieldNamePlaceholder;
17 changes: 17 additions & 0 deletions src/components/MenuItem/leaves/content/MenuItemFieldValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';

import type {MenuItemPrimaryTextProps} from './base/types';

import BaseMenuItemPrimaryText from './base/BaseMenuItemPrimaryText';

/** Value a field holds */
function MenuItemFieldValue(props: MenuItemPrimaryTextProps) {
return (
<BaseMenuItemPrimaryText
{...props}
slot="bottom"
/>
);
}

export default MenuItemFieldValue;
28 changes: 28 additions & 0 deletions src/components/MenuItem/leaves/content/MenuItemLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import useThemeStyles from '@hooks/useThemeStyles';

import React from 'react';

import type {MenuItemSupportingTextProps} from './base/types';

import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText';

/**
* Text naming what the row holds, the top line of a `MenuItem.Content`.
*
* Use this when the label and the value should read as one lockup — it lives inside `Root`, so it
* shares the row's press target and hover background. Reach for the `MenuItemWithLabel` preset
* instead when the label should stay outside both.
*/
function MenuItemLabel(props: MenuItemSupportingTextProps) {
const styles = useThemeStyles();

return (
<BaseMenuItemSupportingText
{...props}
slot="top"
style={[styles.optionAlternateText, styles.pre]}
/>
);
}

export default MenuItemLabel;
22 changes: 22 additions & 0 deletions src/components/MenuItem/leaves/content/MenuItemTitle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import useThemeStyles from '@hooks/useThemeStyles';

import React from 'react';

import type {MenuItemPrimaryTextProps} from './base/types';

import BaseMenuItemPrimaryText from './base/BaseMenuItemPrimaryText';

/** The title block of a `MenuItem.Content`. Bold, single line */
function MenuItemTitle(props: MenuItemPrimaryTextProps) {
const styles = useThemeStyles();

return (
<BaseMenuItemPrimaryText
{...props}
slot="top"
style={styles.textStrong}
/>
);
}

export default MenuItemTitle;
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,20 @@ import convertToLTR from '@libs/convertToLTR';

import CONST from '@src/CONST';

import type {ReactElement} from 'react';

import React from 'react';

type MenuItemTitleProps =
| {
/** Text to render as the title */
children: string | number;

accessibilityLabel?: never;
}
| {
/** Element to render in place of plain text, e.g. a `DisplayNames` with per-name tooltips */
children: ReactElement;

accessibilityLabel: string;
};
import type {BaseMenuItemTextProps, MenuItemPrimaryTextProps} from './types';

/** The title block of a `MenuItem.Content`. Bold, single line */
function MenuItemTitle({children, accessibilityLabel}: MenuItemTitleProps) {
/** Base of the full-contrast leaves */
function BaseMenuItemPrimaryText({children, accessibilityLabel, slot, style}: MenuItemPrimaryTextProps & BaseMenuItemTextProps) {
const styles = useThemeStyles();
const {isDisabled, isInteractive} = useMenuItemConfig();

useMenuItemAccessibilityLabel('title', accessibilityLabel ?? String(children));
useMenuItemAccessibilityLabel(slot, accessibilityLabel ?? String(children));

return (
<Text
style={[styles.flexShrink1, styles.popoverMenuText, styles.textStrong, styles.pre, isInteractive && isDisabled && styles.userSelectNone, styles.ltr, styles.mw100]}
style={[styles.flexShrink1, styles.popoverMenuText, styles.pre, isInteractive && isDisabled && styles.userSelectNone, styles.ltr, styles.mw100, style]}
numberOfLines={1}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: isInteractive && isDisabled}}
>
Expand All @@ -44,4 +30,4 @@ function MenuItemTitle({children, accessibilityLabel}: MenuItemTitleProps) {
);
}

export default MenuItemTitle;
export default BaseMenuItemPrimaryText;
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext';
import Text from '@components/Text';

import useThemeStyles from '@hooks/useThemeStyles';

import React from 'react';

import type {BaseMenuItemTextProps, MenuItemSupportingTextProps} from './types';

/** Base of the muted leaves */
function BaseMenuItemSupportingText({children, numberOfLines = 2, slot, style}: MenuItemSupportingTextProps & BaseMenuItemTextProps) {
const styles = useThemeStyles();

useMenuItemAccessibilityLabel(slot, String(children));

return (
<Text
style={[styles.textLabelSupporting, styles.breakWord, style]}
numberOfLines={numberOfLines}
>
{children}
</Text>
);
}

export default BaseMenuItemSupportingText;
40 changes: 40 additions & 0 deletions src/components/MenuItem/leaves/content/base/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type {MenuItemLabelSlot} from '@components/MenuItem/MenuItemAccessibilityContext';

import type {ReactElement} from 'react';
import type {StyleProp, TextStyle} from 'react-native';

/** Props of the full-contrast leaves */
type MenuItemPrimaryTextProps =
| {
/** Plain text to render as the primary content */
children: string | number;
Comment thread
war-in marked this conversation as resolved.

accessibilityLabel?: never;
}
| {
/** For content plain text can't express, e.g. `DisplayNames` with per-name tooltips */
children: ReactElement;

Comment thread
war-in marked this conversation as resolved.
/** Required here because the row builds its label from strings and can't read one out of an element */
accessibilityLabel: string;
};

/** Props of the muted leaves */
type MenuItemSupportingTextProps = {
Comment thread
war-in marked this conversation as resolved.
/** Text to render */
children: string | number;

Comment thread
war-in marked this conversation as resolved.
/** Defaults to 2. Supporting text wraps, unlike primary text, which is always one line */
numberOfLines?: number;
};

/** Props shared by MenuItem's content text */
type BaseMenuItemTextProps = {
/** Which line of the row this leaf occupies, so the row announces its text in visual order */
slot: MenuItemLabelSlot;

Comment thread
war-in marked this conversation as resolved.
/** Typography layered on top of the shared base. Each leaf brings its own size and line height */
style?: StyleProp<TextStyle>;
};

export type {MenuItemSupportingTextProps, MenuItemPrimaryTextProps, BaseMenuItemTextProps};
25 changes: 0 additions & 25 deletions src/components/MenuItem/leaves/text/MenuItemLabel.tsx

This file was deleted.

This file was deleted.

Loading
Loading