summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--gui/src/renderer/components/Connect.tsx5
-rw-r--r--gui/src/renderer/components/Focus.tsx76
-rw-r--r--gui/src/renderer/components/HeaderBar.tsx4
-rw-r--r--gui/src/renderer/components/ImageView.tsx5
-rw-r--r--gui/src/renderer/components/Modal.tsx37
-rw-r--r--gui/src/renderer/components/NotificationArea.tsx2
-rw-r--r--gui/src/renderer/components/SecuredLabel.tsx2
-rw-r--r--gui/src/renderer/components/TransitionContainer.tsx53
-rw-r--r--gui/src/renderer/routes.tsx61
9 files changed, 183 insertions, 62 deletions
diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/Connect.tsx
index 36b88b81df..c0c257b736 100644
--- a/gui/src/renderer/components/Connect.tsx
+++ b/gui/src/renderer/components/Connect.tsx
@@ -6,6 +6,7 @@ import NotificationArea from '../components/NotificationArea';
import { AuthFailureKind, parseAuthFailure } from '../../shared/auth-failure';
import { LoginState } from '../redux/account/reducers';
import { IConnectionReduxState } from '../redux/connection/reducers';
+import { FocusFallback } from './Focus';
import { Brand, HeaderBarStyle, HeaderBarSettingsButton } from './HeaderBar';
import ImageView from './ImageView';
import { Container, Header, Layout } from './Layout';
@@ -89,7 +90,9 @@ export default class Connect extends React.Component<IProps, IState> {
<ModalContainer>
<Layout>
<Header barStyle={this.headerBarStyle()}>
- <Brand />
+ <FocusFallback>
+ <Brand />
+ </FocusFallback>
<HeaderBarSettingsButton />
</Header>
<StyledContainer>
diff --git a/gui/src/renderer/components/Focus.tsx b/gui/src/renderer/components/Focus.tsx
new file mode 100644
index 0000000000..6a2e9c7126
--- /dev/null
+++ b/gui/src/renderer/components/Focus.tsx
@@ -0,0 +1,76 @@
+import path from 'path';
+import React, { useImperativeHandle, useState } from 'react';
+import { useLocation } from 'react-router';
+import { sprintf } from 'sprintf-js';
+import styled from 'styled-components';
+import { messages } from '../../shared/gettext';
+
+const FOCUS_FALLBACK_CLASS = 'focus-fallback';
+
+const PageChangeAnnouncer = styled.div({
+ width: 0,
+ height: 0,
+ overflow: 'hidden',
+});
+
+export interface IFocusHandle {
+ resetFocus(): void;
+}
+
+interface IFocusProps {
+ children?: React.ReactElement;
+}
+
+function Focus(props: IFocusProps, ref: React.Ref<IFocusHandle>) {
+ const location = useLocation();
+ const [title, setTitle] = useState<string>();
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ resetFocus: () => {
+ const pageName = path.basename(location.pathname);
+ const titleElement = document.getElementsByTagName('h1')[0];
+ const titleContent = titleElement?.textContent ?? pageName;
+ setTitle(titleContent);
+
+ const focusElement =
+ titleElement ?? document.getElementsByClassName(FOCUS_FALLBACK_CLASS)[0];
+ if (focusElement) {
+ focusElement.setAttribute('tabindex', '-1');
+ focusElement.focus();
+ }
+ },
+ }),
+ [location.pathname],
+ );
+
+ return (
+ <>
+ {title && (
+ <PageChangeAnnouncer aria-live="polite">
+ {
+ // TRANSLATORS: This string is used to notify users of screenreaders that the view has
+ // TRANSLATORS: changed, usually as a result of pressing a navigation button.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(title)s - page title
+ sprintf(messages.pgettext('accessibility', '%(title)s, View loaded'), { title })
+ }
+ </PageChangeAnnouncer>
+ )}
+ {props.children}
+ </>
+ );
+}
+
+export default React.memo(React.forwardRef(Focus));
+
+interface IFocusFallbackProps {
+ children: React.ReactElement;
+}
+
+export function FocusFallback(props: IFocusFallbackProps) {
+ return React.cloneElement(props.children, {
+ className: `${props.children.props.className} ${FOCUS_FALLBACK_CLASS}`,
+ });
+}
diff --git a/gui/src/renderer/components/HeaderBar.tsx b/gui/src/renderer/components/HeaderBar.tsx
index a18e3f09b3..88b1200277 100644
--- a/gui/src/renderer/components/HeaderBar.tsx
+++ b/gui/src/renderer/components/HeaderBar.tsx
@@ -66,9 +66,9 @@ const Logo = styled(ImageView)({
margin: '4px 0 3px',
});
-export function Brand() {
+export function Brand(props: React.HTMLAttributes<HTMLDivElement>) {
return (
- <BrandContainer>
+ <BrandContainer {...props}>
<Logo width={44} height={44} source="logo-icon" />
<Title>{messages.pgettext('generic', 'MULLVAD VPN')}</Title>
</BrandContainer>
diff --git a/gui/src/renderer/components/ImageView.tsx b/gui/src/renderer/components/ImageView.tsx
index c8fb3da025..fa57985363 100644
--- a/gui/src/renderer/components/ImageView.tsx
+++ b/gui/src/renderer/components/ImageView.tsx
@@ -53,9 +53,10 @@ export default function ImageView(props: IImageViewProps) {
</ImageMask>
);
} else {
+ const { source: _source, width, height, ...otherProps } = props;
return (
- <Wrapper onClick={props.onClick} className={props.className}>
- <img src={url} width={props.width} height={props.height} aria-hidden={true} />
+ <Wrapper {...otherProps}>
+ <img src={url} width={width} height={height} aria-hidden={true} />
</Wrapper>
);
}
diff --git a/gui/src/renderer/components/Modal.tsx b/gui/src/renderer/components/Modal.tsx
index b045803626..b1303ad45f 100644
--- a/gui/src/renderer/components/Modal.tsx
+++ b/gui/src/renderer/components/Modal.tsx
@@ -1,4 +1,4 @@
-import React, { useContext, useRef, useState } from 'react';
+import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import styled from 'styled-components';
import { colors } from '../../config.json';
@@ -41,6 +41,7 @@ interface IModalContext {
activeModal: boolean;
setActiveModal: (value: boolean) => void;
modalContainerRef: React.RefObject<HTMLDivElement>;
+ previousActiveElement: React.MutableRefObject<HTMLElement | undefined>;
}
const noActiveModalContextError = new Error('ActiveModalContext.Provider missing');
@@ -54,14 +55,34 @@ const ActiveModalContext = React.createContext<IModalContext>({
get modalContainerRef(): React.RefObject<HTMLDivElement> {
throw noActiveModalContextError;
},
+ get previousActiveElement(): React.MutableRefObject<HTMLElement | undefined> {
+ throw noActiveModalContextError;
+ },
});
export function ModalContainer(props: IModalContainerProps) {
const [activeModal, setActiveModal] = useState(false);
+ const previousActiveElement = useRef<HTMLElement>();
const modalContainerRef = useRef() as React.RefObject<HTMLDivElement>;
+ const contextValue = useMemo(
+ () => ({
+ activeModal,
+ setActiveModal,
+ modalContainerRef,
+ previousActiveElement,
+ }),
+ [activeModal],
+ );
+
+ useEffect(() => {
+ if (!activeModal) {
+ previousActiveElement.current?.focus();
+ }
+ }, [activeModal]);
+
return (
- <ActiveModalContext.Provider value={{ activeModal, setActiveModal, modalContainerRef }}>
+ <ActiveModalContext.Provider value={contextValue}>
<StyledModalContainer ref={modalContainerRef}>
<ModalContent aria-hidden={activeModal}>{props.children}</ModalContent>
</StyledModalContainer>
@@ -118,8 +139,17 @@ export function ModalAlert(props: IModalAlertProps) {
class ModalAlertWithContext extends React.Component<IModalAlertProps & IModalContext> {
private element = document.createElement('div');
+ private modalRef = React.createRef<HTMLDivElement>();
private appendScheduler = new Scheduler();
+ constructor(props: IModalAlertProps & IModalContext) {
+ super(props);
+
+ if (document.activeElement) {
+ props.previousActiveElement.current = document.activeElement as HTMLElement;
+ }
+ }
+
public componentDidMount() {
this.props.setActiveModal(true);
document.addEventListener('keydown', this.handleKeyPress);
@@ -131,6 +161,7 @@ class ModalAlertWithContext extends React.Component<IModalAlertProps & IModalCon
// Postponing it to the next event cycle solves this issue.
this.appendScheduler.schedule(() => {
modalContainer.appendChild(this.element);
+ this.modalRef.current?.focus();
});
} else {
throw Error('Modal container not found when mounting modal');
@@ -153,7 +184,7 @@ class ModalAlertWithContext extends React.Component<IModalAlertProps & IModalCon
return (
<ModalBackground>
<ModalAlertContainer>
- <StyledModalAlert role="alertdialog">
+ <StyledModalAlert ref={this.modalRef} tabIndex={-1} role="dialog" aria-modal>
{this.props.type && (
<ModalAlertIcon>{this.renderTypeIcon(this.props.type)}</ModalAlertIcon>
)}
diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx
index 9c5d8f13f6..8e14cdf68a 100644
--- a/gui/src/renderer/components/NotificationArea.tsx
+++ b/gui/src/renderer/components/NotificationArea.tsx
@@ -66,7 +66,7 @@ export default function NotificationArea(props: IProps) {
return (
<NotificationBanner className={props.className} visible>
<NotificationIndicator type={notification.indicator} />
- <NotificationContent role="alert" aria-live="assertive">
+ <NotificationContent role="status" aria-live="polite">
<NotificationTitle>{notification.title}</NotificationTitle>
<NotificationSubtitle>{notification.subtitle}</NotificationSubtitle>
</NotificationContent>
diff --git a/gui/src/renderer/components/SecuredLabel.tsx b/gui/src/renderer/components/SecuredLabel.tsx
index 36cee2db18..08eda0afca 100644
--- a/gui/src/renderer/components/SecuredLabel.tsx
+++ b/gui/src/renderer/components/SecuredLabel.tsx
@@ -30,7 +30,7 @@ interface ISecuredLabelProps {
export default function SecuredLabel(props: ISecuredLabelProps) {
return (
- <StyledSecuredLabel {...props} role="alert" aria-live="polite">
+ <StyledSecuredLabel {...props} role="status" aria-live="polite">
{getLabelText(props.displayStyle)}
</StyledSecuredLabel>
);
diff --git a/gui/src/renderer/components/TransitionContainer.tsx b/gui/src/renderer/components/TransitionContainer.tsx
index 9b4c2520e3..ce7efc7fc0 100644
--- a/gui/src/renderer/components/TransitionContainer.tsx
+++ b/gui/src/renderer/components/TransitionContainer.tsx
@@ -1,6 +1,5 @@
import * as React from 'react';
import styled from 'styled-components';
-import { Scheduler } from '../../shared/scheduler';
import { ITransitionGroupProps } from '../transitions';
interface ITransitioningViewProps {
@@ -16,6 +15,7 @@ interface ITransitionQueueItem {
interface IProps extends ITransitionGroupProps {
children: TransitioningView;
+ onTransitionEnd: () => void;
}
interface IItemStyle {
@@ -82,7 +82,9 @@ export default class TransitionContainer extends React.Component<IProps, IState>
};
private isCycling = false;
- private cycleScheduler = new Scheduler();
+
+ private currentContentRef = React.createRef<HTMLDivElement>();
+ private nextContentRef = React.createRef<HTMLDivElement>();
public UNSAFE_componentWillReceiveProps(nextProps: IProps) {
const candidate = nextProps.children;
@@ -131,33 +133,17 @@ export default class TransitionContainer extends React.Component<IProps, IState>
this.state.nextItemStyle &&
this.state.nextItemTransition
) {
- this.setState(
- (state) => ({
- currentItemStyle: Object.assign({}, state.currentItemStyle, state.currentItemTransition),
- nextItemStyle: Object.assign({}, state.nextItemStyle, state.nextItemTransition),
- currentItemTransition: undefined,
- nextItemTransition: undefined,
- }),
- () => {
- // Schedule call to continueCycling instead of using onTransitionEnd since there are
- // multiple simultaneous transitions which would result in the listener being called
- // multiple times.
- const duration = Math.max(
- this.state.currentItemStyle?.duration ?? 450,
- this.state.nextItemStyle?.duration ?? 450,
- );
- this.cycleScheduler.schedule(this.continueCycling, duration);
- },
- );
+ this.setState((state) => ({
+ currentItemStyle: Object.assign({}, state.currentItemStyle, state.currentItemTransition),
+ nextItemStyle: Object.assign({}, state.nextItemStyle, state.nextItemTransition),
+ currentItemTransition: undefined,
+ nextItemTransition: undefined,
+ }));
} else {
this.cycle();
}
}
- public componentWillUnmount() {
- this.cycleScheduler.cancel();
- }
-
public render() {
const disableUserInteraction =
this.state.itemQueue.length > 0 || this.state.nextItem ? true : false;
@@ -167,7 +153,9 @@ export default class TransitionContainer extends React.Component<IProps, IState>
{this.state.currentItem && (
<StyledTransitionContent
key={this.state.currentItem.view.props.viewId}
- transition={this.state.currentItemStyle}>
+ ref={this.currentContentRef}
+ transition={this.state.currentItemStyle}
+ onTransitionEnd={this.onTransitionEnd}>
{this.state.currentItem.view}
</StyledTransitionContent>
)}
@@ -175,7 +163,9 @@ export default class TransitionContainer extends React.Component<IProps, IState>
{this.state.nextItem && (
<StyledTransitionContent
key={this.state.nextItem.view.props.viewId}
- transition={this.state.nextItemStyle}>
+ ref={this.nextContentRef}
+ transition={this.state.nextItemStyle}
+ onTransitionEnd={this.onTransitionEnd}>
{this.state.nextItem.view}
</StyledTransitionContent>
)}
@@ -183,6 +173,16 @@ export default class TransitionContainer extends React.Component<IProps, IState>
);
}
+ private onTransitionEnd = (event: React.TransitionEvent<HTMLDivElement>) => {
+ if (
+ this.isCycling &&
+ (event.target === this.currentContentRef.current ||
+ event.target === this.nextContentRef.current)
+ ) {
+ this.continueCycling();
+ }
+ };
+
private cycle() {
if (!this.isCycling) {
this.isCycling = true;
@@ -192,6 +192,7 @@ export default class TransitionContainer extends React.Component<IProps, IState>
private finishCycling() {
this.isCycling = false;
+ this.props.onTransitionEnd();
}
private continueCycling = () => {
diff --git a/gui/src/renderer/routes.tsx b/gui/src/renderer/routes.tsx
index 63a5a3a90e..e4256af994 100644
--- a/gui/src/renderer/routes.tsx
+++ b/gui/src/renderer/routes.tsx
@@ -1,6 +1,7 @@
import * as React from 'react';
import { Route, RouteComponentProps, Switch, withRouter } from 'react-router';
import Launch from './components/Launch';
+import Focus, { IFocusHandle } from './components/Focus';
import LinuxSplitTunnelingSettings from './components/LinuxSplitTunnelingSettings';
import TransitionContainer, { TransitionView } from './components/TransitionContainer';
import AccountPage from './containers/AccountPage';
@@ -24,6 +25,8 @@ interface IAppRoutesState {
class AppRoutes extends React.Component<RouteComponentProps, IAppRoutesState> {
private unobserveHistory?: () => void;
+ private focusRef = React.createRef<IFocusHandle>();
+
constructor(props: RouteComponentProps) {
super(props);
@@ -58,35 +61,41 @@ class AppRoutes extends React.Component<RouteComponentProps, IAppRoutesState> {
return (
<PlatformWindowContainer>
- <TransitionContainer {...transitionProps}>
- <TransitionView viewId={location.key || ''}>
- <Switch key={location.key} location={location}>
- <Route exact={true} path="/" component={Launch} />
- <Route exact={true} path="/login" component={LoginPage} />
- <Route exact={true} path="/connect" component={ConnectPage} />
- <Route exact={true} path="/settings" component={SettingsPage} />
- <Route exact={true} path="/settings/language" component={SelectLanguagePage} />
- <Route exact={true} path="/settings/account" component={AccountPage} />
- <Route exact={true} path="/settings/preferences" component={PreferencesPage} />
- <Route exact={true} path="/settings/advanced" component={AdvancedSettingsPage} />
- <Route
- exact={true}
- path="/settings/advanced/wireguard-keys"
- component={WireguardKeysPage}
- />
- <Route
- exact={true}
- path="/settings/advanced/linux-split-tunneling"
- component={LinuxSplitTunnelingSettings}
- />
- <Route exact={true} path="/settings/support" component={SupportPage} />
- <Route exact={true} path="/select-location" component={SelectLocationPage} />
- </Switch>
- </TransitionView>
- </TransitionContainer>
+ <Focus ref={this.focusRef}>
+ <TransitionContainer onTransitionEnd={this.onNavigation} {...transitionProps}>
+ <TransitionView viewId={location.key || ''}>
+ <Switch key={location.key} location={location}>
+ <Route exact={true} path="/" component={Launch} />
+ <Route exact={true} path="/login" component={LoginPage} />
+ <Route exact={true} path="/connect" component={ConnectPage} />
+ <Route exact={true} path="/settings" component={SettingsPage} />
+ <Route exact={true} path="/settings/language" component={SelectLanguagePage} />
+ <Route exact={true} path="/settings/account" component={AccountPage} />
+ <Route exact={true} path="/settings/preferences" component={PreferencesPage} />
+ <Route exact={true} path="/settings/advanced" component={AdvancedSettingsPage} />
+ <Route
+ exact={true}
+ path="/settings/advanced/wireguard-keys"
+ component={WireguardKeysPage}
+ />
+ <Route
+ exact={true}
+ path="/settings/advanced/linux-split-tunneling"
+ component={LinuxSplitTunnelingSettings}
+ />
+ <Route exact={true} path="/settings/support" component={SupportPage} />
+ <Route exact={true} path="/select-location" component={SelectLocationPage} />
+ </Switch>
+ </TransitionView>
+ </TransitionContainer>
+ </Focus>
</PlatformWindowContainer>
);
}
+
+ private onNavigation = () => {
+ this.focusRef.current?.resetFocus();
+ };
}
const AppRoutesWithRouter = withRouter(AppRoutes);