From 27c1c4fa11aed4f42dd871465d5bffb407960bce Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 14 Oct 2025 08:01:34 -0400 Subject: [PATCH] refactor: use static classes for beacon utilities --- css/tailwind.source.css | 22 +++++ tests/torrent/style-helpers.test.mjs | 123 +++++---------------------- tests/torrent/toast-service.test.mjs | 5 +- torrent/app.js | 21 ++--- torrent/ui/styleHelpers.js | 38 +-------- torrent/ui/toastService.js | 25 ++++-- 6 files changed, 77 insertions(+), 157 deletions(-) diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 2ba38c5f..30a77823 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -1268,10 +1268,15 @@ } /* Torrent helper classes */ + .beacon-hidden-download, + [data-beacon-hidden-download], .torrent-download-anchor { display: none !important; } + .beacon-clipboard-offscreen, + [data-beacon-clipboard-state="offscreen"], + [data-beacon-clipboard="offscreen"], .torrent-clipboard-textarea { position: fixed; top: 0; @@ -1282,9 +1287,26 @@ pointer-events: none; } + .beacon-toast-motion, .torrent-toast-motion { transition-property: opacity, transform; will-change: opacity, transform; + opacity: 0; + transform: translateY(0.75rem); + } + + .beacon-toast-motion[data-beacon-motion="enter"], + .beacon-toast-motion[data-beacon-motion="show"], + .torrent-toast-motion[data-beacon-motion="enter"], + .torrent-toast-motion[data-beacon-motion="show"] { + opacity: 1; + transform: translateY(0); + } + + .beacon-toast-motion[data-beacon-motion="exit"], + .torrent-toast-motion[data-beacon-motion="exit"] { + opacity: 0; + transform: translateY(0.75rem); } .torrent-toast { diff --git a/tests/torrent/style-helpers.test.mjs b/tests/torrent/style-helpers.test.mjs index 9c7faf30..ebc4a6c5 100644 --- a/tests/torrent/style-helpers.test.mjs +++ b/tests/torrent/style-helpers.test.mjs @@ -1,5 +1,5 @@ import { strict as assert } from "node:assert"; -import { describe, it, beforeEach, afterEach } from "node:test"; +import { describe, it } from "node:test"; import { JSDOM } from "jsdom"; import { @@ -8,113 +8,34 @@ import { beaconDynamicFallbackClasses, } from "../../torrent/ui/styleHelpers.js"; -const ORIGINAL_WINDOW = globalThis.window; -const ORIGINAL_DOCUMENT = globalThis.document; -const ORIGINAL_ELEMENT = globalThis.HTMLElement; -const ORIGINAL_STYLE_ELEMENT = globalThis.HTMLStyleElement; - -let dom; - -function bootstrapDom() { - dom = new JSDOM("
", { - pretendToBeVisual: true, - }); - globalThis.window = dom.window; - globalThis.document = dom.window.document; - globalThis.HTMLElement = dom.window.HTMLElement; - globalThis.HTMLStyleElement = dom.window.HTMLStyleElement; -} - -function teardownDom() { - if (dom) { - dom.window.close(); - dom = null; - } - if (typeof ORIGINAL_WINDOW === "undefined") { - delete globalThis.window; - } else { - globalThis.window = ORIGINAL_WINDOW; - } - if (typeof ORIGINAL_DOCUMENT === "undefined") { - delete globalThis.document; - } else { - globalThis.document = ORIGINAL_DOCUMENT; - } - if (typeof ORIGINAL_ELEMENT === "undefined") { - delete globalThis.HTMLElement; - } else { - globalThis.HTMLElement = ORIGINAL_ELEMENT; - } - if (typeof ORIGINAL_STYLE_ELEMENT === "undefined") { - delete globalThis.HTMLStyleElement; - } else { - globalThis.HTMLStyleElement = ORIGINAL_STYLE_ELEMENT; - } -} - describe("torrent/ui/styleHelpers", () => { - beforeEach(() => { - bootstrapDom(); + it("returns null when no element is provided", () => { + assert.equal(applyBeaconDynamicStyles(null), null); }); - afterEach(() => { - teardownDom(); + it("returns the original element without mutations", () => { + const dom = new JSDOM(""); + const { document } = dom.window; + const element = document.createElement("div"); + + const result = applyBeaconDynamicStyles(element); + + assert.equal(result, element); + assert.equal(element.getAttributeNames().length, 0); }); - it("registers fallback classes for known slots", () => { - assert.equal( - beaconDynamicFallbackClasses.hiddenDownload, - "torrent-download-anchor", - ); - assert.equal( - beaconDynamicFallbackClasses.clipboard, - "torrent-clipboard-textarea", - ); - assert.equal(beaconDynamicFallbackClasses.toast, "torrent-toast-motion"); + it("provides an empty, frozen fallback map", () => { + assert.deepEqual(beaconDynamicFallbackClasses, {}); + assert.throws(() => { + beaconDynamicFallbackClasses.test = "nope"; + }, TypeError); }); - it("applies dynamic styles and fallback classes", () => { - const element = document.createElement("a"); - document.body.appendChild(element); - - const className = applyBeaconDynamicStyles( - element, - { display: "none" }, - "hiddenDownload", - ); - - assert.ok(className, "should receive a generated class name"); - assert.ok( - element.classList.contains("torrent-download-anchor"), - "fallback class should be present", - ); - assert.ok( - element.classList.contains(className), - "dynamic class should be applied", - ); - - const styleTag = document.getElementById("bitvid-style-system"); - assert.ok(styleTag, "style system tag should be created"); - }); - - it("removes dynamic styles and fallback classes", () => { - const element = document.createElement("textarea"); - document.body.appendChild(element); - - applyBeaconDynamicStyles(element, { opacity: "0" }, "clipboard"); - assert.ok(element.classList.contains("torrent-clipboard-textarea")); - - removeBeaconDynamicStyles(element, "clipboard"); - - assert.ok( - !element.classList.contains("torrent-clipboard-textarea"), - "fallback class should be removed", - ); - }); - - it("gracefully handles missing elements", () => { - const result = applyBeaconDynamicStyles(null, { display: "none" }); - assert.equal(result, null); + it("no-ops when removing styles", () => { assert.doesNotThrow(() => removeBeaconDynamicStyles(null)); + const dom = new JSDOM(""); + const { document } = dom.window; + const element = document.createElement("div"); + assert.doesNotThrow(() => removeBeaconDynamicStyles(element)); }); }); diff --git a/tests/torrent/toast-service.test.mjs b/tests/torrent/toast-service.test.mjs index 55778c3f..bf011a41 100644 --- a/tests/torrent/toast-service.test.mjs +++ b/tests/torrent/toast-service.test.mjs @@ -22,6 +22,7 @@ describe("torrent/ui/toastService", () => { it("renders a toast with Tailwind token classes and removes it on dismiss", async () => { const documentRef = dom.window.document; + documentRef.defaultView.requestAnimationFrame = (cb) => cb(); const toastManager = createToastManager(documentRef); const handle = toastManager.success("Magnet copied", { sticky: true }); @@ -31,7 +32,8 @@ describe("torrent/ui/toastService", () => { const wrapper = container.firstElementChild; assert.ok(wrapper, "toast wrapper should exist"); - assert.equal(wrapper.classList.contains("torrent-toast-motion"), true); + assert.equal(wrapper.classList.contains("beacon-toast-motion"), true); + assert.equal(wrapper.getAttribute("data-beacon-motion"), "enter"); const toast = wrapper.querySelector("div[role='status']"); assert.ok(toast, "toast element should be present"); @@ -43,6 +45,7 @@ describe("torrent/ui/toastService", () => { assert.ok(closeButton, "close button should be rendered"); handle.dismiss(); + assert.equal(wrapper.getAttribute("data-beacon-motion"), "exit"); await new Promise((resolve) => setTimeout(resolve, 250)); diff --git a/torrent/app.js b/torrent/app.js index 5d305bb2..684125ce 100644 --- a/torrent/app.js +++ b/torrent/app.js @@ -1,5 +1,4 @@ /* global WebTorrent, angular, moment */ -import { applyBeaconDynamicStyles } from "./ui/styleHelpers.js"; import { registerBeaconToast } from "./ui/toastService.js"; import { registerTorrentTable } from "./ui/torrentTable.js"; @@ -192,7 +191,8 @@ app.controller("BTorrentCtrl", [ // Create an anchor to trigger the download. const blobUrl = URL.createObjectURL(blob); const a = document.createElement("a"); - applyBeaconDynamicStyles(a, { display: "none" }, "hiddenDownload"); + a.classList.add("beacon-hidden-download"); + a.setAttribute("data-beacon-hidden-download", "true"); a.href = blobUrl; a.download = file.name; // Append, click, remove, revoke. @@ -224,18 +224,8 @@ app.controller("BTorrentCtrl", [ try { const textarea = document.createElement("textarea"); textarea.value = magnetURI; - applyBeaconDynamicStyles( - textarea, - { - position: "fixed", - top: "0", - left: "-9999px", - width: "0", - height: "0", - opacity: "0", - }, - "clipboard", - ); + textarea.classList.add("beacon-clipboard-offscreen"); + textarea.setAttribute("data-beacon-clipboard-state", "offscreen"); document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); @@ -256,7 +246,8 @@ app.controller("BTorrentCtrl", [ const fileName = torrent.fileName || `${torrent.name}.torrent`; // Create a hidden to force download of the .torrent file. const a = document.createElement("a"); - applyBeaconDynamicStyles(a, { display: "none" }, "hiddenDownload"); + a.classList.add("beacon-hidden-download"); + a.setAttribute("data-beacon-hidden-download", "true"); a.href = torrent.torrentFileBlobURL; a.download = fileName; document.body.appendChild(a); diff --git a/torrent/ui/styleHelpers.js b/torrent/ui/styleHelpers.js index c4c5c37a..23e3ff5a 100644 --- a/torrent/ui/styleHelpers.js +++ b/torrent/ui/styleHelpers.js @@ -1,37 +1,7 @@ -import { applyDynamicStyles, removeDynamicStyles } from "../../js/ui/styleSystem.js"; - -const FALLBACK_CLASSES = { - hiddenDownload: "torrent-download-anchor", - clipboard: "torrent-clipboard-textarea", - toast: "torrent-toast-motion", -}; - -export function applyBeaconDynamicStyles(element, styleObject = {}, slotKey) { - if (!element) { - return null; - } - - const slot = slotKey ? String(slotKey) : undefined; - - if (slot && FALLBACK_CLASSES[slot]) { - element.classList.add(FALLBACK_CLASSES[slot]); - } - - return applyDynamicStyles(element, styleObject, slot ? { slot } : {}); +export function applyBeaconDynamicStyles(element) { + return element ?? null; } -export function removeBeaconDynamicStyles(element, slotKey) { - if (!element) { - return; - } +export function removeBeaconDynamicStyles() {} - const slot = slotKey ? String(slotKey) : undefined; - - if (slot && FALLBACK_CLASSES[slot]) { - element.classList.remove(FALLBACK_CLASSES[slot]); - } - - removeDynamicStyles(element, slot ? { slot } : {}); -} - -export const beaconDynamicFallbackClasses = { ...FALLBACK_CLASSES }; +export const beaconDynamicFallbackClasses = Object.freeze({}); diff --git a/torrent/ui/toastService.js b/torrent/ui/toastService.js index e1398afb..88a90ba4 100644 --- a/torrent/ui/toastService.js +++ b/torrent/ui/toastService.js @@ -1,5 +1,3 @@ -import { applyBeaconDynamicStyles } from "./styleHelpers.js"; - const TOAST_TYPE_CLASSES = { info: "torrent-toast torrent-toast--info", success: "torrent-toast torrent-toast--success", @@ -8,6 +6,19 @@ const TOAST_TYPE_CLASSES = { }; const TRANSITION_CLASSES = "transition duration-200 ease-out"; +const MOTION_CLASS = "beacon-toast-motion"; +const MOTION_ATTR = "data-beacon-motion"; + +function scheduleMotionFrame(documentRef, callback) { + const view = documentRef?.defaultView; + + if (view && typeof view.requestAnimationFrame === "function") { + view.requestAnimationFrame(callback); + return; + } + + setTimeout(callback, 16); +} function ensureContainer(doc) { const documentRef = doc || (typeof document !== "undefined" ? document : null); @@ -76,9 +87,9 @@ function buildToastElement(documentRef, message, typeClass) { "relative", "w-full", TRANSITION_CLASSES, - "translate-y-3", - "opacity-0", + MOTION_CLASS, ].join(" "); + wrapper.setAttribute(MOTION_ATTR, "exit"); wrapper.appendChild(toast); toast.appendChild(closeButton); @@ -108,11 +119,13 @@ export function createToastManager(documentRef, timeoutRef = setTimeout, clearTi container.appendChild(wrapper); - applyBeaconDynamicStyles(wrapper, { opacity: "1", transform: "translateY(0)" }, "toast"); + scheduleMotionFrame(documentRef, () => { + wrapper.setAttribute(MOTION_ATTR, "enter"); + }); const duration = typeof options.duration === "number" ? options.duration : 3500; const cleanup = () => { - applyBeaconDynamicStyles(wrapper, { opacity: "0", transform: "translateY(0.75rem)" }, "toast"); + wrapper.setAttribute(MOTION_ATTR, "exit"); const removalTimer = timeoutRef(() => { if (wrapper.parentNode) { wrapper.parentNode.removeChild(wrapper);