From 1c813dcb4661e38fcbe6a217a4d386a0919deeb9 Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 16:48:20 +0200 Subject: [PATCH 01/17] fix(fab): align MD3 variants and interaction states Add surface and branded variants, apply web hover elevation, and prevent hidden FABs from retaining focus or actions. Keep large icons at 36dp. BREAKING CHANGE: Replace tonalPrimary, tonalSecondary, and tonalTertiary with primaryContainer, secondaryContainer, and tertiaryContainer. The old variant names are removed without aliases. --- docs/6.x/docs/guides/migration.md | 18 +++ example/src/Examples/FABExample.tsx | 37 ++++- src/components/FAB/Extended.tsx | 9 +- src/components/FAB/FAB.tsx | 9 +- src/components/FAB/Menu.tsx | 33 ++-- src/components/FAB/Shell.tsx | 56 ++++++- src/components/FAB/tokens.ts | 16 +- src/components/FAB/utils.ts | 2 +- src/components/__tests__/FAB.test.tsx | 149 +++++++++++++++++- src/components/__tests__/FABUtils.test.tsx | 47 +++++- .../__snapshots__/FABExtended.test.tsx.snap | 1 + .../__snapshots__/FABMenu.test.tsx.snap | 1 + 12 files changed, 330 insertions(+), 48 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 72aee6d00f..93d2bfc7fa 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -307,6 +307,24 @@ The misspelled `traileringIcon` props have been renamed: /> ``` +### FAB + +The color presets for `FAB` and `FAB.Extended` now match the MD3 color roles: +`primary`, `primaryContainer` (default), `secondary`, `secondaryContainer`, +`tertiary`, `tertiaryContainer`, `surface`, and `branded`. + +If you used an earlier 6.x build, replace `tonalPrimary`, `tonalSecondary`, and +`tonalTertiary` with `primaryContainer`, `secondaryContainer`, and +`tertiaryContainer`. The old names have been removed without aliases. This also +applies to the FAB menu trigger's `variant`. + +Both `surface` and `branded` use `surfaceContainerHigh`. Surface FAB content uses +`primary`; branded content defaults to `onSurface`. A custom icon source can +render brand artwork in its own colors. + +On web, hovering a FAB raises its elevation from level 3 to level 4. Focused and +pressed states use level 3. The large FAB icon remains **36dp**. + ### 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..3937dcdf4c 100644 --- a/example/src/Examples/FABExample.tsx +++ b/example/src/Examples/FABExample.tsx @@ -34,9 +34,11 @@ const variants: FabColor[] = [ 'primary', 'secondary', 'tertiary', - 'tonalPrimary', - 'tonalSecondary', - 'tonalTertiary', + 'primaryContainer', + 'secondaryContainer', + 'tertiaryContainer', + 'surface', + 'branded', 'custom', ]; @@ -91,7 +93,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 +140,29 @@ const FABExample = () => { v !== 'surface' && v !== 'branded') + : variants + } value={variant} onChange={setVariant} /> - + { + if ( + nextType === 'menu' && + (variant === 'surface' || variant === 'branded') + ) { + setVariant('primaryContainer'); + } + setType(nextType); + }} + /> { {type === 'icon' && ( { alignment={position} trigger={{ icon: 'pencil', - variant: activeVariant, + variant: + activeVariant === 'surface' || activeVariant === 'branded' + ? undefined + : activeVariant, containerColor: activeContainerColor, size, visible: showFab, diff --git a/src/components/FAB/Extended.tsx b/src/components/FAB/Extended.tsx index 17da09bd56..964839d6a8 100644 --- a/src/components/FAB/Extended.tsx +++ b/src/components/FAB/Extended.tsx @@ -38,7 +38,12 @@ export type Props = { */ label: string; /** - * Role-color preset. Defaults to `tonalPrimary`. + * Role-color preset. Defaults to `primaryContainer`. + * Choose `primary`, `primaryContainer`, `secondary`, `secondaryContainer`, + * `tertiary`, `tertiaryContainer`, `surface`, or `branded`. + * `surface` and `branded` use `surfaceContainerHigh`; `surface` uses primary + * content, while `branded` uses on-surface content. Pass a custom icon source + * to preserve brand artwork colors. */ variant?: Variant; /** @@ -154,7 +159,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..07143a558f 100644 --- a/src/components/FAB/FAB.tsx +++ b/src/components/FAB/FAB.tsx @@ -21,7 +21,12 @@ export type Props = { */ icon: IconSource; /** - * Role-color preset. Defaults to `tonalPrimary`. + * Role-color preset. Defaults to `primaryContainer`. + * Choose `primary`, `primaryContainer`, `secondary`, `secondaryContainer`, + * `tertiary`, `tertiaryContainer`, `surface`, or `branded`. + * `surface` and `branded` use `surfaceContainerHigh`; `surface` uses primary + * content, while `branded` uses on-surface content. Pass a custom icon source + * to preserve brand artwork colors. */ variant?: Variant; /** @@ -118,7 +123,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..fb7e7a3a2d 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 @@ -302,7 +310,7 @@ const MenuItem = ({ }; type MorphingTriggerProps = { - triggerVariant: Variant; + triggerVariant: MenuVariant; closeVariant: Variant; triggerContainerColor?: ColorValue; triggerContentColor?: ColorValue; @@ -447,6 +455,7 @@ const MorphingTrigger = ({ ]} > { const theme = useInternalTheme(themeOverrides); + const [hovered, setHovered] = React.useState(false); + const [pressed, setPressed] = React.useState(false); + const [focused, setFocused] = React.useState(false); + const touchableRef = React.useRef(null); + + // Explicit elevations (including flat menu items) keep their own treatment. + const resolvedElevation = + elevation ?? + (Platform.OS === 'web' && visible && onPress + ? pressed + ? Tokens.stateElevation.pressed + : focused + ? Tokens.stateElevation.focus + : hovered + ? Tokens.stateElevation.hover + : Tokens.stateElevation.enabled + : Tokens.stateElevation.enabled); const dimensions = React.useMemo( () => getDimensions({ theme, size, shape, iconSize, leading, trailing }), @@ -299,6 +316,18 @@ const Shell = ({ const { focusedSV, onFocus, onBlur } = useFocusRing(); + React.useEffect(() => { + if (!visible || !onPress) { + if (Platform.OS === 'web') { + touchableRef.current?.blur(); + } + setHovered(false); + setPressed(false); + setFocused(false); + onBlur(); + } + }, [visible, onPress, onBlur]); + const focusRingStyle = useAnimatedStyle( () => ({ opacity: focusedSV.value ? 1 : 0, @@ -310,6 +339,7 @@ const Shell = ({ return ( {overlay} setHovered(true)} + onHoverOut={() => setHovered(false)} + onPressIn={() => setPressed(true)} + onPressOut={() => setPressed(false)} + onFocus={() => { + setFocused(true); + onFocus(); + }} + onBlur={() => { + setFocused(false); + onBlur(); + }} aria-label={ariaLabel} role="button" aria-checked={ariaChecked} diff --git a/src/components/FAB/tokens.ts b/src/components/FAB/tokens.ts index 0fb79d1d9c..5347c1a996 100644 --- a/src/components/FAB/tokens.ts +++ b/src/components/FAB/tokens.ts @@ -12,9 +12,11 @@ export type Variant = | 'primary' | 'secondary' | 'tertiary' - | 'tonalPrimary' - | 'tonalSecondary' - | 'tonalTertiary'; + | 'primaryContainer' + | 'secondaryContainer' + | 'tertiaryContainer' + | 'surface' + | 'branded'; export type Size = 'default' | 'medium' | 'large'; @@ -68,18 +70,20 @@ const stateElevation = { } as const satisfies Record; const variants = { + surface: { container: 'surfaceContainerHigh', content: 'primary' }, + 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..749199471c 100644 --- a/src/components/__tests__/FAB.test.tsx +++ b/src/components/__tests__/FAB.test.tsx @@ -1,8 +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'; +import Shell from '../FAB/Shell'; + +afterEach(() => { + jest.restoreAllMocks(); +}); it('renders FAB with default props', async () => { const tree = (await render()).toJSON(); @@ -24,16 +36,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 +113,130 @@ 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('keeps an explicit shell elevation of zero on hover', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + await render( {}} elevation={0} />); + await fireEvent(screen.getByTestId('fab-shell'), 'hoverIn'); + const [flatShadow] = shadow(0, getTheme().colors.shadow); + expect(screen.getByTestId('fab-shell-container')).toHaveStyle(flatShadow); +}); + +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 disables its action', 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).toBeDisabled(); + 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('keeps the menu trigger at its existing elevation on web', 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 [restingShadow] = shadow(3, getTheme().colors.shadow); + expect(screen.getByTestId('fab-shell-container')).toHaveStyle(restingShadow); +}); + +it.each(['ios', 'android'] as const)( + 'keeps native elevation unchanged on hover on %s', + async (platform) => { + jest.replaceProperty(Platform, 'OS', platform); + await render( {}} />); + await fireEvent(screen.getByTestId('floating-action-button'), 'hoverIn'); + const [restingShadow] = shadow(3, getTheme().colors.shadow); + expect(screen.getByTestId('floating-action-button-container')).toHaveStyle( + platform === 'android' + ? { elevation: androidElevationLevels[3] } + : restingShadow + ); + } +); + +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 9389ccd78d..75a0068ed5 100644 --- a/src/components/__tests__/FABUtils.test.tsx +++ b/src/components/__tests__/FABUtils.test.tsx @@ -1,10 +1,45 @@ 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 MD3 color variant (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'], + surface: ['surfaceContainerHigh', 'primary'], + branded: ['surfaceContainerHigh', 'onSurface'], + } as const; + + const variantNames = [ + 'primary', + 'primaryContainer', + 'secondary', + 'secondaryContainer', + 'tertiary', + 'tertiaryContainer', + 'surface', + '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 colors = resolveColors({ theme: LightTheme }); expect(colors).toEqual({ container: LightTheme.colors.primaryContainer, @@ -36,10 +71,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 +82,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__/FABExtended.test.tsx.snap b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap index 644a640c5a..13bc46cabc 100644 --- a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap @@ -1307,6 +1307,7 @@ exports[`renders extended FAB medium size 1`] = ` exports[`renders extended FAB not visible 1`] = ` <> Date: Fri, 11 Sep 2026 11:16:44 +0200 Subject: [PATCH 02/17] fix(fab): address review feedback Remove the surface preset and static elevation override. Apply hover elevation across platforms and restore focus when hiding a focused FAB. Separate hidden and disabled semantics, and rewrite the migration guide around v5 consumer changes. --- docs/6.x/docs/guides/migration.md | 33 ++-- example/src/Examples/FABExample.tsx | 15 +- src/components/FAB/Extended.tsx | 5 - src/components/FAB/FAB.tsx | 5 - src/components/FAB/Menu.tsx | 3 +- src/components/FAB/Shell.tsx | 71 ++++--- src/components/FAB/tokens.ts | 3 +- src/components/__tests__/FAB.test.tsx | 182 +++++++++++------- src/components/__tests__/FABUtils.test.tsx | 4 +- .../__tests__/__snapshots__/FAB.test.tsx.snap | 52 ++--- .../__snapshots__/FABExtended.test.tsx.snap | 15 +- .../__snapshots__/FABMenu.test.tsx.snap | 7 +- 12 files changed, 224 insertions(+), 171 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 93d2bfc7fa..ae22bec7f8 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -309,21 +309,30 @@ The misspelled `traileringIcon` props have been renamed: ### FAB -The color presets for `FAB` and `FAB.Extended` now match the MD3 color roles: -`primary`, `primaryContainer` (default), `secondary`, `secondaryContainer`, -`tertiary`, `tertiaryContainer`, `surface`, and `branded`. +To preserve the v5 FAB color treatment, update the `variant` prop: -If you used an earlier 6.x build, replace `tonalPrimary`, `tonalSecondary`, and -`tonalTertiary` with `primaryContainer`, `secondaryContainer`, and -`tertiaryContainer`. The old names have been removed without aliases. This also -applies to the FAB menu trigger's `variant`. +| v5 | v6 | +| --- | --- | +| `primary` | `primaryContainer` | +| `secondary` | `secondaryContainer` | +| `tertiary` | `tertiaryContainer` | -Both `surface` and `branded` use `surfaceContainerHigh`. Surface FAB content uses -`primary`; branded content defaults to `onSurface`. A custom icon source can -render brand artwork in its own colors. +If you omit `variant`, no change is needed. Replace `variant="surface"` with +one of the supported color variants, such as `primaryContainer`. -On web, hovering a FAB raises its elevation from level 3 to level 4. Focused and -pressed states use level 3. The large FAB icon remains **36dp**. +For custom colors, replace `color` with `contentColor` and move +`style.backgroundColor` to `containerColor`: + +```diff + +``` ### TextInput diff --git a/example/src/Examples/FABExample.tsx b/example/src/Examples/FABExample.tsx index 3937dcdf4c..f878c61b24 100644 --- a/example/src/Examples/FABExample.tsx +++ b/example/src/Examples/FABExample.tsx @@ -37,7 +37,6 @@ const variants: FabColor[] = [ 'primaryContainer', 'secondaryContainer', 'tertiaryContainer', - 'surface', 'branded', 'custom', ]; @@ -141,9 +140,7 @@ const FABExample = () => { v !== 'surface' && v !== 'branded') - : variants + type === 'menu' ? variants.filter((v) => v !== 'branded') : variants } value={variant} onChange={setVariant} @@ -154,10 +151,7 @@ const FABExample = () => { options={types} value={type} onChange={(nextType) => { - if ( - nextType === 'menu' && - (variant === 'surface' || variant === 'branded') - ) { + if (nextType === 'menu' && variant === 'branded') { setVariant('primaryContainer'); } setType(nextType); @@ -234,10 +228,7 @@ const FABExample = () => { alignment={position} trigger={{ icon: 'pencil', - variant: - activeVariant === 'surface' || activeVariant === 'branded' - ? undefined - : 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 964839d6a8..103d6cc6a7 100644 --- a/src/components/FAB/Extended.tsx +++ b/src/components/FAB/Extended.tsx @@ -39,11 +39,6 @@ export type Props = { label: string; /** * Role-color preset. Defaults to `primaryContainer`. - * Choose `primary`, `primaryContainer`, `secondary`, `secondaryContainer`, - * `tertiary`, `tertiaryContainer`, `surface`, or `branded`. - * `surface` and `branded` use `surfaceContainerHigh`; `surface` uses primary - * content, while `branded` uses on-surface content. Pass a custom icon source - * to preserve brand artwork colors. */ variant?: Variant; /** diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FAB.tsx index 07143a558f..17c8face9f 100644 --- a/src/components/FAB/FAB.tsx +++ b/src/components/FAB/FAB.tsx @@ -22,11 +22,6 @@ export type Props = { icon: IconSource; /** * Role-color preset. Defaults to `primaryContainer`. - * Choose `primary`, `primaryContainer`, `secondary`, `secondaryContainer`, - * `tertiary`, `tertiaryContainer`, `surface`, or `branded`. - * `surface` and `branded` use `surfaceContainerHigh`; `surface` uses primary - * content, while `branded` uses on-surface content. Pass a custom icon source - * to preserve brand artwork colors. */ variant?: Variant; /** diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx index fb7e7a3a2d..eb10a716c5 100644 --- a/src/components/FAB/Menu.tsx +++ b/src/components/FAB/Menu.tsx @@ -55,7 +55,7 @@ export type MenuItemProps = { testID?: string; }; -type MenuVariant = Exclude; +type MenuVariant = Exclude; export type MenuTriggerProps = { /** @@ -455,7 +455,6 @@ const MorphingTrigger = ({ ]} > (null); + const previousFocusedElement = React.useRef(null); - // Explicit elevations (including flat menu items) keep their own treatment. const resolvedElevation = - elevation ?? - (Platform.OS === 'web' && visible && onPress + visible && onPress ? pressed ? Tokens.stateElevation.pressed - : focused - ? Tokens.stateElevation.focus - : hovered - ? Tokens.stateElevation.hover - : Tokens.stateElevation.enabled - : Tokens.stateElevation.enabled); + : hovered + ? Tokens.stateElevation.hover + : Tokens.stateElevation.enabled + : Tokens.stateElevation.enabled; const dimensions = React.useMemo( () => getDimensions({ theme, size, shape, iconSize, leading, trailing }), @@ -317,16 +307,32 @@ const Shell = ({ const { focusedSV, onFocus, onBlur } = useFocusRing(); React.useEffect(() => { - if (!visible || !onPress) { - if (Platform.OS === 'web') { - touchableRef.current?.blur(); + 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); - setFocused(false); onBlur(); } - }, [visible, onPress, onBlur]); + }, [visible, onBlur]); + + React.useEffect(() => { + if (!onPress) { + setHovered(false); + setPressed(false); + } + }, [onPress]); const focusRingStyle = useAnimatedStyle( () => ({ @@ -358,19 +364,26 @@ const Shell = ({ borderless background={background} onPress={visible ? onPress : undefined} - disabled={!onPress || !visible} + disabled={!onPress} + accessible={visible} + focusable={visible && !!onPress} + tabIndex={visible ? undefined : -1} onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)} onPressIn={() => setPressed(true)} onPressOut={() => setPressed(false)} - onFocus={() => { - setFocused(true); + 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={() => { - setFocused(false); - onBlur(); - }} + onBlur={onBlur} aria-label={ariaLabel} role="button" aria-checked={ariaChecked} diff --git a/src/components/FAB/tokens.ts b/src/components/FAB/tokens.ts index 5347c1a996..19c992491b 100644 --- a/src/components/FAB/tokens.ts +++ b/src/components/FAB/tokens.ts @@ -15,7 +15,6 @@ export type Variant = | 'primaryContainer' | 'secondaryContainer' | 'tertiaryContainer' - | 'surface' | 'branded'; export type Size = 'default' | 'medium' | 'large'; @@ -70,7 +69,7 @@ const stateElevation = { } as const satisfies Record; const variants = { - surface: { container: 'surfaceContainerHigh', content: 'primary' }, + // 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' }, diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx index 749199471c..91ca832121 100644 --- a/src/components/__tests__/FAB.test.tsx +++ b/src/components/__tests__/FAB.test.tsx @@ -1,16 +1,18 @@ -import { Platform } from 'react-native'; +import * as React from 'react'; +import { Platform, View } 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 { getAnimatedStyle } from 'react-native-reanimated'; + import { render, screen } from '../../test-utils'; +import { LightTheme } from '../../theme/schemes'; import { androidElevationLevels, shadow, } from '../../theme/tokens/sys/elevation'; import FAB from '../FAB'; -import Shell from '../FAB/Shell'; afterEach(() => { jest.restoreAllMocks(); @@ -114,129 +116,177 @@ 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); +}; + 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 ref = React.createRef(); 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); + const fab = screen.getByRole('button', { name: 'Create' }); + const [restingShadow] = shadow(3, LightTheme.colors.shadow); + const [hoverShadow] = shadow(4, LightTheme.colors.shadow); - expect(container).toHaveStyle(restingShadow); + expect(getStyle(ref)).toMatchObject(restingShadow); await fireEvent(fab, 'hoverIn'); - expect(container).toHaveStyle(hoverShadow); + expect(getStyle(ref)).toMatchObject(hoverShadow); await fireEvent(fab, 'pressIn'); - expect(container).toHaveStyle(restingShadow); + expect(getStyle(ref)).toMatchObject(restingShadow); await fireEvent(fab, 'pressOut'); - expect(container).toHaveStyle(hoverShadow); + expect(getStyle(ref)).toMatchObject(hoverShadow); await fireEvent(fab, 'hoverOut'); - expect(container).toHaveStyle(restingShadow); + expect(getStyle(ref)).toMatchObject(restingShadow); } ); -it('keeps an explicit shell elevation of zero on hover', async () => { - jest.replaceProperty(Platform, 'OS', 'web'); - await render( {}} elevation={0} />); - await fireEvent(screen.getByTestId('fab-shell'), 'hoverIn'); - const [flatShadow] = shadow(0, getTheme().colors.shadow); - expect(screen.getByTestId('fab-shell-container')).toHaveStyle(flatShadow); -}); - 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 disables its action', async () => { +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).toBeDisabled(); + 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); expect(onPress).not.toHaveBeenCalled(); }); it('clears interaction elevation when a FAB is hidden and shown again', async () => { - jest.replaceProperty(Platform, 'OS', 'web'); + const ref = React.createRef(); 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 + const { rerender } = await render( + + ); + await fireEvent(screen.getByRole('button', { name: 'Create' }), 'hoverIn'); + await rerender( + ); + await rerender(); + const [restingShadow] = shadow(3, LightTheme.colors.shadow); + expect(getStyle(ref)).toMatchObject(restingShadow); }); -it('keeps the menu trigger at its existing elevation on web', async () => { - jest.replaceProperty(Platform, 'OS', 'web'); - await render( +it('uses the shared FAB hover elevation for the menu trigger', async () => { + const { toJSON } = await render( {}} - trigger={{ icon: 'plus', testID: 'menu-trigger', onPress: () => {} }} + trigger={{ icon: 'plus', 'aria-label': 'Create', onPress: () => {} }} items={[ { label: 'First', onPress: () => {} }, { label: 'Second', onPress: () => {} }, ]} /> ); - await fireEvent(screen.getByTestId('fab-shell'), 'hoverIn'); - const [restingShadow] = shadow(3, getTheme().colors.shadow); - expect(screen.getByTestId('fab-shell-container')).toHaveStyle(restingShadow); + 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); }); it.each(['ios', 'android'] as const)( - 'keeps native elevation unchanged on hover on %s', + 'applies hover elevation alongside focus and restores it after press on %s', async (platform) => { jest.replaceProperty(Platform, 'OS', platform); - await render( {}} />); - await fireEvent(screen.getByTestId('floating-action-button'), 'hoverIn'); - const [restingShadow] = shadow(3, getTheme().colors.shadow); - expect(screen.getByTestId('floating-action-button-container')).toHaveStyle( + const ref = React.createRef(); + await render( + {}} + /> + ); + const fab = screen.getByRole('button', { name: 'Create' }); + const [restingShadow] = shadow(3, LightTheme.colors.shadow); + const [hoverShadow] = shadow(4, LightTheme.colors.shadow); + const restingStyle = platform === 'android' ? { elevation: androidElevationLevels[3] } - : restingShadow - ); + : restingShadow; + const hoverStyle = + platform === 'android' + ? { elevation: androidElevationLevels[4] } + : hoverShadow; + + await fireEvent(fab, 'focus', { nativeEvent: {} }); + expect(getStyle(ref)).toMatchObject(restingStyle); + await fireEvent(fab, 'hoverIn'); + expect(getStyle(ref)).toMatchObject(hoverStyle); + await fireEvent(fab, 'focus', { nativeEvent: {} }); + expect(getStyle(ref)).toMatchObject(hoverStyle); + await fireEvent(fab, 'pressIn'); + expect(getStyle(ref)).toMatchObject(restingStyle); + await fireEvent(fab, 'pressOut'); + expect(getStyle(ref)).toMatchObject(hoverStyle); + await fireEvent(fab, 'hoverOut'); + expect(getStyle(ref)).toMatchObject(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 +it('restores the resting elevation when a hovered FAB loses its action', async () => { + const ref = React.createRef(); + const { rerender } = await render( + {}} + /> ); + await fireEvent(screen.getByRole('button', { name: 'Create' }), 'hoverIn'); + await rerender(); + const [restingShadow] = shadow(3, LightTheme.colors.shadow); + expect(getStyle(ref)).toMatchObject(restingShadow); + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); + await rerender( {}} />); + expect(getStyle(ref)).toMatchObject(restingShadow); }); diff --git a/src/components/__tests__/FABUtils.test.tsx b/src/components/__tests__/FABUtils.test.tsx index 75a0068ed5..6d80f0bc4e 100644 --- a/src/components/__tests__/FABUtils.test.tsx +++ b/src/components/__tests__/FABUtils.test.tsx @@ -5,7 +5,7 @@ import { getDimensions, resolveColors } from '../FAB/utils'; describe('resolveColors', () => { it.each([false, true])( - 'resolves every MD3 color variant (dark=%s)', + 'resolves every FAB color preset (dark=%s)', (dark) => { const theme = dark ? DarkTheme : LightTheme; const variants = { @@ -15,7 +15,6 @@ describe('resolveColors', () => { secondaryContainer: ['secondaryContainer', 'onSecondaryContainer'], tertiary: ['tertiary', 'onTertiary'], tertiaryContainer: ['tertiaryContainer', 'onTertiaryContainer'], - surface: ['surfaceContainerHigh', 'primary'], branded: ['surfaceContainerHigh', 'onSurface'], } as const; @@ -26,7 +25,6 @@ describe('resolveColors', () => { 'secondaryContainer', 'tertiary', 'tertiaryContainer', - 'surface', 'branded', ] as const; for (const variant of variantNames) { 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`] = ` Date: Wed, 23 Sep 2026 11:28:58 +0200 Subject: [PATCH 03/17] fix(fab): skip hidden focus restoration targets --- src/components/FAB/Shell.tsx | 8 ++++++-- src/components/__tests__/FAB.test.tsx | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx index 8959f88cb3..29aee2490b 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -314,8 +314,12 @@ const Shell = ({ target instanceof HTMLElement && target === document.activeElement ) { - if (previousFocusedElement.current?.isConnected) { - previousFocusedElement.current.focus({ preventScroll: true }); + const previous = previousFocusedElement.current; + if ( + previous?.isConnected && + !previous.closest('[aria-hidden="true"], [inert]') + ) { + previous.focus({ preventScroll: true }); } // The previous element may have been removed or become unfocusable. if (target === document.activeElement) target.blur(); diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx index 91ca832121..e7919ba5cd 100644 --- a/src/components/__tests__/FAB.test.tsx +++ b/src/components/__tests__/FAB.test.tsx @@ -3,7 +3,6 @@ import { Platform, View } from 'react-native'; import { afterEach, 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'; From 6e033f87204980c38e5115c93c12eb6bf5149a47 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 12:59:23 +0100 Subject: [PATCH 04/17] fix(fab): remove the branded variant The branded FAB comes from material-web's md-branded-fab, which is meant for multicolor brand logos and has no icon color token, so it is not one of the MD3 FAB color styles. Removing it also removes the menu-only variant type and the example's special cases for it. --- example/src/Examples/FABExample.tsx | 19 +++---------------- src/components/FAB/Menu.tsx | 12 +++++------- src/components/FAB/tokens.ts | 5 +---- src/components/__tests__/FABUtils.test.tsx | 2 -- 4 files changed, 9 insertions(+), 29 deletions(-) diff --git a/example/src/Examples/FABExample.tsx b/example/src/Examples/FABExample.tsx index f878c61b24..afa758fca9 100644 --- a/example/src/Examples/FABExample.tsx +++ b/example/src/Examples/FABExample.tsx @@ -37,7 +37,6 @@ const variants: FabColor[] = [ 'primaryContainer', 'secondaryContainer', 'tertiaryContainer', - 'branded', 'custom', ]; @@ -139,24 +138,12 @@ const FABExample = () => { v !== 'branded') : variants - } + options={variants} value={variant} onChange={setVariant} /> - { - if (nextType === 'menu' && variant === 'branded') { - setVariant('primaryContainer'); - } - setType(nextType); - }} - /> + { alignment={position} trigger={{ icon: 'pencil', - variant: activeVariant === 'branded' ? undefined : activeVariant, + variant: activeVariant, containerColor: activeContainerColor, size, visible: showFab, diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx index eb10a716c5..069b2bde17 100644 --- a/src/components/FAB/Menu.tsx +++ b/src/components/FAB/Menu.tsx @@ -55,15 +55,13 @@ 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?: MenuVariant; + variant?: Variant; size?: Size; containerColor?: ColorValue; contentColor?: ColorValue; @@ -123,7 +121,7 @@ export type MenuProps = { * The close button is always the saturated role color; items are always the * tonal (container) role color. */ -const getCloseVariant = (triggerVariant: MenuVariant): Variant => { +const getCloseVariant = (triggerVariant: Variant): Variant => { if (triggerVariant === 'primary' || triggerVariant === 'primaryContainer') { return 'primary'; } @@ -136,7 +134,7 @@ const getCloseVariant = (triggerVariant: MenuVariant): Variant => { return 'tertiary'; }; -const getItemsVariant = (triggerVariant: MenuVariant): Variant => { +const getItemsVariant = (triggerVariant: Variant): Variant => { if (triggerVariant === 'primary' || triggerVariant === 'primaryContainer') { return 'primaryContainer'; } @@ -310,7 +308,7 @@ const MenuItem = ({ }; type MorphingTriggerProps = { - triggerVariant: MenuVariant; + triggerVariant: Variant; closeVariant: Variant; triggerContainerColor?: ColorValue; triggerContentColor?: ColorValue; @@ -552,7 +550,7 @@ const Menu = ({ const isRTL = direction === 'rtl'; const insets = useSafeAreaInsets(); - const triggerVariant: MenuVariant = trigger.variant ?? 'primaryContainer'; + 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/tokens.ts b/src/components/FAB/tokens.ts index 19c992491b..c37a79ef0b 100644 --- a/src/components/FAB/tokens.ts +++ b/src/components/FAB/tokens.ts @@ -14,8 +14,7 @@ export type Variant = | 'tertiary' | 'primaryContainer' | 'secondaryContainer' - | 'tertiaryContainer' - | 'branded'; + | 'tertiaryContainer'; export type Size = 'default' | 'medium' | 'large'; @@ -69,8 +68,6 @@ 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' }, diff --git a/src/components/__tests__/FABUtils.test.tsx b/src/components/__tests__/FABUtils.test.tsx index 6d80f0bc4e..d5bb8b87f0 100644 --- a/src/components/__tests__/FABUtils.test.tsx +++ b/src/components/__tests__/FABUtils.test.tsx @@ -15,7 +15,6 @@ describe('resolveColors', () => { secondaryContainer: ['secondaryContainer', 'onSecondaryContainer'], tertiary: ['tertiary', 'onTertiary'], tertiaryContainer: ['tertiaryContainer', 'onTertiaryContainer'], - branded: ['surfaceContainerHigh', 'onSurface'], } as const; const variantNames = [ @@ -25,7 +24,6 @@ describe('resolveColors', () => { 'secondaryContainer', 'tertiary', 'tertiaryContainer', - 'branded', ] as const; for (const variant of variantNames) { const [container, content] = variants[variant]; From 21783e7e070af6625a3740dbb244d5d473e7be69 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:00:25 +0100 Subject: [PATCH 05/17] docs(fab): show how to keep the surface look after migrating primaryContainer does not match the v5 surface variant, which used a surface container with a primary icon. Point to containerColor and contentColor with the closest v6 roles instead. --- docs/6.x/docs/guides/migration.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index ae22bec7f8..6ad5eb9e48 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -317,8 +317,17 @@ To preserve the v5 FAB color treatment, update the `variant` prop: | `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`. +If you omit `variant`, no change is needed. The `surface` variant was removed. To keep its look, pass the colors directly: + +```jsx +const { colors } = useTheme(); + + +``` For custom colors, replace `color` with `contentColor` and move `style.backgroundColor` to `containerColor`: From 6b2bce828d758fec109bb4765f6a2c1a74205e74 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:00:47 +0100 Subject: [PATCH 06/17] refactor(fab): drop the redundant document check Effects do not run during server rendering and document always exists on web, so the check never fails. --- src/components/FAB/Shell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx index 29aee2490b..d4bce63054 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -308,7 +308,7 @@ const Shell = ({ React.useEffect(() => { if (!visible) { - if (Platform.OS === 'web' && typeof document !== 'undefined') { + if (Platform.OS === 'web') { const target: unknown = touchableRef.current; if ( target instanceof HTMLElement && From 0e1332d05c32d62a445e18f4e6b8ed4b7e8ac946 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:01:00 +0100 Subject: [PATCH 07/17] fix(fab): name the menu trigger in the example The menu trigger is an icon-only FAB, so without a label it is announced as an unnamed button. --- example/src/Examples/FABExample.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/example/src/Examples/FABExample.tsx b/example/src/Examples/FABExample.tsx index afa758fca9..e6470cc091 100644 --- a/example/src/Examples/FABExample.tsx +++ b/example/src/Examples/FABExample.tsx @@ -215,6 +215,7 @@ const FABExample = () => { alignment={position} trigger={{ icon: 'pencil', + 'aria-label': 'Compose', variant: activeVariant, containerColor: activeContainerColor, size, From a1898aeebdff39a0e5490243be75a929db29e3c1 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:05:19 +0100 Subject: [PATCH 08/17] docs(fab): format the migration snippet and table Align the variant table and wrap the surface example in a component so it is self-contained and prettier keeps it as written. --- docs/6.x/docs/guides/migration.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 6ad5eb9e48..697cdecfaf 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -311,22 +311,26 @@ The misspelled `traileringIcon` props have been renamed: To preserve the v5 FAB color treatment, update the `variant` prop: -| v5 | v6 | -| --- | --- | -| `primary` | `primaryContainer` | +| v5 | v6 | +| ----------- | -------------------- | +| `primary` | `primaryContainer` | | `secondary` | `secondaryContainer` | -| `tertiary` | `tertiaryContainer` | +| `tertiary` | `tertiaryContainer` | If you omit `variant`, no change is needed. The `surface` variant was removed. To keep its look, pass the colors directly: ```jsx -const { colors } = useTheme(); - - +const MyFAB = () => { + const { colors } = useTheme(); + + return ( + + ); +}; ``` For custom colors, replace `color` with `contentColor` and move From 651cc0882c4dd7b9a8656abe95d1ea7f8702633f Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:11:17 +0100 Subject: [PATCH 09/17] fix(fab): restore focus only after keyboard focus Browsers also focus the FAB on a mouse click or tap, so moving focus back to the previous element when it hides could jump into an earlier text field. Check :focus-visible at hide time and blur in the other cases. --- src/components/FAB/Shell.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx index d4bce63054..52d8d7dc9a 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -315,13 +315,17 @@ const Shell = ({ 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 }); } - // The previous element may have been removed or become unfocusable. + // 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(); } } From 176a2743050ad7c554c2bb12568a4d1bd5b4b026 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:11:56 +0100 Subject: [PATCH 10/17] test(fab): name the disabled FAB test after its behavior --- src/components/__tests__/FAB.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx index e7919ba5cd..77d235dc99 100644 --- a/src/components/__tests__/FAB.test.tsx +++ b/src/components/__tests__/FAB.test.tsx @@ -162,7 +162,7 @@ it.each(['icon', 'extended'] as const)( } ); -it('does not enable a FAB without an action when adding interaction handlers', async () => { +it('renders a FAB without onPress as a disabled button', async () => { await render(); expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); }); From 3d9741d5d059d338fb5ea744ecee2cd4129f02ad Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:12:00 +0100 Subject: [PATCH 11/17] test(fab): cover the press guard on a hidden FAB userEvent.press is already blocked by pointerEvents none, so the test passed without the guard. A click event still reaches the pressable and fails the test when the guard is removed. --- src/components/__tests__/FAB.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx index 77d235dc99..33dd205896 100644 --- a/src/components/__tests__/FAB.test.tsx +++ b/src/components/__tests__/FAB.test.tsx @@ -185,6 +185,7 @@ it('hides an invisible FAB from accessibility and keyboard navigation without ma expect(fab).toHaveProp('focusable', false); expect(fab).toHaveProp('tabIndex', -1); await userEvent.press(fab); + await fireEvent(fab, 'click', { nativeEvent: {} }); expect(onPress).not.toHaveBeenCalled(); }); From 512d0331687451641599494c6aadd2d3a5516665 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:12:04 +0100 Subject: [PATCH 12/17] test(fab): cover focus restoration when a FAB hides on web Cover moving focus back after keyboard focus, blurring instead when the previous element is hidden, inert or disconnected or when the FAB was focused by a mouse click, and leaving focus alone when the FAB no longer has it. --- src/components/__tests__/FAB.test.tsx | 169 +++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 1 deletion(-) diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx index 33dd205896..c3249bbb42 100644 --- a/src/components/__tests__/FAB.test.tsx +++ b/src/components/__tests__/FAB.test.tsx @@ -1,7 +1,14 @@ import * as React from 'react'; import { Platform, View } from 'react-native'; -import { afterEach, expect, it, jest } from '@jest/globals'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; import { fireEvent, userEvent } from '@testing-library/react-native'; import { getAnimatedStyle } from 'react-native-reanimated'; @@ -290,3 +297,163 @@ it('restores the resting elevation when a hovered FAB loses its action', async ( await rerender( {}} />); expect(getStyle(ref)).toMatchObject(restingShadow); }); + +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, + hiddenBy, + }: { + isConnected?: boolean; + hiddenBy?: string; + } = {}) => { + const element = new View({}); + return Object.assign(element, { + isConnected, + closest: (selectors: string) => + hiddenBy && selectors.includes(hiddenBy) ? {} : null, + focus: jest.fn(() => { + 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('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 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(); + }); +}); From 9a62da81ffc1953f613c4f92a5b9d1f9e24fabb6 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:12:09 +0100 Subject: [PATCH 13/17] test(fab): compare elevation between interaction states Compare the FAB's own style between hover, focus and press states instead of asserting the Surface shadow tokens, and describe the iOS and Android cases by what they check, since React Native 0.85 sends no hover events there by default. --- src/components/__tests__/FAB.test.tsx | 128 ++++++++++++-------------- 1 file changed, 58 insertions(+), 70 deletions(-) diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx index c3249bbb42..6b7cb733d3 100644 --- a/src/components/__tests__/FAB.test.tsx +++ b/src/components/__tests__/FAB.test.tsx @@ -13,11 +13,6 @@ import { fireEvent, userEvent } from '@testing-library/react-native'; import { getAnimatedStyle } from 'react-native-reanimated'; import { render, screen } from '../../test-utils'; -import { LightTheme } from '../../theme/schemes'; -import { - androidElevationLevels, - shadow, -} from '../../theme/tokens/sys/elevation'; import FAB from '../FAB'; afterEach(() => { @@ -127,48 +122,6 @@ const getStyle = (ref: React.RefObject) => { return getAnimatedStyle(ref.current); }; -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 ref = React.createRef(); - const onPress = jest.fn(); - await render( - type === 'icon' ? ( - - ) : ( - - ) - ); - const fab = screen.getByRole('button', { name: 'Create' }); - const [restingShadow] = shadow(3, LightTheme.colors.shadow); - const [hoverShadow] = shadow(4, LightTheme.colors.shadow); - - expect(getStyle(ref)).toMatchObject(restingShadow); - await fireEvent(fab, 'hoverIn'); - expect(getStyle(ref)).toMatchObject(hoverShadow); - await fireEvent(fab, 'pressIn'); - expect(getStyle(ref)).toMatchObject(restingShadow); - await fireEvent(fab, 'pressOut'); - expect(getStyle(ref)).toMatchObject(hoverShadow); - await fireEvent(fab, 'hoverOut'); - expect(getStyle(ref)).toMatchObject(restingShadow); - } -); - it('renders a FAB without onPress as a disabled button', async () => { await render(); expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); @@ -208,13 +161,14 @@ it('clears interaction elevation when a FAB is hidden and shown again', async () onPress={onPress} /> ); + const resting = getStyle(ref); await fireEvent(screen.getByRole('button', { name: 'Create' }), 'hoverIn'); + expect(getStyle(ref)).not.toEqual(resting); await rerender( ); await rerender(); - const [restingShadow] = shadow(3, LightTheme.colors.shadow); - expect(getStyle(ref)).toMatchObject(restingShadow); + expect(getStyle(ref)).toEqual(resting); }); it('uses the shared FAB hover elevation for the menu trigger', async () => { @@ -237,8 +191,10 @@ it('uses the shared FAB hover elevation for the menu trigger', async () => { 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)( - 'applies hover elevation alongside focus and restores it after press on %s', + 'follows hover events and keeps the hover elevation on focus when Platform.OS is %s', async (platform) => { jest.replaceProperty(Platform, 'OS', platform); const ref = React.createRef(); @@ -252,29 +208,19 @@ it.each(['ios', 'android'] as const)( /> ); const fab = screen.getByRole('button', { name: 'Create' }); - const [restingShadow] = shadow(3, LightTheme.colors.shadow); - const [hoverShadow] = shadow(4, LightTheme.colors.shadow); - const restingStyle = - platform === 'android' - ? { elevation: androidElevationLevels[3] } - : restingShadow; - const hoverStyle = - platform === 'android' - ? { elevation: androidElevationLevels[4] } - : hoverShadow; + const resting = getStyle(ref); - await fireEvent(fab, 'focus', { nativeEvent: {} }); - expect(getStyle(ref)).toMatchObject(restingStyle); await fireEvent(fab, 'hoverIn'); - expect(getStyle(ref)).toMatchObject(hoverStyle); + const hovered = getStyle(ref); + expect(hovered).not.toEqual(resting); await fireEvent(fab, 'focus', { nativeEvent: {} }); - expect(getStyle(ref)).toMatchObject(hoverStyle); + expect(getStyle(ref)).toEqual(hovered); await fireEvent(fab, 'pressIn'); - expect(getStyle(ref)).toMatchObject(restingStyle); + expect(getStyle(ref)).toEqual(resting); await fireEvent(fab, 'pressOut'); - expect(getStyle(ref)).toMatchObject(hoverStyle); + expect(getStyle(ref)).toEqual(hovered); await fireEvent(fab, 'hoverOut'); - expect(getStyle(ref)).toMatchObject(restingStyle); + expect(getStyle(ref)).toEqual(resting); } ); @@ -289,13 +235,14 @@ it('restores the resting elevation when a hovered FAB loses its action', async ( onPress={() => {}} /> ); + const resting = getStyle(ref); await fireEvent(screen.getByRole('button', { name: 'Create' }), 'hoverIn'); + expect(getStyle(ref)).not.toEqual(resting); await rerender(); - const [restingShadow] = shadow(3, LightTheme.colors.shadow); - expect(getStyle(ref)).toMatchObject(restingShadow); + expect(getStyle(ref)).toEqual(resting); expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); await rerender( {}} />); - expect(getStyle(ref)).toMatchObject(restingShadow); + expect(getStyle(ref)).toEqual(resting); }); describe('on web', () => { @@ -408,6 +355,47 @@ describe('on web', () => { return { blur, hide: () => rerender(fab(false)) }; }; + it.each(['icon', 'extended'] as const)( + 'raises the %s FAB while hovered, keeps it raised on focus and lowers 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, '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); From 3d770f25fa19e18911dd22c6bf105652298fe9fc Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:04:34 +0100 Subject: [PATCH 14/17] test(fab): check that focus alone keeps the resting elevation MD3 keeps a focused FAB at level 3. Without this step, folding focus into the hover state would still pass the elevation tests. --- src/components/__tests__/FAB.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx index 6b7cb733d3..7af293e432 100644 --- a/src/components/__tests__/FAB.test.tsx +++ b/src/components/__tests__/FAB.test.tsx @@ -194,7 +194,7 @@ it('uses the shared FAB hover elevation for the menu trigger', async () => { // 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)( - 'follows hover events and keeps the hover elevation on focus when Platform.OS is %s', + '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(); @@ -210,6 +210,8 @@ it.each(['ios', 'android'] as const)( 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); @@ -356,7 +358,7 @@ describe('on web', () => { }; it.each(['icon', 'extended'] as const)( - 'raises the %s FAB while hovered, keeps it raised on focus and lowers it while pressed', + 'keeps the %s FAB at rest on focus, raises it while hovered and lowers it while pressed', async (type) => { const ref = React.createRef(); await render( @@ -382,6 +384,8 @@ describe('on web', () => { 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); From 8022eb79d34431823da786196ad98a8f6053ecd7 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:05:14 +0100 Subject: [PATCH 15/17] test(fab): pin the direction of the hover elevation The state comparisons only checked that hover changes the style. Android exposes elevation as a number, so assert there that hover raises it and pressing lowers it, and title the web test by what it checks. --- src/components/__tests__/FAB.test.tsx | 33 ++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx index 7af293e432..94098b0a35 100644 --- a/src/components/__tests__/FAB.test.tsx +++ b/src/components/__tests__/FAB.test.tsx @@ -122,6 +122,16 @@ const getStyle = (ref: React.RefObject) => { 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(); @@ -226,6 +236,27 @@ it.each(['ios', 'android'] as const)( } ); +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( @@ -358,7 +389,7 @@ describe('on web', () => { }; it.each(['icon', 'extended'] as const)( - 'keeps the %s FAB at rest on focus, raises it while hovered and lowers it while pressed', + '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( From 3c55f90e4dae36f6b0547e65394cf0b4270bd1eb Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:05:46 +0100 Subject: [PATCH 16/17] test(fab): cover the blur fallback when focus can't move back The element stub always took focus, so nothing checked the blur after a restore attempt that the browser ignores. Make focus() a no-op on disconnected or unfocusable elements, as browsers do, and cover that case. --- src/components/__tests__/FAB.test.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx index 94098b0a35..c696cf8ff8 100644 --- a/src/components/__tests__/FAB.test.tsx +++ b/src/components/__tests__/FAB.test.tsx @@ -350,9 +350,11 @@ describe('on web', () => { const createElement = ({ isConnected = true, + focusable = true, hiddenBy, }: { isConnected?: boolean; + focusable?: boolean; hiddenBy?: string; } = {}) => { const element = new View({}); @@ -360,8 +362,11 @@ describe('on web', () => { 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(() => { - activeElement = element; + if (isConnected && focusable) { + activeElement = element; + } }), }); }; @@ -458,6 +463,16 @@ describe('on web', () => { } ); + 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(); From 55b9ea935dc2f55a6b8484f624d67115ad7c07d1 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:05:53 +0100 Subject: [PATCH 17/17] docs(fab): import useTheme from paper in the surface snippet Apps using React Navigation also have a useTheme whose colors lack surfaceContainerHigh, so make the source explicit. --- docs/6.x/docs/guides/migration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 697cdecfaf..84cb793efb 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -320,6 +320,8 @@ To preserve the v5 FAB color treatment, update the `variant` prop: 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();