ngwr 15.2.0 → 15.3.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 (99) hide show
  1. package/alert/styles/_index.scss +13 -5
  2. package/fesm2022/ngwr-alert.mjs +2 -2
  3. package/fesm2022/ngwr-alert.mjs.map +1 -1
  4. package/fesm2022/ngwr-breadcrumbs.mjs +18 -2
  5. package/fesm2022/ngwr-breadcrumbs.mjs.map +1 -1
  6. package/fesm2022/ngwr-hotkey.mjs +23 -1
  7. package/fesm2022/ngwr-hotkey.mjs.map +1 -1
  8. package/fesm2022/ngwr-i18n-ar.mjs +2 -0
  9. package/fesm2022/ngwr-i18n-ar.mjs.map +1 -1
  10. package/fesm2022/ngwr-i18n-cs.mjs +2 -0
  11. package/fesm2022/ngwr-i18n-cs.mjs.map +1 -1
  12. package/fesm2022/ngwr-i18n-de.mjs +2 -0
  13. package/fesm2022/ngwr-i18n-de.mjs.map +1 -1
  14. package/fesm2022/ngwr-i18n-en.mjs +2 -0
  15. package/fesm2022/ngwr-i18n-en.mjs.map +1 -1
  16. package/fesm2022/ngwr-i18n-es.mjs +2 -0
  17. package/fesm2022/ngwr-i18n-es.mjs.map +1 -1
  18. package/fesm2022/ngwr-i18n-fr.mjs +2 -0
  19. package/fesm2022/ngwr-i18n-fr.mjs.map +1 -1
  20. package/fesm2022/ngwr-i18n-he.mjs +2 -0
  21. package/fesm2022/ngwr-i18n-he.mjs.map +1 -1
  22. package/fesm2022/ngwr-i18n-hi.mjs +2 -0
  23. package/fesm2022/ngwr-i18n-hi.mjs.map +1 -1
  24. package/fesm2022/ngwr-i18n-id.mjs +2 -0
  25. package/fesm2022/ngwr-i18n-id.mjs.map +1 -1
  26. package/fesm2022/ngwr-i18n-it.mjs +2 -0
  27. package/fesm2022/ngwr-i18n-it.mjs.map +1 -1
  28. package/fesm2022/ngwr-i18n-ja.mjs +2 -0
  29. package/fesm2022/ngwr-i18n-ja.mjs.map +1 -1
  30. package/fesm2022/ngwr-i18n-ko.mjs +2 -0
  31. package/fesm2022/ngwr-i18n-ko.mjs.map +1 -1
  32. package/fesm2022/ngwr-i18n-nl.mjs +2 -0
  33. package/fesm2022/ngwr-i18n-nl.mjs.map +1 -1
  34. package/fesm2022/ngwr-i18n-pl.mjs +2 -0
  35. package/fesm2022/ngwr-i18n-pl.mjs.map +1 -1
  36. package/fesm2022/ngwr-i18n-pt.mjs +2 -0
  37. package/fesm2022/ngwr-i18n-pt.mjs.map +1 -1
  38. package/fesm2022/ngwr-i18n-ru.mjs +2 -0
  39. package/fesm2022/ngwr-i18n-ru.mjs.map +1 -1
  40. package/fesm2022/ngwr-i18n-sv.mjs +2 -0
  41. package/fesm2022/ngwr-i18n-sv.mjs.map +1 -1
  42. package/fesm2022/ngwr-i18n-tr.mjs +2 -0
  43. package/fesm2022/ngwr-i18n-tr.mjs.map +1 -1
  44. package/fesm2022/ngwr-i18n-uk.mjs +2 -0
  45. package/fesm2022/ngwr-i18n-uk.mjs.map +1 -1
  46. package/fesm2022/ngwr-i18n-vi.mjs +2 -0
  47. package/fesm2022/ngwr-i18n-vi.mjs.map +1 -1
  48. package/fesm2022/ngwr-i18n-zh-tw.mjs +2 -0
  49. package/fesm2022/ngwr-i18n-zh-tw.mjs.map +1 -1
  50. package/fesm2022/ngwr-i18n-zh.mjs +2 -0
  51. package/fesm2022/ngwr-i18n-zh.mjs.map +1 -1
  52. package/fesm2022/ngwr-pagination.mjs +3 -0
  53. package/fesm2022/ngwr-pagination.mjs.map +1 -1
  54. package/fesm2022/ngwr-pipes.mjs +5 -2
  55. package/fesm2022/ngwr-pipes.mjs.map +1 -1
  56. package/fesm2022/ngwr-schema-form.mjs +3 -0
  57. package/fesm2022/ngwr-schema-form.mjs.map +1 -1
  58. package/fesm2022/ngwr-select-testing.mjs +16 -0
  59. package/fesm2022/ngwr-select-testing.mjs.map +1 -1
  60. package/fesm2022/ngwr-select.mjs +218 -38
  61. package/fesm2022/ngwr-select.mjs.map +1 -1
  62. package/fesm2022/ngwr-statistic.mjs +9 -5
  63. package/fesm2022/ngwr-statistic.mjs.map +1 -1
  64. package/fesm2022/ngwr-version.mjs +1 -1
  65. package/fesm2022/ngwr-version.mjs.map +1 -1
  66. package/i18n/ar.json +2 -0
  67. package/i18n/cs.json +2 -0
  68. package/i18n/de.json +2 -0
  69. package/i18n/en.json +2 -0
  70. package/i18n/es.json +2 -0
  71. package/i18n/fr.json +2 -0
  72. package/i18n/he.json +2 -0
  73. package/i18n/hi.json +2 -0
  74. package/i18n/id.json +2 -0
  75. package/i18n/it.json +2 -0
  76. package/i18n/ja.json +2 -0
  77. package/i18n/ko.json +2 -0
  78. package/i18n/nl.json +2 -0
  79. package/i18n/pl.json +2 -0
  80. package/i18n/pt.json +2 -0
  81. package/i18n/ru.json +2 -0
  82. package/i18n/sv.json +2 -0
  83. package/i18n/tr.json +2 -0
  84. package/i18n/uk.json +2 -0
  85. package/i18n/vi.json +2 -0
  86. package/i18n/zh-TW.json +2 -0
  87. package/i18n/zh.json +2 -0
  88. package/llms-full.txt +3 -3
  89. package/package.json +1 -1
  90. package/schematics/use/symbol-map.json +1 -0
  91. package/select/styles/_index.scss +23 -1
  92. package/skills/ngwr/references/catalog.md +1 -1
  93. package/types/ngwr-alert.d.ts +3 -1
  94. package/types/ngwr-breadcrumbs.d.ts +15 -0
  95. package/types/ngwr-hotkey.d.ts +14 -2
  96. package/types/ngwr-select-testing.d.ts +9 -0
  97. package/types/ngwr-select.d.ts +133 -2
  98. package/types/ngwr-sidebar.d.ts +1 -1
  99. package/types/ngwr-version.d.ts +1 -1
