From b4d237061109c349714919e068eaad0810d62b0a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 30 Oct 2025 21:01:37 -0400 Subject: [PATCH] Trim video modal tag list to container width --- css/tailwind.source.css | 4 +- js/ui/components/VideoModal.js | 143 +++++++++++++++++++++++-- tests/video-modal-controllers.test.mjs | 113 +++++++++++++++++++ 3 files changed, 247 insertions(+), 13 deletions(-) diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 0ef8ed60..40172068 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2354,9 +2354,7 @@ /* Video modal tag strip */ .video-modal__tags { - @apply -mx-1 flex gap-2 overflow-x-auto px-1; - scrollbar-gutter: stable both-edges; - overscroll-behavior-x: contain; + @apply -mx-1 flex gap-2 px-1; } .video-modal__similar .video-modal__tags { diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 8d0a6210..4f41ed9e 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -16,6 +16,7 @@ import { import { renderTagPillStrip, applyTagPreferenceState, + trimTagPillStripToFit, } from "./tagPillList.js"; import { SimilarContentCard } from "./SimilarContentCard.js"; import { @@ -129,6 +130,10 @@ export class VideoModal { this.videoViewCountEl = null; this.videoTagsRoot = null; this.videoTagsData = Object.freeze([]); + this.videoTagsSourceData = this.videoTagsData; + this.videoTagsResizeObserver = null; + this.videoTagsLastObservedWidth = 0; + this.handleVideoTagsResize = this.handleVideoTagsResize.bind(this); this.creatorAvatar = null; this.creatorName = null; this.creatorNpub = null; @@ -390,6 +395,7 @@ export class VideoModal { } else { this.loaded = false; this.cleanupVideoTags(); + this.teardownVideoTagsResizeObserver(); this.videoTagsRoot = null; this.playerModal = null; } @@ -509,6 +515,7 @@ export class VideoModal { if (this.videoTagsRoot && this.videoTagsRoot !== nextVideoTagsRoot) { this.cleanupVideoTags(this.videoTagsRoot); } + this.teardownVideoTagsResizeObserver(); this.videoTagsRoot = nextVideoTagsRoot; if (this.videoTagsRoot) { this.cleanupVideoTags(this.videoTagsRoot); @@ -540,10 +547,11 @@ export class VideoModal { statsContainer: statsContainerCandidate, }); - const previousTags = Array.isArray(this.videoTagsData) - ? [...this.videoTagsData] + const previousTags = Array.isArray(this.videoTagsSourceData) + ? [...this.videoTagsSourceData] : []; this.videoTagsData = null; + this.videoTagsSourceData = Object.freeze([]); this.moderationController.initialize({ playerModal }); @@ -1930,6 +1938,7 @@ export class VideoModal { destroy() { this.close(); + this.teardownVideoTagsResizeObserver(); this.commentsController.destroy(); this.reactionsController.destroy(); this.similarContentController.destroy(); @@ -2795,21 +2804,29 @@ export class VideoModal { return true; } - renderVideoTags(tags = []) { + renderVideoTags(tags = [], options = {}) { + const { force = false } = options || {}; const normalized = this.normalizeVideoTags(tags); - const hasTags = normalized.length > 0; const root = this.videoTagsRoot; if (root) { - if (this.areVideoTagsEqual(normalized, this.videoTagsData)) { - this.toggleVideoTagsVisibility(hasTags); - this.refreshTagPreferenceStates(); + if (!force && this.areVideoTagsEqual(normalized, this.videoTagsData)) { + const hasVisibleButtons = + root.querySelector("button[data-tag]") !== null; + this.toggleVideoTagsVisibility(hasVisibleButtons); + if (hasVisibleButtons) { + this.ensureVideoTagsResizeObserver(); + this.refreshTagPreferenceStates(); + } else { + this.teardownVideoTagsResizeObserver(); + } return; } this.cleanupVideoTags(root); - if (hasTags) { + let hasVisibleButtons = false; + if (normalized.length > 0) { const { root: strip } = renderTagPillStrip({ document: root.ownerDocument || this.document, tags: normalized, @@ -2819,13 +2836,119 @@ export class VideoModal { if (strip) { root.appendChild(strip); + trimTagPillStripToFit({ strip, container: root }); + + if (strip.querySelector("button[data-tag]")) { + hasVisibleButtons = true; + } else if (strip.parentNode === root) { + root.removeChild(strip); + } } } - this.toggleVideoTagsVisibility(hasTags); + this.toggleVideoTagsVisibility(hasVisibleButtons); + if (hasVisibleButtons) { + this.ensureVideoTagsResizeObserver(); + this.refreshTagPreferenceStates(); + } else { + this.teardownVideoTagsResizeObserver(); + } + } else { + this.teardownVideoTagsResizeObserver(); } - this.videoTagsData = Object.freeze([...normalized]); + const stored = Object.freeze([...normalized]); + this.videoTagsData = stored; + this.videoTagsSourceData = stored; + } + + reflowVideoTags() { + if (!Array.isArray(this.videoTagsSourceData)) { + return; + } + + if (this.videoTagsSourceData.length === 0) { + return; + } + + this.renderVideoTags([...this.videoTagsSourceData], { force: true }); + } + + ensureVideoTagsResizeObserver() { + const root = this.videoTagsRoot; + if (!root) { + return; + } + + const ResizeObserverCtor = + (this.window && this.window.ResizeObserver) || + globalThis.ResizeObserver || + null; + if (typeof ResizeObserverCtor !== "function") { + return; + } + + if (!this.videoTagsResizeObserver) { + this.videoTagsResizeObserver = new ResizeObserverCtor( + this.handleVideoTagsResize, + ); + } else if (typeof this.videoTagsResizeObserver.disconnect === "function") { + this.videoTagsResizeObserver.disconnect(); + } + + try { + this.videoTagsResizeObserver.observe(root); + } catch (error) { + // Ignore observer errors (e.g., observing a detached node). + } + } + + teardownVideoTagsResizeObserver() { + if (this.videoTagsResizeObserver) { + try { + this.videoTagsResizeObserver.disconnect(); + } catch (error) { + // Ignore teardown errors. + } + } + this.videoTagsResizeObserver = null; + this.videoTagsLastObservedWidth = 0; + } + + handleVideoTagsResize(entries = []) { + if (!this.videoTagsRoot) { + return; + } + + const entry = + Array.isArray(entries) && entries.length > 0 + ? entries.find((item) => item?.target === this.videoTagsRoot) || + entries[0] + : null; + + const measuredWidthRaw = entry + ? entry.contentRect && typeof entry.contentRect.width === "number" + ? entry.contentRect.width + : typeof entry.contentBoxSize?.[0]?.inlineSize === "number" + ? entry.contentBoxSize[0].inlineSize + : this.videoTagsRoot.clientWidth || 0 + : this.videoTagsRoot.clientWidth || 0; + + const measuredWidth = Number.isFinite(measuredWidthRaw) + ? Math.max(0, Math.round(measuredWidthRaw)) + : 0; + + if (measuredWidth <= 0) { + this.videoTagsLastObservedWidth = 0; + return; + } + + if (this.videoTagsLastObservedWidth === measuredWidth) { + return; + } + + this.videoTagsLastObservedWidth = measuredWidth; + this.reflowVideoTags(); } handleVideoTagActivate(tag, { event, button } = {}) { diff --git a/tests/video-modal-controllers.test.mjs b/tests/video-modal-controllers.test.mjs index b5849cdf..297bfb86 100644 --- a/tests/video-modal-controllers.test.mjs +++ b/tests/video-modal-controllers.test.mjs @@ -77,3 +77,116 @@ test("ModerationController reset clears moderation overlay references", async (t ); }); +test("VideoModal trims tag strip to fit modal width", async (t) => { + const { window, modal, cleanup } = await setupModal(); + t.after(cleanup); + + const root = modal.videoTagsRoot; + assert.ok(root, "tag container should be available after hydrate"); + + modal.setTagPreferenceStateResolver((tag) => { + if (tag === "alpha") { + return "interest"; + } + if (tag === "beta") { + return "disinterest"; + } + return "neutral"; + }); + + let containerWidth = 180; + Object.defineProperty(root, "clientWidth", { + configurable: true, + get() { + return containerWidth; + }, + }); + root.getBoundingClientRect = () => ({ width: containerWidth }); + + const elementPrototype = window.HTMLElement.prototype; + const originalScrollWidthDescriptor = + Object.getOwnPropertyDescriptor(elementPrototype, "scrollWidth") || null; + + Object.defineProperty(elementPrototype, "scrollWidth", { + configurable: true, + get() { + if (this.classList?.contains("video-tag-strip")) { + const buttonCount = this.querySelectorAll("button[data-tag]").length; + return buttonCount * 90; + } + if (originalScrollWidthDescriptor?.get) { + return originalScrollWidthDescriptor.get.call(this); + } + return 0; + }, + }); + + t.after(() => { + if (originalScrollWidthDescriptor) { + Object.defineProperty( + elementPrototype, + "scrollWidth", + originalScrollWidthDescriptor, + ); + } else { + delete elementPrototype.scrollWidth; + } + delete root.clientWidth; + delete root.getBoundingClientRect; + }); + + modal.renderVideoTags(["gamma", "beta", "delta", "alpha"]); + + const trimmedButtons = [ + ...root.querySelectorAll("button[data-tag]"), + ]; + assert.equal(trimmedButtons.length, 2, "tag list should be trimmed to fit"); + assert.deepEqual( + trimmedButtons.map((button) => button.dataset.tag), + ["alpha", "beta"], + "leading tags should remain after trimming", + ); + assert.equal( + trimmedButtons[0].dataset.preferenceState, + "interest", + "interest styling should persist on trimmed buttons", + ); + assert.equal( + trimmedButtons[0].dataset.variant, + "success", + "interest variant token should remain on trimmed buttons", + ); + assert.equal( + trimmedButtons[1].dataset.preferenceState, + "disinterest", + "disinterest styling should persist on trimmed buttons", + ); + assert.equal( + trimmedButtons[1].dataset.variant, + "critical", + "disinterest variant token should remain on trimmed buttons", + ); + assert.equal( + root.hasAttribute("hidden"), + false, + "tag container should stay visible when pills remain", + ); + + containerWidth = 300; + modal.reflowVideoTags(); + + const expandedButtons = [ + ...root.querySelectorAll("button[data-tag]"), + ]; + assert.equal( + expandedButtons.length, + 3, + "reflow should allow more tags when width increases", + ); + assert.deepEqual( + expandedButtons.map((button) => button.dataset.tag), + ["alpha", "beta", "delta"], + "reflow should restore full list order before trimming", + ); +}); +