From 92db121b9ef889c86a149d569578caf223114943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Tue, 18 Aug 2026 14:24:05 +0200 Subject: [PATCH] fix: make hidden banner content inert and fix region semantics --- example/src/Examples/BannerExample.tsx | 18 +- src/components/Banner.tsx | 280 +++- src/components/__tests__/Banner.test.tsx | 665 +++++++- .../__snapshots__/Banner.test.tsx.snap | 1347 +++++++++-------- 4 files changed, 1617 insertions(+), 693 deletions(-) diff --git a/example/src/Examples/BannerExample.tsx b/example/src/Examples/BannerExample.tsx index b3a0d8a002..5d47ccc0d5 100644 --- a/example/src/Examples/BannerExample.tsx +++ b/example/src/Examples/BannerExample.tsx @@ -13,6 +13,7 @@ const PHOTOS = Array.from({ length: 24 }).map( const BannerExample = () => { const [visible, setVisible] = React.useState(true); const [useCustomTheme, setUseCustomTheme] = React.useState(false); + const [urgent, setUrgent] = React.useState(false); const defaultTheme = useTheme(); const [height, setHeight] = React.useState(0); @@ -52,8 +53,14 @@ const BannerExample = () => { setVisible(!visible)} /> + setUrgent(!urgent)} + /> { theme={useCustomTheme ? customTheme : defaultTheme} style={styles.banner} > - Two line text string with two actions. One to two lines is preferable on - mobile. + {urgent + ? 'Urgent: this message interrupts the screen reader.' + : 'Two line text string with two actions. One to two lines is preferable on mobile.'} ); @@ -128,6 +136,12 @@ const styles = StyleSheet.create({ bottom: 0, margin: 16, }, + urgentFab: { + alignSelf: 'flex-end', + position: 'absolute', + bottom: 0, + margin: 16, + }, }); export default BannerExample; diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 4f28cf0390..962424ab4a 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -1,5 +1,12 @@ import * as React from 'react'; -import { Animated, StyleSheet, View } from 'react-native'; +import { + AccessibilityInfo, + Animated, + findNodeHandle, + Platform, + StyleSheet, + View, +} from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; import type { LayoutChangeEvent } from 'react-native'; @@ -14,6 +21,8 @@ import { useInternalTheme } from '../core/theming'; import type { $Omit, $RemoveChildren, Theme, ThemeProp } from '../types'; const DEFAULT_MAX_WIDTH = 960; +// banners carry at most two actions per the material spec +const MAX_ACTIONS = 2; export type Props = $Omit<$RemoveChildren, 'mode'> & { /** @@ -36,6 +45,9 @@ export type Props = $Omit<$RemoveChildren, 'mode'> & { * - `onPress`: callback that is called when button is pressed (required) * * To customize button you can pass other props that button component takes. + * + * A maximum of 2 actions is supported, per the Material spec. Any further + * actions are ignored, with a warning in development. */ actions?: Array< { @@ -52,6 +64,12 @@ export type Props = $Omit<$RemoveChildren, 'mode'> & { * Changes Banner shadow and background on iOS and Android. */ elevation?: 0 | 1 | 2 | 3 | 4 | 5 | Animated.Value; + /** + * Whether the message should interrupt whatever the screen reader is saying + * instead of waiting for it to finish. Use it for messages that need + * immediate attention, such as errors. + */ + urgent?: boolean; /** * Specifies the largest possible scale a text font can reach. */ @@ -130,6 +148,8 @@ const Banner = ({ onShowAnimationFinished = () => {}, onHideAnimationFinished = () => {}, maxFontSizeMultiplier, + urgent = false, + testID, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); @@ -144,6 +164,9 @@ const Banner = ({ height: 0, measured: false, }); + // content is dropped from the tree once it's fully hidden, so it can't be + // read, focused or pressed. the spacer stays behind to keep the layout + const [exited, setExited] = React.useState(false); const showCallback = useLatestCallback(onShowAnimationFinished); const hideCallback = useLatestCallback(onHideAnimationFinished); @@ -155,9 +178,26 @@ const Banner = ({ outputRange: [0, 1, 1], }); + const prevVisible = React.useRef(null); + React.useEffect(() => { + // only animate for transitions that actually happened, so the callbacks + // don't fire on mount or when unrelated deps (e.g. scale) change + if (prevVisible.current === visible) { + return; + } + + const isFirstRender = prevVisible.current === null; + prevVisible.current = visible; + + // position is already initialised to the matching end state + if (isFirstRender) { + return; + } + if (visible) { // show + setExited(false); Animated.timing(position, { duration: 250 * scale, toValue: 1, @@ -169,14 +209,96 @@ const Banner = ({ duration: 200 * scale, toValue: 0, useNativeDriver: false, - }).start(hideCallback); + }).start((result) => { + if (result.finished) { + setExited(true); + } + hideCallback(result); + }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible, position, scale]); + const visibleActions = actions.slice(0, MAX_ACTIONS); + const actionCount = visibleActions.length; + React.useEffect(() => { + if (process.env.NODE_ENV !== 'production' && actions.length > MAX_ACTIONS) { + console.warn( + `Banner supports a maximum of ${MAX_ACTIONS} actions, received ${actions.length}. The extra actions are ignored.` + ); + } + }, [actions.length]); + + const liveRegion = urgent ? 'assertive' : 'polite'; + const message = React.Children.toArray(children) + .filter((child) => typeof child === 'string' || typeof child === 'number') + .join(''); + + // aria-live only reaches a real live region on android and web. ios has no + // equivalent, so announce there by hand whenever the message becomes + // available. doing it everywhere would double up with the live region + React.useEffect(() => { + if (Platform.OS !== 'ios' || !visible || !message) { + return; + } + + AccessibilityInfo.announceForAccessibilityWithOptions(message, { + queue: !urgent, + }); + }, [visible, message, urgent]); + + // one stable ref per action slot; the cap is what bounds the array + const actionRefs = React.useRef>>([]); + for (let i = 0; i < MAX_ACTIONS; i++) { + // Button types touchableRef as non-nullable, but a ref always starts null + actionRefs.current[i] ??= React.createRef() as React.RefObject; + } + const messageRef = React.useRef(null); + const focusedAction = React.useRef(null); + + const focusNode = (node: View | null) => { + if (!node) { + return; + } + + const handle = findNodeHandle(node); + if (handle !== null) { + AccessibilityInfo.setAccessibilityFocus(handle); + } + + // rnw resolves the ref to the dom node, which takes focus directly + (node as unknown as { focus?: () => void }).focus?.(); + }; + + // a removed action would otherwise strand focus at the top of the document + React.useEffect(() => { + const focused = focusedAction.current; + + if (focused === null || focused < actionCount) { + return; + } + + if (!visible) { + focusedAction.current = null; + return; + } + + const next = actionCount - 1; + focusedAction.current = next < 0 ? null : next; + focusNode(next < 0 ? messageRef.current : actionRefs.current[next].current); + }, [actionCount, visible]); + const handleLayout = ({ nativeEvent }: LayoutChangeEvent) => { const { height } = nativeEvent.layout; + const isFirstMeasure = !layout.measured; + setLayout({ height, measured: true }); + + // mounted hidden: we only render to measure the spacer height, so drop the + // content again right after. later measurements happen mid-transition + if (isFirstMeasure && !visible) { + setExited(true); + } }; // The banner animation has 2 parts: @@ -192,9 +314,14 @@ const Banner = ({ Animated.add(position, -1), layout.height ); + // rnw forwards `inert` to the dom, which drops the subtree from the a11y + // tree and the tab order. native ignores the unknown prop + const inertProps = visible ? null : ({ inert: true } as object); + return ( - - - {icon ? ( - - + {exited ? null : ( + + + {/* icon and message travel together as one flex item, so only + the actions can be wrapped onto the next line */} + + {icon ? ( + + + + ) : null} + {/* the region is scoped to the message: status/alert imply + aria-atomic, so keeping the actions out of it stops their + labels from re-announcing the whole banner */} + + + {children} + + - ) : null} - - {children} - - - - {actions.map(({ label, ...others }, i) => ( - - ))} - - + {/* same wrapping row as the message, so the actions sit inline + when they fit and drop to their own line when they don't */} + {visibleActions.length ? ( + + {visibleActions.map(({ label, ...others }, i) => ( + + ))} + + ) : null} + + + )} ); @@ -274,20 +430,32 @@ const styles = StyleSheet.create({ }, content: { flexDirection: 'row', - justifyContent: 'flex-start', + flexWrap: 'wrap', + alignItems: 'center', + justifyContent: 'flex-end', marginHorizontal: 8, marginTop: 16, marginBottom: 0, }, + body: { + flexDirection: 'row', + alignItems: 'center', + flexGrow: 1, + flexShrink: 1, + flexBasis: 'auto', + }, icon: { margin: 8, }, message: { - flex: 1, + flexGrow: 1, + flexShrink: 1, + flexBasis: 'auto', margin: 8, }, actions: { flexDirection: 'row', + flexShrink: 0, justifyContent: 'flex-end', margin: 4, }, diff --git a/src/components/__tests__/Banner.test.tsx b/src/components/__tests__/Banner.test.tsx index 80bd3e9017..514925e538 100644 --- a/src/components/__tests__/Banner.test.tsx +++ b/src/components/__tests__/Banner.test.tsx @@ -1,8 +1,8 @@ -import { Animated, Image } from 'react-native'; +import { AccessibilityInfo, Animated, Image, Platform } from 'react-native'; import { afterAll, - beforeAll, + afterEach, beforeEach, describe, expect, @@ -11,7 +11,7 @@ import { } from '@jest/globals'; import { act } from '@testing-library/react-native'; -import { render, screen } from '../../test-utils'; +import { fireEvent, render, screen, within } from '../../test-utils'; import Banner from '../Banner'; it('renders hidden banner, without action buttons and without image', async () => { @@ -126,6 +126,598 @@ it('render visible banner, with custom theme', async () => { expect(tree).toMatchSnapshot(); }); +describe('inert when hidden', () => { + const ACTIONS = [{ label: 'Fix it', onPress: () => {} }]; + // queries are a11y-aware by default, so opt in explicitly to tell + // "hidden from screen readers" apart from "not in the tree at all" + const ALL = { includeHiddenElements: true }; + + it('exposes the content while visible', async () => { + await render( + + Message + + ); + await act(() => { + jest.runAllTimers(); + }); + + expect(screen.getByText('Message')).toBeOnTheScreen(); + expect(screen.getByText('Fix it')).toBeOnTheScreen(); + expect(screen.getByTestId('banner-content')).toHaveProp( + 'aria-hidden', + false + ); + expect(screen.getByTestId('banner-content')).toHaveProp( + 'pointerEvents', + 'auto' + ); + }); + + it('keeps the content inert while the hide animation is running', async () => { + const view = await render( + + Message + + ); + await act(() => { + jest.runAllTimers(); + }); + + await view.rerender( + + Message + + ); + + // animation still in flight, so the content is mounted but must be dead + const content = screen.getByTestId('banner-content', ALL); + expect(content).toHaveProp('aria-hidden', true); + expect(content).toHaveProp('pointerEvents', 'none'); + expect(content).toHaveProp('inert', true); + + // and already unreachable through a11y-aware queries + expect(screen.queryByText('Message')).toBeNull(); + expect(screen.queryByText('Fix it')).toBeNull(); + }); + + it('unmounts the content once the hide animation finishes', async () => { + const view = await render( + + Message + + ); + await act(() => { + jest.runAllTimers(); + }); + + await view.rerender( + + Message + + ); + await act(() => { + jest.runAllTimers(); + }); + + // ALL, so null means genuinely gone rather than merely hidden + expect(screen.queryByText('Message', ALL)).toBeNull(); + expect(screen.queryByText('Fix it', ALL)).toBeNull(); + expect(screen.queryByTestId('banner-content', ALL)).toBeNull(); + }); + + it('measures once then unmounts the content when mounted hidden', async () => { + await render( + + Message + + ); + + // the measuring pass must not be reachable either + const content = screen.getByTestId('banner-content', ALL); + expect(content).toHaveProp('aria-hidden', true); + expect(screen.queryByText('Message')).toBeNull(); + + await fireEvent(content, 'layout', { + nativeEvent: { layout: { height: 80, width: 320 } }, + }); + + expect(screen.queryByTestId('banner-content', ALL)).toBeNull(); + }); + + it('keeps the content mounted when the hide animation is interrupted', async () => { + // a hide interrupted by a re-show reports finished:false; acting on it + // would unmount the content while the banner is on its way back in + let hideDone: ((result: { finished: boolean }) => void) | undefined; + const timing = jest + .spyOn(Animated, 'timing') + .mockImplementation((_value: any, config: any) => { + return { + start: (cb?: (result: { finished: boolean }) => void) => { + if (config.toValue === 0) { + hideDone = cb; + } + }, + stop: () => {}, + reset: () => {}, + } as any; + }); + + const view = await render( + + Message + + ); + + await view.rerender( + + Message + + ); + // banner comes back before the hide finishes + await view.rerender( + + Message + + ); + + await act(() => { + hideDone?.({ finished: false }); + }); + + expect(screen.getByTestId('banner-content', ALL)).toBeTruthy(); + expect(screen.getByText('Message')).toBeOnTheScreen(); + + timing.mockRestore(); + }); + + it('remounts the content when shown again', async () => { + const view = await render( + + Message + + ); + await act(() => { + jest.runAllTimers(); + }); + + await view.rerender( + + Message + + ); + await act(() => { + jest.runAllTimers(); + }); + + expect(screen.getByText('Message')).toBeOnTheScreen(); + expect(screen.getByTestId('banner-content')).toHaveProp( + 'aria-hidden', + false + ); + }); +}); + +describe('actions', () => { + let warn: jest.SpiedFunction; + + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + warn.mockClear(); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('renders every action up to the two the spec allows', async () => { + await render( + {} }, + { label: 'second', onPress: () => {} }, + ]} + > + Message + + ); + + expect(screen.getByText('first')).toBeOnTheScreen(); + expect(screen.getByText('second')).toBeOnTheScreen(); + expect(warn).not.toHaveBeenCalled(); + }); + + it('drops actions beyond the second one', async () => { + await render( + {} }, + { label: 'second', onPress: () => {} }, + { label: 'third', onPress: () => {} }, + ]} + > + Message + + ); + + expect(screen.getByText('first')).toBeOnTheScreen(); + expect(screen.getByText('second')).toBeOnTheScreen(); + expect(screen.queryByText('third')).toBeNull(); + }); + + it('moves focus to a surviving action when the focused one disappears', async () => { + const setFocus = jest + .spyOn(AccessibilityInfo, 'setAccessibilityFocus') + .mockImplementation(() => {}); + setFocus.mockClear(); + + const view = await render( + {}, testID: 'action-first' }, + { label: 'second', onPress: () => {}, testID: 'action-second' }, + ]} + > + Message + + ); + + await fireEvent(screen.getByTestId('action-second-container'), 'focus'); + expect(setFocus).not.toHaveBeenCalled(); + + await view.rerender( + {}, testID: 'action-first' }, + ]} + > + Message + + ); + + expect(setFocus).toHaveBeenCalledTimes(1); + setFocus.mockRestore(); + }); + + it('leaves focus alone when the focused action survives a shrink', async () => { + const setFocus = jest + .spyOn(AccessibilityInfo, 'setAccessibilityFocus') + .mockImplementation(() => {}); + setFocus.mockClear(); + + const view = await render( + {}, testID: 'action-first' }, + { label: 'second', onPress: () => {}, testID: 'action-second' }, + ]} + > + Message + + ); + + // focus the first action, then drop the second: the count changes, so the + // effect runs, but the focused index is still valid and must be left alone + await fireEvent(screen.getByTestId('action-first-container'), 'focus'); + await view.rerender( + {}, testID: 'action-first' }, + ]} + > + Message + + ); + + expect(screen.getByText('first')).toBeOnTheScreen(); + expect(setFocus).not.toHaveBeenCalled(); + setFocus.mockRestore(); + }); + + it('does not move focus into the banner once it starts hiding', async () => { + // the content is inert from the moment it hides, so focusing it would be + // worse than releasing focus. returning focus to wherever it came from + // needs an api the consumer owns + const setFocus = jest + .spyOn(AccessibilityInfo, 'setAccessibilityFocus') + .mockImplementation(() => {}); + setFocus.mockClear(); + + const view = await render( + {}, testID: 'action-first' }, + ]} + > + Message + + ); + + await fireEvent(screen.getByTestId('action-first-container'), 'focus'); + + await view.rerender( + {}, testID: 'action-first' }, + ]} + > + Message + + ); + + expect(setFocus).not.toHaveBeenCalled(); + setFocus.mockRestore(); + }); + + it('moves focus off the last action when every action is removed', async () => { + const setFocus = jest + .spyOn(AccessibilityInfo, 'setAccessibilityFocus') + .mockImplementation(() => {}); + setFocus.mockClear(); + + const view = await render( + {}, testID: 'action-first' }, + ]} + > + Message + + ); + + await fireEvent(screen.getByTestId('action-first-container'), 'focus'); + + await view.rerender( + + Message + + ); + + // nothing left to focus inside the actions, so land on the message + expect(setFocus).toHaveBeenCalledTimes(1); + setFocus.mockRestore(); + }); + + it('still calls a consumer onFocus handler on an action', async () => { + const onFocus = jest.fn(); + + await render( + {}, + onFocus, + testID: 'action-first', + }, + ]} + > + Message + + ); + + await fireEvent(screen.getByTestId('action-first-container'), 'focus'); + + expect(onFocus).toHaveBeenCalledTimes(1); + }); + + it('warns when given more actions than it can render', async () => { + await render( + {} }, + { label: 'second', onPress: () => {} }, + { label: 'third', onPress: () => {} }, + ]} + > + Message + + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Banner supports a maximum of 2 actions') + ); + }); +}); + +describe('live region', () => { + const ALL = { includeHiddenElements: true }; + + it('is a polite status region by default', async () => { + await render( + + Message + + ); + + const region = screen.getByTestId('banner-message'); + expect(region).toHaveProp('role', 'status'); + expect(region).toHaveProp('aria-live', 'polite'); + }); + + it('is an assertive alert region when urgent', async () => { + await render( + + Message + + ); + + const region = screen.getByTestId('banner-message'); + expect(region).toHaveProp('role', 'alert'); + expect(region).toHaveProp('aria-live', 'assertive'); + }); + + it('silences the region while hidden', async () => { + await render( + + Message + + ); + + expect(screen.getByTestId('banner-message', ALL)).toHaveProp( + 'aria-live', + 'off' + ); + }); + + it('scopes the region to the message so actions do not re-announce it', async () => { + // status/alert imply aria-atomic, so anything inside the region is + // re-announced whenever it changes - keep the buttons out of it + await render( + {} }]} + > + Message + + ); + + const region = screen.getByTestId('banner-message'); + expect(within(region).getByText('Message')).toBeOnTheScreen(); + expect(within(region).queryByText('Fix it')).toBeNull(); + }); + + it('does not leave the live region on the message text', async () => { + await render( + + Message + + ); + + // react-native only maps aria-live -> accessibilityLiveRegion on View, + // so leaving it on Text is a no-op on android + const message = screen.getByText('Message'); + expect(message).not.toHaveProp('aria-live'); + expect(message).not.toHaveProp('role'); + }); +}); + +describe('announcements', () => { + const originalPlatform = Platform.OS; + let announce: jest.SpiedFunction< + typeof AccessibilityInfo.announceForAccessibilityWithOptions + >; + + beforeEach(() => { + Platform.OS = 'ios'; + // the rn jest preset already mocks AccessibilityInfo, so spyOn hands back + // that mock with every earlier test's calls still on it + announce = jest + .spyOn(AccessibilityInfo, 'announceForAccessibilityWithOptions') + .mockImplementation(() => {}); + announce.mockClear(); + }); + + afterEach(() => { + Platform.OS = originalPlatform; + announce.mockRestore(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('announces on ios when mounted visible', async () => { + await render(Something went wrong); + + expect(announce).toHaveBeenCalledTimes(1); + // polite by default: queue behind whatever the screen reader is saying + expect(announce).toHaveBeenCalledWith('Something went wrong', { + queue: true, + }); + }); + + it('does not announce on ios while hidden', async () => { + const view = await render(Quiet); + + expect(announce).not.toHaveBeenCalled(); + + await view.rerender(Quiet); + expect(announce).toHaveBeenCalledTimes(1); + expect(announce).toHaveBeenCalledWith('Quiet', { queue: true }); + }); + + it('re-announces on ios when the message changes while visible', async () => { + const view = await render(First); + expect(announce).toHaveBeenCalledTimes(1); + + await view.rerender(Second); + + expect(announce).toHaveBeenCalledTimes(2); + expect(announce).toHaveBeenLastCalledWith('Second', { queue: true }); + }); + + it('does not announce again when an unrelated prop changes', async () => { + const view = await render(Same); + expect(announce).toHaveBeenCalledTimes(1); + + await view.rerender( + + Same + + ); + + expect(announce).toHaveBeenCalledTimes(1); + }); + + it('interrupts the screen reader on ios when urgent', async () => { + await render( + + Your payment failed + + ); + + expect(announce).toHaveBeenCalledWith('Your payment failed', { + queue: false, + }); + }); + + it('re-announces on ios when urgency changes while visible', async () => { + const view = await render(Same message); + expect(announce).toHaveBeenCalledTimes(1); + + await view.rerender( + + Same message + + ); + + expect(announce).toHaveBeenCalledTimes(2); + expect(announce).toHaveBeenLastCalledWith('Same message', { + queue: false, + }); + }); + + it('announces children that are not a plain string', async () => { + const name = 'Ada'; + await render(Hello {name}, your card was declined); + + expect(announce).toHaveBeenCalledWith('Hello Ada, your card was declined', { + queue: true, + }); + }); + + it('leaves announcing to the live region off ios', async () => { + Platform.OS = 'android'; + + await render(Handled by the live region); + + expect(announce).not.toHaveBeenCalled(); + }); +}); + describe('animations', () => { let showCallback: (() => void) | undefined, hideCallback: (() => void) | undefined; @@ -135,19 +727,13 @@ describe('animations', () => { hideCallback = jest.fn(); }); - beforeAll(() => { - jest.useFakeTimers(); - }); - afterAll(() => { - jest.useRealTimers(); showCallback = undefined; hideCallback = undefined; }); describe('when component is rendered hidden', () => { - // This behaviour is probably a bug. Needs triage before next version. - it('will fire onHideAnimationFinished on mount', async () => { + it('will not fire any callback on mount', async () => { await render( { jest.runAllTimers(); }); expect(showCallback).not.toHaveBeenCalled(); - expect(hideCallback).toHaveBeenCalled(); + expect(hideCallback).not.toHaveBeenCalled(); }); it('should fire onShowAnimationFinished upon opening', async () => { @@ -183,7 +769,7 @@ describe('animations', () => { jest.runAllTimers(); }); expect(showCallback).toHaveBeenCalledTimes(0); - expect(hideCallback).toHaveBeenCalledTimes(1); + expect(hideCallback).toHaveBeenCalledTimes(0); await view.rerender( { jest.runAllTimers(); }); expect(showCallback).toHaveBeenCalledTimes(1); - expect(hideCallback).toHaveBeenCalledTimes(1); + expect(hideCallback).toHaveBeenCalledTimes(0); }); }); describe('when component is rendered visible', () => { - // This behaviour is probably a bug. Needs triage before next version. - it('will fire onShowAnimationFinished on mount', async () => { + it('will not fire any callback on mount', async () => { await render( { await act(() => { jest.runAllTimers(); }); - expect(showCallback).toHaveBeenCalled(); + expect(showCallback).not.toHaveBeenCalled(); expect(hideCallback).not.toHaveBeenCalled(); }); @@ -239,7 +824,7 @@ describe('animations', () => { await act(() => { jest.runAllTimers(); }); - expect(showCallback).toHaveBeenCalledTimes(1); + expect(showCallback).toHaveBeenCalledTimes(0); expect(hideCallback).toHaveBeenCalledTimes(0); await view.rerender( @@ -254,7 +839,7 @@ describe('animations', () => { await act(() => { jest.runAllTimers(); }); - expect(showCallback).toHaveBeenCalledTimes(1); + expect(showCallback).toHaveBeenCalledTimes(0); expect(hideCallback).toHaveBeenCalledTimes(1); }); }); @@ -274,7 +859,7 @@ describe('animations', () => { await act(() => { jest.runAllTimers(); }); - expect(showCallback).toHaveBeenCalledTimes(1); + expect(showCallback).toHaveBeenCalledTimes(0); expect(hideCallback).toHaveBeenCalledTimes(0); const nextShowCallback = jest.fn(); @@ -293,7 +878,7 @@ describe('animations', () => { await act(() => { jest.runAllTimers(); }); - expect(showCallback).toHaveBeenCalledTimes(1); + expect(showCallback).toHaveBeenCalledTimes(0); expect(hideCallback).toHaveBeenCalledTimes(0); expect(nextShowCallback).toHaveBeenCalledTimes(0); expect(nextHideCallback).toHaveBeenCalledTimes(0); @@ -313,7 +898,7 @@ describe('animations', () => { await act(() => { jest.runAllTimers(); }); - expect(showCallback).toHaveBeenCalledTimes(1); + expect(showCallback).toHaveBeenCalledTimes(0); expect(hideCallback).toHaveBeenCalledTimes(0); const nextShowCallback = jest.fn(); @@ -332,7 +917,7 @@ describe('animations', () => { await act(() => { jest.runAllTimers(); }); - expect(showCallback).toHaveBeenCalledTimes(1); + expect(showCallback).toHaveBeenCalledTimes(0); expect(hideCallback).toHaveBeenCalledTimes(0); expect(nextShowCallback).toHaveBeenCalledTimes(0); expect(nextHideCallback).toHaveBeenCalledTimes(0); @@ -350,13 +935,47 @@ describe('animations', () => { await act(() => { jest.runAllTimers(); }); - expect(showCallback).toHaveBeenCalledTimes(1); + expect(showCallback).toHaveBeenCalledTimes(0); expect(hideCallback).toHaveBeenCalledTimes(0); expect(nextShowCallback).toHaveBeenCalledTimes(0); expect(nextHideCallback).toHaveBeenCalledTimes(1); }); }); + it('should not fire callbacks when only the theme animation scale changes', async () => { + const view = await render( + + Text + + ); + + await act(() => { + jest.runAllTimers(); + }); + + await view.rerender( + + Text + + ); + await act(() => { + jest.runAllTimers(); + }); + + expect(showCallback).not.toHaveBeenCalled(); + expect(hideCallback).not.toHaveBeenCalled(); + }); + it('animated value changes correctly', async () => { const value = new Animated.Value(1); await render( diff --git a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap index a6fb8c8dee..da0898d6f0 100644 --- a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap @@ -57,95 +57,96 @@ exports[`render visible banner, with custom theme 1`] = ` } /> - + + - Custom theme - - - + [ + { + "fontFamily": "System", + "fontSize": 14, + "fontWeight": "400", + "letterSpacing": 0.25, + "lineHeight": 20, + }, + { + "color": "#00f", + }, + ], + ] + } + > + Custom theme + + + - + - first - + ] + } + testID="button-text" + > + first + + @@ -333,8 +359,11 @@ exports[`renders hidden banner, without action buttons and without image 1`] = ` } /> - @@ -468,15 +509,20 @@ exports[`renders visible banner, with action buttons and with image 1`] = ` } /> - + + + - - + - Two line text string with two actions. One to two lines is preferable on mobile. - - - + [ + { + "fontFamily": "System", + "fontSize": 14, + "fontWeight": "400", + "letterSpacing": 0.25, + "lineHeight": 20, + }, + { + "color": "rgba(29, 27, 32, 1)", + }, + ], + ] + } + > + Two line text string with two actions. One to two lines is preferable on mobile. + + + - + - first - + ] + } + testID="button-text" + > + first + + @@ -766,95 +833,96 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` } /> - + + - Two line text string with two actions. One to two lines is preferable on mobile. - - - + [ + { + "fontFamily": "System", + "fontSize": 14, + "fontWeight": "400", + "letterSpacing": 0.25, + "lineHeight": 20, + }, + { + "color": "rgba(29, 27, 32, 1)", + }, + ], + ] + } + > + Two line text string with two actions. One to two lines is preferable on mobile. + + + - + - first - + ] + } + testID="button-text" + > + first + + - - - + - second - + ] + } + testID="button-text" + > + second + + @@ -1194,15 +1289,20 @@ exports[`renders visible banner, without action buttons and with image 1`] = ` } /> - + + + - - + - Two line text string with two actions. One to two lines is preferable on mobile. - + [ + { + "fontFamily": "System", + "fontSize": 14, + "fontWeight": "400", + "letterSpacing": 0.25, + "lineHeight": 20, + }, + { + "color": "rgba(29, 27, 32, 1)", + }, + ], + ] + } + > + Two line text string with two actions. One to two lines is preferable on mobile. + + + - @@ -1339,66 +1448,80 @@ exports[`renders visible banner, without action buttons and without image 1`] = } /> - + + - Two line text string with two actions. One to two lines is preferable on mobile. - + [ + { + "fontFamily": "System", + "fontSize": 14, + "fontWeight": "400", + "letterSpacing": 0.25, + "lineHeight": 20, + }, + { + "color": "rgba(29, 27, 32, 1)", + }, + ], + ] + } + > + Two line text string with two actions. One to two lines is preferable on mobile. + + + -