@rivinia/menu 0.0.0-stage → 0.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 CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # @rivinia/menu
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@rivinia/menu.svg)](https://www.npmjs.com/package/@rivinia/menu)
4
+ [![license](https://img.shields.io/npm/l/@rivinia/menu.svg)](https://www.npmjs.com/package/@rivinia/menu)
5
+
6
+ Headless menu behavior for Angular 22+. Per-instance open state, item
7
+ registry, and keyboard navigation model — no DOM, no styling, no
8
+ positioning. Rendering lives in `@rivinia/menu/ui` (or your own UI).
9
+
10
+ - Version: **0.1.0**
11
+ - Requires: **Angular `^22.0.0`**, `tslib`
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @rivinia/menu
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```ts
22
+ const menu = new RiviniaMenu();
23
+ menu.register({ id: 'profile' });
24
+ menu.register({ id: 'logout' });
25
+
26
+ menu.openMenu(); // focuses 'profile'
27
+ menu.activateNext(); // 'logout' (disabled items are skipped, then wraps)
28
+ menu.closeMenu();
29
+ ```
30
+
31
+ Keyboard mapping (UI-owned, behavior owned here): Enter/Space/ArrowDown
32
+ opens, Escape closes (UI restores focus to the trigger), arrows/Home/End
33
+ move through enabled items, printable characters typeahead to the next match
34
+ (buffer resets after 500ms), Tab closes and moves on naturally.
35
+
36
+ ## API
37
+
38
+ | Export | Kind | Purpose |
39
+ | ------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- |
40
+ | `RiviniaMenu` | class | per-instance controller: `open/activeId/items` signals + `register/unregister/openMenu/closeMenu/toggle/activate*/pickActive` |
41
+ | types | types | `RiviniaMenuController` (structural UI contract), `RiviniaMenuItem`, `RiviniaMenuPlacement`, `RiviniaMenuFocus` |
42
+
43
+ ## Ready-made UI (`@rivinia/menu/ui`)
44
+
45
+ Optional secondary entry point. Zero new runtime dependencies.
46
+
47
+ ```html
48
+ <rv-menu [menu]="menu" [placement]="'bottom-start'" (picked)="go($event)">
49
+ <button rv-menu-trigger><img src="avatar.png" alt="Account" /></button>
50
+ <div rv-menu-panel>
51
+ <div class="head">Sophie · sophie@ui.live</div>
52
+ <button rvMenuItem id="profile">Profile</button>
53
+ <button rvMenuItem id="logout">Sign out</button>
54
+ </div>
55
+ </rv-menu>
56
+ ```
57
+
58
+ ```ts
59
+ menu = new RiviniaMenu();
60
+ ```
61
+
62
+ Keyboard (APG menu-button pattern): Enter/Space/↓ opens, Escape closes and
63
+ refocuses the trigger, arrows/Home/End move through enabled items, printable
64
+ characters typeahead to the next match, Tab closes
65
+ and moves on (non-modal by design), outside pointer-down closes. The trigger
66
+ carries `aria-haspopup="menu"` + `aria-expanded` (synced onto the
67
+ consumer's trigger element). Disabled
68
+ items are skipped and reported via `aria-disabled`. SSR renders closed.
69
+
70
+ Positioning: capable browsers render the panel in the top layer (manual
71
+ `popover` + measured `fixed` coordinates) — it escapes clipping ancestors
72
+ and flips to the fitting side near viewport edges (`data-flipped`
73
+ re-aims the enter animation). Older browsers keep absolute placement with
74
+ the same classes. Outside scroll/resize closes (scrolling inside the panel
75
+ is exempt). Pure helper `computeMenuPosition` is exported for testing.
76
+
77
+ ## License
78
+
79
+ MIT.
@@ -0,0 +1,565 @@
1
+ import * as i0 from "@angular/core";
2
+ import { Component, Directive, ElementRef, ViewEncapsulation, afterRenderEffect, computed, effect, inject, input, output, signal } from "@angular/core";
3
+ import { RiviniaMenu } from "@rivinia/menu";
4
+ const MENU_GAP = 6;
5
+ const EDGE = 8;
6
+ function computeMenuPosition(trigger, panel, placement, viewport, rtl, gap = 6) {
7
+ const [vertical, horizontal] = placement.split("-");
8
+ const below = vertical !== "top";
9
+ const alignLeft = horizontal !== "end" !== rtl;
10
+ let left = alignLeft ? trigger.left : trigger.left + trigger.width - panel.width;
11
+ let top = below ? trigger.top + trigger.height + gap : trigger.top - gap - panel.height;
12
+ let flipped = false;
13
+ const fitsBelow = trigger.top + trigger.height + gap + panel.height <= viewport.height - EDGE;
14
+ const fitsAbove = trigger.top - gap - panel.height >= EDGE;
15
+ if (below && top + panel.height > viewport.height - EDGE && fitsAbove) {
16
+ top = trigger.top - gap - panel.height;
17
+ flipped = true;
18
+ } else if (!below && top < EDGE && fitsBelow) {
19
+ top = trigger.top + trigger.height + gap;
20
+ flipped = true;
21
+ }
22
+ const fitsLeft = trigger.left + trigger.width - panel.width >= EDGE;
23
+ const fitsRight = trigger.left + panel.width <= viewport.width - EDGE;
24
+ if (alignLeft && left + panel.width > viewport.width - EDGE && fitsLeft) left = trigger.left + trigger.width - panel.width;
25
+ else if (!alignLeft && left < EDGE && fitsRight) left = trigger.left;
26
+ left = Math.min(Math.max(left, EDGE), Math.max(EDGE, viewport.width - panel.width - EDGE));
27
+ top = Math.min(Math.max(top, EDGE), Math.max(EDGE, viewport.height - panel.height - EDGE));
28
+ return {
29
+ left: Math.round(left),
30
+ top: Math.round(top),
31
+ flipped
32
+ };
33
+ }
34
+ var RiviniaMenuItem = class RiviniaMenuItem {
35
+ id = input.required(...ngDevMode ? [{ debugName: "id" }] : /* istanbul ignore next */ []);
36
+ disabled = input(false, {
37
+ ...ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {},
38
+ transform: (v) => v === "" || v === true
39
+ });
40
+ checked = input(null, ...ngDevMode ? [{ debugName: "checked" }] : /* istanbul ignore next */ []);
41
+ menu = inject(RiviniaMenuComponent, {
42
+ optional: true,
43
+ skipSelf: true
44
+ });
45
+ active = computed(() => this.menu?.controller().activeId() === this.id(), ...ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []);
46
+ tab() {
47
+ return this.active() ? "0" : "-1";
48
+ }
49
+ computedRole() {
50
+ return this.checked() === null ? "menuitem" : "menuitemcheckbox";
51
+ }
52
+ checkedAttr() {
53
+ const checked = this.checked();
54
+ return checked === null ? null : String(checked);
55
+ }
56
+ pick() {
57
+ this.menu?.pickItem(this.id());
58
+ }
59
+ hover() {
60
+ this.menu?.hoverItem(this.id());
61
+ }
62
+ ngOnInit() {
63
+ this.menu?.registerItem({
64
+ id: this.id(),
65
+ disabled: this.disabled()
66
+ });
67
+ }
68
+ ngOnDestroy() {
69
+ this.menu?.unregisterItem(this.id());
70
+ }
71
+ static ɵfac = i0.ɵɵngDeclareFactory({
72
+ minVersion: "12.0.0",
73
+ version: "22.2.2",
74
+ ngImport: i0,
75
+ type: RiviniaMenuItem,
76
+ deps: [],
77
+ target: i0.ɵɵFactoryTarget.Directive
78
+ });
79
+ static ɵdir = i0.ɵɵngDeclareDirective({
80
+ minVersion: "17.1.0",
81
+ version: "22.2.2",
82
+ type: RiviniaMenuItem,
83
+ isStandalone: true,
84
+ selector: "[rvMenuItem]",
85
+ inputs: {
86
+ id: {
87
+ classPropertyName: "id",
88
+ publicName: "id",
89
+ isSignal: true,
90
+ isRequired: true,
91
+ transformFunction: null
92
+ },
93
+ disabled: {
94
+ classPropertyName: "disabled",
95
+ publicName: "disabled",
96
+ isSignal: true,
97
+ isRequired: false,
98
+ transformFunction: null
99
+ },
100
+ checked: {
101
+ classPropertyName: "checked",
102
+ publicName: "checked",
103
+ isSignal: true,
104
+ isRequired: false,
105
+ transformFunction: null
106
+ }
107
+ },
108
+ host: {
109
+ listeners: {
110
+ "click": "pick()",
111
+ "pointerenter": "hover()"
112
+ },
113
+ properties: {
114
+ "attr.role": "computedRole()",
115
+ "attr.data-rv-id": "id()",
116
+ "attr.tabindex": "tab()",
117
+ "attr.aria-disabled": "disabled()",
118
+ "attr.data-active": "active()",
119
+ "attr.aria-checked": "checkedAttr()"
120
+ }
121
+ },
122
+ ngImport: i0
123
+ });
124
+ };
125
+ i0.ɵɵngDeclareClassMetadata({
126
+ minVersion: "12.0.0",
127
+ version: "22.2.2",
128
+ ngImport: i0,
129
+ type: RiviniaMenuItem,
130
+ decorators: [{
131
+ type: Directive,
132
+ args: [{
133
+ selector: "[rvMenuItem]",
134
+ standalone: true,
135
+ host: {
136
+ "[attr.role]": "computedRole()",
137
+ "[attr.data-rv-id]": "id()",
138
+ "[attr.tabindex]": "tab()",
139
+ "[attr.aria-disabled]": "disabled()",
140
+ "[attr.data-active]": "active()",
141
+ "[attr.aria-checked]": "checkedAttr()",
142
+ "(click)": "pick()",
143
+ "(pointerenter)": "hover()"
144
+ }
145
+ }]
146
+ }],
147
+ propDecorators: {
148
+ id: [{
149
+ type: i0.Input,
150
+ args: [{
151
+ isSignal: true,
152
+ alias: "id",
153
+ required: true
154
+ }]
155
+ }],
156
+ disabled: [{
157
+ type: i0.Input,
158
+ args: [{
159
+ isSignal: true,
160
+ alias: "disabled",
161
+ required: false
162
+ }]
163
+ }],
164
+ checked: [{
165
+ type: i0.Input,
166
+ args: [{
167
+ isSignal: true,
168
+ alias: "checked",
169
+ required: false
170
+ }]
171
+ }]
172
+ }
173
+ });
174
+ var RiviniaMenuComponent = class RiviniaMenuComponent {
175
+ menu = input(null, ...ngDevMode ? [{ debugName: "menu" }] : /* istanbul ignore next */ []);
176
+ placement = input("bottom-start", ...ngDevMode ? [{ debugName: "placement" }] : /* istanbul ignore next */ []);
177
+ picked = output();
178
+ opened = output();
179
+ closed = output();
180
+ owned = new RiviniaMenu();
181
+ self = inject(ElementRef);
182
+ focusPending = false;
183
+ topLayerSupported = typeof HTMLElement !== "undefined" && "showPopover" in HTMLElement.prototype;
184
+ positioned = signal(false, ...ngDevMode ? [{ debugName: "positioned" }] : /* istanbul ignore next */ []);
185
+ flipped = signal(false, ...ngDevMode ? [{ debugName: "flipped" }] : /* istanbul ignore next */ []);
186
+ typeBuffer = "";
187
+ typeTimer = null;
188
+ controller() {
189
+ return this.menu() ?? this.owned;
190
+ }
191
+ registerItem(item) {
192
+ this.controller().register(item);
193
+ }
194
+ unregisterItem(id) {
195
+ this.controller().unregister(id);
196
+ }
197
+ constructor() {
198
+ effect((onCleanup) => {
199
+ if (typeof window === "undefined") return;
200
+ if (!this.controller().open()) return;
201
+ const onPointer = (event) => {
202
+ const host = this.self.nativeElement;
203
+ if (host && !host.contains(event.target)) this.doClose(true);
204
+ };
205
+ document.addEventListener("pointerdown", onPointer);
206
+ const onScroll = (event) => {
207
+ const panel = this.self.nativeElement.querySelector(".rv-menu-panel");
208
+ if (panel && event.target instanceof Node && panel.contains(event.target)) return;
209
+ this.doClose(false);
210
+ };
211
+ window.addEventListener("scroll", onScroll, true);
212
+ window.addEventListener("resize", onScroll);
213
+ onCleanup(() => {
214
+ document.removeEventListener("pointerdown", onPointer);
215
+ window.removeEventListener("scroll", onScroll, true);
216
+ window.removeEventListener("resize", onScroll);
217
+ });
218
+ });
219
+ effect(() => {
220
+ if (typeof document === "undefined") return;
221
+ const open = this.controller().open();
222
+ const trigger = this.triggerEl();
223
+ trigger?.setAttribute("aria-haspopup", "menu");
224
+ trigger?.setAttribute("aria-expanded", String(open));
225
+ });
226
+ afterRenderEffect(() => {
227
+ if (this.controller().open() && !this.positioned()) this.showAndPosition();
228
+ this.consumePendingFocus();
229
+ });
230
+ }
231
+ showAndPosition() {
232
+ const root = this.self.nativeElement.querySelector(".rv-menu-panel");
233
+ if (!root) return;
234
+ if (typeof window === "undefined" || !this.topLayerSupported) {
235
+ this.positioned.set(true);
236
+ return;
237
+ }
238
+ try {
239
+ const popover = root;
240
+ if (typeof popover.showPopover !== "function") {
241
+ this.positioned.set(true);
242
+ return;
243
+ }
244
+ popover.showPopover();
245
+ const trigger = this.triggerEl()?.getBoundingClientRect();
246
+ if (!trigger) {
247
+ this.positioned.set(true);
248
+ return;
249
+ }
250
+ const at = computeMenuPosition({
251
+ left: trigger.left,
252
+ top: trigger.top,
253
+ width: trigger.width,
254
+ height: trigger.height
255
+ }, {
256
+ width: root.offsetWidth,
257
+ height: root.offsetHeight
258
+ }, this.placement(), {
259
+ width: window.innerWidth,
260
+ height: window.innerHeight
261
+ }, this.isRtl());
262
+ root.style.left = `${at.left}px`;
263
+ root.style.top = `${at.top}px`;
264
+ this.flipped.set(at.flipped);
265
+ this.positioned.set(true);
266
+ } catch {
267
+ this.positioned.set(true);
268
+ }
269
+ }
270
+ isRtl() {
271
+ try {
272
+ const doc = typeof document === "undefined" ? null : document.documentElement;
273
+ if (doc?.dir === "rtl") return true;
274
+ if (doc?.dir === "ltr") return false;
275
+ return getComputedStyle(this.self.nativeElement).direction === "rtl";
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+ markOpening() {
281
+ this.focusPending = true;
282
+ if (typeof window !== "undefined" && this.topLayerSupported) {
283
+ this.flipped.set(false);
284
+ this.positioned.set(false);
285
+ }
286
+ }
287
+ onTriggerClick() {
288
+ const c = this.controller();
289
+ c.toggle();
290
+ if (c.open()) {
291
+ this.opened.emit();
292
+ this.markOpening();
293
+ } else this.closed.emit();
294
+ }
295
+ onTriggerKey(event) {
296
+ const c = this.controller();
297
+ switch (event.key) {
298
+ case "Enter":
299
+ case " ":
300
+ case "ArrowDown":
301
+ event.preventDefault();
302
+ c.openMenu("first");
303
+ this.opened.emit();
304
+ this.markOpening();
305
+ break;
306
+ case "ArrowUp":
307
+ event.preventDefault();
308
+ c.openMenu("last");
309
+ this.opened.emit();
310
+ this.markOpening();
311
+ break;
312
+ case "Escape": if (c.open()) {
313
+ event.preventDefault();
314
+ this.doClose(true);
315
+ }
316
+ }
317
+ }
318
+ onPanelKey(event) {
319
+ const c = this.controller();
320
+ switch (event.key) {
321
+ case "Escape":
322
+ event.preventDefault();
323
+ this.doClose(true);
324
+ break;
325
+ case "ArrowDown":
326
+ event.preventDefault();
327
+ c.activateNext();
328
+ this.focusActive();
329
+ break;
330
+ case "ArrowUp":
331
+ event.preventDefault();
332
+ c.activatePrevious();
333
+ this.focusActive();
334
+ break;
335
+ case "Home":
336
+ event.preventDefault();
337
+ c.activateFirst();
338
+ this.focusActive();
339
+ break;
340
+ case "End":
341
+ event.preventDefault();
342
+ c.activateLast();
343
+ this.focusActive();
344
+ break;
345
+ case "Enter":
346
+ case " ":
347
+ event.preventDefault();
348
+ this.pickFocused();
349
+ break;
350
+ case "Tab":
351
+ this.doClose(false);
352
+ break;
353
+ default: this.onTypeahead(event);
354
+ }
355
+ }
356
+ onTypeahead(event) {
357
+ if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
358
+ const ch = event.key.toLocaleLowerCase();
359
+ if (!(this.typeBuffer !== "" && [...this.typeBuffer].every((c) => c === ch))) this.typeBuffer += ch;
360
+ if (this.typeTimer !== null) clearTimeout(this.typeTimer);
361
+ this.typeTimer = setTimeout(() => {
362
+ this.typeBuffer = "";
363
+ this.typeTimer = null;
364
+ }, 500);
365
+ const id = this.matchTypeahead(this.typeBuffer);
366
+ if (id !== null && this.controller().activate(id)) this.focusActive();
367
+ }
368
+ matchTypeahead(buffer) {
369
+ const root = this.self.nativeElement.querySelector(".rv-menu-panel");
370
+ if (!root) return null;
371
+ const enabled = new Set(this.controller().items().filter((i) => !i.disabled).map((i) => i.id));
372
+ const rows = [...root.querySelectorAll("[data-rv-id]")].map((el) => ({
373
+ id: el.getAttribute("data-rv-id") ?? "",
374
+ text: (el.textContent ?? "").trim().toLocaleLowerCase()
375
+ })).filter((r) => enabled.has(r.id) && r.text.startsWith(buffer));
376
+ if (rows.length === 0) return null;
377
+ if (new Set(buffer).size === 1) {
378
+ const ids = rows.map((r) => r.id);
379
+ return ids[(ids.indexOf(this.controller().activeId() ?? "") + 1) % ids.length];
380
+ }
381
+ return rows[0].id;
382
+ }
383
+ pickFocused() {
384
+ const id = this.controller().pickActive();
385
+ if (id === null) return;
386
+ this.doClose(false);
387
+ this.picked.emit({ id });
388
+ }
389
+ pickItem(id) {
390
+ if (!this.controller().activate(id)) return;
391
+ this.doClose(false);
392
+ this.picked.emit({ id });
393
+ }
394
+ hoverItem(id) {
395
+ this.controller().activate(id);
396
+ }
397
+ doClose(restoreFocus) {
398
+ const was = this.controller().open();
399
+ this.controller().closeMenu();
400
+ if (was) this.closed.emit();
401
+ if (restoreFocus) this.focusTrigger();
402
+ }
403
+ consumePendingFocus() {
404
+ if (!this.controller().open() || !this.focusPending) return;
405
+ this.focusPending = false;
406
+ const root = this.self.nativeElement.querySelector(".rv-menu-panel");
407
+ const active = this.activeDocumentElement();
408
+ if (root && (!active || !root.contains(active))) this.focusActive();
409
+ }
410
+ focusActive() {
411
+ const id = this.controller().activeId();
412
+ const root = this.self.nativeElement.querySelector(".rv-menu-panel");
413
+ if (!root) return;
414
+ const target = id ? root.querySelector(`[data-rv-id="${CSS.escape(id)}"]`) : root.querySelector("[rvmenuitem]");
415
+ try {
416
+ target?.focus({ preventScroll: true });
417
+ } catch {}
418
+ }
419
+ activeDocumentElement() {
420
+ try {
421
+ return typeof document === "undefined" ? null : document.activeElement;
422
+ } catch {
423
+ return null;
424
+ }
425
+ }
426
+ triggerEl() {
427
+ const wrap = this.self.nativeElement.querySelector(".rv-menu-trigger");
428
+ if (!wrap) return null;
429
+ return wrap.querySelector("button, a, [tabindex]") ?? wrap;
430
+ }
431
+ focusTrigger() {
432
+ try {
433
+ this.triggerEl()?.focus({ preventScroll: true });
434
+ } catch {}
435
+ }
436
+ static ɵfac = i0.ɵɵngDeclareFactory({
437
+ minVersion: "12.0.0",
438
+ version: "22.2.2",
439
+ ngImport: i0,
440
+ type: RiviniaMenuComponent,
441
+ deps: [],
442
+ target: i0.ɵɵFactoryTarget.Component
443
+ });
444
+ static ɵcmp = i0.ɵɵngDeclareComponent({
445
+ minVersion: "17.0.0",
446
+ version: "22.2.2",
447
+ type: RiviniaMenuComponent,
448
+ isStandalone: true,
449
+ selector: "rv-menu",
450
+ inputs: {
451
+ menu: {
452
+ classPropertyName: "menu",
453
+ publicName: "menu",
454
+ isSignal: true,
455
+ isRequired: false,
456
+ transformFunction: null
457
+ },
458
+ placement: {
459
+ classPropertyName: "placement",
460
+ publicName: "placement",
461
+ isSignal: true,
462
+ isRequired: false,
463
+ transformFunction: null
464
+ }
465
+ },
466
+ outputs: {
467
+ picked: "picked",
468
+ opened: "opened",
469
+ closed: "closed"
470
+ },
471
+ ngImport: i0,
472
+ template: `
473
+ <div class="rv-menu-trigger" (click)="onTriggerClick()" (keydown)="onTriggerKey($event)">
474
+ <ng-content select="[rv-menu-trigger]"></ng-content>
475
+ </div>
476
+ @if (controller().open()) {
477
+ <div
478
+ #panel
479
+ class="rv-menu-panel"
480
+ role="menu"
481
+ [attr.popover]="topLayerSupported ? 'manual' : null"
482
+ [attr.data-placement]="placement()"
483
+ [attr.data-flipped]="flipped()"
484
+ [attr.data-positioned]="positioned()"
485
+ [attr.data-top-layer]="topLayerSupported"
486
+ (keydown)="onPanelKey($event)"
487
+ >
488
+ <ng-content select="[rv-menu-panel]"></ng-content>
489
+ </div>
490
+ }
491
+ `,
492
+ isInline: true,
493
+ styles: [".rv-menu-trigger{display:inline-flex;align-items:center}.rv-menu-panel{display:flex;flex-direction:column;gap:2px}.rv-menu-panel hr{border:0;border-top:1px solid var(--rv-menu-border);margin:6px 4px}rv-menu{position:relative;display:inline-block}.rv-menu-panel[data-top-layer=true]{position:fixed;inset:auto;margin:0}.rv-menu-panel[data-positioned=false]{animation:none}.rv-menu-panel[data-top-layer=true][data-placement^=bottom][data-flipped=true]{transform-origin:bottom center}.rv-menu-panel[data-top-layer=true][data-placement^=top][data-flipped=true]{transform-origin:top center}.rv-menu-panel{--rv-menu-bg: #ffffff;--rv-menu-fg: #111111;--rv-menu-muted: #555555;--rv-menu-border: #e0e0e0;--rv-menu-radius: 12px;--rv-menu-shadow: 0 16px 48px rgba(0, 0, 0, .22);--rv-menu-pad: 6px;--rv-menu-width: 16rem;--rv-menu-font: system-ui, -apple-system, \"Segoe UI\", sans-serif;position:absolute;z-index:var(--rv-menu-z, 500);min-width:var(--rv-menu-width);max-width:calc(100dvw - 2rem);max-height:min(20rem,calc(100dvh - 2rem));overflow-y:auto;background:var(--rv-menu-bg);color:var(--rv-menu-fg);font-family:var(--rv-menu-font);border:1px solid var(--rv-menu-border);border-radius:var(--rv-menu-radius);box-shadow:var(--rv-menu-shadow);padding:var(--rv-menu-pad)}@media(prefers-color-scheme:dark){.rv-menu-panel{--rv-menu-bg: #161616;--rv-menu-fg: #f2f2f2;--rv-menu-muted: #a8a8a8;--rv-menu-border: #333333}}.rv-menu-panel[data-placement=bottom-start]{inset-block-start:calc(100% + 6px);inset-inline-start:0}.rv-menu-panel[data-placement=bottom-end]{inset-block-start:calc(100% + 6px);inset-inline-end:0}.rv-menu-panel[data-placement=top-start]{inset-block-end:calc(100% + 6px);inset-inline-start:0}.rv-menu-panel[data-placement=top-end]{inset-block-end:calc(100% + 6px);inset-inline-end:0}.rv-menu-panel [rvmenuitem]{display:flex;align-items:center;gap:10px;width:100%;min-height:40px;border:0;border-radius:8px;background:transparent;color:inherit;font:inherit;font-size:14px;line-height:1.4;text-align:start;padding:8px 10px;cursor:pointer}.rv-menu-panel [rvmenuitem][data-active=true]{background:color-mix(in srgb,currentColor 10%,transparent)}.rv-menu-panel [rvmenuitem]:hover{background:color-mix(in srgb,currentColor 8%,transparent)}.rv-menu-panel [rvmenuitem]:focus-visible{outline:2px solid currentColor;outline-offset:-2px}.rv-menu-panel [rvmenuitem][aria-disabled=true]{opacity:.45;cursor:default}.rv-menu-panel [rvmenuitem] svg,.rv-menu-panel [rvmenuitem] [data-icon]{width:16px;height:16px;flex:none;color:var(--rv-menu-muted)}.rv-menu-panel [rvmenuitem][data-active=true] svg,.rv-menu-panel [rvmenuitem][data-active=true] [data-icon]{color:inherit}.rv-menu-panel{--rv-menu-duration: .16s;--rv-menu-easing: cubic-bezier(.16, 1, .3, 1);--rv-menu-from-y: -6px;transform-origin:top center;animation:rv-menu-in var(--rv-menu-duration) var(--rv-menu-easing)}.rv-menu-panel[data-placement=top-start],.rv-menu-panel[data-placement=top-end]{--rv-menu-from-y: 6px;transform-origin:bottom center}@media(prefers-reduced-motion:reduce){.rv-menu-panel{animation:none}}@keyframes rv-menu-in{0%{opacity:0;transform:translateY(var(--rv-menu-from-y)) scale(.96)}}\n"],
494
+ encapsulation: i0.ViewEncapsulation.None
495
+ });
496
+ };
497
+ i0.ɵɵngDeclareClassMetadata({
498
+ minVersion: "12.0.0",
499
+ version: "22.2.2",
500
+ ngImport: i0,
501
+ type: RiviniaMenuComponent,
502
+ decorators: [{
503
+ type: Component,
504
+ args: [{
505
+ selector: "rv-menu",
506
+ standalone: true,
507
+ encapsulation: ViewEncapsulation.None,
508
+ template: `
509
+ <div class="rv-menu-trigger" (click)="onTriggerClick()" (keydown)="onTriggerKey($event)">
510
+ <ng-content select="[rv-menu-trigger]"></ng-content>
511
+ </div>
512
+ @if (controller().open()) {
513
+ <div
514
+ #panel
515
+ class="rv-menu-panel"
516
+ role="menu"
517
+ [attr.popover]="topLayerSupported ? 'manual' : null"
518
+ [attr.data-placement]="placement()"
519
+ [attr.data-flipped]="flipped()"
520
+ [attr.data-positioned]="positioned()"
521
+ [attr.data-top-layer]="topLayerSupported"
522
+ (keydown)="onPanelKey($event)"
523
+ >
524
+ <ng-content select="[rv-menu-panel]"></ng-content>
525
+ </div>
526
+ }
527
+ `,
528
+ styles: [".rv-menu-trigger{display:inline-flex;align-items:center}.rv-menu-panel{display:flex;flex-direction:column;gap:2px}.rv-menu-panel hr{border:0;border-top:1px solid var(--rv-menu-border);margin:6px 4px}rv-menu{position:relative;display:inline-block}.rv-menu-panel[data-top-layer=true]{position:fixed;inset:auto;margin:0}.rv-menu-panel[data-positioned=false]{animation:none}.rv-menu-panel[data-top-layer=true][data-placement^=bottom][data-flipped=true]{transform-origin:bottom center}.rv-menu-panel[data-top-layer=true][data-placement^=top][data-flipped=true]{transform-origin:top center}.rv-menu-panel{--rv-menu-bg: #ffffff;--rv-menu-fg: #111111;--rv-menu-muted: #555555;--rv-menu-border: #e0e0e0;--rv-menu-radius: 12px;--rv-menu-shadow: 0 16px 48px rgba(0, 0, 0, .22);--rv-menu-pad: 6px;--rv-menu-width: 16rem;--rv-menu-font: system-ui, -apple-system, \"Segoe UI\", sans-serif;position:absolute;z-index:var(--rv-menu-z, 500);min-width:var(--rv-menu-width);max-width:calc(100dvw - 2rem);max-height:min(20rem,calc(100dvh - 2rem));overflow-y:auto;background:var(--rv-menu-bg);color:var(--rv-menu-fg);font-family:var(--rv-menu-font);border:1px solid var(--rv-menu-border);border-radius:var(--rv-menu-radius);box-shadow:var(--rv-menu-shadow);padding:var(--rv-menu-pad)}@media(prefers-color-scheme:dark){.rv-menu-panel{--rv-menu-bg: #161616;--rv-menu-fg: #f2f2f2;--rv-menu-muted: #a8a8a8;--rv-menu-border: #333333}}.rv-menu-panel[data-placement=bottom-start]{inset-block-start:calc(100% + 6px);inset-inline-start:0}.rv-menu-panel[data-placement=bottom-end]{inset-block-start:calc(100% + 6px);inset-inline-end:0}.rv-menu-panel[data-placement=top-start]{inset-block-end:calc(100% + 6px);inset-inline-start:0}.rv-menu-panel[data-placement=top-end]{inset-block-end:calc(100% + 6px);inset-inline-end:0}.rv-menu-panel [rvmenuitem]{display:flex;align-items:center;gap:10px;width:100%;min-height:40px;border:0;border-radius:8px;background:transparent;color:inherit;font:inherit;font-size:14px;line-height:1.4;text-align:start;padding:8px 10px;cursor:pointer}.rv-menu-panel [rvmenuitem][data-active=true]{background:color-mix(in srgb,currentColor 10%,transparent)}.rv-menu-panel [rvmenuitem]:hover{background:color-mix(in srgb,currentColor 8%,transparent)}.rv-menu-panel [rvmenuitem]:focus-visible{outline:2px solid currentColor;outline-offset:-2px}.rv-menu-panel [rvmenuitem][aria-disabled=true]{opacity:.45;cursor:default}.rv-menu-panel [rvmenuitem] svg,.rv-menu-panel [rvmenuitem] [data-icon]{width:16px;height:16px;flex:none;color:var(--rv-menu-muted)}.rv-menu-panel [rvmenuitem][data-active=true] svg,.rv-menu-panel [rvmenuitem][data-active=true] [data-icon]{color:inherit}.rv-menu-panel{--rv-menu-duration: .16s;--rv-menu-easing: cubic-bezier(.16, 1, .3, 1);--rv-menu-from-y: -6px;transform-origin:top center;animation:rv-menu-in var(--rv-menu-duration) var(--rv-menu-easing)}.rv-menu-panel[data-placement=top-start],.rv-menu-panel[data-placement=top-end]{--rv-menu-from-y: 6px;transform-origin:bottom center}@media(prefers-reduced-motion:reduce){.rv-menu-panel{animation:none}}@keyframes rv-menu-in{0%{opacity:0;transform:translateY(var(--rv-menu-from-y)) scale(.96)}}\n"]
529
+ }]
530
+ }],
531
+ ctorParameters: () => [],
532
+ propDecorators: {
533
+ menu: [{
534
+ type: i0.Input,
535
+ args: [{
536
+ isSignal: true,
537
+ alias: "menu",
538
+ required: false
539
+ }]
540
+ }],
541
+ placement: [{
542
+ type: i0.Input,
543
+ args: [{
544
+ isSignal: true,
545
+ alias: "placement",
546
+ required: false
547
+ }]
548
+ }],
549
+ picked: [{
550
+ type: i0.Output,
551
+ args: ["picked"]
552
+ }],
553
+ opened: [{
554
+ type: i0.Output,
555
+ args: ["opened"]
556
+ }],
557
+ closed: [{
558
+ type: i0.Output,
559
+ args: ["closed"]
560
+ }]
561
+ }
562
+ });
563
+ export { MENU_GAP, RiviniaMenuComponent, RiviniaMenuItem, computeMenuPosition };
564
+
565
+ //# sourceMappingURL=rivinia-menu-ui.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rivinia-menu-ui.mjs","names":[],"sources":["../../../projects/menu/ui/src/lib/menu-position.ts","../../../projects/menu/ui/src/lib/menu-panel.ts"],"sourcesContent":["import type { RiviniaMenuPlacement } from '@rivinia/menu';\n\n/** Trigger rect in viewport coordinates. */\nexport interface MenuTriggerRect {\n readonly left: number;\n readonly top: number;\n readonly width: number;\n readonly height: number;\n}\n\n/** Measured panel size. */\nexport interface MenuPanelSize {\n readonly width: number;\n readonly height: number;\n}\n\n/** Viewport size. */\nexport interface MenuViewport {\n readonly width: number;\n readonly height: number;\n}\n\n/** Fixed-position coordinates plus whether the vertical side flipped. */\nexport interface MenuCoordinates {\n readonly left: number;\n readonly top: number;\n readonly flipped: boolean;\n}\n\n/** Gap between trigger and panel. */\nexport const MENU_GAP = 6;\n/** Minimum distance kept from viewport edges. */\nconst EDGE = 8;\n\n/**\n * Pure top-layer position for a menu panel: anchors to the trigger per\n * `placement` (logical, RTL-aware), flips to the opposite vertical side when\n * the preferred side overflows but the other fits, then clamps inside the\n * viewport. Zero DOM access — unit-testable with fake rects; the component\n * only measures and applies.\n */\nexport function computeMenuPosition(\n trigger: MenuTriggerRect,\n panel: MenuPanelSize,\n placement: RiviniaMenuPlacement,\n viewport: MenuViewport,\n rtl: boolean,\n gap = MENU_GAP,\n): MenuCoordinates {\n const [vertical, horizontal] = placement.split('-');\n const below = vertical !== 'top';\n // Logical start: left edge in LTR, right edge in RTL.\n const alignLeft = (horizontal !== 'end') !== rtl;\n\n let left = alignLeft ? trigger.left : trigger.left + trigger.width - panel.width;\n let top = below ? trigger.top + trigger.height + gap : trigger.top - gap - panel.height;\n let flipped = false;\n\n const fitsBelow = trigger.top + trigger.height + gap + panel.height <= viewport.height - EDGE;\n const fitsAbove = trigger.top - gap - panel.height >= EDGE;\n if (below && top + panel.height > viewport.height - EDGE && fitsAbove) {\n top = trigger.top - gap - panel.height;\n flipped = true;\n } else if (!below && top < EDGE && fitsBelow) {\n top = trigger.top + trigger.height + gap;\n flipped = true;\n }\n\n const fitsLeft = trigger.left + trigger.width - panel.width >= EDGE;\n const fitsRight = trigger.left + panel.width <= viewport.width - EDGE;\n if (alignLeft && left + panel.width > viewport.width - EDGE && fitsLeft) {\n left = trigger.left + trigger.width - panel.width;\n } else if (!alignLeft && left < EDGE && fitsRight) {\n left = trigger.left;\n }\n\n left = Math.min(Math.max(left, EDGE), Math.max(EDGE, viewport.width - panel.width - EDGE));\n top = Math.min(Math.max(top, EDGE), Math.max(EDGE, viewport.height - panel.height - EDGE));\n return { left: Math.round(left), top: Math.round(top), flipped };\n}\n","import {\n Component,\n Directive,\n ElementRef,\n ViewEncapsulation,\n afterRenderEffect,\n computed,\n effect,\n inject,\n input,\n output,\n signal,\n} from '@angular/core';\nimport type { RiviniaMenuController } from '@rivinia/menu';\nimport { RiviniaMenu } from '@rivinia/menu';\nimport type { RiviniaMenuUiPlacement } from './ui-menu-types';\nimport { computeMenuPosition } from './menu-position';\n\n/** Payload of {@link RiviniaMenuComponent.picked}. */\nexport interface MenuPick {\n readonly id: string;\n}\n\n/**\n * Marker + behavior for one navigable row. Registers itself with the\n * enclosing menu on init (unregisters on destroy), exposes `data-rv-id`\n * for focus targeting, and keeps `tabindex`/`aria-disabled` in sync.\n * Non-`rvMenuItem` content (headers, dividers, badges) is skipped by\n * keyboard navigation automatically.\n */\n@Directive({\n selector: '[rvMenuItem]',\n standalone: true,\n host: {\n '[attr.role]': 'computedRole()',\n '[attr.data-rv-id]': 'id()',\n '[attr.tabindex]': 'tab()',\n '[attr.aria-disabled]': 'disabled()',\n '[attr.data-active]': 'active()',\n '[attr.aria-checked]': 'checkedAttr()',\n '(click)': 'pick()',\n '(pointerenter)': 'hover()',\n },\n})\nexport class RiviniaMenuItem {\n readonly id = input.required<string>();\n readonly disabled = input(false, {\n transform: (v: boolean | string) => v === '' || v === true,\n });\n /**\n * Tri-state selection (e.g. multi-select options). `null` (default) renders\n * a plain `menuitem`; a boolean renders `menuitemcheckbox` + `aria-checked`\n * so screen readers announce the state (the visual check alone is silent).\n */\n readonly checked = input<boolean | null>(null);\n\n private readonly menu = inject(RiviniaMenuComponent, { optional: true, skipSelf: true });\n private readonly active = computed(() => this.menu?.controller().activeId() === this.id());\n\n protected tab(): string {\n return this.active() ? '0' : '-1';\n }\n\n protected computedRole(): string {\n return this.checked() === null ? 'menuitem' : 'menuitemcheckbox';\n }\n\n protected checkedAttr(): string | null {\n const checked = this.checked();\n return checked === null ? null : String(checked);\n }\n\n /** Mouse pick: disabled items are ignored (controller refuses them). */\n protected pick(): void {\n this.menu?.pickItem(this.id());\n }\n\n /** Mouse hover follows keyboard focus so highlight tracks the pointer. */\n protected hover(): void {\n this.menu?.hoverItem(this.id());\n }\n\n ngOnInit(): void {\n this.menu?.registerItem({ id: this.id(), disabled: this.disabled() });\n }\n\n ngOnDestroy(): void {\n this.menu?.unregisterItem(this.id());\n }\n}\n\n/**\n * Ready-made dropdown menu. Binds a {@link RiviniaMenu} controller (or owns\n * one when omitted) and projects trigger + panel:\n *\n * ```html\n * <rv-menu [menu]=\"menu\" [placement]=\"'bottom-start'\" (picked)=\"go($event)\">\n * <button rv-menu-trigger><img src=\"avatar.png\" alt=\"Account\" /></button>\n * <div rv-menu-panel>\n * <div class=\"head\">Sophie · sophie@ui.live</div>\n * <button rvMenuItem id=\"profile\">Profile</button>\n * <button rvMenuItem id=\"logout\">Sign out</button>\n * </div>\n * </rv-menu>\n * ```\n *\n * Keyboard: Enter/Space/ArrowDown opens; Escape closes and refocuses the\n * trigger; arrows/Home/End move through enabled items; Tab closes and moves\n * on (non-modal by design). Outside pointer-down closes. SSR renders closed.\n */\n@Component({\n selector: 'rv-menu',\n standalone: true,\n // Deliberately unencapsulated: projected item buttons belong to the\n // consumer's template, and emulated descendant selectors never match\n // projected content (measured in Chrome: `display` stayed `inline-block`).\n // Every rule below is scoped under `.rv-menu-panel` / `.rv-menu-trigger`,\n // so nothing leaks into application styles.\n encapsulation: ViewEncapsulation.None,\n template: `\n <div class=\"rv-menu-trigger\" (click)=\"onTriggerClick()\" (keydown)=\"onTriggerKey($event)\">\n <ng-content select=\"[rv-menu-trigger]\"></ng-content>\n </div>\n @if (controller().open()) {\n <div\n #panel\n class=\"rv-menu-panel\"\n role=\"menu\"\n [attr.popover]=\"topLayerSupported ? 'manual' : null\"\n [attr.data-placement]=\"placement()\"\n [attr.data-flipped]=\"flipped()\"\n [attr.data-positioned]=\"positioned()\"\n [attr.data-top-layer]=\"topLayerSupported\"\n (keydown)=\"onPanelKey($event)\"\n >\n <ng-content select=\"[rv-menu-panel]\"></ng-content>\n </div>\n }\n `,\n styles: [\n `\n .rv-menu-trigger {\n display: inline-flex;\n align-items: center;\n }\n .rv-menu-panel {\n display: flex;\n flex-direction: column;\n gap: 2px;\n }\n .rv-menu-panel hr {\n border: 0;\n border-top: 1px solid var(--rv-menu-border);\n margin: 6px 4px;\n }\n /* NOTE: the :host pseudo-class does not exist without encapsulation,\n so the host element is styled directly (still rv-prefixed).\n Relative positioning anchors the no-popover fallback panel. */\n rv-menu {\n position: relative;\n display: inline-block;\n }\n /* Top-layer mode (popover-capable browsers): the panel escapes every\n clipping ancestor and stacking context. Coordinates come from the\n component (fixed + measured + flipped); the absolute placement\n classes below stay as the no-popover fallback. */\n .rv-menu-panel[data-top-layer='true'] {\n position: fixed;\n inset: auto;\n margin: 0;\n }\n .rv-menu-panel[data-positioned='false'] {\n animation: none;\n }\n /* Flipped panels grow out of the opposite edge (matches coordinates). */\n .rv-menu-panel[data-top-layer='true'][data-placement^='bottom'][data-flipped='true'] {\n transform-origin: bottom center;\n }\n .rv-menu-panel[data-top-layer='true'][data-placement^='top'][data-flipped='true'] {\n transform-origin: top center;\n }\n .rv-menu-panel {\n --rv-menu-bg: #ffffff;\n --rv-menu-fg: #111111;\n --rv-menu-muted: #555555;\n --rv-menu-border: #e0e0e0;\n --rv-menu-radius: 12px;\n --rv-menu-shadow: 0 16px 48px rgba(0, 0, 0, 0.22);\n --rv-menu-pad: 6px;\n --rv-menu-width: 16rem;\n --rv-menu-font: system-ui, -apple-system, 'Segoe UI', sans-serif;\n position: absolute;\n z-index: var(--rv-menu-z, 500);\n min-width: var(--rv-menu-width);\n max-width: calc(100dvw - 2rem);\n /* Long lists scroll inside the panel instead of leaving the viewport. */\n max-height: min(20rem, calc(100dvh - 2rem));\n overflow-y: auto;\n background: var(--rv-menu-bg);\n color: var(--rv-menu-fg);\n font-family: var(--rv-menu-font);\n border: 1px solid var(--rv-menu-border);\n border-radius: var(--rv-menu-radius);\n box-shadow: var(--rv-menu-shadow);\n padding: var(--rv-menu-pad);\n }\n @media (prefers-color-scheme: dark) {\n .rv-menu-panel {\n --rv-menu-bg: #161616;\n --rv-menu-fg: #f2f2f2;\n --rv-menu-muted: #a8a8a8;\n --rv-menu-border: #333333;\n }\n }\n .rv-menu-panel[data-placement='bottom-start'] {\n inset-block-start: calc(100% + 6px);\n inset-inline-start: 0;\n }\n .rv-menu-panel[data-placement='bottom-end'] {\n inset-block-start: calc(100% + 6px);\n inset-inline-end: 0;\n }\n .rv-menu-panel[data-placement='top-start'] {\n inset-block-end: calc(100% + 6px);\n inset-inline-start: 0;\n }\n .rv-menu-panel[data-placement='top-end'] {\n inset-block-end: calc(100% + 6px);\n inset-inline-end: 0;\n }\n .rv-menu-panel [rvmenuitem] {\n display: flex;\n align-items: center;\n gap: 10px;\n width: 100%;\n min-height: 40px;\n border: 0;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n font-size: 14px;\n line-height: 1.4;\n text-align: start;\n padding: 8px 10px;\n cursor: pointer;\n }\n .rv-menu-panel [rvmenuitem][data-active='true'] {\n background: color-mix(in srgb, currentColor 10%, transparent);\n }\n .rv-menu-panel [rvmenuitem]:hover {\n background: color-mix(in srgb, currentColor 8%, transparent);\n }\n .rv-menu-panel [rvmenuitem]:focus-visible {\n outline: 2px solid currentColor;\n outline-offset: -2px;\n }\n .rv-menu-panel [rvmenuitem][aria-disabled='true'] {\n opacity: 0.45;\n cursor: default;\n }\n /* Optional icons: consumer projects any svg/icon content; the UI only\n owns sizing, alignment and tone so rows stay consistent. */\n .rv-menu-panel [rvmenuitem] svg,\n .rv-menu-panel [rvmenuitem] [data-icon] {\n width: 16px;\n height: 16px;\n flex: none;\n color: var(--rv-menu-muted);\n }\n .rv-menu-panel [rvmenuitem][data-active='true'] svg,\n .rv-menu-panel [rvmenuitem][data-active='true'] [data-icon] {\n color: inherit;\n }\n /* Opening motion (researched: Radix/shadcn/HeadlessUI pattern):\n fade + scale from the trigger edge + a small slide toward rest.\n Origin follows the vertical side so the panel grows out of the\n trigger; duration/easing stay overridable via vars. */\n .rv-menu-panel {\n --rv-menu-duration: 160ms;\n --rv-menu-easing: cubic-bezier(0.16, 1, 0.3, 1);\n --rv-menu-from-y: -6px;\n transform-origin: top center;\n animation: rv-menu-in var(--rv-menu-duration) var(--rv-menu-easing);\n }\n .rv-menu-panel[data-placement='top-start'],\n .rv-menu-panel[data-placement='top-end'] {\n --rv-menu-from-y: 6px;\n transform-origin: bottom center;\n }\n @media (prefers-reduced-motion: reduce) {\n .rv-menu-panel {\n animation: none;\n }\n }\n @keyframes rv-menu-in {\n from {\n opacity: 0;\n transform: translateY(var(--rv-menu-from-y)) scale(0.96);\n }\n }\n `,\n ],\n})\nexport class RiviniaMenuComponent {\n /**\n * External controller (shared/test double). Omitted → the component owns\n * a private one (simplest embedding).\n */\n readonly menu = input<RiviniaMenuController | null>(null);\n /** Panel placement relative to the trigger. @default 'bottom-start' */\n readonly placement = input<RiviniaMenuUiPlacement>('bottom-start');\n\n /** Fired when the user picks an enabled item (id). Closes first. */\n readonly picked = output<MenuPick>();\n readonly opened = output<void>();\n readonly closed = output<void>();\n\n private readonly owned = new RiviniaMenu();\n private readonly self = inject(ElementRef<HTMLElement>);\n /** Deferred initial focus: set on open paths, consumed after render. */\n private focusPending = false;\n /**\n * Manual popover support (top-layer). Feature-detected: modern browsers\n * escape clipping ancestors; older ones keep the absolute fallback.\n */\n protected readonly topLayerSupported =\n typeof HTMLElement !== 'undefined' && 'showPopover' in HTMLElement.prototype;\n /** Coordinates applied (gates the enter animation until measured). */\n protected readonly positioned = signal(false);\n /** Whether the panel flipped to the opposite vertical side. */\n protected readonly flipped = signal(false);\n /** Printable-character search buffer; reset by timer after 500ms quiet. */\n private typeBuffer = '';\n private typeTimer: ReturnType<typeof setTimeout> | null = null;\n\n /** Effective controller: external input or the owned fallback. */\n controller(): RiviniaMenuController {\n return this.menu() ?? this.owned;\n }\n\n /** Called by {@link RiviniaMenuItem} directives. */\n registerItem(item: { id: string; disabled: boolean }): void {\n this.controller().register(item);\n }\n\n /** Called by {@link RiviniaMenuItem} directives. */\n unregisterItem(id: string): void {\n this.controller().unregister(id);\n }\n\n constructor() {\n effect((onCleanup) => {\n if (typeof window === 'undefined') return;\n if (!this.controller().open()) return;\n const onPointer = (event: PointerEvent) => {\n const host = this.self.nativeElement;\n if (host && !host.contains(event.target as Node)) this.doClose(true);\n };\n document.addEventListener('pointerdown', onPointer);\n // Coordinates go stale on outside scroll/resize — close instead of\n // drifting (native-select-like; scrolling inside the panel is exempt).\n // Like Tab-close, this is APG-consistent non-modal behavior.\n const onScroll = (event: Event) => {\n const panel = this.self.nativeElement.querySelector('.rv-menu-panel');\n if (panel && event.target instanceof Node && panel.contains(event.target)) return;\n this.doClose(false);\n };\n window.addEventListener('scroll', onScroll, true);\n window.addEventListener('resize', onScroll);\n onCleanup(() => {\n document.removeEventListener('pointerdown', onPointer);\n window.removeEventListener('scroll', onScroll, true);\n window.removeEventListener('resize', onScroll);\n });\n });\n // APG menu-button: the trigger exposes haspopup + expanded. The trigger\n // element is consumer-owned, so the attributes sync onto its focusable\n // child (or the wrapper itself as a fallback).\n effect(() => {\n if (typeof document === 'undefined') return;\n const open = this.controller().open();\n const trigger = this.triggerEl();\n trigger?.setAttribute('aria-haspopup', 'menu');\n trigger?.setAttribute('aria-expanded', String(open));\n });\n afterRenderEffect(() => {\n if (this.controller().open() && !this.positioned()) this.showAndPosition();\n this.consumePendingFocus();\n });\n }\n\n /**\n * Top-layer presentation: show the manual popover, measure trigger +\n * panel, and pin fixed coordinates (flipping when needed). Runs inside\n * afterRender — before paint, so no flash. Falls back to the absolute CSS\n * placement when popover is unsupported (jsdom included).\n */\n private showAndPosition(): void {\n const root = this.self.nativeElement.querySelector('.rv-menu-panel') as HTMLElement | null;\n if (!root) return;\n if (typeof window === 'undefined' || !this.topLayerSupported) {\n this.positioned.set(true);\n return;\n }\n try {\n const popover = root as HTMLElement & { showPopover?: () => void };\n if (typeof popover.showPopover !== 'function') {\n this.positioned.set(true);\n return;\n }\n popover.showPopover();\n const trigger = this.triggerEl()?.getBoundingClientRect();\n if (!trigger) {\n this.positioned.set(true);\n return;\n }\n const at = computeMenuPosition(\n { left: trigger.left, top: trigger.top, width: trigger.width, height: trigger.height },\n { width: root.offsetWidth, height: root.offsetHeight },\n this.placement(),\n { width: window.innerWidth, height: window.innerHeight },\n this.isRtl(),\n );\n root.style.left = `${at.left}px`;\n root.style.top = `${at.top}px`;\n this.flipped.set(at.flipped);\n this.positioned.set(true);\n } catch {\n this.positioned.set(true);\n }\n }\n\n private isRtl(): boolean {\n try {\n const doc = typeof document === 'undefined' ? null : document.documentElement;\n if (doc?.dir === 'rtl') return true;\n if (doc?.dir === 'ltr') return false;\n return getComputedStyle(this.self.nativeElement).direction === 'rtl';\n } catch {\n return false;\n }\n }\n\n /** Mark an opening pass: deferred focus + (top-layer) re-measurement. */\n private markOpening(): void {\n this.focusPending = true;\n if (typeof window !== 'undefined' && this.topLayerSupported) {\n this.flipped.set(false);\n this.positioned.set(false);\n }\n }\n\n protected onTriggerClick(): void {\n const c = this.controller();\n c.toggle();\n if (c.open()) {\n this.opened.emit();\n this.markOpening();\n } else {\n // Focus never left the trigger on toggle-close — restore is a no-op.\n this.closed.emit();\n }\n }\n\n protected onTriggerKey(event: KeyboardEvent): void {\n const c = this.controller();\n switch (event.key) {\n case 'Enter':\n case ' ':\n case 'ArrowDown':\n event.preventDefault();\n c.openMenu('first');\n this.opened.emit();\n this.markOpening();\n break;\n case 'ArrowUp':\n event.preventDefault();\n c.openMenu('last');\n this.opened.emit();\n this.markOpening();\n break;\n case 'Escape':\n if (c.open()) {\n event.preventDefault();\n this.doClose(true);\n }\n break;\n default:\n break;\n }\n }\n\n protected onPanelKey(event: KeyboardEvent): void {\n const c = this.controller();\n switch (event.key) {\n case 'Escape':\n event.preventDefault();\n this.doClose(true);\n break;\n case 'ArrowDown':\n event.preventDefault();\n c.activateNext();\n this.focusActive();\n break;\n case 'ArrowUp':\n event.preventDefault();\n c.activatePrevious();\n this.focusActive();\n break;\n case 'Home':\n event.preventDefault();\n c.activateFirst();\n this.focusActive();\n break;\n case 'End':\n event.preventDefault();\n c.activateLast();\n this.focusActive();\n break;\n case 'Enter':\n case ' ':\n event.preventDefault();\n this.pickFocused();\n break;\n case 'Tab':\n this.doClose(false);\n break;\n default:\n this.onTypeahead(event);\n break;\n }\n }\n\n /**\n * APG printable-character search: typing moves focus to the next enabled\n * item whose visible text starts with the buffer. Repeating one character\n * cycles through its matches; the buffer resets after 500ms of quiet.\n * Labels are UI-owned (read from the DOM), so this lives in the component,\n * not the headless controller.\n */\n private onTypeahead(event: KeyboardEvent): void {\n if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;\n const ch = event.key.toLocaleLowerCase();\n // Repeating one character cycles its matches instead of growing the buffer.\n const cycling = this.typeBuffer !== '' && [...this.typeBuffer].every((c) => c === ch);\n if (!cycling) this.typeBuffer += ch;\n if (this.typeTimer !== null) clearTimeout(this.typeTimer);\n this.typeTimer = setTimeout(() => {\n this.typeBuffer = '';\n this.typeTimer = null;\n }, 500);\n const id = this.matchTypeahead(this.typeBuffer);\n if (id !== null && this.controller().activate(id)) this.focusActive();\n }\n\n private matchTypeahead(buffer: string): string | null {\n const root: HTMLElement | null = this.self.nativeElement.querySelector('.rv-menu-panel');\n if (!root) return null;\n const enabled = new Set(\n this.controller()\n .items()\n .filter((i) => !i.disabled)\n .map((i) => i.id),\n );\n const rows = [...root.querySelectorAll('[data-rv-id]')]\n .map((el) => ({\n id: el.getAttribute('data-rv-id') ?? '',\n text: (el.textContent ?? '').trim().toLocaleLowerCase(),\n }))\n .filter((r) => enabled.has(r.id) && r.text.startsWith(buffer));\n if (rows.length === 0) return null;\n if (new Set(buffer).size === 1) {\n // Repeated character: cycle from after the current item.\n const ids = rows.map((r) => r.id);\n const at = ids.indexOf(this.controller().activeId() ?? '');\n return ids[(at + 1) % ids.length];\n }\n return rows[0].id;\n }\n\n private pickFocused(): void {\n const c = this.controller();\n const id = c.pickActive();\n if (id === null) return;\n this.doClose(false);\n this.picked.emit({ id });\n }\n\n /**\n * Mouse pick of a concrete item. Called by {@link RiviniaMenuItem}.\n * Disabled/unknown ids are ignored via `activate` (returns false).\n */\n pickItem(id: string): void {\n if (!this.controller().activate(id)) return;\n this.doClose(false);\n this.picked.emit({ id });\n }\n\n /** Mouse hover tracks keyboard focus. Called by {@link RiviniaMenuItem}. */\n hoverItem(id: string): void {\n this.controller().activate(id);\n }\n\n private doClose(restoreFocus: boolean): void {\n const was = this.controller().open();\n this.controller().closeMenu();\n if (was) this.closed.emit();\n if (restoreFocus) this.focusTrigger();\n }\n\n /**\n * Initial focus after open. The panel only exists in the DOM after the\n * opening change-detection pass, so focusing synchronously races rendering.\n * afterRenderEffect re-runs when `open` flips (reactive read) and executes\n * after the commit — panel present, in apps and in tests. Never runs on the\n * server (SSR-safe by construction). Skips when focus already sits inside\n * the panel, so later renders never steal it back.\n */\n private consumePendingFocus(): void {\n const open = this.controller().open();\n if (!open || !this.focusPending) return;\n this.focusPending = false;\n const root: HTMLElement | null = this.self.nativeElement.querySelector('.rv-menu-panel');\n const active = this.activeDocumentElement();\n if (root && (!active || !root.contains(active))) this.focusActive();\n }\n\n private focusActive(): void {\n const id = this.controller().activeId();\n const root: HTMLElement | null = this.self.nativeElement.querySelector('.rv-menu-panel');\n if (!root) return;\n const target = id\n ? root.querySelector<HTMLElement>(`[data-rv-id=\"${CSS.escape(id)}\"]`)\n : root.querySelector<HTMLElement>('[rvmenuitem]');\n try {\n target?.focus({ preventScroll: true });\n } catch {\n // Focus is best-effort.\n }\n }\n\n private activeDocumentElement(): Element | null {\n try {\n return typeof document === 'undefined' ? null : document.activeElement;\n } catch {\n return null;\n }\n }\n\n /** Focusable trigger child (APG target) or the wrapper as fallback. */\n private triggerEl(): HTMLElement | null {\n const wrap = this.self.nativeElement.querySelector('.rv-menu-trigger') as HTMLElement | null;\n if (!wrap) return null;\n return (wrap.querySelector('button, a, [tabindex]') as HTMLElement | null) ?? wrap;\n }\n\n private focusTrigger(): void {\n try {\n this.triggerEl()?.focus({ preventScroll: true });\n } catch {\n // Best-effort.\n }\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,71 @@
1
+ import { signal } from "@angular/core";
2
+ var RiviniaMenu = class {
3
+ open = signal(false, ...ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []);
4
+ activeId = signal(null, ...ngDevMode ? [{ debugName: "activeId" }] : /* istanbul ignore next */ []);
5
+ _items = signal([], ...ngDevMode ? [{ debugName: "_items" }] : /* istanbul ignore next */ []);
6
+ items = this._items.asReadonly();
7
+ register(item) {
8
+ this._items.update((list) => {
9
+ const at = list.findIndex((i) => i.id === item.id);
10
+ if (at === -1) return [...list, { ...item }];
11
+ return list.map((i, index) => index === at ? {
12
+ ...i,
13
+ ...item
14
+ } : i);
15
+ });
16
+ }
17
+ unregister(id) {
18
+ this._items.update((list) => list.filter((i) => i.id !== id));
19
+ if (this.activeId() === id) this.activeId.set(null);
20
+ }
21
+ openMenu(focus = "first") {
22
+ this.open.set(true);
23
+ if (focus === "first") this.activateFirst();
24
+ else if (focus === "last") this.activateLast();
25
+ else this.activeId.set(null);
26
+ }
27
+ closeMenu() {
28
+ this.open.set(false);
29
+ this.activeId.set(null);
30
+ }
31
+ toggle(focus = "first") {
32
+ if (this.open()) this.closeMenu();
33
+ else this.openMenu(focus);
34
+ }
35
+ pickActive() {
36
+ return this.activeId();
37
+ }
38
+ activateFirst() {
39
+ this.activeId.set(firstEnabled(this._items()));
40
+ }
41
+ activateLast() {
42
+ const enabled = this._items().filter((i) => !i.disabled);
43
+ this.activeId.set(enabled.length > 0 ? enabled[enabled.length - 1].id : null);
44
+ }
45
+ activateNext() {
46
+ this.step(1);
47
+ }
48
+ activatePrevious() {
49
+ this.step(-1);
50
+ }
51
+ activate(id) {
52
+ if (!this._items().find((i) => i.id === id && !i.disabled)) return false;
53
+ this.activeId.set(id);
54
+ return true;
55
+ }
56
+ step(direction) {
57
+ const enabled = this._items().filter((i) => !i.disabled);
58
+ if (enabled.length === 0) {
59
+ this.activeId.set(null);
60
+ return;
61
+ }
62
+ const next = enabled[(enabled.findIndex((i) => i.id === this.activeId()) + direction + enabled.length) % enabled.length];
63
+ this.activeId.set(next.id);
64
+ }
65
+ };
66
+ function firstEnabled(items) {
67
+ return items.find((i) => !i.disabled)?.id ?? null;
68
+ }
69
+ export { RiviniaMenu };
70
+
71
+ //# sourceMappingURL=rivinia-menu.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rivinia-menu.mjs","names":[],"sources":["../../../projects/menu/src/lib/rivinia-menu.ts"],"sourcesContent":["import { signal } from '@angular/core';\nimport type { RiviniaMenuController, RiviniaMenuFocus, RiviniaMenuItem } from './menu-types';\n\n/**\n * Headless per-menu controller. Owns open state, the registered items and\n * the active (keyboard-focused) item — no DOM, no styling, no positioning.\n * Instantiate one per menu (`new RiviniaMenu()`); rendering and focus moves\n * are the UI layer's job (`@rivinia/menu/ui` implements them).\n *\n * Navigation skips disabled items and wraps around. Closing clears the\n * active item; the UI restores focus to the trigger.\n */\nexport class RiviniaMenu implements RiviniaMenuController {\n /** Whether the panel is open. */\n readonly open = signal(false);\n /** Id of the keyboard-active item (`null` when closed or unfocused). */\n readonly activeId = signal<string | null>(null);\n\n private readonly _items = signal<readonly RiviniaMenuItem[]>([]);\n /** Registered items, in registration order. */\n readonly items = this._items.asReadonly();\n\n /** Register an item (idempotent by id — re-register updates it). */\n register(item: RiviniaMenuItem): void {\n this._items.update((list) => {\n const at = list.findIndex((i) => i.id === item.id);\n if (at === -1) return [...list, { ...item }];\n return list.map((i, index) => (index === at ? { ...i, ...item } : i));\n });\n }\n\n /** Remove an item; clears `activeId` when it pointed at it. */\n unregister(id: string): void {\n this._items.update((list) => list.filter((i) => i.id !== id));\n if (this.activeId() === id) this.activeId.set(null);\n }\n\n /** Open and optionally focus the first/last enabled item. */\n openMenu(focus: RiviniaMenuFocus = 'first'): void {\n this.open.set(true);\n if (focus === 'first') this.activateFirst();\n else if (focus === 'last') this.activateLast();\n else this.activeId.set(null);\n }\n\n /** Close and clear the active item. Focus restoration is UI-owned. */\n closeMenu(): void {\n this.open.set(false);\n this.activeId.set(null);\n }\n\n toggle(focus: RiviniaMenuFocus = 'first'): void {\n if (this.open()) this.closeMenu();\n else this.openMenu(focus);\n }\n\n /** The active id, or `null` when nothing is active. */\n pickActive(): string | null {\n return this.activeId();\n }\n\n activateFirst(): void {\n this.activeId.set(firstEnabled(this._items()));\n }\n\n activateLast(): void {\n const enabled = this._items().filter((i) => !i.disabled);\n this.activeId.set(enabled.length > 0 ? enabled[enabled.length - 1].id : null);\n }\n\n activateNext(): void {\n this.step(1);\n }\n\n activatePrevious(): void {\n this.step(-1);\n }\n\n activate(id: string): boolean {\n const found = this._items().find((i) => i.id === id && !i.disabled);\n if (!found) return false;\n this.activeId.set(id);\n return true;\n }\n\n private step(direction: 1 | -1): void {\n const enabled = this._items().filter((i) => !i.disabled);\n if (enabled.length === 0) {\n this.activeId.set(null);\n return;\n }\n const at = enabled.findIndex((i) => i.id === this.activeId());\n const next = enabled[(at + direction + enabled.length) % enabled.length];\n this.activeId.set(next.id);\n }\n}\n\nfunction firstEnabled(items: readonly RiviniaMenuItem[]): string | null {\n return items.find((i) => !i.disabled)?.id ?? null;\n}\n"],"mappings":";AAYA,IAAa,cAAb,MAAwB;CAEb,OAAO,OAAO,OAAM,GAAA,YAAA,CAAA,EAAA,WAAA,OAAA,CAAA,+BAAA,CAAA,CAAA;CAEpB,WAAW,OAAsB,MAAK,GAAA,YAAA,CAAA,EAAA,WAAA,WAAA,CAAA,+BAAA,CAAA,CAAA;CAE9B,SAAS,OAAmC,CAAA,GAAG,GAAA,YAAA,CAAA,EAAA,WAAA,SAAA,CAAA,+BAAA,CAAA,CAAA;CAEvD,QAAQ,KAAK,OAAO,WAAU;CAGvC,SAAS,MAAqB;EAC5B,KAAK,OAAO,QAAQ,SAAQ;GAC1B,MAAM,KAAK,KAAK,WAAW,MAAM,EAAE,OAAO,KAAK,EAAE;GACjD,IAAI,OAAO,IAAI,OAAO,CAAC,GAAG,MAAM,EAAE,GAAG,KAAI,CAAE;GAC3C,OAAO,KAAK,KAAK,GAAG,UAAW,UAAU,KAAK;IAAE,GAAG;IAAG,GAAG;GAAI,IAAK,CAAE;EACtE,CAAC;CACH;CAGA,WAAW,IAAU;EACnB,KAAK,OAAO,QAAQ,SAAS,KAAK,QAAQ,MAAM,EAAE,OAAO,EAAE,CAAC;EAC5D,IAAI,KAAK,SAAQ,MAAO,IAAI,KAAK,SAAS,IAAI,IAAI;CACpD;CAGA,SAAS,QAA0B,SAAO;EACxC,KAAK,KAAK,IAAI,IAAI;EAClB,IAAI,UAAU,SAAS,KAAK,cAAa;OACpC,IAAI,UAAU,QAAQ,KAAK,aAAY;OACvC,KAAK,SAAS,IAAI,IAAI;CAC7B;CAGA,YAAS;EACP,KAAK,KAAK,IAAI,KAAK;EACnB,KAAK,SAAS,IAAI,IAAI;CACxB;CAEA,OAAO,QAA0B,SAAO;EACtC,IAAI,KAAK,KAAI,GAAI,KAAK,UAAS;OAC1B,KAAK,SAAS,KAAK;CAC1B;CAGA,aAAU;EACR,OAAO,KAAK,SAAQ;CACtB;CAEA,gBAAa;EACX,KAAK,SAAS,IAAI,aAAa,KAAK,OAAM,CAAE,CAAC;CAC/C;CAEA,eAAY;EACV,MAAM,UAAU,KAAK,OAAM,CAAE,CAAC,QAAQ,MAAM,CAAC,EAAE,QAAQ;EACvD,KAAK,SAAS,IAAI,QAAQ,SAAS,IAAI,QAAQ,QAAQ,SAAS,EAAE,CAAC,KAAK,IAAI;CAC9E;CAEA,eAAY;EACV,KAAK,KAAK,CAAC;CACb;CAEA,mBAAgB;EACd,KAAK,KAAK,EAAE;CACd;CAEA,SAAS,IAAU;EAEjB,IAAI,CADU,KAAK,OAAM,CAAE,CAAC,MAAM,MAAM,EAAE,OAAO,MAAM,CAAC,EAAE,QACrD,GAAO,OAAO;EACnB,KAAK,SAAS,IAAI,EAAE;EACpB,OAAO;CACT;CAEQ,KAAK,WAAiB;EAC5B,MAAM,UAAU,KAAK,OAAM,CAAE,CAAC,QAAQ,MAAM,CAAC,EAAE,QAAQ;EACvD,IAAI,QAAQ,WAAW,GAAG;GACxB,KAAK,SAAS,IAAI,IAAI;GACtB;EACF;EAEA,MAAM,OAAO,SADF,QAAQ,WAAW,MAAM,EAAE,OAAO,KAAK,SAAQ,CACpC,IAAK,YAAY,QAAQ,UAAU,QAAQ;EACjE,KAAK,SAAS,IAAI,KAAK,EAAE;CAC3B;AACD;AAED,SAAS,aAAa,OAAiC;CACrD,OAAO,MAAM,MAAM,MAAM,CAAC,EAAE,QAAQ,CAAC,EAAE,MAAM;AAC/C"}
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@rivinia/menu",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Headless menu behavior for Angular: open state, keyboard navigation, item registry.",
5
+ "keywords": [
6
+ "angular",
7
+ "menu",
8
+ "dropdown",
9
+ "signals",
10
+ "typescript"
11
+ ],
12
+ "license": "MIT",
13
+ "peerDependencies": {
14
+ "@angular/core": "^22.0.0"
15
+ },
16
+ "sideEffects": false,
17
+ "module": "fesm2022/rivinia-menu.mjs",
18
+ "typings": "types/rivinia-menu.d.ts",
19
+ "exports": {
20
+ "./package.json": {
21
+ "default": "./package.json"
22
+ },
23
+ ".": {
24
+ "types": "./types/rivinia-menu.d.ts",
25
+ "default": "./fesm2022/rivinia-menu.mjs"
26
+ },
27
+ "./ui": {
28
+ "types": "./types/rivinia-menu-ui.d.ts",
29
+ "default": "./fesm2022/rivinia-menu-ui.mjs"
30
+ }
31
+ },
32
+ "type": "module",
33
+ "dependencies": {
34
+ "tslib": "^2.3.0"
35
+ }
6
36
  }
@@ -0,0 +1,208 @@
1
+ import { RiviniaMenuController, RiviniaMenuPlacement } from "@rivinia/menu";
2
+ import * as i0 from "@angular/core";
3
+ /** Panel placement relative to the trigger. Logical (RTL-aware). */
4
+ type RiviniaMenuUiPlacement = RiviniaMenuPlacement;
5
+ /** Payload of {@link RiviniaMenuComponent.picked}. */
6
+ interface MenuPick {
7
+ readonly id: string;
8
+ }
9
+ /**
10
+ * Marker + behavior for one navigable row. Registers itself with the
11
+ * enclosing menu on init (unregisters on destroy), exposes `data-rv-id`
12
+ * for focus targeting, and keeps `tabindex`/`aria-disabled` in sync.
13
+ * Non-`rvMenuItem` content (headers, dividers, badges) is skipped by
14
+ * keyboard navigation automatically.
15
+ */
16
+ export declare class RiviniaMenuItem {
17
+ readonly id: import("@angular/core").InputSignal<string>;
18
+ readonly disabled: import("@angular/core").InputSignalWithTransform<boolean, string | boolean>;
19
+ /**
20
+ * Tri-state selection (e.g. multi-select options). `null` (default) renders
21
+ * a plain `menuitem`; a boolean renders `menuitemcheckbox` + `aria-checked`
22
+ * so screen readers announce the state (the visual check alone is silent).
23
+ */
24
+ readonly checked: import("@angular/core").InputSignal<boolean | null>;
25
+ private readonly menu;
26
+ private readonly active;
27
+ protected tab(): string;
28
+ protected computedRole(): string;
29
+ protected checkedAttr(): string | null;
30
+ /** Mouse pick: disabled items are ignored (controller refuses them). */
31
+ protected pick(): void;
32
+ /** Mouse hover follows keyboard focus so highlight tracks the pointer. */
33
+ protected hover(): void;
34
+ ngOnInit(): void;
35
+ ngOnDestroy(): void;
36
+ static ɵfac: i0.ɵɵFactoryDeclaration<RiviniaMenuItem, never>;
37
+ static ɵdir: i0.ɵɵDirectiveDeclaration<RiviniaMenuItem, "[rvMenuItem]", never, {
38
+ "id": {
39
+ "alias": "id";
40
+ "required": true;
41
+ "isSignal": true;
42
+ };
43
+ "disabled": {
44
+ "alias": "disabled";
45
+ "required": false;
46
+ "isSignal": true;
47
+ };
48
+ "checked": {
49
+ "alias": "checked";
50
+ "required": false;
51
+ "isSignal": true;
52
+ };
53
+ }, {}, never, never, true, never>;
54
+ }
55
+ /**
56
+ * Ready-made dropdown menu. Binds a {@link RiviniaMenu} controller (or owns
57
+ * one when omitted) and projects trigger + panel:
58
+ *
59
+ * ```html
60
+ * <rv-menu [menu]="menu" [placement]="'bottom-start'" (picked)="go($event)">
61
+ * <button rv-menu-trigger><img src="avatar.png" alt="Account" /></button>
62
+ * <div rv-menu-panel>
63
+ * <div class="head">Sophie · sophie@ui.live</div>
64
+ * <button rvMenuItem id="profile">Profile</button>
65
+ * <button rvMenuItem id="logout">Sign out</button>
66
+ * </div>
67
+ * </rv-menu>
68
+ * ```
69
+ *
70
+ * Keyboard: Enter/Space/ArrowDown opens; Escape closes and refocuses the
71
+ * trigger; arrows/Home/End move through enabled items; Tab closes and moves
72
+ * on (non-modal by design). Outside pointer-down closes. SSR renders closed.
73
+ */
74
+ export declare class RiviniaMenuComponent {
75
+ /**
76
+ * External controller (shared/test double). Omitted → the component owns
77
+ * a private one (simplest embedding).
78
+ */
79
+ readonly menu: import("@angular/core").InputSignal<RiviniaMenuController | null>;
80
+ /** Panel placement relative to the trigger. @default 'bottom-start' */
81
+ readonly placement: import("@angular/core").InputSignal<import("@rivinia/menu").RiviniaMenuPlacement>;
82
+ /** Fired when the user picks an enabled item (id). Closes first. */
83
+ readonly picked: import("@angular/core").OutputEmitterRef<MenuPick>;
84
+ readonly opened: import("@angular/core").OutputEmitterRef<void>;
85
+ readonly closed: import("@angular/core").OutputEmitterRef<void>;
86
+ private readonly owned;
87
+ private readonly self;
88
+ /** Deferred initial focus: set on open paths, consumed after render. */
89
+ private focusPending;
90
+ /**
91
+ * Manual popover support (top-layer). Feature-detected: modern browsers
92
+ * escape clipping ancestors; older ones keep the absolute fallback.
93
+ */
94
+ protected readonly topLayerSupported: boolean;
95
+ /** Coordinates applied (gates the enter animation until measured). */
96
+ protected readonly positioned: import("@angular/core").WritableSignal<boolean>;
97
+ /** Whether the panel flipped to the opposite vertical side. */
98
+ protected readonly flipped: import("@angular/core").WritableSignal<boolean>;
99
+ /** Printable-character search buffer; reset by timer after 500ms quiet. */
100
+ private typeBuffer;
101
+ private typeTimer;
102
+ /** Effective controller: external input or the owned fallback. */
103
+ controller(): RiviniaMenuController;
104
+ /** Called by {@link RiviniaMenuItem} directives. */
105
+ registerItem(item: {
106
+ id: string;
107
+ disabled: boolean;
108
+ }): void;
109
+ /** Called by {@link RiviniaMenuItem} directives. */
110
+ unregisterItem(id: string): void;
111
+ constructor();
112
+ /**
113
+ * Top-layer presentation: show the manual popover, measure trigger +
114
+ * panel, and pin fixed coordinates (flipping when needed). Runs inside
115
+ * afterRender — before paint, so no flash. Falls back to the absolute CSS
116
+ * placement when popover is unsupported (jsdom included).
117
+ */
118
+ private showAndPosition;
119
+ private isRtl;
120
+ /** Mark an opening pass: deferred focus + (top-layer) re-measurement. */
121
+ private markOpening;
122
+ protected onTriggerClick(): void;
123
+ protected onTriggerKey(event: KeyboardEvent): void;
124
+ protected onPanelKey(event: KeyboardEvent): void;
125
+ /**
126
+ * APG printable-character search: typing moves focus to the next enabled
127
+ * item whose visible text starts with the buffer. Repeating one character
128
+ * cycles through its matches; the buffer resets after 500ms of quiet.
129
+ * Labels are UI-owned (read from the DOM), so this lives in the component,
130
+ * not the headless controller.
131
+ */
132
+ private onTypeahead;
133
+ private matchTypeahead;
134
+ private pickFocused;
135
+ /**
136
+ * Mouse pick of a concrete item. Called by {@link RiviniaMenuItem}.
137
+ * Disabled/unknown ids are ignored via `activate` (returns false).
138
+ */
139
+ pickItem(id: string): void;
140
+ /** Mouse hover tracks keyboard focus. Called by {@link RiviniaMenuItem}. */
141
+ hoverItem(id: string): void;
142
+ private doClose;
143
+ /**
144
+ * Initial focus after open. The panel only exists in the DOM after the
145
+ * opening change-detection pass, so focusing synchronously races rendering.
146
+ * afterRenderEffect re-runs when `open` flips (reactive read) and executes
147
+ * after the commit — panel present, in apps and in tests. Never runs on the
148
+ * server (SSR-safe by construction). Skips when focus already sits inside
149
+ * the panel, so later renders never steal it back.
150
+ */
151
+ private consumePendingFocus;
152
+ private focusActive;
153
+ private activeDocumentElement;
154
+ /** Focusable trigger child (APG target) or the wrapper as fallback. */
155
+ private triggerEl;
156
+ private focusTrigger;
157
+ static ɵfac: i0.ɵɵFactoryDeclaration<RiviniaMenuComponent, never>;
158
+ static ɵcmp: i0.ɵɵComponentDeclaration<RiviniaMenuComponent, "rv-menu", never, {
159
+ "menu": {
160
+ "alias": "menu";
161
+ "required": false;
162
+ "isSignal": true;
163
+ };
164
+ "placement": {
165
+ "alias": "placement";
166
+ "required": false;
167
+ "isSignal": true;
168
+ };
169
+ }, {
170
+ "picked": "picked";
171
+ "opened": "opened";
172
+ "closed": "closed";
173
+ }, never, ["[rv-menu-trigger]", "[rv-menu-panel]"], true, never>;
174
+ }
175
+ /** Trigger rect in viewport coordinates. */
176
+ interface MenuTriggerRect {
177
+ readonly left: number;
178
+ readonly top: number;
179
+ readonly width: number;
180
+ readonly height: number;
181
+ }
182
+ /** Measured panel size. */
183
+ interface MenuPanelSize {
184
+ readonly width: number;
185
+ readonly height: number;
186
+ }
187
+ /** Viewport size. */
188
+ interface MenuViewport {
189
+ readonly width: number;
190
+ readonly height: number;
191
+ }
192
+ /** Fixed-position coordinates plus whether the vertical side flipped. */
193
+ interface MenuCoordinates {
194
+ readonly left: number;
195
+ readonly top: number;
196
+ readonly flipped: boolean;
197
+ }
198
+ /** Gap between trigger and panel. */
199
+ export declare const MENU_GAP = 6;
200
+ /**
201
+ * Pure top-layer position for a menu panel: anchors to the trigger per
202
+ * `placement` (logical, RTL-aware), flips to the opposite vertical side when
203
+ * the preferred side overflows but the other fits, then clamps inside the
204
+ * viewport. Zero DOM access — unit-testable with fake rects; the component
205
+ * only measures and applies.
206
+ */
207
+ export declare function computeMenuPosition(trigger: MenuTriggerRect, panel: MenuPanelSize, placement: RiviniaMenuPlacement, viewport: MenuViewport, rtl: boolean, gap?: number): MenuCoordinates;
208
+ export type { MenuCoordinates, MenuPanelSize, MenuPick, MenuTriggerRect, MenuViewport, RiviniaMenuUiPlacement };
@@ -0,0 +1,71 @@
1
+ import { Signal } from "@angular/core";
2
+ /**
3
+ * One navigable menu entry. The controller only tracks identity and
4
+ * enabled state — labels, icons, badges and actions are UI-owned.
5
+ */
6
+ interface RiviniaMenuItem {
7
+ readonly id: string;
8
+ /** Skipped by keyboard navigation and reported as disabled. @default false */
9
+ readonly disabled?: boolean;
10
+ }
11
+ /** Panel placement relative to the trigger. Logical (RTL-aware). */
12
+ type RiviniaMenuPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
13
+ /** Which item takes focus when the menu opens. @default 'first' */
14
+ type RiviniaMenuFocus = 'first' | 'last' | 'none';
15
+ /**
16
+ * Structural slice of {@link RiviniaMenu} a UI layer drives. The full
17
+ * controller satisfies it from any module realm (same input-composition
18
+ * rule as `@rivinia/modal`'s host manager interface).
19
+ */
20
+ interface RiviniaMenuController {
21
+ readonly open: Signal<boolean>;
22
+ readonly activeId: Signal<string | null>;
23
+ readonly items: Signal<readonly RiviniaMenuItem[]>;
24
+ register(item: RiviniaMenuItem): void;
25
+ unregister(id: string): void;
26
+ openMenu(focus?: RiviniaMenuFocus): void;
27
+ closeMenu(): void;
28
+ toggle(focus?: RiviniaMenuFocus): void;
29
+ pickActive(): string | null;
30
+ activateFirst(): void;
31
+ activateLast(): void;
32
+ activateNext(): void;
33
+ activatePrevious(): void;
34
+ activate(id: string): boolean;
35
+ }
36
+ /**
37
+ * Headless per-menu controller. Owns open state, the registered items and
38
+ * the active (keyboard-focused) item — no DOM, no styling, no positioning.
39
+ * Instantiate one per menu (`new RiviniaMenu()`); rendering and focus moves
40
+ * are the UI layer's job (`@rivinia/menu/ui` implements them).
41
+ *
42
+ * Navigation skips disabled items and wraps around. Closing clears the
43
+ * active item; the UI restores focus to the trigger.
44
+ */
45
+ export declare class RiviniaMenu implements RiviniaMenuController {
46
+ /** Whether the panel is open. */
47
+ readonly open: import("@angular/core").WritableSignal<boolean>;
48
+ /** Id of the keyboard-active item (`null` when closed or unfocused). */
49
+ readonly activeId: import("@angular/core").WritableSignal<string | null>;
50
+ private readonly _items;
51
+ /** Registered items, in registration order. */
52
+ readonly items: import("@angular/core").Signal<readonly RiviniaMenuItem[]>;
53
+ /** Register an item (idempotent by id — re-register updates it). */
54
+ register(item: RiviniaMenuItem): void;
55
+ /** Remove an item; clears `activeId` when it pointed at it. */
56
+ unregister(id: string): void;
57
+ /** Open and optionally focus the first/last enabled item. */
58
+ openMenu(focus?: RiviniaMenuFocus): void;
59
+ /** Close and clear the active item. Focus restoration is UI-owned. */
60
+ closeMenu(): void;
61
+ toggle(focus?: RiviniaMenuFocus): void;
62
+ /** The active id, or `null` when nothing is active. */
63
+ pickActive(): string | null;
64
+ activateFirst(): void;
65
+ activateLast(): void;
66
+ activateNext(): void;
67
+ activatePrevious(): void;
68
+ activate(id: string): boolean;
69
+ private step;
70
+ }
71
+ export type { RiviniaMenuController, RiviniaMenuFocus, RiviniaMenuItem, RiviniaMenuPlacement };