@lilydesignsystem/angular-search-picker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # SearchPicker (Angular helper)
2
+
3
+ A headless Angular 20 site-search control: a single-icon button (a
4
+ bundled magnifying-glass SVG) that opens a dropdown holding a search
5
+ field and, at its right, a submit button labelled `⏎`. Pressing Return
6
+ in the field, or the `⏎` button, navigates to `/?<query>` — a search for
7
+ `foo` goes to `/?foo`.
8
+
9
+ The single source of truth is [spec/index.md](./spec/index.md). This
10
+ file is the human-readable guide.
11
+
12
+ ## Install
13
+
14
+ ```ts
15
+ import { SearchPicker, searchHref } from "@lilydesignsystem/angular-search-picker";
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ ```ts
21
+ import { Component } from "@angular/core";
22
+ import { SearchPicker } from "@lilydesignsystem/angular-search-picker";
23
+
24
+ @Component({
25
+ selector: "app-header",
26
+ standalone: true,
27
+ imports: [SearchPicker],
28
+ template: `
29
+ <lily-search-picker
30
+ label="Search this site"
31
+ inputLabel="Search terms"
32
+ submitLabel="Search"
33
+ />
34
+ `,
35
+ })
36
+ export class AppHeader {}
37
+ ```
38
+
39
+ That is the whole wiring: a search for `foo` performs a GET to `/?foo`.
40
+
41
+ ## Where the search goes
42
+
43
+ The destination is `searchHref(query, action)`:
44
+
45
+ | You type | `action` | Destination |
46
+ | ------------ | ----------- | ------------- |
47
+ | `foo` | `"/"` | `/?foo` |
48
+ | ` foo bar ` | `"/"` | `/?foo%20bar` |
49
+ | `a&b` | `"/"` | `/?a%26b` |
50
+ | `foo` | `"/search"` | `/search?foo` |
51
+
52
+ The query is trimmed and URI-encoded, so spaces and `&` cannot split or
53
+ corrupt it. An empty query goes nowhere.
54
+
55
+ The bare query (`/?foo`, not `/?q=foo`) is why the component navigates
56
+ in script: a native GET form always sends `name=value` pairs, so it
57
+ cancels the native submission and navigates to the exact URL itself.
58
+
59
+ ## Client-side routing
60
+
61
+ By default the component calls `location.assign(href)` — a real GET
62
+ request. In a single-page app, pass a function that hands the URL to
63
+ your router:
64
+
65
+ ```ts
66
+ import { Component, inject } from "@angular/core";
67
+ import { Router } from "@angular/router";
68
+ import { SearchPicker } from "@lilydesignsystem/angular-search-picker";
69
+
70
+ @Component({
71
+ selector: "app-header",
72
+ standalone: true,
73
+ imports: [SearchPicker],
74
+ template: `
75
+ <lily-search-picker
76
+ label="Search this site"
77
+ inputLabel="Search terms"
78
+ submitLabel="Search"
79
+ [navigate]="navigate"
80
+ (searched)="record($event.query)"
81
+ />
82
+ `,
83
+ })
84
+ export class AppHeader {
85
+ private readonly router = inject(Router);
86
+ readonly navigate = (href: string) => this.router.navigateByUrl(href);
87
+ record(query: string): void {
88
+ console.log("searched for", query);
89
+ }
90
+ }
91
+ ```
92
+
93
+ `(searched)` emits `{ query, href }` before navigating, for analytics or
94
+ to record the query. It is named `searched`, not `search`, because
95
+ `<input type="search">` fires a native bubbling `search` DOM event that
96
+ Angular would also deliver to a `(search)` binding.
97
+
98
+ ## Inputs and outputs
99
+
100
+ Full table in [spec/index.md §4.1](./spec/index.md#41-inputs--outputs).
101
+ Required: `label`, `inputLabel`, `submitLabel` — no English defaults,
102
+ because every user-facing string is yours to localise. Optional:
103
+ `placeholder`, `value` (two-way, `[(value)]`), `action`, `navigate`,
104
+ `className`. Output: `searched`. A projected `<ng-template>` (optionally
105
+ marked `lilySearchPickerIcon`) replaces the icon.
106
+
107
+ ## Accessibility
108
+
109
+ - The icon is `aria-hidden`; the button's name comes from `label`.
110
+ - The dropdown is a real `<form role="search">` — a search landmark
111
+ named by `label` — with a real `type="search"` field and
112
+ `type="submit"` button, so Return-to-submit and mobile search
113
+ keyboards just work.
114
+ - `⏎` is the visible label only: it is `aria-hidden`, and the submit
115
+ button's name is `submitLabel`.
116
+ - Opening focuses the field; `Escape` closes and returns focus to the
117
+ button; clicking outside or tabbing away closes.
118
+ - See [docs/accessibility.md](./docs/accessibility.md) for the tradeoffs.
119
+
120
+ ## Styling
121
+
122
+ Class hooks: `.search-picker` (root), `.search-picker-button`,
123
+ `.search-picker-icon`, `.search-picker-panel`, `.search-picker-form`,
124
+ `.search-picker-input`, `.search-picker-submit`,
125
+ `.search-picker-submit-symbol`, `.search-picker-tooltip`.
126
+
127
+ The package ships no CSS beyond the icon markup. The root `themes/`
128
+ stylesheets position the panel and lay the field and `⏎` button out in
129
+ a row.
130
+
131
+ ## Tests
132
+
133
+ `npx vitest run lily-design-system-angular-search-picker` from the
134
+ catalog root — 24 cases, one per §7 clause.
135
+
136
+ ---
137
+
138
+ Lily™ and Lily Design System™ are trademarks.
@@ -0,0 +1,609 @@
1
+ import { NgTemplateOutlet } from "@angular/common";
2
+ import * as i0 from "@angular/core";
3
+ import { ChangeDetectionStrategy, Component, Directive, TemplateRef, computed, contentChild, effect, input, model, output, signal, viewChild } from "@angular/core";
4
+ import { IconButton } from "@lilydesignsystem/angular-headless";
5
+ const RETURN_SYMBOL = "⏎";
6
+ function searchHref(query, action = "/") {
7
+ return `${action}?${encodeURIComponent(query.trim())}`;
8
+ }
9
+ let uid = 0;
10
+ function nextSearchPickerId() {
11
+ uid += 1;
12
+ return `search-picker-${uid}`;
13
+ }
14
+ var SearchPickerIcon = class SearchPickerIcon {
15
+ static ngTemplateContextGuard(_dir, _ctx) {
16
+ return true;
17
+ }
18
+ static {
19
+ this.ɵfac = i0.ɵɵngDeclareFactory({
20
+ minVersion: "12.0.0",
21
+ version: "22.2.1",
22
+ ngImport: i0,
23
+ type: SearchPickerIcon,
24
+ deps: [],
25
+ target: i0.ɵɵFactoryTarget.Directive
26
+ });
27
+ }
28
+ static {
29
+ this.ɵdir = i0.ɵɵngDeclareDirective({
30
+ minVersion: "14.0.0",
31
+ version: "22.2.1",
32
+ type: SearchPickerIcon,
33
+ isStandalone: true,
34
+ selector: "ng-template[lilySearchPickerIcon]",
35
+ ngImport: i0
36
+ });
37
+ }
38
+ };
39
+ i0.ɵɵngDeclareClassMetadata({
40
+ minVersion: "12.0.0",
41
+ version: "22.2.1",
42
+ ngImport: i0,
43
+ type: SearchPickerIcon,
44
+ decorators: [{
45
+ type: Directive,
46
+ args: [{
47
+ selector: "ng-template[lilySearchPickerIcon]",
48
+ standalone: true
49
+ }]
50
+ }]
51
+ });
52
+ var SearchPicker = class SearchPicker {
53
+ constructor() {
54
+ this.label = input.required(...ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []);
55
+ this.inputLabel = input.required(...ngDevMode ? [{ debugName: "inputLabel" }] : /* istanbul ignore next */ []);
56
+ this.submitLabel = input.required(...ngDevMode ? [{ debugName: "submitLabel" }] : /* istanbul ignore next */ []);
57
+ this.placeholder = input(void 0, ...ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []);
58
+ this.value = model("", ...ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []);
59
+ this.action = input("/", ...ngDevMode ? [{ debugName: "action" }] : /* istanbul ignore next */ []);
60
+ this.navigate = input(void 0, ...ngDevMode ? [{ debugName: "navigate" }] : /* istanbul ignore next */ []);
61
+ this.className = input("", ...ngDevMode ? [{ debugName: "className" }] : /* istanbul ignore next */ []);
62
+ this.searched = output();
63
+ this.iconTemplate = contentChild(TemplateRef, ...ngDevMode ? [{ debugName: "iconTemplate" }] : /* istanbul ignore next */ []);
64
+ this.rootRef = viewChild.required("rootEl", ...ngDevMode ? [{ debugName: "rootRef" }] : /* istanbul ignore next */ []);
65
+ this.buttonRef = viewChild.required("buttonEl", ...ngDevMode ? [{ debugName: "buttonRef" }] : /* istanbul ignore next */ []);
66
+ this.panelRef = viewChild.required("panelEl", ...ngDevMode ? [{ debugName: "panelRef" }] : /* istanbul ignore next */ []);
67
+ this.inputRef = viewChild.required("inputEl", ...ngDevMode ? [{ debugName: "inputRef" }] : /* istanbul ignore next */ []);
68
+ this.baseId = nextSearchPickerId();
69
+ this.panelId = `${this.baseId}-panel`;
70
+ this.returnSymbol = "⏎";
71
+ this.open = signal(false, ...ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []);
72
+ this.tooltipId = `${this.baseId}-tooltip`;
73
+ this.hoverButton = signal(false, ...ngDevMode ? [{ debugName: "hoverButton" }] : /* istanbul ignore next */ []);
74
+ this.hoverTooltip = signal(false, ...ngDevMode ? [{ debugName: "hoverTooltip" }] : /* istanbul ignore next */ []);
75
+ this.focusButton = signal(false, ...ngDevMode ? [{ debugName: "focusButton" }] : /* istanbul ignore next */ []);
76
+ this.dismissed = signal(false, ...ngDevMode ? [{ debugName: "dismissed" }] : /* istanbul ignore next */ []);
77
+ this.tooltipVisible = computed(() => !this.open() && !this.dismissed() && (this.hoverButton() || this.hoverTooltip() || this.focusButton()), ...ngDevMode ? [{ debugName: "tooltipVisible" }] : /* istanbul ignore next */ []);
78
+ this.tooltipEscapeListener = effect((onCleanup) => {
79
+ if (!this.tooltipVisible() || typeof document === "undefined") return;
80
+ const onKey = (event) => {
81
+ if (event.key === "Escape") this.dismissed.set(true);
82
+ };
83
+ document.addEventListener("keydown", onKey);
84
+ onCleanup(() => document.removeEventListener("keydown", onKey));
85
+ }, ...ngDevMode ? [{ debugName: "tooltipEscapeListener" }] : /* istanbul ignore next */ []);
86
+ this.childContext = computed(() => {
87
+ const args = {
88
+ open: this.open(),
89
+ query: this.value()
90
+ };
91
+ return {
92
+ $implicit: args,
93
+ ...args
94
+ };
95
+ }, ...ngDevMode ? [{ debugName: "childContext" }] : /* istanbul ignore next */ []);
96
+ }
97
+ onTooltipButtonEnter() {
98
+ this.hoverButton.set(true);
99
+ this.dismissed.set(false);
100
+ }
101
+ onTooltipButtonFocusIn() {
102
+ try {
103
+ this.focusButton.set(this.buttonRef().element?.matches(":focus-visible") ?? false);
104
+ } catch {
105
+ this.focusButton.set(true);
106
+ }
107
+ }
108
+ onTooltipButtonFocusOut() {
109
+ this.focusButton.set(false);
110
+ this.dismissed.set(false);
111
+ }
112
+ onTooltipKeydown(event) {
113
+ if (event.key === "Escape" && this.tooltipVisible()) this.dismissed.set(true);
114
+ }
115
+ openPanel() {
116
+ this.open.set(true);
117
+ this.panelRef().nativeElement.removeAttribute("hidden");
118
+ this.inputRef().nativeElement.focus({ preventScroll: true });
119
+ }
120
+ closePanel(refocus = true) {
121
+ if (!this.open()) return;
122
+ if (refocus) this.buttonRef().focus({ preventScroll: true });
123
+ this.open.set(false);
124
+ }
125
+ onButtonClick() {
126
+ if (this.open()) this.closePanel();
127
+ else this.openPanel();
128
+ }
129
+ onPanelKeydown(event) {
130
+ if (event.key === "Escape") {
131
+ event.preventDefault();
132
+ this.closePanel();
133
+ }
134
+ }
135
+ onRootFocusOut(event) {
136
+ const next = event.relatedTarget;
137
+ if (!next || this.rootRef().nativeElement.contains(next)) return;
138
+ this.closePanel(false);
139
+ }
140
+ onDocumentClick(event) {
141
+ if (!this.open()) return;
142
+ const t = event.target;
143
+ if (t && !this.rootRef().nativeElement.contains(t)) this.closePanel(false);
144
+ }
145
+ onSubmit(event) {
146
+ event.preventDefault();
147
+ const query = this.value().trim();
148
+ if (!query) return;
149
+ const href = searchHref(query, this.action());
150
+ this.searched.emit({
151
+ query,
152
+ href
153
+ });
154
+ this.closePanel(false);
155
+ const navigate = this.navigate();
156
+ if (navigate) navigate(href);
157
+ else if (typeof location !== "undefined") location.assign(href);
158
+ }
159
+ static {
160
+ this.ɵfac = i0.ɵɵngDeclareFactory({
161
+ minVersion: "12.0.0",
162
+ version: "22.2.1",
163
+ ngImport: i0,
164
+ type: SearchPicker,
165
+ deps: [],
166
+ target: i0.ɵɵFactoryTarget.Component
167
+ });
168
+ }
169
+ static {
170
+ this.ɵcmp = i0.ɵɵngDeclareComponent({
171
+ minVersion: "17.0.0",
172
+ version: "22.2.1",
173
+ type: SearchPicker,
174
+ isStandalone: true,
175
+ selector: "lily-search-picker",
176
+ inputs: {
177
+ label: {
178
+ classPropertyName: "label",
179
+ publicName: "label",
180
+ isSignal: true,
181
+ isRequired: true,
182
+ transformFunction: null
183
+ },
184
+ inputLabel: {
185
+ classPropertyName: "inputLabel",
186
+ publicName: "inputLabel",
187
+ isSignal: true,
188
+ isRequired: true,
189
+ transformFunction: null
190
+ },
191
+ submitLabel: {
192
+ classPropertyName: "submitLabel",
193
+ publicName: "submitLabel",
194
+ isSignal: true,
195
+ isRequired: true,
196
+ transformFunction: null
197
+ },
198
+ placeholder: {
199
+ classPropertyName: "placeholder",
200
+ publicName: "placeholder",
201
+ isSignal: true,
202
+ isRequired: false,
203
+ transformFunction: null
204
+ },
205
+ value: {
206
+ classPropertyName: "value",
207
+ publicName: "value",
208
+ isSignal: true,
209
+ isRequired: false,
210
+ transformFunction: null
211
+ },
212
+ action: {
213
+ classPropertyName: "action",
214
+ publicName: "action",
215
+ isSignal: true,
216
+ isRequired: false,
217
+ transformFunction: null
218
+ },
219
+ navigate: {
220
+ classPropertyName: "navigate",
221
+ publicName: "navigate",
222
+ isSignal: true,
223
+ isRequired: false,
224
+ transformFunction: null
225
+ },
226
+ className: {
227
+ classPropertyName: "className",
228
+ publicName: "className",
229
+ isSignal: true,
230
+ isRequired: false,
231
+ transformFunction: null
232
+ }
233
+ },
234
+ outputs: {
235
+ value: "valueChange",
236
+ searched: "searched"
237
+ },
238
+ host: { listeners: { "document:click": "onDocumentClick($event)" } },
239
+ queries: [{
240
+ propertyName: "iconTemplate",
241
+ first: true,
242
+ predicate: TemplateRef,
243
+ descendants: true,
244
+ isSignal: true
245
+ }],
246
+ viewQueries: [
247
+ {
248
+ propertyName: "rootRef",
249
+ first: true,
250
+ predicate: ["rootEl"],
251
+ descendants: true,
252
+ isSignal: true
253
+ },
254
+ {
255
+ propertyName: "buttonRef",
256
+ first: true,
257
+ predicate: ["buttonEl"],
258
+ descendants: true,
259
+ isSignal: true
260
+ },
261
+ {
262
+ propertyName: "panelRef",
263
+ first: true,
264
+ predicate: ["panelEl"],
265
+ descendants: true,
266
+ isSignal: true
267
+ },
268
+ {
269
+ propertyName: "inputRef",
270
+ first: true,
271
+ predicate: ["inputEl"],
272
+ descendants: true,
273
+ isSignal: true
274
+ }
275
+ ],
276
+ ngImport: i0,
277
+ template: `
278
+ <div
279
+ #rootEl
280
+ class="search-picker {{ className() }}"
281
+ (focusout)="onRootFocusOut($event)"
282
+ >
283
+ <lily-icon-button
284
+ #buttonEl
285
+ [label]="label()"
286
+ baseClass="search-picker-button"
287
+ [ariaExpanded]="open()"
288
+ [ariaControls]="panelId"
289
+ (click)="onButtonClick(); hoverButton.set(false)"
290
+ (mouseenter)="onTooltipButtonEnter()"
291
+ (mouseleave)="hoverButton.set(false)"
292
+ (focusin)="onTooltipButtonFocusIn()"
293
+ (focusout)="onTooltipButtonFocusOut()"
294
+ (keydown)="onTooltipKeydown($event)"
295
+ >
296
+ @if (iconTemplate(); as tpl) {
297
+ <ng-container
298
+ [ngTemplateOutlet]="tpl"
299
+ [ngTemplateOutletContext]="childContext()"
300
+ />
301
+ } @else {
302
+ <svg
303
+ class="search-picker-icon"
304
+ viewBox="0 0 16 16"
305
+ width="1.05rem"
306
+ height="1.05rem"
307
+ aria-hidden="true"
308
+ fill="none"
309
+ stroke="currentColor"
310
+ stroke-width="1.6"
311
+ stroke-linecap="round"
312
+ stroke-linejoin="round"
313
+ >
314
+ <circle cx="7" cy="7" r="4.5" />
315
+ <path d="M10.5 10.5 14 14" />
316
+ </svg>
317
+ }
318
+ </lily-icon-button>
319
+
320
+ <!-- Purely visual: the same text is already the button's aria-label,
321
+ so it is not wired with aria-describedby (that would announce
322
+ the name twice). -->
323
+ <div
324
+ class="search-picker-tooltip"
325
+ role="tooltip"
326
+ [id]="tooltipId"
327
+ [attr.hidden]="tooltipVisible() ? null : ''"
328
+ (mouseenter)="hoverTooltip.set(true)"
329
+ (mouseleave)="hoverTooltip.set(false)"
330
+ >{{ label() }}</div>
331
+
332
+ <!-- The keydown handler only listens for Escape bubbling up from the
333
+ field and the submit button inside; the panel itself takes no
334
+ focus. -->
335
+ <div
336
+ #panelEl
337
+ class="search-picker-panel"
338
+ [id]="panelId"
339
+ [attr.hidden]="open() ? null : ''"
340
+ (keydown)="onPanelKeydown($event)"
341
+ >
342
+ <form
343
+ class="search-picker-form"
344
+ role="search"
345
+ [attr.aria-label]="label()"
346
+ [attr.action]="action()"
347
+ method="get"
348
+ (submit)="onSubmit($event)"
349
+ >
350
+ <input
351
+ #inputEl
352
+ class="search-picker-input"
353
+ type="search"
354
+ [attr.aria-label]="inputLabel()"
355
+ [attr.placeholder]="placeholder() ?? null"
356
+ enterkeyhint="search"
357
+ [value]="value()"
358
+ (input)="value.set($any($event.target).value)"
359
+ />
360
+ <button
361
+ type="submit"
362
+ class="search-picker-submit"
363
+ [attr.aria-label]="submitLabel()"
364
+ >
365
+ <span class="search-picker-submit-symbol" aria-hidden="true">{{
366
+ returnSymbol
367
+ }}</span>
368
+ </button>
369
+ </form>
370
+ </div>
371
+ </div>
372
+ `,
373
+ isInline: true,
374
+ dependencies: [{
375
+ kind: "directive",
376
+ type: NgTemplateOutlet,
377
+ selector: "[ngTemplateOutlet]",
378
+ inputs: [
379
+ "ngTemplateOutletContext",
380
+ "ngTemplateOutlet",
381
+ "ngTemplateOutletInjector"
382
+ ]
383
+ }, {
384
+ kind: "component",
385
+ type: IconButton,
386
+ selector: "lily-icon-button",
387
+ inputs: [
388
+ "label",
389
+ "className",
390
+ "baseClass",
391
+ "ariaHaspopup",
392
+ "ariaExpanded",
393
+ "ariaControls",
394
+ "tabIndex",
395
+ "disabled"
396
+ ]
397
+ }],
398
+ changeDetection: i0.ChangeDetectionStrategy.OnPush
399
+ });
400
+ }
401
+ };
402
+ i0.ɵɵngDeclareClassMetadata({
403
+ minVersion: "12.0.0",
404
+ version: "22.2.1",
405
+ ngImport: i0,
406
+ type: SearchPicker,
407
+ decorators: [{
408
+ type: Component,
409
+ args: [{
410
+ selector: "lily-search-picker",
411
+ standalone: true,
412
+ imports: [NgTemplateOutlet, IconButton],
413
+ changeDetection: ChangeDetectionStrategy.OnPush,
414
+ host: { "(document:click)": "onDocumentClick($event)" },
415
+ template: `
416
+ <div
417
+ #rootEl
418
+ class="search-picker {{ className() }}"
419
+ (focusout)="onRootFocusOut($event)"
420
+ >
421
+ <lily-icon-button
422
+ #buttonEl
423
+ [label]="label()"
424
+ baseClass="search-picker-button"
425
+ [ariaExpanded]="open()"
426
+ [ariaControls]="panelId"
427
+ (click)="onButtonClick(); hoverButton.set(false)"
428
+ (mouseenter)="onTooltipButtonEnter()"
429
+ (mouseleave)="hoverButton.set(false)"
430
+ (focusin)="onTooltipButtonFocusIn()"
431
+ (focusout)="onTooltipButtonFocusOut()"
432
+ (keydown)="onTooltipKeydown($event)"
433
+ >
434
+ @if (iconTemplate(); as tpl) {
435
+ <ng-container
436
+ [ngTemplateOutlet]="tpl"
437
+ [ngTemplateOutletContext]="childContext()"
438
+ />
439
+ } @else {
440
+ <svg
441
+ class="search-picker-icon"
442
+ viewBox="0 0 16 16"
443
+ width="1.05rem"
444
+ height="1.05rem"
445
+ aria-hidden="true"
446
+ fill="none"
447
+ stroke="currentColor"
448
+ stroke-width="1.6"
449
+ stroke-linecap="round"
450
+ stroke-linejoin="round"
451
+ >
452
+ <circle cx="7" cy="7" r="4.5" />
453
+ <path d="M10.5 10.5 14 14" />
454
+ </svg>
455
+ }
456
+ </lily-icon-button>
457
+
458
+ <!-- Purely visual: the same text is already the button's aria-label,
459
+ so it is not wired with aria-describedby (that would announce
460
+ the name twice). -->
461
+ <div
462
+ class="search-picker-tooltip"
463
+ role="tooltip"
464
+ [id]="tooltipId"
465
+ [attr.hidden]="tooltipVisible() ? null : ''"
466
+ (mouseenter)="hoverTooltip.set(true)"
467
+ (mouseleave)="hoverTooltip.set(false)"
468
+ >{{ label() }}</div>
469
+
470
+ <!-- The keydown handler only listens for Escape bubbling up from the
471
+ field and the submit button inside; the panel itself takes no
472
+ focus. -->
473
+ <div
474
+ #panelEl
475
+ class="search-picker-panel"
476
+ [id]="panelId"
477
+ [attr.hidden]="open() ? null : ''"
478
+ (keydown)="onPanelKeydown($event)"
479
+ >
480
+ <form
481
+ class="search-picker-form"
482
+ role="search"
483
+ [attr.aria-label]="label()"
484
+ [attr.action]="action()"
485
+ method="get"
486
+ (submit)="onSubmit($event)"
487
+ >
488
+ <input
489
+ #inputEl
490
+ class="search-picker-input"
491
+ type="search"
492
+ [attr.aria-label]="inputLabel()"
493
+ [attr.placeholder]="placeholder() ?? null"
494
+ enterkeyhint="search"
495
+ [value]="value()"
496
+ (input)="value.set($any($event.target).value)"
497
+ />
498
+ <button
499
+ type="submit"
500
+ class="search-picker-submit"
501
+ [attr.aria-label]="submitLabel()"
502
+ >
503
+ <span class="search-picker-submit-symbol" aria-hidden="true">{{
504
+ returnSymbol
505
+ }}</span>
506
+ </button>
507
+ </form>
508
+ </div>
509
+ </div>
510
+ `
511
+ }]
512
+ }],
513
+ propDecorators: {
514
+ label: [{
515
+ type: i0.Input,
516
+ args: [{
517
+ isSignal: true,
518
+ alias: "label",
519
+ required: true
520
+ }]
521
+ }],
522
+ inputLabel: [{
523
+ type: i0.Input,
524
+ args: [{
525
+ isSignal: true,
526
+ alias: "inputLabel",
527
+ required: true
528
+ }]
529
+ }],
530
+ submitLabel: [{
531
+ type: i0.Input,
532
+ args: [{
533
+ isSignal: true,
534
+ alias: "submitLabel",
535
+ required: true
536
+ }]
537
+ }],
538
+ placeholder: [{
539
+ type: i0.Input,
540
+ args: [{
541
+ isSignal: true,
542
+ alias: "placeholder",
543
+ required: false
544
+ }]
545
+ }],
546
+ value: [{
547
+ type: i0.Input,
548
+ args: [{
549
+ isSignal: true,
550
+ alias: "value",
551
+ required: false
552
+ }]
553
+ }, {
554
+ type: i0.Output,
555
+ args: ["valueChange"]
556
+ }],
557
+ action: [{
558
+ type: i0.Input,
559
+ args: [{
560
+ isSignal: true,
561
+ alias: "action",
562
+ required: false
563
+ }]
564
+ }],
565
+ navigate: [{
566
+ type: i0.Input,
567
+ args: [{
568
+ isSignal: true,
569
+ alias: "navigate",
570
+ required: false
571
+ }]
572
+ }],
573
+ className: [{
574
+ type: i0.Input,
575
+ args: [{
576
+ isSignal: true,
577
+ alias: "className",
578
+ required: false
579
+ }]
580
+ }],
581
+ searched: [{
582
+ type: i0.Output,
583
+ args: ["searched"]
584
+ }],
585
+ iconTemplate: [{
586
+ type: i0.ContentChild,
587
+ args: [i0.forwardRef(() => TemplateRef), { isSignal: true }]
588
+ }],
589
+ rootRef: [{
590
+ type: i0.ViewChild,
591
+ args: ["rootEl", { isSignal: true }]
592
+ }],
593
+ buttonRef: [{
594
+ type: i0.ViewChild,
595
+ args: ["buttonEl", { isSignal: true }]
596
+ }],
597
+ panelRef: [{
598
+ type: i0.ViewChild,
599
+ args: ["panelEl", { isSignal: true }]
600
+ }],
601
+ inputRef: [{
602
+ type: i0.ViewChild,
603
+ args: ["inputEl", { isSignal: true }]
604
+ }]
605
+ }
606
+ });
607
+ export { RETURN_SYMBOL, SearchPicker, SearchPickerIcon, nextSearchPickerId, searchHref };
608
+
609
+ //# sourceMappingURL=lilydesignsystem-angular-search-picker.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lilydesignsystem-angular-search-picker.mjs","names":[],"sources":["../../search-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 model,\n output,\n signal,\n viewChild,\n} from \"@angular/core\";\n// Only the trigger button composes a headless primitive. The panel is a\n// real <form role=\"search\"> with a real search field and a real submit\n// button — a disclosure, not a listbox or a menu — so headless `Listbox`\n// is the wrong widget for it, not merely an unmigrated one.\nimport { IconButton } from \"@lilydesignsystem/angular-headless\";\n\n/**\n * The submit button's visible content: U+23CE RETURN SYMBOL, a bare\n * literal character (never an escape — see `bin/test`'s glyph check).\n * It is the button's visible label only; the accessible name comes from\n * the required `submitLabel` input, so assistive technology never has to\n * announce a symbol.\n */\nexport const RETURN_SYMBOL = \"⏎\";\n\n/** Context passed to a custom icon `<ng-template>` (the button icon). */\nexport type ChildArgs = {\n /** Is the search panel open? */\n open: boolean;\n /** The current text in the search field. */\n query: string;\n};\n\n/** Payload of the `searched` output: the trimmed query and its destination. */\nexport type SearchEvent = {\n /** The trimmed, non-empty query. */\n query: string;\n /** Where the search navigates: `searchHref(query, action)`. */\n href: string;\n};\n\n/**\n * The destination for a query: `action` + `?` + the URI-encoded, trimmed\n * query. `searchHref(\"foo\")` is `\"/?foo\"`; `searchHref(\"foo bar\")` is\n * `\"/?foo%20bar\"`.\n */\nexport function searchHref(query: string, action = \"/\"): string {\n return `${action}?${encodeURIComponent(query.trim())}`;\n}\n\nlet uid = 0;\n/** Stable per-instance id prefix; SSR-safe (no Math.random / Date.now). */\nexport function nextSearchPickerId(): string {\n uid += 1;\n return `search-picker-${uid}`;\n}\n\n/**\n * Optional marker for the projected icon template. Gives consumers typed\n * `let-` variables:\n *\n * ```html\n * <lily-search-picker label=\"Search\" inputLabel=\"Search terms\" submitLabel=\"Search\">\n * <ng-template lilySearchPickerIcon let-args>{{ args.open ? \"×\" : \"🔍\" }}</ng-template>\n * </lily-search-picker>\n * ```\n *\n * The component queries any projected `<ng-template>`, so the marker is\n * for type-checking and readability, not for matching.\n */\n@Directive({\n selector: \"ng-template[lilySearchPickerIcon]\",\n standalone: true,\n})\nexport class SearchPickerIcon {\n static ngTemplateContextGuard(\n _dir: SearchPickerIcon,\n _ctx: unknown,\n ): _ctx is ChildArgs & { $implicit: ChildArgs } {\n return true;\n }\n}\n\n/**\n * SearchPicker — a headless site-search control.\n *\n * A single-icon button (a bundled magnifying-glass SVG) that opens a\n * dropdown holding a search field and, at its right, a `⏎` submit button.\n * Return in the field, or the `⏎` button, navigates to `/?<query>` — a\n * search for `foo` goes to `/?foo`.\n *\n * Like `share-picker` this owns an *action*, not a preference: it applies\n * nothing to the document and persists nothing. See `spec/index.md` for\n * the full contract.\n */\n@Component({\n selector: \"lily-search-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=\"search-picker {{ className() }}\"\n (focusout)=\"onRootFocusOut($event)\"\n >\n <lily-icon-button\n #buttonEl\n [label]=\"label()\"\n baseClass=\"search-picker-button\"\n [ariaExpanded]=\"open()\"\n [ariaControls]=\"panelId\"\n (click)=\"onButtonClick(); hoverButton.set(false)\"\n (mouseenter)=\"onTooltipButtonEnter()\"\n (mouseleave)=\"hoverButton.set(false)\"\n (focusin)=\"onTooltipButtonFocusIn()\"\n (focusout)=\"onTooltipButtonFocusOut()\"\n (keydown)=\"onTooltipKeydown($event)\"\n >\n @if (iconTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"childContext()\"\n />\n } @else {\n <svg\n class=\"search-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 <circle cx=\"7\" cy=\"7\" r=\"4.5\" />\n <path d=\"M10.5 10.5 14 14\" />\n </svg>\n }\n </lily-icon-button>\n\n <!-- Purely visual: the same text is already the button's aria-label,\n so it is not wired with aria-describedby (that would announce\n the name twice). -->\n <div\n class=\"search-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 <!-- The keydown handler only listens for Escape bubbling up from the\n field and the submit button inside; the panel itself takes no\n focus. -->\n <div\n #panelEl\n class=\"search-picker-panel\"\n [id]=\"panelId\"\n [attr.hidden]=\"open() ? null : ''\"\n (keydown)=\"onPanelKeydown($event)\"\n >\n <form\n class=\"search-picker-form\"\n role=\"search\"\n [attr.aria-label]=\"label()\"\n [attr.action]=\"action()\"\n method=\"get\"\n (submit)=\"onSubmit($event)\"\n >\n <input\n #inputEl\n class=\"search-picker-input\"\n type=\"search\"\n [attr.aria-label]=\"inputLabel()\"\n [attr.placeholder]=\"placeholder() ?? null\"\n enterkeyhint=\"search\"\n [value]=\"value()\"\n (input)=\"value.set($any($event.target).value)\"\n />\n <button\n type=\"submit\"\n class=\"search-picker-submit\"\n [attr.aria-label]=\"submitLabel()\"\n >\n <span class=\"search-picker-submit-symbol\" aria-hidden=\"true\">{{\n returnSymbol\n }}</span>\n </button>\n </form>\n </div>\n </div>\n `,\n})\nexport class SearchPicker {\n /** Accessible name for the icon button and the search landmark. */\n readonly label = input.required<string>();\n /** Accessible name for the search text field. */\n readonly inputLabel = input.required<string>();\n /** Accessible name for the ⏎ submit button. */\n readonly submitLabel = input.required<string>();\n /** Placeholder text for the search field. No default. */\n readonly placeholder = input<string | undefined>(undefined);\n /** The search text. Two-way bindable: `[(value)]`. */\n readonly value = model<string>(\"\");\n /**\n * Path the query is appended to. The search for `foo` navigates to\n * `${action}?foo`; the default `\"/\"` gives `/?foo`.\n */\n readonly action = input<string>(\"/\");\n /**\n * Performs the navigation. Defaults to `location.assign(href)` — a real\n * GET request. Pass a client-side router's navigate function (e.g.\n * `(href) => router.navigateByUrl(href)`) to keep the navigation in-app.\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 /**\n * Fires with the trimmed query and the destination, before navigating.\n * Named `searched`, not `search`: `<input type=\"search\">` fires a\n * native, bubbling `search` DOM event in Chromium and WebKit, and\n * Angular binds `(search)` on a component host to BOTH the output and\n * any same-named DOM event — the handler would receive a raw `Event`\n * on every Return.\n */\n readonly searched = output<SearchEvent>();\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\n // INSTANCE — exactly what's needed to call IconButton's own `focus()`.\n private readonly buttonRef = viewChild.required<IconButton>(\"buttonEl\");\n private readonly panelRef =\n viewChild.required<ElementRef<HTMLDivElement>>(\"panelEl\");\n private readonly inputRef =\n viewChild.required<ElementRef<HTMLInputElement>>(\"inputEl\");\n\n private readonly baseId = nextSearchPickerId();\n protected readonly panelId = `${this.baseId}-panel`;\n protected readonly returnSymbol = RETURN_SYMBOL;\n\n protected readonly open = signal(false);\n\n // Tooltip: shown while the pointer is over the button or the tooltip\n // itself (hoverable, WCAG 1.4.13) or while the button has keyboard\n // focus; Escape dismisses it without moving focus; never shown while\n // 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\n // Chromium, and the tooltip 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\n // wherever focus is (hover-only tooltips have focus elsewhere). The\n // listener exists only while visible; the effect cleanup removes it on\n // hide and on destroy. No preventDefault/stopPropagation, focus untouched.\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(), query: this.value() };\n return { $implicit: args, ...args };\n });\n\n // ---------------------------------------------------------------\n // Open / close\n // ---------------------------------------------------------------\n\n /** Open the panel and move focus into the search field. */\n openPanel(): void {\n this.open.set(true);\n // Unhide the panel now rather than waiting for change detection, so\n // the field is focusable in this same task — whether the host app is\n // zone-based or zoneless, the template binding settles on the same\n // value afterwards. preventScroll: the panel is positioned by\n // consumer CSS, and focusing a field rendered partly off-screen would\n // otherwise scroll the whole page.\n this.panelRef().nativeElement.removeAttribute(\"hidden\");\n this.inputRef().nativeElement.focus({ preventScroll: true });\n }\n\n /** Close the panel; `refocus` returns focus to the trigger. */\n closePanel(refocus = true): void {\n if (!this.open()) return;\n // Focus the trigger BEFORE hiding the panel, so focus never drops to\n // <body> in between.\n if (refocus) this.buttonRef().focus({ preventScroll: true });\n this.open.set(false);\n }\n\n protected onButtonClick(): void {\n if (this.open()) this.closePanel();\n else this.openPanel();\n }\n\n protected onPanelKeydown(event: KeyboardEvent): void {\n if (event.key === \"Escape\") {\n event.preventDefault();\n this.closePanel();\n }\n }\n\n protected onRootFocusOut(event: FocusEvent): void {\n // Close only when focus moves to a known element outside the picker.\n // A focusout with no relatedTarget is not \"focus left\": Safari does\n // not focus a <button> on click, so pressing ⏎ (or the icon button)\n // blurs the field with relatedTarget = null. Closing there hid the\n // panel before the click landed, so ⏎ never searched and the icon\n // button re-opened instead of closing. Clicks outside the picker are\n // handled by the document click listener below.\n const next = event.relatedTarget as Node | null;\n if (!next || this.rootRef().nativeElement.contains(next)) return;\n this.closePanel(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.closePanel(false);\n }\n\n // ---------------------------------------------------------------\n // Searching\n // ---------------------------------------------------------------\n\n protected onSubmit(event: Event): void {\n // The form's native GET would send `/?name=value`; the contract is\n // the bare query (`/?foo`), so navigation is done here instead.\n event.preventDefault();\n const query = this.value().trim();\n if (!query) return;\n const href = searchHref(query, this.action());\n this.searched.emit({ query, href });\n this.closePanel(false);\n const navigate = this.navigate();\n if (navigate) navigate(href);\n else if (typeof location !== \"undefined\") location.assign(href);\n }\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@lilydesignsystem/angular-search-picker",
3
+ "version": "0.1.0",
4
+ "engines": {
5
+ "node": "=26"
6
+ },
7
+ "description": "Lily Design System Angular 20 search picker: a magnifying-glass icon button opening a search field with a ⏎ submit button that navigates to /?<query>. 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
+ "search",
23
+ "picker"
24
+ ],
25
+ "author": "Joel Parker Henderson <joel@joelparkerhenderson.com>",
26
+ "license": "MIT OR Apache-2.0 OR GPL-2.0-only OR GPL-3.0-only OR BSD-3-Clause",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/LilyDesignSystem/lily-design-system-angular-helpers.git",
30
+ "directory": "lily-design-system-angular-search-picker"
31
+ },
32
+ "homepage": "https://lilydesignsystem.com/",
33
+ "bugs": {
34
+ "url": "https://github.com/LilyDesignSystem/lily-design-system/issues"
35
+ },
36
+ "module": "fesm2022/lilydesignsystem-angular-search-picker.mjs",
37
+ "typings": "types/lilydesignsystem-angular-search-picker.d.ts",
38
+ "exports": {
39
+ "./package.json": {
40
+ "default": "./package.json"
41
+ },
42
+ ".": {
43
+ "types": "./types/lilydesignsystem-angular-search-picker.d.ts",
44
+ "default": "./fesm2022/lilydesignsystem-angular-search-picker.mjs"
45
+ }
46
+ },
47
+ "type": "module"
48
+ }
@@ -0,0 +1,183 @@
1
+ import * as i0 from "@angular/core";
2
+ import { TemplateRef } from "@angular/core";
3
+ /**
4
+ * The submit button's visible content: U+23CE RETURN SYMBOL, a bare
5
+ * literal character (never an escape — see `bin/test`'s glyph check).
6
+ * It is the button's visible label only; the accessible name comes from
7
+ * the required `submitLabel` input, so assistive technology never has to
8
+ * announce a symbol.
9
+ */
10
+ export declare const RETURN_SYMBOL = "⏎";
11
+ /** Context passed to a custom icon `<ng-template>` (the button icon). */
12
+ type ChildArgs = {
13
+ /** Is the search panel open? */
14
+ open: boolean;
15
+ /** The current text in the search field. */
16
+ query: string;
17
+ };
18
+ /** Payload of the `searched` output: the trimmed query and its destination. */
19
+ type SearchEvent = {
20
+ /** The trimmed, non-empty query. */
21
+ query: string;
22
+ /** Where the search navigates: `searchHref(query, action)`. */
23
+ href: string;
24
+ };
25
+ /**
26
+ * The destination for a query: `action` + `?` + the URI-encoded, trimmed
27
+ * query. `searchHref("foo")` is `"/?foo"`; `searchHref("foo bar")` is
28
+ * `"/?foo%20bar"`.
29
+ */
30
+ export declare function searchHref(query: string, action?: string): string;
31
+ /** Stable per-instance id prefix; SSR-safe (no Math.random / Date.now). */
32
+ export declare function nextSearchPickerId(): string;
33
+ /**
34
+ * Optional marker for the projected icon template. Gives consumers typed
35
+ * `let-` variables:
36
+ *
37
+ * ```html
38
+ * <lily-search-picker label="Search" inputLabel="Search terms" submitLabel="Search">
39
+ * <ng-template lilySearchPickerIcon let-args>{{ args.open ? "×" : "🔍" }}</ng-template>
40
+ * </lily-search-picker>
41
+ * ```
42
+ *
43
+ * The component queries any projected `<ng-template>`, so the marker is
44
+ * for type-checking and readability, not for matching.
45
+ */
46
+ export declare class SearchPickerIcon {
47
+ static ngTemplateContextGuard(_dir: SearchPickerIcon, _ctx: unknown): _ctx is ChildArgs & {
48
+ $implicit: ChildArgs;
49
+ };
50
+ static ɵfac: i0.ɵɵFactoryDeclaration<SearchPickerIcon, never>;
51
+ static ɵdir: i0.ɵɵDirectiveDeclaration<SearchPickerIcon, "ng-template[lilySearchPickerIcon]", never, {}, {}, never, never, true, never>;
52
+ }
53
+ /**
54
+ * SearchPicker — a headless site-search control.
55
+ *
56
+ * A single-icon button (a bundled magnifying-glass SVG) that opens a
57
+ * dropdown holding a search field and, at its right, a `⏎` submit button.
58
+ * Return in the field, or the `⏎` button, navigates to `/?<query>` — a
59
+ * search for `foo` goes to `/?foo`.
60
+ *
61
+ * Like `share-picker` this owns an *action*, not a preference: it applies
62
+ * nothing to the document and persists nothing. See `spec/index.md` for
63
+ * the full contract.
64
+ */
65
+ export declare class SearchPicker {
66
+ /** Accessible name for the icon button and the search landmark. */
67
+ readonly label: import("@angular/core").InputSignal<string>;
68
+ /** Accessible name for the search text field. */
69
+ readonly inputLabel: import("@angular/core").InputSignal<string>;
70
+ /** Accessible name for the ⏎ submit button. */
71
+ readonly submitLabel: import("@angular/core").InputSignal<string>;
72
+ /** Placeholder text for the search field. No default. */
73
+ readonly placeholder: import("@angular/core").InputSignal<string | undefined>;
74
+ /** The search text. Two-way bindable: `[(value)]`. */
75
+ readonly value: import("@angular/core").ModelSignal<string>;
76
+ /**
77
+ * Path the query is appended to. The search for `foo` navigates to
78
+ * `${action}?foo`; the default `"/"` gives `/?foo`.
79
+ */
80
+ readonly action: import("@angular/core").InputSignal<string>;
81
+ /**
82
+ * Performs the navigation. Defaults to `location.assign(href)` — a real
83
+ * GET request. Pass a client-side router's navigate function (e.g.
84
+ * `(href) => router.navigateByUrl(href)`) to keep the navigation in-app.
85
+ */
86
+ readonly navigate: import("@angular/core").InputSignal<((href: string) => void) | undefined>;
87
+ /** Extra CSS class on the root div. */
88
+ readonly className: import("@angular/core").InputSignal<string>;
89
+ /**
90
+ * Fires with the trimmed query and the destination, before navigating.
91
+ * Named `searched`, not `search`: `<input type="search">` fires a
92
+ * native, bubbling `search` DOM event in Chromium and WebKit, and
93
+ * Angular binds `(search)` on a component host to BOTH the output and
94
+ * any same-named DOM event — the handler would receive a raw `Event`
95
+ * on every Return.
96
+ */
97
+ readonly searched: import("@angular/core").OutputEmitterRef<SearchEvent>;
98
+ /** Projected icon template; replaces the default icon when supplied. */
99
+ protected readonly iconTemplate: import("@angular/core").Signal<TemplateRef<any> | undefined>;
100
+ private readonly rootRef;
101
+ private readonly buttonRef;
102
+ private readonly panelRef;
103
+ private readonly inputRef;
104
+ private readonly baseId;
105
+ protected readonly panelId: string;
106
+ protected readonly returnSymbol = "⏎";
107
+ protected readonly open: import("@angular/core").WritableSignal<boolean>;
108
+ protected readonly tooltipId: string;
109
+ protected readonly hoverButton: import("@angular/core").WritableSignal<boolean>;
110
+ protected readonly hoverTooltip: import("@angular/core").WritableSignal<boolean>;
111
+ protected readonly focusButton: import("@angular/core").WritableSignal<boolean>;
112
+ protected readonly dismissed: import("@angular/core").WritableSignal<boolean>;
113
+ protected readonly tooltipVisible: import("@angular/core").Signal<boolean>;
114
+ protected onTooltipButtonEnter(): void;
115
+ protected onTooltipButtonFocusIn(): void;
116
+ protected onTooltipButtonFocusOut(): void;
117
+ protected onTooltipKeydown(event: KeyboardEvent): void;
118
+ private readonly tooltipEscapeListener;
119
+ protected readonly childContext: import("@angular/core").Signal<{
120
+ /** Is the search panel open? */
121
+ open: boolean;
122
+ /** The current text in the search field. */
123
+ query: string;
124
+ $implicit: ChildArgs;
125
+ }>;
126
+ /** Open the panel and move focus into the search field. */
127
+ openPanel(): void;
128
+ /** Close the panel; `refocus` returns focus to the trigger. */
129
+ closePanel(refocus?: boolean): void;
130
+ protected onButtonClick(): void;
131
+ protected onPanelKeydown(event: KeyboardEvent): void;
132
+ protected onRootFocusOut(event: FocusEvent): void;
133
+ protected onDocumentClick(event: Event): void;
134
+ protected onSubmit(event: Event): void;
135
+ static ɵfac: i0.ɵɵFactoryDeclaration<SearchPicker, never>;
136
+ static ɵcmp: i0.ɵɵComponentDeclaration<SearchPicker, "lily-search-picker", never, {
137
+ "label": {
138
+ "alias": "label";
139
+ "required": true;
140
+ "isSignal": true;
141
+ };
142
+ "inputLabel": {
143
+ "alias": "inputLabel";
144
+ "required": true;
145
+ "isSignal": true;
146
+ };
147
+ "submitLabel": {
148
+ "alias": "submitLabel";
149
+ "required": true;
150
+ "isSignal": true;
151
+ };
152
+ "placeholder": {
153
+ "alias": "placeholder";
154
+ "required": false;
155
+ "isSignal": true;
156
+ };
157
+ "value": {
158
+ "alias": "value";
159
+ "required": false;
160
+ "isSignal": true;
161
+ };
162
+ "action": {
163
+ "alias": "action";
164
+ "required": false;
165
+ "isSignal": true;
166
+ };
167
+ "navigate": {
168
+ "alias": "navigate";
169
+ "required": false;
170
+ "isSignal": true;
171
+ };
172
+ "className": {
173
+ "alias": "className";
174
+ "required": false;
175
+ "isSignal": true;
176
+ };
177
+ }, {
178
+ "value": "valueChange";
179
+ "searched": "searched";
180
+ }, ["iconTemplate"], never, true, never>;
181
+ }
182
+ export type { ChildArgs, SearchEvent };
183
+ //# sourceMappingURL=lilydesignsystem-angular-search-picker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lilydesignsystem-angular-search-picker.d.ts","names":[],"sources":["../../search-picker.component.ts"],"mappings":";;;;;;;;;qBA6Ba;;KAGD;;EAEV;;EAEA;;;KAIU;;EAEV;;EAEA;;;;;;;wBAQc,WAAW,eAAe;;wBAM1B;;;;;;;;;;;;;;qBAsBH;SACJ,uBACL,MAAM,kBACN,gBACC,QAAQ;IAAc,WAAW;;SAJzB,MAAA,GAAA,qBAAA;SAAA,MAAA,GAAA,uBAAA;;;;;;;;;;;;;;qBA8HA;;WAEF,+BAAK;;WAEL,oCAAU;;WAEV,qCAAW;;WAEX,qCAAW;;WAEX,+BAAK;;;;;WAKL,gCAAM;;;;;;WAMN,kCAAQ,cAAA;;WAER,mCAAS;;;;;;;;;WAUT,kCAAQ,iBAAA;;qBAGE,sCAAY,OAAA;mBAEd;mBAIA;mBACA;mBAEA;mBAGA;qBACE;qBACA;qBAEA,8BAAI;qBAMJ;qBACA,qCAAW;qBACX,sCAAY;qBACZ,qCAAW;qBACX,mCAAS;qBACT,wCAAc;YAOvB;YAKA;YAYA;YAKA,iBAAiB,OAAO;mBAQjB;qBASE,sCAAY;;IAzRzB;;IAEC;;;;EAiSP;;EAaA,WAAW;YAQD;YAKA,eAAe,OAAO;YAOtB,eAAe,OAAO;YAatB,gBAAgB,OAAO;YAUvB,SAAS,OAAO;SA/Kf,MAAA,GAAA,qBAAA;SAAA,MAAA,GAAA,uBAAA;IAAY;MAAA;MAAA;MAAA;;IAAA;MAAA;MAAA;MAAA;;IAAA;MAAA;MAAA;MAAA;;IAAA;MAAA;MAAA;MAAA;;IAAA;MAAA;MAAA;MAAA;;IAAA;MAAA;MAAA;MAAA;;IAAA;MAAA;MAAA;MAAA;;IAAA;MAAA;MAAA;MAAA;;;IAAA;IAAA"}