/**
* 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);