ngwr 14.5.1 → 14.7.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.
Files changed (41) hide show
  1. package/alert/styles/_index.scss +14 -3
  2. package/avatar/styles/_index.scss +90 -0
  3. package/cascader/styles/_rules.scss +0 -4
  4. package/collapse/styles/_index.scss +0 -1
  5. package/fesm2022/ngwr-avatar.mjs +76 -8
  6. package/fesm2022/ngwr-avatar.mjs.map +1 -1
  7. package/fesm2022/ngwr-cascader.mjs +2 -2
  8. package/fesm2022/ngwr-cascader.mjs.map +1 -1
  9. package/fesm2022/ngwr-image-cropper-testing.mjs +34 -3
  10. package/fesm2022/ngwr-image-cropper-testing.mjs.map +1 -1
  11. package/fesm2022/ngwr-image-cropper.mjs +245 -65
  12. package/fesm2022/ngwr-image-cropper.mjs.map +1 -1
  13. package/fesm2022/ngwr-lightbox.mjs +2 -2
  14. package/fesm2022/ngwr-lightbox.mjs.map +1 -1
  15. package/fesm2022/ngwr-pagination.mjs +1 -1
  16. package/fesm2022/ngwr-pagination.mjs.map +1 -1
  17. package/fesm2022/ngwr-schema-form.mjs +1 -1
  18. package/fesm2022/ngwr-schema-form.mjs.map +1 -1
  19. package/fesm2022/ngwr-select-testing.mjs +21 -2
  20. package/fesm2022/ngwr-select-testing.mjs.map +1 -1
  21. package/fesm2022/ngwr-select.mjs +299 -50
  22. package/fesm2022/ngwr-select.mjs.map +1 -1
  23. package/fesm2022/ngwr-version.mjs +1 -1
  24. package/fesm2022/ngwr-version.mjs.map +1 -1
  25. package/image-cropper/styles/_index.scss +7 -0
  26. package/input/styles/_index.scss +8 -2
  27. package/lightbox/styles/_rules.scss +7 -1
  28. package/llms-full.txt +4 -4
  29. package/package.json +1 -1
  30. package/schematics/use/symbol-map.json +1 -0
  31. package/select/styles/_index.scss +50 -4
  32. package/skills/ngwr/references/catalog.md +1 -1
  33. package/table/styles/_index.scss +55 -0
  34. package/toast/styles/_index.scss +9 -3
  35. package/tree/styles/_index.scss +0 -3
  36. package/types/ngwr-avatar.d.ts +51 -4
  37. package/types/ngwr-image-cropper-testing.d.ts +25 -3
  38. package/types/ngwr-image-cropper.d.ts +136 -2
  39. package/types/ngwr-select-testing.d.ts +18 -1
  40. package/types/ngwr-select.d.ts +238 -10
  41. package/types/ngwr-version.d.ts +1 -1
@@ -2,9 +2,9 @@ import { Directionality } from '@angular/cdk/bidi';
2
2
  import { coerceBooleanProperty, coerceNumberProperty } from '@angular/cdk/coercion';
3
3
  import { ScrollStrategyOptions } from '@angular/cdk/overlay';
4
4
  import { TemplatePortal } from '@angular/cdk/portal';
5
- import { isPlatformBrowser } from '@angular/common';
5
+ import { NgTemplateOutlet, isPlatformBrowser } from '@angular/common';
6
6
  import * as i0 from '@angular/core';
7
- import { InjectionToken, input, inject, ElementRef, signal, computed, effect, afterEveryRender, ViewEncapsulation, Component, model, output, contentChildren, viewChild, TemplateRef, afterNextRender, ViewContainerRef, DestroyRef, PLATFORM_ID, Injector, forwardRef } from '@angular/core';
7
+ import { inject, TemplateRef, Directive, InjectionToken, input, ElementRef, contentChild, computed, viewChild, signal, effect, untracked, afterEveryRender, ViewEncapsulation, Component, model, output, contentChildren, afterNextRender, ViewContainerRef, DestroyRef, PLATFORM_ID, Injector, forwardRef } from '@angular/core';
8
8
  import { toObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop';
9
9
  import { skip, debounce, timer, switchMap, of, defer, isObservable, from, tap, catchError, finalize } from 'rxjs';
10
10
  import { useConfigValue } from 'ngwr/config';
@@ -13,6 +13,79 @@ import { useI18nText, useI18nFormatter } from 'ngwr/i18n';
13
13
  import { WR_OVERLAY, WrOutsideClick, WR_RESPONSIVE_OVERLAYS, wrPresentAsSheet, wrFollowDirection } from 'ngwr/overlay';
14
14
  import { isComposing, randomId } from 'ngwr/utils';
15
15
 
16
+ /**
17
+ * @license
18
+ *
19
+ * Use of this source code is governed by an MIT-style license that can be
20
+ * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
21
+ */
22
+ /**
23
+ * A leading visual — an avatar, an icon, a colour dot — drawn before an
24
+ * option's label, and kept out of that label.
25
+ *
26
+ * It is a TEMPLATE, and that is the point. Projected `<wr-option>` children are
27
+ * created as soon as the page renders, panel open or not, so an avatar written
28
+ * straight into each option is 200 components and 200 image requests for an
29
+ * assignee picker nobody has opened. A template is only instantiated where it is
30
+ * drawn:
31
+ *
32
+ * - in an option row, while the panel is OPEN — destroyed again when it closes,
33
+ * and under `virtualScroll` only for the rows in the rendered window;
34
+ * - in a selected chip on a `mode="multi"` trigger, one per visible chip;
35
+ * - beside the selected value on a single-mode BUTTON trigger — one copy;
36
+ * - before the input on a SEARCH-shaped trigger (`mode="search"`, a searchable
37
+ * single), while that input is showing the selected label. It goes beside the
38
+ * field rather than into it — an `<input>` holds a string — and it steps aside
39
+ * while a query is on screen, panel open or half-typed, because the value it
40
+ * names is not what the field is showing then. Picking an option brings it
41
+ * back with the label. Stepping aside is a DESTROY, like a closing panel's
42
+ * rows, so an `<img>`-backed visual re-mounts once per open-and-dismiss.
43
+ *
44
+ * Every copy sits in an `aria-hidden` wrapper and is skipped when the option's
45
+ * text is read, so the trigger label, the chip, the search filter and the
46
+ * option's accessible name all stay the option's words — never "ХР Хегай Роман".
47
+ * Anything the visual says has to be said by the label as well.
48
+ *
49
+ * Two places to declare it. Inside a `<wr-option>` it belongs to that option.
50
+ * As a DIRECT child of `<wr-select>` it is the default for every row, which is the
51
+ * only way to reach rows the select draws itself — `[options]`, `[loader]`
52
+ * results and virtual rows. An option's own template wins over the select's.
53
+ *
54
+ * @example
55
+ * ```html
56
+ * <wr-select mode="multi" placeholder="Assignees" [(value)]="assignees">
57
+ * @for (p of people; track p.id) {
58
+ * <wr-option [value]="p.id">
59
+ * <ng-template wrOptionLeading let-placement="placement">
60
+ * <wr-avatar shape="circle" [size]="placement === 'chip' ? 16 : 24" [url]="p.photo">{{ p.initials }}</wr-avatar>
61
+ * </ng-template>
62
+ * {{ p.name }}
63
+ * </wr-option>
64
+ * }
65
+ * </wr-select>
66
+ * ```
67
+ */
68
+ class WrOptionLeading {
69
+ template = inject(TemplateRef);
70
+ /**
71
+ * Types the template's `let-` variables under `strictTemplates`. Without it
72
+ * `let-placement` is `any`, and indexing a `Record<WrOptionLeadingPlacement, …>`
73
+ * with it — the obvious way to size one visual per surface — does not compile.
74
+ */
75
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- a guard's parameters exist to be narrowed, never read
76
+ static ngTemplateContextGuard(_dir, _ctx) {
77
+ return true;
78
+ }
79
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrOptionLeading, deps: [], target: i0.ɵɵFactoryTarget.Directive });
80
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.6", type: WrOptionLeading, isStandalone: true, selector: "ng-template[wrOptionLeading]", ngImport: i0 });
81
+ }
82
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrOptionLeading, decorators: [{
83
+ type: Directive,
84
+ args: [{
85
+ selector: 'ng-template[wrOptionLeading]',
86
+ }]
87
+ }] });
88
+
16
89
  /**
17
90
  * @license
18
91
  *
@@ -34,16 +107,25 @@ const WR_SELECT = new InjectionToken('WR_SELECT');
34
107
  * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
35
108
  */
36
109
  let uid = 0;
110
+ /** `Node.TEXT_NODE` / `Node.ELEMENT_NODE`, spelled out: `Node` is not a global on the server. */
111
+ const TEXT_NODE = 3;
112
+ const ELEMENT_NODE = 1;
37
113
  /**
38
114
  * Single option inside a `<wr-select>`.
39
115
  *
40
- * The option's display label is taken from its projected text content;
41
- * the form value is its `value` input.
116
+ * The option's display label is taken from its projected text content — or
117
+ * from `label`, when that is set — and the form value is its `value` input. A
118
+ * leading visual goes in an `<ng-template wrOptionLeading>`, which keeps it out
119
+ * of the label and uninstantiated while the panel is closed.
42
120
  *
43
121
  * @example
44
122
  * ```html
45
123
  * <wr-option value="sm">Small</wr-option>
46
124
  * <wr-option [value]="42">Forty-two</wr-option>
125
+ * <wr-option [value]="p.id" [label]="p.name">
126
+ * <ng-template wrOptionLeading><wr-avatar shape="circle" size="1.5rem">{{ p.initials }}</wr-avatar></ng-template>
127
+ * {{ p.name }}
128
+ * </wr-option>
47
129
  * ```
48
130
  */
49
131
  class WrOption {
@@ -52,10 +134,49 @@ class WrOption {
52
134
  ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
53
135
  /** Disable this option. @default false */
54
136
  disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: coerceBooleanProperty });
