Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Announce offline status #57178

Merged
merged 12 commits into from
Dec 28, 2023
61 changes: 57 additions & 4 deletions packages/editor/src/components/offline-status/index.native.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,71 @@
/**
* External dependencies
*/
import { Text, View } from 'react-native';
import { AccessibilityInfo, Text, View } from 'react-native';

/**
* WordPress dependencies
*/
import {
usePreferredColorSchemeStyle,
useNetworkConnectivity,
usePrevious,
} from '@wordpress/compose';
import { Icon } from '@wordpress/components';
import { offline as offlineIcon } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';
import { useEffect } from '@wordpress/element';

/**
* Internal dependencies
*/
import styles from './style.native.scss';

/**
* Conditionally announces messages for screen reader users. This Hook provides
* two benefits over React Native's `accessibilityLiveRegion`:
*
* 1. It works on both iOS and Android.
* 2. It allows announcing a secondary message when the component is inactive.
*
* @param {string} message The message to announce.
* @param {Object} options Options for the Hook.
* @param {boolean} [options.isActive] Whether the message should be announced.
* @param {string} [options.inactiveMessage] The message to announce when inactive.
*/
function useAccessibilityLiveRegion( message, { isActive, inactiveMessage } ) {
const { announceForAccessibility } = AccessibilityInfo;
const prevIsActive = usePrevious( isActive );

useEffect( () => {
const unconditionalMessage = typeof isActive === 'undefined';
const initialRender = typeof prevIsActive === 'undefined';

if (
unconditionalMessage ||
( isActive && ! prevIsActive && ! initialRender )
) {
announceForAccessibility( message );
} else if ( ! isActive && prevIsActive && inactiveMessage ) {
announceForAccessibility( inactiveMessage );
}
}, [
message,
isActive,
prevIsActive,
inactiveMessage,
announceForAccessibility,
] );
}

const OfflineStatus = () => {
const { isConnected } = useNetworkConnectivity();

useAccessibilityLiveRegion( __( 'Network connection re-established' ), {
isActive: isConnected,
inactiveMessage: __( 'Network connection lost, working offline' ),
} );

const containerStyle = usePreferredColorSchemeStyle(
styles.offline,
styles.offline__dark
Expand All @@ -38,9 +82,18 @@ const OfflineStatus = () => {
);

return ! isConnected ? (
<View style={ containerStyle }>
<Icon fill={ iconStyle.fill } icon={ offlineIcon } />
<Text style={ textStyle }>{ __( 'Working Offline' ) }</Text>
<View
accessible
accessibilityRole="alert"
accessibilityLabel={ __(
'Network connection lost, working offline'
) }
style={ containerStyle }
>
<View style={ containerStyle }>
<Icon fill={ iconStyle.fill } icon={ offlineIcon } />
<Text style={ textStyle }>{ __( 'Working Offline' ) }</Text>
</View>
</View>
) : null;
};
Expand Down
108 changes: 108 additions & 0 deletions packages/editor/src/components/offline-status/test/index.native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* External dependencies
*/
import { act, render, screen } from 'test/helpers';

/**
* WordPress dependencies
*/
import {
requestConnectionStatus,
subscribeConnectionStatus,
} from '@wordpress/react-native-bridge';

/**
* Internal dependencies
*/
import OfflineStatus from '../index';
import { AccessibilityInfo } from 'react-native';

jest.mock( '../style.native.scss', () => ( {
'offline--icon': {
fill: '',
},
} ) );

describe( 'when network connectivity is unavailable', () => {
beforeAll( () => {
requestConnectionStatus.mockImplementation( ( callback ) => {
callback( false );
return { remove: jest.fn() };
} );
} );

it( 'should display a helpful message', () => {
render( <OfflineStatus /> );

expect( screen.getByText( 'Working Offline' ) ).toBeVisible();
} );

it( 'should display an accessible message', () => {
render( <OfflineStatus /> );

expect(
screen.getByLabelText( 'Network connection lost, working offline' )
).toBeVisible();
} );

it( 'should announce network status', () => {
render( <OfflineStatus /> );

expect(
AccessibilityInfo.announceForAccessibility
).toHaveBeenCalledWith( 'Network connection lost, working offline' );
} );

it( 'should announce changes to network status', () => {
let subscriptionCallback;
subscribeConnectionStatus.mockImplementation( ( callback ) => {
subscriptionCallback = callback;
return { remove: jest.fn() };
} );
render( <OfflineStatus /> );

act( () => subscriptionCallback( { isConnected: false } ) );

expect(
AccessibilityInfo.announceForAccessibility
).toHaveBeenCalledWith( 'Network connection lost, working offline' );
} );
} );

describe( 'when network connectivity is available', () => {
beforeAll( () => {
requestConnectionStatus.mockImplementation( ( callback ) => {
callback( true );
return { remove: jest.fn() };
} );
} );

it( 'should not display a helpful message', () => {
render( <OfflineStatus /> );

expect( screen.queryByText( 'Working Offline' ) ).toBeNull();
} );

it( 'should not announce network status', () => {
render( <OfflineStatus /> );

expect(
AccessibilityInfo.announceForAccessibility
).not.toHaveBeenCalled();
} );

it( 'should announce changes to network status', () => {
let subscriptionCallback;
subscribeConnectionStatus.mockImplementation( ( callback ) => {
subscriptionCallback = callback;
return { remove: jest.fn() };
} );
render( <OfflineStatus /> );

act( () => subscriptionCallback( { isConnected: false } ) );

expect(
AccessibilityInfo.announceForAccessibility
).toHaveBeenCalledWith( 'Network connection lost, working offline' );
} );
} );
Loading