version 1.1.0
This commit is contained in:
114
src/wc-timer.js
114
src/wc-timer.js
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* wc-timer.js — Web Component (Refactored)
|
||||
* wc-timer
|
||||
* @version 1.1.0
|
||||
* @description A lightweight, efficient countdown/uptimer Web Component.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/* ---------------- Memoizacja Intl ----------------
|
||||
* Obiekty Intl są kosztowne. Tworzymy je raz i używamy wielokrotnie.
|
||||
* Kluczem cache jest locale i zserializowane opcje.
|
||||
/**
|
||||
* Internal cache for Intl objects to optimize performance.
|
||||
* @private
|
||||
*/
|
||||
const IntlCache = (() => {
|
||||
const cache = new Map();
|
||||
|
||||
function get(Ctor, locale, opts = {}) {
|
||||
// Tworzymy unikalny klucz dla kombinacji konstruktora, języka i opcji.
|
||||
const key = `${Ctor.name}:${locale}:${JSON.stringify(opts)}`;
|
||||
if (!cache.has(key)) {
|
||||
cache.set(key, new Ctor(locale, opts));
|
||||
@@ -22,12 +24,14 @@ const IntlCache = (() => {
|
||||
dtf: (loc, opts) => get(Intl.DateTimeFormat, loc, opts),
|
||||
pr: (loc) => get(Intl.PluralRules, loc),
|
||||
df: (loc, opts) => get(Intl.DurationFormat, loc, opts),
|
||||
// Sprawdzamy wsparcie tylko raz
|
||||
hasIDF: typeof Intl !== 'undefined' && 'DurationFormat' in Intl
|
||||
};
|
||||
})();
|
||||
|
||||
/* ---------------- Global synced tick ---------------- */
|
||||
/**
|
||||
* Centralized ticker to sync all instances and prevent drift.
|
||||
* @private
|
||||
*/
|
||||
const TickHub = (() => {
|
||||
const subs = new Set();
|
||||
let timer = null;
|
||||
@@ -35,12 +39,10 @@ const TickHub = (() => {
|
||||
function scheduleNext() {
|
||||
if (subs.size === 0) return;
|
||||
const now = performance.now();
|
||||
// Wyrównanie do pełnej sekundy
|
||||
const msToNext = 1000 - (Math.floor(now) % 1000);
|
||||
|
||||
|
||||
timer = setTimeout(() => {
|
||||
const t = Date.now();
|
||||
// Kopiujemy Set, aby uniknąć problemów przy dodawaniu/usuwaniu w trakcie pętli
|
||||
for (const cb of [...subs]) {
|
||||
try { cb(t); } catch (e) { console.error(e); }
|
||||
}
|
||||
@@ -63,7 +65,7 @@ const TickHub = (() => {
|
||||
};
|
||||
})();
|
||||
|
||||
/* ---------------- Locale & Helpers ---------------- */
|
||||
/* ---------------- Locale Helpers ---------------- */
|
||||
const LABELS = {
|
||||
en: {
|
||||
y: { long: { one: 'year', other: 'years' }, short: 'y' },
|
||||
@@ -89,9 +91,8 @@ function resolveLocale(el) {
|
||||
return (navigator.language || 'en').toLowerCase();
|
||||
}
|
||||
|
||||
/* ---------------- Algorytmy dat ---------------- */
|
||||
/* ---------------- Date Logic ---------------- */
|
||||
|
||||
// Obliczanie różnicy kalendarzowej bez pętli (O(1))
|
||||
function diffCalendar(a, b, wantY, wantM) {
|
||||
let anchor = new Date(a);
|
||||
let y = 0;
|
||||
@@ -99,7 +100,6 @@ function diffCalendar(a, b, wantY, wantM) {
|
||||
|
||||
if (wantY) {
|
||||
y = b.getFullYear() - anchor.getFullYear();
|
||||
// Sprawdzamy, czy "przeskoczyliśmy" datę końcową
|
||||
const probe = new Date(anchor);
|
||||
probe.setFullYear(anchor.getFullYear() + y);
|
||||
if (probe > b) y--;
|
||||
@@ -107,7 +107,6 @@ function diffCalendar(a, b, wantY, wantM) {
|
||||
}
|
||||
|
||||
if (wantM) {
|
||||
// Różnica w miesiącach całkowitych
|
||||
M = (b.getFullYear() - anchor.getFullYear()) * 12 + (b.getMonth() - anchor.getMonth());
|
||||
const probe = new Date(anchor);
|
||||
probe.setMonth(anchor.getMonth() + M);
|
||||
@@ -120,8 +119,6 @@ function diffCalendar(a, b, wantY, wantM) {
|
||||
|
||||
function collapseForUnits(start, end, show) {
|
||||
const { y, M, anchor } = diffCalendar(start, end, !!show.y, !!show.M);
|
||||
|
||||
// Jeśli liczyliśmy Y/M, resztę liczymy od 'anchor'. Jeśli nie, od 'start'.
|
||||
const base = (show.y || show.M) ? anchor : start;
|
||||
let rest = Math.max(0, end - base);
|
||||
|
||||
@@ -136,40 +133,36 @@ function collapseForUnits(start, end, show) {
|
||||
return res;
|
||||
}
|
||||
|
||||
/* ---------------- Formatter ---------------- */
|
||||
/* ---------------- Formatting ---------------- */
|
||||
const pad2 = (n) => String(n ?? 0).padStart(2, '0');
|
||||
|
||||
function formatDurationSmart(locale, labelsMode, show, parts) {
|
||||
// Tryb: none (czysty tekst z dwukropkami)
|
||||
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(); // jeśli ukryte godziny, usuń
|
||||
|
||||
if (!show.h) hms.shift();
|
||||
|
||||
return [...head, hms.join(':')].join(' ');
|
||||
}
|
||||
|
||||
// Tryb: Intl.DurationFormat (jeśli dostępny)
|
||||
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';
|
||||
|
||||
// Przekazujemy tylko niezerowe/wymagane jednostki
|
||||
const inParts = {};
|
||||
for (const key in parts) {
|
||||
// Mapowanie kluczy (years -> years) jest 1:1 w tym przypadku
|
||||
if (show[key.slice(0, 1)] || show[key.slice(0, 2)]) inParts[key] = parts[key];
|
||||
}
|
||||
return IntlCache.df(locale, opts).format(inParts);
|
||||
}
|
||||
|
||||
// Tryb: Fallback (słownikowy)
|
||||
// Fallback
|
||||
const lang2 = LABELS[locale.slice(0, 2)] ? locale.slice(0, 2) : 'en';
|
||||
const L = LABELS[lang2];
|
||||
const pr = IntlCache.pr(locale);
|
||||
@@ -195,20 +188,19 @@ function formatDurationSmart(locale, labelsMode, show, parts) {
|
||||
|
||||
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':
|
||||
// Jeśli więcej niż 30 dni, pokaż dni.
|
||||
return diffMs >= 2592000000 ? base : { ...base, d: false };
|
||||
default: return base;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- Web Component ---------------- */
|
||||
// Style współdzielone (wydajność pamięci)
|
||||
|
||||
const sheet = new CSSStyleSheet();
|
||||
sheet.replaceSync(`
|
||||
:host {
|
||||
@@ -218,13 +210,26 @@ sheet.replaceSync(`
|
||||
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']; }
|
||||
|
||||
// Pola prywatne
|
||||
|
||||
#target = new Date();
|
||||
#preset = 'auto';
|
||||
#labels = 'short';
|
||||
@@ -242,10 +247,10 @@ class WcTimer extends HTMLElement {
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Inicjalizacja węzła tekstowego w Light DOM
|
||||
// Ensures element has dimensions before JS runs (CLS prevention)
|
||||
if (!this.firstChild || this.firstChild.nodeType !== Node.TEXT_NODE) {
|
||||
this.textContent = '';
|
||||
this.#textNode = document.createTextNode('');
|
||||
this.#textNode = document.createTextNode('\u00A0');
|
||||
this.appendChild(this.#textNode);
|
||||
} else {
|
||||
this.#textNode = this.firstChild;
|
||||
@@ -262,10 +267,8 @@ class WcTimer extends HTMLElement {
|
||||
attributeChangedCallback() {
|
||||
if (this.#isAttrUpdate) return;
|
||||
this.#syncAttrs();
|
||||
// Renderujemy natychmiast po zmianie atrybutu
|
||||
this.#render(Date.now());
|
||||
// Jeśli zmieniono cel na przyszły, upewnij się, że zegar tyka
|
||||
this.#checkRestart();
|
||||
this.#checkRestart();
|
||||
}
|
||||
|
||||
#syncAttrs() {
|
||||
@@ -294,7 +297,6 @@ class WcTimer extends HTMLElement {
|
||||
}
|
||||
|
||||
#checkRestart() {
|
||||
// Jeśli mamy przyszły target, a zegar stoi -> wznów go
|
||||
const now = Date.now();
|
||||
if (this.#target > now && !this.#unsub) {
|
||||
this.#startTicking();
|
||||
@@ -304,7 +306,6 @@ class WcTimer extends HTMLElement {
|
||||
#render(nowMs) {
|
||||
if (!this.#textNode) return;
|
||||
|
||||
// Tryb daty statycznej
|
||||
if (this.#preset === 'date') {
|
||||
const dtf = IntlCache.dtf(this.#locale, {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
@@ -319,8 +320,7 @@ class WcTimer extends HTMLElement {
|
||||
const now = new Date(nowMs);
|
||||
const diff = this.#target - now;
|
||||
const isFuture = diff >= 0;
|
||||
|
||||
// Auto-stop, jeśli minęliśmy czas
|
||||
|
||||
if (!isFuture && this.#unsub) {
|
||||
this.#stopTicking();
|
||||
}
|
||||
@@ -329,25 +329,31 @@ class WcTimer extends HTMLElement {
|
||||
const end = isFuture ? this.#target : now;
|
||||
|
||||
let show = getShowConfig(this.#preset, Math.abs(diff));
|
||||
|
||||
// Logika compact (ukrywanie zerowych jednostek wysokiego rzędu)
|
||||
|
||||
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;
|
||||
// Przelicz ponownie po redukcji jednostek
|
||||
|
||||
const probe2 = collapseForUnits(start, end, show);
|
||||
if (show.d && show.h && probe2.hours === 0) show.h = false;
|
||||
}
|
||||
|
||||
const parts = collapseForUnits(start, end, show);
|
||||
const txt = formatDurationSmart(this.#locale, this.#labels, show, parts);
|
||||
|
||||
|
||||
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);
|
||||
@@ -358,6 +364,10 @@ class WcTimer extends HTMLElement {
|
||||
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();
|
||||
@@ -365,6 +375,10 @@ class WcTimer extends HTMLElement {
|
||||
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();
|
||||
@@ -372,16 +386,24 @@ class WcTimer extends HTMLElement {
|
||||
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; // Flaga dla AttributeChanged
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user