@zumer/orbit-kit 0.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/o-timer.js ADDED
@@ -0,0 +1,240 @@
1
+ /**
2
+ * <o-timer>: a countdown ring with start, pause and reset.
3
+ *
4
+ * .orbit-12 > o-arc.inner-orbit track
5
+ * .orbit-12 > o-progress.inner-orbit time left (shrinks back toward 12 o'clock)
6
+ * .orbit-11 > .vector × ticks optional dial marks
7
+ * .orbit-0 > .satellite.at-center time, caption and a start/pause button
8
+ *
9
+ * Methods: start(), pause(), toggle(), reset(). Properties: remaining,
10
+ * running, duration. Events (bubbling): start, pause, reset, tick, done.
11
+ */
12
+ import { OrbitKitElement, define, injectStyleOnce, universe, hub, fixed, escapeHTML as e } from './base.js';
13
+
14
+ const TIMER_CSS = `
15
+ o-timer .ok-track { --o-fill: var(--ok-track, rgba(127,127,127,.18)); --o-stroke: var(--o-fill); }
16
+ o-timer .ok-value { --o-fill: var(--ok-fill, #3da9fc); --o-stroke: var(--o-fill); transition: fill .3s; }
17
+ o-timer.is-warning .ok-value { --o-fill: var(--ok-timer-warn, #f59e0b); }
18
+ o-timer.is-done .ok-value { --o-fill: var(--ok-timer-done, #ef4565); }
19
+ o-timer .ok-timer-tick { background: var(--ok-tick, currentColor); opacity: .35; height: max(1px, .5cqmin); }
20
+ o-timer .ok-timer-tick.is-major { opacity: .7; height: max(1px, .8cqmin); }
21
+ o-timer .ok-timer-hub { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .15em; }
22
+ o-timer .ok-timer-time { font-size: 2em; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1; letter-spacing: -.02em; }
23
+ o-timer .ok-timer-label { font-size: .75em; opacity: .7; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
24
+ o-timer .ok-timer-controls { display: flex; gap: .4em; margin-top: .35em; }
25
+ o-timer .ok-timer-btn {
26
+ font: inherit; font-size: .8em; line-height: 1; display: grid; place-items: center;
27
+ width: 2.4em; aspect-ratio: 1; padding: 0; border-radius: 50%; cursor: pointer;
28
+ color: var(--ok-timer-button-text, #0c0f13); background: var(--ok-timer-button, var(--ok-fill, #3da9fc));
29
+ border: none; transition: filter .15s, scale .15s;
30
+ }
31
+ o-timer .ok-timer-btn.is-secondary { color: inherit; background: var(--ok-timer-button-alt, rgba(127,127,127,.22)); }
32
+ o-timer .ok-timer-btn:hover { filter: brightness(1.12); }
33
+ o-timer .ok-timer-btn:active { scale: .94; }
34
+ o-timer .ok-timer-btn svg { width: 1em; height: 1em; fill: currentColor; }
35
+ o-timer .ok-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
36
+ @media (prefers-reduced-motion: reduce) { o-timer .ok-value, o-timer .ok-timer-btn { transition: none; } }
37
+ `;
38
+
39
+ const ICONS = {
40
+ play: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 2.5v11l9-5.5z"/></svg>',
41
+ pause: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 2.5h3v11h-3zM9.5 2.5h3v11h-3z"/></svg>',
42
+ reset: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2.5a5.5 5.5 0 1 1-5.2 3.7l1.4.5A4 4 0 1 0 8 4v2L4.5 3.25 8 .5z"/></svg>'
43
+ };
44
+
45
+ /** "90", "1:30", "1:02:03" or "90s" → seconds. */
46
+ export function parseDuration(value, fallback = 60) {
47
+ const text = String(value ?? '').trim();
48
+ if (/^\d+(?:\.\d+)?s?$/.test(text)) return Number.parseFloat(text);
49
+ if (/^\d+(?::\d{1,2}){1,2}$/.test(text)) return text.split(':').map(Number).reduce((total, part) => total * 60 + part, 0);
50
+ return fallback;
51
+ }
52
+
53
+ /** Seconds → "m:ss" or "h:mm:ss", rounded up so 0:00 means done. */
54
+ export function formatTime(seconds) {
55
+ const whole = Math.max(0, Math.ceil(seconds - 1e-6));
56
+ const h = Math.floor(whole / 3600), m = Math.floor(whole / 60) % 60, s = whole % 60;
57
+ return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
58
+ }
59
+
60
+ export class OrbitTimer extends OrbitKitElement {
61
+ static get observedAttributes() {
62
+ return ['duration', 'remaining', 'running', 'label', 'size', 'thickness', 'fill', 'track', 'shape', 'ticks', 'warn', 'controls', 'glow'];
63
+ }
64
+
65
+ constructor() {
66
+ super();
67
+ this._frame = this._frame.bind(this);
68
+ }
69
+
70
+ readConfig() {
71
+ const duration = Math.max(.001, parseDuration(this.getAttribute('duration'), 60));
72
+ return {
73
+ duration, size: this._size('size', '200px'), thickness: this._thickness(1.4, 6),
74
+ fill: this._color('fill', null), track: this._color('track', null), shape: this._str('shape', 'rounded'),
75
+ ticks: Math.max(0, Math.min(120, Math.round(this._num('ticks', 0)))),
76
+ warn: Math.max(0, parseDuration(this.getAttribute('warn'), 0)),
77
+ controls: this._str('controls', 'true') !== 'false',
78
+ label: this.getAttribute('label'), glow: this.hasAttribute('glow') ? this._glowPx() : null
79
+ };
80
+ }
81
+
82
+ structuralSignature({ duration, label, fill, track, warn, ...rest }) { return JSON.stringify(rest); }
83
+
84
+ build(cfg) {
85
+ injectStyleOnce('orbit-kit-timer', TIMER_CSS);
86
+ const ring = `--o-size-ratio:${fixed(cfg.thickness)};--o-gap:0`;
87
+ const glow = cfg.glow == null ? '' : ` style="filter:drop-shadow(0 0 ${cfg.glow}px var(--ok-fill, #3da9fc))"`;
88
+ const ticks = cfg.ticks
89
+ ? `<div class="orbit-${Math.max(1, Math.floor(12 - cfg.thickness - .3))}" style="--o-size-ratio:.6">${Array.from({ length: cfg.ticks }, (_, i) =>
90
+ `<div class="vector inner-orbit ok-timer-tick${cfg.ticks % 4 === 0 && i % (cfg.ticks / 4) === 0 ? ' is-major' : ''}"></div>`).join('')}</div>` : '';
91
+ const controls = cfg.controls ? `<span class="ok-timer-controls">
92
+ <button type="button" class="ok-timer-btn ok-timer-toggle"></button>
93
+ <button type="button" class="ok-timer-btn is-secondary ok-timer-reset" aria-label="Reset">${ICONS.reset}</button>
94
+ </span>` : '';
95
+ const inner = Math.floor(12 - cfg.thickness - (cfg.ticks ? 1.2 : .4));
96
+ this.innerHTML = universe(cfg.size, `
97
+ <div class="orbit-12" style="${ring}"><o-arc class="inner-orbit ok-track" value="100" shape="${e(cfg.shape)}"></o-arc></div>
98
+ <div class="orbit-12" style="${ring}"><o-progress class="inner-orbit ok-value" value="100" shape="${e(cfg.shape)}"${glow}></o-progress></div>
99
+ ${ticks}
100
+ ${hub(2 * inner, `<span class="ok-timer-time" aria-hidden="true"></span><span class="ok-timer-label" hidden></span>${controls}<span class="ok-sr" aria-live="polite"></span>`, 'ok-timer-hub')}`);
101
+ this._ring = this.querySelector('o-progress');
102
+ this._time = this.querySelector('.ok-timer-time');
103
+ this._label = this.querySelector('.ok-timer-label');
104
+ this._toggle = this.querySelector('.ok-timer-toggle');
105
+ this._reset = this.querySelector('.ok-timer-reset');
106
+ this._live = this.querySelector('.ok-sr');
107
+ this._toggle?.addEventListener('click', () => this.toggle());
108
+ this._reset?.addEventListener('click', () => this.reset());
109
+ }
110
+
111
+ sync(cfg) {
112
+ this._cfg = cfg;
113
+ this._applyTheme({ '--ok-fill': cfg.fill, '--ok-track': cfg.track });
114
+ // A new duration (or a remaining attribute) sets the clock while stopped.
115
+ const remainingAttr = this.getAttribute('remaining');
116
+ if (this._remaining == null || this._duration !== cfg.duration || remainingAttr !== this._remainingAttr) {
117
+ this._duration = cfg.duration;
118
+ this._remainingAttr = remainingAttr;
119
+ const start = remainingAttr == null ? cfg.duration : Math.min(cfg.duration, Math.max(0, parseDuration(remainingAttr, cfg.duration)));
120
+ if (this._ticking) this.remaining = start;
121
+ else this._remaining = start;
122
+ }
123
+ if (this._label) { this._label.textContent = cfg.label ?? ''; this._label.hidden = !cfg.label; }
124
+ const wantRunning = this.hasAttribute('running') && this.getAttribute('running') !== 'false';
125
+ if (wantRunning && !this._ticking && this.isConnected) this.start();
126
+ else if (!wantRunning && this._ticking) this.pause();
127
+ this._paint(true);
128
+ }
129
+
130
+ get duration() { return this._duration ?? this.readConfig().duration; }
131
+ set duration(value) { this.setAttribute('duration', String(value)); }
132
+ get remaining() { return Math.max(0, this._remaining ?? this.duration); }
133
+ set remaining(value) {
134
+ this._remaining = Math.min(this.duration, Math.max(0, Number(value) || 0));
135
+ if (this._ticking) this._endAt = performance.now() + this._remaining * 1000;
136
+ this._paint(true);
137
+ }
138
+ get running() { return !!this._ticking; }
139
+ set running(value) { value ? this.start() : this.pause(); }
140
+
141
+ start() {
142
+ if (this._ticking) return;
143
+ if (this.remaining <= 0) this._remaining = this.duration;
144
+ this._ticking = true;
145
+ this._doneSent = false;
146
+ this._endAt = performance.now() + this.remaining * 1000;
147
+ if (this.getAttribute('running') == null || this.getAttribute('running') === 'false') this.setAttribute('running', '');
148
+ this._emit('start');
149
+ this._raf = requestAnimationFrame(this._frame);
150
+ this._paint(true);
151
+ }
152
+
153
+ pause() {
154
+ if (!this._ticking) return;
155
+ this._update();
156
+ this._ticking = false;
157
+ cancelAnimationFrame(this._raf);
158
+ if (this.hasAttribute('running')) this.removeAttribute('running');
159
+ this._emit('pause');
160
+ this._paint(true);
161
+ }
162
+
163
+ toggle() { this._ticking ? this.pause() : this.start(); }
164
+
165
+ reset() {
166
+ const wasRunning = this._ticking;
167
+ if (wasRunning) { this._ticking = false; cancelAnimationFrame(this._raf); if (this.hasAttribute('running')) this.removeAttribute('running'); }
168
+ this._remaining = this.duration;
169
+ this._doneSent = false;
170
+ if (this._live) this._live.textContent = '';
171
+ this._emit('reset');
172
+ this._paint(true);
173
+ }
174
+
175
+ onDisconnected() {
176
+ if (this._ticking) { this._update(); this._ticking = false; cancelAnimationFrame(this._raf); }
177
+ }
178
+
179
+ onConnected() {
180
+ if (this.hasAttribute('running') && this.getAttribute('running') !== 'false' && !this._ticking) this.start();
181
+ }
182
+
183
+ _update() {
184
+ if (this._ticking) this._remaining = Math.max(0, (this._endAt - performance.now()) / 1000);
185
+ }
186
+
187
+ _frame() {
188
+ if (!this._ticking) return;
189
+ this._update();
190
+ this._paint();
191
+ if (this._remaining <= 0) {
192
+ this._ticking = false;
193
+ if (this.hasAttribute('running')) this.removeAttribute('running');
194
+ this._paint(true);
195
+ if (!this._doneSent) {
196
+ this._doneSent = true;
197
+ if (this._live) this._live.textContent = `${this._cfg?.label ? `${this._cfg.label}: ` : ''}time is up`;
198
+ this._emit('done');
199
+ }
200
+ return;
201
+ }
202
+ this._raf = requestAnimationFrame(this._frame);
203
+ }
204
+
205
+ _emit(type) {
206
+ this.dispatchEvent(new CustomEvent(type, { detail: { remaining: this.remaining, duration: this.duration }, bubbles: true, composed: true }));
207
+ }
208
+
209
+ _paint(force = false) {
210
+ const remaining = this.remaining, duration = this.duration, cfg = this._cfg;
211
+ const pct = remaining / duration * 100;
212
+ // Redraw the ring in steps of a thousandth: smooth, without a new path every frame.
213
+ if (this._ring && (force || Math.abs(pct - (this._drawn ?? -1)) >= .1)) {
214
+ this._drawn = pct;
215
+ this._ring.setAttribute('value', pct.toFixed(2));
216
+ }
217
+ const text = formatTime(remaining);
218
+ if (force || text !== this._shown) {
219
+ const changed = this._shown !== undefined && text !== this._shown;
220
+ this._shown = text;
221
+ if (this._time) this._time.textContent = text;
222
+ this._describe(`${cfg?.label ? `${cfg.label}: ` : ''}${text} left of ${formatTime(duration)}`, 'timer');
223
+ if (changed && this._ticking) this._emit('tick');
224
+ }
225
+ const done = remaining <= 0;
226
+ this.classList.toggle('is-running', !!this._ticking);
227
+ this.classList.toggle('is-done', done);
228
+ this.classList.toggle('is-warning', !done && !!cfg?.warn && remaining <= cfg.warn);
229
+ if (this._toggle) {
230
+ const state = this._ticking ? 'pause' : 'play';
231
+ if (this._toggle.dataset.state !== state) {
232
+ this._toggle.dataset.state = state;
233
+ this._toggle.innerHTML = ICONS[state];
234
+ this._toggle.setAttribute('aria-label', this._ticking ? 'Pause' : (remaining < duration && !done ? 'Resume' : 'Start'));
235
+ } else if (!this._ticking) this._toggle.setAttribute('aria-label', remaining < duration && !done ? 'Resume' : 'Start');
236
+ }
237
+ }
238
+ }
239
+
240
+ define('o-timer', OrbitTimer);