137
+ /**
138
+ * The text the select reports for this option — the trigger label, the chip,
139
+ * the chip's remove label and the search filter all read it. Unset (or empty),
140
+ * the option's own text is used, as it always was.
141
+ *
142
+ * Set it when the row shows more than the name: a projected email under a
143
+ * person, a count beside a category. It does NOT rename the row itself — the
144
+ * option's accessible name stays the text it draws, since a screen reader user
145
+ * browsing the list should hear what a sighted one reads there.
146
+ *
147
+ * @default null
148
+ */
149
+ label = input(null, /* @ts-ignore */
150
+ ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
55
151
  /** Stable id used for `aria-activedescendant`. */
56
152
  id = `wr-option-${++uid}`;
57
153
  host = inject(ElementRef);
58
154
  parent = inject(WR_SELECT, { optional: true });
155
+ /** This option's own `<ng-template wrOptionLeading>`, if it declares one. */
156
+ ownLeading = contentChild(WrOptionLeading, /* @ts-ignore */
157
+ ...(ngDevMode ? [{ debugName: "ownLeading" }] : /* istanbul ignore next */ []));
158
+ /**
159
+ * The leading template that applies to this option: its own, else the
160
+ * select-wide default. Registered with the parent so a chip can draw it.
161
+ */
162
+ leading = computed(() => this.ownLeading()?.template ?? this.parent?.optionLeading() ?? null, /* @ts-ignore */
163
+ ...(ngDevMode ? [{ debugName: "leading" }] : /* istanbul ignore next */ []));
164
+ /**
165
+ * The leading template to draw in THIS row right now — `null` while the panel
166
+ * is closed. A projected option is created with the page and lives, detached,
167
+ * for as long as the select does, so gating on the template alone would
168
+ * instantiate every row's avatar the moment the page renders. An option with
169
+ * no parent select has no panel to wait for.
170
+ */
171
+ rowLeading = computed(() => {
172
+ if (this.parent && !this.parent.panelOpen())
173
+ return null;
174
+ return this.leading();
175
+ }, /* @ts-ignore */
176
+ ...(ngDevMode ? [{ debugName: "rowLeading" }] : /* istanbul ignore next */ []));
177
+ /** The wrapper the leading visual renders into — skipped when reading the text. */
178
+ leadingSlot = viewChild('leadingSlot', /* @ts-ignore */
179
+ ...(ngDevMode ? [{ debugName: "leadingSlot" }] : /* istanbul ignore next */ []));
59
180
  /**
60
181
  * The option's own text, as a signal — the parent's trigger label and the
61
182
  * client-side filter both read it from here.
@@ -80,7 +201,16 @@ class WrOption {
80
201
  */
81
202
  labelText = signal('', /* @ts-ignore */
82
203
  ...(ngDevMode ? [{ debugName: "labelText" }] : /* istanbul ignore next */ []));
83
- label = this.labelText.asReadonly();
204
+ /**
205
+ * `label` when set, otherwise the option's own text. What the parent reads.
206
+ * Not `??`: an EMPTY label falls back too, since a blank trigger or a nameless
207
+ * chip is never what a `[label]="person.name"` on a missing name meant.
208
+ */
209
+ resolvedLabel = computed(() => {
210
+ const explicit = this.label();
211
+ return explicit !== null && explicit !== '' ? explicit : this.labelText();
212
+ }, /* @ts-ignore */
213
+ ...(ngDevMode ? [{ debugName: "resolvedLabel" }] : /* istanbul ignore next */ []));
84
214
  /**
85
215
  * @internal — true when this option is currently selected. Works for
86
216
  * both single and multi-select parents via `WrSelectContext.isSelected`.
@@ -99,7 +229,8 @@ class WrOption {
99
229
  ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
100
230
  /**
101
231
  * @internal — searchable selects only. True when the parent has a query that
102
- * the option's text content does not match (case-insensitive substring).
232
+ * the option's label does not match (case-insensitive substring) — `label`
233
+ * when set, the option's text otherwise, and never its leading visual.
103
234
  * Hidden options stay in the DOM so registration order survives but
104
235
  * collapse via CSS.
105
236
  */
@@ -110,7 +241,7 @@ class WrOption {
110
241
  const q = parent.searchQuery().trim().toLowerCase();
111
242
  if (!q)
112
243
  return false;
113
- return !this.label().toLowerCase().includes(q);
244
+ return !this.resolvedLabel().toLowerCase().includes(q);
114
245
  }, /* @ts-ignore */
115
246
  ...(ngDevMode ? [{ debugName: "hidden" }] : /* istanbul ignore next */ []));
116
247
  classes = computed(() => {
@@ -132,13 +263,20 @@ class WrOption {
132
263
  // Re-register when disabled or value changes so the parent has fresh metadata.
133
264
  // The label is seeded here as well, which is the read the server takes.
134
265
  effect(onCleanup => {
135
- this.syncLabel();
266
+ // Untracked: the read goes through the `leadingSlot` query, which moves
267
+ // every time the panel opens or closes, and a registration keyed on it
268
+ // would unregister and re-register every option on each of those — a
269
+ // registry write per option, and a recompute of everything the select
270
+ // derives from the registry, for a change the registration does not
271
+ // carry. The label itself still follows through `afterEveryRender`.
272
+ untracked(() => this.syncLabel());
136
273
  const unreg = parent.registerOption({
137
274
  id: this.id,
138
275
  value: this.value(),
139
276
  disabled: this.disabled(),
140
- label: this.label,
277
+ label: this.resolvedLabel,
141
278
  host: this.host.nativeElement,
279
+ leading: this.leading,
142
280
  });
143
281
  onCleanup(() => unreg());
144
282
  });
@@ -147,11 +285,26 @@ class WrOption {
147
285
  }
148
286
  /**
149
287
  * Read the projected text back off the DOM. Setting the same string again is
150
- * a signal no-op, so the render hook costs a `textContent` read and nothing
151
- * else on the passes where nothing moved.
288
+ * a signal no-op, so the render hook costs a short walk over the host's
289
+ * children and nothing else on the passes where nothing moved.
290
+ *
291
+ * A walk rather than the host's `textContent`, because the leading visual
292
+ * renders INSIDE the host while the panel is open: a `textContent` read turned
293
+ * an avatar's initials into the first word of the label, so the chip and the
294
+ * search filter read "ХР Хегай Роман". Only text and element children count —
295
+ * a comment's `textContent` is its data, and Angular anchors every
296
+ * `<ng-template>` with one.
152
297
  */
153
298
  syncLabel() {
154
- this.labelText.set(this.host.nativeElement.textContent?.trim() ?? '');
299
+ const slot = this.leadingSlot()?.nativeElement;
300
+ let text = '';
301
+ for (const node of Array.from(this.host.nativeElement.childNodes)) {
302
+ if (node === slot)
303
+ continue;
304
+ if (node.nodeType === TEXT_NODE || node.nodeType === ELEMENT_NODE)
305
+ text += node.textContent ?? '';
306
+ }
307
+ this.labelText.set(text.trim());
155
308
  }
156
309
  onClick() {
157
310
  if (this.disabled() || !this.parent)
@@ -159,24 +312,19 @@ class WrOption {
159
312
  this.parent.selectOption(this.value());
160
313
  }
161
314
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrOption, deps: [], target: i0.ɵɵFactoryTarget.Component });
162
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.1.6", type: WrOption, isStandalone: true, selector: "wr-option", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: true, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "option" }, listeners: { "click": "onClick()" }, properties: { "attr.id": "id", "class": "classes()", "attr.aria-selected": "selected()", "attr.aria-disabled": "disabled() ? true : null" } }, ngImport: i0, template: '<ng-content />', isInline: true, encapsulation: i0.ViewEncapsulation.None });
315
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: WrOption, isStandalone: true, selector: "wr-option", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: true, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "option" }, listeners: { "click": "onClick()" }, properties: { "attr.id": "id", "class": "classes()", "attr.aria-selected": "selected()", "attr.aria-disabled": "disabled() ? true : null" } }, queries: [{ propertyName: "ownLeading", first: true, predicate: WrOptionLeading, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "leadingSlot", first: true, predicate: ["leadingSlot"], descendants: true, isSignal: true }], ngImport: i0, template: "<!-- The leading visual is a sibling of the projected text, never a wrapper\n around it: `.wr-option` is a flex row, and wrapping the content would fold\n a consumer's own icon and label into one flex item. `aria-hidden` keeps it\n out of the option's accessible name; `syncLabel` skips it by reference. -->\n@if (rowLeading(); as leading) {\n <span #leadingSlot class=\"wr-option__leading\" aria-hidden=\"true\">\n <ng-container\n [ngTemplateOutlet]=\"leading\"\n [ngTemplateOutletContext]=\"{ $implicit: value(), placement: 'option' }\"\n />\n </span>\n}\n<ng-content />\n", dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], encapsulation: i0.ViewEncapsulation.None });
163
316
  }
164
317
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrOption, decorators: [{
165
318
  type: Component,
166
- args: [{
167
- selector: 'wr-option',
168
- template: '<ng-content />',
169
- encapsulation: ViewEncapsulation.None,
170
- host: {
319
+ args: [{ selector: 'wr-option', encapsulation: ViewEncapsulation.None, imports: [NgTemplateOutlet], host: {
171
320
  role: 'option',
172
321
  '[attr.id]': 'id',
173
322
  '[class]': 'classes()',
174
323
  '[attr.aria-selected]': 'selected()',
175
324
  '[attr.aria-disabled]': 'disabled() ? true : null',
176
325
  '(click)': 'onClick()',
177
- },
178
- }]
179
- }], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: true }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }] } });
326
+ }, template: "<!-- The leading visual is a sibling of the projected text, never a wrapper\n around it: `.wr-option` is a flex row, and wrapping the content would fold\n a consumer's own icon and label into one flex item. `aria-hidden` keeps it\n out of the option's accessible name; `syncLabel` skips it by reference. -->\n@if (rowLeading(); as leading) {\n <span #leadingSlot class=\"wr-option__leading\" aria-hidden=\"true\">\n <ng-container\n [ngTemplateOutlet]=\"leading\"\n [ngTemplateOutletContext]=\"{ $implicit: value(), placement: 'option' }\"\n />\n </span>\n}\n<ng-content />\n" }]
327
+ }], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: true }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], ownLeading: [{ type: i0.ContentChild, args: [i0.forwardRef(() => WrOptionLeading), { isSignal: true }] }], leadingSlot: [{ type: i0.ViewChild, args: ['leadingSlot', { isSignal: true }] }] } });
180
328
 
181
329
  /**
182
330
  * @license
@@ -670,6 +818,79 @@ class WrSelect {
670
818
  ...(ngDevMode ? [{ debugName: "activeOptionId" }] : /* istanbul ignore next */ []));
