summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-09-17 17:19:15 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-09-17 17:19:15 +0200
commit0e24a2d3ffd3c8530f6ae6449c6e08f12dbf5908 (patch)
tree30badb4885d0419f1d6d4ce41bfe02e3d2a20a45
parent002fe4fc85eb2d4715f30a6cb897b70e0bc559bb (diff)
parentf71596b39677a204e8c48308b88be45a68c0289b (diff)
downloadmullvadvpn-0e24a2d3ffd3c8530f6ae6449c6e08f12dbf5908.tar.xz
mullvadvpn-0e24a2d3ffd3c8530f6ae6449c6e08f12dbf5908.zip
Merge branch 'improve-accessibility2' into master
-rw-r--r--gui/src/renderer/components/AdvancedSettings.tsx370
-rw-r--r--gui/src/renderer/components/AriaInputGroup.tsx91
-rw-r--r--gui/src/renderer/components/Cell.tsx7
-rw-r--r--gui/src/renderer/components/ExpiredAccountErrorView.tsx3
-rw-r--r--gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx1
-rw-r--r--gui/src/renderer/components/Modal.tsx66
-rw-r--r--gui/src/renderer/components/Preferences.tsx258
-rw-r--r--gui/src/renderer/components/RedeemVoucher.tsx3
-rw-r--r--gui/src/renderer/components/Support.tsx2
-rw-r--r--gui/src/renderer/components/Switch.tsx19
10 files changed, 534 insertions, 286 deletions
diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx
index 40131d58ff..5baeaced3a 100644
--- a/gui/src/renderer/components/AdvancedSettings.tsx
+++ b/gui/src/renderer/components/AdvancedSettings.tsx
@@ -15,6 +15,7 @@ import {
StyledTunnelProtocolContainer,
} from './AdvancedSettingsStyles';
import * as AppButton from './AppButton';
+import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaInputGroup';
import * as Cell from './Cell';
import { Layout } from './Layout';
import { ModalAlert, ModalAlertType, ModalContainer, ModalMessage } from './Modal';
@@ -169,182 +170,230 @@ export default class AdvancedSettings extends React.Component<IProps, IState> {
</HeaderTitle>
</SettingsHeader>
- <Cell.Container>
- <Cell.Label>
- {messages.pgettext('advanced-settings-view', 'Enable IPv6')}
- </Cell.Label>
- <Cell.Switch isOn={this.props.enableIpv6} onChange={this.props.setEnableIpv6} />
- </Cell.Container>
- <Cell.Footer>
- <Cell.FooterText>
- {messages.pgettext(
- 'advanced-settings-view',
- 'Enable IPv6 communication through the tunnel.',
- )}
- </Cell.FooterText>
- </Cell.Footer>
-
- <Cell.Container>
- <Cell.Label>
- {messages.pgettext('advanced-settings-view', 'Always require VPN')}
- </Cell.Label>
- <Cell.Switch
- isOn={this.props.blockWhenDisconnected}
- onChange={this.setBlockWhenDisconnected}
- />
- </Cell.Container>
- <Cell.Footer>
- <Cell.FooterText>
- {messages.pgettext(
- 'advanced-settings-view',
- 'If you disconnect or quit the app, this setting will block your internet.',
- )}
- </Cell.FooterText>
- </Cell.Footer>
+ <AriaInputGroup>
+ <Cell.Container>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {messages.pgettext('advanced-settings-view', 'Enable IPv6')}
+ </Cell.InputLabel>
+ </AriaLabel>
+ <AriaInput>
+ <Cell.Switch
+ isOn={this.props.enableIpv6}
+ onChange={this.props.setEnableIpv6}
+ />
+ </AriaInput>
+ </Cell.Container>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
+ {messages.pgettext(
+ 'advanced-settings-view',
+ 'Enable IPv6 communication through the tunnel.',
+ )}
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
- <StyledTunnelProtocolContainer>
- <StyledTunnelProtocolSelector
- title={messages.pgettext('advanced-settings-view', 'Tunnel protocol')}
- values={this.tunnelProtocolItems(hasWireguardKey)}
- value={this.props.tunnelProtocol}
- onSelect={this.onSelectTunnelProtocol}
- />
- {!hasWireguardKey && (
- <StyledNoWireguardKeyErrorContainer>
- <StyledNoWireguardKeyError>
+ <AriaInputGroup>
+ <Cell.Container>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {messages.pgettext('advanced-settings-view', 'Always require VPN')}
+ </Cell.InputLabel>
+ </AriaLabel>
+ <AriaInput>
+ <Cell.Switch
+ isOn={this.props.blockWhenDisconnected}
+ onChange={this.setBlockWhenDisconnected}
+ />
+ </AriaInput>
+ </Cell.Container>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
{messages.pgettext(
'advanced-settings-view',
- 'To enable WireGuard, generate a key under the "WireGuard key" setting below.',
+ 'If you disconnect or quit the app, this setting will block your internet.',
)}
- </StyledNoWireguardKeyError>
- </StyledNoWireguardKeyErrorContainer>
- )}
- </StyledTunnelProtocolContainer>
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
- {this.props.tunnelProtocol !== 'wireguard' ? (
- <StyledSelectorContainer>
- <Selector
- title={messages.pgettext(
- 'advanced-settings-view',
- 'OpenVPN transport protocol',
- )}
- values={this.protocolItems}
- value={this.props.openvpn.protocol}
- onSelect={this.onSelectOpenvpnProtocol}
+ <AriaInputGroup>
+ <StyledTunnelProtocolContainer>
+ <StyledTunnelProtocolSelector
+ title={messages.pgettext('advanced-settings-view', 'Tunnel protocol')}
+ values={this.tunnelProtocolItems(hasWireguardKey)}
+ value={this.props.tunnelProtocol}
+ onSelect={this.onSelectTunnelProtocol}
/>
+ {!hasWireguardKey && (
+ <StyledNoWireguardKeyErrorContainer>
+ <AriaDescription>
+ <StyledNoWireguardKeyError>
+ {messages.pgettext(
+ 'advanced-settings-view',
+ 'To enable WireGuard, generate a key under the "WireGuard key" setting below.',
+ )}
+ </StyledNoWireguardKeyError>
+ </AriaDescription>
+ </StyledNoWireguardKeyErrorContainer>
+ )}
+ </StyledTunnelProtocolContainer>
+ </AriaInputGroup>
- {this.props.openvpn.protocol ? (
+ {this.props.tunnelProtocol !== 'wireguard' ? (
+ <AriaInputGroup>
+ <StyledSelectorContainer>
<Selector
- title={sprintf(
- // TRANSLATORS: The title for the port selector section.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(portType)s - a selected protocol (either TCP or UDP)
- messages.pgettext('advanced-settings-view', 'OpenVPN %(portType)s port'),
- {
- portType: this.props.openvpn.protocol.toUpperCase(),
- },
+ title={messages.pgettext(
+ 'advanced-settings-view',
+ 'OpenVPN transport protocol',
)}
- values={this.portItems[this.props.openvpn.protocol]}
- value={this.props.openvpn.port}
- onSelect={this.onSelectOpenVpnPort}
+ values={this.protocolItems}
+ value={this.props.openvpn.protocol}
+ onSelect={this.onSelectOpenvpnProtocol}
/>
- ) : undefined}
- </StyledSelectorContainer>
+
+ {this.props.openvpn.protocol ? (
+ <Selector
+ title={sprintf(
+ // TRANSLATORS: The title for the port selector section.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(portType)s - a selected protocol (either TCP or UDP)
+ messages.pgettext(
+ 'advanced-settings-view',
+ 'OpenVPN %(portType)s port',
+ ),
+ {
+ portType: this.props.openvpn.protocol.toUpperCase(),
+ },
+ )}
+ values={this.portItems[this.props.openvpn.protocol]}
+ value={this.props.openvpn.port}
+ onSelect={this.onSelectOpenVpnPort}
+ />
+ ) : undefined}
+ </StyledSelectorContainer>
+ </AriaInputGroup>
) : undefined}
{this.props.tunnelProtocol === 'wireguard' ? (
- <StyledSelectorContainer>
- <Selector
- // TRANSLATORS: The title for the shadowsocks bridge selector section.
- title={messages.pgettext('advanced-settings-view', 'WireGuard port')}
- values={this.wireguardPortItems}
- value={this.props.wireguard.port}
- onSelect={this.onSelectWireguardPort}
- />
- </StyledSelectorContainer>
+ <AriaInputGroup>
+ <StyledSelectorContainer>
+ <Selector
+ // TRANSLATORS: The title for the shadowsocks bridge selector section.
+ title={messages.pgettext('advanced-settings-view', 'WireGuard port')}
+ values={this.wireguardPortItems}
+ value={this.props.wireguard.port}
+ onSelect={this.onSelectWireguardPort}
+ />
+ </StyledSelectorContainer>
+ </AriaInputGroup>
) : undefined}
- <Selector
- title={
- // TRANSLATORS: The title for the shadowsocks bridge selector section.
- messages.pgettext('advanced-settings-view', 'Bridge mode')
- }
- values={this.bridgeStateItems}
- value={this.props.bridgeState}
- onSelect={this.onSelectBridgeState}
- />
+ <AriaInputGroup>
+ <Selector
+ title={
+ // TRANSLATORS: The title for the shadowsocks bridge selector section.
+ messages.pgettext('advanced-settings-view', 'Bridge mode')
+ }
+ values={this.bridgeStateItems}
+ value={this.props.bridgeState}
+ onSelect={this.onSelectBridgeState}
+ />
+ </AriaInputGroup>
- <Cell.Container>
- <Cell.Label>
- {messages.pgettext('advanced-settings-view', 'OpenVPN Mssfix')}
- </Cell.Label>
- <StyledInputFrame>
- <Cell.AutoSizingTextInput
- value={this.props.mssfix ? this.props.mssfix.toString() : ''}
- inputMode={'numeric'}
- maxLength={4}
- placeholder={messages.pgettext('advanced-settings-view', 'Default')}
- onSubmitValue={this.onMssfixSubmit}
- validateValue={AdvancedSettings.mssfixIsValid}
- submitOnBlur={true}
- modifyValue={AdvancedSettings.removeNonNumericCharacters}
- />
- </StyledInputFrame>
- </Cell.Container>
- <Cell.Footer>
- <Cell.FooterText>
- {sprintf(
- // TRANSLATORS: The hint displayed below the Mssfix input field.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(max)d - the maximum possible mssfix value
- // TRANSLATORS: %(min)d - the minimum possible mssfix value
- messages.pgettext(
- 'advanced-settings-view',
- 'Set OpenVPN MSS value. Valid range: %(min)d - %(max)d.',
- ),
- {
- min: MIN_MSSFIX_VALUE,
- max: MAX_MSSFIX_VALUE,
- },
- )}
- </Cell.FooterText>
- </Cell.Footer>
+ <AriaInputGroup>
+ <Cell.Container>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {messages.pgettext('advanced-settings-view', 'OpenVPN Mssfix')}
+ </Cell.InputLabel>
+ </AriaLabel>
+ <StyledInputFrame>
+ <AriaInput>
+ <Cell.AutoSizingTextInput
+ value={this.props.mssfix ? this.props.mssfix.toString() : ''}
+ inputMode={'numeric'}
+ maxLength={4}
+ placeholder={messages.pgettext('advanced-settings-view', 'Default')}
+ onSubmitValue={this.onMssfixSubmit}
+ validateValue={AdvancedSettings.mssfixIsValid}
+ submitOnBlur={true}
+ modifyValue={AdvancedSettings.removeNonNumericCharacters}
+ />
+ </AriaInput>
+ </StyledInputFrame>
+ </Cell.Container>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
+ {sprintf(
+ // TRANSLATORS: The hint displayed below the Mssfix input field.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(max)d - the maximum possible mssfix value
+ // TRANSLATORS: %(min)d - the minimum possible mssfix value
+ messages.pgettext(
+ 'advanced-settings-view',
+ 'Set OpenVPN MSS value. Valid range: %(min)d - %(max)d.',
+ ),
+ {
+ min: MIN_MSSFIX_VALUE,
+ max: MAX_MSSFIX_VALUE,
+ },
+ )}
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
- <Cell.Container>
- <Cell.Label>
- {messages.pgettext('advanced-settings-view', 'WireGuard MTU')}
- </Cell.Label>
- <StyledInputFrame>
- <Cell.AutoSizingTextInput
- value={this.props.wireguardMtu ? this.props.wireguardMtu.toString() : ''}
- inputMode={'numeric'}
- maxLength={4}
- placeholder={messages.pgettext('advanced-settings-view', 'Default')}
- onSubmitValue={this.onWireguardMtuSubmit}
- validateValue={AdvancedSettings.wireguarMtuIsValid}
- submitOnBlur={true}
- modifyValue={AdvancedSettings.removeNonNumericCharacters}
- />
- </StyledInputFrame>
- </Cell.Container>
- <Cell.Footer>
- <Cell.FooterText>
- {sprintf(
- // TRANSLATORS: The hint displayed below the WireGuard MTU input field.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(max)d - the maximum possible wireguard mtu value
- // TRANSLATORS: %(min)d - the minimum possible wireguard mtu value
- messages.pgettext(
- 'advanced-settings-view',
- 'Set WireGuard MTU value. Valid range: %(min)d - %(max)d.',
- ),
- {
- min: MIN_WIREGUARD_MTU_VALUE,
- max: MAX_WIREGUARD_MTU_VALUE,
- },
- )}
- </Cell.FooterText>
- </Cell.Footer>
+ <AriaInputGroup>
+ <Cell.Container>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {messages.pgettext('advanced-settings-view', 'WireGuard MTU')}
+ </Cell.InputLabel>
+ </AriaLabel>
+ <StyledInputFrame>
+ <AriaInput>
+ <Cell.AutoSizingTextInput
+ value={this.props.wireguardMtu ? this.props.wireguardMtu.toString() : ''}
+ inputMode={'numeric'}
+ maxLength={4}
+ placeholder={messages.pgettext('advanced-settings-view', 'Default')}
+ onSubmitValue={this.onWireguardMtuSubmit}
+ validateValue={AdvancedSettings.wireguarMtuIsValid}
+ submitOnBlur={true}
+ modifyValue={AdvancedSettings.removeNonNumericCharacters}
+ />
+ </AriaInput>
+ </StyledInputFrame>
+ </Cell.Container>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
+ {sprintf(
+ // TRANSLATORS: The hint displayed below the WireGuard MTU input field.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(max)d - the maximum possible wireguard mtu value
+ // TRANSLATORS: %(min)d - the minimum possible wireguard mtu value
+ messages.pgettext(
+ 'advanced-settings-view',
+ 'Set WireGuard MTU value. Valid range: %(min)d - %(max)d.',
+ ),
+ {
+ min: MIN_WIREGUARD_MTU_VALUE,
+ max: MAX_WIREGUARD_MTU_VALUE,
+ },
+ )}
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
<StyledBottomCellGroup>
<Cell.CellButton onClick={this.props.onViewWireguardKeys}>
@@ -410,7 +459,8 @@ export default class AdvancedSettings extends React.Component<IProps, IState> {
<AppButton.BlueButton key="back" onClick={this.hideConfirmBlockWhenDisconnectedAlert}>
{messages.gettext('Back')}
</AppButton.BlueButton>,
- ]}>
+ ]}
+ close={this.hideConfirmBlockWhenDisconnectedAlert}>
<ModalMessage>
{messages.pgettext(
'advanced-settings-view',
diff --git a/gui/src/renderer/components/AriaInputGroup.tsx b/gui/src/renderer/components/AriaInputGroup.tsx
new file mode 100644
index 0000000000..ae668e1314
--- /dev/null
+++ b/gui/src/renderer/components/AriaInputGroup.tsx
@@ -0,0 +1,91 @@
+import React, { useContext, useEffect, useMemo, useState } from 'react';
+
+let groupCounter = 0;
+function getNewId() {
+ return groupCounter++;
+}
+
+interface IAriaInputContext {
+ inputId: string;
+ labelId?: string;
+ descriptionId?: string;
+ setHasLabel: (value: boolean) => void;
+ setHasDescription: (value: boolean) => void;
+}
+
+const missingAriaInputContextError = new Error('Missing AriaInputContext.Provider');
+const AriaInputContext = React.createContext<IAriaInputContext>({
+ get inputId(): string {
+ throw missingAriaInputContextError;
+ },
+ setHasLabel() {
+ throw missingAriaInputContextError;
+ },
+ setHasDescription() {
+ throw missingAriaInputContextError;
+ },
+});
+
+interface IAriaInputGroupProps {
+ children: React.ReactNode;
+}
+
+export function AriaInputGroup(props: IAriaInputGroupProps) {
+ const id = useMemo(getNewId, []);
+
+ const [hasLabel, setHasLabel] = useState(false);
+ const [hasDescription, setHasDescription] = useState(false);
+
+ const contextValue = {
+ inputId: `${id}-input`,
+ labelId: hasLabel ? `${id}-label` : undefined,
+ descriptionId: hasDescription ? `${id}-description` : undefined,
+ setHasLabel,
+ setHasDescription,
+ };
+
+ return (
+ <AriaInputContext.Provider value={contextValue}>{props.children}</AriaInputContext.Provider>
+ );
+}
+
+interface IAriaElementProps {
+ children: React.ReactElement;
+}
+
+export function AriaInput(props: IAriaElementProps) {
+ const { inputId, labelId, descriptionId } = useContext(AriaInputContext);
+
+ return React.cloneElement(props.children, {
+ id: inputId,
+ 'aria-labelledby': labelId,
+ 'aria-describedby': descriptionId,
+ });
+}
+
+export function AriaLabel(props: IAriaElementProps) {
+ const { inputId, labelId, setHasLabel } = useContext(AriaInputContext);
+
+ useEffect(() => {
+ setHasLabel(true);
+ return () => setHasLabel(false);
+ }, []);
+
+ return React.cloneElement(props.children, {
+ id: labelId,
+ htmlFor: inputId,
+ });
+}
+
+export function AriaDescription(props: IAriaElementProps) {
+ const { descriptionId, setHasDescription } = useContext(AriaInputContext);
+
+ useEffect(() => {
+ setHasDescription(true);
+ return () => setHasDescription(false);
+ }, []);
+
+ return React.cloneElement(props.children, {
+ id: descriptionId,
+ });
+}
diff --git a/gui/src/renderer/components/Cell.tsx b/gui/src/renderer/components/Cell.tsx
index 8aa4ddde78..80c48fbd0f 100644
--- a/gui/src/renderer/components/Cell.tsx
+++ b/gui/src/renderer/components/Cell.tsx
@@ -72,6 +72,11 @@ export function Label(props: React.HTMLAttributes<HTMLDivElement>) {
return <StyledLabel disabled={disabled} {...props} />;
}
+export function InputLabel(props: React.LabelHTMLAttributes<HTMLLabelElement>) {
+ const disabled = useContext(CellDisabledContext);
+ return <StyledLabel as="label" disabled={disabled} {...props} />;
+}
+
export function SubText(props: React.HTMLAttributes<HTMLDivElement>) {
const disabled = useContext(CellDisabledContext);
return <StyledSubText disabled={disabled} {...props} />;
@@ -216,7 +221,7 @@ export function AutoSizingTextInput({ onChangeValue, ...otherProps }: IInputProp
<StyledAutoSizingTextInputWrapper>
<Input onChangeValue={onChangeValueWrapper} {...otherProps} />
</StyledAutoSizingTextInputWrapper>
- <StyledAutoSizingTextInputFiller className={otherProps.className}>
+ <StyledAutoSizingTextInputFiller className={otherProps.className} aria-hidden={true}>
{value === '' ? otherProps.placeholder : value}
</StyledAutoSizingTextInputFiller>
</StyledAutoSizingTextInputContainer>
diff --git a/gui/src/renderer/components/ExpiredAccountErrorView.tsx b/gui/src/renderer/components/ExpiredAccountErrorView.tsx
index 95177765a5..d64c780808 100644
--- a/gui/src/renderer/components/ExpiredAccountErrorView.tsx
+++ b/gui/src/renderer/components/ExpiredAccountErrorView.tsx
@@ -194,7 +194,8 @@ export default class ExpiredAccountErrorView extends React.Component<
onClick={this.onCloseBlockWhenDisconnectedInstructions}>
{messages.gettext('Close')}
</AppButton.BlueButton>,
- ]}>
+ ]}
+ close={this.onCloseBlockWhenDisconnectedInstructions}>
<ModalMessage>
{messages.pgettext(
'connect-view',
diff --git a/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx b/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx
index 914f5d818f..c5b5521f05 100644
--- a/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx
+++ b/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx
@@ -274,6 +274,7 @@ function ApplicationRow(props: IApplicationRowProps) {
iconColor={warningColor}
message={warningMessage}
buttons={warningDialogButtons}
+ close={hideWarningDialog}
/>
)}
</>
diff --git a/gui/src/renderer/components/Modal.tsx b/gui/src/renderer/components/Modal.tsx
index 3db4dcd626..b045803626 100644
--- a/gui/src/renderer/components/Modal.tsx
+++ b/gui/src/renderer/components/Modal.tsx
@@ -1,12 +1,10 @@
-import * as React from 'react';
+import React, { useContext, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import styled from 'styled-components';
import { colors } from '../../config.json';
import { Scheduler } from '../../shared/scheduler';
import ImageView from './ImageView';
-const MODAL_CONTAINER_ID = 'modalContainer';
-
const ModalContent = styled.div({
position: 'absolute',
display: 'flex',
@@ -39,11 +37,35 @@ interface IModalContainerProps {
children?: React.ReactNode;
}
+interface IModalContext {
+ activeModal: boolean;
+ setActiveModal: (value: boolean) => void;
+ modalContainerRef: React.RefObject<HTMLDivElement>;
+}
+
+const noActiveModalContextError = new Error('ActiveModalContext.Provider missing');
+const ActiveModalContext = React.createContext<IModalContext>({
+ get activeModal(): boolean {
+ throw noActiveModalContextError;
+ },
+ setActiveModal(_value) {
+ throw noActiveModalContextError;
+ },
+ get modalContainerRef(): React.RefObject<HTMLDivElement> {
+ throw noActiveModalContextError;
+ },
+});
+
export function ModalContainer(props: IModalContainerProps) {
+ const [activeModal, setActiveModal] = useState(false);
+ const modalContainerRef = useRef() as React.RefObject<HTMLDivElement>;
+
return (
- <StyledModalContainer id={MODAL_CONTAINER_ID}>
- <ModalContent>{props.children}</ModalContent>
- </StyledModalContainer>
+ <ActiveModalContext.Provider value={{ activeModal, setActiveModal, modalContainerRef }}>
+ <StyledModalContainer ref={modalContainerRef}>
+ <ModalContent aria-hidden={activeModal}>{props.children}</ModalContent>
+ </StyledModalContainer>
+ </ActiveModalContext.Provider>
);
}
@@ -86,18 +108,24 @@ interface IModalAlertProps {
message?: string;
buttons: React.ReactNode[];
children?: React.ReactNode;
+ close?: () => void;
+}
+
+export function ModalAlert(props: IModalAlertProps) {
+ const activeModalContext = useContext(ActiveModalContext);
+ return <ModalAlertWithContext {...activeModalContext} {...props} />;
}
-export class ModalAlert extends React.Component<IModalAlertProps> {
+class ModalAlertWithContext extends React.Component<IModalAlertProps & IModalContext> {
private element = document.createElement('div');
- private modalContainer?: Element;
private appendScheduler = new Scheduler();
public componentDidMount() {
- const modalContainer = document.getElementById(MODAL_CONTAINER_ID);
- if (modalContainer) {
- this.modalContainer = modalContainer;
+ this.props.setActiveModal(true);
+ document.addEventListener('keydown', this.handleKeyPress);
+ const modalContainer = this.props.modalContainerRef.current;
+ if (modalContainer) {
// Mounting the container element immediately results in a graphical issue with the dialog
// first rendering with the wrong proportions and then changing to the correct proportions.
// Postponing it to the next event cycle solves this issue.
@@ -110,11 +138,11 @@ export class ModalAlert extends React.Component<IModalAlertProps> {
}
public componentWillUnmount() {
- this.appendScheduler.cancel();
+ this.props.setActiveModal(false);
+ document.removeEventListener('keydown', this.handleKeyPress);
- if (this.modalContainer) {
- this.modalContainer.removeChild(this.element);
- }
+ this.appendScheduler.cancel();
+ this.props.modalContainerRef.current?.removeChild(this.element);
}
public render() {
@@ -125,7 +153,7 @@ export class ModalAlert extends React.Component<IModalAlertProps> {
return (
<ModalBackground>
<ModalAlertContainer>
- <StyledModalAlert>
+ <StyledModalAlert role="alertdialog">
{this.props.type && (
<ModalAlertIcon>{this.renderTypeIcon(this.props.type)}</ModalAlertIcon>
)}
@@ -157,6 +185,12 @@ export class ModalAlert extends React.Component<IModalAlertProps> {
<ImageView height={44} width={44} source={source} tintColor={this.props.iconColor ?? color} />
);
}
+
+ private handleKeyPress = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ this.props.close?.();
+ }
+ };
}
export const ModalMessage = styled.span({
diff --git a/gui/src/renderer/components/Preferences.tsx b/gui/src/renderer/components/Preferences.tsx
index 0b56c53221..79bd94706c 100644
--- a/gui/src/renderer/components/Preferences.tsx
+++ b/gui/src/renderer/components/Preferences.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
import { messages } from '../../shared/gettext';
+import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaInputGroup';
import * as Cell from './Cell';
import { Layout } from './Layout';
import {
@@ -62,118 +63,181 @@ export default class Preferences extends React.Component<IProps> {
</SettingsHeader>
<StyledContent>
- <Cell.Container>
- <Cell.Label>
- {messages.pgettext('preferences-view', 'Launch app on start-up')}
- </Cell.Label>
- <Cell.Switch isOn={this.props.autoStart} onChange={this.props.setAutoStart} />
- </Cell.Container>
+ <AriaInputGroup>
+ <Cell.Container>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {messages.pgettext('preferences-view', 'Launch app on start-up')}
+ </Cell.InputLabel>
+ </AriaLabel>
+ <AriaInput>
+ <Cell.Switch isOn={this.props.autoStart} onChange={this.props.setAutoStart} />
+ </AriaInput>
+ </Cell.Container>
+ </AriaInputGroup>
<StyledSeparator />
- <Cell.Container>
- <Cell.Label>{messages.pgettext('preferences-view', 'Auto-connect')}</Cell.Label>
- <Cell.Switch isOn={this.props.autoConnect} onChange={this.props.setAutoConnect} />
- </Cell.Container>
- <Cell.Footer>
- <Cell.FooterText>
- {messages.pgettext(
- 'preferences-view',
- 'Automatically connect to a server when the app launches.',
- )}
- </Cell.FooterText>
- </Cell.Footer>
-
- <Cell.Container>
- <Cell.Label>
- {messages.pgettext('preferences-view', 'Local network sharing')}
- </Cell.Label>
- <Cell.Switch isOn={this.props.allowLan} onChange={this.props.setAllowLan} />
- </Cell.Container>
- <Cell.Footer>
- <Cell.FooterText>
- {messages.pgettext(
- 'preferences-view',
- 'Allows access to other devices on the same network for sharing, printing etc.',
- )}
- </Cell.FooterText>
- </Cell.Footer>
+ <AriaInputGroup>
+ <Cell.Container>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {messages.pgettext('preferences-view', 'Auto-connect')}
+ </Cell.InputLabel>
+ </AriaLabel>
+ <AriaInput>
+ <Cell.Switch
+ isOn={this.props.autoConnect}
+ onChange={this.props.setAutoConnect}
+ />
+ </AriaInput>
+ </Cell.Container>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
+ {messages.pgettext(
+ 'preferences-view',
+ 'Automatically connect to a server when the app launches.',
+ )}
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
- <Cell.Container>
- <Cell.Label>{messages.pgettext('preferences-view', 'Notifications')}</Cell.Label>
- <Cell.Switch
- isOn={this.props.enableSystemNotifications}
- onChange={this.props.setEnableSystemNotifications}
- />
- </Cell.Container>
- <Cell.Footer>
- <Cell.FooterText>
- {messages.pgettext(
- 'preferences-view',
- 'Enable or disable system notifications. The critical notifications will always be displayed.',
- )}
- </Cell.FooterText>
- </Cell.Footer>
+ <AriaInputGroup>
+ <Cell.Container>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {messages.pgettext('preferences-view', 'Local network sharing')}
+ </Cell.InputLabel>
+ </AriaLabel>
+ <AriaInput>
+ <Cell.Switch isOn={this.props.allowLan} onChange={this.props.setAllowLan} />
+ </AriaInput>
+ </Cell.Container>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
+ {messages.pgettext(
+ 'preferences-view',
+ 'Allows access to other devices on the same network for sharing, printing etc.',
+ )}
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
- <Cell.Container>
- <Cell.Label>
- {messages.pgettext('preferences-view', 'Monochromatic tray icon')}
- </Cell.Label>
- <Cell.Switch
- isOn={this.props.monochromaticIcon}
- onChange={this.props.setMonochromaticIcon}
- />
- </Cell.Container>
- <Cell.Footer>
- <Cell.FooterText>
- {messages.pgettext(
- 'preferences-view',
- 'Use a monochromatic tray icon instead of a colored one.',
- )}
- </Cell.FooterText>
- </Cell.Footer>
+ <AriaInputGroup>
+ <Cell.Container>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {messages.pgettext('preferences-view', 'Notifications')}
+ </Cell.InputLabel>
+ </AriaLabel>
+ <AriaInput>
+ <Cell.Switch
+ isOn={this.props.enableSystemNotifications}
+ onChange={this.props.setEnableSystemNotifications}
+ />
+ </AriaInput>
+ </Cell.Container>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
+ {messages.pgettext(
+ 'preferences-view',
+ 'Enable or disable system notifications. The critical notifications will always be displayed.',
+ )}
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
- {this.props.enableStartMinimizedToggle ? (
- <React.Fragment>
- <Cell.Container>
- <Cell.Label>
- {messages.pgettext('preferences-view', 'Start minimized')}
- </Cell.Label>
+ <AriaInputGroup>
+ <Cell.Container>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {messages.pgettext('preferences-view', 'Monochromatic tray icon')}
+ </Cell.InputLabel>
+ </AriaLabel>
+ <AriaInput>
<Cell.Switch
- isOn={this.props.startMinimized}
- onChange={this.props.setStartMinimized}
+ isOn={this.props.monochromaticIcon}
+ onChange={this.props.setMonochromaticIcon}
/>
- </Cell.Container>
- <Cell.Footer>
+ </AriaInput>
+ </Cell.Container>
+ <Cell.Footer>
+ <AriaDescription>
<Cell.FooterText>
{messages.pgettext(
'preferences-view',
- 'Show only the tray icon when the app starts.',
+ 'Use a monochromatic tray icon instead of a colored one.',
)}
</Cell.FooterText>
- </Cell.Footer>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
+
+ {this.props.enableStartMinimizedToggle ? (
+ <React.Fragment>
+ <AriaInputGroup>
+ <Cell.Container>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {messages.pgettext('preferences-view', 'Start minimized')}
+ </Cell.InputLabel>
+ </AriaLabel>
+ <AriaInput>
+ <Cell.Switch
+ isOn={this.props.startMinimized}
+ onChange={this.props.setStartMinimized}
+ />
+ </AriaInput>
+ </Cell.Container>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
+ {messages.pgettext(
+ 'preferences-view',
+ 'Show only the tray icon when the app starts.',
+ )}
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
</React.Fragment>
) : undefined}
- <Cell.Container disabled={this.props.isBeta}>
- <Cell.Label>{messages.pgettext('preferences-view', 'Beta program')}</Cell.Label>
- <Cell.Switch
- isOn={this.props.showBetaReleases}
- onChange={this.props.setShowBetaReleases}
- />
- </Cell.Container>
- <Cell.Footer>
- <Cell.FooterText>
- {this.props.isBeta
- ? messages.pgettext(
- 'preferences-view',
- 'This option is unavailable while using a beta version.',
- )
- : messages.pgettext(
- 'preferences-view',
- 'Enable to get notified when new beta versions of the app are released.',
- )}
- </Cell.FooterText>
- </Cell.Footer>
+ <AriaInputGroup>
+ <Cell.Container disabled={this.props.isBeta}>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {messages.pgettext('preferences-view', 'Beta program')}
+ </Cell.InputLabel>
+ </AriaLabel>
+ <AriaInput>
+ <Cell.Switch
+ isOn={this.props.showBetaReleases}
+ onChange={this.props.setShowBetaReleases}
+ />
+ </AriaInput>
+ </Cell.Container>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
+ {this.props.isBeta
+ ? messages.pgettext(
+ 'preferences-view',
+ 'This option is unavailable while using a beta version.',
+ )
+ : messages.pgettext(
+ 'preferences-view',
+ 'Enable to get notified when new beta versions of the app are released.',
+ )}
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
</StyledContent>
</NavigationScrollbars>
</NavigationContainer>
diff --git a/gui/src/renderer/components/RedeemVoucher.tsx b/gui/src/renderer/components/RedeemVoucher.tsx
index 7ca83c43d1..29a84f868c 100644
--- a/gui/src/renderer/components/RedeemVoucher.tsx
+++ b/gui/src/renderer/components/RedeemVoucher.tsx
@@ -198,7 +198,8 @@ export function RedeemVoucherAlert(props: IRedeemVoucherAlertProps) {
<AppButton.BlueButton key="cancel" disabled={cancelDisabled} onClick={props.onClose}>
{messages.pgettext('redeem-voucher-alert', 'Cancel')}
</AppButton.BlueButton>,
- ]}>
+ ]}
+ close={props.onClose}>
<StyledLabel>{messages.pgettext('redeem-voucher-alert', 'Enter voucher code')}</StyledLabel>
<RedeemVoucherInput />
<RedeemVoucherResponse />
diff --git a/gui/src/renderer/components/Support.tsx b/gui/src/renderer/components/Support.tsx
index 295855a9a6..c685ebe946 100644
--- a/gui/src/renderer/components/Support.tsx
+++ b/gui/src/renderer/components/Support.tsx
@@ -252,6 +252,7 @@ export default class Support extends React.Component<ISupportProps, ISupportStat
{messages.gettext('Back')}
</AppButton.BlueButton>,
]}
+ close={this.onCancelNoEmailDialog}
/>
);
}
@@ -286,6 +287,7 @@ export default class Support extends React.Component<ISupportProps, ISupportStat
{messages.gettext('Cancel')}
</AppButton.BlueButton>,
]}
+ close={this.props.onClose}
/>
);
}
diff --git a/gui/src/renderer/components/Switch.tsx b/gui/src/renderer/components/Switch.tsx
index 0578064e71..f04ac91d39 100644
--- a/gui/src/renderer/components/Switch.tsx
+++ b/gui/src/renderer/components/Switch.tsx
@@ -3,6 +3,9 @@ import styled from 'styled-components';
import { colors } from '../../config.json';
interface IProps {
+ id?: string;
+ 'aria-labelledby'?: string;
+ 'aria-describedby'?: string;
isOn: boolean;
onChange?: (isOn: boolean) => void;
className?: string;
@@ -47,7 +50,7 @@ const Knob = styled.div({}, (props: { isOn: boolean; isPressed: boolean; disable
};
});
-export default class Switch extends React.Component<IProps, IState> {
+export default class Switch extends React.PureComponent<IProps, IState> {
public state: IState = {
isOn: this.props.isOn,
isPressed: false,
@@ -59,14 +62,6 @@ export default class Switch extends React.Component<IProps, IState> {
private startPos = 0;
private changedDuringPan = false;
- public shouldComponentUpdate(nextProps: IProps, nextState: IState) {
- return (
- nextState.isOn !== this.state.isOn ||
- nextState.isPressed !== this.state.isPressed ||
- nextProps.isOn !== this.props.isOn
- );
- }
-
public componentDidUpdate(prevProps: IProps, _prevState: IState) {
if (
this.props.isOn !== prevProps.isOn &&
@@ -80,11 +75,15 @@ export default class Switch extends React.Component<IProps, IState> {
public render() {
return (
<SwitchContainer
+ id={this.props.id}
role="checkbox"
- aria-checked={this.state.isOn}
+ aria-labelledby={this.props['aria-labelledby']}
+ aria-describedby={this.props['aria-describedby']}
+ aria-checked={this.props.isOn}
ref={this.containerRef}
onClick={this.handleClick}
disabled={this.props.disabled ?? false}
+ aria-disabled={this.props.disabled ?? false}
className={this.props.className}>
<Knob
disabled={this.props.disabled ?? false}