diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 72aee6d00f..3c4986fe61 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -62,6 +62,7 @@ The following component style props no longer support overriding their backgroun - `Card` - `Chip` - `Dialog` +- `IconButton` - `Menu`: `contentStyle` - `Searchbar` - `Snackbar` @@ -546,6 +547,133 @@ An accordion with a `left` element now indents only its `List.Item` children. An `theme` set on `List.Accordion` no longer reaches its children either. Pass it to the child that needs the override. +### IconButton + +`IconButton` now follows the Material 3 Expressive spec. The color modes, size API, and toggle `selected` behavior have changed. Passing the old numeric `size` (for example `size={24}`) crashes at runtime, because `size` is now a named token and `IconButtonTokens.sizes[24]` is `undefined`. + +#### Mode names + +- **`contained`** → **`filled`** +- **`contained-tonal`** → **`tonal`** +- Unspecified mode is now **`standard`** (no container fill), which matches the previous default. + +```tsx +// Before (v5) + + + +// After (v6) + + +``` + +#### Size + +`size` is a named scale: `'extraSmall' | 'small' | 'medium' | 'large' | 'extraLarge'`. It defaults to `'small'`, which still uses a 24dp icon. + +To set the glyph size in dp without changing the container scale, use `iconSize`. + +```tsx +// Before (v5) + + + +// After (v6) + + +``` + +```tsx +// After (v6) — named container sizes + + + + + +``` + +#### Shape + +`style.borderRadius` no longer overrides the container corner. Use `shape`: `'round'` (default) or `'square'`. Toggle-ON inverts the resting shape; press uses a shared corner for both. Arbitrary radii from `style` are not supported. + +```tsx +// Before (v5) + + +// After (v6) + +``` + +#### `Appbar.Action` and `Appbar.BackAction` + +`size` is no longer a numeric prop on `Appbar.Action` or `Appbar.BackAction`. They inherit `IconButton`'s named `size` (default `'small'`). Drop `size={24}`. For a custom glyph size, pass `iconSize`. + +```tsx +// Before (v5) + + + +// After (v6) + + +``` + +#### `selected={false}` uses toggle-off colors + +`selected` is tri-state. Omit it for the default (non-toggle) colors. Pass `true` / `false` only for a toggle. + +For `filled`, the default and toggle-on containers use `primary`. Toggle-off (`selected={false}`) uses `surfaceContainer` instead of `primary`. + +```tsx +// Before (v5) — selected={false} still used the default (non-toggle) colors + + +// After (v6) — omit selected unless the button is a toggle + + +// After (v6) — explicit toggle off / on + +``` + +#### `Card.Title` `right` props + +`Card.Title` still calls `right({ size: 24 })`. Do not spread those props onto `IconButton`: `size` is no longer a number. Ignore the callback argument, or pass it as `iconSize`. + +```tsx +// Before (v5) + ( + + )} +/> + +// After (v6) + } +/> +``` + +```tsx +// After (v6) — keep the 24dp glyph from Card.Title + ( + + )} +/> +``` + ### ToggleButton `ToggleButton`, `ToggleButton.Group` and `ToggleButton.Row` were removed. For an diff --git a/docs/public/screenshots/icon-button-1.png b/docs/public/screenshots/icon-button-1.png deleted file mode 100644 index acfa6e7097..0000000000 Binary files a/docs/public/screenshots/icon-button-1.png and /dev/null differ diff --git a/docs/public/screenshots/icon-button-2.png b/docs/public/screenshots/icon-button-2.png deleted file mode 100644 index d555823405..0000000000 Binary files a/docs/public/screenshots/icon-button-2.png and /dev/null differ diff --git a/docs/public/screenshots/icon-button-3.png b/docs/public/screenshots/icon-button-3.png deleted file mode 100644 index 367f7d0c3c..0000000000 Binary files a/docs/public/screenshots/icon-button-3.png and /dev/null differ diff --git a/docs/public/screenshots/icon-button-4.png b/docs/public/screenshots/icon-button-4.png deleted file mode 100644 index 57c723ade5..0000000000 Binary files a/docs/public/screenshots/icon-button-4.png and /dev/null differ diff --git a/docs/public/screenshots/icon-button-filled.android.png b/docs/public/screenshots/icon-button-filled.android.png new file mode 100644 index 0000000000..155d794a81 Binary files /dev/null and b/docs/public/screenshots/icon-button-filled.android.png differ diff --git a/docs/public/screenshots/icon-button-filled.ios.png b/docs/public/screenshots/icon-button-filled.ios.png new file mode 100644 index 0000000000..77bffeab12 Binary files /dev/null and b/docs/public/screenshots/icon-button-filled.ios.png differ diff --git a/docs/public/screenshots/icon-button-outlined.android.png b/docs/public/screenshots/icon-button-outlined.android.png new file mode 100644 index 0000000000..ea80b50dea Binary files /dev/null and b/docs/public/screenshots/icon-button-outlined.android.png differ diff --git a/docs/public/screenshots/icon-button-outlined.ios.png b/docs/public/screenshots/icon-button-outlined.ios.png new file mode 100644 index 0000000000..ff6e66f7f4 Binary files /dev/null and b/docs/public/screenshots/icon-button-outlined.ios.png differ diff --git a/docs/public/screenshots/icon-button-standard.android.png b/docs/public/screenshots/icon-button-standard.android.png new file mode 100644 index 0000000000..24632d599d Binary files /dev/null and b/docs/public/screenshots/icon-button-standard.android.png differ diff --git a/docs/public/screenshots/icon-button-standard.ios.png b/docs/public/screenshots/icon-button-standard.ios.png new file mode 100644 index 0000000000..889a007181 Binary files /dev/null and b/docs/public/screenshots/icon-button-standard.ios.png differ diff --git a/docs/public/screenshots/icon-button-tonal.android.png b/docs/public/screenshots/icon-button-tonal.android.png new file mode 100644 index 0000000000..4345feef9c Binary files /dev/null and b/docs/public/screenshots/icon-button-tonal.android.png differ diff --git a/docs/public/screenshots/icon-button-tonal.ios.png b/docs/public/screenshots/icon-button-tonal.ios.png new file mode 100644 index 0000000000..0ad4bdce8c Binary files /dev/null and b/docs/public/screenshots/icon-button-tonal.ios.png differ diff --git a/docs/src/components/ScreenshotTabs.tsx b/docs/src/components/ScreenshotTabs.tsx index ee45872920..124539a3cd 100644 --- a/docs/src/components/ScreenshotTabs.tsx +++ b/docs/src/components/ScreenshotTabs.tsx @@ -26,7 +26,11 @@ const ScreenshotTabs = ({ screenshotData }: ScreenshotTabsProps) => { const screenshots = Object.entries(screenshotData).map(([key, value]) => ( - {typeof value === 'string' ? renderScreenshot(value) : null} + {typeof value === 'string' ? ( + renderScreenshot(value) + ) : ( + + )} )); diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts index b7c59d424d..6e8f8b7d3b 100644 --- a/docs/src/data/screenshots.ts +++ b/docs/src/data/screenshots.ts @@ -81,10 +81,22 @@ export const screenshots = { 'FAB.Group': 'screenshots/fab-group.gif', Icon: 'screenshots/icon.png', IconButton: { - default: 'screenshots/icon-button-1.png', - outlined: 'screenshots/icon-button-4.png', - contained: 'screenshots/icon-button-2.png', - 'contained-tonal': 'screenshots/icon-button-3.png', + standard: { + Android: 'screenshots/icon-button-standard.android.png', + iOS: 'screenshots/icon-button-standard.ios.png', + }, + filled: { + Android: 'screenshots/icon-button-filled.android.png', + iOS: 'screenshots/icon-button-filled.ios.png', + }, + tonal: { + Android: 'screenshots/icon-button-tonal.android.png', + iOS: 'screenshots/icon-button-tonal.ios.png', + }, + outlined: { + Android: 'screenshots/icon-button-outlined.android.png', + iOS: 'screenshots/icon-button-outlined.ios.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 118114da27..d52febcef9 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 9c4e04f8e2..3cca440ecc 100644 --- a/example/src/Examples/CardExample.tsx +++ b/example/src/Examples/CardExample.tsx @@ -87,9 +87,7 @@ const CardExample = () => { title="Berries that are trimmed at the end" subtitle="Omega Ruby" left={(props: any) => } - right={(props: any) => ( - {}} /> - )} + right={() => {}} />} /> @@ -144,10 +142,10 @@ const CardExample = () => { ( + right={() => ( setIsSelected(!isSelected)} /> )} diff --git a/example/src/Examples/IconButtonExample.tsx b/example/src/Examples/IconButtonExample.tsx index 5fb463880b..dee40ab2f8 100644 --- a/example/src/Examples/IconButtonExample.tsx +++ b/example/src/Examples/IconButtonExample.tsx @@ -1,203 +1,298 @@ -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)} + aria-label="Playground icon button" /> {}} + aria-label="Disabled icon button" /> {}} + aria-label="Loading icon button" /> + + Press the first button to toggle. Pressed and selected states morph + the container shape. + + + + + {modes.map((colorMode) => ( + + + {colorMode} + + {}} + /> + {}} + /> + {}} + /> + {}} + /> + + ))} + + Default, toggle off, toggle on, disabled. + - + - {}} - /> - {}} - /> - {}} - /> - {}} - loading - /> + {sizes.map((item) => ( + {}} + aria-label={`${item} icon button`} + /> + ))} - + + + {widths.map((item) => ( + {}} + aria-label={`${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', + gap: 8, }, - 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 4ecbdfe544..aebff5d51c 100644 --- a/example/src/Examples/TooltipExample.tsx +++ b/example/src/Examples/TooltipExample.tsx @@ -83,7 +83,6 @@ const TooltipExample = () => { > {}} /> @@ -95,7 +94,7 @@ const TooltipExample = () => { setTextAlign('left')} /> @@ -103,7 +102,7 @@ const TooltipExample = () => { setTextAlign('center')} /> @@ -111,7 +110,7 @@ const TooltipExample = () => { setTextAlign('right')} 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 ed8289cde5..e347d5f4db 100644 --- a/src/components/Card/CardTitle.tsx +++ b/src/components/Card/CardTitle.tsx @@ -115,7 +115,7 @@ const LEFT_SIZE = 40; * title="Card Title" * subtitle="Card Subtitle" * left={(props) => } - * right={(props) => {}} />} + * right={() => {}} />} * /> * ); * diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 3bccccf33b..63aff03ad9 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -18,6 +18,7 @@ import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../theme/types'; +import { webNoOutline } from '../../utils/focusRing'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; @@ -301,10 +302,6 @@ const Checkbox = ({ ); }; -// Web-only style; not in StyleSheet because `outline` is outside ViewStyle. -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -const webNoOutline = { outline: 'none' } as unknown as ViewStyle; - const styles = StyleSheet.create({ tapTarget: { width: STATE_LAYER_SIZE, diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx index e4884647d6..0783156381 100644 --- a/src/components/FAB/Menu.tsx +++ b/src/components/FAB/Menu.tsx @@ -14,13 +14,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Content from './Content'; import Shell from './Shell'; -import { - MenuTokens, - Tokens, - FOCUS_RING_INSET, - FOCUS_RING_THICKNESS, - webNoOutline, -} from './tokens'; +import { MenuTokens, Tokens } from './tokens'; import type { Size, Variant } from './tokens'; import { useFocusRing } from './useFocusRing'; import { resolveColors } from './utils'; @@ -30,6 +24,11 @@ import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import type { InternalTheme, ThemeProp } from '../../theme/types'; import { resolveCornerRadius } from '../../theme/utils/shape'; +import { + FOCUS_RING_INSET, + FOCUS_RING_THICKNESS, + webNoOutline, +} from '../../utils/focusRing'; import Icon from '../Icon'; import type { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx index 83a71f72ed..24714a0120 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -17,12 +17,7 @@ import type { SharedValue } from 'react-native-reanimated'; import type { AnimatedStyle } from 'react-native-reanimated'; import Content from './Content'; -import { - Tokens, - FOCUS_RING_INSET, - FOCUS_RING_THICKNESS, - webNoOutline, -} from './tokens'; +import { Tokens } from './tokens'; import type { Size, Variant } from './tokens'; import { useFocusRing } from './useFocusRing'; import { getDimensions, resolveColors } from './utils'; @@ -31,6 +26,11 @@ import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import type { Elevation, ThemeProp } from '../../theme/types'; import type { ShapeToken } from '../../theme/utils/shape'; +import { + FOCUS_RING_INSET, + FOCUS_RING_THICKNESS, + webNoOutline, +} from '../../utils/focusRing'; import type { IconSource } from '../Icon'; import Surface from '../Surface'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; diff --git a/src/components/FAB/tokens.ts b/src/components/FAB/tokens.ts index 0fb79d1d9c..4188bc340a 100644 --- a/src/components/FAB/tokens.ts +++ b/src/components/FAB/tokens.ts @@ -1,6 +1,3 @@ -import type { ViewStyle } from 'react-native'; - -import { tokens } from '../../theme/tokens'; import type { ColorRole, Elevation, @@ -119,11 +116,3 @@ export const MenuTokens = { listItem, spacing, }; - -const focusIndicator = tokens.md.sys.state.focusIndicator; -export const FOCUS_RING_THICKNESS = focusIndicator.thickness; -export const FOCUS_RING_OUTER_OFFSET = focusIndicator.outerOffset; -export const FOCUS_RING_INSET = FOCUS_RING_OUTER_OFFSET + FOCUS_RING_THICKNESS; - -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -export const webNoOutline = { outline: 'none' } as unknown as ViewStyle; diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index 1d673a3d27..42ca2779c5 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,11 +7,24 @@ import type { ViewStyle, } from 'react-native'; -import Animated, { type AnimatedStyle } from 'react-native-reanimated'; +import Animated, { + type AnimatedStyle, + cubicBezier, + type CSSStyle, +} from 'react-native-reanimated'; -import { getIconButtonColor } from './utils'; +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 type { ThemeProp } from '../../theme/types'; +import { + FOCUS_RING_INSET, + FOCUS_RING_THICKNESS, + webNoOutline, +} from '../../utils/focusRing'; +import hasTouchHandler from '../../utils/hasTouchHandler'; +import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import ActivityIndicator from '../ActivityIndicator'; import CrossFadeIcon from '../CrossFadeIcon'; import Icon from '../Icon'; @@ -19,23 +32,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 +59,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 +96,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; /** @@ -102,7 +131,7 @@ export type Props = Omit< * console.log('Pressed')} * /> * ); @@ -116,31 +145,51 @@ 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, loading = false, contentStyle, ref, + onPressIn: onPressInProp, + onPressOut: onPressOutProp, + onFocus: onFocusProp, + onBlur: onBlurProp, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); + const [pressed, setPressed] = React.useState(false); + const [focused, setFocused] = React.useState(false); 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,64 +197,133 @@ const IconButton = ({ mode, customIconColor, customContainerColor, + outlineWidth: dimensions.outlineWidth, }); - const buttonSize = size + 2 * PADDING; + const borderRadius = pressed + ? dimensions.pressedRadius + : dimensions.restingRadius; + const radiusStyle: CSSStyle = { + borderRadius, + transitionProperty: ['borderRadius'], + transitionDuration: reduceMotion ? 0 : theme.motion.duration.short4, + transitionTimingFunction: cubicBezier(...theme.motion.easing.standard), + }; + const focusRingRadiusStyle: CSSStyle = { + ...radiusStyle, + borderRadius: borderRadius + FOCUS_RING_INSET, + }; - const borderStyles = { - borderWidth: mode === 'outlined' && !selected ? 1 : 0, - borderRadius: buttonSize / 2, - borderColor, + const handlePressIn = (e: GestureResponderEvent) => { + setPressed(true); + onPressInProp?.(e); }; + const handlePressOut = (e: GestureResponderEvent) => { + setPressed(false); + onPressOutProp?.(e); + }; + + const handleFocus = (e: Parameters>[0]) => { + if (isKeyboardFocusEvent(e)) { + setFocused(true); + } + onFocusProp?.(e); + }; + + const handleBlur = (e: Parameters>[0]) => { + setFocused(false); + onBlurProp?.(e); + }; + + const hasPassedTouchHandler = hasTouchHandler({ + onPress, + onLongPress: rest.onLongPress, + onPressIn: onPressInProp, + onPressOut: onPressOutProp, + }); + + const hitSlop = getHitSlop(dimensions.width, dimensions.height); + return ( - {backgroundOpacity < 1 && ( - + {backgroundOpacity < 1 && ( + + )} + - )} - - - {loading ? ( - - ) : ( - - )} - - + role="button" + aria-disabled={disabled} + aria-selected={selected} + disabled={disabled} + hitSlop={hitSlop} + testID={testID} + {...rest} + > + + {loading ? ( + + ) : ( + + )} + + + + ); }; @@ -213,6 +331,11 @@ const IconButton = ({ const styles = StyleSheet.create({ container: { margin: 6, + overflow: 'visible', + }, + clip: { + width: '100%', + height: '100%', overflow: 'hidden', }, touchable: { @@ -220,6 +343,21 @@ 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', + }, + focusRingVisible: { + opacity: 1, + }, + focusRingHidden: { + opacity: 0, + }, }); export default IconButton; diff --git a/src/components/IconButton/tokens.ts b/src/components/IconButton/tokens.ts new file mode 100644 index 0000000000..f6b28096d5 --- /dev/null +++ b/src/components/IconButton/tokens.ts @@ -0,0 +1,156 @@ +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; + +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/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index 2c86f8d963..0e176f18ea 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -30,6 +30,7 @@ import { tokens } from '../../theme/tokens'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import { cornerFull } from '../../theme/tokens/sys/shape'; import type { StateOpacityKey, ThemeProp } from '../../theme/types'; +import { webNoOutline } from '../../utils/focusRing'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import Icon, { type IconSource } from '../Icon'; @@ -538,8 +539,4 @@ const styles = StyleSheet.create({ }, }); -// Web-only style; not in StyleSheet because `outline` is outside ViewStyle. -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -const webNoOutline = { outline: 'none' } as unknown as ViewStyle; - export default Switch; 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/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index d0dc180115..a652c27db0 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 faeb89916b..0c54a383f8 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -194,117 +194,161 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` [ { "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - + + - close - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + + + @@ -582,162 +670,206 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A [ { "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } > - + - + > + + + - + - - menu - + + menu + + + `; @@ -1062,155 +1238,201 @@ exports[`AppbarAction should be rendered with custom color 1`] = ` [ { "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="appbar-action-container" > - + - - menu - + + menu + + + `; @@ -1320,155 +1542,201 @@ exports[`AppbarAction should be rendered with default theme color 1`] = ` [ { "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="appbar-action-container" > - + - - menu - + + menu + + + `; @@ -1578,155 +1846,201 @@ exports[`AppbarAction should be rendered with specific theme color if is leading [ { "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="appbar-action-container" > - + - - menu - + + menu + + + `; @@ -1836,163 +2150,209 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = ` [ { "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="appbar-action-container" > - + - + > + + + `; diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index 914f1e2c55..186c8ac9cf 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -1,22 +1,26 @@ +import { useState } from 'react'; import { StyleSheet } from 'react-native'; -import { describe, expect, it } from '@jest/globals'; +import { describe, expect, it, jest } from '@jest/globals'; -import { render } from '../../test-utils'; +import { fireEvent, render, screen, userEvent } from '../../test-utils'; import { pink500 } from '../../theme/colors'; import { LightTheme } from '../../theme/schemes'; import { tokens } from '../../theme/tokens'; import IconButton from '../IconButton/IconButton'; -import { getIconButtonColor } from '../IconButton/utils'; +import { + getDimensions, + getHitSlop, + getIconButtonColor, + resolveShapeToken, +} from '../IconButton/utils'; const stateOpacity = tokens.md.sys.state.opacity; +const theme = LightTheme; const styles = StyleSheet.create({ - square: { - borderRadius: 0, - }, - slightlyRounded: { - borderRadius: 4, + scaled: { + transform: [{ scale: 1 }], }, }); @@ -26,16 +30,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(); }); @@ -52,39 +82,133 @@ it('renders icon change animated', async () => { expect(tree).toMatchSnapshot(); }); -it('renders icon button with custom border radius', async () => { - const { toJSON } = await render( - {}} - style={styles.square} - /> +it('renders a square icon button', async () => { + const tree = ( + await render( + {}} + /> + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('applies a static transform from style', async () => { + const tree = ( + await render( + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +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); +}); + +it('exposes a selected toggle to assistive technology', async () => { + await render( {}} />); + + expect(screen.getByRole('button', { selected: true })).toBeOnTheScreen(); +}); - expect(toJSON()).toMatchSnapshot(); +it('exposes an unselected toggle to assistive technology', async () => { + await render( + {}} /> + ); + + expect(screen.getByRole('button', { selected: false })).toBeOnTheScreen(); +}); + +it('does not expose a selected state when it is not a toggle', async () => { + await render( {}} />); + + expect(screen.getByRole('button')).toHaveProp( + 'accessibilityState', + expect.objectContaining({ selected: undefined }) + ); }); -it('renders icon button with small border radius', async () => { - const { toJSON } = await render( +it('uses a square resting shape when a round button is selected', async () => { + await render( {}} - style={styles.slightlyRounded} /> ); - expect(toJSON()).toMatchSnapshot(); + expect(screen.getByTestId('icon-button-container')).toHaveStyle({ + borderRadius: theme.shapes.corner.medium, + }); +}); + +it('keeps the inverted resting shape after a tap that selects the button', async () => { + const ToggleIconButton = () => { + const [selected, setSelected] = useState(false); + + return ( + setSelected((value) => !value)} + /> + ); + }; + const user = userEvent.setup(); + + await render(); + await user.press(screen.getByTestId('icon-button')); + + expect(screen.getByTestId('icon-button-container')).toHaveStyle({ + borderRadius: theme.shapes.corner.medium, + }); + + await user.press(screen.getByTestId('icon-button')); + + expect(screen.getByTestId('icon-button-container')).toHaveStyle({ + borderRadius: getDimensions({ theme }).restingRadius, + }); +}); + +it('morphs to the pressed shape with only onLongPress', async () => { + await render( + {}} /> + ); + + await fireEvent(screen.getByTestId('icon-button'), 'pressIn'); + + expect(screen.getByTestId('icon-button-container')).toHaveStyle({ + borderRadius: getDimensions({ theme }).pressedRadius, + }); +}); + +it('stays non-interactive without touch handlers', async () => { + await render(); + + expect(screen.getByTestId('icon-button')).toBeDisabled(); }); describe('getIconButtonColor - icon color', () => { it('should return custom icon color', () => { expect( getIconButtonColor({ - theme: LightTheme, + theme, customIconColor: 'purple', }) ).toMatchObject({ @@ -92,105 +216,106 @@ describe('getIconButtonColor - icon color', () => { }); }); - it('should return correct disabled color, for theme version 3', () => { + it('should return disabled icon color', () => { expect( getIconButtonColor({ - theme: LightTheme, + theme, disabled: true, }) ).toMatchObject({ - iconColor: LightTheme.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: LightTheme, - mode: 'contained', + theme, + mode: 'filled', }) ).toMatchObject({ - iconColor: LightTheme.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: LightTheme, - mode: 'contained', + theme, + mode: 'filled', selected: true, }) ).toMatchObject({ - iconColor: LightTheme.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: LightTheme, - mode: 'contained-tonal', + theme, + mode: 'filled', + selected: false, }) ).toMatchObject({ - iconColor: LightTheme.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: LightTheme, - mode: 'contained-tonal', - selected: true, + theme, + mode: 'tonal', }) ).toMatchObject({ - iconColor: LightTheme.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: LightTheme, - mode: 'outlined', + theme, + mode: 'tonal', + selected: true, }) ).toMatchObject({ - iconColor: LightTheme.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: LightTheme, + theme, mode: 'outlined', selected: true, }) ).toMatchObject({ - iconColor: LightTheme.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: LightTheme, + theme, + selected: true, }) ).toMatchObject({ - iconColor: LightTheme.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: LightTheme, - selected: true, + theme, }) ).toMatchObject({ - iconColor: LightTheme.colors.primary, + iconColor: theme.colors.onSurfaceVariant, }); }); }); @@ -199,7 +324,7 @@ describe('getIconButtonColor - background color', () => { it('should return custom background color', () => { expect( getIconButtonColor({ - theme: LightTheme, + theme, customContainerColor: 'purple', }) ).toMatchObject({ @@ -207,83 +332,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: LightTheme, + theme, mode, disabled: true, }) ).toMatchObject({ - backgroundColor: LightTheme.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: LightTheme, - mode: 'contained', + theme, + mode: 'filled', }) ).toMatchObject({ - backgroundColor: LightTheme.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: LightTheme, - mode: 'contained', - selected: true, + theme, + mode: 'filled', + selected: false, }) ).toMatchObject({ - backgroundColor: LightTheme.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: LightTheme, - mode: 'contained-tonal', + theme, + mode: 'tonal', }) ).toMatchObject({ - backgroundColor: LightTheme.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: LightTheme, - mode: 'contained-tonal', + theme, + mode: 'tonal', selected: true, }) ).toMatchObject({ - backgroundColor: LightTheme.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: LightTheme, + theme, mode: 'outlined', selected: true, }) ).toMatchObject({ - backgroundColor: LightTheme.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: LightTheme, + theme, }) ).toMatchObject({ backgroundColor: undefined, @@ -292,24 +418,102 @@ 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: LightTheme, + theme, disabled: true, }) ).toMatchObject({ - borderColor: LightTheme.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: LightTheme, + theme, + mode: 'outlined', + outlineWidth: 1, }) ).toMatchObject({ - borderColor: LightTheme.colors.outlineVariant, + borderColor: theme.colors.outlineVariant, + borderWidth: 1, + }); + }); +}); + +describe('resolveShapeToken', () => { + it('keeps a round button full until it is selected', () => { + expect(resolveShapeToken({ shape: 'round' })).toBe('full'); + expect(resolveShapeToken({ shape: 'round', selected: false })).toBe('full'); + expect(resolveShapeToken({ shape: 'round', selected: true })).toBe( + 'medium' + ); + }); + + it('inverts a square button to full when selected', () => { + expect(resolveShapeToken({ shape: 'square' })).toBe('medium'); + expect(resolveShapeToken({ shape: 'square', selected: true })).toBe('full'); + }); + + it('uses the shared pressed shape for round and square', () => { + expect(resolveShapeToken({ shape: 'round', pressed: true })).toBe('small'); + expect(resolveShapeToken({ shape: 'square', pressed: true })).toBe('small'); + expect( + resolveShapeToken({ shape: 'round', selected: true, pressed: true }) + ).toBe('small'); + }); +}); + +describe('getDimensions', () => { + it('returns small default size', () => { + expect(getDimensions({ theme })).toMatchObject({ + width: 40, + height: 40, + iconSize: 24, + restingRadius: 20, + }); + }); + + it('returns the square resting radius when a round button is selected', () => { + expect(getDimensions({ theme, selected: true })).toMatchObject({ + restingRadius: theme.shapes.corner.medium, + }); + }); + + 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 9084841e9b..a40f7be684 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -687,117 +687,161 @@ exports[`DataTable.Pagination renders data table pagination 1`] = ` [ { "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } > - + + - chevron-left - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-left + + + - + + - chevron-right - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-right + + + @@ -981,117 +1069,161 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu [ { "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } > - + + - 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 + + + @@ -1511,117 +1775,161 @@ exports[`DataTable.Pagination renders data table pagination with label 1`] = ` [ { "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } > - + + - chevron-left - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-left + + + + - - + + - chevron-right - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-right + + + @@ -2103,117 +2455,161 @@ exports[`DataTable.Pagination renders data table pagination with options select [ { "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } > - + + - page-first - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + page-first + + + - + - chevron-left - + + 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 7534e6f77e..d4264c863e 100644 --- a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap @@ -1,885 +1,1683 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renders disabled icon button 1`] = ` +exports[`applies a static transform from style 1`] = ` - + + - camera - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + testID="icon-button-icon" + > + camera + + + `; -exports[`renders icon button by default 1`] = ` +exports[`renders a square icon button 1`] = ` - + + - camera - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + testID="icon-button-icon" + > + camera + + + `; -exports[`renders icon button with color 1`] = ` +exports[`renders disabled icon button 1`] = ` - + + - camera - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + + + `; -exports[`renders icon button with custom border radius 1`] = ` +exports[`renders extraLarge icon button 1`] = ` - + + + camera + + + + + + +`; + +exports[`renders filled icon button 1`] = ` + + + + + + camera + + + + + + +`; + +exports[`renders icon button by default 1`] = ` + + + + + + camera + + + + + + +`; + +exports[`renders icon button with color 1`] = ` + + + + - camera - + + camera + + + `; -exports[`renders icon button with size 1`] = ` +exports[`renders icon change animated 1`] = ` - + + - camera - + ] + } + > + + + camera + + + + + `; -exports[`renders icon button with small border radius 1`] = ` +exports[`renders outlined icon button 1`] = ` - + + - camera - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + + + `; -exports[`renders icon change animated 1`] = ` +exports[`renders tonal icon button 1`] = ` - + - - - camera - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + + `; diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap index c200e71706..03e0bed8d5 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -96,117 +96,161 @@ exports[`activity indicator snapshot test 1`] = ` [ { "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - - - - magnify - - - - - - @@ -700,30 +597,28 @@ exports[`renders searchbar in "view" mode 1`] = ` style={ [ { - "margin": 6, + "height": "100%", "overflow": "hidden", + "width": "100%", }, { "backgroundColor": undefined, - "height": 40, - "width": 40, + "borderColor": "rgba(202, 196, 208, 1)", + "borderWidth": 0, }, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } > - close + magnify - - + + + + + + + + + close + + + + + + + + @@ -938,117 +1070,161 @@ exports[`renders with placeholder 1`] = ` [ { "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - + + - close - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + + + @@ -1316,117 +1536,161 @@ exports[`renders with text 1`] = ` [ { "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, + undefined, { - "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, - "borderWidth": 0, }, - undefined, ] } > - + + - 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 6efc467066..a3913fd078 100644 --- a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap @@ -150,18 +150,12 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` [ { "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,104 +168,154 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "width": 24, }, false, + { + "borderRadius": 20, + }, ] } > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - + + - close - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + + + @@ -624,18 +712,12 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is [ { "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderRadius": 20, - "borderWidth": 0, - }, { "margin": 0, }, @@ -648,104 +730,154 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "width": 24, }, false, + { + "borderRadius": 20, + }, ] } > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - + + - close - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + + + @@ -1276,18 +1452,12 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` [ { "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderRadius": 20, - "borderWidth": 0, - }, { "margin": 0, }, @@ -1300,104 +1470,154 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "width": 24, }, false, + { + "borderRadius": 20, + }, ] } > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - + + - close - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + + + @@ -1730,18 +1994,12 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i [ { "margin": 6, - "overflow": "hidden", + "overflow": "visible", }, { - "backgroundColor": undefined, "height": 40, "width": 40, }, - { - "borderColor": "rgba(202, 196, 208, 1)", - "borderRadius": 20, - "borderWidth": 0, - }, { "margin": 0, }, @@ -1754,104 +2012,154 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "width": 24, }, false, + { + "borderRadius": 20, + }, ] } > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + - + + - close - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + + + diff --git a/src/index.tsx b/src/index.tsx index 1807d2f249..be72dc8bff 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -105,6 +105,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'; diff --git a/src/utils/focusRing.ts b/src/utils/focusRing.ts new file mode 100644 index 0000000000..c8ae63aba6 --- /dev/null +++ b/src/utils/focusRing.ts @@ -0,0 +1,14 @@ +import type { ViewStyle } from 'react-native'; + +import { tokens } from '../theme/tokens'; + +const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; + +export const FOCUS_RING_THICKNESS = thickness; + +/** Outer offset plus stroke so the ring's inner edge clears the surface. */ +export const FOCUS_RING_INSET = outerOffset + thickness; + +// Web-only style; not in StyleSheet because `outline` is outside ViewStyle. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +export const webNoOutline = { outline: 'none' } as unknown as ViewStyle;