diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index a4d7123a09..bcd5771dde 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -159,6 +159,33 @@ 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 + +To preserve the v5 FAB color treatment, update the `variant` prop: + +| v5 | v6 | +| --- | --- | +| `primary` | `primaryContainer` | +| `secondary` | `secondaryContainer` | +| `tertiary` | `tertiaryContainer` | + +If you omit `variant`, no change is needed. Replace `variant="surface"` with +one of the supported color variants, such as `primaryContainer`. + +For custom colors, replace `color` with `contentColor` and move +`style.backgroundColor` to `containerColor`: + +```diff + +``` + ### TextInput The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly. diff --git a/example/src/Examples/FABExample.tsx b/example/src/Examples/FABExample.tsx index 09ce39aa14..f878c61b24 100644 --- a/example/src/Examples/FABExample.tsx +++ b/example/src/Examples/FABExample.tsx @@ -34,9 +34,10 @@ const variants: FabColor[] = [ 'primary', 'secondary', 'tertiary', - 'tonalPrimary', - 'tonalSecondary', - 'tonalTertiary', + 'primaryContainer', + 'secondaryContainer', + 'tertiaryContainer', + 'branded', 'custom', ]; @@ -91,7 +92,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 +139,24 @@ const FABExample = () => { v !== 'branded') : variants + } value={variant} onChange={setVariant} /> - + { + if (nextType === 'menu' && variant === 'branded') { + setVariant('primaryContainer'); + } + setType(nextType); + }} + /> { {type === 'icon' && ( { alignment={position} trigger={{ icon: 'pencil', - variant: 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 fb58e730e6..ec53bf1acb 100644 --- a/src/components/FAB/Extended.tsx +++ b/src/components/FAB/Extended.tsx @@ -38,7 +38,7 @@ export type Props = { */ label: string; /** - * Role-color preset. Defaults to `tonalPrimary`. + * Role-color preset. Defaults to `primaryContainer`. */ variant?: Variant; /** @@ -154,7 +154,7 @@ export type Props = { const Extended = ({ icon, label, - variant = 'tonalPrimary', + variant = 'primaryContainer', containerColor, contentColor, size = 'default', diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FAB.tsx index 036edd57d9..595352555e 100644 --- a/src/components/FAB/FAB.tsx +++ b/src/components/FAB/FAB.tsx @@ -21,7 +21,7 @@ export type Props = { */ icon: IconSource; /** - * Role-color preset. Defaults to `tonalPrimary`. + * Role-color preset. Defaults to `primaryContainer`. */ variant?: Variant; /** @@ -118,7 +118,7 @@ export type Props = { */ const FAB = ({ icon, - variant = 'tonalPrimary', + variant = 'primaryContainer', size = 'default', visible = true, onPress, diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx index 5a08323060..c226b016bf 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; @@ -545,7 +553,7 @@ const Menu = ({ const isRTL = direction === 'rtl'; const insets = useSafeAreaInsets(); - const triggerVariant: Variant = trigger.variant ?? 'tonalPrimary'; + const triggerVariant: MenuVariant = trigger.variant ?? 'primaryContainer'; const size: Size = trigger.size ?? 'default'; const openIcon: IconSource = trigger.icon; const openOnPress = trigger.onPress; diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx index 5a7d3b81bf..28b4d3461e 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -29,7 +29,7 @@ import { getDimensions, resolveColors } from './utils'; import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; -import type { Elevation, ThemeProp } from '../../theme/types'; +import type { ThemeProp } from '../../theme/types'; import type { ShapeToken } from '../../theme/utils/shape'; import type { IconSource } from '../Icon'; import Surface from '../Surface'; @@ -46,7 +46,7 @@ export type ShellProps = { */ label?: string; /** - * Role-color preset. Defaults to `tonalPrimary`. + * Role-color preset. Defaults to `primaryContainer`. */ variant?: Variant; /** @@ -77,11 +77,6 @@ export type ShellProps = { * Trailing-padding override. */ trailing?: number; - /** - * Resting elevation level. Defaults to the FAB's enabled-state elevation. - * Pass `0` to disable the shadow entirely. - */ - elevation?: Elevation; /** * When `false`, the shell animates out (scale + alpha) and stops accepting * touches. @@ -186,7 +181,7 @@ export type ShellProps = { const Shell = ({ icon, label, - variant = 'tonalPrimary', + variant = 'primaryContainer', size = 'default', containerColor, contentColor, @@ -194,7 +189,6 @@ const Shell = ({ iconSize, leading, trailing, - elevation = Tokens.stateElevation.enabled, visible = true, onPress, 'aria-label': ariaLabel = label, @@ -217,6 +211,19 @@ const Shell = ({ ref, }: ShellProps) => { const theme = useInternalTheme(themeOverrides); + const [hovered, setHovered] = React.useState(false); + const [pressed, setPressed] = React.useState(false); + const touchableRef = React.useRef(null); + const previousFocusedElement = React.useRef(null); + + const resolvedElevation = + visible && onPress + ? pressed + ? Tokens.stateElevation.pressed + : hovered + ? Tokens.stateElevation.hover + : Tokens.stateElevation.enabled + : Tokens.stateElevation.enabled; const dimensions = React.useMemo( () => getDimensions({ theme, size, shape, iconSize, leading, trailing }), @@ -299,6 +306,34 @@ const Shell = ({ const { focusedSV, onFocus, onBlur } = useFocusRing(); + React.useEffect(() => { + 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); + onBlur(); + } + }, [visible, onBlur]); + + React.useEffect(() => { + if (!onPress) { + setHovered(false); + setPressed(false); + } + }, [onPress]); + const focusRingStyle = useAnimatedStyle( () => ({ opacity: focusedSV.value ? 1 : 0, @@ -310,6 +345,7 @@ const Shell = ({ return ( {overlay} setHovered(true)} + onHoverOut={() => setHovered(false)} + onPressIn={() => setPressed(true)} + onPressOut={() => setPressed(false)} + onFocus={(event) => { + if (Platform.OS === 'web') { + const previous = + 'relatedTarget' in event.nativeEvent + ? event.nativeEvent.relatedTarget + : null; + previousFocusedElement.current = + previous instanceof HTMLElement ? previous : null; + } + onFocus(); + }} onBlur={onBlur} aria-label={ariaLabel} role="button" diff --git a/src/components/FAB/tokens.ts b/src/components/FAB/tokens.ts index 0fb79d1d9c..19c992491b 100644 --- a/src/components/FAB/tokens.ts +++ b/src/components/FAB/tokens.ts @@ -12,9 +12,10 @@ export type Variant = | 'primary' | 'secondary' | 'tertiary' - | 'tonalPrimary' - | 'tonalSecondary' - | 'tonalTertiary'; + | 'primaryContainer' + | 'secondaryContainer' + | 'tertiaryContainer' + | 'branded'; export type Size = 'default' | 'medium' | 'large'; @@ -68,18 +69,20 @@ 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' }, - 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..3211d39819 100644 --- a/src/components/__tests__/FAB.test.tsx +++ b/src/components/__tests__/FAB.test.tsx @@ -1,9 +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'; +afterEach(() => { + jest.restoreAllMocks(); +}); + it('renders FAB with default props', async () => { const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); @@ -24,16 +35,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 +112,143 @@ 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('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 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).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 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('uses the shared FAB hover elevation for the menu trigger', 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 [hoverShadow] = shadow(4, getTheme().colors.shadow); + expect(screen.getByTestId('fab-shell-container')).toHaveStyle(hoverShadow); +}); + +it.each(['ios', 'android'] as const)( + 'applies hover elevation alongside focus and restores it after press on %s', + async (platform) => { + jest.replaceProperty(Platform, 'OS', platform); + await render( {}} />); + const fab = screen.getByTestId('floating-action-button'); + const container = screen.getByTestId('floating-action-button-container'); + const [restingShadow] = shadow(3, getTheme().colors.shadow); + const [hoverShadow] = shadow(4, getTheme().colors.shadow); + const restingStyle = + platform === 'android' + ? { elevation: androidElevationLevels[3] } + : 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); + } +); + +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..7d614bd421 100644 --- a/src/components/__tests__/FABUtils.test.tsx +++ b/src/components/__tests__/FABUtils.test.tsx @@ -4,7 +4,40 @@ 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 FAB color preset (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'], + branded: ['surfaceContainerHigh', 'onSurface'], + } as const; + + const variantNames = [ + 'primary', + 'primaryContainer', + 'secondary', + 'secondaryContainer', + 'tertiary', + 'tertiaryContainer', + '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 +73,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..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]} @@ -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`] = `