Merge pull request #1022 from PR0M3TH3AN/codex/cap-modal-tag-list-using-tag-strip-helper

Trim video modal tags to container width
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-30 21:02:45 -04:00
commit 3d60d865fa
3 files changed
+247 -13

No files matched your search

+1 -3
View File
@@ -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 {
+133 -10
View File
@@ -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 } = {}) {
+113
View File
@@ -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",
);
});