/** * wc-timer * @version 1.1.0 * @description A lightweight, efficient countdown/uptimer Web Component. * @license MIT */ /** * Internal cache for Intl objects to optimize performance. * @private */ const IntlCache = (() => { const cache = new Map(); function get(Ctor, locale, opts = {}) { const key = `${Ctor.name}:${locale}:${JSON.stringify(opts)}`; if (!cache.has(key)) { cache.set(key, new Ctor(locale, opts)); } return cache.get(key); } return { dtf: (loc, opts) => get(Intl.DateTimeFormat, loc, opts), pr: (loc) => get(Intl.PluralRules, loc), df: (loc, opts) => get(Intl.DurationFormat, loc, opts), hasIDF: typeof Intl !== 'undefined' && 'DurationFormat' in Intl }; })(); /** * Centralized ticker to sync all instances and prevent drift. * @private */ const TickHub = (() => { const subs = new Set(); let timer = null; function scheduleNext() { if (subs.size === 0) return; const now = performance.now(); const msToNext = 1000 - (Math.floor(now) % 1000); timer = setTimeout(() => { const t = Date.now(); for (const cb of [...subs]) { try { cb(t); } catch (e) { console.error(e); } } scheduleNext(); }, msToNext); } return { sub(cb) { subs.add(cb); if (!timer) scheduleNext(); return () => { subs.delete(cb); if (subs.size === 0 && timer) { clearTimeout(timer); timer = null; } }; } }; })(); /* ---------------- Locale Helpers ---------------- */ const LABELS = { en: { y: { long: { one: 'year', other: 'years' }, short: 'y' }, M: { long: { one: 'month', other: 'months' }, short: 'mo' }, d: { long: { one: 'day', other: 'days' }, short: 'd' }, h: { long: { one: 'hour', other: 'hours' }, short: 'h' }, m: { long: { one: 'minute', other: 'minutes' }, short: 'min' }, s: { long: { one: 'second', other: 'seconds' }, short: 's' }, }, pl: { y: { long: { one: 'rok', few: 'lata', many: 'lat', other: 'lat' }, short: 'r.' }, M: { long: { one: 'miesiąc', few: 'miesiące', many: 'miesięcy', other: 'miesięcy' }, short: 'mies.' }, d: { long: { one: 'dzień', few: 'dni', many: 'dni', other: 'dni' }, short: 'd' }, h: { long: { one: 'godzina', few: 'godziny', many: 'godzin', other: 'godzin' }, short: 'godz.' }, m: { long: { one: 'minuta', few: 'minuty', many: 'minut', other: 'minut' }, short: 'min' }, s: { long: { one: 'sekunda', few: 'sekundy', many: 'sekund', other: 'sekund' }, short: 's' }, } }; function resolveLocale(el) { const attr = el.getAttribute('locale') || el.getAttribute('lang'); if (attr) return attr.toLowerCase(); return (navigator.language || 'en').toLowerCase(); } /* ---------------- Date Logic ---------------- */ function diffCalendar(a, b, wantY, wantM) { let anchor = new Date(a); let y = 0; let M = 0; if (wantY) { y = b.getFullYear() - anchor.getFullYear(); const probe = new Date(anchor); probe.setFullYear(anchor.getFullYear() + y); if (probe > b) y--; anchor.setFullYear(anchor.getFullYear() + y); } if (wantM) { M = (b.getFullYear() - anchor.getFullYear()) * 12 + (b.getMonth() - anchor.getMonth()); const probe = new Date(anchor); probe.setMonth(anchor.getMonth() + M); if (probe > b) M--; anchor.setMonth(anchor.getMonth() + M); } return { y, M, anchor }; } function collapseForUnits(start, end, show) { const { y, M, anchor } = diffCalendar(start, end, !!show.y, !!show.M); const base = (show.y || show.M) ? anchor : start; let rest = Math.max(0, end - base); const MS = { d: 86400000, h: 3600000, m: 60000, s: 1000 }; const res = { years: y, months: M, days: 0, hours: 0, minutes: 0, seconds: 0 }; if (show.d) { res.days = Math.floor(rest / MS.d); rest %= MS.d; } if (show.h) { res.hours = Math.floor(rest / MS.h); rest %= MS.h; } if (show.m) { res.minutes = Math.floor(rest / MS.m); rest %= MS.m; } if (show.s) { res.seconds = Math.floor(rest / MS.s); } return res; } /* ---------------- Formatting ---------------- */ const pad2 = (n) => String(n ?? 0).padStart(2, '0'); function formatDurationSmart(locale, labelsMode, show, parts) { if (labelsMode === 'none') { const head = []; if (show.y) head.push(parts.years); if (show.M) head.push(parts.months); if (show.d) head.push(parts.days); const hms = [pad2(parts.hours), pad2(parts.minutes), pad2(parts.seconds)]; if (!show.h) hms.shift(); return [...head, hms.join(':')].join(' '); } if (IntlCache.hasIDF) { const opts = { style: labelsMode === 'short' ? 'short' : 'long' }; if (show.h) opts.hours = '2-digit'; if (show.m) opts.minutes = '2-digit'; if (show.s) opts.seconds = '2-digit'; const inParts = {}; for (const key in parts) { if (show[key.slice(0, 1)] || show[key.slice(0, 2)]) inParts[key] = parts[key]; } return IntlCache.df(locale, opts).format(inParts); } // Fallback const lang2 = LABELS[locale.slice(0, 2)] ? locale.slice(0, 2) : 'en'; const L = LABELS[lang2]; const pr = IntlCache.pr(locale); const out = []; const add = (k, v, forcePad = false) => { const cat = pr.select(v); const word = L[k].long[cat] ?? L[k].long.other; const num = forcePad ? pad2(v) : v; const suffix = labelsMode === 'short' ? L[k].short : word; out.push(`${num} ${suffix}`); }; if (show.y) add('y', parts.years); if (show.M) add('M', parts.months); if (show.d) add('d', parts.days); if (show.h) add('h', parts.hours, true); if (show.m) add('m', parts.minutes, true); if (show.s) add('s', parts.seconds, true); return out.join(' '); } function getShowConfig(preset, diffMs) { const base = { y: false, M: false, d: true, h: true, m: true, s: true }; switch (preset) { case 'hms': return { ...base, d: false }; case 'dhms': return base; case 'ydhms': return { y: true, M: true, d: true, h: true, m: true, s: true }; case 'auto': return diffMs >= 2592000000 ? base : { ...base, d: false }; default: return base; } } /* ---------------- Web Component ---------------- */ const sheet = new CSSStyleSheet(); sheet.replaceSync(` :host { display:inline-block; color: inherit; user-select: text; -webkit-user-select: text; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; white-space: nowrap; min-height: 1.2em; line-height: 1.2; } `); /** * A versatile countdown/uptimer Web Component that renders as selectable text. * @element wc-timer * @attr {string} target - The target date/time (ISO string or anything Date.parse accepts). * @attr {string} [preset="auto"] - Display configuration ('auto', 'hms', 'dhms', 'ydhms', 'date'). * @attr {string} [labels="short"] - Label style ('none', 'short', 'long'). * @attr {boolean} [compact] - If set, hides high-order units that are zero. * @attr {string} [locale] - Force a specific locale (e.g. 'pl', 'en-US'). * @example * * */ class WcTimer extends HTMLElement { static get observedAttributes() { return ['target', 'preset', 'labels', 'locale', 'lang', 'compact']; } #target = new Date(); #preset = 'auto'; #labels = 'short'; #compact = false; #locale = 'en'; #unsub = null; #textNode = null; #isAttrUpdate = false; constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.adoptedStyleSheets = [sheet]; shadow.appendChild(document.createElement('slot')); } connectedCallback() { // Ensures element has dimensions before JS runs (CLS prevention) if (!this.firstChild || this.firstChild.nodeType !== Node.TEXT_NODE) { this.textContent = ''; this.#textNode = document.createTextNode('\u00A0'); this.appendChild(this.#textNode); } else { this.#textNode = this.firstChild; } this.#syncAttrs(); this.#startTicking(); } disconnectedCallback() { this.#stopTicking(); } attributeChangedCallback() { if (this.#isAttrUpdate) return; this.#syncAttrs(); this.#render(Date.now()); this.#checkRestart(); } #syncAttrs() { const tRaw = this.getAttribute('target'); this.#target = tRaw ? new Date(tRaw) : new Date(); if (isNaN(this.#target)) this.#target = new Date(); this.#preset = (this.getAttribute('preset') || 'auto').toLowerCase(); this.#labels = (this.getAttribute('labels') || 'short').toLowerCase(); this.#compact = this.hasAttribute('compact'); this.#locale = resolveLocale(this); } #startTicking() { if (!this.#unsub) { this.#unsub = TickHub.sub((now) => this.#render(now)); this.#render(Date.now()); } } #stopTicking() { if (this.#unsub) { this.#unsub(); this.#unsub = null; } } #checkRestart() { const now = Date.now(); if (this.#target > now && !this.#unsub) { this.#startTicking(); } } #render(nowMs) { if (!this.#textNode) return; if (this.#preset === 'date') { const dtf = IntlCache.dtf(this.#locale, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); const txt = dtf.format(this.#target); if (this.#textNode.data !== txt) this.#textNode.data = txt; return; } const now = new Date(nowMs); const diff = this.#target - now; const isFuture = diff >= 0; if (!isFuture && this.#unsub) { this.#stopTicking(); } const start = isFuture ? now : this.#target; const end = isFuture ? this.#target : now; let show = getShowConfig(this.#preset, Math.abs(diff)); if (this.#compact) { const probe = collapseForUnits(start, end, show); if (show.y && probe.years === 0) show.y = false; if (show.M && !show.y && probe.months === 0) show.M = false; if (show.d && !show.y && !show.M && probe.days === 0) show.d = false; const probe2 = collapseForUnits(start, end, show); if (show.d && show.h && probe2.hours === 0) show.h = false; } const parts = collapseForUnits(start, end, show); let txt = formatDurationSmart(this.#locale, this.#labels, show, parts); if (!txt) txt = '00:00:00'; if (this.#textNode.data !== txt) this.#textNode.data = txt; } /* Public API */ /** * The target date object. * @type {Date} */ get target() { return new Date(this.#target); } set target(val) { const d = new Date(val); if (isNaN(d)) return; this.#target = d; this.#reflect('target', d.toISOString()); this.#render(Date.now()); this.#checkRestart(); } /** * Current preset configuration. * @type {string} - 'auto' | 'hms' | 'dhms' | 'ydhms' | 'date' */ get preset() { return this.#preset; } set preset(val) { this.#preset = String(val).toLowerCase(); this.#reflect('preset', this.#preset); this.#render(Date.now()); } /** * Label display mode. * @type {string} - 'none' | 'short' | 'long' */ get labels() { return this.#labels; } set labels(val) { this.#labels = String(val).toLowerCase(); this.#reflect('labels', this.#labels); this.#render(Date.now()); } /** * Whether compact mode is enabled (hides empty high units). * @type {boolean} */ get compact() { return this.#compact; } set compact(val) { this.#compact = !!val; if (this.#compact) this.setAttribute('compact', ''); else this.removeAttribute('compact'); this.#isAttrUpdate = true; this.#render(Date.now()); this.#isAttrUpdate = false; } /** * Current locale override. * @type {string} */ set locale(val) { if (!val) return; this.#locale = String(val).toLowerCase(); this.#reflect('locale', this.#locale); this.#render(Date.now()); } #reflect(name, val) { this.#isAttrUpdate = true; this.setAttribute(name, val); this.#isAttrUpdate = false; } } customElements.define('wc-timer', WcTimer);