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;