@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/o-menu.js
CHANGED
|
@@ -1,280 +1,368 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* <o-menu
|
|
2
|
+
* <o-menu>: a radial menu with drill-down or concentric branches.
|
|
3
|
+
* Items have a label, an optional emoji or sanitized inline SVG icon, a value
|
|
4
|
+
* and children. Events: select { value, label, item, path } and
|
|
5
|
+
* navigate { path, depth }; both bubble. Picking an item also fires `input`
|
|
6
|
+
* and `change`, like a <select>. It is a form control (FormControl): with a
|
|
7
|
+
* `name` it submits the picked value; `value` picks one from markup.
|
|
3
8
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* expand="drill" (default) — the ring REPLACES itself with the
|
|
9
|
-
* sub-level; the center becomes a "back" button.
|
|
10
|
-
* expand="concentric" — the parent ring STAYS and the children fan out in an
|
|
11
|
-
* outer arc aligned to the chosen sector's wedge
|
|
12
|
-
* (the radial-editor look). Click an open branch to
|
|
13
|
-
* collapse it; the center resets to the top.
|
|
14
|
-
*
|
|
15
|
-
* Items may carry an `icon` (emoji or raw SVG string), placed dead-center on
|
|
16
|
-
* the sector via `--o-angle-composite` and kept upright with `spin-lock`.
|
|
17
|
-
*
|
|
18
|
-
* Events (bubbling):
|
|
19
|
-
* - `select` detail { value, label, item, path:[indices] }
|
|
20
|
-
* - `navigate` detail { path:[indices], depth }
|
|
21
|
-
*
|
|
22
|
-
* `el.path` reads the open path; `el.back()` / `el.reset()` navigate.
|
|
9
|
+
* Sectors are `interactive` <o-arc>s with the label drawn along the arc. The
|
|
10
|
+
* outer ring touches the edge of the universe; in concentric mode the open
|
|
11
|
+
* levels stack inward from it.
|
|
23
12
|
*/
|
|
24
|
-
import { OrbitKitElement, define, injectStyleOnce } from './base.js';
|
|
13
|
+
import { OrbitKitElement, FormControl, define, injectStyleOnce, escapeHTML, safeIcon, cssValue, fixed, withShared, FORM_ATTRS } from './base.js';
|
|
25
14
|
|
|
26
15
|
const MENU_CSS = `
|
|
27
|
-
o-menu { --
|
|
28
|
-
o-menu o-arc.o-menu-item { cursor: pointer; transition: filter .18s ease; }
|
|
16
|
+
o-menu { --ok-font-size: 5cqmin; }
|
|
17
|
+
o-menu o-arc.o-menu-item { cursor: pointer; outline: none; transition: filter .18s ease; --o-color: var(--ok-menu-text, #f8fafc); }
|
|
29
18
|
o-menu o-arc.o-menu-item:hover { filter: brightness(1.25); }
|
|
30
|
-
o-menu o-arc.o-menu-item.is-active { filter: brightness(1.4) drop-shadow(0 0 6px currentColor); }
|
|
19
|
+
o-menu o-arc.o-menu-item:is(.is-active,.is-selected) { filter: brightness(1.4) drop-shadow(0 0 6px currentColor); }
|
|
20
|
+
o-menu o-arc.o-menu-item:focus-visible { filter: brightness(1.5) drop-shadow(0 0 4px var(--ok-menu-accent, var(--ok-accent, #3da9fc))); }
|
|
31
21
|
o-menu .o-menu-icon { pointer-events: none; }
|
|
32
|
-
o-menu .o-menu-icon .capsule { display: flex; align-items: center; justify-content: center; }
|
|
33
|
-
o-menu .
|
|
34
|
-
|
|
22
|
+
o-menu .o-menu-icon .capsule { display: flex; align-items: center; justify-content: center; font-size: 1.3em; }
|
|
23
|
+
o-menu .o-menu-icon svg { width: 1.2em; height: 1.2em; }
|
|
24
|
+
o-menu .ok-menu-ring { animation: ok-menu-in .22s cubic-bezier(.2,.7,.3,1); }
|
|
25
|
+
@keyframes ok-menu-in { from { opacity: .25; scale: .92; } to { opacity: 1; scale: 1; } }
|
|
35
26
|
o-menu .o-menu-hub {
|
|
36
|
-
|
|
37
|
-
background: var(--o-menu-hub);
|
|
38
|
-
|
|
39
|
-
padding:
|
|
40
|
-
font-weight:
|
|
27
|
+
-webkit-backdrop-filter: var(--ok-backdrop, none); backdrop-filter: var(--ok-backdrop, none);
|
|
28
|
+
border-radius: 50%; aspect-ratio: 1; background: var(--ok-menu-hub, var(--o-menu-hub, var(--ok-surface, rgba(20,22,30,.92))));
|
|
29
|
+
color: var(--ok-menu-text, #f8fafc);
|
|
30
|
+
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); padding: 12%;
|
|
31
|
+
font-weight: 700; text-align: center; line-height: 1.12; overflow-wrap: anywhere;
|
|
41
32
|
display: flex; align-items: center; justify-content: center;
|
|
42
33
|
transition: color .18s ease, background .18s ease;
|
|
43
34
|
}
|
|
44
35
|
o-menu .o-menu-hub.is-back { cursor: pointer; }
|
|
45
|
-
o-menu .o-menu-hub.is-back:hover { background: var(--o-menu-hub-hover, rgba(36,40,54,.96)); }
|
|
46
|
-
|
|
47
|
-
o-menu.o-menu--mono o-arc.o-menu-item { --o-fill: var(--ok-menu-slate, #2b333f); --o-stroke: var(--
|
|
48
|
-
o-menu.o-menu--mono o-arc.o-menu-item:hover { --o-fill: var(--ok-menu-accent,
|
|
49
|
-
o-menu
|
|
36
|
+
o-menu .o-menu-hub.is-back:hover { background: var(--ok-menu-hub-hover, var(--o-menu-hub-hover, rgba(36,40,54,.96))); }
|
|
37
|
+
o-menu .o-menu-hub:focus-visible { outline: 2px solid var(--ok-menu-accent, var(--ok-accent, #3da9fc)); outline-offset: 2px; }
|
|
38
|
+
o-menu.o-menu--mono o-arc.o-menu-item { --o-fill: var(--ok-menu-slate, #2b333f); --o-stroke: var(--o-fill); }
|
|
39
|
+
o-menu.o-menu--mono o-arc.o-menu-item:is(:hover,:focus-visible,.is-active,.is-selected) { --o-fill: var(--ok-menu-accent, var(--ok-accent, #3da9fc)); filter: none; }
|
|
40
|
+
o-menu[data-disabled] > .bigbang { opacity: .5; }
|
|
41
|
+
o-menu[data-disabled] :is(o-arc.o-menu-item, .o-menu-hub) { cursor: default; pointer-events: none; }
|
|
42
|
+
@media (prefers-reduced-motion: reduce) { o-menu .ok-menu-ring { animation: none; } o-menu o-arc.o-menu-item { transition: none; } }
|
|
50
43
|
`;
|
|
51
|
-
|
|
52
|
-
// Refined, slightly desaturated palette (not the saturated rainbow).
|
|
53
44
|
const PALETTE = ['#6b72c9', '#3ea58f', '#cf9a52', '#cf6b6b', '#5e9fd0', '#9b80c9', '#c77ba2', '#85ab5c'];
|
|
45
|
+
const equalPath = (a, b) => !!a && !!b && a.length === b.length && a.every((value, i) => value === b[i]);
|
|
46
|
+
const sameValue = (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b) || (typeof b === 'string' && (String(a) === b || JSON.stringify(a) === b));
|
|
54
47
|
|
|
55
|
-
export class OrbitMenu extends OrbitKitElement {
|
|
48
|
+
export class OrbitMenu extends FormControl(OrbitKitElement) {
|
|
56
49
|
static get observedAttributes() {
|
|
57
|
-
return ['items', 'size', 'orbit', '
|
|
50
|
+
return withShared(['items', 'size', 'orbit', 'thickness', 'range', 'from', 'gap', 'label', 'label-color', 'expand', 'theme', 'palette', 'accent', 'value'], FORM_ATTRS);
|
|
58
51
|
}
|
|
59
52
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return (
|
|
63
|
-
label:
|
|
64
|
-
value:
|
|
65
|
-
color:
|
|
66
|
-
icon:
|
|
67
|
-
children:
|
|
53
|
+
_norm(items, depth = 0) {
|
|
54
|
+
if (!Array.isArray(items) || depth > 32) return [];
|
|
55
|
+
return items.filter(item => item && typeof item === 'object' && !Array.isArray(item)).map((item, i) => ({
|
|
56
|
+
label: item.label != null ? String(item.label) : `Item ${i + 1}`,
|
|
57
|
+
value: item.value !== undefined ? item.value : (item.label ?? i),
|
|
58
|
+
color: cssValue(item.color, null),
|
|
59
|
+
icon: item.icon == null ? null : String(item.icon),
|
|
60
|
+
children: Array.isArray(item.children) ? this._norm(item.children, depth + 1) : null
|
|
68
61
|
}));
|
|
69
62
|
}
|
|
70
63
|
|
|
71
64
|
readConfig() {
|
|
72
|
-
const range = this._num('range', 360);
|
|
65
|
+
const range = Math.min(360, Math.max(0, this._num('range', 360)));
|
|
73
66
|
return {
|
|
74
|
-
items: this._norm(this._json('items', [])
|
|
75
|
-
range,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
accent: this._str('accent', '#3da9fc'),
|
|
67
|
+
items: this._norm(this._json('items', [])), range,
|
|
68
|
+
from: this.hasAttribute('from') ? this._num('from', 0) : (range === 360 ? 0 : this._autoFrom(range)),
|
|
69
|
+
thickness: this._thickness(3.6, 6),
|
|
70
|
+
size: this._size('size', '280px'), gap: this._gap(1.2),
|
|
71
|
+
labelColor: this._color('label-color', null),
|
|
72
|
+
// Sector colors: palette="color" (1.0: theme="color"); theme also takes dark, light, glass.
|
|
73
|
+
theme: (this.getAttribute('palette') ?? (this._str('theme', '').split(/\s+/).includes('color') ? 'color' : 'mono')) === 'color' ? 'color' : 'mono',
|
|
74
|
+
disabled: this._isDisabled(),
|
|
75
|
+
accent: this._color('accent', null),
|
|
84
76
|
expand: this._str('expand', 'drill') === 'concentric' ? 'concentric' : 'drill',
|
|
85
|
-
label: this.
|
|
77
|
+
label: this._str('label', 'MENU')
|
|
86
78
|
};
|
|
87
79
|
}
|
|
88
80
|
|
|
89
|
-
structuralSignature(cfg) {
|
|
90
|
-
|
|
81
|
+
structuralSignature(cfg) { return JSON.stringify(cfg); }
|
|
82
|
+
|
|
83
|
+
/** The `value` attribute picks an item; it is also what a form reset returns to. */
|
|
84
|
+
sync() {
|
|
85
|
+
const attr = this.getAttribute('value');
|
|
86
|
+
if (attr === this._valueAttr) return;
|
|
87
|
+
this._valueAttr = attr;
|
|
88
|
+
if (attr == null) this._clearSelection();
|
|
89
|
+
else this._selectValue(attr);
|
|
91
90
|
}
|
|
92
91
|
|
|
92
|
+
_findValue(value, items = this._cfg?.items || [], path = []) {
|
|
93
|
+
for (const [index, item] of items.entries()) {
|
|
94
|
+
if (item.children?.length) {
|
|
95
|
+
const found = this._findValue(value, item.children, [...path, index]);
|
|
96
|
+
if (found) return found;
|
|
97
|
+
} else if (sameValue(item.value, value)) return { item, path: [...path, index] };
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_selectValue(value) {
|
|
103
|
+
const found = this._findValue(value);
|
|
104
|
+
if (!found) { this._clearSelection(); return; }
|
|
105
|
+
this._value = found.item.value;
|
|
106
|
+
this._selectedLabel = found.item.label;
|
|
107
|
+
this._selectedPath = found.path;
|
|
108
|
+
if (this._cfg) this.build(this._cfg);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_clearSelection() {
|
|
112
|
+
const had = this._selectedPath != null;
|
|
113
|
+
this._selectedPath = null;
|
|
114
|
+
this._value = undefined;
|
|
115
|
+
this._selectedLabel = undefined;
|
|
116
|
+
if (had && this._cfg) this.build(this._cfg);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_onFormReset() { this._valueAttr = undefined; }
|
|
120
|
+
_formValidity() {
|
|
121
|
+
return this.required && this._value === undefined ? { flags: { valueMissing: true }, message: 'Select an item.' } : {};
|
|
122
|
+
}
|
|
123
|
+
_formAnchor() { return this._arcs?.[0]; }
|
|
124
|
+
|
|
93
125
|
build(cfg) {
|
|
94
126
|
injectStyleOnce('orbit-kit-menu', MENU_CSS);
|
|
127
|
+
const active = this.ownerDocument?.activeElement;
|
|
128
|
+
const restoreFocus = active === this || (active && this.contains?.(active));
|
|
129
|
+
const focusKey = this._nextFocus || this._focusKey;
|
|
130
|
+
this._nextFocus = null;
|
|
95
131
|
this._cfg = cfg;
|
|
132
|
+
this._path ||= [];
|
|
133
|
+
this._validateSelection();
|
|
96
134
|
this.classList.toggle('o-menu--mono', cfg.theme === 'mono');
|
|
97
|
-
this.
|
|
98
|
-
|
|
135
|
+
this._applyTheme({ '--ok-menu-accent': cfg.accent, '--ok-menu-text': cfg.labelColor });
|
|
136
|
+
this.setAttribute('role', 'menu');
|
|
137
|
+
if (!this.hasAttribute('aria-labelledby') && (!this.hasAttribute('aria-label') || this.getAttribute('aria-label') === this._autoLabel)) {
|
|
138
|
+
this._autoLabel = cfg.label || 'Radial menu';
|
|
139
|
+
this.setAttribute('aria-label', this._autoLabel);
|
|
140
|
+
}
|
|
99
141
|
if (cfg.expand === 'concentric') this._buildConcentric(cfg);
|
|
100
142
|
else this._buildDrill(cfg);
|
|
101
143
|
this._wireKeys();
|
|
144
|
+
this._options = [...this._arcs, ...(this._path.length && this._hub ? [this._hub] : [])];
|
|
145
|
+
const target = cfg.disabled ? null : this._options.find(option => this._keyFor(option) === focusKey) || this._options[0];
|
|
146
|
+
this.tabIndex = target || cfg.disabled ? -1 : 0;
|
|
147
|
+
if (cfg.disabled) for (const option of this._options) option.tabIndex = -1;
|
|
148
|
+
this._setFocus(target, restoreFocus);
|
|
149
|
+
this._setHub();
|
|
150
|
+
const pathKey = JSON.stringify(this._path);
|
|
151
|
+
const pathChanged = this._announcedPath !== undefined && this._announcedPath !== pathKey;
|
|
152
|
+
this._announcedPath = pathKey;
|
|
153
|
+
if (pathChanged) {
|
|
154
|
+
this.dispatchEvent(new CustomEvent('navigate', { detail: { path: this.path, depth: this._path.length }, bubbles: true, composed: true }));
|
|
155
|
+
}
|
|
102
156
|
}
|
|
103
157
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
158
|
+
_validateSelection() {
|
|
159
|
+
if (!this._selectedPath) return;
|
|
160
|
+
let items = this._cfg.items, item;
|
|
161
|
+
for (const index of this._selectedPath) {
|
|
162
|
+
item = items[index];
|
|
163
|
+
if (!item) break;
|
|
164
|
+
items = item.children || [];
|
|
165
|
+
}
|
|
166
|
+
if (!item || item.children?.length || JSON.stringify(item.value) !== JSON.stringify(this._value)) {
|
|
167
|
+
this._selectedPath = null;
|
|
168
|
+
this._value = undefined;
|
|
169
|
+
this._selectedLabel = undefined;
|
|
170
|
+
} else this._selectedLabel = item.label;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_ring(items, from, range, orbit, thickness, level, openIndex, cfg) {
|
|
174
|
+
const e = escapeHTML, slice = range / (items.length || 1);
|
|
175
|
+
const arcs = items.map((item, index) => {
|
|
176
|
+
const color = item.color || PALETTE[index % PALETTE.length];
|
|
177
|
+
const branch = !!item.children?.length;
|
|
178
|
+
const selected = !branch && equalPath(this._selectedPath, [...this._path.slice(0, level), index]);
|
|
179
|
+
const text = item.icon ? '' : item.label + (branch ? ' ›' : '');
|
|
180
|
+
const cls = `inner-orbit o-menu-item${branch ? ' is-branch' : ''}${openIndex === index ? ' is-active' : ''}${selected ? ' is-selected' : ''}`;
|
|
181
|
+
const style = cfg.theme === 'mono' ? '' : `--o-fill:${color};--o-stroke:${color};`;
|
|
182
|
+
const phi = ((from + (index + .5) * slice) % 360 + 360) % 360;
|
|
183
|
+
return `<o-arc class="${cls}" data-level="${level}" data-index="${index}" role="menuitem" interactive tabindex="-1"${cfg.disabled ? ' aria-disabled="true"' : ''} aria-label="${e(item.label)}"${branch ? ` aria-haspopup="menu" aria-expanded="${openIndex === index}"` : ''}${selected ? ' aria-current="true"' : ''} shape="rounded"${phi > 90 && phi < 270 ? ' flip' : ''}${style ? ` style="${e(style)}"` : ''}>${e(text)}</o-arc>`;
|
|
122
184
|
}).join('');
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
? `<div class="
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
185
|
+
// Icons ride the middle of their sector: a satellite pointed at the sector
|
|
186
|
+
// center and moved in by half the band (inner-orbit); its capsule stays upright.
|
|
187
|
+
const icons = items.map((item, index) => item.icon
|
|
188
|
+
? `<div class="satellite inner-orbit o-menu-icon" aria-hidden="true" style="--o-angle-composite:${fixed((index + .5) * slice - 90, 2)}deg"><div class="capsule">${safeIcon(item.icon)}</div></div>` : '').join('');
|
|
189
|
+
const ring = `--o-range:${fixed(range)}deg;--o-from:${fixed(from)}deg;--o-size-ratio:${fixed(thickness)};--o-gap:${fixed(cfg.gap * 6)};font-weight:700`;
|
|
190
|
+
return `<div class="orbit-${orbit} ok-menu-ring" style="${ring}">${arcs}</div>` +
|
|
191
|
+
(icons ? `<div class="orbit-${orbit}" style="--o-from:${fixed(from)}deg;--o-size-ratio:${fixed(thickness)}">${icons}</div>` : '');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
_markup(rings, hubText, hubLanes) {
|
|
195
|
+
const cfg = this._cfg, open = this._path.length > 0;
|
|
196
|
+
const hubLabel = cfg.expand === 'concentric' ? 'Reset menu' : 'Back to previous menu';
|
|
197
|
+
return `<div class="bigbang" style="width:${escapeHTML(cfg.size)}">
|
|
198
|
+
<div class="gravity-spot">
|
|
199
|
+
${rings}<div class="orbit-0" style="--o-size-ratio:${fixed(hubLanes)}"><div class="satellite at-center"><div class="capsule o-menu-hub${open ? ' is-back' : ''}"${open ? ` role="menuitem" tabindex="-1" aria-label="${hubLabel}"` : ' aria-hidden="true"'}>${escapeHTML(hubText)}</div></div></div>
|
|
200
|
+
</div></div>`;
|
|
135
201
|
}
|
|
136
202
|
|
|
137
203
|
_buildConcentric(cfg) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (
|
|
143
|
-
const branch = items[openIndex];
|
|
144
|
-
if (!branch || !branch.children || !branch.children.length) { this._path = this._path.slice(0, d); break; }
|
|
145
|
-
const sliceW = range / (items.length || 1);
|
|
146
|
-
from = from + openIndex * sliceW;
|
|
147
|
-
range = sliceW;
|
|
204
|
+
// Resolve the open path first, so the layout knows how many levels show.
|
|
205
|
+
let items = cfg.items;
|
|
206
|
+
for (let depth = 0; depth < this._path.length; depth++) {
|
|
207
|
+
const branch = items[this._path[depth]];
|
|
208
|
+
if (!branch?.children?.length) { this._path = this._path.slice(0, depth); break; }
|
|
148
209
|
items = branch.children;
|
|
149
|
-
orbit += 2;
|
|
150
210
|
}
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
211
|
+
const levels = this._path.length + 1;
|
|
212
|
+
// One level uses the whole band; open levels stack inward from the edge.
|
|
213
|
+
const spacing = levels === 1 ? cfg.thickness : Math.max(2, Math.min(3, Math.floor(9 / levels)));
|
|
214
|
+
const thickness = levels === 1 ? cfg.thickness : spacing * .94;
|
|
215
|
+
let rings = '', from = cfg.from, range = cfg.range;
|
|
216
|
+
items = cfg.items;
|
|
217
|
+
for (let depth = 0; depth < levels; depth++) {
|
|
218
|
+
const openIndex = this._path[depth];
|
|
219
|
+
const orbit = 12 - Math.round((levels - 1 - depth) * spacing);
|
|
220
|
+
rings += this._ring(items, from, range, orbit, thickness, depth, openIndex, cfg);
|
|
221
|
+
if (openIndex == null) break;
|
|
222
|
+
const slice = range / items.length;
|
|
223
|
+
from += openIndex * slice;
|
|
224
|
+
range = slice;
|
|
225
|
+
items = items[openIndex].children;
|
|
226
|
+
}
|
|
227
|
+
const hole = 12 - (levels === 1 ? cfg.thickness : Math.round((levels - 1) * spacing) + thickness);
|
|
228
|
+
this.innerHTML = this._markup(rings, cfg.label, Math.max(3, 2 * hole * .8));
|
|
229
|
+
this._bindArcs();
|
|
163
230
|
}
|
|
164
231
|
|
|
165
232
|
_buildDrill(cfg) {
|
|
166
233
|
const { level, trail } = this._resolveLevel(cfg.items);
|
|
167
|
-
this.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
<div class="gravity-spot">
|
|
174
|
-
${ring}
|
|
175
|
-
<div class="orbit-0"><div class="satellite at-center"><div class="capsule o-menu-hub${atRoot ? '' : ' is-back'}">${hubText}</div></div></div>
|
|
176
|
-
</div>
|
|
177
|
-
</div>`;
|
|
234
|
+
const hubText = this._path.length ? `‹ ${trail[trail.length - 1].label}` : cfg.label;
|
|
235
|
+
this.innerHTML = this._markup(this._ring(level, cfg.from, cfg.range, 12, cfg.thickness, this._path.length, null, cfg), hubText, 2 * (12 - cfg.thickness) * .8);
|
|
236
|
+
this._bindArcs();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
_bindArcs() {
|
|
178
240
|
this._hub = this.querySelector('.o-menu-hub');
|
|
179
241
|
this._arcs = [...this.querySelectorAll('.o-menu-item')];
|
|
180
|
-
|
|
181
|
-
|
|
242
|
+
for (const arc of this._arcs) {
|
|
243
|
+
const level = Number(arc.dataset.level), index = Number(arc.dataset.index);
|
|
244
|
+
arc.addEventListener('click', () => { arc.focus(); this._activateAt(level, index); });
|
|
245
|
+
arc.addEventListener('focus', () => { this._setFocus(arc); this._setHub(this._itemsAtLevel(level)[index]?.label); });
|
|
246
|
+
arc.addEventListener('blur', () => this._setHub());
|
|
247
|
+
arc.addEventListener('pointerenter', () => this._setHub(this._itemsAtLevel(level)[index]?.label));
|
|
248
|
+
arc.addEventListener('pointerleave', () => this._setHub());
|
|
249
|
+
}
|
|
250
|
+
if (this._hub && this._path.length) {
|
|
251
|
+
this._hub.addEventListener('focus', () => this._setFocus(this._hub));
|
|
252
|
+
this._hub.addEventListener('click', () => { this._hub.focus(); this._cfg.expand === 'concentric' ? this.reset() : this.back(); });
|
|
253
|
+
}
|
|
182
254
|
}
|
|
183
255
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
});
|
|
256
|
+
_keyFor(option) { return option === this._hub ? 'hub' : `${option?.dataset.level}:${option?.dataset.index}`; }
|
|
257
|
+
_setFocus(option, move = false) {
|
|
258
|
+
if (!option) return;
|
|
259
|
+
this._focusKey = this._keyFor(option);
|
|
260
|
+
for (const candidate of this._options || []) candidate.tabIndex = candidate === option ? 0 : -1;
|
|
261
|
+
if (move) option.focus();
|
|
191
262
|
}
|
|
192
263
|
|
|
193
264
|
_wireKeys() {
|
|
194
|
-
this.tabIndex = 0;
|
|
195
265
|
if (this._keyBound) return;
|
|
196
266
|
this._keyBound = true;
|
|
197
|
-
this.addEventListener('keydown',
|
|
198
|
-
if (
|
|
199
|
-
|
|
200
|
-
|
|
267
|
+
this.addEventListener('keydown', event => {
|
|
268
|
+
if (this._isDisabled()) return;
|
|
269
|
+
const keys = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
|
270
|
+
const options = this._options || [];
|
|
271
|
+
const index = Math.max(0, options.indexOf(this.ownerDocument?.activeElement));
|
|
272
|
+
if ((Object.hasOwn(keys, event.key) || event.key === 'Home' || event.key === 'End') && options.length) {
|
|
273
|
+
event.preventDefault();
|
|
274
|
+
const next = event.key === 'Home' ? 0 : event.key === 'End' ? options.length - 1 : (index + keys[event.key] + options.length) % options.length;
|
|
275
|
+
this._setFocus(options[next], true);
|
|
276
|
+
} else if ((event.key === 'Enter' || event.key === ' ') && options.length) {
|
|
277
|
+
event.preventDefault();
|
|
278
|
+
options[index].click();
|
|
279
|
+
} else if ((event.key === 'Escape' || event.key === 'Backspace') && this._path.length) {
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
this.back();
|
|
201
282
|
}
|
|
202
283
|
});
|
|
203
284
|
}
|
|
204
285
|
|
|
205
|
-
/** Items array at a given depth, walking the open path. */
|
|
206
286
|
_itemsAtLevel(level) {
|
|
207
287
|
let items = this._cfg.items;
|
|
208
|
-
for (let
|
|
209
|
-
const node = items[this._path[
|
|
210
|
-
if (!node
|
|
288
|
+
for (let depth = 0; depth < level; depth++) {
|
|
289
|
+
const node = items[this._path[depth]];
|
|
290
|
+
if (!node?.children) return [];
|
|
211
291
|
items = node.children;
|
|
212
292
|
}
|
|
213
293
|
return items;
|
|
214
294
|
}
|
|
215
295
|
|
|
216
|
-
/** Drill-mode: resolve items at the current path; reset if invalid. */
|
|
217
296
|
_resolveLevel(root) {
|
|
218
|
-
let level = root;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
297
|
+
let level = root;
|
|
298
|
+
const trail = [];
|
|
299
|
+
for (const index of this._path) {
|
|
300
|
+
const node = level[index];
|
|
301
|
+
if (!node?.children?.length) { this._path = []; return { level: root, trail: [] }; }
|
|
302
|
+
trail.push(node);
|
|
303
|
+
level = node.children;
|
|
223
304
|
}
|
|
224
305
|
return { level, trail };
|
|
225
306
|
}
|
|
226
307
|
|
|
227
308
|
_setHub(text) {
|
|
228
309
|
if (!this._hub) return;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
310
|
+
const nextText = text ?? (this._path.length && this._cfg.expand === 'drill'
|
|
311
|
+
? `‹ ${this._itemsAtLevel(this._path.length - 1)[this._path[this._path.length - 1]]?.label ?? this._cfg.label}`
|
|
312
|
+
: (this._selectedLabel ?? this._cfg.label));
|
|
313
|
+
// Keep the hit-tested text node stable through hover and focus transitions.
|
|
314
|
+
if (this._hub.textContent !== nextText) this._hub.textContent = nextText;
|
|
233
315
|
}
|
|
234
316
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
// Concentric toggles; drill always opens the next level.
|
|
243
|
-
if (this._cfg.expand === 'concentric' && this._path[level] === i) this._path = this._path.slice(0, level);
|
|
244
|
-
else this._path = [...this._path.slice(0, level), i];
|
|
317
|
+
_activateAt(level, index) {
|
|
318
|
+
const item = this._itemsAtLevel(level)[index];
|
|
319
|
+
if (!item || this._isDisabled()) return;
|
|
320
|
+
if (item.children?.length) {
|
|
321
|
+
const collapse = this._cfg.expand === 'concentric' && this._path[level] === index;
|
|
322
|
+
this._path = collapse ? this._path.slice(0, level) : [...this._path.slice(0, level), index];
|
|
323
|
+
this._nextFocus = collapse ? `${level}:${index}` : `${level + 1}:0`;
|
|
245
324
|
this.build(this._cfg);
|
|
246
|
-
this.dispatchEvent(new CustomEvent('navigate', { detail: { path: [...this._path], depth: this._path.length }, bubbles: true }));
|
|
247
325
|
} else {
|
|
248
|
-
this._value =
|
|
249
|
-
this._selectedLabel =
|
|
326
|
+
this._value = item.value;
|
|
327
|
+
this._selectedLabel = item.label;
|
|
250
328
|
this._path = this._path.slice(0, level);
|
|
329
|
+
this._selectedPath = [...this._path, index];
|
|
330
|
+
this._nextFocus = `${level}:${index}`;
|
|
251
331
|
this.build(this._cfg);
|
|
252
|
-
this.
|
|
253
|
-
|
|
254
|
-
}));
|
|
332
|
+
this._updateForm();
|
|
333
|
+
const detail = { value: item.value, label: item.label, item, path: [...this._selectedPath] };
|
|
334
|
+
this.dispatchEvent(new CustomEvent('select', { detail, bubbles: true, composed: true }));
|
|
335
|
+
for (const type of ['input', 'change']) this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
|
|
255
336
|
}
|
|
256
337
|
}
|
|
257
338
|
|
|
258
|
-
// --- public API ---
|
|
259
339
|
get path() { return [...(this._path || [])]; }
|
|
260
340
|
get value() { return this._value; }
|
|
341
|
+
/** Pick the item with this value (or clear with null) without firing events. */
|
|
342
|
+
set value(value) {
|
|
343
|
+
if (value == null || value === '') this._clearSelection();
|
|
344
|
+
else this._selectValue(value);
|
|
345
|
+
this._updateForm();
|
|
346
|
+
}
|
|
347
|
+
get selectedIndex() {
|
|
348
|
+
const path = this._path || [];
|
|
349
|
+
return this._selectedPath?.length === path.length + 1 && equalPath(this._selectedPath.slice(0, -1), path)
|
|
350
|
+
? this._selectedPath[this._selectedPath.length - 1] : -1;
|
|
351
|
+
}
|
|
261
352
|
back() {
|
|
262
|
-
if (!this._path
|
|
353
|
+
if (!this._path?.length) return;
|
|
354
|
+
this._nextFocus = `${this._path.length - 1}:${this._path[this._path.length - 1]}`;
|
|
263
355
|
this._path = this._path.slice(0, -1);
|
|
264
356
|
this.build(this._cfg);
|
|
265
|
-
this.dispatchEvent(new CustomEvent('navigate', { detail: { path: [...this._path], depth: this._path.length }, bubbles: true }));
|
|
266
357
|
}
|
|
267
358
|
reset() {
|
|
268
|
-
if (!this._path
|
|
359
|
+
if (!this._path?.length) return;
|
|
360
|
+
this._nextFocus = `0:${this._path[0]}`;
|
|
269
361
|
this._path = [];
|
|
270
362
|
this.build(this._cfg);
|
|
271
|
-
this.dispatchEvent(new CustomEvent('navigate', { detail: { path: [], depth: 0 }, bubbles: true }));
|
|
272
363
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
_activate(i) { this._activateAt(this._path.length, i); }
|
|
276
|
-
_select(i) { this._activateAt(this._path.length, i); }
|
|
277
|
-
get selectedIndex() { return this._value != null && this._path.length === 0 ? this._arcs.findIndex((a) => a.classList.contains('is-active')) : -1; }
|
|
364
|
+
_activate(index) { this._activateAt(this._path?.length || 0, index); }
|
|
365
|
+
_select(index) { this._activate(index); }
|
|
278
366
|
}
|
|
279
367
|
|
|
280
368
|
define('o-menu', OrbitMenu);
|