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 {
isOpen = false;
triggerEl: HTMLElement | null = null;
contentEl: HTMLElement | null = null;
isClosing = false; // debounce flag
previouslyFocused: HTMLElement | null = null;
constructor() {
super();
this.toggle = this.toggle.bind(this);
this.handleClickOutside = this.handleClickOutside.bind(this);
this.onTriggerClick = this.onTriggerClick.bind(this);
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleKeydown = this.handleKeydown.bind(this);
}
connectedCallback(): void {
const trigger = this.querySelector("[data-trigger]");
this.triggerEl = this.querySelector("[data-trigger]");
this.contentEl = this.querySelector("[data-content]");
if (trigger) {
trigger.addEventListener("click", this.toggle);
if (this.contentEl) {
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 {
const trigger = this.querySelector("[data-trigger]");
if (trigger) {
trigger.removeEventListener("click", this.toggle);
this.triggerEl?.removeEventListener("click", this.onTriggerClick);
document.removeEventListener("click", this.handleDocumentClick);
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 {
if (this.isClosing) {
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) {
this.close();
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
this.open();
}
handleClickOutside(event: MouseEvent): void {
if (!this.contains(event.target as Node)) {
this.close();
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]");
if (!btn) return;
const input = document.getElementById("studio-input") as HTMLInputElement | null;
const form = document.getElementById("browse-search-form") as HTMLFormElement | null;
if (!input || !form) return;
const input = document.getElementById("studio-input");
const form = document.getElementById("browse-search-form");
if (!(input instanceof HTMLInputElement) || !(form instanceof HTMLFormElement)) return;
input.value = btn.dataset.studioSelect ?? "";
form.requestSubmit();
const dropdown = btn.closest("ui-dropdown") as { close?: () => void } | null;
dropdown?.close?.();
const dropdown = btn.closest("ui-dropdown");
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();
initCheckboxVisuals();