Compare commits
15 Commits
9cbd21d22b
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4bb8bdd75 | ||
|
|
2451b1a9e7 | ||
|
|
f9e2615d11 | ||
|
|
2ef4a3352d | ||
|
|
0e5316c88b | ||
|
|
39c3aa5000 | ||
|
|
d314aab25d | ||
|
|
86abe417be | ||
|
|
a733644d67 | ||
|
|
374843574e | ||
|
|
3ed9fe10b9 | ||
|
|
1c6d791ee2 | ||
|
|
095a6e2af4 | ||
|
|
d874213bdf | ||
|
|
ba6654a489 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
e2e/cypress/screenshots/*
|
e2e/cypress/screenshots/*
|
||||||
|
*.old
|
||||||
|
|||||||
13
.npmignore
13
.npmignore
@@ -1,13 +0,0 @@
|
|||||||
# Ignoruj narzędzia CI/CD
|
|
||||||
Jenkinsfile
|
|
||||||
Dockerfile
|
|
||||||
docker-compose.test.yml
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Ignoruj testy i demo
|
|
||||||
e2e/
|
|
||||||
public/
|
|
||||||
|
|
||||||
# Ignoruj konfigurację IDE
|
|
||||||
.vscode/
|
|
||||||
23
CHANGELOG.md
Normal file
23
CHANGELOG.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# CHANGELOG
|
||||||
|
|
||||||
|
## [1.1.0] : Refactor and Optimization
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
* **IntlCache**: Implemented caching for `Intl` objects. Date formatting no longer creates new instances on every refresh.
|
||||||
|
* **Shared Styles**: Used `adoptedStyleSheets` instead of `<style>` tags. The browser parses CSS only once.
|
||||||
|
* **Date Math**: Removed `while` loops for year/month calculations. Replaced them with faster O(1) mathematical formulas.
|
||||||
|
|
||||||
|
### Stability and UI
|
||||||
|
* **Layout Shift (CLS)**: Fixed content jumping on load. The component now has a default `min-height`.
|
||||||
|
* **Initialization**: Added a non-breaking space (`\u00A0`) at start. This prevents zero height before the first render.
|
||||||
|
* **TickHub Safety**: Iteration over subscribers now uses a copy. An error in one timer will not stop others.
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
* **Private Fields**: Changed `_variable` convention to native private fields `#variable`. Internal state is now fully protected.
|
||||||
|
* **Attribute Handling**: Improved attribute update logic (`#isAttrUpdate`). This eliminates unnecessary render cycles.
|
||||||
|
* **Auto-restart**: The stopped timer restarts automatically if the target date changes to the future.
|
||||||
|
* **Fallback**: Added a safety check returning `00:00:00` if formatting results in an empty string.
|
||||||
|
|
||||||
|
## [1.0.1] : Added README.md
|
||||||
|
|
||||||
|
## [1.0.0] : First version
|
||||||
@@ -6,6 +6,8 @@ WORKDIR /app/wc-timer
|
|||||||
|
|
||||||
# Kopiowanie plików manifestu i instalacja zależności
|
# Kopiowanie plików manifestu i instalacja zależności
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
COPY README.md ./
|
||||||
|
COPY CHANGELOG.md ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
# Kopiowanie reszty plików projektu (kod źródłowy)
|
# Kopiowanie reszty plików projektu (kod źródłowy)
|
||||||
|
|||||||
121
Jenkinsfile
vendored
121
Jenkinsfile
vendored
@@ -2,77 +2,96 @@ pipeline {
|
|||||||
agent any
|
agent any
|
||||||
|
|
||||||
environment {
|
environment {
|
||||||
// Tożsamość bota, który będzie widoczny w historii Gita jako autor scalenia
|
|
||||||
GIT_AUTHOR_NAME = 'Jenkins Bot'
|
GIT_AUTHOR_NAME = 'Jenkins Bot'
|
||||||
GIT_AUTHOR_EMAIL = 'jenkins@alidavid.hu'
|
GIT_AUTHOR_EMAIL = 'jenkins@alidavid.hu'
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
// ETAP 1: Testy (Gatekeeper)
|
// =========================================================
|
||||||
// Jeśli ten etap nie przejdzie, pipeline się zatrzyma i nic nie trafi na main.
|
// ETAP 1: DEVELOP (Testy i Merge)
|
||||||
stage('Build & Test (E2E)') {
|
// =========================================================
|
||||||
steps {
|
stage('CI: Test & Merge (Develop)') {
|
||||||
script {
|
|
||||||
echo '--- 1. Start środowiska testowego ---'
|
|
||||||
|
|
||||||
// Sprzątanie (na wypadek, gdyby coś zostało z poprzedniego uruchomienia)
|
|
||||||
sh 'docker compose -f docker-compose.test.yml down -v || true'
|
|
||||||
|
|
||||||
// Uruchomienie testów
|
|
||||||
// --abort-on-container-exit: zamyka kontenery, gdy Cypress skończy
|
|
||||||
// --exit-code-from cypress: jeśli Cypress zwróci błąd, Jenkins przerwie zadanie
|
|
||||||
sh 'docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from cypress'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ETAP 2: Automatyczny Merge (Tylko dla gałęzi develop)
|
|
||||||
stage('Merge Develop -> Main') {
|
|
||||||
when {
|
when {
|
||||||
// Uruchom ten krok TYLKO, gdy zmiany są na gałęzi 'develop'
|
|
||||||
branch 'develop'
|
branch 'develop'
|
||||||
}
|
}
|
||||||
steps {
|
steps {
|
||||||
// Używamy klucza SSH z Jenkinsa do autoryzacji w Gitea
|
script {
|
||||||
|
echo '--- [DEVELOP] 1. Uruchamianie testów E2E ---'
|
||||||
|
sh 'docker compose -f docker-compose.test.yml down -v || true'
|
||||||
|
sh 'docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from cypress'
|
||||||
|
}
|
||||||
sshagent(credentials: ['gitea-ssh-key']) {
|
sshagent(credentials: ['gitea-ssh-key']) {
|
||||||
script {
|
script {
|
||||||
echo '--- 2. Testy zaliczone. Scalanie do produkcji (main) ---'
|
echo '--- [DEVELOP] 2. Testy OK. Scalanie do Main ---'
|
||||||
|
|
||||||
// Konfiguracja Gita wewnątrz runnera
|
|
||||||
sh "git config user.name '${GIT_AUTHOR_NAME}'"
|
sh "git config user.name '${GIT_AUTHOR_NAME}'"
|
||||||
sh "git config user.email '${GIT_AUTHOR_EMAIL}'"
|
sh "git config user.email '${GIT_AUTHOR_EMAIL}'"
|
||||||
|
|
||||||
// 1. Pobieramy aktualny stan repozytorium
|
|
||||||
sh 'git fetch origin main'
|
sh 'git fetch origin main'
|
||||||
|
|
||||||
// 2. Przełączamy się na main
|
|
||||||
sh 'git checkout main'
|
sh 'git checkout main'
|
||||||
// Upewniamy się, że mamy najnowszą wersję main
|
|
||||||
sh 'git pull origin main'
|
sh 'git pull origin main'
|
||||||
|
sh 'git merge origin/develop --no-ff -m "Release: Merge develop to main"'
|
||||||
// 3. Scalamy develop do main
|
|
||||||
// --no-ff tworzy commit scalający (widać historię w grafie)
|
|
||||||
sh 'git merge origin/develop --no-ff -m "Merge branch develop into main (Jenkins CI)"'
|
|
||||||
|
|
||||||
// 4. Wysyłamy zmiany do Gitea
|
|
||||||
// StrictHostKeyChecking=no zapobiega pytaniom o akceptację klucza hosta
|
|
||||||
sh 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no" git push origin main'
|
sh 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no" git push origin main'
|
||||||
|
|
||||||
echo '--- SUKCES: Kod został zaktualizowany na gałęzi main ---'
|
|
||||||
|
|
||||||
// Powrót na develop dla porządku
|
|
||||||
sh 'git checkout develop'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
echo '--- [DEVELOP] Sprzątanie ---'
|
||||||
|
sh 'docker compose -f docker-compose.test.yml down -v'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// ETAP 2: MAIN (Weryfikacja wersji i Publikacja w Dockerze)
|
||||||
|
// =========================================================
|
||||||
|
stage('CD: Publish to NPM (Main)') {
|
||||||
|
when {
|
||||||
|
branch 'main'
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
withCredentials([string(credentialsId: 'npm-token', variable: 'NPM_TOKEN')]) {
|
||||||
|
script {
|
||||||
|
echo '--- [MAIN] Start procedury publikacji ---'
|
||||||
|
|
||||||
|
def localVersion = sh(script: "grep '\"version\":' package.json | cut -d\\\" -f4", returnStdout: true).trim()
|
||||||
|
echo "Lokalna wersja w package.json: ${localVersion}"
|
||||||
|
|
||||||
|
echo '--- Przebudowywanie obrazu z aktualnym kodem ---'
|
||||||
|
sh 'docker compose -f docker-compose.test.yml build app'
|
||||||
|
|
||||||
|
def versionExists = false
|
||||||
|
try {
|
||||||
|
sh "docker compose -f docker-compose.test.yml run --rm -w /app/wc-timer app npm view wc-timer@${localVersion} version"
|
||||||
|
versionExists = true
|
||||||
|
} catch (err) {
|
||||||
|
versionExists = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionExists) {
|
||||||
|
echo "[ERROR] BŁĄD: Wersja ${localVersion} jest już opublikowana w NPM!"
|
||||||
|
error("Przerwano publikację: Wersja ${localVersion} już istnieje.")
|
||||||
|
} else {
|
||||||
|
echo "[OK] Wersja ${localVersion} jest nowa. Publikuję..."
|
||||||
|
|
||||||
|
sh """
|
||||||
|
docker compose -f docker-compose.test.yml run --rm \
|
||||||
|
-w /app/wc-timer \
|
||||||
|
-e NPM_TOKEN=${NPM_TOKEN} \
|
||||||
|
app \
|
||||||
|
sh -c "echo '//registry.npmjs.org/:_authToken=\${NPM_TOKEN}' > .npmrc && npm publish --access public"
|
||||||
|
"""
|
||||||
|
|
||||||
|
echo "[OK] SUKCES: Wersja ${localVersion} została opublikowana."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
echo '--- [MAIN] Sprzątanie ---'
|
||||||
|
sh 'docker compose -f docker-compose.test.yml down -v'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
|
||||||
always {
|
|
||||||
// Bez względu na wynik (sukces/błąd), sprzątamy kontenery Dockerowe
|
|
||||||
echo '--- Sprzątanie po testach ---'
|
|
||||||
sh 'docker compose -f docker-compose.test.yml down -v'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
76
README.md
76
README.md
@@ -1,14 +1,14 @@
|
|||||||
# wc-timer
|
# 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.
|
A high-performance, dependency-free Web Component for countdowns, elapsed time, and static dates. It uses a shared ticker and cached `Intl` formatters for maximum efficiency.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* **Zero Dependencies**: Built with standard Web Components API.
|
* **High Performance**: Uses a shared global ticker (TickHub) to sync all instances. It calculates dates using O(1) math and caches `Intl` objects to save memory.
|
||||||
* **Smart Formatting**: Supports various presets like `hms`, `dhms`, or `ydhms`.
|
* **Smart Formatting**: Supports `Intl.DurationFormat` with a robust fallback for older browsers.
|
||||||
* **Performance**: Uses a shared global ticker (TickHub) to sync all instances and save CPU.
|
* **Versatile Presets**: Choose from `hms`, `dhms`, `ydhms`, `auto`, or static `date`.
|
||||||
* **Light DOM**: Renders text directly, making it easy to style with global CSS.
|
* **Light DOM**: Renders as a standard Text Node. The text is selectable and inherits global CSS styles easily.
|
||||||
* **Responsive**: Automatically updates when attributes change.
|
* **Auto-Restart**: If you update the target to a future date, the timer restarts automatically.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -23,24 +23,26 @@ npm install wc-timer
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import 'wc-timer';
|
import 'wc-timer';
|
||||||
// Or via script tag
|
// Or via script tag:
|
||||||
// <script type="module" src="path/to/wc-timer.js"></script>
|
// <script type="module" src="wc-timer.js"></script>
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Use the tag in HTML**:
|
2. **Use in HTML**:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<wc-timer target="2025-12-31T23:59:59Z"></wc-timer>
|
<wc-timer target="2026-01-01T00:00:00Z"></wc-timer>
|
||||||
|
|
||||||
<wc-timer
|
<wc-timer
|
||||||
target="2026-01-01T00:00:00Z"
|
target="2026-06-01T12:00:00Z"
|
||||||
preset="dhms"
|
preset="dhms"
|
||||||
labels="short"
|
labels="long"
|
||||||
compact
|
compact
|
||||||
locale="en-US">
|
locale="pl">
|
||||||
</wc-timer>
|
</wc-timer>
|
||||||
|
|
||||||
|
<wc-timer preset="date" target="2025-12-31"></wc-timer>
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
@@ -49,32 +51,60 @@ import 'wc-timer';
|
|||||||
|
|
||||||
| Attribute | Type | Default | Description |
|
| Attribute | Type | Default | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `target` | `ISO 8601` | `now` | The target date/time (e.g., `2025-12-31T23:59:59Z`). |
|
| `target` | `string` | `now` | The target date (ISO 8601 string or Date compatible). |
|
||||||
| `preset` | `string` | `auto` | Format preset: `auto`, `hms`, `dhms`, `ydhms`, `date`. |
|
| `preset` | `string` | `auto` | formatting mode: `auto`, `hms`, `dhms`, `ydhms`, `date`. |
|
||||||
| `labels` | `string` | `long` | Unit labels: `none` (00:00:00), `short` (1h 5m), `long` (1 hour 5 minutes). |
|
| `labels` | `string` | `short` | 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"). |
|
| `compact` | `boolean` | `false` | if present, hides zero-value high units (e.g. hides "0 years"). |
|
||||||
| `locale` | `BCP 47` | `auto` | Forces a specific language (e.g., `pl`, `en`, `de`). Defaults to browser settings. |
|
| `locale` | `string` | `auto` | overrides browser language (e.g. `pl`, `en-US`). |
|
||||||
|
|
||||||
|
### Presets Details
|
||||||
|
|
||||||
|
* **auto**: shows `HH:MM:SS`. If duration > 30 days, it adds days.
|
||||||
|
* **hms**: hours, minutes, seconds.
|
||||||
|
* **dhms**: days, hours, minutes, seconds.
|
||||||
|
* **ydhms**: years, months, days, hours, minutes, seconds.
|
||||||
|
* **date**: static date formatted via `Intl.DateTimeFormat`.
|
||||||
|
|
||||||
### JavaScript Properties
|
### JavaScript Properties
|
||||||
|
|
||||||
All attributes are reflected as properties on the DOM element.
|
You can control the element programmatically.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const el = document.querySelector('wc-timer');
|
const el = document.querySelector('wc-timer');
|
||||||
|
|
||||||
// Update target dynamically
|
// Update target (accepts Date object, string, or timestamp)
|
||||||
el.target = new Date('2030-01-01');
|
el.target = new Date('2030-01-01');
|
||||||
|
|
||||||
// Change configuration
|
// Change settings on the fly
|
||||||
el.preset = 'ydhms';
|
el.preset = 'ydhms';
|
||||||
el.compact = true;
|
el.compact = true;
|
||||||
|
el.labels = 'long';
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
Since `wc-timer` uses Light DOM, you can style it with standard CSS.
|
||||||
|
|
||||||
|
```css
|
||||||
|
wc-timer {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Browser Support
|
## Browser Support
|
||||||
|
|
||||||
Works in all modern browsers supporting Web Components and `Intl.DurationFormat` (or falls back gracefully).
|
Works in all modern browsers. It uses `Intl.DurationFormat` if available. Otherwise, it falls back to a custom formatter using `Intl.PluralRules`.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
Copyright 2026 Dávid Ali
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wc-timer",
|
"name": "wc-timer",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "A lightweight, dependency-free countdown timer Web Component.",
|
"description": "A lightweight, dependency-free countdown timer Web Component.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/wc-timer.js",
|
"main": "src/wc-timer.js",
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
".": "./src/wc-timer.js"
|
".": "./src/wc-timer.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src"
|
"src/wc-timer.js",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"README.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo 'Tests are running in Docker environment' && exit 0"
|
"test": "echo 'Tests are running in Docker environment' && exit 0"
|
||||||
|
|||||||
@@ -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);
|
|
||||||
585
src/wc-timer.js
585
src/wc-timer.js
@@ -1,94 +1,71 @@
|
|||||||
/**
|
/**
|
||||||
* wc-timer.js — Web Component
|
* wc-timer
|
||||||
* ---------------------------------------------
|
* @version 1.1.0
|
||||||
* A tiny countdown/uptimer that renders as plain text
|
* @description A lightweight, efficient countdown/uptimer Web Component.
|
||||||
* (light DOM). Text is selectable and styles with normal CSS.
|
* @license MIT
|
||||||
*
|
|
||||||
* 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,
|
* Internal cache for Intl objects to optimize performance.
|
||||||
* aligned to the next real second boundary.
|
* @private
|
||||||
* Saves CPU and avoids per-element setInterval drift.
|
*/
|
||||||
|
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 TickHub = (() => {
|
||||||
const subs = new Set();
|
const subs = new Set();
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
|
||||||
function scheduleNext() {
|
function scheduleNext() {
|
||||||
// Align next tick to the next full second.
|
if (subs.size === 0) return;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const msToNext = 1000 - (Math.floor(now) % 1000);
|
const msToNext = 1000 - (Math.floor(now) % 1000);
|
||||||
|
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
const t = Date.now();
|
const t = Date.now();
|
||||||
// Notify all subscribers. Errors are isolated.
|
for (const cb of [...subs]) {
|
||||||
subs.forEach(cb => { try { cb(t); } catch { } });
|
try { cb(t); } catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
scheduleNext();
|
scheduleNext();
|
||||||
}, msToNext);
|
}, msToNext);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/**
|
|
||||||
* sub(cb): subscribe to ticks.
|
|
||||||
* Returns an unsubscribe function.
|
|
||||||
*/
|
|
||||||
sub(cb) {
|
sub(cb) {
|
||||||
if (subs.size === 0) scheduleNext();
|
|
||||||
subs.add(cb);
|
subs.add(cb);
|
||||||
|
if (!timer) scheduleNext();
|
||||||
return () => {
|
return () => {
|
||||||
subs.delete(cb);
|
subs.delete(cb);
|
||||||
if (!subs.size && timer) { clearTimeout(timer); timer = null; }
|
if (subs.size === 0 && timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/* ---------------- Locale helpers ----------------
|
/* ---------------- Locale Helpers ---------------- */
|
||||||
* We prefer: explicit element attribute -> nearest [lang] -> <html lang>
|
|
||||||
* -> navigator.languages[0] -> navigator.language -> 'en'
|
|
||||||
*/
|
|
||||||
const hasIDF = typeof Intl !== 'undefined' && 'DurationFormat' in Intl;
|
|
||||||
|
|
||||||
function resolveLocale(el) {
|
|
||||||
const attr = el.getAttribute('locale') || el.getAttribute('lang');
|
|
||||||
const tree = el.closest?.('[lang]')?.getAttribute('lang') || document.documentElement.lang;
|
|
||||||
const nav = Array.isArray(navigator.languages) && navigator.languages.length
|
|
||||||
? navigator.languages[0]
|
|
||||||
: navigator.language;
|
|
||||||
return (attr || tree || nav || 'en').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback label dictionaries (English + Polish).
|
|
||||||
// Used only when Intl.DurationFormat is not available.
|
|
||||||
const LABELS = {
|
const LABELS = {
|
||||||
en: {
|
en: {
|
||||||
y: { long: { one: 'year', other: 'years' }, short: 'y' },
|
y: { long: { one: 'year', other: 'years' }, short: 'y' },
|
||||||
@@ -107,347 +84,337 @@ const LABELS = {
|
|||||||
s: { long: { one: 'sekunda', few: 'sekundy', many: 'sekund', other: 'sekund' }, short: 's' },
|
s: { long: { one: 'sekunda', few: 'sekundy', many: 'sekund', other: 'sekund' }, short: 's' },
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const clampLocale = loc => (LABELS[loc.slice(0, 2)] ? loc.slice(0, 2) : 'en');
|
|
||||||
|
|
||||||
/* ---------------- Diff with smart collapsing ----------------
|
function resolveLocale(el) {
|
||||||
* We compute calendar years/months only if those units are shown.
|
const attr = el.getAttribute('locale') || el.getAttribute('lang');
|
||||||
* If not shown, their time “falls down” into lower units (days/hours/...).
|
if (attr) return attr.toLowerCase();
|
||||||
*/
|
return (navigator.language || 'en').toLowerCase();
|
||||||
function extractYearsMonthsCalendar(a, b, wantY, wantM) {
|
}
|
||||||
|
|
||||||
|
/* ---------------- Date Logic ---------------- */
|
||||||
|
|
||||||
|
function diffCalendar(a, b, wantY, wantM) {
|
||||||
let anchor = new Date(a);
|
let anchor = new Date(a);
|
||||||
let y = 0, M = 0;
|
let y = 0;
|
||||||
|
let M = 0;
|
||||||
|
|
||||||
// Add full years while not passing end.
|
|
||||||
if (wantY) {
|
if (wantY) {
|
||||||
while (new Date(
|
y = b.getFullYear() - anchor.getFullYear();
|
||||||
anchor.getFullYear() + 1, anchor.getMonth(), anchor.getDate(),
|
const probe = new Date(anchor);
|
||||||
anchor.getHours(), anchor.getMinutes(), anchor.getSeconds()
|
probe.setFullYear(anchor.getFullYear() + y);
|
||||||
) <= b) { anchor.setFullYear(anchor.getFullYear() + 1); y++; }
|
if (probe > b) y--;
|
||||||
|
anchor.setFullYear(anchor.getFullYear() + y);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then add full months while not passing end.
|
|
||||||
if (wantM) {
|
if (wantM) {
|
||||||
while (new Date(
|
M = (b.getFullYear() - anchor.getFullYear()) * 12 + (b.getMonth() - anchor.getMonth());
|
||||||
anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate(),
|
const probe = new Date(anchor);
|
||||||
anchor.getHours(), anchor.getMinutes(), anchor.getSeconds()
|
probe.setMonth(anchor.getMonth() + M);
|
||||||
) <= b) { anchor.setMonth(anchor.getMonth() + 1); M++; }
|
if (probe > b) M--;
|
||||||
|
anchor.setMonth(anchor.getMonth() + M);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { y, M, anchor };
|
return { y, M, anchor };
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseForUnits(start, end, show) {
|
function collapseForUnits(start, end, show) {
|
||||||
const { y, M, anchor } = extractYearsMonthsCalendar(start, end, !!show.y, !!show.M);
|
const { y, M, anchor } = diffCalendar(start, end, !!show.y, !!show.M);
|
||||||
|
|
||||||
// If we showed Y/M, base the remaining ms on the anchor (after adding Y/M).
|
|
||||||
// Otherwise, consume everything from the start.
|
|
||||||
const base = (show.y || show.M) ? anchor : start;
|
const base = (show.y || show.M) ? anchor : start;
|
||||||
let rest = Math.max(0, end - base);
|
let rest = Math.max(0, end - base);
|
||||||
|
|
||||||
// Greedy consume into D/H/m/s only for units we plan to display.
|
const MS = { d: 86400000, h: 3600000, m: 60000, s: 1000 };
|
||||||
const dayMs = 86400000, hourMs = 3600000, minMs = 60000, secMs = 1000;
|
const res = { years: y, months: M, days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||||
let d = 0, h = 0, m = 0, s = 0;
|
|
||||||
|
|
||||||
if (show.d) { d = Math.floor(rest / dayMs); rest -= d * dayMs; }
|
if (show.d) { res.days = Math.floor(rest / MS.d); rest %= MS.d; }
|
||||||
if (show.h) { h = Math.floor(rest / hourMs); rest -= h * hourMs; }
|
if (show.h) { res.hours = Math.floor(rest / MS.h); rest %= MS.h; }
|
||||||
if (show.m) { m = Math.floor(rest / minMs); rest -= m * minMs; }
|
if (show.m) { res.minutes = Math.floor(rest / MS.m); rest %= MS.m; }
|
||||||
if (show.s) { s = Math.floor(rest / secMs); rest -= s * secMs; }
|
if (show.s) { res.seconds = Math.floor(rest / MS.s); }
|
||||||
|
|
||||||
return { years: y, months: M, days: d, hours: h, minutes: m, seconds: s };
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Formatters ----------------
|
/* ---------------- Formatting ---------------- */
|
||||||
* pad2: always pad for H/M/S “digital” look.
|
const pad2 = (n) => String(n ?? 0).padStart(2, '0');
|
||||||
* formatHMS: joins visible HMS with ':'.
|
|
||||||
* formatDurationSmart: uses DurationFormat if available; otherwise fallback.
|
|
||||||
*/
|
|
||||||
function pad2(n) { return String(n ?? 0).padStart(2, '0'); }
|
|
||||||
|
|
||||||
function formatHMS(show, p) {
|
|
||||||
const segs = [];
|
|
||||||
if (show.h) segs.push(pad2(p.hours));
|
|
||||||
if (show.m) segs.push(pad2(p.minutes));
|
|
||||||
if (show.s) segs.push(pad2(p.seconds));
|
|
||||||
return segs.join(':');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDurationSmart(locale, labelsMode, show, parts) {
|
function formatDurationSmart(locale, labelsMode, show, parts) {
|
||||||
const lang2 = LABELS[locale.slice(0, 2)] ? locale.slice(0, 2) : 'en';
|
|
||||||
|
|
||||||
// labels="none" → head units as numbers, HMS as HH:MM:SS
|
|
||||||
if (labelsMode === 'none') {
|
if (labelsMode === 'none') {
|
||||||
const head = [];
|
const head = [];
|
||||||
if (show.y) head.push(String(parts.years));
|
if (show.y) head.push(parts.years);
|
||||||
if (show.M) head.push(String(parts.months));
|
if (show.M) head.push(parts.months);
|
||||||
if (show.d) head.push(String(parts.days));
|
if (show.d) head.push(parts.days);
|
||||||
const tail = formatHMS(show, parts);
|
|
||||||
return [head.join(' '), tail].filter(Boolean).join(head.length && tail ? ' ' : '');
|
const hms = [pad2(parts.hours), pad2(parts.minutes), pad2(parts.seconds)];
|
||||||
|
if (!show.h) hms.shift();
|
||||||
|
|
||||||
|
return [...head, hms.join(':')].join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer native DurationFormat, it handles pluralization and spacing.
|
if (IntlCache.hasIDF) {
|
||||||
if (hasIDF) {
|
const opts = { style: labelsMode === 'short' ? 'short' : 'long' };
|
||||||
const style = labelsMode === 'short' ? 'short' : 'long';
|
|
||||||
const opts = { style };
|
|
||||||
if (show.h) opts.hours = '2-digit';
|
if (show.h) opts.hours = '2-digit';
|
||||||
if (show.m) opts.minutes = '2-digit';
|
if (show.m) opts.minutes = '2-digit';
|
||||||
if (show.s) opts.seconds = '2-digit';
|
if (show.s) opts.seconds = '2-digit';
|
||||||
|
|
||||||
const df = new Intl.DurationFormat(locale, opts);
|
|
||||||
const inParts = {};
|
const inParts = {};
|
||||||
if (show.y) inParts.years = parts.years;
|
for (const key in parts) {
|
||||||
if (show.M) inParts.months = parts.months;
|
if (show[key.slice(0, 1)] || show[key.slice(0, 2)]) inParts[key] = parts[key];
|
||||||
if (show.d) inParts.days = parts.days;
|
}
|
||||||
if (show.h) inParts.hours = parts.hours;
|
return IntlCache.df(locale, opts).format(inParts);
|
||||||
if (show.m) inParts.minutes = parts.minutes;
|
|
||||||
if (show.s) inParts.seconds = parts.seconds;
|
|
||||||
return df.format(inParts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: plural rules + our dictionaries
|
// Fallback
|
||||||
const PR = new Intl.PluralRules(locale);
|
const lang2 = LABELS[locale.slice(0, 2)] ? locale.slice(0, 2) : 'en';
|
||||||
const L = LABELS[lang2];
|
const L = LABELS[lang2];
|
||||||
|
const pr = IntlCache.pr(locale);
|
||||||
|
const out = [];
|
||||||
|
|
||||||
const head = [];
|
const add = (k, v, forcePad = false) => {
|
||||||
const push = (key, val) => {
|
const cat = pr.select(v);
|
||||||
const cat = PR.select(val);
|
const word = L[k].long[cat] ?? L[k].long.other;
|
||||||
const word = L[key].long[cat] ?? L[key].long.other;
|
const num = forcePad ? pad2(v) : v;
|
||||||
head.push(labelsMode === 'short' ? `${val} ${L[key].short}` : `${val} ${word}`);
|
const suffix = labelsMode === 'short' ? L[k].short : word;
|
||||||
|
out.push(`${num} ${suffix}`);
|
||||||
};
|
};
|
||||||
if (show.y) push('y', parts.years);
|
|
||||||
if (show.M) push('M', parts.months);
|
|
||||||
if (show.d) push('d', parts.days);
|
|
||||||
|
|
||||||
const hms = [];
|
if (show.y) add('y', parts.years);
|
||||||
const pushH = (key, val) => {
|
if (show.M) add('M', parts.months);
|
||||||
const cat = PR.select(val);
|
if (show.d) add('d', parts.days);
|
||||||
const word = L[key].long[cat] ?? L[key].long.other;
|
if (show.h) add('h', parts.hours, true);
|
||||||
if (labelsMode === 'short') hms.push(`${pad2(val)} ${L[key].short}`);
|
if (show.m) add('m', parts.minutes, true);
|
||||||
else hms.push(`${pad2(val)} ${word}`);
|
if (show.s) add('s', parts.seconds, true);
|
||||||
};
|
|
||||||
if (show.h) pushH('h', parts.hours);
|
|
||||||
if (show.m) pushH('m', parts.minutes);
|
|
||||||
if (show.s) pushH('s', parts.seconds);
|
|
||||||
|
|
||||||
return [head.join(' '), hms.join(' ')].filter(Boolean).join(head.length && hms.length ? ' ' : '');
|
return out.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Presets ----------------
|
function getShowConfig(preset, diffMs) {
|
||||||
* hms → HH:MM:SS
|
const base = { y: false, M: false, d: true, h: true, m: true, s: true };
|
||||||
* dhms → D + HH:MM:SS
|
|
||||||
* ydhms → Y + M + D + HH:MM:SS (calendar-aware)
|
switch (preset) {
|
||||||
* auto → HH:MM:SS when near; D + HH:MM:SS when far (>= 30 days)
|
case 'hms': return { ...base, d: false };
|
||||||
* default → dhms
|
case 'dhms': return base;
|
||||||
*/
|
case 'ydhms': return { y: true, M: true, d: true, h: true, m: true, s: true };
|
||||||
function unitsForPreset(preset, start, end) {
|
case 'auto':
|
||||||
if (preset === 'hms') return { y: false, M: false, d: false, h: true, m: true, s: true };
|
return diffMs >= 2592000000 ? base : { ...base, d: false };
|
||||||
if (preset === 'dhms') return { y: false, M: false, d: true, h: true, m: true, s: true };
|
default: return base;
|
||||||
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 ----------------
|
/* ---------------- 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.
|
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 {
|
class WcTimer extends HTMLElement {
|
||||||
static get observedAttributes() { return ['target', 'preset', 'labels', 'locale', 'lang', 'compact']; }
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
const shadow = this.attachShadow({ mode: 'open' });
|
||||||
|
shadow.adoptedStyleSheets = [sheet];
|
||||||
// Shadow only defines style and a default slot.
|
shadow.appendChild(document.createElement('slot'));
|
||||||
// Text is in light DOM (selectable, styleable).
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
:host {
|
|
||||||
display:inline-block;
|
|
||||||
color: inherit;
|
|
||||||
user-select: text; -webkit-user-select: text;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
::slotted(*) { all: unset; }
|
|
||||||
`;
|
|
||||||
const slot = document.createElement('slot');
|
|
||||||
this.shadowRoot.append(style, slot);
|
|
||||||
|
|
||||||
// Single managed text node in light DOM.
|
|
||||||
this._text = null;
|
|
||||||
|
|
||||||
// Internal state.
|
|
||||||
this._unsub = null;
|
|
||||||
this._updatingAttr = false; // guard against attribute echo from setters
|
|
||||||
this._target = new Date();
|
|
||||||
this._preset = 'auto';
|
|
||||||
this._labels = 'short'; // 'none' | 'short' | 'long'
|
|
||||||
this._compact = false;
|
|
||||||
this._locale = 'en';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
// Ensure the first child is our Text node.
|
// Ensures element has dimensions before JS runs (CLS prevention)
|
||||||
if (!this._text) {
|
if (!this.firstChild || this.firstChild.nodeType !== Node.TEXT_NODE) {
|
||||||
if (this.firstChild && this.firstChild.nodeType === Node.TEXT_NODE) {
|
this.textContent = '';
|
||||||
this._text = this.firstChild;
|
this.#textNode = document.createTextNode('\u00A0');
|
||||||
} else {
|
this.appendChild(this.#textNode);
|
||||||
this.textContent = '';
|
} else {
|
||||||
this._text = document.createTextNode('');
|
this.#textNode = this.firstChild;
|
||||||
this.appendChild(this._text);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this._syncFromAttrs();
|
this.#syncAttrs();
|
||||||
this._renderOnce(Date.now());
|
this.#startTicking();
|
||||||
this._unsub = TickHub.sub(t => this._renderOnce(t));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
if (this._unsub) { this._unsub(); this._unsub = null; }
|
this.#stopTicking();
|
||||||
}
|
}
|
||||||
|
|
||||||
attributeChangedCallback() {
|
attributeChangedCallback() {
|
||||||
// Ignore updates triggered by our own setters.
|
if (this.#isAttrUpdate) return;
|
||||||
if (this._updatingAttr) return;
|
this.#syncAttrs();
|
||||||
this._syncFromAttrs();
|
this.#render(Date.now());
|
||||||
this._renderOnce(Date.now());
|
this.#checkRestart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull current attributes into state.
|
#syncAttrs() {
|
||||||
_syncFromAttrs() {
|
const tRaw = this.getAttribute('target');
|
||||||
const raw = this.getAttribute('target');
|
this.#target = tRaw ? new Date(tRaw) : new Date();
|
||||||
const t = raw ? new Date(raw) : new Date();
|
if (isNaN(this.#target)) this.#target = new Date();
|
||||||
this._target = Number.isNaN(t.getTime()) ? new Date() : t;
|
|
||||||
|
|
||||||
this._preset = (this.getAttribute('preset') || 'auto').toLowerCase(); // 'auto'|'hms'|'dhms'|'ydhms'|'date'
|
this.#preset = (this.getAttribute('preset') || 'auto').toLowerCase();
|
||||||
this._labels = (this.getAttribute('labels') || 'short').toLowerCase(); // 'none'|'short'|'long'
|
this.#labels = (this.getAttribute('labels') || 'short').toLowerCase();
|
||||||
this._compact = this.hasAttribute('compact');
|
this.#compact = this.hasAttribute('compact');
|
||||||
|
this.#locale = resolveLocale(this);
|
||||||
this._locale = resolveLocale(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute and render the string once.
|
#startTicking() {
|
||||||
_renderOnce(nowMs) {
|
if (!this.#unsub) {
|
||||||
if (!this._text) return;
|
this.#unsub = TickHub.sub((now) => this.#render(now));
|
||||||
|
this.#render(Date.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Date-only preset: defer to Intl.DateTimeFormat.
|
#stopTicking() {
|
||||||
if (this._preset === 'date') {
|
if (this.#unsub) {
|
||||||
const dtf = new Intl.DateTimeFormat(this._locale, {
|
this.#unsub();
|
||||||
|
this.#unsub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#checkRestart() {
|
||||||
|
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',
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
hour12: false
|
hour12: false
|
||||||
});
|
});
|
||||||
const out = dtf.format(this._target);
|
const txt = dtf.format(this.#target);
|
||||||
if (this._text.data !== out) this._text.data = out;
|
if (this.#textNode.data !== txt) this.#textNode.data = txt;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the time window depending on future/past.
|
|
||||||
const now = new Date(nowMs);
|
const now = new Date(nowMs);
|
||||||
const future = this._target - now >= 0;
|
const diff = this.#target - now;
|
||||||
const start = future ? now : this._target;
|
const isFuture = diff >= 0;
|
||||||
const end = future ? this._target : now;
|
|
||||||
|
|
||||||
// Decide what to show based on preset.
|
if (!isFuture && this.#unsub) {
|
||||||
let show = unitsForPreset(this._preset, start, end);
|
this.#stopTicking();
|
||||||
|
}
|
||||||
|
|
||||||
// compact: hide empty high units (Y/M/D), and 0 hours when days are shown.
|
const start = isFuture ? now : this.#target;
|
||||||
if (this._compact) {
|
const end = isFuture ? this.#target : now;
|
||||||
|
|
||||||
|
let show = getShowConfig(this.#preset, Math.abs(diff));
|
||||||
|
|
||||||
|
if (this.#compact) {
|
||||||
const probe = collapseForUnits(start, end, show);
|
const probe = collapseForUnits(start, end, show);
|
||||||
if (show.y && probe.years === 0) show = { ...show, y: false };
|
if (show.y && probe.years === 0) show.y = false;
|
||||||
if (show.M && !show.y && probe.months === 0) show = { ...show, M: false };
|
if (show.M && !show.y && probe.months === 0) show.M = false;
|
||||||
if (show.d && !show.y && !show.M && probe.days === 0) show = { ...show, d: false };
|
if (show.d && !show.y && !show.M && probe.days === 0) show.d = false;
|
||||||
|
|
||||||
const probe2 = collapseForUnits(start, end, show);
|
const probe2 = collapseForUnits(start, end, show);
|
||||||
if (show.d && show.h && probe2.hours === 0) show = { ...show, h: false };
|
if (show.d && show.h && probe2.hours === 0) show.h = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = collapseForUnits(start, end, show);
|
const parts = collapseForUnits(start, end, show);
|
||||||
|
|
||||||
// If countdown reached zero, stop ticking to save CPU.
|
let txt = formatDurationSmart(this.#locale, this.#labels, show, parts);
|
||||||
if (future && end - now <= 0 && this._unsub) { this._unsub(); this._unsub = null; }
|
if (!txt) txt = '00:00:00';
|
||||||
|
|
||||||
// Format the output string and update only TextNode.data.
|
if (this.#textNode.data !== txt) this.#textNode.data = txt;
|
||||||
const out = formatDurationSmart(this._locale, this._labels, show, parts);
|
|
||||||
if (this._text.data !== out) this._text.data = out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =======================
|
/* Public API */
|
||||||
* Public API (properties)
|
|
||||||
* =======================
|
/**
|
||||||
* Use these instead of touching private fields.
|
* The target date object.
|
||||||
|
* @type {Date}
|
||||||
*/
|
*/
|
||||||
|
get target() { return new Date(this.#target); }
|
||||||
// target: Date | string | number(ms)
|
set target(val) {
|
||||||
get target() { return this._target; }
|
const d = new Date(val);
|
||||||
set target(v) {
|
if (isNaN(d)) return;
|
||||||
const d = this._coerceDate(v);
|
this.#target = d;
|
||||||
if (!d) return;
|
this.#reflect('target', d.toISOString());
|
||||||
this._target = d;
|
this.#render(Date.now());
|
||||||
this._updatingAttr = true; // prevent attributeChanged echo
|
this.#checkRestart();
|
||||||
this.setAttribute('target', d.toISOString());
|
|
||||||
this._updatingAttr = false;
|
|
||||||
this._renderOnce(Date.now());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// preset: 'auto' | 'hms' | 'dhms' | 'ydhms' | 'date'
|
/**
|
||||||
get preset() { return this._preset; }
|
* Current preset configuration.
|
||||||
set preset(v) {
|
* @type {string} - 'auto' | 'hms' | 'dhms' | 'ydhms' | 'date'
|
||||||
const p = String(v || '').toLowerCase();
|
*/
|
||||||
this._preset = p;
|
get preset() { return this.#preset; }
|
||||||
this._updatingAttr = true;
|
set preset(val) {
|
||||||
this.setAttribute('preset', p);
|
this.#preset = String(val).toLowerCase();
|
||||||
this._updatingAttr = false;
|
this.#reflect('preset', this.#preset);
|
||||||
this._renderOnce(Date.now());
|
this.#render(Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
// labels: 'none' | 'short' | 'long'
|
/**
|
||||||
get labels() { return this._labels; }
|
* Label display mode.
|
||||||
set labels(v) {
|
* @type {string} - 'none' | 'short' | 'long'
|
||||||
const l = String(v || '').toLowerCase();
|
*/
|
||||||
this._labels = l;
|
get labels() { return this.#labels; }
|
||||||
this._updatingAttr = true;
|
set labels(val) {
|
||||||
this.setAttribute('labels', l);
|
this.#labels = String(val).toLowerCase();
|
||||||
this._updatingAttr = false;
|
this.#reflect('labels', this.#labels);
|
||||||
this._renderOnce(Date.now());
|
this.#render(Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
// compact: boolean
|
/**
|
||||||
get compact() { return this._compact; }
|
* Whether compact mode is enabled (hides empty high units).
|
||||||
set compact(v) {
|
* @type {boolean}
|
||||||
const on = !!v;
|
*/
|
||||||
this._compact = on;
|
get compact() { return this.#compact; }
|
||||||
this._updatingAttr = true;
|
set compact(val) {
|
||||||
this.toggleAttribute('compact', on);
|
this.#compact = !!val;
|
||||||
this._updatingAttr = false;
|
if (this.#compact) this.setAttribute('compact', '');
|
||||||
this._renderOnce(Date.now());
|
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; }
|
* Current locale override.
|
||||||
set locale(v) {
|
* @type {string}
|
||||||
const loc = (v || '').toString();
|
*/
|
||||||
if (!loc) return;
|
set locale(val) {
|
||||||
this._locale = loc.toLowerCase();
|
if (!val) return;
|
||||||
this._updatingAttr = true;
|
this.#locale = String(val).toLowerCase();
|
||||||
this.setAttribute('locale', this._locale);
|
this.#reflect('locale', this.#locale);
|
||||||
this._updatingAttr = false;
|
this.#render(Date.now());
|
||||||
this._renderOnce(Date.now());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
#reflect(name, val) {
|
||||||
_coerceDate(v) {
|
this.#isAttrUpdate = true;
|
||||||
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
|
this.setAttribute(name, val);
|
||||||
if (typeof v === 'number') { const d = new Date(v); return isNaN(d.getTime()) ? null : d; }
|
this.#isAttrUpdate = false;
|
||||||
if (typeof v === 'string') { const d = new Date(v); return isNaN(d.getTime()) ? null : d; }
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user