UNPKG

remarqueeble

Version:

Custom element tribute to the cursed glory of marquee.

365 lines (358 loc) 13.1 kB
/*! remarqueeble v0.6.0 | (c) 2026 Rémino Rem <https://remino.net/> | ISC Licence */ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); //#region src/lib/remarqueeble.ts var DEFAULT_DIRECTION = "left"; var DEFAULT_BEHAVIOR = "scroll"; var DEFAULT_ANIMATE = "always"; var DEFAULT_SCROLL_AMOUNT = 6; var DEFAULT_SCROLL_AMOUNT_LENGTH = `${DEFAULT_SCROLL_AMOUNT}px`; var DEFAULT_SCROLL_DELAY = 85; var MIN_SCROLL_DELAY = 60; var DEFAULT_VERTICAL_HEIGHT = "200px"; var DEFAULT_HOST_WIDTH = "calc(100% - (var(--attr-hspace, 0px) * 2))"; var ATTR_DIRECTION = "direction"; var ATTR_BEHAVIOR = "behavior"; var ATTR_ANIMATE = "animate"; var ATTR_SCROLL_AMOUNT = "scrollamount"; var ATTR_SCROLL_DELAY = "scrolldelay"; var ATTR_TRUE_SPEED = "truespeed"; var ATTR_LOOP = "loop"; var ATTR_BG_COLOR = "bgcolor"; var ATTR_WIDTH = "width"; var ATTR_HEIGHT = "height"; var ATTR_HSPACE = "hspace"; var ATTR_VSPACE = "vspace"; var CSS_VAR_WIDTH = "--attr-width"; var CSS_VAR_HEIGHT = "--attr-height"; var CSS_VAR_HSPACE = "--attr-hspace"; var CSS_VAR_VSPACE = "--attr-vspace"; var CSS_VAR_BG_COLOR = "--attr-bgcolor"; var CSS_VAR_ANIMATION_DURATION = "--animation-duration"; var CSS_VAR_ANIMATION_DIRECTION = "--animation-direction"; var CSS_VAR_ANIMATION_ITERATION_COUNT = "--animation-iteration-count"; var CSS_VAR_ANIMATION_PLAY_STATE = "--animation-play-state"; var CSS_VAR_ANIMATION_TIMING_FUNCTION = "--animation-timing-function"; var CSS_VAR_TRANSLATE_X_END = "--translate-x-end"; var CSS_VAR_TRANSLATE_X_START = "--translate-x-start"; var CSS_VAR_TRANSLATE_Y_END = "--translate-y-end"; var CSS_VAR_TRANSLATE_Y_START = "--translate-y-start"; var HTMLElementBase = globalThis.HTMLElement ?? class {}; var parsePresentationalDimension = (value) => { if (value === null) return null; const trimmed = value.trim(); if (trimmed === "") return null; if (/^[+-]?(?:\d+|\d*\.\d+)$/.test(trimmed)) return `${trimmed}px`; return globalThis.CSS?.supports("width", trimmed) ? trimmed : null; }; var parseScrollAmount = (value) => { if (value === null) return null; const trimmed = value.trim(); if (trimmed === "") return null; if (/^[+-]?(?:\d+|\d*\.\d+)$/.test(trimmed)) return Number(trimmed) >= 0 ? `${trimmed}px` : null; return globalThis.CSS?.supports("width", trimmed) ? trimmed : null; }; var parseLegacyColor = (value) => { if (value === null) return null; const trimmed = value.trim(); if (trimmed === "") return null; return globalThis.CSS?.supports("background-color", trimmed) ? trimmed : null; }; var ATTRIBUTE_HINTS = [ { attribute: ATTR_WIDTH, cssVar: CSS_VAR_WIDTH, parser: parsePresentationalDimension }, { attribute: ATTR_HEIGHT, cssVar: CSS_VAR_HEIGHT, parser: parsePresentationalDimension, fallback(element) { return element.isVerticalDirection && !element.hasAttribute(ATTR_HEIGHT) ? DEFAULT_VERTICAL_HEIGHT : null; } }, { attribute: ATTR_HSPACE, cssVar: CSS_VAR_HSPACE, parser: parsePresentationalDimension }, { attribute: ATTR_VSPACE, cssVar: CSS_VAR_VSPACE, parser: parsePresentationalDimension }, { attribute: ATTR_BG_COLOR, cssVar: CSS_VAR_BG_COLOR, parser: parseLegacyColor } ]; var RemarqueebleElement = class extends HTMLElementBase { static observedAttributes = [ ATTR_DIRECTION, ATTR_BEHAVIOR, ATTR_ANIMATE, ATTR_SCROLL_AMOUNT, ATTR_SCROLL_DELAY, ATTR_TRUE_SPEED, ATTR_LOOP, ATTR_BG_COLOR, ATTR_WIDTH, ATTR_HEIGHT, ATTR_HSPACE, ATTR_VSPACE ]; track; scrollAmountProbe; running = false; constructor() { super(); const shadowRoot = this.attachShadow({ mode: "open" }); shadowRoot.innerHTML = ` <style> :host { display: inline-block; text-align: initial; overflow: hidden !important; white-space: nowrap; width: var(${CSS_VAR_WIDTH}, ${DEFAULT_HOST_WIDTH}); height: var(${CSS_VAR_HEIGHT}, auto); margin-inline: var(${CSS_VAR_HSPACE}, 0px); margin-block: var(${CSS_VAR_VSPACE}, 0px); background-color: var(${CSS_VAR_BG_COLOR}, transparent); box-sizing: border-box; } :host([direction="up"]), :host([direction="down"]) { white-space: normal; } .track { animation: remarqueeble-motion var(${CSS_VAR_ANIMATION_DURATION}, 10s) var(${CSS_VAR_ANIMATION_TIMING_FUNCTION}, linear) var(${CSS_VAR_ANIMATION_ITERATION_COUNT}, infinite) var(${CSS_VAR_ANIMATION_DIRECTION}, normal) both; animation-play-state: var(${CSS_VAR_ANIMATION_PLAY_STATE}, running); display: inline-block; will-change: transform; } .scrollamount-probe { block-size: 0; display: block; inline-size: ${DEFAULT_SCROLL_AMOUNT_LENGTH}; overflow: hidden; pointer-events: none; position: absolute; visibility: hidden; } @keyframes remarqueeble-motion { from { transform: translate( var(${CSS_VAR_TRANSLATE_X_START}, 100%), var(${CSS_VAR_TRANSLATE_Y_START}, 0px) ); } to { transform: translate( var(${CSS_VAR_TRANSLATE_X_END}, -100%), var(${CSS_VAR_TRANSLATE_Y_END}, 0px) ); } } </style> <span class="track"><slot></slot></span> <span class="scrollamount-probe" aria-hidden="true"></span> `; const track = shadowRoot.querySelector(".track"); if (!track) throw new Error("Remarqueeble track element was not created."); const scrollAmountProbe = shadowRoot.querySelector(".scrollamount-probe"); if (!scrollAmountProbe) throw new Error("Remarqueeble scrollamount probe was not created."); this.track = track; this.scrollAmountProbe = scrollAmountProbe; this.track.addEventListener("animationend", () => this.handleAnimationEnd()); } connectedCallback() { this.running = true; this.syncPresentationalHints(); requestAnimationFrame(() => { if (!this.isConnected || !this.running) return; this.reset(); }); } disconnectedCallback() { this.running = false; } attributeChangedCallback(_name, oldValue, newValue) { if (oldValue === newValue) return; this.syncPresentationalHints(); if (this.isConnected) this.reset(); } get direction() { return this.getAttribute(ATTR_DIRECTION) || DEFAULT_DIRECTION; } get behavior() { return this.getAttribute(ATTR_BEHAVIOR) || DEFAULT_BEHAVIOR; } get scrollAmount() { const value = parseScrollAmount(this.getAttribute(ATTR_SCROLL_AMOUNT)) ?? DEFAULT_SCROLL_AMOUNT_LENGTH; this.scrollAmountProbe.style.inlineSize = value; const measured = this.scrollAmountProbe.getBoundingClientRect().width; return Number.isFinite(measured) && measured >= 0 ? measured : DEFAULT_SCROLL_AMOUNT; } get scrollDelay() { const raw = this.getAttribute(ATTR_SCROLL_DELAY); const value = raw === null || raw.trim() === "" ? NaN : Number(raw); const delay = Number.isFinite(value) && value >= 0 ? value : DEFAULT_SCROLL_DELAY; if (this.hasAttribute(ATTR_TRUE_SPEED)) return delay; return Math.max(delay, MIN_SCROLL_DELAY); } get loop() { const value = this.getAttribute(ATTR_LOOP); return value === null ? -1 : Number(value); } get directionSign() { return this.direction === "right" || this.direction === "down" ? 1 : -1; } get isVerticalDirection() { return this.direction === "up" || this.direction === "down"; } start() { if (this.running) return; this.running = true; this.reset(); this.syncAnimationPlayState(); } stop() { this.running = false; this.syncAnimationPlayState(); } syncPresentationalHints() { for (const hint of ATTRIBUTE_HINTS) this.syncVar(hint); this.syncAnimationPlayState(); } syncVar(hint) { const raw = this.getAttribute(hint.attribute); const value = hint.parser(raw); const fallback = hint.fallback ? hint.fallback(this) : null; if (value == null) { if (fallback == null) this.style.removeProperty(hint.cssVar); else this.style.setProperty(hint.cssVar, fallback); return; } this.style.setProperty(hint.cssVar, value); } reset() { const hostSize = this.getHostSize(); const trackSize = this.getTrackSize(); this.syncAnimation(hostSize, trackSize); } getHostSize() { return this.isVerticalDirection ? this.clientHeight : this.clientWidth; } getTrackSize() { return this.isVerticalDirection ? this.track.offsetHeight : this.track.offsetWidth; } getStartPosition(hostSize, trackSize) { return this.directionSign < 0 ? hostSize : -trackSize; } getFlushEndPosition(hostSize, trackSize) { return this.directionSign < 0 ? 0 : hostSize - trackSize; } getOffEndPosition(hostSize, trackSize) { return this.directionSign < 0 ? -trackSize : hostSize; } getSlideEndPosition(hostSize, trackSize) { return this.directionSign < 0 ? 0 : hostSize - trackSize; } getAlternateStartPosition(hostSize, trackSize) { return this.directionSign < 0 ? hostSize - trackSize : 0; } syncAnimationPlayState() { this.style.setProperty(CSS_VAR_ANIMATION_PLAY_STATE, this.running ? "running" : "paused"); } syncAnimation(hostSize, trackSize) { if (!this.shouldAnimate(hostSize, trackSize) || this.scrollAmount === 0) { this.syncStaticAnimation(); return; } const startPosition = this.behavior === "alternate" ? this.getAlternateStartPosition(hostSize, trackSize) : this.getStartPosition(hostSize, trackSize); const endPosition = this.behavior === "slide" ? this.getSlideEndPosition(hostSize, trackSize) : this.behavior === "alternate" ? this.getFlushEndPosition(hostSize, trackSize) : this.getOffEndPosition(hostSize, trackSize); const distance = Math.abs(endPosition - startPosition); const steps = Math.max(1, Math.ceil(distance / Math.max(1, this.scrollAmount))); const duration = Math.max(1, steps * this.scrollDelay); const iterationCount = this.getCssIterationCount(); this.track.style.removeProperty("transform"); this.style.setProperty(CSS_VAR_ANIMATION_DURATION, `${duration}ms`); this.style.setProperty(CSS_VAR_ANIMATION_DIRECTION, this.behavior === "alternate" ? "alternate" : "normal"); this.style.setProperty(CSS_VAR_ANIMATION_ITERATION_COUNT, iterationCount); this.style.setProperty(CSS_VAR_ANIMATION_TIMING_FUNCTION, `steps(${steps}, end)`); if (this.isVerticalDirection) { this.style.setProperty(CSS_VAR_TRANSLATE_X_START, "0px"); this.style.setProperty(CSS_VAR_TRANSLATE_X_END, "0px"); this.style.setProperty(CSS_VAR_TRANSLATE_Y_START, `${startPosition}px`); this.style.setProperty(CSS_VAR_TRANSLATE_Y_END, `${endPosition}px`); } else { this.style.setProperty(CSS_VAR_TRANSLATE_X_START, `${startPosition}px`); this.style.setProperty(CSS_VAR_TRANSLATE_X_END, `${endPosition}px`); this.style.setProperty(CSS_VAR_TRANSLATE_Y_START, "0px"); this.style.setProperty(CSS_VAR_TRANSLATE_Y_END, "0px"); } this.restartAnimation(); } shouldAnimate(hostSize, trackSize) { if (this.animationMode === "never") return false; if (this.animationMode === "overflow") return trackSize > hostSize; return true; } get animationMode() { const value = this.getAttribute(ATTR_ANIMATE); if (value === "overflow" || value === "never") return value; return DEFAULT_ANIMATE; } syncStaticAnimation() { this.style.setProperty(CSS_VAR_ANIMATION_DURATION, "0ms"); this.style.setProperty(CSS_VAR_ANIMATION_DIRECTION, "normal"); this.style.setProperty(CSS_VAR_ANIMATION_ITERATION_COUNT, "1"); this.style.setProperty(CSS_VAR_ANIMATION_TIMING_FUNCTION, "linear"); this.style.setProperty(CSS_VAR_TRANSLATE_X_START, "0px"); this.style.setProperty(CSS_VAR_TRANSLATE_X_END, "0px"); this.style.setProperty(CSS_VAR_TRANSLATE_Y_START, "0px"); this.style.setProperty(CSS_VAR_TRANSLATE_Y_END, "0px"); this.track.style.animationName = "none"; this.track.style.transform = "translate(0px, 0px)"; } getCssIterationCount() { if (this.behavior === "slide" && !this.hasAttribute(ATTR_LOOP)) return "1"; if (!this.hasAttribute(ATTR_LOOP)) return "infinite"; if (Number.isFinite(this.loop) && this.loop > 0) return String(this.loop); return "infinite"; } handleAnimationEnd() { if (!this.hasFiniteAnimation()) return; this.running = false; this.syncAnimationPlayState(); } restartAnimation() { this.track.style.animationName = "none"; this.track.offsetWidth; this.track.style.removeProperty("animation-name"); } hasFiniteAnimation() { if (this.behavior === "slide" && !this.hasAttribute(ATTR_LOOP)) return true; return this.hasAttribute(ATTR_LOOP) && Number.isFinite(this.loop) && this.loop > 0; } }; var ReMarqueeElement = class extends RemarqueebleElement {}; var ReMarqueeBleElement = class extends RemarqueebleElement {}; var defineRemarqueebleElements = () => { if (typeof customElements === "undefined") return; if (!customElements.get("re-marquee")) customElements.define("re-marquee", ReMarqueeElement); if (!customElements.get("re-marquee-ble")) customElements.define("re-marquee-ble", ReMarqueeBleElement); }; //#endregion exports.RemarqueebleElement = RemarqueebleElement; exports.defineRemarqueebleElements = defineRemarqueebleElements; exports.parseLegacyColor = parseLegacyColor; exports.parsePresentationalDimension = parsePresentationalDimension; exports.parseScrollAmount = parseScrollAmount;