diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 92b9f1b1..b2a0bece 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2424,27 +2424,14 @@ overflow-y: hidden; -webkit-overflow-scrolling: touch; padding-bottom: var(--space-xs); - scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb) transparent; + scrollbar-width: none; + -ms-overflow-style: none; } .video-tag-strip--scroll::-webkit-scrollbar { - height: 4px; - } - - .video-tag-strip--scroll::-webkit-scrollbar-track { - background: - linear-gradient( - to bottom, - transparent calc(50% - 0.5px), - var(--scrollbar-track) calc(50% - 0.5px) calc(50% + 0.5px), - transparent calc(50% + 0.5px) - ); - } - - .video-tag-strip--scroll::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border-radius: var(--radius-pill); + display: none; + width: 0; + height: 0; } .video-tag-pill { diff --git a/js/ui/components/hashtagStripHelper.js b/js/ui/components/hashtagStripHelper.js index b141e414..beb90108 100644 --- a/js/ui/components/hashtagStripHelper.js +++ b/js/ui/components/hashtagStripHelper.js @@ -200,6 +200,8 @@ export class HashtagStripHelper { this._tagStrip = strip; if (!this.scrollable) { trimTagPillStripToFit({ strip, container: root }); + } else { + this._triggerScrollHint(); } root.hidden = strip.childElementCount === 0; } finally { @@ -218,6 +220,44 @@ export class HashtagStripHelper { this._sortedTags = []; } + _triggerScrollHint() { + const strip = this._tagStrip; + if (!strip) { + return; + } + + if ( + this.window && + typeof this.window.requestAnimationFrame === "function" && + typeof strip.scrollTo === "function" + ) { + this.window.requestAnimationFrame(() => { + if (!strip.isConnected) { + return; + } + + if (strip.scrollWidth > strip.clientWidth) { + if (this.window && typeof this.window.setTimeout === "function") { + this.window.setTimeout(() => { + if (!strip.isConnected || strip.scrollLeft !== 0) { + return; + } + + strip.scrollTo({ left: 40, behavior: "smooth" }); + + this.window.setTimeout(() => { + if (!strip.isConnected) { + return; + } + strip.scrollTo({ left: 0, behavior: "smooth" }); + }, 800); + }, 600); + } + } + }); + } + } + _resolveTagState(tag) { const resolver = this.tagStateResolver; if (typeof resolver !== "function") { diff --git a/tests/ui/hashtag-strip-helper-bounce.test.mjs b/tests/ui/hashtag-strip-helper-bounce.test.mjs new file mode 100644 index 00000000..50d682c1 --- /dev/null +++ b/tests/ui/hashtag-strip-helper-bounce.test.mjs @@ -0,0 +1,144 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import { HashtagStripHelper } from "../../js/ui/components/hashtagStripHelper.js"; + +function createDom() { + const dom = new JSDOM("
", { + pretendToBeVisual: true, + }); + + // Mock requestAnimationFrame to capture callback + let rafCallback = null; + dom.window.requestAnimationFrame = (cb) => { + rafCallback = cb; + return 1; + }; + + // Helper to trigger pending RAF + dom.window.triggerRaf = () => { + if (rafCallback) { + const cb = rafCallback; + rafCallback = null; + cb(); + return true; + } + return false; + }; + + // Mock createElement to ensure scrollTo exists on created elements + // This is required because JSDOM might not implement scrollTo on div elements + // and _triggerScrollHint checks for it. + const originalCreateElement = dom.window.document.createElement; + dom.window.document.createElement = function(tagName, options) { + const el = originalCreateElement.call(dom.window.document, tagName, options); + if (typeof el.scrollTo !== 'function') { + el.scrollTo = () => {}; + } + return el; + }; + + return { dom, window: dom.window, document: dom.window.document }; +} + +test("HashtagStripHelper triggers scroll hint when content overflows", (t) => { + const { window, document } = createDom(); + const container = document.getElementById("container"); + + // Mock setTimeout + const timeouts = []; + window.setTimeout = (cb, delay) => { + timeouts.push({ cb, delay }); + return timeouts.length; + }; + + const helper = new HashtagStripHelper({ + window, + document, + scrollable: true, + }); + + helper.mount(container); + + helper._sortedTags = ["Tag1", "Tag2", "Tag3"]; + + // Render creates the element and requests RAF + helper.render(); + + const strip = container.querySelector(".video-tag-strip"); + assert(strip, "Strip element should be rendered"); + + // Mock scroll properties on the newly created element + Object.defineProperty(strip, "scrollWidth", { value: 200, configurable: true }); + Object.defineProperty(strip, "clientWidth", { value: 100, configurable: true }); + Object.defineProperty(strip, "scrollLeft", { value: 0, writable: true, configurable: true }); + Object.defineProperty(strip, "isConnected", { value: true, configurable: true }); + + // Mock scrollTo to spy on calls + let scrollToCalls = []; + strip.scrollTo = (options) => { + scrollToCalls.push(options); + }; + + // Trigger the RAF callback which was scheduled during render() + const triggered = window.triggerRaf(); + assert(triggered, "Should have scheduled a RAF callback"); + + assert.equal(timeouts.length, 1, "Should schedule initial timeout"); + + // Execute first timeout + const firstTimeout = timeouts.shift(); + assert.equal(firstTimeout.delay, 600, "First timeout should be 600ms"); + firstTimeout.cb(); // Execute + + assert.equal(scrollToCalls.length, 1, "Should have called scrollTo once"); + assert.equal(scrollToCalls[0].left, 40, "Should scroll to 40px"); + + assert.equal(timeouts.length, 1, "Should schedule second timeout"); + const secondTimeout = timeouts.shift(); + assert.equal(secondTimeout.delay, 800, "Second timeout should be 800ms"); + + // Execute second timeout + secondTimeout.cb(); + + assert.equal(scrollToCalls.length, 2, "Should have called scrollTo twice"); + assert.equal(scrollToCalls[1].left, 0, "Should scroll back to 0px"); +}); + +test("HashtagStripHelper does not trigger scroll hint when no overflow", (t) => { + const { window, document } = createDom(); + const container = document.getElementById("container"); + + const timeouts = []; + window.setTimeout = (cb, delay) => { + timeouts.push({ cb, delay }); + return timeouts.length; + }; + + const helper = new HashtagStripHelper({ + window, + document, + scrollable: true, + }); + + helper.mount(container); + helper._sortedTags = ["Tag1"]; + helper.render(); + + const strip = container.querySelector(".video-tag-strip"); + + // Mock properties + Object.defineProperty(strip, "scrollWidth", { value: 100, configurable: true }); + Object.defineProperty(strip, "clientWidth", { value: 100, configurable: true }); + Object.defineProperty(strip, "isConnected", { value: true, configurable: true }); + + strip.scrollTo = () => { + throw new Error("Should not scroll"); + }; + + // Trigger RAF + const triggered = window.triggerRaf(); + assert(triggered, "Should have scheduled a RAF callback"); + + assert.equal(timeouts.length, 0, "Should not schedule timeout"); +});