Trim popular tag pills to container width

This commit is contained in:
thePR0M3TH3AN committed 2025-10-30 16:44:02 -04:00
1 parent 68a5fe83b2
commit aff9d11d25
4 files changed
+353 -28

No files matched your search

+8
View File
@@ -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;
+87 -5
View File
@@ -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 = [],
+167 -23
View File
@@ -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) {
@@ -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 });