diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2020-09-18 17:37:26 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2020-09-18 17:37:26 +0200 |
| commit | a6a1f7ca37e7ca967b22977da8234d0f392afcfa (patch) | |
| tree | 73c0248f3295c7d16b9ff2ad8c8fb16110174ab5 | |
| parent | 79f91b3a529b39a087e48e0c0052a5bb09610e9c (diff) | |
| parent | 0e3b356b0ffd2b62b6d7c159f0c30a522276ca27 (diff) | |
| download | mullvadvpn-a6a1f7ca37e7ca967b22977da8234d0f392afcfa.tar.xz mullvadvpn-a6a1f7ca37e7ca967b22977da8234d0f392afcfa.zip | |
Merge branch 'improve-accessibility3' into master
| -rw-r--r-- | gui/src/renderer/components/AdvancedSettings.tsx | 2 | ||||
| -rw-r--r-- | gui/src/renderer/components/AriaGroup.tsx (renamed from gui/src/renderer/components/AriaInputGroup.tsx) | 39 | ||||
| -rw-r--r-- | gui/src/renderer/components/Cell.tsx | 17 | ||||
| -rw-r--r-- | gui/src/renderer/components/ImageView.tsx | 2 | ||||
| -rw-r--r-- | gui/src/renderer/components/Login.tsx | 80 | ||||
| -rw-r--r-- | gui/src/renderer/components/LoginStyles.tsx | 29 | ||||
| -rw-r--r-- | gui/src/renderer/components/Preferences.tsx | 2 | ||||
| -rw-r--r-- | gui/src/renderer/components/Selector.tsx | 26 | ||||
| -rw-r--r-- | gui/src/shared/localization-contexts.ts | 1 |
9 files changed, 141 insertions, 57 deletions
diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx index 5baeaced3a..33b8424f80 100644 --- a/gui/src/renderer/components/AdvancedSettings.tsx +++ b/gui/src/renderer/components/AdvancedSettings.tsx @@ -15,7 +15,7 @@ import { StyledTunnelProtocolContainer, } from './AdvancedSettingsStyles'; import * as AppButton from './AppButton'; -import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaInputGroup'; +import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; import * as Cell from './Cell'; import { Layout } from './Layout'; import { ModalAlert, ModalAlertType, ModalContainer, ModalMessage } from './Modal'; diff --git a/gui/src/renderer/components/AriaInputGroup.tsx b/gui/src/renderer/components/AriaGroup.tsx index ae668e1314..19913171ac 100644 --- a/gui/src/renderer/components/AriaInputGroup.tsx +++ b/gui/src/renderer/components/AriaGroup.tsx @@ -5,6 +5,29 @@ function getNewId() { return groupCounter++; } +interface IAriaControlContext { + controlledId: string; +} + +const AriaControlContext = React.createContext<IAriaControlContext>({ + get controlledId(): string { + throw new Error('Missing AriaControlContext.Provider'); + }, +}); + +interface IAriaGroupProps { + children: React.ReactNode; +} + +export function AriaControlGroup(props: IAriaGroupProps) { + const id = useMemo(getNewId, []); + const contextValue = useMemo(() => ({ controlledId: `${id}-controlled` }), []); + + return ( + <AriaControlContext.Provider value={contextValue}>{props.children}</AriaControlContext.Provider> + ); +} + interface IAriaInputContext { inputId: string; labelId?: string; @@ -26,11 +49,7 @@ const AriaInputContext = React.createContext<IAriaInputContext>({ }, }); -interface IAriaInputGroupProps { - children: React.ReactNode; -} - -export function AriaInputGroup(props: IAriaInputGroupProps) { +export function AriaInputGroup(props: IAriaGroupProps) { const id = useMemo(getNewId, []); const [hasLabel, setHasLabel] = useState(false); @@ -53,6 +72,16 @@ interface IAriaElementProps { children: React.ReactElement; } +export function AriaControlled(props: IAriaElementProps) { + const { controlledId } = useContext(AriaControlContext); + return React.cloneElement(props.children, { id: controlledId }); +} + +export function AriaControls(props: IAriaElementProps) { + const { controlledId } = useContext(AriaControlContext); + return React.cloneElement(props.children, { 'aria-controls': controlledId }); +} + export function AriaInput(props: IAriaElementProps) { const { inputId, labelId, descriptionId } = useContext(AriaInputContext); diff --git a/gui/src/renderer/components/Cell.tsx b/gui/src/renderer/components/Cell.tsx index 80c48fbd0f..1848426e91 100644 --- a/gui/src/renderer/components/Cell.tsx +++ b/gui/src/renderer/components/Cell.tsx @@ -54,15 +54,11 @@ export const CellButton = React.forwardRef(function Button( ); }); -interface ISectionProps { - children?: React.ReactNode; - className?: string; -} - -export function Section(props: ISectionProps) { +export function Section(props: React.HTMLAttributes<HTMLDivElement>) { + const { children, ...otherProps } = props; return ( - <StyledSection className={props.className}> - <CellSectionContext.Provider value={true}>{props.children}</CellSectionContext.Provider> + <StyledSection {...otherProps}> + <CellSectionContext.Provider value={true}>{children}</CellSectionContext.Provider> </StyledSection> ); } @@ -158,12 +154,15 @@ export class Input extends React.Component<IInputProps, IInputState> { ...otherProps } = this.props; + const valid = validateValue?.(this.state.value); + return ( <CellDisabledContext.Consumer> {(disabled) => ( <StyledInput type="text" - valid={validateValue?.(this.state.value)} + valid={valid} + aria-invalid={!valid} onChange={this.onChange} onFocus={this.onFocus} onBlur={this.onBlur} diff --git a/gui/src/renderer/components/ImageView.tsx b/gui/src/renderer/components/ImageView.tsx index 0414f8f1ff..c8fb3da025 100644 --- a/gui/src/renderer/components/ImageView.tsx +++ b/gui/src/renderer/components/ImageView.tsx @@ -7,7 +7,7 @@ export interface IImageViewProps extends IImageMaskProps { className?: string; } -interface IImageMaskProps { +interface IImageMaskProps extends React.HTMLAttributes<HTMLElement> { source: string; width?: number; height?: number; diff --git a/gui/src/renderer/components/Login.tsx b/gui/src/renderer/components/Login.tsx index cf2a52dc58..8c1659dc5e 100644 --- a/gui/src/renderer/components/Login.tsx +++ b/gui/src/renderer/components/Login.tsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react'; +import { sprintf } from 'sprintf-js'; import { colors } from '../../config.json'; import consumePromise from '../../shared/promise'; import { messages } from '../../shared/gettext'; @@ -9,8 +10,11 @@ import { Brand, HeaderBarSettingsButton } from './HeaderBar'; import ImageView from './ImageView'; import { Container, Header, Layout } from './Layout'; import { + StyledAccountDropdownContainer, + StyledAccountDropdownItem, StyledAccountDropdownItemButton, StyledAccountDropdownItemButtonLabel, + StyledAccountDropdownRemoveButton, StyledAccountDropdownRemoveIcon, StyledAccountInputBackdrop, StyledAccountInputGroup, @@ -28,6 +32,7 @@ import { import { AccountToken } from '../../shared/daemon-rpc-types'; import { LoginState } from '../redux/account/reducers'; +import { AriaControlGroup, AriaControlled, AriaControls } from './AriaGroup'; interface IProps { accountToken?: AccountToken; @@ -88,7 +93,7 @@ export default class Login extends React.Component<IProps, IState> { <Container> <StyledLoginForm> {this.getStatusIcon()} - <StyledTitle>{this.formTitle()}</StyledTitle> + <StyledTitle aria-live="polite">{this.formTitle()}</StyledTitle> {this.createLoginForm()} </StyledLoginForm> @@ -115,7 +120,9 @@ export default class Login extends React.Component<IProps, IState> { this.setState({ isActive: false }); }; - private onSubmit = () => { + private onSubmit = (event?: React.FormEvent) => { + event?.preventDefault(); + if (this.accountTokenValid()) { this.props.login(this.props.accountToken!); } @@ -132,12 +139,6 @@ export default class Login extends React.Component<IProps, IState> { this.props.updateAccountToken(accountToken); }; - private onKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => { - if (event.key === 'Enter') { - this.onSubmit(); - } - }; - private formTitle() { switch (this.props.loginState.type) { case 'logging in': @@ -239,6 +240,7 @@ export default class Login extends React.Component<IProps, IState> { private createLoginForm() { const allowInteraction = this.allowInteraction(); + const allowLogin = allowInteraction && this.accountTokenValid(); const hasError = this.props.loginState.type === 'failed' && this.props.loginState.method === 'existing_account'; @@ -249,7 +251,8 @@ export default class Login extends React.Component<IProps, IState> { <StyledAccountInputGroup active={allowInteraction && this.state.isActive} editable={allowInteraction} - error={hasError}> + error={hasError} + onSubmit={this.onSubmit}> <StyledAccountInputBackdrop> <StyledInput placeholder="0000 0000 0000 0000" @@ -258,12 +261,18 @@ export default class Login extends React.Component<IProps, IState> { onFocus={this.onFocus} onBlur={this.onBlur} onChange={this.onInputChange} - onKeyPress={this.onKeyPress} autoFocus={true} ref={this.accountInput} + aria-autocomplete="list" /> <StyledInputButton - visible={this.allowInteraction() && this.accountTokenValid()} + type="submit" + visible={allowLogin} + disabled={!allowLogin} + aria-label={ + // TRANSLATORS: This is used by screenreaders to communicate the login button. + messages.pgettext('accessibility', 'Login') + } onClick={this.onSubmit}> <StyledInputSubmitIcon visible={this.props.loginState.type !== 'logging in'} @@ -275,13 +284,13 @@ export default class Login extends React.Component<IProps, IState> { </StyledInputButton> </StyledAccountInputBackdrop> <Accordion expanded={this.shouldShowAccountHistory()}> - { + <StyledAccountDropdownContainer> <AccountDropdown items={this.props.accountHistory.slice().reverse()} onSelect={this.onSelectAccountFromHistory} onRemove={this.onRemoveAccountFromHistory} /> - } + </StyledAccountDropdownContainer> </Accordion> </StyledAccountInputGroup> </> @@ -349,19 +358,38 @@ function AccountDropdownItem(props: IAccountDropdownItemProps) { return ( <> <StyledDropdownSpacer /> - <StyledAccountDropdownItemButton> - <StyledAccountDropdownItemButtonLabel onClick={handleSelect}> - {props.label} - </StyledAccountDropdownItemButtonLabel> - <StyledAccountDropdownRemoveIcon - tintColor={colors.blue40} - tintHoverColor={colors.blue} - source="icon-close-sml" - height={16} - width={16} - onClick={handleRemove} - /> - </StyledAccountDropdownItemButton> + <StyledAccountDropdownItem> + <AriaControlGroup> + <AriaControlled> + <StyledAccountDropdownItemButton id={props.label} onClick={handleSelect}> + <StyledAccountDropdownItemButtonLabel> + {props.label} + </StyledAccountDropdownItemButtonLabel> + </StyledAccountDropdownItemButton> + </AriaControlled> + <AriaControls> + <StyledAccountDropdownRemoveButton + onClick={handleRemove} + aria-controls={props.label} + aria-label={ + // TRANSLATORS: This is used by screenreaders to communicate the "x" button next to a saved account number. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(accountToken)s - the account token to the left of the button + sprintf(messages.pgettext('accessibility', 'Forget %(accountToken)s'), { + accountToken: props.label, + }) + }> + <StyledAccountDropdownRemoveIcon + tintColor={colors.blue40} + tintHoverColor={colors.blue} + source="icon-close-sml" + height={16} + width={16} + /> + </StyledAccountDropdownRemoveButton> + </AriaControls> + </AriaControlGroup> + </StyledAccountDropdownItem> </> ); } diff --git a/gui/src/renderer/components/LoginStyles.tsx b/gui/src/renderer/components/LoginStyles.tsx index 65d6266ceb..aaa7fd2874 100644 --- a/gui/src/renderer/components/LoginStyles.tsx +++ b/gui/src/renderer/components/LoginStyles.tsx @@ -4,6 +4,16 @@ import ImageView from './ImageView'; import * as Cell from './Cell'; import { bigText, smallText } from './common-styles'; +export const StyledAccountDropdownContainer = styled.ul({ + display: 'flex', + flexDirection: 'column', +}); + +export const StyledAccountDropdownRemoveButton = styled.button({ + border: 'none', + background: 'none', +}); + export const StyledAccountDropdownRemoveIcon = styled(ImageView)({ justifyContent: 'center', paddingTop: '10px', @@ -22,15 +32,24 @@ export const StyledInputSubmitIcon = styled(ImageView)((props: { visible: boolea opacity: props.visible ? 1 : 0, })); +export const StyledAccountDropdownItem = styled.li({ + display: 'flex', + flex: 1, + backgroundColor: colors.white60, + cursor: 'default', + ':hover': { + backgroundColor: colors.white40, + }, +}); + export const StyledAccountDropdownItemButton = styled(Cell.CellButton)({ padding: '0px', marginBottom: '0px', flexDirection: 'row', alignItems: 'stretch', - backgroundColor: colors.white60, - cursor: 'default', + backgroundColor: 'transparent', ':not(:disabled):hover': { - backgroundColor: colors.white40, + backgroundColor: 'transparent', }, }); @@ -83,7 +102,7 @@ interface IStyledAccountInputGroupProps { error: boolean; } -export const StyledAccountInputGroup = styled.div((props: IStyledAccountInputGroupProps) => ({ +export const StyledAccountInputGroup = styled.form((props: IStyledAccountInputGroupProps) => ({ borderWidth: '2px', borderStyle: 'solid', borderRadius: '8px', @@ -124,7 +143,7 @@ export const StyledLoginFooterPrompt = styled.span({ marginBottom: '8px', }); -export const StyledTitle = styled.span(bigText, { +export const StyledTitle = styled.h1(bigText, { lineHeight: '40px', marginBottom: '7px', flex: 0, diff --git a/gui/src/renderer/components/Preferences.tsx b/gui/src/renderer/components/Preferences.tsx index 79bd94706c..f3c35f90d0 100644 --- a/gui/src/renderer/components/Preferences.tsx +++ b/gui/src/renderer/components/Preferences.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { messages } from '../../shared/gettext'; -import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaInputGroup'; +import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; import * as Cell from './Cell'; import { Layout } from './Layout'; import { diff --git a/gui/src/renderer/components/Selector.tsx b/gui/src/renderer/components/Selector.tsx index 155509e7fa..56390e8c7d 100644 --- a/gui/src/renderer/components/Selector.tsx +++ b/gui/src/renderer/components/Selector.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import styled from 'styled-components'; import { colors } from '../../config.json'; +import { AriaInput, AriaLabel } from './AriaGroup'; import * as Cell from './Cell'; export interface ISelectorItem<T> { @@ -40,16 +41,20 @@ export default class Selector<T> extends React.Component<ISelectorProps<T>> { ); }); - if (this.props.title) { - return ( - <Section className={this.props.className}> - <Cell.SectionTitle>{this.props.title}</Cell.SectionTitle> + const title = this.props.title && ( + <AriaLabel> + <Cell.SectionTitle as="label">{this.props.title}</Cell.SectionTitle> + </AriaLabel> + ); + + return ( + <AriaInput> + <Section role="listbox" className={this.props.className}> + {title} {items} </Section> - ); - } else { - return <Section className={this.props.className}>{items}</Section>; - } + </AriaInput> + ); } } @@ -78,7 +83,10 @@ export class SelectorCell<T> extends React.Component<ISelectorCellProps<T>> { <Cell.CellButton onClick={this.onClick} selected={this.props.selected} - disabled={this.props.disabled}> + disabled={this.props.disabled} + role="option" + aria-selected={this.props.selected} + aria-disabled={this.props.disabled}> <StyledCellIcon visible={this.props.selected} source="icon-tick" diff --git a/gui/src/shared/localization-contexts.ts b/gui/src/shared/localization-contexts.ts index 32d591b7a4..6d86920870 100644 --- a/gui/src/shared/localization-contexts.ts +++ b/gui/src/shared/localization-contexts.ts @@ -1,5 +1,6 @@ export type LocalizationContexts = | 'generic' + | 'accessibility' | 'login-view' | 'auth-failure' | 'launch-view' |
