@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/README.md +110 -316
- package/package.json +20 -8
- package/src/base.js +606 -41
- package/src/color.js +139 -0
- package/src/o-clock.js +106 -129
- package/src/o-color-wheel.js +322 -0
- package/src/o-compass.js +45 -49
- package/src/o-donut.js +67 -79
- package/src/o-gauge.js +51 -117
- package/src/o-instrument.js +116 -105
- package/src/o-knob.js +137 -129
- package/src/o-menu.js +271 -183
- package/src/o-meter.js +35 -89
- package/src/o-radar-chart.js +391 -0
- package/src/o-radar.js +80 -123
- package/src/o-range.js +509 -0
- package/src/o-rings.js +66 -77
- package/src/o-spinner.js +214 -0
- package/src/o-status.js +209 -0
- package/src/o-sunburst.js +569 -0
- package/src/o-time-picker.js +447 -0
- package/src/o-timer.js +240 -0
- package/src/o-wheel.js +436 -0
- package/src/orbit-kit.js +23 -5
package/src/base.js
CHANGED
|
@@ -1,29 +1,219 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OrbitKitElement
|
|
2
|
+
* OrbitKitElement: the shared base for the orbit-kit components.
|
|
3
3
|
*
|
|
4
|
-
* Every
|
|
5
|
-
* (.bigbang > .gravity-spot > .orbit-N > o-arc|o-progress
|
|
6
|
-
*
|
|
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
|
|
11
|
+
* sync(cfg) -> patch values without a rebuild
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
-
*
|
|
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
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
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(
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
color: var(--ok-hub-color, inherit);
|
|
130
|
-
background:
|
|
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);
|