vibe refactor

This commit is contained in:
David Ali
2026-01-06 00:01:23 +01:00
parent 2ef4a3352d
commit f9e2615d11

View File

@@ -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:
* - Dont 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) { }
/* ---------------- Algorytmy dat ---------------- */
// Obliczanie różnicy kalendarzowej bez pętli (O(1))
function diffCalendar(a, b, wantY, wantM) {
let anchor = new Date(a); let anchor = new Date(a);
let y = 0, M = 0; let y = 0;
let M = 0;
// Add full years while not passing end.
if (wantY) { if (wantY) {
while (new Date( y = b.getFullYear() - anchor.getFullYear();
anchor.getFullYear() + 1, anchor.getMonth(), anchor.getDate(), // Sprawdzamy, czy "przeskoczyliśmy" datę końcową
anchor.getHours(), anchor.getMinutes(), anchor.getSeconds() const probe = new Date(anchor);
) <= b) { anchor.setFullYear(anchor.getFullYear() + 1); y++; } 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(`
: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;
}
`);
class WcTimer extends HTMLElement { class WcTimer extends HTMLElement {
static get observedAttributes() { return ['target', 'preset', 'labels', 'locale', 'lang', 'compact']; } static get observedAttributes() { return ['target', 'preset', 'labels', 'locale', 'lang', 'compact']; }
// Pola prywatne
#target = new Date();
#preset = 'auto';
#labels = 'short';
#compact = false;
#locale = 'en';
#unsub = null;
#textNode = null;
#isAttrUpdate = false;
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); const shadow = this.attachShadow({ mode: 'open' });
shadow.adoptedStyleSheets = [sheet];
// Shadow only defines style and a default slot. shadow.appendChild(document.createElement('slot'));
// Text is in light DOM (selectable, styleable).
const style = document.createElement('style');
style.textContent = `
:host {
display:inline-block;
color: inherit;
user-select: text; -webkit-user-select: text;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
::slotted(*) { all: unset; }
`;
const slot = document.createElement('slot');
this.shadowRoot.append(style, slot);
// Single managed text node in light DOM.
this._text = null;
// Internal state.
this._unsub = null;
this._updatingAttr = false; // guard against attribute echo from setters
this._target = new Date();
this._preset = 'auto';
this._labels = 'short'; // 'none' | 'short' | 'long'
this._compact = false;
this._locale = 'en';
} }
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.textContent = '';
this._text = this.firstChild; this.#textNode = document.createTextNode('');
} else { this.appendChild(this.#textNode);
this.textContent = ''; } else {
this._text = document.createTextNode(''); this.#textNode = this.firstChild;
this.appendChild(this._text);
}
} }
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;
} }
} }