-
Notifications
You must be signed in to change notification settings - Fork 2
Description
This issue describes how keyboard focus should behave according to WAI-ARIA Authoring Practices Guide (APG) for each component in the naked_ui library. Proper focus management is critical for accessibility—ensuring keyboard and assistive technology users can navigate predictably and efficiently.
🎯 Key Principles (Apply to All Components)
| Principle | Description |
|---|---|
| Logical Tab Order | Tab sequence must follow reading order (top-to-bottom, left-to-right in LTR languages). |
| Single Tab Stop for Composites | Composite widgets (tabs, menus, radio groups) should have only ONE element in the global Tab sequence; internal navigation uses arrow keys. |
| Focus Persistence | When content is removed or overlays close, focus must return to a logical element—never leave focus "nowhere" (body). |
| Never Hide Focusable Elements | Elements with aria-hidden="true" must NOT be focusable. |
🔧 Component-Specific Focus Behavior
1. Button (NakedButton)
Role: button
| Action | Expected Behavior |
|---|---|
| Tab | Button receives focus in natural tab order |
| Shift+Tab | Button loses focus to previous focusable element |
| Enter / Space | Activates the button |
Notes:
- Disabled buttons (
aria-disabled="true") should generally NOT receive focus - Toggle buttons should announce their pressed/unpressed state
2. Checkbox (NakedCheckbox)
Role: checkbox
| Action | Expected Behavior |
|---|---|
| Tab | Checkbox receives focus in natural tab order |
| Space | Toggles checked state |
States:
aria-checked="true"— checkedaria-checked="false"— uncheckedaria-checked="mixed"— indeterminate (for tri-state checkboxes)
Notes:
- Each checkbox is an independent tab stop
- Disabled checkboxes should NOT receive focus
3. Radio Group (NakedRadio)
Role: radiogroup (container) + radio (items)
| Action | Expected Behavior |
|---|---|
| Tab | Focus enters the radio group on the checked radio; if none checked, focus goes to first radio |
| Tab (exit) | Focus leaves the entire radio group to next focusable element |
| Arrow Down / Arrow Right | Moves focus to next radio (and selects it in most implementations) |
| Arrow Up / Arrow Left | Moves focus to previous radio (and selects it) |
| Space | Selects the focused radio (if not already selected) |
| Home | (Optional) Moves focus to first radio |
| End | (Optional) Moves focus to last radio |
Focus Management:
- ✅ Uses roving
tabindex— only one radio hastabindex="0" - ✅ The entire group is ONE tab stop
- ✅ Arrow keys wrap (optional, but recommended)
4. Toggle/Switch (NakedToggle)
Role: switch
| Action | Expected Behavior |
|---|---|
| Tab | Switch receives focus in natural tab order |
| Space | Toggles the switch on/off |
| Enter | (Optional) May also toggle |
States:
aria-checked="true"— onaria-checked="false"— off
5. Slider (NakedSlider)
Role: slider
| Action | Expected Behavior |
|---|---|
| Tab | Slider thumb receives focus |
| Arrow Right / Arrow Up | Increases value by one step |
| Arrow Left / Arrow Down | Decreases value by one step |
| Page Up | Increases value by larger step (e.g., 10%) |
| Page Down | Decreases value by larger step |
| Home | Sets value to minimum |
| End | Sets value to maximum |
Required Attributes:
aria-valuenow— current valuearia-valuemin— minimum valuearia-valuemax— maximum valuearia-valuetext— (optional) human-readable value
Notes:
- For range sliders with two thumbs, each thumb is a separate tab stop
6. Tabs (NakedTabs)
Role: tablist (container), tab (tabs), tabpanel (panels)
| Action | Expected Behavior |
|---|---|
| Tab (into tablist) | Focus lands on the selected tab; if none selected, first tab |
| Tab (from tablist) | Focus moves to the tab panel content (or next focusable outside) |
| Arrow Left / Arrow Up | Moves focus to previous tab |
| Arrow Right / Arrow Down | Moves focus to next tab |
| Home | Moves focus to first tab |
| End | Moves focus to last tab |
| Space / Enter | Activates the focused tab (in manual activation mode) |
Activation Modes:
| Mode | Behavior |
|---|---|
| Automatic | Moving focus with arrows immediately activates (shows) the tab panel |
| Manual | Arrow keys only move focus; user must press Space/Enter to activate |
Focus Management:
- ✅ Uses roving
tabindexwithin tablist - ✅ Tablist is ONE tab stop
- ✅ Tab panels should be focusable only if they contain no focusable content, OR focus moves directly to first focusable inside panel
Arrow Key Wrapping:
- Arrows should wrap from last tab to first (and vice versa)
7. Accordion (NakedAccordion)
Role: Uses button for headers (or heading + button), region for panels (optional)
| Action | Expected Behavior |
|---|---|
| Tab | Moves focus between accordion headers (each header is a tab stop) |
| Enter / Space | Toggles the accordion panel open/closed |
| Arrow Down | (Optional) Moves focus to next accordion header |
| Arrow Up | (Optional) Moves focus to previous accordion header |
| Home | (Optional) Moves focus to first accordion header |
| End | (Optional) Moves focus to last accordion header |
Focus Behavior:
- Each accordion header/trigger is in the tab order
- When a panel opens, focus stays on the trigger (does NOT auto-move into panel)
- Content inside expanded panels is reachable via Tab
Notes:
- If using optional arrow key navigation, treat headers as a composite widget with roving tabindex
- Without arrow keys, each header is simply a normal tab stop
8. Menu (NakedMenu)
Role: menu (container), menuitem / menuitemcheckbox / menuitemradio (items)
| Action | Expected Behavior |
|---|---|
| Click/Enter on Trigger | Opens menu, focus moves to first menu item |
| Arrow Down | Moves focus to next menu item |
| Arrow Up | Moves focus to previous menu item |
| Home | Moves focus to first menu item |
| End | Moves focus to last menu item |
| Enter / Space | Activates focused menu item |
| Escape | Closes menu, returns focus to trigger |
| Tab | Closes menu, moves focus to next focusable (or trigger) |
| Type-ahead | Typing characters moves focus to matching item |
For Submenus:
- Arrow Right — Opens submenu, focus on first item
- Arrow Left / Escape — Closes submenu, focus returns to parent item
Focus Management:
- ✅ Uses roving
tabindexinside menu - ✅ Menu trigger is in tab order; menu items are NOT
- ✅ Focus must be trapped inside open menu
- ✅ On close, focus MUST return to the trigger element
9. Select / Combobox (NakedSelect)
Role: combobox (input), listbox (dropdown), option (items)
| Action | Expected Behavior |
|---|---|
| Tab | Combobox trigger/input receives focus |
| Enter / Space / Arrow Down | Opens dropdown, focus moves to selected option (or first) |
| Arrow Down (open) | Moves focus to next option |
| Arrow Up (open) | Moves focus to previous option |
| Home (open) | Moves focus to first option |
| End (open) | Moves focus to last option |
| Enter / Space (open) | Selects focused option, closes dropdown |
| Escape | Closes dropdown without selecting, returns focus to trigger |
| Tab (open) | Selects focused option, closes dropdown, moves to next element |
| Type-ahead | Filters or jumps to matching option |
Focus Management Options:
| Pattern | Description |
|---|---|
| DOM Focus | Focus actually moves to options in the listbox |
aria-activedescendant |
Focus stays on combobox; aria-activedescendant points to active option |
Notes:
- When dropdown closes, focus MUST return to trigger
- Selected option should be announced to screen readers
- For multi-select: Space toggles selection, maintains dropdown open
10. Dialog/Modal (NakedDialog)
Role: dialog or alertdialog
On Open:
| Behavior | Description |
|---|---|
| Move focus into dialog | Focus should move to the first focusable element, OR a primary action button, OR the dialog title (tabindex="-1") if content is lengthy |
| Trap focus | Tab/Shift+Tab must cycle within the dialog only |
| Disable background | Background content should be aria-hidden="true" and not receive focus |
While Open:
| Action | Expected Behavior |
|---|---|
| Tab | Cycles forward through focusable elements in dialog |
| Shift+Tab | Cycles backward through focusable elements |
| Escape | Closes dialog (for dismissible dialogs) |
On Close:
| Behavior | Description |
|---|---|
| Return focus | Focus MUST return to the element that opened the dialog |
| Fallback | If trigger no longer exists, focus should go to a logical alternative |
Focus Trap Implementation:
First focusable ← Shift+Tab ← Last focusable
Last focusable → Tab → First focusable
11. Popover (NakedPopover)
Role: Depends on content (may use dialog for interactive popovers)
Non-Modal Popover (e.g., information popover):
| Action | Expected Behavior |
|---|---|
| Click/Enter on Trigger | Opens popover |
| Focus behavior | If popover has focusable content, focus MAY move into it; otherwise stays on trigger |
| Escape | Closes popover |
| Click outside | Closes popover |
| Tab | If focus in popover, tabs through content; then closes and continues |
Interactive Popover (with focusable content):
| Action | Expected Behavior |
|---|---|
| On open | Focus should move to first focusable element in popover |
| On close | Focus returns to trigger |
Notes:
- Unlike modal dialogs, popovers generally don't trap focus
- Escape should always close the popover
- Consider using
aria-haspopupon trigger
12. Tooltip (NakedTooltip)
Role: tooltip
| Action | Expected Behavior |
|---|---|
| Focus on trigger | Tooltip appears |
| Blur from trigger | Tooltip disappears |
| Escape | Tooltip disappears (while trigger still focused) |
Focus Behavior:
⚠️ Tooltips should NOT be focusable themselves- Focus stays on the trigger element
- Tooltip is purely supplementary information
- Use
aria-describedbyto associate tooltip content with trigger
Notes:
- If tooltip content needs to be interactive, use a Popover instead
- Must be dismissible via Escape without moving focus
13. TextField (NakedTextField)
Role: textbox (native <input> or <textarea>)
| Action | Expected Behavior |
|---|---|
| Tab | Field receives focus |
| Typing | Characters entered into field |
| Arrow Left/Right | Moves cursor within text |
| Home/End | Moves cursor to start/end |
| Shift+Arrows | Text selection |
| Ctrl/Cmd+A | Select all |
Associated Elements:
- Labels should be properly associated via
aria-labelledbyor<label for> - Error messages via
aria-describedbyandaria-invalid="true" - Required fields via
aria-required="true"
📊 Focus Behavior Summary Table
| Component | Tab Stops | Internal Navigation | On Open Focus | On Close Focus |
|---|---|---|---|---|
| Button | 1 | N/A | N/A | N/A |
| Checkbox | 1 each | N/A | N/A | N/A |
| Radio Group | 1 (group) | Arrow keys | N/A | N/A |
| Toggle/Switch | 1 | N/A | N/A | N/A |
| Slider | 1 per thumb | Arrow/Home/End/Page | N/A | N/A |
| Tabs | 1 (tablist) | Arrow keys | N/A | N/A |
| Accordion | 1 per header (or roving) | Optional arrows | Stays on trigger | N/A |
| Menu | 1 (trigger) | Arrow keys | First item | Return to trigger |
| Select | 1 (trigger) | Arrow keys | Selected/first option | Return to trigger |
| Dialog | 1+ (trapped) | Tab cycles | First focusable | Return to trigger |
| Popover | 1+ (not trapped) | Tab | First focusable (if interactive) | Return to trigger |
| Tooltip | 0 (not focusable) | N/A | N/A | N/A |
| TextField | 1 | Arrow keys (cursor) | N/A | N/A |
🧪 Testing Checklist
- Can navigate to every interactive element using only Tab/Shift+Tab
- Composite widgets have correct arrow key navigation
- Dialogs trap focus and return it correctly on close
- Menus/Selects return focus to trigger on close
- Escape key closes overlays appropriately
- Disabled elements don't receive focus (unless in composite for discoverability)
- No focusable elements inside
aria-hiddencontainers - Screen reader announces focus changes appropriately