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..42011957c2 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -1,6 +1,13 @@ import * as React from 'react'; -import { Animated, StyleSheet, View } from 'react-native'; -import type { StyleProp, ViewStyle } from 'react-native'; +import { + AccessibilityInfo, + Animated, + findNodeHandle, + Platform, + StyleSheet, + View, +} from 'react-native'; +import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; import type { LayoutChangeEvent } from 'react-native'; import useLatestCallback from 'use-latest-callback'; @@ -12,8 +19,35 @@ import Surface from './Surface'; import Text from './Typography/Text'; import { useInternalTheme } from '../core/theming'; import type { $Omit, $RemoveChildren, Theme, ThemeProp } from '../types'; +import { mergeRefs } from '../utils/mergeRefs'; +import useLayout from '../utils/useLayout'; const DEFAULT_MAX_WIDTH = 960; +// banners carry at most two actions per the material spec +const MAX_ACTIONS = 2; +// md3's compact window size class. md3 has no banner spec, so where the +// actions go is a choice, not a spec value +const COMPACT_BREAKPOINT = 600; +// a tradeoff, not a safe number: too short and the set and clear can batch into +// one a11y update, too long and a talkback swipe hears the message twice +const ANNOUNCE_CLEAR_DELAY = 3000; + +// only android and web have a real live region. everywhere else announces by hand +const hasLiveRegion = () => Platform.OS === 'android' || Platform.OS === 'web'; + +// a nested would otherwise be dropped from the announcement +const extractText = (node: React.ReactNode): string => + React.Children.toArray(node) + .map((child) => { + if (typeof child === 'string' || typeof child === 'number') { + return String(child); + } + if (React.isValidElement<{ children?: React.ReactNode }>(child)) { + return extractText(child.props.children); + } + return ''; + }) + .join(''); export type Props = $Omit<$RemoveChildren, 'mode'> & { /** @@ -28,6 +62,12 @@ export type Props = $Omit<$RemoveChildren, 'mode'> & { * Icon to display for the `Banner`. Can be an image. */ icon?: IconSource; + /** + * Accessibility label for the icon. Leave it out when the icon is decorative + * and the message already says everything - the icon is then hidden from + * screen readers instead of being read out as an unlabelled image. + */ + iconAccessibilityLabel?: string; /** * Action items to shown in the banner. * An action item should contain the following properties: @@ -36,6 +76,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 +95,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. */ @@ -121,6 +170,7 @@ export type Props = $Omit<$RemoveChildren, 'mode'> & { const Banner = ({ visible, icon, + iconAccessibilityLabel, children, actions = [], contentStyle, @@ -130,6 +180,8 @@ const Banner = ({ onShowAnimationFinished = () => {}, onHideAnimationFinished = () => {}, maxFontSizeMultiplier, + urgent = false, + testID, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); @@ -144,6 +196,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,28 +210,151 @@ 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, useNativeDriver: false, - }).start(showCallback); + }).start((result) => { + if (result.finished) { + showCallback(result); + } + }); } else { // hide Animated.timing(position, { 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 region: Pick = urgent + ? { role: 'alert', 'aria-live': 'assertive' } + : { role: 'status', 'aria-live': 'polite' }; + const message = extractText(children); + + React.useEffect(() => { + if (hasLiveRegion() || !visible || !message) { + return; + } + + AccessibilityInfo.announceForAccessibilityWithOptions(message, { + queue: !urgent, + }); + }, [visible, message, urgent]); + + // a region only fires when its text changes while it is already in the tree. + // the banner unmounts when hidden, so the message can never be that change + const [announcement, setAnnouncement] = React.useState(''); + React.useEffect(() => { + if (!hasLiveRegion() || !visible || !message) { + setAnnouncement(''); + return; + } + + // re-setting the same string is not a render, so empty it first or an + // unchanged message announces nothing + setAnnouncement(''); + const fill = setTimeout(() => setAnnouncement(message), 0); + const clear = setTimeout(() => setAnnouncement(''), ANNOUNCE_CLEAR_DELAY); + return () => { + clearTimeout(fill); + clearTimeout(clear); + }; + }, [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++) { + actionRefs.current[i] ??= React.createRef(); + } + const [row, onRowLayout] = useLayout(); + const messageRef = React.useRef(null); + const focusedAction = React.useRef(null); + + const focusNode = (node: View | null) => { + if (!node) { + return; + } + + // rnw's findNodeHandle throws, and the ref is already the dom node there + if (Platform.OS === 'web') { + node.focus(); + return; + } + + const handle = findNodeHandle(node); + if (handle !== null) { + AccessibilityInfo.setAccessibilityFocus(handle); + } + }; + + // 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 +370,20 @@ 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: { inert?: boolean } = visible ? {} : { inert: true }; + + const stacked = !row.measured || row.width < COMPACT_BREAKPOINT; + + // annotated, not cast: the literal -1 widens to number on its own + const messageFocusProps: Pick = + Platform.OS === 'web' ? { tabIndex: -1 } : { accessible: true }; + return ( - - - {icon ? ( - - - - ) : null} - + - {children} - - - - {actions.map(({ label, ...others }, i) => ( - - ))} + + {/* rnw gives the icon role="img" with no name */} + {icon ? ( + + + + ) : null} + + + {children} + + + + {visibleActions.length ? ( + + {visibleActions.map(({ label, ...others }, i) => ( + + ))} + + ) : null} + + + )} + {/* last, so a screen reader reaches the real message first */} + {hasLiveRegion() ? ( + + {announcement} - + ) : null} ); @@ -274,11 +508,24 @@ const styles = StyleSheet.create({ }, content: { flexDirection: 'row', - justifyContent: 'flex-start', + alignItems: 'center', marginHorizontal: 8, marginTop: 16, marginBottom: 0, }, + contentStacked: { + flexDirection: 'column', + alignItems: 'stretch', + }, + body: { + flexDirection: 'row', + alignItems: 'center', + flexShrink: 1, + }, + bodyInline: { + flexGrow: 1, + flexBasis: 0, + }, icon: { margin: 8, }, @@ -288,6 +535,7 @@ const styles = StyleSheet.create({ }, actions: { flexDirection: 'row', + flexShrink: 0, justifyContent: 'flex-end', margin: 4, }, @@ -297,6 +545,14 @@ const styles = StyleSheet.create({ transparent: { opacity: 0, }, + announcer: { + position: 'absolute', + top: 0, + left: 0, + width: 1, + height: 1, + overflow: 'hidden', + }, }); export default Banner; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 53d61a2d6f..8b61da2b1e 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -134,7 +134,7 @@ export type Props = $Omit, 'mode'> & { /** * Reference for the touchable */ - touchableRef?: React.RefObject; + touchableRef?: React.Ref; ref?: React.Ref; /** * testID to be used on tests. diff --git a/src/components/__tests__/Banner.test.tsx b/src/components/__tests__/Banner.test.tsx index 80bd3e9017..d2ba691733 100644 --- a/src/components/__tests__/Banner.test.tsx +++ b/src/components/__tests__/Banner.test.tsx @@ -1,129 +1,1092 @@ -import { Animated, Image } from 'react-native'; +import * as React from 'react'; +import { + AccessibilityInfo, + Animated, + Image, + Platform, + Text, + View, +} from 'react-native'; + +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { act } from '@testing-library/react-native'; + +import { fireEvent, render, screen, within } from '../../test-utils'; +import Banner from '../Banner'; + +it('renders hidden banner, without action buttons and without image', async () => { + const tree = ( + await render( + + Two line text string with two actions. One to two lines is preferable on + mobile. + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders visible banner, without action buttons and without image', async () => { + const tree = ( + await render( + + Two line text string with two actions. One to two lines is preferable on + mobile. + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders visible banner, with action buttons and without image', async () => { + const tree = ( + await render( + {} }, + { label: 'second', onPress: () => {} }, + ]} + > + Two line text string with two actions. One to two lines is preferable on + mobile. + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders visible banner, without action buttons and with image', async () => { + const tree = ( + await render( + ( + + )} + > + Two line text string with two actions. One to two lines is preferable on + mobile. + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders visible banner, with action buttons and with image', async () => { + const tree = ( + await render( + ( + + )} + actions={[{ label: 'first', onPress: () => {} }]} + > + Two line text string with two actions. One to two lines is preferable on + mobile. + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('render visible banner, with custom theme', async () => { + const tree = ( + await render( + {} }]} + > + Custom theme + + ) + ).toJSON(); + + 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, config) => { + return { + start: (cb) => { + if (config.toValue === 0) { + hideDone = cb; + } + }, + stop: () => {}, + reset: () => {}, + }; + }); + + 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('keeps a touchableRef passed through an action', async () => { + const setFocus = jest + .spyOn(AccessibilityInfo, 'setAccessibilityFocus') + .mockImplementation(() => {}); + setFocus.mockClear(); + const touchableRef = React.createRef(); + + const view = await render( + {}, + testID: 'action-first', + touchableRef, + }, + ]} + > + Message + + ); + + // the consumer gets the node, and the internal ref still restores focus + expect(touchableRef.current).not.toBeNull(); + + await fireEvent(screen.getByTestId('action-first-container'), 'focus'); + await view.rerender( + + Message + + ); + + expect(setFocus).toHaveBeenCalledTimes(1); + setFocus.mockRestore(); + }); + + 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('reflow', () => { + const ACTIONS_TWO = [ + { label: 'first', onPress: () => {} }, + { label: 'second', onPress: () => {} }, + ]; + + const measure = (width: number) => + fireEvent(screen.getByTestId('banner-row'), 'layout', { + nativeEvent: { layout: { height: 80, width } }, + }); + + it('stacks the actions below the message on a phone width', async () => { + await render( + + Two line text string with two actions. + + ); + + await measure(400); + + expect(screen.getByTestId('banner-row')).toHaveStyle({ + flexDirection: 'column', + }); + }); + + it('puts the actions inline once there is room', async () => { + await render( + + Two line text string with two actions. + + ); + + await measure(800); + + expect(screen.getByTestId('banner-row')).toHaveStyle({ + flexDirection: 'row', + }); + }); + + it('stacks until the banner has been measured', async () => { + await render( + + Two line text string with two actions. + + ); + + expect(screen.getByTestId('banner-row')).toHaveStyle({ + flexDirection: 'column', + }); + }); + + it('reflows again when the banner is resized', async () => { + await render( + + Two line text string with two actions. + + ); + + await measure(800); + expect(screen.getByTestId('banner-row')).toHaveStyle({ + flexDirection: 'row', + }); + + await measure(400); + expect(screen.getByTestId('banner-row')).toHaveStyle({ + flexDirection: 'column', + }); + }); +}); + +describe('live region', () => { + const ALL = { includeHiddenElements: true }; + const originalPlatform = Platform.OS; + + // the preset runs as ios, which has no live region and announces by hand + beforeEach(() => { + Platform.OS = 'android'; + }); + + afterEach(() => { + Platform.OS = originalPlatform; + }); + + // the announcer empties itself first and fills on a later task, so the text + // is never there on the render pass itself + const flush = () => + act(() => { + jest.advanceTimersByTime(1); + }); + + const announcerHas = (text: string) => + within(screen.getByTestId('banner-announcer', ALL)).queryByText(text) !== + null; + + it('is a polite status region by default', async () => { + await render( + + Message + + ); + + const region = screen.getByTestId('banner-announcer'); + 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-announcer'); + expect(region).toHaveProp('role', 'alert'); + expect(region).toHaveProp('aria-live', 'assertive'); + }); + + it('stays mounted and empty while the banner is hidden', async () => { + await render( + + Message + + ); + await flush(); + + expect(screen.getByTestId('banner-announcer', ALL)).toBeOnTheScreen(); + expect(announcerHas('Message')).toBe(false); + }); + + it('announces again every time the banner is shown', async () => { + const view = await render( + + Message + + ); + await flush(); + expect(announcerHas('Message')).toBe(true); + + await view.rerender( + + Message + + ); + // finish the hide so the content really unmounts, which the bug needed + await act(() => { + jest.runAllTimers(); + }); + expect(screen.queryByTestId('banner-content', ALL)).toBeNull(); + expect(announcerHas('Message')).toBe(false); + + await view.rerender( + + Message + + ); + await flush(); + expect(announcerHas('Message')).toBe(true); + }); + + it('announces an unchanged message again on every show', async () => { + const view = await render( + + Same text + + ); + + for (let i = 0; i < 2; i++) { + await view.rerender( + + Same text + + ); + await flush(); + expect(announcerHas('Same text')).toBe(true); + + await view.rerender( + + Same text + + ); + await act(() => { + jest.runAllTimers(); + }); + expect(announcerHas('Same text')).toBe(false); + } + }); + + it('re-announces when the message changes while visible', async () => { + const view = await render( + + First + + ); + await flush(); + expect(announcerHas('First')).toBe(true); + + await view.rerender( + + Second + + ); + await flush(); + expect(announcerHas('Second')).toBe(true); + }); + + it('re-announces when urgency changes while visible', async () => { + const view = await render( + + Message + + ); + await act(() => { + jest.runAllTimers(); + }); + expect(announcerHas('Message')).toBe(false); + + await view.rerender( + + Message + + ); + await flush(); + + expect(screen.getByTestId('banner-announcer')).toHaveProp('role', 'alert'); + expect(announcerHas('Message')).toBe(true); + }); + + it('announces text nested inside elements', async () => { + await render( + + Your card ending 4242 was declined + + ); + await flush(); + + expect(announcerHas('Your card ending 4242 was declined')).toBe(true); + }); + + it('drops the text again so it is not a second copy of the message', async () => { + await render( + + Message + + ); + await act(() => { + jest.runAllTimers(); + }); + + expect(announcerHas('Message')).toBe(false); + expect( + within(screen.getByTestId('banner-message')).getByText('Message') + ).toBeOnTheScreen(); + }); + + it('carries the message only, so action labels never 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 + + ); + await flush(); -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - it, - jest, -} from '@jest/globals'; -import { act } from '@testing-library/react-native'; + const region = screen.getByTestId('banner-announcer'); + expect(within(region).getByText('Message')).toBeOnTheScreen(); + expect(within(region).queryByText('Fix it')).toBeNull(); + }); -import { render, screen } from '../../test-utils'; -import Banner from '../Banner'; + it('leaves the region off the message and its text', async () => { + // the message is a focus target now, not a region + await render( + + Message + + ); + + const container = screen.getByTestId('banner-message'); + expect(container).not.toHaveProp('aria-live'); + expect(container).not.toHaveProp('role'); + + const text = within(container).getByText('Message'); + expect(text).not.toHaveProp('aria-live'); + expect(text).not.toHaveProp('role'); + }); + + it('is left out on ios, which announces by hand instead', async () => { + Platform.OS = 'ios'; -it('renders hidden banner, without action buttons and without image', async () => { - const tree = ( await render( - - Two line text string with two actions. One to two lines is preferable on - mobile. + + Message - ) - ).toJSON(); + ); - expect(tree).toMatchSnapshot(); + expect(screen.queryByTestId('banner-announcer', ALL)).toBeNull(); + }); }); -it('renders visible banner, without action buttons and without image', async () => { - const tree = ( +describe('icon', () => { + it('hides a decorative icon from screen readers', async () => { + // an unlabelled icon reads as a bare "image" await render( - - Two line text string with two actions. One to two lines is preferable on - mobile. + + Message - ) - ).toJSON(); + ); - expect(tree).toMatchSnapshot(); -}); + expect(screen.queryByTestId('banner-icon')).toBeNull(); + expect( + screen.getByTestId('banner-icon', { includeHiddenElements: true }) + ).toHaveProp('aria-hidden', true); + }); -it('renders visible banner, with action buttons and without image', async () => { - const tree = ( + it('exposes the icon when it is given a label', async () => { await render( {} }, - { label: 'second', onPress: () => {} }, - ]} + icon="camera" + iconAccessibilityLabel="Payment failed" + testID="banner" > - Two line text string with two actions. One to two lines is preferable on - mobile. + Message - ) - ).toJSON(); + ); - expect(tree).toMatchSnapshot(); + const wrapper = screen.getByTestId('banner-icon'); + expect(wrapper).toHaveProp('aria-hidden', false); + expect(wrapper).toHaveProp('accessible', true); + expect(wrapper).toHaveProp('aria-label', 'Payment failed'); + }); }); -it('renders visible banner, without action buttons and with image', async () => { - const tree = ( +describe('message focus', () => { + const originalPlatform = Platform.OS; + + afterEach(() => { + Platform.OS = originalPlatform; + }); + + it('makes the message focusable on web', async () => { + Platform.OS = 'web'; + await render( - ( - - )} - > - Two line text string with two actions. One to two lines is preferable on - mobile. + + Message - ) - ).toJSON(); + ); - expect(tree).toMatchSnapshot(); -}); + expect(screen.getByTestId('banner-message')).toHaveProp('tabIndex', -1); + }); + + it('makes the message an accessibility element on native', async () => { + Platform.OS = 'ios'; -it('renders visible banner, with action buttons and with image', async () => { - const tree = ( await render( + + Message + + ); + + expect(screen.getByTestId('banner-message')).toHaveProp('accessible', true); + }); + + it('does not reach for a native handle on web', async () => { + // rnw's findNodeHandle throws, and calling it took the whole tree down + Platform.OS = 'web'; + const setFocus = jest + .spyOn(AccessibilityInfo, 'setAccessibilityFocus') + .mockImplementation(() => {}); + setFocus.mockClear(); + + const view = await render( ( - - )} - actions={[{ label: 'first', onPress: () => {} }]} + testID="banner" + actions={[ + { label: 'first', onPress: () => {}, testID: 'action-first' }, + ]} > - Two line text string with two actions. One to two lines is preferable on - mobile. + Message - ) - ).toJSON(); + ); - expect(tree).toMatchSnapshot(); + await fireEvent(screen.getByTestId('action-first-container'), 'focus'); + + await view.rerender( + + Message + + ); + + expect(setFocus).not.toHaveBeenCalled(); + expect(screen.getByTestId('banner-message')).toBeOnTheScreen(); + setFocus.mockRestore(); + }); }); -it('render visible banner, with custom theme', async () => { - const tree = ( +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(); + }); + + 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( - {} }]} - > - Custom theme + + Your payment failed - ) - ).toJSON(); + ); - expect(tree).toMatchSnapshot(); + 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', () => { @@ -135,19 +1098,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 +1140,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 +1195,7 @@ describe('animations', () => { await act(() => { jest.runAllTimers(); }); - expect(showCallback).toHaveBeenCalledTimes(1); + expect(showCallback).toHaveBeenCalledTimes(0); expect(hideCallback).toHaveBeenCalledTimes(0); await view.rerender( @@ -254,7 +1210,7 @@ describe('animations', () => { await act(() => { jest.runAllTimers(); }); - expect(showCallback).toHaveBeenCalledTimes(1); + expect(showCallback).toHaveBeenCalledTimes(0); expect(hideCallback).toHaveBeenCalledTimes(1); }); }); @@ -274,7 +1230,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 +1249,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 +1269,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 +1288,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 +1306,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( @@ -387,3 +1377,97 @@ describe('animations', () => { }); }); }); + +describe('interrupted animations', () => { + let showDone: ((result: { finished: boolean }) => void) | undefined; + let hideDone: ((result: { finished: boolean }) => void) | undefined; + let timing: jest.SpiedFunction; + + beforeEach(() => { + showDone = undefined; + hideDone = undefined; + timing = jest + .spyOn(Animated, 'timing') + .mockImplementation((_value, config) => { + return { + start: (cb) => { + if (config.toValue === 1) { + showDone = cb; + } else if (config.toValue === 0) { + hideDone = cb; + } + }, + stop: () => {}, + reset: () => {}, + }; + }); + }); + + afterEach(() => { + timing.mockRestore(); + }); + + it('does not fire onHideAnimationFinished when hide is interrupted', async () => { + const onHideAnimationFinished = jest.fn(); + const onShowAnimationFinished = jest.fn(); + + const view = await render( + + Text + + ); + + await view.rerender( + + Text + + ); + + await act(() => { + hideDone?.({ finished: false }); + }); + + expect(onShowAnimationFinished).not.toHaveBeenCalled(); + expect(onHideAnimationFinished).not.toHaveBeenCalled(); + }); + + it('does not fire onShowAnimationFinished when show is interrupted', async () => { + const onHideAnimationFinished = jest.fn(); + const onShowAnimationFinished = jest.fn(); + + const view = await render( + + Text + + ); + + await view.rerender( + + Text + + ); + + await act(() => { + showDone?.({ finished: false }); + }); + + expect(onShowAnimationFinished).not.toHaveBeenCalled(); + expect(onHideAnimationFinished).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap index a6fb8c8dee..6a45ae6500 100644 --- a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap @@ -57,95 +57,100 @@ 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 +363,11 @@ exports[`renders hidden banner, without action buttons and without image 1`] = ` } /> - @@ -468,117 +517,124 @@ 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 +847,100 @@ 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,88 +1307,108 @@ 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 +1472,84 @@ 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. + + + - diff --git a/src/utils/__tests__/mergeRefs.test.ts b/src/utils/__tests__/mergeRefs.test.ts new file mode 100644 index 0000000000..111bfbdb13 --- /dev/null +++ b/src/utils/__tests__/mergeRefs.test.ts @@ -0,0 +1,38 @@ +import { createRef } from 'react'; + +import { describe, expect, it, jest } from '@jest/globals'; + +import { mergeRefs } from '../mergeRefs'; + +describe('mergeRefs', () => { + it('writes the node to object refs and calls callback refs', () => { + const object = createRef(); + const callback = jest.fn<(node: string | null) => void>(); + + mergeRefs(object, callback)('node'); + + expect(object.current).toBe('node'); + expect(callback).toHaveBeenCalledWith('node'); + }); + + it('skips refs that were not passed', () => { + const object = createRef(); + + expect(() => + mergeRefs(undefined, object, null)('node') + ).not.toThrow(); + expect(object.current).toBe('node'); + }); + + it('clears every ref when react detaches it', () => { + const object = createRef(); + const callback = jest.fn<(node: string | null) => void>(); + const merged = mergeRefs(object, callback); + + merged('node'); + merged(null); + + expect(object.current).toBeNull(); + expect(callback).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/src/utils/mergeRefs.ts b/src/utils/mergeRefs.ts new file mode 100644 index 0000000000..d62d43aaf7 --- /dev/null +++ b/src/utils/mergeRefs.ts @@ -0,0 +1,14 @@ +import type * as React from 'react'; + +/** Feeds a node to every ref, so an internal ref doesn't drop the consumer's. */ +export const mergeRefs = + (...refs: Array | undefined>): React.RefCallback => + (node) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + ref.current = node; + } + }); + };