@zumer/orbit-kit 0.1.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/o-knob.js CHANGED
@@ -1,191 +1,199 @@
1
1
  /**
2
- * <o-knob> — interactive rotary control.
2
+ * <o-knob>: a rotary slider. Grab the band and turn it, drag anywhere else
3
+ * vertically, use the wheel while focused, or press arrows / Page Up·Down /
4
+ * Home·End. Emits `input` while changing and `change` when a gesture commits,
5
+ * like <input type="range">; setting `value` from code fires neither.
6
+ * It is a form control (FormControl): `name`, `disabled`, form reset/restore.
3
7
  *
4
- * Collapses the Orbit recipe:
5
- *
6
- * .bigbang > .gravity-spot
7
- * ├── .orbit-N > <o-arc shape="circle-a"> (the dial)
8
- * └── .orbit-0 > .satellite.at-center > .capsule (value readout)
9
- *
10
- * ...into one tag that behaves like a form control:
11
- *
12
- * <o-knob value="65" min="0" max="100" unit="%" fill="#a1a1aa"></o-knob>
13
- *
14
- * Interaction: drag vertically (or use the mouse wheel / arrow keys when
15
- * focused) to change the value. Emits a bubbling `input` event on every change
16
- * and `change` when the gesture ends. `el.value` reads/writes the number.
8
+ * The track is an `interactive` <o-arc> with a `hit-width`, so the whole band
9
+ * takes the pointer even where it is transparent.
17
10
  */
18
- import { OrbitKitElement, define } from './base.js';
11
+ import { OrbitKitElement, FormControl, define, injectStyleOnce, universe, hub, fixed, escapeHTML, finiteNumber, snapValue, pointerAngle, valueAtAngle, withShared, RANGE_ATTRS, FORMAT_ATTRS, FORM_ATTRS } from './base.js';
19
12
 
