diff options
| author | Markus Pettersson <markus.pettersson@mullvad.net> | 2025-01-22 13:16:25 +0100 |
|---|---|---|
| committer | Markus Pettersson <markus.pettersson@mullvad.net> | 2025-01-22 13:16:25 +0100 |
| commit | 39d978ec1e3c86dfdf3966ac970164361d71561d (patch) | |
| tree | b3d62d6ae4fc1bbe5b42e6ca33117b739a6d37df | |
| parent | 658e7e7360a0fdc49558e9b5389da1191f11e1fe (diff) | |
| parent | c6f149c3be49a4ddebb6fb0304c68a9967038ab5 (diff) | |
| download | mullvadvpn-39d978ec1e3c86dfdf3966ac970164361d71561d.tar.xz mullvadvpn-39d978ec1e3c86dfdf3966ac970164361d71561d.zip | |
Merge branch 'cannot-move-focus-from-login-input-using-keyboard-des-1636'
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'; |
