diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts index 92bf8f2788..f645602337 100644 --- a/docs/src/data/screenshots.ts +++ b/docs/src/data/screenshots.ts @@ -81,10 +81,10 @@ export const screenshots = { 'FAB.Group': 'screenshots/fab-group.gif', Icon: 'screenshots/icon.png', IconButton: { - default: 'screenshots/icon-button-1.png', + standard: 'screenshots/icon-button-1.png', + filled: 'screenshots/icon-button-2.png', + tonal: 'screenshots/icon-button-3.png', outlined: 'screenshots/icon-button-4.png', - contained: 'screenshots/icon-button-2.png', - 'contained-tonal': 'screenshots/icon-button-3.png', }, 'List.Accordion': { 'with left icon': 'screenshots/list-accordion-1.png', diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts index 20f16962f7..91d583a95b 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -191,56 +191,72 @@ export const themeColors = { }, }, IconButton: { + default: { + standard: { + iconColor: 'theme.colors.onSurfaceVariant', + }, + outlined: { + iconColor: 'theme.colors.onSurfaceVariant', + borderColor: 'theme.colors.outlineVariant', + }, + filled: { + backgroundColor: 'theme.colors.primary', + iconColor: 'theme.colors.onPrimary', + }, + tonal: { + backgroundColor: 'theme.colors.secondaryContainer', + iconColor: 'theme.colors.onSecondaryContainer', + }, + }, selected: { - default: { + standard: { iconColor: 'theme.colors.primary', }, outlined: { backgroundColor: 'theme.colors.inverseSurface', iconColor: 'theme.colors.inverseOnSurface', }, - contained: { + filled: { backgroundColor: 'theme.colors.primary', iconColor: 'theme.colors.onPrimary', }, - 'contained-tonal': { - backgroundColor: 'theme.colors.secondaryContainer', - iconColor: 'theme.colors.onSecondaryContainer', + tonal: { + backgroundColor: 'theme.colors.secondary', + iconColor: 'theme.colors.onSecondary', }, }, unselected: { - default: { + standard: { iconColor: 'theme.colors.onSurfaceVariant', }, outlined: { iconColor: 'theme.colors.onSurfaceVariant', - borderColor: 'theme.colors.outline', - }, - contained: { - backgroundColor: 'theme.colors.surfaceVariant', - iconColor: 'theme.colors.primary', + borderColor: 'theme.colors.outlineVariant', }, - 'contained-tonal': { - backgroundColor: 'theme.colors.surfaceVariant', + filled: { + backgroundColor: 'theme.colors.surfaceContainer', iconColor: 'theme.colors.onSurfaceVariant', }, + tonal: { + backgroundColor: 'theme.colors.secondaryContainer', + iconColor: 'theme.colors.onSecondaryContainer', + }, }, disabled: { - default: { - iconColor: 'theme.colors.onSurfaceDisabled', + standard: { + iconColor: 'theme.colors.onSurface', }, outlined: { - backgroundColor: 'theme.colors.surfaceDisabled', - iconColor: 'theme.colors.onSurfaceDisabled', - borderColor: 'theme.colors.surfaceDisabled', + iconColor: 'theme.colors.onSurface', + borderColor: 'theme.colors.outlineVariant', }, - contained: { - backgroundColor: 'theme.colors.surfaceDisabled', - iconColor: 'theme.colors.onSurfaceDisabled', + filled: { + backgroundColor: 'theme.colors.onSurface', + iconColor: 'theme.colors.onSurface', }, - 'contained-tonal': { - backgroundColor: 'theme.colors.surfaceDisabled', - iconColor: 'theme.colors.onSurfaceDisabled', + tonal: { + backgroundColor: 'theme.colors.onSurface', + iconColor: 'theme.colors.onSurface', }, }, }, diff --git a/example/src/Examples/BadgeExample.tsx b/example/src/Examples/BadgeExample.tsx index 7ca1a43f9c..41fabfc9ed 100644 --- a/example/src/Examples/BadgeExample.tsx +++ b/example/src/Examples/BadgeExample.tsx @@ -29,13 +29,17 @@ const BadgeExample = () => { - + 12 - + { - + - + diff --git a/example/src/Examples/CardExample.tsx b/example/src/Examples/CardExample.tsx index 016bef3e21..32c6540d28 100644 --- a/example/src/Examples/CardExample.tsx +++ b/example/src/Examples/CardExample.tsx @@ -83,9 +83,7 @@ const CardExample = () => { title="Berries that are trimmed at the end" subtitle="Omega Ruby" left={(props: any) => } - right={(props: any) => ( - {}} /> - )} + right={() => {}} />} /> @@ -138,10 +136,10 @@ const CardExample = () => { ( + right={() => ( setIsSelected(!isSelected)} /> )} diff --git a/example/src/Examples/IconButtonExample.tsx b/example/src/Examples/IconButtonExample.tsx index 5fb463880b..628914ecf4 100644 --- a/example/src/Examples/IconButtonExample.tsx +++ b/example/src/Examples/IconButtonExample.tsx @@ -1,203 +1,295 @@ -import { StyleSheet, View } from 'react-native'; +import * as React from 'react'; +import { ScrollView, StyleSheet, View } from 'react-native'; -import { IconButton, List, Palette } from 'react-native-paper'; +import { + Chip, + IconButton, + List, + Palette, + Text, + useTheme, +} from 'react-native-paper'; +import type { + IconButtonMode, + IconButtonShape, + IconButtonSize, + IconButtonWidth, +} from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; -const ButtonExample = () => { - return ( - - - - {}} /> - {}} /> - {}} /> - {}} loading /> - - +const modes: IconButtonMode[] = ['standard', 'filled', 'tonal', 'outlined']; +const sizes: IconButtonSize[] = [ + 'extraSmall', + 'small', + 'medium', + 'large', + 'extraLarge', +]; +const widths: IconButtonWidth[] = ['narrow', 'default', 'wide']; +const shapes: IconButtonShape[] = ['round', 'square']; - - - {}} - /> +type ChipRowProps = { + label: string; + options: readonly T[]; + value: T; + onChange: (value: T) => void; +}; + +const ChipRow = ({ + label, + options, + value, + onChange, +}: ChipRowProps) => ( + + + {label} + + + {options.map((option) => ( + onChange(option)} + > + {option} + + ))} + + +); + +const IconButtonExample = () => { + const { colors } = useTheme(); + const [mode, setMode] = React.useState('filled'); + const [size, setSize] = React.useState('small'); + const [width, setWidth] = React.useState('default'); + const [shape, setShape] = React.useState('round'); + const [selected, setSelected] = React.useState(false); + const [toggles, setToggles] = React.useState>({}); + + const toggle = (key: string) => { + setToggles((current) => ({ ...current, [key]: !current[key] })); + }; + + return ( + + + + + + + {}} + icon={selected ? 'camera' : 'camera-outline'} + mode={mode} + size={size} + width={width} + shape={shape} + selected={selected} + onPress={() => setSelected((value) => !value)} + accessibilityLabel="Playground icon button" /> {}} /> {}} /> + + Press the first button to toggle. Pressed and selected states morph + the container shape. + + + + + {modes.map((colorMode) => ( + + + {colorMode} + + {}} + /> + toggle(`${colorMode}-off`)} + /> + {}} + /> + {}} + /> + + ))} + + Default, toggle off, toggle on, disabled. + - + - {}} - /> - {}} - /> - {}} - /> - {}} - loading - /> + {sizes.map((item) => ( + {}} + accessibilityLabel={`${item} icon button`} + /> + ))} - + + + {widths.map((item) => ( + {}} + accessibilityLabel={`${item} width icon button`} + /> + ))} + + + + {}} - /> - {}} - /> - {}} + icon={toggles.round ? 'heart' : 'heart-outline'} + mode="filled" + shape="round" + selected={!!toggles.round} + onPress={() => toggle('round')} /> {}} - loading + icon={toggles.square ? 'heart' : 'heart-outline'} + mode="filled" + shape="square" + selected={!!toggles.square} + onPress={() => toggle('square')} /> + + Round (default) and square. Toggle inverts the resting shape. + {}} - /> - {}} - /> - {}} /> {}} /> {}} - /> - {}} /> - {}} - containerColor={Palette.tertiary60} /> - {}} /> - {}} loading /> ); }; -ButtonExample.title = 'Icon Button'; +IconButtonExample.title = 'Icon Button'; const styles = StyleSheet.create({ - v3Container: { + container: { flexDirection: 'column', + paddingHorizontal: 12, + paddingBottom: 24, }, - row: { + chipRow: { flexDirection: 'row', - paddingHorizontal: 12, + alignItems: 'center', + marginBottom: 8, }, - square: { - borderRadius: 0, + chipRowLabel: { + width: 56, + marginRight: 8, }, - slightlyRounded: { - borderRadius: 4, - width: 48, - height: 48, + chipRowContent: { + gap: 8, + paddingVertical: 4, }, - contentPadding: { - padding: 8, + playgroundRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: 8, + paddingVertical: 12, + }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + }, + colorRow: { + flexDirection: 'row', + alignItems: 'center', }, - differentBorderRadius: { - borderTopLeftRadius: 2, - borderTopRightRadius: 4, - borderBottomLeftRadius: 8, - borderBottomRightRadius: 6, + colorLabel: { + width: 88, }, }); -export default ButtonExample; +export default IconButtonExample; diff --git a/example/src/Examples/SurfaceExample.tsx b/example/src/Examples/SurfaceExample.tsx index e941c7c843..19b2888fe5 100644 --- a/example/src/Examples/SurfaceExample.tsx +++ b/example/src/Examples/SurfaceExample.tsx @@ -20,13 +20,13 @@ const AnimatedSurface = () => { setIndex(index - 1)} /> setIndex(index + 1)} diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx index 325e0f0113..f78b69654f 100644 --- a/example/src/Examples/TooltipExample.tsx +++ b/example/src/Examples/TooltipExample.tsx @@ -84,7 +84,6 @@ const TooltipExample = () => { > {}} /> diff --git a/package.json b/package.json index 6db11f9fef..cd01ee03a3 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ ], "scripts": { "lint": "eslint", - "typecheck": "tsc -b", + "typecheck": "node ./node_modules/typescript/bin/tsc -b", "test": "jest", "prepack": "bob build", "generate-mappings": "node ./scripts/generate-mappings.ts", diff --git a/src/components/Appbar/AppbarAction.tsx b/src/components/Appbar/AppbarAction.tsx index 1c358397c9..3d8fbde25b 100644 --- a/src/components/Appbar/AppbarAction.tsx +++ b/src/components/Appbar/AppbarAction.tsx @@ -18,10 +18,6 @@ export type Props = React.PropsWithoutRef & { * Name of the icon to show. */ icon: IconSource; - /** - * Optional icon size. - */ - size?: number; /** * Whether the button is disabled. A disabled button is greyed out and `onPress` is not called on touch. */ @@ -71,7 +67,6 @@ export type Props = React.PropsWithoutRef & { * ``` */ const AppbarAction = ({ - size = 24, color: iconColor, icon, disabled, @@ -93,7 +88,6 @@ const AppbarAction = ({ return ( , 'icon'> & { * Custom color for back icon. */ color?: ColorValue; - /** - * Optional icon size. - */ - size?: number; /** * Whether the button is disabled. A disabled button is greyed out and `onPress` is not called on touch. */ diff --git a/src/components/Card/CardTitle.tsx b/src/components/Card/CardTitle.tsx index 7332c23014..fb1c74649f 100644 --- a/src/components/Card/CardTitle.tsx +++ b/src/components/Card/CardTitle.tsx @@ -119,7 +119,7 @@ const LEFT_SIZE = 40; * title="Card Title" * subtitle="Card Subtitle" * left={(props) => } - * right={(props) => {}} />} + * right={() => {}} />} * /> * ); * diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index 62b86d3754..d7667b9cdd 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -7,10 +7,19 @@ import type { ViewStyle, } from 'react-native'; -import Animated, { type AnimatedStyle } from 'react-native-reanimated'; +import Animated, { + type AnimatedStyle, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; -import { getIconButtonColor } from './utils'; +import { FOCUS_RING_INSET, FOCUS_RING_THICKNESS, webNoOutline } from './tokens'; +import type { Mode, Shape, Size, Width } from './tokens'; +import { getDimensions, getHitSlop, getIconButtonColor } from './utils'; import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; import type { ThemeProp } from '../../theme/types'; import ActivityIndicator from '../ActivityIndicator'; import CrossFadeIcon from '../CrossFadeIcon'; @@ -19,23 +28,23 @@ import type { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; -const PADDING = 8; - -type IconButtonMode = 'outlined' | 'contained' | 'contained-tonal'; - export type Props = Omit< React.PropsWithoutRef, - 'children' | 'style' + 'children' | 'style' | 'onPress' > & { /** * Icon to display. */ icon: IconSource; /** - * @supported Available in v5.x with theme version 3 - * Mode of the icon button. By default there is no specified mode - only pressable icon will be rendered. + * Color style of the icon button. + * + * - `standard` — no container fill (default) + * - `filled` — primary container + * - `tonal` — secondary-container fill + * - `outlined` — outline, no fill (selected uses inverse surface) */ - mode?: IconButtonMode; + mode?: Mode; /** * @renamed Renamed from 'color' to 'iconColor' in v5.x * Color of the icon. @@ -46,13 +55,29 @@ export type Props = Omit< */ containerColor?: ColorValue; /** - * Whether icon button is selected. A selected button receives alternative combination of icon and container colors. + * Toggle state. Omit for the default (non-toggle) color set; + * `true` / `false` use the toggle-ON / toggle-OFF color sets and invert + * the resting shape. */ selected?: boolean; /** - * Size of the icon. + * Named size on the MD3 Expressive scale. Defaults to `small`. + */ + size?: Size; + /** + * Horizontal padding around the icon: `narrow`, `default`, or `wide`. + * Uniform (`default`) sizes are square. + */ + width?: Width; + /** + * Resting container shape. Toggle-ON inverts round ↔ square. Pressed + * uses a shared corner for both shapes. + */ + shape?: Shape; + /** + * Icon size override in dp. Defaults to the size token. */ - size?: number; + iconSize?: number; /** * Whether the button is disabled. A disabled button is greyed out and `onPress` is not called on touch. */ @@ -67,7 +92,7 @@ export type Props = Omit< 'aria-label'?: string; /** * Style of button's inner content. - * Use this prop to apply custom height and width or to set a custom padding`. + * Use this prop to apply custom height and width or to set a custom padding. */ contentStyle?: StyleProp; /** @@ -90,6 +115,26 @@ export type Props = Omit< loading?: boolean; }; +const useFocusRing = () => { + const focusedSV = useSharedValue(false); + + const onFocus = React.useCallback(() => { + if ( + Platform.OS === 'web' && + !document.activeElement?.matches(':focus-visible') + ) { + return; + } + focusedSV.value = true; + }, [focusedSV]); + + const onBlur = React.useCallback(() => { + focusedSV.value = false; + }, [focusedSV]); + + return { focusedSV, onFocus, onBlur }; +}; + /** * An icon button is a button which displays only an icon without a label. * @@ -102,7 +147,7 @@ export type Props = Omit< * console.log('Pressed')} * /> * ); @@ -116,31 +161,49 @@ const IconButton = ({ icon, iconColor: customIconColor, containerColor: customContainerColor, - size = 24, + size = 'small', + width = 'default', + shape = 'round', + iconSize, 'aria-label': ariaLabel, disabled, onPress, - selected = false, + selected, animated = false, - mode, + mode = 'standard', style, theme: themeOverrides, testID = 'icon-button', loading = false, contentStyle, ref, + onPressIn: onPressInProp, + onPressOut: onPressOutProp, + onFocus: onFocusProp, + onBlur: onBlurProp, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); const IconComponent = animated ? CrossFadeIcon : Icon; + const dimensions = getDimensions({ + theme, + size, + width, + shape, + selected, + iconSize, + }); + const { iconColor, iconOpacity, backgroundColor, borderColor, backgroundOpacity, + borderWidth, } = getIconButtonColor({ theme, disabled, @@ -148,16 +211,92 @@ const IconButton = ({ mode, customIconColor, customContainerColor, + outlineWidth: dimensions.outlineWidth, }); - const buttonSize = size + 2 * PADDING; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const flattenedStyle = StyleSheet.flatten(style as StyleProp); + const customRadius = flattenedStyle?.borderRadius; + const hasCustomRadius = typeof customRadius === 'number'; + const restingRadius = hasCustomRadius + ? customRadius + : dimensions.restingRadius; + const pressedRadius = hasCustomRadius + ? customRadius + : dimensions.pressedRadius; - const borderStyles = { - borderWidth: mode === 'outlined' && !selected ? 1 : 0, - borderRadius: buttonSize / 2, - borderColor, + const radius = useSharedValue(restingRadius); + const isFirstRadiusSync = React.useRef(true); + + const springTo = React.useCallback( + (target: number) => { + if (reduceMotion) { + radius.value = target; + return; + } + radius.value = withSpring( + target, + toRawSpring(theme.motion.spring.fast.spatial) + ); + }, + [radius, reduceMotion, theme] + ); + + React.useEffect(() => { + if (isFirstRadiusSync.current) { + isFirstRadiusSync.current = false; + radius.value = restingRadius; + return; + } + springTo(restingRadius); + }, [radius, restingRadius, springTo]); + + const handlePressIn = (e: GestureResponderEvent) => { + springTo(pressedRadius); + onPressInProp?.(e); + }; + + const handlePressOut = (e: GestureResponderEvent) => { + springTo(restingRadius); + onPressOutProp?.(e); + }; + + const { focusedSV, onFocus, onBlur } = useFocusRing(); + + const handleFocus = (e: Parameters>[0]) => { + onFocus(); + onFocusProp?.(e); }; + const handleBlur = (e: Parameters>[0]) => { + onBlur(); + onBlurProp?.(e); + }; + + const outerStyle = useAnimatedStyle( + () => ({ + borderRadius: radius.value, + }), + [radius] + ); + + const clipStyle = useAnimatedStyle( + () => ({ + borderRadius: radius.value, + }), + [radius] + ); + + const focusRingStyle = useAnimatedStyle( + () => ({ + opacity: focusedSV.value ? 1 : 0, + borderRadius: radius.value + FOCUS_RING_INSET, + }), + [radius] + ); + + const hitSlop = getHitSlop(dimensions.width, dimensions.height); + return ( - {backgroundOpacity < 1 && ( - + {backgroundOpacity < 1 && ( + + )} + - )} - - - {loading ? ( - - ) : ( - - )} - - + role="button" + aria-disabled={disabled} + disabled={disabled} + hitSlop={hitSlop} + testID={testID} + {...rest} + > + + {loading ? ( + + ) : ( + + )} + + + + ); }; const styles = StyleSheet.create({ container: { - margin: 6, + overflow: 'visible', + }, + clip: { + width: '100%', + height: '100%', overflow: 'hidden', }, touchable: { @@ -221,6 +392,15 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + focusRing: { + position: 'absolute', + top: -FOCUS_RING_INSET, + left: -FOCUS_RING_INSET, + right: -FOCUS_RING_INSET, + bottom: -FOCUS_RING_INSET, + borderWidth: FOCUS_RING_THICKNESS, + pointerEvents: 'none', + }, }); export default IconButton; diff --git a/src/components/IconButton/tokens.ts b/src/components/IconButton/tokens.ts new file mode 100644 index 0000000000..7e61b7bd4e --- /dev/null +++ b/src/components/IconButton/tokens.ts @@ -0,0 +1,167 @@ +import type { ViewStyle } from 'react-native'; + +import { tokens } from '../../theme/tokens'; +import type { ColorRole } from '../../theme/types'; +import type { ShapeToken } from '../../theme/utils/shape'; + +export type Size = 'extraSmall' | 'small' | 'medium' | 'large' | 'extraLarge'; + +export type Width = 'narrow' | 'default' | 'wide'; + +export type Shape = 'round' | 'square'; + +export type Mode = 'standard' | 'filled' | 'tonal' | 'outlined'; + +type WidthSpec = { + leading: number; + trailing: number; +}; + +type SizeSpec = { + containerHeight: number; + icon: number; + outlineWidth: number; + squareShape: ShapeToken; + pressedShape: ShapeToken; + widths: Record; +}; + +export type ColorSet = { + container?: ColorRole; + icon: ColorRole; + outline?: ColorRole; +}; + +/** + * MD3 Expressive Icon Button size scale. + * @see https://m3.material.io/components/icon-buttons/specs + * @see androidx `{XSmall|Small|Medium|Large|XLarge}IconButtonTokens` 14_1_0 + */ +const sizes = { + extraSmall: { + containerHeight: 32, + icon: 20, + outlineWidth: 1, + squareShape: 'medium', + pressedShape: 'small', + widths: { + narrow: { leading: 4, trailing: 4 }, + default: { leading: 6, trailing: 6 }, + wide: { leading: 10, trailing: 10 }, + }, + }, + small: { + containerHeight: 40, + icon: 24, + outlineWidth: 1, + squareShape: 'medium', + pressedShape: 'small', + widths: { + narrow: { leading: 4, trailing: 4 }, + default: { leading: 8, trailing: 8 }, + wide: { leading: 14, trailing: 14 }, + }, + }, + medium: { + containerHeight: 56, + icon: 24, + outlineWidth: 1, + squareShape: 'large', + pressedShape: 'medium', + widths: { + narrow: { leading: 12, trailing: 12 }, + default: { leading: 16, trailing: 16 }, + wide: { leading: 24, trailing: 24 }, + }, + }, + large: { + containerHeight: 96, + icon: 32, + outlineWidth: 2, + squareShape: 'extraLarge', + pressedShape: 'large', + widths: { + narrow: { leading: 16, trailing: 16 }, + default: { leading: 32, trailing: 32 }, + wide: { leading: 48, trailing: 48 }, + }, + }, + extraLarge: { + containerHeight: 136, + icon: 40, + outlineWidth: 3, + squareShape: 'extraLarge', + pressedShape: 'large', + widths: { + narrow: { leading: 32, trailing: 32 }, + default: { leading: 48, trailing: 48 }, + wide: { leading: 72, trailing: 72 }, + }, + }, +} as const satisfies Record; + +/** + * Default (non-toggle) vs toggle selected/unselected color roles. + * Filled default uses `primary`; toggle-OFF uses `surfaceContainer`. + * @see androidx `FilledIconButtonTokens` / `FilledTonalIconButtonTokens` / + * `OutlinedIconButtonTokens` / `StandardIconButtonTokens` 14_1_0 + */ +const modes = { + filled: { + default: { container: 'primary', icon: 'onPrimary' }, + selected: { container: 'primary', icon: 'onPrimary' }, + unselected: { container: 'surfaceContainer', icon: 'onSurfaceVariant' }, + }, + tonal: { + default: { container: 'secondaryContainer', icon: 'onSecondaryContainer' }, + selected: { container: 'secondary', icon: 'onSecondary' }, + unselected: { + container: 'secondaryContainer', + icon: 'onSecondaryContainer', + }, + }, + outlined: { + default: { icon: 'onSurfaceVariant', outline: 'outlineVariant' }, + selected: { container: 'inverseSurface', icon: 'inverseOnSurface' }, + unselected: { icon: 'onSurfaceVariant', outline: 'outlineVariant' }, + }, + standard: { + default: { icon: 'onSurfaceVariant' }, + selected: { icon: 'primary' }, + unselected: { icon: 'onSurfaceVariant' }, + }, +} satisfies Record< + Mode, + { default: ColorSet; selected: ColorSet; unselected: ColorSet } +>; + +const disabled = { + container: 'onSurface', + icon: 'onSurface', + outline: 'outlineVariant', + /** Filled/tonal (and outlined-selected) disabled container opacity. */ + containerOpacity: 0.1, +} as const satisfies { + container: ColorRole; + icon: ColorRole; + outline: ColorRole; + containerOpacity: number; +}; + +/** Extra-small and small icon buttons must meet a 48dp touch target. */ +const minTouchTarget = 48; + +const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; +export const FOCUS_RING_THICKNESS = thickness; +export const FOCUS_RING_OUTER_OFFSET = outerOffset; +export const FOCUS_RING_INSET = outerOffset + thickness; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +export const webNoOutline = { outline: 'none' } as unknown as ViewStyle; + +export const IconButtonTokens = { + sizes, + modes, + disabled, + minTouchTarget, +}; diff --git a/src/components/IconButton/utils.ts b/src/components/IconButton/utils.ts index 3b43fa2a4a..760ea3b4bd 100644 --- a/src/components/IconButton/utils.ts +++ b/src/components/IconButton/utils.ts @@ -1,149 +1,200 @@ import type { ColorValue } from 'react-native'; +import { IconButtonTokens } from './tokens'; +import type { ColorSet, Mode, Shape, Size, Width } from './tokens'; import { tokens } from '../../theme/tokens'; import type { InternalTheme } from '../../theme/types'; +import { resolveCornerRadius } from '../../theme/utils/shape'; +import type { ShapeToken } from '../../theme/utils/shape'; const stateOpacity = tokens.md.sys.state.opacity; -type IconButtonMode = 'outlined' | 'contained' | 'contained-tonal'; - -type BaseProps = { - theme: InternalTheme; - isMode: (mode: IconButtonMode) => boolean; - disabled?: boolean; - selected?: boolean; +export type IconButtonColors = { + iconColor: ColorValue; + iconOpacity: number; + backgroundColor: ColorValue | undefined; + backgroundOpacity: number; + borderColor: ColorValue; + borderWidth: number; }; -const getBackgroundColor = ({ - theme, - isMode, - disabled, - selected, - customContainerColor, -}: BaseProps & { customContainerColor?: ColorValue }) => { - if (disabled) { - if (isMode('contained') || isMode('contained-tonal')) { - return theme.colors.onSurface; - } - } - - if (typeof customContainerColor !== 'undefined') { - return customContainerColor; - } +export type IconButtonDimensions = { + width: number; + height: number; + iconSize: number; + outlineWidth: number; + restingRadius: number; + pressedRadius: number; +}; - if (isMode('contained')) { - if (selected) { - return theme.colors.primary; - } - return theme.colors.surfaceVariant; +const colorSetFor = (mode: Mode, selected: boolean | undefined): ColorSet => { + if (selected === true) { + return IconButtonTokens.modes[mode].selected; } - - if (isMode('contained-tonal')) { - if (selected) { - return theme.colors.secondaryContainer; - } - return theme.colors.surfaceVariant; + if (selected === false) { + return IconButtonTokens.modes[mode].unselected; } + return IconButtonTokens.modes[mode].default; +}; - if (isMode('outlined')) { - if (selected) { - return theme.colors.inverseSurface; - } +const hasDisabledContainer = ( + mode: Mode, + selected: boolean | undefined +): boolean => { + if (mode === 'filled' || mode === 'tonal') { + return true; } - - return undefined; + return mode === 'outlined' && selected === true; }; -const getIconColor = ({ +/** + * Resolve container, icon, and outline colors for an icon button. + * + * `selected` is a tri-state: `undefined` is the default (non-toggle) + * appearance; `true`/`false` are the toggle-ON / toggle-OFF color sets. + */ +export const getIconButtonColor = ({ theme, - isMode, disabled, + mode = 'standard', selected, customIconColor, -}: BaseProps & { customIconColor?: ColorValue }) => { - if (disabled) { - return theme.colors.onSurface; + customContainerColor, + outlineWidth = 0, +}: { + theme: InternalTheme; + disabled?: boolean; + mode?: Mode; + selected?: boolean; + customIconColor?: ColorValue; + customContainerColor?: ColorValue; + outlineWidth?: number; +}): IconButtonColors => { + const iconOpacity = disabled ? stateOpacity.disabled : stateOpacity.enabled; + const usesDisabledContainer = + Boolean(disabled) && hasDisabledContainer(mode, selected); + + if (usesDisabledContainer) { + return { + iconColor: + customIconColor ?? theme.colors[IconButtonTokens.disabled.icon], + iconOpacity, + backgroundColor: theme.colors[IconButtonTokens.disabled.container], + backgroundOpacity: IconButtonTokens.disabled.containerOpacity, + borderColor: theme.colors[IconButtonTokens.disabled.outline], + borderWidth: 0, + }; } - if (typeof customIconColor !== 'undefined') { - return customIconColor; + if (disabled) { + const set = colorSetFor(mode, selected); + return { + iconColor: + customIconColor ?? theme.colors[IconButtonTokens.disabled.icon], + iconOpacity, + backgroundColor: customContainerColor, + backgroundOpacity: stateOpacity.enabled, + borderColor: + theme.colors[set.outline ?? IconButtonTokens.disabled.outline], + borderWidth: set.outline ? outlineWidth : 0, + }; } - if (isMode('contained')) { - if (selected) { - return theme.colors.onPrimary; - } - return theme.colors.primary; - } + const set = colorSetFor(mode, selected); + const backgroundColor = + customContainerColor ?? + (set.container ? theme.colors[set.container] : undefined); - if (isMode('contained-tonal')) { - if (selected) { - return theme.colors.onSecondaryContainer; - } - return theme.colors.onSurfaceVariant; - } + return { + iconColor: customIconColor ?? theme.colors[set.icon], + iconOpacity, + backgroundColor, + backgroundOpacity: stateOpacity.enabled, + borderColor: theme.colors[set.outline ?? 'outlineVariant'], + borderWidth: set.outline ? outlineWidth : 0, + }; +}; - if (isMode('outlined')) { - if (selected) { - return theme.colors.inverseOnSurface; - } - return theme.colors.onSurfaceVariant; +/** + * Resting container shape. Toggle-ON inverts round↔square; pressed uses + * the shared pressed shape (same for round and square). + */ +export const resolveShapeToken = ({ + size = 'small', + shape = 'round', + selected, + pressed = false, +}: { + size?: Size; + shape?: Shape; + selected?: boolean; + pressed?: boolean; +}): ShapeToken => { + const spec = IconButtonTokens.sizes[size]; + if (pressed) { + return spec.pressedShape; } - - if (selected) { - return theme.colors.primary; + const inverted = selected === true; + if (shape === 'round') { + return inverted ? spec.squareShape : 'full'; } - return theme.colors.onSurfaceVariant; + return inverted ? 'full' : spec.squareShape; }; -export const getIconButtonColor = ({ +export const getDimensions = ({ theme, - disabled, - mode, + size = 'small', + width = 'default', + shape = 'round', selected, - customIconColor, - customContainerColor, + iconSize, }: { theme: InternalTheme; - disabled?: boolean; + size?: Size; + width?: Width; + shape?: Shape; selected?: boolean; - mode?: IconButtonMode; - customIconColor?: ColorValue; - customContainerColor?: ColorValue; -}) => { - const isMode = (modeToCompare: IconButtonMode) => { - return mode === modeToCompare; - }; - - const baseIconColorProps = { - theme, - isMode, - disabled, + iconSize?: number; +}): IconButtonDimensions => { + const spec = IconButtonTokens.sizes[size]; + const padding = spec.widths[width]; + const resolvedIcon = iconSize ?? spec.icon; + const height = spec.containerHeight; + const containerWidth = resolvedIcon + padding.leading + padding.trailing; + + const restingToken = resolveShapeToken({ + size, + shape, selected, - }; - - const iconColor = getIconColor({ - ...baseIconColorProps, - customIconColor, + pressed: false, }); - - const iconOpacity = disabled ? stateOpacity.disabled : stateOpacity.enabled; - - const backgroundColor = getBackgroundColor({ - ...baseIconColorProps, - customContainerColor, + const pressedToken = resolveShapeToken({ + size, + shape, + selected, + pressed: true, }); - const backgroundOpacity = - disabled && (isMode('contained') || isMode('contained-tonal')) - ? stateOpacity.disabled - : stateOpacity.enabled; + const roundRadius = height / 2; + const resolveRadius = (token: ShapeToken) => + token === 'full' ? roundRadius : resolveCornerRadius(theme, token); return { - iconColor, - iconOpacity, - backgroundColor, - borderColor: theme.colors.outlineVariant, - backgroundOpacity, + width: containerWidth, + height, + iconSize: resolvedIcon, + outlineWidth: spec.outlineWidth, + restingRadius: resolveRadius(restingToken), + pressedRadius: resolveRadius(pressedToken), }; }; + +export const getHitSlop = (width: number, height: number) => { + const min = IconButtonTokens.minTouchTarget; + const extraX = Math.max(0, (min - width) / 2); + const extraY = Math.max(0, (min - height) / 2); + if (extraX === 0 && extraY === 0) { + return undefined; + } + return { top: extraY, bottom: extraY, left: extraX, right: extraX }; +}; diff --git a/src/components/TextInput/TextInputIcon.tsx b/src/components/TextInput/TextInputIcon.tsx index 012e2f45f9..a1f8a4fc21 100644 --- a/src/components/TextInput/TextInputIcon.tsx +++ b/src/components/TextInput/TextInputIcon.tsx @@ -16,7 +16,12 @@ export type TextInputAccessoryProps = { }; export type TextInputIconProps = TextInputAccessoryProps & - Omit; + Omit & { + /** + * Size of the icon. + */ + size?: number; + }; /** * A component to render a leading / trailing icon in the TextInput @@ -85,7 +90,7 @@ const TextInputIcon = ({ {...rest} icon={icon} iconColor={color} - size={iconSize} + iconSize={iconSize} style={[styles.icon, style]} onPress={onPressHandler} /> diff --git a/src/components/ToggleButton/ToggleButton.tsx b/src/components/ToggleButton/ToggleButton.tsx index 72f6dc3161..7d98584710 100644 --- a/src/components/ToggleButton/ToggleButton.tsx +++ b/src/components/ToggleButton/ToggleButton.tsx @@ -124,7 +124,7 @@ const ToggleButton = ({ context.onValueChange(!checked ? value : null); } }} - size={size} + iconSize={size} aria-label={ariaLabel} aria-disabled={disabled} aria-selected={checked} diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 144acc3d32..264ad6056e 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -55,7 +55,7 @@ export type Props = { * * const MyComponent = () => ( * - * {}} /> + * {}} /> * * ); * diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index 882652f92d..fa754bd692 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -194,120 +194,162 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } testID="search-bar-icon-container" > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - + + - close - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + + + @@ -588,166 +672,208 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } testID="icon-button-container" > - - + - + > + + + - + - - menu - + + menu + + + `; diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index a8bc540aa9..91d29af40f 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -1,15 +1,20 @@ import { StyleSheet } from 'react-native'; -import { describe, expect, it } from '@jest/globals'; +import { describe, expect, it, jest } from '@jest/globals'; import { getTheme } from '../../core/theming'; -import { render, screen } from '../../test-utils'; +import { render, screen, userEvent } from '../../test-utils'; import { pink500 } from '../../theme/colors'; import { tokens } from '../../theme/tokens'; import IconButton from '../IconButton/IconButton'; -import { getIconButtonColor } from '../IconButton/utils'; +import { + getDimensions, + getHitSlop, + getIconButtonColor, +} from '../IconButton/utils'; const stateOpacity = tokens.md.sys.state.opacity; +const theme = getTheme(); const styles = StyleSheet.create({ square: { @@ -18,6 +23,9 @@ const styles = StyleSheet.create({ slightlyRounded: { borderRadius: 4, }, + scaled: { + transform: [{ scale: 1 }], + }, }); it('renders icon button by default', async () => { @@ -26,16 +34,42 @@ it('renders icon button by default', async () => { expect(tree).toMatchSnapshot(); }); -it('renders icon button with color', async () => { +it('renders filled icon button', async () => { const tree = ( - await render() + await render() + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders tonal icon button', async () => { + const tree = ( + await render() ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders icon button with size', async () => { - const tree = (await render()).toJSON(); +it('renders outlined icon button', async () => { + const tree = ( + await render() + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders extraLarge icon button', async () => { + const tree = ( + await render() + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders icon button with color', async () => { + const tree = ( + await render() + ).toJSON(); expect(tree).toMatchSnapshot(); }); @@ -57,7 +91,6 @@ it('renders icon button with custom border radius', async () => { {}} style={styles.square} /> @@ -73,7 +106,6 @@ it('renders icon button with small border radius', async () => { {}} style={styles.slightlyRounded} /> @@ -84,11 +116,33 @@ it('renders icon button with small border radius', async () => { }); }); +it('applies a static transform from style', async () => { + await render( + + ); + + expect(screen.getByTestId('icon-button-container')).toHaveStyle({ + transform: [{ scale: 1 }], + }); +}); + +it('calls onPress', async () => { + const onPress = jest.fn(); + const user = userEvent.setup(); + + await render( + + ); + await user.press(screen.getByTestId('icon-button')); + + expect(onPress).toHaveBeenCalledTimes(1); +}); + describe('getIconButtonColor - icon color', () => { it('should return custom icon color', () => { expect( getIconButtonColor({ - theme: getTheme(), + theme, customIconColor: 'purple', }) ).toMatchObject({ @@ -96,105 +150,106 @@ describe('getIconButtonColor - icon color', () => { }); }); - it('should return correct disabled color, for theme version 3', () => { + it('should return disabled icon color', () => { expect( getIconButtonColor({ - theme: getTheme(), + theme, disabled: true, }) ).toMatchObject({ - iconColor: getTheme().colors.onSurface, + iconColor: theme.colors.onSurface, iconOpacity: stateOpacity.disabled, }); }); - it('should return theme icon color, for theme version 3, mode contained', () => { + it('should return filled default icon color', () => { expect( getIconButtonColor({ - theme: getTheme(), - mode: 'contained', + theme, + mode: 'filled', }) ).toMatchObject({ - iconColor: getTheme().colors.primary, + iconColor: theme.colors.onPrimary, }); }); - it('should return theme icon color, for theme version 3, mode contained, selected', () => { + it('should return filled selected icon color', () => { expect( getIconButtonColor({ - theme: getTheme(), - mode: 'contained', + theme, + mode: 'filled', selected: true, }) ).toMatchObject({ - iconColor: getTheme().colors.onPrimary, + iconColor: theme.colors.onPrimary, }); }); - it('should return theme icon color, for theme version 3, mode contained-tonal', () => { + it('should return filled unselected icon color', () => { expect( getIconButtonColor({ - theme: getTheme(), - mode: 'contained-tonal', + theme, + mode: 'filled', + selected: false, }) ).toMatchObject({ - iconColor: getTheme().colors.onSurfaceVariant, + iconColor: theme.colors.onSurfaceVariant, }); }); - it('should return theme icon color, for theme version 3, mode contained-tonal, selected', () => { + it('should return tonal default icon color', () => { expect( getIconButtonColor({ - theme: getTheme(), - mode: 'contained-tonal', - selected: true, + theme, + mode: 'tonal', }) ).toMatchObject({ - iconColor: getTheme().colors.onSecondaryContainer, + iconColor: theme.colors.onSecondaryContainer, }); }); - it('should return theme icon color, for theme version 3, mode outlined', () => { + it('should return tonal selected icon color', () => { expect( getIconButtonColor({ - theme: getTheme(), - mode: 'outlined', + theme, + mode: 'tonal', + selected: true, }) ).toMatchObject({ - iconColor: getTheme().colors.onSurfaceVariant, + iconColor: theme.colors.onSecondary, }); }); - it('should return theme icon color, for theme version 3, mode outlined, selected', () => { + it('should return outlined selected icon color', () => { expect( getIconButtonColor({ - theme: getTheme(), + theme, mode: 'outlined', selected: true, }) ).toMatchObject({ - iconColor: getTheme().colors.inverseOnSurface, + iconColor: theme.colors.inverseOnSurface, }); }); - it('should return theme icon color, for theme version 3', () => { + it('should return standard selected icon color', () => { expect( getIconButtonColor({ - theme: getTheme(), + theme, + selected: true, }) ).toMatchObject({ - iconColor: getTheme().colors.onSurfaceVariant, + iconColor: theme.colors.primary, }); }); - it('should return theme icon color, for theme version 3, selected', () => { + it('should return standard default icon color', () => { expect( getIconButtonColor({ - theme: getTheme(), - selected: true, + theme, }) ).toMatchObject({ - iconColor: getTheme().colors.primary, + iconColor: theme.colors.onSurfaceVariant, }); }); }); @@ -203,7 +258,7 @@ describe('getIconButtonColor - background color', () => { it('should return custom background color', () => { expect( getIconButtonColor({ - theme: getTheme(), + theme, customContainerColor: 'purple', }) ).toMatchObject({ @@ -211,83 +266,84 @@ describe('getIconButtonColor - background color', () => { }); }); - (['contained', 'contained-tonal'] as const).forEach((mode) => - it(`should return correct disabled color, for theme version 3, ${mode} mode`, () => { + (['filled', 'tonal'] as const).forEach((mode) => + it(`should use 0.10 container opacity when disabled in ${mode} mode`, () => { expect( getIconButtonColor({ - theme: getTheme(), + theme, mode, disabled: true, }) ).toMatchObject({ - backgroundColor: getTheme().colors.onSurface, - backgroundOpacity: stateOpacity.disabled, + backgroundColor: theme.colors.onSurface, + backgroundOpacity: 0.1, }); }) ); - it('should return theme icon color, for theme version 3, mode contained', () => { + it('should return filled default container color', () => { expect( getIconButtonColor({ - theme: getTheme(), - mode: 'contained', + theme, + mode: 'filled', }) ).toMatchObject({ - backgroundColor: getTheme().colors.surfaceVariant, + backgroundColor: theme.colors.primary, }); }); - it('should return theme icon color, for theme version 3, mode contained, selected', () => { + it('should return filled unselected container color', () => { expect( getIconButtonColor({ - theme: getTheme(), - mode: 'contained', - selected: true, + theme, + mode: 'filled', + selected: false, }) ).toMatchObject({ - backgroundColor: getTheme().colors.primary, + backgroundColor: theme.colors.surfaceContainer, }); }); - it('should return theme icon color, for theme version 3, mode contained-tonal', () => { + it('should return tonal default container color', () => { expect( getIconButtonColor({ - theme: getTheme(), - mode: 'contained-tonal', + theme, + mode: 'tonal', }) ).toMatchObject({ - backgroundColor: getTheme().colors.surfaceVariant, + backgroundColor: theme.colors.secondaryContainer, }); }); - it('should return theme icon color, for theme version 3, mode contained-tonal, selected', () => { + it('should return tonal selected container color', () => { expect( getIconButtonColor({ - theme: getTheme(), - mode: 'contained-tonal', + theme, + mode: 'tonal', selected: true, }) ).toMatchObject({ - backgroundColor: getTheme().colors.secondaryContainer, + backgroundColor: theme.colors.secondary, }); }); - it('should return theme icon color, for theme version 3, mode outlined, selected', () => { + it('should return outlined selected container color', () => { expect( getIconButtonColor({ - theme: getTheme(), + theme, mode: 'outlined', selected: true, }) ).toMatchObject({ - backgroundColor: getTheme().colors.inverseSurface, + backgroundColor: theme.colors.inverseSurface, + borderWidth: 0, }); }); - it('should return undefined, for theme version 3, if mode not specified', () => { + it('should return undefined container for standard mode', () => { expect( getIconButtonColor({ - theme: getTheme(), + theme, }) ).toMatchObject({ backgroundColor: undefined, @@ -296,24 +352,72 @@ describe('getIconButtonColor - background color', () => { }); describe('getIconButtonColor - border color', () => { - it('should return correct disabled color, for theme version 3', () => { + it('should return outline variant when disabled', () => { expect( getIconButtonColor({ - theme: getTheme(), + theme, disabled: true, }) ).toMatchObject({ - borderColor: getTheme().colors.outlineVariant, + borderColor: theme.colors.outlineVariant, }); }); - it('should return theme color, for theme version 3', () => { + it('should return outline variant for outlined mode', () => { expect( getIconButtonColor({ - theme: getTheme(), + theme, + mode: 'outlined', + outlineWidth: 1, }) ).toMatchObject({ - borderColor: getTheme().colors.outlineVariant, + borderColor: theme.colors.outlineVariant, + borderWidth: 1, + }); + }); +}); + +describe('getDimensions', () => { + it('returns small default size', () => { + expect(getDimensions({ theme })).toMatchObject({ + width: 40, + height: 40, + iconSize: 24, + }); + }); + + it('returns extraSmall narrow size', () => { + expect( + getDimensions({ theme, size: 'extraSmall', width: 'narrow' }) + ).toMatchObject({ + width: 28, + height: 32, + iconSize: 20, + }); + }); + + it('returns extraLarge wide size', () => { + expect( + getDimensions({ theme, size: 'extraLarge', width: 'wide' }) + ).toMatchObject({ + width: 184, + height: 136, + iconSize: 40, + }); + }); +}); + +describe('getHitSlop', () => { + it('expands extraSmall containers to a 48dp target', () => { + expect(getHitSlop(32, 32)).toEqual({ + top: 8, + bottom: 8, + left: 8, + right: 8, }); }); + + it('is omitted when the container already meets 48dp', () => { + expect(getHitSlop(56, 56)).toBeUndefined(); + }); }); diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index b1b80b7341..768bd776d2 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -384,240 +384,324 @@ exports[`DataTable.Pagination renders data table pagination 1`] = ` style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } testID="icon-button-container" > - + + - chevron-left - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-left + + + - + + - chevron-right - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-right + + + @@ -682,480 +766,648 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } testID="icon-button-container" > - - page-first - - + } + accessible={true} + centered={true} + collapsable={false} + focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + role="button" + style={ + [ + { + "overflow": "hidden", + }, + [ + { + "alignItems": "center", + "flexGrow": 1, + "justifyContent": "center", + }, + null, + undefined, + ], + ] + } + testID="icon-button" + > + + + page-first + + + + - + + - chevron-left - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-left + + + - + + - chevron-right - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-right + + + - + + - page-last - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + page-last + + + @@ -1220,240 +1472,324 @@ exports[`DataTable.Pagination renders data table pagination with label 1`] = ` style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } testID="icon-button-container" > - + + - chevron-left - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-left + + + - - + + - chevron-right - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-right + + + @@ -1822,480 +2158,648 @@ exports[`DataTable.Pagination renders data table pagination with options select style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } testID="icon-button-container" > - + + - page-first - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + page-first + + + - + + - chevron-left - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-left + + + - + + - chevron-right - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-right + + + - + + - page-last - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + page-last + + + diff --git a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap index a5564668f6..a5a9812bae 100644 --- a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap @@ -6,645 +6,1339 @@ exports[`renders disabled icon button 1`] = ` style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } testID="icon-button-container" > - + + - camera - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + + + `; -exports[`renders icon button by default 1`] = ` +exports[`renders extraLarge icon button 1`] = ` - + + - camera - + [ + { + "lineHeight": 40, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + + + `; -exports[`renders icon button with color 1`] = ` +exports[`renders filled icon button 1`] = ` - + + - camera - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + + + `; -exports[`renders icon button with size 1`] = ` +exports[`renders icon button by default 1`] = ` - + + - camera - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + + + `; -exports[`renders icon change animated 1`] = ` +exports[`renders icon button with color 1`] = ` + + camera + + + + + + +`; + +exports[`renders icon change animated 1`] = ` + + + + - - camera - + + camera + + + + +`; + +exports[`renders outlined icon button 1`] = ` + + + + + + camera + + + + + + +`; + +exports[`renders tonal icon button 1`] = ` + + + + + + camera + + + + + `; diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap index c324b1874c..7e9721e9ef 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -96,120 +96,162 @@ exports[`activity indicator snapshot test 1`] = ` style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } testID="search-bar-icon-container" > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - - - - magnify - - - - - - - close + magnify + - -`; - -exports[`renders with text 1`] = ` - + + + + + + + close + + + + + + + + +`; + +exports[`renders with text 1`] = ` + - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - + + - close - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + + + diff --git a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap index 9a4edcd5c1..69b361307d 100644 --- a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap @@ -149,19 +149,12 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderRadius": 20, - "borderWidth": 0, - }, { "margin": 0, }, @@ -174,106 +167,155 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "width": 24, }, false, + { + "borderRadius": 20, + }, ] } testID="icon-button-container" > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - + + - close - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + + + @@ -627,19 +711,12 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderRadius": 20, - "borderWidth": 0, - }, { "margin": 0, }, @@ -652,106 +729,155 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "width": 24, }, false, + { + "borderRadius": 20, + }, ] } testID="icon-button-container" > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - + + - close - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + + + @@ -1283,19 +1451,12 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderRadius": 20, - "borderWidth": 0, - }, { "margin": 0, }, @@ -1308,106 +1469,155 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "width": 24, }, false, + { + "borderRadius": 20, + }, ] } testID="icon-button-container" > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - + + - close - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + + + @@ -1741,19 +1993,12 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderRadius": 20, - "borderWidth": 0, - }, { "margin": 0, }, @@ -1766,106 +2011,155 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "width": 24, }, false, + { + "borderRadius": 20, + }, ] } testID="icon-button-container" > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - + + - close - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + + + diff --git a/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap b/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap index 07f5de55eb..589cc44a08 100644 --- a/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ToggleButton.test.tsx.snap @@ -6,19 +6,12 @@ exports[`renders disabled toggle button 1`] = ` style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderRadius": 20, - "borderWidth": 0, - }, { "height": 42, "margin": 0, @@ -30,103 +23,152 @@ exports[`renders disabled toggle button 1`] = ` "borderRadius": 4, }, undefined, + { + "borderRadius": 4, + }, ] } testID="icon-button-container" > - + + - heart - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + heart + + + `; @@ -136,19 +178,12 @@ exports[`renders toggle button 1`] = ` style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderRadius": 20, - "borderWidth": 0, - }, { "height": 42, "margin": 0, @@ -160,103 +195,152 @@ exports[`renders toggle button 1`] = ` "borderRadius": 4, }, undefined, + { + "borderRadius": 4, + }, ] } testID="icon-button-container" > - + + - heart - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + heart + + + `; @@ -266,19 +350,12 @@ exports[`renders unchecked toggle button 1`] = ` style={ [ { - "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderRadius": 20, - "borderWidth": 0, - }, { "height": 42, "margin": 0, @@ -290,102 +367,151 @@ exports[`renders unchecked toggle button 1`] = ` "borderRadius": 4, }, undefined, + { + "borderRadius": 4, + }, ] } testID="icon-button-container" > - + + - heart - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + heart + + + `; diff --git a/src/index.tsx b/src/index.tsx index f46d8e22d8..b2cf9d4f11 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -106,6 +106,12 @@ export type { Size as FABSize, } from './components/FAB/tokens'; export type { Props as IconButtonProps } from './components/IconButton/IconButton'; +export type { + Mode as IconButtonMode, + Size as IconButtonSize, + Width as IconButtonWidth, + Shape as IconButtonShape, +} from './components/IconButton/tokens'; export type { Props as ListAccordionProps } from './components/List/ListAccordion'; export type { Props as ListAccordionGroupProps } from './components/List/ListAccordionGroup'; export type { Props as ListIconProps } from './components/List/ListIcon';