@lilydesignsystem/angular-menu-picker 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +49 -0
- package/CHANGELOG.md +12 -0
- package/dist/README.md +63 -0
- package/dist/fesm2022/lilydesignsystem-angular-menu-picker.mjs +494 -0
- package/dist/fesm2022/lilydesignsystem-angular-menu-picker.mjs.map +1 -0
- package/dist/package.json +50 -0
- package/dist/types/lilydesignsystem-angular-menu-picker.d.ts +112 -0
- package/dist/types/lilydesignsystem-angular-menu-picker.d.ts.map +1 -0
- package/docs/accessibility.md +31 -0
- package/examples/README.md +9 -0
- package/examples/basic.component.ts +19 -0
- package/examples/custom-icon.component.ts +17 -0
- package/examples/form.component.ts +19 -0
- package/index.md +63 -0
- package/index.ts +7 -0
- package/menu-picker.component.spec.ts +276 -0
- package/menu-picker.component.ts +325 -0
- package/ng-package.json +10 -0
- package/package.json +39 -4
- package/spec/index.md +124 -0
- package/tsconfig.lib.json +13 -0
- package/README.md +0 -3
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { NgTemplateOutlet } from "@angular/common";
|
|
2
|
+
import {
|
|
3
|
+
ChangeDetectionStrategy,
|
|
4
|
+
Component,
|
|
5
|
+
Directive,
|
|
6
|
+
ElementRef,
|
|
7
|
+
TemplateRef,
|
|
8
|
+
computed,
|
|
9
|
+
contentChild,
|
|
10
|
+
effect,
|
|
11
|
+
input,
|
|
12
|
+
model,
|
|
13
|
+
signal,
|
|
14
|
+
viewChild,
|
|
15
|
+
} from "@angular/core";
|
|
16
|
+
// Only the trigger button composes a headless primitive. The panel is a disclosure of whatever the
|
|
17
|
+
// app projects into it — links, buttons, forms — so it carries no ARIA menu roles (role="menu" would
|
|
18
|
+
// promise a roving-focus menuitem widget that arbitrary content is not). See spec/index.md §3.
|
|
19
|
+
import { IconButton } from "@lilydesignsystem/angular-headless";
|
|
20
|
+
|
|
21
|
+
/** Context passed to a custom icon `<ng-template>` (the button icon). */
|
|
22
|
+
export type ChildArgs = {
|
|
23
|
+
/** Is the panel open? */
|
|
24
|
+
open: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let uid = 0;
|
|
28
|
+
/** Stable per-instance id prefix; SSR-safe (no Math.random / Date.now). */
|
|
29
|
+
export function nextMenuPickerId(): string {
|
|
30
|
+
uid += 1;
|
|
31
|
+
return `menu-picker-${uid}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The selector for focusable things inside the panel. */
|
|
35
|
+
export const FOCUSABLE =
|
|
36
|
+
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Marker for the projected icon template. Gives consumers typed `let-` variables:
|
|
40
|
+
*
|
|
41
|
+
* ```html
|
|
42
|
+
* <lily-menu-picker label="Menu">
|
|
43
|
+
* <ng-template lilyMenuPickerIcon let-args>{{ args.open ? "×" : "☰" }}</ng-template>
|
|
44
|
+
* …the panel's content…
|
|
45
|
+
* </lily-menu-picker>
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
@Directive({
|
|
49
|
+
selector: "ng-template[lilyMenuPickerIcon]",
|
|
50
|
+
standalone: true,
|
|
51
|
+
})
|
|
52
|
+
export class MenuPickerIcon {
|
|
53
|
+
static ngTemplateContextGuard(
|
|
54
|
+
_dir: MenuPickerIcon,
|
|
55
|
+
_ctx: unknown,
|
|
56
|
+
): _ctx is ChildArgs & { $implicit: ChildArgs } {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* MenuPicker — a headless dropdown control.
|
|
63
|
+
*
|
|
64
|
+
* A single-icon button (a bundled hamburger SVG) that opens a disclosure panel holding whatever
|
|
65
|
+
* the app projects into it. It applies nothing to the document and persists nothing. Give the
|
|
66
|
+
* element a template reference (`#menu`) to call `menu.close()` from the projected content.
|
|
67
|
+
* See `spec/index.md`.
|
|
68
|
+
*/
|
|
69
|
+
@Component({
|
|
70
|
+
selector: "lily-menu-picker",
|
|
71
|
+
standalone: true,
|
|
72
|
+
exportAs: "lilyMenuPicker",
|
|
73
|
+
imports: [NgTemplateOutlet, IconButton],
|
|
74
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
75
|
+
host: {
|
|
76
|
+
"(document:click)": "onDocumentClick($event)",
|
|
77
|
+
},
|
|
78
|
+
template: `
|
|
79
|
+
<div
|
|
80
|
+
#rootEl
|
|
81
|
+
class="menu-picker {{ className() }}"
|
|
82
|
+
(focusout)="onRootFocusOut($event)"
|
|
83
|
+
>
|
|
84
|
+
<lily-icon-button
|
|
85
|
+
#buttonEl
|
|
86
|
+
[label]="label()"
|
|
87
|
+
baseClass="menu-picker-button"
|
|
88
|
+
[ariaExpanded]="open()"
|
|
89
|
+
[ariaControls]="panelId"
|
|
90
|
+
(click)="onButtonClick(); hoverButton.set(false)"
|
|
91
|
+
(mouseenter)="onTooltipButtonEnter()"
|
|
92
|
+
(mouseleave)="hoverButton.set(false)"
|
|
93
|
+
(focusin)="onTooltipButtonFocusIn()"
|
|
94
|
+
(focusout)="onTooltipButtonFocusOut()"
|
|
95
|
+
(keydown)="onButtonKeydown($event); onTooltipKeydown($event)"
|
|
96
|
+
>
|
|
97
|
+
@if (iconTemplate(); as tpl) {
|
|
98
|
+
<ng-container
|
|
99
|
+
[ngTemplateOutlet]="tpl"
|
|
100
|
+
[ngTemplateOutletContext]="childContext()"
|
|
101
|
+
/>
|
|
102
|
+
} @else {
|
|
103
|
+
<svg
|
|
104
|
+
class="menu-picker-icon"
|
|
105
|
+
viewBox="0 0 16 16"
|
|
106
|
+
width="1.05rem"
|
|
107
|
+
height="1.05rem"
|
|
108
|
+
aria-hidden="true"
|
|
109
|
+
fill="none"
|
|
110
|
+
stroke="currentColor"
|
|
111
|
+
stroke-width="1.6"
|
|
112
|
+
stroke-linecap="round"
|
|
113
|
+
stroke-linejoin="round"
|
|
114
|
+
>
|
|
115
|
+
<path d="M1.5 3.5h13M1.5 8h13M1.5 12.5h13" />
|
|
116
|
+
</svg>
|
|
117
|
+
}
|
|
118
|
+
</lily-icon-button>
|
|
119
|
+
|
|
120
|
+
<!-- Purely visual: the same text is already the button's aria-label, so it is not wired
|
|
121
|
+
with aria-describedby (that would announce the name twice). -->
|
|
122
|
+
<div
|
|
123
|
+
class="menu-picker-tooltip"
|
|
124
|
+
role="tooltip"
|
|
125
|
+
[id]="tooltipId"
|
|
126
|
+
[attr.hidden]="tooltipVisible() ? null : ''"
|
|
127
|
+
(mouseenter)="hoverTooltip.set(true)"
|
|
128
|
+
(mouseleave)="hoverTooltip.set(false)"
|
|
129
|
+
>{{ label() }}</div>
|
|
130
|
+
|
|
131
|
+
<!-- A named group, not a menu: the content is the app's. The handlers are pure delegation
|
|
132
|
+
for whatever focusable content the app projects. -->
|
|
133
|
+
<div
|
|
134
|
+
#panelEl
|
|
135
|
+
class="menu-picker-panel"
|
|
136
|
+
[id]="panelId"
|
|
137
|
+
role="group"
|
|
138
|
+
[attr.aria-label]="label() || null"
|
|
139
|
+
[attr.hidden]="open() ? null : ''"
|
|
140
|
+
(keydown)="onPanelKeydown($event)"
|
|
141
|
+
(click)="onPanelClick($event)"
|
|
142
|
+
>
|
|
143
|
+
<ng-content />
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
`,
|
|
147
|
+
})
|
|
148
|
+
export class MenuPicker {
|
|
149
|
+
/** Accessible name for the button, the panel and the tooltip. */
|
|
150
|
+
readonly label = input.required<string>();
|
|
151
|
+
/** Is the panel open? Two-way bindable: `[(open)]`. */
|
|
152
|
+
readonly open = model<boolean>(false);
|
|
153
|
+
/**
|
|
154
|
+
* Close the panel when a link, button or `[role="menuitem"]` inside it is activated. Add
|
|
155
|
+
* `data-menu-picker-keep-open` to an element (or an ancestor) to opt it out. Default `true`.
|
|
156
|
+
*/
|
|
157
|
+
readonly closeOnSelect = input<boolean>(true);
|
|
158
|
+
/** Extra CSS class on the root div. */
|
|
159
|
+
readonly className = input<string>("");
|
|
160
|
+
|
|
161
|
+
/** Projected icon template; replaces the default icon when supplied. */
|
|
162
|
+
protected readonly iconTemplate = contentChild(MenuPickerIcon, { read: TemplateRef });
|
|
163
|
+
|
|
164
|
+
private readonly rootRef =
|
|
165
|
+
viewChild.required<ElementRef<HTMLDivElement>>("rootEl");
|
|
166
|
+
// A template-ref-variable on a component tag resolves to the component INSTANCE, which is what
|
|
167
|
+
// is needed to call the headless component's own public `focus()` method.
|
|
168
|
+
private readonly buttonRef = viewChild.required<IconButton>("buttonEl");
|
|
169
|
+
private readonly panelRef =
|
|
170
|
+
viewChild.required<ElementRef<HTMLDivElement>>("panelEl");
|
|
171
|
+
|
|
172
|
+
private readonly baseId = nextMenuPickerId();
|
|
173
|
+
protected readonly panelId = `${this.baseId}-panel`;
|
|
174
|
+
|
|
175
|
+
// Tooltip: shown while the pointer is over the button or the tooltip itself (hoverable, WCAG
|
|
176
|
+
// 1.4.13) or while the button has keyboard focus; Escape dismisses it without moving focus;
|
|
177
|
+
// never shown while the popup is open, since the popup then explains the control.
|
|
178
|
+
protected readonly tooltipId = `${this.baseId}-tooltip`;
|
|
179
|
+
protected readonly hoverButton = signal(false);
|
|
180
|
+
protected readonly hoverTooltip = signal(false);
|
|
181
|
+
protected readonly focusButton = signal(false);
|
|
182
|
+
protected readonly dismissed = signal(false);
|
|
183
|
+
protected readonly tooltipVisible = computed(
|
|
184
|
+
() =>
|
|
185
|
+
!this.open() &&
|
|
186
|
+
!this.dismissed() &&
|
|
187
|
+
(this.hoverButton() || this.hoverTooltip() || this.focusButton()),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
protected onTooltipButtonEnter(): void {
|
|
191
|
+
this.hoverButton.set(true);
|
|
192
|
+
this.dismissed.set(false);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
protected onTooltipButtonFocusIn(): void {
|
|
196
|
+
// Keyboard focus only: a mouse click also focuses the button in Chromium, and the tooltip
|
|
197
|
+
// should not stick after a click.
|
|
198
|
+
try {
|
|
199
|
+
this.focusButton.set(
|
|
200
|
+
this.buttonRef().element?.matches(":focus-visible") ?? false,
|
|
201
|
+
);
|
|
202
|
+
} catch {
|
|
203
|
+
this.focusButton.set(true); // engine without :focus-visible — err towards showing
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
protected onTooltipButtonFocusOut(): void {
|
|
208
|
+
this.focusButton.set(false);
|
|
209
|
+
this.dismissed.set(false);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
protected onTooltipKeydown(event: KeyboardEvent): void {
|
|
213
|
+
if (event.key === "Escape" && this.tooltipVisible()) this.dismissed.set(true);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// WCAG 1.4.13 "dismissable": while the tooltip is visible, Escape works wherever focus is. The
|
|
217
|
+
// listener exists only while visible; the effect cleanup removes it on hide and on destroy.
|
|
218
|
+
private readonly tooltipEscapeListener = effect((onCleanup) => {
|
|
219
|
+
if (!this.tooltipVisible() || typeof document === "undefined") return;
|
|
220
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
221
|
+
if (event.key === "Escape") this.dismissed.set(true);
|
|
222
|
+
};
|
|
223
|
+
document.addEventListener("keydown", onKey);
|
|
224
|
+
onCleanup(() => document.removeEventListener("keydown", onKey));
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
protected readonly childContext = computed(() => {
|
|
228
|
+
const args: ChildArgs = { open: this.open() };
|
|
229
|
+
return { $implicit: args, ...args };
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
private focusables(): HTMLElement[] {
|
|
233
|
+
return Array.from(
|
|
234
|
+
this.panelRef().nativeElement.querySelectorAll<HTMLElement>(FOCUSABLE),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------
|
|
239
|
+
// Open / close
|
|
240
|
+
// ---------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/** Open the panel, optionally focusing its first or last focusable element. */
|
|
243
|
+
openPanel(focus: "none" | "first" | "last" = "none"): void {
|
|
244
|
+
this.open.set(true);
|
|
245
|
+
if (focus === "none") return;
|
|
246
|
+
// Deferred so the `hidden` attribute is gone before focus is moved.
|
|
247
|
+
queueMicrotask(() => {
|
|
248
|
+
const all = this.focusables();
|
|
249
|
+
(focus === "last" ? all[all.length - 1] : all[0])?.focus({ preventScroll: true });
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Close the panel; `refocus` returns focus to the trigger. */
|
|
254
|
+
closePanel(refocus = true): void {
|
|
255
|
+
if (!this.open()) return;
|
|
256
|
+
this.open.set(false);
|
|
257
|
+
if (refocus) queueMicrotask(() => this.buttonRef().focus({ preventScroll: true }));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Close the panel and return focus to the button. For the projected content to call. */
|
|
261
|
+
close(): void {
|
|
262
|
+
this.closePanel();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
protected onButtonClick(): void {
|
|
266
|
+
if (this.open()) this.closePanel();
|
|
267
|
+
else this.openPanel();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
protected onPanelClick(event: MouseEvent): void {
|
|
271
|
+
if (!this.closeOnSelect()) return;
|
|
272
|
+
const target = event.target as Element | null;
|
|
273
|
+
const hit = target?.closest?.('a[href], button, [role="menuitem"]');
|
|
274
|
+
if (!hit || !this.panelRef().nativeElement.contains(hit)) return;
|
|
275
|
+
if (hit.closest("[data-menu-picker-keep-open]")) return;
|
|
276
|
+
this.closePanel();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------
|
|
280
|
+
// Keyboard
|
|
281
|
+
// ---------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
protected onButtonKeydown(event: KeyboardEvent): void {
|
|
284
|
+
if (event.key === "Escape" && this.open()) {
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
this.closePanel();
|
|
287
|
+
} else if (event.key === "ArrowDown") {
|
|
288
|
+
// Enter and Space already produce a click; the arrows open and move into the panel.
|
|
289
|
+
event.preventDefault();
|
|
290
|
+
if (!this.open()) this.openPanel("first");
|
|
291
|
+
else this.focusables()[0]?.focus({ preventScroll: true });
|
|
292
|
+
} else if (event.key === "ArrowUp") {
|
|
293
|
+
event.preventDefault();
|
|
294
|
+
if (!this.open()) this.openPanel("last");
|
|
295
|
+
else {
|
|
296
|
+
const all = this.focusables();
|
|
297
|
+
all[all.length - 1]?.focus({ preventScroll: true });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
protected onPanelKeydown(event: KeyboardEvent): void {
|
|
303
|
+
if (event.key === "Escape") {
|
|
304
|
+
event.preventDefault();
|
|
305
|
+
this.closePanel();
|
|
306
|
+
} else if (event.key === "Tab") {
|
|
307
|
+
// Focus goes to the button FIRST, without cancelling the key, so the browser continues
|
|
308
|
+
// from the picker's position rather than from <body>.
|
|
309
|
+
this.buttonRef().focus?.({ preventScroll: true });
|
|
310
|
+
this.closePanel(false);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
protected onRootFocusOut(event: FocusEvent): void {
|
|
315
|
+
const next = event.relatedTarget as Node | null;
|
|
316
|
+
if (next && this.rootRef().nativeElement.contains(next)) return;
|
|
317
|
+
this.closePanel(false);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
protected onDocumentClick(event: Event): void {
|
|
321
|
+
if (!this.open()) return;
|
|
322
|
+
const t = event.target as Node | null;
|
|
323
|
+
if (t && !this.rootRef().nativeElement.contains(t)) this.closePanel(false);
|
|
324
|
+
}
|
|
325
|
+
}
|
package/ng-package.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lilydesignsystem/angular-menu-picker",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": "=26"
|
|
6
|
+
},
|
|
7
|
+
"description": "Lily Design System Angular menu picker: an icon button (a hamburger icon) opening a dropdown of whatever the app provides. Headless, SSR-safe, no CSS.",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@lilydesignsystem/angular-headless": "^0.2.0"
|
|
10
|
+
},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"@angular/core": ">=20.0.0 <23.0.0",
|
|
13
|
+
"@angular/common": ">=20.0.0 <23.0.0"
|
|
14
|
+
},
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "cd .. && pnpm build",
|
|
18
|
+
"prepublishOnly": "cd .. && pnpm build"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"lily",
|
|
22
|
+
"design",
|
|
23
|
+
"system",
|
|
24
|
+
"angular",
|
|
25
|
+
"menu",
|
|
26
|
+
"hamburger",
|
|
27
|
+
"dropdown",
|
|
28
|
+
"picker"
|
|
29
|
+
],
|
|
30
|
+
"author": "Joel Parker Henderson <joel@joelparkerhenderson.com>",
|
|
31
|
+
"license": "MIT OR Apache-2.0 OR GPL-2.0-only OR GPL-3.0-only OR BSD-3-Clause",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/LilyDesignSystem/lily-design-system-angular-helpers.git",
|
|
35
|
+
"directory": "lily-design-system-angular-menu-picker"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://lilydesignsystem.com/",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/LilyDesignSystem/lily-design-system/issues"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/spec/index.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# MenuPicker (Angular) — spec
|
|
2
|
+
|
|
3
|
+
**Package:** `@lilydesignsystem/angular-menu-picker` · **Canonical contract:** this file; the other seven catalogs port it.
|
|
4
|
+
Topic summary: [spec/menu-picker](../../../spec/menu-picker/index.md).
|
|
5
|
+
|
|
6
|
+
## 1. Goal
|
|
7
|
+
|
|
8
|
+
An icon button — a typical **hamburger** (three horizontal lines) — that opens a dropdown panel holding **whatever the app
|
|
9
|
+
provides**: links, buttons, a search form, a small list of settings. It owns the whole interaction: open/close, focus,
|
|
10
|
+
keyboard, tooltip. It owns no content and no words: the panel's content and every accessible name come from the consumer.
|
|
11
|
+
|
|
12
|
+
## 2. Non-goals
|
|
13
|
+
|
|
14
|
+
- No default content, no English text, no routing, no data.
|
|
15
|
+
- Not an ARIA menu: the panel is a named group of arbitrary content, so it has no `role="menu"` and no roving focus.
|
|
16
|
+
- Not a site navigation bar (use `BreadcrumbNav`, `SectionNav`) and not a modal dialog (use `Dialog`).
|
|
17
|
+
- No persistence.
|
|
18
|
+
|
|
19
|
+
## 3. Architectural decisions
|
|
20
|
+
|
|
21
|
+
- **A disclosure of arbitrary content, not a menu.** `role="menu"` promises a roving-focus `menuitem` widget; arbitrary app
|
|
22
|
+
content (links, inputs, headings) is not that, and the WAI-ARIA APG's disclosure pattern fits it exactly. The trigger carries
|
|
23
|
+
`aria-expanded` and `aria-controls`; the panel is a `role="group"` named by `label`.
|
|
24
|
+
- **Only the trigger composes headless** (`IconButton`); the panel is self-built, as in `link-picker` and `share-picker`.
|
|
25
|
+
- **The app provides the panel's content** through `children` (a template, slot, `ChildContent`, children …). It receives
|
|
26
|
+
`{ open, close }` so its own controls can close the panel.
|
|
27
|
+
- **Closes on select by default.** Activating a link, a button or a `[role="menuitem"]` inside the panel closes it and returns
|
|
28
|
+
focus to the button. `closeOnSelect={false}` turns that off for the whole panel; an ancestor with
|
|
29
|
+
`data-menu-picker-keep-open` opts one element (or a region, e.g. a form) out.
|
|
30
|
+
- **Focus is not moved on a click.** A disclosure must not steal focus on open; `ArrowDown`/`ArrowUp` on the button move into
|
|
31
|
+
the panel's first/last focusable element for keyboard users.
|
|
32
|
+
|
|
33
|
+
## 4. Public API
|
|
34
|
+
|
|
35
|
+
### 4.1 Props
|
|
36
|
+
|
|
37
|
+
| Prop | Type | Required | Notes |
|
|
38
|
+
| --- | --- | --- | --- |
|
|
39
|
+
| `label` | `string` | yes | Accessible name of the button **and** the panel; also the tooltip text. |
|
|
40
|
+
| `[(open)]` | `boolean` (model) | no | Is the panel open? Default `false`. |
|
|
41
|
+
| `closeOnSelect` | `boolean` | no | Close when a link/button/menuitem inside is activated. Default `true`. |
|
|
42
|
+
| `(openChange)` | output `boolean` | no | Emitted whenever the open state changes (the `[(open)]` update). |
|
|
43
|
+
| projected content | `<ng-content>` | no | The panel's content. Call `menu.close()` on a `#menu="lilyMenuPicker"` reference to close it. |
|
|
44
|
+
| `<ng-template lilyMenuPickerIcon>` | context `{ open }` | no | Replaces the hamburger **icon** inside the button. |
|
|
45
|
+
| `className` | `string` | no | Appended to the root class. |
|
|
46
|
+
| `...rest` | | | Spread on the root. |
|
|
47
|
+
|
|
48
|
+
### 4.2 DOM contract
|
|
49
|
+
|
|
50
|
+
```html
|
|
51
|
+
<div class="menu-picker {class}">
|
|
52
|
+
<button class="menu-picker-button" type="button" aria-label="{label}" aria-expanded="false" aria-controls="{id}-panel">
|
|
53
|
+
<svg class="menu-picker-icon" aria-hidden="true" viewBox="0 0 16 16" …three lines…/>
|
|
54
|
+
</button>
|
|
55
|
+
<div class="menu-picker-tooltip" role="tooltip" id="{id}-tooltip" hidden>{label}</div>
|
|
56
|
+
<div class="menu-picker-panel" id="{id}-panel" role="group" aria-label="{label}" hidden>
|
|
57
|
+
…whatever the app provides…
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 4.3 Re-exports
|
|
63
|
+
|
|
64
|
+
`MenuPicker`, `MenuPickerIcon`, `nextMenuPickerId`, `FOCUSABLE`, type `ChildArgs`.
|
|
65
|
+
|
|
66
|
+
**Differences from the Svelte contract (idiom, not behaviour).** Angular renders the same DOM in a `<lily-menu-picker>` element; the bindable `open` is a `model()` (`[(open)]`), the content is projected, the icon override is a projected `<ng-template lilyMenuPickerIcon>`, `class` is the `className` input, `close()` is a public method reachable through `exportAs: "lilyMenuPicker"`, and ids come from a module counter.
|
|
67
|
+
|
|
68
|
+
## 5. Behaviour
|
|
69
|
+
|
|
70
|
+
### 5.1 Activation
|
|
71
|
+
|
|
72
|
+
Click toggles the panel; focus stays on the button. Activating a link, button or menuitem inside the panel closes it (see
|
|
73
|
+
§3) and returns focus to the button.
|
|
74
|
+
|
|
75
|
+
### 5.2 Keyboard
|
|
76
|
+
|
|
77
|
+
- Button: `Enter`/`Space` toggle; `ArrowDown` opens and focuses the panel's first focusable element (or moves into an open
|
|
78
|
+
panel); `ArrowUp` focuses the last; `Escape` closes an open panel.
|
|
79
|
+
- Panel: `Escape` closes and returns focus to the button; `Tab` closes and moves on (focus goes to the button first so the
|
|
80
|
+
browser continues from the picker's position). Everything else is the app's content's own behaviour.
|
|
81
|
+
- Clicking outside, or focus leaving the picker, closes the panel.
|
|
82
|
+
|
|
83
|
+
### 5.3 Tooltip
|
|
84
|
+
|
|
85
|
+
`<div class="menu-picker-tooltip" role="tooltip" hidden>` holds the button's `label`. Visible while the pointer is over the
|
|
86
|
+
button or the tooltip (hoverable) or while the button has keyboard focus (`:focus-visible`); `Escape` dismisses it wherever
|
|
87
|
+
focus is; never shown while the panel is open. Not linked with `aria-describedby` (it duplicates the name).
|
|
88
|
+
|
|
89
|
+
## 6. Accessibility
|
|
90
|
+
|
|
91
|
+
WCAG 2.2 AAA target. The button has an accessible name and `aria-expanded`/`aria-controls`; the icon is `aria-hidden`; the
|
|
92
|
+
panel is a named group; focus stays visible; no motion. The content's own accessibility is the app's.
|
|
93
|
+
|
|
94
|
+
## 7. Testing acceptance criteria
|
|
95
|
+
|
|
96
|
+
1. Renders `div.menu-picker` containing `button.menu-picker-button`, a tooltip and a hidden `div.menu-picker-panel`.
|
|
97
|
+
2. The button's accessible name is `label`; it has `aria-expanded="false"` and `aria-controls` pointing at the panel.
|
|
98
|
+
3. The default icon is an `aria-hidden` SVG with class `menu-picker-icon`; `icon` replaces it.
|
|
99
|
+
4. The panel is a `role="group"` named with `label`.
|
|
100
|
+
5. The app's content renders inside the panel; with no content the panel is empty (nothing is invented).
|
|
101
|
+
6. Click opens: panel visible, `aria-expanded="true"`, focus stays on the button.
|
|
102
|
+
7. Click again closes.
|
|
103
|
+
8. `ArrowDown` on the button opens and focuses the first focusable element in the panel; `ArrowUp` focuses the last.
|
|
104
|
+
9. `Escape` closes and returns focus to the button.
|
|
105
|
+
10. `Tab` closes without teleporting focus.
|
|
106
|
+
11. Clicking outside closes.
|
|
107
|
+
12. Focus moving out of the picker closes.
|
|
108
|
+
13. Activating a link or button inside closes the panel and focuses the button; an element inside
|
|
109
|
+
`data-menu-picker-keep-open` does not close it; `closeOnSelect=false` never closes on select.
|
|
110
|
+
14. The content receives `close()`, which closes the panel.
|
|
111
|
+
15. `open` is bindable and `onOpenChange` fires once per actual change.
|
|
112
|
+
16. The tooltip exists with `role="tooltip"`, holds `label`, and is hidden at rest.
|
|
113
|
+
17. The tooltip shows on hover, hides on `Escape`, and never shows while the panel is open.
|
|
114
|
+
18. The tooltip is not referenced by `aria-describedby`.
|
|
115
|
+
19. `class` is appended to the root; rest props are spread on the root.
|
|
116
|
+
20. Two instances get distinct ids.
|
|
117
|
+
21. The package source contains no stylesheet, no inline `style` and no English default text.
|
|
118
|
+
|
|
119
|
+
## 8. Tracking
|
|
120
|
+
|
|
121
|
+
- Package: `@lilydesignsystem/angular-menu-picker`
|
|
122
|
+
- Version: 0.1.0 (new, 2026-10-08)
|
|
123
|
+
- Framework: Angular `>=20 <23` (signals, standalone, OnPush)
|
|
124
|
+
- Test runner: vitest + TestBed
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./out-tsc/lib",
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"declarationMap": true,
|
|
7
|
+
"types": []
|
|
8
|
+
},
|
|
9
|
+
"angularCompilerOptions": {
|
|
10
|
+
"compilationMode": "partial"
|
|
11
|
+
},
|
|
12
|
+
"exclude": ["**/*.spec.ts", "node_modules", "dist"]
|
|
13
|
+
}
|
package/README.md
DELETED