Skip to content

Commit 8c3576e

Browse files
committed
feat: keyboard navigation updates
1 parent 2137963 commit 8c3576e

File tree

4 files changed

+161
-93
lines changed

4 files changed

+161
-93
lines changed
Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,33 @@
11
import { useTranslation } from "next-i18next"
2-
import { Box, Text } from "@chakra-ui/react"
2+
import { FormHelperText, forwardRef, Text } from "@chakra-ui/react"
33

44
import { BaseLink } from "@/components/Link"
55

66
import MenuItem from "./MenuItem"
77

88
type NoResultsCalloutProps = { onClose: () => void }
99

10-
const NoResultsCallout = ({ onClose }: NoResultsCalloutProps) => {
11-
const { t } = useTranslation("page-languages")
12-
return (
13-
<Box>
14-
<Text fontWeight="bold" mb="2">
15-
{t("page-languages-want-more-header")}
16-
</Text>
17-
{t("page-languages-want-more-paragraph")}{" "}
18-
<BaseLink
19-
as={MenuItem}
20-
key="item-no-results"
21-
href="contributing/translation-program"
22-
onClick={onClose}
23-
>
24-
{t("page-languages-want-more-link")}
25-
</BaseLink>
26-
</Box>
27-
)
28-
}
10+
const NoResultsCallout = forwardRef(
11+
({ onClose }: NoResultsCalloutProps, ref) => {
12+
const { t } = useTranslation("page-languages")
13+
return (
14+
<FormHelperText color="body.medium" lineHeight="base" fontSize="md">
15+
<Text fontWeight="bold" mb="2" color="body.base">
16+
{t("page-languages-want-more-header")}
17+
</Text>
18+
{t("page-languages-want-more-paragraph")}{" "}
19+
<BaseLink
20+
ref={ref}
21+
as={MenuItem}
22+
key="item-no-results"
23+
href="contributing/translation-program"
24+
onClick={onClose}
25+
>
26+
{t("page-languages-want-more-link")}
27+
</BaseLink>
28+
</FormHelperText>
29+
)
30+
}
31+
)
2932

3033
export default NoResultsCallout

src/components/LanguagePicker/index.tsx

Lines changed: 121 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import {
22
Box,
33
Flex,
4+
FormControl,
5+
FormLabel,
46
Input,
7+
InputGroup,
8+
InputRightElement,
9+
Kbd,
510
Menu,
6-
MenuItem as ChakraMenuItem,
711
MenuList,
812
type MenuListProps,
913
type MenuProps,
1014
Text,
15+
type UseDisclosureReturn,
16+
useEventListener,
1117
} from "@chakra-ui/react"
1218

1319
import { Button } from "@/components/Buttons"
@@ -21,34 +27,45 @@ type LanguagePickerProps = Omit<MenuListProps, "children"> & {
2127
children: React.ReactNode
2228
placement: MenuProps["placement"]
2329
handleClose?: () => void
30+
menuState?: UseDisclosureReturn
2431
}
2532