@@ -4,13 +4,52 @@ import { coerceBooleanProperty, coerceNumberProperty } from "@angular/cdk/coerci
4
4
  import { NgTemplateOutlet, isPlatformBrowser } from "@angular/common";
5
5
  import { useI18nFormatter, useI18nText } from "ngwr/i18n";
6
6
  import { isComposing, randomId, toClassList } from "ngwr/utils";
7
+ import { WrSpinner } from "ngwr/spinner";
7
8
  import { Directionality } from "@angular/cdk/bidi";
8
9
  import { ScrollStrategyOptions } from "@angular/cdk/overlay";
9
10
  import { TemplatePortal } from "@angular/cdk/portal";
10
11
  import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop";
11
12
  import { WR_FORM_FIELD, useFormFieldAria } from "ngwr/form";
12
13
  import { WR_OVERLAY, WrOutsideClick, wrFollowDirection } from "ngwr/overlay";
13
- import { catchError, debounce, defer, finalize, from, isObservable, of, skip, switchMap, tap, timer } from "rxjs";
14
+ import { Subject, catchError, debounce, defer, finalize, from, isObservable, map, merge, of, skip, switchMap, tap, timer } from "rxjs";
15
+ /**
16
+ * @license
17
+ *
18
+ * Use of this source code is governed by an MIT-style license that can be
19
+ * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
20
+ */
21
+ var WrSelectLoadError = class WrSelectLoadError {
22
+ template = inject(TemplateRef);
23
+ static ngTemplateContextGuard(_dir, _ctx) {
24
+ return true;
25
+ }
26
+ static ɵfac = i0.ɵɵngDeclareFactory({
27
+ minVersion: "12.0.0",
28
+ version: "22.2.4",
29
+ ngImport: i0,
30
+ type: WrSelectLoadError,
31
+ deps: [],
32
+ target: i0.ɵɵFactoryTarget.Directive
33
+ });
34
+ static ɵdir = i0.ɵɵngDeclareDirective({
35
+ minVersion: "14.0.0",
36
+ version: "22.2.4",
37
+ type: WrSelectLoadError,
38
+ isStandalone: true,
39
+ selector: "ng-template[wrSelectLoadError]",
40
+ ngImport: i0
41
+ });
42
+ };
43
+ i0.ɵɵngDeclareClassMetadata({
44
+ minVersion: "12.0.0",
45
+ version: "22.2.4",
46
+ ngImport: i0,
47
+ type: WrSelectLoadError,
48
+ decorators: [{
49
+ type: Directive,
50
+ args: [{ selector: "ng-template[wrSelectLoadError]" }]
51
+ }]
52
+ });
14
53
  /**
15
54
  * @license
16
55
  *
@@ -370,8 +409,16 @@ var WrSelect = class WrSelect {
370
409
  });
371
410
  noResultsText = input(null, ...ngDevMode ? [{ debugName: "noResultsText" }] : /* istanbul ignore next */ []);
