diff --git a/docs/6.x/docs/components/FAB/FABMenu.mdx b/docs/6.x/docs/components/FAB/FABMenu.mdx index 4d460f0fc4..25b21e327b 100644 --- a/docs/6.x/docs/components/FAB/FABMenu.mdx +++ b/docs/6.x/docs/components/FAB/FABMenu.mdx @@ -1,5 +1,5 @@ --- -title: Menu +title: FAB.Menu --- import PropTable from '@docs/components/PropTable.tsx'; @@ -18,9 +18,7 @@ via the close button or by tapping an item. - - - + ## Usage ```tsx @@ -113,10 +111,6 @@ 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..40c41f55f9 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
@@ -107,6 +166,22 @@ export default MyComponent;
+### supportingTextMaxFontSizeMultiplier + +
+ + + +
+ +### trailingSupportingTextMaxFontSizeMultiplier + +
+ + + +
+ ### style
@@ -139,6 +214,22 @@ export default MyComponent;
+### supportingTextStyle + +
+ + + +
+ +### trailingSupportingTextStyle + +
+ + + +
+ ### theme
@@ -208,7 +299,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 300c315b99..a3cda39243 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -6386,12 +6386,12 @@ }, "FAB/Menu": { "filepath": "FAB/Menu.tsx", - "title": "Menu", + "title": "FAB.Menu", "description": "Floating action button menu. Wraps a trigger FAB; when `expanded` is true,\nitems appear stacked above and the trigger morphs into the spec'd close\nbutton (`shape: 'full'`, 56 dp, saturated role color).\n\nNo visual backdrop and no outside-tap dismiss — that matches the MD3 spec\nand lets the user keep interacting with the content underneath. Dismiss\nvia the close button or by tapping an item.\n\n## Usage\n```tsx\nconst [open, setOpen] = React.useState(false);\n\n\n setOpen(false)}\n trigger={{ icon: 'plus', variant: 'primary', onPress: () => setOpen(true) }}\n items={[\n { icon: 'email', label: 'Send', onPress: () => {} },\n { icon: 'bell', label: 'Remind', onPress: () => {} },\n ]}\n />\n\n```", - "link": "menu", + "link": "fab-menu", "data": { "description": "Floating action button menu. Wraps a trigger FAB; when `expanded` is true,\nitems appear stacked above and the trigger morphs into the spec'd close\nbutton (`shape: 'full'`, 56 dp, saturated role color).\n\nNo visual backdrop and no outside-tap dismiss — that matches the MD3 spec\nand lets the user keep interacting with the content underneath. Dismiss\nvia the close button or by tapping an item.\n\n## Usage\n```tsx\nconst [open, setOpen] = React.useState(false);\n\n\n setOpen(false)}\n trigger={{ icon: 'plus', variant: 'primary', onPress: () => setOpen(true) }}\n items={[\n { icon: 'email', label: 'Send', onPress: () => {} },\n { icon: 'bell', label: 'Remind', onPress: () => {} },\n ]}\n />\n\n```", - "displayName": "Menu", + "displayName": "FAB.Menu", "methods": [], "statics": [], "props": { @@ -7788,7 +7788,8 @@ "type": "component", "dependencies": [ "src/components/FAB/Menu.tsx" - ] + ], + "group": "FAB" }, "IconButton/IconButton": { "filepath": "IconButton/IconButton.tsx", @@ -8945,10 +8946,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": [], @@ -9093,10 +9094,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": { @@ -9115,12 +9116,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": { @@ -9157,10 +9169,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": [], @@ -9173,6 +9185,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": { @@ -9187,6 +9215,20 @@ }, "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.\n\nOn web this also marks the item as `menuitemradio`, so assistive tech\nannounces it as one of a set. Pass `selected={false}` on the other items\nin the group so they share the role." + }, + "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": { @@ -9201,6 +9243,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": { @@ -9241,6 +9297,28 @@ "computed": false } }, + "supportingTextMaxFontSizeMultiplier": { + "required": false, + "tsType": { + "name": "number" + }, + "description": "Specifies the largest possible scale the supporting text font can reach.", + "defaultValue": { + "value": "1.5", + "computed": false + } + }, + "trailingSupportingTextMaxFontSizeMultiplier": { + "required": false, + "tsType": { + "name": "number" + }, + "description": "Specifies the largest possible scale the trailing supporting text font can reach.", + "defaultValue": { + "value": "1.5", + "computed": false + } + }, "style": { "required": false, "tsType": { @@ -9278,7 +9356,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, @@ -9293,6 +9371,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": { @@ -9341,14 +9445,14 @@ } ] }, - "description": "Indicates whether the element is checked. Accepts `true`, `false`,\nor `'mixed'` for an indeterminate state." + "description": "Indicates whether the element is checked. Accepts `true`, `false`,\nor `'mixed'` for an indeterminate state.\n\nOn web this marks the item as `menuitemcheckbox`, for options that toggle\nindependently rather than forming a single-choice group." }, "aria-selected": { "required": false, "tsType": { "name": "boolean" }, - "description": "Indicates whether the element is selected." + "description": "Indicates whether the element is selected. Prefer `selected`, which maps to\n`aria-checked` — the state assistive tech reads on a menu item." }, "aria-busy": { "required": false, 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/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/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx index 58a75291cf..8bb64fb5df 100644 --- a/src/components/FAB/Menu.tsx +++ b/src/components/FAB/Menu.tsx @@ -654,7 +654,7 @@ const Menu = ({ ); }; -Menu.displayName = 'Menu'; +Menu.displayName = 'FAB.Menu'; const styles = StyleSheet.create({ container: { diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index ec4c256dad..b1df442e89 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; import { - Animated, Dimensions, - Easing, Keyboard, Platform, ScrollView, @@ -10,7 +8,7 @@ import { View, Pressable, } from 'react-native'; -import type { KeyboardEvent as RNKeyboardEvent } from 'react-native'; +import type { Animated, KeyboardEvent as RNKeyboardEvent } from 'react-native'; import type { EmitterSubscription, LayoutRectangle, @@ -20,17 +18,31 @@ import type { ViewStyle, } from 'react-native'; +import Reanimated, { + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { MenuItemPositionContext, 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, ThemeProp } from '../../types'; import { addEventListener } from '../../utils/addEventListener'; import { BackHandler } from '../../utils/BackHandler/BackHandler'; import Portal from '../Portal/Portal'; import Surface from '../Surface'; +const isMenuItemElement = ( + child: React.ReactNode +): child is React.ReactElement> => + React.isValidElement(child) && child.type === MenuItem; + export type Props = { /** * Whether the Menu is currently visible. @@ -70,18 +82,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 +118,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 +149,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 +178,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 +223,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; + const reduceMotion = useReduceMotion(); const insets = useSafeAreaInsets(); const [rendered, setRendered] = React.useState(visible); const [left, setLeft] = React.useState(0); @@ -209,8 +245,15 @@ const Menu = ({ height: WINDOW_LAYOUT.height, }); - const opacityAnimationRef = React.useRef(new Animated.Value(0)); - const scaleAnimationRef = React.useRef(new Animated.ValueXY({ x: 0, y: 0 })); + // `show` / `hide` are memoised and drive state, so they must not be + // invalidated on every render. Shared values are already stable in + // Reanimated; pinning them keeps that true under the Jest mock too. + const opacityValue = useSharedValue(0); + const scaleValue = useSharedValue(0); + const motion = React.useRef({ + opacity: opacityValue, + scale: scaleValue, + }).current; const keyboardHeightRef = React.useRef(0); const prevVisible = React.useRef(null); const anchorRef = React.useRef(null); @@ -345,44 +388,47 @@ 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, + scale: motion.scale, + opacity: motion.opacity, + theme, + onFinish: finish, }); }); - }, [anchor, attachListeners, measureAnchorLayout, theme]); + }, [ + anchor, + attachListeners, + measureAnchorLayout, + motion, + 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, + opacity: motion.opacity, + theme, + onFinish: finish, }); - }, [removeListeners, theme]); + }, [motion, reduceMotion, removeListeners, theme]); const updateVisibility = React.useCallback( async (display: boolean) => { @@ -403,8 +449,6 @@ const Menu = ({ ); React.useEffect(() => { - const opacityAnimation = opacityAnimationRef.current; - const scaleAnimation = scaleAnimationRef.current; keyboardDidShowListenerRef.current = Keyboard.addListener( 'keyboardDidShow', keyboardDidShow @@ -418,8 +462,6 @@ const Menu = ({ removeListeners(); keyboardDidShowListenerRef.current?.remove(); keyboardDidHideListenerRef.current?.remove(); - scaleAnimation.removeAllListeners(); - opacityAnimation?.removeAllListeners(); }; }, [removeListeners, keyboardDidHide, keyboardDidShow]); @@ -452,7 +494,8 @@ const Menu = ({ }); // We need to translate menu while animating scale to imitate transform origin for scale animation - const positionTransforms = []; + let translateXFrom = 0; + let translateYFrom = 0; let leftTransformation = left; let topTransformation = !isCoordinate(anchorRef.current) && anchorPosition === 'bottom' @@ -461,24 +504,14 @@ const Menu = ({ // Check if menu fits horizontally and if not align it to right. if (left <= windowLayout.width - menuLayout.width - SCREEN_INDENT) { - positionTransforms.push({ - translateX: scaleAnimationRef.current.x.interpolate({ - inputRange: [0, menuLayout.width], - outputRange: [-(menuLayout.width / 2), 0], - }), - }); + translateXFrom = -(menuLayout.width / 2); // Check if menu position has enough space from left side if (leftTransformation < SCREEN_INDENT) { leftTransformation = SCREEN_INDENT; } } else { - positionTransforms.push({ - translateX: scaleAnimationRef.current.x.interpolate({ - inputRange: [0, menuLayout.width], - outputRange: [menuLayout.width / 2, 0], - }), - }); + translateXFrom = menuLayout.width / 2; leftTransformation += anchorLayout.width - menuLayout.width; @@ -559,24 +592,14 @@ const Menu = ({ // And bottom side of the screen has more space than top side topTransformation <= windowLayout.height - topTransformation) ) { - positionTransforms.push({ - translateY: scaleAnimationRef.current.y.interpolate({ - inputRange: [0, menuLayout.height], - outputRange: [-((scrollableMenuHeight || menuLayout.height) / 2), 0], - }), - }); + translateYFrom = -((scrollableMenuHeight || menuLayout.height) / 2); // Check if menu position has enough space from top side if (topTransformation < SCREEN_INDENT) { topTransformation = SCREEN_INDENT; } } else { - positionTransforms.push({ - translateY: scaleAnimationRef.current.y.interpolate({ - inputRange: [0, menuLayout.height], - outputRange: [(scrollableMenuHeight || menuLayout.height) / 2, 0], - }), - }); + translateYFrom = (scrollableMenuHeight || menuLayout.height) / 2; topTransformation += anchorLayout.height - (scrollableMenuHeight || menuLayout.height); @@ -599,25 +622,22 @@ const Menu = ({ } const shadowMenuContainerStyle = { - opacity: opacityAnimationRef.current, - transform: [ - { - scaleX: scaleAnimationRef.current.x.interpolate({ - inputRange: [0, menuLayout.width], - outputRange: [0, 1], - }), - }, - { - scaleY: scaleAnimationRef.current.y.interpolate({ - inputRange: [0, menuLayout.height], - outputRange: [0, 1], - }), - }, - ], - borderRadius: theme.shapes.corner.extraSmall, + borderRadius: getMenuContainerBorderRadius(theme), ...(scrollableMenuHeight ? { height: scrollableMenuHeight } : {}), }; + const positionAnimatedStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: translateXFrom * (1 - scaleValue.value) }, + { translateY: translateYFrom * (1 - scaleValue.value) }, + ], + })); + + const surfaceAnimatedStyle = useAnimatedStyle(() => ({ + opacity: opacityValue.value, + transform: [{ scaleX: scaleValue.value }, { scaleY: scaleValue.value }], + })); + const positionStyle = { top: isCoordinate(anchor) ? topTransformation @@ -629,6 +649,40 @@ const Menu = ({ const pointerEvents = visible ? 'box-none' : 'none'; + const rootContext = React.useMemo(() => ({ colorScheme }), [colorScheme]); + + // Positional only, like `Card.Actions`. Nothing is injected into children — + // `colorScheme` comes from `MenuRootContext`, so wrappers keep working. + const items = React.Children.toArray(children); + const firstItemIndex = items.findIndex(isMenuItemElement); + const lastItemIndex = items.reduce( + (last, child, index) => (isMenuItemElement(child) ? index : last), + -1 + ); + const renderedChildren = items.map((child, index) => { + if (!isMenuItemElement(child)) { + return child; + } + + return ( + + {child} + + ); + }); + + const surfaceBackground = getMenuContainerColor({ + theme, + elevation, + colorScheme, + }); + return ( { @@ -657,37 +711,44 @@ const Menu = ({ onAccessibilityEscape={onDismiss} testID={`${testID}-view`} > - - - {(scrollableMenuHeight && ( - - {children} - - )) || {children}} - - + + + {(scrollableMenuHeight && ( + + {children} + + )) || {renderedChildren}} + + + + ) : null} @@ -703,7 +764,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 149a93a752..0185eeeaa4 100644 --- a/src/components/Menu/MenuItem.tsx +++ b/src/components/Menu/MenuItem.tsx @@ -1,15 +1,21 @@ import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; -import type { - GestureResponderEvent, - PressableAndroidRippleConfig, - StyleProp, - TextStyle, - ViewStyle, +import { + Platform, + StyleSheet, + View, + type Role, + type GestureResponderEvent, + type PressableAndroidRippleConfig, + type StyleProp, + type TextStyle, + type ViewStyle, } from 'react-native'; +import { useMenuItemPosition, useMenuRoot } from './context'; +import { MenuTokens, type MenuColorScheme } from './tokens'; import { getContentMaxWidth, + getMenuItemBorderRadius, getMenuItemColor, MAX_WIDTH, MIN_WIDTH, @@ -27,6 +33,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 +53,22 @@ 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. + * + * On web this also marks the item as `menuitemradio`, so assistive tech + * announces it as one of a set. Pass `selected={false}` on the other items + * in the group so they share the role. + */ + 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 +79,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 @@ -62,6 +101,14 @@ export type Props = { * Specifies the largest possible scale a title font can reach. */ titleMaxFontSizeMultiplier?: number; + /** + * Specifies the largest possible scale the supporting text font can reach. + */ + supportingTextMaxFontSizeMultiplier?: number; + /** + * Specifies the largest possible scale the trailing supporting text font can reach. + */ + trailingSupportingTextMaxFontSizeMultiplier?: number; /** * Style that is passed to the root TouchableRipple container. * @optional @@ -72,13 +119,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 */ @@ -98,10 +153,14 @@ export type Props = { /** * Indicates whether the element is checked. Accepts `true`, `false`, * or `'mixed'` for an indeterminate state. + * + * On web this marks the item as `menuitemcheckbox`, for options that toggle + * independently rather than forming a single-choice group. */ 'aria-checked'?: boolean | 'mixed'; /** - * Indicates whether the element is selected. + * Indicates whether the element is selected. Prefer `selected`, which maps to + * `aria-checked` — the state assistive tech reads on a menu item. */ 'aria-selected'?: boolean; /** @@ -117,6 +176,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 +191,15 @@ export type Props = { * {}} title="Undo" /> * {}} title="Cut" disabled /> * {}} title="Copy" disabled /> - * {}} title="Paste" /> + * {}} + * title="Paste" + * supportingText="Insert clipboard" + * trailingSupportingText="⌘V" + * selected + * /> + * * * ); * @@ -141,13 +211,21 @@ const MenuItem = ({ trailingIcon, dense, title, + supportingText, + trailingSupportingText, + selected: selectedProp, + colorScheme: colorSchemeProp, disabled, + roundedTop: roundedTopProp, + roundedBottom: roundedBottomProp, background, onPress, style, containerStyle, contentStyle, titleStyle, + supportingTextStyle, + trailingSupportingTextStyle, testID = 'menu-item', 'aria-label': ariaLabel, 'aria-checked': ariaChecked, @@ -156,36 +234,110 @@ const MenuItem = ({ 'aria-expanded': ariaExpanded, theme: themeOverrides, titleMaxFontSizeMultiplier = 1.5, + supportingTextMaxFontSizeMultiplier = 1.5, + trailingSupportingTextMaxFontSizeMultiplier = 1.5, hitSlop, }: Props) => { const theme = useInternalTheme(themeOverrides); - const { titleColor, iconColor, contentOpacity } = getMenuItemColor({ + const root = useMenuRoot(); + const position = useMenuItemPosition(); + const selected = selectedProp ?? false; + + // Native keeps plain `menuitem`: Android drops the role description for a + // value it cannot map, and iOS ignores all of them anyway. + const role: Role = React.useMemo(() => { + if (Platform.OS !== 'web') { + return 'menuitem'; + } + if (ariaChecked !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- valid ARIA role missing from RN's union + return 'menuitemcheckbox' as Role; + } + if (selectedProp !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- valid ARIA role missing from RN's union + return 'menuitemradio' as Role; + } + return 'menuitem'; + }, [ariaChecked, selectedProp]); + + const colorScheme = colorSchemeProp ?? root?.colorScheme ?? 'standard'; + const roundedTop = roundedTopProp ?? position?.roundedTop ?? false; + const roundedBottom = roundedBottomProp ?? position?.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 hasSupportingText = + supportingText != null && typeof supportingText !== 'boolean'; + const hasTrailingSupportingText = + trailingSupportingText != null && + typeof trailingSupportingText !== 'boolean'; - const maxWidth = getContentMaxWidth({ - iconWidth, + const contentMaxWidth = getContentMaxWidth({ + iconWidth: iconSize, leadingIcon, trailingIcon, + hasTrailingSupportingText, }); const titleTextStyle = { color: titleColor, - ...theme.fonts.bodyLarge, + ...theme.fonts[MenuTokens.typography.label], + }; + + const supportingTextStyleResolved = { + color: supportingColor, + ...theme.fonts[MenuTokens.typography.supporting], }; + const trailingSupportingTextStyleResolved = { + color: supportingColor, + ...theme.fonts[MenuTokens.typography.trailingSupporting], + }; + + const borderRadiusStyle = getMenuItemBorderRadius({ + theme, + selected, + roundedTop, + roundedBottom, + }); + return ( - + {leadingIcon ? ( - - + + ) : null} {title} + {hasSupportingText ? ( + + {supportingText} + + ) : null} + {hasTrailingSupportingText ? ( + + {trailingSupportingText} + + ) : null} {trailingIcon ? ( - - + + ) : null} @@ -243,23 +437,29 @@ 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', + maxWidth: MAX_WIDTH - MenuTokens.sizes.itemPaddingHorizontal * 2, + }, + leadingIcon: { + flexShrink: 0, }, content: { + flexShrink: 1, + flexGrow: 1, justifyContent: 'center', }, - md3LeadingIcon: { + trailingSupporting: { marginLeft: 12, + flexShrink: 0, }, - md3WithoutLeadingIcon: { - marginLeft: 4, + trailingIcon: { + marginLeft: 12, + flexShrink: 0, }, }); diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx new file mode 100644 index 0000000000..d855b8deaf --- /dev/null +++ b/src/components/Menu/context.tsx @@ -0,0 +1,35 @@ +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 +); + +export function useMenuRoot() { + return React.useContext(MenuRootContext); +} + +/** + * Where an item sits in the menu, so first / last can round their outer + * corners. `Menu` derives it from child order; no props reach the children. + */ +export type MenuItemPositionContextValue = { + roundedTop: boolean; + roundedBottom: boolean; +}; + +export const MenuItemPositionContext = + React.createContext(null); + +export function useMenuItemPosition() { + return React.useContext(MenuItemPositionContext); +} diff --git a/src/components/Menu/motion.ts b/src/components/Menu/motion.ts new file mode 100644 index 0000000000..df1ef114c1 --- /dev/null +++ b/src/components/Menu/motion.ts @@ -0,0 +1,87 @@ +import { runOnJS, withSpring } from 'react-native-reanimated'; + +import { toRawSpring } from '../../theme/tokens/sys/motion'; +import type { InternalTheme } from '../../types'; + +/** + * The slice of a Reanimated shared value these helpers touch. Keeping it + * structural lets unit tests drive the real code path with a plain object. + */ +type MotionValue = { value: number }; + +type OpenMotionArgs = { + reduceMotion: boolean; + scale: MotionValue; + opacity: MotionValue; + theme: InternalTheme; + onFinish: () => void; +}; + +type CloseMotionArgs = { + reduceMotion: boolean; + opacity: MotionValue; + 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, + scale, + opacity, + theme, + onFinish, +}: OpenMotionArgs): 'snap' | 'spring' { + if (reduceMotion) { + scale.value = 1; + opacity.value = 1; + onFinish(); + return 'snap'; + } + + scale.value = withSpring(1, toRawSpring(theme.motion.spring.fast.spatial)); + opacity.value = withSpring( + 1, + toRawSpring(theme.motion.spring.fast.effects), + (finished) => { + 'worklet'; + if (finished) { + runOnJS(onFinish)(); + } + } + ); + + return 'spring'; +} + +/** + * Close the menu surface: spring opacity out, or snap when reduce-motion. + */ +export function runMenuCloseMotion({ + reduceMotion, + opacity, + theme, + onFinish, +}: CloseMotionArgs): 'snap' | 'spring' { + if (reduceMotion) { + opacity.value = 0; + onFinish(); + return 'snap'; + } + + opacity.value = withSpring( + 0, + toRawSpring(theme.motion.spring.fast.effects), + (finished) => { + 'worklet'; + if (finished) { + runOnJS(onFinish)(); + } + } + ); + + return 'spring'; +} diff --git a/src/components/Menu/tokens.ts b/src/components/Menu/tokens.ts new file mode 100644 index 0000000000..289537efd4 --- /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', + /** First / last / selected item corners. Spec: corner.medium. */ + item: 'medium', +} as const satisfies Record; + +const typography = { + label: 'labelLarge', + supporting: 'bodySmall', + trailingSupporting: 'labelLarge', +} as const satisfies Record; + +/** + * 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', + label: 'onSurface', + icon: 'onSurfaceVariant', + supporting: 'onSurfaceVariant', + selectedContainer: 'tertiaryContainer', + selectedContent: 'onTertiaryContainer', +} as const satisfies Record; + +/** + * M3 Expressive vibrant menu roles. + * @see https://m3.material.io/components/menus/specs + */ +const vibrantColors = { + label: 'onTertiaryContainer', + icon: 'onTertiaryContainer', + supporting: 'onTertiaryContainer', + /** Vibrant menu surface uses tertiaryContainer instead of elevation surface. */ + container: 'tertiaryContainer', + selectedContainer: 'tertiary', + selectedContent: 'onTertiary', +} as const satisfies Record; + +const elevation = { + default: 2, +} as const satisfies Record; + +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 bd14689f70..6dcf9e9f14 100644 --- a/src/components/__tests__/Menu.test.tsx +++ b/src/components/__tests__/Menu.test.tsx @@ -3,13 +3,24 @@ 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'; +/** Read an Animated.Value without reaching into its internals. */ +const currentValue = (node: Animated.Value) => { + let value = NaN; + node.stopAnimation((v) => { + value = v; + }); + return value; +}; + const styles = StyleSheet.create({ contentStyle: { borderTopLeftRadius: 0, @@ -78,7 +89,7 @@ it('renders menu with content styles', async () => { const elevations: Elevation[] = [0, 1, 2, 3, 4, 5]; elevations.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( @@ -95,12 +106,193 @@ elevations.forEach((elevation) => ); + // 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('inherits the vibrant color scheme for items rendered inside a wrapper', async () => { + const theme = getTheme(); + + await render( + + Open menu} + > + + + + + + ); + + expect(screen.getByTestId('wrapped-item-title')).toHaveStyle({ + color: theme.colors.onTertiaryContainer, + }); +}); + +it('rounds first and last item corners from child order', 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, + }); + expect(screen.getByTestId('mid-item')).not.toHaveStyle({ + borderRadius: radius, + }); +}); + +it('applies medium corners from explicit roundedTop / roundedBottom props', 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, + }); + expect(screen.getByTestId('mid-item')).not.toHaveStyle({ + borderRadius: radius, + }); +}); + +it('inherits the vibrant color scheme without a React.Children walk', async () => { + const theme = getTheme(); + + await render( + + Open menu} + > + + + + + + ); + + expect(screen.getByTestId('nested-item-title')).toHaveStyle({ + color: theme.colors.onTertiaryContainer, + }); +}); + +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, @@ -210,26 +402,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, @@ -238,6 +493,16 @@ it('animated value changes correctly', async () => { await act(() => { jest.advanceTimersByTime(200); }); + + // Animation driver (jest Animated.timing stub) must update the value. + expect(currentValue(advanced)).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..1dacd4c6f0 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 { Platform } from 'react-native'; + +import { afterEach, 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,210 @@ 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('renders numeric supporting text values like 0', async () => { + await render( + + ); + + expect(screen.getByTestId('menu-item-supporting')).toHaveTextContent('0'); + expect( + screen.getByTestId('menu-item-trailing-supporting') + ).toHaveTextContent('0'); + }); + + it('ignores boolean placeholders for supporting text', async () => { + await render( + + ); + + expect(screen.queryByTestId('menu-item-supporting')).toBeNull(); + expect(screen.queryByTestId('menu-item-trailing-supporting')).toBeNull(); + }); + + it('grows for a trailing-only row so large font scales are not clipped', async () => { + await render(); + + expect(screen.getByTestId('menu-item')).toHaveStyle({ + minHeight: MenuTokens.sizes.itemHeight, + paddingVertical: 8, + }); + expect(screen.getByTestId('menu-item')).not.toHaveStyle({ + height: MenuTokens.sizes.itemHeight, + }); + }); + + it('caps supporting text font scaling like the title', async () => { + await render( + + ); + + expect(screen.getByTestId('menu-item-supporting')).toHaveProp( + 'maxFontSizeMultiplier', + 2 + ); + expect(screen.getByTestId('menu-item-trailing-supporting')).toHaveProp( + 'maxFontSizeMultiplier', + 3 + ); + }); + + it('defaults supporting text scaling to the title cap', async () => { + await render( + + ); + + expect(screen.getByTestId('menu-item-supporting')).toHaveProp( + 'maxFontSizeMultiplier', + 1.5 + ); + expect(screen.getByTestId('menu-item-trailing-supporting')).toHaveProp( + 'maxFontSizeMultiplier', + 1.5 + ); + }); + + it('pins trailing supporting text to the trailing edge', async () => { + await render(); + + expect(screen.getByTestId('menu-item-content')).not.toHaveStyle({ + alignSelf: 'flex-start', + }); + expect(screen.getByTestId('menu-item-trailing-supporting')).toHaveStyle({ + flexShrink: 0, + }); + }); + + 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('announces selection as checked state and applies selected colors', async () => { + const theme = getTheme(); + await render(); + + expect(screen.getByRole('menuitem')).toHaveProp( + 'accessibilityState', + expect.objectContaining({ checked: 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 +299,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 +354,155 @@ 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', () => { + it('snaps scale and opacity when reduce-motion is enabled', () => { + const scale = { value: 0 }; + const opacity = { value: 0 }; + const finish = jest.fn(); + + const path = runMenuOpenMotion({ + reduceMotion: true, + scale, + opacity, + theme: getTheme(), + onFinish: finish, + }); + + expect(path).toBe('snap'); + expect(finish).toHaveBeenCalled(); + expect(scale.value).toBe(1); + expect(opacity.value).toBe(1); + }); + + it('springs to the resting state when reduce-motion is off', () => { + const scale = { value: 0 }; + const opacity = { value: 0 }; + const finish = jest.fn(); + + const path = runMenuOpenMotion({ + reduceMotion: false, + scale, + opacity, + theme: getTheme(), + onFinish: finish, + }); + + expect(path).toBe('spring'); + expect(scale.value).toBe(1); + expect(opacity.value).toBe(1); + }); + + it('snaps opacity to 0 on close when reduce-motion is enabled', () => { + const opacity = { value: 1 }; + const finish = jest.fn(); + + const path = runMenuCloseMotion({ + reduceMotion: true, + opacity, + theme: getTheme(), + onFinish: finish, + }); + + expect(path).toBe('snap'); + expect(finish).toHaveBeenCalled(); + expect(opacity.value).toBe(0); + }); +}); + +describe('Menu.Item ARIA role', () => { + const setPlatform = (os: 'web' | 'ios') => { + Object.defineProperty(Platform, 'OS', { + get: () => os, + configurable: true, + }); + }; + + afterEach(() => setPlatform('ios')); + + it('keeps plain menuitem on native so the Android role description survives', async () => { + setPlatform('ios'); + await render(); + expect(screen.getByRole('menuitem')).toBeOnTheScreen(); + }); + + it('uses menuitemradio on web for single selection', async () => { + setPlatform('web'); + await render(); + expect(screen.getByTestId('menu-item')).toHaveProp('role', 'menuitemradio'); + }); + + it('uses menuitemcheckbox on web when aria-checked is given', async () => { + setPlatform('web'); + await render(); + expect(screen.getByTestId('menu-item')).toHaveProp( + 'role', + 'menuitemcheckbox' + ); + }); + + it('stays menuitem on web when neither selection prop is used', async () => { + setPlatform('web'); + await render(); + expect(screen.getByTestId('menu-item')).toHaveProp('role', 'menuitem'); + }); +}); diff --git a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap index 3e027106ae..56ae427af0 100644 --- a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap @@ -243,7 +243,6 @@ exports[`renders menu with content styles 1`] = ` testID="menu-view" > - + - Undo - + ] + } + testID="menu-item-title" + > + Undo + + - - - - + - Redo - + ] + } + testID="menu-item-title" + > + Redo + + @@ -974,7 +1010,6 @@ exports[`renders visible menu 1`] = ` testID="menu-view" > - + - Undo - + ] + } + testID="menu-item-title" + > + Undo + + - - - - + - Redo - + ] + } + testID="menu-item-title" + > + Redo + + diff --git a/src/components/__tests__/__snapshots__/MenuItem.test.tsx.snap b/src/components/__tests__/__snapshots__/MenuItem.test.tsx.snap index 6977abf932..6b94cfdef4 100644 --- a/src/components/__tests__/__snapshots__/MenuItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/MenuItem.test.tsx.snap @@ -6,7 +6,7 @@ exports[`Menu Item renders menu item 1`] = ` accessibilityState={ { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": false, "expanded": undefined, "selected": undefined, @@ -38,15 +38,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, ], ] @@ -57,7 +67,9 @@ exports[`Menu Item renders menu item 1`] = ` style={ [ { + "alignItems": "center", "flexDirection": "row", + "maxWidth": 256, }, { "opacity": 1, @@ -65,6 +77,7 @@ exports[`Menu Item renders menu item 1`] = ` undefined, ] } + testID="menu-item-content" > @@ -111,11 +127,12 @@ exports[`Menu Item renders menu item 1`] = ` style={ [ { + "flexGrow": 1, + "flexShrink": 1, "justifyContent": "center", }, { "maxWidth": 232, - "minWidth": 100, }, { "marginLeft": 12, @@ -140,19 +157,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, ], @@ -170,7 +187,7 @@ exports[`Menu Item renders menu item 1`] = ` accessibilityState={ { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": false, "expanded": undefined, "selected": undefined, @@ -202,15 +219,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 +248,9 @@ exports[`Menu Item renders menu item 1`] = ` style={ [ { + "alignItems": "center", "flexDirection": "row", + "maxWidth": 256, }, { "opacity": 1, @@ -229,6 +258,7 @@ exports[`Menu Item renders menu item 1`] = ` undefined, ] } + testID="menu-item-content" > @@ -275,11 +308,12 @@ exports[`Menu Item renders menu item 1`] = ` style={ [ { + "flexGrow": 1, + "flexShrink": 1, "justifyContent": "center", }, { "maxWidth": 232, - "minWidth": 100, }, { "marginLeft": 12, @@ -304,19 +338,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, ], @@ -334,7 +368,7 @@ exports[`Menu Item renders menu item 1`] = ` accessibilityState={ { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": true, "expanded": undefined, "selected": undefined, @@ -366,15 +400,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 +429,9 @@ exports[`Menu Item renders menu item 1`] = ` style={ [ { + "alignItems": "center", "flexDirection": "row", + "maxWidth": 256, }, { "opacity": 0.38, @@ -393,6 +439,7 @@ exports[`Menu Item renders menu item 1`] = ` undefined, ] } + testID="menu-item-content" > @@ -439,11 +489,12 @@ exports[`Menu Item renders menu item 1`] = ` style={ [ { + "flexGrow": 1, + "flexShrink": 1, "justifyContent": "center", }, { "maxWidth": 232, - "minWidth": 100, }, { "marginLeft": 12, @@ -468,19 +519,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, ], @@ -498,7 +549,7 @@ exports[`Menu Item renders menu item 1`] = ` accessibilityState={ { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": true, "expanded": undefined, "selected": undefined, @@ -530,15 +581,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 +610,9 @@ exports[`Menu Item renders menu item 1`] = ` style={ [ { + "alignItems": "center", "flexDirection": "row", + "maxWidth": 256, }, { "opacity": 0.38, @@ -557,6 +620,7 @@ exports[`Menu Item renders menu item 1`] = ` undefined, ] } + testID="menu-item-content" > @@ -603,11 +670,12 @@ exports[`Menu Item renders menu item 1`] = ` style={ [ { + "flexGrow": 1, + "flexShrink": 1, "justifyContent": "center", }, { "maxWidth": 232, - "minWidth": 100, }, { "marginLeft": 12, @@ -632,19 +700,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, ], @@ -662,7 +730,7 @@ exports[`Menu Item renders menu item 1`] = ` accessibilityState={ { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": false, "expanded": undefined, "selected": undefined, @@ -694,15 +762,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 +791,9 @@ exports[`Menu Item renders menu item 1`] = ` style={ [ { + "alignItems": "center", "flexDirection": "row", + "maxWidth": 256, }, { "opacity": 1, @@ -721,17 +801,19 @@ exports[`Menu Item renders menu item 1`] = ` undefined, ] } + testID="menu-item-content" >