@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.
- package/README.md +718 -2
- package/fesm2022/rivinia-menu-ui.mjs +770 -0
- package/fesm2022/rivinia-menu-ui.mjs.map +1 -0
- package/fesm2022/rivinia-menu.mjs +71 -0
- package/fesm2022/rivinia-menu.mjs.map +1 -0
- package/package.json +33 -3
- package/types/rivinia-menu-ui.d.ts +299 -0
- package/types/rivinia-menu.d.ts +83 -0
package/README.md
CHANGED
|
@@ -1,3 +1,719 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rivinia/menu
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@rivinia/menu)
|
|
4
|
+
[](https://www.npmjs.com/package/@rivinia/menu)
|
|
5
|
+
|
|
6
|
+
Dropdown menu for Angular 22+ following the WAI-ARIA APG **menu button**
|
|
7
|
+
pattern. Two entry points:
|
|
8
|
+
|
|
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
|
+
|
|
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.**
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { Component } from '@angular/core';
|
|
67
|
+
import { RiviniaMenuComponent } from '@rivinia/menu/ui';
|
|
68
|
+
import type { MenuPick, RiviniaMenuItemData } from '@rivinia/menu/ui';
|
|
69
|
+
|
|
70
|
+
@Component({
|
|
71
|
+
imports: [RiviniaMenuComponent],
|
|
72
|
+
template: `
|
|
73
|
+
<rv-menu [items]="accountItems" menuLabel="Account actions" (picked)="onAction($event)">
|
|
74
|
+
<button rvMenuTrigger>Account</button>
|
|
75
|
+
</rv-menu>
|
|
76
|
+
`,
|
|
77
|
+
})
|
|
78
|
+
export class AccountMenu {
|
|
79
|
+
readonly accountItems: RiviniaMenuItemData[] = [
|
|
80
|
+
{ id: 'profile', label: 'Profile' },
|
|
81
|
+
{ id: 'settings', label: 'Settings' },
|
|
82
|
+
{ id: 'logout', label: 'Sign out', disabled: true },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
// `picked` emits MenuPick — an object, NOT a bare string.
|
|
86
|
+
onAction(pick: MenuPick) {
|
|
87
|
+
console.log(pick.id); // 'profile'
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
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`.
|
|
98
|
+
|
|
99
|
+
### The `picked` payload
|
|
100
|
+
|
|
101
|
+
`(picked)` emits `MenuPick`, which is `{ readonly id: string }` — it is **not** a
|
|
102
|
+
bare string. Read `pick.id`:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
onAction(pick: MenuPick) {
|
|
106
|
+
this.navigate(pick.id);
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`opened` and `closed` emit `void` and tell you when the panel opened/closed for
|
|
111
|
+
any reason (selection, Escape, outside click, Tab).
|
|
112
|
+
|
|
113
|
+
## 5b. Composable — custom markup
|
|
114
|
+
|
|
115
|
+
When you need headers, dividers, icons or per-row markup, project your own
|
|
116
|
+
rows. Items still register themselves.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { Component } from '@angular/core';
|
|
120
|
+
import { RiviniaMenuComponent, RiviniaMenuItem } from '@rivinia/menu/ui';
|
|
121
|
+
import type { MenuPick } from '@rivinia/menu/ui';
|
|
122
|
+
|
|
123
|
+
@Component({
|
|
124
|
+
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
|
+
`,
|
|
145
|
+
})
|
|
146
|
+
export class AccountMenu {
|
|
147
|
+
onAction(pick: MenuPick) {
|
|
148
|
+
console.log(pick.id); // 'profile' | 'logout'
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
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';
|
|
192
|
+
```
|
|
193
|
+
|
|
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).
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { Component } from '@angular/core';
|
|
206
|
+
import { RiviniaMenu } from '@rivinia/menu';
|
|
207
|
+
import { RiviniaMenuComponent, RiviniaMenuItem } from '@rivinia/menu/ui';
|
|
208
|
+
|
|
209
|
+
@Component({
|
|
210
|
+
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
|
+
`,
|
|
220
|
+
})
|
|
221
|
+
export class SaveMenu {
|
|
222
|
+
protected readonly menu = new RiviniaMenu();
|
|
223
|
+
|
|
224
|
+
async save(id: string) {
|
|
225
|
+
await this.persist(id); // async work
|
|
226
|
+
this.menu.closeMenu(); // controller API stays available
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
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
|
+
|
|
259
|
+
Items register on init, **stay in sync when `id` or `disabled` change**, and
|
|
260
|
+
unregister on destroy — no manual bookkeeping, and `@for`-driven collections
|
|
261
|
+
work.
|
|
262
|
+
|
|
263
|
+
### `RiviniaMenuItemData` (for `[items]`)
|
|
264
|
+
|
|
265
|
+
| Field | Type | Default | Required | Purpose |
|
|
266
|
+
| ---------- | --------- | ------- | -------- | --------------------------------------------------------------------- |
|
|
267
|
+
| `id` | `string` | — | **yes** | Identity, emitted in `picked`. |
|
|
268
|
+
| `label` | `string` | — | **yes** | Visible text. Also the accessible name and the typeahead search text. |
|
|
269
|
+
| `disabled` | `boolean` | `false` | no | Rendered but not selectable. |
|
|
270
|
+
|
|
271
|
+
## 9. Full public API reference
|
|
272
|
+
|
|
273
|
+
### `@rivinia/menu` (headless)
|
|
274
|
+
|
|
275
|
+
| Export | Kind | Summary |
|
|
276
|
+
| ----------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
277
|
+
| `RiviniaMenu` | class | Controller. `openMenu(focus?)`, `closeMenu()`, `toggle(focus?)`, `activate(id)`, `activateFirst()`, `activateLast()`, `activateNext()`, `activatePrevious()`, `register(item)`, `unregister(id)`, `pickActive()`. |
|
|
278
|
+
| `RiviniaMenuController` | interface | Structural subset a UI layer needs — accepts a controller from any module realm. |
|
|
279
|
+
| `RiviniaMenuItem` | interface | `{ id: string; disabled?: boolean }`. |
|
|
280
|
+
| `RiviniaMenuPlacement` | type | `'bottom-start' \| 'bottom-end' \| 'top-start' \| 'top-end'`. |
|
|
281
|
+
| `RiviniaMenuFocus` | type | `'first' \| 'last' \| 'none'` — which item is active when opening. |
|
|
282
|
+
|
|
283
|
+
Controller state is readonly: `open`, `activeId`, `items` signals. Writes only
|
|
284
|
+
happen through the explicit methods above.
|
|
285
|
+
|
|
286
|
+
### `@rivinia/menu/ui`
|
|
287
|
+
|
|
288
|
+
| Export | Kind | Summary |
|
|
289
|
+
| --------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------- |
|
|
290
|
+
| `RiviniaMenuComponent` | component | `<rv-menu>`. |
|
|
291
|
+
| `RiviniaMenuItem` | directive | `[rvMenuItem]`. |
|
|
292
|
+
| `MenuPick` | interface | `{ id: string }` — `picked` payload. |
|
|
293
|
+
| `RiviniaMenuUiPlacement` | type | Alias of `RiviniaMenuPlacement`, for UI-only imports. |
|
|
294
|
+
| `computeMenuPosition` | function | Pure `(trigger, panel, placement, viewport, rtl, gap?) => { left, top, flipped }`. |
|
|
295
|
+
| `MENU_GAP` | const | Default trigger↔panel gap in px (`6`). |
|
|
296
|
+
| `MenuTriggerRect`, `MenuPanelSize`, `MenuViewport`, `MenuCoordinates` | types | Arguments/result of `computeMenuPosition`. |
|
|
297
|
+
|
|
298
|
+
## 10. Keyboard interaction
|
|
299
|
+
|
|
300
|
+
Focus starts on the **trigger**. With the menu open:
|
|
301
|
+
|
|
302
|
+
| Key | Behavior |
|
|
303
|
+
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
304
|
+
| `Enter` / `Space` on trigger | Open, focus first item |
|
|
305
|
+
| `ArrowDown` on trigger | Open, focus first item |
|
|
306
|
+
| `ArrowUp` on trigger | Open, focus last item |
|
|
307
|
+
| `ArrowDown` / `ArrowUp` | Move to next / previous **enabled** item, wrapping |
|
|
308
|
+
| `Home` / `End` | First / last enabled item |
|
|
309
|
+
| Printable characters | Typeahead: focus the first item whose text starts with the buffer; repeating one character cycles its matches; buffer resets after 500 ms |
|
|
310
|
+
| `Enter` / `Space` | Activate the focused item → `picked` → close |
|
|
311
|
+
| `Escape` | Close and return focus to the trigger |
|
|
312
|
+
| `Tab` / `Shift+Tab` | Close, then focus continues to the next/previous element in document order |
|
|
313
|
+
| `Enter`/`Space`/`↓`/`↑`/`Esc`/`Tab` | Handled → `preventDefault()`. Anything else is left to the browser. |
|
|
314
|
+
|
|
315
|
+
The menu is **non-modal** by design (no focus trap, no `inert`) — Tab leaves
|
|
316
|
+
it and traversal stays natural.
|
|
317
|
+
|
|
318
|
+
## 11. Accessibility and focus behavior
|
|
319
|
+
|
|
320
|
+
**Roles and wiring**
|
|
321
|
+
|
|
322
|
+
- Panel: `role="menu"`, stable `id`, `aria-label` from `label`.
|
|
323
|
+
- Trigger: `aria-haspopup="menu"`, `aria-expanded` (synchronized with real open
|
|
324
|
+
state), `aria-controls` pointing at the panel id.
|
|
325
|
+
- Items: `role="menuitem"`, or `role="menuitemcheckbox"` + `aria-checked` when
|
|
326
|
+
`checked` is set. Disabled items get `aria-disabled="true"` (kept focusable
|
|
327
|
+
so screen readers can still reach them; the controller refuses activation).
|
|
328
|
+
|
|
329
|
+
**Focus contract**
|
|
330
|
+
|
|
331
|
+
| Event | Focus result |
|
|
332
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
333
|
+
| Open (mouse or keyboard) | First enabled item (`ArrowUp` → last). Deferred to after render so it always lands on a real element. |
|
|
334
|
+
| Arrow / Home / End / typeahead | Physical DOM focus moves to the target item (roving `tabindex`: only the active item is `tabindex="0"`). |
|
|
335
|
+
| `Escape` | Returns to the trigger. |
|
|
336
|
+
| `Tab` / `Shift+Tab` | Menu closes, focus continues naturally to the next/previous focusable element. |
|
|
337
|
+
| 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. |
|
|
338
|
+
| Outside pointer-down | Menu closes and **focus is not stolen** — the browser's natural focus behavior for the clicked element is preserved. |
|
|
339
|
+
| Outside scroll / window resize | Menu closes (coordinates would drift). Scrolling _inside_ the panel does not close it. |
|
|
340
|
+
|
|
341
|
+
**Empty / all-disabled menus:** the menu still opens, nothing is focused, and
|
|
342
|
+
arrow keys, Home/End, Enter and typeahead are safe no-ops. No exceptions, no
|
|
343
|
+
focus on `<body>`.
|
|
344
|
+
|
|
345
|
+
Verified with: 21 unit tests (`ng test menu`) and 19 Playwright scenarios in a
|
|
346
|
+
real Angular production build, including axe-core with zero violations on the
|
|
347
|
+
open menu.
|
|
348
|
+
|
|
349
|
+
## 12. Disabled and dynamic items
|
|
350
|
+
|
|
351
|
+
- **Disabled items** are skipped by arrows/Home/End/typeahead and cannot be
|
|
352
|
+
activated by click or Enter/Space. They remain in the tab order as
|
|
353
|
+
`aria-disabled` so assistive tech can discover them.
|
|
354
|
+
- **Dynamic items** register on init and unregister on destroy, so `@for`,
|
|
355
|
+
`@if` and route-driven changes are safe. Removing or disabling the currently
|
|
356
|
+
focused item clears `activeId` and focus moves to a valid target.
|
|
357
|
+
- **Duplicate ids** are idempotent: re-registering updates the entry in place.
|
|
358
|
+
- **Duplicate labels** are fine — ids are the identity. Typeahead matches the
|
|
359
|
+
first item whose text starts with the buffer.
|
|
360
|
+
|
|
361
|
+
## 13. Styling and CSS variables
|
|
362
|
+
|
|
363
|
+
The panel ships with a **default theme implemented entirely through CSS custom
|
|
364
|
+
properties**. Override any of them from your own CSS — no `!important`, no
|
|
365
|
+
specificity war:
|
|
366
|
+
|
|
367
|
+
| Variable | Default | Purpose |
|
|
368
|
+
| -------------------- | ----------------------------- | ----------------------------------------------------- |
|
|
369
|
+
| `--rv-menu-bg` | `#ffffff` (`#161616` in dark) | Panel background |
|
|
370
|
+
| `--rv-menu-fg` | `#111111` (`#f2f2f2`) | Text color |
|
|
371
|
+
| `--rv-menu-muted` | `#555555` (`#a8a8a8`) | Icons, secondary text |
|
|
372
|
+
| `--rv-menu-border` | `#e0e0e0` (`#333333`) | Border and divider color |
|
|
373
|
+
| `--rv-menu-radius` | `12px` | Corner radius |
|
|
374
|
+
| `--rv-menu-shadow` | `0 16px 48px rgba(0,0,0,.22)` | Panel shadow |
|
|
375
|
+
| `--rv-menu-pad` | `6px` | Panel padding |
|
|
376
|
+
| `--rv-menu-width` | `16rem` | Panel min-width |
|
|
377
|
+
| `--rv-menu-font` | system-ui stack | Panel font-family |
|
|
378
|
+
| `--rv-menu-z` | `500` | Stacking level (only used by the no-popover fallback) |
|
|
379
|
+
| `--rv-menu-duration` | `160ms` | Enter animation duration |
|
|
380
|
+
| `--rv-menu-easing` | `cubic-bezier(0.16,1,0.3,1)` | Enter animation easing |
|
|
381
|
+
| `--rv-menu-from-y` | `-6px` / `6px` for `top-*` | Enter animation offset |
|
|
382
|
+
|
|
383
|
+
Every default is a **`var(--token, fallback)` value**, not a declaration on the
|
|
384
|
+
element. A value you set on any ancestor (or on `rv-menu` / `.rv-menu-panel`
|
|
385
|
+
itself) therefore always wins, and a consumer token set from an Angular
|
|
386
|
+
component-scoped stylesheet works — unlike the selectors themselves, which
|
|
387
|
+
cannot reach the panel (see [ViewEncapsulation](#16-viewencapsulation-behavior)).
|
|
388
|
+
|
|
389
|
+
Set them on any ancestor of `rv-menu`, or on the `rv-menu` element itself:
|
|
390
|
+
|
|
391
|
+
```css
|
|
392
|
+
.my-accent {
|
|
393
|
+
--rv-menu-bg: #0b1220;
|
|
394
|
+
--rv-menu-fg: #e2e8f0;
|
|
395
|
+
--rv-menu-radius: 4px;
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**State hooks** for anything the variables do not cover:
|
|
400
|
+
|
|
401
|
+
| Hook | Where | Meaning |
|
|
402
|
+
| ------------------------ | --------------- | ----------------------------------------- |
|
|
403
|
+
| `.rv-menu-panel` | panel | The popup container |
|
|
404
|
+
| `.rv-menu-trigger` | trigger wrapper | Wraps the projected trigger |
|
|
405
|
+
| `[rvmenuitem]` | item | Any menu item row |
|
|
406
|
+
| `[data-active='true']` | item | Keyboard-active / pointer-hovered item |
|
|
407
|
+
| `[aria-disabled='true']` | item | Disabled item |
|
|
408
|
+
| `[data-placement]` | panel | `'bottom-start' \| …` |
|
|
409
|
+
| `[data-flipped='true']` | panel | Auto-flipped away from the requested side |
|
|
410
|
+
| `[data-positioned]` | panel | `false` until measured, `true` after |
|
|
411
|
+
| `[data-top-layer]` | panel | `true` when rendered in the top layer |
|
|
412
|
+
| `[data-rv-id]` | item | The item `id` |
|
|
413
|
+
|
|
414
|
+
```css
|
|
415
|
+
/* Brand the active row without touching library internals */
|
|
416
|
+
.my-accent .rv-menu-panel [data-active='true'] {
|
|
417
|
+
background: color-mix(in srgb, currentColor 14%, transparent);
|
|
418
|
+
outline: 2px dashed currentColor;
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Dark mode is **opt-in**: the library ships light defaults only (as
|
|
423
|
+
`var(--x, fallback)` values), so nothing overrides your theme. Opt in with:
|
|
424
|
+
|
|
425
|
+
```css
|
|
426
|
+
@media (prefers-color-scheme: dark) {
|
|
427
|
+
.my-menu {
|
|
428
|
+
--rv-menu-bg: #161616;
|
|
429
|
+
--rv-menu-fg: #f2f2f2;
|
|
430
|
+
--rv-menu-muted: #a8a8a8;
|
|
431
|
+
--rv-menu-border: #333333;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
## 14. Tailwind integration
|
|
437
|
+
|
|
438
|
+
The documented override path is CSS variables and the state hooks above —
|
|
439
|
+
those work with any utility framework. For utilities on the panel itself, put
|
|
440
|
+
them on your own wrapper:
|
|
441
|
+
|
|
442
|
+
```html
|
|
443
|
+
<div class="rounded-xl border border-slate-200 shadow-lg">
|
|
444
|
+
<rv-menu [menu]="menu"> … </rv-menu>
|
|
445
|
+
</div>
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
> **Status:** verified with plain CSS and CSS variables in a real consumer
|
|
449
|
+
> production build. A dedicated Tailwind project is **not** part of the test
|
|
450
|
+
> suite — treat Tailwind-specific behavior as untested until you verify it in
|
|
451
|
+
> your app. Because the panel uses a small set of predictable classes and
|
|
452
|
+
> `data-*` hooks (no `!important`, no global resets), utilities should win
|
|
453
|
+
> through normal cascade rules, but that is a reasoned expectation, not a
|
|
454
|
+
> measured result.
|
|
455
|
+
|
|
456
|
+
## 15. Plain CSS integration
|
|
457
|
+
|
|
458
|
+
Fully supported — that is the primary path (see §13). No build plugin, no CSS
|
|
459
|
+
import, no configuration. The library's styles ship inside the component
|
|
460
|
+
(`ViewEncapsulation.None`, see §16) and are scoped under `.rv-menu-*` /
|
|
461
|
+
`rv-menu`, so they cannot leak into your app.
|
|
462
|
+
|
|
463
|
+
## 16. ViewEncapsulation behavior
|
|
464
|
+
|
|
465
|
+
`rv-menu` uses **`ViewEncapsulation.None`** deliberately, because projected
|
|
466
|
+
content lives in _your_ template: item buttons are created by the consumer, so
|
|
467
|
+
emulated (attribute-scoped) selectors can never match them. This was measured
|
|
468
|
+
in Chrome — with emulated encapsulation `display` on item buttons stayed
|
|
469
|
+
`inline-block` (unstyled, laid out inline).
|
|
470
|
+
|
|
471
|
+
Implications:
|
|
472
|
+
|
|
473
|
+
- Every library rule is scoped under `.rv-menu-panel` / `.rv-menu-trigger` /
|
|
474
|
+
`rv-menu`, so nothing leaks out.
|
|
475
|
+
- Conversely, your global styles _can_ reach the panel — that is the intended
|
|
476
|
+
escape hatch.
|
|
477
|
+
- `Emulated` is **not** claimed to give isolation; global CSS and cascade
|
|
478
|
+
interactions can still affect the component.
|
|
479
|
+
- Shadow DOM is **not** used: it would make consumer styling, inherited theme
|
|
480
|
+
variables, Tailwind utilities and top-layer overlays harder, for no gain.
|
|
481
|
+
|
|
482
|
+
### What this means for YOUR component styles
|
|
483
|
+
|
|
484
|
+
Angular's default `Emulated` encapsulation rewrites your selectors to require an
|
|
485
|
+
attribute that only elements in _your_ template carry. The panel and the rows
|
|
486
|
+
the library renders are **not** in your template, so:
|
|
487
|
+
|
|
488
|
+
| You write | Does it reach the panel? |
|
|
489
|
+
| --------------------------------------------------------------- | -------------------------------------------------- |
|
|
490
|
+
| `.my-scope { --rv-menu-radius: 5px; }` in your component styles | ✅ **Yes** — custom properties inherit |
|
|
491
|
+
| `.my-panel { background: red; }` in your component styles | ❌ No — selector needs your content attribute |
|
|
492
|
+
| `.rv-menu-panel { … }` in a **global** stylesheet | ✅ Yes — that is the escape hatch |
|
|
493
|
+
| `panelClass="my-panel"` | ✅ Yes — the class is applied to the panel element |
|
|
494
|
+
|
|
495
|
+
So the supported paths for reaching the panel are **CSS custom properties**,
|
|
496
|
+
`panelClass`, or a global stylesheet. Component-scoped _selectors_ cannot reach
|
|
497
|
+
it; component-scoped _variables_ do. This was verified in Chromium and Firefox
|
|
498
|
+
(a wrapper with `--rv-menu-radius` and `--rv-menu-bg` in component styles was
|
|
499
|
+
measured on the rendered panel).
|
|
500
|
+
|
|
501
|
+
## 17. RTL and positioning
|
|
502
|
+
|
|
503
|
+
`placement` values are **logical** (`start`/`end`), never physical:
|
|
504
|
+
|
|
505
|
+
| Placement | LTR | RTL |
|
|
506
|
+
| -------------- | ------------------------- | ----------------------------- |
|
|
507
|
+
| `bottom-start` | below, left edge aligned | below, **right** edge aligned |
|
|
508
|
+
| `bottom-end` | below, right edge aligned | below, **left** edge aligned |
|
|
509
|
+
|
|
510
|
+
Direction is read from `document.dir` first, then the computed style.
|
|
511
|
+
|
|
512
|
+
**Collision handling** (implemented, not aspirational):
|
|
513
|
+
|
|
514
|
+
- The panel renders in the **top layer** (native `popover="manual"` +
|
|
515
|
+
measured `fixed` coordinates) when the browser supports it, so it escapes
|
|
516
|
+
`overflow: hidden` ancestors and stacking contexts. Verified in Chromium and
|
|
517
|
+
Firefox (Playwright). **WebKit is not verified** — it could not be launched in
|
|
518
|
+
the test environment (missing system libraries), so treat WebKit positioning
|
|
519
|
+
as untested.
|
|
520
|
+
- If the preferred vertical side overflows and the other side fits, the panel
|
|
521
|
+
**flips** and sets `data-flipped="true"`.
|
|
522
|
+
- It is then **clamped** so it always stays inside the viewport, keeping an
|
|
523
|
+
8 px margin.
|
|
524
|
+
- Long lists scroll inside the panel (`max-height: min(20rem, 100dvh - 2rem)`).
|
|
525
|
+
- Outside scroll / resize closes the menu instead of drifting.
|
|
526
|
+
- Browsers without `popover` fall back to absolute placement with the same
|
|
527
|
+
placement values (no flip, no top layer).
|
|
528
|
+
|
|
529
|
+
Positioning is pure and reusable: `computeMenuPosition(trigger, panel,
|
|
530
|
+
placement, viewport, rtl)`.
|
|
531
|
+
|
|
532
|
+
## 18. Advanced headless usage
|
|
533
|
+
|
|
534
|
+
Use the controller with your own markup for full control:
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
import { RiviniaMenu } from '@rivinia/menu';
|
|
538
|
+
|
|
539
|
+
const menu = new RiviniaMenu();
|
|
540
|
+
menu.register({ id: 'profile' });
|
|
541
|
+
menu.register({ id: 'billing' });
|
|
542
|
+
menu.register({ id: 'legacy', disabled: true });
|
|
543
|
+
|
|
544
|
+
menu.openMenu(); // open, activeId === 'profile'
|
|
545
|
+
menu.activateNext(); // 'billing' (skips 'legacy')
|
|
546
|
+
menu.activateNext(); // wraps to 'profile'
|
|
547
|
+
menu.activatePrevious(); // 'billing'
|
|
548
|
+
menu.toggle('last'); // closes; openMenu('last') would focus 'billing'
|
|
549
|
+
|
|
550
|
+
menu.unregister('legacy');
|
|
551
|
+
menu.closeMenu(); // activeId back to null
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Contract: navigation skips disabled items and wraps; `closeMenu()` clears the
|
|
555
|
+
active item; writes only through these methods. The controller has no DOM
|
|
556
|
+
knowledge, so it is independently unit-testable (see
|
|
557
|
+
`projects/menu/src/lib/rivinia-menu.spec.ts`).
|
|
558
|
+
|
|
559
|
+
## 19. Submenus and limitations
|
|
560
|
+
|
|
561
|
+
**No submenu support.** There is no `aria-haspopup` on items, no submenu
|
|
562
|
+
placement, no nested open/close lifecycle, and no tests for one. If you need
|
|
563
|
+
hierarchical navigation, compose separate menus — do not expect menubar
|
|
564
|
+
compliance from this package.
|
|
565
|
+
|
|
566
|
+
Other limitations:
|
|
567
|
+
|
|
568
|
+
- `placement` covers vertical + logical inline sides only; no arrow/caret,
|
|
569
|
+
no offset customization.
|
|
570
|
+
- Outside scroll closes the menu rather than re-positioning it.
|
|
571
|
+
- No `ControlValueAccessor` — wire forms yourself (e.g. `[value]` + a
|
|
572
|
+
`(selectionChange)`-style handler on your own component).
|
|
573
|
+
- Typeahead matches `textContent`, so an item's accessible name should match
|
|
574
|
+
its visible text.
|
|
575
|
+
- Very large lists (hundreds of items) are not virtualized.
|
|
576
|
+
|
|
577
|
+
## 20. SSR / hydration
|
|
578
|
+
|
|
579
|
+
- The panel renders **closed** on the server, so SSR output is deterministic
|
|
580
|
+
and hydration-safe. No panel id or `aria-expanded` state exists in the
|
|
581
|
+
server HTML.
|
|
582
|
+
- Focus and positioning are browser-only: `afterRenderEffect` never runs on the
|
|
583
|
+
server, and the top-layer measurement is guarded by `typeof window` /
|
|
584
|
+
feature detection.
|
|
585
|
+
- `computeMenuPosition` is pure and environment-independent, so it can run
|
|
586
|
+
anywhere (server-side position hints included).
|
|
587
|
+
|
|
588
|
+
## 21. Testing commands
|
|
589
|
+
|
|
590
|
+
```bash
|
|
591
|
+
# unit tests (vitest + jsdom, includes headless controller and DOM UI)
|
|
592
|
+
npx ng test menu --watch=false
|
|
593
|
+
|
|
594
|
+
# library production build
|
|
595
|
+
pnpm build:menu
|
|
596
|
+
|
|
597
|
+
# Playwright browser suite (needs the fixture built first)
|
|
598
|
+
npx ng build menu-e2e --configuration production
|
|
599
|
+
npx playwright test --project=chromium
|
|
600
|
+
npx playwright test --project=firefox
|
|
601
|
+
npx playwright test --project=webkit
|
|
602
|
+
|
|
603
|
+
# packed-artifact inspection
|
|
604
|
+
npm pack ./dist/menu
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
The Playwright suite serves `dist/menu-e2e/browser` from a local static server
|
|
608
|
+
and runs against a real Angular **production** build of a consumer app, not
|
|
609
|
+
unit mocks. A fresh server is always started — a stale server silently serves
|
|
610
|
+
an old bundle, which looks exactly like a product bug.
|
|
611
|
+
|
|
612
|
+
## 22. Troubleshooting
|
|
613
|
+
|
|
614
|
+
**Items look unstyled / sit inline.**
|
|
615
|
+
You are probably styling `rvMenuItem` (camelCase) in CSS — HTML lowercases
|
|
616
|
+
attribute names, so the selector never matches. Use `[rvmenuitem]`. The
|
|
617
|
+
shipped styles already handle this; if you wrote your own, use the lowercase
|
|
618
|
+
selector.
|
|
619
|
+
|
|
620
|
+
**Panel renders at the bottom of the page.**
|
|
621
|
+
The host must be positioned. The library sets `rv-menu { position: relative }`
|
|
622
|
+
itself; if you override `position` on `rv-menu`, absolute fallback panels lose
|
|
623
|
+
their anchor.
|
|
624
|
+
|
|
625
|
+
**Menu closes as soon as I click an item.**
|
|
626
|
+
An ancestor is calling `stopPropagation()` on `pointerdown`, so the outside
|
|
627
|
+
listener never sees the event as inside. Check overlay/menu containers.
|
|
628
|
+
|
|
629
|
+
**Focus does not land on the first item.**
|
|
630
|
+
Only happens when the menu contains **no enabled items**. The panel still
|
|
631
|
+
opens; add at least one enabled item or focus the trigger yourself.
|
|
632
|
+
|
|
633
|
+
**Everything is invisible in forced-colors mode.**
|
|
634
|
+
The default theme uses backgrounds and shadows. Supply your own
|
|
635
|
+
`--rv-menu-*` values or a `@media (forced-colors: active)` block.
|
|
636
|
+
|
|
637
|
+
## 23. Migration notes
|
|
638
|
+
|
|
639
|
+
### 0.1.0 → 0.3.1
|
|
640
|
+
|
|
641
|
+
`0.2.0` and `0.3.0` were never published — `0.1.0` is the only released
|
|
642
|
+
version, so migrate straight from it.
|
|
643
|
+
|
|
644
|
+
**Additions:** `[items]` declarative API, `panelClass`, `itemClass`, camelCase
|
|
645
|
+
projection attributes (`rvMenuTrigger`, `rvMenuPanel`), `menuLabel`, panel
|
|
646
|
+
`id` + trigger `aria-controls`, `checked` items (`menuitemcheckbox`),
|
|
647
|
+
top-layer positioning with auto-flip.
|
|
648
|
+
|
|
649
|
+
**Breaking / behaviour changes:**
|
|
650
|
+
|
|
651
|
+
- **`label` → `menuLabel`** on `rv-menu`. It names the _menu_ (it maps to the
|
|
652
|
+
panel's `aria-label`), while `label` on `RiviniaMenuItemData` names a _row_.
|
|
653
|
+
```diff
|
|
654
|
+
-<rv-menu label="Account actions">
|
|
655
|
+
+<rv-menu menuLabel="Account actions">
|
|
656
|
+
```
|
|
657
|
+
- **Dark mode is no longer forced.** The shipped `prefers-color-scheme: dark`
|
|
658
|
+
defaults also over-rode consumer tokens in dark mode. Tokens are now
|
|
659
|
+
`var(--token, fallback)` values, so your theme always wins; opt into dark mode
|
|
660
|
+
yourself (see [Styling](#13-styling-and-css-variables)).
|
|
661
|
+
```css
|
|
662
|
+
@media (prefers-color-scheme: dark) {
|
|
663
|
+
.my-menu {
|
|
664
|
+
--rv-menu-bg: #161616;
|
|
665
|
+
--rv-menu-fg: #f2f2f2;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
```
|
|
669
|
+
- **Outside pointer-down no longer restores focus to the trigger.** In 0.1.0
|
|
670
|
+
clicking outside pulled focus back, which fought the browser and could steal
|
|
671
|
+
focus from what the user actually clicked. If your app relied on that, restore
|
|
672
|
+
it explicitly in your own outside handler.
|
|
673
|
+
- **Tab / Shift+Tab step focus explicitly** to the stop before/after the
|
|
674
|
+
_trigger_. Previously the browser re-entered the still-mounted panel and focus
|
|
675
|
+
was then lost to `<body>` (reproduced on Chromium and Firefox).
|
|
676
|
+
|
|
677
|
+
**Bug fixes that may change observed behaviour:**
|
|
678
|
+
|
|
679
|
+
- A disabled item can no longer be activated with Enter, including when it was
|
|
680
|
+
disabled _after_ receiving focus.
|
|
681
|
+
- Opening can no longer strand focus on `<body>`.
|
|
682
|
+
- The typeahead buffer resets per open instead of surviving a close/reopen.
|
|
683
|
+
- A static `disabled` attribute on `<button rvMenuItem>` is now honoured.
|
|
684
|
+
- CSS variables set on an ancestor now reach the panel.
|
|
685
|
+
|
|
686
|
+
### 0.3.1 → next (planned)
|
|
687
|
+
|
|
688
|
+
Nothing yet.
|
|
689
|
+
|
|
690
|
+
## 24. Known verification gaps
|
|
691
|
+
|
|
692
|
+
State plainly what has **not** been proven:
|
|
693
|
+
|
|
694
|
+
- **Tailwind** — no Tailwind project exists in the test suite. Utility-class
|
|
695
|
+
behavior is a reasoned expectation, not a measured result.
|
|
696
|
+
- **WebKit** — the suite cannot launch WebKit here (missing
|
|
697
|
+
`libWPEWebKit-2.0`, `libbacktrace`, `libjxl.so.0.8`, `libavif.so.16`).
|
|
698
|
+
Fix with `npx playwright install-deps webkit`. Chromium and Firefox are green.
|
|
699
|
+
- **Screen readers** — only automated axe-core checks were run. Manual testing
|
|
700
|
+
with NVDA/JAWS/VoiceOver is still required; automated checks are necessary,
|
|
701
|
+
not sufficient.
|
|
702
|
+
- **Forced colors / high contrast** — the default theme uses backgrounds and
|
|
703
|
+
shadows, so supply your own `--rv-menu-*` values or a `@media
|
|
704
|
+
(forced-colors: active)` block.
|
|
705
|
+
|
|
706
|
+
## 25. Links
|
|
707
|
+
|
|
708
|
+
- Package: https://www.npmjs.com/package/@rivinia/menu
|
|
709
|
+
- Repository: https://github.com/abdelfattahqandil21-oss/Revinia
|
|
710
|
+
- WAI-ARIA APG — Menu button pattern:
|
|
711
|
+
https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
|
|
712
|
+
- WAI-ARIA APG — Menu (menubar) pattern, for the differences:
|
|
713
|
+
https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
|
|
714
|
+
- Runnable fixture: `projects/menu-e2e` in this repository
|
|
715
|
+
(`basic`, `dynamic`, `two`, `rtl`, `edge`, `styled` routes)
|
|
716
|
+
|
|
717
|
+
## License
|
|
718
|
+
|
|
719
|
+
MIT
|