diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index 788359afb5..ef20d38fad 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -322,6 +322,48 @@ The misspelled `traileringIcon` props have been renamed:
/>
```
+### FAB
+
+`variant="primary"`, `"secondary"` and `"tertiary"` still type-check in 6.x, but they now draw the filled role color instead of the 5.x container color, so an app that sets them explicitly changes look without any error. 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. The `surface` variant was removed. To keep its look, pass the colors directly:
+
+```jsx
+import { FAB, useTheme } from 'react-native-paper';
+
+const MyFAB = () => {
+ const { colors } = useTheme();
+
+ return (
+
+ );
+};
+```
+
+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..e6470cc091 100644
--- a/example/src/Examples/FABExample.tsx
+++ b/example/src/Examples/FABExample.tsx
@@ -34,9 +34,9 @@ const variants: FabColor[] = [
'primary',
'secondary',
'tertiary',
- 'tonalPrimary',
- 'tonalSecondary',
- 'tonalTertiary',
+ 'primaryContainer',
+ 'secondaryContainer',
+ 'tertiaryContainer',
'custom',
];
@@ -91,7 +91,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;
@@ -187,6 +187,7 @@ const FABExample = () => {
{type === 'icon' && (
{
alignment={position}
trigger={{
icon: 'pencil',
+ 'aria-label': 'Compose',
variant: activeVariant,
containerColor: activeContainerColor,
size,
diff --git a/src/components/FAB/Extended.tsx b/src/components/FAB/Extended.tsx
index 5a35159f4f..9febe2bba2 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 b6e862aa08..9f52a55cb0 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 e4884647d6..069b2bde17 100644
--- a/src/components/FAB/Menu.tsx
+++ b/src/components/FAB/Menu.tsx
@@ -122,23 +122,29 @@ export type MenuProps = {
* tonal (container) role color.
*/
const getCloseVariant = (triggerVariant: Variant): Variant => {
- if (triggerVariant === 'primary' || triggerVariant === 'tonalPrimary') {
+ 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';
+ 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
@@ -544,7 +550,7 @@ const Menu = ({
const isRTL = direction === 'rtl';
const insets = useSafeAreaInsets();
- const triggerVariant: Variant = trigger.variant ?? 'tonalPrimary';
+ const triggerVariant: Variant = 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 26bd118885..69419a1e44 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,42 @@ const Shell = ({
const { focusedSV, onFocus, onBlur } = useFocusRing();
+ React.useEffect(() => {
+ if (!visible) {
+ if (Platform.OS === 'web') {
+ const target: unknown = touchableRef.current;
+ if (
+ target instanceof HTMLElement &&
+ target === document.activeElement
+ ) {
+ const previous = previousFocusedElement.current;
+ // Only restore keyboard focus. A mouse click or tap also focuses the
+ // FAB, and moving focus back could jump into an earlier text field.
+ if (
+ target.matches(':focus-visible') &&
+ previous?.isConnected &&
+ !previous.closest('[aria-hidden="true"], [inert]')
+ ) {
+ previous.focus({ preventScroll: true });
+ }
+ // Blur when focus didn't move back, e.g. after a mouse click or when
+ // the previous element is gone or can't take focus.
+ 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 +353,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..c37a79ef0b 100644
--- a/src/components/FAB/tokens.ts
+++ b/src/components/FAB/tokens.ts
@@ -12,9 +12,9 @@ export type Variant =
| 'primary'
| 'secondary'
| 'tertiary'
- | 'tonalPrimary'
- | 'tonalSecondary'
- | 'tonalTertiary';
+ | 'primaryContainer'
+ | 'secondaryContainer'
+ | 'tertiaryContainer';
export type Size = 'default' | 'medium' | 'large';
@@ -71,15 +71,15 @@ const variants = {
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..832f28b150 100644
--- a/src/components/__tests__/FAB.test.tsx
+++ b/src/components/__tests__/FAB.test.tsx
@@ -1,9 +1,25 @@
-import { expect, it, jest } from '@jest/globals';
+import * as React from 'react';
+import { Platform, View } from 'react-native';
+import type { ViewProps } from 'react-native';
+
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ jest,
+} from '@jest/globals';
import { fireEvent, userEvent } from '@testing-library/react-native';
+import { getAnimatedStyle } from 'react-native-reanimated';
import { render, screen } from '../../test-utils';
import FAB from '../FAB';
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
it('renders FAB with default props', async () => {
const tree = (await render()).toJSON();
expect(tree).toMatchSnapshot();
@@ -24,16 +40,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 +117,393 @@ it('forwards event object to onPress', async () => {
});
expect(onPress).toHaveBeenCalledWith({ key: 'value' });
});
+
+const getStyle = (
+ ref: React.RefObject | null>
+) => {
+ if (!ref.current) throw new Error('Expected FAB ref to be attached');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const animatedComponent = ref.current as unknown as {
+ props: Record;
+ };
+ return getAnimatedStyle(animatedComponent);
+};
+
+// Android exposes elevation as a plain number, so the direction is checkable
+// without the shadow tokens.
+const getElevation = (
+ ref: React.RefObject | null>
+) => {
+ const style = getStyle(ref);
+ if (!('elevation' in style) || typeof style.elevation !== 'number') {
+ throw new Error('Expected a numeric elevation');
+ }
+ return style.elevation;
+};
+
+it('renders a FAB without onPress as a disabled button', 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('create', { 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);
+ await fireEvent(fab, 'click', { nativeEvent: {} });
+ expect(onPress).not.toHaveBeenCalled();
+});
+
+it('clears interaction elevation when a FAB is hidden and shown again', async () => {
+ const ref = React.createRef>();
+ const onPress = jest.fn();
+ const { rerender } = await render(
+
+ );
+ const resting = getStyle(ref);
+ await fireEvent(screen.getByRole('button', { name: 'Create' }), 'hoverIn');
+ expect(getStyle(ref)).not.toEqual(resting);
+ await rerender(
+
+ );
+ await rerender();
+ expect(getStyle(ref)).toEqual(resting);
+});
+
+it('uses the shared FAB hover elevation for the menu trigger', async () => {
+ const { toJSON } = await render(
+ {}}
+ trigger={{ icon: 'plus', 'aria-label': 'Create', onPress: () => {} }}
+ items={[
+ { label: 'First', onPress: () => {} },
+ { label: 'Second', onPress: () => {} },
+ ]}
+ />
+ );
+ const before = JSON.stringify(toJSON());
+ await fireEvent(screen.getByRole('button', { name: 'Create' }), 'hoverIn');
+ const hovered = JSON.stringify(toJSON());
+ expect(hovered).not.toBe(before);
+ await fireEvent(screen.getByRole('button', { name: 'Create' }), 'hoverOut');
+ expect(JSON.stringify(toJSON())).toBe(before);
+});
+
+// React Native 0.85 sends no hover events on iOS or Android by default. These
+// only check that the elevation logic itself has no platform check.
+it.each(['ios', 'android'] as const)(
+ 'keeps the resting elevation on focus and follows hover and press events when Platform.OS is %s',
+ async (platform) => {
+ jest.replaceProperty(Platform, 'OS', platform);
+ const ref = React.createRef>();
+ await render(
+ {}}
+ />
+ );
+ const fab = screen.getByRole('button', { name: 'Create' });
+ const resting = getStyle(ref);
+
+ await fireEvent(fab, 'focus', { nativeEvent: {} });
+ expect(getStyle(ref)).toEqual(resting);
+ await fireEvent(fab, 'hoverIn');
+ const hovered = getStyle(ref);
+ expect(hovered).not.toEqual(resting);
+ await fireEvent(fab, 'focus', { nativeEvent: {} });
+ expect(getStyle(ref)).toEqual(hovered);
+ await fireEvent(fab, 'pressIn');
+ expect(getStyle(ref)).toEqual(resting);
+ await fireEvent(fab, 'pressOut');
+ expect(getStyle(ref)).toEqual(hovered);
+ await fireEvent(fab, 'hoverOut');
+ expect(getStyle(ref)).toEqual(resting);
+ }
+);
+
+it('raises the elevation while hovered and lowers it while pressed when Platform.OS is android', async () => {
+ jest.replaceProperty(Platform, 'OS', 'android');
+ const ref = React.createRef>();
+ await render(
+ {}}
+ />
+ );
+ const fab = screen.getByRole('button', { name: 'Create' });
+ const resting = getElevation(ref);
+
+ await fireEvent(fab, 'hoverIn');
+ expect(getElevation(ref)).toBeGreaterThan(resting);
+ await fireEvent(fab, 'pressIn');
+ expect(getElevation(ref)).toBe(resting);
+});
+
+it('restores the resting elevation when a hovered FAB loses its action', async () => {
+ const ref = React.createRef>();
+ const { rerender } = await render(
+ {}}
+ />
+ );
+ const resting = getStyle(ref);
+ await fireEvent(screen.getByRole('button', { name: 'Create' }), 'hoverIn');
+ expect(getStyle(ref)).not.toEqual(resting);
+ await rerender();
+ expect(getStyle(ref)).toEqual(resting);
+ expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
+ await rerender( {}} />);
+ expect(getStyle(ref)).toEqual(resting);
+});
+
+describe('on web', () => {
+ const originalHTMLElement = Object.getOwnPropertyDescriptor(
+ globalThis,
+ 'HTMLElement'
+ );
+ const originalDocument = Object.getOwnPropertyDescriptor(
+ globalThis,
+ 'document'
+ );
+ const originalMatches = Object.getOwnPropertyDescriptor(
+ View.prototype,
+ 'matches'
+ );
+ const originalComponentDidMount = Object.getOwnPropertyDescriptor(
+ View.prototype,
+ 'componentDidMount'
+ );
+ let activeElement: unknown;
+ let focusVisible: boolean;
+ // The jest View mock is a class component, so its instances have props.
+ type ViewMockInstance = { props: ViewProps; blur: () => void };
+ let fabElement: ViewMockInstance | undefined;
+
+ beforeEach(() => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ activeElement = null;
+ focusVisible = true;
+ fabElement = undefined;
+ // Native View refs stand in for DOM elements in the native test renderer.
+ Object.defineProperty(globalThis, 'HTMLElement', {
+ configurable: true,
+ value: View,
+ });
+ Object.defineProperty(globalThis, 'document', {
+ configurable: true,
+ value: {
+ get activeElement() {
+ return activeElement;
+ },
+ },
+ });
+ Object.defineProperty(View.prototype, 'matches', {
+ configurable: true,
+ value: (selectors: string) =>
+ selectors === ':focus-visible' && focusVisible,
+ });
+ // The pressable view with the FAB's testID is the element that takes focus.
+ Object.defineProperty(View.prototype, 'componentDidMount', {
+ configurable: true,
+ value(this: ViewMockInstance) {
+ if (this.props.testID === 'create') {
+ fabElement = this;
+ }
+ },
+ });
+ });
+
+ afterEach(() => {
+ for (const [target, key, descriptor] of [
+ [globalThis, 'HTMLElement', originalHTMLElement],
+ [globalThis, 'document', originalDocument],
+ [View.prototype, 'matches', originalMatches],
+ [View.prototype, 'componentDidMount', originalComponentDidMount],
+ ] as const) {
+ if (descriptor) {
+ Object.defineProperty(target, key, descriptor);
+ } else {
+ Reflect.deleteProperty(target, key);
+ }
+ }
+ });
+
+ const createElement = ({
+ isConnected = true,
+ focusable = true,
+ hiddenBy,
+ }: {
+ isConnected?: boolean;
+ focusable?: boolean;
+ hiddenBy?: string;
+ } = {}) => {
+ // Inherits from the View mock to pass the HTMLElement instanceof check.
+ const element: object = Object.create(View.prototype);
+ return Object.assign(element, {
+ isConnected,
+ closest: (selectors: string) =>
+ hiddenBy && selectors.includes(hiddenBy) ? {} : null,
+ // Like browsers, focus() does nothing on an element that can't take it.
+ focus: jest.fn(() => {
+ if (isConnected && focusable) {
+ activeElement = element;
+ }
+ }),
+ });
+ };
+
+ const renderFocusedFAB = async (relatedTarget: unknown) => {
+ const fab = (visible: boolean) => (
+ {}}
+ visible={visible}
+ />
+ );
+ const { rerender } = await render(fab(true));
+ if (!fabElement) throw new Error('Expected the FAB to mount');
+ activeElement = fabElement;
+ // The View mock shares one `blur` mock across instances.
+ const blur = jest.fn();
+ fabElement.blur = blur;
+ await fireEvent(screen.getByRole('button', { name: 'Create' }), 'focus', {
+ nativeEvent: { relatedTarget },
+ });
+ return { blur, hide: () => rerender(fab(false)) };
+ };
+
+ it.each(['icon', 'extended'] as const)(
+ 'keeps the %s FAB at rest on focus, changes its elevation while hovered and restores it while pressed',
+ async (type) => {
+ const ref = React.createRef>();
+ await render(
+ type === 'icon' ? (
+ {}}
+ />
+ ) : (
+ {}}
+ />
+ )
+ );
+ const fab = screen.getByRole('button', { name: 'Create' });
+ const resting = getStyle(ref);
+
+ await fireEvent(fab, 'focus', { nativeEvent: {} });
+ expect(getStyle(ref)).toEqual(resting);
+ await fireEvent(fab, 'hoverIn');
+ const hovered = getStyle(ref);
+ expect(hovered).not.toEqual(resting);
+ await fireEvent(fab, 'focus', { nativeEvent: {} });
+ expect(getStyle(ref)).toEqual(hovered);
+ await fireEvent(fab, 'pressIn');
+ expect(getStyle(ref)).toEqual(resting);
+ await fireEvent(fab, 'pressOut');
+ expect(getStyle(ref)).toEqual(hovered);
+ await fireEvent(fab, 'hoverOut');
+ expect(getStyle(ref)).toEqual(resting);
+ }
+ );
+
+ it('moves focus back to the previous element when a keyboard-focused FAB hides', async () => {
+ const previous = createElement();
+ const { blur, hide } = await renderFocusedFAB(previous);
+
+ await hide();
+
+ expect(previous.focus).toHaveBeenCalledWith({ preventScroll: true });
+ expect(blur).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['inside an aria-hidden subtree', { hiddenBy: '[aria-hidden="true"]' }],
+ ['inside an inert subtree', { hiddenBy: '[inert]' }],
+ ['disconnected', { isConnected: false }],
+ ])(
+ 'blurs a keyboard-focused FAB that hides when the previous element is %s',
+ async (_, options) => {
+ const previous = createElement(options);
+ const { blur, hide } = await renderFocusedFAB(previous);
+
+ await hide();
+
+ expect(previous.focus).not.toHaveBeenCalled();
+ expect(blur).toHaveBeenCalledTimes(1);
+ }
+ );
+
+ it("blurs a keyboard-focused FAB that hides when the previous element can't take focus", async () => {
+ const previous = createElement({ focusable: false });
+ const { blur, hide } = await renderFocusedFAB(previous);
+
+ await hide();
+
+ expect(previous.focus).toHaveBeenCalledWith({ preventScroll: true });
+ expect(blur).toHaveBeenCalledTimes(1);
+ });
+
+ it('blurs a FAB focused by a mouse click instead of moving focus back', async () => {
+ focusVisible = false;
+ const previous = createElement();
+ const { blur, hide } = await renderFocusedFAB(previous);
+
+ await hide();
+
+ expect(previous.focus).not.toHaveBeenCalled();
+ expect(blur).toHaveBeenCalledTimes(1);
+ });
+
+ it('leaves focus alone when a FAB that lost focus hides', async () => {
+ const previous = createElement();
+ const { blur, hide } = await renderFocusedFAB(previous);
+ activeElement = createElement();
+
+ await hide();
+
+ expect(previous.focus).not.toHaveBeenCalled();
+ expect(blur).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/__tests__/FABUtils.test.tsx b/src/components/__tests__/FABUtils.test.tsx
index 9389ccd78d..d5bb8b87f0 100644
--- a/src/components/__tests__/FABUtils.test.tsx
+++ b/src/components/__tests__/FABUtils.test.tsx
@@ -1,10 +1,41 @@
import { describe, expect, it } from '@jest/globals';
-import { LightTheme } from '../../theme/schemes';
+import { DarkTheme, LightTheme } from '../../theme/schemes';
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 = dark ? DarkTheme : LightTheme;
+ const variants = {
+ primary: ['primary', 'onPrimary'],
+ primaryContainer: ['primaryContainer', 'onPrimaryContainer'],
+ secondary: ['secondary', 'onSecondary'],
+ secondaryContainer: ['secondaryContainer', 'onSecondaryContainer'],
+ tertiary: ['tertiary', 'onTertiary'],
+ tertiaryContainer: ['tertiaryContainer', 'onTertiaryContainer'],
+ } as const;
+
+ const variantNames = [
+ 'primary',
+ 'primaryContainer',
+ 'secondary',
+ 'secondaryContainer',
+ 'tertiary',
+ 'tertiaryContainer',
+ ] 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 colors = resolveColors({ theme: LightTheme });
expect(colors).toEqual({
container: LightTheme.colors.primaryContainer,
@@ -36,10 +67,10 @@ describe('resolveColors', () => {
});
});
- it('returns theme colors for tonalSecondary variant', () => {
+ it('returns theme colors for secondaryContainer variant', () => {
const colors = resolveColors({
theme: LightTheme,
- variant: 'tonalSecondary',
+ variant: 'secondaryContainer',
});
expect(colors).toEqual({
container: LightTheme.colors.secondaryContainer,
@@ -47,10 +78,10 @@ describe('resolveColors', () => {
});
});
- it('returns theme colors for tonalTertiary variant', () => {
+ it('returns theme colors for tertiaryContainer variant', () => {
const colors = resolveColors({
theme: LightTheme,
- variant: 'tonalTertiary',
+ variant: 'tertiaryContainer',
});
expect(colors).toEqual({
container: LightTheme.colors.tertiaryContainer,
diff --git a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap
index 4b06302358..a1ed339730 100644
--- a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap
@@ -138,7 +138,7 @@ exports[`renders FAB large size 1`] = `
}
accessible={true}
collapsable={false}
- focusable={true}
+ focusable={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -377,7 +377,7 @@ exports[`renders FAB medium size 1`] = `
}
accessible={true}
collapsable={false}
- focusable={true}
+ focusable={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -480,6 +480,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`] = `