@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/base.js CHANGED
@@ -1,29 +1,219 @@
1
1
  /**
2
- * OrbitKitElement — shared base for orbit-kit high-level components.
2
+ * OrbitKitElement: the shared base for the orbit-kit components.
3
3
  *
4
- * Every kit component collapses a multi-level Orbit recipe
5
- * (.bigbang > .gravity-spot > .orbit-N > o-arc|o-progress|.satellite)
6
- * into a single declarative tag. They all share the same lifecycle:
4
+ * Every component turns one tag into an Orbit universe
5
+ * (.bigbang > .gravity-spot > .orbit-N > o-arc | o-progress | .satellite).
6
+ * They share one lifecycle:
7
7
  *
8
8
  * readConfig() -> parse attributes into a plain config object
9
9
  * structuralSignature(c) -> string; when it changes the subtree is rebuilt
10
10
  * build(cfg) -> render the Orbit structure into the light DOM
11
- * sync(cfg) -> patch cheap/animated values without a rebuild
11
+ * sync(cfg) -> patch values without a rebuild
12
12
  *
13
- * LIGHT DOM on purpose: Orbit positions elements through global CSS
14
- * (cos()/sin()/:has()) and inherited --o-* custom properties, so the kit must
15
- * render into the light DOM for orbit.css to apply and the inner <o-arc> /
16
- * <o-progress> to participate in the cascade. Requires @zumer/orbit (CSS + JS)
17
- * loaded on the page; orbit-kit declares it as a peer dependency.
13
+ * Sizing follows Orbit 2: the .bigbang is the universe and its width is the
14
+ * `size` attribute. orbit-12 reaches its edge, so the kit lays everything out
15
+ * in lanes (twelfths of the radius) and leaves --o-force alone.
16
+ *
17
+ * LIGHT DOM on purpose: Orbit positions elements through global CSS and
18
+ * inherited --o-* custom properties, and its runtime watches the document.
19
+ * Requires @zumer/orbit 2 (CSS + JS) on the page; it is a peer dependency.
20
+ */
21
+ export const HTMLElementBase = typeof HTMLElement === 'undefined' ? class {} : HTMLElement;
22
+
23
+ /** Text and attribute escaping share the same conservative encoding. */
24
+ export function escapeHTML(value) {
25
+ return String(value ?? '').replace(/[&<>"']/g, (c) => ({
26
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
27
+ }[c]));
28
+ }
29
+
30
+ export function finiteNumber(value, fallback = 0) {
31
+ if (value == null || String(value).trim() === '') return fallback;
32
+ const n = Number(value);
33
+ return Number.isFinite(n) ? n : fallback;
34
+ }
35
+
36
+ export function arrayData(value) {
37
+ return Array.isArray(value) ? value.filter((item) => item && typeof item === 'object' && !Array.isArray(item)) : [];
38
+ }
39
+
40
+ /** A declaration value, never a second declaration or active URL. */
41
+ export function cssValue(value, fallback = '') {
42
+ const text = String(value ?? '').trim();
43
+ return text && !/[;{}<>"'\\\x00-\x1f]/.test(text) && !/(?:url|expression)\s*\(/i.test(text) ? text : fallback;
44
+ }
45
+
46
+ export function cssLength(value, fallback = '200px') {
47
+ const text = cssValue(value);
48
+ if (!text || /^(?:auto|inherit|initial|unset|none)$/i.test(text)) return fallback;
49
+ if (typeof CSS !== 'undefined' && CSS.supports) return CSS.supports('width', text) ? text : fallback;
50
+ return /^(?:0|(?:\d*\.)?\d+(?:px|rem|em|%|vw|vh|vmin|vmax|svw|svh|dvw|dvh|cqw|cqh|cqmin|cqmax|ch|ex)|(?:calc|min|max|clamp|var)\(.+\))$/i.test(text) ? text : fallback;
51
+ }
52
+
53
+
54
+ const clampNumber = (value, min, max) => Math.min(max, Math.max(min, value));
55
+
56
+ /* ---------------------------------------------------------------------------
57
+ * Shared options. Every component gets them from OrbitKitElement; the design
58
+ * note (DESIGN-shared.md) shows how a new component wires them in.
59
+ * ------------------------------------------------------------------------- */
60
+
61
+ /** Attribute groups, for observedAttributes: withShared([...own], RANGE_ATTRS, FORMAT_ATTRS). */
62
+ export const RANGE_ATTRS = ['min', 'max', 'step'];
63
+ export const FORMAT_ATTRS = ['format', 'locale', 'decimals', 'currency', 'unit'];
64
+ export const FORM_ATTRS = ['name', 'disabled', 'required'];
65
+ export const withShared = (own, ...groups) => [...new Set([...own, ...groups.flat()])];
66
+
67
+ /**
68
+ * min / max / step, the way <input type="range"> reads them: a max below min
69
+ * becomes min, a step of "any" (or 0 where the component allows it) turns
70
+ * snapping off, and a step that is not a positive number falls back.
18
71
  */
19
- export class OrbitKitElement extends HTMLElement {
72
+ export function normalizeRange({ min = 0, max = 100, step = 0 } = {}) {
73
+ let lo = finiteNumber(min, 0), hi = finiteNumber(max, lo + 100);
74
+ if (!(hi >= lo)) hi = lo;
75
+ if (!Number.isFinite(hi - lo)) { lo = 0; hi = 100; }
76
+ const s = Number(step);
77
+ return { min: lo, max: hi, span: hi - lo, step: Number.isFinite(s) && s > 0 ? s : 0 };
78
+ }
79
+
80
+ /** Clamp to [min, max] and snap to the step grid that starts at min. Both ends stay reachable. */
81
+ export function snapValue(raw, { min, max, step }) {
82
+ const value = clampNumber(finiteNumber(raw, min), min, max);
83
+ if (value === min || value === max) return value;
84
+ // Twelve significant digits drop float noise (0.1 + 0.2) and keep real precision.
85
+ const snapped = step ? min + Math.round((value - min) / step) * step : value;
86
+ return clampNumber(Number(snapped.toPrecision(12)), min, max);
87
+ }
88
+
89
+ /** Where a value sits in its range, 0 to 1 (0 when the range is empty). */
90
+ export const rangeFraction = (value, { min, max }) => (max > min ? clampNumber((value - min) / (max - min), 0, 1) : 0);
91
+
92
+ export const FORMATS = ['decimal', 'percent', 'currency', 'unit', 'compact'];
93
+ const numberFormats = new Map();
94
+ function numberFormat(locale, options) {
95
+ const key = JSON.stringify([locale || '', options]);
96
+ if (!numberFormats.has(key)) numberFormats.set(key, new Intl.NumberFormat(locale || undefined, options));
97
+ return numberFormats.get(key);
98
+ }
99
+
100
+ /**
101
+ * Format a number with Intl.NumberFormat.
102
+ * format decimal | percent | currency | unit | compact
103
+ * decimals fixed fraction digits (otherwise Intl's default for the style)
104
+ * currency ISO code for "currency" (USD by default)
105
+ * unit Intl unit for "unit", e.g. "kilometer-per-hour"
106
+ * min, max "percent" shows where the value sits in this range; without a
107
+ * range the value is read as percentage points (72 -> 72%)
108
+ * A bad locale, currency or unit never throws: it falls back to a plain number.
109
+ */
110
+ export function formatNumber(value, { format = 'decimal', locale, decimals, currency, unit, min, max } = {}) {
111
+ let n = Number(value);
112
+ if (!Number.isFinite(n)) return '';
113
+ const d = Number(decimals);
114
+ const digits = decimals != null && decimals !== '' && Number.isInteger(d) && d >= 0 && d <= 20
115
+ ? { minimumFractionDigits: d, maximumFractionDigits: d } : null;
116
+ let options;
117
+ if (format === 'percent') {
118
+ n = Number.isFinite(min) && Number.isFinite(max) ? (max > min ? (n - min) / (max - min) : 0) : n / 100;
119
+ options = { style: 'percent', ...(digits || { maximumFractionDigits: 0 }) };
120
+ } else if (format === 'currency') options = { style: 'currency', currency: currency || 'USD', ...digits };
121
+ else if (format === 'unit') options = { style: 'unit', unit: String(unit || ''), unitDisplay: 'short', ...digits };
122
+ else if (format === 'compact') options = { notation: 'compact', ...(digits || { maximumFractionDigits: 1 }) };
123
+ else options = { ...digits };
124
+ for (const [lang, opts] of [[locale, options], [undefined, options], [locale, digits || {}], [undefined, digits || {}]]) {
125
+ try {
126
+ const text = numberFormat(lang, opts).format(n);
127
+ return opts === options || format !== 'unit' || !unit ? text : `${text} ${unit}`;
128
+ } catch { /* try the next, simpler setup */ }
129
+ }
130
+ return String(n);
131
+ }
132
+
133
+ /** The x/y angle of a pointer around the center of a box, in degrees clockwise from 12 o'clock. */
134
+ export function pointerAngle(event, box) {
135
+ const r = box?.getBoundingClientRect?.();
136
+ if (!r) return 0;
137
+ const angle = Math.atan2(event.clientX - (r.left + r.width / 2), (r.top + r.height / 2) - event.clientY) * 180 / Math.PI;
138
+ return (angle + 360) % 360;
139
+ }
140
+
141
+ /**
142
+ * The value at a screen angle on an arc that starts at `from` and spans
143
+ * `range` degrees. Past the end of a partial arc it snaps to the closer end.
144
+ */
145
+ export function valueAtAngle(angle, { from = 0, range = 360, min = 0, max = 100 }) {
146
+ if (!range) return min;
147
+ let along = ((angle - from) % 360 + 360) % 360;
148
+ if (along > range) along = along - range < 360 - along ? range : 0;
149
+ return min + along / range * (max - min);
150
+ }
151
+
152
+ /** Round for markup: no "1e-7deg" or 17-digit noise. */
153
+ export const fixed = (n, digits = 3) => String(Number(Number(n).toFixed(digits)));
154
+
155
+ /** Icons are text or a small inert SVG vocabulary; no HTML, scripts or URLs. */
156
+ export function safeIcon(value) {
157
+ const source = String(value ?? '');
158
+ if (!/^\s*<svg(?:\s|>)/i.test(source) || typeof document === 'undefined') return escapeHTML(source);
159
+ const template = document.createElement('template');
160
+ template.innerHTML = source;
161
+ if (!template.content) return escapeHTML(source);
162
+ const roots = [...template.content.children];
163
+ if (roots.length !== 1 || roots[0].localName !== 'svg') return escapeHTML(source);
164
+ const tags = new Set(['svg', 'g', 'path', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'rect', 'title', 'desc']);
165
+ const attrs = new Set(['viewbox', 'width', 'height', 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'd', 'points', 'fill', 'fill-rule', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'opacity', 'transform', 'preserveaspectratio', 'xmlns', 'aria-hidden', 'focusable']);
166
+ const clean = (node) => {
167
+ for (const child of [...node.children]) {
168
+ if (!tags.has(child.localName)) child.remove();
169
+ else clean(child);
170
+ }
171
+ for (const attr of [...node.attributes]) {
172
+ if (!attrs.has(attr.name.toLowerCase()) || /(?:url\s*\(|javascript:|data:|https?:|[<>])/i.test(attr.value)) node.removeAttribute(attr.name);
173
+ }
174
+ };
175
+ clean(roots[0]);
176
+ roots[0].setAttribute('aria-hidden', 'true');
177
+ roots[0].setAttribute('focusable', 'false');
178
+ return roots[0].outerHTML;
179
+ }
180
+
181
+ /**
182
+ * The universe a component renders: a .bigbang as wide as `size` (square by
183
+ * default, like every Orbit 2 universe) with one gravity spot.
184
+ */
185
+ export function universe(size, content, { spot = '', cls = '', box = '' } = {}) {
186
+ return `<div class="bigbang${cls ? ` ${cls}` : ''}" style="width:${escapeHTML(size)}${box ? `;${escapeHTML(box)}` : ''}">` +
187
+ `<div class="gravity-spot"${spot ? ` style="${escapeHTML(spot)}"` : ''}>${content}</div></div>`;
188
+ }
189
+
190
+ /**
191
+ * The center readout: a satellite as wide as the hole inside the bands
192
+ * (`lanes` is its diameter in lanes), so text wraps inside the ring.
193
+ */
194
+ export function hub(lanes, inner = '', cls = 'ok-hub', attrs = '') {
195
+ return `<div class="orbit-0" style="--o-size-ratio:${fixed(Math.max(1, lanes))}"><div class="satellite at-center"><div class="capsule ${cls}"${attrs}>${inner}</div></div></div>`;
196
+ }
197
+
198
+ export class OrbitKitElement extends HTMLElementBase {
20
199
  connectedCallback() {
200
+ // A property set before the element was defined (a page script that runs
201
+ // before the module) shadows the class setter; hand it to the setter now.
202
+ for (const name of ['formatter', 'value', 'disabled', 'name', 'required']) {
203
+ if (Object.hasOwn(this, name)) {
204
+ const value = this[name];
205
+ delete this[name];
206
+ this[name] = value;
207
+ }
208
+ }
21
209
  this._render();
210
+ // build() injected this component's stylesheet; a shadow root needs it too.
211
+ adoptKitStyles(this.getRootNode?.());
22
212
  this.onConnected?.();
23
213
  }
24
214
 
25
- attributeChangedCallback() {
26
- if (this.isConnected) this._render();
215
+ attributeChangedCallback(name, oldValue, newValue) {
216
+ if (oldValue !== newValue && this.isConnected) this._render();
27
217
  }
28
218
 
29
219
  disconnectedCallback() {
@@ -34,8 +224,7 @@ export class OrbitKitElement extends HTMLElement {
34
224
  _num(name, fallback) {
35
225
  const raw = this.getAttribute(name);
36
226
  if (raw === null || raw === '') return fallback;
37
- const n = parseFloat(raw);
38
- return Number.isFinite(n) ? n : fallback;
227
+ return finiteNumber(raw, fallback);
39
228
  }
40
229
 
41
230
  /** Read a string attribute with a fallback. */
@@ -56,6 +245,169 @@ export class OrbitKitElement extends HTMLElement {
56
245
  }
57
246
  }
58
247
 
248
+ _array(name, fallback = []) { return arrayData(this._json(name, fallback)); }
249
+ _size(name = 'size', fallback = '200px') { return cssLength(this.getAttribute(name), fallback); }
250
+ _color(name, fallback) { return cssValue(this.getAttribute(name), fallback); }
251
+
252
+ /**
253
+ * Band thickness in lanes (a lane is a twelfth of the radius). `thickness`
254
+ * wins; the 0.x `orbit` attribute still works: those widgets drew one lane
255
+ * of a system scaled so that orbit-N touched the edge.
256
+ */
257
+ _thickness(fallback, max = 11) {
258
+ let lanes = this._num('thickness', NaN);
259
+ if (!Number.isFinite(lanes) && this.hasAttribute('orbit')) lanes = 12 / (Math.max(1, Math.round(this._num('orbit', 4))) + 1);
260
+ if (!Number.isFinite(lanes)) lanes = fallback;
261
+ return Math.min(max, Math.max(.25, lanes));
262
+ }
263
+
264
+ /** Spacing between segments: `gap` is a share of the widget width, in percent. */
265
+ _gap(fallback) {
266
+ return Math.min(20, Math.max(0, this._num('gap', fallback)));
267
+ }
268
+
269
+
270
+ /* ---- shared options: min / max / step -------------------------------- */
271
+
272
+ /**
273
+ * Read min, max, step and value with the component's defaults. Returns
274
+ * { min, max, span, step, value, fraction }; value is clamped and snapped.
275
+ * `step: 0` as a default means "no snapping" (display components);
276
+ * interactive ones pass their own step (the knob uses 1). step="any" is 0.
277
+ */
278
+ _range({ min = 0, max = 100, step = 0, value } = {}) {
279
+ const raw = this.getAttribute('step')?.trim().toLowerCase();
280
+ let s = raw === 'any' ? 0 : this._num('step', step);
281
+ if (raw !== 'any' && !(s > 0)) s = step;
282
+ const range = normalizeRange({ min: this._num('min', min), max: this._num('max', max), step: s });
283
+ range.value = snapValue(this._num('value', value ?? range.min), range);
284
+ range.fraction = rangeFraction(range.value, range);
285
+ return range;
286
+ }
287
+
288
+ /** Next value for a key press, or null: arrows one step, Page keys ten, Home/End the ends. */
289
+ _keyValue(event, range, current = range.value) {
290
+ const step = range.step || range.span / 100 || 1;
291
+ const moves = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: 10, PageDown: -10 };
292
+ if (Object.hasOwn(moves, event.key)) return current + moves[event.key] * step;
293
+ if (event.key === 'Home') return range.min;
294
+ if (event.key === 'End') return range.max;
295
+ return null;
296
+ }
297
+
298
+ /** `input` / `change` (or any event) with { value, ...extra } in detail; bubbles like a native one. */
299
+ _emitValue(type, value, extra = {}) {
300
+ this.dispatchEvent(new CustomEvent(type, { detail: { value, ...extra }, bubbles: true, composed: true }));
301
+ }
302
+
303
+ /** role="meter" / "slider" values; aria-valuetext goes through the formatter. */
304
+ _ariaRange(range, value = range.value, text = this._formatText(value, { min: range.min, max: range.max, kind: 'aria' })) {
305
+ this.setAttribute('aria-valuenow', String(value));
306
+ this.setAttribute('aria-valuemin', String(range.min));
307
+ this.setAttribute('aria-valuemax', String(range.max));
308
+ this.setAttribute('aria-valuetext', text);
309
+ }
310
+
311
+ /* ---- shared options: number format ----------------------------------- */
312
+
313
+ /** A function (value, context) => string that replaces the built-in formatting. */
314
+ get formatter() { return this._formatter || null; }
315
+ set formatter(fn) {
316
+ this._formatter = typeof fn === 'function' ? fn : null;
317
+ this._formatterWarned = false;
318
+ // Tick labels may be baked into the structure, so rebuild once.
319
+ this._built = false;
320
+ if (this.isConnected) this._render();
321
+ }
322
+
323
+ /** The language for numbers: `locale`, else the closest `lang`, else the browser's. */
324
+ _locale() {
325
+ return this.getAttribute('locale') || this.closest?.('[lang]')?.getAttribute('lang') || undefined;
326
+ }
327
+
328
+ /**
329
+ * Format options from the attributes, or null when none is set: then
330
+ * components keep their 1.0 output. `unit` is plain text after the number,
331
+ * except with format="unit", where it names an Intl unit.
332
+ */
333
+ _formatOptions() {
334
+ const format = this.getAttribute('format');
335
+ const currency = this.getAttribute('currency');
336
+ const decimals = this.getAttribute('decimals');
337
+ if (!format && !currency && decimals == null && !this.hasAttribute('locale')) return null;
338
+ return {
339
+ format: FORMATS.includes(format) ? format : (currency ? 'currency' : 'decimal'),
340
+ locale: this._locale(), decimals, currency: cssValue(currency, 'USD').toUpperCase(),
341
+ unit: this.getAttribute('unit')
342
+ };
343
+ }
344
+
345
+ /** The unit text to print after a number ('' when format="unit" prints the unit itself). */
346
+ _unitText(fallback = '') {
347
+ return this.getAttribute('format') === 'unit' ? '' : this._str('unit', fallback) ?? '';
348
+ }
349
+
350
+ /**
351
+ * One number, as text. ctx: { kind: 'value' | 'tick' | 'aria', min, max,
352
+ * fallback }. `fallback(value)` is the component's own 1.0 formatting, used
353
+ * when no format attribute is set.
354
+ */
355
+ _format(value, ctx = {}) { return this._formatParts(value, ctx).text; }
356
+
357
+ /** A number with its unit text, unless a `formatter` took over the whole string. */
358
+ _formatText(value, ctx = {}, unit = this._unitText()) {
359
+ const { text, custom } = this._formatParts(value, ctx);
360
+ return custom ? text : `${text}${unit}`;
361
+ }
362
+
363
+ _formatParts(value, ctx) {
364
+ const { fallback, ...info } = ctx;
365
+ const options = this._formatOptions();
366
+ if (this._formatter) {
367
+ try {
368
+ const out = this._formatter(value, { kind: 'value', ...info, locale: options?.locale ?? this._locale(), format: options?.format ?? null, unit: this.getAttribute('unit'), element: this });
369
+ if (out != null) return { text: String(out), custom: true };
370
+ } catch (err) {
371
+ if (!this._formatterWarned) { this._formatterWarned = true; console.warn(`<${this.localName}> formatter threw:`, err); }
372
+ }
373
+ }
374
+ if (!options) return { text: fallback ? fallback(value) : String(value), custom: false };
375
+ // Whole-number ticks drop the cents: a scale reads 500 €, not 500,00 €.
376
+ const decimals = options.decimals == null && info.kind === 'tick' && Number.isInteger(Number(value)) ? 0 : options.decimals;
377
+ return { text: formatNumber(value, { ...options, decimals, min: info.min, max: info.max }), custom: false };
378
+ }
379
+
380
+ /** Restore the author's original inline declaration when an override ends. */
381
+ _applyTheme(values) {
382
+ this._themeOriginals ??= new Map();
383
+ for (const [prop, value] of Object.entries(values)) {
384
+ const clean = value == null ? '' : cssValue(value);
385
+ if (clean !== '') {
386
+ if (!this._themeOriginals.has(prop)) this._themeOriginals.set(prop, [this.style.getPropertyValue?.(prop) || '', this.style.getPropertyPriority?.(prop) || '']);
387
+ this.style.setProperty(prop, clean);
388
+ } else if (this._themeOriginals.has(prop)) {
389
+ const [original, priority] = this._themeOriginals.get(prop);
390
+ if (original) this.style.setProperty(prop, original, priority);
391
+ else this.style.removeProperty?.(prop);
392
+ this._themeOriginals.delete(prop);
393
+ }
394
+ }
395
+ }
396
+
397
+ /** Generated roles and names never replace ones the author supplied. */
398
+ _describe(text, role = 'img') {
399
+ if (!this.hasAttribute('role') || this.getAttribute('role') === this._generatedRole) {
400
+ this._generatedRole = role;
401
+ if (this.getAttribute('role') !== role) this.setAttribute('role', role);
402
+ }
403
+ if (this.hasAttribute('aria-labelledby')) return;
404
+ const current = this.getAttribute('aria-label');
405
+ if (current == null || current === this._generatedLabel) {
406
+ this._generatedLabel = String(text);
407
+ if (current !== this._generatedLabel) this.setAttribute('aria-label', this._generatedLabel);
408
+ }
409
+ }
410
+
59
411
  /**
60
412
  * Default `from` angle that centers an arc's gap at the bottom (6 o'clock),
61
413
  * matching the classic 3/4 gauge: range 270 -> from 225.
@@ -64,15 +416,15 @@ export class OrbitKitElement extends HTMLElement {
64
416
  return ((360 - range / 2) % 360 + 360) % 360;
65
417
  }
66
418
 
67
- /**
68
- * Opt-in neon glow. When the `glow` attribute is present, returns a
69
- * `filter: drop-shadow(...)` fragment tinted with the indicator color so the
70
- * fill reads as emissive. `glow="12"` overrides the blur radius (px).
71
- */
419
+ /** Blur radius for the opt-in `glow` attribute (`glow` or `glow="12"`). */
420
+ _glowPx(fallbackPx = 6) {
421
+ return Math.max(0, this._num('glow', fallbackPx));
422
+ }
423
+
424
+ /** Opt-in neon glow: a drop-shadow tinted with the indicator color. */
72
425
  _glow(color, fallbackPx = 6) {
73
426
  if (!this.hasAttribute('glow')) return '';
74
- const px = this._num('glow', fallbackPx) || fallbackPx;
75
- return `filter:drop-shadow(0 0 ${px}px ${color});`;
427
+ return `filter:drop-shadow(0 0 ${this._glowPx(fallbackPx)}px ${color});`;
76
428
  }
77
429
 
78
430
  _render() {
@@ -87,6 +439,119 @@ export class OrbitKitElement extends HTMLElement {
87
439
  }
88
440
  }
89
441
 
442
+
443
+ /**
444
+ * FormControl(Base): turns a kit component into a form-associated custom
445
+ * element, like <input>. It submits under `name`, takes `disabled` (also from
446
+ * a disabled <fieldset>) and `required`, resets with its form, restores on
447
+ * back/forward navigation, and exposes validity like a native control.
448
+ *
449
+ * The component supplies:
450
+ * get value() / set value(v) the current value; setting it fires no event
451
+ * _formValue() optional: string | FormData | null to submit
452
+ * (default: String(this.value), null when undefined)
453
+ * _formValidity() optional: { flags, message } (default: valid)
454
+ * _formAnchor() optional: element the browser points at when invalid
455
+ * and calls this._updateForm() after its value changes outside _render().
456
+ * The `value` attribute is the default value: setting it from markup or code
457
+ * moves the control, and form reset goes back to it. Values the user picks
458
+ * are written back to the attribute through _reflectValue(), which leaves the
459
+ * default alone.
460
+ */
461
+ export const FormControl = (Base) => class extends Base {
462
+ static formAssociated = true;
463
+
464
+ constructor() {
465
+ super();
466
+ try { this._internals = this.attachInternals?.() ?? null; } catch { this._internals = null; }
467
+ }
468
+
469
+ get form() { return this._internals?.form ?? null; }
470
+ get labels() { return this._internals?.labels ?? []; }
471
+ get type() { return this.localName; }
472
+ get name() { return this.getAttribute('name') ?? ''; }
473
+ set name(value) { this.setAttribute('name', String(value)); }
474
+ get disabled() { return this.hasAttribute('disabled'); }
475
+ set disabled(value) { if (value) this.setAttribute('disabled', ''); else this.removeAttribute('disabled'); }
476
+ get required() { return this.hasAttribute('required'); }
477
+ set required(value) { if (value) this.setAttribute('required', ''); else this.removeAttribute('required'); }
478
+ get defaultValue() { return this._defaultValue !== undefined ? this._defaultValue : this.getAttribute('value'); }
479
+ set defaultValue(value) { this.setAttribute('value', String(value)); }
480
+ get validity() { return this._internals?.validity; }
481
+ get validationMessage() { return this._internals?.validationMessage ?? ''; }
482
+ get willValidate() { return this._internals?.willValidate ?? false; }
483
+ checkValidity() { return this._internals?.checkValidity() ?? true; }
484
+ reportValidity() { return this._internals?.reportValidity() ?? true; }
485
+
486
+ /** Disabled by its own attribute or by a disabled fieldset around it. */
487
+ _isDisabled() { return this.hasAttribute('disabled') || !!this._fieldsetDisabled; }
488
+
489
+ attributeChangedCallback(name, oldValue, newValue) {
490
+ if (name === 'value' && !this._reflecting) this._defaultValue = newValue;
491
+ super.attributeChangedCallback(name, oldValue, newValue);
492
+ }
493
+
494
+ connectedCallback() {
495
+ if (this._defaultValue === undefined) this._defaultValue = this.getAttribute('value');
496
+ super.connectedCallback();
497
+ }
498
+
499
+ /** Write a value the user picked to the attribute without making it the default. */
500
+ _reflectValue(text) {
501
+ if (this.getAttribute('value') === text) return;
502
+ this._reflecting = true;
503
+ try { if (text == null) this.removeAttribute('value'); else this.setAttribute('value', text); } finally { this._reflecting = false; }
504
+ }
505
+
506
+ _render() {
507
+ super._render();
508
+ this._updateForm();
509
+ }
510
+
511
+ _formValue() {
512
+ const value = this.value;
513
+ if (value === undefined || value === null) return null;
514
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
515
+ }
516
+
517
+ _updateForm() {
518
+ const disabled = this._isDisabled();
519
+ this.setAttribute('aria-disabled', String(disabled));
520
+ if (this.toggleAttribute) this.toggleAttribute('data-disabled', disabled);
521
+ if (this.hasAttribute('required')) this.setAttribute('aria-required', 'true');
522
+ else if (this.getAttribute('aria-required') === 'true') this.removeAttribute('aria-required');
523
+ const internals = this._internals;
524
+ if (!internals) return;
525
+ const value = this._formValue();
526
+ try { internals.setFormValue(value, value); } catch { /* not in a form-capable browser */ }
527
+ const { flags = {}, message = '' } = this._formValidity?.() || {};
528
+ try {
529
+ if (Object.values(flags).some(Boolean)) internals.setValidity(flags, message || 'Invalid value', this._formAnchor?.() || undefined);
530
+ else internals.setValidity({});
531
+ } catch { /* setValidity needs a connected element in some engines */ }
532
+ }
533
+
534
+ formDisabledCallback(disabled) {
535
+ this._fieldsetDisabled = !!disabled;
536
+ if (this.isConnected) this._render();
537
+ }
538
+
539
+ formResetCallback() {
540
+ this._reflecting = true;
541
+ try {
542
+ const fallback = this._defaultValue;
543
+ if (fallback == null) this.removeAttribute('value'); else this.setAttribute('value', fallback);
544
+ } finally { this._reflecting = false; }
545
+ this._onFormReset?.();
546
+ if (this.isConnected) this._render();
547
+ }
548
+
549
+ formStateRestoreCallback(state) {
550
+ if (state == null || typeof state !== 'string') return;
551
+ this.value = state;
552
+ }
553
+ };
554
+
90
555
  /** Register a custom element once (safe against double-registration). */
91
556
  export function define(tag, cls) {
92
557
  if (typeof customElements !== 'undefined' && !customElements.get(tag)) {
@@ -94,9 +559,13 @@ export function define(tag, cls) {
94
559
  }
95
560
  }
96
561
 
97
- /** Inject a stylesheet once, keyed by id (used by animated components). */
562
+ /** The kit's stylesheets by id: the page gets a <style>, a shadow root adopts them. */
563
+ const SHEETS = new Map();
564
+
565
+ /** Inject a stylesheet once, keyed by id. */
98
566
  export function injectStyleOnce(id, css) {
99
- if (typeof document === 'undefined' || document.getElementById(id)) return;
567
+ if (!SHEETS.has(id)) SHEETS.set(id, { css, sheets: new WeakMap() });
568
+ if (typeof document === 'undefined' || !document.head || document.getElementById(id)) return;
100
569
  const style = document.createElement('style');
101
570
  style.id = id;
102
571
  style.textContent = css;
@@ -104,30 +573,126 @@ export function injectStyleOnce(id, css) {
104
573
  }
105
574
 
106
575
  /**
107
- * Kit baseline overrides, injected once for the whole kit.
576
+ * The page's stylesheets stop at a shadow root, so a component inside one
577
+ * adopts the kit's stylesheets into that root (every component does this when
578
+ * it connects): constructed sheets, one per document, or a <style> where the
579
+ * browser has none. Orbit's own stylesheet is yours to put in that root, as
580
+ * for any Orbit layout in shadow DOM.
581
+ */
582
+ export function adoptKitStyles(root) {
583
+ if (!root || root.nodeType !== 11 || !root.host) return;
584
+ const doc = root.ownerDocument;
585
+ const Sheet = doc?.defaultView?.CSSStyleSheet;
586
+ if (Array.isArray(root.adoptedStyleSheets) && Sheet) {
587
+ try {
588
+ const missing = [];
589
+ for (const entry of SHEETS.values()) {
590
+ let sheet = entry.sheets.get(doc);
591
+ if (!sheet) {
592
+ sheet = new Sheet();
593
+ sheet.replaceSync(entry.css);
594
+ entry.sheets.set(doc, sheet);
595
+ }
596
+ if (!root.adoptedStyleSheets.includes(sheet)) missing.push(sheet);
597
+ }
598
+ if (missing.length) root.adoptedStyleSheets = [...root.adoptedStyleSheets, ...missing];
599
+ return;
600
+ } catch { /* no constructed sheets here: fall back to <style> */ }
601
+ }
602
+ for (const [id, entry] of SHEETS) {
603
+ if (root.querySelector(`style[data-orbit-kit="${id}"]`)) continue;
604
+ const style = doc.createElement('style');
605
+ style.dataset.orbitKit = id;
606
+ style.textContent = entry.css;
607
+ root.prepend(style);
608
+ }
609
+ }
610
+
611
+ export const KIT_TAGS = 'o-gauge,o-donut,o-rings,o-meter,o-knob,o-menu,o-compass,o-radar,o-spinner,o-radar-chart,o-clock,o-instrument,o-timer,o-status,o-range,o-time-picker,o-sunburst,o-wheel,o-color-wheel';
612
+
613
+
614
+ /**
615
+ * Themes: sets of the shared --ok-* properties. `theme="dark|light|glass"` on
616
+ * a component, or the class `ok-theme-dark|light|glass` on any ancestor (the
617
+ * attribute works on any element too), and everything inside follows. They sit
618
+ * in :where(), so any rule of yours wins; the color attributes win too. With
619
+ * no theme, components keep their own defaults.
108
620
  *
109
- * Orbit's default theme draws `border: 1px solid currentcolor` on every
110
- * `.satellite` (see orbit _themes.scss). For composed widgets that's an
111
- * unwanted ring around the center label / cardinals / radar blips. We strip it
112
- * — but ONLY inside kit components, so hand-written Orbit markup keeps the
113
- * default. `:where()` keeps specificity at (0,1,0) and the rule lands after
114
- * orbit.css in document order, so the user can still re-add a border.
621
+ * Shared tokens: --ok-fill, --ok-track, --ok-accent, --ok-text, --ok-face,
622
+ * --ok-bezel, --ok-tick, --ok-hand (hands, needles' partners, major ticks,
623
+ * strong numbers), --ok-case, --ok-surface (panels like the menu hub),
624
+ * --ok-focus, --ok-backdrop (a backdrop-filter for .ok-surface elements).
625
+ */
626
+ export const THEMES = {
627
+ dark: {
628
+ '--ok-fill': '#4fd1c5', '--ok-track': '#222a33', '--ok-accent': '#ffb547', '--ok-text': '#e8edf2',
629
+ '--ok-face': 'radial-gradient(circle at 50% 35%, #18202a, #0c0f13)', '--ok-bezel': '#2a3440',
630
+ '--ok-tick': 'rgba(232,237,242,.35)', '--ok-hand': '#e8edf2', '--ok-case': 'linear-gradient(160deg, #1b222b, #0e1217)',
631
+ '--ok-surface': '#161b22', '--ok-focus': '#4fd1c5',
632
+ '--ok-menu-slate': '#232b35', '--ok-menu-text': '#e8edf2', '--ok-menu-hub-hover': '#1f2630',
633
+ '--ok-radar-color': '#4fd1c5', '--ok-timer-button-text': '#0c0f13'
634
+ },
635
+ light: {
636
+ '--ok-fill': '#0d9488', '--ok-track': '#e2e8f0', '--ok-accent': '#ea580c', '--ok-text': '#12161c',
637
+ '--ok-face': 'radial-gradient(circle at 50% 35%, #ffffff, #eef2f6)', '--ok-bezel': '#cbd5e1',
638
+ '--ok-tick': 'rgba(18,22,28,.32)', '--ok-hand': '#1e293b', '--ok-case': 'linear-gradient(160deg, #ffffff, #e5eaf0)',
639
+ '--ok-surface': '#ffffff', '--ok-focus': '#0d9488',
640
+ '--ok-menu-slate': '#e2e8f0', '--ok-menu-text': '#12161c', '--ok-menu-hub-hover': '#f1f5f9',
641
+ '--ok-radar-color': '#0d9488', '--ok-timer-button-text': '#ffffff', '--ok-status-off': '#94a3b8'
642
+ },
643
+ glass: {
644
+ '--ok-fill': 'rgba(255,255,255,.92)', '--ok-track': 'rgba(255,255,255,.16)', '--ok-accent': '#7dd3fc', '--ok-text': '#ffffff',
645
+ '--ok-face': 'rgba(255,255,255,.08)', '--ok-bezel': 'rgba(255,255,255,.24)',
646
+ '--ok-tick': 'rgba(255,255,255,.4)', '--ok-hand': '#ffffff', '--ok-case': 'rgba(255,255,255,.1)',
647
+ '--ok-surface': 'rgba(255,255,255,.12)', '--ok-focus': '#ffffff', '--ok-backdrop': 'blur(14px) saturate(140%)',
648
+ '--ok-menu-slate': 'rgba(255,255,255,.14)', '--ok-menu-text': '#ffffff', '--ok-menu-hub-hover': 'rgba(255,255,255,.2)',
649
+ '--ok-radar-color': '#e0f2fe', '--ok-timer-button-text': '#0c0f13', '--ok-timer-button-alt': 'rgba(255,255,255,.18)'
650
+ }
651
+ };
652
+
653
+ const THEME_CSS = Object.entries(THEMES).map(([name, tokens]) =>
654
+ `:where([theme~="${name}"], .ok-theme-${name}) {\n${Object.entries(tokens).map(([k, v]) => ` ${k}: ${v};`).join('\n')}\n}`).join('\n') + `
655
+ :where(.ok-surface) {
656
+ -webkit-backdrop-filter: var(--ok-backdrop, none);
657
+ backdrop-filter: var(--ok-backdrop, none);
658
+ }`;
659
+
660
+ /**
661
+ * Kit baseline, injected once. Everything sits in :where(), so a single class
662
+ * or custom property of yours wins.
663
+ *
664
+ * Text scales with the widget: the base font size of a kit universe is a share
665
+ * of its box (--ok-font-size) and readouts are sized in em. Colors come from
666
+ * custom properties that inherit, so a theme can be set once on a parent:
667
+ * --ok-fill, --ok-track, --ok-accent, --ok-text, --ok-focus.
115
668
  */
116
669
  const KIT_BASE_CSS = `
117
- :where(o-gauge,o-donut,o-rings,o-meter,o-knob,o-menu,o-compass,o-radar,o-clock,o-instrument) .satellite {
670
+ :where(${KIT_TAGS}) [hidden] { display: none !important; }
671
+ :where(${KIT_TAGS}) :where(.satellite) {
118
672
  border: none;
119
673
  background: transparent;
120
674
  }
121
- /* Shared center hub: a soft radial halo behind the label. Uses a gradient
122
- (not a hard circle) so wide readouts like "125 km/h" never get clipped.
123
- Theme it with --ok-hub-bg / --ok-hub-color. */
124
- :where(o-gauge,o-donut,o-rings,o-meter,o-knob,o-compass) .ok-hub {
125
- font-weight: 800;
675
+ :where(${KIT_TAGS}) :where(.bigbang) > :where(.gravity-spot) {
676
+ font-size: var(--ok-font-size, 8cqmin);
677
+ color: var(--ok-text, inherit);
678
+ }
679
+ :where(${KIT_TAGS}) :where(.ok-hub) {
680
+ font-size: var(--ok-hub-size, 1.4em);
681
+ font-weight: 700;
126
682
  text-align: center;
127
683
  line-height: 1.15;
128
- padding: .5em .7em;
129
- color: var(--ok-hub-color, inherit);
130
- background: radial-gradient(closest-side, var(--ok-hub-bg, rgba(8, 12, 18, .82)), transparent 100%);
684
+ font-variant-numeric: tabular-nums;
685
+ color: var(--ok-hub-color, var(--ok-text, inherit));
686
+ background: var(--ok-hub-bg, none);
687
+ border-radius: 50%;
688
+ aspect-ratio: 1;
689
+ }
690
+ :where(${KIT_TAGS}):focus-visible { outline: none; }
691
+ :where(${KIT_TAGS}):focus-visible > :where(.bigbang),
692
+ :where(${KIT_TAGS}) :where(button, .capsule[tabindex]):focus-visible {
693
+ outline: 2px solid var(--ok-focus, #3da9fc);
694
+ outline-offset: 3px;
695
+ border-radius: 50%;
131
696
  }`;
132
697
 
133
- injectStyleOnce('orbit-kit-base', KIT_BASE_CSS);
698
+ injectStyleOnce('orbit-kit-base', KIT_BASE_CSS + THEME_CSS);