Merge pull request #1604 from PR0M3TH3AN/codex/enhance-filter-popover-accessibility

Improve search filter popover accessibility and keyboard shortcuts
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-23 13:44:29 -05:00
commit 08e5620824
1 file changed
+217 -5
+217 -5
View File
@@ -4,6 +4,9 @@ import { DEFAULT_FILTERS } from "../../search/searchFilters.js";
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) {
return documentRef;
@@ -14,6 +17,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) {
@@ -42,6 +50,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 () => {};
@@ -157,10 +238,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";
@@ -178,6 +260,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", {
@@ -201,6 +284,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 },
@@ -268,15 +352,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,
},
});
[
{ label: "Most recent", value: "recent" },
@@ -317,6 +414,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", {
@@ -366,6 +464,38 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
durationSection.appendChild(durationGrid);
panel.appendChild(durationSection);
const hasSection = createElement(doc, "div", {
className: "bv-stack bv-stack--tight",
});
hasSection.appendChild(
createElement(doc, "p", {
className: "text-xs font-semibold uppercase tracking-wide text-muted",
text: "Has",
}),
);
const hasChipRow = createElement(doc, "div", {
className: "flex flex-wrap gap-2",
});
enableArrowNavigation(hasChipRow);
const hasChips = new Map();
[
{ key: "magnet", label: "Magnet" },
{ key: "url", label: "URL" },
{ key: "transcript", label: "Transcript" },
].forEach(({ key, label }) => {
const chip = createElement(doc, "button", {
className: "search-filter-chip focus-ring",
text: label,
attrs: { type: "button", "aria-pressed": "false" },
});
chip.dataset.state = "off";
chip.addEventListener("click", () => toggleChip(chip));
hasChips.set(key, chip);
hasChipRow.appendChild(chip);
});
hasSection.appendChild(hasChipRow);
panel.appendChild(hasSection);
const toggleSection = createElement(doc, "div", {
className: "grid gap-3",
});
@@ -545,10 +675,45 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
restoreFocusOnClose: true,
});
triggerElement.addEventListener("click", async (event) => {
event.preventDefault();
if (!suggestedFacetUnsubscribe) {
suggestedFacetUnsubscribe = subscribeSearchFacetCounts((next) => {
if (typeof suggestedFacetRenderer === "function") {
suggestedFacetRenderer(next);
}
});
}
const originalDestroy = popover.destroy?.bind(popover);
popover.destroy = (...args) => {
if (suggestedFacetUnsubscribe) {
suggestedFacetUnsubscribe();
suggestedFacetUnsubscribe = null;
}
suggestedFacetRenderer = null;
if (originalDestroy) {
return originalDestroy(...args);
}
return undefined;
};
const syncFromQueryInput = (rawQuery) => {
if (typeof rawQuery !== "string") {
return { filters: DEFAULT_FILTERS, text: "", tokens: [], errors: [] };
}
const parsed = parseFilterQuery(rawQuery);
if (controlState?.applyStateToControls) {
controlState.applyStateToControls(parsed.filters || DEFAULT_FILTERS);
}
if (controlState?.setParseErrors) {
controlState.setParseErrors(parsed.errors || []);
}
if (typeof options.onQueryParsed === "function") {
options.onQueryParsed(parsed);
}
return parsed;
};
const openPopover = async () => {
if (popover.isOpen()) {
popover.close();
return;
}
popover.preload();
@@ -558,8 +723,55 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
controlState.applyStateToControls(nextState?.filters || DEFAULT_FILTERS);
}
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;
}