diff --git a/js/ui/components/tagPillList.js b/js/ui/components/tagPillList.js new file mode 100644 index 00000000..7900e127 --- /dev/null +++ b/js/ui/components/tagPillList.js @@ -0,0 +1,103 @@ +/** + * Tag pill list helpers for rendering and updating the compact tag strip used on video cards. + * + * Usage: + * ```js + * const { root } = renderTagPillStrip({ tags: ["nostr", "bitvid"], onTagActivate }); + * container.append(root); + * ``` + */ + +function resolveDocument(doc) { + if (doc) { + return doc; + } + + return typeof globalThis !== "undefined" ? globalThis.document : undefined; +} + +function assertDocument(doc) { + if (!doc) { + throw new Error("A document instance is required to render tag pill lists."); + } +} + +function createTagButton({ doc, tag, onTagActivate }) { + const button = doc.createElement("button"); + button.type = "button"; + button.classList.add("pill", "video-tag-pill", "focus-ring"); + button.dataset.size = "compact"; + button.dataset.tag = tag; + button.title = `#${tag}`; + + const label = doc.createElement("span"); + label.className = "video-tag-pill__label"; + label.textContent = `#${tag}`; + + const icon = doc.createElement("span"); + icon.className = "video-tag-pill__icon"; + icon.setAttribute("aria-hidden", "true"); + icon.textContent = "+"; + + button.append(label, icon); + + if (onTagActivate) { + const handler = (event) => { + onTagActivate(tag, { event, button }); + }; + button.addEventListener("click", handler); + // Store the handler for cleanup when the strip is updated. + button.__tagPillClickHandler = handler; + } + + return button; +} + +function cleanupExistingButtons(root) { + for (const button of root.querySelectorAll("button")) { + const handler = button.__tagPillClickHandler; + if (handler) { + button.removeEventListener("click", handler); + delete button.__tagPillClickHandler; + } + } + root.textContent = ""; +} + +export function renderTagPillStrip({ document: doc, tags = [], onTagActivate } = {}) { + const resolvedDocument = resolveDocument(doc); + assertDocument(resolvedDocument); + + const root = resolvedDocument.createElement("div"); + root.classList.add("video-tag-strip"); + + const buttons = tags.map((tag) => + createTagButton({ doc: resolvedDocument, tag, onTagActivate }), + ); + root.append(...buttons); + + return { root, buttons }; +} + +export function updateTagPillStrip({ + root, + tags = [], + onTagActivate, + document: doc, +} = {}) { + if (!root) { + throw new Error("A root element is required to update the tag pill strip."); + } + + const resolvedDocument = resolveDocument(doc ?? root?.ownerDocument); + assertDocument(resolvedDocument); + + cleanupExistingButtons(root); + + const buttons = tags.map((tag) => + createTagButton({ doc: resolvedDocument, tag, onTagActivate }), + ); + root.append(...buttons); + + return { root, buttons }; +} diff --git a/tests/ui/tag-pill-list.test.mjs b/tests/ui/tag-pill-list.test.mjs new file mode 100644 index 00000000..9384db30 --- /dev/null +++ b/tests/ui/tag-pill-list.test.mjs @@ -0,0 +1,97 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; + +import { renderTagPillStrip, updateTagPillStrip } from "../../js/ui/components/tagPillList.js"; + +test("renderTagPillStrip builds buttons with labels and icons", () => { + const { document } = new JSDOM("").window; + const { root, buttons } = renderTagPillStrip({ + document, + tags: ["nostr", "video"], + }); + + assert.equal(root.tagName, "DIV"); + assert(root.classList.contains("video-tag-strip")); + assert.equal(buttons.length, 2); + + for (const [index, button] of buttons.entries()) { + const tag = index === 0 ? "nostr" : "video"; + assert.equal(button.tagName, "BUTTON"); + assert.equal(button.type, "button"); + assert.ok(button.classList.contains("pill")); + assert.ok(button.classList.contains("video-tag-pill")); + assert.ok(button.classList.contains("focus-ring")); + assert.equal(button.dataset.size, "compact"); + assert.equal(button.dataset.tag, tag); + assert.equal(button.title, `#${tag}`); + + const label = button.querySelector(".video-tag-pill__label"); + assert(label, "label span should be present"); + assert.equal(label.textContent, `#${tag}`); + + const icon = button.querySelector(".video-tag-pill__icon"); + assert(icon, "icon span should be present"); + assert.equal(icon.textContent, "+"); + assert.equal(icon.getAttribute("aria-hidden"), "true"); + } +}); + +test("renderTagPillStrip wires the activation callback", () => { + const { document } = new JSDOM("").window; + const activations = []; + const { root, buttons } = renderTagPillStrip({ + document, + tags: ["nostr"], + onTagActivate(tag, { button, event }) { + activations.push({ tag, button, event }); + }, + }); + + document.body.append(root); + + buttons[0].click(); + + assert.equal(activations.length, 1); + assert.equal(activations[0].tag, "nostr"); + assert.equal(activations[0].button, buttons[0]); + assert(activations[0].event instanceof document.defaultView.Event); +}); + +test("updateTagPillStrip replaces buttons and rewires handlers", () => { + const { document } = new JSDOM("").window; + const firstActivations = []; + const secondActivations = []; + + const { root, buttons: initialButtons } = renderTagPillStrip({ + document, + tags: ["nostr"], + onTagActivate(tag) { + firstActivations.push(tag); + }, + }); + + document.body.append(root); + + initialButtons[0].click(); + assert.deepEqual(firstActivations, ["nostr"]); + + const { buttons: updatedButtons } = updateTagPillStrip({ + root, + document, + tags: ["video"], + onTagActivate(tag, { button }) { + secondActivations.push({ tag, button }); + }, + }); + + assert.equal(root.querySelectorAll("button").length, 1); + assert.equal(updatedButtons[0].dataset.tag, "video"); + + // Ensure the old handler is removed by dispatching a click on the detached button. + initialButtons[0].dispatchEvent(new document.defaultView.Event("click", { bubbles: true })); + assert.deepEqual(firstActivations, ["nostr"]); + + updatedButtons[0].click(); + assert.deepEqual(secondActivations, [{ tag: "video", button: updatedButtons[0] }]); +});