refactor(v6): modernize Button API with label and iconPosition props#4943
refactor(v6): modernize Button API with label and iconPosition props#4943azizbecha wants to merge 24 commits into
Conversation
|
Hey @azizbecha, thank you for your pull request 🤗. The documentation from this branch can be viewed here. |
satya164
left a comment
There was a problem hiding this comment.
Current API relies heavily on children composition in ways that are difficult to optimize
@ruben-rebelo can you clarify what the optimization problem is?
I'm against changing the API from children to label: string. It's practically the same except for the type, which limits usage of other components. I don't see the benefit of this restriction, and it's also a big breaking change as Button is a common component.
|
To clarify removal of children patterns that we discussed, it's about the usage of |
4a849e5 to
1ae9094
Compare
There was a problem hiding this comment.
Hey @azizbecha, thanks for the PR!
I've updated #4928 with a link to a component vs specs review we did internally. You'll find more details about Button there.
Some general comments:
- It would be a great moment to revamp the example page for Buttons with a way to toggle all possible states (sizes, configurations, shapes, toggle etc) in a more compact way; now it's huge and messy IMHO. This will also help a lot with testing the modernization.
- please make sure you're following the specs in terms of colors, spacing etc; For example, the outlined variant should use
onSurfaceVariantas label color, not primary; - use the same naming as in the specs (filled instead of contained, tonal etc)
- default mode is text now but specs hierarchy implies filled as default
- we're using the legacy icon size of 18dp when no size is set: "extra-small" should have the iconSize of 20, not 16.
- we're using the legacy horizontal padding of 24dp instead of 16dp
- it would be nice to extract all "component specific tokens" in the same place for easier access/update if needed;
- use the theme shape tokens instead of magic numbers in
BUTTON_SHAPE_RADIUS, for example here are the values for thesmallbutton variant; I can share the other values if you want to; observe that there's a separate field forpressed:
// extract from the native Jetpack Compose library
internal object ButtonSmallTokens {
val ContainerHeight = 40.0.dp
val ContainerShapeRound = ShapeKeyTokens.CornerFull
val ContainerShapeSquare = ShapeKeyTokens.CornerMedium
val IconLabelSpace = 8.0.dp
val IconSize = 20.0.dp
val LeadingSpace = 16.0.dp
val OutlinedOutlineWidth = 1.0.dp
val PressedContainerShape = ShapeKeyTokens.CornerSmall
val SelectedContainerShapeRound = ShapeKeyTokens.CornerFull
val SelectedContainerShapeSquare = ShapeKeyTokens.CornerMedium
val TrailingSpace = 16.0.dp
}- I'm glad you added shapes but we're missing the shape morph animation on press and on toggle; we have motion tokens for that BTW
- drop the
md3prefix from styles - make sure we respect "Extra small and small icon buttons must have a target size of 48x48dp or larger to be accessible." as mentioned in the specs
- we should make use of
useLocale()for toggling the icon position - consolidate the naming; we're currently using
text(textColor, textOpacity etc) andlabelto refer to the same thing; Let's use label as in the MD specs - @satya164 do you think we should look into
hovered/focusedstates too? something relevant for screen readers, pointing device attached to device - It would be nice to consolidate all buttons under src/components/Buttons; See https://m3.material.io/components/all-buttons; Also, I believe we could reuse code between button types as they have a lot in common
yes. we also support web where this is much more important. |
Introduce a `label?: string` prop as the primary way to set the button text. The `children` prop keeps working as a deprecated fallback (when both are set, `label` wins) and emits a dev-only warning. This decouples the button layout from arbitrary child structures and makes `uppercase` work reliably, since the label is always a string.
Update the components that compose Button (Banner, Snackbar, DataTablePagination) to pass the new `label` prop instead of children, and update the `## Usage` / `@example` JSDoc blocks (and the test files) accordingly so nothing relies on the deprecated `children` prop.
Add an `iconPosition?: 'leading' | 'trailing'` prop to control where the
icon sits relative to the label. The previous approach of setting
`contentStyle={{ flexDirection: 'row-reverse' }}` still works but is now
deprecated and emits a dev-only warning.
The icon margins are extracted into a `getButtonIconStyle` helper,
replacing the previous matrix of computed StyleSheet keys, and
DataTablePagination is updated to use the new prop.
Add a `rippleColor?: ColorValue` prop and, by default, drive the ripple / state layer with the label color at the pressed-state opacity (per Material Design 3) instead of TouchableRipple's onSurface-based default. The color is computed by a new `getButtonRippleColor` helper, which falls back to `undefined` (TouchableRipple's own default) when the label color is not a plain string, e.g. an Android Material You PlatformColor.
Wrap the expensive derived values (color computation, border-radius extraction, ripple color, icon style, touchable ripple style, and the flattened style objects) in `useMemo`, memoize the press handlers with `useCallback`, and replace the `isMode` `useCallback` with a plain local function. No behavior or render-output change.
1ae9094 to
8ad8216
Compare
Update the example screens to use the new `label` prop instead of
children, and the `iconPosition="trailing"` prop instead of the
`contentStyle={{ flexDirection: 'row-reverse' }}` hack.
Update the hand-written guide snippets (icons, react-navigation, ripple effect) and the docs-site example components to use the new `label` prop instead of children. The generated component reference pages are derived from the JSDoc and will be regenerated by the docs build.
Add a `size?: 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large'` prop. When omitted, the Button keeps its current visuals; when set, the per-size MD3 metrics (minHeight, horizontal padding, icon size, icon/label gap, label typescale) are applied via a new `getButtonSizeStyle` helper.
Add a `shape?: 'round' | 'square'` prop. When omitted, the button keeps its legacy corner radius. When set, `'round'` uses the full-pill radius and `'square'` uses a per-size smaller corner; the mapping comes from a new `getButtonShapeRadius` helper. An explicit `borderRadius` in `style` still wins.
Add a `selected?: boolean` prop. When `true`, the button flips its `shape` (round ↔ square) so the selected/unselected pair contrasts, and for `outlined`/`text` modes adopts a filled tonal-selected appearance (`secondaryContainer` background, `onSecondaryContainer` label, no border). `accessibilityState.selected` is set so screen readers announce the toggle state. Other modes keep their colors and only flip the shape. The `selected` flag is threaded through `getButtonColors` and its sub-helpers.
Showcase the new expressive props in the example app: one button per size in the Size section, a round and a square row across sizes in the Shape section, and stateful selected/unselected toggles in the Toggle section.
8ad8216 to
d339f8f
Compare
Adds an `error?: boolean` prop to Checkbox, CheckboxAndroid and CheckboxIOS. When true, the outline (unchecked) and container (checked / indeterminate) use `theme.colors.error`. The `disabled` state and explicit `color` / `uncheckedColor` overrides take precedence. Addresses one bullet from callstack#4937 / callstack#4949 (Checkbox section, "Error state not implemented"). Verified visually on iOS Simulator and Android Emulator across light and dark themes.
Treat `iconPosition` as logical (reading-order) and swap leading/trailing under RTL. The layout engine already mirrors `flexDirection` and physical margins when the platform direction is RTL (native I18nManager, or react-native-web with an active I18nManager), so we only flip manually when the locale direction disagrees with the platform default — e.g. a web LocaleProvider override where I18nManager is a no-op. This avoids a double-flip on native RTL.
Use a single `label` vocabulary internally to match the public `label` prop and the MD3 spec: - getButtonColors returns labelColor/labelOpacity (was textColor/textOpacity) - getButtonTextColor -> getButtonLabelColor; customTextColor -> customLabelColor - getButtonRippleColor takes labelColor Also drop the md3 prefix from the no-size styles (md3Label* -> legacyLabel*). The public `textColor` prop is unchanged. Internal-only rename; no behavior or snapshot change.
Align the outlined variant with the MD3 spec: - border color: theme.colors.outline (was outlineVariant) - label color: theme.colors.onSurfaceVariant (was primary) text and elevated modes keep the primary accent; the selected-outlined tonal branch is unchanged. Snapshot updates in Menu and DataTable reflect the same outlined Button color change.
For the legacy (no-`size`) button: - icon size 18 -> 20dp - horizontal padding 24 -> 16dp (legacyLabel marginHorizontal) - keep the 8dp icon-label gap: the icon's negative margin is calibrated against the label margin, so the non-text icon margins go -16 -> -8 to compensate for the smaller label margin (otherwise the gap collapses to 0). Snapshot updates in Menu and DataTable reflect the same legacy Button metrics.
Add src/components/Button/tokens.ts with one token object per MD3 expressive
size (modelled on Jetpack Compose's Button{Size}Tokens), replacing the inline
BUTTON_SIZE_STYLES and BUTTON_SHAPE_RADIUS maps. Corner radii now reference
shape keys (full / medium / large / extraLarge) resolved against
theme.shapes.corner instead of magic numbers.
- getButtonSizeStyle derives from the tokens
- getButtonShapeRadius takes theme and resolves corner keys (resolveButtonCorner)
- fix extra-small iconSize 16 -> 20 per MD3 spec
- tokens also carry pressed/selected shape fields for the upcoming shape morph
No metric/radius change other than the extra-small icon size.
Align Button mode names with the MD3 spec. This is a breaking rename (no aliases), matching the v6 breaking-change window: - contained -> filled - contained-tonal -> tonal The resolved styles are unchanged, so there is no visual difference. Updates the Button mode type, internal isMode checks, CardActions' injected default, and all consumers (example, docs, tests). Card/IconButton/SegmentedButtons/ ToggleButton keep their own independent contained modes.
Change the default Button mode from text to filled to match the MD3 emphasis hierarchy (filled is the highest-emphasis, primary button). A bare <Button> now renders filled instead of text. All library-internal usages pass an explicit mode, so none are affected. Snapshots updated for bare buttons (transparent -> primary background, primary -> onPrimary label).
Extra-small (32dp) and small (40dp) buttons are shorter than the 48dp minimum accessible touch target, so expand the press area with hitSlop without changing the visual size (XS -> top/bottom 8, S -> top/bottom 4). A user-supplied hitSlop wins on the axes it sets; a numeric hitSlop is respected as-is. Verified on device that taps inside the slop zone register and taps outside do not.
Shaped buttons animate their corner radius with the theme motion spring: to corner.small (8dp) while pressed, and between the round/square radii when the selected toggle flips the shape. Stability: the animated path resolves round to the real pill radius (minHeight/2) instead of the cornerFull sentinel so the spring stays bounded, plus a >= 0 clamp guards against overshoot. Scoped to shaped buttons that don't pin a radius via style; legacy/size-only buttons keep a static corner. Web: the inner ripple can't follow an Animated value, so it's rendered as a rectangle and the Surface clips it (overflow: hidden) to the morphing radius, keeping the outline and state layer in sync.
Replace the long exhaustive grid with a compact playground: a live Button driven by Chip controls (mode/size/shape/icon-position) and Switch rows (show icon/disabled/loading/selected/compact), with smart gating so a control is never a no-op (icon-position only with an icon; compact only when size is unset). Keeps trimmed showcase sections: Modes, States, Size, Shape, Toggle, Custom.
Update the Button doc data sources to match the v6 API: rename the contained/contained-tonal color + screenshot entries to filled/tonal, set the outlined label to onSurfaceVariant, and switch mode="contained" to mode="filled" in the react-navigation and ripple-effect guides. The Button.mdx page is generated from these sources plus the component JSDoc.
Motivation
Buttonset its label throughchildren, which coupled the internal layout to arbitrary child structures, madeuppercaseunreliable (it couldn't transform React elements), and forced an extraTextwrapper. Icon placement on the trailing edge relied on the undocumentedcontentStyle={{ flexDirection: 'row-reverse' }}hack, the ripple/state-layer color didn't follow the label color as MD3 specifies, and the render derived a number of style objects on every render.This PR (part of the v6 work) modernizes the Button API and rendering without removing anything:
labelprop as the primary way to set the button text.childrenkeeps working as a deprecated fallback (when both are set,labelwins) and emits a dev-only warning, so existing code is unaffected.iconPosition?: 'leading' | 'trailing'prop. The previouscontentStylerow-reverse approach still works but is deprecated (dev-only warning) — the icon-margin matrix is extracted into agetButtonIconStylehelper.rippleColor?: ColorValueprop; by default the ripple / state layer now uses the label color at the pressed-state opacity per MD3 (instead ofTouchableRipple'sonSurface-based default), with a graceful fallback when the label color is aPlatformColor.getButtonColors, border-radius extraction, ripple color, icon style, touchable-ripple style, flattenedstyles) in
useMemo, memoizes the press handlers, and drops theisMode useCallback.Banner,Snackbar,DataTablePagination), the example app, and the guide/docs-site snippets to the new props.Migration:
<Button>Text</Button>→<Button label="Text" />;contentStyle={{ flexDirection: 'row-reverse' }}→iconPosition="trailing".Related issue
closes #4928
Test plan
yarn typescript,yarn lint,yarn testall pass (snapshots updated where theiconPositionrestructure changed the rendered tree; reviewed —the only diffs are a short-circuit
falseslot in the content style array, the icon-container style going from a 3-element array to an equivalent single object, androw-reversemoving into astyles.contentReverse).labelrenders / takes precedence overchildren; deprecated children still renders and warns;iconPosition="trailing"and the legacycontentStylefallback (+ warning);getButtonRippleColor(custom color, default = label color @ pressed opacity,PlatformColor→ undefined).iconPositionand the legacycontentStylepath), loading spinner placement, disabled appearance, compact, custom radius, elevated press-elevation animation, and the ripple color matching the label color.