diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index 72aee6d00f..84cb793efb 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -307,6 +307,48 @@ The misspelled `traileringIcon` props have been renamed:
/>
```
+### 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. 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 17da09bd56..103d6cc6a7 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 fb30396939..17c8face9f 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 83a71f72ed..52d8d7dc9a 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..c696cf8ff8 100644
--- a/src/components/__tests__/FAB.test.tsx
+++ b/src/components/__tests__/FAB.test.tsx
@@ -1,9 +1,24 @@
-import { expect, it, jest } from '@jest/globals';
+import * as React from 'react';
+import { Platform, View } 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 +39,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 +116,382 @@ it('forwards event object to onPress', async () => {
});
expect(onPress).toHaveBeenCalledWith({ key: 'value' });
});
+
+const getStyle = (ref: React.RefObject) => {
+ if (!ref.current) throw new Error('Expected FAB ref to be attached');
+ return getAnimatedStyle(ref.current);
+};
+
+// Android exposes elevation as a plain number, so the direction is checkable
+// without the shadow tokens.
+const getElevation = (ref: React.RefObject) => {
+ 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;
+ let fabElement: View | 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: View) {
+ 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;
+ } = {}) => {
+ const element = new View({});
+ 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`] = `