671
819
  open = signal(false, /* @ts-ignore */
672
820
  ...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
821
+ /** WrSelectContext — whether the panel is open. Options gate their leading visual on it. */
822
+ panelOpen = this.open.asReadonly();
823
+ /**
824
+ * A `<ng-template wrOptionLeading>` declared as a DIRECT child of the select —
825
+ * `descendants: false`, so an option's own template is never mistaken for the
826
+ * select-wide default.
827
+ */
828
+ defaultLeading = contentChild(WrOptionLeading, { ...(ngDevMode ? { debugName: "defaultLeading" } : /* istanbul ignore next */ {}), descendants: false });
829
+ /**
830
+ * WrSelectContext — the select-wide leading template, or `null`. The only
831
+ * way to give a leading visual to the rows the select draws itself (`[options]`,
832
+ * `[loader]` results, virtual rows), and the default for projected options that
833
+ * declare none of their own.
834
+ */
835
+ optionLeading = computed(() => this.defaultLeading()?.template ?? null, /* @ts-ignore */
836
+ ...(ngDevMode ? [{ debugName: "optionLeading" }] : /* istanbul ignore next */ []));
837
+ /**
838
+ * The leading template for a value: the registered option's (its own, or the
839
+ * default it resolved to), else the select-wide default for a value with no
840
+ * registered option — a virtual row, or one a loader has since dropped.
841
+ */
842
+ leadingFor(value) {
843
+ const found = this.registry().find(o => o.value === value);
844
+ if (found?.leading)
845
+ return found.leading();
846
+ return this.optionLeading();
847
+ }
848
+ /**
849
+ * The leading visual beside the selected value on a single-mode BUTTON trigger
850
+ * — one instance, for the one selected value. A search-shaped trigger draws the
851
+ * same visual through {@link searchLeading}, on a narrower condition.
852
+ */
853
+ selectedLeading = computed(() => {
854
+ if (this.isMulti() || this.selectedLabel() == null)
855
+ return null;
856
+ return this.leadingFor(this.value());
857
+ }, /* @ts-ignore */
858
+ ...(ngDevMode ? [{ debugName: "selectedLeading" }] : /* istanbul ignore next */ []));
859
+ /**
860
+ * The same visual on a SEARCH-shaped trigger (`mode="search"`, a searchable
861
+ * single), drawn before the input — the surface an assignee picker actually
862
+ * uses, and the one that lost its avatar the moment the trigger became a field.
863
+ *
864
+ * The condition is {@link searchShowsValue} rather than `hasSelection()`, and
865
+ * the two part company on every open panel: focusing the field replaces the
866
+ * label with the live query, which starts empty, so what is on screen is the
867
+ * placeholder. An avatar left standing there names a value the field no longer
868
+ * shows, and then sits in front of whatever the user types next — the visual
869
+ * would be answering a different question from the text beside it. It comes
870
+ * back with the label, which is one signal flipping rather than two things
871
+ * kept in step: committing a choice clears the query and closes the panel.
872
+ *
873
+ * Nothing is instantiated for a search select with no selection, panel open or
874
+ * shut — `selectedLabel()` is `null` there, and this returns `null` with it.
875
+ *
876
+ * The COST of that rule, stated because it is a consumer's to weigh: stepping
877
+ * aside is a destroy, so opening the picker and dismissing it unchanged
878
+ * re-creates the visual — a template built on an `<img>` re-mounts once per
879
+ * round trip, and re-requests unless the cache answers. Kept rather than
880
+ * traded for a hidden-but-alive node: the panel's own rows already create and
881
+ * destroy one apiece on the same gesture, so holding this one back buys a
882
+ * fraction of that, and a node retained while it must not be seen is a second
883
+ * piece of state to keep in step with the field. Pinned in `option.spec.ts`
884
+ * ("re-creates it after a round trip"), so a future change here has to say so.
885
+ */
886
+ searchLeading = computed(() => {
887
+ if (!this.isSearchTrigger() || !this.searchShowsValue())
888
+ return null;
889
+ if (this.selectedLabel() == null)
890
+ return null;
891
+ return this.leadingFor(this.value());
892
+ }, /* @ts-ignore */
893
+ ...(ngDevMode ? [{ debugName: "searchLeading" }] : /* istanbul ignore next */ []));
673
894
  /**
674
895
  * Trigger label for single mode. Multi reads `selectedChips` instead.
675
896
  *
@@ -705,7 +926,8 @@ class WrSelect {
705
926
  return [];
706
927
  const arr = this.asArray(this.value());
707
928
  if (this.isTag()) {
708
- return arr.map(v => ({ value: v, label: String(v) }));
929
+ // Tag values are free text with no option behind them, so no leading.
930
+ return arr.map(v => ({ value: v, label: String(v), leading: null }));
709
931
  }
710
932
  const list = this.registry();
711
933
  return arr.map(v => {
@@ -714,7 +936,11 @@ class WrSelect {
714
936
  // directly: a searchable multi fed from `[options]` has no registered
715
937
  // `<wr-option>` for a virtualized or unmounted row, and object items
716
938
  // would otherwise render as "[object Object]".
717
- return { value: v, label: found?.label() ?? this.displayWith()(v) };
939
+ return {
940
+ value: v,
941
+ label: found?.label() ?? this.displayWith()(v),
942
+ leading: found?.leading ? found.leading() : this.optionLeading(),
943
+ };
718
944
  });
719
945
  }, /* @ts-ignore */
720
946
  ...(ngDevMode ? [{ debugName: "selectedChips" }] : /* istanbul ignore next */ []));
@@ -1186,25 +1412,36 @@ class WrSelect {
1186
1412
  this.onTriggerKey(event);
1187
1413
  }
1188
1414
  /**
1189
- * Display text for the search input. Shows the selected option's
1190
- * label when collapsed, or the live query while typing / panel open.
1415
+ * Whether the search field is showing the SELECTED VALUE rather than a query.
1416
+ * `searchDisplay` takes its text off this, and `searchLeading` the visual that
1417
+ * goes before it, so the two can never disagree about which of the two things
1418
+ * the field is holding.
1419
+ *
1420
+ * FOCUS as well as open, and that second term is the whole `[minChars]` story.
1421
+ * Backspacing under the threshold closes the panel, and falling back to the
1422
+ * selected label the moment `open()` goes false rewrote the field out from
1423
+ * under someone who was still typing — "sma" -> "sm" showed an empty box.
1424
+ *
1425
+ * A NON-EMPTY query, though: picking an option clears it and leaves focus in
1426
+ * the field, and there the label is what belongs on screen.
1427
+ *
1428
+ * Only the search-shaped trigger reads it. A searchable MULTI binds its input
1429
+ * to `searchQuery()` directly and shows its selection as chips, which carry
1430
+ * their own leading visuals whatever the field holds.
1191
1431
  * @internal
1192
1432
  */
