Skip to content

Commit

Permalink
[i18n/audio] Add <ReadOnLoad> for reading intro text on page/route lo…
Browse files Browse the repository at this point in the history
…ad (#4483)
  • Loading branch information
kofi-q authored Jan 8, 2024
1 parent 83d5740 commit 9fdd602
Show file tree
Hide file tree
Showing 4 changed files with 213 additions and 4 deletions.
14 changes: 10 additions & 4 deletions libs/mark-flow-ui/src/pages/start_page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
/* istanbul ignore file - tested via Mark/Mark-Scan */
import { singlePrecinctSelectionFor } from '@votingworks/utils';
import styled from 'styled-components';
import { Screen, Button, appStrings, AudioOnly, Wobble } from '@votingworks/ui';
import {
Screen,
Button,
appStrings,
AudioOnly,
Wobble,
ReadOnLoad,
} from '@votingworks/ui';

import { assert } from '@votingworks/basics';

Expand Down Expand Up @@ -91,8 +98,7 @@ export function StartPage(props: StartPageProps): JSX.Element {
return (
<Screen>
<Body>
{/* TODO(kofi): Create a component for this 'audiofocus' functionality */}
<div id="audiofocus">
<ReadOnLoad>
<ElectionInfoContainer>
<ElectionInfo
electionDefinition={electionDefinition}
Expand All @@ -102,7 +108,7 @@ export function StartPage(props: StartPageProps): JSX.Element {
/>
</ElectionInfoContainer>
<AudioOnly>{appStrings.instructionsBmdBallotNavigation()}</AudioOnly>
</div>
</ReadOnLoad>
<StartVotingButtonContainer>
{startVotingButton}
</StartVotingButtonContainer>
Expand Down
1 change: 1 addition & 0 deletions libs/ui/src/ui_strings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export * from './date_string';
export * from './election_strings';
export * from './language_override';
export * from './number_string';
export * from './read_on_load';
export * from './ui_strings_context';
export * from './utils';
140 changes: 140 additions & 0 deletions libs/ui/src/ui_strings/read_on_load.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { Optional, assert } from '@votingworks/basics';
import { act } from 'react-dom/test-utils';
import React from 'react';
import { createMemoryHistory } from 'history';
import { Route, Router } from 'react-router-dom';
import { ReadOnLoad } from './read_on_load';
import { render, screen } from '../../test/react_testing_library';
import {
UiStringsAudioContextInterface,
UiStringsAudioContextProvider,
useAudioContext,
} from './audio_context';
import {
UiStringsReactQueryApi,
createUiStringsApi,
} from '../hooks/ui_strings_api';

const mockUiStringsApi: UiStringsReactQueryApi = createUiStringsApi(() => ({
getAudioClips: jest.fn(),
getAvailableLanguages: jest.fn(),
getUiStringAudioIds: jest.fn(),
getUiStrings: jest.fn(),
}));

function renderWithClickListener(ui: React.ReactNode) {
const mockOnClick = jest.fn();
const result = render(<div onClickCapture={mockOnClick}>{ui}</div>);

return {
mockOnClick,
result,
};
}

let audioContext: Optional<UiStringsAudioContextInterface>;
function TestContextConsumer() {
audioContext = useAudioContext();

return null;
}

afterEach(() => {
audioContext = undefined;
});

test('is no-op when audio context is absent', () => {
const { mockOnClick } = renderWithClickListener(
<ReadOnLoad>Bonjour!</ReadOnLoad>
);

screen.getByText('Bonjour!');
expect(mockOnClick).not.toHaveBeenCalled();
});

test('is no-op when audio playback is disabled', () => {
const testHistory = createMemoryHistory();

const { mockOnClick } = renderWithClickListener(
<UiStringsAudioContextProvider api={mockUiStringsApi}>
<Router history={testHistory}>
<TestContextConsumer />
<ReadOnLoad>Bonjour!</ReadOnLoad>
</Router>
</UiStringsAudioContextProvider>
);

screen.getByText('Bonjour!');
expect(audioContext?.isEnabled).toEqual(false);
expect(mockOnClick).not.toHaveBeenCalled();

// Should still be a no-op for subsequent URL changes:
act(() => testHistory.push('/new-url'));
expect(mockOnClick).not.toHaveBeenCalled();
});

test('triggers click actions on render', () => {
const { mockOnClick } = renderWithClickListener(
<UiStringsAudioContextProvider api={mockUiStringsApi}>
<TestContextConsumer />
<ReadOnLoad>Bonjour!</ReadOnLoad>
<div>Comment allez-vous?</div>
</UiStringsAudioContextProvider>
);

screen.getByText('Bonjour!');
expect(mockOnClick).not.toHaveBeenCalled();

mockOnClick.mockImplementation((event: MouseEvent) => {
assert(event.target instanceof HTMLElement);
expect(event.target.textContent).toEqual('Bonjour!');
});

act(() => audioContext?.setIsEnabled(true));

expect(mockOnClick).toHaveBeenCalled();
});

test('triggers click event on URL change', () => {
const testHistory = createMemoryHistory();
testHistory.push('/contests/1');

const { mockOnClick } = renderWithClickListener(
<UiStringsAudioContextProvider api={mockUiStringsApi}>
<Router history={testHistory}>
<TestContextConsumer />
<ReadOnLoad>
<div>
<Route path="/contests/1">President</Route>
<Route path="/contests/2">Mayor</Route>
</div>
<div>Vote for 1</div>
</ReadOnLoad>
<div>Candidate 1</div>
</Router>
</UiStringsAudioContextProvider>
);

screen.getByText('President');
expect(mockOnClick).not.toHaveBeenCalled();

mockOnClick.mockImplementation((event: MouseEvent) => {
assert(event.target instanceof HTMLElement);
expect(event.target.textContent).toMatch(/^President.?Vote for 1$/);
});

act(() => audioContext?.setIsEnabled(true));

expect(mockOnClick).toHaveBeenCalled();

mockOnClick.mockReset();
mockOnClick.mockImplementation((event: MouseEvent) => {
assert(event.target instanceof HTMLElement);
expect(event.target.textContent).toMatch(/^Mayor.?Vote for 1$/);
});

act(() => testHistory.push('/contests/2'));

screen.getByText('Mayor');
expect(mockOnClick).toHaveBeenCalled();
});
62 changes: 62 additions & 0 deletions libs/ui/src/ui_strings/read_on_load.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { useAudioContext } from './audio_context';

export interface ReadOnLoadProps {
children: React.ReactNode;
className?: string;
}

/**
* Returns the react-router location state if a react-router context exists,
* without failing if there isn't one available.
*
* Allows us to conditionally use router-aware logic in {@link ReadOnLoad}
* without requiring a react-router context.
*/
function useLocationIfAvailable() {
try {
return useLocation();
} catch {
return undefined;
}
}

/**
* On initial render, this triggers an audio read-out of any descendant
* `UiString` elements rendered within (in order of appearance in the DOM), if
* audio playback is enabled.
*
* Re-triggers a read-out on subsequent route changes, if applicable, or whenever
* audio playback is newly enabled.
*
* NOTE: Intended for use as a single instance on any given app screen. If
* multiple instances are present on screen, audio will be played only for
* content within the last `ReadOnLoad` instance.
*/
export function ReadOnLoad(props: ReadOnLoadProps): JSX.Element {
const { children, className } = props;

const location = useLocationIfAvailable();
const currentUrl = location?.pathname;

const audioContext = useAudioContext();
const isAudioEnabled = audioContext?.isEnabled;

const containerRef = React.useRef<HTMLDivElement>(null);

React.useEffect(() => {
if (!containerRef.current || !isAudioEnabled) {
return;
}

containerRef.current.focus();
containerRef.current.click();
}, [currentUrl, isAudioEnabled]);

return (
<div className={className} ref={containerRef}>
{children}
</div>
);
}

0 comments on commit 9fdd602

Please sign in to comment.