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 /> + + {}} + colorScheme="vibrant" + anchor={}> + {}} title="Featured" selected /> @@ -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 Show menu}>\n {}} title=\"Item 1\" />\n {}} title=\"Item 2\" />\n \n {}} title=\"Item 3\" />\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 Show menu}>\n {}}\n title=\"Paste\"\n supportingText=\"Insert clipboard\"\n trailingSupportingText=\"⌘V\"\n selected\n />\n {}} title=\"Undo\" />\n \n {}} title=\"Share\" dense />\n \n {}}\n colorScheme=\"vibrant\"\n anchor={}>\n {}} title=\"Featured\" selected />\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 Show menu}>\n {}} title=\"Item 1\" />\n {}} title=\"Item 2\" />\n \n {}} title=\"Item 3\" />\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 Show menu}>\n {}}\n title=\"Paste\"\n supportingText=\"Insert clipboard\"\n trailingSupportingText=\"⌘V\"\n selected\n />\n {}} title=\"Undo\" />\n \n {}} title=\"Share\" dense />\n \n {}}\n colorScheme=\"vibrant\"\n anchor={}>\n {}} title=\"Featured\" selected />\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 /> + + + + + + Vibrant color scheme + + } + > + {}} title="Featured" /> + {}} + title="Favorite" + selected + /> + {}} + title="Muted" + 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 /> + * + * {}} + * colorScheme="vibrant" + * anchor={}> + * {}} title="Featured" selected /> * * * @@ -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( + + Open menu} + > + + + + ); + + expect(screen.getByTestId('menu-surface')).toHaveStyle({ + borderRadius: theme.shapes.corner.large, + }); +}); + +it('uses tertiaryContainer for vibrant color scheme', async () => { + const theme = getTheme(); + + await render( + + Open menu} + > + + + + ); + + 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( + + Open menu} + > + + + + + + ); + + 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( + + Open menu} + > + + + + + + ); + 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 ( + + + Open menu + + } + testID="menu" + > + + + + ); + } + + 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) => ( Open menu} testID="menu" - contentStyle={[{ transform: [{ scale: value }] }]} + contentStyle={[{ transform: [{ scale }] }]} > ); + + 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" >