Fix menu dropdown focus traps (#944)

This commit is contained in:
Sascha Ißbrücker
2025-01-11 12:44:20 +01:00
committed by GitHub
parent c3149409b0
commit d2e8a95e3c
5 changed files with 52 additions and 17 deletions

View File

@@ -6,23 +6,38 @@ class DropdownBehavior extends Behavior {
this.opened = false; this.opened = false;
this.onClick = this.onClick.bind(this); this.onClick = this.onClick.bind(this);
this.onOutsideClick = this.onOutsideClick.bind(this); this.onOutsideClick = this.onOutsideClick.bind(this);
this.onEscape = this.onEscape.bind(this);
this.onFocusOut = this.onFocusOut.bind(this);
// Prevent opening the dropdown automatically on focus, so that it only
// opens on click then JS is enabled
this.element.style.setProperty("--dropdown-focus-display", "none");
this.element.addEventListener("keydown", this.onEscape);
this.element.addEventListener("focusout", this.onFocusOut);
this.toggle = element.querySelector(".dropdown-toggle"); this.toggle = element.querySelector(".dropdown-toggle");
this.toggle.setAttribute("aria-expanded", "false");
this.toggle.addEventListener("click", this.onClick); this.toggle.addEventListener("click", this.onClick);
} }
destroy() { destroy() {
this.close(); this.close();
this.toggle.removeEventListener("click", this.onClick); this.toggle.removeEventListener("click", this.onClick);
this.element.removeEventListener("keydown", this.onEscape);
this.element.removeEventListener("focusout", this.onFocusOut);
} }
open() { open() {
this.opened = true;
this.element.classList.add("active"); this.element.classList.add("active");
this.toggle.setAttribute("aria-expanded", "true");
document.addEventListener("click", this.onOutsideClick); document.addEventListener("click", this.onOutsideClick);
} }
close() { close() {
this.opened = false;
this.element.classList.remove("active"); this.element.classList.remove("active");
this.toggle.setAttribute("aria-expanded", "false");
document.removeEventListener("click", this.onOutsideClick); document.removeEventListener("click", this.onOutsideClick);
} }
@@ -39,6 +54,20 @@ class DropdownBehavior extends Behavior {
this.close(); this.close();
} }
} }
onEscape(event) {
if (event.key === "Escape" && this.opened) {
event.preventDefault();
this.close();
this.toggle.focus();
}
}
onFocusOut(event) {
if (!this.element.contains(event.relatedTarget)) {
this.close();
}
}
} }
registerBehavior("ld-dropdown", DropdownBehavior); registerBehavior("ld-dropdown", DropdownBehavior);

View File

@@ -1,5 +1,7 @@
/* Dropdown */ /* Dropdown */
.dropdown { .dropdown {
--dropdown-focus-display: block;
display: inline-block; display: inline-block;
position: relative; position: relative;
@@ -20,9 +22,13 @@
} }
} }
&.active .menu, &:focus-within .menu {
.dropdown-toggle:focus + .menu, /* Use custom CSS property to allow disabling opening on focus when using JS */
.menu:hover { display: var(--dropdown-focus-display);
}
&.active .menu {
/* Always show menu when class is added through JS */
display: block; display: block;
} }

View File

@@ -3,11 +3,11 @@
{# Basic menu list #} {# Basic menu list #}
<div class="hide-md"> <div class="hide-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a> <a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
<div class="dropdown"> <div ld-dropdown class="dropdown">
<button class="btn btn-link dropdown-toggle" tabindex="0"> <button class="btn btn-link dropdown-toggle" tabindex="0">
Bookmarks Bookmarks
</button> </button>
<ul class="menu"> <ul class="menu" role="list">
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a> <a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
</li> </li>
@@ -49,7 +49,7 @@
</svg> </svg>
</button> </button>
<!-- menu component --> <!-- menu component -->
<ul class="menu"> <ul class="menu" role="list">
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a> <a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
</li> </li>

View File

@@ -15,7 +15,8 @@
{% endfor %} {% endfor %}
</form> </form>
<div ld-dropdown class="search-options dropdown dropdown-right"> <div ld-dropdown class="search-options dropdown dropdown-right">
<button type="button" class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}"> <button type="button" aria-label="Search preferences"
class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2" <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>

View File

@@ -190,27 +190,26 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
# Without modifications # Without modifications
url = "/test" url = "/test"
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn( self.assertNotIn("badge", button["class"])
'<button type="button" class="btn dropdown-toggle">', rendered_template
)
# With modifications # With modifications
url = "/test?sort=title_asc" url = "/test?sort=title_asc"
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn( self.assertIn("badge", button["class"])
'<button type="button" class="btn dropdown-toggle badge">',
rendered_template,
)
# Ignores non-preferences modifications # Ignores non-preferences modifications
url = "/test?q=foo&user=john" url = "/test?q=foo&user=john"
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn( self.assertNotIn("badge", button["class"])
'<button type="button" class="btn dropdown-toggle">', rendered_template
)
def test_modified_labels(self): def test_modified_labels(self):
# Without modifications # Without modifications