vibe refactor
This commit is contained in:
563
src/wc-timer.js
563
src/wc-timer.js
@@ -1,94 +1,69 @@
|
|||||||
/**
|
/**
|
||||||
* wc-timer.js — Web Component
|
* wc-timer.js — Web Component (Refactored)
|
||||||
* ---------------------------------------------
|
|
||||||
* A tiny countdown/uptimer that renders as plain text
|
|
||||||
* (light DOM). Text is selectable and styles with normal CSS.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Presets: auto | hms | dhms | ydhms | date
|
|
||||||
* - Auto locale (with override via locale/lang attrib)
|
|
||||||
* - Uses Intl.DurationFormat when available (fallback included)
|
|
||||||
* - “Compact” mode hides empty high units (e.g., 0 years)
|
|
||||||
* - One global, synced tick for all instances (no drift, low CPU)
|
|
||||||
* - Public JS API (properties): target, preset, labels, compact, locale
|
|
||||||
*
|
|
||||||
* Usage (HTML):
|
|
||||||
* <script type="module" src="/assets/wc-timer.js"></script>
|
|
||||||
* <wc-timer target="2025-12-31T23:59:59Z"></wc-timer>
|
|
||||||
* <wc-timer preset="dhms" labels="long" compact target="..."></wc-timer>
|
|
||||||
*
|
|
||||||
* Styling:
|
|
||||||
* wc-timer { font-size: 20px; color: #4da3ff; }
|
|
||||||
*
|
|
||||||
* JS control:
|
|
||||||
* const t = document.querySelector('wc-timer');
|
|
||||||
* t.target = '2025-10-12T00:30:00Z'; // string | Date | ms number
|
|
||||||
* t.preset = 'dhms'; // 'auto' | 'hms' | 'dhms' | 'ydhms' | 'date'
|
|
||||||
* t.labels = 'short'; // 'none' | 'short' | 'long'
|
|
||||||
* t.compact = true; // boolean
|
|
||||||
* t.locale = 'pl'; // locale override
|
|
||||||
*
|
|
||||||
* Notes:
|
|
||||||
* - Don’t mutate private fields (those starting with “_”).
|
|
||||||
* Use public setters (e.g., el.target = ...).
|
|
||||||
* - “auto” shows HH:MM:SS for near targets and adds days for far targets.
|
|
||||||
* - If DurationFormat is missing, we use a small fallback with plural rules.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ---------------- Global synced tick ----------------
|
/* ---------------- Memoizacja Intl ----------------
|
||||||
* One scheduler for all instances. It fires once per second,
|
* Obiekty Intl są kosztowne. Tworzymy je raz i używamy wielokrotnie.
|
||||||
* aligned to the next real second boundary.
|
* Kluczem cache jest locale i zserializowane opcje.
|
||||||
* Saves CPU and avoids per-element setInterval drift.
|
|
||||||
*/
|
*/
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
// Sprawdzamy wsparcie tylko raz
|
||||||
|
hasIDF: typeof Intl !== 'undefined' && 'DurationFormat' in Intl
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ---------------- Global synced tick ---------------- */
|
||||||
const TickHub = (() => {
|
const TickHub = (() => {
|
||||||
const subs = new Set();
|
const subs = new Set();
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
|
||||||
function scheduleNext() {
|
function scheduleNext() {
|
||||||
// Align next tick to the next full second.
|
if (subs.size === 0) return;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
|
// Wyrównanie do pełnej sekundy
|
||||||
const msToNext = 1000 - (Math.floor(now) % 1000);
|
const msToNext = 1000 - (Math.floor(now) % 1000);
|
||||||
|
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
const t = Date.now();
|
const t = Date.now();
|
||||||
// Notify all subscribers. Errors are isolated.
|
// Kopiujemy Set, aby uniknąć problemów przy dodawaniu/usuwaniu w trakcie pętli
|
||||||
subs.forEach(cb => { try { cb(t); } catch { } });
|
for (const cb of [...subs]) {
|
||||||
|
try { cb(t); } catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
scheduleNext();
|
scheduleNext();
|
||||||
}, msToNext);
|
}, msToNext);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/**
|
|
||||||
* sub(cb): subscribe to ticks.
|
|
||||||
* Returns an unsubscribe function.
|
|
||||||
*/
|
|
||||||
sub(cb) {
|
sub(cb) {
|
||||||
if (subs.size === 0) scheduleNext();
|
|
||||||
subs.add(cb);
|
subs.add(cb);
|
||||||
|
if (!timer) scheduleNext();
|
||||||
return () => {
|
return () => {
|
||||||
subs.delete(cb);
|
subs.delete(cb);
|
||||||
if (!subs.size && timer) { clearTimeout(timer); timer = null; }
|
if (subs.size === 0 && timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/* ---------------- Locale helpers ----------------
|
/* ---------------- Locale & Helpers ---------------- */
|
||||||
* We prefer: explicit element attribute -> nearest [lang] -> <html lang>
|
|
||||||
* -> navigator.languages[0] -> navigator.language -> 'en'
|
|
||||||
*/
|
|
||||||
const hasIDF = typeof Intl !== 'undefined' && 'DurationFormat' in Intl;
|
|
||||||
|
|
||||||
function resolveLocale(el) {
|
|
||||||
const attr = el.getAttribute('locale') || el.getAttribute('lang');
|
|
||||||
const tree = el.closest?.('[lang]')?.getAttribute('lang') || document.documentElement.lang;
|
|
||||||
const nav = Array.isArray(navigator.languages) && navigator.languages.length
|
|
||||||
? navigator.languages[0]
|
|
||||||
: navigator.language;
|
|
||||||
return (attr || tree || nav || 'en').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback label dictionaries (English + Polish).
|
|
||||||
// Used only when Intl.DurationFormat is not available.
|
|
||||||
const LABELS = {
|
const LABELS = {
|
||||||
en: {
|
en: {
|
||||||
y: { long: { one: 'year', other: 'years' }, short: 'y' },
|
y: { long: { one: 'year', other: 'years' }, short: 'y' },
|
||||||
@@ -107,347 +82,317 @@ const LABELS = {
|
|||||||
s: { long: { one: 'sekunda', few: 'sekundy', many: 'sekund', other: 'sekund' }, short: 's' },
|
s: { long: { one: 'sekunda', few: 'sekundy', many: 'sekund', other: 'sekund' }, short: 's' },
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const clampLocale = loc => (LABELS[loc.slice(0, 2)] ? loc.slice(0, 2) : 'en');
|
|
||||||
|
|
||||||
/* ---------------- Diff with smart collapsing ----------------
|
function resolveLocale(el) {
|
||||||
* We compute calendar years/months only if those units are shown.
|
const attr = el.getAttribute('locale') || el.getAttribute('lang');
|
||||||
* If not shown, their time “falls down” into lower units (days/hours/...).
|
if (attr) return attr.toLowerCase();
|
||||||
*/
|
return (navigator.language || 'en').toLowerCase();
|
||||||
function extractYearsMonthsCalendar(a, b, wantY, wantM) {
|
}
|
||||||
let anchor = new Date(a);
|
|
||||||
let y = 0, M = 0;
|
/* ---------------- Algorytmy dat ---------------- */
|
||||||
|
|
||||||
// Add full years while not passing end.
|
// Obliczanie różnicy kalendarzowej bez pętli (O(1))
|
||||||
if (wantY) {
|
function diffCalendar(a, b, wantY, wantM) {
|
||||||
while (new Date(
|
let anchor = new Date(a);
|
||||||
anchor.getFullYear() + 1, anchor.getMonth(), anchor.getDate(),
|
let y = 0;
|
||||||
anchor.getHours(), anchor.getMinutes(), anchor.getSeconds()
|
let M = 0;
|
||||||
) <= b) { anchor.setFullYear(anchor.getFullYear() + 1); y++; }
|
|
||||||
|
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--;
|
||||||
|
anchor.setFullYear(anchor.getFullYear() + y);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then add full months while not passing end.
|
|
||||||
if (wantM) {
|
if (wantM) {
|
||||||
while (new Date(
|
// Różnica w miesiącach całkowitych
|
||||||
anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate(),
|
M = (b.getFullYear() - anchor.getFullYear()) * 12 + (b.getMonth() - anchor.getMonth());
|
||||||
anchor.getHours(), anchor.getMinutes(), anchor.getSeconds()
|
const probe = new Date(anchor);
|
||||||
) <= b) { anchor.setMonth(anchor.getMonth() + 1); M++; }
|
probe.setMonth(anchor.getMonth() + M);
|
||||||
|
if (probe > b) M--;
|
||||||
|
anchor.setMonth(anchor.getMonth() + M);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { y, M, anchor };
|
return { y, M, anchor };
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseForUnits(start, end, show) {
|
function collapseForUnits(start, end, show) {
|
||||||
const { y, M, anchor } = extractYearsMonthsCalendar(start, end, !!show.y, !!show.M);
|
const { y, M, anchor } = diffCalendar(start, end, !!show.y, !!show.M);
|
||||||
|
|
||||||
// If we showed Y/M, base the remaining ms on the anchor (after adding Y/M).
|
// Jeśli liczyliśmy Y/M, resztę liczymy od 'anchor'. Jeśli nie, od 'start'.
|
||||||
// Otherwise, consume everything from the start.
|
|
||||||
const base = (show.y || show.M) ? anchor : start;
|
const base = (show.y || show.M) ? anchor : start;
|
||||||
let rest = Math.max(0, end - base);
|
let rest = Math.max(0, end - base);
|
||||||
|
|
||||||
// Greedy consume into D/H/m/s only for units we plan to display.
|
const MS = { d: 86400000, h: 3600000, m: 60000, s: 1000 };
|
||||||
const dayMs = 86400000, hourMs = 3600000, minMs = 60000, secMs = 1000;
|
const res = { years: y, months: M, days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||||
let d = 0, h = 0, m = 0, s = 0;
|
|
||||||
|
|
||||||
if (show.d) { d = Math.floor(rest / dayMs); rest -= d * dayMs; }
|
if (show.d) { res.days = Math.floor(rest / MS.d); rest %= MS.d; }
|
||||||
if (show.h) { h = Math.floor(rest / hourMs); rest -= h * hourMs; }
|
if (show.h) { res.hours = Math.floor(rest / MS.h); rest %= MS.h; }
|
||||||
if (show.m) { m = Math.floor(rest / minMs); rest -= m * minMs; }
|
if (show.m) { res.minutes = Math.floor(rest / MS.m); rest %= MS.m; }
|
||||||
if (show.s) { s = Math.floor(rest / secMs); rest -= s * secMs; }
|
if (show.s) { res.seconds = Math.floor(rest / MS.s); }
|
||||||
|
|
||||||
return { years: y, months: M, days: d, hours: h, minutes: m, seconds: s };
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Formatters ----------------
|
/* ---------------- Formatter ---------------- */
|
||||||
* pad2: always pad for H/M/S “digital” look.
|
const pad2 = (n) => String(n ?? 0).padStart(2, '0');
|
||||||
* formatHMS: joins visible HMS with ':'.
|
|
||||||
* formatDurationSmart: uses DurationFormat if available; otherwise fallback.
|
|
||||||
*/
|
|
||||||
function pad2(n) { return String(n ?? 0).padStart(2, '0'); }
|
|
||||||
|
|
||||||
function formatHMS(show, p) {
|
|
||||||
const segs = [];
|
|
||||||
if (show.h) segs.push(pad2(p.hours));
|
|
||||||
if (show.m) segs.push(pad2(p.minutes));
|
|
||||||
if (show.s) segs.push(pad2(p.seconds));
|
|
||||||
return segs.join(':');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDurationSmart(locale, labelsMode, show, parts) {
|
function formatDurationSmart(locale, labelsMode, show, parts) {
|
||||||
const lang2 = LABELS[locale.slice(0, 2)] ? locale.slice(0, 2) : 'en';
|
// Tryb: none (czysty tekst z dwukropkami)
|
||||||
|
|
||||||
// labels="none" → head units as numbers, HMS as HH:MM:SS
|
|
||||||
if (labelsMode === 'none') {
|
if (labelsMode === 'none') {
|
||||||
const head = [];
|
const head = [];
|
||||||
if (show.y) head.push(String(parts.years));
|
if (show.y) head.push(parts.years);
|
||||||
if (show.M) head.push(String(parts.months));
|
if (show.M) head.push(parts.months);
|
||||||
if (show.d) head.push(String(parts.days));
|
if (show.d) head.push(parts.days);
|
||||||
const tail = formatHMS(show, parts);
|
|
||||||
return [head.join(' '), tail].filter(Boolean).join(head.length && tail ? ' ' : '');
|
const hms = [pad2(parts.hours), pad2(parts.minutes), pad2(parts.seconds)];
|
||||||
|
if (!show.h) hms.shift(); // jeśli ukryte godziny, usuń
|
||||||
|
|
||||||
|
return [...head, hms.join(':')].join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer native DurationFormat, it handles pluralization and spacing.
|
// Tryb: Intl.DurationFormat (jeśli dostępny)
|
||||||
if (hasIDF) {
|
if (IntlCache.hasIDF) {
|
||||||
const style = labelsMode === 'short' ? 'short' : 'long';
|
const opts = { style: labelsMode === 'short' ? 'short' : 'long' };
|
||||||
const opts = { style };
|
|
||||||
if (show.h) opts.hours = '2-digit';
|
if (show.h) opts.hours = '2-digit';
|
||||||
if (show.m) opts.minutes = '2-digit';
|
if (show.m) opts.minutes = '2-digit';
|
||||||
if (show.s) opts.seconds = '2-digit';
|
if (show.s) opts.seconds = '2-digit';
|
||||||
|
|
||||||
const df = new Intl.DurationFormat(locale, opts);
|
// Przekazujemy tylko niezerowe/wymagane jednostki
|
||||||
const inParts = {};
|
const inParts = {};
|
||||||
if (show.y) inParts.years = parts.years;
|
for (const key in parts) {
|
||||||
if (show.M) inParts.months = parts.months;
|
// Mapowanie kluczy (years -> years) jest 1:1 w tym przypadku
|
||||||
if (show.d) inParts.days = parts.days;
|
if (show[key.slice(0, 1)] || show[key.slice(0, 2)]) inParts[key] = parts[key];
|
||||||
if (show.h) inParts.hours = parts.hours;
|
}
|
||||||
if (show.m) inParts.minutes = parts.minutes;
|
return IntlCache.df(locale, opts).format(inParts);
|
||||||
if (show.s) inParts.seconds = parts.seconds;
|
|
||||||
return df.format(inParts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: plural rules + our dictionaries
|
// Tryb: Fallback (słownikowy)
|
||||||
const PR = new Intl.PluralRules(locale);
|
const lang2 = LABELS[locale.slice(0, 2)] ? locale.slice(0, 2) : 'en';
|
||||||
const L = LABELS[lang2];
|
const L = LABELS[lang2];
|
||||||
|
const pr = IntlCache.pr(locale);
|
||||||
|
const out = [];
|
||||||
|
|
||||||
const head = [];
|
const add = (k, v, forcePad = false) => {
|
||||||
const push = (key, val) => {
|
const cat = pr.select(v);
|
||||||
const cat = PR.select(val);
|
const word = L[k].long[cat] ?? L[k].long.other;
|
||||||
const word = L[key].long[cat] ?? L[key].long.other;
|
const num = forcePad ? pad2(v) : v;
|
||||||
head.push(labelsMode === 'short' ? `${val} ${L[key].short}` : `${val} ${word}`);
|
const suffix = labelsMode === 'short' ? L[k].short : word;
|
||||||
|
out.push(`${num} ${suffix}`);
|
||||||
};
|
};
|
||||||
if (show.y) push('y', parts.years);
|
|
||||||
if (show.M) push('M', parts.months);
|
|
||||||
if (show.d) push('d', parts.days);
|
|
||||||
|
|
||||||
const hms = [];
|
if (show.y) add('y', parts.years);
|
||||||
const pushH = (key, val) => {
|
if (show.M) add('M', parts.months);
|
||||||
const cat = PR.select(val);
|
if (show.d) add('d', parts.days);
|
||||||
const word = L[key].long[cat] ?? L[key].long.other;
|
if (show.h) add('h', parts.hours, true);
|
||||||
if (labelsMode === 'short') hms.push(`${pad2(val)} ${L[key].short}`);
|
if (show.m) add('m', parts.minutes, true);
|
||||||
else hms.push(`${pad2(val)} ${word}`);
|
if (show.s) add('s', parts.seconds, true);
|
||||||
};
|
|
||||||
if (show.h) pushH('h', parts.hours);
|
|
||||||
if (show.m) pushH('m', parts.minutes);
|
|
||||||
if (show.s) pushH('s', parts.seconds);
|
|
||||||
|
|
||||||
return [head.join(' '), hms.join(' ')].filter(Boolean).join(head.length && hms.length ? ' ' : '');
|
return out.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Presets ----------------
|
function getShowConfig(preset, diffMs) {
|
||||||
* hms → HH:MM:SS
|
const base = { y: false, M: false, d: true, h: true, m: true, s: true };
|
||||||
* dhms → D + HH:MM:SS
|
|
||||||
* ydhms → Y + M + D + HH:MM:SS (calendar-aware)
|
switch (preset) {
|
||||||
* auto → HH:MM:SS when near; D + HH:MM:SS when far (>= 30 days)
|
case 'hms': return { ...base, d: false };
|
||||||
* default → dhms
|
case 'dhms': return base;
|
||||||
*/
|
case 'ydhms': return { y: true, M: true, d: true, h: true, m: true, s: true };
|
||||||
function unitsForPreset(preset, start, end) {
|
case 'auto':
|
||||||
if (preset === 'hms') return { y: false, M: false, d: false, h: true, m: true, s: true };
|
// Jeśli więcej niż 30 dni, pokaż dni.
|
||||||
if (preset === 'dhms') return { y: false, M: false, d: true, h: true, m: true, s: true };
|
return diffMs >= 2592000000 ? base : { ...base, d: false };
|
||||||
if (preset === 'ydhms') return { y: true, M: true, d: true, h: true, m: true, s: true };
|
default: return base;
|
||||||
if (preset === 'auto') {
|
|
||||||
const diff = Math.max(0, end - start);
|
|
||||||
const dayMs = 86400000;
|
|
||||||
return diff >= 30 * dayMs
|
|
||||||
? { y: false, M: false, d: true, h: true, m: true, s: true }
|
|
||||||
: { y: false, M: false, d: false, h: true, m: true, s: true };
|
|
||||||
}
|
}
|
||||||
return { y: false, M: false, d: true, h: true, m: true, s: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Web Component ----------------
|
/* ---------------- Web Component ---------------- */
|
||||||
* Light-DOM: visible text is a single Text node inside <wc-timer>.
|
// Style współdzielone (wydajność pamięci)
|
||||||
* We never replace that node; we only update its .data → selection is stable.
|
const sheet = new CSSStyleSheet();
|
||||||
*/
|
sheet.replaceSync(`
|
||||||
class WcTimer extends HTMLElement {
|
|
||||||
static get observedAttributes() { return ['target', 'preset', 'labels', 'locale', 'lang', 'compact']; }
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.attachShadow({ mode: 'open' });
|
|
||||||
|
|
||||||
// Shadow only defines style and a default slot.
|
|
||||||
// Text is in light DOM (selectable, styleable).
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
:host {
|
:host {
|
||||||
display:inline-block;
|
display:inline-block;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
user-select: text; -webkit-user-select: text;
|
user-select: text; -webkit-user-select: text;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
::slotted(*) { all: unset; }
|
`);
|
||||||
`;
|
|
||||||
const slot = document.createElement('slot');
|
|
||||||
this.shadowRoot.append(style, slot);
|
|
||||||
|
|
||||||
// Single managed text node in light DOM.
|
class WcTimer extends HTMLElement {
|
||||||
this._text = null;
|
static get observedAttributes() { return ['target', 'preset', 'labels', 'locale', 'lang', 'compact']; }
|
||||||
|
|
||||||
// Internal state.
|
// Pola prywatne
|
||||||
this._unsub = null;
|
#target = new Date();
|
||||||
this._updatingAttr = false; // guard against attribute echo from setters
|
#preset = 'auto';
|
||||||
this._target = new Date();
|
#labels = 'short';
|
||||||
this._preset = 'auto';
|
#compact = false;
|
||||||
this._labels = 'short'; // 'none' | 'short' | 'long'
|
#locale = 'en';
|
||||||
this._compact = false;
|
#unsub = null;
|
||||||
this._locale = 'en';
|
#textNode = null;
|
||||||
|
#isAttrUpdate = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const shadow = this.attachShadow({ mode: 'open' });
|
||||||
|
shadow.adoptedStyleSheets = [sheet];
|
||||||
|
shadow.appendChild(document.createElement('slot'));
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
// Ensure the first child is our Text node.
|
// Inicjalizacja węzła tekstowego w Light DOM
|
||||||
if (!this._text) {
|
if (!this.firstChild || this.firstChild.nodeType !== Node.TEXT_NODE) {
|
||||||
if (this.firstChild && this.firstChild.nodeType === Node.TEXT_NODE) {
|
|
||||||
this._text = this.firstChild;
|
|
||||||
} else {
|
|
||||||
this.textContent = '';
|
this.textContent = '';
|
||||||
this._text = document.createTextNode('');
|
this.#textNode = document.createTextNode('');
|
||||||
this.appendChild(this._text);
|
this.appendChild(this.#textNode);
|
||||||
}
|
} else {
|
||||||
|
this.#textNode = this.firstChild;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._syncFromAttrs();
|
this.#syncAttrs();
|
||||||
this._renderOnce(Date.now());
|
this.#startTicking();
|
||||||
this._unsub = TickHub.sub(t => this._renderOnce(t));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
if (this._unsub) { this._unsub(); this._unsub = null; }
|
this.#stopTicking();
|
||||||
}
|
}
|
||||||
|
|
||||||
attributeChangedCallback() {
|
attributeChangedCallback() {
|
||||||
// Ignore updates triggered by our own setters.
|
if (this.#isAttrUpdate) return;
|
||||||
if (this._updatingAttr) return;
|
this.#syncAttrs();
|
||||||
this._syncFromAttrs();
|
// Renderujemy natychmiast po zmianie atrybutu
|
||||||
this._renderOnce(Date.now());
|
this.#render(Date.now());
|
||||||
|
// Jeśli zmieniono cel na przyszły, upewnij się, że zegar tyka
|
||||||
|
this.#checkRestart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull current attributes into state.
|
#syncAttrs() {
|
||||||
_syncFromAttrs() {
|
const tRaw = this.getAttribute('target');
|
||||||
const raw = this.getAttribute('target');
|
this.#target = tRaw ? new Date(tRaw) : new Date();
|
||||||
const t = raw ? new Date(raw) : new Date();
|
if (isNaN(this.#target)) this.#target = new Date();
|
||||||
this._target = Number.isNaN(t.getTime()) ? new Date() : t;
|
|
||||||
|
|
||||||
this._preset = (this.getAttribute('preset') || 'auto').toLowerCase(); // 'auto'|'hms'|'dhms'|'ydhms'|'date'
|
this.#preset = (this.getAttribute('preset') || 'auto').toLowerCase();
|
||||||
this._labels = (this.getAttribute('labels') || 'short').toLowerCase(); // 'none'|'short'|'long'
|
this.#labels = (this.getAttribute('labels') || 'short').toLowerCase();
|
||||||
this._compact = this.hasAttribute('compact');
|
this.#compact = this.hasAttribute('compact');
|
||||||
|
this.#locale = resolveLocale(this);
|
||||||
this._locale = resolveLocale(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute and render the string once.
|
#startTicking() {
|
||||||
_renderOnce(nowMs) {
|
if (!this.#unsub) {
|
||||||
if (!this._text) return;
|
this.#unsub = TickHub.sub((now) => this.#render(now));
|
||||||
|
this.#render(Date.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Date-only preset: defer to Intl.DateTimeFormat.
|
#stopTicking() {
|
||||||
if (this._preset === 'date') {
|
if (this.#unsub) {
|
||||||
const dtf = new Intl.DateTimeFormat(this._locale, {
|
this.#unsub();
|
||||||
|
this.#unsub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#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',
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
hour12: false
|
hour12: false
|
||||||
});
|
});
|
||||||
const out = dtf.format(this._target);
|
const txt = dtf.format(this.#target);
|
||||||
if (this._text.data !== out) this._text.data = out;
|
if (this.#textNode.data !== txt) this.#textNode.data = txt;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the time window depending on future/past.
|
|
||||||
const now = new Date(nowMs);
|
const now = new Date(nowMs);
|
||||||
const future = this._target - now >= 0;
|
const diff = this.#target - now;
|
||||||
const start = future ? now : this._target;
|
const isFuture = diff >= 0;
|
||||||
const end = future ? this._target : now;
|
|
||||||
|
|
||||||
// Decide what to show based on preset.
|
// Auto-stop, jeśli minęliśmy czas
|
||||||
let show = unitsForPreset(this._preset, start, end);
|
if (!isFuture && this.#unsub) {
|
||||||
|
this.#stopTicking();
|
||||||
|
}
|
||||||
|
|
||||||
// compact: hide empty high units (Y/M/D), and 0 hours when days are shown.
|
const start = isFuture ? now : this.#target;
|
||||||
if (this._compact) {
|
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);
|
const probe = collapseForUnits(start, end, show);
|
||||||
if (show.y && probe.years === 0) show = { ...show, y: false };
|
if (show.y && probe.years === 0) show.y = false;
|
||||||
if (show.M && !show.y && probe.months === 0) show = { ...show, M: false };
|
if (show.M && !show.y && probe.months === 0) show.M = false;
|
||||||
if (show.d && !show.y && !show.M && probe.days === 0) show = { ...show, d: 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);
|
const probe2 = collapseForUnits(start, end, show);
|
||||||
if (show.d && show.h && probe2.hours === 0) show = { ...show, h: false };
|
if (show.d && show.h && probe2.hours === 0) show.h = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = collapseForUnits(start, end, show);
|
const parts = collapseForUnits(start, end, show);
|
||||||
|
const txt = formatDurationSmart(this.#locale, this.#labels, show, parts);
|
||||||
|
|
||||||
// If countdown reached zero, stop ticking to save CPU.
|
if (this.#textNode.data !== txt) this.#textNode.data = txt;
|
||||||
if (future && end - now <= 0 && this._unsub) { this._unsub(); this._unsub = null; }
|
|
||||||
|
|
||||||
// Format the output string and update only TextNode.data.
|
|
||||||
const out = formatDurationSmart(this._locale, this._labels, show, parts);
|
|
||||||
if (this._text.data !== out) this._text.data = out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =======================
|
/* Public API */
|
||||||
* Public API (properties)
|
get target() { return new Date(this.#target); }
|
||||||
* =======================
|
set target(val) {
|
||||||
* Use these instead of touching private fields.
|
const d = new Date(val);
|
||||||
*/
|
if (isNaN(d)) return;
|
||||||
|
this.#target = d;
|
||||||
// target: Date | string | number(ms)
|
this.#reflect('target', d.toISOString());
|
||||||
get target() { return this._target; }
|
this.#render(Date.now());
|
||||||
set target(v) {
|
this.#checkRestart();
|
||||||
const d = this._coerceDate(v);
|
|
||||||
if (!d) return;
|
|
||||||
this._target = d;
|
|
||||||
this._updatingAttr = true; // prevent attributeChanged echo
|
|
||||||
this.setAttribute('target', d.toISOString());
|
|
||||||
this._updatingAttr = false;
|
|
||||||
this._renderOnce(Date.now());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// preset: 'auto' | 'hms' | 'dhms' | 'ydhms' | 'date'
|
get preset() { return this.#preset; }
|
||||||
get preset() { return this._preset; }
|
set preset(val) {
|
||||||
set preset(v) {
|
this.#preset = String(val).toLowerCase();
|
||||||
const p = String(v || '').toLowerCase();
|
this.#reflect('preset', this.#preset);
|
||||||
this._preset = p;
|
this.#render(Date.now());
|
||||||
this._updatingAttr = true;
|
|
||||||
this.setAttribute('preset', p);
|
|
||||||
this._updatingAttr = false;
|
|
||||||
this._renderOnce(Date.now());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// labels: 'none' | 'short' | 'long'
|
get labels() { return this.#labels; }
|
||||||
get labels() { return this._labels; }
|
set labels(val) {
|
||||||
set labels(v) {
|
this.#labels = String(val).toLowerCase();
|
||||||
const l = String(v || '').toLowerCase();
|
this.#reflect('labels', this.#labels);
|
||||||
this._labels = l;
|
this.#render(Date.now());
|
||||||
this._updatingAttr = true;
|
|
||||||
this.setAttribute('labels', l);
|
|
||||||
this._updatingAttr = false;
|
|
||||||
this._renderOnce(Date.now());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// compact: boolean
|
get compact() { return this.#compact; }
|
||||||
get compact() { return this._compact; }
|
set compact(val) {
|
||||||
set compact(v) {
|
this.#compact = !!val;
|
||||||
const on = !!v;
|
if (this.#compact) this.setAttribute('compact', '');
|
||||||
this._compact = on;
|
else this.removeAttribute('compact');
|
||||||
this._updatingAttr = true;
|
this.#isAttrUpdate = true; // Flaga dla AttributeChanged
|
||||||
this.toggleAttribute('compact', on);
|
this.#render(Date.now());
|
||||||
this._updatingAttr = false;
|
this.#isAttrUpdate = false;
|
||||||
this._renderOnce(Date.now());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// locale: IETF BCP 47 (e.g., 'pl', 'en-GB'); overrides auto detection
|
set locale(val) {
|
||||||
get locale() { return this._locale; }
|
if (!val) return;
|
||||||
set locale(v) {
|
this.#locale = String(val).toLowerCase();
|
||||||
const loc = (v || '').toString();
|
this.#reflect('locale', this.#locale);
|
||||||
if (!loc) return;
|
this.#render(Date.now());
|
||||||
this._locale = loc.toLowerCase();
|
|
||||||
this._updatingAttr = true;
|
|
||||||
this.setAttribute('locale', this._locale);
|
|
||||||
this._updatingAttr = false;
|
|
||||||
this._renderOnce(Date.now());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
#reflect(name, val) {
|
||||||
_coerceDate(v) {
|
this.#isAttrUpdate = true;
|
||||||
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
|
this.setAttribute(name, val);
|
||||||
if (typeof v === 'number') { const d = new Date(v); return isNaN(d.getTime()) ? null : d; }
|
this.#isAttrUpdate = false;
|
||||||
if (typeof v === 'string') { const d = new Date(v); return isNaN(d.getTime()) ? null : d; }
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user