@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/base.js CHANGED
@@ -1,29 +1,217 @@
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.
18
20
  */
19
- export class OrbitKitElement extends HTMLElement {
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.
71
+ */
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();
22
210
  this.onConnected?.();
23
211
  }
24
212
 
25
- attributeChangedCallback() {
26
- if (this.isConnected) this._render();
213
+ attributeChangedCallback(name, oldValue, newValue) {
214
+ if (oldValue !== newValue && this.isConnected) this._render();
27
215
  }
28
216
 
29
217
  disconnectedCallback() {
@@ -34,8 +222,7 @@ export class OrbitKitElement extends HTMLElement {
34
222
  _num(name, fallback) {
35
223
  const raw = this.getAttribute(name);
36
224
  if (raw === null || raw === '') return fallback;
37
- const n = parseFloat(raw);
38
- return Number.isFinite(n) ? n : fallback;
225
+ return finiteNumber(raw, fallback);
39
226
  }
40
227
 
41
228
  /** Read a string attribute with a fallback. */
@@ -56,6 +243,169 @@ export class OrbitKitElement extends HTMLElement {
56
243
  }
57
244
  }
58
245
 
246
+ _array(name, fallback = []) { return arrayData(this._json(name, fallback)); }
247
+ _size(name = 'size', fallback = '200px') { return cssLength(this.getAttribute(name), fallback); }
248
+ _color(name, fallback) { return cssValue(this.getAttribute(name), fallback); }
249
+
250
+ /**
251
+ * Band thickness in lanes (a lane is a twelfth of the radius). `thickness`
252
+ * wins; the 0.x `orbit` attribute still works: those widgets drew one lane
253
+ * of a system scaled so that orbit-N touched the edge.
254
+ */
255
+ _thickness(fallback, max = 11) {
256
+ let lanes = this._num('thickness', NaN);
257
+ if (!Number.isFinite(lanes) && this.hasAttribute('orbit')) lanes = 12 / (Math.max(1, Math.round(this._num('orbit', 4))) + 1);
258
+ if (!Number.isFinite(lanes)) lanes = fallback;
259
+ return Math.min(max, Math.max(.25, lanes));
260
+ }
261
+
262
+ /** Spacing between segments: `gap` is a share of the widget width, in percent. */
263
+ _gap(fallback) {
264
+ return Math.min(20, Math.max(0, this._num('gap', fallback)));
265
+ }
266
+
267
+
268
+ /* ---- shared options: min / max / step -------------------------------- */
269
+
270
+ /**
271
+ * Read min, max, step and value with the component's defaults. Returns
272
+ * { min, max, span, step, value, fraction }; value is clamped and snapped.
273
+ * `step: 0` as a default means "no snapping" (display components);
274
+ * interactive ones pass their own step (the knob uses 1). step="any" is 0.
275
+ */
276
+ _range({ min = 0, max = 100, step = 0, value } = {}) {
277
+ const raw = this.getAttribute('step')?.trim().toLowerCase();
278
+ let s = raw === 'any' ? 0 : this._num('step', step);
279
+ if (raw !== 'any' && !(s > 0)) s = step;
280
+ const range = normalizeRange({ min: this._num('min', min), max: this._num('max', max), step: s });
281
+ range.value = snapValue(this._num('value', value ?? range.min), range);
282
+ range.fraction = rangeFraction(range.value, range);
283
+ return range;
284
+ }
285
+
286
+ /** Next value for a key press, or null: arrows one step, Page keys ten, Home/End the ends. */
287
+ _keyValue(event, range, current = range.value) {
288
+ const step = range.step || range.span / 100 || 1;
289
+ const moves = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: 10, PageDown: -10 };
290
+ if (Object.hasOwn(moves, event.key)) return current + moves[event.key] * step;
291
+ if (event.key === 'Home') return range.min;
292
+ if (event.key === 'End') return range.max;
293
+ return null;
294
+ }
295
+
296
+ /** `input` / `change` (or any event) with { value, ...extra } in detail; bubbles like a native one. */
297
+ _emitValue(type, value, extra = {}) {
298
+ this.dispatchEvent(new CustomEvent(type, { detail: { value, ...extra }, bubbles: true, composed: true }));
299
+ }
300
+
301
+ /** role="meter" / "slider" values; aria-valuetext goes through the formatter. */
302
+ _ariaRange(range, value = range.value, text = this._formatText(value, { min: range.min, max: range.max, kind: 'aria' })) {
303
+ this.setAttribute('aria-valuenow', String(value));
304
+ this.setAttribute('aria-valuemin', String(range.min));
305
+ this.setAttribute('aria-valuemax', String(range.max));
306
+ this.setAttribute('aria-valuetext', text);
307
+ }
308
+
309
+ /* ---- shared options: number format ----------------------------------- */
310
+
311
+ /** A function (value, context) => string that replaces the built-in formatting. */
312
+ get formatter() { return this._formatter || null; }
313
+ set formatter(fn) {
314
+ this._formatter = typeof fn === 'function' ? fn : null;
315
+ this._formatterWarned = false;
316
+ // Tick labels may be baked into the structure, so rebuild once.
317
+ this._built = false;
318
+ if (this.isConnected) this._render();
319
+ }
320
+
321
+ /** The language for numbers: `locale`, else the closest `lang`, else the browser's. */
322
+ _locale() {
323
+ return this.getAttribute('locale') || this.closest?.('[lang]')?.getAttribute('lang') || undefined;
324
+ }
325
+
326
+ /**
327
+ * Format options from the attributes, or null when none is set: then
328
+ * components keep their 1.0 output. `unit` is plain text after the number,
329
+ * except with format="unit", where it names an Intl unit.
330
+ */
331
+ _formatOptions() {
332
+ const format = this.getAttribute('format');
333
+ const currency = this.getAttribute('currency');
334
+ const decimals = this.getAttribute('decimals');
335
+ if (!format && !currency && decimals == null && !this.hasAttribute('locale')) return null;
336
+ return {
337
+ format: FORMATS.includes(format) ? format : (currency ? 'currency' : 'decimal'),
338
+ locale: this._locale(), decimals, currency: cssValue(currency, 'USD').toUpperCase(),
339
+ unit: this.getAttribute('unit')
340
+ };
341
+ }
342
+
343
+ /** The unit text to print after a number ('' when format="unit" prints the unit itself). */
344
+ _unitText(fallback = '') {
345
+ return this.getAttribute('format') === 'unit' ? '' : this._str('unit', fallback) ?? '';
346
+ }
347
+
348
+ /**
349
+ * One number, as text. ctx: { kind: 'value' | 'tick' | 'aria', min, max,
350
+ * fallback }. `fallback(value)` is the component's own 1.0 formatting, used
351
+ * when no format attribute is set.
352
+ */
353
+ _format(value, ctx = {}) { return this._formatParts(value, ctx).text; }
354
+
355
+ /** A number with its unit text, unless a `formatter` took over the whole string. */
356
+ _formatText(value, ctx = {}, unit = this._unitText()) {
357
+ const { text, custom } = this._formatParts(value, ctx);
358
+ return custom ? text : `${text}${unit}`;
359
+ }
360
+
361
+ _formatParts(value, ctx) {
362
+ const { fallback, ...info } = ctx;
363
+ const options = this._formatOptions();
364
+ if (this._formatter) {
365
+ try {
366
+ const out = this._formatter(value, { kind: 'value', ...info, locale: options?.locale ?? this._locale(), format: options?.format ?? null, unit: this.getAttribute('unit'), element: this });
367
+ if (out != null) return { text: String(out), custom: true };
368
+ } catch (err) {
369
+ if (!this._formatterWarned) { this._formatterWarned = true; console.warn(`<${this.localName}> formatter threw:`, err); }
370
+ }
371
+ }
372
+ if (!options) return { text: fallback ? fallback(value) : String(value), custom: false };
373
+ // Whole-number ticks drop the cents: a scale reads 500 €, not 500,00 €.
374
+ const decimals = options.decimals == null && info.kind === 'tick' && Number.isInteger(Number(value)) ? 0 : options.decimals;
375
+ return { text: formatNumber(value, { ...options, decimals, min: info.min, max: info.max }), custom: false };
376
+ }
377
+
378
+ /** Restore the author's original inline declaration when an override ends. */
379
+ _applyTheme(values) {
380
+ this._themeOriginals ??= new Map();
381
+ for (const [prop, value] of Object.entries(values)) {
382
+ const clean = value == null ? '' : cssValue(value);
383
+ if (clean !== '') {
384
+ if (!this._themeOriginals.has(prop)) this._themeOriginals.set(prop, [this.style.getPropertyValue?.(prop) || '', this.style.getPropertyPriority?.(prop) || '']);
385
+ this.style.setProperty(prop, clean);
386
+ } else if (this._themeOriginals.has(prop)) {
387
+ const [original, priority] = this._themeOriginals.get(prop);
388
+ if (original) this.style.setProperty(prop, original, priority);
389
+ else this.style.removeProperty?.(prop);
390
+ this._themeOriginals.delete(prop);
391
+ }
392
+ }
393
+ }
394
+
395
+ /** Generated roles and names never replace ones the author supplied. */
396
+ _describe(text, role = 'img') {
397
+ if (!this.hasAttribute('role') || this.getAttribute('role') === this._generatedRole) {
398
+ this._generatedRole = role;
399
+ if (this.getAttribute('role') !== role) this.setAttribute('role', role);
400
+ }
401
+ if (this.hasAttribute('aria-labelledby')) return;
402
+ const current = this.getAttribute('aria-label');
403
+ if (current == null || current === this._generatedLabel) {
404
+ this._generatedLabel = String(text);
405
+ if (current !== this._generatedLabel) this.setAttribute('aria-label', this._generatedLabel);
406
+ }
407
+ }
408
+
59
409
  /**
60
410
  * Default `from` angle that centers an arc's gap at the bottom (6 o'clock),
61
411
  * matching the classic 3/4 gauge: range 270 -> from 225.
@@ -64,15 +414,15 @@ export class OrbitKitElement extends HTMLElement {
64
414
  return ((360 - range / 2) % 360 + 360) % 360;
65
415
  }
66
416
 
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
- */
417
+ /** Blur radius for the opt-in `glow` attribute (`glow` or `glow="12"`). */
418
+ _glowPx(fallbackPx = 6) {
419
+ return Math.max(0, this._num('glow', fallbackPx));
420
+ }
421
+
422
+ /** Opt-in neon glow: a drop-shadow tinted with the indicator color. */
72
423
  _glow(color, fallbackPx = 6) {
73
424
  if (!this.hasAttribute('glow')) return '';
74
- const px = this._num('glow', fallbackPx) || fallbackPx;
75
- return `filter:drop-shadow(0 0 ${px}px ${color});`;
425
+ return `filter:drop-shadow(0 0 ${this._glowPx(fallbackPx)}px ${color});`;
76
426
  }
77
427
 
78
428
  _render() {
@@ -87,6 +437,119 @@ export class OrbitKitElement extends HTMLElement {
87
437
  }
88
438
  }
89
439
 
440
+
441
+ /**
442
+ * FormControl(Base): turns a kit component into a form-associated custom
443
+ * element, like <input>. It submits under `name`, takes `disabled` (also from
444
+ * a disabled <fieldset>) and `required`, resets with its form, restores on
445
+ * back/forward navigation, and exposes validity like a native control.
446
+ *
447
+ * The component supplies:
448
+ * get value() / set value(v) the current value; setting it fires no event
449
+ * _formValue() optional: string | FormData | null to submit
450
+ * (default: String(this.value), null when undefined)
451
+ * _formValidity() optional: { flags, message } (default: valid)
452
+ * _formAnchor() optional: element the browser points at when invalid
453
+ * and calls this._updateForm() after its value changes outside _render().
454
+ * The `value` attribute is the default value: setting it from markup or code
455
+ * moves the control, and form reset goes back to it. Values the user picks
456
+ * are written back to the attribute through _reflectValue(), which leaves the
457
+ * default alone.
458
+ */
459
+ export const FormControl = (Base) => class extends Base {
460
+ static formAssociated = true;
461
+
462
+ constructor() {
463
+ super();
464
+ try { this._internals = this.attachInternals?.() ?? null; } catch { this._internals = null; }
465
+ }
466
+
467
+ get form() { return this._internals?.form ?? null; }
468
+ get labels() { return this._internals?.labels ?? []; }
469
+ get type() { return this.localName; }
470
+ get name() { return this.getAttribute('name') ?? ''; }
471
+ set name(value) { this.setAttribute('name', String(value)); }
472
+ get disabled() { return this.hasAttribute('disabled'); }
473
+ set disabled(value) { if (value) this.setAttribute('disabled', ''); else this.removeAttribute('disabled'); }
474
+ get required() { return this.hasAttribute('required'); }
475
+ set required(value) { if (value) this.setAttribute('required', ''); else this.removeAttribute('required'); }
476
+ get defaultValue() { return this._defaultValue !== undefined ? this._defaultValue : this.getAttribute('value'); }
477
+ set defaultValue(value) { this.setAttribute('value', String(value)); }
478
+ get validity() { return this._internals?.validity; }
479
+ get validationMessage() { return this._internals?.validationMessage ?? ''; }
480
+ get willValidate() { return this._internals?.willValidate ?? false; }
481
+ checkValidity() { return this._internals?.checkValidity() ?? true; }
482
+ reportValidity() { return this._internals?.reportValidity() ?? true; }
483
+
484
+ /** Disabled by its own attribute or by a disabled fieldset around it. */
485
+ _isDisabled() { return this.hasAttribute('disabled') || !!this._fieldsetDisabled; }
486
+
487
+ attributeChangedCallback(name, oldValue, newValue) {
488
+ if (name === 'value' && !this._reflecting) this._defaultValue = newValue;
489
+ super.attributeChangedCallback(name, oldValue, newValue);
490
+ }
491
+
492
+ connectedCallback() {
493
+ if (this._defaultValue === undefined) this._defaultValue = this.getAttribute('value');
494
+ super.connectedCallback();
495
+ }
496
+
497
+ /** Write a value the user picked to the attribute without making it the default. */
498
+ _reflectValue(text) {
499
+ if (this.getAttribute('value') === text) return;
500
+ this._reflecting = true;
501
+ try { if (text == null) this.removeAttribute('value'); else this.setAttribute('value', text); } finally { this._reflecting = false; }
502
+ }
503
+
504
+ _render() {
505
+ super._render();
506
+ this._updateForm();
507
+ }
508
+
509
+ _formValue() {
510
+ const value = this.value;
511
+ if (value === undefined || value === null) return null;
512
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
513
+ }
514
+
515
+ _updateForm() {
516
+ const disabled = this._isDisabled();
517
+ this.setAttribute('aria-disabled', String(disabled));
518
+ if (this.toggleAttribute) this.toggleAttribute('data-disabled', disabled);
519
+ if (this.hasAttribute('required')) this.setAttribute('aria-required', 'true');
520
+ else if (this.getAttribute('aria-required') === 'true') this.removeAttribute('aria-required');
521
+ const internals = this._internals;
522
+ if (!internals) return;
523
+ const value = this._formValue();
524
+ try { internals.setFormValue(value, value); } catch { /* not in a form-capable browser */ }
525
+ const { flags = {}, message = '' } = this._formValidity?.() || {};
526
+ try {
527
+ if (Object.values(flags).some(Boolean)) internals.setValidity(flags, message || 'Invalid value', this._formAnchor?.() || undefined);
528
+ else internals.setValidity({});
529
+ } catch { /* setValidity needs a connected element in some engines */ }
530
+ }
531
+
532
+ formDisabledCallback(disabled) {
533
+ this._fieldsetDisabled = !!disabled;
534
+ if (this.isConnected) this._render();
535
+ }
536
+
537
+ formResetCallback() {
538
+ this._reflecting = true;
539
+ try {
540
+ const fallback = this._defaultValue;
541
+ if (fallback == null) this.removeAttribute('value'); else this.setAttribute('value', fallback);
542
+ } finally { this._reflecting = false; }
543
+ this._onFormReset?.();
544
+ if (this.isConnected) this._render();
545
+ }
546
+
547
+ formStateRestoreCallback(state) {
548
+ if (state == null || typeof state !== 'string') return;
549
+ this.value = state;
550
+ }
551
+ };
552
+
90
553
  /** Register a custom element once (safe against double-registration). */
91
554
  export function define(tag, cls) {
92
555
  if (typeof customElements !== 'undefined' && !customElements.get(tag)) {
@@ -94,40 +557,100 @@ export function define(tag, cls) {
94
557
  }
95
558
  }
96
559
 
97
- /** Inject a stylesheet once, keyed by id (used by animated components). */
560
+ /** Inject a stylesheet once, keyed by id. */
98
561
  export function injectStyleOnce(id, css) {
99
- if (typeof document === 'undefined' || document.getElementById(id)) return;
562
+ if (typeof document === 'undefined' || !document.head || document.getElementById(id)) return;
100
563
  const style = document.createElement('style');
101
564
  style.id = id;
102
565
  style.textContent = css;
103
566
  document.head.appendChild(style);
104
567
  }
105
568
 
569
+ 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';
570
+
571
+
106
572
  /**
107
- * Kit baseline overrides, injected once for the whole kit.
573
+ * Themes: sets of the shared --ok-* properties. `theme="dark|light|glass"` on
574
+ * a component, or the class `ok-theme-dark|light|glass` on any ancestor (the
575
+ * attribute works on any element too), and everything inside follows. They sit
576
+ * in :where(), so any rule of yours wins; the color attributes win too. With
577
+ * no theme, components keep their own defaults.
108
578
  *
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.
579
+ * Shared tokens: --ok-fill, --ok-track, --ok-accent, --ok-text, --ok-face,
580
+ * --ok-bezel, --ok-tick, --ok-hand (hands, needles' partners, major ticks,
581
+ * strong numbers), --ok-case, --ok-surface (panels like the menu hub),
582
+ * --ok-focus, --ok-backdrop (a backdrop-filter for .ok-surface elements).
583
+ */
584
+ export const THEMES = {
585
+ dark: {
586
+ '--ok-fill': '#4fd1c5', '--ok-track': '#222a33', '--ok-accent': '#ffb547', '--ok-text': '#e8edf2',
587
+ '--ok-face': 'radial-gradient(circle at 50% 35%, #18202a, #0c0f13)', '--ok-bezel': '#2a3440',
588
+ '--ok-tick': 'rgba(232,237,242,.35)', '--ok-hand': '#e8edf2', '--ok-case': 'linear-gradient(160deg, #1b222b, #0e1217)',
589
+ '--ok-surface': '#161b22', '--ok-focus': '#4fd1c5',
590
+ '--ok-menu-slate': '#232b35', '--ok-menu-text': '#e8edf2', '--ok-menu-hub-hover': '#1f2630',
591
+ '--ok-radar-color': '#4fd1c5', '--ok-timer-button-text': '#0c0f13'
592
+ },
593
+ light: {
594
+ '--ok-fill': '#0d9488', '--ok-track': '#e2e8f0', '--ok-accent': '#ea580c', '--ok-text': '#12161c',
595
+ '--ok-face': 'radial-gradient(circle at 50% 35%, #ffffff, #eef2f6)', '--ok-bezel': '#cbd5e1',
596
+ '--ok-tick': 'rgba(18,22,28,.32)', '--ok-hand': '#1e293b', '--ok-case': 'linear-gradient(160deg, #ffffff, #e5eaf0)',
597
+ '--ok-surface': '#ffffff', '--ok-focus': '#0d9488',
598
+ '--ok-menu-slate': '#e2e8f0', '--ok-menu-text': '#12161c', '--ok-menu-hub-hover': '#f1f5f9',
599
+ '--ok-radar-color': '#0d9488', '--ok-timer-button-text': '#ffffff', '--ok-status-off': '#94a3b8'
600
+ },
601
+ glass: {
602
+ '--ok-fill': 'rgba(255,255,255,.92)', '--ok-track': 'rgba(255,255,255,.16)', '--ok-accent': '#7dd3fc', '--ok-text': '#ffffff',
603
+ '--ok-face': 'rgba(255,255,255,.08)', '--ok-bezel': 'rgba(255,255,255,.24)',
604
+ '--ok-tick': 'rgba(255,255,255,.4)', '--ok-hand': '#ffffff', '--ok-case': 'rgba(255,255,255,.1)',
605
+ '--ok-surface': 'rgba(255,255,255,.12)', '--ok-focus': '#ffffff', '--ok-backdrop': 'blur(14px) saturate(140%)',
606
+ '--ok-menu-slate': 'rgba(255,255,255,.14)', '--ok-menu-text': '#ffffff', '--ok-menu-hub-hover': 'rgba(255,255,255,.2)',
607
+ '--ok-radar-color': '#e0f2fe', '--ok-timer-button-text': '#0c0f13', '--ok-timer-button-alt': 'rgba(255,255,255,.18)'
608
+ }
609
+ };
610
+
611
+ const THEME_CSS = Object.entries(THEMES).map(([name, tokens]) =>
612
+ `:where([theme~="${name}"], .ok-theme-${name}) {\n${Object.entries(tokens).map(([k, v]) => ` ${k}: ${v};`).join('\n')}\n}`).join('\n') + `
613
+ :where(.ok-surface) {
614
+ -webkit-backdrop-filter: var(--ok-backdrop, none);
615
+ backdrop-filter: var(--ok-backdrop, none);
616
+ }`;
617
+
618
+ /**
619
+ * Kit baseline, injected once. Everything sits in :where(), so a single class
620
+ * or custom property of yours wins.
621
+ *
622
+ * Text scales with the widget: the base font size of a kit universe is a share
623
+ * of its box (--ok-font-size) and readouts are sized in em. Colors come from
624
+ * custom properties that inherit, so a theme can be set once on a parent:
625
+ * --ok-fill, --ok-track, --ok-accent, --ok-text, --ok-focus.
115
626
  */
116
627
  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 {
628
+ :where(${KIT_TAGS}) [hidden] { display: none !important; }
629
+ :where(${KIT_TAGS}) :where(.satellite) {
118
630
  border: none;
119
631
  background: transparent;
120
632
  }
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;
633
+ :where(${KIT_TAGS}) :where(.bigbang) > :where(.gravity-spot) {
634
+ font-size: var(--ok-font-size, 8cqmin);
635
+ color: var(--ok-text, inherit);
636
+ }
637
+ :where(${KIT_TAGS}) :where(.ok-hub) {
638
+ font-size: var(--ok-hub-size, 1.4em);
639
+ font-weight: 700;
126
640
  text-align: center;
127
641
  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%);
642
+ font-variant-numeric: tabular-nums;
643
+ color: var(--ok-hub-color, var(--ok-text, inherit));
644
+ background: var(--ok-hub-bg, none);
645
+ border-radius: 50%;
646
+ aspect-ratio: 1;
647
+ }
648
+ :where(${KIT_TAGS}):focus-visible { outline: none; }
649
+ :where(${KIT_TAGS}):focus-visible > :where(.bigbang),
650
+ :where(${KIT_TAGS}) :where(button, .capsule[tabindex]):focus-visible {
651
+ outline: 2px solid var(--ok-focus, #3da9fc);
652
+ outline-offset: 3px;
653
+ border-radius: 50%;
131
654
  }`;
132
655
 
133
- injectStyleOnce('orbit-kit-base', KIT_BASE_CSS);
656
+ injectStyleOnce('orbit-kit-base', KIT_BASE_CSS + THEME_CSS);