version 1.1.0

This commit is contained in:
David Ali
2026-01-06 01:50:20 +01:00
parent f9e2615d11
commit 2451b1a9e7
8 changed files with 153 additions and 88 deletions

View File

@@ -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();