diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index a4d7123a09..bcd5771dde 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -159,6 +159,33 @@ e.g.:
- The default elevation changed from level `1` to level `3`.
- The `style` prop no longer configures the background color or border radius. You can override `theme.colors.surfaceContainerHigh` and `theme.shapes.corner.extraLarge` using the `theme` prop instead.
+### FAB
+
+To preserve the v5 FAB color treatment, update the `variant` prop:
+
+| v5 | v6 |
+| --- | --- |
+| `primary` | `primaryContainer` |
+| `secondary` | `secondaryContainer` |
+| `tertiary` | `tertiaryContainer` |
+
+If you omit `variant`, no change is needed. Replace `variant="surface"` with
+one of the supported color variants, such as `primaryContainer`.
+
+For custom colors, replace `color` with `contentColor` and move
+`style.backgroundColor` to `containerColor`:
+
+```diff
+
+```
+
### TextInput
The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly.
diff --git a/example/src/Examples/FABExample.tsx b/example/src/Examples/FABExample.tsx
index 09ce39aa14..f878c61b24 100644
--- a/example/src/Examples/FABExample.tsx
+++ b/example/src/Examples/FABExample.tsx
@@ -34,9 +34,10 @@ const variants: FabColor[] = [
'primary',
'secondary',
'tertiary',
- 'tonalPrimary',
- 'tonalSecondary',
- 'tonalTertiary',
+ 'primaryContainer',
+ 'secondaryContainer',
+ 'tertiaryContainer',
+ 'branded',
'custom',
];
@@ -91,7 +92,7 @@ const FABExample = () => {
const { colors } = useTheme();
const insets = useSafeAreaInsets();
- const [variant, setVariant] = React.useState('tonalPrimary');
+ const [variant, setVariant] = React.useState('primaryContainer');
const activeVariant = variant === 'custom' ? undefined : variant;
const activeContainerColor =
variant === 'custom' ? CUSTOM_CONTAINER_COLOR : undefined;
@@ -138,12 +139,24 @@ const FABExample = () => {
v !== 'branded') : variants
+ }
value={variant}
onChange={setVariant}
/>
-
+ {
+ if (nextType === 'menu' && variant === 'branded') {
+ setVariant('primaryContainer');
+ }
+ setType(nextType);
+ }}
+ />
{
{type === 'icon' && (
{
alignment={position}
trigger={{
icon: 'pencil',
- variant: activeVariant,
+ variant: activeVariant === 'branded' ? undefined : activeVariant,
containerColor: activeContainerColor,
size,
visible: showFab,
diff --git a/src/components/FAB/Extended.tsx b/src/components/FAB/Extended.tsx
index fb58e730e6..ec53bf1acb 100644
--- a/src/components/FAB/Extended.tsx
+++ b/src/components/FAB/Extended.tsx
@@ -38,7 +38,7 @@ export type Props = {
*/
label: string;
/**
- * Role-color preset. Defaults to `tonalPrimary`.
+ * Role-color preset. Defaults to `primaryContainer`.
*/
variant?: Variant;
/**
@@ -154,7 +154,7 @@ export type Props = {
const Extended = ({
icon,
label,
- variant = 'tonalPrimary',
+ variant = 'primaryContainer',
containerColor,
contentColor,
size = 'default',
diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FAB.tsx
index 036edd57d9..595352555e 100644
--- a/src/components/FAB/FAB.tsx
+++ b/src/components/FAB/FAB.tsx
@@ -21,7 +21,7 @@ export type Props = {
*/
icon: IconSource;
/**
- * Role-color preset. Defaults to `tonalPrimary`.
+ * Role-color preset. Defaults to `primaryContainer`.
*/
variant?: Variant;
/**
@@ -118,7 +118,7 @@ export type Props = {
*/
const FAB = ({
icon,
- variant = 'tonalPrimary',
+ variant = 'primaryContainer',
size = 'default',
visible = true,
onPress,
diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx
index 5a08323060..c226b016bf 100644
--- a/src/components/FAB/Menu.tsx
+++ b/src/components/FAB/Menu.tsx
@@ -55,13 +55,15 @@ export type MenuItemProps = {
testID?: string;
};
+type MenuVariant = Exclude;
+
export type MenuTriggerProps = {
/**
* Icon displayed in the trigger FAB (and cross-faded to `closeIcon` when
* the menu is open).
*/
icon: IconSource;
- variant?: Variant;
+ variant?: MenuVariant;
size?: Size;
containerColor?: ColorValue;
contentColor?: ColorValue;
@@ -121,24 +123,30 @@ export type MenuProps = {
* The close button is always the saturated role color; items are always the
* tonal (container) role color.
*/
-const getCloseVariant = (triggerVariant: Variant): Variant => {
- if (triggerVariant === 'primary' || triggerVariant === 'tonalPrimary') {
+const getCloseVariant = (triggerVariant: MenuVariant): Variant => {
+ if (triggerVariant === 'primary' || triggerVariant === 'primaryContainer') {
return 'primary';
}
- if (triggerVariant === 'secondary' || triggerVariant === 'tonalSecondary') {
+ if (
+ triggerVariant === 'secondary' ||
+ triggerVariant === 'secondaryContainer'
+ ) {
return 'secondary';
}
return 'tertiary';
};
-const getItemsVariant = (triggerVariant: Variant): Variant => {
- if (triggerVariant === 'primary' || triggerVariant === 'tonalPrimary') {
- return 'tonalPrimary';
+const getItemsVariant = (triggerVariant: MenuVariant): Variant => {
+ if (triggerVariant === 'primary' || triggerVariant === 'primaryContainer') {
+ return 'primaryContainer';
}
- if (triggerVariant === 'secondary' || triggerVariant === 'tonalSecondary') {
- return 'tonalSecondary';
+ if (
+ triggerVariant === 'secondary' ||
+ triggerVariant === 'secondaryContainer'
+ ) {
+ return 'secondaryContainer';
}
- return 'tonalTertiary';
+ return 'tertiaryContainer';
};
// Per-item delay used by the stagger. Compose uses a single SlowEffects-driven
@@ -303,7 +311,7 @@ const MenuItem = ({
};
type MorphingTriggerProps = {
- triggerVariant: Variant;
+ triggerVariant: MenuVariant;
closeVariant: Variant;
triggerContainerColor?: ColorValue;
triggerContentColor?: ColorValue;
@@ -545,7 +553,7 @@ const Menu = ({
const isRTL = direction === 'rtl';
const insets = useSafeAreaInsets();
- const triggerVariant: Variant = trigger.variant ?? 'tonalPrimary';
+ const triggerVariant: MenuVariant = trigger.variant ?? 'primaryContainer';
const size: Size = trigger.size ?? 'default';
const openIcon: IconSource = trigger.icon;
const openOnPress = trigger.onPress;
diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx
index 5a7d3b81bf..28b4d3461e 100644
--- a/src/components/FAB/Shell.tsx
+++ b/src/components/FAB/Shell.tsx
@@ -29,7 +29,7 @@ import { getDimensions, resolveColors } from './utils';
import { useInternalTheme } from '../../core/theming';
import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
import { toRawSpring } from '../../theme/tokens/sys/motion';
-import type { Elevation, ThemeProp } from '../../theme/types';
+import type { ThemeProp } from '../../theme/types';
import type { ShapeToken } from '../../theme/utils/shape';
import type { IconSource } from '../Icon';
import Surface from '../Surface';
@@ -46,7 +46,7 @@ export type ShellProps = {
*/
label?: string;
/**
- * Role-color preset. Defaults to `tonalPrimary`.
+ * Role-color preset. Defaults to `primaryContainer`.
*/
variant?: Variant;
/**
@@ -77,11 +77,6 @@ export type ShellProps = {
* Trailing-padding override.
*/
trailing?: number;
- /**
- * Resting elevation level. Defaults to the FAB's enabled-state elevation.
- * Pass `0` to disable the shadow entirely.
- */
- elevation?: Elevation;
/**
* When `false`, the shell animates out (scale + alpha) and stops accepting
* touches.
@@ -186,7 +181,7 @@ export type ShellProps = {
const Shell = ({
icon,
label,
- variant = 'tonalPrimary',
+ variant = 'primaryContainer',
size = 'default',
containerColor,
contentColor,
@@ -194,7 +189,6 @@ const Shell = ({
iconSize,
leading,
trailing,
- elevation = Tokens.stateElevation.enabled,
visible = true,
onPress,
'aria-label': ariaLabel = label,
@@ -217,6 +211,19 @@ const Shell = ({
ref,
}: ShellProps) => {
const theme = useInternalTheme(themeOverrides);
+ const [hovered, setHovered] = React.useState(false);
+ const [pressed, setPressed] = React.useState(false);
+ const touchableRef = React.useRef(null);
+ const previousFocusedElement = React.useRef(null);
+
+ const resolvedElevation =
+ visible && onPress
+ ? pressed
+ ? Tokens.stateElevation.pressed
+ : hovered
+ ? Tokens.stateElevation.hover
+ : Tokens.stateElevation.enabled
+ : Tokens.stateElevation.enabled;
const dimensions = React.useMemo(
() => getDimensions({ theme, size, shape, iconSize, leading, trailing }),
@@ -299,6 +306,34 @@ const Shell = ({
const { focusedSV, onFocus, onBlur } = useFocusRing();
+ React.useEffect(() => {
+ if (!visible) {
+ if (Platform.OS === 'web' && typeof document !== 'undefined') {
+ const target: unknown = touchableRef.current;
+ if (
+ target instanceof HTMLElement &&
+ target === document.activeElement
+ ) {
+ if (previousFocusedElement.current?.isConnected) {
+ previousFocusedElement.current.focus({ preventScroll: true });
+ }
+ // The previous element may have been removed or become unfocusable.
+ if (target === document.activeElement) target.blur();
+ }
+ }
+ setHovered(false);
+ setPressed(false);
+ onBlur();
+ }
+ }, [visible, onBlur]);
+
+ React.useEffect(() => {
+ if (!onPress) {
+ setHovered(false);
+ setPressed(false);
+ }
+ }, [onPress]);
+
const focusRingStyle = useAnimatedStyle(
() => ({
opacity: focusedSV.value ? 1 : 0,
@@ -310,6 +345,7 @@ const Shell = ({
return (
{overlay}
setHovered(true)}
+ onHoverOut={() => setHovered(false)}
+ onPressIn={() => setPressed(true)}
+ onPressOut={() => setPressed(false)}
+ onFocus={(event) => {
+ if (Platform.OS === 'web') {
+ const previous =
+ 'relatedTarget' in event.nativeEvent
+ ? event.nativeEvent.relatedTarget
+ : null;
+ previousFocusedElement.current =
+ previous instanceof HTMLElement ? previous : null;
+ }
+ onFocus();
+ }}
onBlur={onBlur}
aria-label={ariaLabel}
role="button"
diff --git a/src/components/FAB/tokens.ts b/src/components/FAB/tokens.ts
index 0fb79d1d9c..19c992491b 100644
--- a/src/components/FAB/tokens.ts
+++ b/src/components/FAB/tokens.ts
@@ -12,9 +12,10 @@ export type Variant =
| 'primary'
| 'secondary'
| 'tertiary'
- | 'tonalPrimary'
- | 'tonalSecondary'
- | 'tonalTertiary';
+ | 'primaryContainer'
+ | 'secondaryContainer'
+ | 'tertiaryContainer'
+ | 'branded';
export type Size = 'default' | 'medium' | 'large';
@@ -68,18 +69,20 @@ const stateElevation = {
} as const satisfies Record;
const variants = {
+ // Branded artwork has no prescribed icon color; onSurface is a fallback.
+ branded: { container: 'surfaceContainerHigh', content: 'onSurface' },
primary: { container: 'primary', content: 'onPrimary' },
secondary: { container: 'secondary', content: 'onSecondary' },
tertiary: { container: 'tertiary', content: 'onTertiary' },
- tonalPrimary: {
+ primaryContainer: {
container: 'primaryContainer',
content: 'onPrimaryContainer',
},
- tonalSecondary: {
+ secondaryContainer: {
container: 'secondaryContainer',
content: 'onSecondaryContainer',
},
- tonalTertiary: {
+ tertiaryContainer: {
container: 'tertiaryContainer',
content: 'onTertiaryContainer',
},
diff --git a/src/components/FAB/utils.ts b/src/components/FAB/utils.ts
index cd894f048d..9c1a0eb72d 100644
--- a/src/components/FAB/utils.ts
+++ b/src/components/FAB/utils.ts
@@ -19,7 +19,7 @@ export type ResolvedColors = {
*/
export const resolveColors = ({
theme,
- variant = 'tonalPrimary',
+ variant = 'primaryContainer',
containerColor,
contentColor,
}: {
diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx
index eedcb3e0f3..3211d39819 100644
--- a/src/components/__tests__/FAB.test.tsx
+++ b/src/components/__tests__/FAB.test.tsx
@@ -1,9 +1,20 @@
-import { expect, it, jest } from '@jest/globals';
+import { Platform } from 'react-native';
+
+import { afterEach, expect, it, jest } from '@jest/globals';
import { fireEvent, userEvent } from '@testing-library/react-native';
+import { getTheme } from '../../core/theming';
import { render, screen } from '../../test-utils';
+import {
+ androidElevationLevels,
+ shadow,
+} from '../../theme/tokens/sys/elevation';
import FAB from '../FAB';
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
it('renders FAB with default props', async () => {
const tree = (await render()).toJSON();
expect(tree).toMatchSnapshot();
@@ -24,16 +35,16 @@ it('renders FAB with tertiary variant', async () => {
expect(tree).toMatchSnapshot();
});
-it('renders FAB with tonalSecondary variant', async () => {
+it('renders FAB with secondaryContainer variant', async () => {
const tree = (
- await render()
+ await render()
).toJSON();
expect(tree).toMatchSnapshot();
});
-it('renders FAB with tonalTertiary variant', async () => {
+it('renders FAB with tertiaryContainer variant', async () => {
const tree = (
- await render()
+ await render()
).toJSON();
expect(tree).toMatchSnapshot();
});
@@ -101,3 +112,143 @@ it('forwards event object to onPress', async () => {
});
expect(onPress).toHaveBeenCalledWith({ key: 'value' });
});
+
+it.each(['icon', 'extended'] as const)(
+ 'applies web hover elevation to the %s FAB and restores it after press and exit',
+ async (type) => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ const onPress = jest.fn();
+ await render(
+ type === 'icon' ? (
+
+ ) : (
+
+ )
+ );
+ const fab = screen.getByTestId('floating-action-button');
+ const container = screen.getByTestId('floating-action-button-container');
+ const theme = getTheme();
+ const [restingShadow] = shadow(3, theme.colors.shadow);
+ const [hoverShadow] = shadow(4, theme.colors.shadow);
+
+ expect(container).toHaveStyle(restingShadow);
+ await fireEvent(fab, 'hoverIn');
+ expect(container).toHaveStyle(hoverShadow);
+ await fireEvent(fab, 'pressIn');
+ expect(container).toHaveStyle(restingShadow);
+ await fireEvent(fab, 'pressOut');
+ expect(container).toHaveStyle(hoverShadow);
+ await fireEvent(fab, 'hoverOut');
+ expect(container).toHaveStyle(restingShadow);
+ }
+);
+
+it('does not enable a FAB without an action when adding interaction handlers', async () => {
+ await render();
+ expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
+});
+
+it('hides an invisible FAB from accessibility and keyboard navigation without marking it disabled', async () => {
+ const onPress = jest.fn();
+ await render(
+
+ );
+ expect(screen.queryByRole('button', { name: 'Create' })).toBeNull();
+ const fab = screen.getByTestId('floating-action-button', {
+ includeHiddenElements: true,
+ });
+ expect(fab).not.toBeDisabled();
+ expect(fab).toHaveProp('accessible', false);
+ expect(fab).toHaveProp('focusable', false);
+ expect(fab).toHaveProp('tabIndex', -1);
+ await userEvent.press(fab);
+ expect(onPress).not.toHaveBeenCalled();
+});
+
+it('clears interaction elevation when a FAB is hidden and shown again', async () => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ const onPress = jest.fn();
+ const { rerender } = await render();
+ await fireEvent(screen.getByTestId('floating-action-button'), 'hoverIn');
+ await rerender();
+ await rerender();
+ const [restingShadow] = shadow(3, getTheme().colors.shadow);
+ expect(screen.getByTestId('floating-action-button-container')).toHaveStyle(
+ restingShadow
+ );
+});
+
+it('uses the shared FAB hover elevation for the menu trigger', async () => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ await render(
+ {}}
+ trigger={{ icon: 'plus', testID: 'menu-trigger', onPress: () => {} }}
+ items={[
+ { label: 'First', onPress: () => {} },
+ { label: 'Second', onPress: () => {} },
+ ]}
+ />
+ );
+ await fireEvent(screen.getByTestId('fab-shell'), 'hoverIn');
+ const [hoverShadow] = shadow(4, getTheme().colors.shadow);
+ expect(screen.getByTestId('fab-shell-container')).toHaveStyle(hoverShadow);
+});
+
+it.each(['ios', 'android'] as const)(
+ 'applies hover elevation alongside focus and restores it after press on %s',
+ async (platform) => {
+ jest.replaceProperty(Platform, 'OS', platform);
+ await render( {}} />);
+ const fab = screen.getByTestId('floating-action-button');
+ const container = screen.getByTestId('floating-action-button-container');
+ const [restingShadow] = shadow(3, getTheme().colors.shadow);
+ const [hoverShadow] = shadow(4, getTheme().colors.shadow);
+ const restingStyle =
+ platform === 'android'
+ ? { elevation: androidElevationLevels[3] }
+ : restingShadow;
+ const hoverStyle =
+ platform === 'android'
+ ? { elevation: androidElevationLevels[4] }
+ : hoverShadow;
+
+ await fireEvent(fab, 'focus', { nativeEvent: {} });
+ expect(container).toHaveStyle(restingStyle);
+ await fireEvent(fab, 'hoverIn');
+ expect(container).toHaveStyle(hoverStyle);
+ await fireEvent(fab, 'focus', { nativeEvent: {} });
+ expect(container).toHaveStyle(hoverStyle);
+ await fireEvent(fab, 'pressIn');
+ expect(container).toHaveStyle(restingStyle);
+ await fireEvent(fab, 'pressOut');
+ expect(container).toHaveStyle(hoverStyle);
+ await fireEvent(fab, 'hoverOut');
+ expect(container).toHaveStyle(restingStyle);
+ }
+);
+
+it('restores the resting elevation when a hovered FAB is hidden or its action is removed', async () => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ const onPress = jest.fn();
+ const { rerender } = await render();
+ await fireEvent(screen.getByTestId('floating-action-button'), 'hoverIn');
+ await rerender();
+ const [restingShadow] = shadow(3, getTheme().colors.shadow);
+ expect(
+ screen.getByTestId('floating-action-button-container', {
+ includeHiddenElements: true,
+ })
+ ).toHaveStyle(restingShadow);
+ await rerender();
+ expect(screen.getByTestId('floating-action-button-container')).toHaveStyle(
+ restingShadow
+ );
+});
diff --git a/src/components/__tests__/FABUtils.test.tsx b/src/components/__tests__/FABUtils.test.tsx
index b72de366f7..7d614bd421 100644
--- a/src/components/__tests__/FABUtils.test.tsx
+++ b/src/components/__tests__/FABUtils.test.tsx
@@ -4,7 +4,40 @@ import { getTheme } from '../../core/theming';
import { getDimensions, resolveColors } from '../FAB/utils';
describe('resolveColors', () => {
- it('returns theme colors for default variant (tonalPrimary)', () => {
+ it.each([false, true])(
+ 'resolves every FAB color preset (dark=%s)',
+ (dark) => {
+ const theme = getTheme(dark);
+ const variants = {
+ primary: ['primary', 'onPrimary'],
+ primaryContainer: ['primaryContainer', 'onPrimaryContainer'],
+ secondary: ['secondary', 'onSecondary'],
+ secondaryContainer: ['secondaryContainer', 'onSecondaryContainer'],
+ tertiary: ['tertiary', 'onTertiary'],
+ tertiaryContainer: ['tertiaryContainer', 'onTertiaryContainer'],
+ branded: ['surfaceContainerHigh', 'onSurface'],
+ } as const;
+
+ const variantNames = [
+ 'primary',
+ 'primaryContainer',
+ 'secondary',
+ 'secondaryContainer',
+ 'tertiary',
+ 'tertiaryContainer',
+ 'branded',
+ ] as const;
+ for (const variant of variantNames) {
+ const [container, content] = variants[variant];
+ expect(resolveColors({ theme, variant })).toEqual({
+ container: theme.colors[container],
+ content: theme.colors[content],
+ });
+ }
+ }
+ );
+
+ it('returns theme colors for default variant (primaryContainer)', () => {
const theme = getTheme();
const colors = resolveColors({ theme });
expect(colors).toEqual({
@@ -40,18 +73,18 @@ describe('resolveColors', () => {
});
});
- it('returns theme colors for tonalSecondary variant', () => {
+ it('returns theme colors for secondaryContainer variant', () => {
const theme = getTheme();
- const colors = resolveColors({ theme, variant: 'tonalSecondary' });
+ const colors = resolveColors({ theme, variant: 'secondaryContainer' });
expect(colors).toEqual({
container: theme.colors.secondaryContainer,
content: theme.colors.onSecondaryContainer,
});
});
- it('returns theme colors for tonalTertiary variant', () => {
+ it('returns theme colors for tertiaryContainer variant', () => {
const theme = getTheme();
- const colors = resolveColors({ theme, variant: 'tonalTertiary' });
+ const colors = resolveColors({ theme, variant: 'tertiaryContainer' });
expect(colors).toEqual({
container: theme.colors.tertiaryContainer,
content: theme.colors.onTertiaryContainer,
diff --git a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap
index 391413183a..b7f71660a5 100644
--- a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap
@@ -139,7 +139,7 @@ exports[`renders FAB large size 1`] = `
}
accessible={true}
collapsable={false}
- focusable={true}
+ focusable={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -380,7 +380,7 @@ exports[`renders FAB medium size 1`] = `
}
accessible={true}
collapsable={false}
- focusable={true}
+ focusable={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -484,6 +484,7 @@ exports[`renders FAB medium size 1`] = `
exports[`renders FAB transitioning to not visible 1`] = `
`;
-exports[`renders FAB with tertiary variant 1`] = `
+exports[`renders FAB with secondaryContainer variant 1`] = `
`;
-exports[`renders FAB with tonalSecondary variant 1`] = `
+exports[`renders FAB with tertiary variant 1`] = `
`;
-exports[`renders FAB with tonalTertiary variant 1`] = `
+exports[`renders FAB with tertiaryContainer variant 1`] = `