@@ -1,94 +1,71 @@
/**
* 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.
* wc-timer
* @version 1.1.0
* @description A lightweight, efficient countdown/uptimer Web Component.
* @ license MIT
*/
/* ---------------- 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.
/**
* Internal cache for Intl objects to optimize performance.
* @private
*/
const IntlCache = ( ( ) => {
const cache = new Map ( ) ;
function get ( Ctor , locale , opts = { } ) {
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 ) ,
hasIDF : typeof Intl !== 'undefined' && 'DurationFormat' in Intl
} ;
} ) ( ) ;
/**
* Centralized ticker to sync all instances and prevent drift.
* @private
*/
const TickHub = ( ( ) => {
const subs = new Set ( ) ;
let timer = null ;
function scheduleNext ( ) {
// Align next tick to the next full second.
if ( subs . size === 0 ) return ;
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 { } } ) ;
for ( const cb of [ ... subs ] ) {
try { cb ( t ) ; } catch ( e ) { console . error ( e ) ; }
}
scheduleNext ( ) ;
} , msToNext ) ;
}
return {
/**
* sub(cb): subscribe to ticks.
* Returns an unsubscribe function.
*/
sub ( cb ) {
if ( subs . size === 0 ) scheduleNext ( ) ;
subs . add ( cb ) ;
if ( ! timer ) scheduleNext ( ) ;
return ( ) => {
subs . delete ( cb ) ;
if ( ! subs . size && timer ) { clearTimeout ( timer ) ; timer = null ; }
if ( subs . size === 0 && timer ) {
clearTimeout ( timer ) ;
timer = null ;
}
} ;
}
} ;
} ) ( ) ;
/* ---------------- Locale h elpers ----------------
* 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.
/* ---------------- Locale H elpers ---------------- */
const LABELS = {
en : {
y : { long : { one : 'year' , other : 'years' } , short : 'y' } ,
@@ -107,348 +84,338 @@ const LABELS = {
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 ) {
function resolveLocale ( el ) {
const attr = el . getAttribute ( 'locale' ) || el . getAttribute ( 'lang' ) ;
if ( attr ) return attr . toLowerCase ( ) ;
return ( navigator . language || 'en' ) . toLowerCase ( ) ;
}
/* ---------------- Date Logic ---------------- */
function diffCalendar ( a , b , wantY , wantM ) {
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 ) {
while ( new Date (
anchor . getFullYear ( ) + 1 , anchor . getMonth ( ) , anchor . getDate ( ) ,
anchor . getHours ( ) , anchor . getMinutes ( ) , anchor . getSeconds ( )
) <= b ) { anchor . setFullYear ( anchor . getFullYear ( ) + 1 ) ; y ++ ; }
y = b . getFullYear ( ) - anchor . getFullYear ( ) ;
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 ) {
while ( new Date (
anchor . getFullYear ( ) , anchor . getMonth ( ) + 1 , anchor . getDate ( ) ,
anchor . getHours ( ) , anchor . getMinutes ( ) , anchor . getSeconds ( )
) <= b ) { anchor . setMonth ( anchor . getMonth ( ) + 1 ) ; M ++ ; }
M = ( b . getFullYear ( ) - anchor . getFullYear ( ) ) * 12 + ( b . getMonth ( ) - anchor . getMonth ( ) ) ;
const probe = new Date ( anchor ) ;
probe . setMonth ( anchor . getMonth ( ) + M ) ;
if ( probe > b ) M -- ;
anchor . setMonth ( anchor . getMonth ( ) + 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 { y , M , anchor } = diffCalendar ( start , end , ! ! show . y , ! ! show . M ) ;
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 ;
const MS = { d : 86400000 , h : 3600000 , m : 60000 , s : 1000 } ;
const res = { years : y , months : M , days : 0 , hours : 0 , minutes : 0 , seconds : 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 ; }
if ( show . d ) { res . days = Math . floor ( rest / MS . d ) ; rest % = MS . d ; }
if ( show . h ) { res . hours = Math . floor ( rest / MS . h ) ; rest % = MS . h ; }
if ( show . m ) { res . minutes = Math . floor ( rest / MS . m ) ; rest % = MS . m ; }
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 ----------------
* 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 ( ':' ) ;
}
/* ---------------- Formatting ---------------- */
const pad2 = ( n ) => String ( n ? ? 0 ) . padStart ( 2 , '0' ) ;
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 ? ' ' : '' ) ;
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 ( ) ;
return [ ... head , hms . join ( ':' ) ] . join ( ' ' ) ;
}
// Prefer native DurationFormat, it handles pluralization and spacing.
if ( hasIDF ) {
const style = labelsMode === 'short' ? 'short' : 'long' ;
const opts = { style } ;
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' ;
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 ) ;
for ( const key in parts ) {
if ( show [ key . slice ( 0 , 1 ) ] || show [ key . slice ( 0 , 2 ) ] ) inParts [ key ] = parts [ key ] ;
}
return IntlCache . df ( locale , opts ) . format ( inParts ) ;
}
// Fallback: plural rules + our dictionaries
const PR = new Intl . PluralRules ( locale ) ;
// Fallback
const lang2 = LABELS [ locale . slice ( 0 , 2 ) ] ? locale . slice ( 0 , 2 ) : 'en' ;
const L = LABELS [ lang2 ] ;
const pr = IntlCache . pr ( locale ) ;
const out = [ ] ;
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 } ` ) ;
const add = ( k , v , forcePad = false ) => {
const cat = pr . select ( v ) ;
const word = L [ k ] . long [ cat ] ? ? L [ k ] . long . other ;
const num = forcePad ? pad2 ( v ) : v ;
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 = [ ] ;
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 ) ;
if ( show . y ) add ( 'y' , parts . years ) ;
if ( show . M ) add ( 'M' , parts . months ) ;
if ( show . d ) add ( 'd' , parts . days ) ;
if ( show . h ) add ( 'h' , parts . hours , true ) ;
if ( show . m ) add ( 'm' , parts . minutes , true ) ;
if ( show . s ) add ( 's' , parts . seconds , true ) ;
return [ head . join ( ' ' ) , hms . join ( ' ' ) ] . filter ( Boolean ) . join ( head . length && hms . length ? ' ' : ' ') ;
return out . join ( ' ') ;
}
/* ---------------- 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 } ;
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' :
return diffMs >= 2592000000 ? base : { ... base , d : false } ;
default : return base ;
}
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.
/* ---------------- Web Component ---------------- */
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;
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' ] ; }
# target = new Date ( ) ;
# preset = 'auto' ;
# labels = 'short' ;
# compact = false ;
# locale = 'en' ;
# unsub = null ;
# textNode = null ;
# isAttrUpdate = false ;
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' ;
const shadow = this . attachShadow ( { mode : 'open' } ) ;
shadow . adoptedStyleSheets = [ sheet ] ;
shadow . appendChild ( document . createElement ( 'slot' ) ) ;
}
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 ) ;
}
// Ensures element has dimensions before JS runs (CLS prevention)
if ( ! this . firstChild || this . firstChild . nodeType !== Node . TEXT _NODE ) {
this . textContent = '' ;
this . # textNode = document . createTextNode ( '\u00A0' ) ;
this . appendChild ( this . # textNode ) ;
} else {
this . # textNode = this . firstChild ;
}
this . _syncFromAttrs ( ) ;
this . _renderOnce ( Date . now ( ) ) ;
this . _unsub = TickHub . sub ( t => this . _renderOnce ( t ) ) ;
this . # syncAttrs ( ) ;
this . # startTicking ( ) ;
}
disconnectedCallback ( ) {
if ( this . _unsub ) { this . _unsub ( ) ; this . _unsub = null ; }
this . # stopTicking ( ) ;
}
attributeChangedCallback ( ) {
// Ignore updates triggered by our own setters.
if ( this . _updatingAttr ) return ;
this . _syncFromAttrs ( ) ;
this . _renderOnce ( Date . now ( ) ) ;
if ( this . # isAttrUpdate ) return ;
this . # syncAttrs ( ) ;
this . # render ( Date . now ( ) ) ;
this . # checkRestart ( ) ;
}
// 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 ;
# syncAttrs ( ) {
const tRaw = this . getAttribute ( 'target' ) ;
this . # target = tRaw ? new Date ( tRaw ) : new Date ( ) ;
if ( isNaN ( this . # target ) ) this . # target = new Date ( ) ;
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 ) ;
this . # preset = ( this . getAttribute ( 'preset' ) || 'auto' ) . toLowerCase ( ) ;
this . # labels = ( this . getAttribute ( 'labels' ) || 'short' ) . toLowerCase ( ) ;
this . # compact = this . hasAttribute ( 'compact' ) ;
this . # locale = resolveLocale ( this ) ;
}
// Compute and render the string once.
_renderOnce ( nowMs ) {
if ( ! this . _text ) return ;
# startTicking ( ) {
if ( ! this . # unsub ) {
this . # unsub = TickHub . sub ( ( now ) => this . # render ( now ) ) ;
this . # render ( Date . now ( ) ) ;
}
}
// Date-only preset: defer to Intl.DateTimeFormat.
if ( this . _preset === 'date' ) {
const dtf = new Intl . DateTimeFormat ( this . _locale , {
# stopTicking ( ) {
if ( this . # unsub ) {
this . # unsub ( ) ;
this . # unsub = null ;
}
}
# checkRestart ( ) {
const now = Date . now ( ) ;
if ( this . # target > now && ! this . # unsub ) {
this . # startTicking ( ) ;
}
}
# render ( nowMs ) {
if ( ! this . # textNode ) return ;
if ( this . # preset === 'date' ) {
const dtf = IntlCache . dtf ( 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 ;
const txt = dtf . format ( this . # target ) ;
if ( this . # textNode . data !== txt ) this . # textNode . data = txt ;
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 ;
const diff = this . # target - now ;
const isFuture = diff >= 0 ;
// Decide what to show based on preset.
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.
if ( this . _compact ) {
const start = isFuture ? now : this . # target ;
const end = isFuture ? this . # target : now ;
let show = getShowConfig ( this . # preset , Math . abs ( diff ) ) ;
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 } ;
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 ;
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 ) ;
// If countdown reached zero, stop ticking to save CPU.
if ( future && end - now <= 0 && this . _unsub ) { this . _unsub ( ) ; this . _unsub = null ; }
let txt = formatDurationSmart ( this . # locale , this . # labels , show , parts ) ;
if ( ! txt ) txt = '00:00:00' ;
// 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 ;
if ( this . # textNode . data !== txt ) this . # textNode . data = txt ;
}
/* =======================
* Public API (properties)
* =======================
* Use these instead of touching private fields .
/* Public API */
/**
* The target date object .
* @type {Date}
*/
// 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 ( ) ) ;
get target ( ) { return new Date ( this . # target ) ; }
set target ( val ) {
const d = new Date ( val ) ;
if ( isNaN ( d ) ) return ;
this . # target = d ;
this . # reflect ( 'target' , d . toISOString ( ) ) ;
this . # render ( Date . now ( ) ) ;
this . # checkRestart ( ) ;
}
// 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 ( ) ) ;
/**
* Current preset configuration.
* @type {string} - 'auto' | 'hms' | 'dhms' | 'ydhms' | 'date'
*/
get preset ( ) { return this . # preset ; }
set preset ( val ) {
this . # preset = String ( val ) . toLowerCase ( ) ;
this . # reflect ( 'preset' , this . # preset ) ;
this . # render ( 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 ( ) ) ;
/**
* Label display mode.
* @type {string} - 'none' | 'short' | 'long'
*/
get labels ( ) { return this . # labels ; }
set labels ( val ) {
this . # labels = String ( val ) . toLowerCase ( ) ;
this . # reflect ( 'labels' , this . # labels ) ;
this . # render ( 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 ( ) ) ;
/**
* 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 ;
this . # render ( Date . now ( ) ) ;
this . # isAttrUpdate = false ;
}
// 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 ( ) ) ;
/**
* Current locale override.
* @type {string}
*/
set locale ( val ) {
if ( ! val ) return ;
this . # locale = String ( val ) . toLowerCase ( ) ;
this . # reflect ( 'locale' , this . # locale ) ;
this . # render ( 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 ;
# reflect ( name , val ) {
this . # isAttrUpdate = true ;
this . setAttribute ( name , val ) ;
this . # isAttrUpdate = false ;
}
}
customElements . define ( 'wc-timer' , WcTimer ) ;
customElements . define ( 'wc-timer' , WcTimer ) ;