372
411
  loadingText = input(null, ...ngDevMode ? [{ debugName: "loadingText" }] : /* istanbul ignore next */ []);
412
+ loadErrorText = input(null, ...ngDevMode ? [{ debugName: "loadErrorText" }] : /* istanbul ignore next */ []);
413
+ retryable = input(false, {
414
+ ...ngDevMode ? { debugName: "retryable" } : /* istanbul ignore next */ {},
415
+ transform: coerceBooleanProperty
416
+ });
417
+ retryText = input(null, ...ngDevMode ? [{ debugName: "retryText" }] : /* istanbul ignore next */ []);
373
418
  resolvedNoResults = useI18nText(this.noResultsText, "select.noResults", "No results");
374
419
  resolvedLoading = useI18nText(this.loadingText, "select.loading", "Loading…");
420
+ resolvedLoadError = useI18nText(this.loadErrorText, "select.loadError", "Could not load results");
421
+ resolvedRetry = useI18nText(this.retryText, "select.retry", "Try again");
375
422
  searchChange = output();
376
423
  loading = input(false, {
377
424
  ...ngDevMode ? { debugName: "loading" } : /* istanbul ignore next */ {},
@@ -380,6 +427,11 @@ var WrSelect = class WrSelect {
380
427
  loadedOptions = signal([], ...ngDevMode ? [{ debugName: "loadedOptions" }] : /* istanbul ignore next */ []);
381
428
  loaderLoading = signal(false, ...ngDevMode ? [{ debugName: "loaderLoading" }] : /* istanbul ignore next */ []);
382
429
  isLoading = computed(() => this.loading() || this.loaderLoading(), ...ngDevMode ? [{ debugName: "isLoading" }] : /* istanbul ignore next */ []);
430
+ failedQuery = signal(null, ...ngDevMode ? [{ debugName: "failedQuery" }] : /* istanbul ignore next */ []);
431
+ loadErrorValue = signal(null, ...ngDevMode ? [{ debugName: "loadErrorValue" }] : /* istanbul ignore next */ []);
432
+ retrying = signal(false, ...ngDevMode ? [{ debugName: "retrying" }] : /* istanbul ignore next */ []);
433
+ loadFailed = computed(() => this.failedQuery() === this.searchQuery(), ...ngDevMode ? [{ debugName: "loadFailed" }] : /* istanbul ignore next */ []);
434
+ retryRequests = new Subject();
383
435
  separators = input(["Enter", ","], ...ngDevMode ? [{ debugName: "separators" }] : /* istanbul ignore next */ []);
384
436
  allowDuplicates = input(false, {
385
437
  ...ngDevMode ? { debugName: "allowDuplicates" } : /* istanbul ignore next */ {},
@@ -418,6 +470,7 @@ var WrSelect = class WrSelect {
418
470
  }, ...ngDevMode ? [{ debugName: "orderedRegistry" }] : /* istanbul ignore next */ []);
419
471
  activeIndex = signal(-1, ...ngDevMode ? [{ debugName: "activeIndex" }] : /* istanbul ignore next */ []);
420
472
  activeOptionId = computed(() => {
473
+ if (this.retryOffered() && !this.freeText()) return this.retryRowId;
421
474
  const idx = this.activeIndex();
422
475
  if (this.virtualActive()) {
423
476
  const { start, end } = this.virtualWindow();
@@ -616,10 +669,25 @@ var WrSelect = class WrSelect {
616
669
  }, ...ngDevMode ? [{ debugName: "visibleCount" }] : /* istanbul ignore next */ []);
617
670
  meetsMinChars = computed(() => !this.isSearchable() || this.searchQuery().trim().length >= this.minChars(), ...ngDevMode ? [{ debugName: "meetsMinChars" }] : /* istanbul ignore next */ []);
618
671
  hasNoResults = computed(() => {
619
- if (!this.isSearchable() || !this.open() || this.isLoading()) return false;
672
+ if (!this.isSearchable() || !this.open() || this.isLoading() || this.loadFailed()) return false;
620
673
  if (this.searchQuery().trim().length < this.minChars()) return false;
621
674
  return this.visibleCount() === 0;
622
675
  }, ...ngDevMode ? [{ debugName: "hasNoResults" }] : /* istanbul ignore next */ []);
676
+ showLoadError = computed(() => this.isSearchable() && this.open() && this.loadFailed() && (!this.isLoading() || this.retrying()), ...ngDevMode ? [{ debugName: "showLoadError" }] : /* istanbul ignore next */ []);
677
+ loadErrorTpl = contentChild(WrSelectLoadError, {
678
+ ...ngDevMode ? { debugName: "loadErrorTpl" } : /* istanbul ignore next */ {},
679
+ descendants: false
680
+ });
681
+ loadErrorContext = computed(() => ({
682
+ $implicit: this.loadErrorValue(),
683
+ query: this.failedQuery() ?? "",
684
+ canRetry: this.retryOffered(),
685
+ retry: this.retryFn,
686
+ retrying: this.retrying()
687
+ }), ...ngDevMode ? [{ debugName: "loadErrorContext" }] : /* istanbul ignore next */ []);
688
+ retryFn = () => this.onLoadErrorClick();
689
+ retryOffered = computed(() => this.retryable() && this.showLoadError() && this.visibleCount() === 0, ...ngDevMode ? [{ debugName: "retryOffered" }] : /* istanbul ignore next */ []);
690
+ retryRowId = `${this.listboxId}-retry`;
623
691
  isOptionHidden(label) {
624
692
  if (!this.isSearchable() || !this.clientFilter()) return false;
625
693
  const q = this.searchQuery().trim().toLowerCase();
@@ -709,6 +777,11 @@ var WrSelect = class WrSelect {
709
777
  }
710
778
  onSearchKey(event) {
711
779
  if (this.imeOwnsKey(event)) return;
780
+ if (event.key === "Enter" && this.retryOffered() && !this.freeText()) {
781
+ event.preventDefault();
782
+ this.retryLoad();
783
+ return;
784
+ }
712
785
  if (event.key === "Enter" && this.freeText() && this.open()) {
713
786
  const q = this.searchQuery().trim();
714
787
  const idx = this.activeIndex();
@@ -769,17 +842,43 @@ var WrSelect = class WrSelect {
769
842
  debouncedQuery.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((query) => {
770
843
  if (this.isSearchable()) this.searchChange.emit(query);
771
844
  });
772
- debouncedQuery.pipe(switchMap((query) => {
845
+ merge(debouncedQuery.pipe(map((query) => ({
846
+ query,
847
+ retry: false
848
+ }))), this.retryRequests.pipe(map(() => ({
849
+ query: this.failedQuery() ?? this.searchQuery(),
850
+ retry: true
851
+ })))).pipe(switchMap(({ query, retry }) => {
773
852
  const loader = this.loader();
774
853
  if (!loader || !this.isSearchable() || query.length < this.minChars()) {
775
854
  this.loaderLoading.set(false);
855
+ this.retrying.set(false);
856
+ this.failedQuery.set(null);
776
857
  return of([]);
777
858
  }
859
+ this.retrying.set(retry);
860
+ if (!retry) this.failedQuery.set(null);
861
+ let settled = false;
778
862
  this.loaderLoading.set(true);
779
863
  return defer(() => {
780
864
  const result = loader(query);
781
865
  return isObservable(result) ? result : from(Promise.resolve(result));
782
- }).pipe(tap(() => this.loaderLoading.set(false)), catchError(() => of([])), finalize(() => this.loaderLoading.set(false)));
866
+ }).pipe(tap(() => {
867
+ settled = true;
868
+ this.loaderLoading.set(false);
869
+ this.failedQuery.set(null);
870
+ this.retrying.set(false);
871
+ }), catchError((error) => {
872
+ settled = true;
873
+ this.failedQuery.set(query);
874
+ this.loadErrorValue.set(error);
875
+ this.retrying.set(false);
876
+ return of([]);
877
+ }), finalize(() => {
878
+ this.loaderLoading.set(false);
879
+ this.retrying.set(false);
880
+ if (retry && !settled) this.failedQuery.set(null);
881
+ }));
783
882
  }), takeUntilDestroyed(this.destroyRef)).subscribe((items) => this.loadedOptions.set(items));
784
883
  effect((onCleanup) => {
785
884
  if (!isPlatformBrowser(this.platformId)) return;
@@ -823,6 +922,13 @@ var WrSelect = class WrSelect {
823
922
  if (this.isMulti()) return this.asArray(this.value()).includes(value);
824
923
  return this.value() === value;
825
924
  }
925
+ retryLoad() {
926
+ if (this.retrying()) return;
927
+ this.retryRequests.next();
928
+ }
929
+ onLoadErrorClick() {
930
+ if (this.retryOffered()) this.retryLoad();
931
+ }
826
932
  selectOption(value) {
827
933
  if (this.isReadonly()) return;
828
934
  if (this.isMulti()) {
@@ -1257,6 +1363,27 @@ var WrSelect = class WrSelect {
1257
1363
  isRequired: false,
1258
1364
  transformFunction: null
1259
1365
  },
1366
+ loadErrorText: {
1367
+ classPropertyName: "loadErrorText",
1368
+ publicName: "loadErrorText",
1369
+ isSignal: true,
1370
+ isRequired: false,
1371
+ transformFunction: null
1372
+ },
1373
+ retryable: {
1374
+ classPropertyName: "retryable",
1375
+ publicName: "retryable",
1376
+ isSignal: true,
1377
+ isRequired: false,
1378
+ transformFunction: null
1379
+ },
1380
+ retryText: {
1381
+ classPropertyName: "retryText",
1382
+ publicName: "retryText",
1383
+ isSignal: true,
1384
+ isRequired: false,
1385
+ transformFunction: null
1386
+ },
1260
1387
  loading: {
1261
1388
  classPropertyName: "loading",
1262
1389
  publicName: "loading",
@@ -1332,17 +1459,26 @@ var WrSelect = class WrSelect {
1332
1459
  provide: WR_SELECT,
1333
1460
  useExisting: forwardRef(() => WrSelect)
1334
1461
  }],
1335
- queries: [{
1336
- propertyName: "defaultLeading",
1337
- first: true,
1338
- predicate: WrOptionLeading,
1339
- isSignal: true
1340
- }, {
1341
- propertyName: "projectedOptions",
1342
- predicate: WrOption,
1343
- descendants: true,
1344
- isSignal: true
1345
- }],
1462
+ queries: [
1463
+ {
1464
+ propertyName: "defaultLeading",
1465
+ first: true,
1466
+ predicate: WrOptionLeading,
1467
+ isSignal: true
1468
+ },
1469
+ {
1470
+ propertyName: "projectedOptions",
1471
+ predicate: WrOption,
1472
+ descendants: true,
1473
+ isSignal: true
1474
+ },
1475
+ {
1476
+ propertyName: "loadErrorTpl",
1477
+ first: true,
1478
+ predicate: WrSelectLoadError,
1479
+ isSignal: true
1480
+ }
1481
+ ],
1346
1482
  viewQueries: [
1347
1483
  {
1348
1484
  propertyName: "panelTpl",
@@ -1375,26 +1511,35 @@ var WrSelect = class WrSelect {
1375
1511
  }
1376
1512
  ],
1377
1513
  ngImport: i0,
1378
- 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 — 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 — 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 — 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 — 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()` — 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]=\"nameLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\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 ×\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` — 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 — 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 × and the clear button are\n `tabindex=\"-1\"` spans), so disabling it left a full tag select with\n nothing focusable at all — 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]=\"nameLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\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\n <!-- Only for the searchable MULTI, never for tag mode. The two share this\n trigger and differ in the one thing a chevron stands for: the searchable\n multi's input is a `role=\"combobox\"` with `aria-haspopup=\"listbox\"` and\n a panel of options behind it, while tag mode takes free text and has no\n popup at all. Folding the second into this branch left it as the only\n combobox shape in the component with no affordance saying it opens —\n `mode=\"search\"` has one, a searchable single has one, the button trigger\n has one, and this one rendered a bare field. -->\n @if (hasChipSearch()) {\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 }\n </div>\n} @else {\n <button\n #trigger\n type=\"button\"\n class=\"wr-select__trigger\"\n role=\"combobox\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"nameLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\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 — 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]=\"size() === 'sm'\"\n [class.wr-select-panel--lg]=\"size() === '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 — 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 [class.wr-option--disabled]=\"atMaxItems() && !isSelected(entry.item)\"\n [attr.aria-selected]=\"isSelected(entry.item)\"\n [attr.aria-disabled]=\"atMaxItems() && !isSelected(entry.item) ? true : null\"\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 — there is no\n `<wr-option>` here to declare one — 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 — 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 — 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 — 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",
1379
- dependencies: [{
1380
- kind: "component",
1381
- type: WrOption,
1382
- selector: "wr-option",
1383
- inputs: [
1384
- "value",
1385
- "disabled",
1386
- "label"
1387
- ]
1388
- }, {
1389
- kind: "directive",
1390
- type: NgTemplateOutlet,
1391
- selector: "[ngTemplateOutlet]",
1392
- inputs: [
1393
- "ngTemplateOutletContext",
1394
- "ngTemplateOutlet",
1395
- "ngTemplateOutletInjector"
1396
- ]
1397
- }],
1514
+ 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 — 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 — 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 — 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 — 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()` — 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]=\"nameLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\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 ×\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` — 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 — 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 × and the clear button are\n `tabindex=\"-1\"` spans), so disabling it left a full tag select with\n nothing focusable at all — 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]=\"nameLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\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\n <!-- Only for the searchable MULTI, never for tag mode. The two share this\n trigger and differ in the one thing a chevron stands for: the searchable\n multi's input is a `role=\"combobox\"` with `aria-haspopup=\"listbox\"` and\n a panel of options behind it, while tag mode takes free text and has no\n popup at all. Folding the second into this branch left it as the only\n combobox shape in the component with no affordance saying it opens —\n `mode=\"search\"` has one, a searchable single has one, the button trigger\n has one, and this one rendered a bare field. -->\n @if (hasChipSearch()) {\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 }\n </div>\n} @else {\n <button\n #trigger\n type=\"button\"\n class=\"wr-select__trigger\"\n role=\"combobox\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"nameLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\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 — 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]=\"size() === 'sm'\"\n [class.wr-select-panel--lg]=\"size() === '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 — only ELEMENTS are checked. -->\n <!-- While the error row is up, through a retry, it carries the loader. -->\n @if (isLoading() && !showLoadError()) {\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 [class.wr-option--disabled]=\"atMaxItems() && !isSelected(entry.item)\"\n [attr.aria-selected]=\"isSelected(entry.item)\"\n [attr.aria-disabled]=\"atMaxItems() && !isSelected(entry.item) ? true : null\"\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 — there is no\n `<wr-option>` here to declare one — 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 — 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 — 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 — 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\n <!-- A failed load, in place of \"no results\". An option for the same reason as\n that row. While it offers a retry it is the option that runs the loader\n again: the field's aria-activedescendant names it, Enter there reaches it,\n and mousedown keeps focus in the field. The retry is DRAWN as a button but\n cannot be one, since a button inside an option is nested interactive\n content; it wears `.wr-btn` and takes the click. -->\n @if (showLoadError()) {\n <div\n class=\"wr-select-panel__error\"\n [class.wr-select-panel__error--retryable]=\"retryOffered()\"\n [class.wr-select-panel__error--active]=\"retryOffered() && !freeText()\"\n role=\"option\"\n aria-selected=\"false\"\n [attr.id]=\"retryOffered() ? retryRowId : null\"\n [attr.aria-disabled]=\"retryOffered() ? null : true\"\n (mousedown)=\"$event.preventDefault()\"\n >\n @if (loadErrorTpl(); as custom) {\n <ng-container [ngTemplateOutlet]=\"custom.template\" [ngTemplateOutletContext]=\"loadErrorContext()\" />\n } @else {\n {{ resolvedLoadError() }}\n @if (retryOffered()) {\n <!-- The spinner as `wr-btn` draws it while loading: hidden from the\n accessibility tree, since the row already reads the retry label. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span\n class=\"wr-btn wr-btn--sm wr-select-panel__retry\"\n [class.wr-btn--loading]=\"retrying()\"\n (click)=\"onLoadErrorClick()\"\n >\n @if (retrying()) {\n <wr-spinner class=\"wr-btn__spin\" size=\"sm\" aria-hidden=\"true\" />\n }\n <span class=\"wr-btn__label\">{{ resolvedRetry() }}</span>\n </span>\n }\n }\n </div>\n }\n </div>\n</ng-template>\n",
1515
+ dependencies: [
1516
+ {
1517
+ kind: "component",
1518
+ type: WrOption,
1519
+ selector: "wr-option",
1520
+ inputs: [
1521
+ "value",
1522
+ "disabled",
1523
+ "label"
1524
+ ]
1525
+ },
1526
+ {
1527
+ kind: "directive",
1528
+ type: NgTemplateOutlet,
1529
+ selector: "[ngTemplateOutlet]",
1530
+ inputs: [
1531
+ "ngTemplateOutletContext",
1532
+ "ngTemplateOutlet",
1533
+ "ngTemplateOutletInjector"
1534
+ ]
1535
+ },
1536
+ {
1537
+ kind: "component",
1538
+ type: WrSpinner,
1539
+ selector: "wr-spinner",
1540
+ inputs: ["size", "ariaLabel"]
1541
+ }
1542
+ ],
1398
1543
  encapsulation: i0.ViewEncapsulation.None
1399
1544
  });
1400
1545
  };
@@ -1409,12 +1554,16 @@ i0.ɵɵngDeclareClassMetadata({
1409
1554
  selector: "wr-select",
1410
1555
  encapsulation: ViewEncapsulation.None,
1411
1556
  host: { "[class]": "classes()" },
1412
- imports: [WrOption, NgTemplateOutlet],
1557
+ imports: [
1558
+ WrOption,
1559
+ NgTemplateOutlet,
1560
+ WrSpinner
1561
+ ],
1413
1562
  providers: [{
1414
1563
  provide: WR_SELECT,
1415
1564
  useExisting: forwardRef(() => WrSelect)
1416
1565
  }],
1417
- 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 — 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 — 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 — 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 — 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()` — 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]=\"nameLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\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 ×\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` — 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 — 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 × and the clear button are\n `tabindex=\"-1\"` spans), so disabling it left a full tag select with\n nothing focusable at all — 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]=\"nameLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\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\n <!-- Only for the searchable MULTI, never for tag mode. The two share this\n trigger and differ in the one thing a chevron stands for: the searchable\n multi's input is a `role=\"combobox\"` with `aria-haspopup=\"listbox\"` and\n a panel of options behind it, while tag mode takes free text and has no\n popup at all. Folding the second into this branch left it as the only\n combobox shape in the component with no affordance saying it opens —\n `mode=\"search\"` has one, a searchable single has one, the button trigger\n has one, and this one rendered a bare field. -->\n @if (hasChipSearch()) {\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 }\n </div>\n} @else {\n <button\n #trigger\n type=\"button\"\n class=\"wr-select__trigger\"\n role=\"combobox\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"nameLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\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 — 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]=\"size() === 'sm'\"\n [class.wr-select-panel--lg]=\"size() === '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 — 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 [class.wr-option--disabled]=\"atMaxItems() && !isSelected(entry.item)\"\n [attr.aria-selected]=\"isSelected(entry.item)\"\n [attr.aria-disabled]=\"atMaxItems() && !isSelected(entry.item) ? true : null\"\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 — there is no\n `<wr-option>` here to declare one — 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 — 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 — 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 — 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"
1566
+ 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 — 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 — 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 — 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 — 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()` — 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]=\"nameLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\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 ×\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` — 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 — 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 × and the clear button are\n `tabindex=\"-1\"` spans), so disabling it left a full tag select with\n nothing focusable at all — 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]=\"nameLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\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\n <!-- Only for the searchable MULTI, never for tag mode. The two share this\n trigger and differ in the one thing a chevron stands for: the searchable\n multi's input is a `role=\"combobox\"` with `aria-haspopup=\"listbox\"` and\n a panel of options behind it, while tag mode takes free text and has no\n popup at all. Folding the second into this branch left it as the only\n combobox shape in the component with no affordance saying it opens —\n `mode=\"search\"` has one, a searchable single has one, the button trigger\n has one, and this one rendered a bare field. -->\n @if (hasChipSearch()) {\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 }\n </div>\n} @else {\n <button\n #trigger\n type=\"button\"\n class=\"wr-select__trigger\"\n role=\"combobox\"\n [attr.id]=\"controlId()\"\n [attr.aria-label]=\"nameLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\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 — 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]=\"size() === 'sm'\"\n [class.wr-select-panel--lg]=\"size() === '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 — only ELEMENTS are checked. -->\n <!-- While the error row is up, through a retry, it carries the loader. -->\n @if (isLoading() && !showLoadError()) {\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 [class.wr-option--disabled]=\"atMaxItems() && !isSelected(entry.item)\"\n [attr.aria-selected]=\"isSelected(entry.item)\"\n [attr.aria-disabled]=\"atMaxItems() && !isSelected(entry.item) ? true : null\"\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 — there is no\n `<wr-option>` here to declare one — 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 — 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 — 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 — 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\n <!-- A failed load, in place of \"no results\". An option for the same reason as\n that row. While it offers a retry it is the option that runs the loader\n again: the field's aria-activedescendant names it, Enter there reaches it,\n and mousedown keeps focus in the field. The retry is DRAWN as a button but\n cannot be one, since a button inside an option is nested interactive\n content; it wears `.wr-btn` and takes the click. -->\n @if (showLoadError()) {\n <div\n class=\"wr-select-panel__error\"\n [class.wr-select-panel__error--retryable]=\"retryOffered()\"\n [class.wr-select-panel__error--active]=\"retryOffered() && !freeText()\"\n role=\"option\"\n aria-selected=\"false\"\n [attr.id]=\"retryOffered() ? retryRowId : null\"\n [attr.aria-disabled]=\"retryOffered() ? null : true\"\n (mousedown)=\"$event.preventDefault()\"\n >\n @if (loadErrorTpl(); as custom) {\n <ng-container [ngTemplateOutlet]=\"custom.template\" [ngTemplateOutletContext]=\"loadErrorContext()\" />\n } @else {\n {{ resolvedLoadError() }}\n @if (retryOffered()) {\n <!-- The spinner as `wr-btn` draws it while loading: hidden from the\n accessibility tree, since the row already reads the retry label. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span\n class=\"wr-btn wr-btn--sm wr-select-panel__retry\"\n [class.wr-btn--loading]=\"retrying()\"\n (click)=\"onLoadErrorClick()\"\n >\n @if (retrying()) {\n <wr-spinner class=\"wr-btn__spin\" size=\"sm\" aria-hidden=\"true\" />\n }\n <span class=\"wr-btn__label\">{{ resolvedRetry() }}</span>\n </span>\n }\n }\n </div>\n }\n </div>\n</ng-template>\n"
1418
1567
  }]
1419
1568
  }],
