@rivinia/menu 0.0.0-stage → 0.3.1

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.
@@ -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 contentChildren,\n effect,\n inject,\n input,\n output,\n signal,\n} from '@angular/core';\nimport type { RiviniaMenuController, RiviniaMenuItemData } 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/** Per-instance panel id counter (client-side; SSR renders closed). */\nlet nextPanelId = 0;\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]': 'isDisabled()',\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 host = inject(ElementRef<HTMLElement>, { optional: true });\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 /**\n * `disabled` collides with the native `<button disabled>` property, so a\n * static attribute on a native button element can land on the DOM instead\n * of this input (measured: `<button rvMenuItem disabled>` reported\n * `aria-disabled=\"false\"`). Accept both so the two spellings agree.\n */\n private isDisabled(): boolean {\n if (this.disabled()) return true;\n return this.host?.nativeElement?.disabled === true;\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 /** Id currently present in the registry, so renames can be de-registered. */\n private registeredId: string | null = null;\n\n constructor() {\n // `[disabled]` and `id` are ordinary bindings that change at runtime.\n // Registering once in ngOnInit left the registry stale — measured:\n // toggling `[disabled]` on a focused row still let Enter activate it.\n //\n // The initial registration stays in ngOnInit (synchronous) because an\n // effect-based first pass lands after the opening change-detection tick,\n // which raced the menu's own open and produced intermittently empty\n // panels. The effect only handles SUBSEQUENT changes.\n effect(() => {\n const id = this.id();\n const menu = this.menu;\n if (this.registeredId === null) return;\n if (this.registeredId !== id) menu?.unregisterItem(this.registeredId);\n // Re-register unconditionally: covers a `disabled` flip as well as an\n // id rename (register is idempotent by id).\n menu?.registerItem({ id, disabled: this.isDisabled() });\n this.registeredId = id;\n });\n }\n\n ngOnInit(): void {\n this.registeredId = this.id();\n this.menu?.registerItem({ id: this.id(), disabled: this.isDisabled() });\n }\n\n ngOnDestroy(): void {\n if (this.registeredId === null) return;\n this.menu?.unregisterItem(this.registeredId);\n this.registeredId = null;\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 // The declarative `[items]` mode renders its own rows through the SAME\n // directive consumers use, so registration, keyboard and click handling have\n // exactly one implementation.\n imports: [RiviniaMenuItem],\n template: `\n <div class=\"rv-menu-trigger\" (click)=\"onTriggerClick()\" (keydown)=\"onTriggerKey($event)\">\n <ng-content select=\"[rv-menu-trigger], [rvMenuTrigger]\"></ng-content>\n </div>\n @if (controller().open()) {\n <div\n #panel\n class=\"rv-menu-panel\"\n [class]=\"panelClass()\"\n role=\"menu\"\n [attr.id]=\"panelId\"\n [attr.aria-label]=\"menuLabel()\"\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 @if (dataItems().length > 0) {\n @for (item of dataItems(); track item.id) {\n <button\n rvMenuItem\n [id]=\"item.id\"\n [disabled]=\"item.disabled ?? false\"\n [checked]=\"checkedFor(item.id)\"\n [class]=\"itemClass()\"\n >\n {{ item.label }}\n </button>\n }\n }\n <ng-content select=\"[rv-menu-panel], [rvMenuPanel]\"></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, #e0e0e0);\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 position: absolute;\n z-index: var(--rv-menu-z, 500);\n min-width: var(--rv-menu-width, 16rem);\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, #ffffff);\n color: var(--rv-menu-fg, #111111);\n font-family: var(--rv-menu-font, system-ui, -apple-system, 'Segoe UI', sans-serif);\n border: 1px solid var(--rv-menu-border, #e0e0e0);\n border-radius: var(--rv-menu-radius, 12px);\n box-shadow: var(--rv-menu-shadow, 0 16px 48px rgba(0, 0, 0, 0.22));\n padding: var(--rv-menu-pad, 6px);\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 transform-origin: top center;\n animation: rv-menu-in var(--rv-menu-duration, 160ms)\n var(--rv-menu-easing, cubic-bezier(0.16, 1, 0.3, 1));\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, -6px)) 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 * Accessible name for the menu panel (`aria-label`). Menus should be named\n * so screen-reader users know what they chose from; omitted → no label.\n *\n * Named `menuLabel` (not `label`) so it cannot be confused with the\n * per-item `label` in {@link RiviniaMenuItemData}.\n */\n readonly menuLabel = input<string | null>(null);\n /**\n * Declarative items. When provided, the panel renders these rows itself —\n * no `rvMenuItem` markup and no controller needed. Mutually exclusive with\n * projected `rvMenuItem` children (see `ambiguous`).\n */\n readonly items = input<readonly RiviniaMenuItemData[] | null>(null);\n /** Extra class(es) on the panel, alongside `.rv-menu-panel`. */\n readonly panelClass = input<string | null>(null);\n /** Extra class(es) on data-driven rows only (projected rows use your markup). */\n readonly itemClass = input<string | null>(null);\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 /** Item directives the consumer projected (not the ones we render). */\n private readonly projectedItems = contentChildren(RiviniaMenuItem, { descendants: true });\n /**\n * Stable panel id for `aria-controls`. Instance-unique via module counter;\n * SSR-safe because the panel (and its id) only renders client-side on open.\n */\n protected readonly panelId = `rv-menu-panel-${++nextPanelId}`;\n /** Deferred initial focus: set on open paths, consumed after render. */\n private focusPending = false;\n /** True between an open and the first row registered for it. */\n private pendingActivation = 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 /** Data-driven rows to render, or empty when the consumer projects markup. */\n protected dataItems(): readonly RiviniaMenuItemData[] {\n return this.ambiguous() ? [] : (this.items() ?? []);\n }\n\n /**\n * Checkbox semantics only when a consumer supplied an external controller\n * with `multi`-style values; a single-select menu keeps plain `menuitem`\n * roles. Kept a method (not a computed) so it stays cheap and reactive via\n * template reads.\n */\n protected checkedFor(id: string): boolean | null {\n const c = this.controller() as RiviniaMenuController & {\n isSelected?: (id: string) => boolean;\n };\n return typeof c.isSelected === 'function' ? c.isSelected(id) : null;\n }\n\n /**\n * `[items]` and projected `rvMenuItem` children are two ways to populate the\n * same panel. Supplying both is ambiguous: we throw instead of silently\n * picking a winner, because a silently-ignored list is a data-loss bug.\n */\n protected ambiguous(): boolean {\n return this.items() !== null && this.projectedItems().length > 0;\n }\n\n /** Called by {@link RiviniaMenuItem} directives. */\n registerItem(item: { id: string; disabled: boolean }): void {\n const c = this.controller();\n c.register(item);\n // Rows can register AFTER the panel opened — the declarative `[items]`\n // path only renders them once `open()` is true, so at open time the\n // registry was still empty and `openMenu('first')` had nothing to\n // activate (measured: `activeId` stayed null, ArrowDown could not move,\n // and the highlight never appeared).\n if (this.pendingActivation && c.open() && c.activeId() === null && !item.disabled) {\n c.activateFirst();\n this.pendingActivation = false;\n }\n }\n\n /** Called by {@link RiviniaMenuItem} directives. */\n unregisterItem(id: string): void {\n this.controller().unregister(id);\n }\n\n constructor() {\n effect(() => {\n if (!this.ambiguous()) return;\n throw new Error(\n 'rv-menu: `[items]` cannot be combined with projected `rvMenuItem` ' +\n 'children — pick one way to populate the panel.',\n );\n });\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 // Close WITHOUT restoring focus: the pointer is heading somewhere the\n // browser will focus naturally (or nowhere focusable). Stealing it\n // back to the trigger would break that (APG + WCAG focus-visible).\n if (host && !host.contains(event.target as Node)) this.doClose(false);\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). aria-controls references\n // the stable panel id (valid while hidden per ARIA).\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 trigger?.setAttribute('aria-controls', this.panelId);\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 /**\n * Mark an opening pass: deferred focus + re-measurement.\n *\n * `positioned` is reset unconditionally (not only in top-layer mode) so the\n * afterRenderEffect always gets a second pass: once with the panel measured,\n * then again after `positioned` flips back to true — that second pass is what\n * guarantees focus lands even if the first one raced the panel's insertion.\n */\n private markOpening(): void {\n this.focusPending = true;\n // Set on every open so rows that register after the panel rendered can\n // claim the initial activation exactly once.\n this.pendingActivation = true;\n this.flipped.set(false);\n this.positioned.set(false);\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 // Non-modal Tab must not trap focus. The panel is still in the DOM\n // when this event is handled (Angular removes it on the next CD), so\n // the browser's own Tab step would re-enter the panel and then lose\n // focus to <body> when it unmounted (measured on both Chromium and\n // Firefox). So we close, and step focus explicitly from the trigger.\n event.preventDefault();\n const backwards = event.shiftKey;\n this.doClose(false);\n this.focusAdjacentToTrigger(backwards);\n break;\n }\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 resetTypeahead(): void {\n this.typeBuffer = '';\n if (this.typeTimer !== null) {\n clearTimeout(this.typeTimer);\n this.typeTimer = null;\n }\n }\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 // Re-validate before emitting: the active item may have been disabled\n // after it received focus (dynamic `[disabled]`), and Enter must never\n // activate a disabled row (measured: all-disabled menu emitted `picked=one`).\n const item = c.items().find((i) => i.id === id);\n if (!item || item.disabled) 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 // APG: the typeahead buffer is per-open. Keeping it across a close made\n // the next open start mid-word (measured: `due` + `d` matched nothing).\n this.resetTypeahead();\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 // `positioned` is read so the effect re-runs after measurement even when\n // `open` never changes again — the second pass is the retry.\n const ready = this.positioned();\n if (!ready || !this.controller().open() || !this.focusPending) return;\n const root: HTMLElement | null = this.self.nativeElement.querySelector('.rv-menu-panel');\n // Panel not committed yet: keep the flag so the next pass retries. Clearing\n // it here is what used to strand focus on <body> (measured: Playwright\n // \"received: inactive\" on every arrow assertion).\n if (!root) return;\n const active = this.activeDocumentElement();\n if (active && root.contains(active)) {\n this.focusPending = false;\n return;\n }\n // No enabled item to focus (empty/all-disabled menu): done, nothing to retry.\n this.focusPending = false;\n 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 /**\n * Move focus to the element before/after the trigger in document tab order.\n *\n * Explicit rather than delegating to the browser: the panel is still\n * mounted when Tab is handled, so the browser's own step re-enters the panel\n * and focus is then lost to <body> when it unmounts (measured on Chromium\n * and Firefox). Leaves focus on the document when nothing is left in that\n * direction.\n */\n private focusAdjacentToTrigger(backwards: boolean): void {\n try {\n const trigger = this.triggerEl();\n const doc = trigger?.ownerDocument;\n if (!trigger || !doc) return;\n const selector =\n 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n const stops = [...doc.querySelectorAll<HTMLElement>(selector)].filter((el) => {\n if (el.hasAttribute('disabled') || el.getAttribute('aria-hidden') === 'true') return false;\n // Rows inside any open panel are part of that menu, not this page flow.\n if (el.closest('.rv-menu-panel')) return false;\n return el.getClientRects().length > 0;\n });\n stops.sort((a, b) =>\n a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,\n );\n const at = stops.indexOf(trigger);\n if (at === -1) return;\n stops[backwards ? at - 1 : at + 1]?.focus({ preventScroll: true });\n } catch {\n // Focus stepping is best-effort; never break the close path.\n }\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.3.1",
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,299 @@
1
+ import { RiviniaMenuController, RiviniaMenuItemData, RiviniaMenuItemData as RiviniaMenuItemData$1, 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 host;
26
+ private readonly menu;
27
+ private readonly active;
28
+ protected tab(): string;
29
+ protected computedRole(): string;
30
+ protected checkedAttr(): string | null;
31
+ /**
32
+ * `disabled` collides with the native `<button disabled>` property, so a
33
+ * static attribute on a native button element can land on the DOM instead
34
+ * of this input (measured: `<button rvMenuItem disabled>` reported
35
+ * `aria-disabled="false"`). Accept both so the two spellings agree.
36
+ */
37
+ private isDisabled;
38
+ /** Mouse pick: disabled items are ignored (controller refuses them). */
39
+ protected pick(): void;
40
+ /** Mouse hover follows keyboard focus so highlight tracks the pointer. */
41
+ protected hover(): void;
42
+ /** Id currently present in the registry, so renames can be de-registered. */
43
+ private registeredId;
44
+ constructor();
45
+ ngOnInit(): void;
46
+ ngOnDestroy(): void;
47
+ static ɵfac: i0.ɵɵFactoryDeclaration<RiviniaMenuItem, never>;
48
+ static ɵdir: i0.ɵɵDirectiveDeclaration<RiviniaMenuItem, "[rvMenuItem]", never, {
49
+ "id": {
50
+ "alias": "id";
51
+ "required": true;
52
+ "isSignal": true;
53
+ };
54
+ "disabled": {
55
+ "alias": "disabled";
56
+ "required": false;
57
+ "isSignal": true;
58
+ };
59
+ "checked": {
60
+ "alias": "checked";
61
+ "required": false;
62
+ "isSignal": true;
63
+ };
64
+ }, {}, never, never, true, never>;
65
+ }
66
+ /**
67
+ * Ready-made dropdown menu. Binds a {@link RiviniaMenu} controller (or owns
68
+ * one when omitted) and projects trigger + panel:
69
+ *
70
+ * ```html
71
+ * <rv-menu [menu]="menu" [placement]="'bottom-start'" (picked)="go($event)">
72
+ * <button rv-menu-trigger><img src="avatar.png" alt="Account" /></button>
73
+ * <div rv-menu-panel>
74
+ * <div class="head">Sophie · sophie@ui.live</div>
75
+ * <button rvMenuItem id="profile">Profile</button>
76
+ * <button rvMenuItem id="logout">Sign out</button>
77
+ * </div>
78
+ * </rv-menu>
79
+ * ```
80
+ *
81
+ * Keyboard: Enter/Space/ArrowDown opens; Escape closes and refocuses the
82
+ * trigger; arrows/Home/End move through enabled items; Tab closes and moves
83
+ * on (non-modal by design). Outside pointer-down closes. SSR renders closed.
84
+ */
85
+ export declare class RiviniaMenuComponent {
86
+ /**
87
+ * External controller (shared/test double). Omitted → the component owns
88
+ * a private one (simplest embedding).
89
+ */
90
+ readonly menu: import("@angular/core").InputSignal<RiviniaMenuController | null>;
91
+ /** Panel placement relative to the trigger. @default 'bottom-start' */
92
+ readonly placement: import("@angular/core").InputSignal<import("@rivinia/menu").RiviniaMenuPlacement>;
93
+ /**
94
+ * Accessible name for the menu panel (`aria-label`). Menus should be named
95
+ * so screen-reader users know what they chose from; omitted → no label.
96
+ *
97
+ * Named `menuLabel` (not `label`) so it cannot be confused with the
98
+ * per-item `label` in {@link RiviniaMenuItemData}.
99
+ */
100
+ readonly menuLabel: import("@angular/core").InputSignal<string | null>;
101
+ /**
102
+ * Declarative items. When provided, the panel renders these rows itself —
103
+ * no `rvMenuItem` markup and no controller needed. Mutually exclusive with
104
+ * projected `rvMenuItem` children (see `ambiguous`).
105
+ */
106
+ readonly items: import("@angular/core").InputSignal<readonly RiviniaMenuItemData$1[] | null>;
107
+ /** Extra class(es) on the panel, alongside `.rv-menu-panel`. */
108
+ readonly panelClass: import("@angular/core").InputSignal<string | null>;
109
+ /** Extra class(es) on data-driven rows only (projected rows use your markup). */
110
+ readonly itemClass: import("@angular/core").InputSignal<string | null>;
111
+ /** Fired when the user picks an enabled item (id). Closes first. */
112
+ readonly picked: import("@angular/core").OutputEmitterRef<MenuPick>;
113
+ readonly opened: import("@angular/core").OutputEmitterRef<void>;
114
+ readonly closed: import("@angular/core").OutputEmitterRef<void>;
115
+ private readonly owned;
116
+ private readonly self;
117
+ /** Item directives the consumer projected (not the ones we render). */
118
+ private readonly projectedItems;
119
+ /**
120
+ * Stable panel id for `aria-controls`. Instance-unique via module counter;
121
+ * SSR-safe because the panel (and its id) only renders client-side on open.
122
+ */
123
+ protected readonly panelId: string;
124
+ /** Deferred initial focus: set on open paths, consumed after render. */
125
+ private focusPending;
126
+ /** True between an open and the first row registered for it. */
127
+ private pendingActivation;
128
+ /**
129
+ * Manual popover support (top-layer). Feature-detected: modern browsers
130
+ * escape clipping ancestors; older ones keep the absolute fallback.
131
+ */
132
+ protected readonly topLayerSupported: boolean;
133
+ /** Coordinates applied (gates the enter animation until measured). */
134
+ protected readonly positioned: import("@angular/core").WritableSignal<boolean>;
135
+ /** Whether the panel flipped to the opposite vertical side. */
136
+ protected readonly flipped: import("@angular/core").WritableSignal<boolean>;
137
+ /** Printable-character search buffer; reset by timer after 500ms quiet. */
138
+ private typeBuffer;
139
+ private typeTimer;
140
+ /** Effective controller: external input or the owned fallback. */
141
+ controller(): RiviniaMenuController;
142
+ /** Data-driven rows to render, or empty when the consumer projects markup. */
143
+ protected dataItems(): readonly RiviniaMenuItemData$1[];
144
+ /**
145
+ * Checkbox semantics only when a consumer supplied an external controller
146
+ * with `multi`-style values; a single-select menu keeps plain `menuitem`
147
+ * roles. Kept a method (not a computed) so it stays cheap and reactive via
148
+ * template reads.
149
+ */
150
+ protected checkedFor(id: string): boolean | null;
151
+ /**
152
+ * `[items]` and projected `rvMenuItem` children are two ways to populate the
153
+ * same panel. Supplying both is ambiguous: we throw instead of silently
154
+ * picking a winner, because a silently-ignored list is a data-loss bug.
155
+ */
156
+ protected ambiguous(): boolean;
157
+ /** Called by {@link RiviniaMenuItem} directives. */
158
+ registerItem(item: {
159
+ id: string;
160
+ disabled: boolean;
161
+ }): void;
162
+ /** Called by {@link RiviniaMenuItem} directives. */
163
+ unregisterItem(id: string): void;
164
+ constructor();
165
+ /**
166
+ * Top-layer presentation: show the manual popover, measure trigger +
167
+ * panel, and pin fixed coordinates (flipping when needed). Runs inside
168
+ * afterRender — before paint, so no flash. Falls back to the absolute CSS
169
+ * placement when popover is unsupported (jsdom included).
170
+ */
171
+ private showAndPosition;
172
+ private isRtl;
173
+ /**
174
+ * Mark an opening pass: deferred focus + re-measurement.
175
+ *
176
+ * `positioned` is reset unconditionally (not only in top-layer mode) so the
177
+ * afterRenderEffect always gets a second pass: once with the panel measured,
178
+ * then again after `positioned` flips back to true — that second pass is what
179
+ * guarantees focus lands even if the first one raced the panel's insertion.
180
+ */
181
+ private markOpening;
182
+ protected onTriggerClick(): void;
183
+ protected onTriggerKey(event: KeyboardEvent): void;
184
+ protected onPanelKey(event: KeyboardEvent): void;
185
+ /**
186
+ * APG printable-character search: typing moves focus to the next enabled
187
+ * item whose visible text starts with the buffer. Repeating one character
188
+ * cycles through its matches; the buffer resets after 500ms of quiet.
189
+ * Labels are UI-owned (read from the DOM), so this lives in the component,
190
+ * not the headless controller.
191
+ */
192
+ private resetTypeahead;
193
+ private onTypeahead;
194
+ private matchTypeahead;
195
+ private pickFocused;
196
+ /**
197
+ * Mouse pick of a concrete item. Called by {@link RiviniaMenuItem}.
198
+ * Disabled/unknown ids are ignored via `activate` (returns false).
199
+ */
200
+ pickItem(id: string): void;
201
+ /** Mouse hover tracks keyboard focus. Called by {@link RiviniaMenuItem}. */
202
+ hoverItem(id: string): void;
203
+ private doClose;
204
+ /**
205
+ * Initial focus after open. The panel only exists in the DOM after the
206
+ * opening change-detection pass, so focusing synchronously races rendering.
207
+ * afterRenderEffect re-runs when `open` flips (reactive read) and executes
208
+ * after the commit — panel present, in apps and in tests. Never runs on the
209
+ * server (SSR-safe by construction). Skips when focus already sits inside
210
+ * the panel, so later renders never steal it back.
211
+ */
212
+ private consumePendingFocus;
213
+ private focusActive;
214
+ private activeDocumentElement;
215
+ /** Focusable trigger child (APG target) or the wrapper as fallback. */
216
+ private triggerEl;
217
+ /**
218
+ * Move focus to the element before/after the trigger in document tab order.
219
+ *
220
+ * Explicit rather than delegating to the browser: the panel is still
221
+ * mounted when Tab is handled, so the browser's own step re-enters the panel
222
+ * and focus is then lost to <body> when it unmounts (measured on Chromium
223
+ * and Firefox). Leaves focus on the document when nothing is left in that
224
+ * direction.
225
+ */
226
+ private focusAdjacentToTrigger;
227
+ private focusTrigger;
228
+ static ɵfac: i0.ɵɵFactoryDeclaration<RiviniaMenuComponent, never>;
229
+ static ɵcmp: i0.ɵɵComponentDeclaration<RiviniaMenuComponent, "rv-menu", never, {
230
+ "menu": {
231
+ "alias": "menu";
232
+ "required": false;
233
+ "isSignal": true;
234
+ };
235
+ "placement": {
236
+ "alias": "placement";
237
+ "required": false;
238
+ "isSignal": true;
239
+ };
240
+ "menuLabel": {
241
+ "alias": "menuLabel";
242
+ "required": false;
243
+ "isSignal": true;
244
+ };
245
+ "items": {
246
+ "alias": "items";
247
+ "required": false;
248
+ "isSignal": true;
249
+ };
250
+ "panelClass": {
251
+ "alias": "panelClass";
252
+ "required": false;
253
+ "isSignal": true;
254
+ };
255
+ "itemClass": {
256
+ "alias": "itemClass";
257
+ "required": false;
258
+ "isSignal": true;
259
+ };
260
+ }, {
261
+ "picked": "picked";
262
+ "opened": "opened";
263
+ "closed": "closed";
264
+ }, ["projectedItems"], ["[rv-menu-trigger], [rvMenuTrigger]", "[rv-menu-panel], [rvMenuPanel]"], true, never>;
265
+ }
266
+ /** Trigger rect in viewport coordinates. */
267
+ interface MenuTriggerRect {
268
+ readonly left: number;
269
+ readonly top: number;
270
+ readonly width: number;
271
+ readonly height: number;
272
+ }
273
+ /** Measured panel size. */
274
+ interface MenuPanelSize {
275
+ readonly width: number;
276
+ readonly height: number;
277
+ }
278
+ /** Viewport size. */
279
+ interface MenuViewport {
280
+ readonly width: number;
281
+ readonly height: number;
282
+ }
283
+ /** Fixed-position coordinates plus whether the vertical side flipped. */
284
+ interface MenuCoordinates {
285
+ readonly left: number;
286
+ readonly top: number;
287
+ readonly flipped: boolean;
288
+ }
289
+ /** Gap between trigger and panel. */
290
+ export declare const MENU_GAP = 6;
291
+ /**
292
+ * Pure top-layer position for a menu panel: anchors to the trigger per
293
+ * `placement` (logical, RTL-aware), flips to the opposite vertical side when
294
+ * the preferred side overflows but the other fits, then clamps inside the
295
+ * viewport. Zero DOM access — unit-testable with fake rects; the component
296
+ * only measures and applies.
297
+ */
298
+ export declare function computeMenuPosition(trigger: MenuTriggerRect, panel: MenuPanelSize, placement: RiviniaMenuPlacement, viewport: MenuViewport, rtl: boolean, gap?: number): MenuCoordinates;
299
+ export type { MenuCoordinates, MenuPanelSize, MenuPick, MenuTriggerRect, MenuViewport, RiviniaMenuItemData, RiviniaMenuUiPlacement };
@@ -0,0 +1,83 @@
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
+ * Data-driven item, for the declarative `[items]` API of `@rivinia/menu/ui`.
38
+ * Headless consumers of the registry use {@link RiviniaMenuItem} instead.
39
+ */
40
+ interface RiviniaMenuItemData {
41
+ /** Identity, emitted in the `picked` payload. */
42
+ readonly id: string;
43
+ /** Visible text. Also the accessible name and the typeahead search text. */
44
+ readonly label: string;
45
+ /** Rendered but not selectable. @default false */
46
+ readonly disabled?: boolean;
47
+ }
48
+ /**
49
+ * Headless per-menu controller. Owns open state, the registered items and
50
+ * the active (keyboard-focused) item — no DOM, no styling, no positioning.
51
+ * Instantiate one per menu (`new RiviniaMenu()`); rendering and focus moves
52
+ * are the UI layer's job (`@rivinia/menu/ui` implements them).
53
+ *
54
+ * Navigation skips disabled items and wraps around. Closing clears the
55
+ * active item; the UI restores focus to the trigger.
56
+ */
57
+ export declare class RiviniaMenu implements RiviniaMenuController {
58
+ /** Whether the panel is open. */
59
+ readonly open: import("@angular/core").WritableSignal<boolean>;
60
+ /** Id of the keyboard-active item (`null` when closed or unfocused). */
61
+ readonly activeId: import("@angular/core").WritableSignal<string | null>;
62
+ private readonly _items;
63
+ /** Registered items, in registration order. */
64
+ readonly items: import("@angular/core").Signal<readonly RiviniaMenuItem[]>;
65
+ /** Register an item (idempotent by id — re-register updates it). */
66
+ register(item: RiviniaMenuItem): void;
67
+ /** Remove an item; clears `activeId` when it pointed at it. */
68
+ unregister(id: string): void;
69
+ /** Open and optionally focus the first/last enabled item. */
70
+ openMenu(focus?: RiviniaMenuFocus): void;
71
+ /** Close and clear the active item. Focus restoration is UI-owned. */
72
+ closeMenu(): void;
73
+ toggle(focus?: RiviniaMenuFocus): void;
74
+ /** The active id, or `null` when nothing is active. */
75
+ pickActive(): string | null;
76
+ activateFirst(): void;
77
+ activateLast(): void;
78
+ activateNext(): void;
79
+ activatePrevious(): void;
80
+ activate(id: string): boolean;
81
+ private step;
82
+ }
83
+ export type { RiviniaMenuController, RiviniaMenuFocus, RiviniaMenuItem, RiviniaMenuItemData, RiviniaMenuPlacement };