@lilydesignsystem/angular-link-picker 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +29 -0
- package/CHANGELOG.md +8 -0
- package/{fesm2022 → dist/fesm2022}/lilydesignsystem-angular-link-picker.mjs +6 -6
- package/dist/fesm2022/lilydesignsystem-angular-link-picker.mjs.map +1 -0
- package/dist/package.json +50 -0
- package/docs/accessibility.md +29 -0
- package/examples/README.md +9 -0
- package/examples/basic.component.ts +18 -0
- package/examples/custom-icon.component.ts +22 -0
- package/examples/router.component.ts +22 -0
- package/index.md +86 -0
- package/index.ts +11 -0
- package/link-picker.component.spec.ts +287 -0
- package/link-picker.component.ts +398 -0
- package/ng-package.json +10 -0
- package/package.json +8 -17
- package/spec/index.md +102 -0
- package/tsconfig.lib.json +13 -0
- package/fesm2022/lilydesignsystem-angular-link-picker.mjs.map +0 -1
- /package/{README.md → dist/README.md} +0 -0
- /package/{types → dist/types}/lilydesignsystem-angular-link-picker.d.ts +0 -0
- /package/{types → dist/types}/lilydesignsystem-angular-link-picker.d.ts.map +0 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# AGENTS — LinkPicker (Angular)
|
|
2
|
+
|
|
3
|
+
Single source of truth: [spec/index.md](./spec/index.md); the contract is the Svelte package's, ported.
|
|
4
|
+
|
|
5
|
+
## What this package is
|
|
6
|
+
|
|
7
|
+
A headless Angular `>=20 <23` page-links control: a single-icon button (a bundled home SVG) opening a disclosure list of page links the
|
|
8
|
+
**app defines**. No CSS, no routes, no English.
|
|
9
|
+
|
|
10
|
+
## Files
|
|
11
|
+
|
|
12
|
+
| File | Purpose |
|
|
13
|
+
| ---- | ------- |
|
|
14
|
+
| `link-picker.component.ts` | Implementation. |
|
|
15
|
+
| `link-picker.component.spec.ts` | 24 tests mapped to the spec §7 clauses. |
|
|
16
|
+
| `spec/index.md` | Contract and this port's differences. |
|
|
17
|
+
| `index.md` | Guide. |
|
|
18
|
+
| `docs/accessibility.md` | Trade-offs, stated plainly. |
|
|
19
|
+
| `examples/` | Home / About Us / Contact Us / Privacy Policy, router integration, custom icon. |
|
|
20
|
+
|
|
21
|
+
## Surface
|
|
22
|
+
|
|
23
|
+
`LinkPicker`, `LinkPickerIcon`, `linkId`, `nextLinkPickerId`, types `ChildArgs`, `LinkItem`, `LinkNavigatedEvent`.
|
|
24
|
+
|
|
25
|
+
## HTML
|
|
26
|
+
|
|
27
|
+
`div.link-picker` → `button.link-picker-button` (aria-hidden SVG `link-picker-icon`) → `div.link-picker-tooltip[role=tooltip]` →
|
|
28
|
+
`ul.link-picker-list[hidden]` of `li.link-picker-list-item > a.link-picker-link`. Not a menu: real `<a href>`;
|
|
29
|
+
`aria-current="page"` where marked.
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog — LinkPicker (Angular)
|
|
2
|
+
|
|
3
|
+
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/); the project follows [Semantic Versioning](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
## 0.1.0 — 2026-10-07
|
|
6
|
+
|
|
7
|
+
**First release.** A home-icon button opening a disclosure of app-defined page links. Real `<a>` links (not a menu), `aria-current="page"`,
|
|
8
|
+
hover/keyboard-focus tooltip, full keyboard support, no default links and no English. 24 tests, one per spec clause.
|
|
@@ -313,9 +313,9 @@ var LinkPicker = class LinkPicker {
|
|
|
313
313
|
stroke-linecap="round"
|
|
314
314
|
stroke-linejoin="round"
|
|
315
315
|
>
|
|
316
|
-
<path d="
|
|
317
|
-
<path d="
|
|
318
|
-
<path d="M6.5
|
|
316
|
+
<path d="M1 8 8 1.5l7 6.5" />
|
|
317
|
+
<path d="M2.5 7v7.5h11V7" />
|
|
318
|
+
<path d="M6.5 14.5v-4h3v4" />
|
|
319
319
|
</svg>
|
|
320
320
|
}
|
|
321
321
|
</lily-icon-button>
|
|
@@ -438,9 +438,9 @@ i0.ɵɵngDeclareClassMetadata({
|
|
|
438
438
|
stroke-linecap="round"
|
|
439
439
|
stroke-linejoin="round"
|
|
440
440
|
>
|
|
441
|
-
<path d="
|
|
442
|
-
<path d="
|
|
443
|
-
<path d="M6.5
|
|
441
|
+
<path d="M1 8 8 1.5l7 6.5" />
|
|
442
|
+
<path d="M2.5 7v7.5h11V7" />
|
|
443
|
+
<path d="M6.5 14.5v-4h3v4" />
|
|
444
444
|
</svg>
|
|
445
445
|
}
|
|
446
446
|
</lily-icon-button>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lilydesignsystem-angular-link-picker.mjs","names":[],"sources":["../../link-picker.component.ts"],"sourcesContent":["import { NgTemplateOutlet } from \"@angular/common\";\nimport {\n ChangeDetectionStrategy,\n Component,\n Directive,\n ElementRef,\n TemplateRef,\n computed,\n contentChild,\n effect,\n input,\n output,\n signal,\n viewChild,\n} from \"@angular/core\";\n// Only the trigger button composes a headless primitive. The list below is real `<a>` navigation\n// with a roving-focus pattern of its own — not an ARIA listbox or menu: role=\"menuitem\" would strip\n// middle-click, open-in-new-tab and copy-link-address from ordinary links. See spec/index.md §3.\nimport { IconButton } from \"@lilydesignsystem/angular-headless\";\n\n/**\n * One destination in the list. The app defines them all: this package ships no routes and no\n * English — `label` is the consumer's text.\n */\nexport type LinkItem = {\n /** Stable identifier, passed back on the `navigated` output. Defaults to `href`. */\n id?: string;\n /** Visible link text. Consumer-supplied, so it localises. */\n label: string;\n /** Where the link goes: a route (\"/about/\") or a full URL. */\n href: string;\n /** Marks this link as the current page (`aria-current=\"page\"`). */\n current?: boolean;\n /** Open in a new tab (`target=\"_blank\"` with `rel=\"noopener noreferrer\"`). */\n newTab?: boolean;\n};\n\n/** Context passed to a custom icon `<ng-template>` (the button icon). */\nexport type ChildArgs = {\n /** Is the list open? */\n open: boolean;\n};\n\n/** Payload of the `navigated` output. */\nexport type LinkNavigatedEvent = {\n /** The chosen link's `id` (its `href` when it has none). */\n id: string;\n /** The chosen link's `href`. */\n href: string;\n};\n\n/** The id a link reports: its explicit `id`, else its `href`. */\nexport function linkId(link: LinkItem): string {\n return link.id ?? link.href;\n}\n\nlet uid = 0;\n/** Stable per-instance id prefix; SSR-safe (no Math.random / Date.now). */\nexport function nextLinkPickerId(): string {\n uid += 1;\n return `link-picker-${uid}`;\n}\n\n/**\n * Optional marker for the projected icon template. Gives consumers typed `let-` variables:\n *\n * ```html\n * <lily-link-picker label=\"Pages\" [links]=\"links\">\n * <ng-template lilyLinkPickerIcon let-args>{{ args.open ? \"×\" : \"☰\" }}</ng-template>\n * </lily-link-picker>\n * ```\n */\n@Directive({\n selector: \"ng-template[lilyLinkPickerIcon]\",\n standalone: true,\n})\nexport class LinkPickerIcon {\n static ngTemplateContextGuard(\n _dir: LinkPickerIcon,\n _ctx: unknown,\n ): _ctx is ChildArgs & { $implicit: ChildArgs } {\n return true;\n }\n}\n\n/**\n * LinkPicker — a headless page-links control.\n *\n * A single-icon button (a bundled home SVG) that opens a disclosure list of page links the app\n * defines. It applies nothing to the document and persists nothing. See `spec/index.md`.\n */\n@Component({\n selector: \"lily-link-picker\",\n standalone: true,\n imports: [NgTemplateOutlet, IconButton],\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: {\n \"(document:click)\": \"onDocumentClick($event)\",\n },\n template: `\n <div\n #rootEl\n class=\"link-picker {{ className() }}\"\n (focusout)=\"onRootFocusOut($event)\"\n >\n <lily-icon-button\n #buttonEl\n [label]=\"label()\"\n baseClass=\"link-picker-button\"\n [ariaExpanded]=\"open()\"\n [ariaControls]=\"listId\"\n (click)=\"onButtonClick(); hoverButton.set(false)\"\n (mouseenter)=\"onTooltipButtonEnter()\"\n (mouseleave)=\"hoverButton.set(false)\"\n (focusin)=\"onTooltipButtonFocusIn()\"\n (focusout)=\"onTooltipButtonFocusOut()\"\n (keydown)=\"onButtonKeydown($event); onTooltipKeydown($event)\"\n >\n @if (iconTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"childContext()\"\n />\n } @else {\n <svg\n class=\"link-picker-icon\"\n viewBox=\"0 0 16 16\"\n width=\"1.05rem\"\n height=\"1.05rem\"\n aria-hidden=\"true\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.6\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <path d=\"M1 8 8 1.5l7 6.5\" />\n <path d=\"M2.5 7v7.5h11V7\" />\n <path d=\"M6.5 14.5v-4h3v4\" />\n </svg>\n }\n </lily-icon-button>\n\n <!-- Purely visual: the same text is already the button's aria-label, so it is not wired\n with aria-describedby (that would announce the name twice). -->\n <div\n class=\"link-picker-tooltip\"\n role=\"tooltip\"\n [id]=\"tooltipId\"\n [attr.hidden]=\"tooltipVisible() ? null : ''\"\n (mouseenter)=\"hoverTooltip.set(true)\"\n (mouseleave)=\"hoverTooltip.set(false)\"\n >{{ label() }}</div>\n\n <!-- Named like the sibling pickers' popups: a screen reader entering the list hears what\n it is for. -->\n <ul\n #listEl\n class=\"link-picker-list\"\n [id]=\"listId\"\n [attr.aria-label]=\"label() || null\"\n [attr.hidden]=\"open() ? null : ''\"\n (keydown)=\"onListKeydown($event)\"\n >\n @for (link of links(); track idOf(link)) {\n <li class=\"link-picker-list-item\">\n <!-- A real link, not role=\"menuitem\": these ARE navigation. -->\n <a\n class=\"link-picker-link\"\n [attr.data-link-id]=\"idOf(link)\"\n [attr.href]=\"link.href\"\n [attr.aria-current]=\"link.current ? 'page' : null\"\n [attr.target]=\"link.newTab ? '_blank' : null\"\n [attr.rel]=\"link.newTab ? 'noopener noreferrer' : null\"\n (click)=\"onLinkClick($event, link)\"\n >{{ link.label }}</a\n >\n </li>\n }\n </ul>\n </div>\n `,\n})\nexport class LinkPicker {\n /** Accessible name for the button and the list. */\n readonly label = input.required<string>();\n /** The page links to offer. Defined by the app. */\n readonly links = input.required<LinkItem[]>();\n /**\n * Client-side navigation hook, e.g. a router's `navigateByUrl`. When given, a plain left click\n * on a link calls `navigate(href)` instead of letting the browser load the page; modified\n * clicks (Ctrl/Cmd/Shift/Alt, middle button) and `newTab` links stay native.\n */\n readonly navigate = input<((href: string) => void) | undefined>(undefined);\n /** Extra CSS class on the root div. */\n readonly className = input<string>(\"\");\n\n /** Fires after a link is chosen. */\n readonly navigated = output<LinkNavigatedEvent>();\n\n /** Projected icon template; replaces the default icon when supplied. */\n protected readonly iconTemplate = contentChild(TemplateRef);\n\n private readonly rootRef =\n viewChild.required<ElementRef<HTMLDivElement>>(\"rootEl\");\n // A template-ref-variable on a component tag resolves to the component INSTANCE, which is what\n // is needed to call the headless component's own public `focus()` method.\n private readonly buttonRef = viewChild.required<IconButton>(\"buttonEl\");\n private readonly listRef =\n viewChild.required<ElementRef<HTMLUListElement>>(\"listEl\");\n\n private readonly baseId = nextLinkPickerId();\n protected readonly listId = `${this.baseId}-list`;\n\n protected readonly open = signal(false);\n\n // Tooltip: shown while the pointer is over the button or the tooltip itself (hoverable, WCAG\n // 1.4.13) or while the button has keyboard focus; Escape dismisses it without moving focus;\n // never shown while the popup is open, since the popup then explains the control.\n protected readonly tooltipId = `${this.baseId}-tooltip`;\n protected readonly hoverButton = signal(false);\n protected readonly hoverTooltip = signal(false);\n protected readonly focusButton = signal(false);\n protected readonly dismissed = signal(false);\n protected readonly tooltipVisible = computed(\n () =>\n !this.open() &&\n !this.dismissed() &&\n (this.hoverButton() || this.hoverTooltip() || this.focusButton()),\n );\n\n protected onTooltipButtonEnter(): void {\n this.hoverButton.set(true);\n this.dismissed.set(false);\n }\n\n protected onTooltipButtonFocusIn(): void {\n // Keyboard focus only: a mouse click also focuses the button in Chromium, and the tooltip\n // should not stick after a click.\n try {\n this.focusButton.set(\n this.buttonRef().element?.matches(\":focus-visible\") ?? false,\n );\n } catch {\n this.focusButton.set(true); // engine without :focus-visible — err towards showing\n }\n }\n\n protected onTooltipButtonFocusOut(): void {\n this.focusButton.set(false);\n this.dismissed.set(false);\n }\n\n protected onTooltipKeydown(event: KeyboardEvent): void {\n if (event.key === \"Escape\" && this.tooltipVisible()) this.dismissed.set(true);\n }\n\n // WCAG 1.4.13 \"dismissable\": while the tooltip is visible, Escape works wherever focus is. The\n // listener exists only while visible; the effect cleanup removes it on hide and on destroy.\n private readonly tooltipEscapeListener = effect((onCleanup) => {\n if (!this.tooltipVisible() || typeof document === \"undefined\") return;\n const onKey = (event: KeyboardEvent): void => {\n if (event.key === \"Escape\") this.dismissed.set(true);\n };\n document.addEventListener(\"keydown\", onKey);\n onCleanup(() => document.removeEventListener(\"keydown\", onKey));\n });\n\n protected readonly childContext = computed(() => {\n const args: ChildArgs = { open: this.open() };\n return { $implicit: args, ...args };\n });\n\n protected idOf(link: LinkItem): string {\n return linkId(link);\n }\n\n /** Every focusable link in the list, in DOM order. */\n private items(): HTMLElement[] {\n return Array.from(\n this.listRef().nativeElement.querySelectorAll<HTMLElement>(\".link-picker-link\"),\n );\n }\n\n // ---------------------------------------------------------------\n // Open / close\n // ---------------------------------------------------------------\n\n /** Open the list, focusing the first link (or the last, if `focusLast`). */\n openList(focusLast = false): void {\n this.open.set(true);\n // Deferred so the `hidden` attribute is gone before focus is moved.\n queueMicrotask(() => {\n const all = this.items();\n (focusLast ? all[all.length - 1] : all[0])?.focus({ preventScroll: true });\n });\n }\n\n /** Close the list; `refocus` returns focus to the trigger. */\n closeList(refocus = true): void {\n if (!this.open()) return;\n this.open.set(false);\n if (refocus) queueMicrotask(() => this.buttonRef().focus({ preventScroll: true }));\n }\n\n protected onButtonClick(): void {\n if (this.open()) this.closeList();\n else this.openList();\n }\n\n protected onLinkClick(event: MouseEvent, link: LinkItem): void {\n this.navigated.emit({ id: linkId(link), href: link.href });\n const go = this.navigate();\n const modified =\n event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0;\n if (go && !link.newTab && !modified && !event.defaultPrevented) {\n event.preventDefault();\n go(link.href);\n }\n this.closeList();\n }\n\n // ---------------------------------------------------------------\n // Keyboard\n // ---------------------------------------------------------------\n\n protected onButtonKeydown(event: KeyboardEvent): void {\n // Enter and Space already produce a click; only the arrows need handling here.\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n if (!this.open()) this.openList();\n else this.items()[0]?.focus({ preventScroll: true });\n } else if (event.key === \"ArrowUp\") {\n event.preventDefault();\n if (!this.open()) this.openList(true);\n else {\n const all = this.items();\n all[all.length - 1]?.focus({ preventScroll: true });\n }\n }\n }\n\n private moveFocus(delta: number): void {\n const all = this.items();\n if (all.length === 0) return;\n const i = all.indexOf(document.activeElement as HTMLElement);\n // Clamp rather than wrap.\n const next = Math.min(Math.max((i < 0 ? 0 : i) + delta, 0), all.length - 1);\n all[next]?.focus({ preventScroll: true });\n }\n\n protected onListKeydown(event: KeyboardEvent): void {\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n this.moveFocus(1);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n this.moveFocus(-1);\n break;\n case \"Home\":\n event.preventDefault();\n this.items()[0]?.focus({ preventScroll: true });\n break;\n case \"End\": {\n event.preventDefault();\n const all = this.items();\n all[all.length - 1]?.focus({ preventScroll: true });\n break;\n }\n case \"Escape\":\n event.preventDefault();\n this.closeList();\n break;\n case \"Tab\":\n // Focus goes to the button FIRST, without cancelling the key: hiding the list while a\n // link has focus drops focus to <body>, and the default Tab would restart from the top.\n this.buttonRef().focus?.({ preventScroll: true });\n this.closeList(false);\n break;\n default:\n break;\n }\n }\n\n protected onRootFocusOut(event: FocusEvent): void {\n const next = event.relatedTarget as Node | null;\n if (next && this.rootRef().nativeElement.contains(next)) return;\n this.closeList(false);\n }\n\n protected onDocumentClick(event: Event): void {\n if (!this.open()) return;\n const t = event.target as Node | null;\n if (t && !this.rootRef().nativeElement.contains(t)) this.closeList(false);\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lilydesignsystem/angular-link-picker",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": "=26"
|
|
6
|
+
},
|
|
7
|
+
"description": "Lily Design System Angular link picker: an icon button (a home icon) opening a disclosure of app-defined page links. 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
|
+
"link",
|
|
23
|
+
"navigation",
|
|
24
|
+
"home",
|
|
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-link-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-link-picker.mjs",
|
|
39
|
+
"typings": "types/lilydesignsystem-angular-link-picker.d.ts",
|
|
40
|
+
"exports": {
|
|
41
|
+
"./package.json": {
|
|
42
|
+
"default": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
".": {
|
|
45
|
+
"types": "./types/lilydesignsystem-angular-link-picker.d.ts",
|
|
46
|
+
"default": "./fesm2022/lilydesignsystem-angular-link-picker.mjs"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"type": "module"
|
|
50
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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 list state, and `aria-controls` points at it. The list is named with the same
|
|
9
|
+
`label`.
|
|
10
|
+
- The links keep **native link semantics**: real `<a href>` elements with no `role` override, so middle-click,
|
|
11
|
+
open-in-new-tab and copy-link-address all work. A `role="menuitem"` implementation would take all three away.
|
|
12
|
+
- The current page's link carries `aria-current="page"` when you mark it `current`.
|
|
13
|
+
- Keyboard: `Enter`/`Space` toggle, `ArrowDown`/`ArrowUp` open and move between links (clamping, not wrapping), `Home`/`End`
|
|
14
|
+
jump, `Escape` closes and returns focus to the trigger, `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
|
+
- Opening focuses a link without scrolling the page.
|
|
17
|
+
- The tooltip is hoverable and dismissable (WCAG 1.4.13): visible on pointer hover or keyboard focus, `Escape` hides it
|
|
18
|
+
wherever focus is, and it is never shown while the list is open.
|
|
19
|
+
|
|
20
|
+
## What it costs
|
|
21
|
+
|
|
22
|
+
**The name rests entirely on `label`.** An icon-only control has no visible text fallback. A home icon is widely understood,
|
|
23
|
+
but if `label` is wrong, missing or untranslated there is nothing else to go on. Choose a label that says what the list is
|
|
24
|
+
("Pages", "Site"), not what the icon is.
|
|
25
|
+
|
|
26
|
+
**It hides navigation behind a click.** For a handful of links in a header that is the point; for primary site navigation,
|
|
27
|
+
use a visible `nav` instead (see `BreadcrumbNav`, `SectionNav`).
|
|
28
|
+
|
|
29
|
+
**Focus management is real focus**, not `aria-activedescendant`, so screen readers announce each link as it receives focus.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Examples — LinkPicker (Angular)
|
|
2
|
+
|
|
3
|
+
| File | Shows |
|
|
4
|
+
| ---- | ----- |
|
|
5
|
+
| [basic.component.ts](./basic.component.ts) | Basic usage |
|
|
6
|
+
| [router.component.ts](./router.component.ts) | Angular Router: client-side navigation and the current page |
|
|
7
|
+
| [custom-icon.component.ts](./custom-icon.component.ts) | Replacing the icon |
|
|
8
|
+
|
|
9
|
+
Every user-facing string and every route is a prop — the package ships no default list.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Component } from "@angular/core";
|
|
2
|
+
import { LinkPicker, type LinkItem } from "../link-picker.component";
|
|
3
|
+
|
|
4
|
+
// The app defines the links: the package ships no routes and no English.
|
|
5
|
+
@Component({
|
|
6
|
+
selector: "example-basic",
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [LinkPicker],
|
|
9
|
+
template: `<lily-link-picker label="Pages" [links]="links" />`,
|
|
10
|
+
})
|
|
11
|
+
export class Basic {
|
|
12
|
+
links: LinkItem[] = [
|
|
13
|
+
{ label: "Home", href: "/" },
|
|
14
|
+
{ label: "About Us", href: "/about/" },
|
|
15
|
+
{ label: "Contact Us", href: "/contact/" },
|
|
16
|
+
{ label: "Privacy Policy", href: "/privacy/" },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Component } from "@angular/core";
|
|
2
|
+
import { LinkPicker, LinkPickerIcon } from "../link-picker.component";
|
|
3
|
+
|
|
4
|
+
// A projected <ng-template lilyLinkPickerIcon> replaces the icon inside the button, never the links.
|
|
5
|
+
@Component({
|
|
6
|
+
selector: "example-custom-icon",
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [LinkPicker, LinkPickerIcon],
|
|
9
|
+
template: `
|
|
10
|
+
<lily-link-picker label="Pages" [links]="links">
|
|
11
|
+
<ng-template lilyLinkPickerIcon let-args><span aria-hidden="true">{{ args.open ? "▾" : "☰" }}</span></ng-template>
|
|
12
|
+
</lily-link-picker>
|
|
13
|
+
`,
|
|
14
|
+
})
|
|
15
|
+
export class CustomIcon {
|
|
16
|
+
links = [
|
|
17
|
+
{ label: "Home", href: "/" },
|
|
18
|
+
{ label: "About Us", href: "/about/" },
|
|
19
|
+
{ label: "Contact Us", href: "/contact/" },
|
|
20
|
+
{ label: "Privacy Policy", href: "/privacy/" },
|
|
21
|
+
];
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Component, inject } from "@angular/core";
|
|
2
|
+
import { Router } from "@angular/router";
|
|
3
|
+
import { LinkPicker } from "../link-picker.component";
|
|
4
|
+
|
|
5
|
+
// `navigate` turns a plain left click into router navigation; Ctrl/Cmd-click, middle click and
|
|
6
|
+
// newTab links stay native. `current` marks the page you are on with aria-current="page".
|
|
7
|
+
@Component({
|
|
8
|
+
selector: "example-router",
|
|
9
|
+
standalone: true,
|
|
10
|
+
imports: [LinkPicker],
|
|
11
|
+
template: `<lily-link-picker label="Pages" [links]="links()" [navigate]="go" />`,
|
|
12
|
+
})
|
|
13
|
+
export class RouterExample {
|
|
14
|
+
private router = inject(Router);
|
|
15
|
+
go = (href: string) => void this.router.navigateByUrl(href);
|
|
16
|
+
links = () => [
|
|
17
|
+
{ label: "Home", href: "/" },
|
|
18
|
+
{ label: "About Us", href: "/about/" },
|
|
19
|
+
{ label: "Contact Us", href: "/contact/" },
|
|
20
|
+
{ label: "Privacy Policy", href: "/privacy/" },
|
|
21
|
+
].map((p) => ({ ...p, current: this.router.url === p.href }));
|
|
22
|
+
}
|
package/index.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# LinkPicker (Angular)
|
|
2
|
+
|
|
3
|
+
A headless control for **page links**: a single-icon button (a bundled home SVG) that opens a dropdown of links **your app
|
|
4
|
+
defines** — "Home", "About Us", "Contact Us", "Privacy Policy". No CSS, no routes, no English.
|
|
5
|
+
|
|
6
|
+
The single source of truth is [spec/index.md](./spec/index.md); this file is the guide.
|
|
7
|
+
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { Component } from "@angular/core";
|
|
12
|
+
import { LinkPicker, type LinkItem } from "@lilydesignsystem/angular-link-picker";
|
|
13
|
+
|
|
14
|
+
@Component({
|
|
15
|
+
selector: "app-header",
|
|
16
|
+
standalone: true,
|
|
17
|
+
imports: [LinkPicker],
|
|
18
|
+
template: `<lily-link-picker label="Pages" [links]="links" />`,
|
|
19
|
+
})
|
|
20
|
+
export class AppHeader {
|
|
21
|
+
links: LinkItem[] = [
|
|
22
|
+
{ label: "Home", href: "/" },
|
|
23
|
+
{ label: "About Us", href: "/about/" },
|
|
24
|
+
{ label: "Contact Us", href: "/contact/" },
|
|
25
|
+
{ label: "Privacy Policy", href: "/privacy/" },
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`label` names the button, the list and the tooltip. There is **no default list**: the package ships no routes and no English.
|
|
31
|
+
|
|
32
|
+
## Client-side navigation
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { Component, inject } from "@angular/core";
|
|
36
|
+
import { Router } from "@angular/router";
|
|
37
|
+
import { LinkPicker } from "@lilydesignsystem/angular-link-picker";
|
|
38
|
+
|
|
39
|
+
@Component({
|
|
40
|
+
selector: "app-header",
|
|
41
|
+
standalone: true,
|
|
42
|
+
imports: [LinkPicker],
|
|
43
|
+
template: `<lily-link-picker label="Pages" [links]="links()" [navigate]="go" />`,
|
|
44
|
+
})
|
|
45
|
+
export class AppHeader {
|
|
46
|
+
private router = inject(Router);
|
|
47
|
+
go = (href: string) => void this.router.navigateByUrl(href);
|
|
48
|
+
links = () => [
|
|
49
|
+
{ label: "Home", href: "/" },
|
|
50
|
+
{ label: "About Us", href: "/about/" },
|
|
51
|
+
{ label: "Contact Us", href: "/contact/" },
|
|
52
|
+
{ label: "Privacy Policy", href: "/privacy/" },
|
|
53
|
+
].map((p) => ({ ...p, current: this.router.url === p.href }));
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Ctrl/Cmd/Shift/Alt-click, middle-click and `newTab` links always stay native browser behaviour.
|
|
58
|
+
|
|
59
|
+
## `LinkItem`
|
|
60
|
+
|
|
61
|
+
| Field | Notes |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| label | Visible text. Yours, so it localises. |
|
|
64
|
+
| href | A route (`"/about/"`) or a full URL. |
|
|
65
|
+
| id | Reported when a link is chosen; defaults to the href. |
|
|
66
|
+
| current | Marks the current page with `aria-current="page"`. |
|
|
67
|
+
| newTab | `target="_blank"` with `rel="noopener noreferrer"`. |
|
|
68
|
+
|
|
69
|
+
## Props
|
|
70
|
+
|
|
71
|
+
| `label` | `input.required<string>()` | yes | Accessible name of the button **and** the list; also the tooltip text. |
|
|
72
|
+
| `links` | `input.required<LinkItem[]>()` | yes | `{ id?, label, href, current?, newTab? }`. `id` defaults to `href`. |
|
|
73
|
+
| `navigate` | `input<(href: string) => void>()` | no | Client-side navigation hook (a router's `navigateByUrl`). |
|
|
74
|
+
| `navigated` | `output<{ id, href }>()` | no | Emitted after a link is chosen (the Svelte `onNavigate` callback). |
|
|
75
|
+
| icon template | `<ng-template lilyLinkPickerIcon let-args>` | no | Replaces the **icon**, not the links. |
|
|
76
|
+
| `className` | `input<string>()` | no | Appended to the root class. |
|
|
77
|
+
|
|
78
|
+
## Styling
|
|
79
|
+
|
|
80
|
+
Headless: no CSS ships. Hooks: `.link-picker`, `.link-picker-button`, `.link-picker-icon`, `.link-picker-tooltip`,
|
|
81
|
+
`.link-picker-list` (carries `hidden` when closed), `.link-picker-list-item`, `.link-picker-link`. The monorepo's 45 `themes/`
|
|
82
|
+
style them all, including the popup placement.
|
|
83
|
+
|
|
84
|
+
## Examples
|
|
85
|
+
|
|
86
|
+
[examples/](./examples/README.md): the four-link example, router integration and a custom icon.
|
package/index.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { Component } 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
|
+
LinkPicker,
|
|
9
|
+
LinkPickerIcon,
|
|
10
|
+
linkId,
|
|
11
|
+
nextLinkPickerId,
|
|
12
|
+
type LinkItem,
|
|
13
|
+
} from "./link-picker.component";
|
|
14
|
+
|
|
15
|
+
const LINKS: LinkItem[] = [
|
|
16
|
+
{ label: "Home", href: "/" },
|
|
17
|
+
{ label: "About Us", href: "/about/" },
|
|
18
|
+
{ label: "Contact Us", href: "/contact/" },
|
|
19
|
+
{ label: "Privacy Policy", href: "/privacy/" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
|
|
23
|
+
|
|
24
|
+
let fixtures: ComponentFixture<unknown>[] = [];
|
|
25
|
+
|
|
26
|
+
function mount(inputs: Record<string, unknown> = {}): ComponentFixture<LinkPicker> {
|
|
27
|
+
const fixture = TestBed.createComponent(LinkPicker);
|
|
28
|
+
fixture.componentRef.setInput("label", "Pages");
|
|
29
|
+
fixture.componentRef.setInput("links", LINKS);
|
|
30
|
+
for (const [k, v] of Object.entries(inputs)) fixture.componentRef.setInput(k, v);
|
|
31
|
+
fixture.detectChanges();
|
|
32
|
+
fixtures.push(fixture);
|
|
33
|
+
return fixture;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const q = <T extends Element>(f: ComponentFixture<unknown>, sel: string) =>
|
|
37
|
+
f.nativeElement.querySelector(sel) as T;
|
|
38
|
+
const trigger = (f: ComponentFixture<unknown>) => q<HTMLButtonElement>(f, ".link-picker-button");
|
|
39
|
+
const list = (f: ComponentFixture<unknown>) => q<HTMLUListElement>(f, ".link-picker-list");
|
|
40
|
+
const links = (f: ComponentFixture<unknown>) =>
|
|
41
|
+
Array.from(f.nativeElement.querySelectorAll(".link-picker-link")) as HTMLAnchorElement[];
|
|
42
|
+
|
|
43
|
+
function press(f: ComponentFixture<unknown>, target: HTMLElement, key: string): void {
|
|
44
|
+
target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
|
|
45
|
+
f.detectChanges();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function open(f: ComponentFixture<unknown>): Promise<void> {
|
|
49
|
+
trigger(f).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
50
|
+
f.detectChanges();
|
|
51
|
+
await flush();
|
|
52
|
+
f.detectChanges();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
for (const f of fixtures) f.destroy();
|
|
57
|
+
fixtures = [];
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("LinkPicker", () => {
|
|
61
|
+
test("7.1 renders the root, button, tooltip and a hidden list", () => {
|
|
62
|
+
const f = mount();
|
|
63
|
+
expect(q(f, "div.link-picker")).toBeTruthy();
|
|
64
|
+
expect(trigger(f)).toBeTruthy();
|
|
65
|
+
expect(q(f, ".link-picker-tooltip")).toBeTruthy();
|
|
66
|
+
expect(list(f).hasAttribute("hidden")).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("7.2 the button is named by label with aria-expanded and aria-controls", () => {
|
|
70
|
+
const f = mount();
|
|
71
|
+
expect(trigger(f).getAttribute("aria-label")).toBe("Pages");
|
|
72
|
+
expect(trigger(f).getAttribute("aria-expanded")).toBe("false");
|
|
73
|
+
expect(trigger(f).getAttribute("aria-controls")).toBe(list(f).id);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("7.3 the default icon is an aria-hidden svg; a projected template replaces it", () => {
|
|
77
|
+
const f = mount();
|
|
78
|
+
const svg = q<SVGElement>(f, "svg.link-picker-icon");
|
|
79
|
+
expect(svg.getAttribute("aria-hidden")).toBe("true");
|
|
80
|
+
expect(svg.getAttribute("viewBox")).toBe("0 0 16 16");
|
|
81
|
+
|
|
82
|
+
@Component({
|
|
83
|
+
standalone: true,
|
|
84
|
+
imports: [LinkPicker, LinkPickerIcon],
|
|
85
|
+
template: `<lily-link-picker label="Pages" [links]="links"><ng-template lilyLinkPickerIcon let-args><span class="mine">{{ args.open ? "-" : "+" }}</span></ng-template></lily-link-picker>`,
|
|
86
|
+
})
|
|
87
|
+
class Host {
|
|
88
|
+
links = LINKS;
|
|
89
|
+
}
|
|
90
|
+
const hf = TestBed.createComponent(Host);
|
|
91
|
+
hf.detectChanges();
|
|
92
|
+
fixtures.push(hf);
|
|
93
|
+
expect(hf.nativeElement.querySelector(".mine")).toBeTruthy();
|
|
94
|
+
expect(hf.nativeElement.querySelector("svg.link-picker-icon")).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("7.4 one real link per entry, in order", () => {
|
|
98
|
+
expect(links(mount()).map((a) => [a.textContent?.trim(), a.getAttribute("href")])).toEqual([
|
|
99
|
+
["Home", "/"],
|
|
100
|
+
["About Us", "/about/"],
|
|
101
|
+
["Contact Us", "/contact/"],
|
|
102
|
+
["Privacy Policy", "/privacy/"],
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("7.5 no links are invented", () => {
|
|
107
|
+
expect(links(mount({ links: [] }))).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("7.6 the list is named with label", () => {
|
|
111
|
+
expect(list(mount()).getAttribute("aria-label")).toBe("Pages");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("7.7 click opens the list and focuses the first link", async () => {
|
|
115
|
+
const f = mount();
|
|
116
|
+
await open(f);
|
|
117
|
+
expect(list(f).hasAttribute("hidden")).toBe(false);
|
|
118
|
+
expect(trigger(f).getAttribute("aria-expanded")).toBe("true");
|
|
119
|
+
expect(document.activeElement).toBe(links(f)[0]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("7.8 a second click closes", async () => {
|
|
123
|
+
const f = mount();
|
|
124
|
+
await open(f);
|
|
125
|
+
await open(f);
|
|
126
|
+
expect(list(f).hasAttribute("hidden")).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("7.9 ArrowDown opens at the first link, ArrowUp at the last", async () => {
|
|
130
|
+
const a = mount();
|
|
131
|
+
press(a, trigger(a), "ArrowDown");
|
|
132
|
+
await flush();
|
|
133
|
+
expect(document.activeElement).toBe(links(a)[0]);
|
|
134
|
+
const b = mount();
|
|
135
|
+
press(b, trigger(b), "ArrowUp");
|
|
136
|
+
await flush();
|
|
137
|
+
expect(document.activeElement).toBe(links(b)[3]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("7.10 arrows move and clamp; Home and End jump", async () => {
|
|
141
|
+
const f = mount();
|
|
142
|
+
await open(f);
|
|
143
|
+
press(f, list(f), "ArrowUp");
|
|
144
|
+
expect(document.activeElement).toBe(links(f)[0]);
|
|
145
|
+
press(f, list(f), "ArrowDown");
|
|
146
|
+
expect(document.activeElement).toBe(links(f)[1]);
|
|
147
|
+
press(f, list(f), "End");
|
|
148
|
+
expect(document.activeElement).toBe(links(f)[3]);
|
|
149
|
+
press(f, list(f), "ArrowDown");
|
|
150
|
+
expect(document.activeElement).toBe(links(f)[3]);
|
|
151
|
+
press(f, list(f), "Home");
|
|
152
|
+
expect(document.activeElement).toBe(links(f)[0]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("7.11 Escape closes and returns focus to the button", async () => {
|
|
156
|
+
const f = mount();
|
|
157
|
+
await open(f);
|
|
158
|
+
press(f, list(f), "Escape");
|
|
159
|
+
await flush();
|
|
160
|
+
f.detectChanges();
|
|
161
|
+
expect(list(f).hasAttribute("hidden")).toBe(true);
|
|
162
|
+
expect(document.activeElement).toBe(trigger(f));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("7.12 Tab closes without sending focus to the document top", async () => {
|
|
166
|
+
const f = mount();
|
|
167
|
+
await open(f);
|
|
168
|
+
press(f, list(f), "Tab");
|
|
169
|
+
await flush();
|
|
170
|
+
expect(list(f).hasAttribute("hidden")).toBe(true);
|
|
171
|
+
expect(document.activeElement).toBe(trigger(f));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("7.13 clicking outside closes", async () => {
|
|
175
|
+
const f = mount();
|
|
176
|
+
await open(f);
|
|
177
|
+
document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
178
|
+
f.detectChanges();
|
|
179
|
+
expect(list(f).hasAttribute("hidden")).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("7.14 current marks only that link aria-current=page", () => {
|
|
183
|
+
const f = mount({ links: [LINKS[0], { ...LINKS[1], current: true }, LINKS[2]] });
|
|
184
|
+
expect(links(f).map((a) => a.getAttribute("aria-current"))).toEqual([null, "page", null]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("7.15 newTab adds target and rel", () => {
|
|
188
|
+
const f = mount({ links: [{ label: "Docs", href: "https://example.test/", newTab: true }, LINKS[0]] });
|
|
189
|
+
expect(links(f)[0].getAttribute("target")).toBe("_blank");
|
|
190
|
+
expect(links(f)[0].getAttribute("rel")).toBe("noopener noreferrer");
|
|
191
|
+
expect(links(f)[1].hasAttribute("target")).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("7.16 the navigated output reports the id, else the href", async () => {
|
|
195
|
+
const f = mount({ links: [{ id: "about", label: "About Us", href: "/about/" }, LINKS[0]] });
|
|
196
|
+
const seen: unknown[] = [];
|
|
197
|
+
f.componentInstance.navigated.subscribe((e) => seen.push(e));
|
|
198
|
+
await open(f);
|
|
199
|
+
for (const a of links(f)) a.addEventListener("click", (e) => e.preventDefault());
|
|
200
|
+
links(f)[0].dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
|
|
201
|
+
f.detectChanges();
|
|
202
|
+
await open(f);
|
|
203
|
+
links(f)[1].dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
|
|
204
|
+
expect(seen).toEqual([{ id: "about", href: "/about/" }, { id: "/", href: "/" }]);
|
|
205
|
+
expect(linkId({ label: "x", href: "/y" })).toBe("/y");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("7.17 navigate handles a plain left click only", async () => {
|
|
209
|
+
const navigate = vi.fn();
|
|
210
|
+
const f = mount({ navigate, links: [LINKS[1], { label: "Docs", href: "https://example.test/", newTab: true }] });
|
|
211
|
+
await open(f);
|
|
212
|
+
const plain = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 });
|
|
213
|
+
links(f)[0].dispatchEvent(plain);
|
|
214
|
+
expect(navigate).toHaveBeenCalledWith("/about/");
|
|
215
|
+
expect(plain.defaultPrevented).toBe(true);
|
|
216
|
+
navigate.mockClear();
|
|
217
|
+
f.detectChanges();
|
|
218
|
+
await open(f);
|
|
219
|
+
const ctrl = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, ctrlKey: true });
|
|
220
|
+
links(f)[0].dispatchEvent(ctrl);
|
|
221
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
222
|
+
expect(ctrl.defaultPrevented).toBe(false);
|
|
223
|
+
f.detectChanges();
|
|
224
|
+
await open(f);
|
|
225
|
+
const tab = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 });
|
|
226
|
+
links(f)[1].dispatchEvent(tab);
|
|
227
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
228
|
+
expect(tab.defaultPrevented).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("7.18 without navigate a click is left to the browser", async () => {
|
|
232
|
+
const f = mount();
|
|
233
|
+
await open(f);
|
|
234
|
+
const ev = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 });
|
|
235
|
+
links(f)[0].dispatchEvent(ev);
|
|
236
|
+
expect(ev.defaultPrevented).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("7.19 the tooltip is a hidden role=tooltip holding the label", () => {
|
|
240
|
+
const tip = q<HTMLElement>(mount(), ".link-picker-tooltip");
|
|
241
|
+
expect(tip.getAttribute("role")).toBe("tooltip");
|
|
242
|
+
expect(tip.textContent).toBe("Pages");
|
|
243
|
+
expect(tip.hasAttribute("hidden")).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("7.20 the tooltip shows on hover, hides on Escape, and never shows while open", async () => {
|
|
247
|
+
const f = mount();
|
|
248
|
+
const tip = q<HTMLElement>(f, ".link-picker-tooltip");
|
|
249
|
+
const host = q<HTMLElement>(f, "lily-icon-button");
|
|
250
|
+
host.dispatchEvent(new MouseEvent("mouseenter"));
|
|
251
|
+
f.detectChanges();
|
|
252
|
+
expect(tip.hasAttribute("hidden")).toBe(false);
|
|
253
|
+
await flush();
|
|
254
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
255
|
+
f.detectChanges();
|
|
256
|
+
expect(tip.hasAttribute("hidden")).toBe(true);
|
|
257
|
+
host.dispatchEvent(new MouseEvent("mouseleave"));
|
|
258
|
+
host.dispatchEvent(new MouseEvent("mouseenter"));
|
|
259
|
+
f.detectChanges();
|
|
260
|
+
expect(tip.hasAttribute("hidden")).toBe(false);
|
|
261
|
+
await open(f);
|
|
262
|
+
expect(tip.hasAttribute("hidden")).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("7.21 the tooltip is not wired with aria-describedby", () => {
|
|
266
|
+
expect(trigger(mount()).hasAttribute("aria-describedby")).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("7.22 className is appended to the root", () => {
|
|
270
|
+
expect(q(mount({ className: "extra" }), ".link-picker").classList.contains("extra")).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("7.23 two instances get distinct ids", () => {
|
|
274
|
+
expect(nextLinkPickerId()).not.toBe(nextLinkPickerId());
|
|
275
|
+
expect(list(mount()).id).not.toBe(list(mount()).id);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("7.24 the source ships no stylesheet, inline style, English default or route", () => {
|
|
279
|
+
const src = readFileSync(resolve(__dirname, "link-picker.component.ts"), "utf8")
|
|
280
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
281
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
282
|
+
.replace(/^\s*\/\/.*$/gm, "");
|
|
283
|
+
expect(src).not.toMatch(/styles\s*:|<style|\sstyle=/);
|
|
284
|
+
expect(src).not.toMatch(/(label|href):\s*["'`]/);
|
|
285
|
+
expect(src).not.toMatch(/href="\//);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -0,0 +1,398 @@
|
|
|
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
|
+
output,
|
|
13
|
+
signal,
|
|
14
|
+
viewChild,
|
|
15
|
+
} from "@angular/core";
|
|
16
|
+
// Only the trigger button composes a headless primitive. The list below is real `<a>` navigation
|
|
17
|
+
// with a roving-focus pattern of its own — not an ARIA listbox or menu: role="menuitem" would strip
|
|
18
|
+
// middle-click, open-in-new-tab and copy-link-address from ordinary links. See spec/index.md §3.
|
|
19
|
+
import { IconButton } from "@lilydesignsystem/angular-headless";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One destination in the list. The app defines them all: this package ships no routes and no
|
|
23
|
+
* English — `label` is the consumer's text.
|
|
24
|
+
*/
|
|
25
|
+
export type LinkItem = {
|
|
26
|
+
/** Stable identifier, passed back on the `navigated` output. Defaults to `href`. */
|
|
27
|
+
id?: string;
|
|
28
|
+
/** Visible link text. Consumer-supplied, so it localises. */
|
|
29
|
+
label: string;
|
|
30
|
+
/** Where the link goes: a route ("/about/") or a full URL. */
|
|
31
|
+
href: string;
|
|
32
|
+
/** Marks this link as the current page (`aria-current="page"`). */
|
|
33
|
+
current?: boolean;
|
|
34
|
+
/** Open in a new tab (`target="_blank"` with `rel="noopener noreferrer"`). */
|
|
35
|
+
newTab?: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Context passed to a custom icon `<ng-template>` (the button icon). */
|
|
39
|
+
export type ChildArgs = {
|
|
40
|
+
/** Is the list open? */
|
|
41
|
+
open: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Payload of the `navigated` output. */
|
|
45
|
+
export type LinkNavigatedEvent = {
|
|
46
|
+
/** The chosen link's `id` (its `href` when it has none). */
|
|
47
|
+
id: string;
|
|
48
|
+
/** The chosen link's `href`. */
|
|
49
|
+
href: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The id a link reports: its explicit `id`, else its `href`. */
|
|
53
|
+
export function linkId(link: LinkItem): string {
|
|
54
|
+
return link.id ?? link.href;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let uid = 0;
|
|
58
|
+
/** Stable per-instance id prefix; SSR-safe (no Math.random / Date.now). */
|
|
59
|
+
export function nextLinkPickerId(): string {
|
|
60
|
+
uid += 1;
|
|
61
|
+
return `link-picker-${uid}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Optional marker for the projected icon template. Gives consumers typed `let-` variables:
|
|
66
|
+
*
|
|
67
|
+
* ```html
|
|
68
|
+
* <lily-link-picker label="Pages" [links]="links">
|
|
69
|
+
* <ng-template lilyLinkPickerIcon let-args>{{ args.open ? "×" : "☰" }}</ng-template>
|
|
70
|
+
* </lily-link-picker>
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
@Directive({
|
|
74
|
+
selector: "ng-template[lilyLinkPickerIcon]",
|
|
75
|
+
standalone: true,
|
|
76
|
+
})
|
|
77
|
+
export class LinkPickerIcon {
|
|
78
|
+
static ngTemplateContextGuard(
|
|
79
|
+
_dir: LinkPickerIcon,
|
|
80
|
+
_ctx: unknown,
|
|
81
|
+
): _ctx is ChildArgs & { $implicit: ChildArgs } {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* LinkPicker — a headless page-links control.
|
|
88
|
+
*
|
|
89
|
+
* A single-icon button (a bundled home SVG) that opens a disclosure list of page links the app
|
|
90
|
+
* defines. It applies nothing to the document and persists nothing. See `spec/index.md`.
|
|
91
|
+
*/
|
|
92
|
+
@Component({
|
|
93
|
+
selector: "lily-link-picker",
|
|
94
|
+
standalone: true,
|
|
95
|
+
imports: [NgTemplateOutlet, IconButton],
|
|
96
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
97
|
+
host: {
|
|
98
|
+
"(document:click)": "onDocumentClick($event)",
|
|
99
|
+
},
|
|
100
|
+
template: `
|
|
101
|
+
<div
|
|
102
|
+
#rootEl
|
|
103
|
+
class="link-picker {{ className() }}"
|
|
104
|
+
(focusout)="onRootFocusOut($event)"
|
|
105
|
+
>
|
|
106
|
+
<lily-icon-button
|
|
107
|
+
#buttonEl
|
|
108
|
+
[label]="label()"
|
|
109
|
+
baseClass="link-picker-button"
|
|
110
|
+
[ariaExpanded]="open()"
|
|
111
|
+
[ariaControls]="listId"
|
|
112
|
+
(click)="onButtonClick(); hoverButton.set(false)"
|
|
113
|
+
(mouseenter)="onTooltipButtonEnter()"
|
|
114
|
+
(mouseleave)="hoverButton.set(false)"
|
|
115
|
+
(focusin)="onTooltipButtonFocusIn()"
|
|
116
|
+
(focusout)="onTooltipButtonFocusOut()"
|
|
117
|
+
(keydown)="onButtonKeydown($event); onTooltipKeydown($event)"
|
|
118
|
+
>
|
|
119
|
+
@if (iconTemplate(); as tpl) {
|
|
120
|
+
<ng-container
|
|
121
|
+
[ngTemplateOutlet]="tpl"
|
|
122
|
+
[ngTemplateOutletContext]="childContext()"
|
|
123
|
+
/>
|
|
124
|
+
} @else {
|
|
125
|
+
<svg
|
|
126
|
+
class="link-picker-icon"
|
|
127
|
+
viewBox="0 0 16 16"
|
|
128
|
+
width="1.05rem"
|
|
129
|
+
height="1.05rem"
|
|
130
|
+
aria-hidden="true"
|
|
131
|
+
fill="none"
|
|
132
|
+
stroke="currentColor"
|
|
133
|
+
stroke-width="1.6"
|
|
134
|
+
stroke-linecap="round"
|
|
135
|
+
stroke-linejoin="round"
|
|
136
|
+
>
|
|
137
|
+
<path d="M1 8 8 1.5l7 6.5" />
|
|
138
|
+
<path d="M2.5 7v7.5h11V7" />
|
|
139
|
+
<path d="M6.5 14.5v-4h3v4" />
|
|
140
|
+
</svg>
|
|
141
|
+
}
|
|
142
|
+
</lily-icon-button>
|
|
143
|
+
|
|
144
|
+
<!-- Purely visual: the same text is already the button's aria-label, so it is not wired
|
|
145
|
+
with aria-describedby (that would announce the name twice). -->
|
|
146
|
+
<div
|
|
147
|
+
class="link-picker-tooltip"
|
|
148
|
+
role="tooltip"
|
|
149
|
+
[id]="tooltipId"
|
|
150
|
+
[attr.hidden]="tooltipVisible() ? null : ''"
|
|
151
|
+
(mouseenter)="hoverTooltip.set(true)"
|
|
152
|
+
(mouseleave)="hoverTooltip.set(false)"
|
|
153
|
+
>{{ label() }}</div>
|
|
154
|
+
|
|
155
|
+
<!-- Named like the sibling pickers' popups: a screen reader entering the list hears what
|
|
156
|
+
it is for. -->
|
|
157
|
+
<ul
|
|
158
|
+
#listEl
|
|
159
|
+
class="link-picker-list"
|
|
160
|
+
[id]="listId"
|
|
161
|
+
[attr.aria-label]="label() || null"
|
|
162
|
+
[attr.hidden]="open() ? null : ''"
|
|
163
|
+
(keydown)="onListKeydown($event)"
|
|
164
|
+
>
|
|
165
|
+
@for (link of links(); track idOf(link)) {
|
|
166
|
+
<li class="link-picker-list-item">
|
|
167
|
+
<!-- A real link, not role="menuitem": these ARE navigation. -->
|
|
168
|
+
<a
|
|
169
|
+
class="link-picker-link"
|
|
170
|
+
[attr.data-link-id]="idOf(link)"
|
|
171
|
+
[attr.href]="link.href"
|
|
172
|
+
[attr.aria-current]="link.current ? 'page' : null"
|
|
173
|
+
[attr.target]="link.newTab ? '_blank' : null"
|
|
174
|
+
[attr.rel]="link.newTab ? 'noopener noreferrer' : null"
|
|
175
|
+
(click)="onLinkClick($event, link)"
|
|
176
|
+
>{{ link.label }}</a
|
|
177
|
+
>
|
|
178
|
+
</li>
|
|
179
|
+
}
|
|
180
|
+
</ul>
|
|
181
|
+
</div>
|
|
182
|
+
`,
|
|
183
|
+
})
|
|
184
|
+
export class LinkPicker {
|
|
185
|
+
/** Accessible name for the button and the list. */
|
|
186
|
+
readonly label = input.required<string>();
|
|
187
|
+
/** The page links to offer. Defined by the app. */
|
|
188
|
+
readonly links = input.required<LinkItem[]>();
|
|
189
|
+
/**
|
|
190
|
+
* Client-side navigation hook, e.g. a router's `navigateByUrl`. When given, a plain left click
|
|
191
|
+
* on a link calls `navigate(href)` instead of letting the browser load the page; modified
|
|
192
|
+
* clicks (Ctrl/Cmd/Shift/Alt, middle button) and `newTab` links stay native.
|
|
193
|
+
*/
|
|
194
|
+
readonly navigate = input<((href: string) => void) | undefined>(undefined);
|
|
195
|
+
/** Extra CSS class on the root div. */
|
|
196
|
+
readonly className = input<string>("");
|
|
197
|
+
|
|
198
|
+
/** Fires after a link is chosen. */
|
|
199
|
+
readonly navigated = output<LinkNavigatedEvent>();
|
|
200
|
+
|
|
201
|
+
/** Projected icon template; replaces the default icon when supplied. */
|
|
202
|
+
protected readonly iconTemplate = contentChild(TemplateRef);
|
|
203
|
+
|
|
204
|
+
private readonly rootRef =
|
|
205
|
+
viewChild.required<ElementRef<HTMLDivElement>>("rootEl");
|
|
206
|
+
// A template-ref-variable on a component tag resolves to the component INSTANCE, which is what
|
|
207
|
+
// is needed to call the headless component's own public `focus()` method.
|
|
208
|
+
private readonly buttonRef = viewChild.required<IconButton>("buttonEl");
|
|
209
|
+
private readonly listRef =
|
|
210
|
+
viewChild.required<ElementRef<HTMLUListElement>>("listEl");
|
|
211
|
+
|
|
212
|
+
private readonly baseId = nextLinkPickerId();
|
|
213
|
+
protected readonly listId = `${this.baseId}-list`;
|
|
214
|
+
|
|
215
|
+
protected readonly open = signal(false);
|
|
216
|
+
|
|
217
|
+
// Tooltip: shown while the pointer is over the button or the tooltip itself (hoverable, WCAG
|
|
218
|
+
// 1.4.13) or while the button has keyboard focus; Escape dismisses it without moving focus;
|
|
219
|
+
// never shown while the popup is open, since the popup then explains the control.
|
|
220
|
+
protected readonly tooltipId = `${this.baseId}-tooltip`;
|
|
221
|
+
protected readonly hoverButton = signal(false);
|
|
222
|
+
protected readonly hoverTooltip = signal(false);
|
|
223
|
+
protected readonly focusButton = signal(false);
|
|
224
|
+
protected readonly dismissed = signal(false);
|
|
225
|
+
protected readonly tooltipVisible = computed(
|
|
226
|
+
() =>
|
|
227
|
+
!this.open() &&
|
|
228
|
+
!this.dismissed() &&
|
|
229
|
+
(this.hoverButton() || this.hoverTooltip() || this.focusButton()),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
protected onTooltipButtonEnter(): void {
|
|
233
|
+
this.hoverButton.set(true);
|
|
234
|
+
this.dismissed.set(false);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
protected onTooltipButtonFocusIn(): void {
|
|
238
|
+
// Keyboard focus only: a mouse click also focuses the button in Chromium, and the tooltip
|
|
239
|
+
// should not stick after a click.
|
|
240
|
+
try {
|
|
241
|
+
this.focusButton.set(
|
|
242
|
+
this.buttonRef().element?.matches(":focus-visible") ?? false,
|
|
243
|
+
);
|
|
244
|
+
} catch {
|
|
245
|
+
this.focusButton.set(true); // engine without :focus-visible — err towards showing
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
protected onTooltipButtonFocusOut(): void {
|
|
250
|
+
this.focusButton.set(false);
|
|
251
|
+
this.dismissed.set(false);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
protected onTooltipKeydown(event: KeyboardEvent): void {
|
|
255
|
+
if (event.key === "Escape" && this.tooltipVisible()) this.dismissed.set(true);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// WCAG 1.4.13 "dismissable": while the tooltip is visible, Escape works wherever focus is. The
|
|
259
|
+
// listener exists only while visible; the effect cleanup removes it on hide and on destroy.
|
|
260
|
+
private readonly tooltipEscapeListener = effect((onCleanup) => {
|
|
261
|
+
if (!this.tooltipVisible() || typeof document === "undefined") return;
|
|
262
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
263
|
+
if (event.key === "Escape") this.dismissed.set(true);
|
|
264
|
+
};
|
|
265
|
+
document.addEventListener("keydown", onKey);
|
|
266
|
+
onCleanup(() => document.removeEventListener("keydown", onKey));
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
protected readonly childContext = computed(() => {
|
|
270
|
+
const args: ChildArgs = { open: this.open() };
|
|
271
|
+
return { $implicit: args, ...args };
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
protected idOf(link: LinkItem): string {
|
|
275
|
+
return linkId(link);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Every focusable link in the list, in DOM order. */
|
|
279
|
+
private items(): HTMLElement[] {
|
|
280
|
+
return Array.from(
|
|
281
|
+
this.listRef().nativeElement.querySelectorAll<HTMLElement>(".link-picker-link"),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------
|
|
286
|
+
// Open / close
|
|
287
|
+
// ---------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/** Open the list, focusing the first link (or the last, if `focusLast`). */
|
|
290
|
+
openList(focusLast = false): void {
|
|
291
|
+
this.open.set(true);
|
|
292
|
+
// Deferred so the `hidden` attribute is gone before focus is moved.
|
|
293
|
+
queueMicrotask(() => {
|
|
294
|
+
const all = this.items();
|
|
295
|
+
(focusLast ? all[all.length - 1] : all[0])?.focus({ preventScroll: true });
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Close the list; `refocus` returns focus to the trigger. */
|
|
300
|
+
closeList(refocus = true): void {
|
|
301
|
+
if (!this.open()) return;
|
|
302
|
+
this.open.set(false);
|
|
303
|
+
if (refocus) queueMicrotask(() => this.buttonRef().focus({ preventScroll: true }));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
protected onButtonClick(): void {
|
|
307
|
+
if (this.open()) this.closeList();
|
|
308
|
+
else this.openList();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
protected onLinkClick(event: MouseEvent, link: LinkItem): void {
|
|
312
|
+
this.navigated.emit({ id: linkId(link), href: link.href });
|
|
313
|
+
const go = this.navigate();
|
|
314
|
+
const modified =
|
|
315
|
+
event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0;
|
|
316
|
+
if (go && !link.newTab && !modified && !event.defaultPrevented) {
|
|
317
|
+
event.preventDefault();
|
|
318
|
+
go(link.href);
|
|
319
|
+
}
|
|
320
|
+
this.closeList();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------
|
|
324
|
+
// Keyboard
|
|
325
|
+
// ---------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
protected onButtonKeydown(event: KeyboardEvent): void {
|
|
328
|
+
// Enter and Space already produce a click; only the arrows need handling here.
|
|
329
|
+
if (event.key === "ArrowDown") {
|
|
330
|
+
event.preventDefault();
|
|
331
|
+
if (!this.open()) this.openList();
|
|
332
|
+
else this.items()[0]?.focus({ preventScroll: true });
|
|
333
|
+
} else if (event.key === "ArrowUp") {
|
|
334
|
+
event.preventDefault();
|
|
335
|
+
if (!this.open()) this.openList(true);
|
|
336
|
+
else {
|
|
337
|
+
const all = this.items();
|
|
338
|
+
all[all.length - 1]?.focus({ preventScroll: true });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private moveFocus(delta: number): void {
|
|
344
|
+
const all = this.items();
|
|
345
|
+
if (all.length === 0) return;
|
|
346
|
+
const i = all.indexOf(document.activeElement as HTMLElement);
|
|
347
|
+
// Clamp rather than wrap.
|
|
348
|
+
const next = Math.min(Math.max((i < 0 ? 0 : i) + delta, 0), all.length - 1);
|
|
349
|
+
all[next]?.focus({ preventScroll: true });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
protected onListKeydown(event: KeyboardEvent): void {
|
|
353
|
+
switch (event.key) {
|
|
354
|
+
case "ArrowDown":
|
|
355
|
+
event.preventDefault();
|
|
356
|
+
this.moveFocus(1);
|
|
357
|
+
break;
|
|
358
|
+
case "ArrowUp":
|
|
359
|
+
event.preventDefault();
|
|
360
|
+
this.moveFocus(-1);
|
|
361
|
+
break;
|
|
362
|
+
case "Home":
|
|
363
|
+
event.preventDefault();
|
|
364
|
+
this.items()[0]?.focus({ preventScroll: true });
|
|
365
|
+
break;
|
|
366
|
+
case "End": {
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
const all = this.items();
|
|
369
|
+
all[all.length - 1]?.focus({ preventScroll: true });
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
case "Escape":
|
|
373
|
+
event.preventDefault();
|
|
374
|
+
this.closeList();
|
|
375
|
+
break;
|
|
376
|
+
case "Tab":
|
|
377
|
+
// Focus goes to the button FIRST, without cancelling the key: hiding the list while a
|
|
378
|
+
// link has focus drops focus to <body>, and the default Tab would restart from the top.
|
|
379
|
+
this.buttonRef().focus?.({ preventScroll: true });
|
|
380
|
+
this.closeList(false);
|
|
381
|
+
break;
|
|
382
|
+
default:
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
protected onRootFocusOut(event: FocusEvent): void {
|
|
388
|
+
const next = event.relatedTarget as Node | null;
|
|
389
|
+
if (next && this.rootRef().nativeElement.contains(next)) return;
|
|
390
|
+
this.closeList(false);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
protected onDocumentClick(event: Event): void {
|
|
394
|
+
if (!this.open()) return;
|
|
395
|
+
const t = event.target as Node | null;
|
|
396
|
+
if (t && !this.rootRef().nativeElement.contains(t)) this.closeList(false);
|
|
397
|
+
}
|
|
398
|
+
}
|
package/ng-package.json
ADDED
package/package.json
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lilydesignsystem/angular-link-picker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": "=26"
|
|
6
6
|
},
|
|
7
7
|
"description": "Lily Design System Angular link picker: an icon button (a home icon) opening a disclosure of app-defined page links. Headless, SSR-safe, no CSS.",
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@lilydesignsystem/angular-headless": "^0.2.0"
|
|
10
|
-
"tslib": "^2.3.0"
|
|
9
|
+
"@lilydesignsystem/angular-headless": "^0.2.0"
|
|
11
10
|
},
|
|
12
11
|
"peerDependencies": {
|
|
13
12
|
"@angular/core": ">=20.0.0 <23.0.0",
|
|
14
13
|
"@angular/common": ">=20.0.0 <23.0.0"
|
|
15
14
|
},
|
|
16
15
|
"sideEffects": false,
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "cd .. && pnpm build",
|
|
18
|
+
"prepublishOnly": "cd .. && pnpm build"
|
|
19
|
+
},
|
|
17
20
|
"keywords": [
|
|
18
21
|
"lily",
|
|
19
22
|
"design",
|
|
@@ -34,17 +37,5 @@
|
|
|
34
37
|
"homepage": "https://lilydesignsystem.com/",
|
|
35
38
|
"bugs": {
|
|
36
39
|
"url": "https://github.com/LilyDesignSystem/lily-design-system/issues"
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
"typings": "types/lilydesignsystem-angular-link-picker.d.ts",
|
|
40
|
-
"exports": {
|
|
41
|
-
"./package.json": {
|
|
42
|
-
"default": "./package.json"
|
|
43
|
-
},
|
|
44
|
-
".": {
|
|
45
|
-
"types": "./types/lilydesignsystem-angular-link-picker.d.ts",
|
|
46
|
-
"default": "./fesm2022/lilydesignsystem-angular-link-picker.mjs"
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
"type": "module"
|
|
50
|
-
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/spec/index.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# LinkPicker (Angular) — spec
|
|
2
|
+
|
|
3
|
+
**Package:** `@lilydesignsystem/angular-link-picker` · **Canonical contract:** the Svelte catalog's spec; this file is its Angular port.
|
|
4
|
+
Topic summary: [spec/link-picker](../../../spec/link-picker/index.md).
|
|
5
|
+
|
|
6
|
+
## 1. Goal
|
|
7
|
+
|
|
8
|
+
An icon button — a typical **home** icon — that opens a dropdown of **page links the app defines** ("Home", "About Us",
|
|
9
|
+
"Contact Us", "Privacy Policy", …). It owns the whole interaction: open/close, focus, keyboard, tooltip. It owns no routes
|
|
10
|
+
and no words: every link, and every accessible name, comes from the consumer.
|
|
11
|
+
|
|
12
|
+
## 2. Non-goals
|
|
13
|
+
|
|
14
|
+
- No default links, no English text, no routing. A client-side router is supplied through `navigate`.
|
|
15
|
+
- Not a site navigation bar and not an ARIA menu: five links behind an icon, in a page header.
|
|
16
|
+
- No persistence, no current-page detection (the app marks `current`).
|
|
17
|
+
|
|
18
|
+
## 3. Architectural decisions
|
|
19
|
+
|
|
20
|
+
- **A disclosure of real links, not a menu or listbox.** The items are navigation, so they are `<a href>`: `role="menuitem"`
|
|
21
|
+
would strip middle-click, open-in-new-tab and copy-link-address, and the WAI-ARIA APG itself suggests a disclosure when the
|
|
22
|
+
items are links. Same decision as `share-picker`.
|
|
23
|
+
- **Only the trigger composes headless** (`IconButton`); the list is self-built for the same reason `share-picker`'s is.
|
|
24
|
+
- **The app defines the links** through the required `links` prop. No default, because a default would be a hardcoded string
|
|
25
|
+
and a hardcoded route.
|
|
26
|
+
- **`navigate` is optional.** Without it links are ordinary page loads; with it (SvelteKit `goto`) a plain left click is a
|
|
27
|
+
client-side navigation and modified clicks stay native.
|
|
28
|
+
|
|
29
|
+
## 4. Public API
|
|
30
|
+
|
|
31
|
+
### 4.1 Props
|
|
32
|
+
|
|
33
|
+
| Prop | Type | Required | Notes |
|
|
34
|
+
| --- | --- | --- | --- |
|
|
35
|
+
| `label` | `input.required<string>()` | yes | Accessible name of the button **and** the list; also the tooltip text. |
|
|
36
|
+
| `links` | `input.required<LinkItem[]>()` | yes | `{ id?, label, href, current?, newTab? }`. `id` defaults to `href`. |
|
|
37
|
+
| `navigate` | `input<(href: string) => void>()` | no | Client-side navigation hook (a router's `navigateByUrl`). |
|
|
38
|
+
| `navigated` | `output<{ id, href }>()` | no | Emitted after a link is chosen (the Svelte `onNavigate` callback). |
|
|
39
|
+
| icon template | `<ng-template lilyLinkPickerIcon let-args>` | no | Replaces the **icon**, not the links. |
|
|
40
|
+
| `className` | `input<string>()` | no | Appended to the root class. |
|
|
41
|
+
|
|
42
|
+
**Differences from the Svelte contract (idiom, not behaviour).** Angular renders the same DOM through the `lily-link-picker` selector; the trigger is the headless `lily-icon-button`, the Svelte `onNavigate` callback is the `navigated` output, and the icon override is a projected `<ng-template>`.
|
|
43
|
+
|
|
44
|
+
### 4.2 DOM contract
|
|
45
|
+
|
|
46
|
+
```html
|
|
47
|
+
<div class="link-picker {class}">
|
|
48
|
+
<button class="link-picker-button" type="button" aria-label="{label}" aria-expanded="false" aria-controls="{id}-list">
|
|
49
|
+
<svg class="link-picker-icon" aria-hidden="true" viewBox="0 0 16 16" …home…/>
|
|
50
|
+
</button>
|
|
51
|
+
<div class="link-picker-tooltip" role="tooltip" id="{id}-tooltip" hidden>{label}</div>
|
|
52
|
+
<ul class="link-picker-list" id="{id}-list" aria-label="{label}" hidden>
|
|
53
|
+
<li class="link-picker-list-item"><a class="link-picker-link" href="/" data-link-id="/">Home</a></li>
|
|
54
|
+
…
|
|
55
|
+
</ul>
|
|
56
|
+
</div>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The current page's link carries `aria-current="page"`. `newTab` links carry `target="_blank" rel="noopener noreferrer"`.
|
|
60
|
+
|
|
61
|
+
### 4.3 Re-exports
|
|
62
|
+
|
|
63
|
+
`LinkPicker`, `LinkPickerIcon`, `linkId`, `nextLinkPickerId`, types `ChildArgs`, `LinkItem`, `LinkNavigatedEvent`.
|
|
64
|
+
|
|
65
|
+
## 5. Behaviour
|
|
66
|
+
|
|
67
|
+
### 5.1 Activation
|
|
68
|
+
|
|
69
|
+
Click toggles the list. Opening focuses the first link; the page does not scroll (`preventScroll`). Choosing a link closes the
|
|
70
|
+
list and returns focus to the button (when the link did not navigate away).
|
|
71
|
+
|
|
72
|
+
### 5.2 Keyboard
|
|
73
|
+
|
|
74
|
+
- Button: `ArrowDown` opens and focuses the first link (or moves into an open list); `ArrowUp` opens and focuses the last;
|
|
75
|
+
`Enter`/`Space` toggle.
|
|
76
|
+
- List: `ArrowDown`/`ArrowUp` move (clamped, no wrap), `Home`/`End` jump, `Escape` closes and returns focus to the button,
|
|
77
|
+
`Tab` closes and moves on.
|
|
78
|
+
- Clicking outside, or focus leaving the picker, closes the list.
|
|
79
|
+
|
|
80
|
+
### 5.3 Tooltip
|
|
81
|
+
|
|
82
|
+
`<div class="link-picker-tooltip" role="tooltip" hidden>` holds the button's `label`. Visible while the pointer is over the
|
|
83
|
+
button or the tooltip (hoverable) or while the button has keyboard focus (`:focus-visible`); `Escape` dismisses it wherever
|
|
84
|
+
focus is; never shown while the list is open. Not linked with `aria-describedby` (it duplicates the name).
|
|
85
|
+
|
|
86
|
+
## 6. Accessibility
|
|
87
|
+
|
|
88
|
+
WCAG 2.2 AAA target. The button has an accessible name and `aria-expanded`/`aria-controls`; the icon is `aria-hidden`; the
|
|
89
|
+
list is named; links are real links with `aria-current="page"` where marked; focus stays visible; no motion.
|
|
90
|
+
|
|
91
|
+
## 7. Testing acceptance criteria
|
|
92
|
+
|
|
93
|
+
The behavioural clauses are the Svelte spec's §7 (open/close, focus, keyboard, `aria-current`, `newTab`, id reporting,
|
|
94
|
+
tooltip, no stylesheet / English / routes). This port's suite, `link-picker.component.spec.ts`, has 24 tests, one per clause
|
|
95
|
+
(§7.1–§7.24), in the same order; where the idiom changes a clause the test name says how. Deviations are listed in §4.1.
|
|
96
|
+
|
|
97
|
+
## 8. Tracking
|
|
98
|
+
|
|
99
|
+
- Package: `@lilydesignsystem/angular-link-picker`
|
|
100
|
+
- Version: 0.1.0 (new, 2026-10-07)
|
|
101
|
+
- Framework: Angular `>=20 <23` (signals, standalone, OnPush)
|
|
102
|
+
- 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
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"lilydesignsystem-angular-link-picker.mjs","names":[],"sources":["../../link-picker.component.ts"],"sourcesContent":["import { NgTemplateOutlet } from \"@angular/common\";\nimport {\n ChangeDetectionStrategy,\n Component,\n Directive,\n ElementRef,\n TemplateRef,\n computed,\n contentChild,\n effect,\n input,\n output,\n signal,\n viewChild,\n} from \"@angular/core\";\n// Only the trigger button composes a headless primitive. The list below is real `<a>` navigation\n// with a roving-focus pattern of its own — not an ARIA listbox or menu: role=\"menuitem\" would strip\n// middle-click, open-in-new-tab and copy-link-address from ordinary links. See spec/index.md §3.\nimport { IconButton } from \"@lilydesignsystem/angular-headless\";\n\n/**\n * One destination in the list. The app defines them all: this package ships no routes and no\n * English — `label` is the consumer's text.\n */\nexport type LinkItem = {\n /** Stable identifier, passed back on the `navigated` output. Defaults to `href`. */\n id?: string;\n /** Visible link text. Consumer-supplied, so it localises. */\n label: string;\n /** Where the link goes: a route (\"/about/\") or a full URL. */\n href: string;\n /** Marks this link as the current page (`aria-current=\"page\"`). */\n current?: boolean;\n /** Open in a new tab (`target=\"_blank\"` with `rel=\"noopener noreferrer\"`). */\n newTab?: boolean;\n};\n\n/** Context passed to a custom icon `<ng-template>` (the button icon). */\nexport type ChildArgs = {\n /** Is the list open? */\n open: boolean;\n};\n\n/** Payload of the `navigated` output. */\nexport type LinkNavigatedEvent = {\n /** The chosen link's `id` (its `href` when it has none). */\n id: string;\n /** The chosen link's `href`. */\n href: string;\n};\n\n/** The id a link reports: its explicit `id`, else its `href`. */\nexport function linkId(link: LinkItem): string {\n return link.id ?? link.href;\n}\n\nlet uid = 0;\n/** Stable per-instance id prefix; SSR-safe (no Math.random / Date.now). */\nexport function nextLinkPickerId(): string {\n uid += 1;\n return `link-picker-${uid}`;\n}\n\n/**\n * Optional marker for the projected icon template. Gives consumers typed `let-` variables:\n *\n * ```html\n * <lily-link-picker label=\"Pages\" [links]=\"links\">\n * <ng-template lilyLinkPickerIcon let-args>{{ args.open ? \"×\" : \"☰\" }}</ng-template>\n * </lily-link-picker>\n * ```\n */\n@Directive({\n selector: \"ng-template[lilyLinkPickerIcon]\",\n standalone: true,\n})\nexport class LinkPickerIcon {\n static ngTemplateContextGuard(\n _dir: LinkPickerIcon,\n _ctx: unknown,\n ): _ctx is ChildArgs & { $implicit: ChildArgs } {\n return true;\n }\n}\n\n/**\n * LinkPicker — a headless page-links control.\n *\n * A single-icon button (a bundled home SVG) that opens a disclosure list of page links the app\n * defines. It applies nothing to the document and persists nothing. See `spec/index.md`.\n */\n@Component({\n selector: \"lily-link-picker\",\n standalone: true,\n imports: [NgTemplateOutlet, IconButton],\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: {\n \"(document:click)\": \"onDocumentClick($event)\",\n },\n template: `\n <div\n #rootEl\n class=\"link-picker {{ className() }}\"\n (focusout)=\"onRootFocusOut($event)\"\n >\n <lily-icon-button\n #buttonEl\n [label]=\"label()\"\n baseClass=\"link-picker-button\"\n [ariaExpanded]=\"open()\"\n [ariaControls]=\"listId\"\n (click)=\"onButtonClick(); hoverButton.set(false)\"\n (mouseenter)=\"onTooltipButtonEnter()\"\n (mouseleave)=\"hoverButton.set(false)\"\n (focusin)=\"onTooltipButtonFocusIn()\"\n (focusout)=\"onTooltipButtonFocusOut()\"\n (keydown)=\"onButtonKeydown($event); onTooltipKeydown($event)\"\n >\n @if (iconTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"childContext()\"\n />\n } @else {\n <svg\n class=\"link-picker-icon\"\n viewBox=\"0 0 16 16\"\n width=\"1.05rem\"\n height=\"1.05rem\"\n aria-hidden=\"true\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.6\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <path d=\"M2 7.5 8 2.5l6 5\" />\n <path d=\"M3.5 6.5v7h9v-7\" />\n <path d=\"M6.5 13.5V10h3v3.5\" />\n </svg>\n }\n </lily-icon-button>\n\n <!-- Purely visual: the same text is already the button's aria-label, so it is not wired\n with aria-describedby (that would announce the name twice). -->\n <div\n class=\"link-picker-tooltip\"\n role=\"tooltip\"\n [id]=\"tooltipId\"\n [attr.hidden]=\"tooltipVisible() ? null : ''\"\n (mouseenter)=\"hoverTooltip.set(true)\"\n (mouseleave)=\"hoverTooltip.set(false)\"\n >{{ label() }}</div>\n\n <!-- Named like the sibling pickers' popups: a screen reader entering the list hears what\n it is for. -->\n <ul\n #listEl\n class=\"link-picker-list\"\n [id]=\"listId\"\n [attr.aria-label]=\"label() || null\"\n [attr.hidden]=\"open() ? null : ''\"\n (keydown)=\"onListKeydown($event)\"\n >\n @for (link of links(); track idOf(link)) {\n <li class=\"link-picker-list-item\">\n <!-- A real link, not role=\"menuitem\": these ARE navigation. -->\n <a\n class=\"link-picker-link\"\n [attr.data-link-id]=\"idOf(link)\"\n [attr.href]=\"link.href\"\n [attr.aria-current]=\"link.current ? 'page' : null\"\n [attr.target]=\"link.newTab ? '_blank' : null\"\n [attr.rel]=\"link.newTab ? 'noopener noreferrer' : null\"\n (click)=\"onLinkClick($event, link)\"\n >{{ link.label }}</a\n >\n </li>\n }\n </ul>\n </div>\n `,\n})\nexport class LinkPicker {\n /** Accessible name for the button and the list. */\n readonly label = input.required<string>();\n /** The page links to offer. Defined by the app. */\n readonly links = input.required<LinkItem[]>();\n /**\n * Client-side navigation hook, e.g. a router's `navigateByUrl`. When given, a plain left click\n * on a link calls `navigate(href)` instead of letting the browser load the page; modified\n * clicks (Ctrl/Cmd/Shift/Alt, middle button) and `newTab` links stay native.\n */\n readonly navigate = input<((href: string) => void) | undefined>(undefined);\n /** Extra CSS class on the root div. */\n readonly className = input<string>(\"\");\n\n /** Fires after a link is chosen. */\n readonly navigated = output<LinkNavigatedEvent>();\n\n /** Projected icon template; replaces the default icon when supplied. */\n protected readonly iconTemplate = contentChild(TemplateRef);\n\n private readonly rootRef =\n viewChild.required<ElementRef<HTMLDivElement>>(\"rootEl\");\n // A template-ref-variable on a component tag resolves to the component INSTANCE, which is what\n // is needed to call the headless component's own public `focus()` method.\n private readonly buttonRef = viewChild.required<IconButton>(\"buttonEl\");\n private readonly listRef =\n viewChild.required<ElementRef<HTMLUListElement>>(\"listEl\");\n\n private readonly baseId = nextLinkPickerId();\n protected readonly listId = `${this.baseId}-list`;\n\n protected readonly open = signal(false);\n\n // Tooltip: shown while the pointer is over the button or the tooltip itself (hoverable, WCAG\n // 1.4.13) or while the button has keyboard focus; Escape dismisses it without moving focus;\n // never shown while the popup is open, since the popup then explains the control.\n protected readonly tooltipId = `${this.baseId}-tooltip`;\n protected readonly hoverButton = signal(false);\n protected readonly hoverTooltip = signal(false);\n protected readonly focusButton = signal(false);\n protected readonly dismissed = signal(false);\n protected readonly tooltipVisible = computed(\n () =>\n !this.open() &&\n !this.dismissed() &&\n (this.hoverButton() || this.hoverTooltip() || this.focusButton()),\n );\n\n protected onTooltipButtonEnter(): void {\n this.hoverButton.set(true);\n this.dismissed.set(false);\n }\n\n protected onTooltipButtonFocusIn(): void {\n // Keyboard focus only: a mouse click also focuses the button in Chromium, and the tooltip\n // should not stick after a click.\n try {\n this.focusButton.set(\n this.buttonRef().element?.matches(\":focus-visible\") ?? false,\n );\n } catch {\n this.focusButton.set(true); // engine without :focus-visible — err towards showing\n }\n }\n\n protected onTooltipButtonFocusOut(): void {\n this.focusButton.set(false);\n this.dismissed.set(false);\n }\n\n protected onTooltipKeydown(event: KeyboardEvent): void {\n if (event.key === \"Escape\" && this.tooltipVisible()) this.dismissed.set(true);\n }\n\n // WCAG 1.4.13 \"dismissable\": while the tooltip is visible, Escape works wherever focus is. The\n // listener exists only while visible; the effect cleanup removes it on hide and on destroy.\n private readonly tooltipEscapeListener = effect((onCleanup) => {\n if (!this.tooltipVisible() || typeof document === \"undefined\") return;\n const onKey = (event: KeyboardEvent): void => {\n if (event.key === \"Escape\") this.dismissed.set(true);\n };\n document.addEventListener(\"keydown\", onKey);\n onCleanup(() => document.removeEventListener(\"keydown\", onKey));\n });\n\n protected readonly childContext = computed(() => {\n const args: ChildArgs = { open: this.open() };\n return { $implicit: args, ...args };\n });\n\n protected idOf(link: LinkItem): string {\n return linkId(link);\n }\n\n /** Every focusable link in the list, in DOM order. */\n private items(): HTMLElement[] {\n return Array.from(\n this.listRef().nativeElement.querySelectorAll<HTMLElement>(\".link-picker-link\"),\n );\n }\n\n // ---------------------------------------------------------------\n // Open / close\n // ---------------------------------------------------------------\n\n /** Open the list, focusing the first link (or the last, if `focusLast`). */\n openList(focusLast = false): void {\n this.open.set(true);\n // Deferred so the `hidden` attribute is gone before focus is moved.\n queueMicrotask(() => {\n const all = this.items();\n (focusLast ? all[all.length - 1] : all[0])?.focus({ preventScroll: true });\n });\n }\n\n /** Close the list; `refocus` returns focus to the trigger. */\n closeList(refocus = true): void {\n if (!this.open()) return;\n this.open.set(false);\n if (refocus) queueMicrotask(() => this.buttonRef().focus({ preventScroll: true }));\n }\n\n protected onButtonClick(): void {\n if (this.open()) this.closeList();\n else this.openList();\n }\n\n protected onLinkClick(event: MouseEvent, link: LinkItem): void {\n this.navigated.emit({ id: linkId(link), href: link.href });\n const go = this.navigate();\n const modified =\n event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0;\n if (go && !link.newTab && !modified && !event.defaultPrevented) {\n event.preventDefault();\n go(link.href);\n }\n this.closeList();\n }\n\n // ---------------------------------------------------------------\n // Keyboard\n // ---------------------------------------------------------------\n\n protected onButtonKeydown(event: KeyboardEvent): void {\n // Enter and Space already produce a click; only the arrows need handling here.\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n if (!this.open()) this.openList();\n else this.items()[0]?.focus({ preventScroll: true });\n } else if (event.key === \"ArrowUp\") {\n event.preventDefault();\n if (!this.open()) this.openList(true);\n else {\n const all = this.items();\n all[all.length - 1]?.focus({ preventScroll: true });\n }\n }\n }\n\n private moveFocus(delta: number): void {\n const all = this.items();\n if (all.length === 0) return;\n const i = all.indexOf(document.activeElement as HTMLElement);\n // Clamp rather than wrap.\n const next = Math.min(Math.max((i < 0 ? 0 : i) + delta, 0), all.length - 1);\n all[next]?.focus({ preventScroll: true });\n }\n\n protected onListKeydown(event: KeyboardEvent): void {\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n this.moveFocus(1);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n this.moveFocus(-1);\n break;\n case \"Home\":\n event.preventDefault();\n this.items()[0]?.focus({ preventScroll: true });\n break;\n case \"End\": {\n event.preventDefault();\n const all = this.items();\n all[all.length - 1]?.focus({ preventScroll: true });\n break;\n }\n case \"Escape\":\n event.preventDefault();\n this.closeList();\n break;\n case \"Tab\":\n // Focus goes to the button FIRST, without cancelling the key: hiding the list while a\n // link has focus drops focus to <body>, and the default Tab would restart from the top.\n this.buttonRef().focus?.({ preventScroll: true });\n this.closeList(false);\n break;\n default:\n break;\n }\n }\n\n protected onRootFocusOut(event: FocusEvent): void {\n const next = event.relatedTarget as Node | null;\n if (next && this.rootRef().nativeElement.contains(next)) return;\n this.closeList(false);\n }\n\n protected onDocumentClick(event: Event): void {\n if (!this.open()) return;\n const t = event.target as Node | null;\n if (t && !this.rootRef().nativeElement.contains(t)) this.closeList(false);\n }\n}\n"],"mappings":"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"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|