diff --git a/docs/6.x/docs/components/FAB/FABMenu.mdx b/docs/6.x/docs/components/FAB/FABMenu.mdx
index 4d460f0fc4..a17b3963a6 100644
--- a/docs/6.x/docs/components/FAB/FABMenu.mdx
+++ b/docs/6.x/docs/components/FAB/FABMenu.mdx
@@ -19,7 +19,7 @@ via the close button or by tapping an item.
-
+
## Usage
@@ -116,7 +116,7 @@ const [open, setOpen] = React.useState(false);
## Theme colors
-
+
diff --git a/docs/6.x/docs/components/Menu/Menu.mdx b/docs/6.x/docs/components/Menu/Menu.mdx
index 1d1ee928b2..8cc60e6736 100644
--- a/docs/6.x/docs/components/Menu/Menu.mdx
+++ b/docs/6.x/docs/components/Menu/Menu.mdx
@@ -10,10 +10,14 @@ import ExtendedExample from '@docs/components/ExtendedExample.tsx';
Menus display a list of choices on temporary elevated surfaces. Their placement varies based on the element that opens them.
+Follows [Material Design 3 menus](https://m3.material.io/components/menus/specs): container
+`corner.large`, fill `surfaceContainerLow` (elevation controls shadow only), item label
+`labelLarge`, selected items use `tertiaryContainer` / `onTertiaryContainer`.
-
+
+
## Usage
@@ -41,10 +45,24 @@ const MyComponent = () => {
visible={visible}
onDismiss={closeMenu}
anchor={}>
-
{}} title="Item 1" />
- {}} title="Item 2" />
+ {}}
+ title="Paste"
+ supportingText="Insert clipboard"
+ trailingSupportingText="⌘V"
+ selected
+ />
+ {}} title="Undo" />
- {}} title="Item 3" />
+ {}} title="Share" dense />
+
+
@@ -155,6 +173,14 @@ wrapping is not necessary if you use Paper's `Modal` instead.
+### colorScheme
+
+
+
+
+
+
+
### theme
@@ -184,7 +210,7 @@ wrapping is not necessary if you use Paper's `Modal` instead.
## Theme colors
-
+
diff --git a/docs/6.x/docs/components/Menu/MenuItem.mdx b/docs/6.x/docs/components/Menu/MenuItem.mdx
index b3fb4e5110..5622580de8 100644
--- a/docs/6.x/docs/components/Menu/MenuItem.mdx
+++ b/docs/6.x/docs/components/Menu/MenuItem.mdx
@@ -10,6 +10,9 @@ import ExtendedExample from '@docs/components/ExtendedExample.tsx';
A component to show a single list item inside a Menu.
+Supports MD3 selection (`selected`), supporting text, trailing supporting text,
+and dense layout.
+
@@ -28,7 +31,15 @@ const MyComponent = () => (
{}} title="Undo" />
{}} title="Cut" disabled />
{}} title="Copy" disabled />
- {}} title="Paste" />
+ {}}
+ title="Paste"
+ supportingText="Insert clipboard"
+ trailingSupportingText="⌘V"
+ selected
+ />
+
);
@@ -51,6 +62,22 @@ export default MyComponent;
+### supportingText
+
+
+
+
+
+
+
+### trailingSupportingText
+
+
+
+
+
+
+
### leadingIcon Renamed from 'icon' to 'leadingIcon' in v5.x
@@ -67,6 +94,22 @@ export default MyComponent;
+### selected
+
+
+
+
+
+
+
+### colorScheme
+
+
+
+
+
+
+
### disabled
@@ -83,6 +126,22 @@ export default MyComponent;
+### roundedTop
+
+
+
+
+
+
+
+### roundedBottom
+
+
+
+
+
+
+
### background
@@ -139,6 +198,22 @@ export default MyComponent;
+### supportingTextStyle
+
+
+
+
+
+
+
+### trailingSupportingTextStyle
+
+
+
+
+
+
+
### theme
@@ -208,7 +283,7 @@ export default MyComponent;
## Theme colors
-
+
diff --git a/docs/public/screenshots/menu-0.png b/docs/public/screenshots/menu-0.png
new file mode 100644
index 0000000000..ae8946fdd7
Binary files /dev/null and b/docs/public/screenshots/menu-0.png differ
diff --git a/docs/public/screenshots/menu-1.png b/docs/public/screenshots/menu-1.png
index babbd17e84..fd308dc6a9 100644
Binary files a/docs/public/screenshots/menu-1.png and b/docs/public/screenshots/menu-1.png differ
diff --git a/docs/public/screenshots/menu-2.png b/docs/public/screenshots/menu-2.png
index 313453f353..5a2d0c96ef 100644
Binary files a/docs/public/screenshots/menu-2.png and b/docs/public/screenshots/menu-2.png differ
diff --git a/docs/public/screenshots/menu-3.png b/docs/public/screenshots/menu-3.png
new file mode 100644
index 0000000000..f3c68e9f02
Binary files /dev/null and b/docs/public/screenshots/menu-3.png differ
diff --git a/docs/public/screenshots/menu-4.png b/docs/public/screenshots/menu-4.png
new file mode 100644
index 0000000000..88235c92a9
Binary files /dev/null and b/docs/public/screenshots/menu-4.png differ
diff --git a/docs/public/screenshots/menu-ios-bottom.png b/docs/public/screenshots/menu-ios-bottom.png
new file mode 100644
index 0000000000..2e405da0f2
Binary files /dev/null and b/docs/public/screenshots/menu-ios-bottom.png differ
diff --git a/docs/public/screenshots/menu-ios-closed.png b/docs/public/screenshots/menu-ios-closed.png
new file mode 100644
index 0000000000..e6abb0461c
Binary files /dev/null and b/docs/public/screenshots/menu-ios-closed.png differ
diff --git a/docs/public/screenshots/menu-ios-icons.png b/docs/public/screenshots/menu-ios-icons.png
new file mode 100644
index 0000000000..b3d2455bee
Binary files /dev/null and b/docs/public/screenshots/menu-ios-icons.png differ
diff --git a/docs/public/screenshots/menu-ios-selected.png b/docs/public/screenshots/menu-ios-selected.png
new file mode 100644
index 0000000000..1fe4b254e2
Binary files /dev/null and b/docs/public/screenshots/menu-ios-selected.png differ
diff --git a/docs/public/screenshots/menu-ios-vibrant.png b/docs/public/screenshots/menu-ios-vibrant.png
new file mode 100644
index 0000000000..4f2ad5a787
Binary files /dev/null and b/docs/public/screenshots/menu-ios-vibrant.png differ
diff --git a/docs/public/screenshots/menu-item.png b/docs/public/screenshots/menu-item.png
index 09a7702557..5a2d0c96ef 100644
Binary files a/docs/public/screenshots/menu-item.png and b/docs/public/screenshots/menu-item.png differ
diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json
index 56b356912b..69b06cf16e 100644
--- a/docs/src/data/componentDocs6x.json
+++ b/docs/src/data/componentDocs6x.json
@@ -8936,10 +8936,10 @@
"Menu/Menu": {
"filepath": "Menu/Menu.tsx",
"title": "Menu",
- "description": "Menus display a list of choices on temporary elevated surfaces. Their placement varies based on the element that opens them.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { View } from 'react-native';\nimport { Button, Menu, Divider, PaperProvider } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [visible, setVisible] = React.useState(false);\n\n const openMenu = () => setVisible(true);\n\n const closeMenu = () => setVisible(false);\n\n return (\n \n \n \n \n \n );\n};\n\nexport default MyComponent;\n```\n\n### Note\nWhen using `Menu` within a React Native's `Modal` component, you need to wrap all\n`Modal` contents within a `PaperProvider` in order for the menu to show. This\nwrapping is not necessary if you use Paper's `Modal` instead.",
+ "description": "Menus display a list of choices on temporary elevated surfaces. Their placement varies based on the element that opens them.\n\nFollows [Material Design 3 menus](https://m3.material.io/components/menus/specs): container\n`corner.large`, fill `surfaceContainerLow` (elevation controls shadow only), item label\n`labelLarge`, selected items use `tertiaryContainer` / `onTertiaryContainer`.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { View } from 'react-native';\nimport { Button, Menu, Divider, PaperProvider } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [visible, setVisible] = React.useState(false);\n\n const openMenu = () => setVisible(true);\n\n const closeMenu = () => setVisible(false);\n\n return (\n \n \n \n \n \n \n );\n};\n\nexport default MyComponent;\n```\n\n### Note\nWhen using `Menu` within a React Native's `Modal` component, you need to wrap all\n`Modal` contents within a `PaperProvider` in order for the menu to show. This\nwrapping is not necessary if you use Paper's `Modal` instead.",
"link": "menu",
"data": {
- "description": "Menus display a list of choices on temporary elevated surfaces. Their placement varies based on the element that opens them.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { View } from 'react-native';\nimport { Button, Menu, Divider, PaperProvider } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [visible, setVisible] = React.useState(false);\n\n const openMenu = () => setVisible(true);\n\n const closeMenu = () => setVisible(false);\n\n return (\n \n \n \n \n \n );\n};\n\nexport default MyComponent;\n```\n\n### Note\nWhen using `Menu` within a React Native's `Modal` component, you need to wrap all\n`Modal` contents within a `PaperProvider` in order for the menu to show. This\nwrapping is not necessary if you use Paper's `Modal` instead.",
+ "description": "Menus display a list of choices on temporary elevated surfaces. Their placement varies based on the element that opens them.\n\nFollows [Material Design 3 menus](https://m3.material.io/components/menus/specs): container\n`corner.large`, fill `surfaceContainerLow` (elevation controls shadow only), item label\n`labelLarge`, selected items use `tertiaryContainer` / `onTertiaryContainer`.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { View } from 'react-native';\nimport { Button, Menu, Divider, PaperProvider } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [visible, setVisible] = React.useState(false);\n\n const openMenu = () => setVisible(true);\n\n const closeMenu = () => setVisible(false);\n\n return (\n \n \n \n \n \n \n );\n};\n\nexport default MyComponent;\n```\n\n### Note\nWhen using `Menu` within a React Native's `Modal` component, you need to wrap all\n`Modal` contents within a `PaperProvider` in order for the menu to show. This\nwrapping is not necessary if you use Paper's `Modal` instead.",
"displayName": "Menu",
"methods": [],
"statics": [],
@@ -9084,10 +9084,10 @@
"tsType": {
"name": "Elevation"
},
- "description": "Elevation level of the menu's content. Shadow styles are calculated based on this value. Default `backgroundColor` is taken from the corresponding `theme.colors.elevation` property. By default equals `2`.\n@supported Available in v5.x with theme version 3",
+ "description": "Elevation level of the menu's content. Controls shadow only.\nDefault fill is MD3 `surfaceContainerLow` (standard scheme) regardless of\nelevation — Paper's `elevation.levelN` tones are not the same as that role.\nBy default equals `2`.\n@supported Available in v5.x with theme version 3",
"defaultValue": {
- "value": "2",
- "computed": false
+ "value": "MenuTokens.elevation.default",
+ "computed": true
}
},
"mode": {
@@ -9106,12 +9106,23 @@
}
]
},
- "description": "Mode of the menu's content.\n- `elevated` - Surface with a shadow and background color corresponding to set `elevation` value.\n- `flat` - Surface without a shadow, with the background color corresponding to set `elevation` value.\n\n@supported Available in v5.x with theme version 3",
+ "description": "Mode of the menu's content.\n- `elevated` - Surface with a shadow; fill from MD3 menu container role.\n- `flat` - Surface without a shadow; same fill role.\n\n@supported Available in v5.x with theme version 3",
"defaultValue": {
"value": "'elevated'",
"computed": false
}
},
+ "colorScheme": {
+ "required": false,
+ "tsType": {
+ "name": "MenuColorScheme"
+ },
+ "description": "Color scheme for the menu surface and its items.\n- `standard` (default) — `surfaceContainerLow` fill + onSurface content\n- `vibrant` — M3 Expressive tertiary roles",
+ "defaultValue": {
+ "value": "'standard'",
+ "computed": false
+ }
+ },
"theme": {
"required": false,
"tsType": {
@@ -9148,10 +9159,10 @@
"Menu/MenuItem": {
"filepath": "Menu/MenuItem.tsx",
"title": "Menu.Item",
- "description": "A component to show a single list item inside a Menu.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { View } from 'react-native';\nimport { Menu } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n {}} title=\"Redo\" />\n {}} title=\"Undo\" />\n {}} title=\"Cut\" disabled />\n {}} title=\"Copy\" disabled />\n {}} title=\"Paste\" />\n \n);\n\nexport default MyComponent;\n```",
+ "description": "A component to show a single list item inside a Menu.\n\nSupports MD3 selection (`selected`), supporting text, trailing supporting text,\nand dense layout.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { View } from 'react-native';\nimport { Menu } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n {}} title=\"Redo\" />\n {}} title=\"Undo\" />\n {}} title=\"Cut\" disabled />\n {}} title=\"Copy\" disabled />\n {}}\n title=\"Paste\"\n supportingText=\"Insert clipboard\"\n trailingSupportingText=\"⌘V\"\n selected\n />\n \n \n);\n\nexport default MyComponent;\n```",
"link": "menu-item",
"data": {
- "description": "A component to show a single list item inside a Menu.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { View } from 'react-native';\nimport { Menu } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n {}} title=\"Redo\" />\n {}} title=\"Undo\" />\n {}} title=\"Cut\" disabled />\n {}} title=\"Copy\" disabled />\n {}} title=\"Paste\" />\n \n);\n\nexport default MyComponent;\n```",
+ "description": "A component to show a single list item inside a Menu.\n\nSupports MD3 selection (`selected`), supporting text, trailing supporting text,\nand dense layout.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { View } from 'react-native';\nimport { Menu } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n {}} title=\"Redo\" />\n {}} title=\"Undo\" />\n {}} title=\"Cut\" disabled />\n {}} title=\"Copy\" disabled />\n {}}\n title=\"Paste\"\n supportingText=\"Insert clipboard\"\n trailingSupportingText=\"⌘V\"\n selected\n />\n \n \n);\n\nexport default MyComponent;\n```",
"displayName": "Menu.Item",
"methods": [],
"statics": [],
@@ -9164,6 +9175,22 @@
},
"description": "Title text for the `MenuItem`."
},
+ "supportingText": {
+ "required": false,
+ "tsType": {
+ "name": "ReactReactNode",
+ "raw": "React.ReactNode"
+ },
+ "description": "Optional supporting text rendered under the title (`bodySmall`)."
+ },
+ "trailingSupportingText": {
+ "required": false,
+ "tsType": {
+ "name": "ReactReactNode",
+ "raw": "React.ReactNode"
+ },
+ "description": "Optional trailing supporting text (`labelLarge`), e.g. a keyboard shortcut."
+ },
"leadingIcon": {
"required": false,
"tsType": {
@@ -9178,6 +9205,24 @@
},
"description": "@supported Available in v5.x with theme version 3\n\nTrailing icon to display for the `MenuItem`."
},
+ "selected": {
+ "required": false,
+ "tsType": {
+ "name": "boolean"
+ },
+ "description": "Whether the item is selected / active. Applies MD3 selected colors\n(`tertiaryContainer` / `onTertiaryContainer` in the standard scheme)\nand `corner.medium` rounding.",
+ "defaultValue": {
+ "value": "false",
+ "computed": false
+ }
+ },
+ "colorScheme": {
+ "required": false,
+ "tsType": {
+ "name": "MenuColorScheme"
+ },
+ "description": "Color scheme for the item. Inherited from parent `Menu` when omitted.\n- `standard` (default)\n- `vibrant` — M3 Expressive tertiary roles"
+ },
"disabled": {
"required": false,
"tsType": {
@@ -9192,6 +9237,20 @@
},
"description": "@supported Available in v5.x with theme version 3\n\nSets min height with densed layout."
},
+ "roundedTop": {
+ "required": false,
+ "tsType": {
+ "name": "boolean"
+ },
+ "description": "Round the top corners (`corner.medium`). Used for the first item in a menu.\nPrefer letting the parent `Menu` set this via context when possible."
+ },
+ "roundedBottom": {
+ "required": false,
+ "tsType": {
+ "name": "boolean"
+ },
+ "description": "Round the bottom corners (`corner.medium`). Used for the last item in a menu."
+ },
"background": {
"required": false,
"tsType": {
@@ -9269,7 +9328,7 @@
],
"raw": "StyleProp"
},
- "description": "Style that is passed to the content container, which wraps the title text."
+ "description": "Style that is passed to the content container."
},
"titleStyle": {
"required": false,
@@ -9284,6 +9343,32 @@
},
"description": "Style that is passed to the Title element."
},
+ "supportingTextStyle": {
+ "required": false,
+ "tsType": {
+ "name": "StyleProp",
+ "elements": [
+ {
+ "name": "TextStyle"
+ }
+ ],
+ "raw": "StyleProp"
+ },
+ "description": "Style that is passed to the supporting text element."
+ },
+ "trailingSupportingTextStyle": {
+ "required": false,
+ "tsType": {
+ "name": "StyleProp",
+ "elements": [
+ {
+ "name": "TextStyle"
+ }
+ ],
+ "raw": "StyleProp"
+ },
+ "description": "Style that is passed to the trailing supporting text element."
+ },
"theme": {
"required": false,
"tsType": {
diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts
index 92bf8f2788..90a96a5eab 100644
--- a/docs/src/data/screenshots.ts
+++ b/docs/src/data/screenshots.ts
@@ -101,7 +101,9 @@ export const screenshots = {
'List.Section': 'screenshots/list-section.png',
Menu: {
'with icons': 'screenshots/menu-1.png',
- 'without icons': 'screenshots/menu-2.png',
+ 'selected + supporting': 'screenshots/menu-2.png',
+ 'vibrant scheme': 'screenshots/menu-3.png',
+ 'bottom anchor': 'screenshots/menu-4.png',
},
'Menu.Item': 'screenshots/menu-item.png',
Modal: 'screenshots/modal.gif',
diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts
index 20f16962f7..d526a7ab48 100644
--- a/docs/src/data/themeColors.ts
+++ b/docs/src/data/themeColors.ts
@@ -245,18 +245,41 @@ export const themeColors = {
},
},
Menu: {
- '-': {
- backgroundColor: 'theme.colors.elevation.level2',
+ standard: {
+ backgroundColor: 'theme.colors.surfaceContainerLow',
+ },
+ vibrant: {
+ backgroundColor: 'theme.colors.tertiaryContainer',
+ },
+ 'selected item': {
+ backgroundColor: 'theme.colors.tertiaryContainer',
+ textColor: 'theme.colors.onTertiaryContainer',
},
},
'Menu.Item': {
default: {
textColor: 'theme.colors.onSurface',
iconColor: 'theme.colors.onSurfaceVariant',
+ supportingColor: 'theme.colors.onSurfaceVariant',
+ },
+ selected: {
+ backgroundColor: 'theme.colors.tertiaryContainer',
+ textColor: 'theme.colors.onTertiaryContainer',
+ iconColor: 'theme.colors.onTertiaryContainer',
},
disabled: {
- textColor: 'theme.colors.onSurfaceDisabled',
- iconColor: 'theme.colors.onSurfaceDisabled',
+ textColor: 'theme.colors.onSurface',
+ iconColor: 'theme.colors.onSurfaceVariant',
+ contentOpacity: 'theme.state.opacity.disabled (0.38)',
+ },
+ vibrant: {
+ textColor: 'theme.colors.onTertiaryContainer',
+ iconColor: 'theme.colors.onTertiaryContainer',
+ },
+ 'vibrant selected': {
+ backgroundColor: 'theme.colors.tertiary',
+ textColor: 'theme.colors.onTertiary',
+ iconColor: 'theme.colors.onTertiary',
},
},
Modal: {
diff --git a/example/src/Examples/MenuExample.tsx b/example/src/Examples/MenuExample.tsx
index 2a55069090..86f4da5ee8 100644
--- a/example/src/Examples/MenuExample.tsx
+++ b/example/src/Examples/MenuExample.tsx
@@ -138,13 +138,57 @@ const MenuExample = () => {
anchorPosition="bottom"
anchor={
}
>
- {}} title="Item 1" />
- {}} title="Item 2" />
- {}} title="Item 3" />
+ {}}
+ title="Copy"
+ trailingSupportingText="⌘C"
+ />
+ {}}
+ title="Paste"
+ supportingText="Insert clipboard"
+ selected
+ />
+ {}}
+ title="Share"
+ dense
+ />
+ {}} title="Delete" disabled />
+
+
+
+
+
diff --git a/jest/setNodeEnv.js b/jest/setNodeEnv.js
new file mode 100644
index 0000000000..849e85312a
--- /dev/null
+++ b/jest/setNodeEnv.js
@@ -0,0 +1,4 @@
+// Must run before any React Native modules load. RN Animated only force-updates
+// host styles under Jest when NODE_ENV === 'test'; otherwise setValue/timing
+// go through setNativeProps, which does not update props visible to toHaveStyle.
+process.env.NODE_ENV = 'test';
diff --git a/jest/testSetup.js b/jest/testSetup.js
index c00e611084..9d12f94af4 100644
--- a/jest/testSetup.js
+++ b/jest/testSetup.js
@@ -35,7 +35,10 @@ jest.mock('@react-native-vector-icons/material-design-icons', () => {
jest.mock('react-native', () => {
const RN = jest.requireActual('react-native');
- const timing = (value, config) => {
+ // Stub Animated drivers that hit the native driver. Real spring/timing with
+ // useNativeDriver fails under Jest ("Unable to locate attached view").
+ // setValue + finished callback keeps production code paths intact.
+ const createAnimatedDriver = (value, config) => {
const animation = {
start: (callback) => {
setTimeout(() => {
@@ -58,6 +61,9 @@ jest.mock('react-native', () => {
return animation;
};
+ const timing = (value, config) => createAnimatedDriver(value, config);
+ const spring = (value, config) => createAnimatedDriver(value, config);
+
const loop = (animation) => {
return {
start: (callback) => {
@@ -87,6 +93,7 @@ jest.mock('react-native', () => {
});
RN.Animated.timing = timing;
+ RN.Animated.spring = spring;
RN.Animated.loop = loop;
RN.Animated.parallel = parallel;
diff --git a/package.json b/package.json
index 3a0296b5f5..5480a52569 100644
--- a/package.json
+++ b/package.json
@@ -102,6 +102,7 @@
"jest": {
"preset": "@react-native/jest-preset",
"setupFiles": [
+ "/jest/setNodeEnv.js",
"/jest/testSetup.js"
],
"setupFilesAfterEnv": [
diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx
index ca6e849e3d..e5c396728d 100644
--- a/src/components/Menu/Menu.tsx
+++ b/src/components/Menu/Menu.tsx
@@ -2,7 +2,6 @@ import * as React from 'react';
import {
Animated,
Dimensions,
- Easing,
Keyboard,
Platform,
ScrollView,
@@ -22,9 +21,15 @@ import type {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { composeMenuChildren } from './composeMenuChildren';
+import { MenuRootContext } from './context';
import MenuItem from './MenuItem';
+import { runMenuCloseMotion, runMenuOpenMotion } from './motion';
+import { MenuTokens, type MenuColorScheme } from './tokens';
+import { getMenuContainerBorderRadius, getMenuContainerColor } from './utils';
import { useLocale } from '../../core/locale';
import { useInternalTheme } from '../../core/theming';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
import type { Elevation, Theme, ThemeProp } from '../../types';
import { addEventListener } from '../../utils/addEventListener';
import { BackHandler } from '../../utils/BackHandler/BackHandler';
@@ -70,18 +75,27 @@ export type Props = {
contentStyle?: Animated.WithAnimatedValue>;
style?: StyleProp;
/**
- * Elevation level of the menu's content. Shadow styles are calculated based on this value. Default `backgroundColor` is taken from the corresponding `theme.colors.elevation` property. By default equals `2`.
+ * Elevation level of the menu's content. Controls shadow only.
+ * Default fill is MD3 `surfaceContainerLow` (standard scheme) regardless of
+ * elevation — Paper's `elevation.levelN` tones are not the same as that role.
+ * By default equals `2`.
* @supported Available in v5.x with theme version 3
*/
elevation?: Elevation;
/**
* Mode of the menu's content.
- * - `elevated` - Surface with a shadow and background color corresponding to set `elevation` value.
- * - `flat` - Surface without a shadow, with the background color corresponding to set `elevation` value.
+ * - `elevated` - Surface with a shadow; fill from MD3 menu container role.
+ * - `flat` - Surface without a shadow; same fill role.
*
* @supported Available in v5.x with theme version 3
*/
mode?: 'flat' | 'elevated';
+ /**
+ * Color scheme for the menu surface and its items.
+ * - `standard` (default) — `surfaceContainerLow` fill + onSurface content
+ * - `vibrant` — M3 Expressive tertiary roles
+ */
+ colorScheme?: MenuColorScheme;
/**
* @optional
*/
@@ -97,15 +111,11 @@ export type Props = {
};
// Minimum padding between the edge of the screen and the menu
-const SCREEN_INDENT = 8;
-// From https://material.io/design/motion/speed.html#duration
-const ANIMATION_DURATION = 250;
-// From the 'Standard easing' section of https://material.io/design/motion/speed.html#easing
-const EASING = Easing.bezier(0.4, 0, 0.2, 1);
+const SCREEN_INDENT = MenuTokens.sizes.screenIndent;
const WINDOW_LAYOUT = Dimensions.get('window');
-const DEFAULT_ELEVATION: Elevation = 2;
+const DEFAULT_ELEVATION: Elevation = MenuTokens.elevation.default;
const DEFAULT_MODE = 'elevated';
const focusFirstDOMNode = (el: View | null | undefined) => {
@@ -132,6 +142,10 @@ const isBrowser = () => Platform.OS === 'web' && 'document' in global;
/**
* Menus display a list of choices on temporary elevated surfaces. Their placement varies based on the element that opens them.
*
+ * Follows [Material Design 3 menus](https://m3.material.io/components/menus/specs): container
+ * `corner.large`, fill `surfaceContainerLow` (elevation controls shadow only), item label
+ * `labelLarge`, selected items use `tertiaryContainer` / `onTertiaryContainer`.
+ *
* ## Usage
* ```js
* import * as React from 'react';
@@ -157,10 +171,24 @@ const isBrowser = () => Platform.OS === 'web' && 'document' in global;
* visible={visible}
* onDismiss={closeMenu}
* anchor={}>
- * {}} title="Item 1" />
- * {}} title="Item 2" />
+ * {}}
+ * title="Paste"
+ * supportingText="Insert clipboard"
+ * trailingSupportingText="⌘V"
+ * selected
+ * />
+ * {}} title="Undo" />
*
- * {}} title="Item 3" />
+ * {}} title="Share" dense />
+ *
+ *
*
*
@@ -188,13 +216,14 @@ const Menu = ({
style,
elevation = DEFAULT_ELEVATION,
mode = DEFAULT_MODE,
+ colorScheme = 'standard',
children,
theme: themeOverrides,
keyboardShouldPersistTaps,
}: Props) => {
const theme = useInternalTheme(themeOverrides);
const { direction } = useLocale();
- const { colors: md3Colors } = theme as Theme;
+ const reduceMotion = useReduceMotion();
const insets = useSafeAreaInsets();
const [rendered, setRendered] = React.useState(visible);
const [left, setLeft] = React.useState(0);
@@ -345,44 +374,42 @@ const Menu = ({
attachListeners();
requestAnimationFrame(() => {
- const { animation } = theme;
- Animated.parallel([
- Animated.timing(scaleAnimationRef.current, {
- toValue: { x: menuLayoutResult.width, y: menuLayoutResult.height },
- duration: ANIMATION_DURATION * animation.scale,
- easing: EASING,
- useNativeDriver: true,
- }),
- Animated.timing(opacityAnimationRef.current, {
- toValue: 1,
- duration: ANIMATION_DURATION * animation.scale,
- easing: EASING,
- useNativeDriver: true,
- }),
- ]).start(() => {
+ const finish = () => {
focusFirstDOMNode(menuRef.current);
prevRendered.current = true;
+ };
+
+ // M3 spring motion (spatial for scale, effects for opacity), or snap
+ // under reduce-motion. Shared helper so unit tests drive the real path.
+ runMenuOpenMotion({
+ reduceMotion,
+ scaleAnimation: scaleAnimationRef.current,
+ opacityAnimation: opacityAnimationRef.current,
+ menuWidth: menuLayoutResult.width,
+ menuHeight: menuLayoutResult.height,
+ theme: theme as Theme,
+ onFinish: finish,
});
});
- }, [anchor, attachListeners, measureAnchorLayout, theme]);
+ }, [anchor, attachListeners, measureAnchorLayout, reduceMotion, theme]);
const hide = React.useCallback(() => {
removeListeners();
- const { animation } = theme;
-
- Animated.timing(opacityAnimationRef.current, {
- toValue: 0,
- duration: ANIMATION_DURATION * animation.scale,
- easing: EASING,
- useNativeDriver: true,
- }).start(() => {
+ const finish = () => {
setMenuLayout({ width: 0, height: 0 });
setRendered(false);
prevRendered.current = false;
focusFirstDOMNode(anchorRef.current);
+ };
+
+ runMenuCloseMotion({
+ reduceMotion,
+ opacityAnimation: opacityAnimationRef.current,
+ theme: theme as Theme,
+ onFinish: finish,
});
- }, [removeListeners, theme]);
+ }, [reduceMotion, removeListeners, theme]);
const updateVisibility = React.useCallback(
async (display: boolean) => {
@@ -614,7 +641,7 @@ const Menu = ({
}),
},
],
- borderRadius: theme.shapes.corner.extraSmall,
+ borderRadius: getMenuContainerBorderRadius(theme),
...(scrollableMenuHeight ? { height: scrollableMenuHeight } : {}),
};
@@ -629,6 +656,22 @@ const Menu = ({
const pointerEvents = visible ? 'box-none' : 'none';
+ // Parent-owned layout context (no cloneElement). Type identity only for
+ // first/last detection — no displayName filtering. Wrappers keep working
+ // via explicit roundedTop/roundedBottom/colorScheme props.
+ const renderedChildren = composeMenuChildren({
+ children,
+ colorScheme,
+ });
+
+ const rootContext = React.useMemo(() => ({ colorScheme }), [colorScheme]);
+
+ const surfaceBackground = getMenuContainerColor({
+ theme: theme as Theme,
+ elevation,
+ colorScheme,
+ });
+
return (
{
@@ -670,7 +713,8 @@ const Menu = ({
styles.shadowMenuContainer,
shadowMenuContainerStyle,
{
- backgroundColor: md3Colors.elevation[`level${elevation}`],
+ backgroundColor: surfaceBackground,
+ paddingVertical: MenuTokens.sizes.containerPaddingVertical,
},
contentStyle,
]}
@@ -679,13 +723,15 @@ const Menu = ({
theme={theme}
container
>
- {(scrollableMenuHeight && (
-
- {children}
-
- )) || {children}}
+
+ {(scrollableMenuHeight && (
+
+ {renderedChildren}
+
+ )) || {renderedChildren}}
+
@@ -703,7 +749,6 @@ const styles = StyleSheet.create({
},
shadowMenuContainer: {
opacity: 0,
- paddingVertical: 8,
},
pressableOverlay: {
...Platform.select({
diff --git a/src/components/Menu/MenuItem.tsx b/src/components/Menu/MenuItem.tsx
index 945c12794b..7245720f82 100644
--- a/src/components/Menu/MenuItem.tsx
+++ b/src/components/Menu/MenuItem.tsx
@@ -1,15 +1,19 @@
import * as React from 'react';
-import { StyleSheet, View } from 'react-native';
-import type {
- GestureResponderEvent,
- PressableAndroidRippleConfig,
- StyleProp,
- TextStyle,
- ViewStyle,
+import {
+ StyleSheet,
+ View,
+ type GestureResponderEvent,
+ type PressableAndroidRippleConfig,
+ type StyleProp,
+ type TextStyle,
+ type ViewStyle,
} from 'react-native';
+import { useMenuItemLayout } from './context';
+import { MenuTokens, type MenuColorScheme } from './tokens';
import {
getContentMaxWidth,
+ getMenuItemBorderRadius,
getMenuItemColor,
MAX_WIDTH,
MIN_WIDTH,
@@ -27,6 +31,14 @@ export type Props = {
* Title text for the `MenuItem`.
*/
title: React.ReactNode;
+ /**
+ * Optional supporting text rendered under the title (`bodySmall`).
+ */
+ supportingText?: React.ReactNode;
+ /**
+ * Optional trailing supporting text (`labelLarge`), e.g. a keyboard shortcut.
+ */
+ trailingSupportingText?: React.ReactNode;
/**
* @renamed Renamed from 'icon' to 'leadingIcon' in v5.x
*
@@ -39,6 +51,18 @@ export type Props = {
* Trailing icon to display for the `MenuItem`.
*/
trailingIcon?: IconSource;
+ /**
+ * Whether the item is selected / active. Applies MD3 selected colors
+ * (`tertiaryContainer` / `onTertiaryContainer` in the standard scheme)
+ * and `corner.medium` rounding.
+ */
+ selected?: boolean;
+ /**
+ * Color scheme for the item. Inherited from parent `Menu` when omitted.
+ * - `standard` (default)
+ * - `vibrant` — M3 Expressive tertiary roles
+ */
+ colorScheme?: MenuColorScheme;
/**
* Whether the 'item' is disabled. A disabled 'item' is greyed out and `onPress` is not called on touch.
*/
@@ -49,6 +73,15 @@ export type Props = {
* Sets min height with densed layout.
*/
dense?: boolean;
+ /**
+ * Round the top corners (`corner.medium`). Used for the first item in a menu.
+ * Prefer letting the parent `Menu` set this via context when possible.
+ */
+ roundedTop?: boolean;
+ /**
+ * Round the bottom corners (`corner.medium`). Used for the last item in a menu.
+ */
+ roundedBottom?: boolean;
/**
* Type of background drawabale to display the feedback (Android).
* https://reactnative.dev/docs/pressable#rippleconfig
@@ -72,13 +105,21 @@ export type Props = {
*/
containerStyle?: StyleProp;
/**
- * Style that is passed to the content container, which wraps the title text.
+ * Style that is passed to the content container.
*/
contentStyle?: StyleProp;
/**
* Style that is passed to the Title element.
*/
titleStyle?: StyleProp;
+ /**
+ * Style that is passed to the supporting text element.
+ */
+ supportingTextStyle?: StyleProp;
+ /**
+ * Style that is passed to the trailing supporting text element.
+ */
+ trailingSupportingTextStyle?: StyleProp;
/**
* @optional
*/
@@ -117,6 +158,9 @@ export type Props = {
/**
* A component to show a single list item inside a Menu.
*
+ * Supports MD3 selection (`selected`), supporting text, trailing supporting text,
+ * and dense layout.
+ *
* ## Usage
* ```js
* import * as React from 'react';
@@ -129,7 +173,15 @@ export type Props = {
* {}} title="Undo" />
* {}} title="Cut" disabled />
* {}} title="Copy" disabled />
- * {}} title="Paste" />
+ * {}}
+ * title="Paste"
+ * supportingText="Insert clipboard"
+ * trailingSupportingText="⌘V"
+ * selected
+ * />
+ *
*
* );
*
@@ -141,13 +193,21 @@ const MenuItem = ({
trailingIcon,
dense,
title,
+ supportingText,
+ trailingSupportingText,
+ selected = false,
+ colorScheme: colorSchemeProp,
disabled,
+ roundedTop: roundedTopProp,
+ roundedBottom: roundedBottomProp,
background,
onPress,
style,
containerStyle,
contentStyle,
titleStyle,
+ supportingTextStyle,
+ trailingSupportingTextStyle,
testID = 'menu-item',
'aria-label': ariaLabel,
'aria-checked': ariaChecked,
@@ -159,33 +219,82 @@ const MenuItem = ({
hitSlop,
}: Props) => {
const theme = useInternalTheme(themeOverrides);
- const { titleColor, iconColor, contentOpacity } = getMenuItemColor({
+ const layout = useMenuItemLayout();
+
+ const colorScheme = colorSchemeProp ?? layout?.colorScheme ?? 'standard';
+ const roundedTop = roundedTopProp ?? layout?.roundedTop ?? false;
+ const roundedBottom = roundedBottomProp ?? layout?.roundedBottom ?? false;
+
+ const {
+ titleColor,
+ iconColor,
+ supportingColor,
+ containerColor,
+ contentOpacity,
+ } = getMenuItemColor({
theme,
disabled,
+ selected,
+ colorScheme,
});
- const containerPadding = 12;
- const iconWidth = 24;
+ const {
+ itemPaddingHorizontal,
+ iconSize,
+ iconLabelGap,
+ noLeadingIconStart,
+ itemHeight,
+ denseItemHeight,
+ minWidth,
+ maxWidth,
+ } = MenuTokens.sizes;
- const minWidth = MIN_WIDTH - 12;
-
- const maxWidth = getContentMaxWidth({
- iconWidth,
+ const contentMaxWidth = getContentMaxWidth({
+ iconWidth: iconSize,
leadingIcon,
trailingIcon,
+ hasTrailingSupportingText: Boolean(trailingSupportingText),
});
const titleTextStyle = {
color: titleColor,
- ...(theme as Theme).fonts.bodyLarge,
+ ...(theme as Theme).fonts[MenuTokens.typography.label],
+ };
+
+ const supportingTextStyleResolved = {
+ color: supportingColor,
+ ...(theme as Theme).fonts[MenuTokens.typography.supporting],
+ };
+
+ const trailingSupportingTextStyleResolved = {
+ color: supportingColor,
+ ...(theme as Theme).fonts[MenuTokens.typography.trailingSupporting],
};
+ const borderRadiusStyle = getMenuItemBorderRadius({
+ theme,
+ selected,
+ roundedTop,
+ roundedBottom,
+ });
+
+ const isSelected = selected && !disabled;
+
return (
-
+
{leadingIcon ? (
-
-
+
+
) : null}
{title}
+ {supportingText ? (
+
+ {supportingText}
+
+ ) : null}
+ {trailingSupportingText ? (
+
+ {trailingSupportingText}
+
+ ) : null}
{trailingIcon ? (
-
-
+
+
) : null}
@@ -243,23 +392,30 @@ const styles = StyleSheet.create({
container: {
minWidth: MIN_WIDTH,
maxWidth: MAX_WIDTH,
- height: 48,
justifyContent: 'center',
- },
- md3DenseContainer: {
- height: 32,
+ overflow: 'hidden',
},
row: {
flexDirection: 'row',
+ alignItems: 'center',
+ alignSelf: 'flex-start',
+ maxWidth: MAX_WIDTH - MenuTokens.sizes.itemPaddingHorizontal * 2,
+ },
+ leadingIcon: {
+ flexShrink: 0,
},
content: {
+ flexShrink: 1,
+ flexGrow: 0,
justifyContent: 'center',
},
- md3LeadingIcon: {
+ trailingSupporting: {
marginLeft: 12,
+ flexShrink: 0,
},
- md3WithoutLeadingIcon: {
- marginLeft: 4,
+ trailingIcon: {
+ marginLeft: 12,
+ flexShrink: 0,
},
});
diff --git a/src/components/Menu/composeMenuChildren.tsx b/src/components/Menu/composeMenuChildren.tsx
new file mode 100644
index 0000000000..b807b6f792
--- /dev/null
+++ b/src/components/Menu/composeMenuChildren.tsx
@@ -0,0 +1,62 @@
+import * as React from 'react';
+
+import { MenuItemLayoutContext } from './context';
+import MenuItem from './MenuItem';
+import type { MenuColorScheme } from './tokens';
+
+type ComposeArgs = {
+ children: React.ReactNode;
+ colorScheme: MenuColorScheme;
+};
+
+const isMenuItemElement = (
+ child: React.ReactNode
+): child is React.ReactElement> =>
+ React.isValidElement(child) && child.type === MenuItem;
+
+/**
+ * Count Menu.Item nodes. Type identity only — no displayName filtering.
+ */
+export function countMenuItems(children: React.ReactNode): number {
+ let count = 0;
+ React.Children.forEach(children, (child) => {
+ if (isMenuItemElement(child)) {
+ count += 1;
+ }
+ });
+ return count;
+}
+
+/**
+ * Parent-owned layout: wrap items in layout context instead of cloneElement.
+ * Items get first/last medium corners via context.
+ */
+export function composeMenuChildren({
+ children,
+ colorScheme,
+}: ComposeArgs): React.ReactNode {
+ const totalItems = countMenuItems(children);
+ let itemCursor = 0;
+
+ return React.Children.map(children, (child) => {
+ if (!isMenuItemElement(child)) {
+ return child;
+ }
+
+ const index = itemCursor;
+ itemCursor += 1;
+
+ return (
+
+ {child}
+
+ );
+ });
+}
diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx
new file mode 100644
index 0000000000..ac823dd451
--- /dev/null
+++ b/src/components/Menu/context.tsx
@@ -0,0 +1,36 @@
+import * as React from 'react';
+
+import type { MenuColorScheme } from './tokens';
+
+/**
+ * Root menu state: color scheme.
+ * Provided by `Menu`; consumed by layout helpers and items.
+ */
+export type MenuRootContextValue = {
+ colorScheme: MenuColorScheme;
+};
+
+export const MenuRootContext = React.createContext(
+ null
+);
+
+/**
+ * Per-item layout from the parent `Menu`.
+ * Items prefer explicit props over these defaults. No `cloneElement`.
+ */
+export type MenuItemLayoutContextValue = {
+ colorScheme: MenuColorScheme;
+ roundedTop: boolean;
+ roundedBottom: boolean;
+};
+
+export const MenuItemLayoutContext =
+ React.createContext(null);
+
+export function useMenuItemLayout() {
+ return React.useContext(MenuItemLayoutContext);
+}
+
+export function useMenuRoot() {
+ return React.useContext(MenuRootContext);
+}
diff --git a/src/components/Menu/motion.ts b/src/components/Menu/motion.ts
new file mode 100644
index 0000000000..4545200c6c
--- /dev/null
+++ b/src/components/Menu/motion.ts
@@ -0,0 +1,86 @@
+import { Animated } from 'react-native';
+
+import { toRawSpring } from '../../theme/tokens/sys/motion';
+import type { InternalTheme } from '../../types';
+
+type OpenMotionArgs = {
+ reduceMotion: boolean;
+ scaleAnimation: Animated.ValueXY;
+ opacityAnimation: Animated.Value;
+ menuWidth: number;
+ menuHeight: number;
+ theme: InternalTheme;
+ onFinish: () => void;
+};
+
+type CloseMotionArgs = {
+ reduceMotion: boolean;
+ opacityAnimation: Animated.Value;
+ theme: InternalTheme;
+ onFinish: () => void;
+};
+
+/**
+ * Open the menu surface: spring scale+opacity, or snap when reduce-motion.
+ * Returns which path ran so tests can assert the real shipped branch.
+ */
+export function runMenuOpenMotion({
+ reduceMotion,
+ scaleAnimation,
+ opacityAnimation,
+ menuWidth,
+ menuHeight,
+ theme,
+ onFinish,
+}: OpenMotionArgs): 'snap' | 'spring' {
+ if (reduceMotion) {
+ scaleAnimation.setValue({ x: menuWidth, y: menuHeight });
+ opacityAnimation.setValue(1);
+ onFinish();
+ return 'snap';
+ }
+
+ const spatialSpring = toRawSpring(theme.motion.spring.fast.spatial);
+ const effectsSpring = toRawSpring(theme.motion.spring.fast.effects);
+
+ Animated.parallel([
+ Animated.spring(scaleAnimation, {
+ toValue: { x: menuWidth, y: menuHeight },
+ ...spatialSpring,
+ useNativeDriver: true,
+ }),
+ Animated.spring(opacityAnimation, {
+ toValue: 1,
+ ...effectsSpring,
+ useNativeDriver: true,
+ }),
+ ]).start(onFinish);
+
+ return 'spring';
+}
+
+/**
+ * Close the menu surface: spring opacity out, or snap when reduce-motion.
+ */
+export function runMenuCloseMotion({
+ reduceMotion,
+ opacityAnimation,
+ theme,
+ onFinish,
+}: CloseMotionArgs): 'snap' | 'spring' {
+ if (reduceMotion) {
+ opacityAnimation.setValue(0);
+ onFinish();
+ return 'snap';
+ }
+
+ const effectsSpring = toRawSpring(theme.motion.spring.fast.effects);
+
+ Animated.spring(opacityAnimation, {
+ toValue: 0,
+ ...effectsSpring,
+ useNativeDriver: true,
+ }).start(onFinish);
+
+ return 'spring';
+}
diff --git a/src/components/Menu/tokens.ts b/src/components/Menu/tokens.ts
new file mode 100644
index 0000000000..6955c28db5
--- /dev/null
+++ b/src/components/Menu/tokens.ts
@@ -0,0 +1,87 @@
+import type { ColorRole, Elevation, TypescaleKey } from '../../theme/types';
+import type { ShapeToken } from '../../theme/utils/shape';
+
+/**
+ * MD3 Menu / Menu.Item dimensions, shapes, color roles and typescale.
+ * @see https://m3.material.io/components/menus/specs
+ */
+const sizes = {
+ /** Min width of a menu item (and thus the menu). */
+ minWidth: 112,
+ /** Max width of a menu item (and thus the menu). */
+ maxWidth: 280,
+ /** Standard item height. */
+ itemHeight: 48,
+ /** Dense item height. */
+ denseItemHeight: 32,
+ /** Horizontal padding inside an item. */
+ itemPaddingHorizontal: 12,
+ /** Leading icon size. */
+ iconSize: 24,
+ /** Gap between leading icon and label. */
+ iconLabelGap: 12,
+ /** Extra start inset when there is no leading icon. */
+ noLeadingIconStart: 4,
+ /** Vertical padding of the menu surface. */
+ containerPaddingVertical: 8,
+ /** Min distance from screen edge. */
+ screenIndent: 8,
+} as const;
+
+const shapes = {
+ /** Menu surface corner. Spec: corner.large (16dp). */
+ container: 'large' as ShapeToken,
+ /** First / last / selected item corners. Spec: corner.medium. */
+ item: 'medium' as ShapeToken,
+} as const;
+
+const typography = {
+ label: 'labelLarge' as TypescaleKey,
+ supporting: 'bodySmall' as TypescaleKey,
+ trailingSupporting: 'labelLarge' as TypescaleKey,
+} as const;
+
+/**
+ * Standard (baseline) color scheme roles.
+ * Menu surface fill is the MD3 role `surfaceContainerLow` (not elevation.level2 —
+ * Paper's elevation.level2 maps to surfaceContainer tones, which is a different color).
+ * The `elevation` prop still drives shadow only.
+ */
+const standardColors = {
+ /** MD3 menu container fill. */
+ container: 'surfaceContainerLow' as ColorRole,
+ label: 'onSurface' as ColorRole,
+ icon: 'onSurfaceVariant' as ColorRole,
+ supporting: 'onSurfaceVariant' as ColorRole,
+ selectedContainer: 'tertiaryContainer' as ColorRole,
+ selectedContent: 'onTertiaryContainer' as ColorRole,
+} as const;
+
+/**
+ * M3 Expressive vibrant menu roles.
+ * @see https://m3.material.io/components/menus/specs
+ */
+const vibrantColors = {
+ label: 'onTertiaryContainer' as ColorRole,
+ icon: 'onTertiaryContainer' as ColorRole,
+ supporting: 'onTertiaryContainer' as ColorRole,
+ /** Vibrant menu surface uses tertiaryContainer instead of elevation surface. */
+ container: 'tertiaryContainer' as ColorRole,
+ selectedContainer: 'tertiary' as ColorRole,
+ selectedContent: 'onTertiary' as ColorRole,
+} as const;
+
+const elevation = {
+ default: 2 as Elevation,
+} as const;
+
+export const MenuTokens = {
+ sizes,
+ shapes,
+ typography,
+ standardColors,
+ vibrantColors,
+ elevation,
+};
+
+export type MenuColorScheme = 'standard' | 'vibrant';
diff --git a/src/components/Menu/utils.ts b/src/components/Menu/utils.ts
index 2aae40d2e4..85d70c5695 100644
--- a/src/components/Menu/utils.ts
+++ b/src/components/Menu/utils.ts
@@ -1,55 +1,142 @@
+import type { ColorValue, ViewStyle } from 'react-native';
+
+import { MenuTokens, type MenuColorScheme } from './tokens';
import { tokens } from '../../theme/tokens';
+import { resolveCornerRadius } from '../../theme/utils/shape';
import type { InternalTheme } from '../../types';
import type { IconSource } from '../Icon';
const stateOpacity = tokens.md.sys.state.opacity;
-export const MIN_WIDTH = 112;
-export const MAX_WIDTH = 280;
+export const MIN_WIDTH = MenuTokens.sizes.minWidth;
+export const MAX_WIDTH = MenuTokens.sizes.maxWidth;
type ContentProps = {
iconWidth: number;
leadingIcon?: IconSource;
trailingIcon?: IconSource;
+ hasTrailingSupportingText?: boolean;
};
type ColorProps = {
theme: InternalTheme;
disabled?: boolean;
+ selected?: boolean;
+ colorScheme?: MenuColorScheme;
};
-const getTitleColor = ({ theme }: ColorProps) => {
- return theme.colors.onSurface;
-};
-
-const getIconColor = ({ theme }: ColorProps) => {
- return theme.colors.onSurfaceVariant;
-};
+const resolveRoles = (colorScheme: MenuColorScheme = 'standard') =>
+ colorScheme === 'vibrant'
+ ? MenuTokens.vibrantColors
+ : MenuTokens.standardColors;
-export const getMenuItemColor = ({ theme, disabled }: ColorProps) => {
+export const getMenuItemColor = ({
+ theme,
+ disabled,
+ selected,
+ colorScheme = 'standard',
+}: ColorProps) => {
+ const roles = resolveRoles(colorScheme);
const contentOpacity = disabled
? stateOpacity.disabled
: stateOpacity.enabled;
+ const useSelected = Boolean(selected) && !disabled;
+
+ const titleColor: ColorValue = useSelected
+ ? theme.colors[roles.selectedContent]
+ : theme.colors[roles.label];
+
+ const iconColor: ColorValue = useSelected
+ ? theme.colors[roles.selectedContent]
+ : theme.colors[roles.icon];
+
+ const supportingColor: ColorValue = useSelected
+ ? theme.colors[roles.selectedContent]
+ : theme.colors[roles.supporting];
+
+ const containerColor: ColorValue | undefined = useSelected
+ ? theme.colors[roles.selectedContainer]
+ : undefined;
+
return {
- titleColor: getTitleColor({ theme, disabled }),
- iconColor: getIconColor({ theme, disabled }),
+ titleColor,
+ iconColor,
+ supportingColor,
+ containerColor,
contentOpacity,
};
};
+/**
+ * Resolve the menu surface background.
+ * Standard: MD3 `surfaceContainerLow` (not elevation.levelN — those map to
+ * different surfaceContainer* tones in this theme; elevation drives shadow only).
+ * Vibrant: tertiaryContainer.
+ */
+export const getMenuContainerColor = ({
+ theme,
+ colorScheme = 'standard',
+}: {
+ theme: InternalTheme;
+ /** @deprecated Ignored for fill; elevation still sets Surface shadow. Kept for call-site compat. */
+ elevation?: 0 | 1 | 2 | 3 | 4 | 5;
+ colorScheme?: MenuColorScheme;
+}): ColorValue => {
+ if (colorScheme === 'vibrant') {
+ return theme.colors[MenuTokens.vibrantColors.container];
+ }
+ return theme.colors[MenuTokens.standardColors.container];
+};
+
export const getContentMaxWidth = ({
iconWidth,
leadingIcon,
trailingIcon,
+ hasTrailingSupportingText,
}: ContentProps) => {
- if (leadingIcon && trailingIcon) {
- return MAX_WIDTH - (2 * iconWidth + 24);
+ let reserved = 12;
+ if (leadingIcon) {
+ reserved += iconWidth + MenuTokens.sizes.iconLabelGap;
+ }
+ if (trailingIcon) {
+ reserved += iconWidth + 12;
}
+ if (hasTrailingSupportingText) {
+ // Trailing supporting text shares trailing space; reserve a modest slot.
+ reserved += 48;
+ }
+ return MAX_WIDTH - reserved;
+};
- if (leadingIcon || trailingIcon) {
- return MAX_WIDTH - (iconWidth + 24);
+/**
+ * Per-item corner radii for first / last / selected items.
+ * Spec: corner.medium on the relevant corners; selected uses medium all around.
+ */
+export const getMenuItemBorderRadius = ({
+ theme,
+ selected,
+ roundedTop,
+ roundedBottom,
+}: {
+ theme: InternalTheme;
+ selected?: boolean;
+ roundedTop?: boolean;
+ roundedBottom?: boolean;
+}): ViewStyle => {
+ const medium = resolveCornerRadius(theme, MenuTokens.shapes.item);
+
+ if (selected) {
+ return { borderRadius: medium };
}
- return MAX_WIDTH - 12;
+ return {
+ borderTopLeftRadius: roundedTop ? medium : 0,
+ borderTopRightRadius: roundedTop ? medium : 0,
+ borderBottomLeftRadius: roundedBottom ? medium : 0,
+ borderBottomRightRadius: roundedBottom ? medium : 0,
+ };
};
+
+export const getMenuContainerBorderRadius = (theme: InternalTheme): number =>
+ resolveCornerRadius(theme, MenuTokens.shapes.container);
diff --git a/src/components/__tests__/Menu.test.tsx b/src/components/__tests__/Menu.test.tsx
index 128fb87cdb..97953bb3af 100644
--- a/src/components/__tests__/Menu.test.tsx
+++ b/src/components/__tests__/Menu.test.tsx
@@ -3,10 +3,12 @@ import { Animated, Dimensions, StyleSheet, View } from 'react-native';
import { expect, it, jest } from '@jest/globals';
import { act, screen, waitFor } from '@testing-library/react-native';
+import PaperProvider from '../../core/PaperProvider';
import { getTheme } from '../../core/theming';
import { render } from '../../test-utils';
import type { Elevation } from '../../types';
import Button from '../Button/Button';
+import Divider from '../Divider';
import Menu from '../Menu/Menu';
import Portal from '../Portal/Portal';
@@ -76,7 +78,7 @@ it('renders menu with content styles', async () => {
});
([0, 1, 2, 3, 4, 5] as Elevation[]).forEach((elevation) =>
- it(`renders menu with background color based on elevation value = ${elevation}`, async () => {
+ it(`uses surfaceContainerLow fill independent of elevation value = ${elevation}`, async () => {
const theme = getTheme();
await render(
@@ -93,12 +95,107 @@ it('renders menu with content styles', async () => {
);
+ // C1: MD3 menu fill is surfaceContainerLow, not elevation.levelN
+ // (level2 is surfaceContainer tones in this theme — a different color).
expect(screen.getByTestId('menu-surface')).toHaveStyle({
- backgroundColor: theme.colors.elevation[`level${elevation}`],
+ backgroundColor: theme.colors.surfaceContainerLow,
});
+ expect(theme.colors.surfaceContainerLow).not.toBe(
+ theme.colors.elevation.level2
+ );
})
);
+it('uses corner.large for the menu surface', async () => {
+ const theme = getTheme();
+
+ await render(
+
+
+
+ );
+
+ expect(screen.getByTestId('menu-surface')).toHaveStyle({
+ borderRadius: theme.shapes.corner.large,
+ });
+});
+
+it('uses tertiaryContainer for vibrant color scheme', async () => {
+ const theme = getTheme();
+
+ await render(
+
+
+
+ );
+
+ expect(screen.getByTestId('menu-surface')).toHaveStyle({
+ backgroundColor: theme.colors.tertiaryContainer,
+ });
+});
+
+it('applies first/last medium corners via layout context (no cloneElement props required)', async () => {
+ const theme = getTheme();
+ const radius = theme.shapes.corner.medium;
+
+ await render(
+
+
+
+ );
+
+ expect(screen.getByTestId('first-item')).toHaveStyle({
+ borderTopLeftRadius: radius,
+ borderTopRightRadius: radius,
+ });
+ expect(screen.getByTestId('last-item')).toHaveStyle({
+ borderBottomLeftRadius: radius,
+ borderBottomRightRadius: radius,
+ });
+ // Middle item is not fully rounded on all corners
+ expect(screen.getByTestId('mid-item')).not.toHaveStyle({
+ borderRadius: radius,
+ });
+});
+
+it('still renders Divider between items', async () => {
+ await render(
+
+
+
+ );
+ expect(screen.getByTestId('menu-divider')).toBeOnTheScreen();
+});
+
it('uses the default anchorPosition of top', async () => {
const dimensionsSpy = jest.spyOn(Dimensions, 'get').mockReturnValue({
width: 400,
@@ -208,26 +305,89 @@ it('respects anchorPosition bottom', async () => {
dimensionsSpy.mockRestore();
});
-it('animated value changes correctly', async () => {
- const value = new Animated.Value(1);
- await render(
+it('snaps open without spring when reduce-motion is enabled', async () => {
+ const dimensionsSpy = jest.spyOn(Dimensions, 'get').mockReturnValue({
+ width: 400,
+ height: 800,
+ scale: 2,
+ fontScale: 2,
+ });
+ const measureSpy = jest
+ .spyOn(View.prototype, 'measureInWindow')
+ .mockImplementation((fn) => fn(100, 100, 80, 32));
+
+ function makeMenu(visible: boolean) {
+ // PaperProvider reduceMotion="on" is the real product path for reduce-motion.
+ return (
+
+
+
+ );
+ }
+
+ const { rerender } = await render(makeMenu(false));
+
+ await act(async () => {
+ await rerender(makeMenu(true));
+ await Promise.resolve();
+ jest.runOnlyPendingTimers();
+ await Promise.resolve();
+ });
+
+ // Reduce-motion path must still position the menu and mount the surface.
+ await waitFor(() => {
+ expect(screen.getByTestId('menu-view')).toHaveStyle({
+ position: 'absolute',
+ left: 100,
+ top: 100,
+ });
+ });
+
+ expect(screen.getByTestId('menu-surface')).toBeOnTheScreen();
+
+ measureSpy.mockRestore();
+ dimensionsSpy.mockRestore();
+});
+
+it('applies animated contentStyle transform on the menu surface', async () => {
+ // Drive the real Menu + contentStyle path. Animated host-style updates from
+ // setValue are not reliably visible via toHaveStyle under the RN Jest
+ // environment (same limitation as other Surface consumers), so we re-render
+ // with a new Animated.Value after the driver advances the first value.
+ const initial = new Animated.Value(1);
+ const advanced = new Animated.Value(1);
+
+ const makeUi = (scale: Animated.Value) => (
);
+
+ const { rerender } = await render(makeUi(initial));
expect(screen.getByTestId('menu-surface-outer-layer')).toHaveStyle({
transform: [{ scale: 1 }],
});
- Animated.timing(value, {
+ Animated.timing(advanced, {
toValue: 1.5,
useNativeDriver: false,
duration: 200,
@@ -236,6 +396,18 @@ it('animated value changes correctly', async () => {
await act(() => {
jest.advanceTimersByTime(200);
});
+
+ // Animation driver (jest Animated.timing stub) must update the value.
+ expect(
+ (advanced as Animated.Value & { __getValue: () => number }).__getValue()
+ ).toBe(1.5);
+
+ // Re-mount contentStyle with the advanced value so Surface's render-time
+ // flatten reflects 1.5 on the real menu surface outer layer.
+ await act(async () => {
+ await rerender(makeUi(advanced));
+ });
+
expect(screen.getByTestId('menu-surface-outer-layer')).toHaveStyle({
transform: [{ scale: 1.5 }],
});
diff --git a/src/components/__tests__/MenuItem.test.tsx b/src/components/__tests__/MenuItem.test.tsx
index ba66e705a7..76f6dab77b 100644
--- a/src/components/__tests__/MenuItem.test.tsx
+++ b/src/components/__tests__/MenuItem.test.tsx
@@ -1,10 +1,20 @@
-import { describe, expect, it } from '@jest/globals';
+import { Animated } from 'react-native';
+
+import { describe, expect, it, jest } from '@jest/globals';
import { getTheme } from '../../core/theming';
import { render, screen } from '../../test-utils';
import { tokens } from '../../theme/tokens';
+import { resolveCornerRadius } from '../../theme/utils/shape';
import Menu from '../Menu/Menu';
-import { getMenuItemColor } from '../Menu/utils';
+import { runMenuCloseMotion, runMenuOpenMotion } from '../Menu/motion';
+import { MenuTokens } from '../Menu/tokens';
+import {
+ getMenuContainerBorderRadius,
+ getMenuContainerColor,
+ getMenuItemBorderRadius,
+ getMenuItemColor,
+} from '../Menu/utils';
const stateOpacity = tokens.md.sys.state.opacity;
@@ -61,6 +71,123 @@ describe('Menu Item', () => {
expect.objectContaining({ checked: true })
);
});
+
+ it('uses labelLarge for the title', async () => {
+ await render();
+
+ expect(screen.getByTestId('menu-item-title')).toHaveStyle(
+ getTheme().fonts.labelLarge
+ );
+ });
+
+ it('renders supporting and trailing supporting text', async () => {
+ const theme = getTheme();
+ await render(
+
+ );
+
+ expect(screen.getByTestId('menu-item-supporting')).toHaveTextContent(
+ 'Send a link'
+ );
+ expect(
+ screen.getByTestId('menu-item-trailing-supporting')
+ ).toHaveTextContent('⌘S');
+ // two-line anatomy must not use fixed 48 height (would clip supporting)
+ expect(screen.getByTestId('menu-item')).toHaveStyle({
+ minHeight: 48,
+ paddingVertical: 8,
+ });
+ // B2: supporting uses bodySmall + onSurfaceVariant
+ expect(screen.getByTestId('menu-item-supporting')).toHaveStyle({
+ ...theme.fonts.bodySmall,
+ color: theme.colors.onSurfaceVariant,
+ });
+ // B3: trailing supporting uses labelLarge
+ expect(screen.getByTestId('menu-item-trailing-supporting')).toHaveStyle({
+ ...theme.fonts.labelLarge,
+ color: theme.colors.onSurfaceVariant,
+ });
+ // Supporting + trailing content is user-visible; explicit aria-label stays on the menuitem
+ expect(screen.getByText('Send a link')).toBeOnTheScreen();
+ expect(screen.getByText('⌘S')).toBeOnTheScreen();
+ expect(
+ screen.getByRole('menuitem', {
+ name: 'Share, send a link, keyboard shortcut Command S',
+ })
+ ).toBeOnTheScreen();
+ });
+
+ it('keeps fixed height for single-line items', async () => {
+ await render();
+ expect(screen.getByTestId('menu-item')).toHaveStyle({ height: 48 });
+ });
+
+ it('uses dense item height of 32 when dense', async () => {
+ await render();
+ expect(screen.getByTestId('menu-item')).toHaveStyle({
+ height: MenuTokens.sizes.denseItemHeight,
+ minHeight: MenuTokens.sizes.denseItemHeight,
+ });
+ expect(MenuTokens.sizes.denseItemHeight).toBe(32);
+ });
+
+ it('applies selected colors and aria-selected', async () => {
+ const theme = getTheme();
+ await render();
+
+ expect(screen.getByRole('menuitem')).toHaveProp(
+ 'accessibilityState',
+ expect.objectContaining({ selected: true })
+ );
+ expect(screen.getByTestId('menu-item')).toHaveStyle({
+ backgroundColor: theme.colors.tertiaryContainer,
+ });
+ expect(screen.getByTestId('menu-item-title')).toHaveStyle({
+ color: theme.colors.onTertiaryContainer,
+ });
+ });
+
+ it('disabled wins over selected for content opacity', async () => {
+ await render();
+
+ expect(screen.getByRole('menuitem')).toHaveProp(
+ 'accessibilityState',
+ expect.objectContaining({ disabled: true })
+ );
+ // selected container is suppressed when disabled
+ expect(screen.getByTestId('menu-item')).not.toHaveStyle({
+ backgroundColor: getTheme().colors.tertiaryContainer,
+ });
+ // I3: rendered content row must apply disabled opacity from the real path
+ expect(screen.getByTestId('menu-item-content')).toHaveStyle({
+ opacity: stateOpacity.disabled,
+ });
+ });
+
+ it('applies rendered disabled opacity on the content row', async () => {
+ await render();
+ expect(screen.getByTestId('menu-item-content')).toHaveStyle({
+ opacity: stateOpacity.disabled,
+ });
+ });
+
+ it('applies corner.medium on all sides when selected', async () => {
+ const theme = getTheme();
+ const radius = resolveCornerRadius(theme, MenuTokens.shapes.item);
+
+ await render(
+
+ );
+
+ expect(screen.getByTestId('menu-item')).toHaveStyle({
+ borderRadius: radius,
+ });
+ });
});
describe('getMenuItemColor - title color', () => {
@@ -85,6 +212,36 @@ describe('getMenuItemColor - title color', () => {
titleColor: getTheme().colors.onSurface,
});
});
+
+ it('returns selected tertiary roles when selected', () => {
+ const theme = getTheme();
+ expect(
+ getMenuItemColor({
+ theme,
+ selected: true,
+ })
+ ).toMatchObject({
+ titleColor: theme.colors.onTertiaryContainer,
+ iconColor: theme.colors.onTertiaryContainer,
+ containerColor: theme.colors.tertiaryContainer,
+ contentOpacity: stateOpacity.enabled,
+ });
+ });
+
+ it('ignores selected colors when disabled', () => {
+ const theme = getTheme();
+ expect(
+ getMenuItemColor({
+ theme,
+ selected: true,
+ disabled: true,
+ })
+ ).toMatchObject({
+ titleColor: theme.colors.onSurface,
+ containerColor: undefined,
+ contentOpacity: stateOpacity.disabled,
+ });
+ });
});
describe('getMenuItemColor - icon color', () => {
@@ -110,3 +267,138 @@ describe('getMenuItemColor - icon color', () => {
});
});
});
+
+describe('Menu shape tokens', () => {
+ it('resolves container border radius to corner.large', () => {
+ const theme = getTheme();
+ expect(getMenuContainerBorderRadius(theme)).toBe(
+ resolveCornerRadius(theme, MenuTokens.shapes.container)
+ );
+ expect(getMenuContainerBorderRadius(theme)).toBe(theme.shapes.corner.large);
+ });
+
+ it('resolves standard container fill to surfaceContainerLow (C1)', () => {
+ const theme = getTheme();
+ expect(getMenuContainerColor({ theme, elevation: 2 })).toBe(
+ theme.colors.surfaceContainerLow
+ );
+ expect(getMenuContainerColor({ theme, elevation: 2 })).not.toBe(
+ theme.colors.elevation.level2
+ );
+ expect(MenuTokens.standardColors.container).toBe('surfaceContainerLow');
+ });
+
+ it('applies corner.medium on selected items', () => {
+ const theme = getTheme();
+ const radius = resolveCornerRadius(theme, MenuTokens.shapes.item);
+ expect(getMenuItemBorderRadius({ theme, selected: true })).toMatchObject({
+ borderRadius: radius,
+ });
+ });
+
+ it('applies top/bottom corner.medium for first/last items', () => {
+ const theme = getTheme();
+ const radius = resolveCornerRadius(theme, MenuTokens.shapes.item);
+ expect(getMenuItemBorderRadius({ theme, roundedTop: true })).toMatchObject({
+ borderTopLeftRadius: radius,
+ borderTopRightRadius: radius,
+ borderBottomLeftRadius: 0,
+ borderBottomRightRadius: 0,
+ });
+ expect(
+ getMenuItemBorderRadius({ theme, roundedBottom: true })
+ ).toMatchObject({
+ borderBottomLeftRadius: radius,
+ borderBottomRightRadius: radius,
+ });
+ });
+
+ it('uses full corner.medium when selected even if only roundedTop was set', () => {
+ const theme = getTheme();
+ const radius = resolveCornerRadius(theme, MenuTokens.shapes.item);
+ expect(
+ getMenuItemBorderRadius({
+ theme,
+ selected: true,
+ roundedTop: true,
+ roundedBottom: false,
+ })
+ ).toMatchObject({ borderRadius: radius });
+ });
+});
+
+describe('Menu open/close motion (I6 / D2)', () => {
+ it('snaps scale and opacity when reduce-motion is enabled', () => {
+ const theme = getTheme();
+ const scale = new Animated.ValueXY({ x: 0, y: 0 });
+ const opacity = new Animated.Value(0);
+ const springSpy = jest.spyOn(Animated, 'spring');
+ const finish = jest.fn();
+
+ const path = runMenuOpenMotion({
+ reduceMotion: true,
+ scaleAnimation: scale,
+ opacityAnimation: opacity,
+ menuWidth: 200,
+ menuHeight: 100,
+ theme,
+ onFinish: finish,
+ });
+
+ expect(path).toBe('snap');
+ expect(springSpy).not.toHaveBeenCalled();
+ expect(finish).toHaveBeenCalled();
+ // Concrete snap values on the real Animated drivers Menu uses
+
+ expect((opacity as unknown as { _value: number })._value).toBe(1);
+
+ expect((scale.x as unknown as { _value: number })._value).toBe(200);
+
+ expect((scale.y as unknown as { _value: number })._value).toBe(100);
+
+ springSpy.mockRestore();
+ });
+
+ it('uses spring when reduce-motion is off', () => {
+ const theme = getTheme();
+ const scale = new Animated.ValueXY({ x: 0, y: 0 });
+ const opacity = new Animated.Value(0);
+ const springSpy = jest.spyOn(Animated, 'spring');
+ const finish = jest.fn();
+
+ const path = runMenuOpenMotion({
+ reduceMotion: false,
+ scaleAnimation: scale,
+ opacityAnimation: opacity,
+ menuWidth: 200,
+ menuHeight: 100,
+ theme,
+ onFinish: finish,
+ });
+
+ expect(path).toBe('spring');
+ expect(springSpy).toHaveBeenCalled();
+ springSpy.mockRestore();
+ });
+
+ it('snaps opacity to 0 on close when reduce-motion is enabled', () => {
+ const theme = getTheme();
+ const opacity = new Animated.Value(1);
+ const springSpy = jest.spyOn(Animated, 'spring');
+ const finish = jest.fn();
+
+ const path = runMenuCloseMotion({
+ reduceMotion: true,
+ opacityAnimation: opacity,
+ theme,
+ onFinish: finish,
+ });
+
+ expect(path).toBe('snap');
+ expect(springSpy).not.toHaveBeenCalled();
+ expect(finish).toHaveBeenCalled();
+
+ expect((opacity as unknown as { _value: number })._value).toBe(0);
+ springSpy.mockRestore();
+ });
+});
diff --git a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap
index 11d5aea821..1d1797e580 100644
--- a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap
@@ -262,8 +262,8 @@ exports[`renders menu with content styles 1`] = `
collapsable={false}
style={
{
- "backgroundColor": "rgba(243, 237, 247, 1)",
- "borderRadius": 4,
+ "backgroundColor": "rgba(247, 242, 250, 1)",
+ "borderRadius": 16,
"borderTopLeftRadius": 0,
"borderTopRightRadius": 0,
"opacity": 0,
@@ -291,8 +291,8 @@ exports[`renders menu with content styles 1`] = `
pointerEvents="box-none"
style={
{
- "backgroundColor": "rgba(243, 237, 247, 1)",
- "borderRadius": 4,
+ "backgroundColor": "rgba(247, 242, 250, 1)",
+ "borderRadius": 16,
"borderTopLeftRadius": 0,
"borderTopRightRadius": 0,
"flex": undefined,
@@ -344,15 +344,25 @@ exports[`renders menu with content styles 1`] = `
false,
[
{
- "height": 48,
"justifyContent": "center",
"maxWidth": 280,
"minWidth": 112,
+ "overflow": "hidden",
},
{
+ "height": 48,
+ "maxWidth": 280,
+ "minHeight": 48,
+ "minWidth": 112,
"paddingHorizontal": 12,
},
- undefined,
+ {
+ "borderBottomLeftRadius": 0,
+ "borderBottomRightRadius": 0,
+ "borderTopLeftRadius": 12,
+ "borderTopRightRadius": 12,
+ },
+ null,
undefined,
],
]
@@ -363,7 +373,10 @@ exports[`renders menu with content styles 1`] = `
style={
[
{
+ "alignItems": "center",
+ "alignSelf": "flex-start",
"flexDirection": "row",
+ "maxWidth": 256,
},
{
"opacity": 1,
@@ -371,17 +384,19 @@ exports[`renders menu with content styles 1`] = `
undefined,
]
}
+ testID="menu-item-content"
>
@@ -111,11 +128,12 @@ exports[`Menu Item renders menu item 1`] = `
style={
[
{
+ "flexGrow": 0,
+ "flexShrink": 1,
"justifyContent": "center",
},
{
"maxWidth": 232,
- "minWidth": 100,
},
{
"marginLeft": 12,
@@ -140,19 +158,19 @@ exports[`Menu Item renders menu item 1`] = `
[
{
"fontFamily": "System",
- "fontSize": 16,
- "fontWeight": "400",
- "letterSpacing": 0.5,
- "lineHeight": 24,
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 0.1,
+ "lineHeight": 20,
},
[
{
"color": "rgba(29, 27, 32, 1)",
"fontFamily": "System",
- "fontSize": 16,
- "fontWeight": "400",
- "letterSpacing": 0.5,
- "lineHeight": 24,
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 0.1,
+ "lineHeight": 20,
},
undefined,
],
@@ -202,15 +220,25 @@ exports[`Menu Item renders menu item 1`] = `
false,
[
{
- "height": 48,
"justifyContent": "center",
"maxWidth": 280,
"minWidth": 112,
+ "overflow": "hidden",
},
{
+ "height": 48,
+ "maxWidth": 280,
+ "minHeight": 48,
+ "minWidth": 112,
"paddingHorizontal": 12,
},
- undefined,
+ {
+ "borderBottomLeftRadius": 0,
+ "borderBottomRightRadius": 0,
+ "borderTopLeftRadius": 0,
+ "borderTopRightRadius": 0,
+ },
+ null,
undefined,
],
]
@@ -221,7 +249,10 @@ exports[`Menu Item renders menu item 1`] = `
style={
[
{
+ "alignItems": "center",
+ "alignSelf": "flex-start",
"flexDirection": "row",
+ "maxWidth": 256,
},
{
"opacity": 1,
@@ -229,6 +260,7 @@ exports[`Menu Item renders menu item 1`] = `
undefined,
]
}
+ testID="menu-item-content"
>
@@ -275,11 +310,12 @@ exports[`Menu Item renders menu item 1`] = `
style={
[
{
+ "flexGrow": 0,
+ "flexShrink": 1,
"justifyContent": "center",
},
{
"maxWidth": 232,
- "minWidth": 100,
},
{
"marginLeft": 12,
@@ -304,19 +340,19 @@ exports[`Menu Item renders menu item 1`] = `
[
{
"fontFamily": "System",
- "fontSize": 16,
- "fontWeight": "400",
- "letterSpacing": 0.5,
- "lineHeight": 24,
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 0.1,
+ "lineHeight": 20,
},
[
{
"color": "rgba(29, 27, 32, 1)",
"fontFamily": "System",
- "fontSize": 16,
- "fontWeight": "400",
- "letterSpacing": 0.5,
- "lineHeight": 24,
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 0.1,
+ "lineHeight": 20,
},
undefined,
],
@@ -366,15 +402,25 @@ exports[`Menu Item renders menu item 1`] = `
false,
[
{
- "height": 48,
"justifyContent": "center",
"maxWidth": 280,
"minWidth": 112,
+ "overflow": "hidden",
},
{
+ "height": 48,
+ "maxWidth": 280,
+ "minHeight": 48,
+ "minWidth": 112,
"paddingHorizontal": 12,
},
- undefined,
+ {
+ "borderBottomLeftRadius": 0,
+ "borderBottomRightRadius": 0,
+ "borderTopLeftRadius": 0,
+ "borderTopRightRadius": 0,
+ },
+ null,
undefined,
],
]
@@ -385,7 +431,10 @@ exports[`Menu Item renders menu item 1`] = `
style={
[
{
+ "alignItems": "center",
+ "alignSelf": "flex-start",
"flexDirection": "row",
+ "maxWidth": 256,
},
{
"opacity": 0.38,
@@ -393,6 +442,7 @@ exports[`Menu Item renders menu item 1`] = `
undefined,
]
}
+ testID="menu-item-content"
>
@@ -439,11 +492,12 @@ exports[`Menu Item renders menu item 1`] = `
style={
[
{
+ "flexGrow": 0,
+ "flexShrink": 1,
"justifyContent": "center",
},
{
"maxWidth": 232,
- "minWidth": 100,
},
{
"marginLeft": 12,
@@ -468,19 +522,19 @@ exports[`Menu Item renders menu item 1`] = `
[
{
"fontFamily": "System",
- "fontSize": 16,
- "fontWeight": "400",
- "letterSpacing": 0.5,
- "lineHeight": 24,
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 0.1,
+ "lineHeight": 20,
},
[
{
"color": "rgba(29, 27, 32, 1)",
"fontFamily": "System",
- "fontSize": 16,
- "fontWeight": "400",
- "letterSpacing": 0.5,
- "lineHeight": 24,
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 0.1,
+ "lineHeight": 20,
},
undefined,
],
@@ -530,15 +584,25 @@ exports[`Menu Item renders menu item 1`] = `
false,
[
{
- "height": 48,
"justifyContent": "center",
"maxWidth": 280,
"minWidth": 112,
+ "overflow": "hidden",
},
{
+ "height": 48,
+ "maxWidth": 280,
+ "minHeight": 48,
+ "minWidth": 112,
"paddingHorizontal": 12,
},
- undefined,
+ {
+ "borderBottomLeftRadius": 0,
+ "borderBottomRightRadius": 0,
+ "borderTopLeftRadius": 0,
+ "borderTopRightRadius": 0,
+ },
+ null,
undefined,
],
]
@@ -549,7 +613,10 @@ exports[`Menu Item renders menu item 1`] = `
style={
[
{
+ "alignItems": "center",
+ "alignSelf": "flex-start",
"flexDirection": "row",
+ "maxWidth": 256,
},
{
"opacity": 0.38,
@@ -557,6 +624,7 @@ exports[`Menu Item renders menu item 1`] = `
undefined,
]
}
+ testID="menu-item-content"
>
@@ -603,11 +674,12 @@ exports[`Menu Item renders menu item 1`] = `
style={
[
{
+ "flexGrow": 0,
+ "flexShrink": 1,
"justifyContent": "center",
},
{
"maxWidth": 232,
- "minWidth": 100,
},
{
"marginLeft": 12,
@@ -632,19 +704,19 @@ exports[`Menu Item renders menu item 1`] = `
[
{
"fontFamily": "System",
- "fontSize": 16,
- "fontWeight": "400",
- "letterSpacing": 0.5,
- "lineHeight": 24,
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 0.1,
+ "lineHeight": 20,
},
[
{
"color": "rgba(29, 27, 32, 1)",
"fontFamily": "System",
- "fontSize": 16,
- "fontWeight": "400",
- "letterSpacing": 0.5,
- "lineHeight": 24,
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 0.1,
+ "lineHeight": 20,
},
undefined,
],
@@ -694,15 +766,25 @@ exports[`Menu Item renders menu item 1`] = `
false,
[
{
- "height": 48,
"justifyContent": "center",
"maxWidth": 280,
"minWidth": 112,
+ "overflow": "hidden",
},
{
+ "height": 48,
+ "maxWidth": 280,
+ "minHeight": 48,
+ "minWidth": 112,
"paddingHorizontal": 12,
},
- undefined,
+ {
+ "borderBottomLeftRadius": 0,
+ "borderBottomRightRadius": 0,
+ "borderTopLeftRadius": 0,
+ "borderTopRightRadius": 0,
+ },
+ null,
undefined,
],
]
@@ -713,7 +795,10 @@ exports[`Menu Item renders menu item 1`] = `
style={
[
{
+ "alignItems": "center",
+ "alignSelf": "flex-start",
"flexDirection": "row",
+ "maxWidth": 256,
},
{
"opacity": 1,
@@ -721,17 +806,19 @@ exports[`Menu Item renders menu item 1`] = `
undefined,
]
}
+ testID="menu-item-content"
>