@rivinia/menu 0.3.2 → 0.3.4

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.
Files changed (2) hide show
  1. package/README.md +164 -658
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,205 +1,179 @@
1
1
  # @rivinia/menu
2
2
 
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)
3
+ Angular dropdown menus with a headless controller and an optional ready-made
4
+ UI. The menu follows the WAI-ARIA menu-button interaction pattern; it is for
5
+ actions, not a menubar or a general-purpose listbox.
5
6
 
6
- Dropdown menu for Angular 22+ following the WAI-ARIA APG **menu button**
7
- pattern. Two entry points:
7
+ - Install: `npm install @rivinia/menu`
8
+ - Package version: **0.3.4**
9
+ - Angular peer dependency: `@angular/core ^22.0.0`
10
+ - `@rivinia/menu` exports the headless controller and types.
11
+ - `@rivinia/menu/ui` exports the standalone menu component and item directive.
8
12
 
9
- - `@rivinia/menu` — headless controller. Open state, item registry, keyboard
10
- navigation. No DOM, no styling.
11
- - `@rivinia/menu/ui` — optional ready-made dropdown (`<rv-menu>`) built on the
12
- controller. Zero runtime dependencies beyond Angular.
13
+ ## Ready-made menu
13
14
 
14
- Both share one implementation of the navigation algorithm — the UI does not
15
- reimplement it.
16
-
17
- - Version: **0.3.1** (0.1.0 is the only published version; see [Migration](#23-migration-notes))
18
- - Requires: **Angular `^22.0.0`**, `tslib`
19
-
20
- ---
21
-
22
- ## 1. What this is
23
-
24
- A dropdown that opens from a trigger button and exposes a list of actions
25
- (or navigation links). It is **not** a menubar, and it has **no submenu
26
- support** — see [limitations](#19-submenus-and-limitations).
27
-
28
- ## 2. Requirements
29
-
30
- | Requirement | Range |
31
- | ----------- | -------------------------------------------- |
32
- | Angular | `^22.0.0` (`@angular/core` peer) |
33
- | TypeScript | ships with Angular 22 toolchain |
34
- | Module | ESM (`@rivinia/menu` and `@rivinia/menu/ui`) |
35
- | Node (dev) | 20+ |
36
-
37
- No other runtime dependencies. Verified by FESM import grep on the packed
38
- tarball.
39
-
40
- ## 3. Supported / not supported
41
-
42
- **Supported:** pointer + keyboard open/close, arrow/Home/End navigation with
43
- wrap, typeahead, Escape focus return, non-modal Tab close, outside-click
44
- close, top-layer rendering with viewport auto-flip, RTL, dynamic items,
45
- disabled items, full ARIA wiring, SSR (renders closed).
46
-
47
- **Not supported:** submenus, nested menus, menubar/menitem roles, radio
48
- groups (use `checked` + `menuitemcheckbox`), option groups, virtualization of
49
- very large lists, form binding (no `ControlValueAccessor` as of 0.3.1).
50
-
51
- ## 4. Installation
52
-
53
- ```bash
54
- npm install @rivinia/menu
55
- ```
56
-
57
- Both entry points ship in the same package — install once, import the entry
58
- point you need.
59
-
60
- ## 5. Quick start — data-driven
61
-
62
- The smallest complete example: pass data, the component renders, registers and
63
- manages the rows. **No controller, no `register()`, no item markup.**
15
+ The UI component owns a controller when `[menu]` is omitted. For a typical
16
+ data-driven menu, import `RiviniaMenuComponent` into the standalone component
17
+ that uses `<rv-menu>`:
64
18
 
65
19
  ```ts
66
20
  import { Component } from '@angular/core';
67
- import { RiviniaMenuComponent } from '@rivinia/menu/ui';
68
21
  import type { MenuPick, RiviniaMenuItemData } from '@rivinia/menu/ui';
22
+ import { RiviniaMenuComponent } from '@rivinia/menu/ui';
69
23
 
70
24
  @Component({
25
+ standalone: true,
71
26
  imports: [RiviniaMenuComponent],
72
- template: `
73
- <rv-menu [items]="accountItems" menuLabel="Account actions" (picked)="onAction($event)">
74
- <button rvMenuTrigger>Account</button>
75
- </rv-menu>
76
- `,
27
+ templateUrl: './account-menu.component.html',
77
28
  })
78
29
  export class AccountMenu {
79
- readonly accountItems: RiviniaMenuItemData[] = [
30
+ readonly accountActions: readonly RiviniaMenuItemData[] = [
80
31
  { id: 'profile', label: 'Profile' },
81
32
  { id: 'settings', label: 'Settings' },
82
- { id: 'logout', label: 'Sign out', disabled: true },
33
+ { id: 'sign-out', label: 'Sign out', disabled: true },
83
34
  ];
84
35
 
85
- // `picked` emits MenuPick — an object, NOT a bare string.
86
- onAction(pick: MenuPick) {
87
- console.log(pick.id); // 'profile'
36
+ onPicked(pick: MenuPick): void {
37
+ console.log('Selected action:', pick.id);
88
38
  }
89
39
  }
90
40
  ```
91
41
 
92
- Only the **trigger** is projected — it is the one piece of markup you always
93
- need, because the trigger is yours (label, avatar, styling). Everything inside
94
- the panel comes from `items`.
95
-
96
- Projection attributes accept both spellings: `rvMenuTrigger` /
97
- `rv-menu-trigger`, `rvMenuPanel` / `rv-menu-panel`.
42
+ ```html
43
+ <!-- src/app/account-menu.component.html -->
44
+ <rv-menu
45
+ [items]="accountActions"
46
+ menuLabel="Account actions"
47
+ placement="bottom-end"
48
+ (picked)="onPicked($event)"
49
+ >
50
+ <button type="button" rvMenuTrigger>Account</button>
51
+ </rv-menu>
52
+ ```
98
53
 
99
- ### The `picked` payload
54
+ `menuLabel` provides the accessible name for the menu panel and is
55
+ recommended. The trigger is consumer markup; the component adds its menu
56
+ button ARIA state and handles its keyboard activation. There are no service
57
+ providers or global styles to configure.
100
58
 
101
- `(picked)` emits `MenuPick`, which is `{ readonly id: string }` — it is **not** a
102
- bare string. Read `pick.id`:
59
+ `(picked)` is the output name; `onPicked` is only the example handler method
60
+ name. Rename it freely (for example, `(picked)="onSelect($event)"`) and use
61
+ `$event.id` for the selected action ID.
103
62
 
104
- ```ts
105
- onAction(pick: MenuPick) {
106
- this.navigate(pick.id);
107
- }
108
- ```
63
+ The main template pieces have distinct jobs:
109
64
 
110
- `opened` and `closed` emit `void` and tell you when the panel opened/closed for
111
- any reason (selection, Escape, outside click, Tab).
65
+ | Template piece | What it does |
66
+ | -------------------------- | -------------------------------------------------------------------------------- |
67
+ | `<rv-menu>` | Owns open/close behavior, positioning, keyboard handling, and the panel. |
68
+ | `[items]="accountActions"` | Renders the supplied `{ id, label, disabled? }` rows. |
69
+ | `menuLabel` | Gives assistive technology an accessible name for the menu. |
70
+ | `placement` | Chooses the preferred logical side of the trigger; the panel can flip to fit. |
71
+ | `rvMenuTrigger` | Marks consumer-provided trigger content and enables menu-button interaction. |
72
+ | `rvMenuPanel` | Marks the custom projected panel when using custom content instead of `[items]`. |
73
+ | `rvMenuItem` | Registers one interactive custom row; give each row a unique stable `id`. |
74
+ | `(picked)` | Fixed output name; emits `{ id }` after an enabled row is selected. |
75
+ | `onPicked($event)` | Consumer-chosen handler name; `$event.id` is the selected row ID. |
112
76
 
113
- ## 5b. Composable — custom markup
77
+ ### Custom panel content
114
78
 
115
- When you need headers, dividers, icons or per-row markup, project your own
116
- rows. Items still register themselves.
79
+ Use the `RiviniaMenuItem` directive when rows need icons, custom layout, or
80
+ non-selectable headings and dividers. Import both standalone declarations:
117
81
 
118
82
  ```ts
119
83
  import { Component } from '@angular/core';
120
84
  import { RiviniaMenuComponent, RiviniaMenuItem } from '@rivinia/menu/ui';
121
- import type { MenuPick } from '@rivinia/menu/ui';
122
85
 
123
86
  @Component({
87
+ standalone: true,
124
88
  imports: [RiviniaMenuComponent, RiviniaMenuItem],
125
- template: `
126
- <rv-menu
127
- [placement]="'bottom-start'"
128
- menuLabel="Account actions"
129
- panelClass="account-menu"
130
- (picked)="onAction($event)"
131
- >
132
- <button rvMenuTrigger class="account-trigger">
133
- <img src="avatar.png" alt="" />
134
- Account
135
- </button>
136
-
137
- <div rvMenuPanel>
138
- <div class="account-header">Sophie</div>
139
-
140
- <button rvMenuItem id="profile">Profile</button>
141
- <button rvMenuItem id="logout" disabled>Sign out</button>
142
- </div>
143
- </rv-menu>
144
- `,
89
+ templateUrl: './custom-account-menu.component.html',
145
90
  })
146
- export class AccountMenu {
147
- onAction(pick: MenuPick) {
148
- console.log(pick.id); // 'profile' | 'logout'
91
+ export class CustomAccountMenu {
92
+ runAction(id: string): void {
93
+ console.log('Run account action:', id);
149
94
  }
150
95
  }
151
96
  ```
152
97
 
153
- Anything that is not an `rvMenuItem` (the header above) renders but is skipped
154
- by keyboard navigation automatically. `panelClass`/`itemClass` apply to the
155
- generated panel and rows — in the composable pattern your rows are your markup,
156
- so style them there.
157
-
158
- > **`[items]` and projected `rvMenuItem` cannot be combined.** Supplying both
159
- > throws at runtime (`rv-menu: [items] cannot be combined with projected
160
- rvMenuItem children`). This is deliberate: silently ignoring one of them is a
161
- > data-loss bug. Pick one of the two patterns per menu.
162
-
163
- ## 6. Imports
164
-
165
- The package has exactly two entry points. Which one you import from is not a
166
- style choice — some symbols exist in only one of them.
167
-
168
- | Symbol | `@rivinia/menu` | `@rivinia/menu/ui` |
169
- | ------------------------------------------------------------------- | :-------------: | :----------------: |
170
- | `RiviniaMenuComponent` (`<rv-menu>`) | — | ✅ |
171
- | `RiviniaMenuItem` directive (`[rvMenuItem]`) | — | ✅ |
172
- | `MenuPick` (`picked` payload) | — | ✅ |
173
- | `computeMenuPosition`, `MENU_GAP`, rect types | — | ✅ |
174
- | `RiviniaMenuItemData` (`[items]` row shape) | ✅ | ✅ (re-export) |
175
- | `RiviniaMenu` (headless controller) | ✅ | — |
176
- | `RiviniaMenuController`, `RiviniaMenuFocus`, `RiviniaMenuPlacement` | ✅ | — |
177
- | `RiviniaMenuItem` (headless registry item) | ✅ | — |
178
-
179
- ```ts
180
- // Ready-made UI — the only place components, directives and MenuPick live.
181
- import { RiviniaMenuComponent, RiviniaMenuItem } from '@rivinia/menu/ui';
182
- import type { MenuPick } from '@rivinia/menu/ui';
183
-
184
- // Headless controller + its types (advanced / custom UI).
185
- import { RiviniaMenu } from '@rivinia/menu';
186
- import type {
187
- RiviniaMenuController,
188
- RiviniaMenuFocus,
189
- RiviniaMenuItemData,
190
- RiviniaMenuPlacement,
191
- } from '@rivinia/menu';
98
+ ```html
99
+ <!-- src/app/custom-account-menu.component.html -->
100
+ <rv-menu menuLabel="Account actions" (picked)="runAction($event.id)">
101
+ <button type="button" rvMenuTrigger>Account</button>
102
+ <div rvMenuPanel>
103
+ <p class="menu-heading">Signed in as Ada</p>
104
+ <button type="button" rvMenuItem id="profile">Profile</button>
105
+ <button type="button" rvMenuItem id="sign-out">Sign out</button>
106
+ </div>
107
+ </rv-menu>
192
108
  ```
193
109
 
194
- Importing `RiviniaMenuComponent` or `MenuPick` from `@rivinia/menu` is a
195
- compile error, not a fallback — the headless entry point deliberately ships no
196
- components. Headless consumers who never import `@rivinia/menu/ui` load **zero**
197
- UI code or CSS (the core FESM contains no component markup).
198
-
199
- ## 7. Basic example with a controlled controller
200
-
201
- Pass a controller when you need to drive the menu from outside (open it from a
202
- route guard, close it after an async action, or read the active item).
110
+ Every `rvMenuItem` needs a unique `id` within its menu. Items register and
111
+ unregister automatically, including rows rendered with `@for`; changing an
112
+ item's `id` or `disabled` state updates its registration. Non-item projected
113
+ content (such as the heading above) is not keyboard-navigable. Use a real
114
+ `button` for actions or an `a` with an `href` for navigation.
115
+
116
+ `[items]` and projected `rvMenuItem` rows are alternative ways to populate a
117
+ panel. Do not combine them: the component throws rather than silently ignore
118
+ one source. You may still project non-item content when using `[items]`.
119
+
120
+ The `rvMenuTrigger` / `rv-menu-trigger` and `rvMenuPanel` / `rv-menu-panel`
121
+ spellings are both accepted.
122
+
123
+ ### Inputs and outputs
124
+
125
+ | API | Type | Behavior |
126
+ | ------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------ |
127
+ | `menu` | `RiviniaMenuController \| null` | Optional external controller; omitted means the component owns one. |
128
+ | `items` | `readonly RiviniaMenuItemData[] \| null` | Declarative rows (`id`, `label`, optional `disabled`). |
129
+ | `placement` | `bottom-start \| bottom-end \| top-start \| top-end` | Logical, RTL-aware placement; defaults to `bottom-start` and flips vertically when needed. |
130
+ | `menuLabel` | `string \| null` | Accessible name of the menu panel. |
131
+ | `panelClass`, `itemClass` | `string \| null` | Additional classes on the panel, or on generated data rows. |
132
+ | `picked` | `MenuPick` (`{ id: string }`) | Emits after an enabled item is activated and the menu closes. |
133
+ | `opened`, `closed` | `void` | Notify when the panel opens or closes, for any close reason. |
134
+
135
+ For projected rows, `rvMenuItem` also accepts:
136
+
137
+ - `id` (required), `disabled` (defaults to `false`)
138
+ - `checked: boolean | null` (defaults to `null`; a boolean uses
139
+ `menuitemcheckbox` semantics and sets `aria-checked`)
140
+ - `label: string | null` (optional explicit typeahead text; otherwise visible
141
+ text is used)
142
+
143
+ Disabled rows remain visible but cannot be activated. For a native disabled
144
+ `button`, the directive also recognizes the element's native disabled state.
145
+ Data-driven rows are rendered as buttons and use the `id`, `label`, and
146
+ `disabled` fields from `RiviniaMenuItemData`.
147
+
148
+ The UI supplies basic layout and styling. Override its CSS custom properties
149
+ such as `--rv-menu-bg`, `--rv-menu-fg`, `--rv-menu-border`,
150
+ `--rv-menu-radius`, `--rv-menu-width`, and `--rv-menu-z` to theme it.
151
+ Projected content remains yours to style. Where supported, the panel uses the
152
+ browser popover top layer to avoid clipping ancestors; browsers without that
153
+ support use the positioned-panel fallback.
154
+
155
+ ## Interaction and limitations
156
+
157
+ - Enter, Space, or ArrowDown opens on the first enabled item; ArrowUp opens on
158
+ the last. Arrow keys wrap through enabled items; Home and End jump to the
159
+ first and last. Printable characters perform typeahead matching.
160
+ - Escape closes and returns focus to the trigger. Selecting an item closes
161
+ first, then emits `picked`. Outside pointer-down closes without stealing
162
+ focus from the clicked target.
163
+ - Tab closes and moves focus onward; the menu is non-modal. Scrolling or
164
+ resizing the page closes it so the panel does not drift from its trigger.
165
+ - An empty or all-disabled menu can open, but has no active item to select.
166
+ - The default controller starts closed during server rendering. On the client,
167
+ placement is measured and flipped to fit the viewport when needed.
168
+ - Submenus, menubars, virtualization, form-control binding, and option-group
169
+ behavior are not provided. This component does not implement a persistent
170
+ multi-select menu.
171
+
172
+ ## Headless controller
173
+
174
+ Import `RiviniaMenu` from `@rivinia/menu` when implementing a custom renderer
175
+ or when the component must control open state. Bind that instance to the UI's
176
+ `menu` input to keep rendering and focus behavior integrated:
203
177
 
204
178
  ```ts
205
179
  import { Component } from '@angular/core';
@@ -207,514 +181,46 @@ import { RiviniaMenu } from '@rivinia/menu';
207
181
  import { RiviniaMenuComponent, RiviniaMenuItem } from '@rivinia/menu/ui';
208
182
 
209
183
  @Component({
184
+ standalone: true,
210
185
  imports: [RiviniaMenuComponent, RiviniaMenuItem],
211
- template: `
212
- <rv-menu [menu]="menu" placement="bottom-end" (picked)="save($event.id)">
213
- <button rv-menu-trigger>Save as…</button>
214
- <div rv-menu-panel>
215
- <button rvMenuItem id="pdf">PDF</button>
216
- <button rvMenuItem id="csv">CSV</button>
217
- </div>
218
- </rv-menu>
219
- `,
186
+ templateUrl: './export-menu.component.html',
220
187
  })
221
- export class SaveMenu {
222
- protected readonly menu = new RiviniaMenu();
188
+ export class ExportMenu {
189
+ readonly menu = new RiviniaMenu();
223
190
 
224
- async save(id: string) {
225
- await this.persist(id); // async work
226
- this.menu.closeMenu(); // controller API stays available
191
+ exportAs(format: string): void {
192
+ console.log('Export format:', format);
227
193
  }
228
194
  }
229
195
  ```
230
196
 
231
- ## 8. Inputs and outputs
232
-
233
- ### `rv-menu` (RiviniaMenuComponent)
234
-
235
- | Input | Type | Default | Required | Purpose |
236
- | ----------- | ------------------------------------------------------------ | ---------------- | -------- | ------------------------------------------------------------------------ |
237
- | `menu` | `RiviniaMenuController \| null` | `null` | no | External controller. `null` → component owns a private one. |
238
- | `placement` | `'bottom-start' \| 'bottom-end' \| 'top-start' \| 'top-end'` | `'bottom-start'` | no | Logical (RTL-aware) side to open on. Auto-flips when it does not fit. |
239
- | `label` | `string \| null` | `null` | no | Accessible name for the menu → panel `aria-label`. Strongly recommended. |
240
-
241
- | Output | Payload | When |
242
- | -------- | ---------------- | -------------------------------------------------------------------------------------- |
243
- | `picked` | `{ id: string }` | An enabled item was activated (mouse or Enter/Space). Fires **after** the menu closes. |
244
- | `opened` | `void` | Panel opened (mouse, Enter, Space, ArrowDown, ArrowUp). |
245
- | `closed` | `void` | Panel closed, by any reason. |
246
-
247
- Selection **closes the menu by default** — that is the APG behavior for action
248
- menus. If you need a persistent list (a multi-select), keep the menu open
249
- yourself from `picked`, or drive it with a controller.
250
-
251
- ### `rvMenuItem` (directive)
252
-
253
- | Input | Type | Default | Required | Purpose |
254
- | ---------- | ----------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
255
- | `id` | `string` | — | **yes** | Identity. Also emitted in `picked`, and used for `data-rv-id`. |
256
- | `disabled` | `boolean` | `false` | no | Skipped by keyboard navigation, refused on click, `aria-disabled="true"`. |
257
- | `checked` | `boolean \| null` | `null` | no | `null` → `role="menuitem"`. `true/false` → `role="menuitemcheckbox"` + `aria-checked`. |
258
- | `label` | `string \| null` | `null` | no | Explicit typeahead label. When set, typeahead matches on this instead of the row's `textContent`. Defaults to `null` → falls back to `textContent`. |
259
-
260
- Items register on init, **stay in sync when `id` or `disabled` change**, and
261
- unregister on destroy — no manual bookkeeping, and `@for`-driven collections
262
- work.
263
-
264
- ### `RiviniaMenuItemData` (for `[items]`)
265
-
266
- | Field | Type | Default | Required | Purpose |
267
- | ---------- | --------- | ------- | -------- | --------------------------------------------------------------------- |
268
- | `id` | `string` | — | **yes** | Identity, emitted in `picked`. |
269
- | `label` | `string` | — | **yes** | Visible text. Also the accessible name and the typeahead search text. |
270
- | `disabled` | `boolean` | `false` | no | Rendered but not selectable. |
271
-
272
- ## 9. Full public API reference
273
-
274
- ### `@rivinia/menu` (headless)
275
-
276
- | Export | Kind | Summary |
277
- | ----------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
278
- | `RiviniaMenu` | class | Controller. `openMenu(focus?)`, `closeMenu()`, `toggle(focus?)`, `activate(id)`, `activateFirst()`, `activateLast()`, `activateNext()`, `activatePrevious()`, `register(item)`, `unregister(id)`, `pickActive()`. |
279
- | `RiviniaMenuController` | interface | Structural subset a UI layer needs — accepts a controller from any module realm. |
280
- | `RiviniaMenuItem` | interface | `{ id: string; disabled?: boolean }`. |
281
- | `RiviniaMenuPlacement` | type | `'bottom-start' \| 'bottom-end' \| 'top-start' \| 'top-end'`. |
282
- | `RiviniaMenuFocus` | type | `'first' \| 'last' \| 'none'` — which item is active when opening. |
283
-
284
- Controller state is readonly: `open`, `activeId`, `items` signals. Writes only
285
- happen through the explicit methods above.
286
-
287
- ### `@rivinia/menu/ui`
288
-
289
- | Export | Kind | Summary |
290
- | --------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------- |
291
- | `RiviniaMenuComponent` | component | `<rv-menu>`. |
292
- | `RiviniaMenuItem` | directive | `[rvMenuItem]`. |
293
- | `MenuPick` | interface | `{ id: string }` — `picked` payload. |
294
- | `RiviniaMenuUiPlacement` | type | Alias of `RiviniaMenuPlacement`, for UI-only imports. |
295
- | `computeMenuPosition` | function | Pure `(trigger, panel, placement, viewport, rtl, gap?) => { left, top, flipped }`. |
296
- | `MENU_GAP` | const | Default trigger↔panel gap in px (`6`). |
297
- | `MenuTriggerRect`, `MenuPanelSize`, `MenuViewport`, `MenuCoordinates` | types | Arguments/result of `computeMenuPosition`. |
298
-
299
- ## 10. Keyboard interaction
300
-
301
- Focus starts on the **trigger**. With the menu open:
302
-
303
- | Key | Behavior |
304
- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
305
- | `Enter` / `Space` on trigger | Open, focus first item |
306
- | `ArrowDown` on trigger | Open, focus first item |
307
- | `ArrowUp` on trigger | Open, focus last item |
308
- | `ArrowDown` / `ArrowUp` | Move to next / previous **enabled** item, wrapping |
309
- | `Home` / `End` | First / last enabled item |
310
- | Printable characters | Typeahead: focus the first item whose text starts with the buffer; repeating one character cycles its matches; buffer resets after 500 ms |
311
- | `Enter` / `Space` | Activate the focused item → `picked` → close |
312
- | `Escape` | Close and return focus to the trigger |
313
- | `Tab` / `Shift+Tab` | Close, then focus continues to the next/previous element in document order |
314
- | `Enter`/`Space`/`↓`/`↑`/`Esc`/`Tab` | Handled → `preventDefault()`. Anything else is left to the browser. |
315
-
316
- The menu is **non-modal** by design (no focus trap, no `inert`) — Tab leaves
317
- it and traversal stays natural.
318
-
319
- ## 11. Accessibility and focus behavior
320
-
321
- **Roles and wiring**
322
-
323
- - Panel: `role="menu"`, stable `id`, `aria-label` from `label`.
324
- - Trigger: `aria-haspopup="menu"`, `aria-expanded` (synchronized with real open
325
- state), `aria-controls` pointing at the panel id.
326
- - Items: `role="menuitem"`, or `role="menuitemcheckbox"` + `aria-checked` when
327
- `checked` is set. Disabled items get `aria-disabled="true"` (kept focusable
328
- so screen readers can still reach them; the controller refuses activation).
329
-
330
- **Focus contract**
331
-
332
- | Event | Focus result |
333
- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
334
- | Open (mouse or keyboard) | First enabled item (`ArrowUp` → last). Deferred to after render so it always lands on a real element. |
335
- | Arrow / Home / End / typeahead | Physical DOM focus moves to the target item (roving `tabindex`: only the active item is `tabindex="0"`). |
336
- | `Escape` | Returns to the trigger. |
337
- | `Tab` / `Shift+Tab` | Menu closes, focus continues naturally to the next/previous focusable element. |
338
- | Item activated | Focus is **not** restored — the element you activated is gone, so focus is left where the browser puts it. Drive it explicitly (e.g. navigate) if you need something specific. |
339
- | Outside pointer-down | Menu closes and **focus is not stolen** — the browser's natural focus behavior for the clicked element is preserved. |
340
- | Outside scroll / window resize | Menu closes (coordinates would drift). Scrolling _inside_ the panel does not close it. |
341
-
342
- **Empty / all-disabled menus:** the menu still opens, nothing is focused, and
343
- arrow keys, Home/End, Enter and typeahead are safe no-ops. No exceptions, no
344
- focus on `<body>`.
345
-
346
- Verified with: 21 unit tests (`ng test menu`) and 19 Playwright scenarios in a
347
- real Angular production build, including axe-core with zero violations on the
348
- open menu.
349
-
350
- ## 12. Disabled and dynamic items
351
-
352
- - **Disabled items** are skipped by arrows/Home/End/typeahead and cannot be
353
- activated by click or Enter/Space. They remain in the tab order as
354
- `aria-disabled` so assistive tech can discover them.
355
- - **Dynamic items** register on init and unregister on destroy, so `@for`,
356
- `@if` and route-driven changes are safe. Removing or disabling the currently
357
- focused item clears `activeId` and focus moves to a valid target.
358
- - **Duplicate ids** are idempotent: re-registering updates the entry in place.
359
- - **Duplicate labels** are fine — ids are the identity. Typeahead matches the
360
- first item whose text starts with the buffer.
361
-
362
- ## 13. Styling and CSS variables
363
-
364
- The panel ships with a **default theme implemented entirely through CSS custom
365
- properties**. Override any of them from your own CSS — no `!important`, no
366
- specificity war:
367
-
368
- | Variable | Default | Purpose |
369
- | -------------------- | ----------------------------- | ----------------------------------------------------- |
370
- | `--rv-menu-bg` | `#ffffff` (`#161616` in dark) | Panel background |
371
- | `--rv-menu-fg` | `#111111` (`#f2f2f2`) | Text color |
372
- | `--rv-menu-muted` | `#555555` (`#a8a8a8`) | Icons, secondary text |
373
- | `--rv-menu-border` | `#e0e0e0` (`#333333`) | Border and divider color |
374
- | `--rv-menu-radius` | `12px` | Corner radius |
375
- | `--rv-menu-shadow` | `0 16px 48px rgba(0,0,0,.22)` | Panel shadow |
376
- | `--rv-menu-pad` | `6px` | Panel padding |
377
- | `--rv-menu-width` | `16rem` | Panel min-width |
378
- | `--rv-menu-font` | system-ui stack | Panel font-family |
379
- | `--rv-menu-z` | `500` | Stacking level (only used by the no-popover fallback) |
380
- | `--rv-menu-duration` | `160ms` | Enter animation duration |
381
- | `--rv-menu-easing` | `cubic-bezier(0.16,1,0.3,1)` | Enter animation easing |
382
- | `--rv-menu-from-y` | `-6px` / `6px` for `top-*` | Enter animation offset |
383
-
384
- Every default is a **`var(--token, fallback)` value**, not a declaration on the
385
- element. A value you set on any ancestor (or on `rv-menu` / `.rv-menu-panel`
386
- itself) therefore always wins, and a consumer token set from an Angular
387
- component-scoped stylesheet works — unlike the selectors themselves, which
388
- cannot reach the panel (see [ViewEncapsulation](#16-viewencapsulation-behavior)).
389
-
390
- Set them on any ancestor of `rv-menu`, or on the `rv-menu` element itself:
391
-
392
- ```css
393
- .my-accent {
394
- --rv-menu-bg: #0b1220;
395
- --rv-menu-fg: #e2e8f0;
396
- --rv-menu-radius: 4px;
397
- }
398
- ```
399
-
400
- **State hooks** for anything the variables do not cover:
401
-
402
- | Hook | Where | Meaning |
403
- | ------------------------ | --------------- | ----------------------------------------- |
404
- | `.rv-menu-panel` | panel | The popup container |
405
- | `.rv-menu-trigger` | trigger wrapper | Wraps the projected trigger |
406
- | `[rvmenuitem]` | item | Any menu item row |
407
- | `[data-active='true']` | item | Keyboard-active / pointer-hovered item |
408
- | `[aria-disabled='true']` | item | Disabled item |
409
- | `[data-placement]` | panel | `'bottom-start' \| …` |
410
- | `[data-flipped='true']` | panel | Auto-flipped away from the requested side |
411
- | `[data-positioned]` | panel | `false` until measured, `true` after |
412
- | `[data-top-layer]` | panel | `true` when rendered in the top layer |
413
- | `[data-rv-id]` | item | The item `id` |
414
-
415
- ```css
416
- /* Brand the active row without touching library internals */
417
- .my-accent .rv-menu-panel [data-active='true'] {
418
- background: color-mix(in srgb, currentColor 14%, transparent);
419
- outline: 2px dashed currentColor;
420
- }
421
- ```
422
-
423
- Dark mode is **opt-in**: the library ships light defaults only (as
424
- `var(--x, fallback)` values), so nothing overrides your theme. Opt in with:
425
-
426
- ```css
427
- @media (prefers-color-scheme: dark) {
428
- .my-menu {
429
- --rv-menu-bg: #161616;
430
- --rv-menu-fg: #f2f2f2;
431
- --rv-menu-muted: #a8a8a8;
432
- --rv-menu-border: #333333;
433
- }
434
- }
435
- ```
436
-
437
- ## 14. Tailwind integration
438
-
439
- The documented override path is CSS variables and the state hooks above —
440
- those work with any utility framework. For utilities on the panel itself, put
441
- them on your own wrapper:
442
-
443
197
  ```html
444
- <div class="rounded-xl border border-slate-200 shadow-lg">
445
- <rv-menu [menu]="menu"> … </rv-menu>
446
- </div>
198
+ <!-- src/app/export-menu.component.html -->
199
+ <rv-menu [menu]="menu" menuLabel="Export actions" (picked)="exportAs($event.id)">
200
+ <button type="button" rvMenuTrigger>Export</button>
201
+ <div rvMenuPanel>
202
+ <button type="button" rvMenuItem id="csv">CSV</button>
203
+ <button type="button" rvMenuItem id="json">JSON</button>
204
+ </div>
205
+ </rv-menu>
206
+ <button type="button" (click)="menu.openMenu('last')">Open at last action</button>
447
207
  ```
448
208
 
449
- > **Status:** verified with plain CSS and CSS variables in a real consumer
450
- > production build. A dedicated Tailwind project is **not** part of the test
451
- > suite — treat Tailwind-specific behavior as untested until you verify it in
452
- > your app. Because the panel uses a small set of predictable classes and
453
- > `data-*` hooks (no `!important`, no global resets), utilities should win
454
- > through normal cascade rules, but that is a reasoned expectation, not a
455
- > measured result.
456
-
457
- ## 15. Plain CSS integration
458
-
459
- Fully supported — that is the primary path (see §13). No build plugin, no CSS
460
- import, no configuration. The library's styles ship inside the component
461
- (`ViewEncapsulation.None`, see §16) and are scoped under `.rv-menu-*` /
462
- `rv-menu`, so they cannot leak into your app.
463
-
464
- ## 16. ViewEncapsulation behavior
465
-
466
- `rv-menu` uses **`ViewEncapsulation.None`** deliberately, because projected
467
- content lives in _your_ template: item buttons are created by the consumer, so
468
- emulated (attribute-scoped) selectors can never match them. This was measured
469
- in Chrome — with emulated encapsulation `display` on item buttons stayed
470
- `inline-block` (unstyled, laid out inline).
471
-
472
- Implications:
473
-
474
- - Every library rule is scoped under `.rv-menu-panel` / `.rv-menu-trigger` /
475
- `rv-menu`, so nothing leaks out.
476
- - Conversely, your global styles _can_ reach the panel — that is the intended
477
- escape hatch.
478
- - `Emulated` is **not** claimed to give isolation; global CSS and cascade
479
- interactions can still affect the component.
480
- - Shadow DOM is **not** used: it would make consumer styling, inherited theme
481
- variables, Tailwind utilities and top-layer overlays harder, for no gain.
482
-
483
- ### What this means for YOUR component styles
484
-
485
- Angular's default `Emulated` encapsulation rewrites your selectors to require an
486
- attribute that only elements in _your_ template carry. The panel and the rows
487
- the library renders are **not** in your template, so:
488
-
489
- | You write | Does it reach the panel? |
490
- | --------------------------------------------------------------- | -------------------------------------------------- |
491
- | `.my-scope { --rv-menu-radius: 5px; }` in your component styles | ✅ **Yes** — custom properties inherit |
492
- | `.my-panel { background: red; }` in your component styles | ❌ No — selector needs your content attribute |
493
- | `.rv-menu-panel { … }` in a **global** stylesheet | ✅ Yes — that is the escape hatch |
494
- | `panelClass="my-panel"` | ✅ Yes — the class is applied to the panel element |
495
-
496
- So the supported paths for reaching the panel are **CSS custom properties**,
497
- `panelClass`, or a global stylesheet. Component-scoped _selectors_ cannot reach
498
- it; component-scoped _variables_ do. This was verified in Chromium and Firefox
499
- (a wrapper with `--rv-menu-radius` and `--rv-menu-bg` in component styles was
500
- measured on the rendered panel).
501
-
502
- ## 17. RTL and positioning
503
-
504
- `placement` values are **logical** (`start`/`end`), never physical:
505
-
506
- | Placement | LTR | RTL |
507
- | -------------- | ------------------------- | ----------------------------- |
508
- | `bottom-start` | below, left edge aligned | below, **right** edge aligned |
509
- | `bottom-end` | below, right edge aligned | below, **left** edge aligned |
510
-
511
- Direction is read from `document.dir` first, then the computed style.
512
-
513
- **Collision handling** (implemented, not aspirational):
514
-
515
- - The panel renders in the **top layer** (native `popover="manual"` +
516
- measured `fixed` coordinates) when the browser supports it, so it escapes
517
- `overflow: hidden` ancestors and stacking contexts. Verified in Chromium and
518
- Firefox (Playwright). **WebKit is not verified** — it could not be launched in
519
- the test environment (missing system libraries), so treat WebKit positioning
520
- as untested.
521
- - If the preferred vertical side overflows and the other side fits, the panel
522
- **flips** and sets `data-flipped="true"`.
523
- - It is then **clamped** so it always stays inside the viewport, keeping an
524
- 8 px margin.
525
- - Long lists scroll inside the panel (`max-height: min(20rem, 100dvh - 2rem)`).
526
- - Outside scroll / resize closes the menu instead of drifting.
527
- - Browsers without `popover` fall back to absolute placement with the same
528
- placement values (no flip, no top layer).
529
-
530
- Positioning is pure and reusable: `computeMenuPosition(trigger, panel,
531
- placement, viewport, rtl)`.
532
-
533
- ## 18. Advanced headless usage
534
-
535
- Use the controller with your own markup for full control:
536
-
537
- ```ts
538
- import { RiviniaMenu } from '@rivinia/menu';
539
-
540
- const menu = new RiviniaMenu();
541
- menu.register({ id: 'profile' });
542
- menu.register({ id: 'billing' });
543
- menu.register({ id: 'legacy', disabled: true });
544
-
545
- menu.openMenu(); // open, activeId === 'profile'
546
- menu.activateNext(); // 'billing' (skips 'legacy')
547
- menu.activateNext(); // wraps to 'profile'
548
- menu.activatePrevious(); // 'billing'
549
- menu.toggle('last'); // closes; openMenu('last') would focus 'billing'
550
-
551
- menu.unregister('legacy');
552
- menu.closeMenu(); // activeId back to null
553
- ```
554
-
555
- Contract: navigation skips disabled items and wraps; `closeMenu()` clears the
556
- active item; writes only through these methods. The controller has no DOM
557
- knowledge, so it is independently unit-testable (see
558
- `projects/menu/src/lib/rivinia-menu.spec.ts`).
559
-
560
- ## 19. Submenus and limitations
561
-
562
- **No submenu support.** There is no `aria-haspopup` on items, no submenu
563
- placement, no nested open/close lifecycle, and no tests for one. If you need
564
- hierarchical navigation, compose separate menus — do not expect menubar
565
- compliance from this package.
566
-
567
- Other limitations:
568
-
569
- - `placement` covers vertical + logical inline sides only; no arrow/caret,
570
- no offset customization.
571
- - Outside scroll closes the menu rather than re-positioning it.
572
- - No `ControlValueAccessor` — wire forms yourself (e.g. `[value]` + a
573
- `(selectionChange)`-style handler on your own component).
574
- - Typeahead matches the item's `label` input when set, otherwise its
575
- `textContent`. Keep an item's accessible name aligned with its visible text.
576
- - Very large lists (hundreds of items) are not virtualized.
577
-
578
- ## 20. SSR / hydration
579
-
580
- - The panel renders **closed** on the server, so SSR output is deterministic
581
- and hydration-safe. No panel id or `aria-expanded` state exists in the
582
- server HTML.
583
- - Focus and positioning are browser-only: `afterRenderEffect` never runs on the
584
- server, and the top-layer measurement is guarded by `typeof window` /
585
- feature detection.
586
- - `computeMenuPosition` is pure and environment-independent, so it can run
587
- anywhere (server-side position hints included).
588
-
589
- ## 21. Testing commands
590
-
591
- ```bash
592
- # unit tests (vitest + jsdom, includes headless controller and DOM UI)
593
- npx ng test menu --watch=false
594
-
595
- # library production build
596
- pnpm build:menu
597
-
598
- # Playwright browser suite (needs the fixture built first)
599
- npx ng build menu-e2e --configuration production
600
- npx playwright test --project=chromium
601
- npx playwright test --project=firefox
602
- npx playwright test --project=webkit
603
-
604
- # packed-artifact inspection
605
- npm pack ./dist/menu
606
- ```
607
-
608
- The Playwright suite serves `dist/menu-e2e/browser` from a local static server
609
- and runs against a real Angular **production** build of a consumer app, not
610
- unit mocks. A fresh server is always started — a stale server silently serves
611
- an old bundle, which looks exactly like a product bug.
612
-
613
- ## 22. Troubleshooting
614
-
615
- **Items look unstyled / sit inline.**
616
- You are probably styling `rvMenuItem` (camelCase) in CSS — HTML lowercases
617
- attribute names, so the selector never matches. Use `[rvmenuitem]`. The
618
- shipped styles already handle this; if you wrote your own, use the lowercase
619
- selector.
620
-
621
- **Panel renders at the bottom of the page.**
622
- The host must be positioned. The library sets `rv-menu { position: relative }`
623
- itself; if you override `position` on `rv-menu`, absolute fallback panels lose
624
- their anchor.
625
-
626
- **Menu closes as soon as I click an item.**
627
- An ancestor is calling `stopPropagation()` on `pointerdown`, so the outside
628
- listener never sees the event as inside. Check overlay/menu containers.
629
-
630
- **Focus does not land on the first item.**
631
- Only happens when the menu contains **no enabled items**. The panel still
632
- opens; add at least one enabled item or focus the trigger yourself.
633
-
634
- **Everything is invisible in forced-colors mode.**
635
- The default theme uses backgrounds and shadows. Supply your own
636
- `--rv-menu-*` values or a `@media (forced-colors: active)` block.
637
-
638
- ## 23. Migration notes
639
-
640
- ### 0.1.0 → 0.3.1
641
-
642
- `0.2.0` and `0.3.0` were never published — `0.1.0` is the only released
643
- version, so migrate straight from it.
644
-
645
- **Additions:** `[items]` declarative API, `panelClass`, `itemClass`, camelCase
646
- projection attributes (`rvMenuTrigger`, `rvMenuPanel`), `menuLabel`, panel
647
- `id` + trigger `aria-controls`, `checked` items (`menuitemcheckbox`),
648
- top-layer positioning with auto-flip.
649
-
650
- **Breaking / behaviour changes:**
651
-
652
- - **`label` → `menuLabel`** on `rv-menu`. It names the _menu_ (it maps to the
653
- panel's `aria-label`), while `label` on `RiviniaMenuItemData` names a _row_.
654
- ```diff
655
- -<rv-menu label="Account actions">
656
- +<rv-menu menuLabel="Account actions">
657
- ```
658
- - **Dark mode is no longer forced.** The shipped `prefers-color-scheme: dark`
659
- defaults also over-rode consumer tokens in dark mode. Tokens are now
660
- `var(--token, fallback)` values, so your theme always wins; opt into dark mode
661
- yourself (see [Styling](#13-styling-and-css-variables)).
662
- ```css
663
- @media (prefers-color-scheme: dark) {
664
- .my-menu {
665
- --rv-menu-bg: #161616;
666
- --rv-menu-fg: #f2f2f2;
667
- }
668
- }
669
- ```
670
- - **Outside pointer-down no longer restores focus to the trigger.** In 0.1.0
671
- clicking outside pulled focus back, which fought the browser and could steal
672
- focus from what the user actually clicked. If your app relied on that, restore
673
- it explicitly in your own outside handler.
674
- - **Tab / Shift+Tab step focus explicitly** to the stop before/after the
675
- _trigger_. Previously the browser re-entered the still-mounted panel and focus
676
- was then lost to `<body>` (reproduced on Chromium and Firefox).
677
-
678
- **Bug fixes that may change observed behaviour:**
679
-
680
- - A disabled item can no longer be activated with Enter, including when it was
681
- disabled _after_ receiving focus.
682
- - Opening can no longer strand focus on `<body>`.
683
- - The typeahead buffer resets per open instead of surviving a close/reopen.
684
- - A static `disabled` attribute on `<button rvMenuItem>` is now honoured.
685
- - CSS variables set on an ancestor now reach the panel.
686
-
687
- ### 0.3.1 → next (planned)
688
-
689
- Nothing yet.
690
-
691
- ## 24. Known verification gaps
692
-
693
- State plainly what has **not** been proven:
694
-
695
- - **Tailwind** — no Tailwind project exists in the test suite. Utility-class
696
- behavior is a reasoned expectation, not a measured result.
697
- - **WebKit** — the suite cannot launch WebKit here (missing
698
- `libWPEWebKit-2.0`, `libbacktrace`, `libjxl.so.0.8`, `libavif.so.16`).
699
- Fix with `npx playwright install-deps webkit`. Chromium and Firefox are green.
700
- - **Screen readers** — only automated axe-core checks were run. Manual testing
701
- with NVDA/JAWS/VoiceOver is still required; automated checks are necessary,
702
- not sufficient.
703
- - **Forced colors / high contrast** — the default theme uses backgrounds and
704
- shadows, so supply your own `--rv-menu-*` values or a `@media
705
- (forced-colors: active)` block.
706
-
707
- ## 25. Links
708
-
709
- - Package: https://www.npmjs.com/package/@rivinia/menu
710
- - Repository: https://github.com/abdelfattahqandil21-oss/Revinia
711
- - WAI-ARIA APG — Menu button pattern:
712
- https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
713
- - WAI-ARIA APG — Menu (menubar) pattern, for the differences:
714
- https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
715
- - Runnable fixture: `projects/menu-e2e` in this repository
716
- (`basic`, `dynamic`, `two`, `rtl`, `edge`, `styled` routes)
209
+ `RiviniaMenu` has readonly `open`, `activeId`, and `items` signals. Its
210
+ methods include `openMenu('first' | 'last' | 'none')`, `closeMenu()`,
211
+ `toggle()`, `activate(id)`, `activateFirst()`, `activateLast()`,
212
+ `activateNext()`, `activatePrevious()`, `register(item)`, and
213
+ `unregister(id)`. The UI directive manages registration for projected rows;
214
+ register manually only when building a headless renderer.
215
+
216
+ The core entry point also exports `RiviniaMenuController`,
217
+ `RiviniaMenuItemData`, `RiviniaMenuItem`, `RiviniaMenuFocus`, and
218
+ `RiviniaMenuPlacement`. The UI entry point exports
219
+ `RiviniaMenuComponent`, `RiviniaMenuItem`, `MenuPick`, and
220
+ `RiviniaMenuUiPlacement`, plus `computeMenuPosition`, `MENU_GAP`,
221
+ `MenuTriggerRect`, `MenuPanelSize`, `MenuViewport`, and `MenuCoordinates` for
222
+ custom positioning integrations.
717
223
 
718
224
  ## License
719
225
 
720
- MIT
226
+ MIT.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rivinia/menu",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Headless menu behavior for Angular: open state, keyboard navigation, item registry.",
5
5
  "keywords": [
6
6
  "angular",