summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-09-17 11:13:31 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-09-18 17:27:57 +0200
commit0e3b356b0ffd2b62b6d7c159f0c30a522276ca27 (patch)
tree73c0248f3295c7d16b9ff2ad8c8fb16110174ab5
parent66859a9f130ade461cdae5051caeffd7a4d5b551 (diff)
downloadmullvadvpn-0e3b356b0ffd2b62b6d7c159f0c30a522276ca27.tar.xz
mullvadvpn-0e3b356b0ffd2b62b6d7c159f0c30a522276ca27.zip
Improve accessibility in login view
-rw-r--r--gui/src/renderer/components/AdvancedSettings.tsx2
-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/ImageView.tsx2
-rw-r--r--gui/src/renderer/components/Login.tsx80
-rw-r--r--gui/src/renderer/components/LoginStyles.tsx29
-rw-r--r--gui/src/renderer/components/Preferences.tsx2
-rw-r--r--gui/src/renderer/components/Selector.tsx2
-rw-r--r--gui/src/shared/localization-contexts.ts1
8 files changed, 117 insertions, 40 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/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 6285022b52..56390e8c7d 100644
--- a/gui/src/renderer/components/Selector.tsx
+++ b/gui/src/renderer/components/Selector.tsx
@@ -1,7 +1,7 @@
import * as React from 'react';
import styled from 'styled-components';
import { colors } from '../../config.json';
-import { AriaInput, AriaLabel } from './AriaInputGroup';
+import { AriaInput, AriaLabel } from './AriaGroup';
import * as Cell from './Cell';
export interface ISelectorItem<T> {
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'