diff options
| -rw-r--r-- | gui/src/renderer/components/Connect.tsx | 5 | ||||
| -rw-r--r-- | gui/src/renderer/components/Focus.tsx | 76 | ||||
| -rw-r--r-- | gui/src/renderer/components/HeaderBar.tsx | 4 | ||||
| -rw-r--r-- | gui/src/renderer/components/ImageView.tsx | 5 | ||||
| -rw-r--r-- | gui/src/renderer/components/Modal.tsx | 37 | ||||
| -rw-r--r-- | gui/src/renderer/components/NotificationArea.tsx | 2 | ||||
| -rw-r--r-- | gui/src/renderer/components/SecuredLabel.tsx | 2 | ||||
| -rw-r--r-- | gui/src/renderer/components/TransitionContainer.tsx | 53 | ||||
| -rw-r--r-- | gui/src/renderer/routes.tsx | 61 |
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); |
