@rivinia/menu 0.3.1 → 0.3.3
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 +144 -649
- package/fesm2022/rivinia-menu-ui.mjs +21 -2
- package/fesm2022/rivinia-menu-ui.mjs.map +1 -1
- package/package.json +1 -1
- package/types/rivinia-menu-ui.d.ts +13 -0
package/README.md
CHANGED
|
@@ -1,205 +1,170 @@
|
|
|
1
1
|
# @rivinia/menu
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
7
|
+
- Install: `npm install @rivinia/menu`
|
|
8
|
+
- Prepared patch: **0.3.3** (latest published: **0.3.2**)
|
|
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
|
-
-
|
|
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
|
-
|
|
15
|
-
|
|
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
27
|
template: `
|
|
73
|
-
<rv-menu
|
|
74
|
-
|
|
28
|
+
<rv-menu
|
|
29
|
+
[items]="accountActions"
|
|
30
|
+
menuLabel="Account actions"
|
|
31
|
+
placement="bottom-end"
|
|
32
|
+
(picked)="onPicked($event)"
|
|
33
|
+
>
|
|
34
|
+
<button type="button" rvMenuTrigger>Account</button>
|
|
75
35
|
</rv-menu>
|
|
76
36
|
`,
|
|
77
37
|
})
|
|
78
38
|
export class AccountMenu {
|
|
79
|
-
readonly
|
|
39
|
+
readonly accountActions: readonly RiviniaMenuItemData[] = [
|
|
80
40
|
{ id: 'profile', label: 'Profile' },
|
|
81
41
|
{ id: 'settings', label: 'Settings' },
|
|
82
|
-
{ id: '
|
|
42
|
+
{ id: 'sign-out', label: 'Sign out', disabled: true },
|
|
83
43
|
];
|
|
84
44
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
console.log(pick.id);
|
|
45
|
+
onPicked(pick: MenuPick): void {
|
|
46
|
+
// picked is an object; use pick.id, not the event itself as a string.
|
|
47
|
+
console.log('Selected action:', pick.id);
|
|
88
48
|
}
|
|
89
49
|
}
|
|
90
50
|
```
|
|
91
51
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
Projection attributes accept both spellings: `rvMenuTrigger` /
|
|
97
|
-
`rv-menu-trigger`, `rvMenuPanel` / `rv-menu-panel`.
|
|
52
|
+
`menuLabel` provides the accessible name for the menu panel and is
|
|
53
|
+
recommended. The trigger is consumer markup; the component adds its menu
|
|
54
|
+
button ARIA state and handles its keyboard activation. There are no service
|
|
55
|
+
providers or global styles to configure.
|
|
98
56
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
`(picked)` emits `MenuPick`, which is `{ readonly id: string }` — it is **not** a
|
|
102
|
-
bare string. Read `pick.id`:
|
|
57
|
+
In an NgModule-based application, import the standalone declaration in the
|
|
58
|
+
module's `imports` array (not `declarations`):
|
|
103
59
|
|
|
104
60
|
```ts
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
```
|
|
61
|
+
import { NgModule } from '@angular/core';
|
|
62
|
+
import { RiviniaMenuComponent } from '@rivinia/menu/ui';
|
|
109
63
|
|
|
110
|
-
|
|
111
|
-
|
|
64
|
+
@NgModule({
|
|
65
|
+
imports: [RiviniaMenuComponent],
|
|
66
|
+
})
|
|
67
|
+
export class AccountModule {}
|
|
68
|
+
```
|
|
112
69
|
|
|
113
|
-
|
|
70
|
+
### Custom panel content
|
|
114
71
|
|
|
115
|
-
|
|
116
|
-
|
|
72
|
+
Use the `RiviniaMenuItem` directive when rows need icons, custom layout, or
|
|
73
|
+
non-selectable headings and dividers. Import both standalone declarations:
|
|
117
74
|
|
|
118
75
|
```ts
|
|
119
76
|
import { Component } from '@angular/core';
|
|
120
77
|
import { RiviniaMenuComponent, RiviniaMenuItem } from '@rivinia/menu/ui';
|
|
121
|
-
import type { MenuPick } from '@rivinia/menu/ui';
|
|
122
78
|
|
|
123
79
|
@Component({
|
|
80
|
+
standalone: true,
|
|
124
81
|
imports: [RiviniaMenuComponent, RiviniaMenuItem],
|
|
125
82
|
template: `
|
|
126
|
-
<rv-menu
|
|
127
|
-
|
|
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>
|
|
83
|
+
<rv-menu menuLabel="Account actions" (picked)="runAction($event.id)">
|
|
84
|
+
<button type="button" rvMenuTrigger>Account</button>
|
|
136
85
|
|
|
137
86
|
<div rvMenuPanel>
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
<button rvMenuItem id="
|
|
141
|
-
<button rvMenuItem id="logout" disabled>Sign out</button>
|
|
87
|
+
<p class="menu-heading">Signed in as Ada</p>
|
|
88
|
+
<button type="button" rvMenuItem id="profile">Profile</button>
|
|
89
|
+
<button type="button" rvMenuItem id="sign-out">Sign out</button>
|
|
142
90
|
</div>
|
|
143
91
|
</rv-menu>
|
|
144
92
|
`,
|
|
145
93
|
})
|
|
146
|
-
export class
|
|
147
|
-
|
|
148
|
-
console.log(
|
|
94
|
+
export class CustomAccountMenu {
|
|
95
|
+
runAction(id: string): void {
|
|
96
|
+
console.log('Run account action:', id);
|
|
149
97
|
}
|
|
150
98
|
}
|
|
151
99
|
```
|
|
152
100
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
|
169
|
-
|
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
101
|
+
Every `rvMenuItem` needs a unique `id` within its menu. Items register and
|
|
102
|
+
unregister automatically, including rows rendered with `@for`; changing an
|
|
103
|
+
item's `id` or `disabled` state updates its registration. Non-item projected
|
|
104
|
+
content (such as the heading above) is not keyboard-navigable. Use a real
|
|
105
|
+
`button` for actions or an `a` with an `href` for navigation.
|
|
106
|
+
|
|
107
|
+
`[items]` and projected `rvMenuItem` rows are alternative ways to populate a
|
|
108
|
+
panel. Do not combine them: the component throws rather than silently ignore
|
|
109
|
+
one source. You may still project non-item content when using `[items]`.
|
|
110
|
+
|
|
111
|
+
The `rvMenuTrigger` / `rv-menu-trigger` and `rvMenuPanel` / `rv-menu-panel`
|
|
112
|
+
spellings are both accepted.
|
|
113
|
+
|
|
114
|
+
### Inputs and outputs
|
|
115
|
+
|
|
116
|
+
| API | Type | Behavior |
|
|
117
|
+
| ------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
118
|
+
| `menu` | `RiviniaMenuController \| null` | Optional external controller; omitted means the component owns one. |
|
|
119
|
+
| `items` | `readonly RiviniaMenuItemData[] \| null` | Declarative rows (`id`, `label`, optional `disabled`). |
|
|
120
|
+
| `placement` | `bottom-start \| bottom-end \| top-start \| top-end` | Logical, RTL-aware placement; defaults to `bottom-start` and flips vertically when needed. |
|
|
121
|
+
| `menuLabel` | `string \| null` | Accessible name of the menu panel. |
|
|
122
|
+
| `panelClass`, `itemClass` | `string \| null` | Additional classes on the panel, or on generated data rows. |
|
|
123
|
+
| `picked` | `MenuPick` (`{ id: string }`) | Emits after an enabled item is activated and the menu closes. |
|
|
124
|
+
| `opened`, `closed` | `void` | Notify when the panel opens or closes, for any close reason. |
|
|
125
|
+
|
|
126
|
+
For projected rows, `rvMenuItem` also accepts:
|
|
127
|
+
|
|
128
|
+
- `id` (required), `disabled` (defaults to `false`)
|
|
129
|
+
- `checked: boolean | null` (defaults to `null`; a boolean uses
|
|
130
|
+
`menuitemcheckbox` semantics and sets `aria-checked`)
|
|
131
|
+
- `label: string | null` (optional explicit typeahead text; otherwise visible
|
|
132
|
+
text is used)
|
|
133
|
+
|
|
134
|
+
Disabled rows remain visible but cannot be activated. For a native disabled
|
|
135
|
+
`button`, the directive also recognizes the element's native disabled state.
|
|
136
|
+
Data-driven rows are rendered as buttons and use the `id`, `label`, and
|
|
137
|
+
`disabled` fields from `RiviniaMenuItemData`.
|
|
138
|
+
|
|
139
|
+
The UI supplies basic layout and styling. Override its CSS custom properties
|
|
140
|
+
such as `--rv-menu-bg`, `--rv-menu-fg`, `--rv-menu-border`,
|
|
141
|
+
`--rv-menu-radius`, `--rv-menu-width`, and `--rv-menu-z` to theme it.
|
|
142
|
+
Projected content remains yours to style. Where supported, the panel uses the
|
|
143
|
+
browser popover top layer to avoid clipping ancestors; browsers without that
|
|
144
|
+
support use the positioned-panel fallback.
|
|
145
|
+
|
|
146
|
+
## Interaction and limitations
|
|
147
|
+
|
|
148
|
+
- Enter, Space, or ArrowDown opens on the first enabled item; ArrowUp opens on
|
|
149
|
+
the last. Arrow keys wrap through enabled items; Home and End jump to the
|
|
150
|
+
first and last. Printable characters perform typeahead matching.
|
|
151
|
+
- Escape closes and returns focus to the trigger. Selecting an item closes
|
|
152
|
+
first, then emits `picked`. Outside pointer-down closes without stealing
|
|
153
|
+
focus from the clicked target.
|
|
154
|
+
- Tab closes and moves focus onward; the menu is non-modal. Scrolling or
|
|
155
|
+
resizing the page closes it so the panel does not drift from its trigger.
|
|
156
|
+
- An empty or all-disabled menu can open, but has no active item to select.
|
|
157
|
+
- The default controller starts closed during server rendering. On the client,
|
|
158
|
+
placement is measured and flipped to fit the viewport when needed.
|
|
159
|
+
- Submenus, menubars, virtualization, form-control binding, and option-group
|
|
160
|
+
behavior are not provided. This component does not implement a persistent
|
|
161
|
+
multi-select menu.
|
|
162
|
+
|
|
163
|
+
## Headless controller
|
|
164
|
+
|
|
165
|
+
Import `RiviniaMenu` from `@rivinia/menu` when implementing a custom renderer
|
|
166
|
+
or when the component must control open state. Bind that instance to the UI's
|
|
167
|
+
`menu` input to keep rendering and focus behavior integrated:
|
|
203
168
|
|
|
204
169
|
```ts
|
|
205
170
|
import { Component } from '@angular/core';
|
|
@@ -207,513 +172,43 @@ import { RiviniaMenu } from '@rivinia/menu';
|
|
|
207
172
|
import { RiviniaMenuComponent, RiviniaMenuItem } from '@rivinia/menu/ui';
|
|
208
173
|
|
|
209
174
|
@Component({
|
|
175
|
+
standalone: true,
|
|
210
176
|
imports: [RiviniaMenuComponent, RiviniaMenuItem],
|
|
211
177
|
template: `
|
|
212
|
-
<rv-menu [menu]="menu"
|
|
213
|
-
<button
|
|
214
|
-
<div
|
|
215
|
-
<button rvMenuItem id="
|
|
216
|
-
<button rvMenuItem id="
|
|
178
|
+
<rv-menu [menu]="menu" menuLabel="Export actions" (picked)="exportAs($event.id)">
|
|
179
|
+
<button type="button" rvMenuTrigger>Export</button>
|
|
180
|
+
<div rvMenuPanel>
|
|
181
|
+
<button type="button" rvMenuItem id="csv">CSV</button>
|
|
182
|
+
<button type="button" rvMenuItem id="json">JSON</button>
|
|
217
183
|
</div>
|
|
218
184
|
</rv-menu>
|
|
185
|
+
<button type="button" (click)="menu.openMenu('last')">Open at last action</button>
|
|
219
186
|
`,
|
|
220
187
|
})
|
|
221
|
-
export class
|
|
222
|
-
|
|
188
|
+
export class ExportMenu {
|
|
189
|
+
readonly menu = new RiviniaMenu();
|
|
223
190
|
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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)
|
|
197
|
+
`RiviniaMenu` has readonly `open`, `activeId`, and `items` signals. Its
|
|
198
|
+
methods include `openMenu('first' | 'last' | 'none')`, `closeMenu()`,
|
|
199
|
+
`toggle()`, `activate(id)`, `activateFirst()`, `activateLast()`,
|
|
200
|
+
`activateNext()`, `activatePrevious()`, `register(item)`, and
|
|
201
|
+
`unregister(id)`. The UI directive manages registration for projected rows;
|
|
202
|
+
register manually only when building a headless renderer.
|
|
203
|
+
|
|
204
|
+
The core entry point also exports `RiviniaMenuController`,
|
|
205
|
+
`RiviniaMenuItemData`, `RiviniaMenuItem`, `RiviniaMenuFocus`, and
|
|
206
|
+
`RiviniaMenuPlacement`. The UI entry point exports
|
|
207
|
+
`RiviniaMenuComponent`, `RiviniaMenuItem`, `MenuPick`, and
|
|
208
|
+
`RiviniaMenuUiPlacement`, plus `computeMenuPosition`, `MENU_GAP`,
|
|
209
|
+
`MenuTriggerRect`, `MenuPanelSize`, `MenuViewport`, and `MenuCoordinates` for
|
|
210
|
+
custom positioning integrations.
|
|
716
211
|
|
|
717
212
|
## License
|
|
718
213
|
|
|
719
|
-
MIT
|
|
214
|
+
MIT.
|
|
@@ -39,6 +39,7 @@ var RiviniaMenuItem = class RiviniaMenuItem {
|
|
|
39
39
|
transform: (v) => v === "" || v === true
|
|
40
40
|
});
|
|
41
41
|
checked = input(null, ...ngDevMode ? [{ debugName: "checked" }] : /* istanbul ignore next */ []);
|
|
42
|
+
label = input(null, ...ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []);
|
|
42
43
|
host = inject(ElementRef, { optional: true });
|
|
43
44
|
menu = inject(RiviniaMenuComponent, {
|
|
44
45
|
optional: true,
|
|
@@ -126,6 +127,13 @@ var RiviniaMenuItem = class RiviniaMenuItem {
|
|
|
126
127
|
isSignal: true,
|
|
127
128
|
isRequired: false,
|
|
128
129
|
transformFunction: null
|
|
130
|
+
},
|
|
131
|
+
label: {
|
|
132
|
+
classPropertyName: "label",
|
|
133
|
+
publicName: "label",
|
|
134
|
+
isSignal: true,
|
|
135
|
+
isRequired: false,
|
|
136
|
+
transformFunction: null
|
|
129
137
|
}
|
|
130
138
|
},
|
|
131
139
|
host: {
|
|
@@ -136,6 +144,7 @@ var RiviniaMenuItem = class RiviniaMenuItem {
|
|
|
136
144
|
properties: {
|
|
137
145
|
"attr.role": "computedRole()",
|
|
138
146
|
"attr.data-rv-id": "id()",
|
|
147
|
+
"attr.data-rv-label": "label()",
|
|
139
148
|
"attr.tabindex": "tab()",
|
|
140
149
|
"attr.aria-disabled": "isDisabled()",
|
|
141
150
|
"attr.data-active": "active()",
|
|
@@ -158,6 +167,7 @@ i0.ɵɵngDeclareClassMetadata({
|
|
|
158
167
|
host: {
|
|
159
168
|
"[attr.role]": "computedRole()",
|
|
160
169
|
"[attr.data-rv-id]": "id()",
|
|
170
|
+
"[attr.data-rv-label]": "label()",
|
|
161
171
|
"[attr.tabindex]": "tab()",
|
|
162
172
|
"[attr.aria-disabled]": "isDisabled()",
|
|
163
173
|
"[attr.data-active]": "active()",
|
|
@@ -192,6 +202,14 @@ i0.ɵɵngDeclareClassMetadata({
|
|
|
192
202
|
alias: "checked",
|
|
193
203
|
required: false
|
|
194
204
|
}]
|
|
205
|
+
}],
|
|
206
|
+
label: [{
|
|
207
|
+
type: i0.Input,
|
|
208
|
+
args: [{
|
|
209
|
+
isSignal: true,
|
|
210
|
+
alias: "label",
|
|
211
|
+
required: false
|
|
212
|
+
}]
|
|
195
213
|
}]
|
|
196
214
|
}
|
|
197
215
|
});
|
|
@@ -435,7 +453,7 @@ var RiviniaMenuComponent = class RiviniaMenuComponent {
|
|
|
435
453
|
const enabled = new Set(this.controller().items().filter((i) => !i.disabled).map((i) => i.id));
|
|
436
454
|
const rows = [...root.querySelectorAll("[data-rv-id]")].map((el) => ({
|
|
437
455
|
id: el.getAttribute("data-rv-id") ?? "",
|
|
438
|
-
text: (el.textContent ?? "").trim().toLocaleLowerCase()
|
|
456
|
+
text: (el.getAttribute("data-rv-label") ?? el.textContent ?? "").trim().toLocaleLowerCase()
|
|
439
457
|
})).filter((r) => enabled.has(r.id) && r.text.startsWith(buffer));
|
|
440
458
|
if (rows.length === 0) return null;
|
|
441
459
|
if (new Set(buffer).size === 1) {
|
|
@@ -637,7 +655,8 @@ var RiviniaMenuComponent = class RiviniaMenuComponent {
|
|
|
637
655
|
inputs: [
|
|
638
656
|
"id",
|
|
639
657
|
"disabled",
|
|
640
|
-
"checked"
|
|
658
|
+
"checked",
|
|
659
|
+
"label"
|
|
641
660
|
]
|
|
642
661
|
}],
|
|
643
662
|
encapsulation: i0.ViewEncapsulation.None
|
|
@@ -1 +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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"}
|
|
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.data-rv-label]': 'label()',\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 * Optional explicit typeahead label. When set, typeahead matches on this\n * instead of the row's `textContent` (which can include icons, badges or\n * hints). Defaults to `null` → falls back to `textContent`. Mirrors\n * `@rivinia/select`'s documented rule that an item's accessible name should\n * match its visible text.\n */\n readonly label = input<string | 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.getAttribute('data-rv-label') ?? 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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"}
|
package/package.json
CHANGED
|
@@ -22,6 +22,14 @@ export declare class RiviniaMenuItem {
|
|
|
22
22
|
* so screen readers announce the state (the visual check alone is silent).
|
|
23
23
|
*/
|
|
24
24
|
readonly checked: import("@angular/core").InputSignal<boolean | null>;
|
|
25
|
+
/**
|
|
26
|
+
* Optional explicit typeahead label. When set, typeahead matches on this
|
|
27
|
+
* instead of the row's `textContent` (which can include icons, badges or
|
|
28
|
+
* hints). Defaults to `null` → falls back to `textContent`. Mirrors
|
|
29
|
+
* `@rivinia/select`'s documented rule that an item's accessible name should
|
|
30
|
+
* match its visible text.
|
|
31
|
+
*/
|
|
32
|
+
readonly label: import("@angular/core").InputSignal<string | null>;
|
|
25
33
|
private readonly host;
|
|
26
34
|
private readonly menu;
|
|
27
35
|
private readonly active;
|
|
@@ -61,6 +69,11 @@ export declare class RiviniaMenuItem {
|
|
|
61
69
|
"required": false;
|
|
62
70
|
"isSignal": true;
|
|
63
71
|
};
|
|
72
|
+
"label": {
|
|
73
|
+
"alias": "label";
|
|
74
|
+
"required": false;
|
|
75
|
+
"isSignal": true;
|
|
76
|
+
};
|
|
64
77
|
}, {}, never, never, true, never>;
|
|
65
78
|
}
|
|
66
79
|
/**
|