summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorMarkus Pettersson <markus.pettersson@mullvad.net>2025-01-22 13:16:25 +0100
committerMarkus Pettersson <markus.pettersson@mullvad.net>2025-01-22 13:16:25 +0100
commit39d978ec1e3c86dfdf3966ac970164361d71561d (patch)
treeb3d62d6ae4fc1bbe5b42e6ca33117b739a6d37df
parent658e7e7360a0fdc49558e9b5389da1191f11e1fe (diff)
parentc6f149c3be49a4ddebb6fb0304c68a9967038ab5 (diff)
downloadmullvadvpn-39d978ec1e3c86dfdf3966ac970164361d71561d.tar.xz
mullvadvpn-39d978ec1e3c86dfdf3966ac970164361d71561d.zip
Merge branch 'cannot-move-focus-from-login-input-using-keyboard-des-1636'
-rw-r--r--desktop/packages/mullvad-vpn/locales/messages.pot12
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/AriaGroup.tsx29
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/Login.tsx114
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/LoginStyles.tsx52
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/cell/CellButton.tsx6
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/layout/Box.tsx28
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/layout/Center.tsx13
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/layout/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/typography/Label.tsx12
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/typography/index.ts1
10 files changed, 141 insertions, 128 deletions
diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot
index 6855ce4408..dd5747d6ad 100644
--- a/desktop/packages/mullvad-vpn/locales/messages.pot
+++ b/desktop/packages/mullvad-vpn/locales/messages.pot
@@ -317,8 +317,11 @@ msgctxt "accessibility"
msgid "Expand %(location)s"
msgstr ""
+#. This is used by screenreaders to communicate the "x" button next to a saved account number.
+#. Available placeholders:
+#. %(accountNumber)s - the account number to the left of the button
msgctxt "accessibility"
-msgid "Forget %(accountNumber)s"
+msgid "Forget account number %(accountNumber)s"
msgstr ""
#. Provided to accessibility tools such as screenreaders to describe
@@ -336,6 +339,13 @@ msgctxt "accessibility"
msgid "Login"
msgstr ""
+#. This is used by screenreaders to communicate logging in with a saved account number.
+#. Available placeholders:
+#. %(accountNumber)s - the saved account number
+msgctxt "accessibility"
+msgid "Login with account number %(accountNumber)s"
+msgstr ""
+
msgctxt "accessibility"
msgid "More information"
msgstr ""
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/AriaGroup.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/AriaGroup.tsx
index 9e58283933..184971b562 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/AriaGroup.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/AriaGroup.tsx
@@ -1,29 +1,10 @@
import React, { useContext, useEffect, useId, useMemo, useState } from 'react';
-interface IAriaControlContext {
- controlledId: string;
-}
-
-const AriaControlContext = React.createContext<IAriaControlContext>({
- get controlledId(): string {
- throw new Error('Missing AriaControlContext.Provider');
- },
-});
-
interface IAriaGroupProps {
describedId?: string;
children: React.ReactNode;
}
-export function AriaControlGroup(props: IAriaGroupProps) {
- const id = useId();
- const contextValue = useMemo(() => ({ controlledId: `${id}-controlled` }), [id]);
-
- return (
- <AriaControlContext.Provider value={contextValue}>{props.children}</AriaControlContext.Provider>
- );
-}
-
interface IAriaDescriptionContext {
describedId: string;
descriptionId?: string;
@@ -100,16 +81,6 @@ 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 } = useContext(AriaInputContext);
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/Login.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/Login.tsx
index 08e11b7628..94216a23d0 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/Login.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/Login.tsx
@@ -1,12 +1,13 @@
import React, { useCallback } from 'react';
import { sprintf } from 'sprintf-js';
-import { colors } from '../../config.json';
import { AccountDataError, AccountNumber } from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
import { useAppContext } from '../context';
import { formatAccountNumber } from '../lib/account';
import useActions from '../lib/actionsHook';
+import { Box, Button, Flex, Label, LabelTiny, TitleMedium } from '../lib/components';
+import { Colors, Spacings } from '../lib/foundations';
import { formatHtml } from '../lib/html-formatter';
import accountActions from '../redux/account/actions';
import { LoginState } from '../redux/account/reducers';
@@ -14,16 +15,13 @@ import { useSelector } from '../redux/store';
import Accordion from './Accordion';
import { AppMainHeader } from './app-main-header';
import * as AppButton from './AppButton';
-import { AriaControlGroup, AriaControlled, AriaControls } from './AriaGroup';
import ImageView from './ImageView';
import { Container, Layout } from './Layout';
import {
StyledAccountDropdownContainer,
StyledAccountDropdownItem,
StyledAccountDropdownItemButton,
- StyledAccountDropdownItemButtonLabel,
- StyledAccountDropdownRemoveButton,
- StyledAccountDropdownRemoveIcon,
+ StyledAccountDropdownItemIconButton,
StyledAccountInputBackdrop,
StyledAccountInputGroup,
StyledBlockMessage,
@@ -34,10 +32,8 @@ import {
StyledInput,
StyledInputButton,
StyledInputSubmitIcon,
- StyledLoginFooterPrompt,
StyledLoginForm,
StyledStatusIcon,
- StyledSubtitle,
StyledTitle,
StyledTopInfo,
} from './LoginStyles';
@@ -150,15 +146,7 @@ class Login extends React.Component<IProps, IState> {
this.setState({ isActive: true });
};
- private onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
- // restore focus if click happened within dropdown
- if (e.relatedTarget) {
- if (this.accountInput.current) {
- this.accountInput.current.focus();
- }
- return;
- }
-
+ private onBlur = () => {
this.setState({ isActive: false });
};
@@ -319,6 +307,7 @@ class Login extends React.Component<IProps, IState> {
}
private createLoginForm() {
+ const inputId = 'account-number-input';
const allowInteraction = this.allowInteraction();
const allowLogin = allowInteraction && this.accountNumberValid();
const hasError =
@@ -326,8 +315,10 @@ class Login extends React.Component<IProps, IState> {
this.props.loginState.method === 'existing_account';
return (
- <>
- <StyledSubtitle data-testid="subtitle">{this.formSubtitle()}</StyledSubtitle>
+ <Flex $flexDirection="column" $gap={Spacings.spacing3}>
+ <Label htmlFor={inputId} data-testid="subtitle">
+ {this.formSubtitle()}
+ </Label>
<StyledAccountInputGroup
$active={allowInteraction && this.state.isActive}
$editable={allowInteraction}
@@ -335,6 +326,7 @@ class Login extends React.Component<IProps, IState> {
onSubmit={this.onSubmit}>
<StyledAccountInputBackdrop>
<StyledInput
+ id={inputId}
allowedCharacters="[0-9]"
separator=" "
groupLength={4}
@@ -377,22 +369,23 @@ class Login extends React.Component<IProps, IState> {
</StyledAccountDropdownContainer>
</Accordion>
</StyledAccountInputGroup>
- </>
+ </Flex>
);
}
private createFooter() {
return (
- <>
- <StyledLoginFooterPrompt>
+ <Flex $flexDirection="column" $gap={Spacings.spacing3}>
+ <LabelTiny color={Colors.white60}>
{messages.pgettext('login-view', 'Don’t have an account number?')}
- </StyledLoginFooterPrompt>
- <AppButton.BlueButton
+ </LabelTiny>
+ <Button
+ size="full"
onClick={this.props.createNewAccount}
disabled={!this.allowCreateAccount()}>
{messages.pgettext('login-view', 'Create account')}
- </AppButton.BlueButton>
- </>
+ </Button>
+ </Flex>
);
}
}
@@ -419,63 +412,66 @@ function AccountDropdown(props: IAccountDropdownProps) {
);
}
-interface IAccountDropdownItemProps {
+interface AccountDropdownItemProps {
label: string;
value: AccountNumber;
onRemove: (value: AccountNumber) => void;
onSelect: (value: AccountNumber) => void;
}
-function AccountDropdownItem(props: IAccountDropdownItemProps) {
- const { onSelect, onRemove } = props;
-
+function AccountDropdownItem({ label, onRemove, onSelect, value }: AccountDropdownItemProps) {
const handleSelect = useCallback(() => {
- onSelect(props.value);
- }, [onSelect, props.value]);
+ onSelect(value);
+ }, [onSelect, value]);
const handleRemove = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
// Prevent login form from submitting
event.preventDefault();
- onRemove(props.value);
+ onRemove(value);
},
- [onRemove, props.value],
+ [onRemove, value],
);
+ const itemId = React.useId();
+
return (
<>
<StyledDropdownSpacer />
<StyledAccountDropdownItem>
- <AriaControlGroup>
- <AriaControlled>
- <StyledAccountDropdownItemButton id={props.label} onClick={handleSelect} type="button">
- <StyledAccountDropdownItemButtonLabel>
- {props.label}
- </StyledAccountDropdownItemButtonLabel>
- </StyledAccountDropdownItemButton>
- </AriaControlled>
- <AriaControls>
- <StyledAccountDropdownRemoveButton
+ <Flex $alignItems="center" $justifyContent="space-between" $flexGrow={1}>
+ <StyledAccountDropdownItemButton
+ id={itemId}
+ onClick={handleSelect}
+ type="button"
+ aria-label={sprintf(
+ // TRANSLATORS: This is used by screenreaders to communicate logging in with a saved account number.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(accountNumber)s - the saved account number
+ messages.pgettext('accessibility', 'Login with account number %(accountNumber)s'),
+ {
+ accountNumber: label,
+ },
+ )}>
+ <TitleMedium color={Colors.blue80}>{label}</TitleMedium>
+ </StyledAccountDropdownItemButton>
+ <Box $height="48px" $width="48px" center>
+ <StyledAccountDropdownItemIconButton
onClick={handleRemove}
- aria-controls={props.label}
- aria-label={
+ aria-controls={itemId}
+ aria-label={sprintf(
// TRANSLATORS: This is used by screenreaders to communicate the "x" button next to a saved account number.
// TRANSLATORS: Available placeholders:
// TRANSLATORS: %(accountNumber)s - the account number to the left of the button
- sprintf(messages.pgettext('accessibility', 'Forget %(accountNumber)s'), {
- accountNumber: props.label,
- })
- }>
- <StyledAccountDropdownRemoveIcon
- tintColor={colors.blue40}
- tintHoverColor={colors.blue}
- source="icon-close-sml"
- height={16}
- width={16}
- />
- </StyledAccountDropdownRemoveButton>
- </AriaControls>
- </AriaControlGroup>
+ messages.pgettext('accessibility', 'Forget account number %(accountNumber)s'),
+ {
+ accountNumber: label,
+ },
+ )}>
+ <ImageView source="icon-close" height={16} width={16} />
+ </StyledAccountDropdownItemIconButton>
+ </Box>
+ </Flex>
</StyledAccountDropdownItem>
</>
);
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/LoginStyles.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/LoginStyles.tsx
index 1312b1d333..092e4f1f01 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/LoginStyles.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/LoginStyles.tsx
@@ -1,6 +1,8 @@
import styled from 'styled-components';
import { colors } from '../../config.json';
+import { Spacings } from '../lib/foundations';
+import { buttonReset } from '../lib/styles';
import * as Cell from './cell';
import { hugeText, largeText, measurements, smallText, tinyText } from './common-styles';
import FormattableTextInput from './FormattableTextInput';
@@ -38,35 +40,52 @@ export const StyledInputSubmitIcon = styled(ImageView)<{ $visible: boolean }>((p
export const StyledAccountDropdownItem = styled.li({
display: 'flex',
flex: 1,
+});
+
+const baseButtonStyles = {
+ ...buttonReset,
+ width: '100%',
+ height: '100%',
backgroundColor: colors.white60,
cursor: 'default',
'&&:hover': {
backgroundColor: colors.white40,
},
+ '&:focus-visible': {
+ outline: `2px solid ${colors.white}`,
+ outlineOffset: '-2px',
+ },
+};
+
+export const StyledAccountDropdownItemButton = styled.button({
+ ...baseButtonStyles,
+ paddingLeft: Spacings.spacing5,
});
-export const StyledAccountDropdownItemButton = styled(Cell.CellButton)({
- padding: '0px',
- marginBottom: '0px',
- flexDirection: 'row',
- alignItems: 'stretch',
+export const StyledAccountDropdownItemIconButton = styled.button({
+ ...baseButtonStyles,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+});
+
+export const StyledAccountDropdownTrailingButton = styled.button({
+ ...buttonReset,
backgroundColor: 'transparent',
- '&&:not(:disabled):hover': {
- backgroundColor: 'transparent',
+ cursor: 'pointer',
+ '&:focus-visible': {
+ outline: `2px solid ${colors.white}`,
+ outlineOffset: '2px',
},
});
export const StyledAccountDropdownItemButtonLabel = styled(Cell.Label)(largeText, {
- padding: '11px 0px 11px 12px',
margin: '0',
color: colors.blue80,
borderWidth: 0,
textAlign: 'left',
marginLeft: 0,
cursor: 'default',
- [StyledAccountDropdownItemButton + ':hover']: {
- color: colors.blue,
- },
});
export const StyledTopInfo = styled.div({
@@ -140,23 +159,12 @@ export const StyledDropdownSpacer = styled.div({
backgroundColor: colors.darkBlue,
});
-export const StyledLoginFooterPrompt = styled.span(tinyText, {
- color: colors.white60,
- marginBottom: '8px',
-});
-
export const StyledTitle = styled.h1(hugeText, {
lineHeight: '40px',
marginBottom: '7px',
flex: 0,
});
-export const StyledSubtitle = styled.span(tinyText, {
- lineHeight: '15px',
- marginBottom: '8px',
- color: colors.white60,
-});
-
export const StyledInput = styled(FormattableTextInput)(largeText, {
fontWeight: 700,
minWidth: 0,
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/cell/CellButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/cell/CellButton.tsx
index 181b874251..fd5e86d7d1 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/cell/CellButton.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/cell/CellButton.tsx
@@ -1,7 +1,7 @@
import React, { useContext } from 'react';
import styled from 'styled-components';
-import { Center } from '../../lib/components';
+import { Box } from '../../lib/components';
import { Colors, Spacings } from '../../lib/foundations';
import { IImageViewProps } from '../ImageView';
import { CellDisabledContext } from './Container';
@@ -73,9 +73,9 @@ export function CellNavigationButton({
return (
<CellButton {...props}>
{children}
- <Center $height="24px" $width="24px">
+ <Box $height="24px" $width="24px" center>
<Icon {...icon} />
- </Center>
+ </Box>
</CellButton>
);
}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/layout/Box.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/layout/Box.tsx
new file mode 100644
index 0000000000..548d0e7361
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/layout/Box.tsx
@@ -0,0 +1,28 @@
+import styled from 'styled-components';
+
+import { Layout, LayoutProps } from './Layout';
+
+interface BoxProps extends LayoutProps {
+ $width?: string;
+ $height?: string;
+ center?: boolean;
+}
+
+const StyledBox = styled(Layout)<BoxProps>((props) => ({
+ display: 'block',
+ boxSizing: 'border-box',
+ height: props.$height,
+ width: props.$width,
+}));
+
+const StyledCenter = styled.div({
+ display: 'grid',
+ placeItems: 'center',
+ height: '100%',
+ width: '100%',
+});
+
+export const Box = ({ center, children, ...props }: React.PropsWithChildren<BoxProps>) => {
+ const content = center ? <StyledCenter>{children}</StyledCenter> : children;
+ return <StyledBox {...props}>{content}</StyledBox>;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/layout/Center.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/layout/Center.tsx
deleted file mode 100644
index 3222bb9719..0000000000
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/layout/Center.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import styled from 'styled-components';
-
-interface CenterProps {
- $width?: string;
- $height?: string;
-}
-
-export const Center = styled.div<CenterProps>((props) => ({
- display: 'grid',
- placeItems: 'center',
- height: props.$height,
- width: props.$width,
-}));
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/layout/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/layout/index.ts
index 654de0780d..a3f4f59096 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/layout/index.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/layout/index.ts
@@ -1,4 +1,4 @@
-export * from './Center';
+export * from './Box';
export * from './Container';
export * from './Flex';
export * from './Layout';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/typography/Label.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/typography/Label.tsx
new file mode 100644
index 0000000000..d3ae088dff
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/typography/Label.tsx
@@ -0,0 +1,12 @@
+import { Text } from './Text';
+import { TextProps } from './Text';
+
+export type LabelProps = TextProps & React.LabelHTMLAttributes<HTMLLabelElement>;
+
+export const Label = ({ children, ...props }: LabelProps) => {
+ return (
+ <Text as={'label'} {...props}>
+ {children}
+ </Text>
+ );
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/typography/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/typography/index.ts
index 542c9d5642..cb0ef82111 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/typography/index.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/typography/index.ts
@@ -7,3 +7,4 @@ export * from './TitleBig';
export * from './TitleLarge';
export * from './TitleMedium';
export * from './Link';
+export * from './Label';