Skip to content

Commit df61976

Browse files
authored
Make the language switcher keyboard navigable (#204)
2 parents 50cd2e6 + e395218 commit df61976

File tree

3 files changed

+78
-55
lines changed

3 files changed

+78
-55
lines changed

src/ui/language-switcher.module.css

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,39 @@
77
.language-row-list {
88
display: flex;
99
flex-direction: column;
10-
transition: background-color 0.1s;
10+
transition:
11+
background-color 0.1s,
12+
color 0.1s;
13+
gap: var(--cpd-space-1x);
14+
border-radius: var(--cpd-space-2x);
1115

12-
&[data-pending="true"] {
16+
&:focus-visible {
17+
outline: 2px solid var(--cpd-color-border-focused);
18+
}
19+
20+
&[aria-busy="true"] {
1321
background-color: var(--cpd-color-bg-canvas-disabled);
1422
color: var(--cpd-color-text-disabled);
1523
}
1624

1725
& .language-row {
1826
cursor: pointer;
19-
padding: var(--cpd-space-2x);
20-
transition: background-color 0.1s;
27+
padding: var(--cpd-space-1x) var(--cpd-space-2x);
28+
border-radius: var(--cpd-space-2x);
29+
font: var(--cpd-font-body-md-semibold);
30+
letter-spacing: var(--cpd-letter-spacing-body-md);
2131

2232
&:hover {
2333
background-color: var(--cpd-color-bg-subtle-secondary);
2434
}
2535

26-
&[data-selected="true"],
27-
&[data-selected="true"]:hover {
36+
&:focus-visible {
37+
outline: 2px solid var(--cpd-color-border-focused);
38+
outline-offset: 2px;
39+
}
40+
41+
&[aria-selected="true"],
42+
&[aria-selected="true"]:hover {
2843
background-color: var(--cpd-color-bg-subtle-primary);
2944
}
3045
}

src/ui/language-switcher.tsx

Lines changed: 53 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
//
33
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
44

5+
import { Composite, CompositeItem } from "@floating-ui/react";
56
import { PublicIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
67
import { Button, Tooltip } from "@vector-im/compound-web";
7-
import { forwardRef, useMemo, useState, useTransition } from "react";
8+
import { forwardRef, useCallback, useMemo, useTransition } from "react";
89
import { FormattedMessage, useIntl } from "react-intl";
910

1011
import * as Dialog from "@/components/dialog";
1112
import { AVAILABLE_LOCALES, useBestLocale } from "@/intl";
12-
import * as messages from "@/messages";
1313
import { useLocaleStore } from "@/stores/locale";
1414

1515
import styles from "./language-switcher.module.css";
@@ -59,30 +59,33 @@ const LanguageName: React.FC<LanguageNameProps> = ({
5959
return displayNameFormatter.of(locale);
6060
};
6161

62+
const CHOICES = [null, ...AVAILABLE_LOCALES];
63+
6264
export const LanguageSwitcher: React.FC = () => {
63-
const [open, setOpen] = useState(false);
65+
const intl = useIntl();
6466
const [pending, startTransition] = useTransition();
6567
const { selectedLocale, setLocale, clearLocale } = useLocaleStore();
6668
const bestLocale = useBestLocale();
6769

68-
const handleLanguageSelect = (locale: string) => {
69-
if (locale !== selectedLocale) {
70-
startTransition(() => setLocale(locale));
71-
}
72-
};
70+
const selectItem = useCallback(
71+
(index: number) => {
72+
const locale = CHOICES[index];
73+
if (locale === selectedLocale) return;
74+
startTransition(() => {
75+
if (locale) setLocale(locale);
76+
else clearLocale();
77+
});
78+
},
79+
[setLocale, clearLocale, selectedLocale],
80+
);
7381

74-
const handleClearSelection = () => {
75-
if (selectedLocale !== null) {
76-
startTransition(() => clearLocale());
77-
}
78-
};
82+
const activeIndex = useMemo(() => {
83+
const index = CHOICES.indexOf(selectedLocale);
84+
return index === -1 ? 0 : index;
85+
}, [selectedLocale]);
7986

8087
return (
81-
<Dialog.Root
82-
open={open}
83-
onOpenChange={setOpen}
84-
trigger={<LanguageSwitcherButton />}
85-
>
88+
<Dialog.Root trigger={<LanguageSwitcherButton />}>
8689
<Dialog.Title>
8790
<FormattedMessage
8891
id="ui.language_switcher.title"
@@ -99,41 +102,42 @@ export const LanguageSwitcher: React.FC = () => {
99102
/>
100103
</Dialog.Description>
101104

102-
<div className={styles["language-row-list"]} data-pending={pending}>
103-
<button
104-
type="button"
105-
className={styles["language-row"]}
106-
data-selected={selectedLocale === null}
107-
onClick={handleClearSelection}
108-
>
109-
<FormattedMessage
110-
id="ui.language_switcher.browser_default"
111-
defaultMessage="Use browser settings ({language})"
112-
description="Option to use browser's default language in the language switcher"
113-
values={{
114-
language: <LanguageName locale={bestLocale} />,
115-
}}
116-
/>
117-
</button>
118-
119-
{AVAILABLE_LOCALES.map((locale) => (
120-
<button
105+
<Composite
106+
orientation="vertical"
107+
activeIndex={activeIndex}
108+
onNavigate={selectItem}
109+
role="listbox"
110+
className={styles["language-row-list"]}
111+
aria-busy={pending}
112+
aria-label={intl.formatMessage({
113+
id: "ui.language_switcher.language_list_label",
114+
defaultMessage: "List of available languages",
115+
description:
116+
"Aria label for the list of available languages in the language switcher",
117+
})}
118+
>
119+
{CHOICES.map((locale, index) => (
120+
<CompositeItem
121121
key={locale}
122-
type="button"
122+
role="option"
123+
aria-selected={index === activeIndex}
123124
className={styles["language-row"]}
124-
data-selected={selectedLocale === locale}
125-
onClick={() => handleLanguageSelect(locale)}
126125
>
127-
<LanguageName locale={locale} />
128-
</button>
126+
{locale === null ? (
127+
<FormattedMessage
128+
id="ui.language_switcher.browser_default"
129+
defaultMessage="Use browser settings ({language})"
130+
description="Option to use browser's default language in the language switcher"
131+
values={{
132+
language: <LanguageName locale={bestLocale} />,
133+
}}
134+
/>
135+
) : (
136+
<LanguageName locale={locale} />
137+
)}
138+
</CompositeItem>
129139
))}
130-
</div>
131-
132-
<Dialog.Close asChild>
133-
<Button type="button" kind="tertiary">
134-
<FormattedMessage {...messages.actionClose} />
135-
</Button>
136-
</Dialog.Close>
140+
</Composite>
137141
</Dialog.Root>
138142
);
139143
};

translations/extracted/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,6 +1215,10 @@
12151215
"description": "Description text in the language switcher modal",
12161216
"message": "Choose your preferred language for the interface."
12171217
},
1218+
"ui.language_switcher.language_list_label": {
1219+
"description": "Aria label for the list of available languages in the language switcher",
1220+
"message": "List of available languages"
1221+
},
12181222
"ui.language_switcher.title": {
12191223
"description": "Title of the language switcher modal",
12201224
"message": "Language settings"

0 commit comments

Comments
 (0)