Skip to content

Commit 71c3303

Browse files
committed
Make inline region dropdown keyboard-operable and harden role parser
region-selector.js: the inline dropdown was mouse-only — every option had tabIndex=-1 and only a click handler. Add a standard listbox keyboard contract: - Tab focuses the trigger; ArrowDown / Enter / Space opens the menu and focuses the active option (ArrowUp opens at the last option) - Inside the open menu: ArrowDown/ArrowUp move focus, Home/End jump to first/last, Enter/Space select, Escape closes and returns focus to the trigger, Tab closes and lets the browser advance - Roving tabindex: the focused option holds tabIndex=0, the rest -1, so the menu participates correctly in the page's tab order - Selection closes the menu, restores focus to the trigger, and flows through the existing syncAll() so every other region selector updates in lock-step region_box.py: defensive fallback in robusta_url_role so an empty-after-strip label can never produce an empty anchor — if the label half ends up blank, fall back to the URL as link text. Today's regex can't actually emit an empty label, but the guard makes a future regex tweak safe.
1 parent 6168baf commit 71c3303

2 files changed

Lines changed: 80 additions & 9 deletions

File tree

docs/_ext/region_box.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ def robusta_url_role(name, rawtext, text, lineno, inliner, options=None, content
116116
else:
117117
url = raw_text.strip()
118118
label = url
119+
if not label:
120+
label = url
119121
if not url:
120122
msg = inliner.reporter.error(
121123
"robusta-url role requires a URL", line=lineno

docs/_static/region-selector.js

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -144,34 +144,103 @@
144144
menu.setAttribute("role", "listbox");
145145

146146
const items = [];
147+
148+
function focusItem(idx) {
149+
if (idx < 0) idx = items.length - 1;
150+
if (idx >= items.length) idx = 0;
151+
items.forEach(function (it, i) { it.tabIndex = i === idx ? 0 : -1; });
152+
items[idx].focus();
153+
}
154+
155+
function selectItem(key) {
156+
syncAll(key);
157+
picker.classList.remove("is-open");
158+
trigger.setAttribute("aria-expanded", "false");
159+
trigger.focus();
160+
}
161+
147162
Object.keys(REGIONS).forEach(function (key) {
148163
const item = document.createElement("li");
149164
item.setAttribute("role", "option");
150165
item.setAttribute("data-region", key);
151166
item.className = "robusta-region-inline__option";
152-
item.tabIndex = -1;
153167
const isActive = key === currentRegion;
154168
item.setAttribute("aria-selected", String(isActive));
169+
item.tabIndex = isActive ? 0 : -1;
155170
if (isActive) item.classList.add("is-active");
156171
item.textContent = REGIONS[key].label;
157172
item.addEventListener("click", function (e) {
158173
e.preventDefault();
159-
syncAll(key);
160-
picker.classList.remove("is-open");
161-
trigger.setAttribute("aria-expanded", "false");
162-
trigger.focus();
174+
selectItem(key);
175+
});
176+
item.addEventListener("keydown", function (e) {
177+
const idx = items.indexOf(item);
178+
switch (e.key) {
179+
case "Enter":
180+
case " ":
181+
e.preventDefault();
182+
selectItem(key);
183+
break;
184+
case "ArrowDown":
185+
e.preventDefault();
186+
focusItem(idx + 1);
187+
break;
188+
case "ArrowUp":
189+
e.preventDefault();
190+
focusItem(idx - 1);
191+
break;
192+
case "Home":
193+
e.preventDefault();
194+
focusItem(0);
195+
break;
196+
case "End":
197+
e.preventDefault();
198+
focusItem(items.length - 1);
199+
break;
200+
case "Tab":
201+
picker.classList.remove("is-open");
202+
trigger.setAttribute("aria-expanded", "false");
203+
break;
204+
case "Escape":
205+
e.preventDefault();
206+
picker.classList.remove("is-open");
207+
trigger.setAttribute("aria-expanded", "false");
208+
trigger.focus();
209+
break;
210+
}
163211
});
164212
menu.appendChild(item);
165213
items.push(item);
166214
});
167215

216+
function openMenu() {
217+
closeAllInlineMenus(picker);
218+
picker.classList.add("is-open");
219+
trigger.setAttribute("aria-expanded", "true");
220+
const activeIdx = items.findIndex(function (it) { return it.classList.contains("is-active"); });
221+
focusItem(activeIdx >= 0 ? activeIdx : 0);
222+
}
223+
168224
trigger.addEventListener("click", function (e) {
169225
e.preventDefault();
170226
e.stopPropagation();
171-
const willOpen = !picker.classList.contains("is-open");
172-
closeAllInlineMenus(willOpen ? picker : null);
173-
picker.classList.toggle("is-open", willOpen);
174-
trigger.setAttribute("aria-expanded", String(willOpen));
227+
if (picker.classList.contains("is-open")) {
228+
picker.classList.remove("is-open");
229+
trigger.setAttribute("aria-expanded", "false");
230+
} else {
231+
openMenu();
232+
}
233+
});
234+
235+
trigger.addEventListener("keydown", function (e) {
236+
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
237+
e.preventDefault();
238+
openMenu();
239+
} else if (e.key === "ArrowUp") {
240+
e.preventDefault();
241+
openMenu();
242+
focusItem(items.length - 1);
243+
}
175244
});
176245

177246
picker.appendChild(trigger);

0 commit comments

Comments
 (0)