first commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
e2e/cypress/screenshots/*
|
||||
13
.npmignore
Normal file
13
.npmignore
Normal file
@@ -0,0 +1,13 @@
|
||||
# Ignoruj narzędzia CI/CD
|
||||
Jenkinsfile
|
||||
Dockerfile
|
||||
docker-compose.test.yml
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Ignoruj testy i demo
|
||||
e2e/
|
||||
public/
|
||||
|
||||
# Ignoruj konfigurację IDE
|
||||
.vscode/
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# Użycie lekkiego, stabilnego obrazu Node.js
|
||||
FROM node:20-alpine
|
||||
|
||||
# Ustawienie katalogu roboczego dla kodu źródłowego i zależności
|
||||
WORKDIR /app/wc-timer
|
||||
|
||||
# Kopiowanie plików manifestu i instalacja zależności
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
# Kopiowanie reszty plików projektu (kod źródłowy)
|
||||
COPY src/wc-timer.js src/wc-timer.js
|
||||
|
||||
# Instalacja prostego serwera HTTP
|
||||
RUN npm install -g http-server
|
||||
|
||||
# Kopiowanie publicznego katalogu demonstracyjnego do osobnego miejsca
|
||||
WORKDIR /app/public
|
||||
COPY public/. .
|
||||
|
||||
# Wracamy do głównego katalogu roboczego, skąd chcemy serwować pliki.
|
||||
WORKDIR /app
|
||||
|
||||
# Utworzenie linku symbolicznego do pliku JS (jak w konfiguracji Nginx)
|
||||
# Serwer http-server będzie go poprawnie obsługiwał.
|
||||
RUN mkdir -p public/assets
|
||||
RUN ln -sf ../../wc-timer/src/wc-timer.js public/assets/wc-timer.js
|
||||
|
||||
# Uruchomienie serwera WWW, serwującego katalog /app/public na porcie 8000
|
||||
# -c-1 wyłącza cache, co jest pomocne w development
|
||||
CMD ["http-server", "public", "-p", "8000", "-c-1"]
|
||||
0
Jenkinsfile
vendored
Normal file
0
Jenkinsfile
vendored
Normal file
80
README.md
Normal file
80
README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# wc-timer
|
||||
|
||||
A lightweight, dependency-free Web Component for countdowns and elapsed time. It uses the native `Intl` API for formatting and supports automatic language detection.
|
||||
|
||||
## Features
|
||||
|
||||
* **Zero Dependencies**: Built with standard Web Components API.
|
||||
* **Smart Formatting**: Supports various presets like `hms`, `dhms`, or `ydhms`.
|
||||
* **Performance**: Uses a shared global ticker (TickHub) to sync all instances and save CPU.
|
||||
* **Light DOM**: Renders text directly, making it easy to style with global CSS.
|
||||
* **Responsive**: Automatically updates when attributes change.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install wc-timer
|
||||
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Import the script**:
|
||||
|
||||
```javascript
|
||||
import 'wc-timer';
|
||||
// Or via script tag
|
||||
// <script type="module" src="path/to/wc-timer.js"></script>
|
||||
|
||||
```
|
||||
|
||||
2. **Use the tag in HTML**:
|
||||
|
||||
```html
|
||||
<wc-timer target="2025-12-31T23:59:59Z"></wc-timer>
|
||||
|
||||
<wc-timer
|
||||
target="2026-01-01T00:00:00Z"
|
||||
preset="dhms"
|
||||
labels="short"
|
||||
compact
|
||||
locale="en-US">
|
||||
</wc-timer>
|
||||
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Attributes
|
||||
|
||||
| Attribute | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `target` | `ISO 8601` | `now` | The target date/time (e.g., `2025-12-31T23:59:59Z`). |
|
||||
| `preset` | `string` | `auto` | Format preset: `auto`, `hms`, `dhms`, `ydhms`, `date`. |
|
||||
| `labels` | `string` | `long` | Unit labels: `none` (00:00:00), `short` (1h 5m), `long` (1 hour 5 minutes). |
|
||||
| `compact` | `boolean` | `false` | If present, hides zero-value units (e.g., skips "0 years"). |
|
||||
| `locale` | `BCP 47` | `auto` | Forces a specific language (e.g., `pl`, `en`, `de`). Defaults to browser settings. |
|
||||
|
||||
### JavaScript Properties
|
||||
|
||||
All attributes are reflected as properties on the DOM element.
|
||||
|
||||
```javascript
|
||||
const el = document.querySelector('wc-timer');
|
||||
|
||||
// Update target dynamically
|
||||
el.target = new Date('2030-01-01');
|
||||
|
||||
// Change configuration
|
||||
el.preset = 'ydhms';
|
||||
el.compact = true;
|
||||
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
|
||||
Works in all modern browsers supporting Web Components and `Intl.DurationFormat` (or falls back gracefully).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
23
docker-compose.test.yml
Normal file
23
docker-compose.test.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
# Serwis aplikacji budowany z naszego Dockerfile
|
||||
app:
|
||||
build: .
|
||||
container_name: ntwo-app
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
# Oficjalny kontener Cypress z przegladarkami
|
||||
cypress:
|
||||
image: cypress/included:13.6.0
|
||||
container_name: ntwo-tests
|
||||
# Zaleznosc: testy rusza dopiero po starcie aplikacji
|
||||
depends_on:
|
||||
- app
|
||||
environment:
|
||||
# Adres pod ktorym Cypress widzi nasza aplikacje w sieci Docker
|
||||
- CYPRESS_baseUrl=http://app:8000
|
||||
working_dir: /e2e
|
||||
# Montowanie wolumenow: pozwala zapisac zrzuty ekranu na dysku hosta
|
||||
volumes:
|
||||
- ./e2e:/e2e
|
||||
14
e2e/cypress.config.js
Normal file
14
e2e/cypress.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const { defineConfig } = require("cypress");
|
||||
|
||||
module.exports = defineConfig({
|
||||
e2e: {
|
||||
setupNodeEvents(on, config) {
|
||||
// miejsce na listenery zdarzen (np. do generowania raportow)
|
||||
},
|
||||
// Domyslny url, nadpisywany przez zmienna srodowiskowa w docker-compose
|
||||
baseUrl: 'http://localhost:8000',
|
||||
specPattern: 'cypress/**/*.cy.js', // Dostosowanie sciezki do struktury plikow
|
||||
supportFile: false, // Wylaczamy pliki wsparcia dla uproszczenia struktury
|
||||
video: false // Wylaczamy nagrywanie wideo, wystarcza zrzuty ekranu
|
||||
},
|
||||
});
|
||||
140
e2e/cypress/wc-timer.cy.js
Normal file
140
e2e/cypress/wc-timer.cy.js
Normal file
@@ -0,0 +1,140 @@
|
||||
describe('testy komponentu wc-timer', () => {
|
||||
// Data początkowa: 1 stycznia 2025, godzina 12:00:00
|
||||
const NOW_TIMESTAMP = 1735732800000;
|
||||
|
||||
beforeEach(() => {
|
||||
// 1. Zamrażamy czas
|
||||
cy.clock(NOW_TIMESTAMP);
|
||||
|
||||
// 2. Synchronizujemy performance.now()
|
||||
cy.visit('/', {
|
||||
onBeforeLoad(win) {
|
||||
cy.stub(win.performance, 'now').callsFake(() => win.Date.now());
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Czekamy na definicję komponentu
|
||||
cy.window().then((win) => win.customElements.whenDefined('wc-timer'));
|
||||
});
|
||||
|
||||
describe('widoczność i inicjalizacja', () => {
|
||||
it('element jest widoczny w DOM', () => {
|
||||
cy.get('wc-timer').first().should('be.visible');
|
||||
// SCREENSHOT 1: Stan początkowy aplikacji
|
||||
cy.screenshot('01-init-visible');
|
||||
});
|
||||
|
||||
it('renderuje tekst po załadowaniu', () => {
|
||||
cy.get('wc-timer').first().invoke('text').should('not.be.empty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('atrybuty html (declarative)', () => {
|
||||
it('obsługuje zmianę atrybutu target i preset', () => {
|
||||
const targetStr = new Date(NOW_TIMESTAMP + 2 * 3600000).toISOString();
|
||||
|
||||
cy.get('wc-timer').first()
|
||||
.invoke('attr', 'labels', 'none')
|
||||
.invoke('attr', 'target', targetStr)
|
||||
.invoke('attr', 'preset', 'hms');
|
||||
|
||||
cy.get('wc-timer').first().should('contain.text', '02:00:00');
|
||||
// SCREENSHOT 2: Format HMS bez etykiet
|
||||
cy.screenshot('02-attr-preset-hms');
|
||||
|
||||
// Zmiana na dni-godziny-minuty
|
||||
cy.get('wc-timer').first()
|
||||
.invoke('attr', 'preset', 'dhms')
|
||||
.invoke('attr', 'labels', 'short');
|
||||
|
||||
cy.get('wc-timer').first().invoke('text').should('match', /0\s(d|dni|days)/);
|
||||
// SCREENSHOT 3: Format DHMS z etykietami
|
||||
cy.screenshot('03-attr-preset-dhms');
|
||||
});
|
||||
|
||||
it('tryb compact ukrywa puste jednostki', () => {
|
||||
const targetStr = new Date(NOW_TIMESTAMP + 5 * 60000).toISOString();
|
||||
|
||||
cy.get('wc-timer').first()
|
||||
.invoke('attr', 'labels', 'none')
|
||||
.invoke('attr', 'compact', '')
|
||||
.invoke('attr', 'target', targetStr);
|
||||
|
||||
cy.get('wc-timer').first().should('contain.text', '05:00');
|
||||
// SCREENSHOT 4: Tryb compact (ukryte godziny)
|
||||
cy.screenshot('04-attr-compact');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api javascript (imperative)', () => {
|
||||
it('aktualizuje widok po ustawieniu właściwości .target w JS', () => {
|
||||
const targetStr = new Date(NOW_TIMESTAMP + 10000).toISOString();
|
||||
|
||||
cy.get('wc-timer').first()
|
||||
.should(($el) => {
|
||||
expect($el[0]).to.have.property('preset');
|
||||
})
|
||||
.then(($el) => {
|
||||
const el = $el[0];
|
||||
el.target = targetStr;
|
||||
el.preset = 'hms';
|
||||
el.labels = 'none';
|
||||
});
|
||||
|
||||
cy.get('wc-timer').first().should('contain.text', '00:00:10');
|
||||
// SCREENSHOT 5: Zmiana przez JS API
|
||||
cy.screenshot('05-js-api-change');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lokalizacja', () => {
|
||||
it('reaguje na zmianę atrybutu locale', () => {
|
||||
const targetStr = new Date(NOW_TIMESTAMP + 2 * 86400000).toISOString();
|
||||
|
||||
// Wersja Polska
|
||||
cy.get('wc-timer').first()
|
||||
.invoke('attr', 'target', targetStr)
|
||||
.invoke('attr', 'preset', 'dhms')
|
||||
.invoke('attr', 'labels', 'long')
|
||||
.invoke('attr', 'locale', 'pl');
|
||||
|
||||
cy.get('wc-timer').first().should('contain.text', 'dni');
|
||||
// SCREENSHOT 6: Język polski
|
||||
cy.screenshot('06-locale-pl');
|
||||
|
||||
// Wersja Angielska
|
||||
cy.get('wc-timer').first().invoke('attr', 'locale', 'en');
|
||||
cy.get('wc-timer').first().should('contain.text', 'days');
|
||||
// SCREENSHOT 7: Język angielski
|
||||
cy.screenshot('07-locale-en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('symulacja upływu czasu (ticking)', () => {
|
||||
it('odlicza czas w dół co sekundę', () => {
|
||||
const targetStr = new Date(NOW_TIMESTAMP + 10000).toISOString();
|
||||
|
||||
cy.get('wc-timer').first()
|
||||
.invoke('attr', 'preset', 'hms')
|
||||
.invoke('attr', 'labels', 'none')
|
||||
.invoke('attr', 'target', targetStr);
|
||||
|
||||
// Start: 10 sekund
|
||||
cy.get('wc-timer').first().should('contain.text', '00:00:10');
|
||||
// SCREENSHOT 8: Start odliczania
|
||||
cy.screenshot('08-tick-start');
|
||||
|
||||
// Przeskok o 5 sekund
|
||||
cy.tick(5000);
|
||||
cy.get('wc-timer').first().should('contain.text', '00:00:05');
|
||||
// SCREENSHOT 9: Połowa czasu
|
||||
cy.screenshot('09-tick-middle');
|
||||
|
||||
// Koniec (0 sekund)
|
||||
cy.tick(5000);
|
||||
cy.get('wc-timer').first().should('contain.text', '00:00:00');
|
||||
// SCREENSHOT 10: Koniec odliczania
|
||||
cy.screenshot('10-tick-end');
|
||||
});
|
||||
});
|
||||
});
|
||||
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "wc-timer",
|
||||
"version": "1.0.0",
|
||||
"description": "A lightweight, dependency-free countdown timer Web Component.",
|
||||
"type": "module",
|
||||
"main": "src/wc-timer.js",
|
||||
"exports": {
|
||||
".": "./src/wc-timer.js"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "echo 'Tests are running in Docker environment' && exit 0"
|
||||
},
|
||||
"keywords": [
|
||||
"web-component",
|
||||
"custom-elements",
|
||||
"timer",
|
||||
"countdown",
|
||||
"date",
|
||||
"time",
|
||||
"zero-dependency"
|
||||
],
|
||||
"author": "Dávid Ali",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"jest": "^30.2.0"
|
||||
}
|
||||
}
|
||||
454
public/assets/wc-timer.js
Normal file
454
public/assets/wc-timer.js
Normal file
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* 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);
|
||||
55
public/index.html
Normal file
55
public/index.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>wc-timer demo</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script type="module" src="/assets/wc-timer.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
/* Stylizacja kontenera, żeby ładnie wyglądało na screenshocie */
|
||||
.container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 { margin-top: 0; color: #333; font-size: 1.5rem; }
|
||||
|
||||
/* Styl samego komponentu */
|
||||
wc-timer {
|
||||
display: block;
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: #0066cc;
|
||||
margin: 1rem 0;
|
||||
font-variant-numeric: tabular-nums; /* Aby cyfry nie skakały */
|
||||
}
|
||||
|
||||
p { color: #666; font-size: 0.9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<h1>wc-timer</h1>
|
||||
|
||||
<wc-timer target="2030-01-01T00:00:00Z"></wc-timer>
|
||||
|
||||
<p>Demo & Test Environment</p>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
454
src/wc-timer.js
Normal file
454
src/wc-timer.js
Normal file
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* 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