@zumer/orbit-kit 0.1.0 → 2.1.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-range.js ADDED
@@ -0,0 +1,509 @@
1
+ /**
2
+ * <o-range>: a circular range slider. Two handles on one ring pick a start and
3
+ * an end, like the bedtime dial of a phone. Drag a handle, drag the band to
4
+ * move both, tap the track to bring the closer handle there, or focus a handle
5
+ * and use the keys. The middle shows the span.
6
+ *
7
+ * .bigbang (width = size)
8
+ * └── .gravity-spot
9
+ * ├── .orbit-12 > o-arc.inner-orbit track (interactive)
10
+ * ├── .orbit-12 > o-arc.inner-orbit band: --o-from is the start, value the span
11
+ * ├── .orbit-N > .vector × ticks dial marks
12
+ * ├── .orbit-M > .satellite × numbers hours or values
13
+ * ├── .orbit-0 > .satellite.at-center span readout
14
+ * └── .orbit-12 > .satellite × 2 handles, placed by angle (role="slider")
15
+ *
16
+ * Modes: numbers (min / max / step, the default) or mode="time": a 24-hour
17
+ * dial in minutes, shown as 24h or 12h clock times (`hours`). A time range
18
+ * wraps through midnight; a number range wraps only with `wrap`.
19
+ * The value is "start-end": "22:30-06:45" or "20-80". It is a form control.
20
+ * Events: `input` while it changes, `change` when a gesture or key press ends;
21
+ * detail: { value, start, end, span }.
22
+ */
23
+ import { OrbitKitElement, FormControl, define, injectStyleOnce, universe, hub, fixed, escapeHTML as e, finiteNumber, safeIcon, pointerAngle, valueAtAngle, withShared, RANGE_ATTRS, FORMAT_ATTRS, FORM_ATTRS } from './base.js';
24
+
25
+ const DAY = 1440;
26
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
27
+ const mod = (v, n) => ((v % n) + n) % n;
28
+ const tidy = (n) => Number(Number(n).toPrecision(12));
29
+ const pad = (n) => String(n).padStart(2, '0');
30
+
31
+ /** "22:30" or "7:05" → minutes after midnight; null when it is not a time. */
32
+ export function parseClock(value) {
33
+ const m = /^\s*(\d{1,2}):(\d{2})(?::\d{2}(?:\.\d+)?)?\s*$/.exec(String(value ?? ''));
34
+ if (!m || +m[1] > 24 || +m[2] > 59) return null;
35
+ return (+m[1] * 60 + +m[2]) % DAY;
36
+ }
37
+
38
+ /** Minutes → "HH:MM" (24h), the form and attribute format. */
39
+ export const clockValue = (mins) => `${pad(Math.floor(mod(mins, DAY) / 60))}:${pad(Math.round(mod(mins, DAY)) % 60)}`;
40
+
41
+ /** Minutes → "22:30" or "10:30 PM" for people. */
42
+ export function clockText(mins, twelve) {
43
+ const t = Math.round(mod(mins, DAY)), h = Math.floor(t / 60), m = t % 60;
44
+ return twelve ? `${h % 12 || 12}:${pad(m)} ${h < 12 ? 'AM' : 'PM'}` : `${pad(h)}:${pad(m)}`;
45
+ }
46
+
47
+ /** A span of minutes → [[8, 'h'], [15, 'min']]. */
48
+ export function spanParts(mins) {
49
+ const t = Math.round(mins), h = Math.floor(t / 60), m = t % 60;
50
+ if (!h) return [[m, 'min']];
51
+ return m ? [[h, 'h'], [m, 'min']] : [[h, 'h']];
52
+ }
53
+
54
+ /** The hour cycle of a locale: true for 12-hour clocks (en-US), false for 24 (de, en-GB). */
55
+ export function twelveHour(locale) {
56
+ try {
57
+ const cycle = new Intl.DateTimeFormat(locale || undefined, { hour: 'numeric' }).resolvedOptions().hourCycle;
58
+ return cycle === 'h12' || cycle === 'h11';
59
+ } catch { return false; }
60
+ }
61
+
62
+ /** "20-80", "-10--5", "22:30-06:45" → [start, end] as text, or null. */
63
+ export function splitPair(value) {
64
+ if (Array.isArray(value)) return value.length === 2 ? value.map(String) : null;
65
+ if (value && typeof value === 'object') return 'start' in value && 'end' in value ? [String(value.start), String(value.end)] : null;
66
+ const m = /^\s*(-?[\d.:]+(?:e[+-]?\d+)?)\s*(?:-|–|\/|,)\s*(-?[\d.:]+(?:e[+-]?\d+)?)\s*$/i.exec(String(value ?? ''));
67
+ return m ? [m[1], m[2]] : null;
68
+ }
69
+
70
+ /* Small handle icons. They are ours, so they skip safeIcon. */
71
+ const ICONS = {
72
+ moon: '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M13.6 10.1A5.9 5.9 0 0 1 5.9 2.4a5.9 5.9 0 1 0 7.7 7.7z"/></svg>',
73
+ sun: '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="8" cy="8" r="2.8"/><path d="M8 1.6v1.5M8 12.9v1.5M1.6 8h1.5M12.9 8h1.5M3.5 3.5l1 1M11.5 11.5l1 1M3.5 12.5l1-1M11.5 4.5l1-1"/></svg>',
74
+ bell: '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M8 1.8a4.2 4.2 0 0 0-4.2 4.2v2.7L2.6 11.2h10.8l-1.2-2.5V6A4.2 4.2 0 0 0 8 1.8zM6.3 12.4a1.7 1.7 0 0 0 3.4 0z"/></svg>'
75
+ };
76
+ const icon = (name) => (name == null || name === '' || name === 'none' ? '' : ICONS[name] ?? safeIcon(name));
77
+
78
+ const RANGE_CSS = `
79
+ o-range { display: inline-block; vertical-align: middle; -webkit-tap-highlight-color: transparent; }
80
+ o-range:not([data-disabled]) { touch-action: none; }
81
+ o-range .ok-track { --o-fill: var(--ok-track, #1f2630); --o-stroke: var(--o-fill); }
82
+ o-range .ok-value { --o-fill: var(--ok-fill, #4fd1c5); --o-stroke: var(--o-fill); }
83
+ o-range .ok-range-track { cursor: pointer; }
84
+ o-range .ok-range-band { cursor: grab; }
85
+ o-range .ok-range-thumb { pointer-events: none; }
86
+ o-range .ok-range-handle {
87
+ width: 94%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; cursor: grab; pointer-events: auto;
88
+ background: var(--ok-range-handle, var(--ok-surface, #12161c));
89
+ color: var(--ok-range-icon, var(--ok-fill, #4fd1c5));
90
+ box-shadow: 0 1px 4px rgba(0,0,0,.45), inset 0 0 0 1px rgba(255,255,255,.07);
91
+ }
92
+ o-range .ok-range-handle svg { width: 64%; height: 64%; }
93
+ o-range .ok-range-handle:empty::after { content: ""; width: 28%; aspect-ratio: 1; border-radius: 50%; background: currentColor; }
94
+ o-range.is-dragging, o-range.is-dragging * { cursor: grabbing; }
95
+ o-range[data-disabled] .ok-range-track, o-range[data-disabled] .ok-range-band, o-range[data-disabled] .ok-range-handle { cursor: default; }
96
+ o-range[data-disabled] > .bigbang { opacity: .5; }
97
+ o-range .ok-range-tick { background: var(--ok-tick, currentColor); opacity: .28; height: max(1px, .35cqmin); }
98
+ o-range .ok-range-tick.is-major { opacity: .6; height: max(1px, .6cqmin); }
99
+ o-range .ok-range-num { font-size: .52em; line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; opacity: .5; }
100
+ o-range .ok-range-num.is-major { opacity: .9; font-weight: 600; }
101
+ o-range .ok-range-num small { font-size: .78em; margin-left: .08em; font-weight: 500; }
102
+ o-range .ok-range-hub { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .3em; font-weight: 400; white-space: nowrap; }
103
+ o-range .ok-range-span { font-size: 1.05em; font-weight: 650; letter-spacing: -.02em; line-height: 1; }
104
+ o-range .ok-range-span small { font-size: .5em; font-weight: 500; opacity: .65; margin: 0 .3em 0 .12em; letter-spacing: 0; }
105
+ o-range .ok-range-span small:last-child { margin-right: 0; }
106
+ o-range .ok-range-label { font-size: .42em; font-weight: 500; opacity: .6; }
107
+ o-range .ok-range-times { font-size: .37em; font-weight: 500; opacity: .75; }
108
+ `;
109
+
110
+ export class OrbitRange extends FormControl(OrbitKitElement) {
111
+ static get observedAttributes() {
112
+ return withShared(['start', 'end', 'mode', 'hours', 'wrap', 'min-span', 'max-span', 'range', 'from',
113
+ 'size', 'thickness', 'fill', 'track', 'ticks', 'numbers', 'label', 'start-label', 'end-label',
114
+ 'start-icon', 'end-icon', 'glow'], RANGE_ATTRS, FORMAT_ATTRS, FORM_ATTRS);
115
+ }
116
+
117
+ constructor() {
118
+ super();
119
+ for (const name of ['_onDown', '_onMove', '_onUp', '_onKey']) this[name] = this[name].bind(this);
120
+ this._defaults = {};
121
+ }
122
+
123
+ attributeChangedCallback(name, oldValue, newValue) {
124
+ // start / end set by the author are the defaults a form reset goes back to.
125
+ if ((name === 'start' || name === 'end') && !this._writing) this._defaults[name] = newValue;
126
+ if (this._quiet) return;
127
+ super.attributeChangedCallback(name, oldValue, newValue);
128
+ }
129
+
130
+ connectedCallback() {
131
+ for (const name of ['start', 'end']) if (!(name in this._defaults)) this._defaults[name] = this.getAttribute(name);
132
+ super.connectedCallback();
133
+ }
134
+
135
+ readConfig() {
136
+ const time = this.getAttribute('mode') === 'time';
137
+ let min, max, step;
138
+ if (time) {
139
+ min = 0; max = DAY;
140
+ const s = this._num('step', 5);
141
+ step = this.getAttribute('step') === 'any' ? 0 : s > 0 ? Math.min(s, 720) : 5;
142
+ } else ({ min, max, step } = this._range({ min: 0, max: 100, step: 1 }));
143
+ const period = max - min || 1;
144
+ const wrap = time || (this.hasAttribute('wrap') && this.getAttribute('wrap') !== 'false');
145
+ const range = wrap ? 360 : clamp(this._num('range', 270), 10, 360);
146
+ const from = this.hasAttribute('from') ? this._num('from', 0) : range < 360 ? this._autoFrom(range) : 0;
147
+ const twelve = time && (this.hasAttribute('hours') ? this.getAttribute('hours') === '12' : twelveHour(this._locale()));
148
+ const parse = (raw, fallback) => {
149
+ if (raw == null || raw === '') return fallback;
150
+ const v = time ? parseClock(raw) : finiteNumber(raw, NaN);
151
+ return v == null || !Number.isFinite(v) ? fallback : v;
152
+ };
153
+ const parseSpan = (name, fallback) => {
154
+ const raw = this.getAttribute(name);
155
+ if (raw == null || raw === '') return fallback;
156
+ const v = time && raw.includes(':') ? (() => { const [h, m] = raw.split(':').map(Number); return h * 60 + m; })() : Number(raw);
157
+ return Number.isFinite(v) && v >= 0 ? v : fallback;
158
+ };
159
+ let minSpan = clamp(parseSpan('min-span', wrap ? step : 0), 0, period);
160
+ const maxSpan = clamp(parseSpan('max-span', wrap ? period - step : period), minSpan, period);
161
+ if (wrap && minSpan === 0 && maxSpan === period) minSpan = Math.min(step, period);
162
+ const cfg = {
163
+ time, twelve, min, max, step, period, wrap, range, from, minSpan, maxSpan,
164
+ size: this._size('size', '280px'), thickness: this._thickness(2.4, 5),
165
+ fill: this._color('fill', null), track: this._color('track', null),
166
+ ticks: Math.max(0, Math.min(240, Math.round(this._num('ticks', time ? 48 : 40)))),
167
+ numbers: Math.max(0, Math.min(24, Math.round(this._num('numbers', time ? 12 : range < 360 ? 5 : 4)))),
168
+ label: this.getAttribute('label'),
169
+ startLabel: this._str('start-label', 'Start'), endLabel: this._str('end-label', 'End'),
170
+ startIcon: this.getAttribute('start-icon'), endIcon: this.getAttribute('end-icon'),
171
+ glow: this.hasAttribute('glow') ? this._glowPx() : null,
172
+ disabled: this._isDisabled(),
173
+ // Tick numbers are part of the dial, so their format rebuilds it.
174
+ format: [...['format', 'locale', 'decimals', 'currency'].map((a) => this.getAttribute(a)), this._formatter ? 'fn' : '', this._locale()].join('|')
175
+ };
176
+ const snap = (v) => this._snap(v, cfg);
177
+ let start = snap(parse(this.getAttribute('start'), time ? 22 * 60 : min + period * .25));
178
+ let end = snap(parse(this.getAttribute('end'), time ? 6 * 60 : min + period * .75));
179
+ if (!wrap && end < start) end = start;
180
+ // Keep the span inside [min-span, max-span]: the end gives way first.
181
+ const span = this._spanOf(start, end, cfg);
182
+ if (span < minSpan || span > maxSpan) {
183
+ end = start + (span < minSpan ? minSpan : maxSpan);
184
+ if (!wrap && end > max) { end = max; start = Math.max(min, max - Math.max(minSpan, Math.min(span, maxSpan))); }
185
+ }
186
+ [cfg.start, cfg.end] = this._norm(start, end, cfg);
187
+ cfg.span = this._spanOf(cfg.start, cfg.end, cfg);
188
+ return cfg;
189
+ }
190
+
191
+ /** Snap to the step grid from min; a wrapping dial folds back into [min, max). */
192
+ _snap(v, { min, max, step, wrap, period }) {
193
+ let out = step ? min + Math.round((v - min) / step) * step : v;
194
+ out = wrap ? min + mod(out - min, period) : clamp(out, min, max);
195
+ return tidy(out);
196
+ }
197
+
198
+ _norm(s, e, { min, wrap, period }) {
199
+ return wrap ? [tidy(min + mod(s - min, period)), tidy(min + mod(e - min, period))] : [tidy(s), tidy(e)];
200
+ }
201
+
202
+ _spanOf(s, e, { wrap, period }) { return tidy(wrap ? mod(e - s, period) : e - s); }
203
+
204
+ structuralSignature(cfg) {
205
+ const { start, end, span, label, fill, track, disabled, minSpan, maxSpan, ...rest } = cfg;
206
+ return JSON.stringify(rest);
207
+ }
208
+
209
+ build(cfg) {
210
+ injectStyleOnce('orbit-kit-range', RANGE_CSS);
211
+ const t = cfg.thickness;
212
+ const closed = cfg.range === 360;
213
+ const angles = `--o-range:${cfg.range}deg;--o-from:${fixed(cfg.from)}deg;`;
214
+ const glow = cfg.glow == null ? '' : ` style="filter:drop-shadow(0 0 ${cfg.glow}px var(--ok-fill, #4fd1c5))"`;
215
+ const tickOrbit = Math.max(3, Math.floor(12 - t - .4));
216
+ const numOrbit = tickOrbit - 2;
217
+ const fit = closed ? '' : ' fit-range';
218
+
219
+ // Ticks: `ticks` intervals; the ones under a number are major.
220
+ const tickCount = cfg.ticks ? (closed ? cfg.ticks : cfg.ticks + 1) : 0;
221
+ const intervals = closed ? cfg.numbers : cfg.numbers - 1;
222
+ const every = intervals > 0 && cfg.ticks % intervals === 0 ? cfg.ticks / intervals : 0;
223
+ const ticks = tickCount ? `<div class="orbit-${tickOrbit}${fit}" style="${angles}--o-size-ratio:.7">${Array.from({ length: tickCount }, (_, i) =>
224
+ `<div class="vector inner-orbit ok-range-tick${every && i % every === 0 ? ' is-major' : ''}"></div>`).join('')}</div>` : '';
225
+
226
+ const numbers = cfg.numbers > 1 || (cfg.numbers === 1 && closed)
227
+ ? `<div class="orbit-${numOrbit}${fit}" style="${angles}--o-size-ratio:1.6">${Array.from({ length: cfg.numbers }, (_, i) => {
228
+ const { html, major } = this._numberAt(i, cfg, intervals);
229
+ return `<div class="satellite"><div class="capsule ok-range-num${major ? ' is-major' : ''}">${html}</div></div>`;
230
+ }).join('')}</div>` : '';
231
+
232
+ const handle = (which, label, ico) => `<div class="orbit-12" style="--o-size-ratio:${fixed(t)}"><div class="satellite inner-orbit ok-range-thumb" data-thumb="${which}">` +
233
+ `<div class="capsule ok-range-handle ok-surface" data-handle="${which}" role="slider" tabindex="0" aria-label="${e(label)}">${icon(ico)}</div></div></div>`;
234
+
235
+ const ring = `--o-size-ratio:${fixed(t)};--o-gap:0`;
236
+ this.innerHTML = universe(cfg.size, `
237
+ <div class="orbit-12" style="${angles}${ring}"><o-arc class="inner-orbit ok-track ok-range-track" interactive hit-width="4" value="100" shape="${closed ? 'none' : 'rounded'}"></o-arc></div>
238
+ <div class="orbit-12 ok-range-band-ring" style="--o-range:${cfg.range}deg;${ring}"><o-arc class="inner-orbit ok-value ok-range-band" interactive hit-width="2" value="0" shape="rounded"${glow}></o-arc></div>
239
+ ${ticks}${numbers}
240
+ ${hub(2 * (numOrbit - 1.2), '<span class="ok-range-span"></span><span class="ok-range-label" hidden></span><span class="ok-range-times"></span>', 'ok-hub ok-range-hub', ' aria-hidden="true"')}
241
+ ${handle('start', cfg.startLabel, cfg.startIcon)}
242
+ ${handle('end', cfg.endLabel, cfg.endIcon)}`);
243
+ this._box = this.querySelector('.bigbang');
244
+ this._bandRing = this.querySelector('.ok-range-band-ring');
245
+ this._band = this.querySelector('.ok-range-band');
246
+ this._thumbs = { start: this.querySelector('[data-thumb="start"]'), end: this.querySelector('[data-thumb="end"]') };
247
+ this._handles = { start: this.querySelector('[data-handle="start"]'), end: this.querySelector('[data-handle="end"]') };
248
+ this._spanEl = this.querySelector('.ok-range-span');
249
+ this._labelEl = this.querySelector('.ok-range-label');
250
+ this._timesEl = this.querySelector('.ok-range-times');
251
+ }
252
+
253
+ /** The dial number at index i: hours on a time dial, values otherwise. */
254
+ _numberAt(i, cfg, intervals) {
255
+ if (cfg.time) {
256
+ const h = Math.round(i * 24 / cfg.numbers) % 24, cardinal = h % 6 === 0;
257
+ if (!cfg.twelve) return { html: String(h), major: cardinal };
258
+ return { html: cardinal ? `${h % 12 || 12}<small>${h < 12 ? 'AM' : 'PM'}</small>` : String(h % 12 || 12), major: cardinal };
259
+ }
260
+ const v = cfg.min + i * cfg.period / Math.max(1, intervals);
261
+ return { html: e(this._format(tidy(v), { kind: 'tick', min: cfg.min, max: cfg.max, fallback: (n) => String(Number(n.toFixed(2))) })), major: true };
262
+ }
263
+
264
+ /** Text of one end, for the readout and aria-valuetext. */
265
+ _pointText(v, cfg, kind = 'value') {
266
+ return cfg.time ? clockText(v, cfg.twelve) : this._formatText(v, { kind, min: cfg.min, max: cfg.max });
267
+ }
268
+
269
+ /** The span as HTML (number big, unit small) and as plain text. */
270
+ _spanText(cfg) {
271
+ if (this._formatter) {
272
+ try {
273
+ const out = this._formatter(cfg.span, { kind: 'span', min: 0, max: cfg.period, start: cfg.start, end: cfg.end, element: this, locale: this._locale(), format: null, unit: this.getAttribute('unit') });
274
+ if (out != null) return { html: e(out), text: String(out) };
275
+ } catch (err) {
276
+ if (!this._formatterWarned) { this._formatterWarned = true; console.warn('<o-range> formatter threw:', err); }
277
+ }
278
+ }
279
+ if (cfg.time) {
280
+ const parts = spanParts(cfg.span);
281
+ return { html: parts.map(([n, u]) => `${n}<small>${u}</small>`).join(''), text: parts.map((p) => p.join(' ')).join(' ') };
282
+ }
283
+ const text = this._formatText(cfg.span, { min: 0, max: cfg.period });
284
+ return { html: e(text), text };
285
+ }
286
+
287
+ sync(cfg) {
288
+ this._cfg = cfg;
289
+ this._applyTheme({ '--ok-fill': cfg.fill, '--ok-track': cfg.track });
290
+ const angleOf = (v) => cfg.from + (v - cfg.min) / cfg.period * cfg.range;
291
+ this._bandRing?.style.setProperty('--o-from', `${fixed(angleOf(cfg.start))}deg`);
292
+ this._band?.setAttribute('value', fixed(cfg.span / cfg.period * 100, 3));
293
+ for (const which of ['start', 'end']) {
294
+ this._thumbs?.[which]?.style.setProperty('--o-angle-composite', `${fixed(angleOf(cfg[which]) - 90)}deg`);
295
+ }
296
+ const span = this._spanText(cfg);
297
+ const startText = this._pointText(cfg.start, cfg), endText = this._pointText(cfg.end, cfg);
298
+ if (this._spanEl) this._spanEl.innerHTML = span.html;
299
+ if (this._labelEl) { this._labelEl.textContent = cfg.label ?? ''; this._labelEl.hidden = !cfg.label; }
300
+ if (this._timesEl) this._timesEl.textContent = `${startText} – ${endText}`;
301
+ this._describe(cfg.label || (cfg.time ? 'Time range' : 'Range'), 'group');
302
+
303
+ const lo = cfg.min, hi = cfg.time ? DAY - 1 : cfg.max;
304
+ for (const which of ['start', 'end']) {
305
+ const el = this._handles?.[which];
306
+ if (!el) continue;
307
+ el.setAttribute('aria-valuenow', String(cfg[which]));
308
+ el.setAttribute('aria-valuemin', String(lo));
309
+ el.setAttribute('aria-valuemax', String(hi));
310
+ el.setAttribute('aria-valuetext', `${this._pointText(cfg[which], cfg, 'aria')}, span ${span.text}`);
311
+ el.setAttribute('aria-label', which === 'start' ? cfg.startLabel : cfg.endLabel);
312
+ el.tabIndex = cfg.disabled ? -1 : 0;
313
+ if (cfg.disabled) el.setAttribute('aria-disabled', 'true'); else el.removeAttribute('aria-disabled');
314
+ }
315
+ if (cfg.disabled) this._stopDrag();
316
+ }
317
+
318
+ /* ---- value --------------------------------------------------------- */
319
+
320
+ _text(v, cfg = this.readConfig()) { return cfg.time ? clockValue(v) : String(v); }
321
+
322
+ /** "22:30-06:45" or "20-80". Setting it fires no event. */
323
+ get value() { const cfg = this.readConfig(); return `${this._text(cfg.start, cfg)}-${this._text(cfg.end, cfg)}`; }
324
+ set value(value) {
325
+ const pair = splitPair(value);
326
+ if (pair) this._write(pair[0], pair[1]);
327
+ }
328
+ get start() { const cfg = this.readConfig(); return cfg.time ? clockValue(cfg.start) : cfg.start; }
329
+ set start(value) { this._write(String(value), this.getAttribute('end')); }
330
+ get end() { const cfg = this.readConfig(); return cfg.time ? clockValue(cfg.end) : cfg.end; }
331
+ set end(value) { this._write(this.getAttribute('start'), String(value)); }
332
+ /** The span in values (minutes in time mode). */
333
+ get span() { return this.readConfig().span; }
334
+ get defaultValue() {
335
+ const { start, end } = this._defaults;
336
+ return start == null && end == null ? null : `${start ?? ''}-${end ?? ''}`;
337
+ }
338
+ set defaultValue(value) {
339
+ const pair = splitPair(value);
340
+ if (pair) { this.setAttribute('start', pair[0]); this.setAttribute('end', pair[1]); }
341
+ }
342
+
343
+ _formValue() { return this.value; }
344
+
345
+ /** Write start and end (as attribute text) in one render, without touching the defaults. */
346
+ _write(start, end) {
347
+ this._writing = true; this._quiet = true;
348
+ try {
349
+ for (const [name, v] of [['start', start], ['end', end]]) {
350
+ if (v == null) this.removeAttribute(name); else if (this.getAttribute(name) !== v) this.setAttribute(name, v);
351
+ }
352
+ } finally { this._writing = false; this._quiet = false; }
353
+ if (this.isConnected) this._render();
354
+ }
355
+
356
+ _onFormReset() {
357
+ this._writing = true; this._quiet = true;
358
+ try {
359
+ for (const name of ['start', 'end']) {
360
+ const v = this._defaults[name];
361
+ if (v == null) this.removeAttribute(name); else this.setAttribute(name, v);
362
+ }
363
+ } finally { this._writing = false; this._quiet = false; }
364
+ }
365
+
366
+ /** Move one end (or the band) by dv from base, with the span rules. Returns [start, end]. */
367
+ _solve(which, dv, base, cfg) {
368
+ const { min, max, wrap, minSpan, maxSpan } = cfg;
369
+ const snap = (v) => (cfg.step ? tidy(min + Math.round((v - min) / cfg.step) * cfg.step) : v);
370
+ let s = base.start, en = base.end;
371
+ if (!wrap && en < s) en = s;
372
+ const span0 = this._spanOf(s, en, cfg);
373
+ if (which === 'band') {
374
+ let ns = snap(s + dv);
375
+ if (!wrap) ns = clamp(ns, min, max - span0);
376
+ return this._norm(ns, ns + span0, cfg);
377
+ }
378
+ if (which === 'start') {
379
+ let ns = snap(s + dv);
380
+ if (!wrap) ns = clamp(ns, min, max - minSpan);
381
+ const span = span0 - (ns - s);
382
+ let ne = s + span0;
383
+ if (span < minSpan) ne = ns + minSpan; else if (span > maxSpan) ne = ns + maxSpan;
384
+ if (!wrap) ne = clamp(ne, ns, max);
385
+ return this._norm(ns, ne, cfg);
386
+ }
387
+ let ne = snap(en + dv);
388
+ if (!wrap) ne = clamp(ne, min + minSpan, max);
389
+ const span = span0 + (ne - en);
390
+ let ns = en - span0;
391
+ if (span < minSpan) ns = ne - minSpan; else if (span > maxSpan) ns = ne - maxSpan;
392
+ if (!wrap) ns = clamp(ns, min, ne);
393
+ return this._norm(ns, ne, cfg);
394
+ }
395
+
396
+ /** Apply a user change: write it, then fire `input` (and `change`). */
397
+ _commit([s, en], { change = false } = {}) {
398
+ const cfg = this.readConfig();
399
+ const before = this.value;
400
+ this._write(this._text(s, cfg), this._text(en, cfg));
401
+ const after = this.value;
402
+ if (after === before) return false;
403
+ this._emit('input');
404
+ if (change) this._emit('change');
405
+ return true;
406
+ }
407
+
408
+ _emit(type) {
409
+ const cfg = this.readConfig();
410
+ this._emitValue(type, this.value, { start: cfg.time ? clockValue(cfg.start) : cfg.start, end: cfg.time ? clockValue(cfg.end) : cfg.end, span: cfg.span });
411
+ }
412
+
413
+ /* ---- pointer and keys ------------------------------------------------ */
414
+
415
+ onConnected() {
416
+ this.addEventListener('pointerdown', this._onDown);
417
+ this.addEventListener('lostpointercapture', this._onUp);
418
+ this.addEventListener('keydown', this._onKey);
419
+ }
420
+
421
+ onDisconnected() {
422
+ this.removeEventListener('pointerdown', this._onDown);
423
+ this.removeEventListener('lostpointercapture', this._onUp);
424
+ this.removeEventListener('keydown', this._onKey);
425
+ this._stopDrag();
426
+ }
427
+
428
+ _onDown(event) {
429
+ if (this._isDisabled() || event.button !== 0 || this._drag) return;
430
+ const target = event.target;
431
+ const handle = target?.closest?.('[data-handle]');
432
+ const onBand = target?.closest?.('.ok-range-band');
433
+ const onTrack = target?.closest?.('.ok-range-track');
434
+ if (!handle && !onBand && !onTrack) return;
435
+ event.preventDefault();
436
+ let cfg = this.readConfig();
437
+ const angle = pointerAngle(event, this._box);
438
+ let which = handle ? handle.dataset.handle : onBand ? 'band' : null;
439
+ const startValue = this.value;
440
+ if (!which) {
441
+ // The track: the closer handle jumps there and keeps following.
442
+ const v = valueAtAngle(angle, { from: cfg.from, range: cfg.range, min: cfg.min, max: cfg.max });
443
+ const dist = (a) => (cfg.wrap ? Math.min(mod(v - a, cfg.period), mod(a - v, cfg.period)) : Math.abs(v - a));
444
+ which = dist(cfg.start) <= dist(cfg.end) ? 'start' : 'end';
445
+ const cur = cfg[which];
446
+ const dv = cfg.wrap ? mod(v - cur + cfg.period / 2, cfg.period) - cfg.period / 2 : v - cur;
447
+ this._commit(this._solve(which, dv, cfg, cfg));
448
+ cfg = this.readConfig();
449
+ }
450
+ this._active = which === 'band' ? (this._active || 'start') : which;
451
+ this._handles?.[this._active]?.focus({ preventScroll: true });
452
+ this._drag = { id: event.pointerId, which, last: angle, acc: 0, base: { start: cfg.start, end: cfg.end }, startValue };
453
+ this.classList.add('is-dragging');
454
+ this.setPointerCapture?.(event.pointerId);
455
+ window.addEventListener('pointermove', this._onMove);
456
+ window.addEventListener('pointerup', this._onUp);
457
+ window.addEventListener('pointercancel', this._onUp);
458
+ }
459
+
460
+ _onMove(event) {
461
+ const drag = this._drag;
462
+ if (!drag || event.pointerId !== drag.id || this._isDisabled()) return;
463
+ const angle = pointerAngle(event, this._box);
464
+ // Unwrap the angle so a full turn keeps counting instead of jumping at 12 o'clock.
465
+ drag.acc += mod(angle - drag.last + 180, 360) - 180;
466
+ drag.last = angle;
467
+ const cfg = this.readConfig();
468
+ this._commit(this._solve(drag.which, drag.acc / cfg.range * cfg.period, drag.base, cfg));
469
+ }
470
+
471
+ _stopDrag() {
472
+ if (typeof window === 'undefined') return;
473
+ if (this._drag && this.hasPointerCapture?.(this._drag.id)) this.releasePointerCapture(this._drag.id);
474
+ this._drag = null;
475
+ this.classList?.remove('is-dragging');
476
+ window.removeEventListener('pointermove', this._onMove);
477
+ window.removeEventListener('pointerup', this._onUp);
478
+ window.removeEventListener('pointercancel', this._onUp);
479
+ }
480
+
481
+ _onUp(event) {
482
+ const drag = this._drag;
483
+ if (!drag || event.pointerId !== drag.id) return;
484
+ this._stopDrag();
485
+ if (this.value !== drag.startValue && !this._isDisabled()) this._emit('change');
486
+ }
487
+
488
+ _onKey(event) {
489
+ const handle = event.target?.closest?.('[data-handle]');
490
+ if (!handle || this._isDisabled()) return;
491
+ const cfg = this.readConfig();
492
+ const which = handle.dataset.handle;
493
+ const step = cfg.step || cfg.period / 100;
494
+ const page = cfg.time ? Math.max(60, step) : step * 10;
495
+ const moves = { ArrowUp: step, ArrowRight: step, ArrowDown: -step, ArrowLeft: -step, PageUp: page, PageDown: -page };
496
+ let dv = null;
497
+ if (Object.hasOwn(moves, event.key)) dv = moves[event.key];
498
+ else if (event.key === 'Home') dv = cfg.min - cfg[which];
499
+ else if (event.key === 'End') dv = (cfg.time ? DAY - (cfg.step || 1) : cfg.max) - cfg[which];
500
+ if (dv == null) return;
501
+ event.preventDefault();
502
+ this._active = which;
503
+ // Shift + arrows moves the whole band.
504
+ const mover = event.shiftKey && moves[event.key] ? 'band' : which;
505
+ this._commit(this._solve(mover, dv, cfg, cfg), { change: true });
506
+ }
507
+ }
508
+
509
+ define('o-range', OrbitRange);