421 lines
12 KiB
JavaScript
421 lines
12 KiB
JavaScript
/**
|
|
* 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
|
|
* <wc-timer target="2025-01-01T00:00:00Z"></wc-timer>
|
|
* <wc-timer preset="dhms" labels="long" compact></wc-timer>
|
|
*/
|
|
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); |