1193
- searchDisplay = computed(() => {
1194
- // FOCUS as well as open, and that second term is the whole `[minChars]`
1195
- // story. Backspacing under the threshold closes the panel, and falling back
1196
- // to the selected label the moment `open()` goes false rewrote the field
1197
- // out from under someone who was still typing — "sma" -> "sm" showed an
1198
- // empty box.
1199
- //
1200
- // A NON-EMPTY query, though: picking an option clears it and leaves focus
1201
- // in the field, and there the label is what belongs on screen.
1433
+ searchShowsValue = computed(() => {
1202
1434
  if (this.open())
1203
- return this.searchQuery();
1204
- if (this.searchHasFocus() && this.searchQuery() !== '')
1205
- return this.searchQuery();
1206
- return this.selectedLabel() ?? '';
1435
+ return false;
1436
+ return !(this.searchHasFocus() && this.searchQuery() !== '');
1207
1437
  }, /* @ts-ignore */
1438
+ ...(ngDevMode ? [{ debugName: "searchShowsValue" }] : /* istanbul ignore next */ []));
1439
+ /**
1440
+ * Display text for the search input. Shows the selected option's
1441
+ * label when collapsed, or the live query while typing / panel open.
1442
+ * @internal
1443
+ */
1444
+ searchDisplay = computed(() => this.searchShowsValue() ? (this.selectedLabel() ?? '') : this.searchQuery(), /* @ts-ignore */
1208
1445
  ...(ngDevMode ? [{ debugName: "searchDisplay" }] : /* istanbul ignore next */ []));
1209
1446
  /**
1210
1447
  * Whether the search field holds focus.
@@ -1759,14 +1996,26 @@ class WrSelect {
1759
1996
  this.overlayRef.attach(portal);
1760
1997
  // Seed the projected panel's scroll onto the cursor the same way the virtual
1761
1998
  // one is seeded from its own effect: opening a select whose selected option
1762
- // is the 200th shows the cursor, not the top of the list. Synchronous is
1763
- // correct here and a deferral would not be — `attachTemplatePortal` appends
1764
- // the rows and calls `detectChanges()` before returning, and `activeIndex`
1765
- // was seeded before `open()` flipped, so the row is in the DOM and the panel
1766
- // is measurable on this line. The virtual path stays on its effect, which
1767
- // has to wait to MEASURE a row height this one never needs.
1768
- if (!this.virtualActive())
1999
+ // is the 200th shows the cursor, not the top of the list. The virtual path
2000
+ // stays on its effect, which has to wait to MEASURE a row height this one
2001
+ // never needs.
2002
+ //
2003
+ // Twice, and both are needed. Synchronously, because `attachTemplatePortal`
2004
+ // appends the rows and calls `detectChanges()` before returning, and
2005
+ // `activeIndex` was seeded before `open()` flipped, so the row is in the DOM
2006
+ // and a panel of plain options lands on the cursor from the first frame.
2007
+ // Again after the next render, because that `detectChanges()` refreshes the
2008
+ // PORTAL's view and not the options: a projected `<wr-option>` belongs to the
2009
+ // consumer's view, so whatever it draws only while the panel is open — its
2010
+ // `wrOptionLeading` visual — lands a pass later and makes every row above the
2011
+ // cursor taller than it was measured. Measured with 38px avatar rows, the
2012
+ // cursor on the last of six sat 9px under the fold of a 90px panel, and the
2013
+ // error grows with each row above it. The second call is a no-op for a panel
2014
+ // whose rows did not move.
2015
+ if (!this.virtualActive()) {
1769
2016
  this.ensureVisible(this.activeIndex());
2017
+ afterNextRender(() => this.ensureVisible(this.activeIndex()), { injector: this.injector });
2018
+ }
1770
2019
  if (asSheet) {
1771
2020
  this.overlayRef
1772
2021
  .backdropClick()
@@ -1804,18 +2053,18 @@ class WrSelect {
1804
2053
  // eslint-disable-next-line @angular-eslint/no-forward-ref
1805
2054
  useExisting: forwardRef(() => WrSelect),
1806
2055
  },
1807
- ], queries: [{ propertyName: "projectedOptions", predicate: WrOption, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "panelTpl", first: true, predicate: ["panelTpl"], descendants: true, read: TemplateRef, isSignal: true }, { propertyName: "tagInputEl", first: true, predicate: ["tagInput"], descendants: true, isSignal: true }, { propertyName: "searchInputEl", first: true, predicate: ["searchInput"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (isSearchTrigger()) {\n <!-- Search mode (or a searchable single): a real text input drives a filtered\n overlay panel. The wrapper isn't itself focusable \u2014 the input is the\n tabstop. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div class=\"wr-select__trigger wr-select__trigger--search\" (click)=\"focusSearchInput()\">\n <!-- `id` / `aria-describedby` / `aria-invalid` come from a surrounding\n `<wr-form-field>` and are null without one \u2014 see `controlId` in the\n component. They go on the tab stop, the only labelable element here.\n `aria-label` is the trigger's name and belongs on the same element for\n the same reason: this input carries `role=\"combobox\"`, and the four\n other trigger shapes all bind `resolvedAriaLabel()`. Without it a\n search select with no placeholder rendered a combobox with no name at\n all \u2014 the one shape where the documented `ariaLabel` input reached\n nothing. -->\n <!-- `aria-autocomplete` is static, and `list` rather than `both`: the panel\n is the only place a suggestion appears, nothing is written back into\n the field for the user. It describes a permanent CAPABILITY, which is\n why it does not track `open()` \u2014 the same reasoning `wr-mention`\n records at its own host binding. -->\n <input\n #searchInput\n type=\"text\"\n role=\"combobox\"\n class=\"wr-select__search-input\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"resolvedAriaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-haspopup]=\"'listbox'\"\n aria-autocomplete=\"list\"\n [attr.aria-expanded]=\"open()\"\n [attr.aria-controls]=\"listboxId\"\n [attr.aria-activedescendant]=\"open() ? activeOptionId() || null : null\"\n [attr.aria-readonly]=\"isReadonly() || null\"\n [readonly]=\"isReadonly()\"\n [placeholder]=\"resolvedPlaceholder()\"\n [value]=\"searchDisplay()\"\n (input)=\"onSearchInput($event)\"\n (focus)=\"onSearchFocus()\"\n (keydown)=\"onSearchKey($event)\"\n (blur)=\"onSearchBlur()\"\n />\n <!-- Pointer-only, and the `keydown.enter` / `keydown.space` pair this used to\n carry was dead code rather than the keyboard path it looked like: the \u00D7\n is `tabindex=\"-1\"` with no roving owner, so no key event ever originates\n here to bubble into a handler. It stays unfocusable because the same\n span sits inside the BUTTON trigger further down, where a focusable\n child is `nested-interactive` \u2014 the defect the window taskbar's close\n button already taught this repo. The keyboard reaches the function\n instead: Backspace on the empty field, which `onSearchKey` routes to\n `clearAll` for a single and to the last chip for a multi. -->\n @if (clearable() && hasSelection() && !isDisabled() && !isReadonly()) {\n <span\n class=\"wr-select__clear\"\n role=\"button\"\n [attr.aria-label]=\"resolvedClear()\"\n tabindex=\"-1\"\n (click)=\"clearAll($event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n <svg\n class=\"wr-icon__svg wr-select__chevron\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </div>\n} @else if (isTag() || hasChipSearch()) {\n <!-- Chip trigger \u2014 tag mode, or a searchable multi. A focusable input lives\n inside, so the wrapper itself only exists to host chips and forward\n whitespace clicks to the input. Tag mode needs no role (users tab\n straight into the input); the searchable multi is a real combobox, so it\n carries the listbox wiring. The click handler is a mouse-only\n convenience. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"wr-select__trigger wr-select__trigger--tag\"\n [attr.aria-disabled]=\"isDisabled() || null\"\n (click)=\"focusTagInput()\"\n >\n <span class=\"wr-select__chips\">\n @for (chip of visibleChips(); track $index) {\n <span class=\"wr-select__chip\">\n <span class=\"wr-select__chip-label\">{{ chip.label }}</span>\n @if (!isReadonly()) {\n <span\n class=\"wr-select__chip-remove\"\n role=\"button\"\n [attr.aria-label]=\"chipRemoveLabel({ label: chip.label })\"\n tabindex=\"-1\"\n (click)=\"removeChip($index, $event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n </span>\n }\n @if (hiddenChipCount(); as extra) {\n <span class=\"wr-select__chip wr-select__chip--more\">{{ moreLabel({ count: extra }) }}</span>\n }\n <!-- At `maxItems` this input goes READONLY, never disabled: it is the only\n tab stop in the whole control (the chip \u00D7 and the clear button are\n `tabindex=\"-1\"` spans), so disabling it left a full tag select with\n nothing focusable at all \u2014 and Backspace on an empty draft, the one\n keyboard way back UNDER the cap, is bound to this very element. It was\n never needed as enforcement either: `tryAddTag` already refuses past\n capacity. -->\n <!-- `aria-autocomplete` is conditional here, unlike the search input's:\n this field is only a combobox when a panel backs it. In plain tag\n mode there is no popup to suggest from, and claiming otherwise\n describes a control that does not exist. -->\n <input\n #tagInput\n type=\"text\"\n class=\"wr-select__tag-input\"\n autocomplete=\"off\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"resolvedAriaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n spellcheck=\"false\"\n [value]=\"hasChipSearch() ? searchQuery() : draft()\"\n [disabled]=\"isDisabled()\"\n [readonly]=\"isReadonly() || (isTag() && atCapacity())\"\n [attr.aria-readonly]=\"hasChipSearch() && isReadonly() ? true : null\"\n [placeholder]=\"hasSelection() ? '' : resolvedPlaceholder()\"\n [attr.role]=\"hasChipSearch() ? 'combobox' : null\"\n [attr.aria-haspopup]=\"hasChipSearch() ? 'listbox' : null\"\n [attr.aria-autocomplete]=\"hasChipSearch() ? 'list' : null\"\n [attr.aria-expanded]=\"hasChipSearch() ? open() : null\"\n [attr.aria-controls]=\"hasChipSearch() ? listboxId : null\"\n [attr.aria-activedescendant]=\"hasChipSearch() && open() ? activeOptionId() || null : null\"\n (input)=\"onChipsInput($event)\"\n (keydown)=\"onChipsKeydown($event)\"\n (paste)=\"onChipsPaste($event)\"\n (focus)=\"onChipsFocus()\"\n (blur)=\"onChipsBlur()\"\n />\n </span>\n\n @if (clearable() && hasSelection() && !isDisabled() && !isReadonly()) {\n <span\n class=\"wr-select__clear\"\n role=\"button\"\n [attr.aria-label]=\"resolvedClear()\"\n tabindex=\"-1\"\n (click)=\"clearAll($event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n </div>\n} @else {\n <button\n type=\"button\"\n class=\"wr-select__trigger\"\n role=\"combobox\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"resolvedAriaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-haspopup]=\"'listbox'\"\n [attr.aria-expanded]=\"open()\"\n [attr.aria-controls]=\"listboxId\"\n [attr.aria-activedescendant]=\"open() ? activeOptionId() || null : null\"\n [attr.aria-readonly]=\"isReadonly() || null\"\n (click)=\"onTriggerClick()\"\n (keydown)=\"onTriggerKey($event)\"\n (blur)=\"touch.emit()\"\n >\n @if (isMulti()) {\n @if (hasSelection()) {\n <span class=\"wr-select__chips\">\n @for (chip of visibleChips(); track $index) {\n <span class=\"wr-select__chip\">\n <span class=\"wr-select__chip-label\">{{ chip.label }}</span>\n @if (!isReadonly()) {\n <span\n class=\"wr-select__chip-remove\"\n role=\"button\"\n [attr.aria-label]=\"chipRemoveLabel({ label: chip.label })\"\n tabindex=\"-1\"\n (click)=\"removeChip($index, $event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n </span>\n }\n @if (hiddenChipCount(); as extra) {\n <span class=\"wr-select__chip wr-select__chip--more\">{{ moreLabel({ count: extra }) }}</span>\n }\n </span>\n } @else {\n <span class=\"wr-select__placeholder\">{{ resolvedPlaceholder() }}</span>\n }\n } @else {\n @if (selectedLabel(); as label) {\n <span class=\"wr-select__value\">{{ label }}</span>\n } @else {\n <span class=\"wr-select__placeholder\">{{ resolvedPlaceholder() }}</span>\n }\n }\n\n @if (isMulti() && clearable() && hasSelection() && !isDisabled() && !isReadonly()) {\n <span\n class=\"wr-select__clear\"\n role=\"button\"\n [attr.aria-label]=\"resolvedClear()\"\n tabindex=\"-1\"\n (click)=\"clearAll($event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n\n <svg\n class=\"wr-icon__svg wr-select__chevron\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </button>\n}\n\n<ng-template #panelTpl>\n <div\n class=\"wr-select-panel\"\n [class.wr-select-panel--sm]=\"resolvedSize() === 'sm'\"\n [class.wr-select-panel--lg]=\"resolvedSize() === 'lg'\"\n [class.wr-select-panel--virtual]=\"virtualActive()\"\n role=\"listbox\"\n [attr.id]=\"listboxId\"\n [attr.aria-multiselectable]=\"isMulti() ? 'true' : null\"\n [attr.aria-busy]=\"isLoading() ? 'true' : null\"\n >\n <!-- `aria-busy` belongs on the listbox, not on this row: it is the container\n whose required `option` children are temporarily missing, and a global\n ARIA attribute on a role-less child makes that child an owned element the\n listbox may not own (axe `aria-required-children`, critical). Plain text\n inside the listbox is fine \u2014 only ELEMENTS are checked. -->\n @if (isLoading()) {\n <div class=\"wr-select-panel__loading\">{{ resolvedLoading() }}</div>\n }\n\n @if (virtualActive()) {\n <!-- Windowed data-array rows: plain role=\"option\" elements (not <wr-option>),\n navigated via the input's aria-activedescendant, selected by value. -->\n <div class=\"wr-select-panel__vlist\" [style.max-height]=\"resolvedViewportHeight()\">\n @if (virtualWindow().padTop > 0) {\n <div class=\"wr-select-panel__vspacer\" aria-hidden=\"true\" [style.height.px]=\"virtualWindow().padTop\"></div>\n }\n @for (entry of windowOptions(); track entry.index) {\n <!-- Keyboard handled by the search input via aria-activedescendant; the\n row is a pointer target only. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n role=\"option\"\n class=\"wr-option\"\n [id]=\"virtualRowId(entry.index)\"\n [class.wr-option--active]=\"entry.index === activeIndex()\"\n [class.wr-option--selected]=\"isSelected(entry.item)\"\n [attr.aria-selected]=\"isSelected(entry.item)\"\n [attr.aria-setsize]=\"filteredDynamicOptions().length\"\n [attr.aria-posinset]=\"entry.index + 1\"\n (click)=\"selectOption(entry.item)\"\n >\n {{ displayWith()(entry.item) }}\n </div>\n }\n @if (virtualWindow().padBottom > 0) {\n <div class=\"wr-select-panel__vspacer\" aria-hidden=\"true\" [style.height.px]=\"virtualWindow().padBottom\"></div>\n }\n </div>\n } @else {\n <!-- Dynamic options from [options] or async [loader] (search mode).\n Rendered as wr-option so the standard registration / filter\n pipeline applies uniformly. -->\n @for (item of dynamicOptions(); track $index) {\n <wr-option [value]=\"item\">{{ displayWith()(item) }}</wr-option>\n }\n }\n\n <!-- Projected static options \u2014 always present (a conditional `<ng-content>`\n would drop projected content). Empty whenever `virtualActive`. -->\n <ng-content />\n\n <!-- A listbox may only own `option` and `group` children, and as a bare `<div>`\n this row was the only thing in the tree when it showed \u2014 an\n `aria-required-children` critical. It cannot move out of the listbox the\n way the command palette's does: this element IS the listbox, and the id\n the trigger's `aria-controls` names is what gives it its accessible name.\n So it announces as a disabled option instead. Keyboard navigation skips\n it for free \u2014 arrow keys walk the registered `wr-option`s, and nothing\n registers this row. -->\n @if (hasNoResults()) {\n <div class=\"wr-select-panel__empty\" role=\"option\" aria-selected=\"false\" aria-disabled=\"true\">\n {{ resolvedNoResults() }}\n </div>\n }\n </div>\n</ng-template>\n", dependencies: [{ kind: "component", type: WrOption, selector: "wr-option", inputs: ["value", "disabled"] }], encapsulation: i0.ViewEncapsulation.None });
2056
+ ], queries: [{ propertyName: "defaultLeading", first: true, predicate: WrOptionLeading, isSignal: true }, { propertyName: "projectedOptions", predicate: WrOption, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "panelTpl", first: true, predicate: ["panelTpl"], descendants: true, read: TemplateRef, isSignal: true }, { propertyName: "tagInputEl", first: true, predicate: ["tagInput"], descendants: true, isSignal: true }, { propertyName: "searchInputEl", first: true, predicate: ["searchInput"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (isSearchTrigger()) {\n <!-- Search mode (or a searchable single): a real text input drives a filtered\n overlay panel. The wrapper isn't itself focusable \u2014 the input is the\n tabstop. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div class=\"wr-select__trigger wr-select__trigger--search\" (click)=\"focusSearchInput()\">\n <!-- The selected option's leading visual, before the field \u2014 the same class\n and the same `placement: 'value'` the button trigger uses, because it is\n the same surface and a consumer who styled one should not have to learn\n a second name. `searchLeading()` draws it only while the field is\n showing the VALUE, so it never stands in front of a query or a bare\n placeholder, and it sits OUTSIDE the input for the obvious reason: an\n `<input>` holds a string. `aria-hidden` keeps it out of the combobox's\n name, which is `resolvedAriaLabel()`, and out of its value, which is the\n input's own text. -->\n @if (searchLeading(); as leading) {\n <span class=\"wr-select__value-leading\" aria-hidden=\"true\">\n <ng-container\n [ngTemplateOutlet]=\"leading\"\n [ngTemplateOutletContext]=\"{ $implicit: value(), placement: 'value' }\"\n />\n </span>\n }\n <!-- `id` / `aria-describedby` / `aria-invalid` come from a surrounding\n `<wr-form-field>` and are null without one \u2014 see `controlId` in the\n component. They go on the tab stop, the only labelable element here.\n `aria-label` is the trigger's name and belongs on the same element for\n the same reason: this input carries `role=\"combobox\"`, and the four\n other trigger shapes all bind `resolvedAriaLabel()`. Without it a\n search select with no placeholder rendered a combobox with no name at\n all \u2014 the one shape where the documented `ariaLabel` input reached\n nothing. -->\n <!-- `aria-autocomplete` is static, and `list` rather than `both`: the panel\n is the only place a suggestion appears, nothing is written back into\n the field for the user. It describes a permanent CAPABILITY, which is\n why it does not track `open()` \u2014 the same reasoning `wr-mention`\n records at its own host binding. -->\n <input\n #searchInput\n type=\"text\"\n role=\"combobox\"\n class=\"wr-select__search-input\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"resolvedAriaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-haspopup]=\"'listbox'\"\n aria-autocomplete=\"list\"\n [attr.aria-expanded]=\"open()\"\n [attr.aria-controls]=\"listboxId\"\n [attr.aria-activedescendant]=\"open() ? activeOptionId() || null : null\"\n [attr.aria-readonly]=\"isReadonly() || null\"\n [readonly]=\"isReadonly()\"\n [placeholder]=\"resolvedPlaceholder()\"\n [value]=\"searchDisplay()\"\n (input)=\"onSearchInput($event)\"\n (focus)=\"onSearchFocus()\"\n (keydown)=\"onSearchKey($event)\"\n (blur)=\"onSearchBlur()\"\n />\n <!-- Pointer-only, and the `keydown.enter` / `keydown.space` pair this used to\n carry was dead code rather than the keyboard path it looked like: the \u00D7\n is `tabindex=\"-1\"` with no roving owner, so no key event ever originates\n here to bubble into a handler. It stays unfocusable because the same\n span sits inside the BUTTON trigger further down, where a focusable\n child is `nested-interactive` \u2014 the defect the window taskbar's close\n button already taught this repo. The keyboard reaches the function\n instead: Backspace on the empty field, which `onSearchKey` routes to\n `clearAll` for a single and to the last chip for a multi. -->\n @if (clearable() && hasSelection() && !isDisabled() && !isReadonly()) {\n <span\n class=\"wr-select__clear\"\n role=\"button\"\n [attr.aria-label]=\"resolvedClear()\"\n tabindex=\"-1\"\n (click)=\"clearAll($event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n <svg\n class=\"wr-icon__svg wr-select__chevron\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </div>\n} @else if (isTag() || hasChipSearch()) {\n <!-- Chip trigger \u2014 tag mode, or a searchable multi. A focusable input lives\n inside, so the wrapper itself only exists to host chips and forward\n whitespace clicks to the input. Tag mode needs no role (users tab\n straight into the input); the searchable multi is a real combobox, so it\n carries the listbox wiring. The click handler is a mouse-only\n convenience. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"wr-select__trigger wr-select__trigger--tag\"\n [attr.aria-disabled]=\"isDisabled() || null\"\n (click)=\"focusTagInput()\"\n >\n <span class=\"wr-select__chips\">\n @for (chip of visibleChips(); track $index) {\n <span class=\"wr-select__chip\">\n @if (chip.leading; as leading) {\n <span class=\"wr-select__chip-leading\" aria-hidden=\"true\">\n <ng-container\n [ngTemplateOutlet]=\"leading\"\n [ngTemplateOutletContext]=\"{ $implicit: chip.value, placement: 'chip' }\"\n />\n </span>\n }\n <span class=\"wr-select__chip-label\">{{ chip.label }}</span>\n @if (!isReadonly()) {\n <span\n class=\"wr-select__chip-remove\"\n role=\"button\"\n [attr.aria-label]=\"chipRemoveLabel({ label: chip.label })\"\n tabindex=\"-1\"\n (click)=\"removeChip($index, $event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n </span>\n }\n @if (hiddenChipCount(); as extra) {\n <span class=\"wr-select__chip wr-select__chip--more\">{{ moreLabel({ count: extra }) }}</span>\n }\n <!-- At `maxItems` this input goes READONLY, never disabled: it is the only\n tab stop in the whole control (the chip \u00D7 and the clear button are\n `tabindex=\"-1\"` spans), so disabling it left a full tag select with\n nothing focusable at all \u2014 and Backspace on an empty draft, the one\n keyboard way back UNDER the cap, is bound to this very element. It was\n never needed as enforcement either: `tryAddTag` already refuses past\n capacity. -->\n <!-- `aria-autocomplete` is conditional here, unlike the search input's:\n this field is only a combobox when a panel backs it. In plain tag\n mode there is no popup to suggest from, and claiming otherwise\n describes a control that does not exist. -->\n <input\n #tagInput\n type=\"text\"\n class=\"wr-select__tag-input\"\n autocomplete=\"off\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"resolvedAriaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n spellcheck=\"false\"\n [value]=\"hasChipSearch() ? searchQuery() : draft()\"\n [disabled]=\"isDisabled()\"\n [readonly]=\"isReadonly() || (isTag() && atCapacity())\"\n [attr.aria-readonly]=\"hasChipSearch() && isReadonly() ? true : null\"\n [placeholder]=\"hasSelection() ? '' : resolvedPlaceholder()\"\n [attr.role]=\"hasChipSearch() ? 'combobox' : null\"\n [attr.aria-haspopup]=\"hasChipSearch() ? 'listbox' : null\"\n [attr.aria-autocomplete]=\"hasChipSearch() ? 'list' : null\"\n [attr.aria-expanded]=\"hasChipSearch() ? open() : null\"\n [attr.aria-controls]=\"hasChipSearch() ? listboxId : null\"\n [attr.aria-activedescendant]=\"hasChipSearch() && open() ? activeOptionId() || null : null\"\n (input)=\"onChipsInput($event)\"\n (keydown)=\"onChipsKeydown($event)\"\n (paste)=\"onChipsPaste($event)\"\n (focus)=\"onChipsFocus()\"\n (blur)=\"onChipsBlur()\"\n />\n </span>\n\n @if (clearable() && hasSelection() && !isDisabled() && !isReadonly()) {\n <span\n class=\"wr-select__clear\"\n role=\"button\"\n [attr.aria-label]=\"resolvedClear()\"\n tabindex=\"-1\"\n (click)=\"clearAll($event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n </div>\n} @else {\n <button\n type=\"button\"\n class=\"wr-select__trigger\"\n role=\"combobox\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"resolvedAriaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-haspopup]=\"'listbox'\"\n [attr.aria-expanded]=\"open()\"\n [attr.aria-controls]=\"listboxId\"\n [attr.aria-activedescendant]=\"open() ? activeOptionId() || null : null\"\n [attr.aria-readonly]=\"isReadonly() || null\"\n (click)=\"onTriggerClick()\"\n (keydown)=\"onTriggerKey($event)\"\n (blur)=\"touch.emit()\"\n >\n @if (isMulti()) {\n @if (hasSelection()) {\n <span class=\"wr-select__chips\">\n @for (chip of visibleChips(); track $index) {\n <span class=\"wr-select__chip\">\n <!-- A selected chip draws its option's leading visual \u2014 one copy per\n VISIBLE chip, so `maxTagCount` caps these too. -->\n @if (chip.leading; as leading) {\n <span class=\"wr-select__chip-leading\" aria-hidden=\"true\">\n <ng-container\n [ngTemplateOutlet]=\"leading\"\n [ngTemplateOutletContext]=\"{ $implicit: chip.value, placement: 'chip' }\"\n />\n </span>\n }\n <span class=\"wr-select__chip-label\">{{ chip.label }}</span>\n @if (!isReadonly()) {\n <span\n class=\"wr-select__chip-remove\"\n role=\"button\"\n [attr.aria-label]=\"chipRemoveLabel({ label: chip.label })\"\n tabindex=\"-1\"\n (click)=\"removeChip($index, $event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n </span>\n }\n @if (hiddenChipCount(); as extra) {\n <span class=\"wr-select__chip wr-select__chip--more\">{{ moreLabel({ count: extra }) }}</span>\n }\n </span>\n } @else {\n <span class=\"wr-select__placeholder\">{{ resolvedPlaceholder() }}</span>\n }\n } @else {\n @if (selectedLabel(); as label) {\n <!-- Beside the label, not inside it: the harness and the accessible name\n both read `.wr-select__value`, and the visual says nothing either should. -->\n @if (selectedLeading(); as leading) {\n <span class=\"wr-select__value-leading\" aria-hidden=\"true\">\n <ng-container\n [ngTemplateOutlet]=\"leading\"\n [ngTemplateOutletContext]=\"{ $implicit: value(), placement: 'value' }\"\n />\n </span>\n }\n <span class=\"wr-select__value\">{{ label }}</span>\n } @else {\n <span class=\"wr-select__placeholder\">{{ resolvedPlaceholder() }}</span>\n }\n }\n\n @if (isMulti() && clearable() && hasSelection() && !isDisabled() && !isReadonly()) {\n <span\n class=\"wr-select__clear\"\n role=\"button\"\n [attr.aria-label]=\"resolvedClear()\"\n tabindex=\"-1\"\n (click)=\"clearAll($event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n\n <svg\n class=\"wr-icon__svg wr-select__chevron\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </button>\n}\n\n<ng-template #panelTpl>\n <div\n class=\"wr-select-panel\"\n [class.wr-select-panel--sm]=\"resolvedSize() === 'sm'\"\n [class.wr-select-panel--lg]=\"resolvedSize() === 'lg'\"\n [class.wr-select-panel--virtual]=\"virtualActive()\"\n role=\"listbox\"\n [attr.id]=\"listboxId\"\n [attr.aria-multiselectable]=\"isMulti() ? 'true' : null\"\n [attr.aria-busy]=\"isLoading() ? 'true' : null\"\n >\n <!-- `aria-busy` belongs on the listbox, not on this row: it is the container\n whose required `option` children are temporarily missing, and a global\n ARIA attribute on a role-less child makes that child an owned element the\n listbox may not own (axe `aria-required-children`, critical). Plain text\n inside the listbox is fine \u2014 only ELEMENTS are checked. -->\n @if (isLoading()) {\n <div class=\"wr-select-panel__loading\">{{ resolvedLoading() }}</div>\n }\n\n @if (virtualActive()) {\n <!-- Windowed data-array rows: plain role=\"option\" elements (not <wr-option>),\n navigated via the input's aria-activedescendant, selected by value. -->\n <div class=\"wr-select-panel__vlist\" [style.max-height]=\"resolvedViewportHeight()\">\n @if (virtualWindow().padTop > 0) {\n <div class=\"wr-select-panel__vspacer\" aria-hidden=\"true\" [style.height.px]=\"virtualWindow().padTop\"></div>\n }\n @for (entry of windowOptions(); track entry.index) {\n <!-- Keyboard handled by the search input via aria-activedescendant; the\n row is a pointer target only. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n role=\"option\"\n class=\"wr-option\"\n [id]=\"virtualRowId(entry.index)\"\n [class.wr-option--active]=\"entry.index === activeIndex()\"\n [class.wr-option--selected]=\"isSelected(entry.item)\"\n [attr.aria-selected]=\"isSelected(entry.item)\"\n [attr.aria-setsize]=\"filteredDynamicOptions().length\"\n [attr.aria-posinset]=\"entry.index + 1\"\n (click)=\"selectOption(entry.item)\"\n >\n <!-- Only the select-wide template reaches a virtual row \u2014 there is no\n `<wr-option>` here to declare one \u2014 and only rows inside the\n rendered window instantiate it. -->\n @if (optionLeading(); as leading) {\n <span class=\"wr-option__leading\" aria-hidden=\"true\">\n <ng-container\n [ngTemplateOutlet]=\"leading\"\n [ngTemplateOutletContext]=\"{ $implicit: entry.item, placement: 'option' }\"\n />\n </span>\n }\n {{ displayWith()(entry.item) }}\n </div>\n }\n @if (virtualWindow().padBottom > 0) {\n <div class=\"wr-select-panel__vspacer\" aria-hidden=\"true\" [style.height.px]=\"virtualWindow().padBottom\"></div>\n }\n </div>\n } @else {\n <!-- Dynamic options from [options] or async [loader] (search mode).\n Rendered as wr-option so the standard registration / filter\n pipeline applies uniformly. -->\n @for (item of dynamicOptions(); track $index) {\n <wr-option [value]=\"item\">{{ displayWith()(item) }}</wr-option>\n }\n }\n\n <!-- Projected static options \u2014 always present (a conditional `<ng-content>`\n would drop projected content). Empty whenever `virtualActive`. -->\n <ng-content />\n\n <!-- A listbox may only own `option` and `group` children, and as a bare `<div>`\n this row was the only thing in the tree when it showed \u2014 an\n `aria-required-children` critical. It cannot move out of the listbox the\n way the command palette's does: this element IS the listbox, and the id\n the trigger's `aria-controls` names is what gives it its accessible name.\n So it announces as a disabled option instead. Keyboard navigation skips\n it for free \u2014 arrow keys walk the registered `wr-option`s, and nothing\n registers this row. -->\n @if (hasNoResults()) {\n <div class=\"wr-select-panel__empty\" role=\"option\" aria-selected=\"false\" aria-disabled=\"true\">\n {{ resolvedNoResults() }}\n </div>\n }\n </div>\n</ng-template>\n", dependencies: [{ kind: "component", type: WrOption, selector: "wr-option", inputs: ["value", "disabled", "label"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], encapsulation: i0.ViewEncapsulation.None });
1808
2057
  }
1809
2058
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrSelect, decorators: [{
1810
2059
  type: Component,
1811
- args: [{ selector: 'wr-select', encapsulation: ViewEncapsulation.None, host: { '[class]': 'classes()' }, imports: [WrOption], providers: [
2060
+ args: [{ selector: 'wr-select', encapsulation: ViewEncapsulation.None, host: { '[class]': 'classes()' }, imports: [WrOption, NgTemplateOutlet], providers: [
1812
2061
  {
1813
2062
  provide: WR_SELECT,
1814
2063
  // eslint-disable-next-line @angular-eslint/no-forward-ref
1815
2064
  useExisting: forwardRef(() => WrSelect),
1816
2065
  },
1817
- ], template: "@if (isSearchTrigger()) {\n <!-- Search mode (or a searchable single): a real text input drives a filtered\n overlay panel. The wrapper isn't itself focusable \u2014 the input is the\n tabstop. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div class=\"wr-select__trigger wr-select__trigger--search\" (click)=\"focusSearchInput()\">\n <!-- `id` / `aria-describedby` / `aria-invalid` come from a surrounding\n `<wr-form-field>` and are null without one \u2014 see `controlId` in the\n component. They go on the tab stop, the only labelable element here.\n `aria-label` is the trigger's name and belongs on the same element for\n the same reason: this input carries `role=\"combobox\"`, and the four\n other trigger shapes all bind `resolvedAriaLabel()`. Without it a\n search select with no placeholder rendered a combobox with no name at\n all \u2014 the one shape where the documented `ariaLabel` input reached\n nothing. -->\n <!-- `aria-autocomplete` is static, and `list` rather than `both`: the panel\n is the only place a suggestion appears, nothing is written back into\n the field for the user. It describes a permanent CAPABILITY, which is\n why it does not track `open()` \u2014 the same reasoning `wr-mention`\n records at its own host binding. -->\n <input\n #searchInput\n type=\"text\"\n role=\"combobox\"\n class=\"wr-select__search-input\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"resolvedAriaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-haspopup]=\"'listbox'\"\n aria-autocomplete=\"list\"\n [attr.aria-expanded]=\"open()\"\n [attr.aria-controls]=\"listboxId\"\n [attr.aria-activedescendant]=\"open() ? activeOptionId() || null : null\"\n [attr.aria-readonly]=\"isReadonly() || null\"\n [readonly]=\"isReadonly()\"\n [placeholder]=\"resolvedPlaceholder()\"\n [value]=\"searchDisplay()\"\n (input)=\"onSearchInput($event)\"\n (focus)=\"onSearchFocus()\"\n (keydown)=\"onSearchKey($event)\"\n (blur)=\"onSearchBlur()\"\n />\n <!-- Pointer-only, and the `keydown.enter` / `keydown.space` pair this used to\n carry was dead code rather than the keyboard path it looked like: the \u00D7\n is `tabindex=\"-1\"` with no roving owner, so no key event ever originates\n here to bubble into a handler. It stays unfocusable because the same\n span sits inside the BUTTON trigger further down, where a focusable\n child is `nested-interactive` \u2014 the defect the window taskbar's close\n button already taught this repo. The keyboard reaches the function\n instead: Backspace on the empty field, which `onSearchKey` routes to\n `clearAll` for a single and to the last chip for a multi. -->\n @if (clearable() && hasSelection() && !isDisabled() && !isReadonly()) {\n <span\n class=\"wr-select__clear\"\n role=\"button\"\n [attr.aria-label]=\"resolvedClear()\"\n tabindex=\"-1\"\n (click)=\"clearAll($event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n <svg\n class=\"wr-icon__svg wr-select__chevron\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </div>\n} @else if (isTag() || hasChipSearch()) {\n <!-- Chip trigger \u2014 tag mode, or a searchable multi. A focusable input lives\n inside, so the wrapper itself only exists to host chips and forward\n whitespace clicks to the input. Tag mode needs no role (users tab\n straight into the input); the searchable multi is a real combobox, so it\n carries the listbox wiring. The click handler is a mouse-only\n convenience. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"wr-select__trigger wr-select__trigger--tag\"\n [attr.aria-disabled]=\"isDisabled() || null\"\n (click)=\"focusTagInput()\"\n >\n <span class=\"wr-select__chips\">\n @for (chip of visibleChips(); track $index) {\n <span class=\"wr-select__chip\">\n <span class=\"wr-select__chip-label\">{{ chip.label }}</span>\n @if (!isReadonly()) {\n <span\n class=\"wr-select__chip-remove\"\n role=\"button\"\n [attr.aria-label]=\"chipRemoveLabel({ label: chip.label })\"\n tabindex=\"-1\"\n (click)=\"removeChip($index, $event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n </span>\n }\n @if (hiddenChipCount(); as extra) {\n <span class=\"wr-select__chip wr-select__chip--more\">{{ moreLabel({ count: extra }) }}</span>\n }\n <!-- At `maxItems` this input goes READONLY, never disabled: it is the only\n tab stop in the whole control (the chip \u00D7 and the clear button are\n `tabindex=\"-1\"` spans), so disabling it left a full tag select with\n nothing focusable at all \u2014 and Backspace on an empty draft, the one\n keyboard way back UNDER the cap, is bound to this very element. It was\n never needed as enforcement either: `tryAddTag` already refuses past\n capacity. -->\n <!-- `aria-autocomplete` is conditional here, unlike the search input's:\n this field is only a combobox when a panel backs it. In plain tag\n mode there is no popup to suggest from, and claiming otherwise\n describes a control that does not exist. -->\n <input\n #tagInput\n type=\"text\"\n class=\"wr-select__tag-input\"\n autocomplete=\"off\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"resolvedAriaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n spellcheck=\"false\"\n [value]=\"hasChipSearch() ? searchQuery() : draft()\"\n [disabled]=\"isDisabled()\"\n [readonly]=\"isReadonly() || (isTag() && atCapacity())\"\n [attr.aria-readonly]=\"hasChipSearch() && isReadonly() ? true : null\"\n [placeholder]=\"hasSelection() ? '' : resolvedPlaceholder()\"\n [attr.role]=\"hasChipSearch() ? 'combobox' : null\"\n [attr.aria-haspopup]=\"hasChipSearch() ? 'listbox' : null\"\n [attr.aria-autocomplete]=\"hasChipSearch() ? 'list' : null\"\n [attr.aria-expanded]=\"hasChipSearch() ? open() : null\"\n [attr.aria-controls]=\"hasChipSearch() ? listboxId : null\"\n [attr.aria-activedescendant]=\"hasChipSearch() && open() ? activeOptionId() || null : null\"\n (input)=\"onChipsInput($event)\"\n (keydown)=\"onChipsKeydown($event)\"\n (paste)=\"onChipsPaste($event)\"\n (focus)=\"onChipsFocus()\"\n (blur)=\"onChipsBlur()\"\n />\n </span>\n\n @if (clearable() && hasSelection() && !isDisabled() && !isReadonly()) {\n <span\n class=\"wr-select__clear\"\n role=\"button\"\n [attr.aria-label]=\"resolvedClear()\"\n tabindex=\"-1\"\n (click)=\"clearAll($event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n </div>\n} @else {\n <button\n type=\"button\"\n class=\"wr-select__trigger\"\n role=\"combobox\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"resolvedAriaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-haspopup]=\"'listbox'\"\n [attr.aria-expanded]=\"open()\"\n [attr.aria-controls]=\"listboxId\"\n [attr.aria-activedescendant]=\"open() ? activeOptionId() || null : null\"\n [attr.aria-readonly]=\"isReadonly() || null\"\n (click)=\"onTriggerClick()\"\n (keydown)=\"onTriggerKey($event)\"\n (blur)=\"touch.emit()\"\n >\n @if (isMulti()) {\n @if (hasSelection()) {\n <span class=\"wr-select__chips\">\n @for (chip of visibleChips(); track $index) {\n <span class=\"wr-select__chip\">\n <span class=\"wr-select__chip-label\">{{ chip.label }}</span>\n @if (!isReadonly()) {\n <span\n class=\"wr-select__chip-remove\"\n role=\"button\"\n [attr.aria-label]=\"chipRemoveLabel({ label: chip.label })\"\n tabindex=\"-1\"\n (click)=\"removeChip($index, $event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n </span>\n }\n @if (hiddenChipCount(); as extra) {\n <span class=\"wr-select__chip wr-select__chip--more\">{{ moreLabel({ count: extra }) }}</span>\n }\n </span>\n } @else {\n <span class=\"wr-select__placeholder\">{{ resolvedPlaceholder() }}</span>\n }\n } @else {\n @if (selectedLabel(); as label) {\n <span class=\"wr-select__value\">{{ label }}</span>\n } @else {\n <span class=\"wr-select__placeholder\">{{ resolvedPlaceholder() }}</span>\n }\n }\n\n @if (isMulti() && clearable() && hasSelection() && !isDisabled() && !isReadonly()) {\n <span\n class=\"wr-select__clear\"\n role=\"button\"\n [attr.aria-label]=\"resolvedClear()\"\n tabindex=\"-1\"\n (click)=\"clearAll($event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n\n <svg\n class=\"wr-icon__svg wr-select__chevron\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </button>\n}\n\n<ng-template #panelTpl>\n <div\n class=\"wr-select-panel\"\n [class.wr-select-panel--sm]=\"resolvedSize() === 'sm'\"\n [class.wr-select-panel--lg]=\"resolvedSize() === 'lg'\"\n [class.wr-select-panel--virtual]=\"virtualActive()\"\n role=\"listbox\"\n [attr.id]=\"listboxId\"\n [attr.aria-multiselectable]=\"isMulti() ? 'true' : null\"\n [attr.aria-busy]=\"isLoading() ? 'true' : null\"\n >\n <!-- `aria-busy` belongs on the listbox, not on this row: it is the container\n whose required `option` children are temporarily missing, and a global\n ARIA attribute on a role-less child makes that child an owned element the\n listbox may not own (axe `aria-required-children`, critical). Plain text\n inside the listbox is fine \u2014 only ELEMENTS are checked. -->\n @if (isLoading()) {\n <div class=\"wr-select-panel__loading\">{{ resolvedLoading() }}</div>\n }\n\n @if (virtualActive()) {\n <!-- Windowed data-array rows: plain role=\"option\" elements (not <wr-option>),\n navigated via the input's aria-activedescendant, selected by value. -->\n <div class=\"wr-select-panel__vlist\" [style.max-height]=\"resolvedViewportHeight()\">\n @if (virtualWindow().padTop > 0) {\n <div class=\"wr-select-panel__vspacer\" aria-hidden=\"true\" [style.height.px]=\"virtualWindow().padTop\"></div>\n }\n @for (entry of windowOptions(); track entry.index) {\n <!-- Keyboard handled by the search input via aria-activedescendant; the\n row is a pointer target only. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n role=\"option\"\n class=\"wr-option\"\n [id]=\"virtualRowId(entry.index)\"\n [class.wr-option--active]=\"entry.index === activeIndex()\"\n [class.wr-option--selected]=\"isSelected(entry.item)\"\n [attr.aria-selected]=\"isSelected(entry.item)\"\n [attr.aria-setsize]=\"filteredDynamicOptions().length\"\n [attr.aria-posinset]=\"entry.index + 1\"\n (click)=\"selectOption(entry.item)\"\n >\n {{ displayWith()(entry.item) }}\n </div>\n }\n @if (virtualWindow().padBottom > 0) {\n <div class=\"wr-select-panel__vspacer\" aria-hidden=\"true\" [style.height.px]=\"virtualWindow().padBottom\"></div>\n }\n </div>\n } @else {\n <!-- Dynamic options from [options] or async [loader] (search mode).\n Rendered as wr-option so the standard registration / filter\n pipeline applies uniformly. -->\n @for (item of dynamicOptions(); track $index) {\n <wr-option [value]=\"item\">{{ displayWith()(item) }}</wr-option>\n }\n }\n\n <!-- Projected static options \u2014 always present (a conditional `<ng-content>`\n would drop projected content). Empty whenever `virtualActive`. -->\n <ng-content />\n\n <!-- A listbox may only own `option` and `group` children, and as a bare `<div>`\n this row was the only thing in the tree when it showed \u2014 an\n `aria-required-children` critical. It cannot move out of the listbox the\n way the command palette's does: this element IS the listbox, and the id\n the trigger's `aria-controls` names is what gives it its accessible name.\n So it announces as a disabled option instead. Keyboard navigation skips\n it for free \u2014 arrow keys walk the registered `wr-option`s, and nothing\n registers this row. -->\n @if (hasNoResults()) {\n <div class=\"wr-select-panel__empty\" role=\"option\" aria-selected=\"false\" aria-disabled=\"true\">\n {{ resolvedNoResults() }}\n </div>\n }\n </div>\n</ng-template>\n" }]
1818
- }], ctorParameters: () => [], propDecorators: { placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], clearLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearLabel", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], rounded: [{ type: i0.Input, args: [{ isSignal: true, alias: "rounded", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], responsive: [{ type: i0.Input, args: [{ isSignal: true, alias: "responsive", required: false }] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], searchable: [{ type: i0.Input, args: [{ isSignal: true, alias: "searchable", required: false }] }], searchQuery: [{ type: i0.Input, args: [{ isSignal: true, alias: "searchQuery", required: false }] }, { type: i0.Output, args: ["searchQueryChange"] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], displayWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "displayWith", required: false }] }], loader: [{ type: i0.Input, args: [{ isSignal: true, alias: "loader", required: false }] }], serverSearch: [{ type: i0.Input, args: [{ isSignal: true, alias: "serverSearch", required: false }] }], debounceMs: [{ type: i0.Input, args: [{ isSignal: true, alias: "debounceMs", required: false }] }], minChars: [{ type: i0.Input, args: [{ isSignal: true, alias: "minChars", required: false }] }], freeText: [{ type: i0.Input, args: [{ isSignal: true, alias: "freeText", required: false }] }], virtualScroll: [{ type: i0.Input, args: [{ isSignal: true, alias: "virtualScroll", required: false }] }], rowHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowHeight", required: false }] }], viewportHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "viewportHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], noResultsText: [{ type: i0.Input, args: [{ isSignal: true, alias: "noResultsText", required: false }] }], loadingText: [{ type: i0.Input, args: [{ isSignal: true, alias: "loadingText", required: false }] }], searchChange: [{ type: i0.Output, args: ["searchChange"] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], separators: [{ type: i0.Input, args: [{ isSignal: true, alias: "separators", required: false }] }], allowDuplicates: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowDuplicates", required: false }] }], validate: [{ type: i0.Input, args: [{ isSignal: true, alias: "validate", required: false }] }], clearable: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearable", required: false }] }], maxItems: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxItems", required: false }] }], maxTagCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxTagCount", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], touch: [{ type: i0.Output, args: ["touch"] }], projectedOptions: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => WrOption), { ...{ descendants: true }, isSignal: true }] }], panelTpl: [{ type: i0.ViewChild, args: ['panelTpl', { ...{ read: TemplateRef }, isSignal: true }] }], tagInputEl: [{ type: i0.ViewChild, args: ['tagInput', { isSignal: true }] }], searchInputEl: [{ type: i0.ViewChild, args: ['searchInput', { isSignal: true }] }] } });
2066
+ ], template: "@if (isSearchTrigger()) {\n <!-- Search mode (or a searchable single): a real text input drives a filtered\n overlay panel. The wrapper isn't itself focusable \u2014 the input is the\n tabstop. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div class=\"wr-select__trigger wr-select__trigger--search\" (click)=\"focusSearchInput()\">\n <!-- The selected option's leading visual, before the field \u2014 the same class\n and the same `placement: 'value'` the button trigger uses, because it is\n the same surface and a consumer who styled one should not have to learn\n a second name. `searchLeading()` draws it only while the field is\n showing the VALUE, so it never stands in front of a query or a bare\n placeholder, and it sits OUTSIDE the input for the obvious reason: an\n `<input>` holds a string. `aria-hidden` keeps it out of the combobox's\n name, which is `resolvedAriaLabel()`, and out of its value, which is the\n input's own text. -->\n @if (searchLeading(); as leading) {\n <span class=\"wr-select__value-leading\" aria-hidden=\"true\">\n <ng-container\n [ngTemplateOutlet]=\"leading\"\n [ngTemplateOutletContext]=\"{ $implicit: value(), placement: 'value' }\"\n />\n </span>\n }\n <!-- `id` / `aria-describedby` / `aria-invalid` come from a surrounding\n `<wr-form-field>` and are null without one \u2014 see `controlId` in the\n component. They go on the tab stop, the only labelable element here.\n `aria-label` is the trigger's name and belongs on the same element for\n the same reason: this input carries `role=\"combobox\"`, and the four\n other trigger shapes all bind `resolvedAriaLabel()`. Without it a\n search select with no placeholder rendered a combobox with no name at\n all \u2014 the one shape where the documented `ariaLabel` input reached\n nothing. -->\n <!-- `aria-autocomplete` is static, and `list` rather than `both`: the panel\n is the only place a suggestion appears, nothing is written back into\n the field for the user. It describes a permanent CAPABILITY, which is\n why it does not track `open()` \u2014 the same reasoning `wr-mention`\n records at its own host binding. -->\n <input\n #searchInput\n type=\"text\"\n role=\"combobox\"\n class=\"wr-select__search-input\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"resolvedAriaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-haspopup]=\"'listbox'\"\n aria-autocomplete=\"list\"\n [attr.aria-expanded]=\"open()\"\n [attr.aria-controls]=\"listboxId\"\n [attr.aria-activedescendant]=\"open() ? activeOptionId() || null : null\"\n [attr.aria-readonly]=\"isReadonly() || null\"\n [readonly]=\"isReadonly()\"\n [placeholder]=\"resolvedPlaceholder()\"\n [value]=\"searchDisplay()\"\n (input)=\"onSearchInput($event)\"\n (focus)=\"onSearchFocus()\"\n (keydown)=\"onSearchKey($event)\"\n (blur)=\"onSearchBlur()\"\n />\n <!-- Pointer-only, and the `keydown.enter` / `keydown.space` pair this used to\n carry was dead code rather than the keyboard path it looked like: the \u00D7\n is `tabindex=\"-1\"` with no roving owner, so no key event ever originates\n here to bubble into a handler. It stays unfocusable because the same\n span sits inside the BUTTON trigger further down, where a focusable\n child is `nested-interactive` \u2014 the defect the window taskbar's close\n button already taught this repo. The keyboard reaches the function\n instead: Backspace on the empty field, which `onSearchKey` routes to\n `clearAll` for a single and to the last chip for a multi. -->\n @if (clearable() && hasSelection() && !isDisabled() && !isReadonly()) {\n <span\n class=\"wr-select__clear\"\n role=\"button\"\n [attr.aria-label]=\"resolvedClear()\"\n tabindex=\"-1\"\n (click)=\"clearAll($event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n <svg\n class=\"wr-icon__svg wr-select__chevron\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </div>\n} @else if (isTag() || hasChipSearch()) {\n <!-- Chip trigger \u2014 tag mode, or a searchable multi. A focusable input lives\n inside, so the wrapper itself only exists to host chips and forward\n whitespace clicks to the input. Tag mode needs no role (users tab\n straight into the input); the searchable multi is a real combobox, so it\n carries the listbox wiring. The click handler is a mouse-only\n convenience. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"wr-select__trigger wr-select__trigger--tag\"\n [attr.aria-disabled]=\"isDisabled() || null\"\n (click)=\"focusTagInput()\"\n >\n <span class=\"wr-select__chips\">\n @for (chip of visibleChips(); track $index) {\n <span class=\"wr-select__chip\">\n @if (chip.leading; as leading) {\n <span class=\"wr-select__chip-leading\" aria-hidden=\"true\">\n <ng-container\n [ngTemplateOutlet]=\"leading\"\n [ngTemplateOutletContext]=\"{ $implicit: chip.value, placement: 'chip' }\"\n />\n </span>\n }\n <span class=\"wr-select__chip-label\">{{ chip.label }}</span>\n @if (!isReadonly()) {\n <span\n class=\"wr-select__chip-remove\"\n role=\"button\"\n [attr.aria-label]=\"chipRemoveLabel({ label: chip.label })\"\n tabindex=\"-1\"\n (click)=\"removeChip($index, $event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n </span>\n }\n @if (hiddenChipCount(); as extra) {\n <span class=\"wr-select__chip wr-select__chip--more\">{{ moreLabel({ count: extra }) }}</span>\n }\n <!-- At `maxItems` this input goes READONLY, never disabled: it is the only\n tab stop in the whole control (the chip \u00D7 and the clear button are\n `tabindex=\"-1\"` spans), so disabling it left a full tag select with\n nothing focusable at all \u2014 and Backspace on an empty draft, the one\n keyboard way back UNDER the cap, is bound to this very element. It was\n never needed as enforcement either: `tryAddTag` already refuses past\n capacity. -->\n <!-- `aria-autocomplete` is conditional here, unlike the search input's:\n this field is only a combobox when a panel backs it. In plain tag\n mode there is no popup to suggest from, and claiming otherwise\n describes a control that does not exist. -->\n <input\n #tagInput\n type=\"text\"\n class=\"wr-select__tag-input\"\n autocomplete=\"off\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"resolvedAriaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n spellcheck=\"false\"\n [value]=\"hasChipSearch() ? searchQuery() : draft()\"\n [disabled]=\"isDisabled()\"\n [readonly]=\"isReadonly() || (isTag() && atCapacity())\"\n [attr.aria-readonly]=\"hasChipSearch() && isReadonly() ? true : null\"\n [placeholder]=\"hasSelection() ? '' : resolvedPlaceholder()\"\n [attr.role]=\"hasChipSearch() ? 'combobox' : null\"\n [attr.aria-haspopup]=\"hasChipSearch() ? 'listbox' : null\"\n [attr.aria-autocomplete]=\"hasChipSearch() ? 'list' : null\"\n [attr.aria-expanded]=\"hasChipSearch() ? open() : null\"\n [attr.aria-controls]=\"hasChipSearch() ? listboxId : null\"\n [attr.aria-activedescendant]=\"hasChipSearch() && open() ? activeOptionId() || null : null\"\n (input)=\"onChipsInput($event)\"\n (keydown)=\"onChipsKeydown($event)\"\n (paste)=\"onChipsPaste($event)\"\n (focus)=\"onChipsFocus()\"\n (blur)=\"onChipsBlur()\"\n />\n </span>\n\n @if (clearable() && hasSelection() && !isDisabled() && !isReadonly()) {\n <span\n class=\"wr-select__clear\"\n role=\"button\"\n [attr.aria-label]=\"resolvedClear()\"\n tabindex=\"-1\"\n (click)=\"clearAll($event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n </div>\n} @else {\n <button\n type=\"button\"\n class=\"wr-select__trigger\"\n role=\"combobox\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"resolvedAriaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-haspopup]=\"'listbox'\"\n [attr.aria-expanded]=\"open()\"\n [attr.aria-controls]=\"listboxId\"\n [attr.aria-activedescendant]=\"open() ? activeOptionId() || null : null\"\n [attr.aria-readonly]=\"isReadonly() || null\"\n (click)=\"onTriggerClick()\"\n (keydown)=\"onTriggerKey($event)\"\n (blur)=\"touch.emit()\"\n >\n @if (isMulti()) {\n @if (hasSelection()) {\n <span class=\"wr-select__chips\">\n @for (chip of visibleChips(); track $index) {\n <span class=\"wr-select__chip\">\n <!-- A selected chip draws its option's leading visual \u2014 one copy per\n VISIBLE chip, so `maxTagCount` caps these too. -->\n @if (chip.leading; as leading) {\n <span class=\"wr-select__chip-leading\" aria-hidden=\"true\">\n <ng-container\n [ngTemplateOutlet]=\"leading\"\n [ngTemplateOutletContext]=\"{ $implicit: chip.value, placement: 'chip' }\"\n />\n </span>\n }\n <span class=\"wr-select__chip-label\">{{ chip.label }}</span>\n @if (!isReadonly()) {\n <span\n class=\"wr-select__chip-remove\"\n role=\"button\"\n [attr.aria-label]=\"chipRemoveLabel({ label: chip.label })\"\n tabindex=\"-1\"\n (click)=\"removeChip($index, $event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n </span>\n }\n @if (hiddenChipCount(); as extra) {\n <span class=\"wr-select__chip wr-select__chip--more\">{{ moreLabel({ count: extra }) }}</span>\n }\n </span>\n } @else {\n <span class=\"wr-select__placeholder\">{{ resolvedPlaceholder() }}</span>\n }\n } @else {\n @if (selectedLabel(); as label) {\n <!-- Beside the label, not inside it: the harness and the accessible name\n both read `.wr-select__value`, and the visual says nothing either should. -->\n @if (selectedLeading(); as leading) {\n <span class=\"wr-select__value-leading\" aria-hidden=\"true\">\n <ng-container\n [ngTemplateOutlet]=\"leading\"\n [ngTemplateOutletContext]=\"{ $implicit: value(), placement: 'value' }\"\n />\n </span>\n }\n <span class=\"wr-select__value\">{{ label }}</span>\n } @else {\n <span class=\"wr-select__placeholder\">{{ resolvedPlaceholder() }}</span>\n }\n }\n\n @if (isMulti() && clearable() && hasSelection() && !isDisabled() && !isReadonly()) {\n <span\n class=\"wr-select__clear\"\n role=\"button\"\n [attr.aria-label]=\"resolvedClear()\"\n tabindex=\"-1\"\n (click)=\"clearAll($event)\"\n >\n <svg\n class=\"wr-icon__svg\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </span>\n }\n\n <svg\n class=\"wr-icon__svg wr-select__chevron\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </button>\n}\n\n<ng-template #panelTpl>\n <div\n class=\"wr-select-panel\"\n [class.wr-select-panel--sm]=\"resolvedSize() === 'sm'\"\n [class.wr-select-panel--lg]=\"resolvedSize() === 'lg'\"\n [class.wr-select-panel--virtual]=\"virtualActive()\"\n role=\"listbox\"\n [attr.id]=\"listboxId\"\n [attr.aria-multiselectable]=\"isMulti() ? 'true' : null\"\n [attr.aria-busy]=\"isLoading() ? 'true' : null\"\n >\n <!-- `aria-busy` belongs on the listbox, not on this row: it is the container\n whose required `option` children are temporarily missing, and a global\n ARIA attribute on a role-less child makes that child an owned element the\n listbox may not own (axe `aria-required-children`, critical). Plain text\n inside the listbox is fine \u2014 only ELEMENTS are checked. -->\n @if (isLoading()) {\n <div class=\"wr-select-panel__loading\">{{ resolvedLoading() }}</div>\n }\n\n @if (virtualActive()) {\n <!-- Windowed data-array rows: plain role=\"option\" elements (not <wr-option>),\n navigated via the input's aria-activedescendant, selected by value. -->\n <div class=\"wr-select-panel__vlist\" [style.max-height]=\"resolvedViewportHeight()\">\n @if (virtualWindow().padTop > 0) {\n <div class=\"wr-select-panel__vspacer\" aria-hidden=\"true\" [style.height.px]=\"virtualWindow().padTop\"></div>\n }\n @for (entry of windowOptions(); track entry.index) {\n <!-- Keyboard handled by the search input via aria-activedescendant; the\n row is a pointer target only. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n role=\"option\"\n class=\"wr-option\"\n [id]=\"virtualRowId(entry.index)\"\n [class.wr-option--active]=\"entry.index === activeIndex()\"\n [class.wr-option--selected]=\"isSelected(entry.item)\"\n [attr.aria-selected]=\"isSelected(entry.item)\"\n [attr.aria-setsize]=\"filteredDynamicOptions().length\"\n [attr.aria-posinset]=\"entry.index + 1\"\n (click)=\"selectOption(entry.item)\"\n >\n <!-- Only the select-wide template reaches a virtual row \u2014 there is no\n `<wr-option>` here to declare one \u2014 and only rows inside the\n rendered window instantiate it. -->\n @if (optionLeading(); as leading) {\n <span class=\"wr-option__leading\" aria-hidden=\"true\">\n <ng-container\n [ngTemplateOutlet]=\"leading\"\n [ngTemplateOutletContext]=\"{ $implicit: entry.item, placement: 'option' }\"\n />\n </span>\n }\n {{ displayWith()(entry.item) }}\n </div>\n }\n @if (virtualWindow().padBottom > 0) {\n <div class=\"wr-select-panel__vspacer\" aria-hidden=\"true\" [style.height.px]=\"virtualWindow().padBottom\"></div>\n }\n </div>\n } @else {\n <!-- Dynamic options from [options] or async [loader] (search mode).\n Rendered as wr-option so the standard registration / filter\n pipeline applies uniformly. -->\n @for (item of dynamicOptions(); track $index) {\n <wr-option [value]=\"item\">{{ displayWith()(item) }}</wr-option>\n }\n }\n\n <!-- Projected static options \u2014 always present (a conditional `<ng-content>`\n would drop projected content). Empty whenever `virtualActive`. -->\n <ng-content />\n\n <!-- A listbox may only own `option` and `group` children, and as a bare `<div>`\n this row was the only thing in the tree when it showed \u2014 an\n `aria-required-children` critical. It cannot move out of the listbox the\n way the command palette's does: this element IS the listbox, and the id\n the trigger's `aria-controls` names is what gives it its accessible name.\n So it announces as a disabled option instead. Keyboard navigation skips\n it for free \u2014 arrow keys walk the registered `wr-option`s, and nothing\n registers this row. -->\n @if (hasNoResults()) {\n <div class=\"wr-select-panel__empty\" role=\"option\" aria-selected=\"false\" aria-disabled=\"true\">\n {{ resolvedNoResults() }}\n </div>\n }\n </div>\n</ng-template>\n" }]
2067
+ }], ctorParameters: () => [], propDecorators: { placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], clearLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearLabel", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], rounded: [{ type: i0.Input, args: [{ isSignal: true, alias: "rounded", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], responsive: [{ type: i0.Input, args: [{ isSignal: true, alias: "responsive", required: false }] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], searchable: [{ type: i0.Input, args: [{ isSignal: true, alias: "searchable", required: false }] }], searchQuery: [{ type: i0.Input, args: [{ isSignal: true, alias: "searchQuery", required: false }] }, { type: i0.Output, args: ["searchQueryChange"] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], displayWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "displayWith", required: false }] }], loader: [{ type: i0.Input, args: [{ isSignal: true, alias: "loader", required: false }] }], serverSearch: [{ type: i0.Input, args: [{ isSignal: true, alias: "serverSearch", required: false }] }], debounceMs: [{ type: i0.Input, args: [{ isSignal: true, alias: "debounceMs", required: false }] }], minChars: [{ type: i0.Input, args: [{ isSignal: true, alias: "minChars", required: false }] }], freeText: [{ type: i0.Input, args: [{ isSignal: true, alias: "freeText", required: false }] }], virtualScroll: [{ type: i0.Input, args: [{ isSignal: true, alias: "virtualScroll", required: false }] }], rowHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowHeight", required: false }] }], viewportHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "viewportHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], noResultsText: [{ type: i0.Input, args: [{ isSignal: true, alias: "noResultsText", required: false }] }], loadingText: [{ type: i0.Input, args: [{ isSignal: true, alias: "loadingText", required: false }] }], searchChange: [{ type: i0.Output, args: ["searchChange"] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], separators: [{ type: i0.Input, args: [{ isSignal: true, alias: "separators", required: false }] }], allowDuplicates: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowDuplicates", required: false }] }], validate: [{ type: i0.Input, args: [{ isSignal: true, alias: "validate", required: false }] }], clearable: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearable", required: false }] }], maxItems: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxItems", required: false }] }], maxTagCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxTagCount", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], touch: [{ type: i0.Output, args: ["touch"] }], defaultLeading: [{ type: i0.ContentChild, args: [i0.forwardRef(() => WrOptionLeading), { ...{ descendants: false }, isSignal: true }] }], projectedOptions: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => WrOption), { ...{ descendants: true }, isSignal: true }] }], panelTpl: [{ type: i0.ViewChild, args: ['panelTpl', { ...{ read: TemplateRef }, isSignal: true }] }], tagInputEl: [{ type: i0.ViewChild, args: ['tagInput', { isSignal: true }] }], searchInputEl: [{ type: i0.ViewChild, args: ['searchInput', { isSignal: true }] }] } });
1819
2068
 
1820
2069
  /**
1821
2070
  * @license
@@ -1855,5 +2104,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
1855
2104
  * Generated bundle index. Do not edit.
1856
2105
  */
1857
2106
 
1858
- export { WR_SELECT, WrOption, WrOptionGroup, WrSelect };
2107
+ export { WR_SELECT, WrOption, WrOptionGroup, WrOptionLeading, WrSelect };
1859
2108
  //# sourceMappingURL=ngwr-select.mjs.map