1420
1569
  ctorParameters: () => [],
@@ -1606,6 +1755,30 @@ i0.ɵɵngDeclareClassMetadata({
1606
1755
  required: false
1607
1756
  }]
1608
1757
  }],
1758
+ loadErrorText: [{
1759
+ type: i0.Input,
1760
+ args: [{
1761
+ isSignal: true,
1762
+ alias: "loadErrorText",
1763
+ required: false
1764
+ }]
1765
+ }],
1766
+ retryable: [{
1767
+ type: i0.Input,
1768
+ args: [{
1769
+ isSignal: true,
1770
+ alias: "retryable",
1771
+ required: false
1772
+ }]
1773
+ }],
1774
+ retryText: [{
1775
+ type: i0.Input,
1776
+ args: [{
1777
+ isSignal: true,
1778
+ alias: "retryText",
1779
+ required: false
1780
+ }]
1781
+ }],
1609
1782
  searchChange: [{
1610
1783
  type: i0.Output,
1611
1784
  args: ["searchChange"]
@@ -1703,6 +1876,13 @@ i0.ɵɵngDeclareClassMetadata({
1703
1876
  isSignal: true
1704
1877
  }]
1705
1878
  }],
1879
+ loadErrorTpl: [{
1880
+ type: i0.ContentChild,
1881
+ args: [i0.forwardRef(() => WrSelectLoadError), {
1882
+ descendants: false,
1883
+ isSignal: true
1884
+ }]
1885
+ }],
1706
1886
  panelTpl: [{
1707
1887
  type: i0.ViewChild,
1708
1888
  args: ["panelTpl", {
@@ -1791,6 +1971,6 @@ i0.ɵɵngDeclareClassMetadata({
1791
1971
  }]
1792
1972
  }] }
1793
1973
  });
1794
- export { WR_SELECT, WrOption, WrOptionGroup, WrOptionLeading, WrSelect };
1974
+ export { WR_SELECT, WrOption, WrOptionGroup, WrOptionLeading, WrSelect, WrSelectLoadError };
1795
1975
 
1796
1976
  //# sourceMappingURL=ngwr-select.mjs.map