Skip to content

Commit 7cdf183

Browse files
authored
feat(a11y): keyboard navigation for listbox popups (#44)
Shared useListboxNavigation hook for LanguageSwitcher, PhoneInput and NavBar: arrow keys, Home/End, Enter, Escape, open-on-arrow, aria-activedescendant / roving focus, pinned scrolling, and a maxHeight prop. Moves role=listbox onto the options container. Closes #29.
1 parent a3d749f commit 7cdf183

19 files changed

Lines changed: 1384 additions & 152 deletions

‎content/docs/components/inputs/phone-input.mdx‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export function PhoneField() {
7272
| `placeholder` | `string` | No | Placeholder text |
7373
| `disabled` | `boolean` | No | Disable the input |
7474
| `showFlags` | `boolean` | No | Show country flag next to dial code (default: `true`) |
75+
| `maxHeight` | `number \| string` | No | Max height of the scrolling country list (default: `"15rem"`) |
7576
| `label` | `string` | No | Accessible label for the input |
7677
| `className` | `string` | No | Additional CSS classes |
7778

@@ -180,13 +181,34 @@ export default function App() {
180181
}
181182
```
182183

184+
## Keyboard support
185+
186+
The country menu follows the WAI-ARIA combobox pattern: focus stays in the search
187+
field, which is exposed as a `role="combobox"`, and the highlighted country is
188+
announced through `aria-activedescendant`.
189+
190+
| Key | Action |
191+
| -------------------------------- | --------------------------------------------------------------------- |
192+
| `Enter`, `Space`, or click | Open the country menu from the flag trigger |
193+
| `ArrowDown` / `ArrowUp` (closed) | Open with the first / last country highlighted |
194+
| `ArrowDown` / `ArrowUp` | Move the highlight, wrapping at both ends |
195+
| `Home` / `End` | Highlight the first / last country |
196+
| `Enter` | Select the highlighted country; focus moves to the number field |
197+
| `Escape` | Close the menu and return focus to the flag trigger |
198+
| Typing | Filter by name, code, or dial code; the highlight restarts at the top |
199+
200+
The highlight stays on the row where it appeared when the menu opened — the
201+
list scrolls beneath it, like a native menu — and the page itself never
202+
scrolls. Pass `maxHeight` to change how tall the country list gets before it
203+
scrolls.
204+
183205
## Features
184206

185207
- **50 countries** — pre-loaded with dial codes, flags, and names
186208
- **Dropdown search** — filter countries by name or dial code
187209
- **Flag emojis** — auto-detected from country code, with `showFlags` toggle
188-
- **Keyboard accessible** — arrow keys, Enter, Escape, Tab navigation
189-
- **ARIA labels** — proper `aria-expanded`, `aria-haspopup`, `aria-label`
210+
- **Keyboard accessible** — arrow keys, Home / End, Enter, and Escape navigation
211+
- **ARIA labels** — `role="combobox"` search field with `aria-activedescendant`, plus `aria-expanded`, `aria-haspopup`, and `aria-selected`
190212
- **Dark mode** — built with Tailwind CSS, dark-mode compatible
191213
- **Controlled/uncontrolled** — works with or without `value` prop
192214

‎content/docs/components/language-switchers/language-switcher.mdx‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,16 @@ export function LocaleSwitcher() {
6363

6464
## Props
6565

66-
| Prop | Type | Required | Description |
67-
| --------------- | ------------------------ | -------- | ----------------------------------------------------------- |
68-
| `locales` | `Locale[]` | Yes | Array of available locales — only `code` is required |
69-
| `value` | `string` | No | Selected locale code — pass this to control the component |
70-
| `defaultValue` | `string` | No | Initial selected locale code (default: first locale's code) |
71-
| `onValueChange` | `(code: string) => void` | No | Callback when locale changes |
72-
| `label` | `"native" \| "english"` | No | Display labels in native or English (default: `"english"`) |
73-
| `showFlags` | `boolean` | No | Show flag emojis (default: `false`) |
74-
| `className` | `string` | No | Additional CSS classes |
66+
| Prop | Type | Required | Description |
67+
| --------------- | ------------------------ | -------- | ------------------------------------------------------------ |
68+
| `locales` | `Locale[]` | Yes | Array of available locales — only `code` is required |
69+
| `value` | `string` | No | Selected locale code — pass this to control the component |
70+
| `defaultValue` | `string` | No | Initial selected locale code (default: first locale's code) |
71+
| `onValueChange` | `(code: string) => void` | No | Callback when locale changes |
72+
| `label` | `"native" \| "english"` | No | Display labels in native or English (default: `"english"`) |
73+
| `showFlags` | `boolean` | No | Show flag emojis (default: `false`) |
74+
| `maxHeight` | `number \| string` | No | Max height of the scrolling option list (default: `"15rem"`) |
75+
| `className` | `string` | No | Additional CSS classes |
7576

7677
### Locale Object
7778

@@ -183,13 +184,33 @@ export default function App() {
183184
}
184185
```
185186

187+
## Keyboard support
188+
189+
The popup follows the WAI-ARIA combobox pattern: focus stays in the search field,
190+
which is exposed as a `role="combobox"`, and the highlighted option is announced
191+
through `aria-activedescendant`.
192+
193+
| Key | Action |
194+
| -------------------------------- | ------------------------------------------------------------- |
195+
| `Enter`, `Space`, or click | Open the popup from the trigger |
196+
| `ArrowDown` / `ArrowUp` (closed) | Open with the first / last locale highlighted |
197+
| `ArrowDown` / `ArrowUp` | Move the highlight, wrapping at both ends |
198+
| `Home` / `End` | Highlight the first / last locale |
199+
| `Enter` | Select the highlighted locale and return focus to the trigger |
200+
| `Escape` | Close the popup and return focus to the trigger |
201+
| Typing | Filter by name or code; the highlight restarts at the top |
202+
203+
The highlight stays on the row where it appeared when the menu opened — the
204+
list scrolls beneath it, like a native menu — and the page itself never
205+
scrolls. Pass `maxHeight` to change how tall the list gets before it scrolls.
206+
186207
## Features
187208

188209
- **Zero-config** — just pass codes, labels and flags are auto-resolved
189210
- **Dropdown with search** — filter locales by name or code
190211
- **RTL-aware** — auto-detects RTL locales (ar, he, fa, ur) and adjusts layout
191-
- **Keyboard accessible** — arrow keys, Enter, Escape, Tab navigation
192-
- **ARIA labels** — proper `aria-expanded`, `aria-haspopup`, `aria-selected`
212+
- **Keyboard accessible** — arrow keys, Home / End, Enter, and Escape navigation
213+
- **ARIA labels** — `role="combobox"` search field with `aria-activedescendant`, plus `aria-expanded`, `aria-haspopup`, and `aria-selected`
193214
- **Dark mode** — built with Tailwind CSS, dark-mode compatible
194215
- **Library agnostic** — works with any i18n setup via callback
195216

‎content/docs/components/navigation/navbar.mdx‎

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -69,21 +69,22 @@ export function SiteNav() {
6969

7070
## Props
7171

72-
| Prop | Type | Required | Description |
73-
| --------------- | --------------------------------- | -------- | --------------------------------------------------------- |
74-
| `logo` | `React.ReactNode` | No | Logo element (renders before nav links) |
75-
| `links` | `NavLink[]` | No | Navigation links |
76-
| `locales` | `Locale[]` | No | Available locales for language selector |
77-
| `value` | `string` | No | Selected locale code — pass this to control the component |
78-
| `defaultValue` | `string` | No | Initial locale code when uncontrolled (default: `"en"`) |
79-
| `onValueChange` | `(code: string) => void` | No | Callback when locale changes |
80-
| `cta` | `{ label: string; href: string }` | No | CTA button |
81-
| `showGitHub` | `boolean` | No | Show GitHub icon button |
82-
| `githubUrl` | `string` | No | GitHub repository URL |
83-
| `sticky` | `boolean` | No | Make navbar sticky at top (default: `true`) |
84-
| `showFlags` | `boolean` | No | Show flags in language selector (default: `true`) |
85-
| `linkComponent` | `React.ElementType` | No | Component used for internal links (default: `"a"`) |
86-
| `className` | `string` | No | Additional CSS classes |
72+
| Prop | Type | Required | Description |
73+
| --------------- | --------------------------------- | -------- | -------------------------------------------------------------- |
74+
| `logo` | `React.ReactNode` | No | Logo element (renders before nav links) |
75+
| `links` | `NavLink[]` | No | Navigation links |
76+
| `locales` | `Locale[]` | No | Available locales for language selector |
77+
| `value` | `string` | No | Selected locale code — pass this to control the component |
78+
| `defaultValue` | `string` | No | Initial locale code when uncontrolled (default: `"en"`) |
79+
| `onValueChange` | `(code: string) => void` | No | Callback when locale changes |
80+
| `cta` | `{ label: string; href: string }` | No | CTA button |
81+
| `showGitHub` | `boolean` | No | Show GitHub icon button |
82+
| `githubUrl` | `string` | No | GitHub repository URL |
83+
| `sticky` | `boolean` | No | Make navbar sticky at top (default: `true`) |
84+
| `showFlags` | `boolean` | No | Show flags in language selector (default: `true`) |
85+
| `maxHeight` | `number \| string` | No | Max height of the scrolling language menu (default: `"15rem"`) |
86+
| `linkComponent` | `React.ElementType` | No | Component used for internal links (default: `"a"`) |
87+
| `className` | `string` | No | Additional CSS classes |
8788

8889
Any other prop is spread onto the underlying `<nav>` element, and the ref is
8990
forwarded to it.
@@ -238,6 +239,25 @@ export default function App() {
238239
}
239240
```
240241

242+
## Keyboard support
243+
244+
The language menu follows the WAI-ARIA listbox pattern. With no text field in the
245+
menu, arrow keys move real DOM focus between the options instead of tracking a
246+
highlight from a search box. The same keys work from the desktop and the mobile
247+
bar.
248+
249+
| Key | Action |
250+
| -------------------------------- | -------------------------------------------------------------------- |
251+
| `Enter`, `Space`, or click | Open the menu from the language trigger |
252+
| `ArrowDown` / `ArrowUp` (closed) | Open with the first / last locale focused |
253+
| `ArrowDown` / `ArrowUp` | Move focus between locales, wrapping at both ends |
254+
| `Home` / `End` | Focus the first / last locale |
255+
| `Enter` | Select the focused locale and return focus to the trigger |
256+
| `Escape` | Close the menu (and the mobile menu) and return focus to the trigger |
257+
258+
The highlight stays on its row while the list scrolls beneath it — a long list
259+
never scrolls the page. Pass `maxHeight` to set how tall the menu gets.
260+
241261
## Features
242262

243263
- **Responsive** — hamburger menu on mobile, full layout on desktop
@@ -246,8 +266,8 @@ export default function App() {
246266
- **CTA button** — optional call-to-action with customizable text and href
247267
- **GitHub link** — optional icon button linking to your repo
248268
- **Sticky/non-sticky** — toggle top-of-page stickiness
249-
- **Keyboard accessible** — arrow keys, Enter, Escape, Tab navigation
250-
- **ARIA labels** — proper `aria-expanded`, `aria-haspopup`, `aria-label`
269+
- **Keyboard accessible** — arrow keys, Home / End, Enter, and Escape navigation
270+
- **ARIA labels** — `aria-expanded`, `aria-haspopup`, `aria-selected`, and roving focus over the options
251271
- **Dark mode** — built with Tailwind CSS, dark-mode compatible
252272
- **Next.js Link** — uses `next/link` for client-side navigation
253273

‎content/docs/getting-started/contributing.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ Note the four conventions every component follows: a `value` / `defaultValue` /
8181
onto the root element, and the shared `Locale` type from `./types` rather than a
8282
local copy.
8383

84+
A component that renders a `role="listbox"` popup — a language menu, a country
85+
picker — shares its keyboard handling through `useListboxNavigation` from
86+
`@/lib/use-listbox-navigation` instead of wiring arrow keys, `Home` / `End`,
87+
`Enter`, and `Escape` itself. See `LanguageSwitcher` (search field plus
88+
`aria-activedescendant`) and `NavBar` (roving focus) for the two focus
89+
strategies.
90+
8491
## Contribution checklist
8592

8693
- [ ] TypeScript-first with exported interfaces for all props

‎content/docs/getting-started/shadcn-cli.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ npx shadcn@latest add @elements/navbar
3939

4040
The CLI resolves each component's dependencies (including the `cn` utility) and
4141
writes each file to the alias its type selects — components to your `ui` alias,
42-
the `useControllableState` hook to `lib`. If you have relocated `ui` in
43-
`components.json`, components follow it.
42+
shared hooks such as `useControllableState` and `useListboxNavigation` to `lib`.
43+
If you have relocated `ui` in `components.json`, components follow it.
4444

4545
<Callout title="Your `cn` is left alone">
4646
Components import `cn` through your `utils` alias and expect it to already be

‎src/cli/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ function rewriteImports(content: string, aliases: Record<string, string>): strin
117117
// hook path must not be partially rewritten by a shorter rule later.
118118
return content
119119
.replaceAll("@/lib/use-controllable-state", `${aliases.lib}/use-controllable-state`)
120+
.replaceAll("@/lib/use-listbox-navigation", `${aliases.lib}/use-listbox-navigation`)
120121
.replaceAll("@/lib/utils", aliases.utils);
121122
}
122123

‎src/components/docs/code-panel.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ export function CodePanel({
8989
"not-prose bg-[#FAFAFA] dark:bg-[#0A0A0A]",
9090
standalone
9191
? "mt-3 overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"
92-
: "border-t border-black/10 dark:border-white/10",
92+
: // Embedded in a PreviewShell card, which stopped clipping with
93+
// `overflow-hidden` — the panel rounds its own bottom corners.
94+
"rounded-b-2xl border-t border-black/10 dark:border-white/10",
9395
className,
9496
)}
9597
>

‎src/components/docs/preview-shell.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function PreviewShell({ code, highlighted, fullBleed, children }: Preview
2020
const [previewDark, setPreviewDark] = useState(true);
2121

2222
return (
23-
<div className="not-prose mt-3 overflow-hidden rounded-2xl border border-black/10 bg-black/[0.02] dark:border-white/10 dark:bg-white/[0.02]">
23+
<div className="not-prose mt-3 rounded-2xl border border-black/10 bg-black/[0.02] dark:border-white/10 dark:bg-white/[0.02]">
2424
<div className="flex items-center justify-between gap-3 px-3 py-2">
2525
<div className="flex items-center gap-0.5 rounded-lg bg-black/5 p-0.5 dark:bg-white/5">
2626
{(["preview", "code"] as const).map((v) => (
@@ -57,6 +57,9 @@ export function PreviewShell({ code, highlighted, fullBleed, children }: Preview
5757
<div
5858
className={cn(
5959
"relative overflow-visible border-t border-black/10 transition-colors dark:border-white/10",
60+
// Rounds the card's bottom corners by itself now that the card no
61+
// longer clips with `overflow-hidden` — dropdowns may overflow it.
62+
"rounded-b-2xl",
6063
// `light` is what turns the stage back off inside the dark site shell —
6164
// the absence of `dark` is not enough, see the `@variant` in globals.css.
6265
previewDark ? "dark" : "light",

0 commit comments

Comments
 (0)