From 6b92400c5269b3f0b85511984748ce0b3f6ff60e Mon Sep 17 00:00:00 2001 From: likevy Date: Mon, 7 Sep 2026 16:48:20 +0200 Subject: [PATCH 1/2] 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 | 45 +++++- .../__tests__/__snapshots__/FAB.test.tsx.snap | 23 +-- .../__snapshots__/FABExtended.test.tsx.snap | 1 + .../__snapshots__/FABMenu.test.tsx.snap | 1 + 13 files changed, 341 insertions(+), 58 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index a4d7123a09..31bb8732c6 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -159,6 +159,24 @@ e.g.: - The default elevation changed from level `1` to level `3`. - The `style` prop no longer configures the background color or border radius. You can override `theme.colors.surfaceContainerHigh` and `theme.shapes.corner.extraLarge` using the `theme` prop instead. +### 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 fb58e730e6..49c505c6a5 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 036edd57d9..1a69555634 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 5a08323060..40be114f14 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 @@ -303,7 +311,7 @@ const MenuItem = ({ }; type MorphingTriggerProps = { - triggerVariant: Variant; + triggerVariant: MenuVariant; closeVariant: Variant; triggerContainerColor?: ColorValue; triggerContentColor?: ColorValue; @@ -449,6 +457,7 @@ const MorphingTrigger = ({ testID={testID} > { 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 b72de366f7..bc6b057cdd 100644 --- a/src/components/__tests__/FABUtils.test.tsx +++ b/src/components/__tests__/FABUtils.test.tsx @@ -4,7 +4,42 @@ import { getTheme } from '../../core/theming'; 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 = getTheme(dark); + 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 theme = getTheme(); const colors = resolveColors({ theme }); expect(colors).toEqual({ @@ -40,18 +75,18 @@ describe('resolveColors', () => { }); }); - it('returns theme colors for tonalSecondary variant', () => { + it('returns theme colors for secondaryContainer variant', () => { const theme = getTheme(); - const colors = resolveColors({ theme, variant: 'tonalSecondary' }); + const colors = resolveColors({ theme, variant: 'secondaryContainer' }); expect(colors).toEqual({ container: theme.colors.secondaryContainer, content: theme.colors.onSecondaryContainer, }); }); - it('returns theme colors for tonalTertiary variant', () => { + it('returns theme colors for tertiaryContainer variant', () => { const theme = getTheme(); - const colors = resolveColors({ theme, variant: 'tonalTertiary' }); + const colors = resolveColors({ theme, variant: 'tertiaryContainer' }); expect(colors).toEqual({ container: theme.colors.tertiaryContainer, content: theme.colors.onTertiaryContainer, diff --git a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap index 391413183a..98033a045e 100644 --- a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap @@ -484,6 +484,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: Fri, 11 Sep 2026 11:16:44 +0200 Subject: [PATCH 2/2] 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 | 50 ++++++++----- src/components/__tests__/FABUtils.test.tsx | 4 +- .../__tests__/__snapshots__/FAB.test.tsx.snap | 29 ++++---- .../__snapshots__/FABExtended.test.tsx.snap | 15 ++-- .../__snapshots__/FABMenu.test.tsx.snap | 7 +- 12 files changed, 127 insertions(+), 113 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 31bb8732c6..bcd5771dde 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -161,21 +161,30 @@ e.g.: ### 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 49c505c6a5..ec53bf1acb 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 1a69555634..595352555e 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 40be114f14..c226b016bf 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 = { /** @@ -457,7 +457,6 @@ const MorphingTrigger = ({ testID={testID} > (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( () => ({ @@ -359,19 +365,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..3211d39819 100644 --- a/src/components/__tests__/FAB.test.tsx +++ b/src/components/__tests__/FAB.test.tsx @@ -10,7 +10,6 @@ import { shadow, } from '../../theme/tokens/sys/elevation'; import FAB from '../FAB'; -import Shell from '../FAB/Shell'; afterEach(() => { jest.restoreAllMocks(); @@ -150,20 +149,12 @@ it.each(['icon', 'extended'] as const)( } ); -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( @@ -172,7 +163,10 @@ it('hides an invisible FAB from accessibility and disables its action', async () const fab = screen.getByTestId('floating-action-button', { includeHiddenElements: true, }); - expect(fab).toBeDisabled(); + 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(); }); @@ -190,7 +184,7 @@ it('clears interaction elevation when a FAB is hidden and shown again', async () ); }); -it('keeps the menu trigger at its existing elevation on web', async () => { +it('uses the shared FAB hover elevation for the menu trigger', async () => { jest.replaceProperty(Platform, 'OS', 'web'); await render( { /> ); await fireEvent(screen.getByTestId('fab-shell'), 'hoverIn'); - const [restingShadow] = shadow(3, getTheme().colors.shadow); - expect(screen.getByTestId('fab-shell-container')).toHaveStyle(restingShadow); + const [hoverShadow] = shadow(4, getTheme().colors.shadow); + expect(screen.getByTestId('fab-shell-container')).toHaveStyle(hoverShadow); }); 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 fab = screen.getByTestId('floating-action-button'); + const container = screen.getByTestId('floating-action-button-container'); const [restingShadow] = shadow(3, getTheme().colors.shadow); - expect(screen.getByTestId('floating-action-button-container')).toHaveStyle( + const [hoverShadow] = shadow(4, getTheme().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(container).toHaveStyle(restingStyle); + await fireEvent(fab, 'hoverIn'); + expect(container).toHaveStyle(hoverStyle); + await fireEvent(fab, 'focus', { nativeEvent: {} }); + expect(container).toHaveStyle(hoverStyle); + await fireEvent(fab, 'pressIn'); + expect(container).toHaveStyle(restingStyle); + await fireEvent(fab, 'pressOut'); + expect(container).toHaveStyle(hoverStyle); + await fireEvent(fab, 'hoverOut'); + expect(container).toHaveStyle(restingStyle); } ); diff --git a/src/components/__tests__/FABUtils.test.tsx b/src/components/__tests__/FABUtils.test.tsx index bc6b057cdd..7d614bd421 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 = getTheme(dark); 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 98033a045e..b7f71660a5 100644 --- a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap @@ -139,7 +139,7 @@ exports[`renders FAB large size 1`] = ` } accessible={true} collapsable={false} - focusable={true} + focusable={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -380,7 +380,7 @@ exports[`renders FAB medium size 1`] = ` } accessible={true} collapsable={false} - focusable={true} + focusable={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -620,9 +620,9 @@ exports[`renders FAB transitioning to not visible 1`] = ` "text": undefined, } } - accessible={true} + accessible={false} collapsable={false} - focusable={true} + focusable={false} onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -644,6 +644,7 @@ exports[`renders FAB transitioning to not visible 1`] = ` ], ] } + tabIndex={-1} testID="floating-action-button" >