Built-in screen reader #966
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
import React from 'react' | ||
import React, { useState, useCallback } from 'react' | ||
import { BrowserRouter, Route } from 'react-router-dom' | ||
|
||
import 'normalize.css' | ||
|
@@ -7,18 +7,111 @@ import './App.css' | |
import FocusManager from './components/FocusManager' | ||
|
||
import AppRoot from './AppRoot' | ||
import { | ||
ScreenReader, | ||
AriaScreenReader, | ||
SpeechSynthesisTextToSpeech, | ||
NullTextToSpeech, | ||
TextToSpeech, | ||
} from './utils/ScreenReader' | ||
|
||
/* istanbul ignore next - unsure how to test */ | ||
window.oncontextmenu = (e: MouseEvent): void => { | ||
e.preventDefault() | ||
} | ||
|
||
const App = () => ( | ||
<BrowserRouter> | ||
<FocusManager> | ||
<Route path="/" component={AppRoot} /> | ||
</FocusManager> | ||
</BrowserRouter> | ||
) | ||
export interface Props { | ||
tts?: { | ||
enabled: TextToSpeech | ||
disabled: TextToSpeech | ||
} | ||
} | ||
|
||
const App = ({ | ||
tts = { | ||
enabled: new SpeechSynthesisTextToSpeech(), | ||
disabled: new NullTextToSpeech(), | ||
}, | ||
}: Props) => { | ||
const [screenReaderEnabled, setScreenReaderEnabled] = useState(false) | ||
const [screenReader, setScreenReader] = useState<ScreenReader>( | ||
new AriaScreenReader(tts.disabled) | ||
) | ||
|
||
/* istanbul ignore next - need to figure out how to test this */ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm having flashbacks from trying to test the previous keyboard shortcut (⌘-K) which we never figured out how to do before we eventually removed the feature. cc @ben There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One way to do it is to move these out of |
||
const onKeyPress = useCallback( | ||
(event: React.KeyboardEvent) => { | ||
if (event.key === 'r') { | ||
if (screenReaderEnabled) { | ||
screenReader.onScreenReaderDisabled() | ||
setScreenReader(new AriaScreenReader(tts.disabled)) | ||
setScreenReaderEnabled(false) | ||
} else { | ||
const newScreenReader = new AriaScreenReader(tts.enabled) | ||
setScreenReader(newScreenReader) | ||
setScreenReaderEnabled(true) | ||
newScreenReader.onScreenReaderEnabled() | ||
} | ||
} | ||
}, | ||
[ | ||
screenReader, | ||
setScreenReader, | ||
screenReaderEnabled, | ||
setScreenReaderEnabled, | ||
tts.disabled, | ||
tts.enabled, | ||
] | ||
) | ||
|
||
/* istanbul ignore next - need to figure out how to test this */ | ||
const onClick = useCallback( | ||
({ target }: React.MouseEvent) => { | ||
if (target) { | ||
const currentPath = window.location.pathname | ||
|
||
setImmediate(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it safe to use
Perhaps we need to add There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That’s interesting. It does work, so where is it coming from…? |
||
// Only send `onClick` to the screen reader if the click didn't | ||
// trigger navigation. | ||
if (window.location.pathname === currentPath) { | ||
screenReader.onClick(target) | ||
} | ||
}) | ||
} | ||
}, | ||
[screenReader] | ||
) | ||
|
||
/* istanbul ignore next - need to figure out how to test this */ | ||
const onFocus = useCallback( | ||
({ target }: React.FocusEvent) => { | ||
if (target) { | ||
const currentPath = window.location.pathname | ||
|
||
setImmediate(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When referencing window methods we've been explicitly using the window method ( @eventualbuddha - What do you think about this? Perhaps there is an ESLint rule we can add to enforce this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don’t feel strongly about it. There already is an eslint rule that enforces things like |
||
// Only send `onFocus` to the screen reader if the focus didn't | ||
// trigger navigation. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Where does There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just for safety. |
||
if (window.location.pathname === currentPath) { | ||
screenReader.onFocus(target) | ||
} | ||
}) | ||
} | ||
}, | ||
[screenReader] | ||
) | ||
|
||
return ( | ||
<BrowserRouter> | ||
<FocusManager | ||
screenReader={screenReader} | ||
onKeyPress={onKeyPress} | ||
onClickCapture={onClick} | ||
onFocusCapture={onFocus} | ||
> | ||
<Route path="/" component={AppRoot} /> | ||
</FocusManager> | ||
</BrowserRouter> | ||
) | ||
} | ||
|
||
export default App |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -32,15 +32,9 @@ const LinkButton = (props: Props) => { | |
if (onPress) { | ||
onPress(event) | ||
} else if (goBack && !to) { | ||
// Delay to avoid passing tap to next screen | ||
window.setTimeout(() => { | ||
history.goBack() | ||
}, 100) | ||
history.goBack() | ||
} else if (to && !goBack) { | ||
// Delay to avoid passing tap to next screen | ||
window.setTimeout(() => { | ||
history.push(to) | ||
}, 100) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These timeouts did not seem to be required anymore. I tried it both with a mouse and with a touchscreen and I never saw clicks "bleed through". I removed them because they were making the screen reader's job much harder. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Super! There are at least 7 more places we use |
||
history.push(to) | ||
} | ||
} | ||
return ( | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -54,7 +54,8 @@ const Modal: React.FC<Props> = ({ | |
document.getElementById('root')! || document.body.firstElementChild | ||
} | ||
ariaHideApp | ||
role="none" | ||
aria-modal | ||
role="alertdialog" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These seemed to be appropriate attribute values for a modal, though nothing in this PR actually requires them. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If not required or not useful, wouldn't it be better to delete 'em? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I could go either way. It’s not clear to me what the future of our ARIA markup should be. I’m okay removing it if you think we should. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah right… we're not really using ARIA for anyone else other than ourselves. Well, I leave it to you to decide as perhaps you have the most recent experience understanding exactly what each of these does and how we benefit from them. |
||
isOpen={isOpen} | ||
contentLabel={ariaLabel} | ||
portalClassName="modal-portal" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -361,7 +361,7 @@ export default class YesNoContest extends React.Component<Props> { | |
content={ | ||
<Prose> | ||
{overvoteSelection && ( | ||
<p> | ||
<p id="modalaudiofocus"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is carried over from |
||
Do you want to change your vote to{' '} | ||
<strong>{YES_NO_VOTES[overvoteSelection]}</strong>? To change | ||
your vote, first unselect your vote for{' '} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -50,7 +50,6 @@ it('gamepad controls work', async () => { | |
|
||
// Go to First Contest | ||
handleGamepadButtonDown('DPadRight') | ||
fireEvent.click(getByText('Start Voting')) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was no longer needed because I removed the |
||
advanceTimers() | ||
|
||
// First Contest Page | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,7 @@ | ||
import { Button } from 'react-gamepad' | ||
import mod from '../utils/mod' | ||
|
||
export const getActiveElement = () => | ||
document.activeElement! as HTMLInputElement | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nothing was relying on this being an There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, this was related to legacy usage. |
||
export const getActiveElement = () => document.activeElement! as HTMLElement | ||
|
||
function getFocusableElements(): HTMLElement[] { | ||
const tabbableElements = Array.from( | ||
|
@@ -41,15 +40,15 @@ function handleArrowDown() { | |
} | ||
|
||
function handleArrowLeft() { | ||
const prevButton = document.getElementById('previous') as HTMLButtonElement | ||
const prevButton = document.getElementById('previous') | ||
/* istanbul ignore else */ | ||
if (prevButton) { | ||
prevButton.click() | ||
} | ||
} | ||
|
||
function handleArrowRight() { | ||
const nextButton = document.getElementById('next') as HTMLButtonElement | ||
const nextButton = document.getElementById('next') | ||
/* istanbul ignore else */ | ||
if (nextButton) { | ||
nextButton.click() | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
import React, { useContext } from 'react' | ||
import React, { useContext, useRef } from 'react' | ||
import { Redirect, RouteComponentProps } from 'react-router-dom' | ||
import { CandidateVote, OptionalYesNoVote } from '@votingworks/ballot-encoder' | ||
|
||
|
@@ -35,6 +35,7 @@ const ContestPage = (props: RouteComponentProps<ContestParams>) => { | |
votes, | ||
} = useContext(BallotContext) | ||
|
||
const rootRef = useRef<HTMLElement>() | ||
const currentContestIndex = parseInt(contestNumber, 10) | ||
const contest = contests[currentContestIndex] | ||
|
||
|
@@ -58,7 +59,7 @@ const ContestPage = (props: RouteComponentProps<ContestParams>) => { | |
// - confirm intent when navigating away without selecting a candidate | ||
|
||
return ( | ||
<Screen> | ||
<Screen ref={rootRef as any}> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All changes in this file are unnecessary and represent a path I did not end up taking. I was trying to get a reference to the root element of the screen for the purpose of adding/removing event handlers, but I ended up doing that by adding handlers to |
||
{contest.type === 'candidate' && ( | ||
<CandidateContest | ||
aria-live="assertive" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,6 +25,39 @@ window.print = jest.fn(() => { | |
|
||
const printMock = mockOf(window.print) | ||
|
||
function mockSpeechSynthesis() { | ||
const w = window as { | ||
speechSynthesis: typeof speechSynthesis | ||
SpeechSynthesisUtterance: typeof SpeechSynthesisUtterance | ||
SpeechSynthesisEvent: typeof SpeechSynthesisEvent | ||
} | ||
|
||
w.speechSynthesis = makeSpeechSynthesisDouble() | ||
w.SpeechSynthesisUtterance = jest.fn().mockImplementation(text => ({ text })) | ||
w.SpeechSynthesisEvent = jest.fn() | ||
} | ||
|
||
function makeSpeechSynthesisDouble(): typeof speechSynthesis { | ||
return { | ||
addEventListener: jest.fn(), | ||
cancel: jest.fn(), | ||
dispatchEvent: jest.fn(), | ||
getVoices: jest.fn(), | ||
onvoiceschanged: jest.fn(), | ||
pause: jest.fn(), | ||
paused: false, | ||
pending: false, | ||
removeEventListener: jest.fn(), | ||
resume: jest.fn(), | ||
speak: jest.fn(), | ||
speaking: false, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It'd be great if all of this was just part of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's lonely on the bleeding edge of A11y. 🤦🏼♂️ |
||
} | ||
} | ||
|
||
beforeEach(() => { | ||
mockSpeechSynthesis() | ||
}) | ||
|
||
afterEach(() => { | ||
fetchMock.restore() | ||
printMock.mockClear() | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What doe this do for us?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was seeing errors with this in my local environment. Not sure why. It was complaining that we didn’t use
.ts
extensions.