feat: improve dropdown accessibility with aria and focus management

This commit is contained in:
2026-06-06 16:52:52 +02:00
parent 5cc03579b2
commit 5441b14737

View File

@@ -1,65 +1,101 @@
import { closestFocusable, onHtmxLoad } from "./utils";
class UIDropdown extends HTMLElement { class UIDropdown extends HTMLElement {
isOpen = false; isOpen = false;
triggerEl: HTMLElement | null = null;
contentEl: HTMLElement | null = null; contentEl: HTMLElement | null = null;
isClosing = false; // debounce flag previouslyFocused: HTMLElement | null = null;
constructor() { constructor() {
super(); super();
this.toggle = this.toggle.bind(this); this.onTriggerClick = this.onTriggerClick.bind(this);
this.handleClickOutside = this.handleClickOutside.bind(this); this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleKeydown = this.handleKeydown.bind(this);
} }
connectedCallback(): void { connectedCallback(): void {
const trigger = this.querySelector("[data-trigger]"); this.triggerEl = this.querySelector("[data-trigger]");
this.contentEl = this.querySelector("[data-content]"); this.contentEl = this.querySelector("[data-content]");
if (trigger) { if (this.contentEl) {
trigger.addEventListener("click", this.toggle); this.contentEl.classList.add("hidden");
this.contentEl.setAttribute("aria-hidden", "true");
} }
document.addEventListener("click", this.handleClickOutside); const triggerButton = this.triggerButton();
triggerButton?.setAttribute("aria-expanded", "false");
this.triggerEl?.addEventListener("click", this.onTriggerClick);
document.addEventListener("click", this.handleDocumentClick);
document.addEventListener("keydown", this.handleKeydown);
} }
disconnectedCallback(): void { disconnectedCallback(): void {
const trigger = this.querySelector("[data-trigger]"); this.triggerEl?.removeEventListener("click", this.onTriggerClick);
if (trigger) { document.removeEventListener("click", this.handleDocumentClick);
trigger.removeEventListener("click", this.toggle); document.removeEventListener("keydown", this.handleKeydown);
}
triggerButton(): HTMLButtonElement | null {
const button = this.triggerEl?.querySelector("button");
return button instanceof HTMLButtonElement ? button : null;
}
open(): void {
if (!this.contentEl || this.isOpen) return;
document.querySelectorAll<UIDropdown>("ui-dropdown").forEach((dropdown) => {
if (dropdown !== this) {
dropdown.close();
}
});
this.isOpen = true;
this.previouslyFocused =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
this.contentEl.classList.remove("hidden");
this.contentEl.setAttribute("aria-hidden", "false");
this.triggerButton()?.setAttribute("aria-expanded", "true");
closestFocusable(this.contentEl)?.focus();
}
close(options: { restoreFocus?: boolean } = {}): void {
if (!this.contentEl || !this.isOpen) return;
this.isOpen = false;
this.contentEl.classList.add("hidden");
this.contentEl.setAttribute("aria-hidden", "true");
this.triggerButton()?.setAttribute("aria-expanded", "false");
if (options.restoreFocus !== false) {
this.previouslyFocused?.focus();
} }
document.removeEventListener("click", this.handleClickOutside);
} }
toggle(): void { toggle(): void {
if (this.isClosing) { if (this.isOpen) {
return;
}
this.isOpen = !this.isOpen;
if (this.contentEl) {
if (this.isOpen) {
this.contentEl.classList.remove("hidden");
} else {
this.contentEl.classList.add("hidden");
}
}
}
close(): void {
if (this.isClosing) {
return;
}
this.isClosing = true;
this.isOpen = false;
if (this.contentEl) {
this.contentEl.classList.add("hidden");
}
setTimeout(() => {
this.isClosing = false;
}, 100); // delay prevents rapid open/close flicker
}
handleClickOutside(event: MouseEvent): void {
if (!this.contains(event.target as Node)) {
this.close(); this.close();
return;
} }
this.open();
}
onTriggerClick(event: Event): void {
event.preventDefault();
this.toggle();
}
handleDocumentClick(event: MouseEvent): void {
if (!this.isOpen) return;
if (!(event.target instanceof Node)) return;
if (this.contains(event.target)) return;
this.close({ restoreFocus: false });
}
handleKeydown(event: KeyboardEvent): void {
if (!this.isOpen) return;
if (event.key !== "Escape") return;
event.preventDefault();
this.close();
} }
} }
@@ -73,16 +109,65 @@ const initStudioDropdown = (): void => {
const btn = target.closest<HTMLButtonElement>("button[data-studio-select]"); const btn = target.closest<HTMLButtonElement>("button[data-studio-select]");
if (!btn) return; if (!btn) return;
const input = document.getElementById("studio-input") as HTMLInputElement | null; const input = document.getElementById("studio-input");
const form = document.getElementById("browse-search-form") as HTMLFormElement | null; const form = document.getElementById("browse-search-form");
if (!input || !form) return; if (!(input instanceof HTMLInputElement) || !(form instanceof HTMLFormElement)) return;
input.value = btn.dataset.studioSelect ?? ""; input.value = btn.dataset.studioSelect ?? "";
form.requestSubmit(); form.requestSubmit();
const dropdown = btn.closest("ui-dropdown") as { close?: () => void } | null; const dropdown = btn.closest("ui-dropdown");
dropdown?.close?.(); if (dropdown instanceof UIDropdown) {
dropdown.close({ restoreFocus: false });
}
});
};
const initCheckboxVisuals = (): void => {
const syncCheckboxVisual = (input: HTMLInputElement): void => {
const box = input.nextElementSibling;
if (!(box instanceof HTMLElement)) return;
const icon = box.querySelector("svg");
icon?.classList.toggle("hidden", !input.checked);
if (input.matches("[data-genre-visual]")) {
box.classList.toggle("border-accent", input.checked);
box.classList.toggle("bg-foreground-muted/12", input.checked);
box.classList.toggle("border-white/45", !input.checked);
box.classList.toggle("bg-transparent", !input.checked);
return;
}
if (input.matches("[data-sfw-checkbox]")) {
box.classList.toggle("border-accent", input.checked);
box.classList.toggle("bg-foreground-muted/12", input.checked);
box.classList.toggle("border-white/45", !input.checked);
box.classList.toggle("bg-transparent", !input.checked);
const value = input.form?.querySelector<HTMLInputElement>("[data-sfw-value]");
if (value) {
value.value = String(input.checked);
}
}
};
document.addEventListener("change", (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
if (!target.matches("[data-checkbox-visual], [data-sfw-checkbox], [data-genre-visual]")) {
return;
}
syncCheckboxVisual(target);
});
onHtmxLoad((root) => {
root
.querySelectorAll<HTMLInputElement>(
"[data-checkbox-visual], [data-sfw-checkbox], [data-genre-visual]",
)
.forEach(syncCheckboxVisual);
}); });
}; };
initStudioDropdown(); initStudioDropdown();
initCheckboxVisuals();