feat(menu): modernize Menu to Material Design 3 specs - #5037
Conversation
Menu.Item used fixed 48dp height with two-line supporting text and a content minWidth that collapsed trailingSupportingText to zero width. Use minHeight + padding for supporting rows, intrinsic row sizing for trailing slots, and showcase the vibrant color scheme in the example.
MikitasK
left a comment
There was a problem hiding this comment.
great work on this modernization 👏
just a few suggestions to consider before merging the PR:
| const { titleColor, iconColor, contentOpacity } = getMenuItemColor({ | ||
| const layout = useMenuItemLayout(); | ||
|
|
||
| const colorScheme = colorSchemeProp ?? layout?.colorScheme ?? 'standard'; |
There was a problem hiding this comment.
what about using MenuRootContext as a fallback here?
I suppose, items rendered through wrapper / Fragment won't receive MenuItemLayoutContext & will fall back to standard scheme even inside vibrant menu
| const colorScheme = colorSchemeProp ?? layout?.colorScheme ?? 'standard'; | |
| const root = useMenuRoot(); | |
| const colorScheme = colorSchemeProp ?? layout?.colorScheme ?? root?.colorScheme ?? 'standard'; |
| > | ||
| {title} | ||
| </Text> | ||
| {supportingText ? ( |
There was a problem hiding this comment.
could use explicit presence check for supportingText & trailingSupportingText?
this way values like supportingText={0} will be rendered
instead, we can define such variable:
const hasSupportingText = supportingText != null && typeof supportingText !== 'boolean';
and then use it here
| {supportingText ? ( | |
| {hasSupportingText ? ( |
| </Text> | ||
| ) : null} | ||
| </View> | ||
| {trailingSupportingText ? ( |
| ...(supportingText | ||
| ? { paddingVertical: 8 } | ||
| : { height: dense ? denseItemHeight : itemHeight }), |
There was a problem hiding this comment.
could we allow the item to grow when trailing supporting text is present?
it currently keeps fixed 48/32dp height & might be clipped with large accessibility font sizes
we can define the following vars first:
const hasSupportingText = supportingText != null && typeof supportingText !== 'boolean';
const hasTrailingSupportingText = trailingSupportingText != null && typeof trailingSupportingText !== 'boolean';
and then use it like that:
| ...(supportingText | |
| ? { paddingVertical: 8 } | |
| : { height: dense ? denseItemHeight : itemHeight }), | |
| ...(hasSupportingText || hasTrailingSupportingText | |
| ? { paddingVertical: 8 } | |
| : { height: dense ? denseItemHeight : itemHeight }), |
| const contentMaxWidth = getContentMaxWidth({ | ||
| iconWidth: iconSize, | ||
| leadingIcon, | ||
| trailingIcon, | ||
| hasTrailingSupportingText: Boolean(trailingSupportingText), | ||
| }); |
There was a problem hiding this comment.
could we use explicit presence flags for these props?
this approach should keep numeric values (such as 0), but ignore boolean placeholders
| const contentMaxWidth = getContentMaxWidth({ | |
| iconWidth: iconSize, | |
| leadingIcon, | |
| trailingIcon, | |
| hasTrailingSupportingText: Boolean(trailingSupportingText), | |
| }); | |
| const hasTrailingSupportingText = trailingSupportingText != null && typeof trailingSupportingText !== 'boolean'; | |
| const contentMaxWidth = getContentMaxWidth({ | |
| iconWidth: iconSize, | |
| leadingIcon, | |
| trailingIcon, | |
| hasTrailingSupportingText, | |
| }); |
| @@ -0,0 +1,86 @@ | |||
| import { Animated } from 'react-native'; | |||
| */ | ||
| export function countMenuItems(children: React.ReactNode): number { | ||
| let count = 0; | ||
| React.Children.forEach(children, (child) => { |
|
|
||
| const shapes = { | ||
| /** Menu surface corner. Spec: corner.large (16dp). */ | ||
| container: 'large' as ShapeToken, |
There was a problem hiding this comment.
Try not to use as casts
| opacityAnimation: opacityAnimationRef.current, | ||
| menuWidth: menuLayoutResult.width, | ||
| menuHeight: menuLayoutResult.height, | ||
| theme: theme as Theme, |
There was a problem hiding this comment.
Try not to use as casts
| row: { | ||
| flexDirection: 'row', | ||
| alignItems: 'center', | ||
| alignSelf: 'flex-start', |
There was a problem hiding this comment.
alignSelf: 'flex-start' shrink-wraps the row, so the trailing shortcut sits next to the label instead of at the item's trailing edge — visible in menu-2.png. Drop it and give content flexGrow: 1.
| aria-disabled={disabled} | ||
| aria-checked={ariaChecked} | ||
| aria-selected={ariaSelected} | ||
| aria-selected={ariaSelected ?? (isSelected ? true : undefined)} |
There was a problem hiding this comment.
aria-selected isn't valid on role="menuitem", so on web a selected item announces the same as an unselected one. Use menuitemradio / menuitemcheckbox with aria-checked.
| // 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'; |
There was a problem hiding this comment.
Jest already sets NODE_ENV=test when it's unset (jest-cli/bin/jest.js) and nothing in this repo overrides it, so this file is a no-op. Remove it and the setupFiles entry.
| ## Theme colors | ||
|
|
||
| <ThemeColorsTable themeColorsData={{"-":{"backgroundColor":"theme.colors.elevation.level2"}}} componentName="Menu" /> | ||
| <ThemeColorsTable themeColorsData={{"standard":{"backgroundColor":"theme.colors.surfaceContainerLow"},"vibrant":{"backgroundColor":"theme.colors.tertiaryContainer"},"selected item":{"backgroundColor":"theme.colors.tertiaryContainer","textColor":"theme.colors.onTertiaryContainer"}}} componentName="Menu" /> |
There was a problem hiding this comment.
This page is generated with componentName="Menu", so FAB.Menu now claims a surfaceContainerLow fill and a vibrant tertiaryContainer variant it doesn't have, and shows Menu's screenshots.
|
@JKobrynski i have updated my PR #5073 to fix bugs from your comments. I think we can close this one and continue review on #5073 -> it preserves the git history of this PR, is rebased onto the newest main and has commits with comments fixes |
|
@mikolajadamowicz closing this one |
Motivation
Modernize
Menu/Menu.Itemto the latest Material Design 3 specs. Reuse theme tokens (shape, typography, motion, color roles) and extract component tokens in the same pattern as Checkbox / FAB / ConnectedButtonGroup.Spec re-check (M3 menus)
Re-checked M3 menus specs and overview:
surfaceContainerLow. Theelevationprop controls shadow only (Paper’selevation.level2maps to different surfaceContainer tones, so it is not used as the menu fill).colorScheme="vibrant"; spring-animated item corner morph as focus/selection moves; optional badge and lightweight submenu portal surface; Menu.Section group gaps (8dp) in addition to Divider.Changes
Tokens / Menu / Menu.Item
surfaceContainerLow; surfacecorner.large; first/last/selected itemcorner.mediumvia layout context (nocloneElement/ nodisplayNamefiltering)labelLarge, supporting + trailing supporting text, selected colors (disabled wins with content opacity)toRawSpring+ reduce-motion snapcolorSchemestandard | vibrantMenu.Sectiongroup gaps; badge; lightweight submenu portal;Animated.springper-corner radius morph on focus/selectionExample / docs / tests
Related issue
Fixes #4977
Test plan
yarn typecheck/yarn lint/ Menu unit testsVisual verification
Docs screenshots:
docs/public/screenshots/menu-*.pngandmenu-ios-*.png.