@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-menu.js CHANGED
@@ -1,280 +1,368 @@
1
1
  /**
2
- * <o-menu> — radial / pie menu, single- or multi-level, two expansion modes.
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
- * Each sector is an <o-arc> whose curved label rides the arc itself (Orbit
5
- * renders textContent along the sector path, auto-centered — no angle math).
6
- * Items may nest via `children`. Two ways to reveal a branch's children:
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 { --o-menu-hub: rgba(20,22,30,.92); }
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 .ok-menu-ring { animation: ok-menu-in .22s cubic-bezier(.2,.7,.3,1); transform-origin: center; }
34
- @keyframes ok-menu-in { from { opacity: .25; transform: scale(.9); } to { opacity: 1; transform: scale(1); } }
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
- border-radius: 50%;
37
- background: var(--o-menu-hub);
38
- box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
39
- padding: 16%;
40
- font-weight: 800; text-align: center; line-height: 1.12;
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
- /* Monochrome theme: slate sectors that tint to the accent on hover / active. */
47
- o-menu.o-menu--mono o-arc.o-menu-item { --o-fill: var(--ok-menu-slate, #2b333f); --o-stroke: var(--ok-menu-slate, #2b333f); transition: filter .18s, --o-fill .18s; }
48
- o-menu.o-menu--mono o-arc.o-menu-item:hover { --o-fill: var(--ok-menu-accent, #3da9fc); --o-stroke: var(--ok-menu-accent, #3da9fc); filter: none; }
49
- o-menu.o-menu--mono o-arc.o-menu-item.is-active { --o-fill: var(--ok-menu-accent, #3da9fc); --o-stroke: var(--ok-menu-accent, #3da9fc); filter: none; }
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', 'range', 'from', 'gap', 'fill', 'label', 'label-color', 'expand', 'theme', 'accent'];
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
- /** Recursively normalize the items tree. */
61
- _norm(items) {
62
- return (items || []).map((it, i) => ({
63
- label: it.label != null ? String(it.label) : `Item ${i + 1}`,
64
- value: it.value !== undefined ? it.value : (it.label ?? i),
65
- color: it.color || null,
66
- icon: it.icon != null ? String(it.icon) : null,
67
- children: it.children ? this._norm(it.children) : null
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
- from: this.hasAttribute('from') ? this._num('from', 0)
77
- : (range === 360 ? 0 : this._autoFrom(range)),
78
- orbit: Math.round(this._num('orbit', 4)),
79
- size: this._str('size', '280px'),
80
- gap: this._num('gap', 6),
81
- labelColor: this._str('label-color', '#f8fafc'),
82
- theme: this._str('theme', 'mono') === 'color' ? 'color' : 'mono',
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.hasAttribute('label') ? this.getAttribute('label') : 'MENU'
77
+ label: this._str('label', 'MENU')
86
78
  };
87
79
  }
88
80
 
89
- structuralSignature(cfg) {
90
- return JSON.stringify(cfg);
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.style.setProperty('--ok-menu-accent', cfg.accent);
98
- if (!this._path) this._path = [];
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
- /** Render one ring of sectors (+ centered icons) at a given orbit/wedge. */
105
- _ring(items, from, range, orbit, level, openIndex, cfg) {
106
- const N = items.length || 1;
107
- const sliceW = range / N;
108
- const arcs = items.map((it, i) => {
109
- const color = it.color || PALETTE[i % PALETTE.length];
110
- const isBranch = !!(it.children && it.children.length);
111
- const text = it.icon ? '' : it.label + (isBranch ? ' ›' : '');
112
- const cls = `o-menu-item${isBranch ? ' is-branch' : ''}${openIndex === i ? ' is-active' : ''}`;
113
- // Mono theme paints fills via CSS (so :hover/:active can tint them);
114
- // color theme sets the per-item color inline.
115
- const style = cfg.theme === 'mono'
116
- ? `--o-gap:${cfg.gap};--o-color:${cfg.labelColor};font-weight:700;`
117
- : `--o-fill:${color};--o-stroke:${color};--o-gap:${cfg.gap};--o-color:${cfg.labelColor};font-weight:700;`;
118
- // Flip labels whose sector sits in the lower half so text stays upright.
119
- const phi = (((from + (i + 0.5) * sliceW) % 360) + 360) % 360;
120
- const flip = phi > 90 && phi < 270 ? ' flip' : '';
121
- return `<o-arc class="${cls}" data-level="${level}" data-index="${i}" shape="rounded"${flip} style="${style}">${text}</o-arc>`;
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
- const arcRing = `<div class="orbit-${orbit} ok-menu-ring" style="--o-range:${range}deg;--o-from:${from}deg;">${arcs}</div>`;
124
- // Icons live in their OWN orbit so they don't pollute the arcs' count-based
125
- // distribution. Each is pinned to its sector center via an explicit
126
- // composite that MATCHES the count-based arc center (no fit-range, which
127
- // would shift the arcs and misalign the icons).
128
- const icons = items.map((it, i) => it.icon
129
- ? `<div class="satellite spin-lock o-menu-icon" style="--o-angle-composite:calc(${((i + 0.5) * sliceW - 90).toFixed(2)}deg);"><div class="capsule">${it.icon}</div></div>`
130
- : '').join('');
131
- const iconRing = icons
132
- ? `<div class="orbit-${orbit}" style="--o-from:${from}deg;">${icons}</div>`
133
- : '';
134
- return arcRing + iconRing;
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
- let rings = '', from = cfg.from, range = cfg.range, items = cfg.items, orbit = cfg.orbit;
139
- for (let d = 0; ; d++) {
140
- const openIndex = this._path[d];
141
- rings += this._ring(items, from, range, orbit, d, openIndex ?? null, cfg);
142
- if (openIndex == null) break;
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 open = this._path.length > 0;
152
- this.innerHTML = `
153
- <div class="bigbang" style="width:${cfg.size};aspect-ratio:1;">
154
- <div class="gravity-spot">
155
- ${rings}
156
- <div class="orbit-0"><div class="satellite at-center"><div class="capsule o-menu-hub${open ? ' is-back' : ''}">${cfg.label}</div></div></div>
157
- </div>
158
- </div>`;
159
- this._hub = this.querySelector('.o-menu-hub');
160
- this._arcs = [...this.querySelectorAll('.o-menu-item')];
161
- this._bindArcs((lvl, i) => this._activateAt(lvl, i));
162
- if (this._hub && open) this._hub.addEventListener('click', () => this.reset());
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._level = level;
168
- const atRoot = this._path.length === 0;
169
- const hubText = atRoot ? cfg.label : `‹ ${trail[trail.length - 1].label}`;
170
- const ring = this._ring(level, cfg.from, cfg.range, cfg.orbit, this._path.length, null, cfg);
171
- this.innerHTML = `
172
- <div class="bigbang" style="width:${cfg.size};aspect-ratio:1;">
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
- this._bindArcs((lvl, i) => this._activateAt(this._path.length, i));
181
- if (this._hub && !atRoot) this._hub.addEventListener('click', () => this.back());
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
- _bindArcs(onActivate) {
185
- this._arcs.forEach((arc) => {
186
- const lvl = Number(arc.dataset.level), i = Number(arc.dataset.index);
187
- arc.addEventListener('click', () => onActivate(lvl, i));
188
- arc.addEventListener('pointerenter', () => this._setHub(this._itemsAtLevel(lvl)[i]?.label));
189
- arc.addEventListener('pointerleave', () => this._setHub());
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', (e) => {
198
- if ((e.key === 'Escape' || e.key === 'Backspace') && this._path.length) {
199
- e.preventDefault();
200
- this._cfg.expand === 'concentric' ? (this._path = this._path.slice(0, -1), this.build(this._cfg)) : this.back();
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 d = 0; d < level; d++) {
209
- const node = items[this._path[d]];
210
- if (!node || !node.children) return items;
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; const trail = [];
219
- for (const idx of this._path) {
220
- const node = level[idx];
221
- if (!node || !node.children || !node.children.length) { this._path = []; return { level: root, trail: [] }; }
222
- trail.push(node); level = node.children;
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
- this._hub.textContent = text != null ? text
230
- : (this._path.length && this._cfg.expand === 'drill'
231
- ? `‹ ${this._itemsAtLevel(this._path.length - 1)[this._path[this._path.length - 1]]?.label ?? this._cfg.label}`
232
- : (this._selectedLabel ?? this._cfg.label));
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
- /** Activate sector `i` at depth `level`: drill/expand a branch, or select a leaf. */
236
- _activateAt(level, i) {
237
- const items = this._itemsAtLevel(level);
238
- const it = items[i];
239
- if (!it) return;
240
- const isBranch = it.children && it.children.length;
241
- if (isBranch) {
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 = it.value;
249
- this._selectedLabel = it.label;
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.dispatchEvent(new CustomEvent('select', {
253
- detail: { value: it.value, label: it.label, item: it, path: [...this._path, i] }, bubbles: true
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 || !this._path.length) return;
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.length) return;
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
- // Back-compat with the former flat menu.
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);