@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,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lilydesignsystem/angular-menu-picker",
|
|
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
|
+
"tslib": "^2.3.0"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@angular/core": ">=20.0.0 <23.0.0",
|
|
14
|
+
"@angular/common": ">=20.0.0 <23.0.0"
|
|
15
|
+
},
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"keywords": [
|
|
18
|
+
"lily",
|
|
19
|
+
"design",
|
|
20
|
+
"system",
|
|
21
|
+
"angular",
|
|
22
|
+
"menu",
|
|
23
|
+
"hamburger",
|
|
24
|
+
"dropdown",
|
|
25
|
+
"picker"
|
|
26
|
+
],
|
|
27
|
+
"author": "Joel Parker Henderson <joel@joelparkerhenderson.com>",
|
|
28
|
+
"license": "MIT OR Apache-2.0 OR GPL-2.0-only OR GPL-3.0-only OR BSD-3-Clause",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/LilyDesignSystem/lily-design-system-angular-helpers.git",
|
|
32
|
+
"directory": "lily-design-system-angular-menu-picker"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://lilydesignsystem.com/",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/LilyDesignSystem/lily-design-system/issues"
|
|
37
|
+
},
|
|
38
|
+
"module": "fesm2022/lilydesignsystem-angular-menu-picker.mjs",
|
|
39
|
+
"typings": "types/lilydesignsystem-angular-menu-picker.d.ts",
|
|
40
|
+
"exports": {
|
|
41
|
+
"./package.json": {
|
|
42
|
+
"default": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
".": {
|
|
45
|
+
"types": "./types/lilydesignsystem-angular-menu-picker.d.ts",
|
|
46
|
+
"default": "./fesm2022/lilydesignsystem-angular-menu-picker.mjs"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"type": "module"
|
|
50
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as i0 from "@angular/core";
|
|
2
|
+
import { TemplateRef } from "@angular/core";
|
|
3
|
+
/** Context passed to a custom icon `<ng-template>` (the button icon). */
|
|
4
|
+
type ChildArgs = {
|
|
5
|
+
/** Is the panel open? */
|
|
6
|
+
open: boolean;
|
|
7
|
+
};
|
|
8
|
+
/** Stable per-instance id prefix; SSR-safe (no Math.random / Date.now). */
|
|
9
|
+
export declare function nextMenuPickerId(): string;
|
|
10
|
+
/** The selector for focusable things inside the panel. */
|
|
11
|
+
export declare const FOCUSABLE = "a[href], button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
|
|
12
|
+
/**
|
|
13
|
+
* Marker for the projected icon template. Gives consumers typed `let-` variables:
|
|
14
|
+
*
|
|
15
|
+
* ```html
|
|
16
|
+
* <lily-menu-picker label="Menu">
|
|
17
|
+
* <ng-template lilyMenuPickerIcon let-args>{{ args.open ? "×" : "☰" }}</ng-template>
|
|
18
|
+
* …the panel's content…
|
|
19
|
+
* </lily-menu-picker>
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare class MenuPickerIcon {
|
|
23
|
+
static ngTemplateContextGuard(_dir: MenuPickerIcon, _ctx: unknown): _ctx is ChildArgs & {
|
|
24
|
+
$implicit: ChildArgs;
|
|
25
|
+
};
|
|
26
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<MenuPickerIcon, never>;
|
|
27
|
+
static ɵdir: i0.ɵɵDirectiveDeclaration<MenuPickerIcon, "ng-template[lilyMenuPickerIcon]", never, {}, {}, never, never, true, never>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* MenuPicker — a headless dropdown control.
|
|
31
|
+
*
|
|
32
|
+
* A single-icon button (a bundled hamburger SVG) that opens a disclosure panel holding whatever
|
|
33
|
+
* the app projects into it. It applies nothing to the document and persists nothing. Give the
|
|
34
|
+
* element a template reference (`#menu`) to call `menu.close()` from the projected content.
|
|
35
|
+
* See `spec/index.md`.
|
|
36
|
+
*/
|
|
37
|
+
export declare class MenuPicker {
|
|
38
|
+
/** Accessible name for the button, the panel and the tooltip. */
|
|
39
|
+
readonly label: import("@angular/core").InputSignal<string>;
|
|
40
|
+
/** Is the panel open? Two-way bindable: `[(open)]`. */
|
|
41
|
+
readonly open: import("@angular/core").ModelSignal<boolean>;
|
|
42
|
+
/**
|
|
43
|
+
* Close the panel when a link, button or `[role="menuitem"]` inside it is activated. Add
|
|
44
|
+
* `data-menu-picker-keep-open` to an element (or an ancestor) to opt it out. Default `true`.
|
|
45
|
+
*/
|
|
46
|
+
readonly closeOnSelect: import("@angular/core").InputSignal<boolean>;
|
|
47
|
+
/** Extra CSS class on the root div. */
|
|
48
|
+
readonly className: import("@angular/core").InputSignal<string>;
|
|
49
|
+
/** Projected icon template; replaces the default icon when supplied. */
|
|
50
|
+
protected readonly iconTemplate: import("@angular/core").Signal<TemplateRef<any> | undefined>;
|
|
51
|
+
private readonly rootRef;
|
|
52
|
+
private readonly buttonRef;
|
|
53
|
+
private readonly panelRef;
|
|
54
|
+
private readonly baseId;
|
|
55
|
+
protected readonly panelId: string;
|
|
56
|
+
protected readonly tooltipId: string;
|
|
57
|
+
protected readonly hoverButton: import("@angular/core").WritableSignal<boolean>;
|
|
58
|
+
protected readonly hoverTooltip: import("@angular/core").WritableSignal<boolean>;
|
|
59
|
+
protected readonly focusButton: import("@angular/core").WritableSignal<boolean>;
|
|
60
|
+
protected readonly dismissed: import("@angular/core").WritableSignal<boolean>;
|
|
61
|
+
protected readonly tooltipVisible: import("@angular/core").Signal<boolean>;
|
|
62
|
+
protected onTooltipButtonEnter(): void;
|
|
63
|
+
protected onTooltipButtonFocusIn(): void;
|
|
64
|
+
protected onTooltipButtonFocusOut(): void;
|
|
65
|
+
protected onTooltipKeydown(event: KeyboardEvent): void;
|
|
66
|
+
private readonly tooltipEscapeListener;
|
|
67
|
+
protected readonly childContext: import("@angular/core").Signal<{
|
|
68
|
+
/** Is the panel open? */
|
|
69
|
+
open: boolean;
|
|
70
|
+
$implicit: ChildArgs;
|
|
71
|
+
}>;
|
|
72
|
+
private focusables;
|
|
73
|
+
/** Open the panel, optionally focusing its first or last focusable element. */
|
|
74
|
+
openPanel(focus?: "none" | "first" | "last"): void;
|
|
75
|
+
/** Close the panel; `refocus` returns focus to the trigger. */
|
|
76
|
+
closePanel(refocus?: boolean): void;
|
|
77
|
+
/** Close the panel and return focus to the button. For the projected content to call. */
|
|
78
|
+
close(): void;
|
|
79
|
+
protected onButtonClick(): void;
|
|
80
|
+
protected onPanelClick(event: MouseEvent): void;
|
|
81
|
+
protected onButtonKeydown(event: KeyboardEvent): void;
|
|
82
|
+
protected onPanelKeydown(event: KeyboardEvent): void;
|
|
83
|
+
protected onRootFocusOut(event: FocusEvent): void;
|
|
84
|
+
protected onDocumentClick(event: Event): void;
|
|
85
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<MenuPicker, never>;
|
|
86
|
+
static ɵcmp: i0.ɵɵComponentDeclaration<MenuPicker, "lily-menu-picker", ["lilyMenuPicker"], {
|
|
87
|
+
"label": {
|
|
88
|
+
"alias": "label";
|
|
89
|
+
"required": true;
|
|
90
|
+
"isSignal": true;
|
|
91
|
+
};
|
|
92
|
+
"open": {
|
|
93
|
+
"alias": "open";
|
|
94
|
+
"required": false;
|
|
95
|
+
"isSignal": true;
|
|
96
|
+
};
|
|
97
|
+
"closeOnSelect": {
|
|
98
|
+
"alias": "closeOnSelect";
|
|
99
|
+
"required": false;
|
|
100
|
+
"isSignal": true;
|
|
101
|
+
};
|
|
102
|
+
"className": {
|
|
103
|
+
"alias": "className";
|
|
104
|
+
"required": false;
|
|
105
|
+
"isSignal": true;
|
|
106
|
+
};
|
|
107
|
+
}, {
|
|
108
|
+
"open": "openChange";
|
|
109
|
+
}, ["iconTemplate"], ["*"], true, never>;
|
|
110
|
+
}
|
|
111
|
+
export type { ChildArgs };
|
|
112
|
+
//# sourceMappingURL=lilydesignsystem-angular-menu-picker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lilydesignsystem-angular-menu-picker.d.ts","names":[],"sources":["../../menu-picker.component.ts"],"mappings":";;;KAqBY;;EAEV;;;wBAKc;;qBAMH;;;;;;;;;;;qBAiBA;SACJ,uBACL,MAAM,gBACN,gBACC,QAAQ;IAAc,WAAW;;SAJzB,MAAA,GAAA,qBAAA;SAAA,MAAA,GAAA,uBAAA;;;;;;;;;;qBAgGA;;WAEF,+BAAK;;WAEL,8BAAI;;;;;WAKJ,uCAAa;;WAEb,mCAAS;;qBAGC,sCAAY,OAAA;mBAEd;mBAIA;mBACA;mBAGA;qBACE;qBAKA;qBACA,qCAAW;qBACX,sCAAY;qBACZ,qCAAW;qBACX,mCAAS;qBACT,wCAAc;YAOvB;YAKA;YAYA;YAKA,iBAAiB,OAAO;mBAMjB;qBASE,sCAAY;;IA3MzB;;;UAgNE;;EAWR,UAAU;;EAWV,WAAW;;EAOX;YAIU;YAKA,aAAa,OAAO;YAapB,gBAAgB,OAAO;YAmBvB,eAAe,OAAO;YAYtB,eAAe,OAAO;YAMtB,gBAAgB,OAAO;SA5KtB,MAAA,GAAA,qBAAA;SAAA,MAAA,GAAA,uBAAA;IAAU;MAAA;MAAA;MAAA;;IAAA;MAAA;MAAA;MAAA;;IAAA;MAAA;MAAA;MAAA;;IAAA;MAAA;MAAA;MAAA;;;IAAA"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Accessibility
|
|
2
|
+
|
|
3
|
+
WCAG 2.2 AAA is the target. This document states what the control does well and what it costs.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- The icon is `aria-hidden="true"`; the accessible name is the consumer-supplied `label`, so it localises with your copy.
|
|
8
|
+
- `aria-expanded` on the trigger reflects the panel state, and `aria-controls` points at it. The panel is a `role="group"`
|
|
9
|
+
named with the same `label`.
|
|
10
|
+
- **It is a disclosure, not an ARIA menu.** Arbitrary content (links, buttons, inputs, headings) inside `role="menu"` would
|
|
11
|
+
promise arrow-key `menuitem` behaviour it does not have, and would strip the native semantics of links. Native semantics
|
|
12
|
+
are kept; the APG's disclosure pattern is the model.
|
|
13
|
+
- Keyboard: `Enter`/`Space` toggle; `ArrowDown`/`ArrowUp` on the button open and move to the panel's first/last focusable
|
|
14
|
+
element; `Escape` closes and returns focus to the button; `Tab` closes — focus lands on the button first so the default Tab
|
|
15
|
+
proceeds from the picker's position instead of restarting from `<body>`.
|
|
16
|
+
- A click does not move focus into the panel (a disclosure must not steal focus); keyboard users reach it with the arrows or
|
|
17
|
+
Tab.
|
|
18
|
+
- Clicking outside, or focus leaving the picker, closes the panel.
|
|
19
|
+
- The tooltip is hoverable and dismissable (WCAG 1.4.13): visible on pointer hover or keyboard focus, `Escape` hides it
|
|
20
|
+
wherever focus is, and it is never shown while the panel is open.
|
|
21
|
+
|
|
22
|
+
## What it costs
|
|
23
|
+
|
|
24
|
+
**The name rests entirely on `label`.** An icon-only control has no visible text fallback. The hamburger is widely understood,
|
|
25
|
+
but if `label` is wrong, missing or untranslated there is nothing else to go on. Choose a label that says what the panel is
|
|
26
|
+
("Menu", "More"), not what the icon is.
|
|
27
|
+
|
|
28
|
+
**The content's accessibility is yours.** The panel contains whatever you put in it; give links real text, inputs real labels
|
|
29
|
+
and headings a sensible order.
|
|
30
|
+
|
|
31
|
+
**It hides things behind a click.** For primary site navigation, use a visible `nav` (see `BreadcrumbNav`, `SectionNav`).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Examples — MenuPicker (Angular)
|
|
2
|
+
|
|
3
|
+
| File | Shows |
|
|
4
|
+
| ---- | ----- |
|
|
5
|
+
| [basic.component.ts](./basic.component.ts) | A panel of links and a button: Home, About Us, Sign out |
|
|
6
|
+
| [form.component.ts](./form.component.ts) | A search form in the panel, closing it through `exportAs` |
|
|
7
|
+
| [custom-icon.component.ts](./custom-icon.component.ts) | Replacing the hamburger through `lilyMenuPickerIcon` |
|
|
8
|
+
|
|
9
|
+
Every user-facing string is yours — the package ships no default content.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Component } from "@angular/core";
|
|
2
|
+
import { MenuPicker } from "../menu-picker.component";
|
|
3
|
+
|
|
4
|
+
// The app provides the panel's content: the package ships no links and no English.
|
|
5
|
+
@Component({
|
|
6
|
+
selector: "example-basic",
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [MenuPicker],
|
|
9
|
+
template: `
|
|
10
|
+
<lily-menu-picker label="Menu">
|
|
11
|
+
<ul>
|
|
12
|
+
<li><a href="/">Home</a></li>
|
|
13
|
+
<li><a href="/about/">About Us</a></li>
|
|
14
|
+
<li><button type="button">Sign out</button></li>
|
|
15
|
+
</ul>
|
|
16
|
+
</lily-menu-picker>
|
|
17
|
+
`,
|
|
18
|
+
})
|
|
19
|
+
export class Basic {}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Component } from "@angular/core";
|
|
2
|
+
import { MenuPicker, MenuPickerIcon } from "../menu-picker.component";
|
|
3
|
+
|
|
4
|
+
// A projected <ng-template lilyMenuPickerIcon> replaces the hamburger inside the button, never the
|
|
5
|
+
// panel's content.
|
|
6
|
+
@Component({
|
|
7
|
+
selector: "example-custom-icon",
|
|
8
|
+
standalone: true,
|
|
9
|
+
imports: [MenuPicker, MenuPickerIcon],
|
|
10
|
+
template: `
|
|
11
|
+
<lily-menu-picker label="More">
|
|
12
|
+
<ng-template lilyMenuPickerIcon let-args><span aria-hidden="true">{{ args.open ? "×" : "⋯" }}</span></ng-template>
|
|
13
|
+
<a href="/help/">Help</a>
|
|
14
|
+
</lily-menu-picker>
|
|
15
|
+
`,
|
|
16
|
+
})
|
|
17
|
+
export class CustomIcon {}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Component } from "@angular/core";
|
|
2
|
+
import { MenuPicker } from "../menu-picker.component";
|
|
3
|
+
|
|
4
|
+
// A form in the panel. `#menu="lilyMenuPicker"` gives the content a way to close the panel;
|
|
5
|
+
// typing in the input does not close it (it is not a link or button).
|
|
6
|
+
@Component({
|
|
7
|
+
selector: "example-form",
|
|
8
|
+
standalone: true,
|
|
9
|
+
imports: [MenuPicker],
|
|
10
|
+
template: `
|
|
11
|
+
<lily-menu-picker #menu="lilyMenuPicker" label="Menu">
|
|
12
|
+
<form (submit)="$event.preventDefault(); menu.close()">
|
|
13
|
+
<input type="search" aria-label="Search" />
|
|
14
|
+
<button type="submit" data-menu-picker-keep-open>Go</button>
|
|
15
|
+
</form>
|
|
16
|
+
</lily-menu-picker>
|
|
17
|
+
`,
|
|
18
|
+
})
|
|
19
|
+
export class Form {}
|
package/index.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# MenuPicker (Angular helper)
|
|
2
|
+
|
|
3
|
+
A headless Angular control for **a dropdown of whatever your app provides**: a single-icon button (a bundled
|
|
4
|
+
three-line hamburger SVG) that opens a panel you fill with links, buttons, a search form, a few settings — anything.
|
|
5
|
+
|
|
6
|
+
The single source of truth is [spec/index.md](./spec/index.md). This file is the human-readable guide.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { MenuPicker, MenuPickerIcon } from "@lilydesignsystem/angular-menu-picker";
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
@Component({
|
|
18
|
+
standalone: true,
|
|
19
|
+
imports: [MenuPicker],
|
|
20
|
+
template: `
|
|
21
|
+
<lily-menu-picker label="Menu">
|
|
22
|
+
<ul>
|
|
23
|
+
<li><a href="/">Home</a></li>
|
|
24
|
+
<li><a href="/about/">About Us</a></li>
|
|
25
|
+
<li><button type="button" (click)="signOut()">Sign out</button></li>
|
|
26
|
+
</ul>
|
|
27
|
+
</lily-menu-picker>
|
|
28
|
+
`,
|
|
29
|
+
})
|
|
30
|
+
export class Header {}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`label` is the button's accessible name, the panel's name and the tooltip text. There is **no default content**: the package
|
|
34
|
+
ships no links and no English, so the panel's content, and every word on it, is yours.
|
|
35
|
+
|
|
36
|
+
## Closing from your content
|
|
37
|
+
|
|
38
|
+
Give the element a template reference to its `exportAs`:
|
|
39
|
+
|
|
40
|
+
```html
|
|
41
|
+
<lily-menu-picker #menu="lilyMenuPicker" label="Menu">
|
|
42
|
+
<form (submit)="$event.preventDefault(); search(); menu.close()">
|
|
43
|
+
<input type="search" aria-label="Search" />
|
|
44
|
+
</form>
|
|
45
|
+
</lily-menu-picker>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
By default, activating a link, a button or a `[role="menuitem"]` inside the panel closes it. Turn that off with
|
|
49
|
+
`[closeOnSelect]="false"`, or opt one element or region out with a `data-menu-picker-keep-open` ancestor.
|
|
50
|
+
|
|
51
|
+
## Inputs and outputs
|
|
52
|
+
|
|
53
|
+
`label` (required), `[(open)]`, `closeOnSelect` (default `true`), `className`; projected content is the panel, and a projected
|
|
54
|
+
`<ng-template lilyMenuPickerIcon let-args>` replaces the **icon**, not the content.
|
|
55
|
+
|
|
56
|
+
## Styling
|
|
57
|
+
|
|
58
|
+
Headless: no CSS ships. Target `.menu-picker`, `.menu-picker-button`, `.menu-picker-icon`, `.menu-picker-tooltip`,
|
|
59
|
+
`.menu-picker-panel`. The 45 reference themes in the monorepo's `themes/` style all of them, including the popup placement.
|
|
60
|
+
|
|
61
|
+
## Examples
|
|
62
|
+
|
|
63
|
+
[examples/](./examples/README.md): a links panel, a panel with a form and `close`, and a custom icon.
|
package/index.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { Component, viewChild } from "@angular/core";
|
|
2
|
+
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
3
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
MenuPicker,
|
|
9
|
+
MenuPickerIcon,
|
|
10
|
+
nextMenuPickerId,
|
|
11
|
+
} from "./menu-picker.component";
|
|
12
|
+
|
|
13
|
+
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
|
|
14
|
+
|
|
15
|
+
@Component({
|
|
16
|
+
standalone: true,
|
|
17
|
+
imports: [MenuPicker, MenuPickerIcon],
|
|
18
|
+
template: `
|
|
19
|
+
<lily-menu-picker #menu label="Menu" [closeOnSelect]="closeOnSelect" [(open)]="isOpen" className="{{ extra }}" [attr.data-x]="'1'">
|
|
20
|
+
<a href="/a/">Alpha</a>
|
|
21
|
+
<button type="button" class="b">Beta</button>
|
|
22
|
+
<div data-menu-picker-keep-open><button type="button" class="keep">Keep</button></div>
|
|
23
|
+
<button type="button" class="closer" (click)="$event.stopPropagation(); menu.close()">Closer</button>
|
|
24
|
+
</lily-menu-picker>
|
|
25
|
+
`,
|
|
26
|
+
})
|
|
27
|
+
class Host {
|
|
28
|
+
closeOnSelect = true;
|
|
29
|
+
isOpen = false;
|
|
30
|
+
extra = "";
|
|
31
|
+
picker = viewChild.required(MenuPicker);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Component({
|
|
35
|
+
standalone: true,
|
|
36
|
+
imports: [MenuPicker, MenuPickerIcon],
|
|
37
|
+
template: `<lily-menu-picker label="Menu"><ng-template lilyMenuPickerIcon let-args><span class="mine">{{ args.open ? "-" : "+" }}</span></ng-template></lily-menu-picker>`,
|
|
38
|
+
})
|
|
39
|
+
class IconHost {}
|
|
40
|
+
|
|
41
|
+
@Component({
|
|
42
|
+
standalone: true,
|
|
43
|
+
imports: [MenuPicker],
|
|
44
|
+
template: `<lily-menu-picker label="Menu" />`,
|
|
45
|
+
})
|
|
46
|
+
class EmptyHost {}
|
|
47
|
+
|
|
48
|
+
let fixtures: ComponentFixture<unknown>[] = [];
|
|
49
|
+
|
|
50
|
+
function mount(patch: Partial<Host> = {}): ComponentFixture<Host> {
|
|
51
|
+
const fixture = TestBed.createComponent(Host);
|
|
52
|
+
Object.assign(fixture.componentInstance, patch);
|
|
53
|
+
fixture.detectChanges();
|
|
54
|
+
fixtures.push(fixture);
|
|
55
|
+
return fixture;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const q = <T extends Element>(f: ComponentFixture<unknown>, sel: string) =>
|
|
59
|
+
f.nativeElement.querySelector(sel) as T;
|
|
60
|
+
const trigger = (f: ComponentFixture<unknown>) => q<HTMLButtonElement>(f, ".menu-picker-button");
|
|
61
|
+
const panel = (f: ComponentFixture<unknown>) => q<HTMLDivElement>(f, ".menu-picker-panel");
|
|
62
|
+
|
|
63
|
+
async function press(f: ComponentFixture<unknown>, target: HTMLElement, key: string): Promise<void> {
|
|
64
|
+
target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
|
|
65
|
+
f.detectChanges();
|
|
66
|
+
await flush();
|
|
67
|
+
f.detectChanges();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function click(f: ComponentFixture<unknown>, target: HTMLElement): Promise<void> {
|
|
71
|
+
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
|
|
72
|
+
f.detectChanges();
|
|
73
|
+
await flush();
|
|
74
|
+
f.detectChanges();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
for (const f of fixtures) f.destroy();
|
|
79
|
+
fixtures = [];
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("MenuPicker", () => {
|
|
83
|
+
test("7.1 renders the root, button, tooltip and a hidden panel", () => {
|
|
84
|
+
const f = mount();
|
|
85
|
+
expect(q(f, "div.menu-picker")).toBeTruthy();
|
|
86
|
+
expect(trigger(f)).toBeTruthy();
|
|
87
|
+
expect(q(f, ".menu-picker-tooltip")).toBeTruthy();
|
|
88
|
+
expect(panel(f).hasAttribute("hidden")).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("7.2 the button is named by label with aria-expanded and aria-controls", () => {
|
|
92
|
+
const f = mount();
|
|
93
|
+
expect(trigger(f).getAttribute("aria-label")).toBe("Menu");
|
|
94
|
+
expect(trigger(f).getAttribute("aria-expanded")).toBe("false");
|
|
95
|
+
expect(trigger(f).getAttribute("aria-controls")).toBe(panel(f).id);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("7.3 the default icon is an aria-hidden svg; a projected template replaces it", () => {
|
|
99
|
+
const f = mount();
|
|
100
|
+
const svg = q<SVGElement>(f, "svg.menu-picker-icon");
|
|
101
|
+
expect(svg.getAttribute("aria-hidden")).toBe("true");
|
|
102
|
+
expect(svg.getAttribute("viewBox")).toBe("0 0 16 16");
|
|
103
|
+
const hf = TestBed.createComponent(IconHost);
|
|
104
|
+
hf.detectChanges();
|
|
105
|
+
fixtures.push(hf);
|
|
106
|
+
expect(hf.nativeElement.querySelector(".mine")).toBeTruthy();
|
|
107
|
+
expect(hf.nativeElement.querySelector("svg.menu-picker-icon")).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("7.4 the panel is a role=group named with label", () => {
|
|
111
|
+
const f = mount();
|
|
112
|
+
expect(panel(f).getAttribute("role")).toBe("group");
|
|
113
|
+
expect(panel(f).getAttribute("aria-label")).toBe("Menu");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("7.5 the app's content renders inside; with none the panel is empty", () => {
|
|
117
|
+
const f = mount();
|
|
118
|
+
expect(panel(f).querySelector("a")?.textContent).toBe("Alpha");
|
|
119
|
+
const ef = TestBed.createComponent(EmptyHost);
|
|
120
|
+
ef.detectChanges();
|
|
121
|
+
fixtures.push(ef);
|
|
122
|
+
expect(q<HTMLElement>(ef, ".menu-picker-panel").textContent?.trim()).toBe("");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("7.6 click opens and leaves focus on the button", async () => {
|
|
126
|
+
const f = mount();
|
|
127
|
+
trigger(f).focus();
|
|
128
|
+
await click(f, trigger(f));
|
|
129
|
+
expect(panel(f).hasAttribute("hidden")).toBe(false);
|
|
130
|
+
expect(trigger(f).getAttribute("aria-expanded")).toBe("true");
|
|
131
|
+
expect(document.activeElement).toBe(trigger(f));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("7.7 a second click closes", async () => {
|
|
135
|
+
const f = mount();
|
|
136
|
+
await click(f, trigger(f));
|
|
137
|
+
await click(f, trigger(f));
|
|
138
|
+
expect(panel(f).hasAttribute("hidden")).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("7.8 ArrowDown opens at the first focusable, ArrowUp at the last", async () => {
|
|
142
|
+
const f = mount();
|
|
143
|
+
await press(f, trigger(f), "ArrowDown");
|
|
144
|
+
expect(panel(f).hasAttribute("hidden")).toBe(false);
|
|
145
|
+
expect(document.activeElement).toBe(panel(f).querySelector("a"));
|
|
146
|
+
const g = mount();
|
|
147
|
+
await press(g, trigger(g), "ArrowUp");
|
|
148
|
+
expect(document.activeElement).toBe(panel(g).querySelector(".closer"));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("7.9 Escape closes and returns focus to the button", async () => {
|
|
152
|
+
const f = mount();
|
|
153
|
+
await press(f, trigger(f), "ArrowDown");
|
|
154
|
+
await press(f, panel(f), "Escape");
|
|
155
|
+
expect(panel(f).hasAttribute("hidden")).toBe(true);
|
|
156
|
+
expect(document.activeElement).toBe(trigger(f));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("7.10 Tab closes without sending focus to the document top", async () => {
|
|
160
|
+
const f = mount();
|
|
161
|
+
await press(f, trigger(f), "ArrowDown");
|
|
162
|
+
await press(f, panel(f), "Tab");
|
|
163
|
+
expect(panel(f).hasAttribute("hidden")).toBe(true);
|
|
164
|
+
expect(document.activeElement).toBe(trigger(f));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("7.11 clicking outside closes", async () => {
|
|
168
|
+
const f = mount();
|
|
169
|
+
await click(f, trigger(f));
|
|
170
|
+
document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
171
|
+
f.detectChanges();
|
|
172
|
+
await flush();
|
|
173
|
+
f.detectChanges();
|
|
174
|
+
expect(panel(f).hasAttribute("hidden")).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("7.12 focus leaving the picker closes", async () => {
|
|
178
|
+
const f = mount();
|
|
179
|
+
await click(f, trigger(f));
|
|
180
|
+
const outside = document.createElement("button");
|
|
181
|
+
document.body.appendChild(outside);
|
|
182
|
+
q(f, "div.menu-picker").dispatchEvent(new FocusEvent("focusout", { bubbles: true, relatedTarget: outside }));
|
|
183
|
+
f.detectChanges();
|
|
184
|
+
expect(panel(f).hasAttribute("hidden")).toBe(true);
|
|
185
|
+
outside.remove();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("7.13 activating a link or button closes; keep-open and closeOnSelect=false do not", async () => {
|
|
189
|
+
const f = mount();
|
|
190
|
+
await click(f, trigger(f));
|
|
191
|
+
panel(f).querySelector("a")!.addEventListener("click", (e) => e.preventDefault());
|
|
192
|
+
await click(f, panel(f).querySelector("a")!);
|
|
193
|
+
expect(panel(f).hasAttribute("hidden")).toBe(true);
|
|
194
|
+
expect(document.activeElement).toBe(trigger(f));
|
|
195
|
+
await click(f, trigger(f));
|
|
196
|
+
await click(f, panel(f).querySelector(".b")!);
|
|
197
|
+
expect(panel(f).hasAttribute("hidden")).toBe(true);
|
|
198
|
+
await click(f, trigger(f));
|
|
199
|
+
await click(f, panel(f).querySelector(".keep")!);
|
|
200
|
+
expect(panel(f).hasAttribute("hidden")).toBe(false);
|
|
201
|
+
const g = mount({ closeOnSelect: false });
|
|
202
|
+
await click(g, trigger(g));
|
|
203
|
+
await click(g, panel(g).querySelector(".b")!);
|
|
204
|
+
expect(panel(g).hasAttribute("hidden")).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("7.14 the content can call close()", async () => {
|
|
208
|
+
const f = mount({ closeOnSelect: false });
|
|
209
|
+
await click(f, trigger(f));
|
|
210
|
+
await click(f, panel(f).querySelector(".closer")!);
|
|
211
|
+
expect(panel(f).hasAttribute("hidden")).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("7.15 [(open)] updates once per actual change", async () => {
|
|
215
|
+
const f = mount();
|
|
216
|
+
await click(f, trigger(f));
|
|
217
|
+
expect(f.componentInstance.isOpen).toBe(true);
|
|
218
|
+
await click(f, trigger(f));
|
|
219
|
+
expect(f.componentInstance.isOpen).toBe(false);
|
|
220
|
+
const g = mount({ isOpen: true });
|
|
221
|
+
expect(panel(g).hasAttribute("hidden")).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("7.16 the tooltip is a hidden role=tooltip holding the label", () => {
|
|
225
|
+
const f = mount();
|
|
226
|
+
const tip = q<HTMLElement>(f, ".menu-picker-tooltip");
|
|
227
|
+
expect(tip.getAttribute("role")).toBe("tooltip");
|
|
228
|
+
expect(tip.textContent).toBe("Menu");
|
|
229
|
+
expect(tip.hasAttribute("hidden")).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("7.17 the tooltip shows on hover, hides on Escape, and never shows while open", async () => {
|
|
233
|
+
const f = mount();
|
|
234
|
+
const tip = q<HTMLElement>(f, ".menu-picker-tooltip");
|
|
235
|
+
const host = q<HTMLElement>(f, "lily-icon-button");
|
|
236
|
+
host.dispatchEvent(new MouseEvent("mouseenter"));
|
|
237
|
+
f.detectChanges();
|
|
238
|
+
expect(tip.hasAttribute("hidden")).toBe(false);
|
|
239
|
+
await flush();
|
|
240
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
241
|
+
f.detectChanges();
|
|
242
|
+
expect(tip.hasAttribute("hidden")).toBe(true);
|
|
243
|
+
host.dispatchEvent(new MouseEvent("mouseleave"));
|
|
244
|
+
host.dispatchEvent(new MouseEvent("mouseenter"));
|
|
245
|
+
f.detectChanges();
|
|
246
|
+
expect(tip.hasAttribute("hidden")).toBe(false);
|
|
247
|
+
await click(f, trigger(f));
|
|
248
|
+
expect(tip.hasAttribute("hidden")).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("7.18 the tooltip is not wired with aria-describedby", () => {
|
|
252
|
+
const f = mount();
|
|
253
|
+
expect(trigger(f).hasAttribute("aria-describedby")).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("7.19 className is appended to the root", () => {
|
|
257
|
+
const f = mount({ extra: "extra" });
|
|
258
|
+
expect(q(f, "div.menu-picker").classList.contains("extra")).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("7.20 two instances get distinct ids", () => {
|
|
262
|
+
expect(nextMenuPickerId()).not.toBe(nextMenuPickerId());
|
|
263
|
+
const a = mount();
|
|
264
|
+
const b = mount();
|
|
265
|
+
expect(panel(a).id).not.toBe(panel(b).id);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("7.21 the source ships no stylesheet, inline style or English default", () => {
|
|
269
|
+
const src = readFileSync(resolve(__dirname, "menu-picker.component.ts"), "utf8")
|
|
270
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
271
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
272
|
+
.replace(/^\s*\/\/.*$/gm, "");
|
|
273
|
+
expect(src).not.toMatch(/styles:|\sstyle=/);
|
|
274
|
+
expect(src).not.toMatch(/(label|href):\s*["'`]/);
|
|
275
|
+
});
|
|
276
|
+
});
|