2633
const LanguagePicker = ({
2734
children,
2835
placement,
2936
handleClose,
37+
menuState,
3038
...props
3139
}: LanguagePickerProps) => {
32-
const {
33-
t,
34-
disclosure,
35-
inputRef,
36-
firstItemRef,
37-
filterValue,
38-
setFilterValue,
39-
filteredNames,
40-
} = useLanguagePicker(handleClose)
41-
40+
const { t, refs, disclosure, filterValue, setFilterValue, filteredNames } =
41+
useLanguagePicker(handleClose, menuState)
42+
const { inputRef, firstItemRef, noResultsRef, footerRef } = refs
4243
const { onClose } = disclosure
4344

45+
/**
46+
* Adds a keydown event listener to focus filter input (\).
47+
* @param {string} event - The keydown event.
48+
*/
49+
useEventListener("keydown", (e) => {
50+
if (e.key !== "\\") return
51+
e.preventDefault()
52+
inputRef.current?.focus()
53+
})
54+
4455
return (
45-
<Menu isLazy placement={placement} closeOnSelect={false} {...disclosure}>
56+
<Menu isLazy placement={placement} autoSelect={false} {...disclosure}>
4657
{children}
4758
<MenuList
4859
position="relative"
4960
overflow="auto"
5061
borderRadius="base"
5162
py="0"
63+
onKeyDown={(e) => {
64+
if (e.key === "Tab" || e.key === "\\") {
65+
e.preventDefault()
66+
;(e.shiftKey ? inputRef : footerRef).current?.focus()
67+
}
68+
}}
5269
{...props}
5370
>
5471
{/* Mobile Close bar */}
@@ -79,63 +96,100 @@ const LanguagePicker = ({
7996
bg="background.highlight"
8097
sx={{ "[role=menuitem]": { py: "3", px: "2" } }}
8198
>
82-
<Text fontSize="xs" color="body.medium">
83-
{t("page-languages-filter-label")}{" "}
84-
<Text as="span" textTransform="lowercase">
85-
({filteredNames.length} {t("common:languages")})
86-
</Text>
87-
</Text>
88-
<ChakraMenuItem
89-
onFocus={() => inputRef.current?.focus()}
90-
p="0"
91-
bg="transparent"
92-
position="relative"
93-
closeOnSelect={false}
94-
>
95-
<Input
96-
placeholder={t("page-languages-filter-placeholder")}
97-
value={filterValue}
98-
onChange={(e) => setFilterValue(e.target.value)}
99-
ref={inputRef}
100-
h="8"
101-
mt="1"
102-
mb="2"
103-
bg="background.base"
104-
color="body.base"
105-
onKeyDown={(e) => {
106-
// Navigate to first result on enter
107-
if (e.key === "Enter") {
99+
<FormControl>
100+
<FormLabel fontSize="xs" color="body.medium">
101+
{t("page-languages-filter-label")}{" "}
102+
<Text as="span" textTransform="lowercase">
103+
({filteredNames.length} {t("common:languages")})
104+
</Text>
105+
</FormLabel>
106+
<InputGroup>
107+
<Input
108+
type="search"
109+
autoComplete="off"
110+
placeholder={t("page-languages-filter-placeholder")}
111+
value={filterValue}
112+
onChange={(e) => setFilterValue(e.target.value)}
113+
onBlur={(e) => {
114+
if (e.relatedTarget?.tagName.toLowerCase() === "div") {
115+
e.currentTarget.focus()
116+
}
117+
}}
118+
ref={inputRef}
119+
h="8"
120+
mt="1"
121+
mb="2"
122+
bg="background.base"
123+
color="body.base"
124+
onKeyDown={(e) => {
125+
// Navigate to first result on enter
126+
if (e.key === "Enter") {
127+
e.preventDefault()
128+
firstItemRef.current?.click()
129+
}
130+
// If Tab/ArrowDown, focus on first item if available, NoResults link otherwise
131+
if (e.key === "Tab" || e.key === "ArrowDown") {
132+
e.preventDefault()
133+
;(filteredNames.length === 0
134+
? noResultsRef
135+
: firstItemRef
136+
).current?.focus()
137+
e.stopPropagation()
138+
}
139+
}}
140+
/>
141+
<InputRightElement
142+
hideBelow="lg" // TODO: Confirm breakpoint after nav-menu PR merged
143+
cursor="text"
144+
>
145+
<Kbd
146+
fontSize="sm"
147+
lineHeight="none"
148+
me="2"
149+
p="1"
150+
py="0.5"
151+
ms="auto"
152+
border="1px"
153+
borderColor="disabled"
154+
color="disabled"
155+
rounded="base"
156+
>
157+
\
158+
</Kbd>
159+
</InputRightElement>
160+
</InputGroup>
161+
162+
{filteredNames.map((displayInfo, index) => (
163+
<MenuItem
164+
key={"item-" + displayInfo.localeOption}
165+
displayInfo={displayInfo}
166+
ref={index === 0 ? firstItemRef : null}
167+
onKeyDown={(e) => {
168+
if (e.key !== "\\") return
108169
e.preventDefault()
109-
firstItemRef.current?.click()
170+
inputRef.current?.focus()
171+
}}
172+
onClick={() =>
173+
onClose({
174+
eventAction: "Locale chosen",
175+
eventName: displayInfo.localeOption,
176+
})
110177
}
111-
}}
112-
/>
113-
</ChakraMenuItem>
178+
/>
179+
))}
114180

115-
{filteredNames.map((displayInfo, index) => (
116-
<MenuItem
117-
key={"item-" + displayInfo.localeOption}
118-
displayInfo={displayInfo}
119-
ref={index === 0 ? firstItemRef : null}
120-
onClick={() =>
121-
onClose({
122-
eventAction: "Locale chosen",
123-
eventName: displayInfo.localeOption,
124-
})
125-
}
126-
/>
127-
))}
128-
129-
{filteredNames.length === 0 && (
130-
<NoResultsCallout
131-
onClose={() =>
132-
onClose({
133-
eventAction: "Translation program link (no results)",
134-
eventName: "/contributing/translation-program",
135-
})
136-
}
137-
/>
138-
)}
181+
{filteredNames.length === 0 && (
182+
<NoResultsCallout
183+
ref={noResultsRef}
184+
onClose={() =>
185+
onClose({
186+
eventAction: "Translation program link (no results)",
187+
eventName: "/contributing/translation-program",
188+
})
189+
}
190+
/>
191+
)}
192+
</FormControl>
139193
</Box>
140194

141195
{/* Footer callout */}
@@ -151,6 +205,7 @@ const LanguagePicker = ({
151205
<Text fontSize="xs" textAlign="center" color="body.base">
152206
{t("page-languages-recruit-community")}{" "}
153207
<BaseLink
208+
ref={footerRef}
154209
href="/contributing/translation-program"
155210
onClick={() =>
156211
onClose({

src/components/LanguagePicker/useLanguagePicker.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useEffect, useRef, useState } from "react"
22
import { useRouter } from "next/router"
33
import { useTranslation } from "next-i18next"
4-
import { useDisclosure } from "@chakra-ui/react"
4+
import { useDisclosure, type UseDisclosureReturn } from "@chakra-ui/react"
55

66
import type {
77
I18nLocale,
@@ -19,11 +19,18 @@ import { DEFAULT_LOCALE } from "@/lib/constants"
1919

2020
const data = progressData as ProjectProgressData[]
2121

22-
export const useLanguagePicker = (handleClose?: () => void) => {
22+
export const useLanguagePicker = (
23+
handleClose?: () => void,
24+
menuState?: UseDisclosureReturn
25+
) => {
2326
const { t } = useTranslation("page-languages")
2427
const { locale, locales } = useRouter()
25-
const inputRef = useRef<HTMLInputElement>(null)
26-
const firstItemRef = useRef<HTMLAnchorElement>(null)
28+
const refs = {
29+
inputRef: useRef<HTMLInputElement>(null),
30+
firstItemRef: useRef<HTMLAnchorElement>(null),
31+
noResultsRef: useRef<HTMLAnchorElement>(null),
32+
footerRef: useRef<HTMLAnchorElement>(null),
33+
}
2734
const [filterValue, setFilterValue] = useState("")
2835

2936
const [filteredNames, setFilteredNames] = useState<LocaleDisplayInfo[]>([])
@@ -143,6 +150,7 @@ export const useLanguagePicker = (handleClose?: () => void) => {
143150

144151
const onOpen = () => {
145152
menu.onOpen()
153+
menuState?.onOpen()
146154
trackCustomEvent({
147155
...eventBase,
148156
eventName: "Opened",
@@ -160,6 +168,7 @@ export const useLanguagePicker = (handleClose?: () => void) => {
160168
setFilterValue("")
161169
handleClose && handleClose()
162170
menu.onClose()
171+
menuState?.onClose()
163172
trackCustomEvent(
164173
(customMatomoEvent
165174
? { ...eventBase, ...customMatomoEvent }
@@ -169,9 +178,8 @@ export const useLanguagePicker = (handleClose?: () => void) => {
169178

170179
return {
171180
t,
181+
refs,
172182
disclosure: { isOpen, onOpen, onClose },
173-
inputRef,
174-
firstItemRef,
175183
filterValue,
176184
setFilterValue,
177185
filteredNames,

src/components/Nav/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const Nav: FC<IProps> = ({ path }) => {
4242
const { t } = useTranslation("common")
4343
const searchModalDisclosure = useDisclosure()
4444
const navWrapperRef = useRef(null)
45+
const languagePickerState = useDisclosure()
4546
const languagePickerRef = useRef<HTMLButtonElement>(null)
4647
/**
4748
* Adds a keydown event listener to toggle color mode (ctrl|cmd + \)
@@ -54,6 +55,7 @@ const Nav: FC<IProps> = ({ path }) => {
5455
if (e.metaKey || e.ctrlKey) {
5556
toggleColorMode()
5657
} else {
58+
if (languagePickerState.isOpen) return
5759
languagePickerRef.current?.click()
5860
}
5961
})
@@ -135,6 +137,7 @@ const Nav: FC<IProps> = ({ path }) => {
135137
w="xs"
136138
inset="unset"
137139
top="unset"
140+
menuState={languagePickerState}
138141
>
139142
<MenuButton
140143
as={Button}
@@ -144,7 +147,6 @@ const Nav: FC<IProps> = ({ path }) => {
144147
transition="color 0.2s"
145148
_hover={{
146149
color: "primary.hover",
147-
bg: "primary.lowContrast",
148150
"& svg": {
149151
transform: "rotate(10deg)",
150152
transition: "transform 0.5s",

0 commit comments

Comments
 (0)