20
- export class OrbitKnob extends OrbitKitElement {
13
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
14
+
15
+ export class OrbitKnob extends FormControl(OrbitKitElement) {
21
16
  static get observedAttributes() {
22
- return ['value', 'min', 'max', 'step', 'size', 'orbit', 'fill', 'track',
23
- 'stroke-width', 'shape', 'unit', 'label', 'disabled', 'range', 'from', 'glow'];
17
+ return withShared(['value', 'size', 'orbit', 'thickness', 'fill', 'track',
18
+ 'stroke-width', 'shape', 'label', 'range', 'from', 'glow'], RANGE_ATTRS, FORMAT_ATTRS, FORM_ATTRS);
19
+ }
20
+
21
+ constructor() {
22
+ super();
23
+ for (const name of ['_onDown', '_onMove', '_onUp', '_onWheel', '_onKey']) {
24
+ this[name] = this[name].bind(this);
25
+ }
24
26
  }
25
27
 
26
28
  readConfig() {
27
- const range = this._num('range', 270);
28
- const min = this._num('min', 0);
29
- const max = this._num('max', 100);
29
+ const range = clamp(this._num('range', 270), 0, 360);
30
+ const r = this._range({ min: 0, max: 100, step: 1 });
30
31
  return {
31
- value: this._num('value', min),
32
- min, max,
33
- span: (max - min) || 1,
34
- step: this._num('step', 1),
32
+ min: r.min, max: r.max, span: r.span || 1, step: r.step, value: r.value, fraction: r.fraction,
35
33
  range,
36
34
  from: this.hasAttribute('from') ? this._num('from', 0) : this._autoFrom(range),
37
- orbit: Math.round(this._num('orbit', 4)),
38
- size: this._str('size', '160px'),
39
- fill: this._str('fill', '#a1a1aa'),
40
- track: this._str('track', '#27272a'),
41
- strokeWidth: this._num('stroke-width', 14),
42
- shape: this._str('shape', 'rounded'),
43
- unit: this._str('unit', ''),
44
- disabled: this.hasAttribute('disabled'),
45
- glow: this.getAttribute('glow'),
35
+ thickness: this._thickness(2.4),
36
+ size: this._size('size', '160px'),
37
+ fill: this._color('fill', null), track: this._color('track', null),
38
+ strokeWidth: Math.max(0, this._num('stroke-width', 0)),
39
+ shape: this._str('shape', 'rounded'), unit: this._unitText(),
40
+ disabled: this._isDisabled(), glow: this.getAttribute('glow'),
46
41
  label: this.hasAttribute('label') ? this.getAttribute('label') : null
47
42
  };
48
43
  }
49
44
 
45
+ /** Clamp and snap like the attribute (kept for 1.0 callers). */
46
+ _normalize(raw, cfg) { return snapValue(finiteNumber(raw, cfg.min), cfg); }
47
+
50
48
  structuralSignature(cfg) {
51
- const { value, label, disabled, ...rest } = cfg;
49
+ const { value, fraction, min, max, span, step, label, disabled, fill, track, unit, ...rest } = cfg;
52
50
  return JSON.stringify(rest);
53
51
  }
54
52
 
55
53
  build(cfg) {
56
- // Scale --o-force so the dial (orbit-N) fits the box with a margin.
57
- const dialPx = parseFloat(cfg.size) || 160;
58
- const force = (dialPx * 12 / (cfg.orbit + 1)).toFixed(1);
59
- const ring = `--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;`;
60
- const trackStyle = `--o-fill:${cfg.track};--o-stroke:${cfg.track};--o-stroke-width:${cfg.strokeWidth}`;
61
- const valStyle = `--o-fill:${cfg.fill};--o-stroke:${cfg.fill};--o-stroke-width:${cfg.strokeWidth};${this._glow(cfg.fill)}`;
62
-
63
- this.innerHTML = `
64
- <div class="bigbang" style="width:${cfg.size};aspect-ratio:1;${cfg.disabled ? 'opacity:.5;' : ''}">
65
- <div class="gravity-spot" style="--o-force:${force}px;">
66
- <div class="orbit-${cfg.orbit}" style="${ring}"><o-arc value="100" shape="${cfg.shape}" style="${trackStyle}"></o-arc></div>
67
- <div class="orbit-${cfg.orbit}" style="${ring}"><o-arc class="o-knob-dial" value="0" shape="${cfg.shape}" style="${valStyle}"></o-arc></div>
68
- <div class="orbit-0">
69
- <div class="satellite at-center">
70
- <div class="capsule ok-hub o-knob-readout"></div>
71
- </div>
72
- </div>
73
- </div>
74
- </div>`;
75
-
54
+ const e = escapeHTML;
55
+ const ring = `--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;--o-size-ratio:${fixed(cfg.thickness)};--o-gap:0`;
56
+ const stroke = cfg.strokeWidth ? `--o-stroke-width:${cfg.strokeWidth};` : '';
57
+ const glow = cfg.glow == null ? '' : `filter:drop-shadow(0 0 ${Math.max(0, this._num('glow', 6))}px var(--ok-fill, #a1a1aa));`;
58
+ this.innerHTML = universe(cfg.size, `
59
+ <div class="orbit-12" style="${ring}"><o-arc aria-hidden="true" class="inner-orbit ok-track o-knob-track" interactive hit-width="6" value="100" shape="${e(cfg.shape)}"${stroke ? ` style="${stroke}"` : ''}></o-arc></div>
60
+ <div class="orbit-12" style="${ring}"><o-arc aria-hidden="true" class="inner-orbit ok-value o-knob-dial" value="0" shape="${e(cfg.shape)}" ${stroke || glow ? ` style="${stroke}${glow}"` : ''}></o-arc></div>
61
+ ${hub(2 * (12 - cfg.thickness) - 1, '', 'ok-hub o-knob-readout', ' aria-hidden="true"')}`);
62
+ this._box = this.querySelector('.bigbang');
76
63
  this._dial = this.querySelector('.o-knob-dial');
77
64
  this._readout = this.querySelector('.o-knob-readout');
78
-
79
- // Accessibility: act as a slider.
80
65
  this.setAttribute('role', 'slider');
81
- this.tabIndex = cfg.disabled ? -1 : 0;
82
- this.style.touchAction = 'none';
83
- this.style.cursor = cfg.disabled ? 'default' : 'ns-resize';
84
66
  }
85
67
 
86
68
  sync(cfg) {
87
- const v = Math.min(cfg.max, Math.max(cfg.min, cfg.value));
88
- const pct = ((v - cfg.min) / cfg.span) * 100;
89
- if (this._dial) this._dial.setAttribute('value', pct.toFixed(2));
90
- if (this._readout) {
91
- this._readout.textContent = cfg.label !== null ? cfg.label : `${Math.round(v)}${cfg.unit}`;
69
+ this._applyTheme({ '--ok-fill': cfg.fill, '--ok-track': cfg.track });
70
+ if (this._dial) this._dial.setAttribute('value', (cfg.fraction * 100).toFixed(2));
71
+ const text = this._formatText(cfg.value, { min: cfg.min, max: cfg.max });
72
+ if (this._readout) this._readout.textContent = cfg.label ?? text;
73
+ this._ariaRange(cfg, cfg.value, this._formatText(cfg.value, { min: cfg.min, max: cfg.max, kind: 'aria' }));
74
+ if (!this.hasAttribute('aria-labelledby') && (!this.hasAttribute('aria-label') || this.getAttribute('aria-label') === this._autoLabel)) {
75
+ this._autoLabel = cfg.label || 'Rotary control';
76
+ this.setAttribute('aria-label', this._autoLabel);
92
77
  }
93
- this.setAttribute('aria-valuenow', String(v));
94
- this.setAttribute('aria-valuemin', String(cfg.min));
95
- this.setAttribute('aria-valuemax', String(cfg.max));
78
+ this.tabIndex = cfg.disabled ? -1 : 0;
79
+ this.style.touchAction = cfg.disabled ? 'auto' : 'none';
80
+ this.style.cursor = cfg.disabled ? 'default' : 'ns-resize';
81
+ this.toggleAttribute?.('data-disabled', cfg.disabled);
82
+ if (this._box) this._box.style.opacity = cfg.disabled ? '.5' : '';
83
+ if (cfg.disabled) this._stopDrag();
96
84
  }
97
85
 
98
- /** Numeric value as a property (framework-friendly). */
99
- get value() { return this._num('value', this._num('min', 0)); }
100
- set value(v) { this._commit(v); }
86
+ get value() { return this.readConfig().value; }
87
+ /** Setting the value snaps and clamps it and fires no event, like a native input. */
88
+ set value(value) { this._commit(finiteNumber(value, this.value), { silent: true }); }
89
+ get valueAsNumber() { return this.value; }
90
+ get min() { return this.readConfig().min; }
91
+ set min(value) { this.setAttribute('min', String(value)); }
92
+ get max() { return this.readConfig().max; }
93
+ set max(value) { this.setAttribute('max', String(value)); }
94
+ get step() { return this.readConfig().step; }
95
+ set step(value) { this.setAttribute('step', String(value)); }
96
+ stepUp(n = 1) { const cfg = this.readConfig(); this._commit(cfg.value + n * (cfg.step || cfg.span / 100), { silent: true }); }
97
+ stepDown(n = 1) { this.stepUp(-n); }
101
98
 
102
99
  onConnected() {
103
- this._onDown = this._onDown.bind(this);
104
- this._onMove = this._onMove.bind(this);
105
- this._onUp = this._onUp.bind(this);
106
- this._onWheel = this._onWheel.bind(this);
107
- this._onKey = this._onKey.bind(this);
108
100
  this.addEventListener('pointerdown', this._onDown);
101
+ this.addEventListener('lostpointercapture', this._onUp);
109
102
  this.addEventListener('wheel', this._onWheel, { passive: false });
110
103
  this.addEventListener('keydown', this._onKey);
111
104
  }
112
105
 
113
106
  onDisconnected() {
114
107
  this.removeEventListener('pointerdown', this._onDown);
108
+ this.removeEventListener('lostpointercapture', this._onUp);
115
109
  this.removeEventListener('wheel', this._onWheel);
116
110
  this.removeEventListener('keydown', this._onKey);
117
- window.removeEventListener('pointermove', this._onMove);
118
- window.removeEventListener('pointerup', this._onUp);
111
+ this._stopDrag();
119
112
  }
120
113
 
121
- _cfg() { return this.readConfig(); }
122
-
123
- _commit(raw, emitChange = false) {
124
- const cfg = this._cfg();
125
- if (cfg.disabled) return;
126
- let v = Math.min(cfg.max, Math.max(cfg.min, raw));
127
- if (cfg.step > 0) v = Math.round(v / cfg.step) * cfg.step;
128
- v = Number(v.toFixed(6));
129
- const prev = this._num('value', cfg.min);
130
- if (v !== prev) {
131
- this.setAttribute('value', String(v)); // triggers re-render via attributeChangedCallback
132
- this.dispatchEvent(new CustomEvent('input', { detail: { value: v }, bubbles: true }));
133
- }
134
- if (emitChange) {
135
- this.dispatchEvent(new CustomEvent('change', { detail: { value: v }, bubbles: true }));
114
+ _emit(type, value) { this._emitValue(type, value); }
115
+
116
+ /** Set a value; user gestures fire `input` (and `change` when they commit). */
117
+ _commit(raw, { change = false, silent = false } = {}) {
118
+ const cfg = this.readConfig();
119
+ const value = this._normalize(raw, cfg);
120
+ const changed = value !== cfg.value;
121
+ // The attribute shows the current value; the default (for form reset) stays.
122
+ this._reflectValue(String(value));
123
+ if (changed && !silent) {
124
+ this._emit('input', value);
125
+ if (change) this._emit('change', value);
136
126
  }
137
127
  }
138
128
 
139
- _onDown(e) {
140
- const cfg = this._cfg();
141
- if (cfg.disabled) return;
142
- e.preventDefault();
129
+ _onDown(event) {
130
+ if (this._isDisabled() || event.button !== 0 || this._pointerId != null) return;
131
+ event.preventDefault();
143
132
  this.focus();
144
- this._dragStartY = e.clientY;
145
- this._dragStartVal = this._num('value', cfg.min);
133
+ this._pointerId = event.pointerId;
134
+ this._dragStartY = event.clientY;
135
+ this._dragStartVal = this.value;
136
+ // On the band the knob follows the pointer around; elsewhere it is a vertical drag.
137
+ this._rotary = !!event.target?.closest?.('.o-knob-track');
138
+ if (this._rotary) this._commit(this._valueAt(event));
139
+ this.setPointerCapture?.(event.pointerId);
146
140
  window.addEventListener('pointermove', this._onMove);
147
141
  window.addEventListener('pointerup', this._onUp);
142
+ window.addEventListener('pointercancel', this._onUp);
148
143
  }
149
144
 
150
- _onMove(e) {
151
- const cfg = this._cfg();
152
- // Full sweep ~= 200px of vertical travel; up increases.
153
- const dy = this._dragStartY - e.clientY;
154
- const delta = (dy / 200) * cfg.span;
155
- this._commit(this._dragStartVal + delta);
145
+ _onMove(event) {
146
+ if (event.pointerId !== this._pointerId || this._isDisabled()) return;
147
+ if (this._rotary) this._commit(this._valueAt(event));
148
+ else this._commit(this._dragStartVal + (this._dragStartY - event.clientY) / 200 * this.readConfig().span);
156
149
  }
157
150
 
158
- _onUp() {
151
+ /** The value under the pointer, from its angle around the center. */
152
+ _valueAt(event) {
153
+ const cfg = this.readConfig();
154
+ if (!this._box?.getBoundingClientRect || !cfg.range) return cfg.value;
155
+ return valueAtAngle(pointerAngle(event, this._box), cfg);
156
+ }
157
+
158
+ _stopDrag() {
159
+ if (typeof window === 'undefined') return;
160
+ if (this._pointerId != null && this.hasPointerCapture?.(this._pointerId)) this.releasePointerCapture(this._pointerId);
161
+ this._pointerId = null;
159
162
  window.removeEventListener('pointermove', this._onMove);
160
163
  window.removeEventListener('pointerup', this._onUp);
161
- this._commit(this._num('value', this._cfg().min), true);
164
+ window.removeEventListener('pointercancel', this._onUp);
162
165
  }
163
166
 
164
- _onWheel(e) {
165
- const cfg = this._cfg();
166
- if (cfg.disabled) return;
167
- e.preventDefault();
168
- const dir = e.deltaY < 0 ? 1 : -1;
169
- this._commit(this._num('value', cfg.min) + dir * (cfg.step || 1), true);
167
+ _onUp(event) {
168
+ if (event.pointerId !== this._pointerId) return;
169
+ const changed = this.value !== this._dragStartVal;
170
+ this._stopDrag();
171
+ if (changed && !this._isDisabled()) this._emit('change', this.value);
170
172
  }
171
173
 
172
- _onKey(e) {
173
- const cfg = this._cfg();
174
- if (cfg.disabled) return;
175
- const step = cfg.step || 1;
176
- const map = {
177
- ArrowUp: step, ArrowRight: step, ArrowDown: -step, ArrowLeft: -step,
178
- PageUp: step * 10, PageDown: -step * 10
179
- };
180
- if (e.key in map) {
181
- e.preventDefault();
182
- this._commit(this._num('value', cfg.min) + map[e.key], true);
183
- } else if (e.key === 'Home') {
184
- e.preventDefault(); this._commit(cfg.min, true);
185
- } else if (e.key === 'End') {
186
- e.preventDefault(); this._commit(cfg.max, true);
187
- }
174
+ _onWheel(event) {
175
+ if (this._isDisabled() || this.ownerDocument?.activeElement !== this || !event.deltaY) return;
176
+ event.preventDefault();
177
+ const cfg = this.readConfig();
178
+ this._commit(cfg.value + (event.deltaY < 0 ? 1 : -1) * (cfg.step || cfg.span / 100), { change: true });
179
+ }
180
+
181
+ _onKey(event) {
182
+ if (this._isDisabled()) return;
183
+ const cfg = this.readConfig();
184
+ const next = this._keyValue(event, cfg);
185
+ if (next == null) return;
186
+ event.preventDefault();
187
+ this._commit(next, { change: true });
188
188
  }
189
189
  }
190
190
 
191
+ injectStyleOnce('orbit-kit-knob', `
192
+ o-knob { -webkit-tap-highlight-color: transparent; }
193
+ o-knob .ok-track { --o-fill: var(--ok-track, #27272a); --o-stroke: var(--o-fill); }
194
+ o-knob .ok-value { --o-fill: var(--ok-fill, #a1a1aa); --o-stroke: var(--o-fill); }
195
+ o-knob .o-knob-track { cursor: grab; }
196
+ o-knob[data-disabled] .o-knob-track { cursor: default; }
197
+ o-knob[data-disabled] > .bigbang { opacity: .5; }
198
+ `);
191
199
  define('o-knob', OrbitKnob);