diff --git a/js/ui/components/SearchFiltersPopover.js b/js/ui/components/SearchFiltersPopover.js index d880fba1..d923ce9d 100644 --- a/js/ui/components/SearchFiltersPopover.js +++ b/js/ui/components/SearchFiltersPopover.js @@ -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; }