Improve search filter popover accessibility

This commit is contained in:
thePR0M3TH3AN committed 2026-01-23 13:41:24 -05:00
1 parent 64a7e20eb5
commit 7eea5110b5
1 file changed
+145 -5
+145 -5
View File
@@ -15,6 +15,7 @@ const FOCUSABLE_SELECTOR =
"a[href], button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
const SEARCH_PRESET_STORAGE_KEY = "bitvid:searchPresets";
let searchFilterPanelIdCounter = 0;
function resolveDocument(documentRef) {
if (documentRef && documentRef.nodeType === 9) {
@@ -26,6 +27,11 @@ function resolveDocument(documentRef) {
return null;
}
function buildFilterId(prefix) {
searchFilterPanelIdCounter += 1;
return `${prefix}-${searchFilterPanelIdCounter}`;
}
function createElement(doc, tagName, { className, text, attrs } = {}) {
const el = doc.createElement(tagName);
if (className) {
@@ -54,6 +60,79 @@ function collectFocusable(panel) {
);
}
function isEditableTarget(target) {
if (!target || target.nodeType !== 1) {
return false;
}
const element = target;
const tagName = element.tagName?.toLowerCase?.() || "";
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
return true;
}
if (element.isContentEditable) {
return true;
}
if (typeof element.closest === "function") {
return Boolean(element.closest("input, textarea, select, [contenteditable=\"true\"]"));
}
return false;
}
function isElementVisible(element) {
if (!element) {
return false;
}
if (element.getClientRects().length === 0) {
return false;
}
return true;
}
function enableArrowNavigation(listElement, { selector = "button" } = {}) {
if (!listElement) {
return () => {};
}
const handler = (event) => {
if (
event.key !== "ArrowLeft" &&
event.key !== "ArrowRight" &&
event.key !== "ArrowUp" &&
event.key !== "ArrowDown"
) {
return;
}
const target = event.target;
if (!target || !listElement.contains(target)) {
return;
}
const items = Array.from(listElement.querySelectorAll(selector)).filter(
(item) =>
item instanceof HTMLElement &&
!item.hasAttribute("disabled") &&
item.getAttribute("aria-hidden") !== "true",
);
if (!items.length) {
return;
}
const index = items.indexOf(target);
if (index < 0) {
return;
}
event.preventDefault();
const direction =
event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
const nextIndex = (index + direction + items.length) % items.length;
const nextItem = items[nextIndex];
if (nextItem && typeof nextItem.focus === "function") {
nextItem.focus();
}
};
listElement.addEventListener("keydown", handler);
return () => listElement.removeEventListener("keydown", handler);
}
function trapFocus(panel) {
if (!panel) {
return () => {};
@@ -225,10 +304,11 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
let suggestedFacetUnsubscribe = null;
const render = ({ container, close }) => {
const headingId = buildFilterId("search-filters-heading");
const panel = createElement(doc, "div", {
className:
"popover-panel w-80 max-w-full space-y-5 md:w-96",
attrs: { "aria-label": "Search filters" },
attrs: { "aria-labelledby": headingId },
});
panel.dataset.popoverVariant = "filters";
@@ -246,6 +326,7 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
createElement(doc, "h3", {
className: "text-base font-semibold text-text-strong",
text: "Search filters",
attrs: { id: headingId },
}),
);
const closeButton = createElement(doc, "button", {
@@ -351,6 +432,7 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
const chipRow = createElement(doc, "div", {
className: "flex flex-wrap gap-2",
});
enableArrowNavigation(chipRow);
const dateChips = [];
const dateChipValues = [
{ label: "24h", days: 1 },
@@ -418,15 +500,28 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
const sortSection = createElement(doc, "div", {
className: "bv-stack bv-stack--tight",
});
const sortHeadingId = buildFilterId("search-filters-sort-heading");
const sortDescriptionId = buildFilterId("search-filters-sort-description");
sortSection.appendChild(
createElement(doc, "p", {
className: "text-xs font-semibold uppercase tracking-wide text-muted",
text: "Sort",
attrs: { id: sortHeadingId },
}),
);
sortSection.appendChild(
createElement(doc, "p", {
className: "text-xs text-muted",
text: "Choose the order for search results.",
attrs: { id: sortDescriptionId },
}),
);
const sortSelect = createElement(doc, "select", {
className: "select w-full",
attrs: { "aria-label": "Sort results" },
attrs: {
"aria-labelledby": sortHeadingId,
"aria-describedby": sortDescriptionId,
},
});
const enableExperimentalSorts = options.enableExperimentalSorts === true;
SORT_OPTIONS.forEach((sortOption) => {
@@ -483,6 +578,7 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
const tagList = createElement(doc, "div", {
className: "flex flex-wrap gap-2",
});
enableArrowNavigation(tagList);
const tagChips = new Map();
["nostr", "music", "live"].forEach((tag) => {
const chip = createElement(doc, "button", {
@@ -544,6 +640,7 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
const hasChipRow = createElement(doc, "div", {
className: "flex flex-wrap gap-2",
});
enableArrowNavigation(hasChipRow);
const hasChips = new Map();
[
{ key: "magnet", label: "Magnet" },
@@ -1040,10 +1137,8 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
return parsed;
};
triggerElement.addEventListener("click", async (event) => {
event.preventDefault();
const openPopover = async () => {
if (popover.isOpen()) {
popover.close();
return;
}
popover.preload();
@@ -1062,10 +1157,55 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
controlState.refreshPresets();
}
await popover.open();
};
triggerElement.addEventListener("click", async (event) => {
event.preventDefault();
if (popover.isOpen()) {
popover.close();
return;
}
await openPopover();
});
const shortcutHandler = (event) => {
if (
event.defaultPrevented ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
if (isEditableTarget(event.target)) {
return;
}
if ((event.key === "f" || event.key === "F") && isElementVisible(triggerElement)) {
event.preventDefault();
event.stopImmediatePropagation();
void openPopover();
}
if (event.key === "Escape" && popover.isOpen()) {
event.preventDefault();
event.stopImmediatePropagation();
popover.close();
}
};
doc.addEventListener("keydown", shortcutHandler);
popover.syncFromQueryInput = syncFromQueryInput;
const originalDestroyPopover = popover.destroy?.bind(popover);
popover.destroy = (...args) => {
doc.removeEventListener("keydown", shortcutHandler);
if (originalDestroyPopover) {
return originalDestroyPopover(...args);
}
return undefined;
};
return popover;
}