delete redundant file
This commit is contained in:
@@ -1,454 +0,0 @@
|
||||
/**
|
||||
* wc-timer.js — Web Component
|
||||
* ---------------------------------------------
|
||||
* 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 ----------------
|
||||
* One scheduler for all instances. It fires once per second,
|
||||
* aligned to the next real second boundary.
|
||||
* Saves CPU and avoids per-element setInterval drift.
|
||||
*/
|
||||
const TickHub = (() => {
|
||||
const subs = new Set();
|
||||
let timer = null;
|
||||
|
||||
function scheduleNext() {
|
||||
// Align next tick to the next full second.
|
||||
const now = performance.now();
|
||||
const msToNext = 1000 - (Math.floor(now) % 1000);
|
||||
timer = setTimeout(() => {
|
||||
const t = Date.now();
|
||||
// Notify all subscribers. Errors are isolated.
|
||||
subs.forEach(cb => { try { cb(t); } catch { } });
|
||||
scheduleNext();
|
||||
}, msToNext);
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* sub(cb): subscribe to ticks.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
sub(cb) {
|
||||
if (subs.size === 0) scheduleNext();
|
||||
subs.add(cb);
|
||||
return () => {
|
||||
subs.delete(cb);
|
||||
if (!subs.size && timer) { clearTimeout(timer); timer = null; }
|
||||
};
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
/* ---------------- 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 = {
|
||||
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' },
|
||||
}
|
||||
};
|
||||
const clampLocale = loc => (LABELS[loc.slice(0, 2)] ? loc.slice(0, 2) : 'en');
|
||||
|
||||
/* ---------------- Diff with smart collapsing ----------------
|
||||
* We compute calendar years/months only if those units are shown.
|
||||
* If not shown, their time "falls down" into lower units (days/hours/...).
|
||||
*/
|
||||
function extractYearsMonthsCalendar(a, b, wantY, wantM) {
|
||||
let anchor = new Date(a);
|
||||
let y = 0, M = 0;
|
||||
|
||||
// Add full years while not passing end.
|
||||
if (wantY) {
|
||||
while (new Date(
|
||||
anchor.getFullYear() + 1, anchor.getMonth(), anchor.getDate(),
|
||||
anchor.getHours(), anchor.getMinutes(), anchor.getSeconds()
|
||||
) <= b) { anchor.setFullYear(anchor.getFullYear() + 1); y++; }
|
||||
}
|
||||
|
||||
// Then add full months while not passing end.
|
||||
if (wantM) {
|
||||
while (new Date(
|
||||
anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate(),
|
||||
anchor.getHours(), anchor.getMinutes(), anchor.getSeconds()
|
||||
) <= b) { anchor.setMonth(anchor.getMonth() + 1); M++; }
|
||||
}
|
||||
|
||||
return { y, M, anchor };
|
||||
}
|
||||
|
||||
function collapseForUnits(start, end, show) {
|
||||
const { y, M, anchor } = extractYearsMonthsCalendar(start, end, !!show.y, !!show.M);
|
||||
|
||||
// If we showed Y/M, base the remaining ms on the anchor (after adding Y/M).
|
||||
// Otherwise, consume everything from the start.
|
||||
const base = (show.y || show.M) ? anchor : start;
|
||||
let rest = Math.max(0, end - base);
|
||||
|
||||
// Greedy consume into D/H/m/s only for units we plan to display.
|
||||
const dayMs = 86400000, hourMs = 3600000, minMs = 60000, secMs = 1000;
|
||||
let d = 0, h = 0, m = 0, s = 0;
|
||||
|
||||
if (show.d) { d = Math.floor(rest / dayMs); rest -= d * dayMs; }
|
||||
if (show.h) { h = Math.floor(rest / hourMs); rest -= h * hourMs; }
|
||||
if (show.m) { m = Math.floor(rest / minMs); rest -= m * minMs; }
|
||||
if (show.s) { s = Math.floor(rest / secMs); rest -= s * secMs; }
|
||||
|
||||
return { years: y, months: M, days: d, hours: h, minutes: m, seconds: s };
|
||||
}
|
||||
|
||||
/* ---------------- Formatters ----------------
|
||||
* pad2: always pad for H/M/S "digital" look.
|
||||
* 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) {
|
||||
const lang2 = LABELS[locale.slice(0, 2)] ? locale.slice(0, 2) : 'en';
|
||||
|
||||
// labels="none" → head units as numbers, HMS as HH:MM:SS
|
||||
if (labelsMode === 'none') {
|
||||
const head = [];
|
||||
if (show.y) head.push(String(parts.years));
|
||||
if (show.M) head.push(String(parts.months));
|
||||
if (show.d) head.push(String(parts.days));
|
||||
const tail = formatHMS(show, parts);
|
||||
return [head.join(' '), tail].filter(Boolean).join(head.length && tail ? ' ' : '');
|
||||
}
|
||||
|
||||
// Prefer native DurationFormat, it handles pluralization and spacing.
|
||||
if (hasIDF) {
|
||||
const style = labelsMode === 'short' ? 'short' : 'long';
|
||||
const opts = { style };
|
||||
if (show.h) opts.hours = '2-digit';
|
||||
if (show.m) opts.minutes = '2-digit';
|
||||
if (show.s) opts.seconds = '2-digit';
|
||||
|
||||
const df = new Intl.DurationFormat(locale, opts);
|
||||
const inParts = {};
|
||||
if (show.y) inParts.years = parts.years;
|
||||
if (show.M) inParts.months = parts.months;
|
||||
if (show.d) inParts.days = parts.days;
|
||||
if (show.h) inParts.hours = parts.hours;
|
||||
if (show.m) inParts.minutes = parts.minutes;
|
||||
if (show.s) inParts.seconds = parts.seconds;
|
||||
return df.format(inParts);
|
||||
}
|
||||
|
||||
// Fallback: plural rules + our dictionaries
|
||||
const PR = new Intl.PluralRules(locale);
|
||||
const L = LABELS[lang2];
|
||||
|
||||
const head = [];
|
||||
const push = (key, val) => {
|
||||
const cat = PR.select(val);
|
||||
const word = L[key].long[cat] ?? L[key].long.other;
|
||||
head.push(labelsMode === 'short' ? `${val} ${L[key].short}` : `${val} ${word}`);
|
||||
};
|
||||
if (show.y) push('y', parts.years);
|
||||
if (show.M) push('M', parts.months);
|
||||
if (show.d) push('d', parts.days);
|
||||
|
||||
const hms = [];
|
||||
const pushH = (key, val) => {
|
||||
const cat = PR.select(val);
|
||||
const word = L[key].long[cat] ?? L[key].long.other;
|
||||
if (labelsMode === 'short') hms.push(`${pad2(val)} ${L[key].short}`);
|
||||
else hms.push(`${pad2(val)} ${word}`);
|
||||
};
|
||||
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 ? ' ' : '');
|
||||
}
|
||||
|
||||
/* ---------------- Presets ----------------
|
||||
* hms → HH:MM:SS
|
||||
* dhms → D + HH:MM:SS
|
||||
* ydhms → Y + M + D + HH:MM:SS (calendar-aware)
|
||||
* auto → HH:MM:SS when near; D + HH:MM:SS when far (>= 30 days)
|
||||
* default → dhms
|
||||
*/
|
||||
function unitsForPreset(preset, start, end) {
|
||||
if (preset === 'hms') return { y: false, M: false, d: false, h: true, m: true, s: true };
|
||||
if (preset === 'dhms') return { y: false, M: false, d: true, h: true, m: true, s: true };
|
||||
if (preset === 'ydhms') return { y: true, M: true, d: true, h: true, m: true, s: true };
|
||||
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 ----------------
|
||||
* Light-DOM: visible text is a single Text node inside <wc-timer>.
|
||||
* We never replace that node; we only update its .data → selection is stable.
|
||||
*/
|
||||
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 {
|
||||
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() {
|
||||
// Ensure the first child is our Text node.
|
||||
if (!this._text) {
|
||||
if (this.firstChild && this.firstChild.nodeType === Node.TEXT_NODE) {
|
||||
this._text = this.firstChild;
|
||||
} else {
|
||||
this.textContent = '';
|
||||
this._text = document.createTextNode('');
|
||||
this.appendChild(this._text);
|
||||
}
|
||||
}
|
||||
|
||||
this._syncFromAttrs();
|
||||
this._renderOnce(Date.now());
|
||||
this._unsub = TickHub.sub(t => this._renderOnce(t));
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._unsub) { this._unsub(); this._unsub = null; }
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
// Ignore updates triggered by our own setters.
|
||||
if (this._updatingAttr) return;
|
||||
this._syncFromAttrs();
|
||||
this._renderOnce(Date.now());
|
||||
}
|
||||
|
||||
// Pull current attributes into state.
|
||||
_syncFromAttrs() {
|
||||
const raw = this.getAttribute('target');
|
||||
const t = raw ? new Date(raw) : new Date();
|
||||
this._target = Number.isNaN(t.getTime()) ? new Date() : t;
|
||||
|
||||
this._preset = (this.getAttribute('preset') || 'auto').toLowerCase(); // 'auto'|'hms'|'dhms'|'ydhms'|'date'
|
||||
this._labels = (this.getAttribute('labels') || 'short').toLowerCase(); // 'none'|'short'|'long'
|
||||
this._compact = this.hasAttribute('compact');
|
||||
|
||||
this._locale = resolveLocale(this);
|
||||
}
|
||||
|
||||
// Compute and render the string once.
|
||||
_renderOnce(nowMs) {
|
||||
if (!this._text) return;
|
||||
|
||||
// Date-only preset: defer to Intl.DateTimeFormat.
|
||||
if (this._preset === 'date') {
|
||||
const dtf = new Intl.DateTimeFormat(this._locale, {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const out = dtf.format(this._target);
|
||||
if (this._text.data !== out) this._text.data = out;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the time window depending on future/past.
|
||||
const now = new Date(nowMs);
|
||||
const future = this._target - now >= 0;
|
||||
const start = future ? now : this._target;
|
||||
const end = future ? this._target : now;
|
||||
|
||||
// Decide what to show based on preset.
|
||||
let show = unitsForPreset(this._preset, start, end);
|
||||
|
||||
// compact: hide empty high units (Y/M/D), and 0 hours when days are shown.
|
||||
if (this._compact) {
|
||||
const probe = collapseForUnits(start, end, show);
|
||||
if (show.y && probe.years === 0) show = { ...show, y: false };
|
||||
if (show.M && !show.y && probe.months === 0) show = { ...show, M: false };
|
||||
if (show.d && !show.y && !show.M && probe.days === 0) show = { ...show, d: false };
|
||||
const probe2 = collapseForUnits(start, end, show);
|
||||
if (show.d && show.h && probe2.hours === 0) show = { ...show, h: false };
|
||||
}
|
||||
|
||||
const parts = collapseForUnits(start, end, show);
|
||||
|
||||
// If countdown reached zero, stop ticking to save CPU.
|
||||
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 (properties)
|
||||
* =======================
|
||||
* Use these instead of touching private fields.
|
||||
*/
|
||||
|
||||
// target: Date | string | number(ms)
|
||||
get target() { return this._target; }
|
||||
set target(v) {
|
||||
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; }
|
||||
set preset(v) {
|
||||
const p = String(v || '').toLowerCase();
|
||||
this._preset = p;
|
||||
this._updatingAttr = true;
|
||||
this.setAttribute('preset', p);
|
||||
this._updatingAttr = false;
|
||||
this._renderOnce(Date.now());
|
||||
}
|
||||
|
||||
// labels: 'none' | 'short' | 'long'
|
||||
get labels() { return this._labels; }
|
||||
set labels(v) {
|
||||
const l = String(v || '').toLowerCase();
|
||||
this._labels = l;
|
||||
this._updatingAttr = true;
|
||||
this.setAttribute('labels', l);
|
||||
this._updatingAttr = false;
|
||||
this._renderOnce(Date.now());
|
||||
}
|
||||
|
||||
// compact: boolean
|
||||
get compact() { return this._compact; }
|
||||
set compact(v) {
|
||||
const on = !!v;
|
||||
this._compact = on;
|
||||
this._updatingAttr = true;
|
||||
this.toggleAttribute('compact', on);
|
||||
this._updatingAttr = false;
|
||||
this._renderOnce(Date.now());
|
||||
}
|
||||
|
||||
// locale: IETF BCP 47 (e.g., 'pl', 'en-GB'); overrides auto detection
|
||||
get locale() { return this._locale; }
|
||||
set locale(v) {
|
||||
const loc = (v || '').toString();
|
||||
if (!loc) return;
|
||||
this._locale = loc.toLowerCase();
|
||||
this._updatingAttr = true;
|
||||
this.setAttribute('locale', this._locale);
|
||||
this._updatingAttr = false;
|
||||
this._renderOnce(Date.now());
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
_coerceDate(v) {
|
||||
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
|
||||
if (typeof v === 'number') { const d = new Date(v); return isNaN(d.getTime()) ? null : d; }
|
||||
if (typeof v === 'string') { const d = new Date(v); return isNaN(d.getTime()) ? null : d; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('wc-timer', WcTimer);
|
||||
Reference in New Issue
Block a user