From aff9d11d25d59f7e7435e2da2cd4ce04cd3b5657 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:44:02 -0400 Subject: [PATCH] Trim popular tag pills to container width --- css/tailwind.source.css | 8 + js/ui/components/tagPillList.js | 92 ++++++++- js/ui/views/VideoListView.js | 190 +++++++++++++++--- .../ui/video-list-view-popular-tags.test.mjs | 91 +++++++++ 4 files changed, 353 insertions(+), 28 deletions(-) diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 07c4042f..0ef8ed60 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2400,6 +2400,14 @@ ); } + .video-tag-strip { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: var(--space-xs); + overflow: hidden; + } + .video-tag-pill { @apply inline-flex items-center gap-2 rounded-full border border-status-neutral-border bg-status-neutral-surface px-3 py-1 text-2xs font-medium text-status-neutral-on; @apply relative justify-center whitespace-nowrap transition; diff --git a/js/ui/components/tagPillList.js b/js/ui/components/tagPillList.js index 9b644fbc..dc194561 100644 --- a/js/ui/components/tagPillList.js +++ b/js/ui/components/tagPillList.js @@ -115,17 +115,99 @@ function createTagButton({ doc, tag, onTagActivate, getTagState }) { return button; } +function cleanupButtonHandler(button) { + if (!button || typeof button !== "object" || button.nodeType !== 1) { + return; + } + + const handler = button.__tagPillClickHandler; + if (handler) { + button.removeEventListener("click", handler); + delete button.__tagPillClickHandler; + } +} + +function removeTagButton(button) { + if (!button || typeof button !== "object" || button.nodeType !== 1) { + return; + } + + cleanupButtonHandler(button); + + if (typeof button.remove === "function") { + button.remove(); + } else if (button.parentNode) { + button.parentNode.removeChild(button); + } +} + function cleanupExistingButtons(root) { for (const button of root.querySelectorAll("button")) { - const handler = button.__tagPillClickHandler; - if (handler) { - button.removeEventListener("click", handler); - delete button.__tagPillClickHandler; - } + cleanupButtonHandler(button); } root.textContent = ""; } +function getAvailableContainerWidth(container) { + if (!container || container.nodeType !== 1) { + return 0; + } + + const clientWidth = Number(container.clientWidth || 0); + if (Number.isFinite(clientWidth) && clientWidth > 0) { + return clientWidth; + } + + if (typeof container.getBoundingClientRect === "function") { + try { + const rect = container.getBoundingClientRect(); + if (rect && typeof rect.width === "number" && rect.width > 0) { + return rect.width; + } + } catch (error) { + // Ignore measurement errors and fall through to return 0. + } + } + + return 0; +} + +export function trimTagPillStripToFit({ strip, container } = {}) { + if (!strip || typeof strip !== "object" || strip.nodeType !== 1) { + return { removedButtons: [] }; + } + + const host = + container && typeof container === "object" && container.nodeType === 1 + ? container + : strip.parentElement; + + const availableWidth = Math.max(0, getAvailableContainerWidth(host)); + const removedButtons = []; + + const buttons = Array.from(strip.querySelectorAll("button")); + if (!buttons.length) { + return { removedButtons }; + } + + if (availableWidth <= 0) { + return { removedButtons }; + } + + let currentButtons = buttons.length; + while (currentButtons > 0 && strip.scrollWidth > availableWidth) { + const button = buttons[currentButtons - 1]; + if (!button) { + break; + } + removeTagButton(button); + removedButtons.push(button); + currentButtons -= 1; + } + + return { removedButtons }; +} + export function renderTagPillStrip({ document: doc, tags = [], diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index dc33716f..8aed5849 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -2,6 +2,7 @@ import { VideoCard } from "../components/VideoCard.js"; import { renderTagPillStrip, applyTagPreferenceState, + trimTagPillStripToFit, } from "../components/tagPillList.js"; import { subscribeToVideoViewCount, @@ -181,6 +182,18 @@ export class VideoListView { this._lastRenderedVideoListElement = null; this.popularTagsRoot = null; this._popularTagStrip = null; + this._popularTagsSortedList = []; + this._popularTagResizeObserver = null; + this._popularTagResizeHandler = null; + this._popularTagResizeCancel = null; + this._popularTagResizeScheduled = false; + this._isApplyingPopularTagTrim = false; + this._handlePopularTagResize = () => { + if (this._isApplyingPopularTagTrim) { + return; + } + this.renderPopularTagStrip(); + }; this.handlers = { playback: null, @@ -268,12 +281,14 @@ export class VideoListView { this.currentVideos = []; this.lastRenderedVideoSignature = null; this._lastRenderedVideoListElement = null; + this._teardownPopularTagResizeObserver(); if (this.popularTagsRoot) { this.popularTagsRoot.textContent = ""; this.popularTagsRoot.hidden = true; } this.popularTagsRoot = null; this._popularTagStrip = null; + this._popularTagsSortedList = []; } setPlaybackHandler(handler) { @@ -320,6 +335,7 @@ export class VideoListView { } setPopularTagsContainer(container) { + this._teardownPopularTagResizeObserver(); if (this.popularTagsRoot && this.popularTagsRoot !== container) { this.popularTagsRoot.textContent = ""; this.popularTagsRoot.hidden = true; @@ -331,6 +347,10 @@ export class VideoListView { if (this.popularTagsRoot) { this.popularTagsRoot.textContent = ""; this.popularTagsRoot.hidden = true; + if (this._popularTagsSortedList.length) { + this.renderPopularTagStrip(); + this._ensurePopularTagResizeObserver(); + } } } @@ -341,9 +361,9 @@ export class VideoListView { } if (!Array.isArray(videos) || videos.length === 0) { - root.textContent = ""; - root.hidden = true; - this._popularTagStrip = null; + this._popularTagsSortedList = []; + this.renderPopularTagStrip(); + this._teardownPopularTagResizeObserver(); return; } @@ -365,9 +385,9 @@ export class VideoListView { }); if (!counts.size) { - root.textContent = ""; - root.hidden = true; - this._popularTagStrip = null; + this._popularTagsSortedList = []; + this.renderPopularTagStrip(); + this._teardownPopularTagResizeObserver(); return; } @@ -388,26 +408,150 @@ export class VideoListView { return lowerA.localeCompare(lowerB); }); - const tags = tagEntries.map((entry) => entry.tag); - const doc = this.document || root.ownerDocument || null; - if (!doc) { - root.textContent = ""; - root.hidden = true; - this._popularTagStrip = null; + this._popularTagsSortedList = tagEntries.map((entry) => entry.tag); + this.renderPopularTagStrip(); + if (this._popularTagsSortedList.length) { + this._ensurePopularTagResizeObserver(); + } else { + this._teardownPopularTagResizeObserver(); + } + } + + renderPopularTagStrip() { + const root = this.popularTagsRoot; + if (!root) { return; } - const { root: strip } = renderTagPillStrip({ - document: doc, - tags, - onTagActivate: (tag, detail = {}) => - this.handlePopularTagActivate(tag, detail), - getTagState: (tag) => this.resolveTagPreferenceState(tag), - }); - root.textContent = ""; - root.appendChild(strip); - root.hidden = false; - this._popularTagStrip = strip; + const tags = Array.isArray(this._popularTagsSortedList) + ? [...this._popularTagsSortedList] + : []; + + this._isApplyingPopularTagTrim = true; + + try { + root.textContent = ""; + this._popularTagStrip = null; + + if (!tags.length) { + root.hidden = true; + return; + } + + const doc = this.document || root.ownerDocument || null; + if (!doc) { + root.hidden = true; + this._teardownPopularTagResizeObserver(); + return; + } + + const { root: strip } = renderTagPillStrip({ + document: doc, + tags, + onTagActivate: (tag, detail = {}) => + this.handlePopularTagActivate(tag, detail), + getTagState: (tag) => this.resolveTagPreferenceState(tag), + }); + + root.appendChild(strip); + this._popularTagStrip = strip; + trimTagPillStripToFit({ strip, container: root }); + root.hidden = strip.childElementCount === 0; + } finally { + this._isApplyingPopularTagTrim = false; + } + } + + _ensurePopularTagResizeObserver() { + const root = this.popularTagsRoot; + if (!root || this._popularTagResizeObserver || this._popularTagResizeHandler) { + return; + } + + const resizeObserverCtor = + (this.window && this.window.ResizeObserver) || + (typeof globalThis !== "undefined" ? globalThis.ResizeObserver : null); + + if (typeof resizeObserverCtor === "function") { + this._popularTagResizeObserver = new resizeObserverCtor(() => { + this._handlePopularTagResize(); + }); + this._popularTagResizeObserver.observe(root); + return; + } + + if (!this.window) { + return; + } + + const schedule = (callback) => { + if (this._popularTagResizeScheduled) { + return; + } + this._popularTagResizeScheduled = true; + + if (typeof this.window.requestAnimationFrame === "function") { + const frameId = this.window.requestAnimationFrame(() => { + this._popularTagResizeScheduled = false; + this._popularTagResizeCancel = null; + callback(); + }); + this._popularTagResizeCancel = () => { + this.window.cancelAnimationFrame(frameId); + this._popularTagResizeCancel = null; + this._popularTagResizeScheduled = false; + }; + } else { + const timeoutId = this.window.setTimeout(() => { + this._popularTagResizeScheduled = false; + this._popularTagResizeCancel = null; + callback(); + }, 50); + this._popularTagResizeCancel = () => { + this.window.clearTimeout(timeoutId); + this._popularTagResizeCancel = null; + this._popularTagResizeScheduled = false; + }; + } + }; + + const handler = () => { + schedule(() => { + if (!this.popularTagsRoot) { + return; + } + this._handlePopularTagResize(); + }); + }; + + this.window.addEventListener("resize", handler); + this._popularTagResizeHandler = handler; + } + + _teardownPopularTagResizeObserver() { + if (this._popularTagResizeObserver) { + try { + this._popularTagResizeObserver.disconnect(); + } catch (error) { + // Ignore disconnect errors. + } + } + this._popularTagResizeObserver = null; + + if (this._popularTagResizeHandler && this.window) { + this.window.removeEventListener("resize", this._popularTagResizeHandler); + } + this._popularTagResizeHandler = null; + + if (typeof this._popularTagResizeCancel === "function") { + try { + this._popularTagResizeCancel(); + } catch (error) { + // Ignore cancellation errors. + } + } + this._popularTagResizeCancel = null; + this._popularTagResizeScheduled = false; } render(videos, metadata = null) { diff --git a/tests/ui/video-list-view-popular-tags.test.mjs b/tests/ui/video-list-view-popular-tags.test.mjs index dfd18df5..15c8d017 100644 --- a/tests/ui/video-list-view-popular-tags.test.mjs +++ b/tests/ui/video-list-view-popular-tags.test.mjs @@ -59,6 +59,97 @@ test("VideoListView renders sorted popular tag pills", () => { ); }); +test("VideoListView trims popular tags to available width", () => { + const { dom, document, listRoot, tagsRoot } = createViewDom(); + const view = new VideoListView({ document }); + view.mount(listRoot); + view.setPopularTagsContainer(tagsRoot); + + const prototype = dom.window.HTMLElement.prototype; + const originalScrollWidth = Object.getOwnPropertyDescriptor( + prototype, + "scrollWidth", + ); + + Object.defineProperty(tagsRoot, "clientWidth", { + configurable: true, + value: 160, + }); + + Object.defineProperty(prototype, "scrollWidth", { + configurable: true, + get() { + if (this.classList && this.classList.contains("video-tag-strip")) { + return this.childElementCount * 80; + } + + if (originalScrollWidth && typeof originalScrollWidth.get === "function") { + return originalScrollWidth.get.call(this); + } + + return 0; + }, + }); + + try { + const videos = [ + { + id: "v1", + title: "First", + pubkey: "pub1", + created_at: 3, + nip71: { hashtags: ["Nostr", "Video"] }, + }, + { + id: "v2", + title: "Second", + pubkey: "pub2", + created_at: 2, + tags: [ + ["t", "nostr"], + ["t", "Learning"], + ], + }, + { + id: "v3", + title: "Third", + pubkey: "pub3", + created_at: 1, + nip71: { hashtags: ["video"] }, + }, + ]; + + view.render(videos); + + let buttons = tagsRoot.querySelectorAll("button"); + assert.equal(buttons.length, 2); + assert.deepEqual( + Array.from(buttons, (button) => button.dataset.tag), + ["Nostr", "Video"], + ); + + Object.defineProperty(tagsRoot, "clientWidth", { + configurable: true, + value: 400, + }); + + view.renderPopularTagStrip(); + + buttons = tagsRoot.querySelectorAll("button"); + assert.equal(buttons.length, 3); + assert.deepEqual( + Array.from(buttons, (button) => button.dataset.tag), + ["Nostr", "Video", "Learning"], + ); + } finally { + if (originalScrollWidth) { + Object.defineProperty(prototype, "scrollWidth", originalScrollWidth); + } else { + delete prototype.scrollWidth; + } + } +}); + test("VideoListView applies tag preference variants to popular tags", () => { const { document, listRoot, tagsRoot } = createViewDom(); const view = new VideoListView({ document });