@lilydesignsystem/angular-link-picker 0.1.1 → 0.2.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.
@@ -1,398 +0,0 @@
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 DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "$schema": "../node_modules/ng-packagr/ng-package.schema.json",
3
- "dest": "./dist",
4
- "lib": {
5
- "entryFile": "index.ts"
6
- },
7
- "allowedNonPeerDependencies": [
8
- "@lilydesignsystem/angular-headless"
9
- ]
10
- }
package/spec/index.md DELETED
@@ -1,102 +0,0 @@
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
package/tsconfig.lib.json DELETED
@@ -1,13 +0,0 @@
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
- }
File without changes