From 5c1828f9b3d7c88ef1c192bdb2acce4f01e04278 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 10 Jun 2026 10:18:39 +0200 Subject: [PATCH 01/29] feat(tooltip): align plain tooltip colors and typography with MD3 Container now uses inverseSurface and text uses inverseOnSurface per the MD3 tooltip spec (previously onSurface/surface). Text variant changes from labelLarge to bodySmall (12sp). Tokens are extracted into a new tokens.ts following the FAB pattern; the rich and motion token sets land here too and are consumed in later commits. --- src/components/Tooltip/Tooltip.tsx | 18 +++--- src/components/Tooltip/tokens.ts | 72 +++++++++++++++++++++++ src/components/__tests__/Tooltip.test.tsx | 31 +++++++++- 3 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 src/components/Tooltip/tokens.ts diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 8d237d6991..ee8659817f 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -8,8 +8,8 @@ import { } from 'react-native'; import type { LayoutChangeEvent, ViewStyle } from 'react-native'; -import { getTooltipPosition } from './utils'; -import type { Measurement, TooltipChildProps } from './utils'; +import { Tokens } from './tokens'; +import { getTooltipPosition, Measurement, TooltipChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import { addEventListener } from '../../utils/addEventListener'; @@ -203,12 +203,12 @@ const Tooltip = ({ style={[ styles.tooltip, { - backgroundColor: theme.colors.onSurface, + backgroundColor: theme.colors[Tokens.plain.container], ...getTooltipPosition( measurement as Measurement, children as React.ReactElement ), - borderRadius: theme.shapes.corner.extraSmall, + borderRadius: theme.shapes.corner[Tokens.plain.shape], ...(measurement.measured ? styles.visible : styles.hidden), }, ]} @@ -218,8 +218,8 @@ const Tooltip = ({ aria-live="polite" numberOfLines={1} selectable={false} - variant="labelLarge" - style={{ color: theme.colors.surface }} + variant={Tokens.plain.typescale} + style={{ color: theme.colors[Tokens.plain.content] }} maxFontSizeMultiplier={titleMaxFontSizeMultiplier} > {title} @@ -247,9 +247,9 @@ const styles = StyleSheet.create({ tooltip: { alignSelf: 'flex-start', justifyContent: 'center', - paddingHorizontal: 16, - height: 32, - maxHeight: 32, + paddingHorizontal: Tokens.plain.paddingHorizontal, + height: Tokens.plain.height, + maxHeight: Tokens.plain.height, }, visible: { opacity: 1, diff --git a/src/components/Tooltip/tokens.ts b/src/components/Tooltip/tokens.ts new file mode 100644 index 0000000000..a4e863431a --- /dev/null +++ b/src/components/Tooltip/tokens.ts @@ -0,0 +1,72 @@ +import type { + ColorRole, + Elevation, + ThemeShapeCorners, + TypescaleKey, +} from '../../theme/types'; + +type ShapeKey = keyof ThemeShapeCorners; + +/** + * Plain tooltip — a single line of text on an inverse-surface container. + * https://m3.material.io/components/tooltips/specs#1e6d4d8a + */ +const plain = { + container: 'inverseSurface', + content: 'inverseOnSurface', + shape: 'extraSmall', + height: 32, + paddingHorizontal: 16, + typescale: 'bodySmall', +} as const satisfies { + container: ColorRole; + content: ColorRole; + shape: ShapeKey; + height: number; + paddingHorizontal: number; + typescale: TypescaleKey; +}; + +/** + * Rich tooltip — an optional subhead, supporting text and action buttons on a + * surface-container container at elevation level 2. + * https://m3.material.io/components/tooltips/specs#8e6cf915 + */ +const rich = { + container: 'surfaceContainer', + title: 'onSurface', + content: 'onSurfaceVariant', + action: 'primary', + shape: 'medium', + elevation: 2, + maxWidth: 312, + paddingHorizontal: 16, + paddingVertical: 12, + titleTypescale: 'titleSmall', + contentTypescale: 'bodyMedium', + gap: 4, +} as const satisfies { + container: ColorRole; + title: ColorRole; + content: ColorRole; + action: ColorRole; + shape: ShapeKey; + elevation: Elevation; + maxWidth: number; + paddingHorizontal: number; + paddingVertical: number; + titleTypescale: TypescaleKey; + contentTypescale: TypescaleKey; + gap: number; +}; + +/** + * Fade transition on show/hide. Keys are resolved against `theme.motion` at + * runtime: enter decelerates in, exit accelerates out, per the M3 motion spec. + */ +const motion = { + enter: { duration: 'short3', easing: 'standardDecelerate' }, + exit: { duration: 'short2', easing: 'standardAccelerate' }, +} as const; + +export const Tokens = { plain, rich, motion }; diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index 75b4a18cf7..561e22dfaf 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -1,6 +1,5 @@ -import React from 'react'; -import { Dimensions, Text, View, Platform } from 'react-native'; -import type { ViewProps } from 'react-native'; +import React, { RefObject } from 'react'; +import { Dimensions, StyleSheet, Text, View, Platform } from 'react-native'; import { afterAll, @@ -14,6 +13,7 @@ import { import { act, fireEvent, userEvent } from '@testing-library/react-native'; import PaperProvider from '../../core/PaperProvider'; +import { getTheme } from '../../core/theming'; import { render } from '../../test-utils'; import Tooltip from '../Tooltip/Tooltip'; @@ -175,6 +175,31 @@ describe('Tooltip', () => { }); }); + describe('MD3 styling', () => { + it('renders an inverseSurface container with inverseOnSurface text', async () => { + const { + wrapper: { getByText, getByTestId, findByText }, + } = setup(); + + fireEvent(getTrigger(getByText), 'longPress'); + + await findByText('some tooltip text'); + + expect(getByTestId('tooltip-container').props.style).toMatchObject([ + {}, + { backgroundColor: getTheme().colors.inverseSurface }, + ]); + + // bodySmall (12sp) text in the inverseOnSurface role. + expect( + StyleSheet.flatten(getByText('some tooltip text').props.style) + ).toMatchObject({ + color: getTheme().colors.inverseOnSurface, + fontSize: 12, + }); + }); + }); + describe('Tooltip position', () => { const LAYOUT_WIDTH = 360; const LAYOUT_HEIGHT = 705; From d2e5c32565d24bd18bbbbbd31e38c690b4dd2542 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 10 Jun 2026 10:52:04 +0200 Subject: [PATCH 02/29] feat(tooltip): add fade enter/exit animation The plain tooltip now fades in on show and out on hide using Reanimated, per the MD3 motion spec (enter short3/standardDecelerate, exit short2/standardAccelerate). Show/hide intent (visible) is split from mount state (rendered) so the tooltip stays mounted through the exit fade before unmounting. Honors reduce-motion via useReduceMotion. --- src/components/Tooltip/Tooltip.tsx | 95 ++++++++++++++-- src/components/__tests__/Tooltip.test.tsx | 127 ++++++++++++++++------ 2 files changed, 176 insertions(+), 46 deletions(-) diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index ee8659817f..b5668712dc 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -8,9 +8,18 @@ import { } from 'react-native'; import type { LayoutChangeEvent, ViewStyle } from 'react-native'; +import Animated, { + Easing, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + import { Tokens } from './tokens'; import { getTooltipPosition, Measurement, TooltipChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { ThemeProp } from '../../types'; import { addEventListener } from '../../utils/addEventListener'; import Portal from '../Portal/Portal'; @@ -74,7 +83,11 @@ const Tooltip = ({ const isWeb = Platform.OS === 'web'; const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); + // `visible` is the show/hide intent; `rendered` keeps the tooltip mounted + // through the exit fade so it can animate out before unmounting. const [visible, setVisible] = React.useState(false); + const [rendered, setRendered] = React.useState(false); const [measurement, setMeasurement] = React.useState({ children: {}, @@ -87,6 +100,36 @@ const Tooltip = ({ const childrenWrapperRef = React.useRef(null); const touched = React.useRef(false); + const opacity = useSharedValue(0); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + const enterConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.enter.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + const exitConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.exit.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + // The visual fade-out is handled by Reanimated; the actual unmount is + // deferred by this same duration so the fade can play. Reduce-motion skips + // the wait entirely. + const exitDurationMs = reduceMotion + ? 0 + : theme.motion.duration[Tokens.motion.exit.duration]; + + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + const isValidChild = React.useMemo( () => React.isValidElement(children), [children] @@ -106,6 +149,43 @@ const Tooltip = ({ }; }, []); + // Mount as soon as the tooltip is requested. + React.useEffect(() => { + if (visible) { + setRendered(true); + } + }, [visible]); + + // Drive the fade and defer unmount until the exit animation has played. + React.useEffect(() => { + if (!rendered) { + return; + } + + if (visible) { + // Hold at 0 until measured so the tooltip never flashes at the wrong + // position, then fade in. + opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0; + return; + } + + opacity.value = withTiming(0, exitConfig); + const id = setTimeout(() => { + setRendered(false); + setMeasurement({ children: {}, tooltip: {}, measured: false }); + }, exitDurationMs) as unknown as NodeJS.Timeout; + + return () => clearTimeout(id); + }, [ + visible, + rendered, + measurement.measured, + opacity, + enterConfig, + exitConfig, + exitDurationMs, + ]); + React.useEffect(() => { const subscription = addEventListener(Dimensions, 'change', () => setVisible(false) @@ -141,7 +221,6 @@ const Tooltip = ({ let id = setTimeout(() => { setVisible(false); - setMeasurement({ children: {}, tooltip: {}, measured: false }); }, leaveTouchDelay) as unknown as NodeJS.Timeout; hideTooltipTimer.current.push(id); }, [leaveTouchDelay]); @@ -196,9 +275,9 @@ const Tooltip = ({ return ( <> - {visible && ( + {rendered && ( - ), borderRadius: theme.shapes.corner[Tokens.plain.shape], - ...(measurement.measured ? styles.visible : styles.hidden), }, + animatedStyle, ]} testID="tooltip-container" > @@ -224,7 +303,7 @@ const Tooltip = ({ > {title} - + )} { await findByText('some tooltip text'); - await runTimers(); + fireEvent(getTrigger(getByText), 'pressOut'); + runTimers(); // leaveTouchDelay → starts the fade-out + runTimers(); // exit fade duration → unmounts expect(queryByText('some tooltip text')).not.toBeOnTheScreen(); }); @@ -188,6 +190,7 @@ describe('Tooltip', () => { expect(getByTestId('tooltip-container').props.style).toMatchObject([ {}, { backgroundColor: getTheme().colors.inverseSurface }, + {}, ]); // bodySmall (12sp) text in the inverseOnSurface role. @@ -200,6 +203,27 @@ describe('Tooltip', () => { }); }); + describe('fade animation', () => { + it('stays mounted through the exit fade before unmounting', async () => { + const { + wrapper: { queryByText, getByText, findByText }, + } = setup({ leaveTouchDelay: 0 }); + + fireEvent(getTrigger(getByText), 'longPress'); + + await findByText('some tooltip text'); + + fireEvent(getTrigger(getByText), 'pressOut'); + runTimers(); // leaveTouchDelay elapses → exit fade starts + + // Still mounted while fading out so the animation can play. + expect(queryByText('some tooltip text')).not.toBeNull(); + + runTimers(); // exit fade duration elapses → unmounts + expect(queryByText('some tooltip text')).toBeNull(); + }); + }); + describe('Tooltip position', () => { const LAYOUT_WIDTH = 360; const LAYOUT_HEIGHT = 705; @@ -229,10 +253,14 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container')).toHaveStyle({ - left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210 - top: 250, // pageY (200) + height (50) - }); + expect(getByTestId('tooltip-container').props.style).toMatchObject([ + {}, + { + left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210 + top: 250, // pageY (200) + height (50) + }, + {}, + ]); }); }); @@ -250,10 +278,14 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container')).toHaveStyle({ - left: 0, // Tooltip renders starting from children's x coord - top: 250, - }); + expect(getByTestId('tooltip-container').props.style).toMatchObject([ + {}, + { + left: 0, // Tooltip renders starting from children's x coord + top: 250, + }, + {}, + ]); }); }); @@ -271,10 +303,14 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container')).toHaveStyle({ - left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen - top: 250, - }); + expect(getByTestId('tooltip-container').props.style).toMatchObject([ + {}, + { + left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen + top: 250, + }, + {}, + ]); }); }); @@ -292,10 +328,14 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container')).toHaveStyle({ - left: 210, - top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component, - }); + expect(getByTestId('tooltip-container').props.style).toMatchObject([ + {}, + { + left: 210, + top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component, + }, + {}, + ]); }); }); }); @@ -388,8 +428,9 @@ describe('Tooltip', () => { await findByText('some tooltip text'); - await fireEvent(getTrigger(getByText), 'hoverOut'); - await runTimers(); + fireEvent(getTrigger(getByText), 'hoverOut'); + runTimers(); // leaveTouchDelay → starts the fade-out + runTimers(); // exit fade duration → unmounts expect(queryByText('some tooltip text')).not.toBeOnTheScreen(); }); @@ -425,10 +466,14 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container')).toHaveStyle({ - left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210 - top: 250, // pageY (200) + height (50) - }); + expect(getByTestId('tooltip-container').props.style).toMatchObject([ + {}, + { + left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210 + top: 250, // pageY (200) + height (50) + }, + {}, + ]); }); }); @@ -447,10 +492,14 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container')).toHaveStyle({ - left: 0, // Tooltip renders starting from children's x coord - top: 250, - }); + expect(getByTestId('tooltip-container').props.style).toMatchObject([ + {}, + { + left: 0, // Tooltip renders starting from children's x coord + top: 250, + }, + {}, + ]); }); }); @@ -469,10 +518,14 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container')).toHaveStyle({ - left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen - top: 250, - }); + expect(getByTestId('tooltip-container').props.style).toMatchObject([ + {}, + { + left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen + top: 250, + }, + {}, + ]); }); }); @@ -491,10 +544,14 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container')).toHaveStyle({ - left: 210, - top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component, - }); + expect(getByTestId('tooltip-container').props.style).toMatchObject([ + {}, + { + left: 210, + top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component, + }, + {}, + ]); }); }); }); From dd5586c6656de22067420f1e3fe30ce11a4c0cca Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 10 Jun 2026 13:37:01 +0200 Subject: [PATCH 03/29] feat(tooltip): add rich tooltip variant Adds Tooltip.Rich, a persistent, interactive rich tooltip per the MD3 spec: an optional subhead title, supporting body text (string or element) and a row of action buttons on a surfaceContainer surface at elevation level 2 with a 12dp corner. Exposed as a compound component (Object.assign) so the plain Tooltip stays untouched. Uncontrolled tap-to-toggle: tapping the trigger toggles it, tapping the Portal backdrop or selecting an action dismisses it. On web it opens on hover and bridges the trigger-to-tooltip gap before hiding. Reuses the plain tooltip's Reanimated fade and reduce-motion handling. --- src/components/Tooltip/RichTooltip.tsx | 408 ++++++++++++++++++++++ src/components/Tooltip/index.tsx | 6 + src/components/__tests__/Tooltip.test.tsx | 170 +++++++++ src/index.tsx | 3 +- 4 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 src/components/Tooltip/RichTooltip.tsx create mode 100644 src/components/Tooltip/index.tsx diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx new file mode 100644 index 0000000000..ce169ca720 --- /dev/null +++ b/src/components/Tooltip/RichTooltip.tsx @@ -0,0 +1,408 @@ +import * as React from 'react'; +import { + Dimensions, + View, + LayoutChangeEvent, + StyleSheet, + Platform, + Pressable, + ViewStyle, +} from 'react-native'; + +import Animated, { + Easing, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { Tokens } from './tokens'; +import { getTooltipPosition, Measurement, TooltipChildProps } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import type { ThemeProp } from '../../types'; +import { addEventListener } from '../../utils/addEventListener'; +import Portal from '../Portal/Portal'; +import Surface from '../Surface'; +import Text from '../Typography/Text'; + +export type Props = { + /** + * Tooltip reference element. Needs to be able to hold a ref. + */ + children: React.ReactElement; + /** + * Optional subhead shown above the content. + */ + title?: string; + /** + * Supporting body text. A string is rendered with the `bodyMedium` type + * style; pass an element to compose inline links or custom content. + */ + content: string | React.ReactElement; + /** + * Action buttons (and/or links) rendered in a row below the content. + * Pressing one dismisses the tooltip. + */ + actions?: React.ReactNode; + /** + * The number of milliseconds a user must hover the element before showing + * the tooltip (web only). + */ + enterTouchDelay?: number; + /** + * The number of milliseconds after the pointer leaves both the trigger and + * the tooltip before hiding it (web only). + */ + leaveTouchDelay?: number; + /** + * Specifies the largest possible scale the title font can reach. + */ + titleMaxFontSizeMultiplier?: number; + /** + * Specifies the largest possible scale the content font can reach. + */ + contentMaxFontSizeMultiplier?: number; + /** + * @optional + */ + theme?: ThemeProp; +}; + +/** + * Rich tooltips display informative text along with an optional subhead and + * action buttons. Unlike plain tooltips they are persistent and interactive: + * tap the element to toggle the tooltip, then tap outside or an action to + * dismiss it. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Button, IconButton, Tooltip } from 'react-native-paper'; + * + * const MyComponent = () => ( + * Learn more} + * > + * {}} /> + * + * ); + * + * export default MyComponent; + * ``` + */ +const RichTooltip = ({ + children, + title, + content, + actions, + enterTouchDelay = 100, + leaveTouchDelay = 500, + titleMaxFontSizeMultiplier, + contentMaxFontSizeMultiplier, + theme: themeOverrides, + ...rest +}: Props) => { + const isWeb = Platform.OS === 'web'; + + const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); + // `visible` is the show/hide intent; `rendered` keeps the tooltip mounted + // through the exit fade so it can animate out before unmounting. + const [visible, setVisible] = React.useState(false); + const [rendered, setRendered] = React.useState(false); + + const [measurement, setMeasurement] = React.useState({ + children: {}, + tooltip: {}, + measured: false, + }); + const showTooltipTimer = React.useRef([]); + const hideTooltipTimer = React.useRef([]); + + const childrenWrapperRef = React.useRef(null); + + const opacity = useSharedValue(0); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + const enterConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.enter.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + const exitConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.exit.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + const exitDurationMs = reduceMotion + ? 0 + : theme.motion.duration[Tokens.motion.exit.duration]; + + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + const isValidChild = React.useMemo( + () => React.isValidElement(children), + [children] + ); + + const clearShowTimers = React.useCallback(() => { + showTooltipTimer.current.forEach((t) => clearTimeout(t)); + showTooltipTimer.current = []; + }, []); + + const clearHideTimers = React.useCallback(() => { + hideTooltipTimer.current.forEach((t) => clearTimeout(t)); + hideTooltipTimer.current = []; + }, []); + + React.useEffect(() => { + return () => { + clearShowTimers(); + clearHideTimers(); + }; + }, [clearShowTimers, clearHideTimers]); + + // Mount as soon as the tooltip is requested. + React.useEffect(() => { + if (visible) { + setRendered(true); + } + }, [visible]); + + // Drive the fade and defer unmount until the exit animation has played. + React.useEffect(() => { + if (!rendered) { + return; + } + + if (visible) { + opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0; + return; + } + + opacity.value = withTiming(0, exitConfig); + const id = setTimeout(() => { + setRendered(false); + setMeasurement({ children: {}, tooltip: {}, measured: false }); + }, exitDurationMs) as unknown as NodeJS.Timeout; + + return () => clearTimeout(id); + }, [ + visible, + rendered, + measurement.measured, + opacity, + enterConfig, + exitConfig, + exitDurationMs, + ]); + + React.useEffect(() => { + const subscription = addEventListener(Dimensions, 'change', () => + setVisible(false) + ); + + return () => subscription.remove(); + }, []); + + const show = React.useCallback(() => { + clearHideTimers(); + setVisible(true); + }, [clearHideTimers]); + + const hide = React.useCallback(() => { + clearShowTimers(); + setVisible(false); + }, [clearShowTimers]); + + const scheduleHide = React.useCallback(() => { + clearShowTimers(); + const id = setTimeout( + () => setVisible(false), + leaveTouchDelay + ) as unknown as NodeJS.Timeout; + hideTooltipTimer.current.push(id); + }, [clearShowTimers, leaveTouchDelay]); + + // Mobile: a tap toggles the tooltip and still forwards the child's onPress. + const handlePress = React.useCallback(() => { + if (visible) { + hide(); + } else { + show(); + } + if (isValidChild) { + (children.props as TooltipChildProps).onPress?.(); + } + }, [visible, hide, show, isValidChild, children.props]); + + // Web: open on hover, with a short enter delay. + const handleHoverIn = React.useCallback(() => { + clearHideTimers(); + const id = setTimeout( + () => setVisible(true), + enterTouchDelay + ) as unknown as NodeJS.Timeout; + showTooltipTimer.current.push(id); + if (isValidChild) { + (children.props as TooltipChildProps).onHoverIn?.(); + } + }, [clearHideTimers, enterTouchDelay, isValidChild, children.props]); + + const handleHoverOut = React.useCallback(() => { + scheduleHide(); + if (isValidChild) { + (children.props as TooltipChildProps).onHoverOut?.(); + } + }, [scheduleHide, isValidChild, children.props]); + + const handleOnLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { + childrenWrapperRef.current?.measure( + (_x, _y, width, height, pageX, pageY) => { + setMeasurement({ + children: { pageX, pageY, height, width }, + tooltip: { ...layout }, + measured: true, + }); + } + ); + }; + + const mobilePressProps = { + onPress: handlePress, + }; + + const webPressProps = { + onHoverIn: handleHoverIn, + onHoverOut: handleHoverOut, + }; + + // Web only: keep the tooltip open while the pointer travels from the trigger + // into the tooltip (and re-schedule the hide once it leaves the tooltip). + const tooltipHoverProps = isWeb + ? { onHoverIn: clearHideTimers, onHoverOut: scheduleHide } + : {}; + + return ( + <> + {rendered && ( + + + + ), + animatedStyle, + ]} + testID="tooltip-rich-container" + > + + + {title ? ( + + {title} + + ) : null} + {typeof content === 'string' ? ( + + {content} + + ) : ( + content + )} + {actions ? ( + // `onTouchEnd` bubbles from the pressed action up to this + // wrapper, so selecting any action dismisses the tooltip. + + {actions} + + ) : null} + + + + + )} + + {React.cloneElement(children, { + ...rest, + ...(isWeb ? webPressProps : mobilePressProps), + })} + + + ); +}; + +RichTooltip.displayName = 'Tooltip.Rich'; + +const styles = StyleSheet.create({ + container: { + alignSelf: 'flex-start', + maxWidth: Tokens.rich.maxWidth, + }, + surface: { + paddingHorizontal: Tokens.rich.paddingHorizontal, + paddingVertical: Tokens.rich.paddingVertical, + rowGap: Tokens.rich.gap, + }, + actions: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + pressContainer: { + ...(Platform.OS === 'web' && { cursor: 'default' }), + } as ViewStyle, +}); + +export default RichTooltip; diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx new file mode 100644 index 0000000000..f5fa880a6b --- /dev/null +++ b/src/components/Tooltip/index.tsx @@ -0,0 +1,6 @@ +import RichTooltip from './RichTooltip'; +import TooltipBase from './Tooltip'; + +const Tooltip = Object.assign(TooltipBase, { Rich: RichTooltip }); + +export default Tooltip; diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index 5d3b9cf33e..9f1b1571f2 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -15,6 +15,7 @@ import { act, fireEvent, userEvent } from '@testing-library/react-native'; import PaperProvider from '../../core/PaperProvider'; import { getTheme } from '../../core/theming'; import { render } from '../../test-utils'; +import TooltipCompound from '../Tooltip'; import Tooltip from '../Tooltip/Tooltip'; const mockedRemoveEventListener = jest.fn(); @@ -557,3 +558,172 @@ describe('Tooltip', () => { }); }); }); + +describe('Tooltip.Rich', () => { + const getTrigger = (getByText: (text: string) => ReactTestInstance) => + getByText('dummy component').parent as ReactTestInstance; + + const runTimers = (ms?: number) => { + act(() => { + if (ms === undefined) { + jest.runOnlyPendingTimers(); + } else { + jest.advanceTimersByTime(ms); + } + }); + }; + + const setup = ( + propOverrides?: Partial> + ) => { + jest + .spyOn(View.prototype, 'measure') + .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200)); + + const wrapper = render( + + + + + + ); + + return { wrapper }; + }; + + it('is exposed as a compound component on Tooltip', () => { + expect(TooltipCompound.Rich).toBeDefined(); + }); + + describe('Mobile', () => { + beforeAll(() => { + Platform.OS = 'android'; + }); + afterEach(() => jest.clearAllMocks()); + + it('toggles title, content and actions when the trigger is pressed', () => { + const { + wrapper: { getByText, getByTestId, queryByText }, + } = setup({ title: 'Heading', actions: Learn more }); + + expect(queryByText('Body text')).toBeNull(); + + fireEvent.press(getTrigger(getByText)); + + expect(getByText('Heading')).toBeTruthy(); + expect(getByText('Body text')).toBeTruthy(); + expect(getByText('Learn more')).toBeTruthy(); + expect(getByTestId('tooltip-rich-container')).toBeTruthy(); + + // Pressing again toggles it back off. + fireEvent.press(getTrigger(getByText)); + runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + + it('renders a custom element as content', () => { + const { + wrapper: { getByText }, + } = setup({ content: Custom node }); + + fireEvent.press(getTrigger(getByText)); + + expect(getByText('Custom node')).toBeTruthy(); + }); + + it('uses the surfaceContainer container with MD3 title/content roles', () => { + const { + wrapper: { getByText, getByTestId }, + } = setup({ title: 'Heading' }); + + fireEvent.press(getTrigger(getByText)); + + expect( + StyleSheet.flatten(getByText('Heading').props.style) + ).toMatchObject({ + color: getTheme().colors.onSurface, + }); + expect( + StyleSheet.flatten(getByText('Body text').props.style) + ).toMatchObject({ + color: getTheme().colors.onSurfaceVariant, + }); + + // Surface (container) uses the surfaceContainer color. + expect( + StyleSheet.flatten( + getByTestId('tooltip-rich-surface-container').props.style + ) + ).toMatchObject({ + backgroundColor: getTheme().colors.surfaceContainer, + }); + }); + + it('dismisses when the backdrop is pressed', () => { + const { + wrapper: { getByText, getByTestId, queryByText }, + } = setup(); + + fireEvent.press(getTrigger(getByText)); + expect(getByText('Body text')).toBeTruthy(); + + fireEvent.press(getByTestId('tooltip-rich-backdrop')); + runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + + it('dismisses when an action is selected', () => { + const { + wrapper: { getByText, getByTestId, queryByText }, + } = setup({ actions: Learn more }); + + fireEvent.press(getTrigger(getByText)); + expect(getByText('Body text')).toBeTruthy(); + + fireEvent(getByTestId('tooltip-rich-actions'), 'touchEnd'); + runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + }); + + describe('Web', () => { + beforeAll(() => { + Platform.OS = 'web'; + }); + afterEach(() => jest.clearAllMocks()); + + it('opens on hover after the enter delay', () => { + const { + wrapper: { getByText, queryByText }, + } = setup({ enterTouchDelay: 100 }); + + fireEvent(getTrigger(getByText), 'hoverIn'); + expect(queryByText('Body text')).toBeNull(); // still within the delay + + runTimers(100); + + expect(getByText('Body text')).toBeTruthy(); + }); + + it('keeps the tooltip open while the pointer moves into it (gap bridge)', () => { + const { + wrapper: { getByText, getByTestId }, + } = setup({ enterTouchDelay: 0, leaveTouchDelay: 500 }); + + fireEvent(getTrigger(getByText), 'hoverIn'); + runTimers(0); + expect(getByText('Body text')).toBeTruthy(); + + // Leaving the trigger schedules a hide... + fireEvent(getTrigger(getByText), 'hoverOut'); + // ...but entering the tooltip cancels it. + fireEvent(getByTestId('tooltip-rich-surface'), 'hoverIn'); + runTimers(500); + + expect(getByText('Body text')).toBeTruthy(); + }); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..0f783a0d47 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -49,7 +49,7 @@ export { default as TouchableRipple } from './components/TouchableRipple/Touchab export { default as TextInput } from './components/TextInput'; export { default as ToggleButton } from './components/ToggleButton'; export { default as SegmentedButtons } from './components/SegmentedButtons/SegmentedButtons'; -export { default as Tooltip } from './components/Tooltip/Tooltip'; +export { default as Tooltip } from './components/Tooltip'; export { default as Text, customText } from './components/Typography/Text'; @@ -146,5 +146,6 @@ export type { Props as TextProps } from './components/Typography/Text'; export type { Props as SegmentedButtonsProps } from './components/SegmentedButtons/SegmentedButtons'; export type { Props as ListImageProps } from './components/List/ListImage'; export type { Props as TooltipProps } from './components/Tooltip/Tooltip'; +export type { Props as TooltipRichProps } from './components/Tooltip/RichTooltip'; export { type TypescaleKey, type Theme, type Elevation } from './types'; From 83a82bc6368699aa6ea559cc5a6a988bc9e0f740 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 10 Jun 2026 13:57:47 +0200 Subject: [PATCH 04/29] docs(tooltip): showcase rich tooltip and document both variants Adds a 'Rich tooltips' section to the example app (full title/content/actions variant plus a body-only one), registers the RichTooltip page in the docs component map so the generated docs cover Tooltip.Rich, and cross-references the rich variant from the plain Tooltip JSDoc. --- example/src/Examples/TooltipExample.tsx | 24 ++++++++++++++++++++++++ src/components/Tooltip/Tooltip.tsx | 2 ++ 2 files changed, 26 insertions(+) diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx index 8e0802d4a4..59a3b6bbef 100644 --- a/example/src/Examples/TooltipExample.tsx +++ b/example/src/Examples/TooltipExample.tsx @@ -6,6 +6,7 @@ import { Appbar, Avatar, Banner, + Button, Chip, FAB, IconButton, @@ -146,6 +147,29 @@ const TooltipExample = () => { + + + + + + + } + > + {}} /> + + + {}} /> + + + diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index b5668712dc..25670eae8b 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -57,6 +57,8 @@ export type Props = { * * Plain tooltips, when activated, display a text label identifying an element, such as a description of its function. Tooltips should include only short, descriptive text and avoid restating visible UI text. * + * For tooltips with a title, supporting text and action buttons, see `Tooltip.Rich`. + * * ## Usage * ```js * import * as React from 'react'; From 76c45189e850f70ba32cbe445dae5bbd84a42702 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 10 Jun 2026 14:13:25 +0200 Subject: [PATCH 05/29] refactor(tooltip): extract shared useTooltipFade hook The fade lifecycle (mount-through-exit, opacity, measurement, motion configs, reduce-motion) was duplicated between Tooltip and Tooltip.Rich. Extract it into a useTooltipFade hook so both variants share one implementation. No behavior change. --- src/components/Tooltip/RichTooltip.tsx | 102 ++--------------------- src/components/Tooltip/Tooltip.tsx | 106 ++--------------------- src/components/Tooltip/hooks.ts | 111 +++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 194 deletions(-) create mode 100644 src/components/Tooltip/hooks.ts diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index ce169ca720..9e97ba7bbe 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -2,25 +2,18 @@ import * as React from 'react'; import { Dimensions, View, - LayoutChangeEvent, StyleSheet, Platform, Pressable, ViewStyle, } from 'react-native'; -import Animated, { - Easing, - ReduceMotion, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; +import { useTooltipFade } from './hooks'; import { Tokens } from './tokens'; import { getTooltipPosition, Measurement, TooltipChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; -import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { ThemeProp } from '../../types'; import { addEventListener } from '../../utils/addEventListener'; import Portal from '../Portal/Portal'; @@ -109,49 +102,15 @@ const RichTooltip = ({ const isWeb = Platform.OS === 'web'; const theme = useInternalTheme(themeOverrides); - const reduceMotion = useReduceMotion(); - // `visible` is the show/hide intent; `rendered` keeps the tooltip mounted - // through the exit fade so it can animate out before unmounting. + // `visible` is the show/hide intent; the fade hook keeps the tooltip mounted + // through the exit animation and owns the measurement + opacity. const [visible, setVisible] = React.useState(false); - const [rendered, setRendered] = React.useState(false); + const { rendered, measurement, animatedStyle, onLayout, childrenWrapperRef } = + useTooltipFade(theme, visible); - const [measurement, setMeasurement] = React.useState({ - children: {}, - tooltip: {}, - measured: false, - }); const showTooltipTimer = React.useRef([]); const hideTooltipTimer = React.useRef([]); - const childrenWrapperRef = React.useRef(null); - - const opacity = useSharedValue(0); - const reanimatedReduceMotion = reduceMotion - ? ReduceMotion.Always - : ReduceMotion.Never; - - const enterConfig = React.useMemo( - () => ({ - duration: theme.motion.duration[Tokens.motion.enter.duration], - easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]), - reduceMotion: reanimatedReduceMotion, - }), - [theme.motion, reanimatedReduceMotion] - ); - const exitConfig = React.useMemo( - () => ({ - duration: theme.motion.duration[Tokens.motion.exit.duration], - easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]), - reduceMotion: reanimatedReduceMotion, - }), - [theme.motion, reanimatedReduceMotion] - ); - const exitDurationMs = reduceMotion - ? 0 - : theme.motion.duration[Tokens.motion.exit.duration]; - - const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); - const isValidChild = React.useMemo( () => React.isValidElement(children), [children] @@ -174,41 +133,6 @@ const RichTooltip = ({ }; }, [clearShowTimers, clearHideTimers]); - // Mount as soon as the tooltip is requested. - React.useEffect(() => { - if (visible) { - setRendered(true); - } - }, [visible]); - - // Drive the fade and defer unmount until the exit animation has played. - React.useEffect(() => { - if (!rendered) { - return; - } - - if (visible) { - opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0; - return; - } - - opacity.value = withTiming(0, exitConfig); - const id = setTimeout(() => { - setRendered(false); - setMeasurement({ children: {}, tooltip: {}, measured: false }); - }, exitDurationMs) as unknown as NodeJS.Timeout; - - return () => clearTimeout(id); - }, [ - visible, - rendered, - measurement.measured, - opacity, - enterConfig, - exitConfig, - exitDurationMs, - ]); - React.useEffect(() => { const subscription = addEventListener(Dimensions, 'change', () => setVisible(false) @@ -268,18 +192,6 @@ const RichTooltip = ({ } }, [scheduleHide, isValidChild, children.props]); - const handleOnLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { - childrenWrapperRef.current?.measure( - (_x, _y, width, height, pageX, pageY) => { - setMeasurement({ - children: { pageX, pageY, height, width }, - tooltip: { ...layout }, - measured: true, - }); - } - ); - }; - const mobilePressProps = { onPress: handlePress, }; @@ -307,7 +219,7 @@ const RichTooltip = ({ testID="tooltip-rich-backdrop" /> ([]); const hideTooltipTimer = React.useRef([]); - const childrenWrapperRef = React.useRef(null); const touched = React.useRef(false); - const opacity = useSharedValue(0); - const reanimatedReduceMotion = reduceMotion - ? ReduceMotion.Always - : ReduceMotion.Never; - - const enterConfig = React.useMemo( - () => ({ - duration: theme.motion.duration[Tokens.motion.enter.duration], - easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]), - reduceMotion: reanimatedReduceMotion, - }), - [theme.motion, reanimatedReduceMotion] - ); - const exitConfig = React.useMemo( - () => ({ - duration: theme.motion.duration[Tokens.motion.exit.duration], - easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]), - reduceMotion: reanimatedReduceMotion, - }), - [theme.motion, reanimatedReduceMotion] - ); - // The visual fade-out is handled by Reanimated; the actual unmount is - // deferred by this same duration so the fade can play. Reduce-motion skips - // the wait entirely. - const exitDurationMs = reduceMotion - ? 0 - : theme.motion.duration[Tokens.motion.exit.duration]; - - const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); - const isValidChild = React.useMemo( () => React.isValidElement(children), [children] @@ -151,43 +108,6 @@ const Tooltip = ({ }; }, []); - // Mount as soon as the tooltip is requested. - React.useEffect(() => { - if (visible) { - setRendered(true); - } - }, [visible]); - - // Drive the fade and defer unmount until the exit animation has played. - React.useEffect(() => { - if (!rendered) { - return; - } - - if (visible) { - // Hold at 0 until measured so the tooltip never flashes at the wrong - // position, then fade in. - opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0; - return; - } - - opacity.value = withTiming(0, exitConfig); - const id = setTimeout(() => { - setRendered(false); - setMeasurement({ children: {}, tooltip: {}, measured: false }); - }, exitDurationMs) as unknown as NodeJS.Timeout; - - return () => clearTimeout(id); - }, [ - visible, - rendered, - measurement.measured, - opacity, - enterConfig, - exitConfig, - exitDurationMs, - ]); - React.useEffect(() => { const subscription = addEventListener(Dimensions, 'change', () => setVisible(false) @@ -251,18 +171,6 @@ const Tooltip = ({ } }, [children.props, handleTouchEnd, isValidChild]); - const handleOnLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { - childrenWrapperRef.current?.measure( - (_x, _y, width, height, pageX, pageY) => { - setMeasurement({ - children: { pageX, pageY, height, width }, - tooltip: { ...layout }, - measured: true, - }); - } - ); - }; - const mobilePressProps = { onPress: handlePress, onLongPress: () => handleTouchStart(), @@ -280,7 +188,7 @@ const Tooltip = ({ {rendered && ( { + const reduceMotion = useReduceMotion(); + const [rendered, setRendered] = React.useState(false); + const [measurement, setMeasurement] = React.useState({ + children: {}, + tooltip: {}, + measured: false, + }); + const childrenWrapperRef = React.useRef(null); + + const opacity = useSharedValue(0); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + const enterConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.enter.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + const exitConfig = React.useMemo( + () => ({ + duration: theme.motion.duration[Tokens.motion.exit.duration], + easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion, reanimatedReduceMotion] + ); + const exitDurationMs = reduceMotion + ? 0 + : theme.motion.duration[Tokens.motion.exit.duration]; + + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + // Mount as soon as the tooltip is requested. + React.useEffect(() => { + if (visible) { + setRendered(true); + } + }, [visible]); + + // Drive the fade and defer unmount until the exit animation has played. + React.useEffect(() => { + if (!rendered) { + return; + } + + if (visible) { + opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0; + return; + } + + opacity.value = withTiming(0, exitConfig); + const id = setTimeout(() => { + setRendered(false); + setMeasurement({ children: {}, tooltip: {}, measured: false }); + }, exitDurationMs) as unknown as NodeJS.Timeout; + + return () => clearTimeout(id); + }, [ + visible, + rendered, + measurement.measured, + opacity, + enterConfig, + exitConfig, + exitDurationMs, + ]); + + const onLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { + childrenWrapperRef.current?.measure( + (_x, _y, width, height, pageX, pageY) => { + setMeasurement({ + children: { pageX, pageY, height, width }, + tooltip: { ...layout }, + measured: true, + }); + } + ); + }; + + return { rendered, measurement, animatedStyle, onLayout, childrenWrapperRef }; +}; From 13f31ed9b316bcd787f0d54e0aafba8215a31e72 Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Fri, 22 May 2026 15:36:58 +0300 Subject: [PATCH 06/29] feat(fab): modernize FloatingActionButton to MD3 --- docs/src/components/BannerExample.tsx | 7 +- .../src/Examples/ActivityIndicatorExample.tsx | 9 +- example/src/Examples/AppbarExample.tsx | 5 +- example/src/Examples/BannerExample.tsx | 13 +- example/src/Examples/FABExample.tsx | 87 +- example/src/Examples/TeamDetails.tsx | 9 +- example/src/Examples/TooltipExample.tsx | 4 +- .../__fixtures__/rewrite-imports/code.js | 2 +- .../__fixtures__/rewrite-imports/output.js | 2 +- .../FAB/ExtendedFloatingActionButton.tsx | 273 ++ src/components/FAB/FabContent.tsx | 160 ++ src/components/FAB/FabShell.tsx | 361 +++ .../FAB/{FAB.tsx => FloatingActionButton.tsx} | 125 +- .../FAB/FloatingActionButtonMenu.tsx | 727 ++++++ src/components/FAB/tokens.ts | 22 +- src/components/FAB/useFabVisibility.ts | 95 + src/components/FAB/utils.ts | 69 +- src/components/__tests__/FAB.test.tsx | 103 - .../__tests__/__snapshots__/FAB.test.tsx.snap | 2238 ----------------- src/index.tsx | 19 +- tsconfig.json | 3 +- 21 files changed, 1821 insertions(+), 2512 deletions(-) create mode 100644 src/components/FAB/ExtendedFloatingActionButton.tsx create mode 100644 src/components/FAB/FabContent.tsx create mode 100644 src/components/FAB/FabShell.tsx rename src/components/FAB/{FAB.tsx => FloatingActionButton.tsx} (50%) create mode 100644 src/components/FAB/FloatingActionButtonMenu.tsx create mode 100644 src/components/FAB/useFabVisibility.ts delete mode 100644 src/components/__tests__/FAB.test.tsx delete mode 100644 src/components/__tests__/__snapshots__/FAB.test.tsx.snap diff --git a/docs/src/components/BannerExample.tsx b/docs/src/components/BannerExample.tsx index 56bd19f194..82e602ffb9 100644 --- a/docs/src/components/BannerExample.tsx +++ b/docs/src/components/BannerExample.tsx @@ -6,6 +6,7 @@ import { BrowserOnly } from '@rspress/core/runtime'; import { Avatar, Button, + FloatingActionButton, DarkTheme, FAB, LightTheme, @@ -103,9 +104,9 @@ const BannerExample = () => { - {}} /> - {}} /> - {}} /> + {}} /> + {}} /> + {}} /> diff --git a/example/src/Examples/ActivityIndicatorExample.tsx b/example/src/Examples/ActivityIndicatorExample.tsx index e5fc90f032..193d9fdffc 100644 --- a/example/src/Examples/ActivityIndicatorExample.tsx +++ b/example/src/Examples/ActivityIndicatorExample.tsx @@ -1,7 +1,12 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; -import { ActivityIndicator, FAB, List, Palette } from 'react-native-paper'; +import { + ActivityIndicator, + FloatingActionButton, + List, + Palette, +} from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; @@ -11,7 +16,7 @@ const ActivityIndicatorExample = () => { return ( - setAnimating(!animating)} /> diff --git a/example/src/Examples/AppbarExample.tsx b/example/src/Examples/AppbarExample.tsx index bdb47ad641..c3d7622186 100644 --- a/example/src/Examples/AppbarExample.tsx +++ b/example/src/Examples/AppbarExample.tsx @@ -4,7 +4,7 @@ import { Platform, StyleSheet, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { Appbar, - FAB, + FloatingActionButton, List, Palette, RadioButton, @@ -23,6 +23,7 @@ const MORE_ICON = Platform.OS === 'ios' ? 'dots-horizontal' : 'dots-vertical'; const MEDIUM_FAB_HEIGHT = 56; const AppbarExample = () => { + // @ts-ignore const navigation = useNavigation('Appbar'); const [showLeftIcon, setShowLeftIcon] = React.useState(true); @@ -82,7 +83,7 @@ const AppbarExample = () => { const renderFAB = () => { return ( - {}} style={[styles.fab, { top: (height - MEDIUM_FAB_HEIGHT) / 2 }]} diff --git a/example/src/Examples/BannerExample.tsx b/example/src/Examples/BannerExample.tsx index b3a0d8a002..c8b779f91b 100644 --- a/example/src/Examples/BannerExample.tsx +++ b/example/src/Examples/BannerExample.tsx @@ -2,7 +2,12 @@ import * as React from 'react'; import { Dimensions, Image, Platform, StyleSheet, View } from 'react-native'; import type { LayoutChangeEvent } from 'react-native'; -import { Banner, FAB, Palette, useTheme } from 'react-native-paper'; +import { + Banner, + FloatingActionButton, + Palette, + useTheme, +} from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; @@ -51,7 +56,11 @@ const BannerExample = () => { ))} - setVisible(!visible)} /> + setVisible(!visible)} + /> ; -const variants: FabColor[] = [ +const variants: FloatingActionButtonVariant[] = [ 'primary', 'secondary', 'tertiary', 'tonalPrimary', 'tonalSecondary', 'tonalTertiary', - 'custom', ]; -const sizes: FABSize[] = ['default', 'medium', 'large']; +const sizes: FloatingActionButtonSize[] = ['default', 'medium', 'large']; const types: FabType[] = ['icon', 'extended', 'extendedTransforming', 'menu']; @@ -91,11 +96,9 @@ const FABExample = () => { const { colors } = useTheme(); const insets = useSafeAreaInsets(); - const [variant, setVariant] = React.useState('tonalPrimary'); - const activeVariant = variant === 'custom' ? undefined : variant; - const activeContainerColor = - variant === 'custom' ? CUSTOM_CONTAINER_COLOR : undefined; - const [size, setSize] = React.useState('medium'); + const [variant, setVariant] = + React.useState('tonalPrimary'); + const [size, setSize] = React.useState('medium'); const [type, setType] = React.useState('icon'); const [position, setPosition] = React.useState('end'); const [showFab, setShowFab] = React.useState(true); @@ -185,21 +188,19 @@ const FABExample = () => { ]} > {type === 'icon' && ( - {}} /> )} {(type === 'extended' || type === 'extendedTransforming') && ( - { )} {type === 'menu' ? ( - setMenuExpanded(false)} - alignment={position} - trigger={{ - icon: 'pencil', - variant: activeVariant, - containerColor: activeContainerColor, - size, - visible: showFab, - onPress: () => setMenuExpanded(true), - }} - items={[ - { icon: 'email', label: 'Send', onPress: () => {} }, - { icon: 'bell', label: 'Remind me', onPress: () => {} }, - { icon: 'star', label: 'Favorite', onPress: () => {} }, - ]} - /> + horizontalAlignment={position} + button={ + setMenuExpanded(true)} + /> + } + > + {}} + /> + {}} + /> + {}} + /> + ) : null} ); diff --git a/example/src/Examples/TeamDetails.tsx b/example/src/Examples/TeamDetails.tsx index 5970274f31..bd4bdb6dd5 100644 --- a/example/src/Examples/TeamDetails.tsx +++ b/example/src/Examples/TeamDetails.tsx @@ -18,7 +18,7 @@ import { Chip, Divider, IconButton, - FAB, + FloatingActionButton, PaperProvider, } from 'react-native-paper'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -116,7 +116,12 @@ const News = () => { - {}} visible style={styles.fab} /> + {}} + visible + style={styles.fab} + /> ); }; diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx index 59a3b6bbef..61e6ddc83a 100644 --- a/example/src/Examples/TooltipExample.tsx +++ b/example/src/Examples/TooltipExample.tsx @@ -8,7 +8,7 @@ import { Banner, Button, Chip, - FAB, + FloatingActionButton, IconButton, List, ToggleButton, @@ -173,7 +173,7 @@ const TooltipExample = () => { - {}} /> + {}} /> diff --git a/src/babel/__fixtures__/rewrite-imports/code.js b/src/babel/__fixtures__/rewrite-imports/code.js index f1253a5e08..868e917133 100644 --- a/src/babel/__fixtures__/rewrite-imports/code.js +++ b/src/babel/__fixtures__/rewrite-imports/code.js @@ -3,7 +3,7 @@ import { PaperProvider, BottomNavigation, Button, - FAB, + FloatingActionButton, Appbar, Palette, NonExistent, diff --git a/src/babel/__fixtures__/rewrite-imports/output.js b/src/babel/__fixtures__/rewrite-imports/output.js index bbe342ad0d..b56be3496e 100644 --- a/src/babel/__fixtures__/rewrite-imports/output.js +++ b/src/babel/__fixtures__/rewrite-imports/output.js @@ -2,7 +2,7 @@ import PaperProvider from "react-native-paper/lib/module/core/PaperProvider"; import BottomNavigation from "react-native-paper/lib/module/components/BottomNavigation/BottomNavigation"; import Button from "react-native-paper/lib/module/components/Button/Button"; -import FAB from "react-native-paper/lib/module/components/FAB"; +import FloatingActionButton from "react-native-paper/lib/module/components/FAB/FloatingActionButton"; import Appbar from "react-native-paper/lib/module/components/Appbar"; import { Palette } from "react-native-paper/lib/module/theme/tokens"; import { NonExistent, NonExistentSecond as Stuff, LightTheme } from "react-native-paper/lib/module/index.js"; diff --git a/src/components/FAB/ExtendedFloatingActionButton.tsx b/src/components/FAB/ExtendedFloatingActionButton.tsx new file mode 100644 index 0000000000..33e9ec5c14 --- /dev/null +++ b/src/components/FAB/ExtendedFloatingActionButton.tsx @@ -0,0 +1,273 @@ +import * as React from 'react'; +import { + AccessibilityState, + GestureResponderEvent, + Platform, + PressableAndroidRippleConfig, + StyleProp, + Text as NativeText, + TextLayoutEvent, + View, + ViewStyle, +} from 'react-native'; + +import { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + +import FabShell from './FabShell'; +import { + FloatingActionButtonSize, + FloatingActionButtonVariant, +} from './tokens'; +import { getDimensions, getLabelSizeWeb } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; +import type { ThemeProp } from '../../types'; +import { forwardRef } from '../../utils/forwardRef'; +import type { IconSource } from '../Icon'; + +export type Props = { + /** + * Icon to display inside the FAB. + */ + icon: IconSource; + /** + * Label rendered next to the icon when expanded. + */ + label: string; + /** + * Role-color preset. Defaults to `tonalPrimary`. + */ + variant?: FloatingActionButtonVariant; + /** + * Spec size. Defaults to `default`. + */ + size?: FloatingActionButtonSize; + /** + * Whether the FAB is expanded (icon + label) or collapsed (icon only). The + * width and label opacity animate per the MD3 Expressive spec on change. + */ + expanded: boolean; + /** + * Whether the FAB is currently visible. Toggling animates the spec'd enter + * and exit (scale + alpha) on the FAB itself. + */ + visible?: boolean; + /** + * Function to execute on press. + */ + onPress?: (e: GestureResponderEvent) => void; + /** + * Accessibility label. Falls back to `label` if unset. + */ + accessibilityLabel?: string; + /** + * Accessibility state forwarded to the underlying button. + */ + accessibilityState?: AccessibilityState; + /** + * Specifies the largest possible scale a label font can reach. + */ + labelMaxFontSizeMultiplier?: number; + /** + * Type of background drawable to display the feedback (Android). + * https://reactnative.dev/docs/pressable#rippleconfig + */ + background?: PressableAndroidRippleConfig; + /** + * Style for positioning the FAB. The visual treatment (size, shape, color) + * is driven by `variant` and `size`. + */ + style?: StyleProp; + /** + * TestID used for testing purposes. + */ + testID?: string; + /** + * @optional + */ + theme?: ThemeProp; + ref?: React.RefObject; +}; + +/** + * An extended floating action button represents the primary action on a screen + * and shows a label next to the icon. Animates between expanded (icon + label) + * and collapsed (icon only) states. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { StyleSheet } from 'react-native'; + * import { ExtendedFloatingActionButton } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [expanded, setExpanded] = React.useState(true); + * + * return ( + * setExpanded((v) => !v)} + * style={styles.fab} + * /> + * ); + * }; + * + * const styles = StyleSheet.create({ + * fab: { + * position: 'absolute', + * margin: 16, + * left: 0, + * bottom: 0, + * }, + * }); + * + * export default MyComponent; + * ``` + */ +const ExtendedFloatingActionButton = forwardRef( + ( + { + icon, + label, + variant = 'tonalPrimary', + size = 'default', + expanded, + visible = true, + onPress, + accessibilityLabel = label, + accessibilityState, + labelMaxFontSizeMultiplier, + background, + style, + testID = 'extended-floating-action-button', + theme: themeOverrides, + }, + ref + ) => { + const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); + const isWeb = Platform.OS === 'web'; + + const dimensions = React.useMemo( + () => getDimensions({ theme, size }), + [theme, size] + ); + + const labelRef = React.useRef(null); + const initialLabelSize = isWeb ? getLabelSizeWeb(labelRef) : null; + const [labelWidth, setLabelWidth] = React.useState( + initialLabelSize?.width ?? 0 + ); + + const collapsedWidth = dimensions.width; + const expandedWidth = + dimensions.leading + + dimensions.iconSize + + dimensions.iconLabelGap + + labelWidth + + dimensions.trailing; + + const widthValue = useSharedValue( + expanded ? expandedWidth : collapsedWidth + ); + const labelOpacity = useSharedValue(expanded ? 1 : 0); + + React.useEffect(() => { + if (!isWeb) { + return; + } + const updateLabelSize = () => { + if (labelRef.current) { + const measured = getLabelSizeWeb(labelRef); + if (measured) { + setLabelWidth(measured.width); + } + } + }; + updateLabelSize(); + window.addEventListener('resize', updateLabelSize); + return () => { + window.removeEventListener('resize', updateLabelSize); + }; + }, [isWeb, label]); + + React.useEffect(() => { + const targetWidth = expanded ? expandedWidth : collapsedWidth; + const targetOpacity = expanded ? 1 : 0; + if (reduceMotion) { + widthValue.value = targetWidth; + labelOpacity.value = targetOpacity; + return; + } + const widthSpring = toRawSpring( + expanded + ? theme.motion.spring.fast.spatial + : theme.motion.spring.default.spatial + ); + const opacitySpring = toRawSpring( + expanded + ? theme.motion.spring.default.effects + : theme.motion.spring.fast.effects + ); + widthValue.value = withSpring(targetWidth, widthSpring); + labelOpacity.value = withSpring(targetOpacity, opacitySpring); + }, [ + expanded, + expandedWidth, + collapsedWidth, + theme, + reduceMotion, + widthValue, + labelOpacity, + ]); + + const labelAnimatedStyle = useAnimatedStyle(() => ({ + opacity: labelOpacity.value, + })); + + const onTextLayout = ({ nativeEvent }: TextLayoutEvent) => { + const measured = Math.ceil(nativeEvent.lines[0]?.width ?? 0); + if (measured !== labelWidth) { + setLabelWidth(measured); + } + }; + + return ( + + ); + } +); + +export default ExtendedFloatingActionButton; + +// @component-docs ignore-next-line +export { ExtendedFloatingActionButton }; diff --git a/src/components/FAB/FabContent.tsx b/src/components/FAB/FabContent.tsx new file mode 100644 index 0000000000..06170f1709 --- /dev/null +++ b/src/components/FAB/FabContent.tsx @@ -0,0 +1,160 @@ +import * as React from 'react'; +import { + ColorValue, + ScrollView, + StyleProp, + StyleSheet, + Text as NativeText, + TextLayoutEvent, + View, + ViewStyle, +} from 'react-native'; + +import Reanimated, { AnimatedStyle } from 'react-native-reanimated'; + +import type { TypescaleKey } from '../../theme/types'; +import Icon, { IconSource } from '../Icon'; +import AnimatedText from '../Typography/AnimatedText'; + +export type FabContentProps = { + icon?: IconSource; + label?: string; + contentColor: ColorValue; + height: number; + iconSize: number; + leading: number; + trailing: number; + iconLabelGap: number; + labelTypescale?: TypescaleKey; + labelMaxFontSizeMultiplier?: number; + /** + * Reanimated style merged onto the label wrapper. Used by the Extended FAB + * to fade the label in and out as the FAB expands and collapses. + */ + labelAnimatedStyle?: StyleProp>; + /** + * Ref to the visible label node. Used by the Extended FAB to measure label + * width on the web. + */ + labelRef?: React.RefObject<(NativeText & HTMLElement) | null>; + /** + * `onTextLayout` for the visible label. Used by iOS, which reports the full + * (unclipped) label width via this callback. Pass `undefined` on platforms + * where the visible label is clipped and reports a useless width. + */ + onLabelTextLayout?: (e: TextLayoutEvent) => void; + labelNumberOfLines?: number; + labelEllipsisMode?: 'clip' | 'tail' | 'head' | 'middle'; + /** + * When set, an off-screen copy of the label is rendered with this callback + * attached. Used by the Extended FAB on Android, where the visible label's + * `onTextLayout` reports only the visible glyph run. + */ + offscreenLabelMeasure?: (e: TextLayoutEvent) => void; + testID?: string; +}; + +/** + * Internal layout primitive: an icon-and-label row used by every FAB-flavored + * surface in this package (regular, Extended, Menu trigger, Menu item). + * + * No animation, no ripple, no shadow, no container shape. Just the content. + */ +const FabContent = ({ + icon, + label, + contentColor, + height, + iconSize, + leading, + trailing, + iconLabelGap, + labelTypescale = 'labelLarge', + labelMaxFontSizeMultiplier, + labelAnimatedStyle, + labelRef, + onLabelTextLayout, + labelNumberOfLines, + labelEllipsisMode, + offscreenLabelMeasure, + testID, +}: FabContentProps) => { + const hasLabel = label !== undefined && label !== ''; + const colorStyle = { color: contentColor }; + + return ( + <> + + {icon ? ( + + ) : null} + {hasLabel ? ( + + + {label} + + + ) : null} + + {hasLabel && offscreenLabelMeasure ? ( + + + {label} + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + pointerEvents: 'none', + }, + rowIconOnly: { + justifyContent: 'center', + }, + labelNoPointerEvents: { + pointerEvents: 'none', + }, + offscreen: { + height: 0, + position: 'absolute', + }, +}); + +export default FabContent; diff --git a/src/components/FAB/FabShell.tsx b/src/components/FAB/FabShell.tsx new file mode 100644 index 0000000000..05af367ba6 --- /dev/null +++ b/src/components/FAB/FabShell.tsx @@ -0,0 +1,361 @@ +import * as React from 'react'; +import { + AccessibilityState, + ColorValue, + GestureResponderEvent, + PressableAndroidRippleConfig, + StyleProp, + StyleSheet, + Text as NativeText, + TextLayoutEvent, + View, + ViewStyle, +} from 'react-native'; + +import Reanimated, { + AnimatedStyle, + useAnimatedStyle, + useSharedValue, + type SharedValue, +} from 'react-native-reanimated'; + +import FabContent from './FabContent'; +import { + FloatingActionButtonSize, + FloatingActionButtonTokens, + FloatingActionButtonVariant, +} from './tokens'; +import { useFabVisibility } from './useFabVisibility'; +import { getDimensions, resolveColors } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import type { ShapeToken } from '../../theme/utils/shape'; +import type { Elevation, ThemeProp } from '../../types'; +import { forwardRef } from '../../utils/forwardRef'; +import type { IconSource } from '../Icon'; +import TouchableRipple from '../TouchableRipple/TouchableRipple'; + +export type FabShellProps = { + /** + * Icon rendered inside the FAB when no custom `children` are provided. + */ + icon?: IconSource; + /** + * Label rendered next to the icon when no custom `children` are provided. + * When present, the FAB grows to fit. + */ + label?: string; + /** + * Role-color preset. Defaults to `tonalPrimary`. + */ + variant?: FloatingActionButtonVariant; + /** + * Spec size. Defaults to `default`. + */ + size?: FloatingActionButtonSize; + /** + * Container color override. Wins over `variant`. + */ + containerColor?: ColorValue; + /** + * Content color override. Wins over `variant`. + */ + contentColor?: ColorValue; + /** + * Shape override. Defaults to the size-driven shape token. + */ + shape?: ShapeToken; + /** + * Icon size override. + */ + iconSize?: number; + /** + * Leading-padding override. + */ + leading?: number; + /** + * 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. + */ + visible?: boolean; + /** + * Function to execute on press. + */ + onPress?: (e: GestureResponderEvent) => void; + /** + * Accessibility label. Falls back to `label` if unset. + */ + accessibilityLabel?: string; + /** + * Accessibility state forwarded to the underlying button. + */ + accessibilityState?: AccessibilityState; + /** + * Largest scale the label font can reach (auto-built content only). + */ + labelMaxFontSizeMultiplier?: number; + /** + * Animated style merged onto the label wrapper. Used by the Extended FAB + * to fade the label in and out as the FAB expands and collapses. + */ + labelAnimatedStyle?: StyleProp>; + /** + * Ref to the visible label node. Used by the Extended FAB to measure + * label width on the web. + */ + labelRef?: React.RefObject<(NativeText & HTMLElement) | null>; + /** + * `onTextLayout` for the visible label. Used on iOS, which reports the + * full (unclipped) label width via this callback. + */ + onLabelTextLayout?: (e: TextLayoutEvent) => void; + /** + * `onTextLayout` for an off-screen full-width copy of the label. Used on + * Android, where the visible label's `onTextLayout` reports only the + * visible glyph run. + */ + offscreenLabelMeasure?: (e: TextLayoutEvent) => void; + /** + * Type of background drawable to display the feedback (Android). + */ + background?: PressableAndroidRippleConfig; + /** + * Shared value driving the outer's animated width. When omitted, the + * outer is sized by its content (icon FAB) or the size token + * (`dimensions.width`). + */ + widthShared?: SharedValue; + /** + * Shared value driving the outer's animated height. When omitted, the + * outer is sized by its content. + */ + heightShared?: SharedValue; + /** + * Shared value driving the outer's animated borderRadius. The same value + * is applied to the inner clip so children are clipped to the same shape. + * When omitted, the static size-driven radius is used. + */ + borderRadiusShared?: SharedValue; + /** + * When `true`, both outer and clip render with `backgroundColor: transparent` + * so the consumer can paint the surface via the `overlay` slot (used by the + * morph trigger's cross-faded color planes). + */ + transparentBackground?: boolean; + /** + * Absolutely-positioned content rendered inside the shell, behind the icon + * and label row. Used by the morphing trigger to cross-fade color planes. + */ + overlay?: React.ReactNode; + /** + * Replaces the default icon + label content. Pass your own `` + * when you need custom typescale, label animation, or measurement. + */ + children?: React.ReactNode; + /** + * Outer-positioning style. Visual treatment (size, shape, color) comes from + * `variant` and `size`. + */ + style?: StyleProp; + /** + * TestID used for testing purposes. + */ + testID?: string; + /** + * @optional + */ + theme?: ThemeProp; + ref?: React.RefObject; +}; + +/** + * Internal shell used by every FAB-flavored component (regular, Extended, + * morphing menu trigger). Owns the outer container, ripple, clip, and the + * visibility animation (scale + alpha + shadow). Consumers that need to + * animate the outer's width/height/borderRadius pass shared values; the + * static size-driven defaults are used otherwise. + * + * Not exported from the package. + */ +const FabShell = forwardRef( + ( + { + icon, + label, + variant = 'tonalPrimary', + size = 'default', + containerColor, + contentColor, + shape, + iconSize, + leading, + trailing, + elevation = FloatingActionButtonTokens.stateElevation.enabled, + visible = true, + onPress, + accessibilityLabel = label, + accessibilityState, + labelMaxFontSizeMultiplier, + labelAnimatedStyle, + labelRef, + onLabelTextLayout, + offscreenLabelMeasure, + background, + widthShared, + heightShared, + borderRadiusShared, + transparentBackground = false, + overlay, + children, + style, + testID = 'fab-shell', + theme: themeOverrides, + }, + ref + ) => { + const theme = useInternalTheme(themeOverrides); + + const dimensions = React.useMemo( + () => getDimensions({ theme, size, shape, iconSize, leading, trailing }), + [theme, size, shape, iconSize, leading, trailing] + ); + + const colors = React.useMemo( + () => resolveColors({ theme, variant, containerColor, contentColor }), + [theme, variant, containerColor, contentColor] + ); + + const { scale, alpha, shadowStyle } = useFabVisibility({ + visible, + theme, + elevation, + }); + + // Fallback shared values track the static size-driven dimensions. Consumers + // that don't supply their own animated shared values get these. Keeping + // everything as a shared value means there's exactly one animated style + // per view — no static-vs-animated merge surprises. + const fallbackWidth = useSharedValue(dimensions.width); + const fallbackHeight = useSharedValue(dimensions.height); + const fallbackBorderRadius = useSharedValue(dimensions.borderRadius); + React.useEffect(() => { + fallbackWidth.value = dimensions.width; + fallbackHeight.value = dimensions.height; + fallbackBorderRadius.value = dimensions.borderRadius; + }, [ + dimensions.width, + dimensions.height, + dimensions.borderRadius, + fallbackWidth, + fallbackHeight, + fallbackBorderRadius, + ]); + + const width = widthShared ?? fallbackWidth; + const height = heightShared ?? fallbackHeight; + const borderRadius = borderRadiusShared ?? fallbackBorderRadius; + const containerBg = transparentBackground + ? 'transparent' + : colors.container; + + const outerStyle = useAnimatedStyle( + () => ({ + transform: [{ scale: scale.value }], + opacity: alpha.value, + width: width.value, + height: height.value, + borderRadius: borderRadius.value, + backgroundColor: containerBg, + }), + [width, height, borderRadius, containerBg] + ); + + const clipStyle = useAnimatedStyle( + () => ({ + borderRadius: borderRadius.value, + backgroundColor: containerBg, + }), + [borderRadius, containerBg] + ); + + return ( + + + {overlay} + + {children ?? ( + + )} + + + + ); + } +); + +const styles = StyleSheet.create({ + container: { + transformOrigin: 'center', + }, + clip: { + width: '100%', + height: '100%', + overflow: 'hidden', + }, + fill: { + flex: 1, + }, + pointerEventsAuto: { + pointerEvents: 'auto', + }, + pointerEventsNone: { + pointerEvents: 'none', + }, +}); + +export default FabShell; diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FloatingActionButton.tsx similarity index 50% rename from src/components/FAB/FAB.tsx rename to src/components/FAB/FloatingActionButton.tsx index fa4de1288c..2e48fefc71 100644 --- a/src/components/FAB/FAB.tsx +++ b/src/components/FAB/FloatingActionButton.tsx @@ -1,16 +1,20 @@ import * as React from 'react'; -import { View } from 'react-native'; -import type { - ColorValue, +import { + AccessibilityState, GestureResponderEvent, PressableAndroidRippleConfig, StyleProp, + View, ViewStyle, } from 'react-native'; -import Shell from './Shell'; -import type { Size, Variant } from './tokens'; +import FabShell from './FabShell'; +import { + FloatingActionButtonSize, + FloatingActionButtonVariant, +} from './tokens'; import type { ThemeProp } from '../../types'; +import { forwardRef } from '../../utils/forwardRef'; import type { IconSource } from '../Icon'; export type Props = { @@ -21,19 +25,11 @@ export type Props = { /** * Role-color preset. Defaults to `tonalPrimary`. */ - variant?: Variant; - /** - * Override the container (background) color. - */ - containerColor?: ColorValue; - /** - * Override the content (icon) color. - */ - contentColor?: ColorValue; + variant?: FloatingActionButtonVariant; /** * Spec size. Defaults to `default`. */ - size?: Size; + size?: FloatingActionButtonSize; /** * Whether the FAB is currently visible. Toggling animates the spec'd enter * and exit (scale + alpha) on the FAB itself. @@ -46,24 +42,11 @@ export type Props = { /** * Accessibility label. Falls back to nothing if unset. */ - 'aria-label'?: string; - /** - * Indicates whether the element is checked. Accepts `true`, `false`, - * or `'mixed'` for an indeterminate state. - */ - 'aria-checked'?: boolean | 'mixed'; - /** - * Indicates whether the element is selected. - */ - 'aria-selected'?: boolean; - /** - * Indicates whether the element is currently busy (e.g. loading). - */ - 'aria-busy'?: boolean; + accessibilityLabel?: string; /** - * Indicates whether the element's controlled content is expanded. + * Accessibility state forwarded to the underlying button. */ - 'aria-expanded'?: boolean; + accessibilityState?: AccessibilityState; /** * Type of background drawable to display the feedback (Android). * https://reactnative.dev/docs/pressable#rippleconfig @@ -82,7 +65,7 @@ export type Props = { * @optional */ theme?: ThemeProp; - ref?: React.Ref; + ref?: React.RefObject; }; /** @@ -92,10 +75,10 @@ export type Props = { * ```js * import * as React from 'react'; * import { StyleSheet } from 'react-native'; - * import { FAB } from 'react-native-paper'; + * import { FloatingActionButton } from 'react-native-paper'; * * const MyComponent = () => ( - * console.log('Pressed')} @@ -114,47 +97,41 @@ export type Props = { * export default MyComponent; * ``` */ -const FAB = ({ - icon, - variant = 'tonalPrimary', - size = 'default', - visible = true, - onPress, - containerColor, - contentColor, - 'aria-label': ariaLabel, - 'aria-checked': ariaChecked, - 'aria-selected': ariaSelected, - 'aria-busy': ariaBusy, - 'aria-expanded': ariaExpanded, - background, - style, - testID = 'floating-action-button', - theme, - ref, -}: Props) => ( - +const FloatingActionButton = forwardRef( + ( + { + icon, + variant = 'tonalPrimary', + size = 'default', + visible = true, + onPress, + accessibilityLabel, + accessibilityState, + background, + style, + testID = 'floating-action-button', + theme, + }, + ref + ) => ( + + ) ); -export default FAB; +export default FloatingActionButton; // @component-docs ignore-next-line -export { FAB }; +export { FloatingActionButton }; diff --git a/src/components/FAB/FloatingActionButtonMenu.tsx b/src/components/FAB/FloatingActionButtonMenu.tsx new file mode 100644 index 0000000000..275531bc4f --- /dev/null +++ b/src/components/FAB/FloatingActionButtonMenu.tsx @@ -0,0 +1,727 @@ +import * as React from 'react'; +import { + ColorValue, + GestureResponderEvent, + StyleSheet, + View, +} from 'react-native'; + +import Animated, { + interpolate, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withDelay, + withSpring, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import FabContent from './FabContent'; +import FabShell from './FabShell'; +import { + FloatingActionButtonMenuTokens, + FloatingActionButtonSize, + FloatingActionButtonTokens, + FloatingActionButtonVariant, +} from './tokens'; +import { resolveColors } from './utils'; +import { useLocale } from '../../core/locale'; +import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; +import { resolveCornerRadius } from '../../theme/utils/shape'; +import type { InternalTheme, ThemeProp } from '../../types'; +import Icon, { IconSource } from '../Icon'; +import TouchableRipple from '../TouchableRipple/TouchableRipple'; + +export type FloatingActionButtonMenuItemProps = { + /** + * Optional icon for the item. + */ + icon?: IconSource; + /** + * Mandatory label. + */ + label: string; + /** + * Called when the item is pressed. The menu is dismissed automatically + * after `onPress` runs. + */ + onPress: (e: GestureResponderEvent) => void; + /** + * Accessibility label. Falls back to `label`. + */ + accessibilityLabel?: string; + testID?: string; +}; + +const FloatingActionButtonMenuItem = ( + _props: FloatingActionButtonMenuItemProps +): React.ReactElement | null => null; +FloatingActionButtonMenuItem.displayName = 'FloatingActionButtonMenu.Item'; + +export type FloatingActionButtonMenuProps = { + /** + * Whether the menu is open. + */ + expanded: boolean; + /** + * Called when the user taps the close button or taps an item. + */ + onDismiss?: () => void; + /** + * Trigger FAB. Pass a ``. The menu reads its + * `variant`, `size`, `icon`, and `onPress` and renders a single morphing + * FAB that animates between the trigger and the spec'd close button. + */ + button: React.ReactElement; + /** + * Horizontal side the menu sits on. Default `'end'`. + */ + horizontalAlignment?: 'start' | 'center' | 'end'; + /** + * Icon used by the close button when the menu is expanded. Default + * `'close'`. + */ + closeIcon?: IconSource; + /** + * Menu items as ``. Spec calls for 2 to 6 + * items; a dev-mode warning fires outside that range. + */ + children: React.ReactNode; + testID?: string; + /** + * @optional + */ + theme?: ThemeProp; +}; + +/** + * Per the M3 FAB Menu spec, the menu picks one of three color sets (primary, + * secondary, tertiary) based on which family the trigger FAB belongs to. + * The close button is always the saturated role color; items are always the + * tonal (container) role color. + */ +const getCloseVariant = ( + triggerVariant: FloatingActionButtonVariant +): FloatingActionButtonVariant => { + if (triggerVariant === 'primary' || triggerVariant === 'tonalPrimary') { + return 'primary'; + } + if (triggerVariant === 'secondary' || triggerVariant === 'tonalSecondary') { + return 'secondary'; + } + return 'tertiary'; +}; + +const getItemsVariant = ( + triggerVariant: FloatingActionButtonVariant +): FloatingActionButtonVariant => { + if (triggerVariant === 'primary' || triggerVariant === 'tonalPrimary') { + return 'tonalPrimary'; + } + if (triggerVariant === 'secondary' || triggerVariant === 'tonalSecondary') { + return 'tonalSecondary'; + } + return 'tonalTertiary'; +}; + +type ButtonExtractableProps = { + variant?: FloatingActionButtonVariant; + size?: FloatingActionButtonSize; + icon?: IconSource; + containerColor?: ColorValue; + contentColor?: ColorValue; + visible?: boolean; + onPress?: (e: GestureResponderEvent) => void; + accessibilityLabel?: string; + testID?: string; +}; + +// Per-item delay used by the stagger. Compose uses a single SlowEffects-driven +// integer count that crosses each item's threshold; we approximate with a +// fixed delay per index. +const STAGGER_MS = 30; + +type AnimatedItemProps = { + expanded: boolean; + index: number; + itemCount: number; + theme: InternalTheme; + transformOrigin: 'left' | 'center' | 'right'; + marginBottom: number; + children: React.ReactNode; +}; + +const AnimatedItem = ({ + expanded, + index, + itemCount, + theme, + transformOrigin, + marginBottom, + children, +}: AnimatedItemProps) => { + const reduceMotion = useReduceMotion(); + // Initial values match the resting state for the current `expanded` prop so + // first mount doesn't animate unexpectedly. + const scaleX = useSharedValue(expanded ? 1 : 0); + const alpha = useSharedValue(expanded ? 1 : 0); + + React.useEffect(() => { + const target = expanded ? 1 : 0; + // Bottom-up on open, top-down on close (matches Compose). + const delay = expanded + ? (itemCount - 1 - index) * STAGGER_MS + : index * STAGGER_MS; + + if (reduceMotion) { + scaleX.value = target; + alpha.value = target; + return; + } + scaleX.value = withDelay( + delay, + withSpring(target, toRawSpring(theme.motion.spring.fast.spatial)) + ); + alpha.value = withDelay( + delay, + withSpring(target, toRawSpring(theme.motion.spring.fast.effects)) + ); + }, [expanded, index, itemCount, theme, reduceMotion, scaleX, alpha]); + + // Only scaleX and opacity animate. Layout height stays at the item's + // natural size — the items container is absolutely positioned above the + // trigger, so this fixed height never affects the trigger's position. + const animStyle = useAnimatedStyle(() => ({ + transform: [{ scaleX: scaleX.value }], + opacity: alpha.value, + })); + + return ( + + {children} + + ); +}; + +type MenuItemProps = { + icon?: IconSource; + label: string; + variant: FloatingActionButtonVariant; + theme: InternalTheme; + onPress: (e: GestureResponderEvent) => void; + accessibilityLabel?: string; + testID?: string; +}; + +/** + * A single FAB Menu item. Visually a tonal pill with an icon and a label, + * but it is not a floating action button: no shadow, no enter/exit scaling + * of its own (the surrounding `AnimatedItem` handles entrance), and its + * shape and dimensions come from the menu spec rather than FAB tokens. + */ +const MenuItem = ({ + icon, + label, + variant, + theme, + onPress, + accessibilityLabel, + testID, +}: MenuItemProps) => { + const colors = resolveColors({ theme, variant }); + const { height, iconSize, leading, trailing, iconLabelGap, shape } = + FloatingActionButtonMenuTokens.listItem; + const borderRadius = resolveCornerRadius(theme, shape); + return ( + + + + + + ); +}; + +type MorphingTriggerProps = { + triggerVariant: FloatingActionButtonVariant; + closeVariant: FloatingActionButtonVariant; + triggerContainerColor?: ColorValue; + triggerContentColor?: ColorValue; + size: FloatingActionButtonSize; + openIcon: IconSource; + closeIcon: IconSource; + expanded: boolean; + /** Whether the trigger FAB is visible; drives the scale/alpha enter/exit. */ + visible: boolean; + horizontalAlignment: 'start' | 'center' | 'end'; + onPress?: (e: GestureResponderEvent) => void; + accessibilityLabel?: string; + theme: InternalTheme; + testID?: string; +}; + +const MorphingTrigger = ({ + triggerVariant, + closeVariant, + triggerContainerColor, + triggerContentColor, + size, + openIcon, + closeIcon, + expanded, + visible, + horizontalAlignment, + onPress, + accessibilityLabel, + theme, + testID, +}: MorphingTriggerProps) => { + const reduceMotion = useReduceMotion(); + + const closedSpec = FloatingActionButtonTokens.sizes[size]; + const closedContainer = closedSpec.container; + const closedIconSize = closedSpec.icon; + const closedBorderRadius = resolveCornerRadius(theme, closedSpec.shape); + + const openContainer = FloatingActionButtonMenuTokens.closeButton.container; + const openIconSize = FloatingActionButtonMenuTokens.closeButton.iconSize; + // Use container/2 (instead of the cornerFull sentinel) as the open radius, + // so the interpolation produces a smooth round-corner morph rather than + // jumping past the visual "circle" threshold almost immediately. + const openBorderRadius = openContainer / 2; + + // Trigger color set (respects user overrides) and close color set (always + // the saturated role color per spec — no overrides). + const triggerColors = resolveColors({ + theme, + variant: triggerVariant, + containerColor: triggerContainerColor, + contentColor: triggerContentColor, + }); + const closeColors = resolveColors({ theme, variant: closeVariant }); + + const progress = useSharedValue(expanded ? 1 : 0); + + React.useEffect(() => { + if (reduceMotion) { + progress.value = expanded ? 1 : 0; + return; + } + // Compose's ToggleFloatingActionButton uses a single FastSpatial spring + // for the full open/close progress (size, corner, color, icon all share + // one timeline). + progress.value = withSpring( + expanded ? 1 : 0, + toRawSpring(theme.motion.spring.fast.spatial) + ); + }, [expanded, theme, reduceMotion, progress]); + + // Derived shared values for the morph shape. Passing them to FabShell as + // individual shared values (rather than packing them into an animated + // style) means FabShell can put each into a single `useAnimatedStyle` with + // no inter-style merge surprises. Explicit deps so toggling `size` while + // the menu is open re-derives immediately — e.g. closed-state values + // change to match the new size's resting shape, and the close-state values + // (always 56 / 28) keep the open shape circular. + const widthShared = useDerivedValue( + () => interpolate(progress.value, [0, 1], [closedContainer, openContainer]), + [closedContainer, openContainer] + ); + const heightShared = useDerivedValue( + () => interpolate(progress.value, [0, 1], [closedContainer, openContainer]), + [closedContainer, openContainer] + ); + const borderRadiusShared = useDerivedValue( + () => + interpolate( + progress.value, + [0, 1], + [closedBorderRadius, openBorderRadius] + ), + [closedBorderRadius, openBorderRadius] + ); + + const openPlaneStyle = useAnimatedStyle(() => ({ + opacity: 1 - progress.value, + })); + const closePlaneStyle = useAnimatedStyle(() => ({ + opacity: progress.value, + })); + + // Outer slot is fixed at the trigger's resting size; the FAB itself + // shrinks toward the top-{start|center|end} corner of that slot when + // expanded (only meaningful for medium / large sizes). + const slotAlign: 'flex-start' | 'center' | 'flex-end' = + horizontalAlignment === 'start' + ? 'flex-start' + : horizontalAlignment === 'center' + ? 'center' + : 'flex-end'; + + return ( + + + + + + } + theme={theme} + > + + + + + + + + + + + ); +}; + +/** + * Floating action button menu. Wraps a trigger FAB; when `expanded` is true, + * items appear stacked above and the trigger morphs into the spec'd close + * button (`shape: 'full'`, 56 dp, saturated role color). + * + * No visual backdrop and no outside-tap dismiss — that matches the MD3 spec + * and lets the user keep interacting with the content underneath. Dismiss + * via the close button or by tapping an item. + * + * ## Usage + * ```tsx + * const [open, setOpen] = React.useState(false); + * + * + * setOpen(false)} + * button={ + * setOpen(true)} + * /> + * } + * > + * {}} + * /> + * {}} + * /> + * + * + * ``` + */ +const FloatingActionButtonMenu = ({ + expanded, + onDismiss, + button, + horizontalAlignment = 'end', + closeIcon = 'close', + children, + testID = 'floating-action-button-menu', + theme: themeOverrides, +}: FloatingActionButtonMenuProps) => { + const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); + const isRTL = direction === 'rtl'; + const insets = useSafeAreaInsets(); + + const items = React.Children.toArray(children) + .filter( + (child): child is React.ReactElement => + React.isValidElement(child) && + child.type === FloatingActionButtonMenuItem + ) + .map((child) => child.props); + + if ( + process.env.NODE_ENV !== 'production' && + (items.length < 2 || items.length > 6) + ) { + console.warn( + `FloatingActionButtonMenu expects 2 to 6 items; received ${items.length}.` + ); + } + + const buttonProps: ButtonExtractableProps = React.isValidElement(button) + ? (button.props as ButtonExtractableProps) + : {}; + const triggerVariant: FloatingActionButtonVariant = + buttonProps.variant ?? 'tonalPrimary'; + const size: FloatingActionButtonSize = buttonProps.size ?? 'default'; + const openIcon: IconSource = buttonProps.icon ?? 'plus'; + const openOnPress = buttonProps.onPress; + const triggerVisible = buttonProps.visible ?? true; + const closeVariant = getCloseVariant(triggerVariant); + const itemsVariant = getItemsVariant(triggerVariant); + + // When the trigger isn't visible, items don't either; they share the + // FAB's enter/exit. + const effectiveExpanded = triggerVisible && expanded; + + const handleItemPress = + (item: FloatingActionButtonMenuItemProps) => (e: GestureResponderEvent) => { + item.onPress(e); + onDismiss?.(); + }; + + const alignment: 'flex-start' | 'center' | 'flex-end' = + horizontalAlignment === 'start' + ? 'flex-start' + : horizontalAlignment === 'center' + ? 'center' + : 'flex-end'; + // Per-item motion is purely horizontal (matches Compose's width animation); + // the bottom-up feel comes from the stagger order, not the scale anchor. + // RN auto-mirrors `left`/`right` position styles in RTL, so the items + // container visually moves to the opposite edge — but `transformOrigin` is + // a transform property and is NOT auto-flipped. Invert the mapping in RTL + // so each item still scales out from the screen-edge side rather than the + // screen-center side. + const itemTransformOrigin: 'left' | 'center' | 'right' = + horizontalAlignment === 'start' + ? isRTL + ? 'right' + : 'left' + : horizontalAlignment === 'center' + ? 'center' + : isRTL + ? 'left' + : 'right'; + // The trigger's slot is fixed at the original FAB's size. The close button + // (always 56 dp) anchors to the top of this slot when expanded. + const triggerSlotSize = FloatingActionButtonTokens.sizes[size].container; + + return ( + + + {/* Absolutely positioned above the trigger so item layout (and the + scaleX bounce on each item) never affects the trigger's position + — no vertical spring. The items container sits closeToLastItem + above the original FAB slot. */} + + {items.map((item, index) => { + const isLast = index === items.length - 1; + return ( + + + + ); + })} + + + + + ); +}; + +FloatingActionButtonMenu.Item = FloatingActionButtonMenuItem; +FloatingActionButtonMenu.displayName = 'FloatingActionButtonMenu'; + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFill, + justifyContent: 'flex-end', + pointerEvents: 'box-none', + }, + stack: { + flexDirection: 'column', + pointerEvents: 'box-none', + }, + items: { + position: 'absolute', + flexDirection: 'column', + pointerEvents: 'box-none', + }, + itemsStart: { + left: 0, + }, + itemsCenter: { + left: 0, + right: 0, + }, + itemsEnd: { + right: 0, + }, + menuItem: { + overflow: 'hidden', + }, + triggerSlot: { + justifyContent: 'flex-start', + }, + colorPlane: { + ...StyleSheet.absoluteFill, + pointerEvents: 'none', + }, + iconStackContainer: { + flex: 1, + pointerEvents: 'none', + }, + iconStack: { + ...StyleSheet.absoluteFill, + alignItems: 'center', + justifyContent: 'center', + pointerEvents: 'none', + }, + pointerEventsAuto: { + pointerEvents: 'auto', + }, + pointerEventsNone: { + pointerEvents: 'none', + }, + pointerEventsBoxNone: { + pointerEvents: 'box-none', + }, +}); + +export default FloatingActionButtonMenu; + +// @component-docs ignore-next-line +export { FloatingActionButtonMenu }; diff --git a/src/components/FAB/tokens.ts b/src/components/FAB/tokens.ts index 73ef1e9ff8..2057e32bb3 100644 --- a/src/components/FAB/tokens.ts +++ b/src/components/FAB/tokens.ts @@ -1,6 +1,3 @@ -import type { ViewStyle } from 'react-native'; - -import { tokens } from '../../theme/tokens'; import type { ColorRole, Elevation, @@ -8,7 +5,7 @@ import type { TypescaleKey, } from '../../theme/types'; -export type Variant = +export type FloatingActionButtonVariant = | 'primary' | 'secondary' | 'tertiary' @@ -16,7 +13,7 @@ export type Variant = | 'tonalSecondary' | 'tonalTertiary'; -export type Size = 'default' | 'medium' | 'large'; +export type FloatingActionButtonSize = 'default' | 'medium' | 'large'; type SizeSpec = { container: number; @@ -58,7 +55,7 @@ const sizes = { iconLabelGap: 16, labelTypescale: 'headlineSmall', }, -} as const satisfies Record; +} as const satisfies Record; const stateElevation = { enabled: 3, @@ -84,11 +81,11 @@ const variants = { content: 'onTertiaryContainer', }, } as const satisfies Record< - Variant, + FloatingActionButtonVariant, { container: ColorRole; content: ColorRole } >; -export const Tokens = { +export const FloatingActionButtonTokens = { sizes, stateElevation, variants, @@ -114,15 +111,8 @@ const spacing = { closeToLastItem: 8, } as const; -export const MenuTokens = { +export const FloatingActionButtonMenuTokens = { closeButton, listItem, spacing, }; - -const focusIndicator = tokens.md.sys.state.focusIndicator; -export const FOCUS_RING_THICKNESS = focusIndicator.thickness; -export const FOCUS_RING_OUTER_OFFSET = focusIndicator.outerOffset; -export const FOCUS_RING_INSET = FOCUS_RING_OUTER_OFFSET + FOCUS_RING_THICKNESS; - -export const webNoOutline = { outline: 'none' } as unknown as ViewStyle; diff --git a/src/components/FAB/useFabVisibility.ts b/src/components/FAB/useFabVisibility.ts new file mode 100644 index 0000000000..a3cb6f89ce --- /dev/null +++ b/src/components/FAB/useFabVisibility.ts @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { Platform, type ViewStyle } from 'react-native'; + +import { + useAnimatedStyle, + useSharedValue, + withSpring, + type AnimatedStyle, + type SharedValue, +} from 'react-native-reanimated'; + +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { + androidElevationLevels, + shadowLayers, +} from '../../theme/tokens/sys/elevation'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; +import type { Elevation, InternalTheme } from '../../types'; + +type UseFabVisibilityArgs = { + visible: boolean; + theme: InternalTheme; + initialScale?: number; + transformOrigin?: ViewStyle['transformOrigin']; + /** + * Elevation level when shown. Shadow fades in/out with the FAB. + */ + elevation?: Elevation; +}; + +type UseFabVisibilityResult = { + scale: SharedValue; + alpha: SharedValue; + transformOrigin: ViewStyle['transformOrigin']; + shadowStyle: AnimatedStyle; +}; + +const isAndroid = Platform.OS === 'android'; + +/** + * Animates a FAB in and out: scale + alpha together. + * Reduce-motion: snap to the final value, no animation. + * + * Returns `shadowStyle` too. Put it on the same view as the transform so the + * shadow stays in sync (Android uses `elevation`, iOS/web uses `shadow*`). + */ +export function useFabVisibility({ + visible, + theme, + initialScale = 0, + transformOrigin = 'center', + elevation = 0, +}: UseFabVisibilityArgs): UseFabVisibilityResult { + const reduceMotion = useReduceMotion(); + const scale = useSharedValue(visible ? 1 : initialScale); + const alpha = useSharedValue(visible ? 1 : 0); + + React.useEffect(() => { + const targetScale = visible ? 1 : initialScale; + const targetAlpha = visible ? 1 : 0; + if (reduceMotion) { + scale.value = targetScale; + alpha.value = targetAlpha; + return; + } + scale.value = withSpring( + targetScale, + toRawSpring(theme.motion.spring.fast.spatial) + ); + alpha.value = withSpring( + targetAlpha, + toRawSpring(theme.motion.spring.fast.effects) + ); + }, [visible, theme, reduceMotion, scale, alpha, initialScale]); + + const restingElevationDp = androidElevationLevels[elevation]; + const restingShadowOpacity = elevation ? shadowLayers[0].shadowOpacity : 0; + const shadowOffsetHeight = shadowLayers[0].height[elevation]; + const shadowRadius = shadowLayers[0].shadowRadius[elevation]; + const shadowColor = theme.colors.shadow; + + const shadowStyle = useAnimatedStyle(() => { + if (isAndroid) { + return { elevation: alpha.value * restingElevationDp }; + } + return { + shadowColor, + shadowOpacity: alpha.value * restingShadowOpacity, + shadowOffset: { width: 0, height: shadowOffsetHeight }, + shadowRadius, + }; + }); + + return { scale, alpha, transformOrigin, shadowStyle }; +} diff --git a/src/components/FAB/utils.ts b/src/components/FAB/utils.ts index 75b7ddb940..3cee69f370 100644 --- a/src/components/FAB/utils.ts +++ b/src/components/FAB/utils.ts @@ -1,11 +1,13 @@ -import type { ColorValue } from 'react-native'; +import { RefObject } from 'react'; +import { ColorValue, Platform } from 'react-native'; -import { Tokens } from './tokens'; -import type { Size, Variant } from './tokens'; +import { + FloatingActionButtonSize, + FloatingActionButtonTokens, + FloatingActionButtonVariant, +} from './tokens'; import type { TypescaleKey } from '../../theme/types'; -import { contentColorFor } from '../../theme/utils/color'; -import { resolveCornerRadius } from '../../theme/utils/shape'; -import type { ShapeToken } from '../../theme/utils/shape'; +import { resolveCornerRadius, ShapeToken } from '../../theme/utils/shape'; import type { InternalTheme } from '../../types'; export type ResolvedColors = { @@ -21,21 +23,15 @@ export type ResolvedColors = { export const resolveColors = ({ theme, variant = 'tonalPrimary', - containerColor, - contentColor, }: { theme: InternalTheme; - variant?: Variant; + variant?: FloatingActionButtonVariant; containerColor?: ColorValue; contentColor?: ColorValue; }): ResolvedColors => { - const roles = Tokens.variants[variant]; - const container = containerColor ?? theme.colors[roles.container]; - const content = - contentColor ?? - (containerColor != null - ? contentColorFor(theme, container) - : theme.colors[roles.content]); + const roles = FloatingActionButtonTokens.variants[variant]; + const container = theme.colors[roles.container]; + const content = theme.colors[roles.content]; return { container, content }; }; @@ -64,13 +60,13 @@ export const getDimensions = ({ trailing, }: { theme: InternalTheme; - size?: Size; + size?: FloatingActionButtonSize; shape?: ShapeToken; iconSize?: number; leading?: number; trailing?: number; }): Dimensions => { - const spec = Tokens.sizes[size]; + const spec = FloatingActionButtonTokens.sizes[size]; const shapeToken: ShapeToken = shape ?? spec.shape; return { height: spec.container, @@ -83,3 +79,40 @@ export const getDimensions = ({ labelTypescale: spec.labelTypescale, }; }; + +export const getLabelSizeWeb = (ref: RefObject) => { + if (Platform.OS !== 'web' || ref.current === null) { + return null; + } + + const canvasContext = getCanvasContext(); + + if (!canvasContext) { + return null; + } + + const elementStyles = window.getComputedStyle(ref.current); + canvasContext.font = elementStyles.font; + + const metrics = canvasContext.measureText(ref.current.innerText); + + return { + width: metrics.width, + height: + (metrics.fontBoundingBoxAscent ?? 0) + + (metrics.fontBoundingBoxDescent ?? 0), + }; +}; + +let cachedContext: CanvasRenderingContext2D | null = null; + +const getCanvasContext = () => { + if (cachedContext) { + return cachedContext; + } + + const canvas = document.createElement('canvas'); + cachedContext = canvas.getContext('2d'); + + return cachedContext; +}; diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx deleted file mode 100644 index eedcb3e0f3..0000000000 --- a/src/components/__tests__/FAB.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { expect, it, jest } from '@jest/globals'; -import { fireEvent, userEvent } from '@testing-library/react-native'; - -import { render, screen } from '../../test-utils'; -import FAB from '../FAB'; - -it('renders FAB with default props', async () => { - const tree = (await render()).toJSON(); - expect(tree).toMatchSnapshot(); -}); - -it('renders FAB with primary variant', async () => { - const tree = (await render()).toJSON(); - expect(tree).toMatchSnapshot(); -}); - -it('renders FAB with secondary variant', async () => { - const tree = (await render()).toJSON(); - expect(tree).toMatchSnapshot(); -}); - -it('renders FAB with tertiary variant', async () => { - const tree = (await render()).toJSON(); - expect(tree).toMatchSnapshot(); -}); - -it('renders FAB with tonalSecondary variant', async () => { - const tree = ( - await render() - ).toJSON(); - expect(tree).toMatchSnapshot(); -}); - -it('renders FAB with tonalTertiary variant', async () => { - const tree = ( - await render() - ).toJSON(); - expect(tree).toMatchSnapshot(); -}); - -it('renders FAB with aria-label', async () => { - const tree = ( - await render() - ).toJSON(); - expect(tree).toMatchSnapshot(); -}); - -it('renders FAB medium size', async () => { - const tree = (await render()).toJSON(); - expect(tree).toMatchSnapshot(); -}); - -it('renders FAB large size', async () => { - const tree = (await render()).toJSON(); - expect(tree).toMatchSnapshot(); -}); - -it('renders FAB with containerColor override', async () => { - const tree = ( - await render() - ).toJSON(); - expect(tree).toMatchSnapshot(); -}); - -it('renders FAB with containerColor and contentColor overrides', async () => { - const tree = ( - await render( - - ) - ).toJSON(); - expect(tree).toMatchSnapshot(); -}); - -it('renders FAB transitioning to not visible', async () => { - const { rerender, toJSON } = await render(); - await rerender(); - expect(toJSON()).toMatchSnapshot(); -}); - -it('renders FAB transitioning to visible', async () => { - const { rerender, toJSON } = await render( - - ); - await rerender(); - expect(toJSON()).toMatchSnapshot(); -}); - -it('calls onPress when FAB is pressed', async () => { - const user = userEvent.setup(); - const onPress = jest.fn(); - await render(); - await user.press(screen.getByRole('button', { name: 'Add item' })); - expect(onPress).toHaveBeenCalledTimes(1); -}); - -it('forwards event object to onPress', async () => { - const onPress = jest.fn(); - await render(); - await fireEvent(screen.getByRole('button', { name: 'Add item' }), 'onPress', { - key: 'value', - }); - expect(onPress).toHaveBeenCalledWith({ key: 'value' }); -}); diff --git a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap deleted file mode 100644 index 348a6bba64..0000000000 --- a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap +++ /dev/null @@ -1,2238 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders FAB large size 1`] = ` - - - - - - plus - - - - - - -`; - -exports[`renders FAB medium size 1`] = ` - - - - - - plus - - - - - - -`; - -exports[`renders FAB transitioning to not visible 1`] = ` - - - - - - plus - - - - - - -`; - -exports[`renders FAB transitioning to visible 1`] = ` - - - - - - plus - - - - - - -`; - -exports[`renders FAB with aria-label 1`] = ` - - - - - - plus - - - - - - -`; - -exports[`renders FAB with containerColor and contentColor overrides 1`] = ` - - - - - - plus - - - - - - -`; - -exports[`renders FAB with containerColor override 1`] = ` - - - - - - plus - - - - - - -`; - -exports[`renders FAB with default props 1`] = ` - - - - - - plus - - - - - - -`; - -exports[`renders FAB with primary variant 1`] = ` - - - - - - plus - - - - - - -`; - -exports[`renders FAB with secondary variant 1`] = ` - - - - - - plus - - - - - - -`; - -exports[`renders FAB with tertiary variant 1`] = ` - - - - - - plus - - - - - - -`; - -exports[`renders FAB with tonalSecondary variant 1`] = ` - - - - - - plus - - - - - - -`; - -exports[`renders FAB with tonalTertiary variant 1`] = ` - - - - - - plus - - - - - - -`; diff --git a/src/index.tsx b/src/index.tsx index 0f783a0d47..6c96503c9a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -32,7 +32,9 @@ export { default as Chip } from './components/Chip/Chip'; export { default as DataTable } from './components/DataTable/DataTable'; export { default as Dialog } from './components/Dialog/Dialog'; export { default as Divider } from './components/Divider'; -export { default as FAB } from './components/FAB'; +export { default as FloatingActionButton } from './components/FAB/FloatingActionButton'; +export { default as ExtendedFloatingActionButton } from './components/FAB/ExtendedFloatingActionButton'; +export { default as FloatingActionButtonMenu } from './components/FAB/FloatingActionButtonMenu'; export { default as Icon } from './components/Icon'; export { default as IconButton } from './components/IconButton/IconButton'; export { default as Menu } from './components/Menu/Menu'; @@ -94,16 +96,15 @@ export type { Props as DividerProps } from './components/Divider'; export type { Props as DrawerCollapsedItemProps } from './components/Drawer/DrawerCollapsedItem'; export type { Props as DrawerItemProps } from './components/Drawer/DrawerItem'; export type { Props as DrawerSectionProps } from './components/Drawer/DrawerSection'; -export type { Props as FABProps } from './components/FAB/FAB'; -export type { Props as FABExtendedProps } from './components/FAB/Extended'; +export type { Props as FloatingActionButtonProps } from './components/FAB/FloatingActionButton'; +export type { Props as ExtendedFloatingActionButtonProps } from './components/FAB/ExtendedFloatingActionButton'; export type { - MenuProps as FABMenuProps, - MenuItemProps as FABMenuItemProps, - MenuTriggerProps as FABMenuTriggerProps, -} from './components/FAB/Menu'; + FloatingActionButtonMenuProps, + FloatingActionButtonMenuItemProps, +} from './components/FAB/FloatingActionButtonMenu'; export type { - Variant as FABVariant, - Size as FABSize, + FloatingActionButtonVariant, + FloatingActionButtonSize, } from './components/FAB/tokens'; export type { Props as IconButtonProps } from './components/IconButton/IconButton'; export type { Props as ListAccordionProps } from './components/List/ListAccordion'; diff --git a/tsconfig.json b/tsconfig.json index 8178b5189f..a89fe5c252 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,8 +28,7 @@ "skipLibCheck": true, "strict": true, "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true + "types": ["jest", "node", "@testing-library/jest-native"] }, "files": [], "references": [ From 28ab4b16a0574e5666ccf3bfaf70a02a7ff5e065 Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Wed, 27 May 2026 16:00:01 +0300 Subject: [PATCH 07/29] feat(fab): add focus ring --- src/components/FAB/FabShell.tsx | 37 +++++++- .../FAB/FloatingActionButtonMenu.tsx | 88 ++++++++++++++----- src/components/FAB/tokens.ts | 10 +++ 3 files changed, 110 insertions(+), 25 deletions(-) diff --git a/src/components/FAB/FabShell.tsx b/src/components/FAB/FabShell.tsx index 05af367ba6..9644666619 100644 --- a/src/components/FAB/FabShell.tsx +++ b/src/components/FAB/FabShell.tsx @@ -3,6 +3,7 @@ import { AccessibilityState, ColorValue, GestureResponderEvent, + Platform, PressableAndroidRippleConfig, StyleProp, StyleSheet, @@ -24,8 +25,12 @@ import { FloatingActionButtonSize, FloatingActionButtonTokens, FloatingActionButtonVariant, + FOCUS_RING_INSET, + FOCUS_RING_THICKNESS, + webNoOutline, } from './tokens'; import { useFabVisibility } from './useFabVisibility'; +import { useFocusRing } from './useFocusRing'; import { getDimensions, resolveColors } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ShapeToken } from '../../theme/utils/shape'; @@ -286,6 +291,15 @@ const FabShell = forwardRef( [borderRadius, containerBg] ); + const { focusedSV, onFocus, onBlur } = useFocusRing(); + const focusRingStyle = useAnimatedStyle( + () => ({ + opacity: focusedSV.value ? 1 : 0, + borderRadius: borderRadius.value + FOCUS_RING_INSET, + }), + [borderRadius] + ); + return ( ( borderless background={background} onPress={onPress} + onFocus={onFocus} + onBlur={onBlur} accessibilityLabel={accessibilityLabel} accessibilityRole="button" accessibilityState={accessibilityState} testID={testID} - style={children ? styles.fill : null} + style={[ + children ? styles.fill : null, + Platform.OS === 'web' ? webNoOutline : null, + ]} > {children ?? ( ( )} + ); } @@ -356,6 +382,15 @@ const styles = StyleSheet.create({ pointerEventsNone: { pointerEvents: 'none', }, + focusRing: { + position: 'absolute', + top: -FOCUS_RING_INSET, + left: -FOCUS_RING_INSET, + right: -FOCUS_RING_INSET, + bottom: -FOCUS_RING_INSET, + borderWidth: FOCUS_RING_THICKNESS, + pointerEvents: 'none', + }, }); export default FabShell; diff --git a/src/components/FAB/FloatingActionButtonMenu.tsx b/src/components/FAB/FloatingActionButtonMenu.tsx index 275531bc4f..4d46557bea 100644 --- a/src/components/FAB/FloatingActionButtonMenu.tsx +++ b/src/components/FAB/FloatingActionButtonMenu.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { ColorValue, GestureResponderEvent, + Platform, StyleSheet, View, } from 'react-native'; @@ -23,7 +24,11 @@ import { FloatingActionButtonSize, FloatingActionButtonTokens, FloatingActionButtonVariant, + FOCUS_RING_INSET, + FOCUS_RING_THICKNESS, + webNoOutline, } from './tokens'; +import { useFocusRing } from './useFocusRing'; import { resolveColors } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; @@ -242,33 +247,56 @@ const MenuItem = ({ const { height, iconSize, leading, trailing, iconLabelGap, shape } = FloatingActionButtonMenuTokens.listItem; const borderRadius = resolveCornerRadius(theme, shape); + + const { focusedSV, onFocus, onBlur } = useFocusRing(); + const focusRingStyle = useAnimatedStyle(() => ({ + opacity: focusedSV.value ? 1 : 0, + })); + return ( - - + - - + > + + + + ); }; @@ -690,9 +718,21 @@ const styles = StyleSheet.create({ itemsEnd: { right: 0, }, + menuItemWrapper: { + position: 'relative', + }, menuItem: { overflow: 'hidden', }, + menuItemFocusRing: { + position: 'absolute', + top: -FOCUS_RING_INSET, + left: -FOCUS_RING_INSET, + right: -FOCUS_RING_INSET, + bottom: -FOCUS_RING_INSET, + borderWidth: FOCUS_RING_THICKNESS, + pointerEvents: 'none', + }, triggerSlot: { justifyContent: 'flex-start', }, diff --git a/src/components/FAB/tokens.ts b/src/components/FAB/tokens.ts index 2057e32bb3..0327eadc65 100644 --- a/src/components/FAB/tokens.ts +++ b/src/components/FAB/tokens.ts @@ -1,3 +1,6 @@ +import type { ViewStyle } from 'react-native'; + +import { tokens } from '../../theme/tokens'; import type { ColorRole, Elevation, @@ -116,3 +119,10 @@ export const FloatingActionButtonMenuTokens = { listItem, spacing, }; + +const focusIndicator = tokens.md.sys.state.focusIndicator; +export const FOCUS_RING_THICKNESS = focusIndicator.thickness; +export const FOCUS_RING_OUTER_OFFSET = focusIndicator.outerOffset; +export const FOCUS_RING_INSET = FOCUS_RING_OUTER_OFFSET + FOCUS_RING_THICKNESS; + +export const webNoOutline = { outline: 'none' } as unknown as ViewStyle; From 880201cda84290a0e01bb7f3f1e17b108cb45481 Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Wed, 3 Jun 2026 17:01:16 +0300 Subject: [PATCH 08/29] fix: review findings --- docs/src/components/BannerExample.tsx | 8 +- .../src/Examples/ActivityIndicatorExample.tsx | 9 +- example/src/Examples/AppbarExample.tsx | 5 +- example/src/Examples/BannerExample.tsx | 13 +- example/src/Examples/FABExample.tsx | 78 +- example/src/Examples/TeamDetails.tsx | 9 +- example/src/Examples/TooltipExample.tsx | 4 +- .../__fixtures__/rewrite-imports/code.js | 2 +- .../__fixtures__/rewrite-imports/output.js | 2 +- src/components/FAB/Content.tsx | 16 +- src/components/FAB/Extended.tsx | 270 +- .../FAB/ExtendedFloatingActionButton.tsx | 273 -- .../FAB/{FloatingActionButton.tsx => FAB.tsx} | 36 +- src/components/FAB/FabContent.tsx | 160 -- src/components/FAB/FabShell.tsx | 396 --- .../FAB/FloatingActionButtonMenu.tsx | 767 ------ src/components/FAB/Menu.tsx | 68 +- src/components/FAB/Shell.tsx | 346 ++- src/components/FAB/tokens.ts | 12 +- src/components/FAB/useFabVisibility.ts | 95 - src/components/FAB/utils.ts | 65 +- src/components/__tests__/FAB.test.tsx | 95 + src/components/__tests__/FABExtended.test.tsx | 92 +- src/components/__tests__/FABMenu.test.tsx | 195 +- src/components/__tests__/FABUtils.test.tsx | 2 - .../__tests__/__snapshots__/FAB.test.tsx.snap | 2238 +++++++++++++++++ .../__snapshots__/FABExtended.test.tsx.snap | 72 +- .../__snapshots__/FABMenu.test.tsx.snap | 86 +- src/index.tsx | 19 +- 29 files changed, 2988 insertions(+), 2445 deletions(-) delete mode 100644 src/components/FAB/ExtendedFloatingActionButton.tsx rename src/components/FAB/{FloatingActionButton.tsx => FAB.tsx} (82%) delete mode 100644 src/components/FAB/FabContent.tsx delete mode 100644 src/components/FAB/FabShell.tsx delete mode 100644 src/components/FAB/FloatingActionButtonMenu.tsx delete mode 100644 src/components/FAB/useFabVisibility.ts create mode 100644 src/components/__tests__/FAB.test.tsx create mode 100644 src/components/__tests__/__snapshots__/FAB.test.tsx.snap diff --git a/docs/src/components/BannerExample.tsx b/docs/src/components/BannerExample.tsx index 82e602ffb9..0dd275062a 100644 --- a/docs/src/components/BannerExample.tsx +++ b/docs/src/components/BannerExample.tsx @@ -6,7 +6,7 @@ import { BrowserOnly } from '@rspress/core/runtime'; import { Avatar, Button, - FloatingActionButton, + FAB, DarkTheme, FAB, LightTheme, @@ -104,9 +104,9 @@ const BannerExample = () => { - {}} /> - {}} /> - {}} /> + {}} /> + {}} /> + {}} /> diff --git a/example/src/Examples/ActivityIndicatorExample.tsx b/example/src/Examples/ActivityIndicatorExample.tsx index 193d9fdffc..e5fc90f032 100644 --- a/example/src/Examples/ActivityIndicatorExample.tsx +++ b/example/src/Examples/ActivityIndicatorExample.tsx @@ -1,12 +1,7 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; -import { - ActivityIndicator, - FloatingActionButton, - List, - Palette, -} from 'react-native-paper'; +import { ActivityIndicator, FAB, List, Palette } from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; @@ -16,7 +11,7 @@ const ActivityIndicatorExample = () => { return ( - setAnimating(!animating)} /> diff --git a/example/src/Examples/AppbarExample.tsx b/example/src/Examples/AppbarExample.tsx index c3d7622186..bdb47ad641 100644 --- a/example/src/Examples/AppbarExample.tsx +++ b/example/src/Examples/AppbarExample.tsx @@ -4,7 +4,7 @@ import { Platform, StyleSheet, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { Appbar, - FloatingActionButton, + FAB, List, Palette, RadioButton, @@ -23,7 +23,6 @@ const MORE_ICON = Platform.OS === 'ios' ? 'dots-horizontal' : 'dots-vertical'; const MEDIUM_FAB_HEIGHT = 56; const AppbarExample = () => { - // @ts-ignore const navigation = useNavigation('Appbar'); const [showLeftIcon, setShowLeftIcon] = React.useState(true); @@ -83,7 +82,7 @@ const AppbarExample = () => { const renderFAB = () => { return ( - {}} style={[styles.fab, { top: (height - MEDIUM_FAB_HEIGHT) / 2 }]} diff --git a/example/src/Examples/BannerExample.tsx b/example/src/Examples/BannerExample.tsx index c8b779f91b..b3a0d8a002 100644 --- a/example/src/Examples/BannerExample.tsx +++ b/example/src/Examples/BannerExample.tsx @@ -2,12 +2,7 @@ import * as React from 'react'; import { Dimensions, Image, Platform, StyleSheet, View } from 'react-native'; import type { LayoutChangeEvent } from 'react-native'; -import { - Banner, - FloatingActionButton, - Palette, - useTheme, -} from 'react-native-paper'; +import { Banner, FAB, Palette, useTheme } from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; @@ -56,11 +51,7 @@ const BannerExample = () => { ))} - setVisible(!visible)} - /> + setVisible(!visible)} /> ; -const variants: FloatingActionButtonVariant[] = [ +const variants: FabColor[] = [ 'primary', 'secondary', 'tertiary', 'tonalPrimary', 'tonalSecondary', 'tonalTertiary', + 'custom', ]; -const sizes: FloatingActionButtonSize[] = ['default', 'medium', 'large']; +const sizes: FABSize[] = ['default', 'medium', 'large']; const types: FabType[] = ['icon', 'extended', 'extendedTransforming', 'menu']; @@ -96,9 +98,11 @@ const FABExample = () => { const { colors } = useTheme(); const insets = useSafeAreaInsets(); - const [variant, setVariant] = - React.useState('tonalPrimary'); - const [size, setSize] = React.useState('medium'); + const [variant, setVariant] = React.useState('tonalPrimary'); + const activeVariant = variant === 'custom' ? undefined : variant; + const activeContainerColor = + variant === 'custom' ? CUSTOM_CONTAINER_COLOR : undefined; + const [size, setSize] = React.useState('medium'); const [type, setType] = React.useState('icon'); const [position, setPosition] = React.useState('end'); const [showFab, setShowFab] = React.useState(true); @@ -188,19 +192,21 @@ const FABExample = () => { ]} > {type === 'icon' && ( - {}} /> )} {(type === 'extended' || type === 'extendedTransforming') && ( - { )} {type === 'menu' ? ( - setMenuExpanded(false)} - horizontalAlignment={position} - button={ - setMenuExpanded(true)} - /> - } - > - {}} - /> - {}} - /> - {}} - /> - + alignment={position} + trigger={{ + icon: 'pencil', + variant: activeVariant, + containerColor: activeContainerColor, + size, + visible: showFab, + onPress: () => setMenuExpanded(true), + }} + items={[ + { icon: 'email', label: 'Send', onPress: () => {} }, + { icon: 'bell', label: 'Remind me', onPress: () => {} }, + { icon: 'star', label: 'Favorite', onPress: () => {} }, + ]} + /> ) : null} ); diff --git a/example/src/Examples/TeamDetails.tsx b/example/src/Examples/TeamDetails.tsx index bd4bdb6dd5..5970274f31 100644 --- a/example/src/Examples/TeamDetails.tsx +++ b/example/src/Examples/TeamDetails.tsx @@ -18,7 +18,7 @@ import { Chip, Divider, IconButton, - FloatingActionButton, + FAB, PaperProvider, } from 'react-native-paper'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -116,12 +116,7 @@ const News = () => { - {}} - visible - style={styles.fab} - /> + {}} visible style={styles.fab} /> ); }; diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx index 61e6ddc83a..59a3b6bbef 100644 --- a/example/src/Examples/TooltipExample.tsx +++ b/example/src/Examples/TooltipExample.tsx @@ -8,7 +8,7 @@ import { Banner, Button, Chip, - FloatingActionButton, + FAB, IconButton, List, ToggleButton, @@ -173,7 +173,7 @@ const TooltipExample = () => { - {}} /> + {}} /> diff --git a/src/babel/__fixtures__/rewrite-imports/code.js b/src/babel/__fixtures__/rewrite-imports/code.js index 868e917133..f1253a5e08 100644 --- a/src/babel/__fixtures__/rewrite-imports/code.js +++ b/src/babel/__fixtures__/rewrite-imports/code.js @@ -3,7 +3,7 @@ import { PaperProvider, BottomNavigation, Button, - FloatingActionButton, + FAB, Appbar, Palette, NonExistent, diff --git a/src/babel/__fixtures__/rewrite-imports/output.js b/src/babel/__fixtures__/rewrite-imports/output.js index b56be3496e..bbe342ad0d 100644 --- a/src/babel/__fixtures__/rewrite-imports/output.js +++ b/src/babel/__fixtures__/rewrite-imports/output.js @@ -2,7 +2,7 @@ import PaperProvider from "react-native-paper/lib/module/core/PaperProvider"; import BottomNavigation from "react-native-paper/lib/module/components/BottomNavigation/BottomNavigation"; import Button from "react-native-paper/lib/module/components/Button/Button"; -import FloatingActionButton from "react-native-paper/lib/module/components/FAB/FloatingActionButton"; +import FAB from "react-native-paper/lib/module/components/FAB"; import Appbar from "react-native-paper/lib/module/components/Appbar"; import { Palette } from "react-native-paper/lib/module/theme/tokens"; import { NonExistent, NonExistentSecond as Stuff, LightTheme } from "react-native-paper/lib/module/index.js"; diff --git a/src/components/FAB/Content.tsx b/src/components/FAB/Content.tsx index 0e5728c2e3..d7dc81a9cb 100644 --- a/src/components/FAB/Content.tsx +++ b/src/components/FAB/Content.tsx @@ -1,12 +1,16 @@ -import { StyleSheet, View } from 'react-native'; -import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; +import * as React from 'react'; +import { + ColorValue, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; -import Reanimated from 'react-native-reanimated'; -import type { AnimatedStyle } from 'react-native-reanimated'; +import Reanimated, { AnimatedStyle } from 'react-native-reanimated'; import type { TypescaleKey } from '../../theme/types'; -import Icon from '../Icon'; -import type { IconSource } from '../Icon'; +import Icon, { IconSource } from '../Icon'; import AnimatedText from '../Typography/AnimatedText'; export type ContentProps = { diff --git a/src/components/FAB/Extended.tsx b/src/components/FAB/Extended.tsx index 06a67cab1f..d10d624596 100644 --- a/src/components/FAB/Extended.tsx +++ b/src/components/FAB/Extended.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; -import type { +import { + AccessibilityState, ColorValue, GestureResponderEvent, PressableAndroidRippleConfig, StyleProp, + StyleSheet, + View, ViewStyle, } from 'react-native'; @@ -18,12 +20,13 @@ import Reanimated, { import { scheduleOnUI } from 'react-native-worklets'; import Shell from './Shell'; -import type { Size, Variant } from './tokens'; +import { Size, Variant } from './tokens'; import { getDimensions } from './utils'; import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import type { ThemeProp } from '../../types'; +import { forwardRef } from '../../utils/forwardRef'; import type { IconSource } from '../Icon'; import AnimatedText from '../Typography/AnimatedText'; @@ -70,24 +73,11 @@ export type Props = { /** * Accessibility label. Falls back to `label` if unset. */ - 'aria-label'?: string; + accessibilityLabel?: string; /** - * Indicates whether the element is checked. Accepts `true`, `false`, - * or `'mixed'` for an indeterminate state. + * Accessibility state forwarded to the underlying button. */ - 'aria-checked'?: boolean | 'mixed'; - /** - * Indicates whether the element is selected. - */ - 'aria-selected'?: boolean; - /** - * Indicates whether the element is currently busy (e.g. loading). - */ - 'aria-busy'?: boolean; - /** - * Indicates whether the element's controlled content is expanded. - */ - 'aria-expanded'?: boolean; + accessibilityState?: AccessibilityState; /** * Specifies the largest possible scale a label font can reach. */ @@ -110,7 +100,7 @@ export type Props = { * @optional */ theme?: ThemeProp; - ref?: React.Ref; + ref?: React.RefObject; }; /** @@ -150,140 +140,138 @@ export type Props = { * export default MyComponent; * ``` */ -const Extended = ({ - icon, - label, - variant = 'tonalPrimary', - containerColor, - contentColor, - size = 'default', - expanded, - visible = true, - onPress, - 'aria-label': ariaLabel = label, - 'aria-checked': ariaChecked, - 'aria-selected': ariaSelected, - 'aria-busy': ariaBusy, - 'aria-expanded': ariaExpanded, - labelMaxFontSizeMultiplier, - background, - style, - testID = 'extended-floating-action-button', - theme: themeOverrides, - ref, -}: Props) => { - const theme = useInternalTheme(themeOverrides); - const reduceMotion = useReduceMotion(); +const Extended = forwardRef( + ( + { + icon, + label, + variant = 'tonalPrimary', + containerColor, + contentColor, + size = 'default', + expanded, + visible = true, + onPress, + accessibilityLabel = label, + accessibilityState, + labelMaxFontSizeMultiplier, + background, + style, + testID = 'extended-floating-action-button', + theme: themeOverrides, + }, + ref + ) => { + const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); + + const dimensions = getDimensions({ theme, size }); - const dimensions = getDimensions({ theme, size }); + const offscreenLabelRef = useAnimatedRef(); - const offscreenLabelRef = useAnimatedRef(); + const widthValue = useSharedValue(dimensions.width); + const labelOpacity = useSharedValue(expanded ? 1 : 0); - const widthValue = useSharedValue(dimensions.width); - const labelOpacity = useSharedValue(expanded ? 1 : 0); + React.useEffect(() => { + const { + width: collapsedWidth, + leading, + iconSize, + iconLabelGap, + trailing, + } = dimensions; + const targetOpacity = expanded ? 1 : 0; - React.useEffect(() => { - const { - width: collapsedWidth, - leading, - iconSize, - iconLabelGap, - trailing, - } = dimensions; - const targetOpacity = expanded ? 1 : 0; + if (reduceMotion) { + scheduleOnUI(() => { + 'worklet'; + const m = measure(offscreenLabelRef); + const lw = m?.width ?? 0; + widthValue.value = expanded + ? leading + iconSize + iconLabelGap + lw + trailing + : collapsedWidth; + labelOpacity.value = targetOpacity; + }); + return; + } + + const widthSpring = toRawSpring( + expanded + ? theme.motion.spring.fast.spatial + : theme.motion.spring.default.spatial + ); + const opacitySpring = toRawSpring( + expanded + ? theme.motion.spring.default.effects + : theme.motion.spring.fast.effects + ); - if (reduceMotion) { scheduleOnUI(() => { 'worklet'; const m = measure(offscreenLabelRef); const lw = m?.width ?? 0; - widthValue.value = expanded - ? leading + iconSize + iconLabelGap + lw + trailing - : collapsedWidth; - labelOpacity.value = targetOpacity; + const expandedWidth = leading + iconSize + iconLabelGap + lw + trailing; + widthValue.value = withSpring( + expanded ? expandedWidth : collapsedWidth, + widthSpring + ); + labelOpacity.value = withSpring(targetOpacity, opacitySpring); }); - return; - } - - const widthSpring = toRawSpring( - expanded - ? theme.motion.spring.fast.spatial - : theme.motion.spring.default.spatial - ); - const opacitySpring = toRawSpring( - expanded - ? theme.motion.spring.default.effects - : theme.motion.spring.fast.effects - ); + }, [ + expanded, + label, + dimensions, + theme, + reduceMotion, + widthValue, + labelOpacity, + offscreenLabelRef, + ]); - scheduleOnUI(() => { - 'worklet'; - const m = measure(offscreenLabelRef); - const lw = m?.width ?? 0; - const expandedWidth = leading + iconSize + iconLabelGap + lw + trailing; - widthValue.value = withSpring( - expanded ? expandedWidth : collapsedWidth, - widthSpring - ); - labelOpacity.value = withSpring(targetOpacity, opacitySpring); - }); - }, [ - expanded, - label, - dimensions, - theme, - reduceMotion, - widthValue, - labelOpacity, - offscreenLabelRef, - ]); - - const labelAnimatedStyle = useAnimatedStyle(() => ({ - opacity: labelOpacity.value, - })); + const labelAnimatedStyle = useAnimatedStyle(() => ({ + opacity: labelOpacity.value, + })); - return ( - <> - - - + + - {label} - - - - ); -}; + + {label} + + + + ); + } +); const styles = StyleSheet.create({ offscreenMeasure: { diff --git a/src/components/FAB/ExtendedFloatingActionButton.tsx b/src/components/FAB/ExtendedFloatingActionButton.tsx deleted file mode 100644 index 33e9ec5c14..0000000000 --- a/src/components/FAB/ExtendedFloatingActionButton.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import * as React from 'react'; -import { - AccessibilityState, - GestureResponderEvent, - Platform, - PressableAndroidRippleConfig, - StyleProp, - Text as NativeText, - TextLayoutEvent, - View, - ViewStyle, -} from 'react-native'; - -import { - useAnimatedStyle, - useSharedValue, - withSpring, -} from 'react-native-reanimated'; - -import FabShell from './FabShell'; -import { - FloatingActionButtonSize, - FloatingActionButtonVariant, -} from './tokens'; -import { getDimensions, getLabelSizeWeb } from './utils'; -import { useInternalTheme } from '../../core/theming'; -import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; -import { toRawSpring } from '../../theme/tokens/sys/motion'; -import type { ThemeProp } from '../../types'; -import { forwardRef } from '../../utils/forwardRef'; -import type { IconSource } from '../Icon'; - -export type Props = { - /** - * Icon to display inside the FAB. - */ - icon: IconSource; - /** - * Label rendered next to the icon when expanded. - */ - label: string; - /** - * Role-color preset. Defaults to `tonalPrimary`. - */ - variant?: FloatingActionButtonVariant; - /** - * Spec size. Defaults to `default`. - */ - size?: FloatingActionButtonSize; - /** - * Whether the FAB is expanded (icon + label) or collapsed (icon only). The - * width and label opacity animate per the MD3 Expressive spec on change. - */ - expanded: boolean; - /** - * Whether the FAB is currently visible. Toggling animates the spec'd enter - * and exit (scale + alpha) on the FAB itself. - */ - visible?: boolean; - /** - * Function to execute on press. - */ - onPress?: (e: GestureResponderEvent) => void; - /** - * Accessibility label. Falls back to `label` if unset. - */ - accessibilityLabel?: string; - /** - * Accessibility state forwarded to the underlying button. - */ - accessibilityState?: AccessibilityState; - /** - * Specifies the largest possible scale a label font can reach. - */ - labelMaxFontSizeMultiplier?: number; - /** - * Type of background drawable to display the feedback (Android). - * https://reactnative.dev/docs/pressable#rippleconfig - */ - background?: PressableAndroidRippleConfig; - /** - * Style for positioning the FAB. The visual treatment (size, shape, color) - * is driven by `variant` and `size`. - */ - style?: StyleProp; - /** - * TestID used for testing purposes. - */ - testID?: string; - /** - * @optional - */ - theme?: ThemeProp; - ref?: React.RefObject; -}; - -/** - * An extended floating action button represents the primary action on a screen - * and shows a label next to the icon. Animates between expanded (icon + label) - * and collapsed (icon only) states. - * - * ## Usage - * ```js - * import * as React from 'react'; - * import { StyleSheet } from 'react-native'; - * import { ExtendedFloatingActionButton } from 'react-native-paper'; - * - * const MyComponent = () => { - * const [expanded, setExpanded] = React.useState(true); - * - * return ( - * setExpanded((v) => !v)} - * style={styles.fab} - * /> - * ); - * }; - * - * const styles = StyleSheet.create({ - * fab: { - * position: 'absolute', - * margin: 16, - * left: 0, - * bottom: 0, - * }, - * }); - * - * export default MyComponent; - * ``` - */ -const ExtendedFloatingActionButton = forwardRef( - ( - { - icon, - label, - variant = 'tonalPrimary', - size = 'default', - expanded, - visible = true, - onPress, - accessibilityLabel = label, - accessibilityState, - labelMaxFontSizeMultiplier, - background, - style, - testID = 'extended-floating-action-button', - theme: themeOverrides, - }, - ref - ) => { - const theme = useInternalTheme(themeOverrides); - const reduceMotion = useReduceMotion(); - const isWeb = Platform.OS === 'web'; - - const dimensions = React.useMemo( - () => getDimensions({ theme, size }), - [theme, size] - ); - - const labelRef = React.useRef(null); - const initialLabelSize = isWeb ? getLabelSizeWeb(labelRef) : null; - const [labelWidth, setLabelWidth] = React.useState( - initialLabelSize?.width ?? 0 - ); - - const collapsedWidth = dimensions.width; - const expandedWidth = - dimensions.leading + - dimensions.iconSize + - dimensions.iconLabelGap + - labelWidth + - dimensions.trailing; - - const widthValue = useSharedValue( - expanded ? expandedWidth : collapsedWidth - ); - const labelOpacity = useSharedValue(expanded ? 1 : 0); - - React.useEffect(() => { - if (!isWeb) { - return; - } - const updateLabelSize = () => { - if (labelRef.current) { - const measured = getLabelSizeWeb(labelRef); - if (measured) { - setLabelWidth(measured.width); - } - } - }; - updateLabelSize(); - window.addEventListener('resize', updateLabelSize); - return () => { - window.removeEventListener('resize', updateLabelSize); - }; - }, [isWeb, label]); - - React.useEffect(() => { - const targetWidth = expanded ? expandedWidth : collapsedWidth; - const targetOpacity = expanded ? 1 : 0; - if (reduceMotion) { - widthValue.value = targetWidth; - labelOpacity.value = targetOpacity; - return; - } - const widthSpring = toRawSpring( - expanded - ? theme.motion.spring.fast.spatial - : theme.motion.spring.default.spatial - ); - const opacitySpring = toRawSpring( - expanded - ? theme.motion.spring.default.effects - : theme.motion.spring.fast.effects - ); - widthValue.value = withSpring(targetWidth, widthSpring); - labelOpacity.value = withSpring(targetOpacity, opacitySpring); - }, [ - expanded, - expandedWidth, - collapsedWidth, - theme, - reduceMotion, - widthValue, - labelOpacity, - ]); - - const labelAnimatedStyle = useAnimatedStyle(() => ({ - opacity: labelOpacity.value, - })); - - const onTextLayout = ({ nativeEvent }: TextLayoutEvent) => { - const measured = Math.ceil(nativeEvent.lines[0]?.width ?? 0); - if (measured !== labelWidth) { - setLabelWidth(measured); - } - }; - - return ( - - ); - } -); - -export default ExtendedFloatingActionButton; - -// @component-docs ignore-next-line -export { ExtendedFloatingActionButton }; diff --git a/src/components/FAB/FloatingActionButton.tsx b/src/components/FAB/FAB.tsx similarity index 82% rename from src/components/FAB/FloatingActionButton.tsx rename to src/components/FAB/FAB.tsx index 2e48fefc71..2a897ddbb9 100644 --- a/src/components/FAB/FloatingActionButton.tsx +++ b/src/components/FAB/FAB.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { AccessibilityState, + ColorValue, GestureResponderEvent, PressableAndroidRippleConfig, StyleProp, @@ -8,11 +9,8 @@ import { ViewStyle, } from 'react-native'; -import FabShell from './FabShell'; -import { - FloatingActionButtonSize, - FloatingActionButtonVariant, -} from './tokens'; +import Shell from './Shell'; +import { Size, Variant } from './tokens'; import type { ThemeProp } from '../../types'; import { forwardRef } from '../../utils/forwardRef'; import type { IconSource } from '../Icon'; @@ -25,11 +23,19 @@ export type Props = { /** * Role-color preset. Defaults to `tonalPrimary`. */ - variant?: FloatingActionButtonVariant; + variant?: Variant; + /** + * Override the container (background) color. + */ + containerColor?: ColorValue; + /** + * Override the content (icon) color. + */ + contentColor?: ColorValue; /** * Spec size. Defaults to `default`. */ - size?: FloatingActionButtonSize; + size?: Size; /** * Whether the FAB is currently visible. Toggling animates the spec'd enter * and exit (scale + alpha) on the FAB itself. @@ -75,10 +81,10 @@ export type Props = { * ```js * import * as React from 'react'; * import { StyleSheet } from 'react-native'; - * import { FloatingActionButton } from 'react-native-paper'; + * import { FAB } from 'react-native-paper'; * * const MyComponent = () => ( - * console.log('Pressed')} @@ -97,7 +103,7 @@ export type Props = { * export default MyComponent; * ``` */ -const FloatingActionButton = forwardRef( +const FAB = forwardRef( ( { icon, @@ -105,6 +111,8 @@ const FloatingActionButton = forwardRef( size = 'default', visible = true, onPress, + containerColor, + contentColor, accessibilityLabel, accessibilityState, background, @@ -114,13 +122,15 @@ const FloatingActionButton = forwardRef( }, ref ) => ( - ( ) ); -export default FloatingActionButton; +export default FAB; // @component-docs ignore-next-line -export { FloatingActionButton }; +export { FAB }; diff --git a/src/components/FAB/FabContent.tsx b/src/components/FAB/FabContent.tsx deleted file mode 100644 index 06170f1709..0000000000 --- a/src/components/FAB/FabContent.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import * as React from 'react'; -import { - ColorValue, - ScrollView, - StyleProp, - StyleSheet, - Text as NativeText, - TextLayoutEvent, - View, - ViewStyle, -} from 'react-native'; - -import Reanimated, { AnimatedStyle } from 'react-native-reanimated'; - -import type { TypescaleKey } from '../../theme/types'; -import Icon, { IconSource } from '../Icon'; -import AnimatedText from '../Typography/AnimatedText'; - -export type FabContentProps = { - icon?: IconSource; - label?: string; - contentColor: ColorValue; - height: number; - iconSize: number; - leading: number; - trailing: number; - iconLabelGap: number; - labelTypescale?: TypescaleKey; - labelMaxFontSizeMultiplier?: number; - /** - * Reanimated style merged onto the label wrapper. Used by the Extended FAB - * to fade the label in and out as the FAB expands and collapses. - */ - labelAnimatedStyle?: StyleProp>; - /** - * Ref to the visible label node. Used by the Extended FAB to measure label - * width on the web. - */ - labelRef?: React.RefObject<(NativeText & HTMLElement) | null>; - /** - * `onTextLayout` for the visible label. Used by iOS, which reports the full - * (unclipped) label width via this callback. Pass `undefined` on platforms - * where the visible label is clipped and reports a useless width. - */ - onLabelTextLayout?: (e: TextLayoutEvent) => void; - labelNumberOfLines?: number; - labelEllipsisMode?: 'clip' | 'tail' | 'head' | 'middle'; - /** - * When set, an off-screen copy of the label is rendered with this callback - * attached. Used by the Extended FAB on Android, where the visible label's - * `onTextLayout` reports only the visible glyph run. - */ - offscreenLabelMeasure?: (e: TextLayoutEvent) => void; - testID?: string; -}; - -/** - * Internal layout primitive: an icon-and-label row used by every FAB-flavored - * surface in this package (regular, Extended, Menu trigger, Menu item). - * - * No animation, no ripple, no shadow, no container shape. Just the content. - */ -const FabContent = ({ - icon, - label, - contentColor, - height, - iconSize, - leading, - trailing, - iconLabelGap, - labelTypescale = 'labelLarge', - labelMaxFontSizeMultiplier, - labelAnimatedStyle, - labelRef, - onLabelTextLayout, - labelNumberOfLines, - labelEllipsisMode, - offscreenLabelMeasure, - testID, -}: FabContentProps) => { - const hasLabel = label !== undefined && label !== ''; - const colorStyle = { color: contentColor }; - - return ( - <> - - {icon ? ( - - ) : null} - {hasLabel ? ( - - - {label} - - - ) : null} - - {hasLabel && offscreenLabelMeasure ? ( - - - {label} - - - ) : null} - - ); -}; - -const styles = StyleSheet.create({ - row: { - flexDirection: 'row', - alignItems: 'center', - pointerEvents: 'none', - }, - rowIconOnly: { - justifyContent: 'center', - }, - labelNoPointerEvents: { - pointerEvents: 'none', - }, - offscreen: { - height: 0, - position: 'absolute', - }, -}); - -export default FabContent; diff --git a/src/components/FAB/FabShell.tsx b/src/components/FAB/FabShell.tsx deleted file mode 100644 index 9644666619..0000000000 --- a/src/components/FAB/FabShell.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import * as React from 'react'; -import { - AccessibilityState, - ColorValue, - GestureResponderEvent, - Platform, - PressableAndroidRippleConfig, - StyleProp, - StyleSheet, - Text as NativeText, - TextLayoutEvent, - View, - ViewStyle, -} from 'react-native'; - -import Reanimated, { - AnimatedStyle, - useAnimatedStyle, - useSharedValue, - type SharedValue, -} from 'react-native-reanimated'; - -import FabContent from './FabContent'; -import { - FloatingActionButtonSize, - FloatingActionButtonTokens, - FloatingActionButtonVariant, - FOCUS_RING_INSET, - FOCUS_RING_THICKNESS, - webNoOutline, -} from './tokens'; -import { useFabVisibility } from './useFabVisibility'; -import { useFocusRing } from './useFocusRing'; -import { getDimensions, resolveColors } from './utils'; -import { useInternalTheme } from '../../core/theming'; -import type { ShapeToken } from '../../theme/utils/shape'; -import type { Elevation, ThemeProp } from '../../types'; -import { forwardRef } from '../../utils/forwardRef'; -import type { IconSource } from '../Icon'; -import TouchableRipple from '../TouchableRipple/TouchableRipple'; - -export type FabShellProps = { - /** - * Icon rendered inside the FAB when no custom `children` are provided. - */ - icon?: IconSource; - /** - * Label rendered next to the icon when no custom `children` are provided. - * When present, the FAB grows to fit. - */ - label?: string; - /** - * Role-color preset. Defaults to `tonalPrimary`. - */ - variant?: FloatingActionButtonVariant; - /** - * Spec size. Defaults to `default`. - */ - size?: FloatingActionButtonSize; - /** - * Container color override. Wins over `variant`. - */ - containerColor?: ColorValue; - /** - * Content color override. Wins over `variant`. - */ - contentColor?: ColorValue; - /** - * Shape override. Defaults to the size-driven shape token. - */ - shape?: ShapeToken; - /** - * Icon size override. - */ - iconSize?: number; - /** - * Leading-padding override. - */ - leading?: number; - /** - * 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. - */ - visible?: boolean; - /** - * Function to execute on press. - */ - onPress?: (e: GestureResponderEvent) => void; - /** - * Accessibility label. Falls back to `label` if unset. - */ - accessibilityLabel?: string; - /** - * Accessibility state forwarded to the underlying button. - */ - accessibilityState?: AccessibilityState; - /** - * Largest scale the label font can reach (auto-built content only). - */ - labelMaxFontSizeMultiplier?: number; - /** - * Animated style merged onto the label wrapper. Used by the Extended FAB - * to fade the label in and out as the FAB expands and collapses. - */ - labelAnimatedStyle?: StyleProp>; - /** - * Ref to the visible label node. Used by the Extended FAB to measure - * label width on the web. - */ - labelRef?: React.RefObject<(NativeText & HTMLElement) | null>; - /** - * `onTextLayout` for the visible label. Used on iOS, which reports the - * full (unclipped) label width via this callback. - */ - onLabelTextLayout?: (e: TextLayoutEvent) => void; - /** - * `onTextLayout` for an off-screen full-width copy of the label. Used on - * Android, where the visible label's `onTextLayout` reports only the - * visible glyph run. - */ - offscreenLabelMeasure?: (e: TextLayoutEvent) => void; - /** - * Type of background drawable to display the feedback (Android). - */ - background?: PressableAndroidRippleConfig; - /** - * Shared value driving the outer's animated width. When omitted, the - * outer is sized by its content (icon FAB) or the size token - * (`dimensions.width`). - */ - widthShared?: SharedValue; - /** - * Shared value driving the outer's animated height. When omitted, the - * outer is sized by its content. - */ - heightShared?: SharedValue; - /** - * Shared value driving the outer's animated borderRadius. The same value - * is applied to the inner clip so children are clipped to the same shape. - * When omitted, the static size-driven radius is used. - */ - borderRadiusShared?: SharedValue; - /** - * When `true`, both outer and clip render with `backgroundColor: transparent` - * so the consumer can paint the surface via the `overlay` slot (used by the - * morph trigger's cross-faded color planes). - */ - transparentBackground?: boolean; - /** - * Absolutely-positioned content rendered inside the shell, behind the icon - * and label row. Used by the morphing trigger to cross-fade color planes. - */ - overlay?: React.ReactNode; - /** - * Replaces the default icon + label content. Pass your own `` - * when you need custom typescale, label animation, or measurement. - */ - children?: React.ReactNode; - /** - * Outer-positioning style. Visual treatment (size, shape, color) comes from - * `variant` and `size`. - */ - style?: StyleProp; - /** - * TestID used for testing purposes. - */ - testID?: string; - /** - * @optional - */ - theme?: ThemeProp; - ref?: React.RefObject; -}; - -/** - * Internal shell used by every FAB-flavored component (regular, Extended, - * morphing menu trigger). Owns the outer container, ripple, clip, and the - * visibility animation (scale + alpha + shadow). Consumers that need to - * animate the outer's width/height/borderRadius pass shared values; the - * static size-driven defaults are used otherwise. - * - * Not exported from the package. - */ -const FabShell = forwardRef( - ( - { - icon, - label, - variant = 'tonalPrimary', - size = 'default', - containerColor, - contentColor, - shape, - iconSize, - leading, - trailing, - elevation = FloatingActionButtonTokens.stateElevation.enabled, - visible = true, - onPress, - accessibilityLabel = label, - accessibilityState, - labelMaxFontSizeMultiplier, - labelAnimatedStyle, - labelRef, - onLabelTextLayout, - offscreenLabelMeasure, - background, - widthShared, - heightShared, - borderRadiusShared, - transparentBackground = false, - overlay, - children, - style, - testID = 'fab-shell', - theme: themeOverrides, - }, - ref - ) => { - const theme = useInternalTheme(themeOverrides); - - const dimensions = React.useMemo( - () => getDimensions({ theme, size, shape, iconSize, leading, trailing }), - [theme, size, shape, iconSize, leading, trailing] - ); - - const colors = React.useMemo( - () => resolveColors({ theme, variant, containerColor, contentColor }), - [theme, variant, containerColor, contentColor] - ); - - const { scale, alpha, shadowStyle } = useFabVisibility({ - visible, - theme, - elevation, - }); - - // Fallback shared values track the static size-driven dimensions. Consumers - // that don't supply their own animated shared values get these. Keeping - // everything as a shared value means there's exactly one animated style - // per view — no static-vs-animated merge surprises. - const fallbackWidth = useSharedValue(dimensions.width); - const fallbackHeight = useSharedValue(dimensions.height); - const fallbackBorderRadius = useSharedValue(dimensions.borderRadius); - React.useEffect(() => { - fallbackWidth.value = dimensions.width; - fallbackHeight.value = dimensions.height; - fallbackBorderRadius.value = dimensions.borderRadius; - }, [ - dimensions.width, - dimensions.height, - dimensions.borderRadius, - fallbackWidth, - fallbackHeight, - fallbackBorderRadius, - ]); - - const width = widthShared ?? fallbackWidth; - const height = heightShared ?? fallbackHeight; - const borderRadius = borderRadiusShared ?? fallbackBorderRadius; - const containerBg = transparentBackground - ? 'transparent' - : colors.container; - - const outerStyle = useAnimatedStyle( - () => ({ - transform: [{ scale: scale.value }], - opacity: alpha.value, - width: width.value, - height: height.value, - borderRadius: borderRadius.value, - backgroundColor: containerBg, - }), - [width, height, borderRadius, containerBg] - ); - - const clipStyle = useAnimatedStyle( - () => ({ - borderRadius: borderRadius.value, - backgroundColor: containerBg, - }), - [borderRadius, containerBg] - ); - - const { focusedSV, onFocus, onBlur } = useFocusRing(); - const focusRingStyle = useAnimatedStyle( - () => ({ - opacity: focusedSV.value ? 1 : 0, - borderRadius: borderRadius.value + FOCUS_RING_INSET, - }), - [borderRadius] - ); - - return ( - - - {overlay} - - {children ?? ( - - )} - - - - - ); - } -); - -const styles = StyleSheet.create({ - container: { - transformOrigin: 'center', - }, - clip: { - width: '100%', - height: '100%', - overflow: 'hidden', - }, - fill: { - flex: 1, - }, - pointerEventsAuto: { - pointerEvents: 'auto', - }, - pointerEventsNone: { - pointerEvents: 'none', - }, - focusRing: { - position: 'absolute', - top: -FOCUS_RING_INSET, - left: -FOCUS_RING_INSET, - right: -FOCUS_RING_INSET, - bottom: -FOCUS_RING_INSET, - borderWidth: FOCUS_RING_THICKNESS, - pointerEvents: 'none', - }, -}); - -export default FabShell; diff --git a/src/components/FAB/FloatingActionButtonMenu.tsx b/src/components/FAB/FloatingActionButtonMenu.tsx deleted file mode 100644 index 4d46557bea..0000000000 --- a/src/components/FAB/FloatingActionButtonMenu.tsx +++ /dev/null @@ -1,767 +0,0 @@ -import * as React from 'react'; -import { - ColorValue, - GestureResponderEvent, - Platform, - StyleSheet, - View, -} from 'react-native'; - -import Animated, { - interpolate, - useAnimatedStyle, - useDerivedValue, - useSharedValue, - withDelay, - withSpring, -} from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import FabContent from './FabContent'; -import FabShell from './FabShell'; -import { - FloatingActionButtonMenuTokens, - FloatingActionButtonSize, - FloatingActionButtonTokens, - FloatingActionButtonVariant, - FOCUS_RING_INSET, - FOCUS_RING_THICKNESS, - webNoOutline, -} from './tokens'; -import { useFocusRing } from './useFocusRing'; -import { resolveColors } from './utils'; -import { useLocale } from '../../core/locale'; -import { useInternalTheme } from '../../core/theming'; -import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; -import { toRawSpring } from '../../theme/tokens/sys/motion'; -import { resolveCornerRadius } from '../../theme/utils/shape'; -import type { InternalTheme, ThemeProp } from '../../types'; -import Icon, { IconSource } from '../Icon'; -import TouchableRipple from '../TouchableRipple/TouchableRipple'; - -export type FloatingActionButtonMenuItemProps = { - /** - * Optional icon for the item. - */ - icon?: IconSource; - /** - * Mandatory label. - */ - label: string; - /** - * Called when the item is pressed. The menu is dismissed automatically - * after `onPress` runs. - */ - onPress: (e: GestureResponderEvent) => void; - /** - * Accessibility label. Falls back to `label`. - */ - accessibilityLabel?: string; - testID?: string; -}; - -const FloatingActionButtonMenuItem = ( - _props: FloatingActionButtonMenuItemProps -): React.ReactElement | null => null; -FloatingActionButtonMenuItem.displayName = 'FloatingActionButtonMenu.Item'; - -export type FloatingActionButtonMenuProps = { - /** - * Whether the menu is open. - */ - expanded: boolean; - /** - * Called when the user taps the close button or taps an item. - */ - onDismiss?: () => void; - /** - * Trigger FAB. Pass a ``. The menu reads its - * `variant`, `size`, `icon`, and `onPress` and renders a single morphing - * FAB that animates between the trigger and the spec'd close button. - */ - button: React.ReactElement; - /** - * Horizontal side the menu sits on. Default `'end'`. - */ - horizontalAlignment?: 'start' | 'center' | 'end'; - /** - * Icon used by the close button when the menu is expanded. Default - * `'close'`. - */ - closeIcon?: IconSource; - /** - * Menu items as ``. Spec calls for 2 to 6 - * items; a dev-mode warning fires outside that range. - */ - children: React.ReactNode; - testID?: string; - /** - * @optional - */ - theme?: ThemeProp; -}; - -/** - * Per the M3 FAB Menu spec, the menu picks one of three color sets (primary, - * secondary, tertiary) based on which family the trigger FAB belongs to. - * The close button is always the saturated role color; items are always the - * tonal (container) role color. - */ -const getCloseVariant = ( - triggerVariant: FloatingActionButtonVariant -): FloatingActionButtonVariant => { - if (triggerVariant === 'primary' || triggerVariant === 'tonalPrimary') { - return 'primary'; - } - if (triggerVariant === 'secondary' || triggerVariant === 'tonalSecondary') { - return 'secondary'; - } - return 'tertiary'; -}; - -const getItemsVariant = ( - triggerVariant: FloatingActionButtonVariant -): FloatingActionButtonVariant => { - if (triggerVariant === 'primary' || triggerVariant === 'tonalPrimary') { - return 'tonalPrimary'; - } - if (triggerVariant === 'secondary' || triggerVariant === 'tonalSecondary') { - return 'tonalSecondary'; - } - return 'tonalTertiary'; -}; - -type ButtonExtractableProps = { - variant?: FloatingActionButtonVariant; - size?: FloatingActionButtonSize; - icon?: IconSource; - containerColor?: ColorValue; - contentColor?: ColorValue; - visible?: boolean; - onPress?: (e: GestureResponderEvent) => void; - accessibilityLabel?: string; - testID?: string; -}; - -// Per-item delay used by the stagger. Compose uses a single SlowEffects-driven -// integer count that crosses each item's threshold; we approximate with a -// fixed delay per index. -const STAGGER_MS = 30; - -type AnimatedItemProps = { - expanded: boolean; - index: number; - itemCount: number; - theme: InternalTheme; - transformOrigin: 'left' | 'center' | 'right'; - marginBottom: number; - children: React.ReactNode; -}; - -const AnimatedItem = ({ - expanded, - index, - itemCount, - theme, - transformOrigin, - marginBottom, - children, -}: AnimatedItemProps) => { - const reduceMotion = useReduceMotion(); - // Initial values match the resting state for the current `expanded` prop so - // first mount doesn't animate unexpectedly. - const scaleX = useSharedValue(expanded ? 1 : 0); - const alpha = useSharedValue(expanded ? 1 : 0); - - React.useEffect(() => { - const target = expanded ? 1 : 0; - // Bottom-up on open, top-down on close (matches Compose). - const delay = expanded - ? (itemCount - 1 - index) * STAGGER_MS - : index * STAGGER_MS; - - if (reduceMotion) { - scaleX.value = target; - alpha.value = target; - return; - } - scaleX.value = withDelay( - delay, - withSpring(target, toRawSpring(theme.motion.spring.fast.spatial)) - ); - alpha.value = withDelay( - delay, - withSpring(target, toRawSpring(theme.motion.spring.fast.effects)) - ); - }, [expanded, index, itemCount, theme, reduceMotion, scaleX, alpha]); - - // Only scaleX and opacity animate. Layout height stays at the item's - // natural size — the items container is absolutely positioned above the - // trigger, so this fixed height never affects the trigger's position. - const animStyle = useAnimatedStyle(() => ({ - transform: [{ scaleX: scaleX.value }], - opacity: alpha.value, - })); - - return ( - - {children} - - ); -}; - -type MenuItemProps = { - icon?: IconSource; - label: string; - variant: FloatingActionButtonVariant; - theme: InternalTheme; - onPress: (e: GestureResponderEvent) => void; - accessibilityLabel?: string; - testID?: string; -}; - -/** - * A single FAB Menu item. Visually a tonal pill with an icon and a label, - * but it is not a floating action button: no shadow, no enter/exit scaling - * of its own (the surrounding `AnimatedItem` handles entrance), and its - * shape and dimensions come from the menu spec rather than FAB tokens. - */ -const MenuItem = ({ - icon, - label, - variant, - theme, - onPress, - accessibilityLabel, - testID, -}: MenuItemProps) => { - const colors = resolveColors({ theme, variant }); - const { height, iconSize, leading, trailing, iconLabelGap, shape } = - FloatingActionButtonMenuTokens.listItem; - const borderRadius = resolveCornerRadius(theme, shape); - - const { focusedSV, onFocus, onBlur } = useFocusRing(); - const focusRingStyle = useAnimatedStyle(() => ({ - opacity: focusedSV.value ? 1 : 0, - })); - - return ( - - - - - - - - - ); -}; - -type MorphingTriggerProps = { - triggerVariant: FloatingActionButtonVariant; - closeVariant: FloatingActionButtonVariant; - triggerContainerColor?: ColorValue; - triggerContentColor?: ColorValue; - size: FloatingActionButtonSize; - openIcon: IconSource; - closeIcon: IconSource; - expanded: boolean; - /** Whether the trigger FAB is visible; drives the scale/alpha enter/exit. */ - visible: boolean; - horizontalAlignment: 'start' | 'center' | 'end'; - onPress?: (e: GestureResponderEvent) => void; - accessibilityLabel?: string; - theme: InternalTheme; - testID?: string; -}; - -const MorphingTrigger = ({ - triggerVariant, - closeVariant, - triggerContainerColor, - triggerContentColor, - size, - openIcon, - closeIcon, - expanded, - visible, - horizontalAlignment, - onPress, - accessibilityLabel, - theme, - testID, -}: MorphingTriggerProps) => { - const reduceMotion = useReduceMotion(); - - const closedSpec = FloatingActionButtonTokens.sizes[size]; - const closedContainer = closedSpec.container; - const closedIconSize = closedSpec.icon; - const closedBorderRadius = resolveCornerRadius(theme, closedSpec.shape); - - const openContainer = FloatingActionButtonMenuTokens.closeButton.container; - const openIconSize = FloatingActionButtonMenuTokens.closeButton.iconSize; - // Use container/2 (instead of the cornerFull sentinel) as the open radius, - // so the interpolation produces a smooth round-corner morph rather than - // jumping past the visual "circle" threshold almost immediately. - const openBorderRadius = openContainer / 2; - - // Trigger color set (respects user overrides) and close color set (always - // the saturated role color per spec — no overrides). - const triggerColors = resolveColors({ - theme, - variant: triggerVariant, - containerColor: triggerContainerColor, - contentColor: triggerContentColor, - }); - const closeColors = resolveColors({ theme, variant: closeVariant }); - - const progress = useSharedValue(expanded ? 1 : 0); - - React.useEffect(() => { - if (reduceMotion) { - progress.value = expanded ? 1 : 0; - return; - } - // Compose's ToggleFloatingActionButton uses a single FastSpatial spring - // for the full open/close progress (size, corner, color, icon all share - // one timeline). - progress.value = withSpring( - expanded ? 1 : 0, - toRawSpring(theme.motion.spring.fast.spatial) - ); - }, [expanded, theme, reduceMotion, progress]); - - // Derived shared values for the morph shape. Passing them to FabShell as - // individual shared values (rather than packing them into an animated - // style) means FabShell can put each into a single `useAnimatedStyle` with - // no inter-style merge surprises. Explicit deps so toggling `size` while - // the menu is open re-derives immediately — e.g. closed-state values - // change to match the new size's resting shape, and the close-state values - // (always 56 / 28) keep the open shape circular. - const widthShared = useDerivedValue( - () => interpolate(progress.value, [0, 1], [closedContainer, openContainer]), - [closedContainer, openContainer] - ); - const heightShared = useDerivedValue( - () => interpolate(progress.value, [0, 1], [closedContainer, openContainer]), - [closedContainer, openContainer] - ); - const borderRadiusShared = useDerivedValue( - () => - interpolate( - progress.value, - [0, 1], - [closedBorderRadius, openBorderRadius] - ), - [closedBorderRadius, openBorderRadius] - ); - - const openPlaneStyle = useAnimatedStyle(() => ({ - opacity: 1 - progress.value, - })); - const closePlaneStyle = useAnimatedStyle(() => ({ - opacity: progress.value, - })); - - // Outer slot is fixed at the trigger's resting size; the FAB itself - // shrinks toward the top-{start|center|end} corner of that slot when - // expanded (only meaningful for medium / large sizes). - const slotAlign: 'flex-start' | 'center' | 'flex-end' = - horizontalAlignment === 'start' - ? 'flex-start' - : horizontalAlignment === 'center' - ? 'center' - : 'flex-end'; - - return ( - - - - - - } - theme={theme} - > - - - - - - - - - - - ); -}; - -/** - * Floating action button menu. Wraps a trigger FAB; when `expanded` is true, - * items appear stacked above and the trigger morphs into the spec'd close - * button (`shape: 'full'`, 56 dp, saturated role color). - * - * No visual backdrop and no outside-tap dismiss — that matches the MD3 spec - * and lets the user keep interacting with the content underneath. Dismiss - * via the close button or by tapping an item. - * - * ## Usage - * ```tsx - * const [open, setOpen] = React.useState(false); - * - * - * setOpen(false)} - * button={ - * setOpen(true)} - * /> - * } - * > - * {}} - * /> - * {}} - * /> - * - * - * ``` - */ -const FloatingActionButtonMenu = ({ - expanded, - onDismiss, - button, - horizontalAlignment = 'end', - closeIcon = 'close', - children, - testID = 'floating-action-button-menu', - theme: themeOverrides, -}: FloatingActionButtonMenuProps) => { - const theme = useInternalTheme(themeOverrides); - const { direction } = useLocale(); - const isRTL = direction === 'rtl'; - const insets = useSafeAreaInsets(); - - const items = React.Children.toArray(children) - .filter( - (child): child is React.ReactElement => - React.isValidElement(child) && - child.type === FloatingActionButtonMenuItem - ) - .map((child) => child.props); - - if ( - process.env.NODE_ENV !== 'production' && - (items.length < 2 || items.length > 6) - ) { - console.warn( - `FloatingActionButtonMenu expects 2 to 6 items; received ${items.length}.` - ); - } - - const buttonProps: ButtonExtractableProps = React.isValidElement(button) - ? (button.props as ButtonExtractableProps) - : {}; - const triggerVariant: FloatingActionButtonVariant = - buttonProps.variant ?? 'tonalPrimary'; - const size: FloatingActionButtonSize = buttonProps.size ?? 'default'; - const openIcon: IconSource = buttonProps.icon ?? 'plus'; - const openOnPress = buttonProps.onPress; - const triggerVisible = buttonProps.visible ?? true; - const closeVariant = getCloseVariant(triggerVariant); - const itemsVariant = getItemsVariant(triggerVariant); - - // When the trigger isn't visible, items don't either; they share the - // FAB's enter/exit. - const effectiveExpanded = triggerVisible && expanded; - - const handleItemPress = - (item: FloatingActionButtonMenuItemProps) => (e: GestureResponderEvent) => { - item.onPress(e); - onDismiss?.(); - }; - - const alignment: 'flex-start' | 'center' | 'flex-end' = - horizontalAlignment === 'start' - ? 'flex-start' - : horizontalAlignment === 'center' - ? 'center' - : 'flex-end'; - // Per-item motion is purely horizontal (matches Compose's width animation); - // the bottom-up feel comes from the stagger order, not the scale anchor. - // RN auto-mirrors `left`/`right` position styles in RTL, so the items - // container visually moves to the opposite edge — but `transformOrigin` is - // a transform property and is NOT auto-flipped. Invert the mapping in RTL - // so each item still scales out from the screen-edge side rather than the - // screen-center side. - const itemTransformOrigin: 'left' | 'center' | 'right' = - horizontalAlignment === 'start' - ? isRTL - ? 'right' - : 'left' - : horizontalAlignment === 'center' - ? 'center' - : isRTL - ? 'left' - : 'right'; - // The trigger's slot is fixed at the original FAB's size. The close button - // (always 56 dp) anchors to the top of this slot when expanded. - const triggerSlotSize = FloatingActionButtonTokens.sizes[size].container; - - return ( - - - {/* Absolutely positioned above the trigger so item layout (and the - scaleX bounce on each item) never affects the trigger's position - — no vertical spring. The items container sits closeToLastItem - above the original FAB slot. */} - - {items.map((item, index) => { - const isLast = index === items.length - 1; - return ( - - - - ); - })} - - - - - ); -}; - -FloatingActionButtonMenu.Item = FloatingActionButtonMenuItem; -FloatingActionButtonMenu.displayName = 'FloatingActionButtonMenu'; - -const styles = StyleSheet.create({ - container: { - ...StyleSheet.absoluteFill, - justifyContent: 'flex-end', - pointerEvents: 'box-none', - }, - stack: { - flexDirection: 'column', - pointerEvents: 'box-none', - }, - items: { - position: 'absolute', - flexDirection: 'column', - pointerEvents: 'box-none', - }, - itemsStart: { - left: 0, - }, - itemsCenter: { - left: 0, - right: 0, - }, - itemsEnd: { - right: 0, - }, - menuItemWrapper: { - position: 'relative', - }, - menuItem: { - overflow: 'hidden', - }, - menuItemFocusRing: { - position: 'absolute', - top: -FOCUS_RING_INSET, - left: -FOCUS_RING_INSET, - right: -FOCUS_RING_INSET, - bottom: -FOCUS_RING_INSET, - borderWidth: FOCUS_RING_THICKNESS, - pointerEvents: 'none', - }, - triggerSlot: { - justifyContent: 'flex-start', - }, - colorPlane: { - ...StyleSheet.absoluteFill, - pointerEvents: 'none', - }, - iconStackContainer: { - flex: 1, - pointerEvents: 'none', - }, - iconStack: { - ...StyleSheet.absoluteFill, - alignItems: 'center', - justifyContent: 'center', - pointerEvents: 'none', - }, - pointerEventsAuto: { - pointerEvents: 'auto', - }, - pointerEventsNone: { - pointerEvents: 'none', - }, - pointerEventsBoxNone: { - pointerEvents: 'box-none', - }, -}); - -export default FloatingActionButtonMenu; - -// @component-docs ignore-next-line -export { FloatingActionButtonMenu }; diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx index 58a75291cf..0af09b5eb5 100644 --- a/src/components/FAB/Menu.tsx +++ b/src/components/FAB/Menu.tsx @@ -1,6 +1,11 @@ import * as React from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; -import type { ColorValue, GestureResponderEvent } from 'react-native'; +import { + ColorValue, + GestureResponderEvent, + Platform, + StyleSheet, + View, +} from 'react-native'; import Animated, { interpolate, @@ -16,12 +21,13 @@ import Content from './Content'; import Shell from './Shell'; import { MenuTokens, + Size, Tokens, + Variant, FOCUS_RING_INSET, FOCUS_RING_THICKNESS, webNoOutline, } from './tokens'; -import type { Size, Variant } from './tokens'; import { useFocusRing } from './useFocusRing'; import { resolveColors } from './utils'; import { useLocale } from '../../core/locale'; @@ -30,8 +36,7 @@ import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import { resolveCornerRadius } from '../../theme/utils/shape'; import type { InternalTheme, ThemeProp } from '../../types'; -import Icon from '../Icon'; -import type { IconSource } from '../Icon'; +import Icon, { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; export type MenuItemProps = { @@ -51,7 +56,7 @@ export type MenuItemProps = { /** * Accessibility label. Falls back to `label`. */ - 'aria-label'?: string; + accessibilityLabel?: string; testID?: string; }; @@ -67,10 +72,7 @@ export type MenuTriggerProps = { contentColor?: ColorValue; visible?: boolean; onPress?: (e: GestureResponderEvent) => void; - /** - * Accessibility label for the trigger FAB. - */ - 'aria-label'?: string; + accessibilityLabel?: string; testID?: string; }; @@ -209,7 +211,7 @@ const AnimatedItem = ({ expanded ? styles.pointerEventsAuto : styles.pointerEventsNone, ]} importantForAccessibility={expanded ? 'yes' : 'no-hide-descendants'} - aria-hidden={!expanded} + accessibilityElementsHidden={!expanded} > {children} @@ -222,10 +224,7 @@ type ItemProps = { variant: Variant; theme: InternalTheme; onPress: (e: GestureResponderEvent) => void; - /** - * Accessibility label. Falls back to `label`. - */ - 'aria-label'?: string; + accessibilityLabel?: string; testID?: string; }; @@ -241,7 +240,7 @@ const MenuItem = ({ variant, theme, onPress, - 'aria-label': ariaLabel, + accessibilityLabel, testID, }: ItemProps) => { const colors = resolveColors({ theme, variant }); @@ -267,8 +266,8 @@ const MenuItem = ({ onPress={onPress} onFocus={onFocus} onBlur={onBlur} - role="button" - aria-label={ariaLabel ?? label} + accessibilityRole="button" + accessibilityLabel={accessibilityLabel ?? label} style={[ { borderRadius }, Platform.OS === 'web' ? webNoOutline : null, @@ -315,10 +314,7 @@ type MorphingTriggerProps = { visible: boolean; alignment: 'start' | 'center' | 'end'; onPress?: (e: GestureResponderEvent) => void; - /** - * Accessibility label for the trigger button. - */ - 'aria-label'?: string; + accessibilityLabel?: string; theme: InternalTheme; testID?: string; }; @@ -335,7 +331,7 @@ const MorphingTrigger = ({ visible, alignment, onPress, - 'aria-label': ariaLabel, + accessibilityLabel, theme, testID, }: MorphingTriggerProps) => { @@ -432,8 +428,8 @@ const MorphingTrigger = ({ alignment === 'start' ? 'flex-start' : alignment === 'center' - ? 'center' - : 'flex-end'; + ? 'center' + : 'flex-end'; return ( @@ -645,7 +641,7 @@ const Menu = ({ visible={triggerVisible} alignment={alignment} onPress={effectiveExpanded ? onDismiss : openOnPress} - aria-label={trigger['aria-label']} + accessibilityLabel={trigger.accessibilityLabel} theme={theme} testID={trigger.testID} /> diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx index 2d2d60e4cd..b748b11e40 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -1,34 +1,39 @@ import * as React from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; -import type { +import { + AccessibilityState, ColorValue, GestureResponderEvent, + Platform, PressableAndroidRippleConfig, StyleProp, + StyleSheet, + View, ViewStyle, } from 'react-native'; import Reanimated, { + AnimatedStyle, useAnimatedStyle, useSharedValue, + type SharedValue, } from 'react-native-reanimated'; -import type { SharedValue } from 'react-native-reanimated'; -import type { AnimatedStyle } from 'react-native-reanimated'; import Content from './Content'; import { + Size, Tokens, + Variant, FOCUS_RING_INSET, FOCUS_RING_THICKNESS, webNoOutline, } from './tokens'; -import type { Size, Variant } from './tokens'; import { useFocusRing } from './useFocusRing'; import { useVisibility } from './useVisibility'; import { getDimensions, resolveColors } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ShapeToken } from '../../theme/utils/shape'; import type { Elevation, ThemeProp } from '../../types'; +import { forwardRef } from '../../utils/forwardRef'; import type { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -91,24 +96,11 @@ export type ShellProps = { /** * Accessibility label. Falls back to `label` if unset. */ - 'aria-label'?: string; - /** - * Indicates whether the element is checked. Accepts `true`, `false`, - * or `'mixed'` for an indeterminate state. - */ - 'aria-checked'?: boolean | 'mixed'; - /** - * Indicates whether the element is selected. - */ - 'aria-selected'?: boolean; - /** - * Indicates whether the element is currently busy (e.g. loading). - */ - 'aria-busy'?: boolean; + accessibilityLabel?: string; /** - * Indicates whether the element's controlled content is expanded. + * Accessibility state forwarded to the underlying button. */ - 'aria-expanded'?: boolean; + accessibilityState?: AccessibilityState; /** * Largest scale the label font can reach (auto-built content only). */ @@ -168,7 +160,7 @@ export type ShellProps = { * @optional */ theme?: ThemeProp; - ref?: React.Ref; + ref?: React.RefObject; }; /** @@ -180,173 +172,173 @@ export type ShellProps = { * * Not exported from the package. */ -const Shell = ({ - icon, - label, - variant = 'tonalPrimary', - size = 'default', - containerColor, - contentColor, - shape, - iconSize, - leading, - trailing, - elevation = Tokens.stateElevation.enabled, - visible = true, - onPress, - 'aria-label': ariaLabel = label, - 'aria-checked': ariaChecked, - 'aria-selected': ariaSelected, - 'aria-busy': ariaBusy, - 'aria-expanded': ariaExpanded, - labelMaxFontSizeMultiplier, - labelAnimatedStyle, - background, - widthShared, - heightShared, - borderRadiusShared, - transparentBackground = false, - overlay, - children, - style, - testID = 'fab-shell', - theme: themeOverrides, - ref, -}: ShellProps) => { - const theme = useInternalTheme(themeOverrides); +const Shell = forwardRef( + ( + { + icon, + label, + variant = 'tonalPrimary', + size = 'default', + containerColor, + contentColor, + shape, + iconSize, + leading, + trailing, + elevation = Tokens.stateElevation.enabled, + visible = true, + onPress, + accessibilityLabel = label, + accessibilityState, + labelMaxFontSizeMultiplier, + labelAnimatedStyle, + background, + widthShared, + heightShared, + borderRadiusShared, + transparentBackground = false, + overlay, + children, + style, + testID = 'fab-shell', + theme: themeOverrides, + }, + ref + ) => { + const theme = useInternalTheme(themeOverrides); - const dimensions = React.useMemo( - () => getDimensions({ theme, size, shape, iconSize, leading, trailing }), - [theme, size, shape, iconSize, leading, trailing] - ); + const dimensions = React.useMemo( + () => getDimensions({ theme, size, shape, iconSize, leading, trailing }), + [theme, size, shape, iconSize, leading, trailing] + ); - const colors = React.useMemo( - () => resolveColors({ theme, variant, containerColor, contentColor }), - [theme, variant, containerColor, contentColor] - ); + const colors = React.useMemo( + () => resolveColors({ theme, variant, containerColor, contentColor }), + [theme, variant, containerColor, contentColor] + ); - const { scale, alpha, shadowStyle } = useVisibility({ - visible, - theme, - elevation, - }); + const { scale, alpha, shadowStyle } = useVisibility({ + visible, + theme, + elevation, + }); - // Fallback shared values track the static size-driven dimensions. Consumers - // that don't supply their own animated shared values get these. Keeping - // everything as a shared value means there's exactly one animated style - // per view — no static-vs-animated merge surprises. - const fallbackWidth = useSharedValue(dimensions.width); - const fallbackHeight = useSharedValue(dimensions.height); - const fallbackBorderRadius = useSharedValue(dimensions.borderRadius); - React.useEffect(() => { - fallbackWidth.value = dimensions.width; - fallbackHeight.value = dimensions.height; - fallbackBorderRadius.value = dimensions.borderRadius; - }, [ - dimensions.width, - dimensions.height, - dimensions.borderRadius, - fallbackWidth, - fallbackHeight, - fallbackBorderRadius, - ]); + // Fallback shared values track the static size-driven dimensions. Consumers + // that don't supply their own animated shared values get these. Keeping + // everything as a shared value means there's exactly one animated style + // per view — no static-vs-animated merge surprises. + const fallbackWidth = useSharedValue(dimensions.width); + const fallbackHeight = useSharedValue(dimensions.height); + const fallbackBorderRadius = useSharedValue(dimensions.borderRadius); + React.useEffect(() => { + fallbackWidth.value = dimensions.width; + fallbackHeight.value = dimensions.height; + fallbackBorderRadius.value = dimensions.borderRadius; + }, [ + dimensions.width, + dimensions.height, + dimensions.borderRadius, + fallbackWidth, + fallbackHeight, + fallbackBorderRadius, + ]); - const width = widthShared ?? fallbackWidth; - const height = heightShared ?? fallbackHeight; - const borderRadius = borderRadiusShared ?? fallbackBorderRadius; - const containerBg = transparentBackground ? 'transparent' : colors.container; + const width = widthShared ?? fallbackWidth; + const height = heightShared ?? fallbackHeight; + const borderRadius = borderRadiusShared ?? fallbackBorderRadius; + const containerBg = transparentBackground + ? 'transparent' + : colors.container; - const outerStyle = useAnimatedStyle( - () => ({ - transform: [{ scale: scale.value }], - opacity: alpha.value, - width: width.value, - height: height.value, - borderRadius: borderRadius.value, - backgroundColor: containerBg, - }), - [width, height, borderRadius, containerBg] - ); + const outerStyle = useAnimatedStyle( + () => ({ + transform: [{ scale: scale.value }], + opacity: alpha.value, + width: width.value, + height: height.value, + borderRadius: borderRadius.value, + backgroundColor: containerBg, + }), + [width, height, borderRadius, containerBg] + ); - const clipStyle = useAnimatedStyle( - () => ({ - borderRadius: borderRadius.value, - backgroundColor: containerBg, - }), - [borderRadius, containerBg] - ); + const clipStyle = useAnimatedStyle( + () => ({ + borderRadius: borderRadius.value, + backgroundColor: containerBg, + }), + [borderRadius, containerBg] + ); - const { focusedSV, onFocus, onBlur } = useFocusRing(); - const focusRingStyle = useAnimatedStyle( - () => ({ - opacity: focusedSV.value ? 1 : 0, - borderRadius: borderRadius.value + FOCUS_RING_INSET, - }), - [borderRadius] - ); + const { focusedSV, onFocus, onBlur } = useFocusRing(); + const focusRingStyle = useAnimatedStyle( + () => ({ + opacity: focusedSV.value ? 1 : 0, + borderRadius: borderRadius.value + FOCUS_RING_INSET, + }), + [borderRadius] + ); - return ( - - - {overlay} - - {children ?? ( - - )} - - + return ( - - ); -}; + testID={`${testID}-container`} + > + + {overlay} + + {children ?? ( + + )} + + + + + ); + } +); const styles = StyleSheet.create({ container: { diff --git a/src/components/FAB/tokens.ts b/src/components/FAB/tokens.ts index 0327eadc65..73ef1e9ff8 100644 --- a/src/components/FAB/tokens.ts +++ b/src/components/FAB/tokens.ts @@ -8,7 +8,7 @@ import type { TypescaleKey, } from '../../theme/types'; -export type FloatingActionButtonVariant = +export type Variant = | 'primary' | 'secondary' | 'tertiary' @@ -16,7 +16,7 @@ export type FloatingActionButtonVariant = | 'tonalSecondary' | 'tonalTertiary'; -export type FloatingActionButtonSize = 'default' | 'medium' | 'large'; +export type Size = 'default' | 'medium' | 'large'; type SizeSpec = { container: number; @@ -58,7 +58,7 @@ const sizes = { iconLabelGap: 16, labelTypescale: 'headlineSmall', }, -} as const satisfies Record; +} as const satisfies Record; const stateElevation = { enabled: 3, @@ -84,11 +84,11 @@ const variants = { content: 'onTertiaryContainer', }, } as const satisfies Record< - FloatingActionButtonVariant, + Variant, { container: ColorRole; content: ColorRole } >; -export const FloatingActionButtonTokens = { +export const Tokens = { sizes, stateElevation, variants, @@ -114,7 +114,7 @@ const spacing = { closeToLastItem: 8, } as const; -export const FloatingActionButtonMenuTokens = { +export const MenuTokens = { closeButton, listItem, spacing, diff --git a/src/components/FAB/useFabVisibility.ts b/src/components/FAB/useFabVisibility.ts deleted file mode 100644 index a3cb6f89ce..0000000000 --- a/src/components/FAB/useFabVisibility.ts +++ /dev/null @@ -1,95 +0,0 @@ -import * as React from 'react'; -import { Platform, type ViewStyle } from 'react-native'; - -import { - useAnimatedStyle, - useSharedValue, - withSpring, - type AnimatedStyle, - type SharedValue, -} from 'react-native-reanimated'; - -import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; -import { - androidElevationLevels, - shadowLayers, -} from '../../theme/tokens/sys/elevation'; -import { toRawSpring } from '../../theme/tokens/sys/motion'; -import type { Elevation, InternalTheme } from '../../types'; - -type UseFabVisibilityArgs = { - visible: boolean; - theme: InternalTheme; - initialScale?: number; - transformOrigin?: ViewStyle['transformOrigin']; - /** - * Elevation level when shown. Shadow fades in/out with the FAB. - */ - elevation?: Elevation; -}; - -type UseFabVisibilityResult = { - scale: SharedValue; - alpha: SharedValue; - transformOrigin: ViewStyle['transformOrigin']; - shadowStyle: AnimatedStyle; -}; - -const isAndroid = Platform.OS === 'android'; - -/** - * Animates a FAB in and out: scale + alpha together. - * Reduce-motion: snap to the final value, no animation. - * - * Returns `shadowStyle` too. Put it on the same view as the transform so the - * shadow stays in sync (Android uses `elevation`, iOS/web uses `shadow*`). - */ -export function useFabVisibility({ - visible, - theme, - initialScale = 0, - transformOrigin = 'center', - elevation = 0, -}: UseFabVisibilityArgs): UseFabVisibilityResult { - const reduceMotion = useReduceMotion(); - const scale = useSharedValue(visible ? 1 : initialScale); - const alpha = useSharedValue(visible ? 1 : 0); - - React.useEffect(() => { - const targetScale = visible ? 1 : initialScale; - const targetAlpha = visible ? 1 : 0; - if (reduceMotion) { - scale.value = targetScale; - alpha.value = targetAlpha; - return; - } - scale.value = withSpring( - targetScale, - toRawSpring(theme.motion.spring.fast.spatial) - ); - alpha.value = withSpring( - targetAlpha, - toRawSpring(theme.motion.spring.fast.effects) - ); - }, [visible, theme, reduceMotion, scale, alpha, initialScale]); - - const restingElevationDp = androidElevationLevels[elevation]; - const restingShadowOpacity = elevation ? shadowLayers[0].shadowOpacity : 0; - const shadowOffsetHeight = shadowLayers[0].height[elevation]; - const shadowRadius = shadowLayers[0].shadowRadius[elevation]; - const shadowColor = theme.colors.shadow; - - const shadowStyle = useAnimatedStyle(() => { - if (isAndroid) { - return { elevation: alpha.value * restingElevationDp }; - } - return { - shadowColor, - shadowOpacity: alpha.value * restingShadowOpacity, - shadowOffset: { width: 0, height: shadowOffsetHeight }, - shadowRadius, - }; - }); - - return { scale, alpha, transformOrigin, shadowStyle }; -} diff --git a/src/components/FAB/utils.ts b/src/components/FAB/utils.ts index 3cee69f370..8e7c83eddb 100644 --- a/src/components/FAB/utils.ts +++ b/src/components/FAB/utils.ts @@ -1,12 +1,8 @@ -import { RefObject } from 'react'; -import { ColorValue, Platform } from 'react-native'; +import { ColorValue } from 'react-native'; -import { - FloatingActionButtonSize, - FloatingActionButtonTokens, - FloatingActionButtonVariant, -} from './tokens'; +import { Size, Tokens, Variant } from './tokens'; import type { TypescaleKey } from '../../theme/types'; +import { contentColorFor } from '../../theme/utils/color'; import { resolveCornerRadius, ShapeToken } from '../../theme/utils/shape'; import type { InternalTheme } from '../../types'; @@ -23,15 +19,21 @@ export type ResolvedColors = { export const resolveColors = ({ theme, variant = 'tonalPrimary', + containerColor, + contentColor, }: { theme: InternalTheme; - variant?: FloatingActionButtonVariant; + variant?: Variant; containerColor?: ColorValue; contentColor?: ColorValue; }): ResolvedColors => { - const roles = FloatingActionButtonTokens.variants[variant]; - const container = theme.colors[roles.container]; - const content = theme.colors[roles.content]; + const roles = Tokens.variants[variant]; + const container = containerColor ?? theme.colors[roles.container]; + const content = + contentColor ?? + (containerColor != null + ? contentColorFor(theme, container) + : theme.colors[roles.content]); return { container, content }; }; @@ -60,13 +62,13 @@ export const getDimensions = ({ trailing, }: { theme: InternalTheme; - size?: FloatingActionButtonSize; + size?: Size; shape?: ShapeToken; iconSize?: number; leading?: number; trailing?: number; }): Dimensions => { - const spec = FloatingActionButtonTokens.sizes[size]; + const spec = Tokens.sizes[size]; const shapeToken: ShapeToken = shape ?? spec.shape; return { height: spec.container, @@ -79,40 +81,3 @@ export const getDimensions = ({ labelTypescale: spec.labelTypescale, }; }; - -export const getLabelSizeWeb = (ref: RefObject) => { - if (Platform.OS !== 'web' || ref.current === null) { - return null; - } - - const canvasContext = getCanvasContext(); - - if (!canvasContext) { - return null; - } - - const elementStyles = window.getComputedStyle(ref.current); - canvasContext.font = elementStyles.font; - - const metrics = canvasContext.measureText(ref.current.innerText); - - return { - width: metrics.width, - height: - (metrics.fontBoundingBoxAscent ?? 0) + - (metrics.fontBoundingBoxDescent ?? 0), - }; -}; - -let cachedContext: CanvasRenderingContext2D | null = null; - -const getCanvasContext = () => { - if (cachedContext) { - return cachedContext; - } - - const canvas = document.createElement('canvas'); - cachedContext = canvas.getContext('2d'); - - return cachedContext; -}; diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx new file mode 100644 index 0000000000..c357914d96 --- /dev/null +++ b/src/components/__tests__/FAB.test.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; + +import { fireEvent } from '@testing-library/react-native'; + +import { render } from '../../test-utils'; +import FAB from '../FAB'; + +it('renders FAB with default props', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders FAB with primary variant', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders FAB with secondary variant', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders FAB with tertiary variant', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders FAB with tonalSecondary variant', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders FAB with tonalTertiary variant', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders FAB medium size', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders FAB large size', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders FAB with containerColor override', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders FAB with containerColor and contentColor overrides', () => { + const tree = render( + + ).toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders FAB with accessibilityLabel', () => { + const tree = render( + + ).toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders FAB transitioning to not visible', () => { + const { update, toJSON } = render(); + update(); + expect(toJSON()).toMatchSnapshot(); +}); + +it('renders FAB transitioning to visible', () => { + const { update, toJSON } = render(); + update(); + expect(toJSON()).toMatchSnapshot(); +}); + +it('calls onPress when FAB is pressed', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + + ); + fireEvent.press(getByTestId('fab')); + expect(onPress).toHaveBeenCalledTimes(1); +}); + +it('forwards event object to onPress', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + + ); + fireEvent(getByTestId('fab'), 'onPress', { key: 'value' }); + expect(onPress).toHaveBeenCalledWith({ key: 'value' }); +}); diff --git a/src/components/__tests__/FABExtended.test.tsx b/src/components/__tests__/FABExtended.test.tsx index fd9a21cec9..ca72466305 100644 --- a/src/components/__tests__/FABExtended.test.tsx +++ b/src/components/__tests__/FABExtended.test.tsx @@ -1,64 +1,55 @@ -import { expect, it, jest } from '@jest/globals'; -import { fireEvent, userEvent } from '@testing-library/react-native'; +import * as React from 'react'; -import { render, screen } from '../../test-utils'; +import { fireEvent } from '@testing-library/react-native'; + +import { render } from '../../test-utils'; import FAB from '../FAB'; -it('renders extended FAB expanded', async () => { - const tree = ( - await render() +it('renders extended FAB expanded', () => { + const tree = render( + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders extended FAB collapsed', async () => { - const tree = ( - await render( - - ) +it('renders extended FAB collapsed', () => { + const tree = render( + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders extended FAB not visible', async () => { - const tree = ( - await render( - - ) +it('renders extended FAB not visible', () => { + const tree = render( + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders extended FAB medium size', async () => { - const tree = ( - await render( - - ) +it('renders extended FAB medium size', () => { + const tree = render( + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders extended FAB large size', async () => { - const tree = ( - await render( - - ) +it('renders extended FAB large size', () => { + const tree = render( + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders extended FAB transitioning to collapsed', async () => { - const { rerender, toJSON } = await render( +it('renders extended FAB transitioning to collapsed', () => { + const { update, toJSON } = render( ); - await rerender( - - ); + update(); expect(toJSON()).toMatchSnapshot(); }); -it('uses label as default aria-label', async () => { - await render( +it('uses label as default accessibilityLabel', () => { + const { getByTestId } = render( { testID="extended-fab" /> ); - - expect(screen.getByLabelText('New message')).toBeOnTheScreen(); + expect(getByTestId('extended-fab').props.accessibilityLabel).toBe( + 'New message' + ); }); -it('respects explicit aria-label', async () => { - await render( +it('respects explicit accessibilityLabel', () => { + const { getByTestId } = render( ); - - expect(screen.getByLabelText('Create new message')).toBeOnTheScreen(); + expect(getByTestId('extended-fab').props.accessibilityLabel).toBe( + 'Create new message' + ); }); -it('calls onPress when pressed', async () => { - const user = userEvent.setup(); +it('calls onPress when pressed', () => { const onPress = jest.fn(); - await render( + const { getByTestId } = render( { testID="extended-fab" /> ); - await user.press(screen.getByRole('button', { name: 'New message' })); + fireEvent.press(getByTestId('extended-fab')); expect(onPress).toHaveBeenCalledTimes(1); }); -it('forwards event object to onPress', async () => { +it('forwards event object to onPress', () => { const onPress = jest.fn(); - await render( + const { getByTestId } = render( { testID="extended-fab" /> ); - await fireEvent( - screen.getByRole('button', { name: 'New message' }), - 'onPress', - { - key: 'value', - } - ); + fireEvent(getByTestId('extended-fab'), 'onPress', { key: 'value' }); expect(onPress).toHaveBeenCalledWith({ key: 'value' }); }); diff --git a/src/components/__tests__/FABMenu.test.tsx b/src/components/__tests__/FABMenu.test.tsx index 50135634d7..cff16cc76f 100644 --- a/src/components/__tests__/FABMenu.test.tsx +++ b/src/components/__tests__/FABMenu.test.tsx @@ -1,131 +1,120 @@ -import { expect, it, jest } from '@jest/globals'; -import { fireEvent, userEvent } from '@testing-library/react-native'; +import * as React from 'react'; -import { render, screen } from '../../test-utils'; +import { fireEvent } from '@testing-library/react-native'; + +import { render } from '../../test-utils'; import FAB from '../FAB'; -import type { MenuItemProps } from '../FAB/Menu'; const makeItems = ( - onItemPress = jest.fn<(event?: unknown) => void>() -): [MenuItemProps, MenuItemProps] => [ + onItemPress = jest.fn() +): [ + { label: string; onPress: jest.Mock; testID: string }, + { label: string; onPress: jest.Mock; testID: string } +] => [ { label: 'Send email', onPress: onItemPress, testID: 'item-0' }, { label: 'Set reminder', onPress: onItemPress, testID: 'item-1' }, ]; -it('renders FAB.Menu closed', async () => { - const tree = ( - await render( - - ) +it('renders FAB.Menu closed', () => { + const tree = render( + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB.Menu open', async () => { - const tree = ( - await render( - - ) +it('renders FAB.Menu open', () => { + const tree = render( + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB.Menu with 6 items', async () => { - const tree = ( - await render( - - ) +it('renders FAB.Menu with 6 items', () => { + const tree = render( + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB.Menu with start alignment', async () => { - const tree = ( - await render( - - ) +it('renders FAB.Menu with start alignment', () => { + const tree = render( + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB.Menu with center alignment', async () => { - const tree = ( - await render( - - ) +it('renders FAB.Menu with center alignment', () => { + const tree = render( + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB.Menu with items having icons', async () => { - const tree = ( - await render( - - ) +it('renders FAB.Menu with items having icons', () => { + const tree = render( + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB.Menu not expanded when trigger is not visible', async () => { - const tree = ( - await render( - - ) +it('renders FAB.Menu not expanded when trigger is not visible', () => { + const tree = render( + ).toJSON(); // effectiveExpanded = visible && expanded = false expect(tree).toMatchSnapshot(); }); -it('calls item onPress when menu item is pressed', async () => { +it('calls item onPress when menu item is pressed', () => { const onItemPress = jest.fn(); - await render( + const { getByTestId } = render( { items={makeItems(onItemPress)} /> ); - await userEvent.press(screen.getByTestId('item-0')); + fireEvent.press(getByTestId('item-0')); expect(onItemPress).toHaveBeenCalledTimes(1); }); -it('forwards event object to item onPress', async () => { +it('forwards event object to item onPress', () => { const onItemPress = jest.fn(); - await render( + const { getByTestId } = render( { items={makeItems(onItemPress)} /> ); - await fireEvent(screen.getByTestId('item-0'), 'onPress', { key: 'value' }); + fireEvent(getByTestId('item-0'), 'onPress', { key: 'value' }); expect(onItemPress).toHaveBeenCalledWith({ key: 'value' }); }); -it('calls onDismiss when menu item is pressed', async () => { +it('calls onDismiss when menu item is pressed', () => { const onDismiss = jest.fn(); - await render( + const { getByTestId } = render( { items={makeItems()} /> ); - await userEvent.press(screen.getByTestId('item-0')); + fireEvent.press(getByTestId('item-0')); expect(onDismiss).toHaveBeenCalledTimes(1); }); -it('calls trigger onPress when menu is closed', async () => { +it('calls trigger onPress when menu is closed', () => { const onTriggerPress = jest.fn(); - await render( + const { getByTestId } = render( { /> ); // Shell's TouchableRipple uses the default testID 'fab-shell' - await userEvent.press(screen.getByTestId('fab-shell')); + fireEvent.press(getByTestId('fab-shell')); expect(onTriggerPress).toHaveBeenCalledTimes(1); }); -it('calls onDismiss when trigger is pressed while menu is open', async () => { +it('calls onDismiss when trigger is pressed while menu is open', () => { const onDismiss = jest.fn(); - await render( + const { getByTestId } = render( { items={makeItems()} /> ); - await userEvent.press(screen.getByTestId('fab-shell')); + fireEvent.press(getByTestId('fab-shell')); expect(onDismiss).toHaveBeenCalledTimes(1); }); diff --git a/src/components/__tests__/FABUtils.test.tsx b/src/components/__tests__/FABUtils.test.tsx index b72de366f7..af00e98f1c 100644 --- a/src/components/__tests__/FABUtils.test.tsx +++ b/src/components/__tests__/FABUtils.test.tsx @@ -1,5 +1,3 @@ -import { describe, expect, it } from '@jest/globals'; - import { getTheme } from '../../core/theming'; import { getDimensions, resolveColors } from '../FAB/utils'; diff --git a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap new file mode 100644 index 0000000000..56dd997edc --- /dev/null +++ b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap @@ -0,0 +1,2238 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders FAB large size 1`] = ` + + + + + + plus + + + + + + +`; + +exports[`renders FAB medium size 1`] = ` + + + + + + plus + + + + + + +`; + +exports[`renders FAB transitioning to not visible 1`] = ` + + + + + + plus + + + + + + +`; + +exports[`renders FAB transitioning to visible 1`] = ` + + + + + + plus + + + + + + +`; + +exports[`renders FAB with accessibilityLabel 1`] = ` + + + + + + plus + + + + + + +`; + +exports[`renders FAB with containerColor and contentColor overrides 1`] = ` + + + + + + plus + + + + + + +`; + +exports[`renders FAB with containerColor override 1`] = ` + + + + + + plus + + + + + + +`; + +exports[`renders FAB with default props 1`] = ` + + + + + + plus + + + + + + +`; + +exports[`renders FAB with primary variant 1`] = ` + + + + + + plus + + + + + + +`; + +exports[`renders FAB with secondary variant 1`] = ` + + + + + + plus + + + + + + +`; + +exports[`renders FAB with tertiary variant 1`] = ` + + + + + + plus + + + + + + +`; + +exports[`renders FAB with tonalSecondary variant 1`] = ` + + + + + + plus + + + + + + +`; + +exports[`renders FAB with tonalTertiary variant 1`] = ` + + + + + + plus + + + + + + +`; diff --git a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap index 2ceb981d60..48d20d29eb 100644 --- a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders extended FAB collapsed 1`] = ` -<> +[ - + , New message - - + , +] `; exports[`renders extended FAB expanded 1`] = ` -<> +[ - + , New message - - + , +] `; exports[`renders extended FAB large size 1`] = ` -<> +[ - + , New message - - + , +] `; exports[`renders extended FAB medium size 1`] = ` -<> +[ - + , New message - - + , +] `; exports[`renders extended FAB not visible 1`] = ` -<> +[ - + , New message - - + , +] `; exports[`renders extended FAB transitioning to collapsed 1`] = ` -<> +[ - + , New message - - + , +] `; diff --git a/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap b/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap index 726f4ef1b2..aadf81f409 100644 --- a/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap @@ -55,7 +55,7 @@ exports[`renders FAB.Menu closed 1`] = ` } > Date: Wed, 17 Jun 2026 13:58:14 +0200 Subject: [PATCH 09/29] refactor(tooltip): drop redundant token type annotations Remove the `satisfies` blocks duplicating the token object shapes (the color roles are already type-checked at the usage sites) and the unused `action` color token. Re #4994 --- src/components/Tooltip/tokens.ts | 34 ++------------------------------ 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/src/components/Tooltip/tokens.ts b/src/components/Tooltip/tokens.ts index a4e863431a..eddf391669 100644 --- a/src/components/Tooltip/tokens.ts +++ b/src/components/Tooltip/tokens.ts @@ -1,12 +1,3 @@ -import type { - ColorRole, - Elevation, - ThemeShapeCorners, - TypescaleKey, -} from '../../theme/types'; - -type ShapeKey = keyof ThemeShapeCorners; - /** * Plain tooltip — a single line of text on an inverse-surface container. * https://m3.material.io/components/tooltips/specs#1e6d4d8a @@ -18,14 +9,7 @@ const plain = { height: 32, paddingHorizontal: 16, typescale: 'bodySmall', -} as const satisfies { - container: ColorRole; - content: ColorRole; - shape: ShapeKey; - height: number; - paddingHorizontal: number; - typescale: TypescaleKey; -}; +} as const; /** * Rich tooltip — an optional subhead, supporting text and action buttons on a @@ -36,7 +20,6 @@ const rich = { container: 'surfaceContainer', title: 'onSurface', content: 'onSurfaceVariant', - action: 'primary', shape: 'medium', elevation: 2, maxWidth: 312, @@ -45,20 +28,7 @@ const rich = { titleTypescale: 'titleSmall', contentTypescale: 'bodyMedium', gap: 4, -} as const satisfies { - container: ColorRole; - title: ColorRole; - content: ColorRole; - action: ColorRole; - shape: ShapeKey; - elevation: Elevation; - maxWidth: number; - paddingHorizontal: number; - paddingVertical: number; - titleTypescale: TypescaleKey; - contentTypescale: TypescaleKey; - gap: number; -}; +} as const; /** * Fade transition on show/hide. Keys are resolved against `theme.motion` at From dd0633eeee967417372a61ead801863cf803aee4 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 17 Jun 2026 14:25:43 +0200 Subject: [PATCH 10/29] refactor(tooltip): render-prop API for Tooltip.Rich Replace React.cloneElement and children.props reading with a render-prop trigger and a render-prop `actions` that receives a `dismiss` callback. This is type-safe and composable, lets the consumer own the trigger element (so disabled state isn't forced), wires web hover + keyboard focus, and removes the onTouchEnd-based action dismissal that didn't fire for click/keyboard. Re #4994 --- example/src/Examples/TooltipExample.tsx | 14 ++- src/components/Tooltip/RichTooltip.tsx | 129 +++++++++++----------- src/components/Tooltip/Tooltip.tsx | 3 +- src/components/Tooltip/utils.ts | 8 +- src/components/__tests__/Tooltip.test.tsx | 29 ++++- src/index.tsx | 5 +- 6 files changed, 102 insertions(+), 86 deletions(-) diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx index 59a3b6bbef..0e86faed5e 100644 --- a/example/src/Examples/TooltipExample.tsx +++ b/example/src/Examples/TooltipExample.tsx @@ -152,21 +152,23 @@ const TooltipExample = () => { ( <> - - - } + )} > - {}} /> + {(props) => } - {}} /> + {(props) => ( + + )} diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index 9e97ba7bbe..0dc4a0a89b 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -12,7 +12,8 @@ import Animated from 'react-native-reanimated'; import { useTooltipFade } from './hooks'; import { Tokens } from './tokens'; -import { getTooltipPosition, Measurement, TooltipChildProps } from './utils'; +import { getTooltipPosition } from './utils'; +import type { Measurement } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import { addEventListener } from '../../utils/addEventListener'; @@ -20,11 +21,30 @@ import Portal from '../Portal/Portal'; import Surface from '../Surface'; import Text from '../Typography/Text'; +/** + * Props passed to the `children` render function. Spread them onto the trigger + * element (and merge with your own handlers when you have them). + */ +export type TooltipRichTriggerProps = { + onPress?: () => void; + onHoverIn?: () => void; + onHoverOut?: () => void; + onFocus?: () => void; + onBlur?: () => void; +}; + export type Props = { /** - * Tooltip reference element. Needs to be able to hold a ref. + * Render function returning the trigger element. The provided props wire the + * tooltip's show/hide behavior and must be spread onto the returned element: + * + * ```js + * + * {(props) => } + * + * ``` */ - children: React.ReactElement; + children: (props: TooltipRichTriggerProps) => React.ReactElement; /** * Optional subhead shown above the content. */ @@ -35,10 +55,16 @@ export type Props = { */ content: string | React.ReactElement; /** - * Action buttons (and/or links) rendered in a row below the content. - * Pressing one dismisses the tooltip. + * Render function for the action buttons (and/or links) shown in a row below + * the content. Call `dismiss` from an action to hide the tooltip: + * + * ```js + * actions={({ dismiss }) => ( + * + * )} + * ``` */ - actions?: React.ReactNode; + actions?: (props: { dismiss: () => void }) => React.ReactNode; /** * The number of milliseconds a user must hover the element before showing * the tooltip (web only). @@ -67,7 +93,7 @@ export type Props = { * Rich tooltips display informative text along with an optional subhead and * action buttons. Unlike plain tooltips they are persistent and interactive: * tap the element to toggle the tooltip, then tap outside or an action to - * dismiss it. + * dismiss it. On web they open on hover and on keyboard focus. * * ## Usage * ```js @@ -78,9 +104,13 @@ export type Props = { * Learn more} + * actions={({ dismiss }) => ( + * + * )} * > - * {}} /> + * {(props) => } * * ); * @@ -97,7 +127,6 @@ const RichTooltip = ({ titleMaxFontSizeMultiplier, contentMaxFontSizeMultiplier, theme: themeOverrides, - ...rest }: Props) => { const isWeb = Platform.OS === 'web'; @@ -111,11 +140,6 @@ const RichTooltip = ({ const showTooltipTimer = React.useRef([]); const hideTooltipTimer = React.useRef([]); - const isValidChild = React.useMemo( - () => React.isValidElement(children), - [children] - ); - const clearShowTimers = React.useCallback(() => { showTooltipTimer.current.forEach((t) => clearTimeout(t)); showTooltipTimer.current = []; @@ -160,19 +184,14 @@ const RichTooltip = ({ hideTooltipTimer.current.push(id); }, [clearShowTimers, leaveTouchDelay]); - // Mobile: a tap toggles the tooltip and still forwards the child's onPress. + // Mobile: a tap toggles the tooltip. const handlePress = React.useCallback(() => { - if (visible) { - hide(); - } else { - show(); - } - if (isValidChild) { - (children.props as TooltipChildProps).onPress?.(); - } - }, [visible, hide, show, isValidChild, children.props]); + setVisible((v) => !v); + clearShowTimers(); + clearHideTimers(); + }, [clearShowTimers, clearHideTimers]); - // Web: open on hover, with a short enter delay. + // Web: open on hover (with a short enter delay) and on keyboard focus. const handleHoverIn = React.useCallback(() => { clearHideTimers(); const id = setTimeout( @@ -180,26 +199,17 @@ const RichTooltip = ({ enterTouchDelay ) as unknown as NodeJS.Timeout; showTooltipTimer.current.push(id); - if (isValidChild) { - (children.props as TooltipChildProps).onHoverIn?.(); - } - }, [clearHideTimers, enterTouchDelay, isValidChild, children.props]); - - const handleHoverOut = React.useCallback(() => { - scheduleHide(); - if (isValidChild) { - (children.props as TooltipChildProps).onHoverOut?.(); - } - }, [scheduleHide, isValidChild, children.props]); + }, [clearHideTimers, enterTouchDelay]); - const mobilePressProps = { - onPress: handlePress, - }; - - const webPressProps = { - onHoverIn: handleHoverIn, - onHoverOut: handleHoverOut, - }; + // Trigger props handed to the consumer's render function. + const triggerProps: TooltipRichTriggerProps = isWeb + ? { + onHoverIn: handleHoverIn, + onHoverOut: scheduleHide, + onFocus: show, + onBlur: scheduleHide, + } + : { onPress: handlePress }; // Web only: keep the tooltip open while the pointer travels from the trigger // into the tooltip (and re-schedule the hide once it leaves the tooltip). @@ -222,10 +232,7 @@ const RichTooltip = ({ onLayout={onLayout} style={[ styles.container, - getTooltipPosition( - measurement as Measurement, - children as React.ReactElement - ), + getTooltipPosition(measurement as Measurement), animatedStyle, ]} testID="tooltip-rich-container" @@ -267,14 +274,8 @@ const RichTooltip = ({ content )} {actions ? ( - // `onTouchEnd` bubbles from the pressed action up to this - // wrapper, so selecting any action dismisses the tooltip. - - {actions} + + {actions({ dismiss: hide })} ) : null} @@ -282,16 +283,9 @@ const RichTooltip = ({ )} - - {React.cloneElement(children, { - ...rest, - ...(isWeb ? webPressProps : mobilePressProps), - })} - + + {children(triggerProps)} + ); }; @@ -313,6 +307,7 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', }, pressContainer: { + alignSelf: 'flex-start', ...(Platform.OS === 'web' && { cursor: 'default' }), } as ViewStyle, }); diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index cbcec23f02..1520d2fce0 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -195,7 +195,8 @@ const Tooltip = ({ backgroundColor: theme.colors[Tokens.plain.container], ...getTooltipPosition( measurement as Measurement, - children as React.ReactElement + (children as React.ReactElement).props + .style ), borderRadius: theme.shapes.corner[Tokens.plain.shape], }, diff --git a/src/components/Tooltip/utils.ts b/src/components/Tooltip/utils.ts index 43baf684fd..190a99b77b 100644 --- a/src/components/Tooltip/utils.ts +++ b/src/components/Tooltip/utils.ts @@ -119,14 +119,12 @@ const getChildrenMeasures = ( export const getTooltipPosition = ( { children, tooltip, measured }: Measurement, - component: React.ReactElement<{ - style: StyleProp; - }> + childStyle?: StyleProp ): {} | { left: number; top: number } => { if (!measured) return {}; let measures = children; - if (component.props.style) { - measures = getChildrenMeasures(component.props.style, children); + if (childStyle) { + measures = getChildrenMeasures(childStyle, children); } return { diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index 9f1b1571f2..c2b88b9404 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -583,7 +583,7 @@ describe('Tooltip.Rich', () => { const wrapper = render( - + {(props) => } ); @@ -604,7 +604,7 @@ describe('Tooltip.Rich', () => { it('toggles title, content and actions when the trigger is pressed', () => { const { wrapper: { getByText, getByTestId, queryByText }, - } = setup({ title: 'Heading', actions: Learn more }); + } = setup({ title: 'Heading', actions: () => Learn more }); expect(queryByText('Body text')).toBeNull(); @@ -674,15 +674,17 @@ describe('Tooltip.Rich', () => { expect(queryByText('Body text')).toBeNull(); }); - it('dismisses when an action is selected', () => { + it('dismisses when an action calls dismiss', () => { const { - wrapper: { getByText, getByTestId, queryByText }, - } = setup({ actions: Learn more }); + wrapper: { getByText, queryByText }, + } = setup({ + actions: ({ dismiss }) => Learn more, + }); fireEvent.press(getTrigger(getByText)); expect(getByText('Body text')).toBeTruthy(); - fireEvent(getByTestId('tooltip-rich-actions'), 'touchEnd'); + fireEvent.press(getByText('Learn more')); runTimers(); // exit fade → unmount expect(queryByText('Body text')).toBeNull(); @@ -708,6 +710,21 @@ describe('Tooltip.Rich', () => { expect(getByText('Body text')).toBeTruthy(); }); + it('opens on keyboard focus and hides on blur', () => { + const { + wrapper: { getByText, queryByText }, + } = setup({ leaveTouchDelay: 500 }); + + fireEvent(getTrigger(getByText), 'focus'); + expect(getByText('Body text')).toBeTruthy(); + + fireEvent(getTrigger(getByText), 'blur'); + runTimers(500); // leave delay → hide intent + runTimers(); // exit fade → unmount + + expect(queryByText('Body text')).toBeNull(); + }); + it('keeps the tooltip open while the pointer moves into it (gap bridge)', () => { const { wrapper: { getByText, getByTestId }, diff --git a/src/index.tsx b/src/index.tsx index 0f783a0d47..da86483501 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -146,6 +146,9 @@ export type { Props as TextProps } from './components/Typography/Text'; export type { Props as SegmentedButtonsProps } from './components/SegmentedButtons/SegmentedButtons'; export type { Props as ListImageProps } from './components/List/ListImage'; export type { Props as TooltipProps } from './components/Tooltip/Tooltip'; -export type { Props as TooltipRichProps } from './components/Tooltip/RichTooltip'; +export type { + Props as TooltipRichProps, + TooltipRichTriggerProps, +} from './components/Tooltip/RichTooltip'; export { type TypescaleKey, type Theme, type Elevation } from './types'; From fa2dd79c577b84b35686a5d3addf8790c962d7a8 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 17 Jun 2026 14:28:03 +0200 Subject: [PATCH 11/29] fix(tooltip): label the rich tooltip dismiss backdrop Give the full-screen backdrop an accessibilityLabel and hint so screen reader users understand it dismisses the tooltip. Re #4994 --- src/components/Tooltip/RichTooltip.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index 0dc4a0a89b..a9f43d49dd 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -223,6 +223,8 @@ const RichTooltip = ({ Date: Fri, 19 Jun 2026 10:17:10 +0200 Subject: [PATCH 12/29] fix(tooltip): open Rich tooltip on web regardless of trigger The render-prop handed hover/focus handlers to the trigger element, but triggers like IconButton don't forward onHoverIn/onHoverOut on web, so rich tooltips never opened on hover. Carry the handlers on the wrapper (web only) like the plain Tooltip does; keep press on the trigger for mobile so it doesn't double-fire. Add a regression test with a non-forwarding trigger. --- src/components/Tooltip/RichTooltip.tsx | 13 ++++++++++-- src/components/__tests__/Tooltip.test.tsx | 24 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index a9f43d49dd..db33619cd7 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -285,9 +285,18 @@ const RichTooltip = ({ )} - + {children(triggerProps)} - + ); }; diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index c2b88b9404..ddcaa4643b 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -742,5 +742,29 @@ describe('Tooltip.Rich', () => { expect(getByText('Body text')).toBeTruthy(); }); + + it('opens on hover even when the trigger ignores the hover props', () => { + // Some triggers (e.g. `IconButton`) don't forward `onHoverIn` on web, + // so the wrapper must carry the handlers itself. Here the trigger + // deliberately drops the provided props. + jest + .spyOn(View.prototype, 'measure') + .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200)); + + const { getByTestId, getByText, queryByText } = render( + + + {() => } + + + ); + + fireEvent(getByTestId('tooltip-rich-trigger'), 'hoverIn'); + expect(queryByText('Body text')).toBeNull(); // within the enter delay + + runTimers(100); + + expect(getByText('Body text')).toBeTruthy(); + }); }); }); From 982c83d124e1124f9217369380bb9c4a8c25d02e Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Tue, 23 Jun 2026 11:15:51 +0200 Subject: [PATCH 13/29] refactor(tooltip): derive mount during render, not in an effect Replace the visible->setRendered(true) effect with a render-time guard (if (visible && !rendered) setRendered(true)), so the mounted state is derived during render instead of synced from an effect. Addresses review feedback on useTooltipFade. --- src/components/Tooltip/hooks.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/Tooltip/hooks.ts b/src/components/Tooltip/hooks.ts index fda9d1ac2c..44646de2c2 100644 --- a/src/components/Tooltip/hooks.ts +++ b/src/components/Tooltip/hooks.ts @@ -60,12 +60,11 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); - // Mount as soon as the tooltip is requested. - React.useEffect(() => { - if (visible) { - setRendered(true); - } - }, [visible]); + // Mount as soon as the tooltip is requested — derived during render rather + // than synced from an effect. + if (visible && !rendered) { + setRendered(true); + } // Drive the fade and defer unmount until the exit animation has played. React.useEffect(() => { From 5e1c36f2b7e5701615fa201482206b1ff785b1ca Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Tue, 23 Jun 2026 11:47:30 +0200 Subject: [PATCH 14/29] refactor(tooltip): measure the trigger in useLayoutEffect Measure the trigger synchronously in a useLayoutEffect when the tooltip is requested, instead of waiting for the tooltip's onLayout to trigger the measurement. The tooltip's own size still comes from its onLayout (it renders inside a Portal, so its node isn't attached during the trigger's layout effect); the two are combined into a single measurement update so position is applied atomically. Addresses review feedback on useTooltipFade. --- src/components/Tooltip/hooks.ts | 42 ++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/components/Tooltip/hooks.ts b/src/components/Tooltip/hooks.ts index 44646de2c2..5bd68cad51 100644 --- a/src/components/Tooltip/hooks.ts +++ b/src/components/Tooltip/hooks.ts @@ -10,6 +10,7 @@ import { } from 'react-native-reanimated'; import { Tokens } from './tokens'; +import type { Measurement } from './utils'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { InternalTheme } from '../../types'; @@ -32,6 +33,11 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { measured: false, }); const childrenWrapperRef = React.useRef(null); + // The trigger is measured synchronously and stashed here so the tooltip's + // own layout can combine the two into the final measurement in one update. + const childrenMeasurement = React.useRef( + null + ); const opacity = useSharedValue(0); const reanimatedReduceMotion = reduceMotion @@ -66,6 +72,21 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { setRendered(true); } + // Measure the trigger synchronously once the tooltip is requested, instead + // of waiting for the tooltip's `onLayout` to do it. (The tooltip itself + // lives in a `Portal`, so its own size still comes from its layout below.) + React.useLayoutEffect(() => { + if (!rendered || !visible) { + return; + } + + childrenWrapperRef.current?.measure( + (_x, _y, width, height, pageX, pageY) => { + childrenMeasurement.current = { pageX, pageY, width, height }; + } + ); + }, [rendered, visible]); + // Drive the fade and defer unmount until the exit animation has played. React.useEffect(() => { if (!rendered) { @@ -81,6 +102,7 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { const id = setTimeout(() => { setRendered(false); setMeasurement({ children: {}, tooltip: {}, measured: false }); + childrenMeasurement.current = null; }, exitDurationMs) as unknown as NodeJS.Timeout; return () => clearTimeout(id); @@ -94,16 +116,18 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { exitDurationMs, ]); + // The tooltip reports its own size on layout; combine it with the trigger + // measurement captured above to compute the final position in one update. const onLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { - childrenWrapperRef.current?.measure( - (_x, _y, width, height, pageX, pageY) => { - setMeasurement({ - children: { pageX, pageY, height, width }, - tooltip: { ...layout }, - measured: true, - }); - } - ); + if (!childrenMeasurement.current) { + return; + } + + setMeasurement({ + children: childrenMeasurement.current, + tooltip: layout, + measured: true, + }); }; return { rendered, measurement, animatedStyle, onLayout, childrenWrapperRef }; From a151776da2bdc71903f93acc2f85c4ab4360dc51 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 24 Jun 2026 10:51:06 +0200 Subject: [PATCH 15/29] feat(tooltip): replace Reanimated shared values with CSS transitions The fade animation is now driven by a CSS transition on `opacity` instead of a shared value + `withTiming`. This removes the dependency on `useAnimatedStyle`, `Easing`, and `ReduceMotion` from the hook, and makes the reduce-motion path trivial (duration = 0). A `cubicBezier` mock is added to `testSetup.js` because the reanimated mock doesn't ship the CSS easing helpers. The test suite is migrated to `@testing-library/react-native` v14: all `render()` calls are awaited, timers are advanced with `jest.advanceTimersByTimeAsync` inside `act(async () => {...})`, and `userEvent.setup()` is used for press gestures to avoid overlapping `act()` calls. `fireEvent` calls that trigger state updates are wrapped in `act(async () => {...})` for the same reason. --- jest/testSetup.js | 12 +- src/components/Tooltip/RichTooltip.tsx | 4 +- src/components/Tooltip/Tooltip.tsx | 4 +- src/components/Tooltip/hooks.ts | 78 +++------- src/components/__tests__/Tooltip.test.tsx | 176 ++++++++++++---------- 5 files changed, 133 insertions(+), 141 deletions(-) diff --git a/jest/testSetup.js b/jest/testSetup.js index c00e611084..b787f0f32e 100644 --- a/jest/testSetup.js +++ b/jest/testSetup.js @@ -8,9 +8,15 @@ jest.mock('react-native-worklets', () => require('react-native-worklets/lib/module/mock') ); -jest.mock('react-native-reanimated', () => - require('react-native-reanimated/mock') -); +jest.mock('react-native-reanimated', () => { + const Reanimated = require('react-native-reanimated/mock'); + + // The mock doesn't ship the CSS easing helpers; stub the ones we use. + return { + ...Reanimated, + cubicBezier: (...points) => `cubic-bezier(${points.join(', ')})`, + }; +}); jest.mock('@react-native-vector-icons/material-design-icons', () => { const React = require('react'); diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index db33619cd7..b3ea36cecb 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -134,7 +134,7 @@ const RichTooltip = ({ // `visible` is the show/hide intent; the fade hook keeps the tooltip mounted // through the exit animation and owns the measurement + opacity. const [visible, setVisible] = React.useState(false); - const { rendered, measurement, animatedStyle, onLayout, childrenWrapperRef } = + const { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef } = useTooltipFade(theme, visible); const showTooltipTimer = React.useRef([]); @@ -235,7 +235,7 @@ const RichTooltip = ({ style={[ styles.container, getTooltipPosition(measurement as Measurement), - animatedStyle, + fadeStyle, ]} testID="tooltip-rich-container" > diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 1520d2fce0..7803fdc452 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -81,7 +81,7 @@ const Tooltip = ({ // `visible` is the show/hide intent; the fade hook keeps the tooltip mounted // through the exit animation and owns the measurement + opacity. const [visible, setVisible] = React.useState(false); - const { rendered, measurement, animatedStyle, onLayout, childrenWrapperRef } = + const { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef } = useTooltipFade(theme, visible); const showTooltipTimer = React.useRef([]); @@ -200,7 +200,7 @@ const Tooltip = ({ ), borderRadius: theme.shapes.corner[Tokens.plain.shape], }, - animatedStyle, + fadeStyle, ]} testID="tooltip-container" > diff --git a/src/components/Tooltip/hooks.ts b/src/components/Tooltip/hooks.ts index 5bd68cad51..7344084460 100644 --- a/src/components/Tooltip/hooks.ts +++ b/src/components/Tooltip/hooks.ts @@ -1,13 +1,7 @@ import * as React from 'react'; import { LayoutChangeEvent, View } from 'react-native'; -import { - Easing, - ReduceMotion, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; +import { cubicBezier } from 'react-native-reanimated'; import { Tokens } from './tokens'; import type { Measurement } from './utils'; @@ -20,9 +14,9 @@ import type { InternalTheme } from '../../types'; * Given a `visible` intent it keeps the tooltip mounted (`rendered`) through * the exit fade so the animation can play before unmounting, holds the opacity * at 0 until the tooltip has been measured (so it never flashes at the wrong - * position), and honors the reduce-motion preference. The actual unmount is - * deferred by the exit duration via a timer rather than a Reanimated callback, - * which keeps the behavior deterministic and testable with fake timers. + * position), and honors the reduce-motion preference. The fade itself is a + * Reanimated CSS transition on `opacity`; the unmount is deferred by the exit + * duration via a timer, which keeps the behavior deterministic and testable. */ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { const reduceMotion = useReduceMotion(); @@ -39,33 +33,13 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { null ); - const opacity = useSharedValue(0); - const reanimatedReduceMotion = reduceMotion - ? ReduceMotion.Always - : ReduceMotion.Never; - - const enterConfig = React.useMemo( - () => ({ - duration: theme.motion.duration[Tokens.motion.enter.duration], - easing: Easing.bezier(...theme.motion.easing[Tokens.motion.enter.easing]), - reduceMotion: reanimatedReduceMotion, - }), - [theme.motion, reanimatedReduceMotion] - ); - const exitConfig = React.useMemo( - () => ({ - duration: theme.motion.duration[Tokens.motion.exit.duration], - easing: Easing.bezier(...theme.motion.easing[Tokens.motion.exit.easing]), - reduceMotion: reanimatedReduceMotion, - }), - [theme.motion, reanimatedReduceMotion] - ); - const exitDurationMs = reduceMotion + const enterDuration = reduceMotion + ? 0 + : theme.motion.duration[Tokens.motion.enter.duration]; + const exitDuration = reduceMotion ? 0 : theme.motion.duration[Tokens.motion.exit.duration]; - const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); - // Mount as soon as the tooltip is requested — derived during render rather // than synced from an effect. if (visible && !rendered) { @@ -87,34 +61,20 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { ); }, [rendered, visible]); - // Drive the fade and defer unmount until the exit animation has played. + // Keep the tooltip mounted through the exit fade, then unmount. React.useEffect(() => { - if (!rendered) { - return; - } - - if (visible) { - opacity.value = measurement.measured ? withTiming(1, enterConfig) : 0; + if (!rendered || visible) { return; } - opacity.value = withTiming(0, exitConfig); const id = setTimeout(() => { setRendered(false); setMeasurement({ children: {}, tooltip: {}, measured: false }); childrenMeasurement.current = null; - }, exitDurationMs) as unknown as NodeJS.Timeout; + }, exitDuration) as unknown as NodeJS.Timeout; return () => clearTimeout(id); - }, [ - visible, - rendered, - measurement.measured, - opacity, - enterConfig, - exitConfig, - exitDurationMs, - ]); + }, [rendered, visible, exitDuration]); // The tooltip reports its own size on layout; combine it with the trigger // measurement captured above to compute the final position in one update. @@ -130,5 +90,17 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { }); }; - return { rendered, measurement, animatedStyle, onLayout, childrenWrapperRef }; + // A Reanimated CSS transition drives the fade — no shared values. Opacity is + // held at 0 until the tooltip has been measured so it never flashes at the + // wrong position; entering decelerates in, exiting accelerates out. + const fadeStyle = { + opacity: visible && measurement.measured ? 1 : 0, + transitionProperty: 'opacity', + transitionDuration: `${visible ? enterDuration : exitDuration}ms`, + transitionTimingFunction: visible + ? cubicBezier(...theme.motion.easing[Tokens.motion.enter.easing]) + : cubicBezier(...theme.motion.easing[Tokens.motion.exit.easing]), + } as const; + + return { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef }; }; diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index ddcaa4643b..1a23db8421 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -1,10 +1,12 @@ -import React, { RefObject } from 'react'; -import { Dimensions, StyleSheet, Text, View, Platform } from 'react-native'; +import React from 'react'; +import { Dimensions, Text, View, Platform } from 'react-native'; +import type { ViewProps } from 'react-native'; import { afterAll, afterEach, beforeAll, + beforeEach, describe, expect, it, @@ -52,13 +54,12 @@ describe('Tooltip', () => { return trigger; }; - const runTimers = async (ms?: number) => { - await act(() => { - if (ms === undefined) { - jest.runOnlyPendingTimers(); - } else { - jest.advanceTimersByTime(ms); - } + // Advancing async lets the timer callbacks' state updates flush and re-render + // (a sync `act` doesn't under the async renderer). Default to a large step + // that drains every pending tooltip timer. + const runTimers = async (ms = 1000) => { + await act(async () => { + await jest.advanceTimersByTimeAsync(ms); }); }; @@ -95,6 +96,14 @@ describe('Tooltip', () => { return { wrapper }; }; + // `userEvent.setup()` coordinates the press gestures with the fake timers so + // its `act()` scopes don't overlap the tooltip's own timer-driven updates + // (overlapping act() calls corrupt the renderer across tests). + let user: ReturnType; + beforeEach(() => { + user = userEvent.setup(); + }); + describe('Mobile', () => { beforeAll(() => { Platform.OS = 'android'; @@ -127,7 +136,7 @@ describe('Tooltip', () => { wrapper: { getByText, findByText, unmount }, } = await setup(); - await userEvent.longPress(getTrigger(getByText)); + await user.longPress(getTrigger(getByText)); await findByText('some tooltip text'); @@ -153,8 +162,8 @@ describe('Tooltip', () => { } = await setup(); const trigger = getTrigger(getByText); - await userEvent.longPress(trigger); - await userEvent.longPress(trigger); + await user.longPress(trigger); + await user.longPress(trigger); expect(global.clearTimeout).toHaveBeenCalledTimes(1); }); @@ -166,13 +175,12 @@ describe('Tooltip', () => { wrapper: { queryByText, getByText, findByText }, } = await setup({ enterTouchDelay: 50, leaveTouchDelay: 0 }); - await userEvent.longPress(getTrigger(getByText)); + // `longPress` includes the release (pressOut), which schedules the hide. + await user.longPress(getTrigger(getByText)); await findByText('some tooltip text'); - fireEvent(getTrigger(getByText), 'pressOut'); - runTimers(); // leaveTouchDelay → starts the fade-out - runTimers(); // exit fade duration → unmounts + await runTimers(); // leaveTouchDelay + exit fade duration → unmounts expect(queryByText('some tooltip text')).not.toBeOnTheScreen(); }); @@ -184,20 +192,16 @@ describe('Tooltip', () => { wrapper: { getByText, getByTestId, findByText }, } = setup(); - fireEvent(getTrigger(getByText), 'longPress'); + await user.longPress(getTrigger(getByText)); await findByText('some tooltip text'); - expect(getByTestId('tooltip-container').props.style).toMatchObject([ - {}, - { backgroundColor: getTheme().colors.inverseSurface }, - {}, - ]); + expect(getByTestId('tooltip-container')).toHaveStyle({ + backgroundColor: getTheme().colors.inverseSurface, + }); // bodySmall (12sp) text in the inverseOnSurface role. - expect( - StyleSheet.flatten(getByText('some tooltip text').props.style) - ).toMatchObject({ + expect(getByText('some tooltip text')).toHaveStyle({ color: getTheme().colors.inverseOnSurface, fontSize: 12, }); @@ -210,15 +214,15 @@ describe('Tooltip', () => { wrapper: { queryByText, getByText, findByText }, } = setup({ leaveTouchDelay: 0 }); - fireEvent(getTrigger(getByText), 'longPress'); + // `longPress` includes the release (pressOut), which schedules the hide. + await user.longPress(getTrigger(getByText)); await findByText('some tooltip text'); - fireEvent(getTrigger(getByText), 'pressOut'); - runTimers(); // leaveTouchDelay elapses → exit fade starts + await runTimers(0); // leaveTouchDelay (0) elapses → exit fade starts // Still mounted while fading out so the animation can play. - expect(queryByText('some tooltip text')).not.toBeNull(); + expect(getByText('some tooltip text')).toBeTruthy(); runTimers(); // exit fade duration elapses → unmounts expect(queryByText('some tooltip text')).toBeNull(); @@ -246,7 +250,7 @@ describe('Tooltip', () => { wrapper: { getByText, getByTestId, findByText }, } = await setup(); - await userEvent.longPress(getTrigger(getByText)); + await user.longPress(getTrigger(getByText)); await fireEvent(await findByText('some tooltip text'), 'layout', { nativeEvent: { @@ -271,7 +275,7 @@ describe('Tooltip', () => { wrapper: { getByText, getByTestId, findByText }, } = await setup({}, { pageX: 0 }); // Component starting at the starting 0 X coord - await userEvent.longPress(getTrigger(getByText)); + await user.longPress(getTrigger(getByText)); await fireEvent(await findByText('some tooltip text'), 'layout', { nativeEvent: { @@ -296,7 +300,7 @@ describe('Tooltip', () => { wrapper: { getByText, getByTestId, findByText }, } = await setup({}, { pageX: 900, width: 150 }); // Component close to the screen limit - await userEvent.longPress(getTrigger(getByText)); + await user.longPress(getTrigger(getByText)); await fireEvent(await findByText('some tooltip text'), 'layout', { nativeEvent: { @@ -321,7 +325,7 @@ describe('Tooltip', () => { wrapper: { getByText, getByTestId, findByText }, } = await setup({}, { pageY: 600, height: 50 }); - await userEvent.longPress(getTrigger(getByText)); + await user.longPress(getTrigger(getByText)); await fireEvent(await findByText('some tooltip text'), 'layout', { nativeEvent: { @@ -429,9 +433,13 @@ describe('Tooltip', () => { await findByText('some tooltip text'); - fireEvent(getTrigger(getByText), 'hoverOut'); - runTimers(); // leaveTouchDelay → starts the fade-out - runTimers(); // exit fade duration → unmounts + // Settle the hover-out in its own act() so its state update can't + // escape act and corrupt the renderer, then drain the fade-out timers. + await act(async () => { + await fireEvent(getTrigger(getByText), 'hoverOut'); + }); + await runTimers(); // leaveTouchDelay → schedules the exit fade + await runTimers(); // exit fade duration → unmounts expect(queryByText('some tooltip text')).not.toBeOnTheScreen(); }); @@ -560,20 +568,22 @@ describe('Tooltip', () => { }); describe('Tooltip.Rich', () => { - const getTrigger = (getByText: (text: string) => ReactTestInstance) => - getByText('dummy component').parent as ReactTestInstance; - - const runTimers = (ms?: number) => { - act(() => { - if (ms === undefined) { - jest.runOnlyPendingTimers(); - } else { - jest.advanceTimersByTime(ms); - } + const getTrigger = ( + getByText: Awaited>['getByText'] + ) => getByText('dummy component').parent!; + + const runTimers = async (ms = 1000) => { + await act(async () => { + await jest.advanceTimersByTimeAsync(ms); }); }; - const setup = ( + let user: ReturnType; + beforeEach(() => { + user = userEvent.setup(); + }); + + const setup = async ( propOverrides?: Partial> ) => { jest @@ -608,7 +618,7 @@ describe('Tooltip.Rich', () => { expect(queryByText('Body text')).toBeNull(); - fireEvent.press(getTrigger(getByText)); + await user.press(getTrigger(getByText)); expect(getByText('Heading')).toBeTruthy(); expect(getByText('Body text')).toBeTruthy(); @@ -616,8 +626,8 @@ describe('Tooltip.Rich', () => { expect(getByTestId('tooltip-rich-container')).toBeTruthy(); // Pressing again toggles it back off. - fireEvent.press(getTrigger(getByText)); - runTimers(); // exit fade → unmount + await user.press(getTrigger(getByText)); + await runTimers(); // exit fade → unmount expect(queryByText('Body text')).toBeNull(); }); @@ -627,7 +637,7 @@ describe('Tooltip.Rich', () => { wrapper: { getByText }, } = setup({ content: Custom node }); - fireEvent.press(getTrigger(getByText)); + await user.press(getTrigger(getByText)); expect(getByText('Custom node')).toBeTruthy(); }); @@ -637,25 +647,17 @@ describe('Tooltip.Rich', () => { wrapper: { getByText, getByTestId }, } = setup({ title: 'Heading' }); - fireEvent.press(getTrigger(getByText)); + await user.press(getTrigger(getByText)); - expect( - StyleSheet.flatten(getByText('Heading').props.style) - ).toMatchObject({ + expect(getByText('Heading')).toHaveStyle({ color: getTheme().colors.onSurface, }); - expect( - StyleSheet.flatten(getByText('Body text').props.style) - ).toMatchObject({ + expect(getByText('Body text')).toHaveStyle({ color: getTheme().colors.onSurfaceVariant, }); // Surface (container) uses the surfaceContainer color. - expect( - StyleSheet.flatten( - getByTestId('tooltip-rich-surface-container').props.style - ) - ).toMatchObject({ + expect(getByTestId('tooltip-rich-surface-container')).toHaveStyle({ backgroundColor: getTheme().colors.surfaceContainer, }); }); @@ -665,11 +667,11 @@ describe('Tooltip.Rich', () => { wrapper: { getByText, getByTestId, queryByText }, } = setup(); - fireEvent.press(getTrigger(getByText)); + await user.press(getTrigger(getByText)); expect(getByText('Body text')).toBeTruthy(); - fireEvent.press(getByTestId('tooltip-rich-backdrop')); - runTimers(); // exit fade → unmount + await user.press(getByTestId('tooltip-rich-backdrop')); + await runTimers(); // exit fade → unmount expect(queryByText('Body text')).toBeNull(); }); @@ -681,11 +683,11 @@ describe('Tooltip.Rich', () => { actions: ({ dismiss }) => Learn more, }); - fireEvent.press(getTrigger(getByText)); + await user.press(getTrigger(getByText)); expect(getByText('Body text')).toBeTruthy(); - fireEvent.press(getByText('Learn more')); - runTimers(); // exit fade → unmount + await user.press(getByText('Learn more')); + await runTimers(); // exit fade → unmount expect(queryByText('Body text')).toBeNull(); }); @@ -702,7 +704,9 @@ describe('Tooltip.Rich', () => { wrapper: { getByText, queryByText }, } = setup({ enterTouchDelay: 100 }); - fireEvent(getTrigger(getByText), 'hoverIn'); + await act(async () => { + await fireEvent(getTrigger(getByText), 'hoverIn'); + }); expect(queryByText('Body text')).toBeNull(); // still within the delay runTimers(100); @@ -715,12 +719,18 @@ describe('Tooltip.Rich', () => { wrapper: { getByText, queryByText }, } = setup({ leaveTouchDelay: 500 }); - fireEvent(getTrigger(getByText), 'focus'); + // Focus shows the tooltip synchronously, so settle it in act() before + // asserting (and so its update can't escape act and corrupt the renderer). + await act(async () => { + await fireEvent(getTrigger(getByText), 'focus'); + }); expect(getByText('Body text')).toBeTruthy(); - fireEvent(getTrigger(getByText), 'blur'); - runTimers(500); // leave delay → hide intent - runTimers(); // exit fade → unmount + await act(async () => { + await fireEvent(getTrigger(getByText), 'blur'); + }); + await runTimers(500); // leave delay → hide intent + await runTimers(); // exit fade → unmount expect(queryByText('Body text')).toBeNull(); }); @@ -730,15 +740,19 @@ describe('Tooltip.Rich', () => { wrapper: { getByText, getByTestId }, } = setup({ enterTouchDelay: 0, leaveTouchDelay: 500 }); - fireEvent(getTrigger(getByText), 'hoverIn'); - runTimers(0); + await act(async () => { + await fireEvent(getTrigger(getByText), 'hoverIn'); + }); + await runTimers(0); expect(getByText('Body text')).toBeTruthy(); // Leaving the trigger schedules a hide... - fireEvent(getTrigger(getByText), 'hoverOut'); - // ...but entering the tooltip cancels it. - fireEvent(getByTestId('tooltip-rich-surface'), 'hoverIn'); - runTimers(500); + await act(async () => { + await fireEvent(getTrigger(getByText), 'hoverOut'); + // ...but entering the tooltip cancels it. + await fireEvent(getByTestId('tooltip-rich-surface'), 'hoverIn'); + }); + await runTimers(500); expect(getByText('Body text')).toBeTruthy(); }); @@ -759,7 +773,7 @@ describe('Tooltip.Rich', () => { ); - fireEvent(getByTestId('tooltip-rich-trigger'), 'hoverIn'); + await fireEvent(getByTestId('tooltip-rich-trigger'), 'hoverIn'); expect(queryByText('Body text')).toBeNull(); // within the enter delay runTimers(100); From d56bfef9d3c56c82a9b4a49bbb442eb127be7516 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 24 Jun 2026 11:55:02 +0200 Subject: [PATCH 16/29] refactor(tooltip): replace isWeb with inline Platform.OS checks --- src/components/Tooltip/RichTooltip.tsx | 28 +++++++++++++------------- src/components/Tooltip/Tooltip.tsx | 10 ++++----- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index b3ea36cecb..d24d877367 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -128,8 +128,6 @@ const RichTooltip = ({ contentMaxFontSizeMultiplier, theme: themeOverrides, }: Props) => { - const isWeb = Platform.OS === 'web'; - const theme = useInternalTheme(themeOverrides); // `visible` is the show/hide intent; the fade hook keeps the tooltip mounted // through the exit animation and owns the measurement + opacity. @@ -202,20 +200,22 @@ const RichTooltip = ({ }, [clearHideTimers, enterTouchDelay]); // Trigger props handed to the consumer's render function. - const triggerProps: TooltipRichTriggerProps = isWeb - ? { - onHoverIn: handleHoverIn, - onHoverOut: scheduleHide, - onFocus: show, - onBlur: scheduleHide, - } - : { onPress: handlePress }; + const triggerProps: TooltipRichTriggerProps = + Platform.OS === 'web' + ? { + onHoverIn: handleHoverIn, + onHoverOut: scheduleHide, + onFocus: show, + onBlur: scheduleHide, + } + : { onPress: handlePress }; // Web only: keep the tooltip open while the pointer travels from the trigger // into the tooltip (and re-schedule the hide once it leaves the tooltip). - const tooltipHoverProps = isWeb - ? { onHoverIn: clearHideTimers, onHoverOut: scheduleHide } - : {}; + const tooltipHoverProps = + Platform.OS === 'web' + ? { onHoverIn: clearHideTimers, onHoverOut: scheduleHide } + : {}; return ( <> @@ -293,7 +293,7 @@ const RichTooltip = ({ // trigger element (e.g. `IconButton`) doesn't reliably forward them. // On mobile the press handler stays on the trigger itself (via // `triggerProps` below) so the wrapper doesn't double-fire the toggle. - {...(isWeb ? triggerProps : null)} + {...(Platform.OS === 'web' ? triggerProps : null)} > {children(triggerProps)} diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 7803fdc452..7584940209 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -75,8 +75,6 @@ const Tooltip = ({ titleMaxFontSizeMultiplier, ...rest }: Props) => { - const isWeb = Platform.OS === 'web'; - const theme = useInternalTheme(themeOverrides); // `visible` is the show/hide intent; the fade hook keeps the tooltip mounted // through the exit animation and owns the measurement + opacity. @@ -122,7 +120,7 @@ const Tooltip = ({ hideTooltipTimer.current = []; } - if (isWeb) { + if (Platform.OS === 'web') { let id = setTimeout(() => { touched.current = true; setVisible(true); @@ -132,7 +130,7 @@ const Tooltip = ({ touched.current = true; setVisible(true); } - }, [isWeb, enterTouchDelay]); + }, [enterTouchDelay]); const handleTouchEnd = React.useCallback(() => { touched.current = false; @@ -220,11 +218,11 @@ const Tooltip = ({ {React.cloneElement(children, { ...rest, - ...(isWeb ? webPressProps : mobilePressProps), + ...(Platform.OS === 'web' ? webPressProps : mobilePressProps), })} From fb8ec26f993b9197b00ffd6dc779a259ee1651ab Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 24 Jun 2026 12:18:16 +0200 Subject: [PATCH 17/29] refactor(tooltip): replace timer arrays with single nullable refs --- src/components/Tooltip/RichTooltip.tsx | 56 +++++++++++++------------- src/components/Tooltip/Tooltip.tsx | 39 +++++++----------- 2 files changed, 44 insertions(+), 51 deletions(-) diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index d24d877367..4fed64f1e7 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -135,25 +135,29 @@ const RichTooltip = ({ const { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef } = useTooltipFade(theme, visible); - const showTooltipTimer = React.useRef([]); - const hideTooltipTimer = React.useRef([]); + const showTimer = React.useRef(null); + const hideTimer = React.useRef(null); - const clearShowTimers = React.useCallback(() => { - showTooltipTimer.current.forEach((t) => clearTimeout(t)); - showTooltipTimer.current = []; + const clearShowTimer = React.useCallback(() => { + if (showTimer.current) { + clearTimeout(showTimer.current); + showTimer.current = null; + } }, []); - const clearHideTimers = React.useCallback(() => { - hideTooltipTimer.current.forEach((t) => clearTimeout(t)); - hideTooltipTimer.current = []; + const clearHideTimer = React.useCallback(() => { + if (hideTimer.current) { + clearTimeout(hideTimer.current); + hideTimer.current = null; + } }, []); React.useEffect(() => { return () => { - clearShowTimers(); - clearHideTimers(); + clearShowTimer(); + clearHideTimer(); }; - }, [clearShowTimers, clearHideTimers]); + }, [clearShowTimer, clearHideTimer]); React.useEffect(() => { const subscription = addEventListener(Dimensions, 'change', () => @@ -164,40 +168,38 @@ const RichTooltip = ({ }, []); const show = React.useCallback(() => { - clearHideTimers(); + clearHideTimer(); setVisible(true); - }, [clearHideTimers]); + }, [clearHideTimer]); const hide = React.useCallback(() => { - clearShowTimers(); + clearShowTimer(); setVisible(false); - }, [clearShowTimers]); + }, [clearShowTimer]); const scheduleHide = React.useCallback(() => { - clearShowTimers(); - const id = setTimeout( + clearShowTimer(); + hideTimer.current = setTimeout( () => setVisible(false), leaveTouchDelay ) as unknown as NodeJS.Timeout; - hideTooltipTimer.current.push(id); - }, [clearShowTimers, leaveTouchDelay]); + }, [clearShowTimer, leaveTouchDelay]); // Mobile: a tap toggles the tooltip. const handlePress = React.useCallback(() => { setVisible((v) => !v); - clearShowTimers(); - clearHideTimers(); - }, [clearShowTimers, clearHideTimers]); + clearShowTimer(); + clearHideTimer(); + }, [clearShowTimer, clearHideTimer]); // Web: open on hover (with a short enter delay) and on keyboard focus. const handleHoverIn = React.useCallback(() => { - clearHideTimers(); - const id = setTimeout( + clearHideTimer(); + showTimer.current = setTimeout( () => setVisible(true), enterTouchDelay ) as unknown as NodeJS.Timeout; - showTooltipTimer.current.push(id); - }, [clearHideTimers, enterTouchDelay]); + }, [clearHideTimer, enterTouchDelay]); // Trigger props handed to the consumer's render function. const triggerProps: TooltipRichTriggerProps = @@ -214,7 +216,7 @@ const RichTooltip = ({ // into the tooltip (and re-schedule the hide once it leaves the tooltip). const tooltipHoverProps = Platform.OS === 'web' - ? { onHoverIn: clearHideTimers, onHoverOut: scheduleHide } + ? { onHoverIn: clearHideTimer, onHoverOut: scheduleHide } : {}; return ( diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 7584940209..99e1cd5722 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -82,8 +82,8 @@ const Tooltip = ({ const { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef } = useTooltipFade(theme, visible); - const showTooltipTimer = React.useRef([]); - const hideTooltipTimer = React.useRef([]); + const showTimer = React.useRef(null); + const hideTimer = React.useRef(null); const touched = React.useRef(false); @@ -94,15 +94,8 @@ const Tooltip = ({ React.useEffect(() => { return () => { - if (showTooltipTimer.current.length) { - showTooltipTimer.current.forEach((t) => clearTimeout(t)); - showTooltipTimer.current = []; - } - - if (hideTooltipTimer.current.length) { - hideTooltipTimer.current.forEach((t) => clearTimeout(t)); - hideTooltipTimer.current = []; - } + if (showTimer.current) clearTimeout(showTimer.current); + if (hideTimer.current) clearTimeout(hideTimer.current); }; }, []); @@ -115,17 +108,16 @@ const Tooltip = ({ }, []); const handleTouchStart = React.useCallback(() => { - if (hideTooltipTimer.current.length) { - hideTooltipTimer.current.forEach((t) => clearTimeout(t)); - hideTooltipTimer.current = []; + if (hideTimer.current) { + clearTimeout(hideTimer.current); + hideTimer.current = null; } if (Platform.OS === 'web') { - let id = setTimeout(() => { + showTimer.current = setTimeout(() => { touched.current = true; setVisible(true); }, enterTouchDelay) as unknown as NodeJS.Timeout; - showTooltipTimer.current.push(id); } else { touched.current = true; setVisible(true); @@ -134,15 +126,14 @@ const Tooltip = ({ const handleTouchEnd = React.useCallback(() => { touched.current = false; - if (showTooltipTimer.current.length) { - showTooltipTimer.current.forEach((t) => clearTimeout(t)); - showTooltipTimer.current = []; + if (showTimer.current) { + clearTimeout(showTimer.current); + showTimer.current = null; } - - let id = setTimeout(() => { - setVisible(false); - }, leaveTouchDelay) as unknown as NodeJS.Timeout; - hideTooltipTimer.current.push(id); + hideTimer.current = setTimeout( + () => setVisible(false), + leaveTouchDelay + ) as unknown as NodeJS.Timeout; }, [leaveTouchDelay]); const handlePress = React.useCallback(() => { From 3fe4981433c84e40dad479e3bee372e9d40605e3 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 24 Jun 2026 14:02:29 +0200 Subject: [PATCH 18/29] refactor(tooltip): remove as type casts Replace NodeJS.Timeout with ReturnType, properly type useState to eliminate measurement casts, and use React.isValidElement as a type predicate to narrow children.props without casting. Drop the isValidChild memo and as const on fadeStyle as both were unnecessary. --- src/components/Tooltip/RichTooltip.tsx | 17 +++------ src/components/Tooltip/Tooltip.tsx | 50 +++++++++++--------------- src/components/Tooltip/hooks.ts | 16 +++++---- 3 files changed, 36 insertions(+), 47 deletions(-) diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index 4fed64f1e7..812963268b 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -13,7 +13,6 @@ import Animated from 'react-native-reanimated'; import { useTooltipFade } from './hooks'; import { Tokens } from './tokens'; import { getTooltipPosition } from './utils'; -import type { Measurement } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import { addEventListener } from '../../utils/addEventListener'; @@ -135,8 +134,8 @@ const RichTooltip = ({ const { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef } = useTooltipFade(theme, visible); - const showTimer = React.useRef(null); - const hideTimer = React.useRef(null); + const showTimer = React.useRef | null>(null); + const hideTimer = React.useRef | null>(null); const clearShowTimer = React.useCallback(() => { if (showTimer.current) { @@ -179,10 +178,7 @@ const RichTooltip = ({ const scheduleHide = React.useCallback(() => { clearShowTimer(); - hideTimer.current = setTimeout( - () => setVisible(false), - leaveTouchDelay - ) as unknown as NodeJS.Timeout; + hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay); }, [clearShowTimer, leaveTouchDelay]); // Mobile: a tap toggles the tooltip. @@ -195,10 +191,7 @@ const RichTooltip = ({ // Web: open on hover (with a short enter delay) and on keyboard focus. const handleHoverIn = React.useCallback(() => { clearHideTimer(); - showTimer.current = setTimeout( - () => setVisible(true), - enterTouchDelay - ) as unknown as NodeJS.Timeout; + showTimer.current = setTimeout(() => setVisible(true), enterTouchDelay); }, [clearHideTimer, enterTouchDelay]); // Trigger props handed to the consumer's render function. @@ -236,7 +229,7 @@ const RichTooltip = ({ onLayout={onLayout} style={[ styles.container, - getTooltipPosition(measurement as Measurement), + getTooltipPosition(measurement), fadeStyle, ]} testID="tooltip-rich-container" diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 99e1cd5722..d85d8340de 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -11,7 +11,8 @@ import Animated from 'react-native-reanimated'; import { useTooltipFade } from './hooks'; import { Tokens } from './tokens'; -import { getTooltipPosition, Measurement, TooltipChildProps } from './utils'; +import { getTooltipPosition } from './utils'; +import type { TooltipChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import { addEventListener } from '../../utils/addEventListener'; @@ -82,16 +83,11 @@ const Tooltip = ({ const { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef } = useTooltipFade(theme, visible); - const showTimer = React.useRef(null); - const hideTimer = React.useRef(null); + const showTimer = React.useRef | null>(null); + const hideTimer = React.useRef | null>(null); const touched = React.useRef(false); - const isValidChild = React.useMemo( - () => React.isValidElement(children), - [children] - ); - React.useEffect(() => { return () => { if (showTimer.current) clearTimeout(showTimer.current); @@ -117,7 +113,7 @@ const Tooltip = ({ showTimer.current = setTimeout(() => { touched.current = true; setVisible(true); - }, enterTouchDelay) as unknown as NodeJS.Timeout; + }, enterTouchDelay); } else { touched.current = true; setVisible(true); @@ -130,35 +126,31 @@ const Tooltip = ({ clearTimeout(showTimer.current); showTimer.current = null; } - hideTimer.current = setTimeout( - () => setVisible(false), - leaveTouchDelay - ) as unknown as NodeJS.Timeout; + hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay); }, [leaveTouchDelay]); const handlePress = React.useCallback(() => { if (touched.current) { return null; } - if (!isValidChild) return null; - const props = children.props as TooltipChildProps; - if (props.disabled) return null; - return props.onPress?.(); - }, [children.props, isValidChild]); + if (!React.isValidElement(children)) return null; + if (children.props.disabled) return null; + return children.props.onPress?.(); + }, [children]); const handleHoverIn = React.useCallback(() => { handleTouchStart(); - if (isValidChild) { - (children.props as TooltipChildProps).onHoverIn?.(); + if (React.isValidElement(children)) { + children.props.onHoverIn?.(); } - }, [children.props, handleTouchStart, isValidChild]); + }, [children, handleTouchStart]); const handleHoverOut = React.useCallback(() => { handleTouchEnd(); - if (isValidChild) { - (children.props as TooltipChildProps).onHoverOut?.(); + if (React.isValidElement(children)) { + children.props.onHoverOut?.(); } - }, [children.props, handleTouchEnd, isValidChild]); + }, [children, handleTouchEnd]); const mobilePressProps = { onPress: handlePress, @@ -172,6 +164,10 @@ const Tooltip = ({ onHoverOut: handleHoverOut, }; + const childStyle = React.isValidElement(children) + ? children.props.style + : undefined; + return ( <> {rendered && ( @@ -182,11 +178,7 @@ const Tooltip = ({ styles.tooltip, { backgroundColor: theme.colors[Tokens.plain.container], - ...getTooltipPosition( - measurement as Measurement, - (children as React.ReactElement).props - .style - ), + ...getTooltipPosition(measurement, childStyle), borderRadius: theme.shapes.corner[Tokens.plain.shape], }, fadeStyle, diff --git a/src/components/Tooltip/hooks.ts b/src/components/Tooltip/hooks.ts index 7344084460..57aabce68a 100644 --- a/src/components/Tooltip/hooks.ts +++ b/src/components/Tooltip/hooks.ts @@ -21,9 +21,9 @@ import type { InternalTheme } from '../../types'; export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { const reduceMotion = useReduceMotion(); const [rendered, setRendered] = React.useState(false); - const [measurement, setMeasurement] = React.useState({ - children: {}, - tooltip: {}, + const [measurement, setMeasurement] = React.useState({ + children: { pageX: 0, pageY: 0, width: 0, height: 0 }, + tooltip: { x: 0, y: 0, width: 0, height: 0 }, measured: false, }); const childrenWrapperRef = React.useRef(null); @@ -69,9 +69,13 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { const id = setTimeout(() => { setRendered(false); - setMeasurement({ children: {}, tooltip: {}, measured: false }); + setMeasurement({ + children: { pageX: 0, pageY: 0, width: 0, height: 0 }, + tooltip: { x: 0, y: 0, width: 0, height: 0 }, + measured: false, + }); childrenMeasurement.current = null; - }, exitDuration) as unknown as NodeJS.Timeout; + }, exitDuration); return () => clearTimeout(id); }, [rendered, visible, exitDuration]); @@ -100,7 +104,7 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { transitionTimingFunction: visible ? cubicBezier(...theme.motion.easing[Tokens.motion.enter.easing]) : cubicBezier(...theme.motion.easing[Tokens.motion.exit.easing]), - } as const; + }; return { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef }; }; From fefe3df1a2df0b74b9105db5cde54b8d966532bc Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Thu, 25 Jun 2026 07:44:51 +0200 Subject: [PATCH 19/29] refactor(tooltip): replace cloneElement with render prop Add TooltipTriggerProps and change children to a render function (props) => ReactElement, matching the Tooltip.Rich pattern. Drop cloneElement, handlePress, handleHoverIn/Out, touched ref, and the separate mobile/webPressProps in favour of a single triggerProps object. Update example app and tests to the new API. --- example/src/Examples/TooltipExample.tsx | 106 +++++++++++++++------- src/components/Tooltip/Tooltip.tsx | 91 +++++++------------ src/components/__tests__/Tooltip.test.tsx | 13 +-- 3 files changed, 113 insertions(+), 97 deletions(-) diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx index 0e86faed5e..54c5d62193 100644 --- a/example/src/Examples/TooltipExample.tsx +++ b/example/src/Examples/TooltipExample.tsx @@ -47,17 +47,28 @@ const TooltipExample = () => { header: () => ( - navigation.goBack()} /> + {(props) => ( + navigation.goBack()} + /> + )} - {}} /> + {(props) => ( + {}} /> + )} - {}} /> + {(props) => ( + {}} /> + )} - {}} /> + {(props) => ( + {}} /> + )} ), @@ -84,11 +95,14 @@ const TooltipExample = () => { enterTouchDelay={transport.enterTouchDelay} leaveTouchDelay={transport.leaveTouchDelay} > - {}} - /> + {(props) => ( + {}} + /> + )} ))} @@ -100,51 +114,75 @@ const TooltipExample = () => { onValueChange={setTextAlign} > - + {(props) => ( + + )} - + {(props) => ( + + )} - + {(props) => ( + + )} - + {(props) => } - - } - > - John Doe - + {(props) => ( + + } + > + John Doe + + )} - - ( - - )} - /> - + {(props) => ( + + ( + + )} + /> + + )} @@ -175,7 +213,7 @@ const TooltipExample = () => { - {}} /> + {(props) => {}} />} diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index d85d8340de..d9a5f68958 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -12,18 +12,36 @@ import Animated from 'react-native-reanimated'; import { useTooltipFade } from './hooks'; import { Tokens } from './tokens'; import { getTooltipPosition } from './utils'; -import type { TooltipChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import { addEventListener } from '../../utils/addEventListener'; import Portal from '../Portal/Portal'; import Text from '../Typography/Text'; +/** + * Props passed to the `children` render function. Spread them onto the trigger + * element (and merge with your own handlers when you have them). + */ +export type TooltipTriggerProps = { + onLongPress?: () => void; + onPressOut?: () => void; + delayLongPress?: number; + onHoverIn?: () => void; + onHoverOut?: () => void; +}; + export type Props = { /** - * Tooltip reference element. Needs to be able to hold a ref. + * Render function returning the trigger element. The provided props wire the + * tooltip's show/hide behavior and must be spread onto the returned element: + * + * ```js + * + * {(props) => {}} />} + * + * ``` */ - children: React.ReactElement; + children: (props: TooltipTriggerProps) => React.ReactElement; /** * The number of milliseconds a user must touch the element before showing the tooltip. */ @@ -60,7 +78,7 @@ export type Props = { * * const MyComponent = () => ( * - * {}} /> + * {(props) => {}} />} * * ); * @@ -74,7 +92,6 @@ const Tooltip = ({ title, theme: themeOverrides, titleMaxFontSizeMultiplier, - ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); // `visible` is the show/hide intent; the fade hook keeps the tooltip mounted @@ -86,8 +103,6 @@ const Tooltip = ({ const showTimer = React.useRef | null>(null); const hideTimer = React.useRef | null>(null); - const touched = React.useRef(false); - React.useEffect(() => { return () => { if (showTimer.current) clearTimeout(showTimer.current); @@ -110,18 +125,13 @@ const Tooltip = ({ } if (Platform.OS === 'web') { - showTimer.current = setTimeout(() => { - touched.current = true; - setVisible(true); - }, enterTouchDelay); + showTimer.current = setTimeout(() => setVisible(true), enterTouchDelay); } else { - touched.current = true; setVisible(true); } }, [enterTouchDelay]); const handleTouchEnd = React.useCallback(() => { - touched.current = false; if (showTimer.current) { clearTimeout(showTimer.current); showTimer.current = null; @@ -129,44 +139,14 @@ const Tooltip = ({ hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay); }, [leaveTouchDelay]); - const handlePress = React.useCallback(() => { - if (touched.current) { - return null; - } - if (!React.isValidElement(children)) return null; - if (children.props.disabled) return null; - return children.props.onPress?.(); - }, [children]); - - const handleHoverIn = React.useCallback(() => { - handleTouchStart(); - if (React.isValidElement(children)) { - children.props.onHoverIn?.(); - } - }, [children, handleTouchStart]); - - const handleHoverOut = React.useCallback(() => { - handleTouchEnd(); - if (React.isValidElement(children)) { - children.props.onHoverOut?.(); - } - }, [children, handleTouchEnd]); - - const mobilePressProps = { - onPress: handlePress, - onLongPress: () => handleTouchStart(), - onPressOut: () => handleTouchEnd(), - delayLongPress: enterTouchDelay, - }; - - const webPressProps = { - onHoverIn: handleHoverIn, - onHoverOut: handleHoverOut, - }; - - const childStyle = React.isValidElement(children) - ? children.props.style - : undefined; + const triggerProps: TooltipTriggerProps = + Platform.OS === 'web' + ? { onHoverIn: handleTouchStart, onHoverOut: handleTouchEnd } + : { + onLongPress: handleTouchStart, + onPressOut: handleTouchEnd, + delayLongPress: enterTouchDelay, + }; return ( <> @@ -178,7 +158,7 @@ const Tooltip = ({ styles.tooltip, { backgroundColor: theme.colors[Tokens.plain.container], - ...getTooltipPosition(measurement, childStyle), + ...getTooltipPosition(measurement), borderRadius: theme.shapes.corner[Tokens.plain.shape], }, fadeStyle, @@ -201,12 +181,9 @@ const Tooltip = ({ - {React.cloneElement(children, { - ...rest, - ...(Platform.OS === 'web' ? webPressProps : mobilePressProps), - })} + {children(triggerProps)} ); diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index 1a23db8421..2461135add 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -18,7 +18,7 @@ import PaperProvider from '../../core/PaperProvider'; import { getTheme } from '../../core/theming'; import { render } from '../../test-utils'; import TooltipCompound from '../Tooltip'; -import Tooltip from '../Tooltip/Tooltip'; +import Tooltip, { type TooltipTriggerProps } from '../Tooltip/Tooltip'; const mockedRemoveEventListener = jest.fn(); @@ -31,10 +31,11 @@ jest.mock('../../utils/addEventListener', () => ({ const DummyComponent = ({ ref, ...props -}: ViewProps & { - ref?: React.RefObject; -}) => ( - +}: ViewProps & + TooltipTriggerProps & { + ref?: React.RefObject; + }) => ( + dummy component ); @@ -68,7 +69,7 @@ describe('Tooltip', () => { measure = {} ) => { const defaultProps = { - children: , + children: (props: TooltipTriggerProps) => , title: 'some tooltip text', ...propOverrides, }; From 730f303fe24166e341e274b11a8930e7430f7276 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Thu, 25 Jun 2026 08:51:09 +0200 Subject: [PATCH 20/29] fix(tooltip): add collapsable={false} to trigger wrapper Prevents the native view optimizer from collapsing the wrapper Pressable before measure() runs, which would otherwise return zeros on Android. --- src/components/Tooltip/RichTooltip.tsx | 1 + src/components/Tooltip/Tooltip.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index 812963268b..1193974d79 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -282,6 +282,7 @@ const RichTooltip = ({ )} From b6527915d21c0346188cc5a8d3290ff553164eed Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Mon, 29 Jun 2026 08:57:59 +0200 Subject: [PATCH 21/29] fix(tooltip): fix web hover flicker and cursor-leave dismiss Switch wrapper from Pressable to View on web to avoid RNW's Pressable lock/unlock mechanism (contain: true) which caused onHoverOut to fire immediately after onHoverIn whenever the trigger button had its own hover state, creating a flicker. Use onPointerEnter/onPointerLeave on the wrapper View (forwarded by RNW without lock/unlock). Add a bounds guard in handlePointerLeave: if the cursor's clientX/clientY is still inside the wrapper's getBoundingClientRect() the event is spurious (some browsers fire pointerleave when the Portal stacking context changes even with pointer-events:none on the tooltip) and the hide timer is not started. For Tooltip.Rich, disable the backdrop's pointer-events on web. When the backdrop was auto it covered the full screen, stole the cursor hit-target on tooltip show, and permanently blocked subsequent pointerleave events from reaching the wrapper. --- src/components/FAB/FAB.tsx | 86 ++++++++------ src/components/FAB/Shell.tsx | 133 +++++++++++++++------ src/components/Tooltip/RichTooltip.tsx | 67 ++++++++--- src/components/Tooltip/Tooltip.tsx | 72 +++++++++--- src/components/Tooltip/hooks.ts | 25 +++- src/components/Tooltip/utils.ts | 17 ++- src/components/__tests__/Tooltip.test.tsx | 134 ++++++++-------------- 7 files changed, 335 insertions(+), 199 deletions(-) diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FAB.tsx index 2a897ddbb9..fc2d3bc573 100644 --- a/src/components/FAB/FAB.tsx +++ b/src/components/FAB/FAB.tsx @@ -45,6 +45,18 @@ export type Props = { * Function to execute on press. */ onPress?: (e: GestureResponderEvent) => void; + /** + * Function to execute on long press. + */ + onLongPress?: (e: GestureResponderEvent) => void; + /** + * Called when the pointer enters the element (web only). + */ + onHoverIn?: () => void; + /** + * Called when the pointer leaves the element (web only). + */ + onHoverOut?: () => void; /** * Accessibility label. Falls back to nothing if unset. */ @@ -103,42 +115,44 @@ export type Props = { * export default MyComponent; * ``` */ -const FAB = forwardRef( - ( - { - icon, - variant = 'tonalPrimary', - size = 'default', - visible = true, - onPress, - containerColor, - contentColor, - accessibilityLabel, - accessibilityState, - background, - style, - testID = 'floating-action-button', - theme, - }, - ref - ) => ( - - ) +const FAB = ({ + icon, + variant = 'tonalPrimary', + size = 'default', + visible = true, + onPress, + onLongPress, + onHoverIn, + onHoverOut, + containerColor, + contentColor, + accessibilityLabel, + accessibilityState, + background, + style, + testID = 'floating-action-button', + theme, + ref, +}: Props) => ( + ); export default FAB; diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx index b748b11e40..e593557979 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -93,6 +93,18 @@ export type ShellProps = { * Function to execute on press. */ onPress?: (e: GestureResponderEvent) => void; + /** + * Function to execute on long press. + */ + onLongPress?: (e: GestureResponderEvent) => void; + /** + * Called when the pointer enters the element (web only). + */ + onHoverIn?: () => void; + /** + * Called when the pointer leaves the element (web only). + */ + onHoverOut?: () => void; /** * Accessibility label. Falls back to `label` if unset. */ @@ -172,40 +184,40 @@ export type ShellProps = { * * Not exported from the package. */ -const Shell = forwardRef( - ( - { - icon, - label, - variant = 'tonalPrimary', - size = 'default', - containerColor, - contentColor, - shape, - iconSize, - leading, - trailing, - elevation = Tokens.stateElevation.enabled, - visible = true, - onPress, - accessibilityLabel = label, - accessibilityState, - labelMaxFontSizeMultiplier, - labelAnimatedStyle, - background, - widthShared, - heightShared, - borderRadiusShared, - transparentBackground = false, - overlay, - children, - style, - testID = 'fab-shell', - theme: themeOverrides, - }, - ref - ) => { - const theme = useInternalTheme(themeOverrides); +const Shell = ({ + icon, + label, + variant = 'tonalPrimary', + size = 'default', + containerColor, + contentColor, + shape, + iconSize, + leading, + trailing, + elevation = Tokens.stateElevation.enabled, + visible = true, + onPress, + onLongPress, + onHoverIn, + onHoverOut, + accessibilityLabel = label, + accessibilityState, + labelMaxFontSizeMultiplier, + labelAnimatedStyle, + background, + widthShared, + heightShared, + borderRadiusShared, + transparentBackground = false, + overlay, + children, + style, + testID = 'fab-shell', + theme: themeOverrides, + ref, +}: ShellProps) => { + const theme = useInternalTheme(themeOverrides); const dimensions = React.useMemo( () => getDimensions({ theme, size, shape, iconSize, leading, trailing }), @@ -279,7 +291,58 @@ const Shell = forwardRef( [borderRadius] ); - return ( + return ( + + + {overlay} + + {children ?? ( + + )} + + { + takeSingletonSlot(() => setVisible(false)); clearHideTimer(); setVisible(true); }, [clearHideTimer]); @@ -183,7 +185,10 @@ const RichTooltip = ({ // Mobile: a tap toggles the tooltip. const handlePress = React.useCallback(() => { - setVisible((v) => !v); + setVisible((v) => { + if (!v) takeSingletonSlot(() => setVisible(false)); + return !v; + }); clearShowTimer(); clearHideTimer(); }, [clearShowTimer, clearHideTimer]); @@ -191,18 +196,47 @@ const RichTooltip = ({ // Web: open on hover (with a short enter delay) and on keyboard focus. const handleHoverIn = React.useCallback(() => { clearHideTimer(); - showTimer.current = setTimeout(() => setVisible(true), enterTouchDelay); + showTimer.current = setTimeout(() => { + takeSingletonSlot(() => setVisible(false)); + setVisible(true); + }, enterTouchDelay); }, [clearHideTimer, enterTouchDelay]); - // Trigger props handed to the consumer's render function. + // On web, pointer events on the wrapper View handle hover without going + // through RNW's Pressable lock/unlock mechanism that caused flicker. + // Guard against spurious pointerleave: only schedule hide when cursor has + // actually left wrapper bounds (same reasoning as Tooltip.tsx). + const handlePointerLeave = React.useCallback( + (e?: PointerEvent) => { + if (Platform.OS === 'web' && e?.nativeEvent) { + const el = childrenWrapperRef.current as unknown as HTMLElement | null; + if (el) { + const { clientX, clientY } = e.nativeEvent; + const rect = el.getBoundingClientRect(); + if ( + (rect.width > 0 || rect.height > 0) && + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) { + return; + } + } + } + scheduleHide(); + }, + [scheduleHide, childrenWrapperRef] + ); + + const wrapperPointerProps = + Platform.OS === 'web' + ? { onPointerEnter: handleHoverIn, onPointerLeave: handlePointerLeave } + : {}; + const triggerProps: TooltipRichTriggerProps = Platform.OS === 'web' - ? { - onHoverIn: handleHoverIn, - onHoverOut: scheduleHide, - onFocus: show, - onBlur: scheduleHide, - } + ? { onFocus: show, onBlur: scheduleHide } : { onPress: handlePress }; // Web only: keep the tooltip open while the pointer travels from the trigger @@ -221,12 +255,13 @@ const RichTooltip = ({ accessibilityLabel="Close" accessibilityHint="Dismisses the tooltip" onPress={hide} - pointerEvents={visible ? 'auto' : 'none'} + pointerEvents={visible && Platform.OS !== 'web' ? 'auto' : 'none'} style={StyleSheet.absoluteFill} testID="tooltip-rich-backdrop" /> )} - {children(triggerProps)} - + ); }; diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 32c79dd27d..afce81e294 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -1,15 +1,10 @@ import * as React from 'react'; -import { - Dimensions, - StyleSheet, - Platform, - Pressable, -} from 'react-native'; -import type { LayoutChangeEvent, ViewStyle } from 'react-native'; +import { Dimensions, StyleSheet, Platform, View } from 'react-native'; +import type { PointerEvent, ViewStyle } from 'react-native'; import Animated from 'react-native-reanimated'; -import { useTooltipFade } from './hooks'; +import { takeSingletonSlot, useTooltipFade } from './hooks'; import { Tokens } from './tokens'; import { getTooltipPosition } from './utils'; import { useInternalTheme } from '../../core/theming'; @@ -125,8 +120,12 @@ const Tooltip = ({ } if (Platform.OS === 'web') { - showTimer.current = setTimeout(() => setVisible(true), enterTouchDelay); + showTimer.current = setTimeout(() => { + takeSingletonSlot(() => setVisible(false)); + setVisible(true); + }, enterTouchDelay); } else { + takeSingletonSlot(() => setVisible(false)); setVisible(true); } }, [enterTouchDelay]); @@ -139,14 +138,47 @@ const Tooltip = ({ hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay); }, [leaveTouchDelay]); - const triggerProps: TooltipTriggerProps = + // On web, pointer events on the wrapper View handle hover without going + // through RNW's Pressable lock/unlock mechanism that caused flicker. + // Long-press in triggerProps lets touchscreen-web users still trigger it. + // + // Guard against spurious pointerleave events (e.g. fired when the tooltip + // renders in the Portal): only start the hide timer when the cursor has + // actually left the wrapper bounds. In JSDOM getBoundingClientRect() returns + // a zero-size rect, so the check is skipped and tests continue to pass. + const handlePointerLeave = React.useCallback( + (e?: PointerEvent) => { + if (Platform.OS === 'web' && e?.nativeEvent) { + const el = childrenWrapperRef.current as unknown as HTMLElement | null; + if (el) { + const { clientX, clientY } = e.nativeEvent; + const rect = el.getBoundingClientRect(); + if ( + (rect.width > 0 || rect.height > 0) && + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) { + return; + } + } + } + handleTouchEnd(); + }, + [handleTouchEnd, childrenWrapperRef] + ); + + const wrapperPointerProps = Platform.OS === 'web' - ? { onHoverIn: handleTouchStart, onHoverOut: handleTouchEnd } - : { - onLongPress: handleTouchStart, - onPressOut: handleTouchEnd, - delayLongPress: enterTouchDelay, - }; + ? { onPointerEnter: handleTouchStart, onPointerLeave: handlePointerLeave } + : {}; + + const triggerProps: TooltipTriggerProps = { + onLongPress: handleTouchStart, + onPressOut: handleTouchEnd, + delayLongPress: enterTouchDelay, + }; return ( <> @@ -154,6 +186,7 @@ const Tooltip = ({ )} - {children(triggerProps)} - + ); }; diff --git a/src/components/Tooltip/hooks.ts b/src/components/Tooltip/hooks.ts index 57aabce68a..7aeb95ab44 100644 --- a/src/components/Tooltip/hooks.ts +++ b/src/components/Tooltip/hooks.ts @@ -1,5 +1,6 @@ import * as React from 'react'; -import { LayoutChangeEvent, View } from 'react-native'; +import { Platform, View } from 'react-native'; +import type { LayoutChangeEvent } from 'react-native'; import { cubicBezier } from 'react-native-reanimated'; @@ -8,6 +9,15 @@ import type { Measurement } from './utils'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { InternalTheme } from '../../types'; +// Ensures only one tooltip is visible at a time. When a tooltip calls +// takeSingletonSlot it immediately hides the previous one. +let dismissCurrentTooltip: (() => void) | null = null; + +export const takeSingletonSlot = (dismiss: () => void) => { + dismissCurrentTooltip?.(); + dismissCurrentTooltip = dismiss; +}; + /** * Drives the show/hide fade shared by both tooltip variants. * @@ -56,7 +66,18 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { childrenWrapperRef.current?.measure( (_x, _y, width, height, pageX, pageY) => { - childrenMeasurement.current = { pageX, pageY, width, height }; + // On web, measure() returns viewport-relative coords but the Portal + // container is positioned at the document origin — add scroll offset. + const scrollX = + Platform.OS === 'web' ? ((window as Window).scrollX ?? 0) : 0; + const scrollY = + Platform.OS === 'web' ? ((window as Window).scrollY ?? 0) : 0; + childrenMeasurement.current = { + pageX: pageX + scrollX, + pageY: pageY + scrollY, + width, + height, + }; } ); }, [rendered, visible]); diff --git a/src/components/Tooltip/utils.ts b/src/components/Tooltip/utils.ts index 190a99b77b..e6aa4b8610 100644 --- a/src/components/Tooltip/utils.ts +++ b/src/components/Tooltip/utils.ts @@ -24,12 +24,14 @@ export type TooltipChildProps = { onHoverOut?: () => void; }; +const EDGE_MARGIN = 8; + /** * Return true when the tooltip center x-coordinate relative to the wrapped element is negative. * The tooltip will be placed at the starting x-coordinate from the wrapped element. */ const overflowLeft = (center: number): boolean => { - return center < 0; + return center < EDGE_MARGIN; }; /** @@ -39,7 +41,7 @@ const overflowLeft = (center: number): boolean => { const overflowRight = (center: number, tooltipWidth: number): boolean => { const { width: layoutWidth } = Dimensions.get('window'); - return center + tooltipWidth > layoutWidth; + return center + tooltipWidth > layoutWidth - EDGE_MARGIN; }; /** @@ -67,10 +69,15 @@ const getTooltipXPosition = ( ? childrenX + (childrenWidth - tooltipWidth) / 2 : childrenX; - if (overflowLeft(center)) return childrenX; + if (overflowLeft(center)) return Math.max(EDGE_MARGIN, childrenX); - if (overflowRight(center, tooltipWidth)) - return childrenX + childrenWidth - tooltipWidth; + if (overflowRight(center, tooltipWidth)) { + const { width: layoutWidth } = Dimensions.get('window'); + return Math.min( + childrenX + childrenWidth - tooltipWidth, + layoutWidth - tooltipWidth - EDGE_MARGIN + ); + } return center; }; diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index 2461135add..adf32644fa 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -284,14 +284,10 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container').props.style).toMatchObject([ - {}, - { - left: 0, // Tooltip renders starting from children's x coord - top: 250, - }, - {}, - ]); + expect(getByTestId('tooltip-container')).toHaveStyle({ + left: 8, // Math.max(EDGE_MARGIN=8, pageX=0) + top: 250, + }); }); }); @@ -309,14 +305,10 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container').props.style).toMatchObject([ - {}, - { - left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen - top: 250, - }, - {}, - ]); + expect(getByTestId('tooltip-container')).toHaveStyle({ + left: 252, // Math.min(950, LAYOUT_WIDTH(360) - TOOLTIP_WIDTH(100) - EDGE_MARGIN(8)) + top: 250, + }); }); }); @@ -351,6 +343,12 @@ describe('Tooltip', () => { beforeAll(() => { Platform.OS = 'web'; }); + + // Hover is handled by onPointerEnter/onPointerLeave on the wrapper View. + const getWrapperTrigger = ( + getByTestId: Awaited>['getByTestId'] + ) => getByTestId('tooltip-trigger'); + describe('Unmount', () => { beforeAll(() => { jest.spyOn(global, 'clearTimeout'); @@ -361,10 +359,10 @@ describe('Tooltip', () => { it('removes showTooltipTimer when the component unmounts', async () => { const { - wrapper: { getByText, unmount }, + wrapper: { getByTestId, unmount }, } = await setup({ enterTouchDelay: 5000 }); - await fireEvent(getTrigger(getByText), 'hoverIn'); + await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter'); await unmount(); @@ -373,10 +371,10 @@ describe('Tooltip', () => { it('removes hideTooltipTimer when the component unmounts', async () => { const { - wrapper: { getByText, unmount }, + wrapper: { getByTestId, unmount }, } = await setup({ enterTouchDelay: 5000 }); - await fireEvent(getTrigger(getByText), 'hoverOut'); + await fireEvent(getWrapperTrigger(getByTestId), 'pointerLeave'); await unmount(); @@ -385,10 +383,10 @@ describe('Tooltip', () => { it('removes Dimensions listener when the component unmount', async () => { const { - wrapper: { getByText, findByText, unmount }, + wrapper: { getByTestId, findByText, unmount }, } = await setup(); - await fireEvent(getTrigger(getByText), 'hoverIn'); + await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter'); await runTimers(500); await findByText('some tooltip text'); @@ -411,13 +409,13 @@ describe('Tooltip', () => { jest.spyOn(global, 'clearTimeout'); const { - wrapper: { getByText }, + wrapper: { getByTestId }, } = await setup(); - const trigger = getTrigger(getByText); - await fireEvent(trigger, 'hoverIn'); - await fireEvent(trigger, 'hoverOut'); - await fireEvent(trigger, 'hoverIn'); + const trigger = getWrapperTrigger(getByTestId); + await fireEvent(trigger, 'pointerEnter'); + await fireEvent(trigger, 'pointerLeave'); + await fireEvent(trigger, 'pointerEnter'); expect(global.clearTimeout).toHaveBeenCalledTimes(2); }); @@ -426,18 +424,18 @@ describe('Tooltip', () => { describe('hoverOut', () => { it('hides the tooltip when the user stops hovering the component', async () => { const { - wrapper: { queryByText, getByText, findByText }, + wrapper: { queryByText, getByTestId, findByText }, } = await setup({ enterTouchDelay: 50, leaveTouchDelay: 0 }); - await fireEvent(getTrigger(getByText), 'hoverIn'); + await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter'); await runTimers(50); await findByText('some tooltip text'); - // Settle the hover-out in its own act() so its state update can't + // Settle the pointer-leave in its own act() so its state update can't // escape act and corrupt the renderer, then drain the fade-out timers. await act(async () => { - await fireEvent(getTrigger(getByText), 'hoverOut'); + await fireEvent(getWrapperTrigger(getByTestId), 'pointerLeave'); }); await runTimers(); // leaveTouchDelay → schedules the exit fade await runTimers(); // exit fade duration → unmounts @@ -464,10 +462,10 @@ describe('Tooltip', () => { describe('When it does not overflow', () => { it('centers the tooltip in the middle of the children component', async () => { const { - wrapper: { getByText, getByTestId, findByText }, + wrapper: { getByTestId, findByText }, } = await setup(); - await fireEvent(getTrigger(getByText), 'hoverIn'); + await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter'); await runTimers(500); await fireEvent(await findByText('some tooltip text'), 'layout', { @@ -490,10 +488,10 @@ describe('Tooltip', () => { describe('When it overflows to left', () => { it('renders the tooltip with the right placement', async () => { const { - wrapper: { getByText, getByTestId, findByText }, + wrapper: { getByTestId, findByText }, } = await setup({}, { pageX: 0 }); // Component starting at the starting 0 X coord - await fireEvent(getTrigger(getByText), 'hoverIn'); + await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter'); await runTimers(500); await fireEvent(await findByText('some tooltip text'), 'layout', { @@ -502,24 +500,20 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container').props.style).toMatchObject([ - {}, - { - left: 0, // Tooltip renders starting from children's x coord - top: 250, - }, - {}, - ]); + expect(getByTestId('tooltip-container')).toHaveStyle({ + left: 8, // Math.max(EDGE_MARGIN=8, pageX=0) + top: 250, + }); }); }); describe('When it overflows to right', () => { it('renders the tooltip with the right placement', async () => { const { - wrapper: { getByText, getByTestId, findByText }, + wrapper: { getByTestId, findByText }, } = await setup({}, { pageX: 900, width: 150 }); // Component close to the screen limit - await fireEvent(getTrigger(getByText), 'hoverIn'); + await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter'); await runTimers(500); await fireEvent(await findByText('some tooltip text'), 'layout', { @@ -528,24 +522,20 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container').props.style).toMatchObject([ - {}, - { - left: 950, // pageX (900) + width (150) - 100 (TOOLTIP_WIDTH) // Tooltip is placed from right to left without going offscreen - top: 250, - }, - {}, - ]); + expect(getByTestId('tooltip-container')).toHaveStyle({ + left: 252, // Math.min(950, LAYOUT_WIDTH(360) - TOOLTIP_WIDTH(100) - EDGE_MARGIN(8)) + top: 250, + }); }); }); describe('When it overflows to bottom', () => { it('renders the tooltip with the right placement', async () => { const { - wrapper: { getByText, getByTestId, findByText }, + wrapper: { getByTestId, findByText }, } = await setup({}, { pageY: 600, height: 50 }); - await fireEvent(getTrigger(getByText), 'hoverIn'); + await fireEvent(getWrapperTrigger(getByTestId), 'pointerEnter'); await runTimers(500); await fireEvent(await findByText('some tooltip text'), 'layout', { @@ -702,11 +692,11 @@ describe('Tooltip.Rich', () => { it('opens on hover after the enter delay', () => { const { - wrapper: { getByText, queryByText }, - } = setup({ enterTouchDelay: 100 }); + wrapper: { getByTestId, getByText, queryByText }, + } = await setup({ enterTouchDelay: 100 }); await act(async () => { - await fireEvent(getTrigger(getByText), 'hoverIn'); + await fireEvent(getByTestId('tooltip-rich-trigger'), 'pointerEnter'); }); expect(queryByText('Body text')).toBeNull(); // still within the delay @@ -742,14 +732,14 @@ describe('Tooltip.Rich', () => { } = setup({ enterTouchDelay: 0, leaveTouchDelay: 500 }); await act(async () => { - await fireEvent(getTrigger(getByText), 'hoverIn'); + await fireEvent(getByTestId('tooltip-rich-trigger'), 'pointerEnter'); }); await runTimers(0); expect(getByText('Body text')).toBeTruthy(); // Leaving the trigger schedules a hide... await act(async () => { - await fireEvent(getTrigger(getByText), 'hoverOut'); + await fireEvent(getByTestId('tooltip-rich-trigger'), 'pointerLeave'); // ...but entering the tooltip cancels it. await fireEvent(getByTestId('tooltip-rich-surface'), 'hoverIn'); }); @@ -757,29 +747,5 @@ describe('Tooltip.Rich', () => { expect(getByText('Body text')).toBeTruthy(); }); - - it('opens on hover even when the trigger ignores the hover props', () => { - // Some triggers (e.g. `IconButton`) don't forward `onHoverIn` on web, - // so the wrapper must carry the handlers itself. Here the trigger - // deliberately drops the provided props. - jest - .spyOn(View.prototype, 'measure') - .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200)); - - const { getByTestId, getByText, queryByText } = render( - - - {() => } - - - ); - - await fireEvent(getByTestId('tooltip-rich-trigger'), 'hoverIn'); - expect(queryByText('Body text')).toBeNull(); // within the enter delay - - runTimers(100); - - expect(getByText('Body text')).toBeTruthy(); - }); }); }); From 75f0dd93cd0c9d23e4d719d5b98aa00f826ee386 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Mon, 29 Jun 2026 13:03:59 +0200 Subject: [PATCH 22/29] fix(tooltip): mobile interaction and Android animation bugs - Fix second-tap bug: takeSingletonSlot was called inside setVisible updater, causing a nested setState race that prevented re-opening after backdrop dismissal - Fix one-tap switching: each trigger registers its screen rect in a global map; backdrop forwards presses to the hit trigger instead of consuming them, so tapping another icon closes the current tooltip and opens the new one in a single tap - Fix Android grey-border artifact: hold elevation=0 during the enter fade (elevation shadows ignore opacity on Android), then apply the correct elevation after enterDuration ms so the shadow appears in sync with the animation completing --- src/components/Tooltip/RichTooltip.tsx | 93 ++++++++++++++++++++--- src/components/Tooltip/hooks.ts | 52 ++++++++++++- src/components/__tests__/Tooltip.test.tsx | 38 ++++++++- 3 files changed, 171 insertions(+), 12 deletions(-) diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index 607fdc9319..e4d1b09329 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -11,7 +11,13 @@ import type { PointerEvent, ViewStyle } from 'react-native'; import Animated from 'react-native-reanimated'; -import { takeSingletonSlot, useTooltipFade } from './hooks'; +import { + takeSingletonSlot, + useTooltipFade, + registerRichTrigger, + unregisterRichTrigger, + forwardPressToTriggerAt, +} from './hooks'; import { Tokens } from './tokens'; import { getTooltipPosition } from './utils'; import { useInternalTheme } from '../../core/theming'; @@ -132,8 +138,31 @@ const RichTooltip = ({ // `visible` is the show/hide intent; the fade hook keeps the tooltip mounted // through the exit animation and owns the measurement + opacity. const [visible, setVisible] = React.useState(false); - const { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef } = - useTooltipFade(theme, visible); + const { + rendered, + measurement, + fadeStyle, + onLayout, + childrenWrapperRef, + enterDuration, + } = useTooltipFade(theme, visible); + + // Android: elevation shadows don't participate in opacity compositing. + // Keep elevation at 0 during the enter fade so there's no grey-border + // artifact, then add it exactly when the content reaches full opacity. + const [elevationReady, setElevationReady] = React.useState(false); + React.useEffect(() => { + if (Platform.OS !== 'android') { + setElevationReady(true); + return; + } + if (visible && measurement.measured) { + const id = setTimeout(() => setElevationReady(true), enterDuration); + return () => clearTimeout(id); + } + setElevationReady(false); + return undefined; + }, [visible, measurement.measured, enterDuration]); const showTimer = React.useRef | null>(null); const hideTimer = React.useRef | null>(null); @@ -184,14 +213,19 @@ const RichTooltip = ({ }, [clearShowTimer, leaveTouchDelay]); // Mobile: a tap toggles the tooltip. + // takeSingletonSlot must be called outside the setVisible updater — calling + // setVisible inside an updater queues it for a later render, so the dismiss + // of the stale slot would undo the show on the same cycle. const handlePress = React.useCallback(() => { - setVisible((v) => { - if (!v) takeSingletonSlot(() => setVisible(false)); - return !v; - }); + if (!visible) { + takeSingletonSlot(() => setVisible(false)); + setVisible(true); + } else { + setVisible(false); + } clearShowTimer(); clearHideTimer(); - }, [clearShowTimer, clearHideTimer]); + }, [visible, clearShowTimer, clearHideTimer]); // Web: open on hover (with a short enter delay) and on keyboard focus. const handleHoverIn = React.useCallback(() => { @@ -246,6 +280,44 @@ const RichTooltip = ({ ? { onHoverIn: clearHideTimer, onHoverOut: scheduleHide } : {}; + // Mobile: stable id + ref for the latest handlePress, used by the global + // trigger registry so the backdrop can forward taps to another trigger. + const triggerId = React.useRef(Symbol()).current; + const handlePressRef = React.useRef(handlePress); + React.useEffect(() => { + handlePressRef.current = handlePress; + }, [handlePress]); + + const updateTriggerRegistration = React.useCallback(() => { + if (Platform.OS === 'web') return; + childrenWrapperRef.current?.measureInWindow?.((x, y, width, height) => { + registerRichTrigger(triggerId, { + pageX: x, + pageY: y, + width, + height, + onPress: () => handlePressRef.current(), + }); + }); + }, [childrenWrapperRef, triggerId]); + + React.useEffect(() => { + return () => { + unregisterRichTrigger(triggerId); + }; + }, [triggerId]); + + const handleBackdropPress = React.useCallback( + (e: { nativeEvent: { pageX?: number; pageY?: number } }) => { + const pageX = e?.nativeEvent?.pageX ?? -1; + const pageY = e?.nativeEvent?.pageY ?? -1; + if (!forwardPressToTriggerAt(pageX, pageY)) { + hide(); + } + }, + [hide] + ); + return ( <> {rendered && ( @@ -254,7 +326,7 @@ const RichTooltip = ({ accessibilityRole="button" accessibilityLabel="Close" accessibilityHint="Dismisses the tooltip" - onPress={hide} + onPress={handleBackdropPress} pointerEvents={visible && Platform.OS !== 'web' ? 'auto' : 'none'} style={StyleSheet.absoluteFill} testID="tooltip-rich-backdrop" @@ -271,7 +343,7 @@ const RichTooltip = ({ > void) => { dismissCurrentTooltip = dismiss; }; +// Mobile backdrop hit-forwarding: each RichTooltip registers its trigger's +// screen rect so the backdrop can forward presses that land on another trigger +// rather than consuming them. This allows one-tap switching between tooltips. +type RichTriggerEntry = { + pageX: number; + pageY: number; + width: number; + height: number; + onPress: () => void; +}; +const richTriggerRegistry = new Map(); + +export const registerRichTrigger = ( + id: symbol, + entry: RichTriggerEntry +): void => { + richTriggerRegistry.set(id, entry); +}; + +export const unregisterRichTrigger = (id: symbol): void => { + richTriggerRegistry.delete(id); +}; + +// Returns true if a registered trigger was hit and pressed (caller should NOT +// also call hide() in this case — the singleton dismisses the current tooltip). +export const forwardPressToTriggerAt = ( + pageX: number, + pageY: number +): boolean => { + for (const entry of richTriggerRegistry.values()) { + if ( + pageX >= entry.pageX && + pageX <= entry.pageX + entry.width && + pageY >= entry.pageY && + pageY <= entry.pageY + entry.height + ) { + entry.onPress(); + return true; + } + } + return false; +}; + /** * Drives the show/hide fade shared by both tooltip variants. * @@ -127,5 +170,12 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { : cubicBezier(...theme.motion.easing[Tokens.motion.exit.easing]), }; - return { rendered, measurement, fadeStyle, onLayout, childrenWrapperRef }; + return { + rendered, + measurement, + fadeStyle, + onLayout, + childrenWrapperRef, + enterDuration, + }; }; diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index adf32644fa..2c94111744 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -580,6 +580,9 @@ describe('Tooltip.Rich', () => { jest .spyOn(View.prototype, 'measure') .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200)); + jest + .spyOn(View.prototype, 'measureInWindow') + .mockImplementation((cb) => cb(0, 0, 0, 0)); const wrapper = render( @@ -667,7 +670,40 @@ describe('Tooltip.Rich', () => { expect(queryByText('Body text')).toBeNull(); }); - it('dismisses when an action calls dismiss', () => { + it('dismisses the open tooltip when another tooltip trigger is pressed', async () => { + jest + .spyOn(View.prototype, 'measure') + .mockImplementation((cb) => cb(0, 0, 80, 50, 220, 200)); + jest + .spyOn(View.prototype, 'measureInWindow') + .mockImplementation((cb) => cb(0, 0, 0, 0)); + + const { getAllByText, getByText, queryByText } = await render( + + + {(props) => } + + + {(props) => } + + + ); + + const [textA, textB] = getAllByText('dummy component'); + const triggerA = textA.parent!; + const triggerB = textB.parent!; + + await user.press(triggerA); + expect(getByText('First tooltip')).toBeTruthy(); + + await user.press(triggerB); + await runTimers(); // exit fade → unmount + + expect(queryByText('First tooltip')).toBeNull(); + expect(getByText('Second tooltip')).toBeTruthy(); + }); + + it('dismisses when an action calls dismiss', async () => { const { wrapper: { getByText, queryByText }, } = setup({ From 6d1dfaceada245c010b4cc265d424e7bcfd5dcec Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Tue, 30 Jun 2026 11:41:15 +0200 Subject: [PATCH 23/29] fix(tooltip): fix Tooltip.Rich one-tap switching after scroll - Register a subscribeToTriggerRefresh callback in each RichTooltip so that takeSingletonSlot triggers a re-measure of all trigger positions before the backdrop needs to hit-test against them. This fixes one-tap switching when the Rich tooltips section has been scrolled (onLayout fires with off-screen coordinates on mount and is never called again on scroll, leaving stale pageX/pageY in the registry). - Fix useTooltipFade measure/onLayout race: stash the tooltip layout in a ref so whichever async call arrives second can complete the combined measurement, preventing the tooltip from staying invisible when onLayout fires before the measure() callback on deeply-nested triggers. --- src/components/Tooltip/RichTooltip.tsx | 7 +++++ src/components/Tooltip/hooks.ts | 38 ++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index e4d1b09329..c29a744b57 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -17,6 +17,7 @@ import { registerRichTrigger, unregisterRichTrigger, forwardPressToTriggerAt, + subscribeToTriggerRefresh, } from './hooks'; import { Tokens } from './tokens'; import { getTooltipPosition } from './utils'; @@ -307,6 +308,12 @@ const RichTooltip = ({ }; }, [triggerId]); + // Re-measure this trigger whenever any tooltip opens so scroll-invalidated + // coordinates are refreshed before the backdrop needs to hit-test against them. + React.useEffect(() => { + return subscribeToTriggerRefresh(updateTriggerRegistration); + }, [updateTriggerRegistration]); + const handleBackdropPress = React.useCallback( (e: { nativeEvent: { pageX?: number; pageY?: number } }) => { const pageX = e?.nativeEvent?.pageX ?? -1; diff --git a/src/components/Tooltip/hooks.ts b/src/components/Tooltip/hooks.ts index e6179ff662..28295e418a 100644 --- a/src/components/Tooltip/hooks.ts +++ b/src/components/Tooltip/hooks.ts @@ -13,9 +13,26 @@ import type { InternalTheme } from '../../types'; // takeSingletonSlot it immediately hides the previous one. let dismissCurrentTooltip: (() => void) | null = null; +// When any tooltip opens, trigger re-measurement of all registered triggers so +// scroll-invalidated coordinates are refreshed before the backdrop is used. +type RefreshCallback = () => void; +const triggerRefreshCallbacks = new Set(); + +export const subscribeToTriggerRefresh = ( + cb: RefreshCallback +): (() => void) => { + triggerRefreshCallbacks.add(cb); + return () => triggerRefreshCallbacks.delete(cb); +}; + +const refreshAllTriggers = () => { + triggerRefreshCallbacks.forEach((cb) => cb()); +}; + export const takeSingletonSlot = (dismiss: () => void) => { dismissCurrentTooltip?.(); dismissCurrentTooltip = dismiss; + refreshAllTriggers(); }; // Mobile backdrop hit-forwarding: each RichTooltip registers its trigger's @@ -80,11 +97,14 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { measured: false, }); const childrenWrapperRef = React.useRef(null); - // The trigger is measured synchronously and stashed here so the tooltip's - // own layout can combine the two into the final measurement in one update. + // The trigger measurement and tooltip layout are each obtained via async + // native calls that can complete in either order. Both refs are written by + // whichever side fires first; the second side checks whether the other is + // already available and, if so, completes the combined measurement. const childrenMeasurement = React.useRef( null ); + const tooltipLayout = React.useRef(null); const enterDuration = reduceMotion ? 0 @@ -121,6 +141,15 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { width, height, }; + // If onLayout already fired before this callback, use the stashed + // tooltip layout to complete the measurement now. + if (tooltipLayout.current) { + setMeasurement({ + children: childrenMeasurement.current, + tooltip: tooltipLayout.current, + measured: true, + }); + } } ); }, [rendered, visible]); @@ -139,14 +168,17 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { measured: false, }); childrenMeasurement.current = null; + tooltipLayout.current = null; }, exitDuration); return () => clearTimeout(id); }, [rendered, visible, exitDuration]); // The tooltip reports its own size on layout; combine it with the trigger - // measurement captured above to compute the final position in one update. + // measurement to compute the final position. Either side can arrive first: + // stash the layout and let the measure callback pick it up if it's late. const onLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { + tooltipLayout.current = layout; if (!childrenMeasurement.current) { return; } From f85fcdb5e0bdce133564e48a64c50c584330f152 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Tue, 30 Jun 2026 12:50:32 +0200 Subject: [PATCH 24/29] fix(tooltip): cancel stale hide timer before scheduling a new one handleTouchEnd / scheduleHide created a new hide timer on every onPressOut without cancelling the previous one. Short presses or repeated interactions could leave orphaned timers that fired later and hid the tooltip at unexpected times, causing intermittent "every other time" failures on mobile. --- src/components/Tooltip/RichTooltip.tsx | 3 +++ src/components/Tooltip/Tooltip.tsx | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index c29a744b57..e5e2f87c4a 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -210,6 +210,9 @@ const RichTooltip = ({ const scheduleHide = React.useCallback(() => { clearShowTimer(); + if (hideTimer.current) { + clearTimeout(hideTimer.current); + } hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay); }, [clearShowTimer, leaveTouchDelay]); diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index afce81e294..86f4c7a861 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -135,6 +135,9 @@ const Tooltip = ({ clearTimeout(showTimer.current); showTimer.current = null; } + if (hideTimer.current) { + clearTimeout(hideTimer.current); + } hideTimer.current = setTimeout(() => setVisible(false), leaveTouchDelay); }, [leaveTouchDelay]); From 416f283d3a8a9a8d6053d52242cdc456743fb2df Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Tue, 30 Jun 2026 13:26:51 +0200 Subject: [PATCH 25/29] refactor(tooltip): replace as casts with proper ref type Type childrenWrapperRef as View & { getBoundingClientRect?(): DOMRect } so callers can check el?.getBoundingClientRect and call it without casting. Removes the as unknown as HTMLElement | null double-cast in both Tooltip and RichTooltip. --- src/components/Tooltip/RichTooltip.tsx | 4 ++-- src/components/Tooltip/Tooltip.tsx | 4 ++-- src/components/Tooltip/hooks.ts | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index e5e2f87c4a..16820f8d8e 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -247,8 +247,8 @@ const RichTooltip = ({ const handlePointerLeave = React.useCallback( (e?: PointerEvent) => { if (Platform.OS === 'web' && e?.nativeEvent) { - const el = childrenWrapperRef.current as unknown as HTMLElement | null; - if (el) { + const el = childrenWrapperRef.current; + if (el?.getBoundingClientRect) { const { clientX, clientY } = e.nativeEvent; const rect = el.getBoundingClientRect(); if ( diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 86f4c7a861..32c1b2341b 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -152,8 +152,8 @@ const Tooltip = ({ const handlePointerLeave = React.useCallback( (e?: PointerEvent) => { if (Platform.OS === 'web' && e?.nativeEvent) { - const el = childrenWrapperRef.current as unknown as HTMLElement | null; - if (el) { + const el = childrenWrapperRef.current; + if (el?.getBoundingClientRect) { const { clientX, clientY } = e.nativeEvent; const rect = el.getBoundingClientRect(); if ( diff --git a/src/components/Tooltip/hooks.ts b/src/components/Tooltip/hooks.ts index 28295e418a..8c85599479 100644 --- a/src/components/Tooltip/hooks.ts +++ b/src/components/Tooltip/hooks.ts @@ -96,7 +96,9 @@ export const useTooltipFade = (theme: InternalTheme, visible: boolean) => { tooltip: { x: 0, y: 0, width: 0, height: 0 }, measured: false, }); - const childrenWrapperRef = React.useRef(null); + const childrenWrapperRef = React.useRef< + View & { getBoundingClientRect?(): DOMRect } + >(null); // The trigger measurement and tooltip layout are each obtained via async // native calls that can complete in either order. Both refs are written by // whichever side fires first; the second side checks whether the other is From 4e6a8a744a3d379cb8e98ce0aef0844e6464dec1 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Tue, 30 Jun 2026 14:01:32 +0200 Subject: [PATCH 26/29] fix(tooltip): fix FAB tooltip and Tooltip.Rich touchscreen web - Add onPressOut and delayLongPress to FAB and Shell props and forward them to TouchableRipple so the hide timer fires correctly after a long-press and the delay is configurable. - Add onPress to Tooltip.Rich web triggerProps so touchscreen-web users can tap to toggle the tooltip (previously only hover and keyboard focus worked on web). --- src/components/FAB/FAB.tsx | 12 ++++++++++++ src/components/FAB/Shell.tsx | 12 ++++++++++++ src/components/Tooltip/RichTooltip.tsx | 2 +- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FAB.tsx index fc2d3bc573..3964664531 100644 --- a/src/components/FAB/FAB.tsx +++ b/src/components/FAB/FAB.tsx @@ -49,6 +49,14 @@ export type Props = { * Function to execute on long press. */ onLongPress?: (e: GestureResponderEvent) => void; + /** + * Function to execute when a touch is released. + */ + onPressOut?: (e: GestureResponderEvent) => void; + /** + * The number of milliseconds a user must touch the element before executing `onLongPress`. + */ + delayLongPress?: number; /** * Called when the pointer enters the element (web only). */ @@ -122,6 +130,8 @@ const FAB = ({ visible = true, onPress, onLongPress, + onPressOut, + delayLongPress, onHoverIn, onHoverOut, containerColor, @@ -142,6 +152,8 @@ const FAB = ({ visible={visible} onPress={onPress} onLongPress={onLongPress} + onPressOut={onPressOut} + delayLongPress={delayLongPress} onHoverIn={onHoverIn} onHoverOut={onHoverOut} containerColor={containerColor} diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx index e593557979..bdf3d02ea8 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -97,6 +97,14 @@ export type ShellProps = { * Function to execute on long press. */ onLongPress?: (e: GestureResponderEvent) => void; + /** + * Function to execute when a touch is released. + */ + onPressOut?: (e: GestureResponderEvent) => void; + /** + * The number of milliseconds a user must touch the element before executing `onLongPress`. + */ + delayLongPress?: number; /** * Called when the pointer enters the element (web only). */ @@ -199,6 +207,8 @@ const Shell = ({ visible = true, onPress, onLongPress, + onPressOut, + delayLongPress, onHoverIn, onHoverOut, accessibilityLabel = label, @@ -310,6 +320,8 @@ const Shell = ({ background={background} onPress={onPress} onLongPress={onLongPress} + onPressOut={onPressOut} + delayLongPress={delayLongPress} onHoverIn={onHoverIn} onHoverOut={onHoverOut} onFocus={onFocus} diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index 16820f8d8e..e49282148d 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -274,7 +274,7 @@ const RichTooltip = ({ const triggerProps: TooltipRichTriggerProps = Platform.OS === 'web' - ? { onFocus: show, onBlur: scheduleHide } + ? { onPress: handlePress, onFocus: show, onBlur: scheduleHide } : { onPress: handlePress }; // Web only: keep the tooltip open while the pointer travels from the trigger From 0be9098e2ce2464dab9bc57fe42b196403a36150 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 1 Jul 2026 14:03:00 +0200 Subject: [PATCH 27/29] fix(fab): apply upstream a11y migration and fix RNTL v14 patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies accessibility prop renames from upstream (#5005) to FAB, Shell, Extended, and Menu (accessibilityLabel → aria-label suite). Updates all FAB tests for RNTL v14 async render, userEvent, and screen queries. Adds src/utils/forwardRef.ts, widens Shell ref to React.Ref, and installs @types/jest to fix the broken tsconfig types reference. --- docs/src/components/BannerExample.tsx | 1 - package.json | 1 + src/components/FAB/Content.tsx | 1 - src/components/FAB/Extended.tsx | 34 ++- src/components/FAB/FAB.tsx | 35 ++- src/components/FAB/Menu.tsx | 22 +- src/components/FAB/Shell.tsx | 228 ++++++++---------- src/components/Tooltip/RichTooltip.tsx | 5 +- src/components/__tests__/FAB.test.tsx | 94 ++++---- src/components/__tests__/FABExtended.test.tsx | 114 +++++---- src/components/__tests__/FABMenu.test.tsx | 191 ++++++++------- src/components/__tests__/Tooltip.test.tsx | 97 ++++---- .../__tests__/__snapshots__/FAB.test.tsx.snap | 28 +-- .../__snapshots__/FABExtended.test.tsx.snap | 60 ++--- .../__snapshots__/FABMenu.test.tsx.snap | 14 +- src/utils/forwardRef.ts | 1 + tsconfig.json | 2 +- yarn.lock | 138 ++++++++++- 18 files changed, 596 insertions(+), 470 deletions(-) create mode 100644 src/utils/forwardRef.ts diff --git a/docs/src/components/BannerExample.tsx b/docs/src/components/BannerExample.tsx index 0dd275062a..201c15de11 100644 --- a/docs/src/components/BannerExample.tsx +++ b/docs/src/components/BannerExample.tsx @@ -8,7 +8,6 @@ import { Button, FAB, DarkTheme, - FAB, LightTheme, ProgressBar, Provider, diff --git a/package.json b/package.json index 3a0296b5f5..c3111e4f76 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@release-it/conventional-changelog": "^1.1.0", "@testing-library/react-native": "^14.0.0", "@types/color": "^3.0.0", + "@types/jest": "^30.0.0", "@types/node": "^24.0.0", "@types/react": "^19.2.7", "all-contributors-cli": "^6.24.0", diff --git a/src/components/FAB/Content.tsx b/src/components/FAB/Content.tsx index d7dc81a9cb..8c5b1fa7c2 100644 --- a/src/components/FAB/Content.tsx +++ b/src/components/FAB/Content.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import { ColorValue, StyleProp, diff --git a/src/components/FAB/Extended.tsx b/src/components/FAB/Extended.tsx index d10d624596..6650e96c6c 100644 --- a/src/components/FAB/Extended.tsx +++ b/src/components/FAB/Extended.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { - AccessibilityState, ColorValue, GestureResponderEvent, PressableAndroidRippleConfig, @@ -73,11 +72,24 @@ export type Props = { /** * Accessibility label. Falls back to `label` if unset. */ - accessibilityLabel?: string; + 'aria-label'?: string; /** - * Accessibility state forwarded to the underlying button. + * Indicates whether the element is checked. Accepts `true`, `false`, + * or `'mixed'` for an indeterminate state. */ - accessibilityState?: AccessibilityState; + 'aria-checked'?: boolean | 'mixed'; + /** + * Indicates whether the element is selected. + */ + 'aria-selected'?: boolean; + /** + * Indicates whether the element is currently busy (e.g. loading). + */ + 'aria-busy'?: boolean; + /** + * Indicates whether the element's controlled content is expanded. + */ + 'aria-expanded'?: boolean; /** * Specifies the largest possible scale a label font can reach. */ @@ -152,8 +164,11 @@ const Extended = forwardRef( expanded, visible = true, onPress, - accessibilityLabel = label, - accessibilityState, + 'aria-label': ariaLabel = label, + 'aria-checked': ariaChecked, + 'aria-selected': ariaSelected, + 'aria-busy': ariaBusy, + 'aria-expanded': ariaExpanded, labelMaxFontSizeMultiplier, background, style, @@ -244,8 +259,11 @@ const Extended = forwardRef( size={size} visible={visible} onPress={onPress} - accessibilityLabel={accessibilityLabel} - accessibilityState={accessibilityState} + aria-label={ariaLabel} + aria-checked={ariaChecked} + aria-selected={ariaSelected} + aria-busy={ariaBusy} + aria-expanded={ariaExpanded} background={background} widthShared={widthValue} labelMaxFontSizeMultiplier={labelMaxFontSizeMultiplier} diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FAB.tsx index 3964664531..b425662c2a 100644 --- a/src/components/FAB/FAB.tsx +++ b/src/components/FAB/FAB.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { - AccessibilityState, ColorValue, GestureResponderEvent, PressableAndroidRippleConfig, @@ -12,7 +11,6 @@ import { import Shell from './Shell'; import { Size, Variant } from './tokens'; import type { ThemeProp } from '../../types'; -import { forwardRef } from '../../utils/forwardRef'; import type { IconSource } from '../Icon'; export type Props = { @@ -68,11 +66,24 @@ export type Props = { /** * Accessibility label. Falls back to nothing if unset. */ - accessibilityLabel?: string; + 'aria-label'?: string; /** - * Accessibility state forwarded to the underlying button. + * Indicates whether the element is checked. Accepts `true`, `false`, + * or `'mixed'` for an indeterminate state. */ - accessibilityState?: AccessibilityState; + 'aria-checked'?: boolean | 'mixed'; + /** + * Indicates whether the element is selected. + */ + 'aria-selected'?: boolean; + /** + * Indicates whether the element is currently busy (e.g. loading). + */ + 'aria-busy'?: boolean; + /** + * Indicates whether the element's controlled content is expanded. + */ + 'aria-expanded'?: boolean; /** * Type of background drawable to display the feedback (Android). * https://reactnative.dev/docs/pressable#rippleconfig @@ -136,8 +147,11 @@ const FAB = ({ onHoverOut, containerColor, contentColor, - accessibilityLabel, - accessibilityState, + 'aria-label': ariaLabel, + 'aria-checked': ariaChecked, + 'aria-selected': ariaSelected, + 'aria-busy': ariaBusy, + 'aria-expanded': ariaExpanded, background, style, testID = 'floating-action-button', @@ -158,8 +172,11 @@ const FAB = ({ onHoverOut={onHoverOut} containerColor={containerColor} contentColor={contentColor} - accessibilityLabel={accessibilityLabel} - accessibilityState={accessibilityState} + aria-label={ariaLabel} + aria-checked={ariaChecked} + aria-selected={ariaSelected} + aria-busy={ariaBusy} + aria-expanded={ariaExpanded} background={background} style={style} testID={testID} diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx index 0af09b5eb5..094efe1da2 100644 --- a/src/components/FAB/Menu.tsx +++ b/src/components/FAB/Menu.tsx @@ -428,8 +428,8 @@ const MorphingTrigger = ({ alignment === 'start' ? 'flex-start' : alignment === 'center' - ? 'center' - : 'flex-end'; + ? 'center' + : 'flex-end'; return ( ; + ref?: React.Ref; }; /** @@ -211,8 +222,11 @@ const Shell = ({ delayLongPress, onHoverIn, onHoverOut, - accessibilityLabel = label, - accessibilityState, + 'aria-label': ariaLabel = label, + 'aria-checked': ariaChecked, + 'aria-selected': ariaSelected, + 'aria-busy': ariaBusy, + 'aria-expanded': ariaExpanded, labelMaxFontSizeMultiplier, labelAnimatedStyle, background, @@ -229,77 +243,75 @@ const Shell = ({ }: ShellProps) => { const theme = useInternalTheme(themeOverrides); - const dimensions = React.useMemo( - () => getDimensions({ theme, size, shape, iconSize, leading, trailing }), - [theme, size, shape, iconSize, leading, trailing] - ); + const dimensions = React.useMemo( + () => getDimensions({ theme, size, shape, iconSize, leading, trailing }), + [theme, size, shape, iconSize, leading, trailing] + ); - const colors = React.useMemo( - () => resolveColors({ theme, variant, containerColor, contentColor }), - [theme, variant, containerColor, contentColor] - ); + const colors = React.useMemo( + () => resolveColors({ theme, variant, containerColor, contentColor }), + [theme, variant, containerColor, contentColor] + ); - const { scale, alpha, shadowStyle } = useVisibility({ - visible, - theme, - elevation, - }); + const { scale, alpha, shadowStyle } = useVisibility({ + visible, + theme, + elevation, + }); - // Fallback shared values track the static size-driven dimensions. Consumers - // that don't supply their own animated shared values get these. Keeping - // everything as a shared value means there's exactly one animated style - // per view — no static-vs-animated merge surprises. - const fallbackWidth = useSharedValue(dimensions.width); - const fallbackHeight = useSharedValue(dimensions.height); - const fallbackBorderRadius = useSharedValue(dimensions.borderRadius); - React.useEffect(() => { - fallbackWidth.value = dimensions.width; - fallbackHeight.value = dimensions.height; - fallbackBorderRadius.value = dimensions.borderRadius; - }, [ - dimensions.width, - dimensions.height, - dimensions.borderRadius, - fallbackWidth, - fallbackHeight, - fallbackBorderRadius, - ]); + // Fallback shared values track the static size-driven dimensions. Consumers + // that don't supply their own animated shared values get these. Keeping + // everything as a shared value means there's exactly one animated style + // per view — no static-vs-animated merge surprises. + const fallbackWidth = useSharedValue(dimensions.width); + const fallbackHeight = useSharedValue(dimensions.height); + const fallbackBorderRadius = useSharedValue(dimensions.borderRadius); + React.useEffect(() => { + fallbackWidth.value = dimensions.width; + fallbackHeight.value = dimensions.height; + fallbackBorderRadius.value = dimensions.borderRadius; + }, [ + dimensions.width, + dimensions.height, + dimensions.borderRadius, + fallbackWidth, + fallbackHeight, + fallbackBorderRadius, + ]); - const width = widthShared ?? fallbackWidth; - const height = heightShared ?? fallbackHeight; - const borderRadius = borderRadiusShared ?? fallbackBorderRadius; - const containerBg = transparentBackground - ? 'transparent' - : colors.container; + const width = widthShared ?? fallbackWidth; + const height = heightShared ?? fallbackHeight; + const borderRadius = borderRadiusShared ?? fallbackBorderRadius; + const containerBg = transparentBackground ? 'transparent' : colors.container; - const outerStyle = useAnimatedStyle( - () => ({ - transform: [{ scale: scale.value }], - opacity: alpha.value, - width: width.value, - height: height.value, - borderRadius: borderRadius.value, - backgroundColor: containerBg, - }), - [width, height, borderRadius, containerBg] - ); + const outerStyle = useAnimatedStyle( + () => ({ + transform: [{ scale: scale.value }], + opacity: alpha.value, + width: width.value, + height: height.value, + borderRadius: borderRadius.value, + backgroundColor: containerBg, + }), + [width, height, borderRadius, containerBg] + ); - const clipStyle = useAnimatedStyle( - () => ({ - borderRadius: borderRadius.value, - backgroundColor: containerBg, - }), - [borderRadius, containerBg] - ); + const clipStyle = useAnimatedStyle( + () => ({ + borderRadius: borderRadius.value, + backgroundColor: containerBg, + }), + [borderRadius, containerBg] + ); - const { focusedSV, onFocus, onBlur } = useFocusRing(); - const focusRingStyle = useAnimatedStyle( - () => ({ - opacity: focusedSV.value ? 1 : 0, - borderRadius: borderRadius.value + FOCUS_RING_INSET, - }), - [borderRadius] - ); + const { focusedSV, onFocus, onBlur } = useFocusRing(); + const focusRingStyle = useAnimatedStyle( + () => ({ + opacity: focusedSV.value ? 1 : 0, + borderRadius: borderRadius.value + FOCUS_RING_INSET, + }), + [borderRadius] + ); return ( - - {overlay} - - {children ?? ( - - )} - - - - - ); - } -); + /> + + ); +}; const styles = StyleSheet.create({ container: { diff --git a/src/components/Tooltip/RichTooltip.tsx b/src/components/Tooltip/RichTooltip.tsx index e49282148d..93b532933c 100644 --- a/src/components/Tooltip/RichTooltip.tsx +++ b/src/components/Tooltip/RichTooltip.tsx @@ -5,7 +5,6 @@ import { StyleSheet, Platform, Pressable, - ViewStyle, } from 'react-native'; import type { PointerEvent, ViewStyle } from 'react-native'; @@ -365,7 +364,7 @@ const RichTooltip = ({ > {title ? ( { - const tree = render().toJSON(); +it('renders FAB with default props', async () => { + const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB with primary variant', () => { - const tree = render().toJSON(); +it('renders FAB with primary variant', async () => { + const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB with secondary variant', () => { - const tree = render().toJSON(); +it('renders FAB with secondary variant', async () => { + const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB with tertiary variant', () => { - const tree = render().toJSON(); +it('renders FAB with tertiary variant', async () => { + const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB with tonalSecondary variant', () => { - const tree = render().toJSON(); +it('renders FAB with tonalSecondary variant', async () => { + const tree = ( + await render() + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB with tonalTertiary variant', () => { - const tree = render().toJSON(); +it('renders FAB with tonalTertiary variant', async () => { + const tree = ( + await render() + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB medium size', () => { - const tree = render().toJSON(); +it('renders FAB medium size', async () => { + const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB large size', () => { - const tree = render().toJSON(); +it('renders FAB large size', async () => { + const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB with containerColor override', () => { - const tree = render().toJSON(); +it('renders FAB with containerColor override', async () => { + const tree = ( + await render() + ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB with containerColor and contentColor overrides', () => { - const tree = render( - +it('renders FAB with containerColor and contentColor overrides', async () => { + const tree = ( + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB with accessibilityLabel', () => { - const tree = render( - +it('renders FAB with aria-label', async () => { + const tree = ( + await render() ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB transitioning to not visible', () => { - const { update, toJSON } = render(); - update(); +it('renders FAB transitioning to not visible', async () => { + const { rerender, toJSON } = await render(); + await rerender(); expect(toJSON()).toMatchSnapshot(); }); -it('renders FAB transitioning to visible', () => { - const { update, toJSON } = render(); - update(); +it('renders FAB transitioning to visible', async () => { + const { rerender, toJSON } = await render( + + ); + await rerender(); expect(toJSON()).toMatchSnapshot(); }); -it('calls onPress when FAB is pressed', () => { +it('calls onPress when FAB is pressed', async () => { + const user = userEvent.setup(); const onPress = jest.fn(); - const { getByTestId } = render( - - ); - fireEvent.press(getByTestId('fab')); + await render(); + await user.press(screen.getByRole('button', { name: 'Add item' })); expect(onPress).toHaveBeenCalledTimes(1); }); -it('forwards event object to onPress', () => { +it('forwards event object to onPress', async () => { const onPress = jest.fn(); - const { getByTestId } = render( - - ); - fireEvent(getByTestId('fab'), 'onPress', { key: 'value' }); + await render(); + await fireEvent(screen.getByRole('button', { name: 'Add item' }), 'onPress', { + key: 'value', + }); expect(onPress).toHaveBeenCalledWith({ key: 'value' }); }); diff --git a/src/components/__tests__/FABExtended.test.tsx b/src/components/__tests__/FABExtended.test.tsx index ca72466305..fd88091282 100644 --- a/src/components/__tests__/FABExtended.test.tsx +++ b/src/components/__tests__/FABExtended.test.tsx @@ -1,108 +1,102 @@ -import * as React from 'react'; - -import { fireEvent } from '@testing-library/react-native'; +import { expect, it, jest } from '@jest/globals'; +import { fireEvent, screen, userEvent } from '@testing-library/react-native'; import { render } from '../../test-utils'; import FAB from '../FAB'; -it('renders extended FAB expanded', () => { - const tree = render( - +it('renders extended FAB expanded', async () => { + const tree = ( + await render() ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders extended FAB collapsed', () => { - const tree = render( - +it('renders extended FAB collapsed', async () => { + const tree = ( + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders extended FAB not visible', () => { - const tree = render( - +it('renders extended FAB not visible', async () => { + const tree = ( + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders extended FAB medium size', () => { - const tree = render( - +it('renders extended FAB medium size', async () => { + const tree = ( + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders extended FAB large size', () => { - const tree = render( - +it('renders extended FAB large size', async () => { + const tree = ( + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders extended FAB transitioning to collapsed', () => { - const { update, toJSON } = render( +it('renders extended FAB transitioning to collapsed', async () => { + const { rerender, toJSON } = await render( ); - update(); + await rerender( + + ); expect(toJSON()).toMatchSnapshot(); }); -it('uses label as default accessibilityLabel', () => { - const { getByTestId } = render( - - ); - expect(getByTestId('extended-fab').props.accessibilityLabel).toBe( - 'New message' - ); +it('uses label as default aria-label', async () => { + await render(); + expect(screen.getByRole('button', { name: 'New message' })).toBeTruthy(); }); -it('respects explicit accessibilityLabel', () => { - const { getByTestId } = render( +it('respects explicit aria-label', async () => { + await render( ); - expect(getByTestId('extended-fab').props.accessibilityLabel).toBe( - 'Create new message' - ); + expect( + screen.getByRole('button', { name: 'Create new message' }) + ).toBeTruthy(); }); -it('calls onPress when pressed', () => { +it('calls onPress when pressed', async () => { + const user = userEvent.setup(); const onPress = jest.fn(); - const { getByTestId } = render( - + await render( + ); - fireEvent.press(getByTestId('extended-fab')); + await user.press(screen.getByRole('button', { name: 'New message' })); expect(onPress).toHaveBeenCalledTimes(1); }); -it('forwards event object to onPress', () => { +it('forwards event object to onPress', async () => { const onPress = jest.fn(); - const { getByTestId } = render( - + await render( + + ); + await fireEvent( + screen.getByRole('button', { name: 'New message' }), + 'onPress', + { + key: 'value', + } ); - fireEvent(getByTestId('extended-fab'), 'onPress', { key: 'value' }); expect(onPress).toHaveBeenCalledWith({ key: 'value' }); }); diff --git a/src/components/__tests__/FABMenu.test.tsx b/src/components/__tests__/FABMenu.test.tsx index cff16cc76f..91a621c0be 100644 --- a/src/components/__tests__/FABMenu.test.tsx +++ b/src/components/__tests__/FABMenu.test.tsx @@ -1,6 +1,5 @@ -import * as React from 'react'; - -import { fireEvent } from '@testing-library/react-native'; +import { expect, it, jest } from '@jest/globals'; +import { fireEvent, screen, userEvent } from '@testing-library/react-native'; import { render } from '../../test-utils'; import FAB from '../FAB'; @@ -9,112 +8,127 @@ const makeItems = ( onItemPress = jest.fn() ): [ { label: string; onPress: jest.Mock; testID: string }, - { label: string; onPress: jest.Mock; testID: string } + { label: string; onPress: jest.Mock; testID: string }, ] => [ { label: 'Send email', onPress: onItemPress, testID: 'item-0' }, { label: 'Set reminder', onPress: onItemPress, testID: 'item-1' }, ]; -it('renders FAB.Menu closed', () => { - const tree = render( - +it('renders FAB.Menu closed', async () => { + const tree = ( + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB.Menu open', () => { - const tree = render( - +it('renders FAB.Menu open', async () => { + const tree = ( + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB.Menu with 6 items', () => { - const tree = render( - +it('renders FAB.Menu with 6 items', async () => { + const tree = ( + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB.Menu with start alignment', () => { - const tree = render( - +it('renders FAB.Menu with start alignment', async () => { + const tree = ( + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB.Menu with center alignment', () => { - const tree = render( - +it('renders FAB.Menu with center alignment', async () => { + const tree = ( + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB.Menu with items having icons', () => { - const tree = render( - +it('renders FAB.Menu with items having icons', async () => { + const tree = ( + await render( + + ) ).toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders FAB.Menu not expanded when trigger is not visible', () => { - const tree = render( - +it('renders FAB.Menu not expanded when trigger is not visible', async () => { + const tree = ( + await render( + + ) ).toJSON(); // effectiveExpanded = visible && expanded = false expect(tree).toMatchSnapshot(); }); -it('calls item onPress when menu item is pressed', () => { +it('calls item onPress when menu item is pressed', async () => { + const user = userEvent.setup(); const onItemPress = jest.fn(); - const { getByTestId } = render( + await render( { items={makeItems(onItemPress)} /> ); - fireEvent.press(getByTestId('item-0')); + await user.press(screen.getByTestId('item-0')); expect(onItemPress).toHaveBeenCalledTimes(1); }); -it('forwards event object to item onPress', () => { +it('forwards event object to item onPress', async () => { const onItemPress = jest.fn(); - const { getByTestId } = render( + await render( { items={makeItems(onItemPress)} /> ); - fireEvent(getByTestId('item-0'), 'onPress', { key: 'value' }); + await fireEvent(screen.getByTestId('item-0'), 'onPress', { key: 'value' }); expect(onItemPress).toHaveBeenCalledWith({ key: 'value' }); }); -it('calls onDismiss when menu item is pressed', () => { +it('calls onDismiss when menu item is pressed', async () => { + const user = userEvent.setup(); const onDismiss = jest.fn(); - const { getByTestId } = render( + await render( { items={makeItems()} /> ); - fireEvent.press(getByTestId('item-0')); + await user.press(screen.getByTestId('item-0')); expect(onDismiss).toHaveBeenCalledTimes(1); }); -it('calls trigger onPress when menu is closed', () => { +it('calls trigger onPress when menu is closed', async () => { + const user = userEvent.setup(); const onTriggerPress = jest.fn(); - const { getByTestId } = render( + await render( { /> ); // Shell's TouchableRipple uses the default testID 'fab-shell' - fireEvent.press(getByTestId('fab-shell')); + await user.press(screen.getByTestId('fab-shell')); expect(onTriggerPress).toHaveBeenCalledTimes(1); }); -it('calls onDismiss when trigger is pressed while menu is open', () => { +it('calls onDismiss when trigger is pressed while menu is open', async () => { + const user = userEvent.setup(); const onDismiss = jest.fn(); - const { getByTestId } = render( + await render( { items={makeItems()} /> ); - fireEvent.press(getByTestId('fab-shell')); + await user.press(screen.getByTestId('fab-shell')); expect(onDismiss).toHaveBeenCalledTimes(1); }); diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index 2c94111744..92d9c63d85 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -191,7 +191,7 @@ describe('Tooltip', () => { it('renders an inverseSurface container with inverseOnSurface text', async () => { const { wrapper: { getByText, getByTestId, findByText }, - } = setup(); + } = await setup(); await user.longPress(getTrigger(getByText)); @@ -213,7 +213,7 @@ describe('Tooltip', () => { it('stays mounted through the exit fade before unmounting', async () => { const { wrapper: { queryByText, getByText, findByText }, - } = setup({ leaveTouchDelay: 0 }); + } = await setup({ leaveTouchDelay: 0 }); // `longPress` includes the release (pressOut), which schedules the hide. await user.longPress(getTrigger(getByText)); @@ -225,7 +225,7 @@ describe('Tooltip', () => { // Still mounted while fading out so the animation can play. expect(getByText('some tooltip text')).toBeTruthy(); - runTimers(); // exit fade duration elapses → unmounts + await runTimers(); // exit fade duration elapses → unmounts expect(queryByText('some tooltip text')).toBeNull(); }); }); @@ -259,14 +259,10 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container').props.style).toMatchObject([ - {}, - { - left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210 - top: 250, // pageY (200) + height (50) - }, - {}, - ]); + expect(getByTestId('tooltip-container')).toHaveStyle({ + left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210 + top: 250, // pageY (200) + height (50) + }); }); }); @@ -326,14 +322,10 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container').props.style).toMatchObject([ - {}, - { - left: 210, - top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component, - }, - {}, - ]); + expect(getByTestId('tooltip-container')).toHaveStyle({ + left: 210, + top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component, + }); }); }); }); @@ -474,14 +466,10 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container').props.style).toMatchObject([ - {}, - { - left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210 - top: 250, // pageY (200) + height (50) - }, - {}, - ]); + expect(getByTestId('tooltip-container')).toHaveStyle({ + left: 210, // pageX (220) + (width (80) - TOOLTIP_WIDTH (100)) / 2 = 210 + top: 250, // pageY (200) + height (50) + }); }); }); @@ -544,14 +532,10 @@ describe('Tooltip', () => { }, }); - expect(getByTestId('tooltip-container').props.style).toMatchObject([ - {}, - { - left: 210, - top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component, - }, - {}, - ]); + expect(getByTestId('tooltip-container')).toHaveStyle({ + left: 210, + top: 500, // pageY (600) - TOOLTIP_HEIGHT (100) // Tooltip is placed at the top of the component, + }); }); }); }); @@ -584,7 +568,7 @@ describe('Tooltip.Rich', () => { .spyOn(View.prototype, 'measureInWindow') .mockImplementation((cb) => cb(0, 0, 0, 0)); - const wrapper = render( + const wrapper = await render( {(props) => } @@ -603,12 +587,17 @@ describe('Tooltip.Rich', () => { beforeAll(() => { Platform.OS = 'android'; }); - afterEach(() => jest.clearAllMocks()); + afterEach(() => { + jest.clearAllMocks(); + }); - it('toggles title, content and actions when the trigger is pressed', () => { + it('toggles title, content and actions when the trigger is pressed', async () => { const { wrapper: { getByText, getByTestId, queryByText }, - } = setup({ title: 'Heading', actions: () => Learn more }); + } = await setup({ + title: 'Heading', + actions: () => Learn more, + }); expect(queryByText('Body text')).toBeNull(); @@ -626,20 +615,20 @@ describe('Tooltip.Rich', () => { expect(queryByText('Body text')).toBeNull(); }); - it('renders a custom element as content', () => { + it('renders a custom element as content', async () => { const { wrapper: { getByText }, - } = setup({ content: Custom node }); + } = await setup({ content: Custom node }); await user.press(getTrigger(getByText)); expect(getByText('Custom node')).toBeTruthy(); }); - it('uses the surfaceContainer container with MD3 title/content roles', () => { + it('uses the surfaceContainer container with MD3 title/content roles', async () => { const { wrapper: { getByText, getByTestId }, - } = setup({ title: 'Heading' }); + } = await setup({ title: 'Heading' }); await user.press(getTrigger(getByText)); @@ -656,10 +645,10 @@ describe('Tooltip.Rich', () => { }); }); - it('dismisses when the backdrop is pressed', () => { + it('dismisses when the backdrop is pressed', async () => { const { wrapper: { getByText, getByTestId, queryByText }, - } = setup(); + } = await setup(); await user.press(getTrigger(getByText)); expect(getByText('Body text')).toBeTruthy(); @@ -706,7 +695,7 @@ describe('Tooltip.Rich', () => { it('dismisses when an action calls dismiss', async () => { const { wrapper: { getByText, queryByText }, - } = setup({ + } = await setup({ actions: ({ dismiss }) => Learn more, }); @@ -724,9 +713,11 @@ describe('Tooltip.Rich', () => { beforeAll(() => { Platform.OS = 'web'; }); - afterEach(() => jest.clearAllMocks()); + afterEach(() => { + jest.clearAllMocks(); + }); - it('opens on hover after the enter delay', () => { + it('opens on hover after the enter delay', async () => { const { wrapper: { getByTestId, getByText, queryByText }, } = await setup({ enterTouchDelay: 100 }); @@ -736,15 +727,15 @@ describe('Tooltip.Rich', () => { }); expect(queryByText('Body text')).toBeNull(); // still within the delay - runTimers(100); + await runTimers(100); expect(getByText('Body text')).toBeTruthy(); }); - it('opens on keyboard focus and hides on blur', () => { + it('opens on keyboard focus and hides on blur', async () => { const { wrapper: { getByText, queryByText }, - } = setup({ leaveTouchDelay: 500 }); + } = await setup({ leaveTouchDelay: 500 }); // Focus shows the tooltip synchronously, so settle it in act() before // asserting (and so its update can't escape act and corrupt the renderer). @@ -762,10 +753,10 @@ describe('Tooltip.Rich', () => { expect(queryByText('Body text')).toBeNull(); }); - it('keeps the tooltip open while the pointer moves into it (gap bridge)', () => { + it('keeps the tooltip open while the pointer moves into it (gap bridge)', async () => { const { wrapper: { getByText, getByTestId }, - } = setup({ enterTouchDelay: 0, leaveTouchDelay: 500 }); + } = await setup({ enterTouchDelay: 0, leaveTouchDelay: 500 }); await act(async () => { await fireEvent(getByTestId('tooltip-rich-trigger'), 'pointerEnter'); diff --git a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap index 56dd997edc..348a6bba64 100644 --- a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap @@ -52,7 +52,6 @@ exports[`renders FAB large size 1`] = ` } > `; -exports[`renders FAB with accessibilityLabel 1`] = ` +exports[`renders FAB with aria-label 1`] = ` - , + New message - , -] + + `; exports[`renders extended FAB expanded 1`] = ` -[ +<> - , + New message - , -] + + `; exports[`renders extended FAB large size 1`] = ` -[ +<> - , + New message - , -] + + `; exports[`renders extended FAB medium size 1`] = ` -[ +<> - , + New message - , -] + + `; exports[`renders extended FAB not visible 1`] = ` -[ +<> - , + New message - , -] + + `; exports[`renders extended FAB transitioning to collapsed 1`] = ` -[ +<> - , + New message - , -] + + `; diff --git a/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap b/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap index aadf81f409..c646a686e7 100644 --- a/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABMenu.test.tsx.snap @@ -493,7 +493,6 @@ exports[`renders FAB.Menu closed 1`] = ` } /> Date: Wed, 1 Jul 2026 14:17:00 +0200 Subject: [PATCH 28/29] fix(tests): import Jest globals from @jest/globals in FABUtils test --- src/components/__tests__/FABUtils.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/__tests__/FABUtils.test.tsx b/src/components/__tests__/FABUtils.test.tsx index af00e98f1c..b72de366f7 100644 --- a/src/components/__tests__/FABUtils.test.tsx +++ b/src/components/__tests__/FABUtils.test.tsx @@ -1,3 +1,5 @@ +import { describe, expect, it } from '@jest/globals'; + import { getTheme } from '../../core/theming'; import { getDimensions, resolveColors } from '../FAB/utils'; From 35ef55ee7703ca0e3af071fb9c473827634a4886 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Thu, 2 Jul 2026 11:17:05 +0200 Subject: [PATCH 29/29] fix(fab): restore aria props and drop forwardRef wrapper - Menu.tsx: restore aria-label/aria-hidden (rebase had reverted the upstream a11y migration back to accessibilityLabel) - Extended.tsx: replace forwardRef with React 19 direct ref prop; restore aria-hidden on offscreen measure view - Delete src/utils/forwardRef.ts (orphaned by above) --- src/components/FAB/Extended.tsx | 250 +++++++++--------- src/components/FAB/Menu.tsx | 24 +- .../__snapshots__/FABExtended.test.tsx.snap | 12 +- .../__snapshots__/FABMenu.test.tsx.snap | 72 ++--- src/utils/forwardRef.ts | 1 - 5 files changed, 178 insertions(+), 181 deletions(-) delete mode 100644 src/utils/forwardRef.ts diff --git a/src/components/FAB/Extended.tsx b/src/components/FAB/Extended.tsx index 6650e96c6c..897fad77bc 100644 --- a/src/components/FAB/Extended.tsx +++ b/src/components/FAB/Extended.tsx @@ -25,7 +25,6 @@ import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import type { ThemeProp } from '../../types'; -import { forwardRef } from '../../utils/forwardRef'; import type { IconSource } from '../Icon'; import AnimatedText from '../Typography/AnimatedText'; @@ -112,6 +111,9 @@ export type Props = { * @optional */ theme?: ThemeProp; + /** + * @optional + */ ref?: React.RefObject; }; @@ -152,144 +154,140 @@ export type Props = { * export default MyComponent; * ``` */ -const Extended = forwardRef( - ( - { - icon, - label, - variant = 'tonalPrimary', - containerColor, - contentColor, - size = 'default', - expanded, - visible = true, - onPress, - 'aria-label': ariaLabel = label, - 'aria-checked': ariaChecked, - 'aria-selected': ariaSelected, - 'aria-busy': ariaBusy, - 'aria-expanded': ariaExpanded, - labelMaxFontSizeMultiplier, - background, - style, - testID = 'extended-floating-action-button', - theme: themeOverrides, - }, - ref - ) => { - const theme = useInternalTheme(themeOverrides); - const reduceMotion = useReduceMotion(); - - const dimensions = getDimensions({ theme, size }); +const Extended = ({ + icon, + label, + variant = 'tonalPrimary', + containerColor, + contentColor, + size = 'default', + expanded, + visible = true, + onPress, + 'aria-label': ariaLabel = label, + 'aria-checked': ariaChecked, + 'aria-selected': ariaSelected, + 'aria-busy': ariaBusy, + 'aria-expanded': ariaExpanded, + labelMaxFontSizeMultiplier, + background, + style, + testID = 'extended-floating-action-button', + theme: themeOverrides, + ref, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); - const offscreenLabelRef = useAnimatedRef(); + const dimensions = getDimensions({ theme, size }); - const widthValue = useSharedValue(dimensions.width); - const labelOpacity = useSharedValue(expanded ? 1 : 0); + const offscreenLabelRef = useAnimatedRef(); - React.useEffect(() => { - const { - width: collapsedWidth, - leading, - iconSize, - iconLabelGap, - trailing, - } = dimensions; - const targetOpacity = expanded ? 1 : 0; + const widthValue = useSharedValue(dimensions.width); + const labelOpacity = useSharedValue(expanded ? 1 : 0); - if (reduceMotion) { - scheduleOnUI(() => { - 'worklet'; - const m = measure(offscreenLabelRef); - const lw = m?.width ?? 0; - widthValue.value = expanded - ? leading + iconSize + iconLabelGap + lw + trailing - : collapsedWidth; - labelOpacity.value = targetOpacity; - }); - return; - } - - const widthSpring = toRawSpring( - expanded - ? theme.motion.spring.fast.spatial - : theme.motion.spring.default.spatial - ); - const opacitySpring = toRawSpring( - expanded - ? theme.motion.spring.default.effects - : theme.motion.spring.fast.effects - ); + React.useEffect(() => { + const { + width: collapsedWidth, + leading, + iconSize, + iconLabelGap, + trailing, + } = dimensions; + const targetOpacity = expanded ? 1 : 0; + if (reduceMotion) { scheduleOnUI(() => { 'worklet'; const m = measure(offscreenLabelRef); const lw = m?.width ?? 0; - const expandedWidth = leading + iconSize + iconLabelGap + lw + trailing; - widthValue.value = withSpring( - expanded ? expandedWidth : collapsedWidth, - widthSpring - ); - labelOpacity.value = withSpring(targetOpacity, opacitySpring); + widthValue.value = expanded + ? leading + iconSize + iconLabelGap + lw + trailing + : collapsedWidth; + labelOpacity.value = targetOpacity; }); - }, [ - expanded, - label, - dimensions, - theme, - reduceMotion, - widthValue, - labelOpacity, - offscreenLabelRef, - ]); + return; + } - const labelAnimatedStyle = useAnimatedStyle(() => ({ - opacity: labelOpacity.value, - })); + const widthSpring = toRawSpring( + expanded + ? theme.motion.spring.fast.spatial + : theme.motion.spring.default.spatial + ); + const opacitySpring = toRawSpring( + expanded + ? theme.motion.spring.default.effects + : theme.motion.spring.fast.effects + ); + + scheduleOnUI(() => { + 'worklet'; + const m = measure(offscreenLabelRef); + const lw = m?.width ?? 0; + const expandedWidth = leading + iconSize + iconLabelGap + lw + trailing; + widthValue.value = withSpring( + expanded ? expandedWidth : collapsedWidth, + widthSpring + ); + labelOpacity.value = withSpring(targetOpacity, opacitySpring); + }); + }, [ + expanded, + label, + dimensions, + theme, + reduceMotion, + widthValue, + labelOpacity, + offscreenLabelRef, + ]); - return ( - <> - - ({ + opacity: labelOpacity.value, + })); + + return ( + <> + + + - - {label} - - - - ); - } -); + {label} + + + + ); +}; const styles = StyleSheet.create({ offscreenMeasure: { diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx index 094efe1da2..cbbe30ea50 100644 --- a/src/components/FAB/Menu.tsx +++ b/src/components/FAB/Menu.tsx @@ -56,7 +56,7 @@ export type MenuItemProps = { /** * Accessibility label. Falls back to `label`. */ - accessibilityLabel?: string; + 'aria-label'?: string; testID?: string; }; @@ -72,7 +72,7 @@ export type MenuTriggerProps = { contentColor?: ColorValue; visible?: boolean; onPress?: (e: GestureResponderEvent) => void; - accessibilityLabel?: string; + 'aria-label'?: string; testID?: string; }; @@ -211,7 +211,7 @@ const AnimatedItem = ({ expanded ? styles.pointerEventsAuto : styles.pointerEventsNone, ]} importantForAccessibility={expanded ? 'yes' : 'no-hide-descendants'} - accessibilityElementsHidden={!expanded} + aria-hidden={!expanded} > {children} @@ -224,7 +224,7 @@ type ItemProps = { variant: Variant; theme: InternalTheme; onPress: (e: GestureResponderEvent) => void; - accessibilityLabel?: string; + 'aria-label'?: string; testID?: string; }; @@ -240,7 +240,7 @@ const MenuItem = ({ variant, theme, onPress, - accessibilityLabel, + 'aria-label': ariaLabel, testID, }: ItemProps) => { const colors = resolveColors({ theme, variant }); @@ -266,8 +266,8 @@ const MenuItem = ({ onPress={onPress} onFocus={onFocus} onBlur={onBlur} - accessibilityRole="button" - accessibilityLabel={accessibilityLabel ?? label} + role="button" + aria-label={ariaLabel ?? label} style={[ { borderRadius }, Platform.OS === 'web' ? webNoOutline : null, @@ -314,7 +314,7 @@ type MorphingTriggerProps = { visible: boolean; alignment: 'start' | 'center' | 'end'; onPress?: (e: GestureResponderEvent) => void; - accessibilityLabel?: string; + 'aria-label'?: string; theme: InternalTheme; testID?: string; }; @@ -331,7 +331,7 @@ const MorphingTrigger = ({ visible, alignment, onPress, - accessibilityLabel, + 'aria-label': ariaLabel, theme, testID, }: MorphingTriggerProps) => { @@ -451,7 +451,7 @@ const MorphingTrigger = ({ contentColor={triggerContentColor} visible={visible} onPress={onPress} - aria-label={accessibilityLabel} + aria-label={ariaLabel} widthShared={widthShared} heightShared={heightShared} borderRadiusShared={borderRadiusShared} @@ -621,7 +621,7 @@ const Menu = ({ label={item.label} variant={itemsVariant} theme={theme} - accessibilityLabel={item.accessibilityLabel ?? item.label} + aria-label={item['aria-label'] ?? item.label} onPress={handleItemPress(item)} testID={item.testID} /> @@ -641,7 +641,7 @@ const Menu = ({ visible={triggerVisible} alignment={alignment} onPress={effectiveExpanded ? onDismiss : openOnPress} - accessibilityLabel={trigger.accessibilityLabel} + aria-label={trigger['aria-label']} theme={theme} testID={trigger.testID} /> diff --git a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap index dbf6ec014d..2ceb981d60 100644 --- a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap @@ -206,7 +206,7 @@ exports[`renders extended FAB collapsed 1`] = ` />