From 8beab9359dd8cc53953d922eda512cae0d46bf3a Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 16:48:20 +0200 Subject: [PATCH 01/20] 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 788359afb5..06ca6c2945 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -322,6 +322,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 5a35159f4f..161e12d176 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 b6e862aa08..37f294ed26 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/20] 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 06ca6c2945..701d874c6a 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -324,21 +324,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 161e12d176..9febe2bba2 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 37f294ed26..9f52a55cb0 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/20] 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 d9ca894762..18a37150eb 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 3a9c58529171fa53d3b49734d6b4c32a74cf3f11 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 12:59:23 +0100 Subject: [PATCH 04/20] 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 2c5d4958bbc0d42d00069c0f2e60fda5e0b53845 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:00:25 +0100 Subject: [PATCH 05/20] 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 701d874c6a..9039e335e5 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -332,8 +332,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 ea12e01c1c0c1f944c4b2204090c126e01e9d5af Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:00:47 +0100 Subject: [PATCH 06/20] 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 18a37150eb..adae3e5720 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 0bd1016d97cbc7543f2117c1b6e91a96bdedffc1 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:01:00 +0100 Subject: [PATCH 07/20] 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 240d5be46c022377e8e105217ed8c6cce39ee993 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:05:19 +0100 Subject: [PATCH 08/20] 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 9039e335e5..ff54f9b962 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -326,22 +326,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 e9dace08286a9f2b83099a79e620468798a81618 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:11:17 +0100 Subject: [PATCH 09/20] 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 adae3e5720..360ca89f99 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 0aefeb393d1d60a1ac3666e51c73c7960646a31c Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:11:56 +0100 Subject: [PATCH 10/20] 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 86ef16054978cd593717a070cb5457f5dcefccd0 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:12:00 +0100 Subject: [PATCH 11/20] 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 e881eca052fb42c36daf852575ab5b75f48ddde3 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:12:04 +0100 Subject: [PATCH 12/20] 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 b9c67a432363b2bb3a7b45d540309ecf31e7cc26 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:12:09 +0100 Subject: [PATCH 13/20] 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 79a3c5f21802597c299b268fa04c87de152192e2 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:04:34 +0100 Subject: [PATCH 14/20] 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 76b2bfab76bc68600928c065323c5e9b8dfa5fc7 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:05:14 +0100 Subject: [PATCH 15/20] 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 fb7e14a2e35fd22b3021ebafb6b16885cada82f3 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:05:46 +0100 Subject: [PATCH 16/20] 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 5ad44a785374114b263ed5374b5aa0a3644c97e7 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:05:53 +0100 Subject: [PATCH 17/20] 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 ff54f9b962..e8af3def78 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -335,6 +335,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(); From de315af63a046f84c577c939612420194d5d489f Mon Sep 17 00:00:00 2001 From: likevy Date: Wed, 4 Nov 2026 11:06:25 +0100 Subject: [PATCH 18/20] docs(fab): note that explicit v5 variants still type-check --- docs/6.x/docs/guides/migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index e8af3def78..ef20d38fad 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -324,7 +324,7 @@ The misspelled `traileringIcon` props have been renamed: ### FAB -To preserve the v5 FAB color treatment, update the `variant` prop: +`variant="primary"`, `"secondary"` and `"tertiary"` still type-check in 6.x, but they now draw the filled role color instead of the 5.x container color, so an app that sets them explicitly changes look without any error. To preserve the v5 FAB color treatment, update the `variant` prop: | v5 | v6 | | ----------- | -------------------- | From 89a91a70a14f55be4a16d40e73701775de3bab08 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 6 Nov 2026 12:12:33 +0100 Subject: [PATCH 19/20] fix(fab): type the touchable ref for React Native 0.88 React Native 0.88 types View as a function component, so the ref type is React.ComponentRef, as main uses elsewhere. --- 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 360ca89f99..69419a1e44 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -213,7 +213,7 @@ const Shell = ({ const theme = useInternalTheme(themeOverrides); const [hovered, setHovered] = React.useState(false); const [pressed, setPressed] = React.useState(false); - const touchableRef = React.useRef(null); + const touchableRef = React.useRef>(null); const previousFocusedElement = React.useRef(null); const resolvedElevation = From 2c6a466139067fa33df2d4e21a4daa955585682c Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 6 Nov 2026 12:14:03 +0100 Subject: [PATCH 20/20] test(fab): type the FAB test refs for React Native 0.88 Use React.ComponentRef for refs, cast to Reanimated's animated component shape where the tests read the animated style, as main does, and type the View mock instances the web focus tests stub. --- src/components/__tests__/FAB.test.tsx | 34 ++++++++++++++++++--------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx index c696cf8ff8..832f28b150 100644 --- a/src/components/__tests__/FAB.test.tsx +++ b/src/components/__tests__/FAB.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Platform, View } from 'react-native'; +import type { ViewProps } from 'react-native'; import { afterEach, @@ -117,14 +118,22 @@ it('forwards event object to onPress', async () => { expect(onPress).toHaveBeenCalledWith({ key: 'value' }); }); -const getStyle = (ref: React.RefObject) => { +const getStyle = ( + ref: React.RefObject | null> +) => { if (!ref.current) throw new Error('Expected FAB ref to be attached'); - return getAnimatedStyle(ref.current); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const animatedComponent = ref.current as unknown as { + props: Record; + }; + return getAnimatedStyle(animatedComponent); }; // Android exposes elevation as a plain number, so the direction is checkable // without the shadow tokens. -const getElevation = (ref: React.RefObject) => { +const getElevation = ( + ref: React.RefObject | null> +) => { const style = getStyle(ref); if (!('elevation' in style) || typeof style.elevation !== 'number') { throw new Error('Expected a numeric elevation'); @@ -160,7 +169,7 @@ it('hides an invisible FAB from accessibility and keyboard navigation without ma }); it('clears interaction elevation when a FAB is hidden and shown again', async () => { - const ref = React.createRef(); + const ref = React.createRef>(); const onPress = jest.fn(); const { rerender } = await render( { jest.replaceProperty(Platform, 'OS', platform); - const ref = React.createRef(); + const ref = React.createRef>(); await render( { jest.replaceProperty(Platform, 'OS', 'android'); - const ref = React.createRef(); + const ref = React.createRef>(); await render( { - const ref = React.createRef(); + const ref = React.createRef>(); const { rerender } = await render( { ); let activeElement: unknown; let focusVisible: boolean; - let fabElement: View | undefined; + // The jest View mock is a class component, so its instances have props. + type ViewMockInstance = { props: ViewProps; blur: () => void }; + let fabElement: ViewMockInstance | undefined; beforeEach(() => { jest.replaceProperty(Platform, 'OS', 'web'); @@ -325,7 +336,7 @@ describe('on web', () => { // The pressable view with the FAB's testID is the element that takes focus. Object.defineProperty(View.prototype, 'componentDidMount', { configurable: true, - value(this: View) { + value(this: ViewMockInstance) { if (this.props.testID === 'create') { fabElement = this; } @@ -357,7 +368,8 @@ describe('on web', () => { focusable?: boolean; hiddenBy?: string; } = {}) => { - const element = new View({}); + // Inherits from the View mock to pass the HTMLElement instanceof check. + const element: object = Object.create(View.prototype); return Object.assign(element, { isConnected, closest: (selectors: string) => @@ -396,7 +408,7 @@ describe('on web', () => { 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(); + const ref = React.createRef>(); await render( type === 'icon' ? (