From 4e5165183c39517077020bf27a2cf7e00524cab6 Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 16:48:20 +0200 Subject: [PATCH 1/3] 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 e51de006aa..2e1f320553 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -209,6 +209,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 2/3] 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 2e1f320553..472b346353 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -211,21 +211,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 3/3] 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';