mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Trim popular tag pills to container width
This commit is contained in:
1 parent
68a5fe83b2
commit
aff9d11d25
4 files changed
+353
-28
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user