@sinequa/atomic-angular 2.13.0 → 2.14.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.
@@ -2,7 +2,7 @@ import * as i0 from "@angular/core";
2
2
  import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DOCUMENT, DestroyRef, Directive, ElementRef, EnvironmentInjector, HostBinding, HostListener, Inject, Injectable, InjectionToken, Injector, LOCALE_ID, Optional, Pipe, Service, TemplateRef, ViewContainerRef, ViewEncapsulation, afterNextRender, afterRenderEffect, assertInInjectionContext, booleanAttribute, computed, contentChild, contentChildren, effect, inject, input, isDevMode, linkedSignal, makeEnvironmentProviders, model, numberAttribute, output, provideEnvironmentInitializer, resource, runInInjectionContext, signal, untracked, viewChild, viewChildren } from "@angular/core";
3
3
  import { BehaviorSubject, EMPTY, Subject, Subscription, catchError, debounceTime, filter, finalize, firstValueFrom, from, fromEvent, map, of, scan, shareReplay, switchMap, tap, throwError } from "rxjs";
4
4
  import { TranslocoPipe, TranslocoService, provideTranslocoScope } from "@jsverse/transloco";
5
- import { ArrowDownIcon, ArrowDownZaIcon, ArrowLeftIcon, ArrowRightFromBracketIcon, ArrowRightIcon, ArrowUpAzIcon, ArrowUpIcon, ArrowUpRightFromSquareIcon, ArrowsMaximizeIcon, AutocompleteComponent, AvatarComponent, AvatarFallbackComponent, AvatarImageComponent, BadgeComponent, BarsIcon, BellIcon, BellSlashIcon, BookmarkIcon, BreakpointObserverService, BuildingIcon, ButtonComponent, CalendarDayIcon, CheckIcon, CheckboxComponent, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, CircleCheckIcon, ClockIcon, CollapseIcon, CommentIcon, CommentsIcon, DIALOG_REF, DesktopIcon, DialogBodyComponent, DialogComponent, DialogContentComponent, DialogDescriptionComponent, DialogFooterComponent, DialogHeaderComponent, DialogTitleComponent, DropdownDirective, EditIcon, EllipsisIcon, EllipsisVerticalIcon, ExpandIcon, EyeIcon, EyeSlashIcon, FileHTMLIcon, FileIcon, FileOutputIcon, FileSpreadsheetIcon, FileTextIcon, FilterIcon, FlagEnglishIcon, FlagFrenchIcon, FlagGermanIcon, FlashlightIcon, FolderIcon, FolderOpenIcon, FrownIcon, GearIcon, GitForkIcon, GlobeIcon, GripVerticalIcon, HighlighterIcon, HistoryIcon, HomeIcon, ImageIcon, InboxIcon, InfoCircleIcon, InputComponent, InputControlDirective, InputIconRightDirective, KbdComponent, KbdGroupComponent, KeyIcon, KeyboardIcon, LanguageIcon, LightbulbIcon, LightbulbSlashIcon, LinkComponent, LinkIcon, ListComponent, ListItemActionComponent, ListItemAvatarDirective, ListItemComponent, ListItemIconDirective, ListItemLeftDirective, ListItemTextComponent, LoadingCircleIcon, LockIcon, MagnifyingGlassIcon, MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon, MapPinIcon, MenuComponent, MenuContentComponent, MenuItemComponent, MenuTriggerDirective, MoonIcon, PaletteIcon, PanelRightIcon, PaperclipIcon, PenToSquareIcon, PlusIcon, PopoverDirective, PopoverResizeHandleComponent, PresentationIcon, QuestionCircleIcon, QuickFilterIcon, RadioComponent, RobotIcon, SaveIcon, SearchBarComponent, SearchBarSuggestionsComponent, SearchIcon, SearchInputComponent, SelectComponent, SelectTriggerDirective, SendIcon, SheetBodyComponent, SheetComponent, SheetHeaderComponent, SidebarInsetDirective, SlashIcon, SlidersIcon, SortableDirective, SortableItemDirective, SparklesIcon, SpinnerIcon, SquareCheckIcon, SquareIcon, SquareMinusIcon, StarIcon, SunBrightIcon, SwitchComponent, SyncIcon, TabComponent, TabListComponent, TabPanelComponent, TabsComponent, TagComponent, TagIcon, TagInputComponent, TagsIcon, TextareaComponent, TextareaControlDirective, ThumbsDownIcon, ThumbsUpIcon, ToggleGroupComponent, ToggleItemComponent, ToggleLeftIcon, ToggleRightIcon, TooltipDirective, TrashCanIcon, TrashIcon, TreeComponent, TreeItemComponent, TreeItemExpanderComponent, TreeItemGroupComponent, TriangleAlertIcon, TruncatedDirective, UndoIcon, UserIcon, UserSecretIcon, VideoIcon, XMarkIcon, createCallable, injectCallRef, injectFloatingPanel, isApplePlatform } from "@sinequa/galactik";
5
+ import { ArrowDownIcon, ArrowDownZaIcon, ArrowLeftIcon, ArrowRightFromBracketIcon, ArrowRightIcon, ArrowUpAzIcon, ArrowUpIcon, ArrowUpRightFromSquareIcon, ArrowsMaximizeIcon, AutocompleteComponent, AvatarComponent, AvatarFallbackComponent, AvatarImageComponent, BadgeComponent, BarsIcon, BellIcon, BellSlashIcon, BookmarkIcon, BreakpointObserverService, BuildingIcon, ButtonComponent, CalendarDayIcon, CheckIcon, CheckboxComponent, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, CircleCheckIcon, ClockIcon, CollapseIcon, CommentIcon, CommentsIcon, DIALOG_REF, DesktopIcon, DialogBodyComponent, DialogComponent, DialogContentComponent, DialogDescriptionComponent, DialogFooterComponent, DialogHeaderComponent, DialogTitleComponent, DropdownDirective, EditIcon, EllipsisIcon, EllipsisVerticalIcon, ExpandIcon, EyeIcon, EyeSlashIcon, FileHTMLIcon, FileIcon, FileMagnifyingGlassIcon, FileOutputIcon, FileSpreadsheetIcon, FileTextIcon, FilesIcon, FilterIcon, FlagEnglishIcon, FlagFrenchIcon, FlagGermanIcon, FlashlightIcon, FolderIcon, FolderOpenIcon, FrownIcon, GearIcon, GitForkIcon, GlobeIcon, GripVerticalIcon, HighlighterIcon, HistoryIcon, HomeIcon, ImageIcon, InboxIcon, InfoCircleIcon, InputComponent, InputControlDirective, InputIconRightDirective, KbdComponent, KbdGroupComponent, KeyIcon, KeyboardIcon, LanguageIcon, LightbulbIcon, LightbulbSlashIcon, LinkComponent, LinkIcon, ListComponent, ListItemActionComponent, ListItemAvatarDirective, ListItemComponent, ListItemIconDirective, ListItemLeftDirective, ListItemTextComponent, LoadingCircleIcon, LockIcon, MagnifyingGlassIcon, MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon, MapPinIcon, MenuComponent, MenuContentComponent, MenuItemComponent, MenuTriggerDirective, MoonIcon, PaletteIcon, PanelRightIcon, PaperclipIcon, PenToSquareIcon, PlusIcon, PopoverDirective, PopoverResizeHandleComponent, PresentationIcon, QuestionCircleIcon, QuickFilterIcon, RadioComponent, RobotIcon, SaveIcon, SearchBarComponent, SearchBarSuggestionsComponent, SearchIcon, SearchInputComponent, SelectComponent, SelectTriggerDirective, SendIcon, SheetBodyComponent, SheetComponent, SheetHeaderComponent, SidebarInsetDirective, SlashIcon, SlidersIcon, SortableDirective, SortableItemDirective, SparklesIcon, SpinnerIcon, SquareCheckIcon, SquareIcon, SquareMinusIcon, StarIcon, SunBrightIcon, SwipeActionComponent, SwitchComponent, SyncIcon, TabComponent, TabListComponent, TabPanelComponent, TabsComponent, TagComponent, TagIcon, TagInputComponent, TagsIcon, TextareaComponent, TextareaControlDirective, ThumbsDownIcon, ThumbsUpIcon, ToggleGroupComponent, ToggleItemComponent, ToggleLeftIcon, ToggleRightIcon, TooltipDirective, TrashCanIcon, TrashIcon, TreeComponent, TreeItemComponent, TreeItemExpanderComponent, TreeItemGroupComponent, TriangleAlertIcon, TruncatedDirective, UndoIcon, UserIcon, UserSecretIcon, VideoIcon, XMarkIcon, createCallable, injectCallRef, injectFloatingPanel, injectKeyboardAvailable, isApplePlatform } from "@sinequa/galactik";
6
6
  import { withDevtools } from "@ngrx-toolkit/core";
7
7
  import { getState, patchState, signalStore, signalStoreFeature, type, withComputed, withMethods, withState } from "@ngrx/signals";
8
8
  import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterModule } from "@angular/router";
@@ -4755,7 +4755,7 @@ var DocumentLocatorComponent = class DocumentLocatorComponent {
4755
4755
  transformFunction: null
4756
4756
  }
4757
4757
  },
4758
- host: { classAttribute: "flex items-center grow gap-2 overflow-hidden" },
4758
+ host: { classAttribute: "relative flex items-center grow gap-2 overflow-hidden" },
4759
4759
  viewQueries: [{
4760
4760
  propertyName: "shadow",
4761
4761
  first: true,
@@ -4773,7 +4773,9 @@ var DocumentLocatorComponent = class DocumentLocatorComponent {
4773
4773
  }],
4774
4774
  ngImport: i0,
4775
4775
  template: `
4776
- <!-- Renders all segment hidden to user to compute width -->
4776
+ <!-- Renders all segment hidden to user to compute width. Absolute against the host, which is relative and clips
4777
+ it: positioned against some ancestor further up, its full width overflowed the page and gave it a horizontal
4778
+ scrollbar. -->
4777
4779
  <div #shadowRender class="pointer-events-none invisible absolute left-0 top-0 -z-10 flex gap-1">
4778
4780
  @for (segment of locationSegments(); track $index) {
4779
4781
  <span class="whitespace-nowrap">{{ segment }}</span>
@@ -4788,7 +4790,9 @@ var DocumentLocatorComponent = class DocumentLocatorComponent {
4788
4790
 
4789
4791
  <chevron-right-icon />
4790
4792
 
4791
- <div #documentLocator class="flex grow items-center gap-1">
4793
+ <!-- min-w-0: as a flex item its width otherwise never went below its content, so its clientWidth, which decides
4794
+ how many segments fit, did not follow a narrower host and the segments never folded into the menu. -->
4795
+ <div #documentLocator class="flex min-w-0 grow items-center gap-1">
4792
4796
  @for (segment of visibleSegments(); track $index) {
4793
4797
  <a link size="md" (click)="navigateToSegment($index)">{{segment }}</a>
4794
4798
 
@@ -4915,7 +4919,9 @@ i0.ɵɵngDeclareClassMetadata({
4915
4919
  MenuItemComponent
4916
4920
  ],
4917
4921
  template: `
4918
- <!-- Renders all segment hidden to user to compute width -->
4922
+ <!-- Renders all segment hidden to user to compute width. Absolute against the host, which is relative and clips
4923
+ it: positioned against some ancestor further up, its full width overflowed the page and gave it a horizontal
4924
+ scrollbar. -->
4919
4925
  <div #shadowRender class="pointer-events-none invisible absolute left-0 top-0 -z-10 flex gap-1">
4920
4926
  @for (segment of locationSegments(); track $index) {
4921
4927
  <span class="whitespace-nowrap">{{ segment }}</span>
@@ -4930,7 +4936,9 @@ i0.ɵɵngDeclareClassMetadata({
4930
4936
 
4931
4937
  <chevron-right-icon />
4932
4938
 
4933
- <div #documentLocator class="flex grow items-center gap-1">
4939
+ <!-- min-w-0: as a flex item its width otherwise never went below its content, so its clientWidth, which decides
4940
+ how many segments fit, did not follow a narrower host and the segments never folded into the menu. -->
4941
+ <div #documentLocator class="flex min-w-0 grow items-center gap-1">
4934
4942
  @for (segment of visibleSegments(); track $index) {
4935
4943
  <a link size="md" (click)="navigateToSegment($index)">{{segment }}</a>
4936
4944
 
@@ -4960,7 +4968,7 @@ i0.ɵɵngDeclareClassMetadata({
4960
4968
  }
4961
4969
  </div>
4962
4970
  `,
4963
- host: { class: "flex items-center grow gap-2 overflow-hidden" }
4971
+ host: { class: "relative flex items-center grow gap-2 overflow-hidden" }
4964
4972
  }]
4965
4973
  }],
4966
4974
  ctorParameters: () => [],
@@ -10272,8 +10280,205 @@ function injectSimilarDocumentsState(article, documents) {
10272
10280
  hasContent: computed(() => similarDocuments().length > 0, ...ngDevMode ? [{ debugName: "hasContent" }] : /* istanbul ignore next */ [])
10273
10281
  };
10274
10282
  }
10283
+ const STEP_BUTTON = "inline-flex size-8 shrink-0 cursor-pointer touch-manipulation items-center justify-center rounded-(--radius-xs) border-0 bg-transparent p-0 text-(--font-primary-muted) transition-[background-color,color] duration-120 ease-[ease] motion-reduce:transition-none hover:bg-(--bg-primary-lighter) hover:text-(--font-primary-base) focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-(--stroke-focus)";
10284
+ var OccurrenceNavigatorComponent = class OccurrenceNavigatorComponent {
10285
+ active = input(false, ...ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []);
10286
+ index = input(0, ...ngDevMode ? [{ debugName: "index" }] : /* istanbul ignore next */ []);
10287
+ total = input(0, ...ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []);
10288
+ previousLabel = input("previous occurrence", ...ngDevMode ? [{ debugName: "previousLabel" }] : /* istanbul ignore next */ []);
10289
+ nextLabel = input("next occurrence", ...ngDevMode ? [{ debugName: "nextLabel" }] : /* istanbul ignore next */ []);
10290
+ previous = output();
10291
+ next = output();
10292
+ stepButton = STEP_BUTTON;
10293
+ step(event, direction) {
10294
+ event.stopPropagation();
10295
+ if (direction === "previous") this.previous.emit();
10296
+ else this.next.emit();
10297
+ }
10298
+ static ɵfac = i0.ɵɵngDeclareFactory({
10299
+ minVersion: "12.0.0",
10300
+ version: "22.2.1",
10301
+ ngImport: i0,
10302
+ type: OccurrenceNavigatorComponent,
10303
+ deps: [],
10304
+ target: i0.ɵɵFactoryTarget.Component
10305
+ });
10306
+ static ɵcmp = i0.ɵɵngDeclareComponent({
10307
+ minVersion: "17.0.0",
10308
+ version: "22.2.1",
10309
+ type: OccurrenceNavigatorComponent,
10310
+ isStandalone: true,
10311
+ selector: "occurrence-navigator, OccurrenceNavigator",
10312
+ inputs: {
10313
+ active: {
10314
+ classPropertyName: "active",
10315
+ publicName: "active",
10316
+ isSignal: true,
10317
+ isRequired: false,
10318
+ transformFunction: null
10319
+ },
10320
+ index: {
10321
+ classPropertyName: "index",
10322
+ publicName: "index",
10323
+ isSignal: true,
10324
+ isRequired: false,
10325
+ transformFunction: null
10326
+ },
10327
+ total: {
10328
+ classPropertyName: "total",
10329
+ publicName: "total",
10330
+ isSignal: true,
10331
+ isRequired: false,
10332
+ transformFunction: null
10333
+ },
10334
+ previousLabel: {
10335
+ classPropertyName: "previousLabel",
10336
+ publicName: "previousLabel",
10337
+ isSignal: true,
10338
+ isRequired: false,
10339
+ transformFunction: null
10340
+ },
10341
+ nextLabel: {
10342
+ classPropertyName: "nextLabel",
10343
+ publicName: "nextLabel",
10344
+ isSignal: true,
10345
+ isRequired: false,
10346
+ transformFunction: null
10347
+ }
10348
+ },
10349
+ outputs: {
10350
+ previous: "previous",
10351
+ next: "next"
10352
+ },
10353
+ host: { classAttribute: "flex shrink-0 items-center gap-2 tabular-nums" },
10354
+ ngImport: i0,
10355
+ template: `
10356
+ @if (active()) {
10357
+ <button type="button" [class]="stepButton" [attr.aria-label]="previousLabel()" (click)="step($event, 'previous')">
10358
+ <chevron-left />
10359
+ </button>
10360
+
10361
+ <span class="min-w-12 text-center">{{ index() }} / {{ total() }}</span>
10362
+
10363
+ <button type="button" [class]="stepButton" [attr.aria-label]="nextLabel()" (click)="step($event, 'next')">
10364
+ <chevron-right />
10365
+ </button>
10366
+ } @else {
10367
+ {{ total() }}
10368
+ }
10369
+ `,
10370
+ isInline: true,
10371
+ dependencies: [{
10372
+ kind: "component",
10373
+ type: ChevronLeftIcon,
10374
+ selector: "chevron-left, chevron-left-icon, ChevronLeft, ChevronLeftIcon",
10375
+ inputs: ["class"]
10376
+ }, {
10377
+ kind: "component",
10378
+ type: ChevronRightIcon,
10379
+ selector: "chevron-right, chevron-right-icon, ChevronRight, ChevronRightIcon",
10380
+ inputs: ["class"]
10381
+ }],
10382
+ changeDetection: i0.ChangeDetectionStrategy.OnPush
10383
+ });
10384
+ };
10385
+ i0.ɵɵngDeclareClassMetadata({
10386
+ minVersion: "12.0.0",
10387
+ version: "22.2.1",
10388
+ ngImport: i0,
10389
+ type: OccurrenceNavigatorComponent,
10390
+ decorators: [{
10391
+ type: Component,
10392
+ args: [{
10393
+ selector: "occurrence-navigator, OccurrenceNavigator",
10394
+ imports: [ChevronLeftIcon, ChevronRightIcon],
10395
+ changeDetection: ChangeDetectionStrategy.OnPush,
10396
+ host: { class: "flex shrink-0 items-center gap-2 tabular-nums" },
10397
+ template: `
10398
+ @if (active()) {
10399
+ <button type="button" [class]="stepButton" [attr.aria-label]="previousLabel()" (click)="step($event, 'previous')">
10400
+ <chevron-left />
10401
+ </button>
10402
+
10403
+ <span class="min-w-12 text-center">{{ index() }} / {{ total() }}</span>
10404
+
10405
+ <button type="button" [class]="stepButton" [attr.aria-label]="nextLabel()" (click)="step($event, 'next')">
10406
+ <chevron-right />
10407
+ </button>
10408
+ } @else {
10409
+ {{ total() }}
10410
+ }
10411
+ `
10412
+ }]
10413
+ }],
10414
+ propDecorators: {
10415
+ active: [{
10416
+ type: i0.Input,
10417
+ args: [{
10418
+ isSignal: true,
10419
+ alias: "active",
10420
+ required: false
10421
+ }]
10422
+ }],
10423
+ index: [{
10424
+ type: i0.Input,
10425
+ args: [{
10426
+ isSignal: true,
10427
+ alias: "index",
10428
+ required: false
10429
+ }]
10430
+ }],
10431
+ total: [{
10432
+ type: i0.Input,
10433
+ args: [{
10434
+ isSignal: true,
10435
+ alias: "total",
10436
+ required: false
10437
+ }]
10438
+ }],
10439
+ previousLabel: [{
10440
+ type: i0.Input,
10441
+ args: [{
10442
+ isSignal: true,
10443
+ alias: "previousLabel",
10444
+ required: false
10445
+ }]
10446
+ }],
10447
+ nextLabel: [{
10448
+ type: i0.Input,
10449
+ args: [{
10450
+ isSignal: true,
10451
+ alias: "nextLabel",
10452
+ required: false
10453
+ }]
10454
+ }],
10455
+ previous: [{
10456
+ type: i0.Output,
10457
+ args: ["previous"]
10458
+ }],
10459
+ next: [{
10460
+ type: i0.Output,
10461
+ args: ["next"]
10462
+ }]
10463
+ }
10464
+ });
10465
+ function injectRowActivation(active) {
10466
+ const isTouch = inject(BreakpointObserverService).isTouch;
10467
+ return {
10468
+ enter: (value) => {
10469
+ if (!isTouch()) active().set(value);
10470
+ },
10471
+ leave: () => {
10472
+ if (!isTouch()) active().set(void 0);
10473
+ },
10474
+ activate: (value) => {
10475
+ if (isTouch()) active().set(value);
10476
+ }
10477
+ };
10478
+ }
10275
10479
  var ArticleEntitiesTabPanel = class ArticleEntitiesTabPanel {
10276
10480
  state = input.required(...ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []);
10481
+ row = injectRowActivation(() => this.state().hovering);
10277
10482
  expandedCategories = signal({}, ...ngDevMode ? [{ debugName: "expandedCategories" }] : /* istanbul ignore next */ []);
10278
10483
  isExpanded(entity) {
10279
10484
  return this.expandedCategories()[entity] ?? false;
@@ -10324,12 +10529,14 @@ var ArticleEntitiesTabPanel = class ArticleEntitiesTabPanel {
10324
10529
  } },
10325
10530
  ngImport: i0,
10326
10531
  template: `
10532
+ @if (state().hasContent()) {
10327
10533
  <!-- Legend, once for the whole tree — not repeated per row (aria-hidden: sighted-keyboard-user
10328
- hint only, the buttons on each row already carry the real accessible names). -->
10534
+ hint only, the buttons on each row already carry the real accessible names). Hidden on a touch
10535
+ screen: there is no keyboard to press the keys on. -->
10329
10536
  <div
10330
10537
  data-name="shortcuts-legend"
10331
10538
  aria-hidden="true"
10332
- class="mb-1 flex items-center gap-1.5 px-2 py-1 text-[10px] text-(--font-info-base)">
10539
+ class="mb-1 flex items-center gap-1.5 px-2 py-1 text-[10px] text-(--font-info-base) pointer-coarse:hidden">
10333
10540
  <KbdGroup class="text-[10px]">
10334
10541
  <kbd size="xs" class="text-[10px]">Ctrl</kbd>
10335
10542
  <kbd size="xs" class="text-[10px]"><arrow-left class="size-2.5" /></kbd>
@@ -10354,7 +10561,7 @@ var ArticleEntitiesTabPanel = class ArticleEntitiesTabPanel {
10354
10561
  [attr.aria-label]="categoryLabel"
10355
10562
  [expanded]="isExpanded(highlight.entity)"
10356
10563
  (expandedChange)="setExpanded(highlight.entity, $event)"
10357
- class="text-(length:--size-base) leading-(--line-height-3) font-semibold capitalize">
10564
+ class="min-h-10 text-(length:--size-lg) leading-(--line-height-3) font-semibold capitalize">
10358
10565
  <TreeItemExpander />
10359
10566
  <span class="inline-block size-3 shrink-0 rounded-full" [style.background]="highlight.color"></span>
10360
10567
  {{ categoryLabel }}
@@ -10371,30 +10578,25 @@ var ArticleEntitiesTabPanel = class ArticleEntitiesTabPanel {
10371
10578
  [value]="highlight.entity + ':' + data.value"
10372
10579
  [label]="data.display"
10373
10580
  [attr.aria-label]="data.display"
10374
- class="text-(length:--size-base) leading-(--line-height-3) select-none"
10375
- (mouseenter)="state().hovering.set(data.value)"
10376
- (mouseleave)="state().hovering.set(undefined)"
10581
+ class="min-h-10 text-(length:--size-lg) leading-(--line-height-3) select-none"
10582
+ (mouseenter)="row.enter(data.value)"
10583
+ (mouseleave)="row.leave()"
10584
+ (click)="row.activate(data.value)"
10377
10585
  (focus)="focusedLeaf.set(data.value)"
10378
10586
  (blur)="onLeafBlur(data.value)"
10379
10587
  (keydown.control.arrowleft)="onNavigateKey($event, highlight.entity, data, 'prev')"
10380
10588
  (keydown.control.arrowright)="onNavigateKey($event, highlight.entity, data, 'next')">
10381
10589
  {{ data.display }}
10382
10590
 
10383
- <span class="ml-auto flex shrink-0 items-center gap-2 text-(length:--size-xs)">
10384
- @if (isLeafActive(data.value)) {
10385
- <button class="hover:scale-125" aria-label="previous entity" (click)="state().navigatePrev(highlight.entity, data)">
10386
- <chevron-left />
10387
- </button>
10388
-
10389
- <span>{{ currentIndex(data) }} / {{ state().total(highlight.entity, data) }}</span>
10390
-
10391
- <button class="hover:scale-125" aria-label="next entity" (click)="state().navigateNext(highlight.entity, data)">
10392
- <chevron-right />
10393
- </button>
10394
- } @else {
10395
- {{ state().total(highlight.entity, data) }}
10396
- }
10397
- </span>
10591
+ <occurrence-navigator
10592
+ class="ml-auto text-(length:--size-sm)"
10593
+ [active]="isLeafActive(data.value)"
10594
+ [index]="currentIndex(data)"
10595
+ [total]="state().total(highlight.entity, data)"
10596
+ previousLabel="previous entity"
10597
+ nextLabel="next entity"
10598
+ (previous)="state().navigatePrev(highlight.entity, data)"
10599
+ (next)="state().navigateNext(highlight.entity, data)" />
10398
10600
  </TreeItem>
10399
10601
  }
10400
10602
  </div>
@@ -10402,6 +10604,14 @@ var ArticleEntitiesTabPanel = class ArticleEntitiesTabPanel {
10402
10604
  }
10403
10605
  }
10404
10606
  </Tree>
10607
+ } @else {
10608
+ <!-- Not reached through the tabs (the Entities tab only exists once a category has a value), but a host may mount it on its own. -->
10609
+ <list-panel-empty color="pink">
10610
+ <building-icon listPanelEmptyIcon />
10611
+ {{ 'drawers.noEntities' | transloco }}
10612
+ <p listPanelEmptyHint>{{ 'drawers.noEntitiesHint' | transloco }}</p>
10613
+ </list-panel-empty>
10614
+ }
10405
10615
  `,
10406
10616
  isInline: true,
10407
10617
  dependencies: [
@@ -10444,27 +10654,40 @@ var ArticleEntitiesTabPanel = class ArticleEntitiesTabPanel {
10444
10654
  },
10445
10655
  {
10446
10656
  kind: "component",
10447
- type: ChevronRightIcon,
10448
- selector: "chevron-right, chevron-right-icon, ChevronRight, ChevronRightIcon",
10657
+ type: ArrowLeftIcon,
10658
+ selector: "arrow-left, arrow-left-icon, ArrowLeft, ArrowLeftIcon",
10449
10659
  inputs: ["class"]
10450
10660
  },
10451
10661
  {
10452
10662
  kind: "component",
10453
- type: ChevronLeftIcon,
10454
- selector: "chevron-left, chevron-left-icon, ChevronLeft, ChevronLeftIcon",
10663
+ type: ArrowRightIcon,
10664
+ selector: "arrow-right, arrow-right-icon, ArrowRight, ArrowRightIcon",
10455
10665
  inputs: ["class"]
10456
10666
  },
10457
10667
  {
10458
10668
  kind: "component",
10459
- type: ArrowLeftIcon,
10460
- selector: "arrow-left, arrow-left-icon, ArrowLeft, ArrowLeftIcon",
10669
+ type: BuildingIcon,
10670
+ selector: "building, building-icon, Building, BuildingIcon",
10461
10671
  inputs: ["class"]
10462
10672
  },
10463
10673
  {
10464
10674
  kind: "component",
10465
- type: ArrowRightIcon,
10466
- selector: "arrow-right, arrow-right-icon, ArrowRight, ArrowRightIcon",
10467
- inputs: ["class"]
10675
+ type: ListPanelEmptyComponent,
10676
+ selector: "list-panel-empty, ListPanelEmpty",
10677
+ inputs: ["color"]
10678
+ },
10679
+ {
10680
+ kind: "component",
10681
+ type: OccurrenceNavigatorComponent,
10682
+ selector: "occurrence-navigator, OccurrenceNavigator",
10683
+ inputs: [
10684
+ "active",
10685
+ "index",
10686
+ "total",
10687
+ "previousLabel",
10688
+ "nextLabel"
10689
+ ],
10690
+ outputs: ["previous", "next"]
10468
10691
  },
10469
10692
  {
10470
10693
  kind: "pipe",
@@ -10491,18 +10714,21 @@ i0.ɵɵngDeclareClassMetadata({
10491
10714
  TreeItemExpanderComponent,
10492
10715
  KbdComponent,
10493
10716
  KbdGroupComponent,
10494
- ChevronRightIcon,
10495
- ChevronLeftIcon,
10496
10717
  ArrowLeftIcon,
10497
- ArrowRightIcon
10718
+ ArrowRightIcon,
10719
+ BuildingIcon,
10720
+ ListPanelEmptyComponent,
10721
+ OccurrenceNavigatorComponent
10498
10722
  ],
10499
10723
  template: `
10724
+ @if (state().hasContent()) {
10500
10725
  <!-- Legend, once for the whole tree — not repeated per row (aria-hidden: sighted-keyboard-user
10501
- hint only, the buttons on each row already carry the real accessible names). -->
10726
+ hint only, the buttons on each row already carry the real accessible names). Hidden on a touch
10727
+ screen: there is no keyboard to press the keys on. -->
10502
10728
  <div
10503
10729
  data-name="shortcuts-legend"
10504
10730
  aria-hidden="true"
10505
- class="mb-1 flex items-center gap-1.5 px-2 py-1 text-[10px] text-(--font-info-base)">
10731
+ class="mb-1 flex items-center gap-1.5 px-2 py-1 text-[10px] text-(--font-info-base) pointer-coarse:hidden">
10506
10732
  <KbdGroup class="text-[10px]">
10507
10733
  <kbd size="xs" class="text-[10px]">Ctrl</kbd>
10508
10734
  <kbd size="xs" class="text-[10px]"><arrow-left class="size-2.5" /></kbd>
@@ -10527,7 +10753,7 @@ i0.ɵɵngDeclareClassMetadata({
10527
10753
  [attr.aria-label]="categoryLabel"
10528
10754
  [expanded]="isExpanded(highlight.entity)"
10529
10755
  (expandedChange)="setExpanded(highlight.entity, $event)"
10530
- class="text-(length:--size-base) leading-(--line-height-3) font-semibold capitalize">
10756
+ class="min-h-10 text-(length:--size-lg) leading-(--line-height-3) font-semibold capitalize">
10531
10757
  <TreeItemExpander />
10532
10758
  <span class="inline-block size-3 shrink-0 rounded-full" [style.background]="highlight.color"></span>
10533
10759
  {{ categoryLabel }}
@@ -10544,30 +10770,25 @@ i0.ɵɵngDeclareClassMetadata({
10544
10770
  [value]="highlight.entity + ':' + data.value"
10545
10771
  [label]="data.display"
10546
10772
  [attr.aria-label]="data.display"
10547
- class="text-(length:--size-base) leading-(--line-height-3) select-none"
10548
- (mouseenter)="state().hovering.set(data.value)"
10549
- (mouseleave)="state().hovering.set(undefined)"
10773
+ class="min-h-10 text-(length:--size-lg) leading-(--line-height-3) select-none"
10774
+ (mouseenter)="row.enter(data.value)"
10775
+ (mouseleave)="row.leave()"
10776
+ (click)="row.activate(data.value)"
10550
10777
  (focus)="focusedLeaf.set(data.value)"
10551
10778
  (blur)="onLeafBlur(data.value)"
10552
10779
  (keydown.control.arrowleft)="onNavigateKey($event, highlight.entity, data, 'prev')"
10553
10780
  (keydown.control.arrowright)="onNavigateKey($event, highlight.entity, data, 'next')">
10554
10781
  {{ data.display }}
10555
10782
 
10556
- <span class="ml-auto flex shrink-0 items-center gap-2 text-(length:--size-xs)">
10557
- @if (isLeafActive(data.value)) {
10558
- <button class="hover:scale-125" aria-label="previous entity" (click)="state().navigatePrev(highlight.entity, data)">
10559
- <chevron-left />
10560
- </button>
10561
-
10562
- <span>{{ currentIndex(data) }} / {{ state().total(highlight.entity, data) }}</span>
10563
-
10564
- <button class="hover:scale-125" aria-label="next entity" (click)="state().navigateNext(highlight.entity, data)">
10565
- <chevron-right />
10566
- </button>
10567
- } @else {
10568
- {{ state().total(highlight.entity, data) }}
10569
- }
10570
- </span>
10783
+ <occurrence-navigator
10784
+ class="ml-auto text-(length:--size-sm)"
10785
+ [active]="isLeafActive(data.value)"
10786
+ [index]="currentIndex(data)"
10787
+ [total]="state().total(highlight.entity, data)"
10788
+ previousLabel="previous entity"
10789
+ nextLabel="next entity"
10790
+ (previous)="state().navigatePrev(highlight.entity, data)"
10791
+ (next)="state().navigateNext(highlight.entity, data)" />
10571
10792
  </TreeItem>
10572
10793
  }
10573
10794
  </div>
@@ -10575,6 +10796,14 @@ i0.ɵɵngDeclareClassMetadata({
10575
10796
  }
10576
10797
  }
10577
10798
  </Tree>
10799
+ } @else {
10800
+ <!-- Not reached through the tabs (the Entities tab only exists once a category has a value), but a host may mount it on its own. -->
10801
+ <list-panel-empty color="pink">
10802
+ <building-icon listPanelEmptyIcon />
10803
+ {{ 'drawers.noEntities' | transloco }}
10804
+ <p listPanelEmptyHint>{{ 'drawers.noEntitiesHint' | transloco }}</p>
10805
+ </list-panel-empty>
10806
+ }
10578
10807
  `
10579
10808
  }]
10580
10809
  }],
@@ -10616,6 +10845,13 @@ var ArticleExtractsTabPanel = class ArticleExtractsTabPanel {
10616
10845
  <div class="flex h-32 items-center justify-center">
10617
10846
  <loading-dots size="md" class="text-(--font-primary-base)" [label]="'drawers.loadingExtracts' | transloco" />
10618
10847
  </div>
10848
+ } @else if (state().extracts().length === 0) {
10849
+ <!-- The empty-state rule of the list features: the feature's icon on a coloured tile, a title, one hint line. -->
10850
+ <list-panel-empty color="green">
10851
+ <file-text-icon listPanelEmptyIcon />
10852
+ {{ 'drawers.noRelevantExtracts' | transloco }}
10853
+ <p listPanelEmptyHint>{{ 'drawers.noRelevantExtractsHint' | transloco }}</p>
10854
+ </list-panel-empty>
10619
10855
  } @else {
10620
10856
  <List class="gap-1" selectionMode="explicit">
10621
10857
  @for (extract of state().extracts(); track extract.id) {
@@ -10627,16 +10863,18 @@ var ArticleExtractsTabPanel = class ArticleExtractsTabPanel {
10627
10863
  (keydown.space)="state().scrollTo(undefined, extract.textIndex, true)">
10628
10864
  <p class="line-clamp-5 text-(length:--size-base) leading-(--line-height-3)" [innerHTML]="extract.text"></p>
10629
10865
  </ListItem>
10630
- } @empty {
10631
- <!-- div, not p: a <p> picks up the app's own paragraph typography (font-size, margin),
10632
- drifting from the row size above it — same fix as article-entities-tab-panel.ts. -->
10633
- <div class="px-3 py-1 text-(length:--size-base) text-(--font-neutral-secondary) italic">{{ 'drawers.noRelevantExtracts' | transloco }}</div>
10634
10866
  }
10635
10867
  </List>
10636
10868
  }
10637
10869
  `,
10638
10870
  isInline: true,
10639
10871
  dependencies: [
10872
+ {
10873
+ kind: "component",
10874
+ type: FileTextIcon,
10875
+ selector: "file-text, file-text-icon, FileText, FileTextIcon",
10876
+ inputs: ["class"]
10877
+ },
10640
10878
  {
10641
10879
  kind: "directive",
10642
10880
  type: ListComponent,
@@ -10660,6 +10898,12 @@ var ArticleExtractsTabPanel = class ArticleExtractsTabPanel {
10660
10898
  ],
10661
10899
  exportAs: ["listItem"]
10662
10900
  },
10901
+ {
10902
+ kind: "component",
10903
+ type: ListPanelEmptyComponent,
10904
+ selector: "list-panel-empty, ListPanelEmpty",
10905
+ inputs: ["color"]
10906
+ },
10663
10907
  {
10664
10908
  kind: "component",
10665
10909
  type: LoadingDotsComponent,
@@ -10685,8 +10929,10 @@ i0.ɵɵngDeclareClassMetadata({
10685
10929
  selector: "article-extracts-tab-panel, ArticleExtractsTabPanel, articleextractstabpanel",
10686
10930
  imports: [
10687
10931
  TranslocoPipe,
10932
+ FileTextIcon,
10688
10933
  ListComponent,
10689
10934
  ListItemComponent,
10935
+ ListPanelEmptyComponent,
10690
10936
  LoadingDotsComponent
10691
10937
  ],
10692
10938
  template: `
@@ -10694,6 +10940,13 @@ i0.ɵɵngDeclareClassMetadata({
10694
10940
  <div class="flex h-32 items-center justify-center">
10695
10941
  <loading-dots size="md" class="text-(--font-primary-base)" [label]="'drawers.loadingExtracts' | transloco" />
10696
10942
  </div>
10943
+ } @else if (state().extracts().length === 0) {
10944
+ <!-- The empty-state rule of the list features: the feature's icon on a coloured tile, a title, one hint line. -->
10945
+ <list-panel-empty color="green">
10946
+ <file-text-icon listPanelEmptyIcon />
10947
+ {{ 'drawers.noRelevantExtracts' | transloco }}
10948
+ <p listPanelEmptyHint>{{ 'drawers.noRelevantExtractsHint' | transloco }}</p>
10949
+ </list-panel-empty>
10697
10950
  } @else {
10698
10951
  <List class="gap-1" selectionMode="explicit">
10699
10952
  @for (extract of state().extracts(); track extract.id) {
@@ -10705,10 +10958,6 @@ i0.ɵɵngDeclareClassMetadata({
10705
10958
  (keydown.space)="state().scrollTo(undefined, extract.textIndex, true)">
10706
10959
  <p class="line-clamp-5 text-(length:--size-base) leading-(--line-height-3)" [innerHTML]="extract.text"></p>
10707
10960
  </ListItem>
10708
- } @empty {
10709
- <!-- div, not p: a <p> picks up the app's own paragraph typography (font-size, margin),
10710
- drifting from the row size above it — same fix as article-entities-tab-panel.ts. -->
10711
- <div class="px-3 py-1 text-(length:--size-base) text-(--font-neutral-secondary) italic">{{ 'drawers.noRelevantExtracts' | transloco }}</div>
10712
10961
  }
10713
10962
  </List>
10714
10963
  }
@@ -10861,6 +11110,8 @@ i0.ɵɵngDeclareClassMetadata({
10861
11110
  });
10862
11111
  var ArticleMatchLocationsTabPanel = class ArticleMatchLocationsTabPanel {
10863
11112
  state = input.required(...ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []);
11113
+ queryText = input("", ...ngDevMode ? [{ debugName: "queryText" }] : /* istanbul ignore next */ []);
11114
+ row = injectRowActivation(() => this.state().hovering);
10864
11115
  focusedValue = signal(void 0, ...ngDevMode ? [{ debugName: "focusedValue" }] : /* istanbul ignore next */ []);
10865
11116
  onBlur(value) {
10866
11117
  if (this.focusedValue() === value) this.focusedValue.set(void 0);
@@ -10892,21 +11143,32 @@ var ArticleMatchLocationsTabPanel = class ArticleMatchLocationsTabPanel {
10892
11143
  type: ArticleMatchLocationsTabPanel,
10893
11144
  isStandalone: true,
10894
11145
  selector: "article-match-locations-tab-panel, ArticleMatchLocationsTabPanel, articlematchlocationstabpanel",
10895
- inputs: { state: {
10896
- classPropertyName: "state",
10897
- publicName: "state",
10898
- isSignal: true,
10899
- isRequired: true,
10900
- transformFunction: null
10901
- } },
11146
+ inputs: {
11147
+ state: {
11148
+ classPropertyName: "state",
11149
+ publicName: "state",
11150
+ isSignal: true,
11151
+ isRequired: true,
11152
+ transformFunction: null
11153
+ },
11154
+ queryText: {
11155
+ classPropertyName: "queryText",
11156
+ publicName: "queryText",
11157
+ isSignal: true,
11158
+ isRequired: false,
11159
+ transformFunction: null
11160
+ }
11161
+ },
10902
11162
  ngImport: i0,
10903
11163
  template: `
11164
+ @if (state().hasContent()) {
10904
11165
  <!-- Legend, once for the whole list — not repeated per row (aria-hidden: sighted-keyboard-user
10905
- hint only, the buttons on each row already carry the real accessible names). -->
11166
+ hint only, the buttons on each row already carry the real accessible names). Hidden on a touch
11167
+ screen: there is no keyboard to press the keys on. -->
10906
11168
  <div
10907
11169
  data-name="shortcuts-legend"
10908
11170
  aria-hidden="true"
10909
- class="mb-1 flex items-center gap-1.5 px-2 py-1 text-[10px] text-(--font-info-base)">
11171
+ class="mb-1 flex items-center gap-1.5 px-2 py-1 text-[10px] text-(--font-info-base) pointer-coarse:hidden">
10910
11172
  <KbdGroup class="text-[10px]">
10911
11173
  <kbd size="xs" class="text-[10px]">Ctrl</kbd>
10912
11174
  <kbd size="xs" class="text-[10px]"><arrow-left class="size-2.5" /></kbd>
@@ -10919,38 +11181,50 @@ var ArticleMatchLocationsTabPanel = class ArticleMatchLocationsTabPanel {
10919
11181
  <span>{{ 'drawers.navigateOccurrences' | transloco }}</span>
10920
11182
  </div>
10921
11183
 
10922
- <List variant="list-menu" class="gap-1" selectionMode="explicit">
11184
+ <List variant="list-menu" class="gap-2" selectionMode="explicit">
10923
11185
  @for (data of state().values(); track data.value) {
10924
11186
  <ListItem
10925
11187
  size="small"
10926
11188
  [value]="data.value"
10927
- class="select-none"
10928
- (mouseenter)="state().hovering.set(data.value)"
10929
- (mouseleave)="state().hovering.set(undefined)"
11189
+ class="select-none min-h-10 [&_.list-item-text]:text-(length:--size-lg)"
11190
+ (mouseenter)="row.enter(data.value)"
11191
+ (mouseleave)="row.leave()"
11192
+ (click)="row.activate(data.value)"
10930
11193
  (focus)="focusedValue.set(data.value)"
10931
11194
  (blur)="onBlur(data.value)"
10932
11195
  (keydown.control.arrowleft)="onNavigateKey($event, data, 'prev')"
10933
11196
  (keydown.control.arrowright)="onNavigateKey($event, data, 'next')">
10934
11197
  <ListItemText>{{ data.display }}</ListItemText>
10935
11198
 
10936
- <span class="ml-auto flex shrink-0 items-center gap-2">
10937
- @if (isActive(data.value)) {
10938
- <button class="hover:scale-125" aria-label="previous match" (click)="state().navigatePrev(data)">
10939
- <chevron-left />
10940
- </button>
10941
-
10942
- <span>{{ currentIndex(data) }} / {{ data.count }}</span>
10943
-
10944
- <button class="hover:scale-125" aria-label="next match" (click)="state().navigateNext(data)">
10945
- <chevron-right />
10946
- </button>
10947
- } @else {
10948
- {{ data.count }}
10949
- }
10950
- </span>
11199
+ <occurrence-navigator
11200
+ class="ml-auto"
11201
+ [active]="isActive(data.value)"
11202
+ [index]="currentIndex(data)"
11203
+ [total]="data.count ?? 0"
11204
+ previousLabel="previous match"
11205
+ nextLabel="next match"
11206
+ (previous)="state().navigatePrev(data)"
11207
+ (next)="state().navigateNext(data)" />
10951
11208
  </ListItem>
10952
11209
  }
10953
11210
  </List>
11211
+ } @else {
11212
+ <!-- With nothing to show it says why: no search yet, or no match. Two whole empty states rather than one with an @if inside:
11213
+ the hint is projected into its slot only when it is a direct child of <list-panel-empty> (NG8011 otherwise). -->
11214
+ @if (queryText()) {
11215
+ <list-panel-empty color="blue">
11216
+ <file-magnifying-glass-icon listPanelEmptyIcon />
11217
+ {{ 'drawers.noMatches' | transloco: { query: queryText() } }}
11218
+ <p listPanelEmptyHint>{{ 'drawers.noMatchesHint' | transloco }}</p>
11219
+ </list-panel-empty>
11220
+ } @else {
11221
+ <list-panel-empty color="blue">
11222
+ <file-magnifying-glass-icon listPanelEmptyIcon />
11223
+ {{ 'drawers.typeToSearch' | transloco }}
11224
+ <p listPanelEmptyHint>{{ 'drawers.typeToSearchHint' | transloco }}</p>
11225
+ </list-panel-empty>
11226
+ }
11227
+ }
10954
11228
  `,
10955
11229
  isInline: true,
10956
11230
  dependencies: [
@@ -10984,18 +11258,6 @@ var ArticleMatchLocationsTabPanel = class ArticleMatchLocationsTabPanel {
10984
11258
  inputs: ["class", "subtext"],
10985
11259
  exportAs: ["listItemText"]
10986
11260
  },
10987
- {
10988
- kind: "component",
10989
- type: ChevronLeftIcon,
10990
- selector: "chevron-left, chevron-left-icon, ChevronLeft, ChevronLeftIcon",
10991
- inputs: ["class"]
10992
- },
10993
- {
10994
- kind: "component",
10995
- type: ChevronRightIcon,
10996
- selector: "chevron-right, chevron-right-icon, ChevronRight, ChevronRightIcon",
10997
- inputs: ["class"]
10998
- },
10999
11261
  {
11000
11262
  kind: "directive",
11001
11263
  type: KbdComponent,
@@ -11019,6 +11281,31 @@ var ArticleMatchLocationsTabPanel = class ArticleMatchLocationsTabPanel {
11019
11281
  selector: "arrow-right, arrow-right-icon, ArrowRight, ArrowRightIcon",
11020
11282
  inputs: ["class"]
11021
11283
  },
11284
+ {
11285
+ kind: "component",
11286
+ type: FileMagnifyingGlassIcon,
11287
+ selector: "file-magnifying-glass, file-magnifying-glass-icon, FileMagnifyingGlass, FileMagnifyingGlassIcon",
11288
+ inputs: ["class"]
11289
+ },
11290
+ {
11291
+ kind: "component",
11292
+ type: ListPanelEmptyComponent,
11293
+ selector: "list-panel-empty, ListPanelEmpty",
11294
+ inputs: ["color"]
11295
+ },
11296
+ {
11297
+ kind: "component",
11298
+ type: OccurrenceNavigatorComponent,
11299
+ selector: "occurrence-navigator, OccurrenceNavigator",
11300
+ inputs: [
11301
+ "active",
11302
+ "index",
11303
+ "total",
11304
+ "previousLabel",
11305
+ "nextLabel"
11306
+ ],
11307
+ outputs: ["previous", "next"]
11308
+ },
11022
11309
  {
11023
11310
  kind: "pipe",
11024
11311
  type: TranslocoPipe,
@@ -11041,20 +11328,23 @@ i0.ɵɵngDeclareClassMetadata({
11041
11328
  ListComponent,
11042
11329
  ListItemComponent,
11043
11330
  ListItemTextComponent,
11044
- ChevronLeftIcon,
11045
- ChevronRightIcon,
11046
11331
  KbdComponent,
11047
11332
  KbdGroupComponent,
11048
11333
  ArrowLeftIcon,
11049
- ArrowRightIcon
11334
+ ArrowRightIcon,
11335
+ FileMagnifyingGlassIcon,
11336
+ ListPanelEmptyComponent,
11337
+ OccurrenceNavigatorComponent
11050
11338
  ],
11051
11339
  template: `
11340
+ @if (state().hasContent()) {
11052
11341
  <!-- Legend, once for the whole list — not repeated per row (aria-hidden: sighted-keyboard-user
11053
- hint only, the buttons on each row already carry the real accessible names). -->
11342
+ hint only, the buttons on each row already carry the real accessible names). Hidden on a touch
11343
+ screen: there is no keyboard to press the keys on. -->
11054
11344
  <div
11055
11345
  data-name="shortcuts-legend"
11056
11346
  aria-hidden="true"
11057
- class="mb-1 flex items-center gap-1.5 px-2 py-1 text-[10px] text-(--font-info-base)">
11347
+ class="mb-1 flex items-center gap-1.5 px-2 py-1 text-[10px] text-(--font-info-base) pointer-coarse:hidden">
11058
11348
  <KbdGroup class="text-[10px]">
11059
11349
  <kbd size="xs" class="text-[10px]">Ctrl</kbd>
11060
11350
  <kbd size="xs" class="text-[10px]"><arrow-left class="size-2.5" /></kbd>
@@ -11067,49 +11357,71 @@ i0.ɵɵngDeclareClassMetadata({
11067
11357
  <span>{{ 'drawers.navigateOccurrences' | transloco }}</span>
11068
11358
  </div>
11069
11359
 
11070
- <List variant="list-menu" class="gap-1" selectionMode="explicit">
11360
+ <List variant="list-menu" class="gap-2" selectionMode="explicit">
11071
11361
  @for (data of state().values(); track data.value) {
11072
11362
  <ListItem
11073
11363
  size="small"
11074
11364
  [value]="data.value"
11075
- class="select-none"
11076
- (mouseenter)="state().hovering.set(data.value)"
11077
- (mouseleave)="state().hovering.set(undefined)"
11365
+ class="select-none min-h-10 [&_.list-item-text]:text-(length:--size-lg)"
11366
+ (mouseenter)="row.enter(data.value)"
11367
+ (mouseleave)="row.leave()"
11368
+ (click)="row.activate(data.value)"
11078
11369
  (focus)="focusedValue.set(data.value)"
11079
11370
  (blur)="onBlur(data.value)"
11080
11371
  (keydown.control.arrowleft)="onNavigateKey($event, data, 'prev')"
11081
11372
  (keydown.control.arrowright)="onNavigateKey($event, data, 'next')">
11082
11373
  <ListItemText>{{ data.display }}</ListItemText>
11083
11374
 
11084
- <span class="ml-auto flex shrink-0 items-center gap-2">
11085
- @if (isActive(data.value)) {
11086
- <button class="hover:scale-125" aria-label="previous match" (click)="state().navigatePrev(data)">
11087
- <chevron-left />
11088
- </button>
11089
-
11090
- <span>{{ currentIndex(data) }} / {{ data.count }}</span>
11091
-
11092
- <button class="hover:scale-125" aria-label="next match" (click)="state().navigateNext(data)">
11093
- <chevron-right />
11094
- </button>
11095
- } @else {
11096
- {{ data.count }}
11097
- }
11098
- </span>
11375
+ <occurrence-navigator
11376
+ class="ml-auto"
11377
+ [active]="isActive(data.value)"
11378
+ [index]="currentIndex(data)"
11379
+ [total]="data.count ?? 0"
11380
+ previousLabel="previous match"
11381
+ nextLabel="next match"
11382
+ (previous)="state().navigatePrev(data)"
11383
+ (next)="state().navigateNext(data)" />
11099
11384
  </ListItem>
11100
11385
  }
11101
11386
  </List>
11387
+ } @else {
11388
+ <!-- With nothing to show it says why: no search yet, or no match. Two whole empty states rather than one with an @if inside:
11389
+ the hint is projected into its slot only when it is a direct child of <list-panel-empty> (NG8011 otherwise). -->
11390
+ @if (queryText()) {
11391
+ <list-panel-empty color="blue">
11392
+ <file-magnifying-glass-icon listPanelEmptyIcon />
11393
+ {{ 'drawers.noMatches' | transloco: { query: queryText() } }}
11394
+ <p listPanelEmptyHint>{{ 'drawers.noMatchesHint' | transloco }}</p>
11395
+ </list-panel-empty>
11396
+ } @else {
11397
+ <list-panel-empty color="blue">
11398
+ <file-magnifying-glass-icon listPanelEmptyIcon />
11399
+ {{ 'drawers.typeToSearch' | transloco }}
11400
+ <p listPanelEmptyHint>{{ 'drawers.typeToSearchHint' | transloco }}</p>
11401
+ </list-panel-empty>
11402
+ }
11403
+ }
11102
11404
  `
11103
11405
  }]
11104
11406
  }],
11105
- propDecorators: { state: [{
11106
- type: i0.Input,
11107
- args: [{
11108
- isSignal: true,
11109
- alias: "state",
11110
- required: true
11407
+ propDecorators: {
11408
+ state: [{
11409
+ type: i0.Input,
11410
+ args: [{
11411
+ isSignal: true,
11412
+ alias: "state",
11413
+ required: true
11414
+ }]
11415
+ }],
11416
+ queryText: [{
11417
+ type: i0.Input,
11418
+ args: [{
11419
+ isSignal: true,
11420
+ alias: "queryText",
11421
+ required: false
11422
+ }]
11111
11423
  }]
11112
- }] }
11424
+ }
11113
11425
  });
11114
11426
  var ArticleSimilarDocumentsTabPanel = class ArticleSimilarDocumentsTabPanel {
11115
11427
  article = input.required(...ngDevMode ? [{ debugName: "article" }] : /* istanbul ignore next */ []);
@@ -11156,6 +11468,7 @@ var ArticleSimilarDocumentsTabPanel = class ArticleSimilarDocumentsTabPanel {
11156
11468
  outputs: { selected: "selected" },
11157
11469
  ngImport: i0,
11158
11470
  template: `
11471
+ @if (state().hasContent()) {
11159
11472
  <List variant="list-navigation" size="small" class="gap-1" selectionMode="explicit">
11160
11473
  @for (document of state().similarDocuments(); track document.id) {
11161
11474
  <ListItem
@@ -11173,6 +11486,14 @@ var ArticleSimilarDocumentsTabPanel = class ArticleSimilarDocumentsTabPanel {
11173
11486
  </ListItem>
11174
11487
  }
11175
11488
  </List>
11489
+ } @else {
11490
+ <!-- Not reached through the tabs (they mount this panel once there is a document), but a host may mount it on its own. -->
11491
+ <list-panel-empty color="indigo">
11492
+ <files-icon listPanelEmptyIcon />
11493
+ {{ 'drawers.noSimilarDocuments' | transloco }}
11494
+ <p listPanelEmptyHint>{{ 'drawers.noSimilarDocumentsHint' | transloco }}</p>
11495
+ </list-panel-empty>
11496
+ }
11176
11497
  `,
11177
11498
  isInline: true,
11178
11499
  dependencies: [
@@ -11182,6 +11503,12 @@ var ArticleSimilarDocumentsTabPanel = class ArticleSimilarDocumentsTabPanel {
11182
11503
  selector: "source, Source",
11183
11504
  inputs: ["collection", "connector"]
11184
11505
  },
11506
+ {
11507
+ kind: "component",
11508
+ type: FilesIcon,
11509
+ selector: "files, files-icon, Files, FilesIcon",
11510
+ inputs: ["class"]
11511
+ },
11185
11512
  {
11186
11513
  kind: "directive",
11187
11514
  type: ListComponent,
@@ -11224,6 +11551,12 @@ var ArticleSimilarDocumentsTabPanel = class ArticleSimilarDocumentsTabPanel {
11224
11551
  inputs: ["class", "subtext"],
11225
11552
  exportAs: ["listItemText"]
11226
11553
  },
11554
+ {
11555
+ kind: "component",
11556
+ type: ListPanelEmptyComponent,
11557
+ selector: "list-panel-empty, ListPanelEmpty",
11558
+ inputs: ["color"]
11559
+ },
11227
11560
  {
11228
11561
  kind: "directive",
11229
11562
  type: SelectArticleDirective,
@@ -11234,6 +11567,11 @@ var ArticleSimilarDocumentsTabPanel = class ArticleSimilarDocumentsTabPanel {
11234
11567
  "redirectUrl"
11235
11568
  ],
11236
11569
  outputs: ["selected"]
11570
+ },
11571
+ {
11572
+ kind: "pipe",
11573
+ type: TranslocoPipe,
11574
+ name: "transloco"
11237
11575
  }
11238
11576
  ]
11239
11577
  });
@@ -11248,15 +11586,19 @@ i0.ɵɵngDeclareClassMetadata({
11248
11586
  args: [{
11249
11587
  selector: "article-similar-documents-tab-panel, ArticleSimilarDocumentsTabPanel, articlesimilardocumentstabpanel",
11250
11588
  imports: [
11589
+ TranslocoPipe,
11251
11590
  SourceComponent,
11591
+ FilesIcon,
11252
11592
  ListComponent,
11253
11593
  ListItemComponent,
11254
11594
  ListItemLeftDirective,
11255
11595
  ListItemAvatarDirective,
11256
11596
  ListItemTextComponent,
11597
+ ListPanelEmptyComponent,
11257
11598
  SelectArticleDirective
11258
11599
  ],
11259
11600
  template: `
11601
+ @if (state().hasContent()) {
11260
11602
  <List variant="list-navigation" size="small" class="gap-1" selectionMode="explicit">
11261
11603
  @for (document of state().similarDocuments(); track document.id) {
11262
11604
  <ListItem
@@ -11274,6 +11616,14 @@ i0.ɵɵngDeclareClassMetadata({
11274
11616
  </ListItem>
11275
11617
  }
11276
11618
  </List>
11619
+ } @else {
11620
+ <!-- Not reached through the tabs (they mount this panel once there is a document), but a host may mount it on its own. -->
11621
+ <list-panel-empty color="indigo">
11622
+ <files-icon listPanelEmptyIcon />
11623
+ {{ 'drawers.noSimilarDocuments' | transloco }}
11624
+ <p listPanelEmptyHint>{{ 'drawers.noSimilarDocumentsHint' | transloco }}</p>
11625
+ </list-panel-empty>
11626
+ }
11277
11627
  `
11278
11628
  }]
11279
11629
  }],
@@ -11398,7 +11748,7 @@ var AdvancedSearchTabs = class AdvancedSearchTabs {
11398
11748
  host: { classAttribute: "flex min-h-0 flex-col overflow-x-hidden text-(length:--size-xs)" },
11399
11749
  providers: [provideTranslocoScope("drawers")],
11400
11750
  ngImport: i0,
11401
- template: "@if (article()) {\n @if (searchBar()) {\n <section class=\"mx-3 mt-3 mb-3 flex shrink-0 flex-col gap-4\">\n <!-- Enter submits through (keydown.enter), the send button through (search). No debounce: same \"submit on\n Enter/send, not on every keystroke\" contract as the previous input-group implementation. -->\n <SearchBar\n size=\"small\"\n [placeholder]=\"'drawers.inputPlaceholder' | transloco\"\n [aria-label]=\"'drawers.inputPlaceholder' | transloco\"\n [(value)]=\"queryText\"\n (keydown.enter)=\"executeSearch()\"\n (search)=\"executeSearch()\" />\n </section>\n }\n\n <!-- flex-1/min-h-0: this is what lets the panel content below be the only thing that scrolls —\n the search bar/tab list above are shrink-0, never pushed off or scrolled themselves. -->\n <Tabs class=\"flex min-h-0 flex-1 flex-col\">\n <!-- noTruncate skips the default `truncate` branch (whose `has-[span]:[&>span]:truncate` would\n otherwise force this span back to single-line ellipsis, defeating the wrap below); overriding\n its own `min-w-fit` with `min-w-[auto]` restores the flex item's normal \"automatic minimum\n size\" instead — for content that can wrap, that floor is the longest word (min-content), not\n the full unwrapped label (max-content, what `fit-content` resolves to here with nothing\n constraining the available space). A single-word label has nothing to wrap, so min-content ==\n max-content and it can't be squeezed below its own readable width — the flex-shrink deficit\n falls on the multi-word labels that actually have room to give, which wrap onto the span's\n line-clamp-2 instead of being cut with an ellipsis (appropriate for a single-line pill, not\n for a tab bar where \"Similar documents\"/\"Match locations\" routinely don't fit on one line).\n h-9 (fixed, not h-auto) + items-center on every tab keeps the row visually even whether a\n given label wraps onto 2 lines or stays on 1. No horizontal scroll/truncation fallback here on\n purpose: with all five tabs the row wraps its labels in the 360px panel of the preview's find\n field (see preview-actions.ts) rather than cutting them with an ellipsis. -->\n <TabList\n class=\"mx-3 shrink-0\"\n [class.mt-3]=\"!searchBar()\"\n variant=\"inner\"\n [selectedTab]=\"selectedTab()\"\n (selectedTabChange)=\"activeTab.set($event)\">\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"snippet\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"drawers.snippetTab\" | transloco }}</span\n >\n </Tab>\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"extracts\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"extracts\" | transloco }}</span\n >\n </Tab>\n @if (entities.hasContent()) {\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"entities\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"drawers.entitiesTab\" | transloco }}</span\n >\n </Tab>\n }\n @if (labels.hasContent()) {\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"labels\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"labels\" | transloco }}</span\n >\n </Tab>\n }\n @if (similar.hasContent()) {\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"similar\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"drawers.similarDocuments\" | transloco }}</span\n >\n </Tab>\n }\n </TabList>\n\n <!-- The single scroll container: each TabPanel is `absolute inset-0` (only the active one\n visible, per TabPanelComponent's own `[class.hidden]`), so they all share this one\n min-h-0/flex-1/overflow-y-auto region instead of the whole component scrolling. -->\n <div class=\"relative m-3 min-h-0 flex-1 scrollbar-thin overflow-y-auto\">\n <!-- Every TabPanel below cedes its own tab stop to its content (TabPanelComponent's `tabIndex`\n doc, ui/tabs/tabs.ts): the APG makes a tabpanel focusable only when it holds no focusable\n child, otherwise Tab from the tab lands on the empty container instead of the first row.\n `extracts` is always rendered (own empty/loading states, per the class doc above) so its\n tabindex is computed from whether it currently has any focusable row;\n `entities`/`labels`/`similar` are only ever mounted via `hasContent()`,\n which — per their `injectXState` — guarantees at least one row, so a static -1 is safe there. -->\n <!-- Always there, like Extracts: with nothing to show it says why — no search yet, or no match. The\n panel keeps its tab stop while it is empty (an empty tabpanel is the only thing Tab can reach\n from its tab), and cedes it to its first row as soon as there is one. -->\n <TabPanel\n value=\"snippet\"\n [tabindex]=\"matchLocations.hasContent() ? -1 : 0\"\n class=\"absolute inset-0\">\n @if (matchLocations.hasContent()) {\n <article-match-locations-tab-panel [state]=\"matchLocations\" />\n } @else {\n <p class=\"px-2 py-1 text-center text-(--font-primary-muted)\">\n @if (queryText()) {\n {{ \"drawers.noMatches\" | transloco: { query: queryText() } }}\n } @else {\n {{ \"drawers.typeToSearch\" | transloco }}\n }\n </p>\n }\n </TabPanel>\n <TabPanel\n value=\"extracts\"\n [tabindex]=\"\n extracts.loading() || extracts.extracts().length === 0 ? 0 : -1\n \"\n class=\"absolute inset-0\">\n <article-extracts-tab-panel [state]=\"extracts\" />\n </TabPanel>\n @if (entities.hasContent()) {\n <TabPanel value=\"entities\" tabindex=\"-1\" class=\"absolute inset-0\">\n <article-entities-tab-panel [state]=\"entities\" />\n </TabPanel>\n }\n @if (labels.hasContent()) {\n <TabPanel value=\"labels\" tabindex=\"-1\" class=\"absolute inset-0\">\n <article-labels-tab-panel [article]=\"article()\" [state]=\"labels\" />\n </TabPanel>\n }\n @if (similar.hasContent()) {\n <TabPanel value=\"similar\" tabindex=\"-1\" class=\"absolute inset-0\">\n <article-similar-documents-tab-panel\n [article]=\"article()\"\n [strategy]=\"previewStrategy()\"\n [state]=\"similar\"\n (selected)=\"selected.emit($event)\" />\n </TabPanel>\n }\n </div>\n </Tabs>\n}\n",
11751
+ template: "@if (article()) {\n @if (searchBar()) {\n <section class=\"mx-3 mt-3 mb-3 flex shrink-0 flex-col gap-4\">\n <!-- Enter submits through (keydown.enter), the send button through (search). No debounce: same \"submit on\n Enter/send, not on every keystroke\" contract as the previous input-group implementation. -->\n <SearchBar\n size=\"small\"\n [placeholder]=\"'drawers.inputPlaceholder' | transloco\"\n [aria-label]=\"'drawers.inputPlaceholder' | transloco\"\n [(value)]=\"queryText\"\n (keydown.enter)=\"executeSearch()\"\n (search)=\"executeSearch()\" />\n </section>\n }\n\n <!-- flex-1/min-h-0: this is what lets the panel content below be the only thing that scrolls —\n the search bar/tab list above are shrink-0, never pushed off or scrolled themselves. -->\n <Tabs class=\"flex min-h-0 flex-1 flex-col\">\n <!-- noTruncate skips the default `truncate` branch (whose `has-[span]:[&>span]:truncate` would\n otherwise force this span back to single-line ellipsis, defeating the wrap below); overriding\n its own `min-w-fit` with `min-w-[auto]` restores the flex item's normal \"automatic minimum\n size\" instead — for content that can wrap, that floor is the longest word (min-content), not\n the full unwrapped label (max-content, what `fit-content` resolves to here with nothing\n constraining the available space). A single-word label has nothing to wrap, so min-content ==\n max-content and it can't be squeezed below its own readable width — the flex-shrink deficit\n falls on the multi-word labels that actually have room to give, which wrap onto the span's\n line-clamp-2 instead of being cut with an ellipsis (appropriate for a single-line pill, not\n for a tab bar where \"Similar documents\"/\"Match locations\" routinely don't fit on one line).\n h-9 (fixed, not h-auto) + items-center on every tab keeps the row visually even whether a\n given label wraps onto 2 lines or stays on 1. No horizontal scroll/truncation fallback here on\n purpose: with all five tabs the row wraps its labels in the 360px panel of the preview's find\n field (see preview-actions.ts) rather than cutting them with an ellipsis. -->\n <TabList\n class=\"mx-3 shrink-0\"\n [class.mt-3]=\"!searchBar()\"\n variant=\"inner\"\n [selectedTab]=\"selectedTab()\"\n (selectedTabChange)=\"activeTab.set($event)\">\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"snippet\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"drawers.snippetTab\" | transloco }}</span\n >\n </Tab>\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"extracts\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"extracts\" | transloco }}</span\n >\n </Tab>\n @if (entities.hasContent()) {\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"entities\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"drawers.entitiesTab\" | transloco }}</span\n >\n </Tab>\n }\n @if (labels.hasContent()) {\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"labels\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"labels\" | transloco }}</span\n >\n </Tab>\n }\n @if (similar.hasContent()) {\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"similar\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"drawers.similarDocuments\" | transloco }}</span\n >\n </Tab>\n }\n </TabList>\n\n <!-- The single scroll container: each TabPanel is `absolute inset-0` (only the active one\n visible, per TabPanelComponent's own `[class.hidden]`), so they all share this one\n min-h-0/flex-1/overflow-y-auto region instead of the whole component scrolling. -->\n <div class=\"relative m-3 min-h-0 flex-1 scrollbar-thin overflow-y-auto\">\n <!-- Every TabPanel below cedes its own tab stop to its content (TabPanelComponent's `tabIndex`\n doc, ui/tabs/tabs.ts): the APG makes a tabpanel focusable only when it holds no focusable\n child, otherwise Tab from the tab lands on the empty container instead of the first row.\n `extracts` is always rendered (own empty/loading states, per the class doc above) so its\n tabindex is computed from whether it currently has any focusable row;\n `entities`/`labels`/`similar` are only ever mounted via `hasContent()`,\n which — per their `injectXState` — guarantees at least one row, so a static -1 is safe there. -->\n <!-- Always there, like Extracts: with nothing to show it says why — no search yet, or no match. The\n panel keeps its tab stop while it is empty (an empty tabpanel is the only thing Tab can reach\n from its tab), and cedes it to its first row as soon as there is one. -->\n <TabPanel\n value=\"snippet\"\n [tabindex]=\"matchLocations.hasContent() ? -1 : 0\"\n class=\"absolute inset-0\">\n <article-match-locations-tab-panel\n [state]=\"matchLocations\"\n [queryText]=\"queryText()\" />\n </TabPanel>\n <TabPanel\n value=\"extracts\"\n [tabindex]=\"\n extracts.loading() || extracts.extracts().length === 0 ? 0 : -1\n \"\n class=\"absolute inset-0\">\n <article-extracts-tab-panel [state]=\"extracts\" />\n </TabPanel>\n @if (entities.hasContent()) {\n <TabPanel value=\"entities\" tabindex=\"-1\" class=\"absolute inset-0\">\n <article-entities-tab-panel [state]=\"entities\" />\n </TabPanel>\n }\n @if (labels.hasContent()) {\n <TabPanel value=\"labels\" tabindex=\"-1\" class=\"absolute inset-0\">\n <article-labels-tab-panel [article]=\"article()\" [state]=\"labels\" />\n </TabPanel>\n }\n @if (similar.hasContent()) {\n <TabPanel value=\"similar\" tabindex=\"-1\" class=\"absolute inset-0\">\n <article-similar-documents-tab-panel\n [article]=\"article()\"\n [strategy]=\"previewStrategy()\"\n [state]=\"similar\"\n (selected)=\"selected.emit($event)\" />\n </TabPanel>\n }\n </div>\n </Tabs>\n}\n",
11402
11752
  dependencies: [
11403
11753
  {
11404
11754
  kind: "component",
@@ -11456,7 +11806,7 @@ var AdvancedSearchTabs = class AdvancedSearchTabs {
11456
11806
  kind: "component",
11457
11807
  type: ArticleMatchLocationsTabPanel,
11458
11808
  selector: "article-match-locations-tab-panel, ArticleMatchLocationsTabPanel, articlematchlocationstabpanel",
11459
- inputs: ["state"]
11809
+ inputs: ["state", "queryText"]
11460
11810
  },
11461
11811
  {
11462
11812
  kind: "component",
@@ -11501,7 +11851,7 @@ i0.ɵɵngDeclareClassMetadata({
11501
11851
  ArticleMatchLocationsTabPanel,
11502
11852
  ArticleSimilarDocumentsTabPanel
11503
11853
  ],
11504
- template: "@if (article()) {\n @if (searchBar()) {\n <section class=\"mx-3 mt-3 mb-3 flex shrink-0 flex-col gap-4\">\n <!-- Enter submits through (keydown.enter), the send button through (search). No debounce: same \"submit on\n Enter/send, not on every keystroke\" contract as the previous input-group implementation. -->\n <SearchBar\n size=\"small\"\n [placeholder]=\"'drawers.inputPlaceholder' | transloco\"\n [aria-label]=\"'drawers.inputPlaceholder' | transloco\"\n [(value)]=\"queryText\"\n (keydown.enter)=\"executeSearch()\"\n (search)=\"executeSearch()\" />\n </section>\n }\n\n <!-- flex-1/min-h-0: this is what lets the panel content below be the only thing that scrolls —\n the search bar/tab list above are shrink-0, never pushed off or scrolled themselves. -->\n <Tabs class=\"flex min-h-0 flex-1 flex-col\">\n <!-- noTruncate skips the default `truncate` branch (whose `has-[span]:[&>span]:truncate` would\n otherwise force this span back to single-line ellipsis, defeating the wrap below); overriding\n its own `min-w-fit` with `min-w-[auto]` restores the flex item's normal \"automatic minimum\n size\" instead — for content that can wrap, that floor is the longest word (min-content), not\n the full unwrapped label (max-content, what `fit-content` resolves to here with nothing\n constraining the available space). A single-word label has nothing to wrap, so min-content ==\n max-content and it can't be squeezed below its own readable width — the flex-shrink deficit\n falls on the multi-word labels that actually have room to give, which wrap onto the span's\n line-clamp-2 instead of being cut with an ellipsis (appropriate for a single-line pill, not\n for a tab bar where \"Similar documents\"/\"Match locations\" routinely don't fit on one line).\n h-9 (fixed, not h-auto) + items-center on every tab keeps the row visually even whether a\n given label wraps onto 2 lines or stays on 1. No horizontal scroll/truncation fallback here on\n purpose: with all five tabs the row wraps its labels in the 360px panel of the preview's find\n field (see preview-actions.ts) rather than cutting them with an ellipsis. -->\n <TabList\n class=\"mx-3 shrink-0\"\n [class.mt-3]=\"!searchBar()\"\n variant=\"inner\"\n [selectedTab]=\"selectedTab()\"\n (selectedTabChange)=\"activeTab.set($event)\">\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"snippet\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"drawers.snippetTab\" | transloco }}</span\n >\n </Tab>\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"extracts\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"extracts\" | transloco }}</span\n >\n </Tab>\n @if (entities.hasContent()) {\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"entities\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"drawers.entitiesTab\" | transloco }}</span\n >\n </Tab>\n }\n @if (labels.hasContent()) {\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"labels\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"labels\" | transloco }}</span\n >\n </Tab>\n }\n @if (similar.hasContent()) {\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"similar\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"drawers.similarDocuments\" | transloco }}</span\n >\n </Tab>\n }\n </TabList>\n\n <!-- The single scroll container: each TabPanel is `absolute inset-0` (only the active one\n visible, per TabPanelComponent's own `[class.hidden]`), so they all share this one\n min-h-0/flex-1/overflow-y-auto region instead of the whole component scrolling. -->\n <div class=\"relative m-3 min-h-0 flex-1 scrollbar-thin overflow-y-auto\">\n <!-- Every TabPanel below cedes its own tab stop to its content (TabPanelComponent's `tabIndex`\n doc, ui/tabs/tabs.ts): the APG makes a tabpanel focusable only when it holds no focusable\n child, otherwise Tab from the tab lands on the empty container instead of the first row.\n `extracts` is always rendered (own empty/loading states, per the class doc above) so its\n tabindex is computed from whether it currently has any focusable row;\n `entities`/`labels`/`similar` are only ever mounted via `hasContent()`,\n which — per their `injectXState` — guarantees at least one row, so a static -1 is safe there. -->\n <!-- Always there, like Extracts: with nothing to show it says why — no search yet, or no match. The\n panel keeps its tab stop while it is empty (an empty tabpanel is the only thing Tab can reach\n from its tab), and cedes it to its first row as soon as there is one. -->\n <TabPanel\n value=\"snippet\"\n [tabindex]=\"matchLocations.hasContent() ? -1 : 0\"\n class=\"absolute inset-0\">\n @if (matchLocations.hasContent()) {\n <article-match-locations-tab-panel [state]=\"matchLocations\" />\n } @else {\n <p class=\"px-2 py-1 text-center text-(--font-primary-muted)\">\n @if (queryText()) {\n {{ \"drawers.noMatches\" | transloco: { query: queryText() } }}\n } @else {\n {{ \"drawers.typeToSearch\" | transloco }}\n }\n </p>\n }\n </TabPanel>\n <TabPanel\n value=\"extracts\"\n [tabindex]=\"\n extracts.loading() || extracts.extracts().length === 0 ? 0 : -1\n \"\n class=\"absolute inset-0\">\n <article-extracts-tab-panel [state]=\"extracts\" />\n </TabPanel>\n @if (entities.hasContent()) {\n <TabPanel value=\"entities\" tabindex=\"-1\" class=\"absolute inset-0\">\n <article-entities-tab-panel [state]=\"entities\" />\n </TabPanel>\n }\n @if (labels.hasContent()) {\n <TabPanel value=\"labels\" tabindex=\"-1\" class=\"absolute inset-0\">\n <article-labels-tab-panel [article]=\"article()\" [state]=\"labels\" />\n </TabPanel>\n }\n @if (similar.hasContent()) {\n <TabPanel value=\"similar\" tabindex=\"-1\" class=\"absolute inset-0\">\n <article-similar-documents-tab-panel\n [article]=\"article()\"\n [strategy]=\"previewStrategy()\"\n [state]=\"similar\"\n (selected)=\"selected.emit($event)\" />\n </TabPanel>\n }\n </div>\n </Tabs>\n}\n"
11854
+ template: "@if (article()) {\n @if (searchBar()) {\n <section class=\"mx-3 mt-3 mb-3 flex shrink-0 flex-col gap-4\">\n <!-- Enter submits through (keydown.enter), the send button through (search). No debounce: same \"submit on\n Enter/send, not on every keystroke\" contract as the previous input-group implementation. -->\n <SearchBar\n size=\"small\"\n [placeholder]=\"'drawers.inputPlaceholder' | transloco\"\n [aria-label]=\"'drawers.inputPlaceholder' | transloco\"\n [(value)]=\"queryText\"\n (keydown.enter)=\"executeSearch()\"\n (search)=\"executeSearch()\" />\n </section>\n }\n\n <!-- flex-1/min-h-0: this is what lets the panel content below be the only thing that scrolls —\n the search bar/tab list above are shrink-0, never pushed off or scrolled themselves. -->\n <Tabs class=\"flex min-h-0 flex-1 flex-col\">\n <!-- noTruncate skips the default `truncate` branch (whose `has-[span]:[&>span]:truncate` would\n otherwise force this span back to single-line ellipsis, defeating the wrap below); overriding\n its own `min-w-fit` with `min-w-[auto]` restores the flex item's normal \"automatic minimum\n size\" instead — for content that can wrap, that floor is the longest word (min-content), not\n the full unwrapped label (max-content, what `fit-content` resolves to here with nothing\n constraining the available space). A single-word label has nothing to wrap, so min-content ==\n max-content and it can't be squeezed below its own readable width — the flex-shrink deficit\n falls on the multi-word labels that actually have room to give, which wrap onto the span's\n line-clamp-2 instead of being cut with an ellipsis (appropriate for a single-line pill, not\n for a tab bar where \"Similar documents\"/\"Match locations\" routinely don't fit on one line).\n h-9 (fixed, not h-auto) + items-center on every tab keeps the row visually even whether a\n given label wraps onto 2 lines or stays on 1. No horizontal scroll/truncation fallback here on\n purpose: with all five tabs the row wraps its labels in the 360px panel of the preview's find\n field (see preview-actions.ts) rather than cutting them with an ellipsis. -->\n <TabList\n class=\"mx-3 shrink-0\"\n [class.mt-3]=\"!searchBar()\"\n variant=\"inner\"\n [selectedTab]=\"selectedTab()\"\n (selectedTabChange)=\"activeTab.set($event)\">\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"snippet\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"drawers.snippetTab\" | transloco }}</span\n >\n </Tab>\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"extracts\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"extracts\" | transloco }}</span\n >\n </Tab>\n @if (entities.hasContent()) {\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"entities\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"drawers.entitiesTab\" | transloco }}</span\n >\n </Tab>\n }\n @if (labels.hasContent()) {\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"labels\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"labels\" | transloco }}</span\n >\n </Tab>\n }\n @if (similar.hasContent()) {\n <Tab\n variant=\"secondary\"\n size=\"sm\"\n value=\"similar\"\n noTruncate\n class=\"h-9 min-w-[auto] items-center text-(length:--size-sm)\">\n <span\n class=\"line-clamp-2 text-center leading-tight whitespace-normal\"\n >{{ \"drawers.similarDocuments\" | transloco }}</span\n >\n </Tab>\n }\n </TabList>\n\n <!-- The single scroll container: each TabPanel is `absolute inset-0` (only the active one\n visible, per TabPanelComponent's own `[class.hidden]`), so they all share this one\n min-h-0/flex-1/overflow-y-auto region instead of the whole component scrolling. -->\n <div class=\"relative m-3 min-h-0 flex-1 scrollbar-thin overflow-y-auto\">\n <!-- Every TabPanel below cedes its own tab stop to its content (TabPanelComponent's `tabIndex`\n doc, ui/tabs/tabs.ts): the APG makes a tabpanel focusable only when it holds no focusable\n child, otherwise Tab from the tab lands on the empty container instead of the first row.\n `extracts` is always rendered (own empty/loading states, per the class doc above) so its\n tabindex is computed from whether it currently has any focusable row;\n `entities`/`labels`/`similar` are only ever mounted via `hasContent()`,\n which — per their `injectXState` — guarantees at least one row, so a static -1 is safe there. -->\n <!-- Always there, like Extracts: with nothing to show it says why — no search yet, or no match. The\n panel keeps its tab stop while it is empty (an empty tabpanel is the only thing Tab can reach\n from its tab), and cedes it to its first row as soon as there is one. -->\n <TabPanel\n value=\"snippet\"\n [tabindex]=\"matchLocations.hasContent() ? -1 : 0\"\n class=\"absolute inset-0\">\n <article-match-locations-tab-panel\n [state]=\"matchLocations\"\n [queryText]=\"queryText()\" />\n </TabPanel>\n <TabPanel\n value=\"extracts\"\n [tabindex]=\"\n extracts.loading() || extracts.extracts().length === 0 ? 0 : -1\n \"\n class=\"absolute inset-0\">\n <article-extracts-tab-panel [state]=\"extracts\" />\n </TabPanel>\n @if (entities.hasContent()) {\n <TabPanel value=\"entities\" tabindex=\"-1\" class=\"absolute inset-0\">\n <article-entities-tab-panel [state]=\"entities\" />\n </TabPanel>\n }\n @if (labels.hasContent()) {\n <TabPanel value=\"labels\" tabindex=\"-1\" class=\"absolute inset-0\">\n <article-labels-tab-panel [article]=\"article()\" [state]=\"labels\" />\n </TabPanel>\n }\n @if (similar.hasContent()) {\n <TabPanel value=\"similar\" tabindex=\"-1\" class=\"absolute inset-0\">\n <article-similar-documents-tab-panel\n [article]=\"article()\"\n [strategy]=\"previewStrategy()\"\n [state]=\"similar\"\n (selected)=\"selected.emit($event)\" />\n </TabPanel>\n }\n </div>\n </Tabs>\n}\n"
11505
11855
  }]
11506
11856
  }],
11507
11857
  propDecorators: {
@@ -11695,7 +12045,9 @@ var ArticleHighlights = class ArticleHighlights {
11695
12045
  <!-- A quiet row: the round mark is the category's colour when it is on, a ring when it is off. The arrows
11696
12046
  take the place of the count while the row is hovered or holds the focus: six sets of arrows at rest were
11697
12047
  the loudest thing in the panel. Nothing is kept free for them, so a name is cut (and shown in full in a
11698
- tooltip) only when it does not fit, with the arrows when they are there. -->
12048
+ tooltip) only when it does not fit, with the arrows when they are there. On a touch screen nothing hovers,
12049
+ and a browser such as Safari does not focus a button at a tap: the arrows of every category that is on are
12050
+ there all the time (a category that is off has nothing to step through). -->
11699
12051
  <li
11700
12052
  role="listitem"
11701
12053
  class="group/row relative flex h-10 items-center rounded-(--radius-md) select-none focus-within:bg-(--bg-primary-lightest) hover:bg-(--bg-primary-lightest)">
@@ -11703,11 +12055,11 @@ var ArticleHighlights = class ArticleHighlights {
11703
12055
  type="button"
11704
12056
  variant="none"
11705
12057
  size="md"
11706
- class="h-9 min-w-0 flex-1 justify-start gap-(--spacing-xs) rounded-(--radius-sm) px-(--spacing-2xs) text-start text-(--font-neutral-muted) focus-visible:-outline-offset-2 aria-pressed:text-(--font-neutral-black)"
12058
+ class="h-10 min-w-0 flex-1 justify-start gap-(--spacing-xs) rounded-(--radius-sm) px-(--spacing-2xs) text-start text-(--font-neutral-muted) focus-visible:-outline-offset-2 aria-pressed:text-(--font-neutral-black)"
11707
12059
  [attr.aria-pressed]="isActive(cat.key)"
11708
12060
  (click)="toggleCategory(cat.key)">
11709
12061
  <!-- The button wraps what it holds in its own label: the layout of the row is this span's. -->
11710
- <span class="flex w-full min-w-0 items-center gap-(--spacing-xs) text-(length:--size-base) leading-(--line-height-3) font-normal">
12062
+ <span class="flex w-full min-w-0 items-center gap-(--spacing-xs) text-(length:--size-lg) leading-(--line-height-3) font-normal">
11711
12063
  <span
11712
12064
  class="inline-flex size-5 shrink-0 items-center justify-center rounded-full border-2 border-(--stroke-neutral-base)"
11713
12065
  [style.background]="isActive(cat.key) ? cat.color : null"
@@ -11719,7 +12071,7 @@ var ArticleHighlights = class ArticleHighlights {
11719
12071
  <span class="min-w-0 truncate" truncated #cut="truncated" [tooltip]="categoryName(cat)" [tooltipDisabled]="!cut.isTruncated()">{{ categoryName(cat) }}</span>
11720
12072
  <span
11721
12073
  class="ms-auto ps-(--spacing-xs) pe-(--spacing-2xs) text-(length:--size-sm) text-(--font-neutral-muted) tabular-nums"
11722
- [class]="isActive(cat.key) ? 'group-focus-within/row:hidden group-hover/row:hidden' : ''"
12074
+ [class]="isActive(cat.key) ? 'group-focus-within/row:hidden group-hover/row:hidden pointer-coarse:hidden' : ''"
11723
12075
  >{{ cat.count }}</span
11724
12076
  >
11725
12077
  </span>
@@ -11727,30 +12079,15 @@ var ArticleHighlights = class ArticleHighlights {
11727
12079
 
11728
12080
  @if (isActive(cat.key)) {
11729
12081
  <span
11730
- class="hidden shrink-0 items-center gap-0.5 pe-(--spacing-3xs) ps-(--spacing-2xs) text-(length:--size-sm) text-(--font-neutral-muted) tabular-nums group-focus-within/row:flex group-hover/row:flex">
11731
- <button
11732
- type="button"
11733
- variant="tertiary"
11734
- scheme="neutral"
11735
- size="sm"
11736
- [iconOnly]="true"
11737
- [attr.aria-label]="'drawers.previousOccurrence' | transloco: { label: categoryName(cat) }"
11738
- (click)="gotoCat(cat.key, -1)">
11739
- <chevron-left />
11740
- </button>
11741
-
11742
- <span class="min-w-12 text-center">{{ cursorLabel(cat.key) }} / {{ cat.count }}</span>
11743
-
11744
- <button
11745
- type="button"
11746
- variant="tertiary"
11747
- scheme="neutral"
11748
- size="sm"
11749
- [iconOnly]="true"
11750
- [attr.aria-label]="'drawers.nextOccurrence' | transloco: { label: categoryName(cat) }"
11751
- (click)="gotoCat(cat.key, 1)">
11752
- <chevron-right />
11753
- </button>
12082
+ class="hidden shrink-0 items-center pe-(--spacing-3xs) ps-(--spacing-2xs) text-(length:--size-sm) text-(--font-neutral-muted) group-focus-within/row:flex group-hover/row:flex pointer-coarse:flex">
12083
+ <occurrence-navigator
12084
+ [active]="true"
12085
+ [index]="cursorLabel(cat.key)"
12086
+ [total]="cat.count"
12087
+ [previousLabel]="'drawers.previousOccurrence' | transloco: { label: categoryName(cat) }"
12088
+ [nextLabel]="'drawers.nextOccurrence' | transloco: { label: categoryName(cat) }"
12089
+ (previous)="gotoCat(cat.key, -1)"
12090
+ (next)="gotoCat(cat.key, 1)" />
11754
12091
  </span>
11755
12092
  }
11756
12093
  </li>
@@ -11765,6 +12102,13 @@ var ArticleHighlights = class ArticleHighlights {
11765
12102
  <span class="ms-auto" aria-live="polite">{{ "drawers.highlightsShown" | transloco: { shown: active().length, total: categories().length } }}</span>
11766
12103
  </div>
11767
12104
  </div>
12105
+ } @else {
12106
+ <!-- The empty-state rule of the list features: the feature's icon on a coloured tile, a title, one hint line. -->
12107
+ <list-panel-empty color="orange">
12108
+ <highlighter-icon listPanelEmptyIcon />
12109
+ {{ 'drawers.noHighlights' | transloco }}
12110
+ <p listPanelEmptyHint>{{ 'drawers.noHighlightsHint' | transloco }}</p>
12111
+ </list-panel-empty>
11768
12112
  }
11769
12113
  `,
11770
12114
  isInline: true,
@@ -11789,15 +12133,28 @@ var ArticleHighlights = class ArticleHighlights {
11789
12133
  },
11790
12134
  {
11791
12135
  kind: "component",
11792
- type: ChevronLeftIcon,
11793
- selector: "chevron-left, chevron-left-icon, ChevronLeft, ChevronLeftIcon",
12136
+ type: HighlighterIcon,
12137
+ selector: "highlighter, highlighter-icon, Highlighter, HighlighterIcon",
11794
12138
  inputs: ["class"]
11795
12139
  },
11796
12140
  {
11797
12141
  kind: "component",
11798
- type: ChevronRightIcon,
11799
- selector: "chevron-right, chevron-right-icon, ChevronRight, ChevronRightIcon",
11800
- inputs: ["class"]
12142
+ type: ListPanelEmptyComponent,
12143
+ selector: "list-panel-empty, ListPanelEmpty",
12144
+ inputs: ["color"]
12145
+ },
12146
+ {
12147
+ kind: "component",
12148
+ type: OccurrenceNavigatorComponent,
12149
+ selector: "occurrence-navigator, OccurrenceNavigator",
12150
+ inputs: [
12151
+ "active",
12152
+ "index",
12153
+ "total",
12154
+ "previousLabel",
12155
+ "nextLabel"
12156
+ ],
12157
+ outputs: ["previous", "next"]
11801
12158
  },
11802
12159
  {
11803
12160
  kind: "component",
@@ -11870,8 +12227,9 @@ i0.ɵɵngDeclareClassMetadata({
11870
12227
  TranslocoPipe,
11871
12228
  ButtonComponent,
11872
12229
  CheckIcon,
11873
- ChevronLeftIcon,
11874
- ChevronRightIcon,
12230
+ HighlighterIcon,
12231
+ ListPanelEmptyComponent,
12232
+ OccurrenceNavigatorComponent,
11875
12233
  SwitchComponent,
11876
12234
  TooltipDirective,
11877
12235
  TruncatedDirective
@@ -11885,7 +12243,9 @@ i0.ɵɵngDeclareClassMetadata({
11885
12243
  <!-- A quiet row: the round mark is the category's colour when it is on, a ring when it is off. The arrows
11886
12244
  take the place of the count while the row is hovered or holds the focus: six sets of arrows at rest were
11887
12245
  the loudest thing in the panel. Nothing is kept free for them, so a name is cut (and shown in full in a
11888
- tooltip) only when it does not fit, with the arrows when they are there. -->
12246
+ tooltip) only when it does not fit, with the arrows when they are there. On a touch screen nothing hovers,
12247
+ and a browser such as Safari does not focus a button at a tap: the arrows of every category that is on are
12248
+ there all the time (a category that is off has nothing to step through). -->
11889
12249
  <li
11890
12250
  role="listitem"
11891
12251
  class="group/row relative flex h-10 items-center rounded-(--radius-md) select-none focus-within:bg-(--bg-primary-lightest) hover:bg-(--bg-primary-lightest)">
@@ -11893,11 +12253,11 @@ i0.ɵɵngDeclareClassMetadata({
11893
12253
  type="button"
11894
12254
  variant="none"
11895
12255
  size="md"
11896
- class="h-9 min-w-0 flex-1 justify-start gap-(--spacing-xs) rounded-(--radius-sm) px-(--spacing-2xs) text-start text-(--font-neutral-muted) focus-visible:-outline-offset-2 aria-pressed:text-(--font-neutral-black)"
12256
+ class="h-10 min-w-0 flex-1 justify-start gap-(--spacing-xs) rounded-(--radius-sm) px-(--spacing-2xs) text-start text-(--font-neutral-muted) focus-visible:-outline-offset-2 aria-pressed:text-(--font-neutral-black)"
11897
12257
  [attr.aria-pressed]="isActive(cat.key)"
11898
12258
  (click)="toggleCategory(cat.key)">
11899
12259
  <!-- The button wraps what it holds in its own label: the layout of the row is this span's. -->
11900
- <span class="flex w-full min-w-0 items-center gap-(--spacing-xs) text-(length:--size-base) leading-(--line-height-3) font-normal">
12260
+ <span class="flex w-full min-w-0 items-center gap-(--spacing-xs) text-(length:--size-lg) leading-(--line-height-3) font-normal">
11901
12261
  <span
11902
12262
  class="inline-flex size-5 shrink-0 items-center justify-center rounded-full border-2 border-(--stroke-neutral-base)"
11903
12263
  [style.background]="isActive(cat.key) ? cat.color : null"
@@ -11909,7 +12269,7 @@ i0.ɵɵngDeclareClassMetadata({
11909
12269
  <span class="min-w-0 truncate" truncated #cut="truncated" [tooltip]="categoryName(cat)" [tooltipDisabled]="!cut.isTruncated()">{{ categoryName(cat) }}</span>
11910
12270
  <span
11911
12271
  class="ms-auto ps-(--spacing-xs) pe-(--spacing-2xs) text-(length:--size-sm) text-(--font-neutral-muted) tabular-nums"
11912
- [class]="isActive(cat.key) ? 'group-focus-within/row:hidden group-hover/row:hidden' : ''"
12272
+ [class]="isActive(cat.key) ? 'group-focus-within/row:hidden group-hover/row:hidden pointer-coarse:hidden' : ''"
11913
12273
  >{{ cat.count }}</span
11914
12274
  >
11915
12275
  </span>
@@ -11917,30 +12277,15 @@ i0.ɵɵngDeclareClassMetadata({
11917
12277
 
11918
12278
  @if (isActive(cat.key)) {
11919
12279
  <span
11920
- class="hidden shrink-0 items-center gap-0.5 pe-(--spacing-3xs) ps-(--spacing-2xs) text-(length:--size-sm) text-(--font-neutral-muted) tabular-nums group-focus-within/row:flex group-hover/row:flex">
11921
- <button
11922
- type="button"
11923
- variant="tertiary"
11924
- scheme="neutral"
11925
- size="sm"
11926
- [iconOnly]="true"
11927
- [attr.aria-label]="'drawers.previousOccurrence' | transloco: { label: categoryName(cat) }"
11928
- (click)="gotoCat(cat.key, -1)">
11929
- <chevron-left />
11930
- </button>
11931
-
11932
- <span class="min-w-12 text-center">{{ cursorLabel(cat.key) }} / {{ cat.count }}</span>
11933
-
11934
- <button
11935
- type="button"
11936
- variant="tertiary"
11937
- scheme="neutral"
11938
- size="sm"
11939
- [iconOnly]="true"
11940
- [attr.aria-label]="'drawers.nextOccurrence' | transloco: { label: categoryName(cat) }"
11941
- (click)="gotoCat(cat.key, 1)">
11942
- <chevron-right />
11943
- </button>
12280
+ class="hidden shrink-0 items-center pe-(--spacing-3xs) ps-(--spacing-2xs) text-(length:--size-sm) text-(--font-neutral-muted) group-focus-within/row:flex group-hover/row:flex pointer-coarse:flex">
12281
+ <occurrence-navigator
12282
+ [active]="true"
12283
+ [index]="cursorLabel(cat.key)"
12284
+ [total]="cat.count"
12285
+ [previousLabel]="'drawers.previousOccurrence' | transloco: { label: categoryName(cat) }"
12286
+ [nextLabel]="'drawers.nextOccurrence' | transloco: { label: categoryName(cat) }"
12287
+ (previous)="gotoCat(cat.key, -1)"
12288
+ (next)="gotoCat(cat.key, 1)" />
11944
12289
  </span>
11945
12290
  }
11946
12291
  </li>
@@ -11955,6 +12300,13 @@ i0.ɵɵngDeclareClassMetadata({
11955
12300
  <span class="ms-auto" aria-live="polite">{{ "drawers.highlightsShown" | transloco: { shown: active().length, total: categories().length } }}</span>
11956
12301
  </div>
11957
12302
  </div>
12303
+ } @else {
12304
+ <!-- The empty-state rule of the list features: the feature's icon on a coloured tile, a title, one hint line. -->
12305
+ <list-panel-empty color="orange">
12306
+ <highlighter-icon listPanelEmptyIcon />
12307
+ {{ 'drawers.noHighlights' | transloco }}
12308
+ <p listPanelEmptyHint>{{ 'drawers.noHighlightsHint' | transloco }}</p>
12309
+ </list-panel-empty>
11958
12310
  }
11959
12311
  `
11960
12312
  }]
@@ -13163,6 +13515,154 @@ i0.ɵɵngDeclareClassMetadata({
13163
13515
  }]
13164
13516
  }
13165
13517
  });
13518
+ function distance(touches) {
13519
+ return Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY);
13520
+ }
13521
+ var PinchZoomDirective = class PinchZoomDirective {
13522
+ zoom = model.required({
13523
+ ...ngDevMode ? { debugName: "zoom" } : /* istanbul ignore next */ {},
13524
+ alias: "pinchZoom"
13525
+ });
13526
+ min = input(.5, {
13527
+ ...ngDevMode ? { debugName: "min" } : /* istanbul ignore next */ {},
13528
+ alias: "pinchZoomMin",
13529
+ transform: numberAttribute
13530
+ });
13531
+ max = input(2, {
13532
+ ...ngDevMode ? { debugName: "max" } : /* istanbul ignore next */ {},
13533
+ alias: "pinchZoomMax",
13534
+ transform: numberAttribute
13535
+ });
13536
+ host = inject(ElementRef).nativeElement;
13537
+ injector = inject(Injector);
13538
+ pinch = null;
13539
+ constructor() {
13540
+ const start = (event) => {
13541
+ this.pinch = null;
13542
+ if (event.touches.length !== 2) return;
13543
+ const startDistance = distance(event.touches);
13544
+ if (startDistance >= 1) this.pinch = {
13545
+ startDistance,
13546
+ startZoom: this.zoom()
13547
+ };
13548
+ };
13549
+ const end = () => {
13550
+ this.pinch = null;
13551
+ };
13552
+ this.host.addEventListener("touchstart", start, { passive: true });
13553
+ this.host.addEventListener("touchmove", this.onMove, { passive: false });
13554
+ this.host.addEventListener("touchend", end, { passive: true });
13555
+ this.host.addEventListener("touchcancel", end, { passive: true });
13556
+ inject(DestroyRef).onDestroy(() => {
13557
+ this.host.removeEventListener("touchstart", start);
13558
+ this.host.removeEventListener("touchmove", this.onMove);
13559
+ this.host.removeEventListener("touchend", end);
13560
+ this.host.removeEventListener("touchcancel", end);
13561
+ });
13562
+ }
13563
+ onMove = (event) => {
13564
+ if (!this.pinch || event.touches.length !== 2) return;
13565
+ event.preventDefault();
13566
+ const previous = this.zoom();
13567
+ const next = Math.min(this.max(), Math.max(this.min(), this.pinch.startZoom * (distance(event.touches) / this.pinch.startDistance)));
13568
+ if (next === previous) return;
13569
+ const rect = this.host.getBoundingClientRect();
13570
+ const x = (event.touches[0].clientX + event.touches[1].clientX) / 2 - rect.left;
13571
+ const y = (event.touches[0].clientY + event.touches[1].clientY) / 2 - rect.top;
13572
+ const left = this.host.scrollLeft;
13573
+ const top = this.host.scrollTop;
13574
+ this.zoom.set(next);
13575
+ afterNextRender({ write: () => {
13576
+ const ratio = next / previous;
13577
+ this.host.scrollLeft = (left + x) * ratio - x;
13578
+ this.host.scrollTop = (top + y) * ratio - y;
13579
+ } }, { injector: this.injector });
13580
+ };
13581
+ static ɵfac = i0.ɵɵngDeclareFactory({
13582
+ minVersion: "12.0.0",
13583
+ version: "22.2.1",
13584
+ ngImport: i0,
13585
+ type: PinchZoomDirective,
13586
+ deps: [],
13587
+ target: i0.ɵɵFactoryTarget.Directive
13588
+ });
13589
+ static ɵdir = i0.ɵɵngDeclareDirective({
13590
+ minVersion: "17.1.0",
13591
+ version: "22.2.1",
13592
+ type: PinchZoomDirective,
13593
+ isStandalone: true,
13594
+ selector: "[pinchZoom]",
13595
+ inputs: {
13596
+ zoom: {
13597
+ classPropertyName: "zoom",
13598
+ publicName: "pinchZoom",
13599
+ isSignal: true,
13600
+ isRequired: true,
13601
+ transformFunction: null
13602
+ },
13603
+ min: {
13604
+ classPropertyName: "min",
13605
+ publicName: "pinchZoomMin",
13606
+ isSignal: true,
13607
+ isRequired: false,
13608
+ transformFunction: null
13609
+ },
13610
+ max: {
13611
+ classPropertyName: "max",
13612
+ publicName: "pinchZoomMax",
13613
+ isSignal: true,
13614
+ isRequired: false,
13615
+ transformFunction: null
13616
+ }
13617
+ },
13618
+ outputs: { zoom: "pinchZoomChange" },
13619
+ host: { properties: { "style.touch-action": "'pan-x pan-y'" } },
13620
+ ngImport: i0
13621
+ });
13622
+ };
13623
+ i0.ɵɵngDeclareClassMetadata({
13624
+ minVersion: "12.0.0",
13625
+ version: "22.2.1",
13626
+ ngImport: i0,
13627
+ type: PinchZoomDirective,
13628
+ decorators: [{
13629
+ type: Directive,
13630
+ args: [{
13631
+ selector: "[pinchZoom]",
13632
+ host: { "[style.touch-action]": "'pan-x pan-y'" }
13633
+ }]
13634
+ }],
13635
+ ctorParameters: () => [],
13636
+ propDecorators: {
13637
+ zoom: [{
13638
+ type: i0.Input,
13639
+ args: [{
13640
+ isSignal: true,
13641
+ alias: "pinchZoom",
13642
+ required: true
13643
+ }]
13644
+ }, {
13645
+ type: i0.Output,
13646
+ args: ["pinchZoomChange"]
13647
+ }],
13648
+ min: [{
13649
+ type: i0.Input,
13650
+ args: [{
13651
+ isSignal: true,
13652
+ alias: "pinchZoomMin",
13653
+ required: false
13654
+ }]
13655
+ }],
13656
+ max: [{
13657
+ type: i0.Input,
13658
+ args: [{
13659
+ isSignal: true,
13660
+ alias: "pinchZoomMax",
13661
+ required: false
13662
+ }]
13663
+ }]
13664
+ }
13665
+ });
13166
13666
  const MARKDOWN_ZOOM_STEP = .1;
13167
13667
  const MARKDOWN_ZOOM_MIN = .5;
13168
13668
  const MARKDOWN_ZOOM_MAX = 2;
@@ -13585,7 +14085,11 @@ var PreviewContentComponent = class PreviewContentComponent {
13585
14085
  div, not preview.js's iframe. Zoom is reimplemented locally below (the CSS zoom property, not
13586
14086
  preview.js's transform+relayout dance — markdown content is far smaller than a converted
13587
14087
  document, so that sophistication buys nothing here). -->
13588
- <div class="h-full overflow-auto rounded-(--radius-sm) bg-(--bg-neutral-white) px-(--spacing-2xl) py-(--spacing-xl) shadow-(--shadow-1)">
14088
+ <div
14089
+ [(pinchZoom)]="markdownZoom"
14090
+ [pinchZoomMin]="markdownZoomMin"
14091
+ [pinchZoomMax]="markdownZoomMax"
14092
+ class="h-full overflow-auto rounded-(--radius-sm) bg-(--bg-neutral-white) px-(--spacing-2xl) py-(--spacing-xl) shadow-(--shadow-1)">
13589
14093
  <div class="prose max-w-none dark:prose-invert" [style.zoom]="markdownZoom()" [innerHTML]="markdownResource.value() | markdown"></div>
13590
14094
  </div>
13591
14095
  } @else {
@@ -13680,6 +14184,17 @@ var PreviewContentComponent = class PreviewContentComponent {
13680
14184
  "zoomFit"
13681
14185
  ]
13682
14186
  },
14187
+ {
14188
+ kind: "directive",
14189
+ type: PinchZoomDirective,
14190
+ selector: "[pinchZoom]",
14191
+ inputs: [
14192
+ "pinchZoom",
14193
+ "pinchZoomMin",
14194
+ "pinchZoomMax"
14195
+ ],
14196
+ outputs: ["pinchZoomChange"]
14197
+ },
13683
14198
  {
13684
14199
  kind: "component",
13685
14200
  type: AdvancedSearchTabs,
@@ -13779,6 +14294,7 @@ i0.ɵɵngDeclareClassMetadata({
13779
14294
  imports: [
13780
14295
  TranslocoPipe,
13781
14296
  PreviewActionsComponent,
14297
+ PinchZoomDirective,
13782
14298
  AdvancedSearchTabs,
13783
14299
  LoadingDotsComponent,
13784
14300
  ImageIcon,
@@ -13874,7 +14390,11 @@ i0.ɵɵngDeclareClassMetadata({
13874
14390
  div, not preview.js's iframe. Zoom is reimplemented locally below (the CSS zoom property, not
13875
14391
  preview.js's transform+relayout dance — markdown content is far smaller than a converted
13876
14392
  document, so that sophistication buys nothing here). -->
13877
- <div class="h-full overflow-auto rounded-(--radius-sm) bg-(--bg-neutral-white) px-(--spacing-2xl) py-(--spacing-xl) shadow-(--shadow-1)">
14393
+ <div
14394
+ [(pinchZoom)]="markdownZoom"
14395
+ [pinchZoomMin]="markdownZoomMin"
14396
+ [pinchZoomMax]="markdownZoomMax"
14397
+ class="h-full overflow-auto rounded-(--radius-sm) bg-(--bg-neutral-white) px-(--spacing-2xl) py-(--spacing-xl) shadow-(--shadow-1)">
13878
14398
  <div class="prose max-w-none dark:prose-invert" [style.zoom]="markdownZoom()" [innerHTML]="markdownResource.value() | markdown"></div>
13879
14399
  </div>
13880
14400
  } @else {
@@ -14109,7 +14629,7 @@ var PreviewHeaderComponent = class PreviewHeaderComponent {
14109
14629
  @if (href()) {
14110
14630
  <arrow-up-right-from-square-icon
14111
14631
  aria-hidden="true"
14112
- class="size-(--icon-sm) shrink-0 opacity-0 transition-opacity duration-150 group-hover/title:opacity-100 group-focus-visible/title:opacity-100" />
14632
+ class="size-(--icon-sm) shrink-0 opacity-0 transition-opacity duration-150 group-hover/title:opacity-100 group-focus-visible/title:opacity-100 pointer-coarse:opacity-100" />
14113
14633
  }
14114
14634
  </a>
14115
14635
 
@@ -14197,7 +14717,7 @@ i0.ɵɵngDeclareClassMetadata({
14197
14717
  @if (href()) {
14198
14718
  <arrow-up-right-from-square-icon
14199
14719
  aria-hidden="true"
14200
- class="size-(--icon-sm) shrink-0 opacity-0 transition-opacity duration-150 group-hover/title:opacity-100 group-focus-visible/title:opacity-100" />
14720
+ class="size-(--icon-sm) shrink-0 opacity-0 transition-opacity duration-150 group-hover/title:opacity-100 group-focus-visible/title:opacity-100 pointer-coarse:opacity-100" />
14201
14721
  }
14202
14722
  </a>
14203
14723
 
@@ -14536,7 +15056,7 @@ var SponsoredResultsComponent = class SponsoredResultsComponent {
14536
15056
  @if (childElement()?.template) {
14537
15057
  <ng-container [ngTemplateOutlet]="childElement()?.template" />
14538
15058
  } @else {
14539
- <span class="promoted-badge bg-(--bg-primary-base) invisible flex items-center rounded-xl px-2 text-xs text-white peer-hover:visible">PROMOTED</span>
15059
+ <span class="promoted-badge bg-(--bg-primary-base) invisible flex items-center rounded-xl px-2 text-xs text-white peer-hover:visible pointer-coarse:visible">PROMOTED</span>
14540
15060
  }
14541
15061
  }
14542
15062
  </li>
@@ -14611,7 +15131,7 @@ i0.ɵɵngDeclareClassMetadata({
14611
15131
  @if (childElement()?.template) {
14612
15132
  <ng-container [ngTemplateOutlet]="childElement()?.template" />
14613
15133
  } @else {
14614
- <span class="promoted-badge bg-(--bg-primary-base) invisible flex items-center rounded-xl px-2 text-xs text-white peer-hover:visible">PROMOTED</span>
15134
+ <span class="promoted-badge bg-(--bg-primary-base) invisible flex items-center rounded-xl px-2 text-xs text-white peer-hover:visible pointer-coarse:visible">PROMOTED</span>
14615
15135
  }
14616
15136
  }
14617
15137
  </li>
@@ -16379,6 +16899,8 @@ var AdvancedFiltersBodyComponent = class AdvancedFiltersBodyComponent {
16379
16899
  "fieldLabelLang",
16380
16900
  "placeholder",
16381
16901
  "ariaLabel",
16902
+ "hideKeyboardLabel",
16903
+ "keyboardLabel",
16382
16904
  "noResultsText",
16383
16905
  "searchingText",
16384
16906
  "errorText",
@@ -16893,6 +17415,7 @@ var ArticleEntities = class ArticleEntities {
16893
17415
  cn = cn;
16894
17416
  article = input.required(...ngDevMode ? [{ debugName: "article" }] : /* istanbul ignore next */ []);
16895
17417
  state = injectEntitiesState(this.article);
17418
+ row = injectRowActivation(() => this.state.hovering);
16896
17419
  static ɵfac = i0.ɵɵngDeclareFactory({
16897
17420
  minVersion: "12.0.0",
16898
17421
  version: "22.2.1",
@@ -16928,30 +17451,24 @@ var ArticleEntities = class ArticleEntities {
16928
17451
  <chevron-right class="transition-transform duration-150 group-open/parent:rotate-90" />
16929
17452
  </summary>
16930
17453
 
16931
- <List class="gap-1 p-3 pt-0" selectionMode="explicit">
17454
+ <List class="gap-2 p-3 pt-0" selectionMode="explicit">
16932
17455
  @for (data of highlight.metadata; track data.value) {
16933
17456
  <ListItem size="medium"
16934
17457
  [value]="data.value"
16935
- class="select-none"
16936
- (mouseenter)="state.hovering.set(data.value)"
16937
- (mouseleave)="state.hovering.set(undefined)">
17458
+ class="select-none min-h-10 [&_.list-item-text]:text-(length:--size-lg)"
17459
+ (mouseenter)="row.enter(data.value)"
17460
+ (mouseleave)="row.leave()"
17461
+ (click)="row.activate(data.value)">
16938
17462
  <ListItemText>{{ data.display }}</ListItemText>
16939
17463
 
16940
- <span class="flex shrink-0 items-center gap-2">
16941
- @if (state.hovering() === data.value) {
16942
- <button class="hover:scale-125" aria-label="previous entity" (click)="state.navigatePrev(highlight.entity, data)">
16943
- <chevron-left />
16944
- </button>
16945
-
16946
- <span>{{ state.hoverIndex() }} / {{ state.total(highlight.entity, data) }}</span>
16947
-
16948
- <button class="hover:scale-125" aria-label="next entity" (click)="state.navigateNext(highlight.entity, data)">
16949
- <chevron-right />
16950
- </button>
16951
- } @else {
16952
- {{ state.total(highlight.entity, data) }}
16953
- }
16954
- </span>
17464
+ <occurrence-navigator
17465
+ [active]="state.hovering() === data.value"
17466
+ [index]="state.hoverIndex()"
17467
+ [total]="state.total(highlight.entity, data)"
17468
+ previousLabel="previous entity"
17469
+ nextLabel="next entity"
17470
+ (previous)="state.navigatePrev(highlight.entity, data)"
17471
+ (next)="state.navigateNext(highlight.entity, data)" />
16955
17472
  </ListItem>
16956
17473
  }
16957
17474
  </List>
@@ -17000,9 +17517,16 @@ var ArticleEntities = class ArticleEntities {
17000
17517
  },
17001
17518
  {
17002
17519
  kind: "component",
17003
- type: ChevronLeftIcon,
17004
- selector: "chevron-left, chevron-left-icon, ChevronLeft, ChevronLeftIcon",
17005
- inputs: ["class"]
17520
+ type: OccurrenceNavigatorComponent,
17521
+ selector: "occurrence-navigator, OccurrenceNavigator",
17522
+ inputs: [
17523
+ "active",
17524
+ "index",
17525
+ "total",
17526
+ "previousLabel",
17527
+ "nextLabel"
17528
+ ],
17529
+ outputs: ["previous", "next"]
17006
17530
  },
17007
17531
  {
17008
17532
  kind: "pipe",
@@ -17027,7 +17551,7 @@ i0.ɵɵngDeclareClassMetadata({
17027
17551
  ListItemComponent,
17028
17552
  ListItemTextComponent,
17029
17553
  ChevronRightIcon,
17030
- ChevronLeftIcon
17554
+ OccurrenceNavigatorComponent
17031
17555
  ],
17032
17556
  template: `
17033
17557
  @for (highlight of state.previewHighlights(); track highlight.entity) {
@@ -17042,30 +17566,24 @@ i0.ɵɵngDeclareClassMetadata({
17042
17566
  <chevron-right class="transition-transform duration-150 group-open/parent:rotate-90" />
17043
17567
  </summary>
17044
17568
 
17045
- <List class="gap-1 p-3 pt-0" selectionMode="explicit">
17569
+ <List class="gap-2 p-3 pt-0" selectionMode="explicit">
17046
17570
  @for (data of highlight.metadata; track data.value) {
17047
17571
  <ListItem size="medium"
17048
17572
  [value]="data.value"
17049
- class="select-none"
17050
- (mouseenter)="state.hovering.set(data.value)"
17051
- (mouseleave)="state.hovering.set(undefined)">
17573
+ class="select-none min-h-10 [&_.list-item-text]:text-(length:--size-lg)"
17574
+ (mouseenter)="row.enter(data.value)"
17575
+ (mouseleave)="row.leave()"
17576
+ (click)="row.activate(data.value)">
17052
17577
  <ListItemText>{{ data.display }}</ListItemText>
17053
17578
 
17054
- <span class="flex shrink-0 items-center gap-2">
17055
- @if (state.hovering() === data.value) {
17056
- <button class="hover:scale-125" aria-label="previous entity" (click)="state.navigatePrev(highlight.entity, data)">
17057
- <chevron-left />
17058
- </button>
17059
-
17060
- <span>{{ state.hoverIndex() }} / {{ state.total(highlight.entity, data) }}</span>
17061
-
17062
- <button class="hover:scale-125" aria-label="next entity" (click)="state.navigateNext(highlight.entity, data)">
17063
- <chevron-right />
17064
- </button>
17065
- } @else {
17066
- {{ state.total(highlight.entity, data) }}
17067
- }
17068
- </span>
17579
+ <occurrence-navigator
17580
+ [active]="state.hovering() === data.value"
17581
+ [index]="state.hoverIndex()"
17582
+ [total]="state.total(highlight.entity, data)"
17583
+ previousLabel="previous entity"
17584
+ nextLabel="next entity"
17585
+ (previous)="state.navigatePrev(highlight.entity, data)"
17586
+ (next)="state.navigateNext(highlight.entity, data)" />
17069
17587
  </ListItem>
17070
17588
  }
17071
17589
  </List>
@@ -17126,8 +17644,18 @@ var ArticleExtracts = class ArticleExtracts {
17126
17644
  <div class="flex h-32 items-center justify-center">
17127
17645
  <loading-dots size="md" class="text-(--font-primary-base)" [label]="'drawers.loadingExtracts' | transloco" />
17128
17646
  </div>
17647
+ } @else if (state.extracts().length === 0) {
17648
+ <!-- The same room as the list below and the other panels (entities, snippet): under the header, 12px around. -->
17649
+ <div class="px-3 pb-3">
17650
+ <list-panel-empty color="green">
17651
+ <file-text-icon listPanelEmptyIcon />
17652
+ {{ 'drawers.noRelevantExtracts' | transloco }}
17653
+ <p listPanelEmptyHint>{{ 'drawers.noRelevantExtractsHint' | transloco }}</p>
17654
+ </list-panel-empty>
17655
+ </div>
17129
17656
  } @else {
17130
- <List class="mr-2 gap-1" selectionMode="explicit">
17657
+ <!-- p-3 pt-0, as the lists of entities and snippet: the header above has its own 12px, the list needs them left, right and below. -->
17658
+ <List class="gap-1 p-3 pt-0" selectionMode="explicit">
17131
17659
  @for (extract of state.extracts(); track extract.id) {
17132
17660
  <ListItem
17133
17661
  [value]="extract.textIndex"
@@ -17137,8 +17665,6 @@ var ArticleExtracts = class ArticleExtracts {
17137
17665
  (keydown.space)="state.scrollTo(undefined, extract.textIndex, true)">
17138
17666
  <p class="line-clamp-5" [innerHTML]="extract.text"></p>
17139
17667
  </ListItem>
17140
- } @empty {
17141
- <p class="px-2 py-1 text-slate-300">{{ 'drawers.noRelevantExtracts' | transloco }}</p>
17142
17668
  }
17143
17669
  </List>
17144
17670
  }
@@ -17153,6 +17679,12 @@ var ArticleExtracts = class ArticleExtracts {
17153
17679
  selector: "chevron-right, chevron-right-icon, ChevronRight, ChevronRightIcon",
17154
17680
  inputs: ["class"]
17155
17681
  },
17682
+ {
17683
+ kind: "component",
17684
+ type: FileTextIcon,
17685
+ selector: "file-text, file-text-icon, FileText, FileTextIcon",
17686
+ inputs: ["class"]
17687
+ },
17156
17688
  {
17157
17689
  kind: "directive",
17158
17690
  type: ListComponent,
@@ -17176,6 +17708,12 @@ var ArticleExtracts = class ArticleExtracts {
17176
17708
  ],
17177
17709
  exportAs: ["listItem"]
17178
17710
  },
17711
+ {
17712
+ kind: "component",
17713
+ type: ListPanelEmptyComponent,
17714
+ selector: "list-panel-empty, ListPanelEmpty",
17715
+ inputs: ["color"]
17716
+ },
17179
17717
  {
17180
17718
  kind: "component",
17181
17719
  type: TagsIcon,
@@ -17208,8 +17746,10 @@ i0.ɵɵngDeclareClassMetadata({
17208
17746
  imports: [
17209
17747
  TranslocoPipe,
17210
17748
  ChevronRightIcon,
17749
+ FileTextIcon,
17211
17750
  ListComponent,
17212
17751
  ListItemComponent,
17752
+ ListPanelEmptyComponent,
17213
17753
  TagsIcon,
17214
17754
  LoadingDotsComponent
17215
17755
  ],
@@ -17228,8 +17768,18 @@ i0.ɵɵngDeclareClassMetadata({
17228
17768
  <div class="flex h-32 items-center justify-center">
17229
17769
  <loading-dots size="md" class="text-(--font-primary-base)" [label]="'drawers.loadingExtracts' | transloco" />
17230
17770
  </div>
17771
+ } @else if (state.extracts().length === 0) {
17772
+ <!-- The same room as the list below and the other panels (entities, snippet): under the header, 12px around. -->
17773
+ <div class="px-3 pb-3">
17774
+ <list-panel-empty color="green">
17775
+ <file-text-icon listPanelEmptyIcon />
17776
+ {{ 'drawers.noRelevantExtracts' | transloco }}
17777
+ <p listPanelEmptyHint>{{ 'drawers.noRelevantExtractsHint' | transloco }}</p>
17778
+ </list-panel-empty>
17779
+ </div>
17231
17780
  } @else {
17232
- <List class="mr-2 gap-1" selectionMode="explicit">
17781
+ <!-- p-3 pt-0, as the lists of entities and snippet: the header above has its own 12px, the list needs them left, right and below. -->
17782
+ <List class="gap-1 p-3 pt-0" selectionMode="explicit">
17233
17783
  @for (extract of state.extracts(); track extract.id) {
17234
17784
  <ListItem
17235
17785
  [value]="extract.textIndex"
@@ -17239,8 +17789,6 @@ i0.ɵɵngDeclareClassMetadata({
17239
17789
  (keydown.space)="state.scrollTo(undefined, extract.textIndex, true)">
17240
17790
  <p class="line-clamp-5" [innerHTML]="extract.text"></p>
17241
17791
  </ListItem>
17242
- } @empty {
17243
- <p class="px-2 py-1 text-slate-300">{{ 'drawers.noRelevantExtracts' | transloco }}</p>
17244
17792
  }
17245
17793
  </List>
17246
17794
  }
@@ -17428,6 +17976,7 @@ i0.ɵɵngDeclareClassMetadata({
17428
17976
  });
17429
17977
  var ArticleMatchLocations = class ArticleMatchLocations {
17430
17978
  state = injectMatchLocationsState();
17979
+ row = injectRowActivation(() => this.state.hovering);
17431
17980
  static ɵfac = i0.ɵɵngDeclareFactory({
17432
17981
  minVersion: "12.0.0",
17433
17982
  version: "22.2.1",
@@ -17455,31 +18004,25 @@ var ArticleMatchLocations = class ArticleMatchLocations {
17455
18004
  <chevron-right class="transition-transform duration-150 group-open/parent:rotate-90" />
17456
18005
  </summary>
17457
18006
 
17458
- <List class="gap-1 p-3 pt-0" selectionMode="explicit">
18007
+ <List class="gap-2 p-3 pt-0" selectionMode="explicit">
17459
18008
  @for (data of state.values(); track data.value) {
17460
18009
  <ListItem
17461
18010
  size="medium"
17462
18011
  [value]="data.value"
17463
- class="select-none"
17464
- (mouseenter)="state.hovering.set(data.value)"
17465
- (mouseleave)="state.hovering.set(undefined)">
18012
+ class="select-none min-h-10 [&_.list-item-text]:text-(length:--size-lg)"
18013
+ (mouseenter)="row.enter(data.value)"
18014
+ (mouseleave)="row.leave()"
18015
+ (click)="row.activate(data.value)">
17466
18016
  <ListItemText>{{ data.display }}</ListItemText>
17467
18017
 
17468
- <span class="flex shrink-0 items-center gap-2">
17469
- @if (state.hovering() === data.value) {
17470
- <button class="hover:scale-125" aria-label="previous match" (click)="state.navigatePrev(data)">
17471
- <chevron-left />
17472
- </button>
17473
-
17474
- <span>{{ state.hoverIndex() }} / {{ data.count }}</span>
17475
-
17476
- <button class="hover:scale-125" aria-label="next match" (click)="state.navigateNext(data)">
17477
- <chevron-right />
17478
- </button>
17479
- } @else {
17480
- {{ data.count }}
17481
- }
17482
- </span>
18018
+ <occurrence-navigator
18019
+ [active]="state.hovering() === data.value"
18020
+ [index]="state.hoverIndex()"
18021
+ [total]="data.count ?? 0"
18022
+ previousLabel="previous match"
18023
+ nextLabel="next match"
18024
+ (previous)="state.navigatePrev(data)"
18025
+ (next)="state.navigateNext(data)" />
17483
18026
  </ListItem>
17484
18027
  }
17485
18028
  </List>
@@ -17521,15 +18064,22 @@ var ArticleMatchLocations = class ArticleMatchLocations {
17521
18064
  },
17522
18065
  {
17523
18066
  kind: "component",
17524
- type: ChevronLeftIcon,
17525
- selector: "chevron-left, chevron-left-icon, ChevronLeft, ChevronLeftIcon",
18067
+ type: ChevronRightIcon,
18068
+ selector: "chevron-right, chevron-right-icon, ChevronRight, ChevronRightIcon",
17526
18069
  inputs: ["class"]
17527
18070
  },
17528
18071
  {
17529
18072
  kind: "component",
17530
- type: ChevronRightIcon,
17531
- selector: "chevron-right, chevron-right-icon, ChevronRight, ChevronRightIcon",
17532
- inputs: ["class"]
18073
+ type: OccurrenceNavigatorComponent,
18074
+ selector: "occurrence-navigator, OccurrenceNavigator",
18075
+ inputs: [
18076
+ "active",
18077
+ "index",
18078
+ "total",
18079
+ "previousLabel",
18080
+ "nextLabel"
18081
+ ],
18082
+ outputs: ["previous", "next"]
17533
18083
  },
17534
18084
  {
17535
18085
  kind: "pipe",
@@ -17553,8 +18103,8 @@ i0.ɵɵngDeclareClassMetadata({
17553
18103
  ListComponent,
17554
18104
  ListItemComponent,
17555
18105
  ListItemTextComponent,
17556
- ChevronLeftIcon,
17557
- ChevronRightIcon
18106
+ ChevronRightIcon,
18107
+ OccurrenceNavigatorComponent
17558
18108
  ],
17559
18109
  template: `
17560
18110
  @if (state.values().length) {
@@ -17568,31 +18118,25 @@ i0.ɵɵngDeclareClassMetadata({
17568
18118
  <chevron-right class="transition-transform duration-150 group-open/parent:rotate-90" />
17569
18119
  </summary>
17570
18120
 
17571
- <List class="gap-1 p-3 pt-0" selectionMode="explicit">
18121
+ <List class="gap-2 p-3 pt-0" selectionMode="explicit">
17572
18122
  @for (data of state.values(); track data.value) {
17573
18123
  <ListItem
17574
18124
  size="medium"
17575
18125
  [value]="data.value"
17576
- class="select-none"
17577
- (mouseenter)="state.hovering.set(data.value)"
17578
- (mouseleave)="state.hovering.set(undefined)">
18126
+ class="select-none min-h-10 [&_.list-item-text]:text-(length:--size-lg)"
18127
+ (mouseenter)="row.enter(data.value)"
18128
+ (mouseleave)="row.leave()"
18129
+ (click)="row.activate(data.value)">
17579
18130
  <ListItemText>{{ data.display }}</ListItemText>
17580
18131
 
17581
- <span class="flex shrink-0 items-center gap-2">
17582
- @if (state.hovering() === data.value) {
17583
- <button class="hover:scale-125" aria-label="previous match" (click)="state.navigatePrev(data)">
17584
- <chevron-left />
17585
- </button>
17586
-
17587
- <span>{{ state.hoverIndex() }} / {{ data.count }}</span>
17588
-
17589
- <button class="hover:scale-125" aria-label="next match" (click)="state.navigateNext(data)">
17590
- <chevron-right />
17591
- </button>
17592
- } @else {
17593
- {{ data.count }}
17594
- }
17595
- </span>
18132
+ <occurrence-navigator
18133
+ [active]="state.hovering() === data.value"
18134
+ [index]="state.hoverIndex()"
18135
+ [total]="data.count ?? 0"
18136
+ previousLabel="previous match"
18137
+ nextLabel="next match"
18138
+ (previous)="state.navigatePrev(data)"
18139
+ (next)="state.navigateNext(data)" />
17596
18140
  </ListItem>
17597
18141
  }
17598
18142
  </List>
@@ -19217,6 +19761,15 @@ var AlertsComponent = class AlertsComponent {
19217
19761
  return new Map(this.order().map((alert) => [alert, alertSummary(lang, alert)]));
19218
19762
  }, ...ngDevMode ? [{ debugName: "summaries" }] : /* istanbul ignore next */ []);
19219
19763
  summaryOf = (alert) => this.summaries().get(alert) ?? [];
19764
+ keyboardAvailable = injectKeyboardAvailable();
19765
+ fullText(alert) {
19766
+ const summary = this.summaryOf(alert).map((part) => part.key ? this.transloco.translate(part.key) : part.text).join(" · ");
19767
+ return summary ? `${alert.name} · ${summary}` : alert.name;
19768
+ }
19769
+ toggleText(tip) {
19770
+ if (tip.isOpen()) tip.hide();
19771
+ else tip.show();
19772
+ }
19220
19773
  announceMove = (alert, position, total) => this.transloco.translate("alerts.moved", {
19221
19774
  name: alert.name,
19222
19775
  position,
@@ -19348,7 +19901,7 @@ var AlertsComponent = class AlertsComponent {
19348
19901
  },
19349
19902
  providers: [provideTranslocoScope("alerts")],
19350
19903
  ngImport: i0,
19351
- template: "<div class=\"flex min-w-80 flex-col gap-(--spacing-3xs) p-2\">\n <div class=\"flex items-center gap-(--spacing-2xs) ps-(--spacing-2xs)\">\n @if (floating) {\n <!-- Not an <h2>: a page's prose styles put a rule and margins on every h2, even inside a popover. -->\n <span\n role=\"heading\"\n aria-level=\"2\"\n class=\"flex-1 text-xl leading-(--line-height-4) font-bold\">\n {{ \"alerts.label\" | transloco }}\n @if (visible().length) {\n <span\n class=\"text-(length:--size-sm) font-normal text-(--font-neutral-muted)\"\n >· {{ visible().length }}</span\n >\n }\n </span>\n } @else {\n <span class=\"flex-1\"></span>\n }\n <!-- With no alert, \"Create\" is the call to action of the empty state, below. -->\n @if (visible().length) {\n <button size=\"md\" (click)=\"createAlert()\">\n <plus-icon />\n {{ \"alerts.create\" | transloco }}\n </button>\n }\n </div>\n\n @if (saveError(); as failure) {\n <alert-save-error class=\"mb-(--spacing-2xs)\" [detail]=\"failure.detail\" />\n }\n\n <ul\n role=\"list\"\n class=\"m-0 flex list-none flex-col p-0\"\n [attr.aria-label]=\"'alerts.label' | transloco\"\n [sortable]=\"visible()\"\n #sortable=\"sortable\"\n [sortableAnnounce]=\"announceMove\"\n (reorder)=\"saveOrder($event)\">\n @for (alert of sortable.items(); track alert.name) {\n @let name = alert.name;\n <li\n [sortableItem]=\"alert\"\n class=\"group relative flex min-h-12 w-full cursor-pointer items-center gap-(--spacing-3xs) rounded-(--radius-sm) py-(--spacing-3xs) ps-(--spacing-3xs) pe-(--spacing-2xs) text-(--font-neutral-base) select-none hover:bg-(--bg-primary-lighter) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--stroke-focus) active:bg-(--bg-primary-light) data-dragging:bg-(--bg-primary-lighter) data-dragging:outline-2 data-dragging:-outline-offset-2 data-dragging:outline-(--stroke-primary-base) data-dragging:outline-dashed data-dragging:*:invisible\"\n tabindex=\"0\"\n [attr.data-inactive]=\"alert.active ? null : ''\"\n (click)=\"onRowClick($event, alert)\"\n (keydown.enter)=\"onRowEnter($event, alert)\">\n <!-- Handle: shown when the row is hovered or focused, only if there is something to reorder. -->\n <grip-vertical-icon\n class=\"shrink-0 cursor-grab text-(--font-neutral-muted) opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 pointer-coarse:hidden\"\n [class.invisible]=\"visible().length < 2\" />\n @if (alert.active) {\n <bell-icon class=\"shrink-0\" />\n } @else {\n <bell-slash-icon class=\"shrink-0 text-(--font-neutral-muted)\" />\n }\n <span class=\"flex min-w-0 flex-1 flex-col\">\n <span\n class=\"line-clamp-1 text-(length:--size-base) group-data-inactive:text-(--font-neutral-muted)\"\n >{{ name }}</span\n >\n @if (summaryOf(alert).length) {\n <span\n class=\"line-clamp-1 text-(length:--size-sm) text-(--font-neutral-muted)\">\n @for (\n part of summaryOf(alert);\n track $index;\n let first = $first\n ) {\n {{ first ? \"\" : \" · \"\n }}{{ part.key ? (part.key | transloco) : part.text }}\n }\n </span>\n }\n </span>\n <span data-row-action class=\"flex shrink-0 items-center\">\n <Switch\n [checked]=\"isActive(alert)\"\n [aria-label]=\"'alerts.alertActiveFor' | transloco: { name }\"\n (checkedChange)=\"toggle(alert, $event)\" />\n </span>\n <span data-row-action class=\"flex shrink-0 items-center\">\n <button\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"text-(--font-error-muted) opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 hover:bg-(--bg-error-lightest) hover:text-(--font-error-base) focus-visible:opacity-100 pointer-coarse:opacity-100\"\n [attr.title]=\"'alerts.deleteAlertNamed' | transloco: { name }\"\n [attr.aria-label]=\"'alerts.deleteAlertNamed' | transloco: { name }\"\n (click)=\"deleteAlert(alert)\">\n <TrashIcon />\n </button>\n </span>\n </li>\n } @empty {\n <li class=\"list-none\">\n <ListPanelEmpty color=\"orange\">\n <bell-icon listPanelEmptyIcon />\n {{ \"alerts.noAlerts\" | transloco }}\n <p listPanelEmptyHint>{{ \"alerts.noAlertsHint\" | transloco }}</p>\n <button listPanelEmptyAction (click)=\"createAlert()\">\n <plus-icon />\n {{ \"alerts.createAlert\" | transloco }}\n </button>\n </ListPanelEmpty>\n </li>\n }\n </ul>\n\n @if (visible().length > 1) {\n <p\n class=\"m-0 ps-(--spacing-2xs) pt-(--spacing-3xs) text-(length:--size-sm) text-(--font-neutral-muted)\">\n {{ \"alerts.reorderHint\" | transloco }}\n </p>\n }\n\n <!-- The regions exist before their text does, which is what makes a screen reader announce the text. -->\n <div role=\"status\">\n @if (pendingDeletion(); as pending) {\n <div\n class=\"mt-(--spacing-2xs) flex items-center gap-(--spacing-2xs) rounded-(--radius-sm) bg-(--bg-neutral-base) py-(--spacing-3xs) ps-(--spacing-xs) pe-(--spacing-3xs) text-(length:--size-sm) text-(--font-neutral-base)\">\n <span class=\"min-w-0 flex-1\">{{\n \"alerts.deleted\" | transloco: { name: pending.name }\n }}</span>\n <button variant=\"tertiary\" size=\"sm\" (click)=\"undoDeletion()\">\n {{ \"alerts.undo\" | transloco }}\n </button>\n </div>\n }\n </div>\n</div>\n",
19904
+ template: "<!-- A minimum width and 8px of padding all round are for a floating popover, which has nothing around the list and would collapse on a\n short one. In a panel they were wrong: the minimum made the list wider than the room it is given (320px in the 286px of the settings\n on a phone, clipped) and the padding pushed its rows 8px in from the headings of the page, where the container sets both. -->\n<div\n class=\"flex flex-col gap-(--spacing-3xs)\"\n [class.min-w-80]=\"!!floating\"\n [class.p-2]=\"!!floating\">\n <div class=\"flex items-center gap-(--spacing-2xs)\" [class.ps-2]=\"!!floating\">\n @if (floating) {\n <!-- Not an <h2>: a page's prose styles put a rule and margins on every h2, even inside a popover. -->\n <span\n role=\"heading\"\n aria-level=\"2\"\n class=\"flex-1 text-xl leading-(--line-height-4) font-bold\">\n {{ \"alerts.label\" | transloco }}\n @if (visible().length) {\n <span\n class=\"text-(length:--size-sm) font-normal text-(--font-neutral-muted)\"\n >· {{ visible().length }}</span\n >\n }\n </span>\n } @else {\n <span class=\"flex-1\"></span>\n }\n <!-- With no alert, \"Create\" is the call to action of the empty state, below. -->\n @if (visible().length) {\n <button size=\"md\" (click)=\"createAlert()\">\n <plus-icon />\n {{ \"alerts.create\" | transloco }}\n </button>\n }\n </div>\n\n @if (saveError(); as failure) {\n <alert-save-error class=\"mb-(--spacing-2xs)\" [detail]=\"failure.detail\" />\n }\n\n <ul\n role=\"list\"\n class=\"m-0 flex list-none flex-col p-0\"\n [attr.aria-label]=\"'alerts.label' | transloco\"\n [sortable]=\"visible()\"\n #sortable=\"sortable\"\n [sortableAnnounce]=\"announceMove\"\n (reorder)=\"saveOrder($event)\">\n @for (alert of sortable.items(); track alert.name) {\n @let name = alert.name;\n <li\n [sortableItem]=\"alert\"\n class=\"group relative block w-full cursor-pointer rounded-(--radius-sm) text-(--font-neutral-base) select-none hover:bg-(--bg-primary-lighter) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--stroke-focus) active:bg-(--bg-primary-light) data-dragging:bg-(--bg-primary-lighter) data-dragging:outline-2 data-dragging:-outline-offset-2 data-dragging:outline-(--stroke-primary-base) data-dragging:outline-dashed data-dragging:*:invisible\"\n tabindex=\"0\"\n [attr.data-inactive]=\"alert.active ? null : ''\"\n (click)=\"onRowClick($event, alert)\"\n (keydown.enter)=\"onRowEnter($event, alert)\">\n <!-- Touch: drag the row to the left to delete it (the undo notice below is the safety net). The cross at the end of the row is\n then out of sight (`pointer-coarse:sr-only`) but still there for a screen reader or a switch, which cannot swipe. The\n layout of the row is the sliding layer's: the row itself only carries the hover tint, the focus ring and the drag look. -->\n <SwipeAction\n class=\"rounded-(--radius-sm)\"\n contentClass=\"flex min-h-12 w-full items-center gap-(--spacing-3xs) py-(--spacing-3xs) ps-(--spacing-3xs) pe-(--spacing-2xs)\"\n endEnabled\n endTone=\"error\"\n (swipedEnd)=\"deleteAlert(alert)\">\n <span swipeActionEnd class=\"flex items-center gap-2\">\n {{ \"alerts.deleteAlert\" | transloco }}\n <XMarkIcon class=\"size-4\" />\n </span>\n <!-- Handle: shown when the row is hovered or focused, only if there is something to reorder. -->\n <grip-vertical-icon\n class=\"shrink-0 cursor-grab text-(--font-neutral-muted) opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 pointer-coarse:hidden\"\n [class.invisible]=\"visible().length < 2\" />\n @if (alert.active) {\n <bell-icon class=\"shrink-0\" />\n } @else {\n <bell-slash-icon class=\"shrink-0 text-(--font-neutral-muted)\" />\n }\n <!-- The text block carries the tooltip that shows the name and the second line whole, when either is cut off (a mouse hovers it;\n a finger has the info button below). Both lines are always in the DOM, the second one hidden when there is nothing in it, so\n that the row can ask each whether it is cut off. -->\n <span\n #tip=\"tooltip\"\n class=\"flex min-w-0 flex-1 flex-col\"\n [tooltip]=\"fullText(alert)\"\n [tooltipDisabled]=\"\n !(cutName.isTruncated() || cutSummary.isTruncated())\n \">\n <span\n truncated\n #cutName=\"truncated\"\n class=\"line-clamp-1 text-(length:--size-base) group-data-inactive:text-(--font-neutral-muted)\"\n >{{ name }}</span\n >\n <span\n truncated\n #cutSummary=\"truncated\"\n [class]=\"\n summaryOf(alert).length\n ? 'line-clamp-1 text-(length:--size-sm) text-(--font-neutral-muted)'\n : 'hidden'\n \">\n @for (\n part of summaryOf(alert);\n track $index;\n let first = $first\n ) {\n {{ first ? \"\" : \" · \"\n }}{{ part.key ? (part.key | transloco) : part.text }}\n }\n </span>\n </span>\n @if (cutName.isTruncated() || cutSummary.isTruncated()) {\n <!-- Touch only: there is no hover to show the tooltip with. In data-row-action, so a tap on it does not open the form of the row. -->\n <span data-row-action class=\"flex shrink-0 items-center\">\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"hidden text-(--font-neutral-muted) pointer-coarse:inline-flex pointer-coarse:size-9\"\n [attr.aria-label]=\"'alerts.showFullText' | transloco\"\n (click)=\"toggleText(tip)\">\n <InfoCircleIcon />\n </button>\n </span>\n }\n <span data-row-action class=\"flex shrink-0 items-center\">\n <Switch\n [checked]=\"isActive(alert)\"\n [aria-label]=\"'alerts.alertActiveFor' | transloco: { name }\"\n (checkedChange)=\"toggle(alert, $event)\" />\n </span>\n <span data-row-action class=\"flex shrink-0 items-center\">\n <button\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"text-(--font-error-muted) opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 hover:bg-(--bg-error-lightest) hover:text-(--font-error-base) focus-visible:opacity-100 pointer-coarse:sr-only\"\n [attr.title]=\"'alerts.deleteAlertNamed' | transloco: { name }\"\n [attr.aria-label]=\"\n 'alerts.deleteAlertNamed' | transloco: { name }\n \"\n (click)=\"deleteAlert(alert)\">\n <XMarkIcon />\n </button>\n </span>\n </SwipeAction>\n </li>\n } @empty {\n <li class=\"list-none\">\n <ListPanelEmpty color=\"orange\">\n <bell-icon listPanelEmptyIcon />\n {{ \"alerts.noAlerts\" | transloco }}\n <p listPanelEmptyHint>{{ \"alerts.noAlertsHint\" | transloco }}</p>\n <button listPanelEmptyAction (click)=\"createAlert()\">\n <plus-icon />\n {{ \"alerts.createAlert\" | transloco }}\n </button>\n </ListPanelEmpty>\n </li>\n }\n </ul>\n\n <!-- The keys to reorder a row mean nothing to a finger (the handle is hidden there too), and nothing without a keyboard. -->\n @if (visible().length > 1 && keyboardAvailable()) {\n <p\n class=\"m-0 pt-(--spacing-3xs) text-(length:--size-sm) text-(--font-neutral-muted)\"\n [class.ps-2]=\"!!floating\">\n {{ \"alerts.reorderHint\" | transloco }}\n </p>\n }\n\n <!-- The regions exist before their text does, which is what makes a screen reader announce the text. -->\n <div role=\"status\">\n @if (pendingDeletion(); as pending) {\n <div\n class=\"mt-(--spacing-2xs) flex items-center gap-(--spacing-2xs) rounded-(--radius-sm) bg-(--bg-neutral-base) py-(--spacing-3xs) ps-(--spacing-xs) pe-(--spacing-3xs) text-(length:--size-sm) text-(--font-neutral-base)\">\n <span class=\"min-w-0 flex-1\">{{\n \"alerts.deleted\" | transloco: { name: pending.name }\n }}</span>\n <button variant=\"tertiary\" size=\"sm\" (click)=\"undoDeletion()\">\n {{ \"alerts.undo\" | transloco }}\n </button>\n </div>\n }\n </div>\n</div>\n",
19352
19905
  dependencies: [
19353
19906
  {
19354
19907
  kind: "directive",
@@ -19397,6 +19950,22 @@ var AlertsComponent = class AlertsComponent {
19397
19950
  selector: "[sortableItem]",
19398
19951
  inputs: ["sortableItem", "sortableItemDisabled"]
19399
19952
  },
19953
+ {
19954
+ kind: "component",
19955
+ type: SwipeActionComponent,
19956
+ selector: "swipe-action, SwipeAction",
19957
+ inputs: [
19958
+ "class",
19959
+ "contentClass",
19960
+ "startEnabled",
19961
+ "endEnabled",
19962
+ "startTone",
19963
+ "endTone",
19964
+ "disabled",
19965
+ "threshold"
19966
+ ],
19967
+ outputs: ["swipedStart", "swipedEnd"]
19968
+ },
19400
19969
  {
19401
19970
  kind: "component",
19402
19971
  type: BellIcon,
@@ -19415,16 +19984,55 @@ var AlertsComponent = class AlertsComponent {
19415
19984
  selector: "grip-vertical, grip-vertical-icon, GripVertical, GripVerticalIcon",
19416
19985
  inputs: ["class"]
19417
19986
  },
19987
+ {
19988
+ kind: "component",
19989
+ type: InfoCircleIcon,
19990
+ selector: "info-circle, info-circle-icon, InfoCircle, InfoCircleIcon",
19991
+ inputs: ["class"]
19992
+ },
19418
19993
  {
19419
19994
  kind: "component",
19420
19995
  type: PlusIcon,
19421
19996
  selector: "plus, plus-icon, Plus, PlusIcon",
19422
19997
  inputs: ["class"]
19423
19998
  },
19999
+ {
20000
+ kind: "directive",
20001
+ type: TooltipDirective,
20002
+ selector: "[tooltip]",
20003
+ inputs: [
20004
+ "tooltip",
20005
+ "tooltipPosition",
20006
+ "tooltipEvent",
20007
+ "showDelay",
20008
+ "hideDelay",
20009
+ "life",
20010
+ "tooltipOffset",
20011
+ "autoHide",
20012
+ "hideOnEscape",
20013
+ "escape",
20014
+ "tooltipDisabled",
20015
+ "tooltipArrow",
20016
+ "tooltipStyleClass",
20017
+ "tooltipOptions",
20018
+ "tooltip-position",
20019
+ "tooltip-delay",
20020
+ "tooltip-duration",
20021
+ "tooltip-offset"
20022
+ ],
20023
+ exportAs: ["tooltip"]
20024
+ },
20025
+ {
20026
+ kind: "directive",
20027
+ type: TruncatedDirective,
20028
+ selector: "[truncated]",
20029
+ inputs: ["truncated"],
20030
+ exportAs: ["truncated"]
20031
+ },
19424
20032
  {
19425
20033
  kind: "component",
19426
- type: TrashIcon,
19427
- selector: "trash, trash-icon, Trash, TrashIcon",
20034
+ type: XMarkIcon,
20035
+ selector: "x-mark, x-mark-icon, XMark, XMarkIcon",
19428
20036
  inputs: ["class"]
19429
20037
  },
19430
20038
  {
@@ -19462,16 +20070,20 @@ i0.ɵɵngDeclareClassMetadata({
19462
20070
  SwitchComponent,
19463
20071
  SortableDirective,
19464
20072
  SortableItemDirective,
20073
+ SwipeActionComponent,
19465
20074
  BellIcon,
19466
20075
  BellSlashIcon,
19467
20076
  GripVerticalIcon,
20077
+ InfoCircleIcon,
19468
20078
  PlusIcon,
19469
- TrashIcon,
20079
+ TooltipDirective,
20080
+ TruncatedDirective,
20081
+ XMarkIcon,
19470
20082
  ListPanelEmptyComponent,
19471
20083
  AlertSaveErrorComponent
19472
20084
  ],
19473
20085
  providers: [provideTranslocoScope("alerts")],
19474
- template: "<div class=\"flex min-w-80 flex-col gap-(--spacing-3xs) p-2\">\n <div class=\"flex items-center gap-(--spacing-2xs) ps-(--spacing-2xs)\">\n @if (floating) {\n <!-- Not an <h2>: a page's prose styles put a rule and margins on every h2, even inside a popover. -->\n <span\n role=\"heading\"\n aria-level=\"2\"\n class=\"flex-1 text-xl leading-(--line-height-4) font-bold\">\n {{ \"alerts.label\" | transloco }}\n @if (visible().length) {\n <span\n class=\"text-(length:--size-sm) font-normal text-(--font-neutral-muted)\"\n >· {{ visible().length }}</span\n >\n }\n </span>\n } @else {\n <span class=\"flex-1\"></span>\n }\n <!-- With no alert, \"Create\" is the call to action of the empty state, below. -->\n @if (visible().length) {\n <button size=\"md\" (click)=\"createAlert()\">\n <plus-icon />\n {{ \"alerts.create\" | transloco }}\n </button>\n }\n </div>\n\n @if (saveError(); as failure) {\n <alert-save-error class=\"mb-(--spacing-2xs)\" [detail]=\"failure.detail\" />\n }\n\n <ul\n role=\"list\"\n class=\"m-0 flex list-none flex-col p-0\"\n [attr.aria-label]=\"'alerts.label' | transloco\"\n [sortable]=\"visible()\"\n #sortable=\"sortable\"\n [sortableAnnounce]=\"announceMove\"\n (reorder)=\"saveOrder($event)\">\n @for (alert of sortable.items(); track alert.name) {\n @let name = alert.name;\n <li\n [sortableItem]=\"alert\"\n class=\"group relative flex min-h-12 w-full cursor-pointer items-center gap-(--spacing-3xs) rounded-(--radius-sm) py-(--spacing-3xs) ps-(--spacing-3xs) pe-(--spacing-2xs) text-(--font-neutral-base) select-none hover:bg-(--bg-primary-lighter) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--stroke-focus) active:bg-(--bg-primary-light) data-dragging:bg-(--bg-primary-lighter) data-dragging:outline-2 data-dragging:-outline-offset-2 data-dragging:outline-(--stroke-primary-base) data-dragging:outline-dashed data-dragging:*:invisible\"\n tabindex=\"0\"\n [attr.data-inactive]=\"alert.active ? null : ''\"\n (click)=\"onRowClick($event, alert)\"\n (keydown.enter)=\"onRowEnter($event, alert)\">\n <!-- Handle: shown when the row is hovered or focused, only if there is something to reorder. -->\n <grip-vertical-icon\n class=\"shrink-0 cursor-grab text-(--font-neutral-muted) opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 pointer-coarse:hidden\"\n [class.invisible]=\"visible().length < 2\" />\n @if (alert.active) {\n <bell-icon class=\"shrink-0\" />\n } @else {\n <bell-slash-icon class=\"shrink-0 text-(--font-neutral-muted)\" />\n }\n <span class=\"flex min-w-0 flex-1 flex-col\">\n <span\n class=\"line-clamp-1 text-(length:--size-base) group-data-inactive:text-(--font-neutral-muted)\"\n >{{ name }}</span\n >\n @if (summaryOf(alert).length) {\n <span\n class=\"line-clamp-1 text-(length:--size-sm) text-(--font-neutral-muted)\">\n @for (\n part of summaryOf(alert);\n track $index;\n let first = $first\n ) {\n {{ first ? \"\" : \" · \"\n }}{{ part.key ? (part.key | transloco) : part.text }}\n }\n </span>\n }\n </span>\n <span data-row-action class=\"flex shrink-0 items-center\">\n <Switch\n [checked]=\"isActive(alert)\"\n [aria-label]=\"'alerts.alertActiveFor' | transloco: { name }\"\n (checkedChange)=\"toggle(alert, $event)\" />\n </span>\n <span data-row-action class=\"flex shrink-0 items-center\">\n <button\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"text-(--font-error-muted) opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 hover:bg-(--bg-error-lightest) hover:text-(--font-error-base) focus-visible:opacity-100 pointer-coarse:opacity-100\"\n [attr.title]=\"'alerts.deleteAlertNamed' | transloco: { name }\"\n [attr.aria-label]=\"'alerts.deleteAlertNamed' | transloco: { name }\"\n (click)=\"deleteAlert(alert)\">\n <TrashIcon />\n </button>\n </span>\n </li>\n } @empty {\n <li class=\"list-none\">\n <ListPanelEmpty color=\"orange\">\n <bell-icon listPanelEmptyIcon />\n {{ \"alerts.noAlerts\" | transloco }}\n <p listPanelEmptyHint>{{ \"alerts.noAlertsHint\" | transloco }}</p>\n <button listPanelEmptyAction (click)=\"createAlert()\">\n <plus-icon />\n {{ \"alerts.createAlert\" | transloco }}\n </button>\n </ListPanelEmpty>\n </li>\n }\n </ul>\n\n @if (visible().length > 1) {\n <p\n class=\"m-0 ps-(--spacing-2xs) pt-(--spacing-3xs) text-(length:--size-sm) text-(--font-neutral-muted)\">\n {{ \"alerts.reorderHint\" | transloco }}\n </p>\n }\n\n <!-- The regions exist before their text does, which is what makes a screen reader announce the text. -->\n <div role=\"status\">\n @if (pendingDeletion(); as pending) {\n <div\n class=\"mt-(--spacing-2xs) flex items-center gap-(--spacing-2xs) rounded-(--radius-sm) bg-(--bg-neutral-base) py-(--spacing-3xs) ps-(--spacing-xs) pe-(--spacing-3xs) text-(length:--size-sm) text-(--font-neutral-base)\">\n <span class=\"min-w-0 flex-1\">{{\n \"alerts.deleted\" | transloco: { name: pending.name }\n }}</span>\n <button variant=\"tertiary\" size=\"sm\" (click)=\"undoDeletion()\">\n {{ \"alerts.undo\" | transloco }}\n </button>\n </div>\n }\n </div>\n</div>\n"
20086
+ template: "<!-- A minimum width and 8px of padding all round are for a floating popover, which has nothing around the list and would collapse on a\n short one. In a panel they were wrong: the minimum made the list wider than the room it is given (320px in the 286px of the settings\n on a phone, clipped) and the padding pushed its rows 8px in from the headings of the page, where the container sets both. -->\n<div\n class=\"flex flex-col gap-(--spacing-3xs)\"\n [class.min-w-80]=\"!!floating\"\n [class.p-2]=\"!!floating\">\n <div class=\"flex items-center gap-(--spacing-2xs)\" [class.ps-2]=\"!!floating\">\n @if (floating) {\n <!-- Not an <h2>: a page's prose styles put a rule and margins on every h2, even inside a popover. -->\n <span\n role=\"heading\"\n aria-level=\"2\"\n class=\"flex-1 text-xl leading-(--line-height-4) font-bold\">\n {{ \"alerts.label\" | transloco }}\n @if (visible().length) {\n <span\n class=\"text-(length:--size-sm) font-normal text-(--font-neutral-muted)\"\n >· {{ visible().length }}</span\n >\n }\n </span>\n } @else {\n <span class=\"flex-1\"></span>\n }\n <!-- With no alert, \"Create\" is the call to action of the empty state, below. -->\n @if (visible().length) {\n <button size=\"md\" (click)=\"createAlert()\">\n <plus-icon />\n {{ \"alerts.create\" | transloco }}\n </button>\n }\n </div>\n\n @if (saveError(); as failure) {\n <alert-save-error class=\"mb-(--spacing-2xs)\" [detail]=\"failure.detail\" />\n }\n\n <ul\n role=\"list\"\n class=\"m-0 flex list-none flex-col p-0\"\n [attr.aria-label]=\"'alerts.label' | transloco\"\n [sortable]=\"visible()\"\n #sortable=\"sortable\"\n [sortableAnnounce]=\"announceMove\"\n (reorder)=\"saveOrder($event)\">\n @for (alert of sortable.items(); track alert.name) {\n @let name = alert.name;\n <li\n [sortableItem]=\"alert\"\n class=\"group relative block w-full cursor-pointer rounded-(--radius-sm) text-(--font-neutral-base) select-none hover:bg-(--bg-primary-lighter) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--stroke-focus) active:bg-(--bg-primary-light) data-dragging:bg-(--bg-primary-lighter) data-dragging:outline-2 data-dragging:-outline-offset-2 data-dragging:outline-(--stroke-primary-base) data-dragging:outline-dashed data-dragging:*:invisible\"\n tabindex=\"0\"\n [attr.data-inactive]=\"alert.active ? null : ''\"\n (click)=\"onRowClick($event, alert)\"\n (keydown.enter)=\"onRowEnter($event, alert)\">\n <!-- Touch: drag the row to the left to delete it (the undo notice below is the safety net). The cross at the end of the row is\n then out of sight (`pointer-coarse:sr-only`) but still there for a screen reader or a switch, which cannot swipe. The\n layout of the row is the sliding layer's: the row itself only carries the hover tint, the focus ring and the drag look. -->\n <SwipeAction\n class=\"rounded-(--radius-sm)\"\n contentClass=\"flex min-h-12 w-full items-center gap-(--spacing-3xs) py-(--spacing-3xs) ps-(--spacing-3xs) pe-(--spacing-2xs)\"\n endEnabled\n endTone=\"error\"\n (swipedEnd)=\"deleteAlert(alert)\">\n <span swipeActionEnd class=\"flex items-center gap-2\">\n {{ \"alerts.deleteAlert\" | transloco }}\n <XMarkIcon class=\"size-4\" />\n </span>\n <!-- Handle: shown when the row is hovered or focused, only if there is something to reorder. -->\n <grip-vertical-icon\n class=\"shrink-0 cursor-grab text-(--font-neutral-muted) opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 pointer-coarse:hidden\"\n [class.invisible]=\"visible().length < 2\" />\n @if (alert.active) {\n <bell-icon class=\"shrink-0\" />\n } @else {\n <bell-slash-icon class=\"shrink-0 text-(--font-neutral-muted)\" />\n }\n <!-- The text block carries the tooltip that shows the name and the second line whole, when either is cut off (a mouse hovers it;\n a finger has the info button below). Both lines are always in the DOM, the second one hidden when there is nothing in it, so\n that the row can ask each whether it is cut off. -->\n <span\n #tip=\"tooltip\"\n class=\"flex min-w-0 flex-1 flex-col\"\n [tooltip]=\"fullText(alert)\"\n [tooltipDisabled]=\"\n !(cutName.isTruncated() || cutSummary.isTruncated())\n \">\n <span\n truncated\n #cutName=\"truncated\"\n class=\"line-clamp-1 text-(length:--size-base) group-data-inactive:text-(--font-neutral-muted)\"\n >{{ name }}</span\n >\n <span\n truncated\n #cutSummary=\"truncated\"\n [class]=\"\n summaryOf(alert).length\n ? 'line-clamp-1 text-(length:--size-sm) text-(--font-neutral-muted)'\n : 'hidden'\n \">\n @for (\n part of summaryOf(alert);\n track $index;\n let first = $first\n ) {\n {{ first ? \"\" : \" · \"\n }}{{ part.key ? (part.key | transloco) : part.text }}\n }\n </span>\n </span>\n @if (cutName.isTruncated() || cutSummary.isTruncated()) {\n <!-- Touch only: there is no hover to show the tooltip with. In data-row-action, so a tap on it does not open the form of the row. -->\n <span data-row-action class=\"flex shrink-0 items-center\">\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"hidden text-(--font-neutral-muted) pointer-coarse:inline-flex pointer-coarse:size-9\"\n [attr.aria-label]=\"'alerts.showFullText' | transloco\"\n (click)=\"toggleText(tip)\">\n <InfoCircleIcon />\n </button>\n </span>\n }\n <span data-row-action class=\"flex shrink-0 items-center\">\n <Switch\n [checked]=\"isActive(alert)\"\n [aria-label]=\"'alerts.alertActiveFor' | transloco: { name }\"\n (checkedChange)=\"toggle(alert, $event)\" />\n </span>\n <span data-row-action class=\"flex shrink-0 items-center\">\n <button\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"text-(--font-error-muted) opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 hover:bg-(--bg-error-lightest) hover:text-(--font-error-base) focus-visible:opacity-100 pointer-coarse:sr-only\"\n [attr.title]=\"'alerts.deleteAlertNamed' | transloco: { name }\"\n [attr.aria-label]=\"\n 'alerts.deleteAlertNamed' | transloco: { name }\n \"\n (click)=\"deleteAlert(alert)\">\n <XMarkIcon />\n </button>\n </span>\n </SwipeAction>\n </li>\n } @empty {\n <li class=\"list-none\">\n <ListPanelEmpty color=\"orange\">\n <bell-icon listPanelEmptyIcon />\n {{ \"alerts.noAlerts\" | transloco }}\n <p listPanelEmptyHint>{{ \"alerts.noAlertsHint\" | transloco }}</p>\n <button listPanelEmptyAction (click)=\"createAlert()\">\n <plus-icon />\n {{ \"alerts.createAlert\" | transloco }}\n </button>\n </ListPanelEmpty>\n </li>\n }\n </ul>\n\n <!-- The keys to reorder a row mean nothing to a finger (the handle is hidden there too), and nothing without a keyboard. -->\n @if (visible().length > 1 && keyboardAvailable()) {\n <p\n class=\"m-0 pt-(--spacing-3xs) text-(length:--size-sm) text-(--font-neutral-muted)\"\n [class.ps-2]=\"!!floating\">\n {{ \"alerts.reorderHint\" | transloco }}\n </p>\n }\n\n <!-- The regions exist before their text does, which is what makes a screen reader announce the text. -->\n <div role=\"status\">\n @if (pendingDeletion(); as pending) {\n <div\n class=\"mt-(--spacing-2xs) flex items-center gap-(--spacing-2xs) rounded-(--radius-sm) bg-(--bg-neutral-base) py-(--spacing-3xs) ps-(--spacing-xs) pe-(--spacing-3xs) text-(length:--size-sm) text-(--font-neutral-base)\">\n <span class=\"min-w-0 flex-1\">{{\n \"alerts.deleted\" | transloco: { name: pending.name }\n }}</span>\n <button variant=\"tertiary\" size=\"sm\" (click)=\"undoDeletion()\">\n {{ \"alerts.undo\" | transloco }}\n </button>\n </div>\n }\n </div>\n</div>\n"
19475
20087
  }]
19476
20088
  }],
19477
20089
  ctorParameters: () => [],
@@ -21384,7 +21996,7 @@ var ListPanelComponent = class ListPanelComponent {
21384
21996
  <ListItemAction [ariaLabel]="deleteButtonTitle() | transloco" [keys]="deleteKeys" [shortcut]="'listPanel.deleteShortcut' | transloco"
21385
21997
  [description]="'listPanel.deleteDescription' | transloco: { key: 'listPanel.deleteShortcut' | transloco }"
21386
21998
  (click)="onDelete(item, i, $event)">
21387
- <TrashIcon />
21999
+ <XMarkIcon />
21388
22000
  </ListItemAction>
21389
22001
  }
21390
22002
  </ListItem>
@@ -21410,8 +22022,8 @@ var ListPanelComponent = class ListPanelComponent {
21410
22022
  },
21411
22023
  {
21412
22024
  kind: "component",
21413
- type: TrashIcon,
21414
- selector: "trash, trash-icon, Trash, TrashIcon",
22025
+ type: XMarkIcon,
22026
+ selector: "x-mark, x-mark-icon, XMark, XMarkIcon",
21415
22027
  inputs: ["class"]
21416
22028
  },
21417
22029
  {
@@ -21470,7 +22082,7 @@ i0.ɵɵngDeclareClassMetadata({
21470
22082
  providers: [provideTranslocoScope("list-panel")],
21471
22083
  imports: [
21472
22084
  NgTemplateOutlet,
21473
- TrashIcon,
22085
+ XMarkIcon,
21474
22086
  TranslocoPipe,
21475
22087
  ListComponent,
21476
22088
  ListItemComponent,
@@ -21491,7 +22103,7 @@ i0.ɵɵngDeclareClassMetadata({
21491
22103
  <ListItemAction [ariaLabel]="deleteButtonTitle() | transloco" [keys]="deleteKeys" [shortcut]="'listPanel.deleteShortcut' | transloco"
21492
22104
  [description]="'listPanel.deleteDescription' | transloco: { key: 'listPanel.deleteShortcut' | transloco }"
21493
22105
  (click)="onDelete(item, i, $event)">
21494
- <TrashIcon />
22106
+ <XMarkIcon />
21495
22107
  </ListItemAction>
21496
22108
  }
21497
22109
  </ListItem>
@@ -21585,6 +22197,7 @@ i0.ɵɵngDeclareClassMetadata({
21585
22197
  var ListPanelFooterComponent = class ListPanelFooterComponent {
21586
22198
  cn = cn;
21587
22199
  class = input(...ngDevMode ? [void 0, { debugName: "class" }] : /* istanbul ignore next */ []);
22200
+ keyboardAvailable = injectKeyboardAvailable();
21588
22201
  hasMore = input.required(...ngDevMode ? [{ debugName: "hasMore" }] : /* istanbul ignore next */ []);
21589
22202
  config = input.required(...ngDevMode ? [{ debugName: "config" }] : /* istanbul ignore next */ []);
21590
22203
  loadMore = output();
@@ -21657,8 +22270,8 @@ var ListPanelFooterComponent = class ListPanelFooterComponent {
21657
22270
  focus est un arrêt unique de tabulation, les flèches font le reste, et rien ne le dit.
21658
22271
  aria-hidden : le listbox annonce déjà sa navigation aux lecteurs d'écran, et l'ouverture
21659
22272
  d'une ligne est ce que tout le monde attend d'Entrée. La touche Suppr est, elle, écrite dans la
21660
- ligne. -->
21661
- @if (navigationHint()) {
22273
+ ligne. Absente quand la page se pilote au doigt (provideInputModality) : sans clavier, elle n'apprend rien. -->
22274
+ @if (navigationHint() && keyboardAvailable()) {
21662
22275
  <p aria-hidden="true" class="flex min-w-0 flex-wrap items-center gap-x-(--spacing-xs) gap-y-(--spacing-4xs) text-(length:--size-sm) leading-(--line-height-2) text-(--font-neutral-base)">
21663
22276
  <span class="inline-flex items-center gap-(--spacing-2xs)">
21664
22277
  <!-- Des icônes et non les caractères ↑ ↓ : le centre visuel d'une flèche typographique ne tombe pas au
@@ -21770,8 +22383,8 @@ i0.ɵɵngDeclareClassMetadata({
21770
22383
  focus est un arrêt unique de tabulation, les flèches font le reste, et rien ne le dit.
21771
22384
  aria-hidden : le listbox annonce déjà sa navigation aux lecteurs d'écran, et l'ouverture
21772
22385
  d'une ligne est ce que tout le monde attend d'Entrée. La touche Suppr est, elle, écrite dans la
21773
- ligne. -->
21774
- @if (navigationHint()) {
22386
+ ligne. Absente quand la page se pilote au doigt (provideInputModality) : sans clavier, elle n'apprend rien. -->
22387
+ @if (navigationHint() && keyboardAvailable()) {
21775
22388
  <p aria-hidden="true" class="flex min-w-0 flex-wrap items-center gap-x-(--spacing-xs) gap-y-(--spacing-4xs) text-(length:--size-sm) leading-(--line-height-2) text-(--font-neutral-base)">
21776
22389
  <span class="inline-flex items-center gap-(--spacing-2xs)">
21777
22390
  <!-- Des icônes et non les caractères ↑ ↓ : le centre visuel d'une flèche typographique ne tombe pas au
@@ -26153,7 +26766,7 @@ var AggregationDateComponent = class AggregationDateComponent {
26153
26766
  host: { properties: { "class": "cn(\"@container flex h-[inherit] max-h-[inherit] flex-col\", class(), isEmpty() && \"hidden\")" } },
26154
26767
  providers: [provideTranslocoScope("filters")],
26155
26768
  ngImport: i0,
26156
- template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion). A facet is a\n disclosure, not a grid row: see `aggregation-panel.html` for the why (same pattern, duplicated here\n since this facet type doesn't go through `AggregationPanelComponent`). The `<summary>` itself is\n `tabindex=\"-1\"`: the header's chevron is the keyboard stop, see the chevron below. Clear and apply are not in the\n header: they are in the body, in a bar at the top of the facet. -->\n<ng-template #content>\n <!-- content wrapper -->\n <!-- RGAA 11.5 / WCAG 1.3.1: the radios below all share one `name`, so they are one set\n and have to be exposed as one. `role=\"group\"` here rather than `role=\"radiogroup\"`\n on the list: a radiogroup owns its radios directly, and the <li> in between would be\n invalid owned children — the list would also stop announcing how many options it\n holds. The name is the aggregation's own title, the words the header already shows;\n with no title the group stays unnamed rather than carrying an empty label. -->\n @let groupName = aggregation()?.display;\n <form\n class=\"flex min-h-0 flex-1 flex-col\"\n (keydown.enter)=\"onOptionEnter($event)\">\n <!-- The same bar as for the list and the tree, in a popover and in the filters drawer alike: clear (a button with a text, there but disabled when there is nothing\n to clear) and, while a choice is pending, apply, without an icon. There is no search field here to put them under:\n they sit at the top of the facet. -->\n <div class=\"flex shrink-0 items-center justify-end gap-1 pt-1 pl-1\">\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n class=\"shrink-0\"\n [disabled]=\"!hasFilters() && !selection()\"\n [attr.aria-label]=\"'filters.clearFilters' | transloco\"\n (click)=\"clear()\">\n {{ \"filters.clearAll\" | transloco }}\n </button>\n @if (\n (!isControlled() || emitOn() === \"apply\") &&\n selection() &&\n validSelection()\n ) {\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"sm\"\n class=\"shrink-0 overflow-hidden\"\n (click)=\"apply()\">\n <span class=\"truncate\">{{ \"filters.apply\" | transloco }}</span>\n </button>\n }\n </div>\n <div\n class=\"flex min-h-0 flex-1 flex-col\"\n role=\"group\"\n [attr.aria-label]=\"groupName ? (groupName | syslang | transloco) : null\">\n <ul\n class=\"flex min-h-0 flex-1 snap-y snap-start scrollbar-thin flex-col gap-0.5 overflow-auto px-1 pt-1 pb-3\"\n role=\"list\">\n @for (option of dateOptions(); track option.display) {\n <li\n role=\"listitem\"\n [attr.aria-label]=\"\n maskedLabel(option) ?? (option.display | syslang | transloco)\n \"\n [class]=\"\n cn(\n itemStateClass,\n 'flex cursor-pointer items-center p-0.5 text-sm text-(--font-neutral-base) select-none',\n dateModel().option === option.display &&\n 'bg-(--bg-primary-lighter)',\n option.hidden && 'hidden',\n option.disabled && 'pointer-events-none opacity-40'\n )\n \"\n [attr.aria-hidden]=\"option.disabled\">\n <radio\n name=\"date-filter\"\n class=\"grow items-center [&_.radio-label]:text-sm [&_.radio-label]:leading-6 [&_.radio-label]:font-normal\"\n [checked]=\"dateModel().option === option.display\"\n [disabled]=\"option.disabled\"\n (checkedChange)=\"selectOption(option.display)\">\n {{\n maskedLabel(option) ?? (option.display | syslang | transloco)\n }}\n </radio>\n </li>\n }\n\n @if (allowCustomRange) {\n <li\n role=\"listitem\"\n aria-label=\"open date range picker\"\n [class]=\"\n cn(\n itemStateClass,\n 'flex cursor-pointer items-center gap-(--spacing-xs) p-0.5 text-sm text-(--font-neutral-base) select-none',\n dateModel().option === 'custom-range' &&\n 'bg-(--bg-primary-lighter)'\n )\n \">\n <radio\n name=\"date-filter\"\n class=\"grow items-center [&_.radio-label]:text-sm [&_.radio-label]:leading-6 [&_.radio-label]:font-normal\"\n [checked]=\"dateModel().option === 'custom-range'\"\n (checkedChange)=\"selectOption('custom-range')\">\n <div class=\"flex flex-wrap items-center gap-x-1 gap-y-1\">\n <div class=\"flex items-center gap-1\">\n <label\n [attr.for]=\"startId\"\n class=\"radio-label min-w-8 truncate\"\n >{{ \"filters.from\" | transloco }}</label\n >\n <input-group\n class=\"h-8 max-w-[18ch] min-w-[18ch] has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"startId\"\n name=\"start\"\n type=\"text\"\n readonly\n [value]=\"customRangeFrom()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n <div class=\"flex items-center gap-1\">\n <label\n [attr.for]=\"endId\"\n class=\"radio-label min-w-8 truncate text-right\"\n >{{ \"filters.to\" | transloco }}</label\n >\n <input-group\n class=\"h-8 max-w-[18ch] min-w-[18ch] has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"endId\"\n name=\"end\"\n type=\"text\"\n readonly\n [value]=\"customRangeTo()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n </div>\n </radio>\n </li>\n }\n </ul>\n </div>\n </form>\n</ng-template>\n\n@if (headless()) {\n <div class=\"flex min-h-0 flex-1 flex-col\">\n <ng-container [ngTemplateOutlet]=\"content\" />\n </div>\n} @else {\n <details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group flex min-h-0 flex-1 flex-col space-y-2 details-content:flex details-content:min-h-0 details-content:flex-col\">\n <!-- scroll-mb-3/scroll-mt-16: see aggregation-panel-row.html for the full rationale\n (bottom margin against the container's edge, much bigger top margin to also clear\n <More>'s sticky facet-name search box when navigating ArrowUp). -->\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible()\"\n [class]=\"rowStateClass\"\n class=\"m-0 flex h-8 w-full shrink-0 scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n [aria-label]=\"'filters.toggle' | transloco\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n </button>\n }\n </summary>\n <ng-container [ngTemplateOutlet]=\"content\" />\n </details>\n}\n",
26769
+ template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion). A facet is a\n disclosure, not a grid row: see `aggregation-panel.html` for the why (same pattern, duplicated here\n since this facet type doesn't go through `AggregationPanelComponent`). The `<summary>` itself is\n `tabindex=\"-1\"`: the header's chevron is the keyboard stop, see the chevron below. Clear and apply are not in the\n header: they are in the body, in a bar at the top of the facet. -->\n<ng-template #content>\n <!-- content wrapper -->\n <!-- RGAA 11.5 / WCAG 1.3.1: the radios below all share one `name`, so they are one set\n and have to be exposed as one. `role=\"group\"` here rather than `role=\"radiogroup\"`\n on the list: a radiogroup owns its radios directly, and the <li> in between would be\n invalid owned children — the list would also stop announcing how many options it\n holds. The name is the aggregation's own title, the words the header already shows;\n with no title the group stays unnamed rather than carrying an empty label. -->\n @let groupName = aggregation()?.display;\n <form\n class=\"flex min-h-0 flex-1 flex-col\"\n (keydown.enter)=\"onOptionEnter($event)\">\n <!-- The same bar as for the list and the tree, in a popover and in the filters drawer alike: clear (a button with a text, there but disabled when there is nothing\n to clear) and, while a choice is pending, apply, without an icon. There is no search field here to put them under:\n they sit at the top of the facet. -->\n <div class=\"flex shrink-0 items-center justify-end gap-1 pt-1 pl-1\">\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"lg\"\n class=\"shrink-0\"\n [disabled]=\"!hasFilters() && !selection()\"\n [attr.aria-label]=\"'filters.clearFilters' | transloco\"\n (click)=\"clear()\">\n {{ \"filters.clearAll\" | transloco }}\n </button>\n @if (\n (!isControlled() || emitOn() === \"apply\") &&\n selection() &&\n validSelection()\n ) {\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"lg\"\n class=\"shrink-0 overflow-hidden\"\n (click)=\"apply()\">\n <span class=\"truncate\">{{ \"filters.apply\" | transloco }}</span>\n </button>\n }\n </div>\n <div\n class=\"flex min-h-0 flex-1 flex-col\"\n role=\"group\"\n [attr.aria-label]=\"groupName ? (groupName | syslang | transloco) : null\">\n <ul\n class=\"flex min-h-0 flex-1 snap-y snap-start scrollbar-thin flex-col gap-0.5 overflow-auto px-1 pt-1 pb-3\"\n role=\"list\">\n @for (option of dateOptions(); track option.display) {\n <li\n role=\"listitem\"\n [attr.aria-label]=\"\n maskedLabel(option) ?? (option.display | syslang | transloco)\n \"\n [class]=\"\n cn(\n itemStateClass,\n 'flex min-h-9 cursor-pointer items-center px-2 py-0.5 text-base text-(--font-neutral-base) select-none [&>radio>label]:min-h-8',\n dateModel().option === option.display &&\n 'bg-(--bg-primary-lighter)',\n option.hidden && 'hidden',\n option.disabled && 'pointer-events-none opacity-40'\n )\n \"\n [attr.aria-hidden]=\"option.disabled\">\n <radio\n name=\"date-filter\"\n class=\"grow items-center [&_.radio-label]:text-base [&_.radio-label]:leading-6 [&_.radio-label]:font-normal\"\n [checked]=\"dateModel().option === option.display\"\n [disabled]=\"option.disabled\"\n (checkedChange)=\"selectOption(option.display)\">\n {{\n maskedLabel(option) ?? (option.display | syslang | transloco)\n }}\n </radio>\n </li>\n }\n\n @if (allowCustomRange) {\n <li\n role=\"listitem\"\n aria-label=\"open date range picker\"\n [class]=\"\n cn(\n itemStateClass,\n 'flex min-h-9 cursor-pointer items-center gap-(--spacing-xs) px-2 py-0.5 text-base text-(--font-neutral-base) select-none [&>radio>label]:min-h-8 [&>radio>label]:max-w-full [&>radio>label]:min-w-0',\n dateModel().option === 'custom-range' &&\n 'bg-(--bg-primary-lighter)'\n )\n \">\n <radio\n name=\"date-filter\"\n class=\"grow items-center [&_.radio-label]:text-base [&_.radio-label]:leading-6 [&_.radio-label]:font-normal\"\n [checked]=\"dateModel().option === 'custom-range'\"\n (checkedChange)=\"selectOption('custom-range')\">\n <!-- `<radio>` is `display: contents`: its `<label>` is a child of the `<li>` in the box tree but not in the DOM, hence\n the `[&>radio>label]` of the row above. Every box of the chain down to the inputs may shrink (`min-w-0`, no fixed `min-w-[18ch]`): in the 15.5rem popover the\n label, the field and the radio do not fit side by side, and a box that cannot shrink spills out of it and is clipped.\n The fields have a definite width (`w-[18ch]`) that may shrink (`max-w-full min-w-0`). On a touch screen the text is 16px\n instead of 14px, which is what made the row wider than the 15.5rem popover. -->\n <div class=\"flex min-w-0 flex-wrap items-center gap-x-1 gap-y-1\">\n <div class=\"flex min-w-0 items-center gap-1\">\n <label\n [attr.for]=\"startId\"\n class=\"radio-label min-w-8 shrink-0 truncate\"\n >{{ \"filters.from\" | transloco }}</label\n >\n <input-group\n class=\"h-8 w-[18ch] max-w-full min-w-0 has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"startId\"\n name=\"start\"\n type=\"text\"\n readonly\n [value]=\"customRangeFrom()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n <div class=\"flex min-w-0 items-center gap-1\">\n <label\n [attr.for]=\"endId\"\n class=\"radio-label min-w-8 shrink-0 truncate text-right\"\n >{{ \"filters.to\" | transloco }}</label\n >\n <input-group\n class=\"h-8 w-[18ch] max-w-full min-w-0 has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"endId\"\n name=\"end\"\n type=\"text\"\n readonly\n [value]=\"customRangeTo()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n </div>\n </radio>\n </li>\n }\n </ul>\n </div>\n </form>\n</ng-template>\n\n@if (headless()) {\n <div class=\"flex min-h-0 flex-1 flex-col\">\n <ng-container [ngTemplateOutlet]=\"content\" />\n </div>\n} @else {\n <details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group flex min-h-0 flex-1 flex-col space-y-2 p-0.5 details-content:flex details-content:min-h-0 details-content:flex-col\">\n <!-- `p-0.5` on the details: the one of AggregationPanel (the list and the tree), so the header and its chevron sit on the same\n lines as theirs in the aside filters and the filters panel — without it the chevron was 2px further right.\n scroll-mb-3/scroll-mt-16: see aggregation-panel-row.html for the full rationale\n (bottom margin against the container's edge, much bigger top margin to also clear\n <More>'s sticky facet-name search box when navigating ArrowUp). -->\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible()\"\n [class]=\"rowStateClass\"\n class=\"m-0 flex h-8 w-full shrink-0 scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"md\"\n [aria-label]=\"'filters.toggle' | transloco\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n </button>\n }\n </summary>\n <ng-container [ngTemplateOutlet]=\"content\" />\n </details>\n}\n",
26157
26770
  styles: [":host{interpolate-size:allow-keywords}details::details-content{flex:1 1 auto;block-size:0;overflow:clip;overflow-clip-margin:.25rem;transition:block-size .2s cubic-bezier(.32,.72,0,1),content-visibility .2s allow-discrete}details[open]::details-content{block-size:auto}@media(prefers-reduced-motion:reduce){details::details-content{transition:none}}\n", ":host{min-width:200px}ul[role=list]{scrollbar-width:thin}\n"],
26158
26771
  dependencies: [
26159
26772
  {
@@ -26285,7 +26898,7 @@ i0.ɵɵngDeclareClassMetadata({
26285
26898
  TooltipDirective
26286
26899
  ],
26287
26900
  host: { "[class]": "cn(\"@container flex h-[inherit] max-h-[inherit] flex-col\", class(), isEmpty() && \"hidden\")" },
26288
- template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion). A facet is a\n disclosure, not a grid row: see `aggregation-panel.html` for the why (same pattern, duplicated here\n since this facet type doesn't go through `AggregationPanelComponent`). The `<summary>` itself is\n `tabindex=\"-1\"`: the header's chevron is the keyboard stop, see the chevron below. Clear and apply are not in the\n header: they are in the body, in a bar at the top of the facet. -->\n<ng-template #content>\n <!-- content wrapper -->\n <!-- RGAA 11.5 / WCAG 1.3.1: the radios below all share one `name`, so they are one set\n and have to be exposed as one. `role=\"group\"` here rather than `role=\"radiogroup\"`\n on the list: a radiogroup owns its radios directly, and the <li> in between would be\n invalid owned children — the list would also stop announcing how many options it\n holds. The name is the aggregation's own title, the words the header already shows;\n with no title the group stays unnamed rather than carrying an empty label. -->\n @let groupName = aggregation()?.display;\n <form\n class=\"flex min-h-0 flex-1 flex-col\"\n (keydown.enter)=\"onOptionEnter($event)\">\n <!-- The same bar as for the list and the tree, in a popover and in the filters drawer alike: clear (a button with a text, there but disabled when there is nothing\n to clear) and, while a choice is pending, apply, without an icon. There is no search field here to put them under:\n they sit at the top of the facet. -->\n <div class=\"flex shrink-0 items-center justify-end gap-1 pt-1 pl-1\">\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n class=\"shrink-0\"\n [disabled]=\"!hasFilters() && !selection()\"\n [attr.aria-label]=\"'filters.clearFilters' | transloco\"\n (click)=\"clear()\">\n {{ \"filters.clearAll\" | transloco }}\n </button>\n @if (\n (!isControlled() || emitOn() === \"apply\") &&\n selection() &&\n validSelection()\n ) {\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"sm\"\n class=\"shrink-0 overflow-hidden\"\n (click)=\"apply()\">\n <span class=\"truncate\">{{ \"filters.apply\" | transloco }}</span>\n </button>\n }\n </div>\n <div\n class=\"flex min-h-0 flex-1 flex-col\"\n role=\"group\"\n [attr.aria-label]=\"groupName ? (groupName | syslang | transloco) : null\">\n <ul\n class=\"flex min-h-0 flex-1 snap-y snap-start scrollbar-thin flex-col gap-0.5 overflow-auto px-1 pt-1 pb-3\"\n role=\"list\">\n @for (option of dateOptions(); track option.display) {\n <li\n role=\"listitem\"\n [attr.aria-label]=\"\n maskedLabel(option) ?? (option.display | syslang | transloco)\n \"\n [class]=\"\n cn(\n itemStateClass,\n 'flex cursor-pointer items-center p-0.5 text-sm text-(--font-neutral-base) select-none',\n dateModel().option === option.display &&\n 'bg-(--bg-primary-lighter)',\n option.hidden && 'hidden',\n option.disabled && 'pointer-events-none opacity-40'\n )\n \"\n [attr.aria-hidden]=\"option.disabled\">\n <radio\n name=\"date-filter\"\n class=\"grow items-center [&_.radio-label]:text-sm [&_.radio-label]:leading-6 [&_.radio-label]:font-normal\"\n [checked]=\"dateModel().option === option.display\"\n [disabled]=\"option.disabled\"\n (checkedChange)=\"selectOption(option.display)\">\n {{\n maskedLabel(option) ?? (option.display | syslang | transloco)\n }}\n </radio>\n </li>\n }\n\n @if (allowCustomRange) {\n <li\n role=\"listitem\"\n aria-label=\"open date range picker\"\n [class]=\"\n cn(\n itemStateClass,\n 'flex cursor-pointer items-center gap-(--spacing-xs) p-0.5 text-sm text-(--font-neutral-base) select-none',\n dateModel().option === 'custom-range' &&\n 'bg-(--bg-primary-lighter)'\n )\n \">\n <radio\n name=\"date-filter\"\n class=\"grow items-center [&_.radio-label]:text-sm [&_.radio-label]:leading-6 [&_.radio-label]:font-normal\"\n [checked]=\"dateModel().option === 'custom-range'\"\n (checkedChange)=\"selectOption('custom-range')\">\n <div class=\"flex flex-wrap items-center gap-x-1 gap-y-1\">\n <div class=\"flex items-center gap-1\">\n <label\n [attr.for]=\"startId\"\n class=\"radio-label min-w-8 truncate\"\n >{{ \"filters.from\" | transloco }}</label\n >\n <input-group\n class=\"h-8 max-w-[18ch] min-w-[18ch] has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"startId\"\n name=\"start\"\n type=\"text\"\n readonly\n [value]=\"customRangeFrom()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n <div class=\"flex items-center gap-1\">\n <label\n [attr.for]=\"endId\"\n class=\"radio-label min-w-8 truncate text-right\"\n >{{ \"filters.to\" | transloco }}</label\n >\n <input-group\n class=\"h-8 max-w-[18ch] min-w-[18ch] has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"endId\"\n name=\"end\"\n type=\"text\"\n readonly\n [value]=\"customRangeTo()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n </div>\n </radio>\n </li>\n }\n </ul>\n </div>\n </form>\n</ng-template>\n\n@if (headless()) {\n <div class=\"flex min-h-0 flex-1 flex-col\">\n <ng-container [ngTemplateOutlet]=\"content\" />\n </div>\n} @else {\n <details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group flex min-h-0 flex-1 flex-col space-y-2 details-content:flex details-content:min-h-0 details-content:flex-col\">\n <!-- scroll-mb-3/scroll-mt-16: see aggregation-panel-row.html for the full rationale\n (bottom margin against the container's edge, much bigger top margin to also clear\n <More>'s sticky facet-name search box when navigating ArrowUp). -->\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible()\"\n [class]=\"rowStateClass\"\n class=\"m-0 flex h-8 w-full shrink-0 scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n [aria-label]=\"'filters.toggle' | transloco\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n </button>\n }\n </summary>\n <ng-container [ngTemplateOutlet]=\"content\" />\n </details>\n}\n",
26901
+ template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion). A facet is a\n disclosure, not a grid row: see `aggregation-panel.html` for the why (same pattern, duplicated here\n since this facet type doesn't go through `AggregationPanelComponent`). The `<summary>` itself is\n `tabindex=\"-1\"`: the header's chevron is the keyboard stop, see the chevron below. Clear and apply are not in the\n header: they are in the body, in a bar at the top of the facet. -->\n<ng-template #content>\n <!-- content wrapper -->\n <!-- RGAA 11.5 / WCAG 1.3.1: the radios below all share one `name`, so they are one set\n and have to be exposed as one. `role=\"group\"` here rather than `role=\"radiogroup\"`\n on the list: a radiogroup owns its radios directly, and the <li> in between would be\n invalid owned children — the list would also stop announcing how many options it\n holds. The name is the aggregation's own title, the words the header already shows;\n with no title the group stays unnamed rather than carrying an empty label. -->\n @let groupName = aggregation()?.display;\n <form\n class=\"flex min-h-0 flex-1 flex-col\"\n (keydown.enter)=\"onOptionEnter($event)\">\n <!-- The same bar as for the list and the tree, in a popover and in the filters drawer alike: clear (a button with a text, there but disabled when there is nothing\n to clear) and, while a choice is pending, apply, without an icon. There is no search field here to put them under:\n they sit at the top of the facet. -->\n <div class=\"flex shrink-0 items-center justify-end gap-1 pt-1 pl-1\">\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"lg\"\n class=\"shrink-0\"\n [disabled]=\"!hasFilters() && !selection()\"\n [attr.aria-label]=\"'filters.clearFilters' | transloco\"\n (click)=\"clear()\">\n {{ \"filters.clearAll\" | transloco }}\n </button>\n @if (\n (!isControlled() || emitOn() === \"apply\") &&\n selection() &&\n validSelection()\n ) {\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"lg\"\n class=\"shrink-0 overflow-hidden\"\n (click)=\"apply()\">\n <span class=\"truncate\">{{ \"filters.apply\" | transloco }}</span>\n </button>\n }\n </div>\n <div\n class=\"flex min-h-0 flex-1 flex-col\"\n role=\"group\"\n [attr.aria-label]=\"groupName ? (groupName | syslang | transloco) : null\">\n <ul\n class=\"flex min-h-0 flex-1 snap-y snap-start scrollbar-thin flex-col gap-0.5 overflow-auto px-1 pt-1 pb-3\"\n role=\"list\">\n @for (option of dateOptions(); track option.display) {\n <li\n role=\"listitem\"\n [attr.aria-label]=\"\n maskedLabel(option) ?? (option.display | syslang | transloco)\n \"\n [class]=\"\n cn(\n itemStateClass,\n 'flex min-h-9 cursor-pointer items-center px-2 py-0.5 text-base text-(--font-neutral-base) select-none [&>radio>label]:min-h-8',\n dateModel().option === option.display &&\n 'bg-(--bg-primary-lighter)',\n option.hidden && 'hidden',\n option.disabled && 'pointer-events-none opacity-40'\n )\n \"\n [attr.aria-hidden]=\"option.disabled\">\n <radio\n name=\"date-filter\"\n class=\"grow items-center [&_.radio-label]:text-base [&_.radio-label]:leading-6 [&_.radio-label]:font-normal\"\n [checked]=\"dateModel().option === option.display\"\n [disabled]=\"option.disabled\"\n (checkedChange)=\"selectOption(option.display)\">\n {{\n maskedLabel(option) ?? (option.display | syslang | transloco)\n }}\n </radio>\n </li>\n }\n\n @if (allowCustomRange) {\n <li\n role=\"listitem\"\n aria-label=\"open date range picker\"\n [class]=\"\n cn(\n itemStateClass,\n 'flex min-h-9 cursor-pointer items-center gap-(--spacing-xs) px-2 py-0.5 text-base text-(--font-neutral-base) select-none [&>radio>label]:min-h-8 [&>radio>label]:max-w-full [&>radio>label]:min-w-0',\n dateModel().option === 'custom-range' &&\n 'bg-(--bg-primary-lighter)'\n )\n \">\n <radio\n name=\"date-filter\"\n class=\"grow items-center [&_.radio-label]:text-base [&_.radio-label]:leading-6 [&_.radio-label]:font-normal\"\n [checked]=\"dateModel().option === 'custom-range'\"\n (checkedChange)=\"selectOption('custom-range')\">\n <!-- `<radio>` is `display: contents`: its `<label>` is a child of the `<li>` in the box tree but not in the DOM, hence\n the `[&>radio>label]` of the row above. Every box of the chain down to the inputs may shrink (`min-w-0`, no fixed `min-w-[18ch]`): in the 15.5rem popover the\n label, the field and the radio do not fit side by side, and a box that cannot shrink spills out of it and is clipped.\n The fields have a definite width (`w-[18ch]`) that may shrink (`max-w-full min-w-0`). On a touch screen the text is 16px\n instead of 14px, which is what made the row wider than the 15.5rem popover. -->\n <div class=\"flex min-w-0 flex-wrap items-center gap-x-1 gap-y-1\">\n <div class=\"flex min-w-0 items-center gap-1\">\n <label\n [attr.for]=\"startId\"\n class=\"radio-label min-w-8 shrink-0 truncate\"\n >{{ \"filters.from\" | transloco }}</label\n >\n <input-group\n class=\"h-8 w-[18ch] max-w-full min-w-0 has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"startId\"\n name=\"start\"\n type=\"text\"\n readonly\n [value]=\"customRangeFrom()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n <div class=\"flex min-w-0 items-center gap-1\">\n <label\n [attr.for]=\"endId\"\n class=\"radio-label min-w-8 shrink-0 truncate text-right\"\n >{{ \"filters.to\" | transloco }}</label\n >\n <input-group\n class=\"h-8 w-[18ch] max-w-full min-w-0 has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"endId\"\n name=\"end\"\n type=\"text\"\n readonly\n [value]=\"customRangeTo()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n </div>\n </radio>\n </li>\n }\n </ul>\n </div>\n </form>\n</ng-template>\n\n@if (headless()) {\n <div class=\"flex min-h-0 flex-1 flex-col\">\n <ng-container [ngTemplateOutlet]=\"content\" />\n </div>\n} @else {\n <details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group flex min-h-0 flex-1 flex-col space-y-2 p-0.5 details-content:flex details-content:min-h-0 details-content:flex-col\">\n <!-- `p-0.5` on the details: the one of AggregationPanel (the list and the tree), so the header and its chevron sit on the same\n lines as theirs in the aside filters and the filters panel — without it the chevron was 2px further right.\n scroll-mb-3/scroll-mt-16: see aggregation-panel-row.html for the full rationale\n (bottom margin against the container's edge, much bigger top margin to also clear\n <More>'s sticky facet-name search box when navigating ArrowUp). -->\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible()\"\n [class]=\"rowStateClass\"\n class=\"m-0 flex h-8 w-full shrink-0 scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"md\"\n [aria-label]=\"'filters.toggle' | transloco\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n </button>\n }\n </summary>\n <ng-container [ngTemplateOutlet]=\"content\" />\n </details>\n}\n",
26289
26902
  styles: [":host{interpolate-size:allow-keywords}details::details-content{flex:1 1 auto;block-size:0;overflow:clip;overflow-clip-margin:.25rem;transition:block-size .2s cubic-bezier(.32,.72,0,1),content-visibility .2s allow-discrete}details[open]::details-content{block-size:auto}@media(prefers-reduced-motion:reduce){details::details-content{transition:none}}\n", ":host{min-width:200px}ul[role=list]{scrollbar-width:thin}\n"]
26290
26903
  }]
26291
26904
  }],
@@ -26821,7 +27434,7 @@ var AggregationPanelComponent = class AggregationPanelComponent {
26821
27434
  isSignal: true
26822
27435
  }],
26823
27436
  ngImport: i0,
26824
- template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion): a facet is\n a disclosure, not a grid row. They used to sit in an `@angular/aria` grid so ArrowUp/ArrowDown could\n jump from one facet header to the next, but a grid without headers reads as a layout table\n (RGAA 5.3). The `<summary>` itself is `tabindex=\"-1\"`: Tab walks the header's chevron, never the header row, and the\n chevron is the keyboard way to collapse. Select all, clear and apply are not in the header: they are in the body, in a\n bar under the search field. Label, header actions (the count and the chevron) and body are `<ng-template>`s: `<ng-content select=\"…\">` can only be\n declared once, and the body is projected content. -->\n\n<ng-template #searchToggleSlot>\n <button\n #searchToggle\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n class=\"shrink-0 cursor-pointer\"\n [attr.aria-expanded]=\"showSearch()\"\n [attr.aria-controls]=\"searchId\"\n [tooltip]=\"\n 'filters.searchInFacet'\n | transloco: { facet: (aggregation()?.display | syslang | transloco) }\n \"\n (click)=\"toggleSearch($event)\">\n <search-icon class=\"shrink-0\" />\n <span class=\"sr-only\">{{\n \"filters.searchInFacet\"\n | transloco: { facet: (aggregation()?.display | syslang | transloco) }\n }}</span>\n </button>\n</ng-template>\n\n<ng-template #labelSlot>\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n</ng-template>\n\n<ng-template #headerActionsSlot>\n @if (showFiltersCount() && filtersCount() > 0) {\n <Badge variant=\"number\" fill=\"primary\" scheme=\"sage\" size=\"xs\" class=\"ml-1\">\n {{ filtersCount() }}\n </Badge>\n }\n @if (canSearch() && !isCollapsed()) {\n <!-- The magnifier opens the search field of the facet (and closes it): the field is not on screen until then. It is\n in the header, next to the chevron, and only while the facet is open — there is nothing to search in a closed\n one. Interactive content in a summary does not toggle the details by itself, and the click is stopped anyway. -->\n <ng-container [ngTemplateOutlet]=\"searchToggleSlot\" />\n }\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n <span class=\"sr-only\">{{ \"filters.toggle\" | transloco }}</span>\n </button>\n }\n</ng-template>\n\n<ng-template #bodySlot>\n <div class=\"flex min-h-0 flex-1 flex-col\">\n @if (canSearch()) {\n <!-- The field is always in the DOM and folded to nothing until it is asked for: a grid row that goes from 0fr to 1fr is\n the one height animation that needs no measuring. `inert` takes the folded field out of the tab order and of the\n accessibility tree. The margins live in the inner box: a padding on the folded element itself would keep it\n from reaching zero.\n\n No `hotkey`: this field is one of many facet search boxes on the page, so it must not advertise a global shortcut.\n A placeholder is not a label (RGAA 11.1): the name says WHICH facet this box filters — the one thing that tells a\n dozen identical fields apart. SearchInput hands its class to its inner field, so a margin on it would count twice,\n which is one more reason for the padding on the box around it. -->\n <div\n [id]=\"searchId\"\n class=\"grid shrink-0 transition-[grid-template-rows,opacity] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none\"\n [class]=\"\n showSearch()\n ? 'grid-rows-[1fr] opacity-100'\n : 'grid-rows-[0fr] opacity-0'\n \"\n [inert]=\"!showSearch()\">\n <!-- `-mx-1 px-1`: the box clips what is outside it, and the focus ring of the field is drawn outside the field,\n so it needs a few pixels of room on each side — taken back by the negative margin so nothing moves. -->\n <div class=\"-mx-1 min-h-0 overflow-hidden px-1\">\n <div [class]=\"headless() ? 'pt-0.5 pb-1' : 'pt-2 pb-1'\">\n <SearchInput\n [placeholder]=\"'search' | transloco\"\n [clearLabel]=\"'filters.clearSearch' | transloco\"\n [aria-label]=\"\n 'filters.searchInFacet'\n | transloco\n : { facet: (aggregation()?.display | syslang | transloco) }\n \"\n [(value)]=\"searchText\"\n (keydown.escape)=\"onSearchEscape($event)\">\n <ng-content select=\"[search-addon]\" />\n </SearchInput>\n </div>\n </div>\n </div>\n }\n\n @if (itemsLength() > 0 || hasFilters() || selection()) {\n <!-- The bar under the search field, in a popover and in the filters drawer alike: clear and apply, and nothing else, so\n both keep their full text in the little room there is. Clear is a button with a text, not an icon, and stays in\n place (disabled) when there is nothing to clear. Apply only shows once something is pending, as it always did, and\n sits at the same level. -->\n <div\n data-bar=\"actions\"\n class=\"mb-1 flex shrink-0 items-center justify-end gap-1\">\n @if (headless() && canSearch()) {\n <ng-container [ngTemplateOutlet]=\"searchToggleSlot\" />\n }\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n class=\"shrink-0\"\n [disabled]=\"!hasFilters() && selectedCount() === 0\"\n [attr.aria-label]=\"'filters.clearFilters' | transloco\"\n (click)=\"cleared.emit()\">\n {{ \"filters.clearAll\" | transloco }}\n </button>\n @if (selection()) {\n <!-- No icon: the room is short, and the word says it. -->\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"sm\"\n class=\"shrink-0 overflow-hidden\"\n (click)=\"applied.emit()\">\n <span class=\"truncate\">{{ \"filters.apply\" | transloco }}</span>\n </button>\n }\n </div>\n }\n\n @if (itemsLength() > 0) {\n <!-- Select all is the first row of the list, written like one: the box lines up with the boxes of the items it drives and\n carries its own text, which a bare box next to two buttons never did. The text lines up with the items' too: same gap\n after the box (`gap-2`) and same size (`text-sm`, where the checkbox's own label would be 12px). The whole row is the label, so it is as easy\n to hit as an item. The count of checked items is part of that same label (\"Select all (3)\"), so the two can never\n overlap: when the room is short the label is cut with an ellipsis as a whole. It stays out of the scroller of the list, so it does not scroll away with the items. -->\n <div\n data-bar=\"select-all\"\n class=\"mb-1 flex shrink-0 items-center pr-3 pl-1.5 text-sm\">\n <checkbox\n class=\"min-w-0 flex-1 items-center gap-2 py-1 [&_.checkbox-label]:truncate [&_.checkbox-label]:text-sm [&_.checkbox-label]:leading-6 [&_.checkbox-text-block]:min-w-0\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"selectedCount() > 0 && !isAllSelected()\"\n (checkedChange)=\"$event ? allSelected.emit() : allUnselected.emit()\">\n {{\n (isAllSelected() ? \"filters.unselectAll\" : \"filters.selectAll\")\n | transloco\n }}\n @if (selectedCount() > 0) {\n <span class=\"text-(--font-primary-muted)\"\n >({{ selectedCount() }})</span\n >\n }\n </checkbox>\n <span class=\"sr-only\" aria-live=\"polite\">{{\n \"filters.selectedCount\" | transloco: { count: selectedCount() }\n }}</span>\n </div>\n }\n\n <ng-content />\n\n @if (hasMore() && searchedItemsLength() === 0) {\n <!-- The placeholder rows of a loading page are not here: they are rows of the scrolling area, in the list and the tree\n (AggregationLoadMoreSkeleton). Here, between the list and the button, they would shorten the list. -->\n <!-- `variant=\"none\"`: galactik's ButtonComponent matches every <button> and would otherwise dress this one as a primary\n button (filled, rounded, 36px high). `none` leaves the classes below as the only style. -->\n <!-- `aria-disabled`, not `disabled`: the focus stays on the button while the page loads. The accessible name stays\n \"load more\" and `aria-busy` tells that it is working, so no extra text has to be translated by the application. -->\n <button\n type=\"button\"\n data-load-more=\"button\"\n variant=\"none\"\n class=\"mt-1 flex w-full shrink-0 cursor-pointer items-center justify-center rounded-(--radius-xs) px-2 py-1.5 text-(--font-primary-muted) underline underline-offset-4 hover:bg-(--bg-primary-lighter) hover:text-(--font-primary-base) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--stroke-focus) focus-visible:outline-solid aria-busy:cursor-progress aria-busy:no-underline aria-busy:hover:bg-transparent\"\n [attr.aria-label]=\"'loadMore' | transloco\"\n [attr.aria-busy]=\"loading() ? 'true' : null\"\n [attr.aria-disabled]=\"loading() ? 'true' : null\"\n (click)=\"requestMore()\">\n <!-- Both states share one grid cell: the button keeps its width and height when the text gives way to the dots. -->\n <span class=\"inline-grid\">\n <span class=\"col-start-1 row-start-1\" [class.invisible]=\"loading()\">\n {{ \"loadMore\" | transloco }}\n </span>\n @if (loading()) {\n <loading-dots size=\"sm\" class=\"col-start-1 row-start-1\" />\n }\n </span>\n </button>\n }\n </div>\n</ng-template>\n\n@if (headless()) {\n <div class=\"flex min-h-0 flex-1 flex-col p-0.5\">\n <ng-container [ngTemplateOutlet]=\"bodySlot\" />\n </div>\n} @else {\n <details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group flex min-h-0 flex-1 flex-col p-0.5 details-content:flex details-content:min-h-0 details-content:flex-col\"\n (toggle)=\"onToggle($event)\">\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible() && !isEmpty()\"\n [class]=\"\n (isEmpty() ? 'text-(--font-neutral-muted) ' : '') + rowStateClass\n \"\n class=\"m-0 flex h-8 w-full shrink-0 scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-container [ngTemplateOutlet]=\"labelSlot\" />\n <ng-container [ngTemplateOutlet]=\"headerActionsSlot\" />\n </summary>\n <ng-container [ngTemplateOutlet]=\"bodySlot\" />\n </details>\n}\n",
27437
+ template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion): a facet is\n a disclosure, not a grid row. They used to sit in an `@angular/aria` grid so ArrowUp/ArrowDown could\n jump from one facet header to the next, but a grid without headers reads as a layout table\n (RGAA 5.3). The `<summary>` itself is `tabindex=\"-1\"`: Tab walks the header's chevron, never the header row, and the\n chevron is the keyboard way to collapse. Select all, clear and apply are not in the header: they are in the body, in a\n bar under the search field. Label, header actions (the count and the chevron) and body are `<ng-template>`s: `<ng-content select=\"…\">` can only be\n declared once, and the body is projected content. -->\n\n<ng-template #searchToggleSlot>\n <button\n #searchToggle\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"md\"\n class=\"shrink-0 cursor-pointer\"\n [attr.aria-expanded]=\"showSearch()\"\n [attr.aria-controls]=\"searchId\"\n [tooltip]=\"\n 'filters.searchInFacet'\n | transloco: { facet: (aggregation()?.display | syslang | transloco) }\n \"\n (click)=\"toggleSearch($event)\">\n <search-icon class=\"shrink-0\" />\n <span class=\"sr-only\">{{\n \"filters.searchInFacet\"\n | transloco: { facet: (aggregation()?.display | syslang | transloco) }\n }}</span>\n </button>\n</ng-template>\n\n<ng-template #labelSlot>\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n</ng-template>\n\n<ng-template #headerActionsSlot>\n @if (showFiltersCount() && filtersCount() > 0) {\n <Badge variant=\"number\" fill=\"primary\" scheme=\"sage\" size=\"xs\" class=\"ml-1\">\n {{ filtersCount() }}\n </Badge>\n }\n @if (canSearch() && !isCollapsed()) {\n <!-- The magnifier opens the search field of the facet (and closes it): the field is not on screen until then. It is\n in the header, next to the chevron, and only while the facet is open — there is nothing to search in a closed\n one. Interactive content in a summary does not toggle the details by itself, and the click is stopped anyway. -->\n <ng-container [ngTemplateOutlet]=\"searchToggleSlot\" />\n }\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"md\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n <span class=\"sr-only\">{{ \"filters.toggle\" | transloco }}</span>\n </button>\n }\n</ng-template>\n\n<ng-template #bodySlot>\n <div class=\"flex min-h-0 flex-1 flex-col\">\n @if (canSearch()) {\n <!-- The field is always in the DOM and folded to nothing until it is asked for: a grid row that goes from 0fr to 1fr is\n the one height animation that needs no measuring. `inert` takes the folded field out of the tab order and of the\n accessibility tree. The margins live in the inner box: a padding on the folded element itself would keep it\n from reaching zero.\n\n No `hotkey`: this field is one of many facet search boxes on the page, so it must not advertise a global shortcut.\n A placeholder is not a label (RGAA 11.1): the name says WHICH facet this box filters — the one thing that tells a\n dozen identical fields apart. SearchInput hands its class to its inner field, so a margin on it would count twice,\n which is one more reason for the padding on the box around it. -->\n <div\n [id]=\"searchId\"\n class=\"grid shrink-0 transition-[grid-template-rows,opacity] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none\"\n [class]=\"\n showSearch()\n ? 'grid-rows-[1fr] opacity-100'\n : 'grid-rows-[0fr] opacity-0'\n \"\n [inert]=\"!showSearch()\">\n <!-- `px-0.5`: the box clips what is outside it, and the focus ring of the field is drawn outside the field (1px past its\n border), so it needs a little room on each side. No negative margin to take it back: it pushed that room OUTSIDE\n the box, into the clipping of the popover or the drawer around, and the ring was cut on both sides. -->\n <div class=\"min-h-0 overflow-hidden px-0.5\">\n <div [class]=\"headless() ? 'pt-0.5 pb-1' : 'pt-2 pb-1'\">\n <SearchInput\n [placeholder]=\"'search' | transloco\"\n [clearLabel]=\"'filters.clearSearch' | transloco\"\n [aria-label]=\"\n 'filters.searchInFacet'\n | transloco\n : { facet: (aggregation()?.display | syslang | transloco) }\n \"\n [(value)]=\"searchText\"\n (keydown.escape)=\"onSearchEscape($event)\">\n <ng-content select=\"[search-addon]\" />\n </SearchInput>\n </div>\n </div>\n </div>\n }\n\n @if (itemsLength() > 0 || hasFilters() || selection()) {\n <!-- The bar under the search field, in a popover and in the filters drawer alike: clear and apply, and nothing else, so\n both keep their full text in the little room there is. Clear is a button with a text, not an icon, and stays in\n place (disabled) when there is nothing to clear. Apply only shows once something is pending, as it always did, and\n sits at the same level. -->\n <div\n data-bar=\"actions\"\n class=\"mb-1 flex shrink-0 items-center justify-end gap-1\">\n @if (headless() && canSearch()) {\n <ng-container [ngTemplateOutlet]=\"searchToggleSlot\" />\n }\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"lg\"\n class=\"shrink-0\"\n [disabled]=\"!hasFilters() && selectedCount() === 0\"\n [attr.aria-label]=\"'filters.clearFilters' | transloco\"\n (click)=\"cleared.emit()\">\n {{ \"filters.clearAll\" | transloco }}\n </button>\n @if (selection()) {\n <!-- No icon: the room is short, and the word says it. -->\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"lg\"\n class=\"shrink-0 overflow-hidden\"\n (click)=\"applied.emit()\">\n <span class=\"truncate\">{{ \"filters.apply\" | transloco }}</span>\n </button>\n }\n </div>\n }\n\n @if (itemsLength() > 0) {\n <!-- Select all is the first row of the list, written like one: the box lines up with the boxes of the items it drives and\n carries its own text, which a bare box next to two buttons never did. The text lines up with the items' too: same gap\n after the box (`gap-2`) and same size (`text-sm`, where the checkbox's own label would be 12px). The whole row is the label, so it is as easy\n to hit as an item. The count of checked items is part of that same label (\"Select all (3)\"), so the two can never\n overlap: when the room is short the label is cut with an ellipsis as a whole. It stays out of the scroller of the list, so it does not scroll away with the items. -->\n <div\n data-bar=\"select-all\"\n class=\"mb-1 flex shrink-0 items-center pr-3 pl-1.5 text-sm\">\n <checkbox\n class=\"min-w-0 flex-1 items-center gap-2 py-1.5 [&_.checkbox-label]:truncate [&_.checkbox-label]:text-base [&_.checkbox-label]:leading-6 [&_.checkbox-text-block]:min-w-0\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"selectedCount() > 0 && !isAllSelected()\"\n (checkedChange)=\"$event ? allSelected.emit() : allUnselected.emit()\">\n {{\n (isAllSelected() ? \"filters.unselectAll\" : \"filters.selectAll\")\n | transloco\n }}\n @if (selectedCount() > 0) {\n <span class=\"text-(--font-primary-muted)\"\n >({{ selectedCount() }})</span\n >\n }\n </checkbox>\n <span class=\"sr-only\" aria-live=\"polite\">{{\n \"filters.selectedCount\" | transloco: { count: selectedCount() }\n }}</span>\n </div>\n }\n\n <ng-content />\n\n @if (hasMore() && searchedItemsLength() === 0) {\n <!-- The placeholder rows of a loading page are not here: they are rows of the scrolling area, in the list and the tree\n (AggregationLoadMoreSkeleton). Here, between the list and the button, they would shorten the list. -->\n <!-- `variant=\"none\"`: galactik's ButtonComponent matches every <button> and would otherwise dress this one as a primary\n button (filled, rounded, 36px high). `none` leaves the classes below as the only style. -->\n <!-- `aria-disabled`, not `disabled`: the focus stays on the button while the page loads. The accessible name stays\n \"load more\" and `aria-busy` tells that it is working, so no extra text has to be translated by the application. -->\n <button\n type=\"button\"\n data-load-more=\"button\"\n variant=\"none\"\n class=\"mt-1 flex w-full shrink-0 cursor-pointer items-center justify-center rounded-(--radius-xs) px-2 py-1.5 text-(--font-primary-muted) underline underline-offset-4 hover:bg-(--bg-primary-lighter) hover:text-(--font-primary-base) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--stroke-focus) focus-visible:outline-solid aria-busy:cursor-progress aria-busy:no-underline aria-busy:hover:bg-transparent\"\n [attr.aria-label]=\"'loadMore' | transloco\"\n [attr.aria-busy]=\"loading() ? 'true' : null\"\n [attr.aria-disabled]=\"loading() ? 'true' : null\"\n (click)=\"requestMore()\">\n <!-- Both states share one grid cell: the button keeps its width and height when the text gives way to the dots. -->\n <span class=\"inline-grid\">\n <span class=\"col-start-1 row-start-1\" [class.invisible]=\"loading()\">\n {{ \"loadMore\" | transloco }}\n </span>\n @if (loading()) {\n <loading-dots size=\"sm\" class=\"col-start-1 row-start-1\" />\n }\n </span>\n </button>\n }\n </div>\n</ng-template>\n\n@if (headless()) {\n <div class=\"flex min-h-0 flex-1 flex-col p-0.5\">\n <ng-container [ngTemplateOutlet]=\"bodySlot\" />\n </div>\n} @else {\n <details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group flex min-h-0 flex-1 flex-col p-0.5 details-content:flex details-content:min-h-0 details-content:flex-col\"\n (toggle)=\"onToggle($event)\">\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible() && !isEmpty()\"\n [class]=\"\n (isEmpty() ? 'text-(--font-neutral-muted) ' : '') + rowStateClass\n \"\n class=\"m-0 flex h-8 w-full shrink-0 scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-container [ngTemplateOutlet]=\"labelSlot\" />\n <ng-container [ngTemplateOutlet]=\"headerActionsSlot\" />\n </summary>\n <ng-container [ngTemplateOutlet]=\"bodySlot\" />\n </details>\n}\n",
26825
27438
  styles: [":host{interpolate-size:allow-keywords}details::details-content{flex:1 1 auto;block-size:0;overflow:clip;overflow-clip-margin:.25rem;transition:block-size .2s cubic-bezier(.32,.72,0,1),content-visibility .2s allow-discrete}details[open]::details-content{block-size:auto}@media(prefers-reduced-motion:reduce){details::details-content{transition:none}}\n"],
26826
27439
  dependencies: [
26827
27440
  {
@@ -26966,7 +27579,7 @@ i0.ɵɵngDeclareClassMetadata({
26966
27579
  ],
26967
27580
  providers: [provideTranslocoScope("filters")],
26968
27581
  host: { class: "flex min-h-0 flex-1 flex-col" },
26969
- template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion): a facet is\n a disclosure, not a grid row. They used to sit in an `@angular/aria` grid so ArrowUp/ArrowDown could\n jump from one facet header to the next, but a grid without headers reads as a layout table\n (RGAA 5.3). The `<summary>` itself is `tabindex=\"-1\"`: Tab walks the header's chevron, never the header row, and the\n chevron is the keyboard way to collapse. Select all, clear and apply are not in the header: they are in the body, in a\n bar under the search field. Label, header actions (the count and the chevron) and body are `<ng-template>`s: `<ng-content select=\"…\">` can only be\n declared once, and the body is projected content. -->\n\n<ng-template #searchToggleSlot>\n <button\n #searchToggle\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n class=\"shrink-0 cursor-pointer\"\n [attr.aria-expanded]=\"showSearch()\"\n [attr.aria-controls]=\"searchId\"\n [tooltip]=\"\n 'filters.searchInFacet'\n | transloco: { facet: (aggregation()?.display | syslang | transloco) }\n \"\n (click)=\"toggleSearch($event)\">\n <search-icon class=\"shrink-0\" />\n <span class=\"sr-only\">{{\n \"filters.searchInFacet\"\n | transloco: { facet: (aggregation()?.display | syslang | transloco) }\n }}</span>\n </button>\n</ng-template>\n\n<ng-template #labelSlot>\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n</ng-template>\n\n<ng-template #headerActionsSlot>\n @if (showFiltersCount() && filtersCount() > 0) {\n <Badge variant=\"number\" fill=\"primary\" scheme=\"sage\" size=\"xs\" class=\"ml-1\">\n {{ filtersCount() }}\n </Badge>\n }\n @if (canSearch() && !isCollapsed()) {\n <!-- The magnifier opens the search field of the facet (and closes it): the field is not on screen until then. It is\n in the header, next to the chevron, and only while the facet is open — there is nothing to search in a closed\n one. Interactive content in a summary does not toggle the details by itself, and the click is stopped anyway. -->\n <ng-container [ngTemplateOutlet]=\"searchToggleSlot\" />\n }\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n <span class=\"sr-only\">{{ \"filters.toggle\" | transloco }}</span>\n </button>\n }\n</ng-template>\n\n<ng-template #bodySlot>\n <div class=\"flex min-h-0 flex-1 flex-col\">\n @if (canSearch()) {\n <!-- The field is always in the DOM and folded to nothing until it is asked for: a grid row that goes from 0fr to 1fr is\n the one height animation that needs no measuring. `inert` takes the folded field out of the tab order and of the\n accessibility tree. The margins live in the inner box: a padding on the folded element itself would keep it\n from reaching zero.\n\n No `hotkey`: this field is one of many facet search boxes on the page, so it must not advertise a global shortcut.\n A placeholder is not a label (RGAA 11.1): the name says WHICH facet this box filters — the one thing that tells a\n dozen identical fields apart. SearchInput hands its class to its inner field, so a margin on it would count twice,\n which is one more reason for the padding on the box around it. -->\n <div\n [id]=\"searchId\"\n class=\"grid shrink-0 transition-[grid-template-rows,opacity] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none\"\n [class]=\"\n showSearch()\n ? 'grid-rows-[1fr] opacity-100'\n : 'grid-rows-[0fr] opacity-0'\n \"\n [inert]=\"!showSearch()\">\n <!-- `-mx-1 px-1`: the box clips what is outside it, and the focus ring of the field is drawn outside the field,\n so it needs a few pixels of room on each side — taken back by the negative margin so nothing moves. -->\n <div class=\"-mx-1 min-h-0 overflow-hidden px-1\">\n <div [class]=\"headless() ? 'pt-0.5 pb-1' : 'pt-2 pb-1'\">\n <SearchInput\n [placeholder]=\"'search' | transloco\"\n [clearLabel]=\"'filters.clearSearch' | transloco\"\n [aria-label]=\"\n 'filters.searchInFacet'\n | transloco\n : { facet: (aggregation()?.display | syslang | transloco) }\n \"\n [(value)]=\"searchText\"\n (keydown.escape)=\"onSearchEscape($event)\">\n <ng-content select=\"[search-addon]\" />\n </SearchInput>\n </div>\n </div>\n </div>\n }\n\n @if (itemsLength() > 0 || hasFilters() || selection()) {\n <!-- The bar under the search field, in a popover and in the filters drawer alike: clear and apply, and nothing else, so\n both keep their full text in the little room there is. Clear is a button with a text, not an icon, and stays in\n place (disabled) when there is nothing to clear. Apply only shows once something is pending, as it always did, and\n sits at the same level. -->\n <div\n data-bar=\"actions\"\n class=\"mb-1 flex shrink-0 items-center justify-end gap-1\">\n @if (headless() && canSearch()) {\n <ng-container [ngTemplateOutlet]=\"searchToggleSlot\" />\n }\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n class=\"shrink-0\"\n [disabled]=\"!hasFilters() && selectedCount() === 0\"\n [attr.aria-label]=\"'filters.clearFilters' | transloco\"\n (click)=\"cleared.emit()\">\n {{ \"filters.clearAll\" | transloco }}\n </button>\n @if (selection()) {\n <!-- No icon: the room is short, and the word says it. -->\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"sm\"\n class=\"shrink-0 overflow-hidden\"\n (click)=\"applied.emit()\">\n <span class=\"truncate\">{{ \"filters.apply\" | transloco }}</span>\n </button>\n }\n </div>\n }\n\n @if (itemsLength() > 0) {\n <!-- Select all is the first row of the list, written like one: the box lines up with the boxes of the items it drives and\n carries its own text, which a bare box next to two buttons never did. The text lines up with the items' too: same gap\n after the box (`gap-2`) and same size (`text-sm`, where the checkbox's own label would be 12px). The whole row is the label, so it is as easy\n to hit as an item. The count of checked items is part of that same label (\"Select all (3)\"), so the two can never\n overlap: when the room is short the label is cut with an ellipsis as a whole. It stays out of the scroller of the list, so it does not scroll away with the items. -->\n <div\n data-bar=\"select-all\"\n class=\"mb-1 flex shrink-0 items-center pr-3 pl-1.5 text-sm\">\n <checkbox\n class=\"min-w-0 flex-1 items-center gap-2 py-1 [&_.checkbox-label]:truncate [&_.checkbox-label]:text-sm [&_.checkbox-label]:leading-6 [&_.checkbox-text-block]:min-w-0\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"selectedCount() > 0 && !isAllSelected()\"\n (checkedChange)=\"$event ? allSelected.emit() : allUnselected.emit()\">\n {{\n (isAllSelected() ? \"filters.unselectAll\" : \"filters.selectAll\")\n | transloco\n }}\n @if (selectedCount() > 0) {\n <span class=\"text-(--font-primary-muted)\"\n >({{ selectedCount() }})</span\n >\n }\n </checkbox>\n <span class=\"sr-only\" aria-live=\"polite\">{{\n \"filters.selectedCount\" | transloco: { count: selectedCount() }\n }}</span>\n </div>\n }\n\n <ng-content />\n\n @if (hasMore() && searchedItemsLength() === 0) {\n <!-- The placeholder rows of a loading page are not here: they are rows of the scrolling area, in the list and the tree\n (AggregationLoadMoreSkeleton). Here, between the list and the button, they would shorten the list. -->\n <!-- `variant=\"none\"`: galactik's ButtonComponent matches every <button> and would otherwise dress this one as a primary\n button (filled, rounded, 36px high). `none` leaves the classes below as the only style. -->\n <!-- `aria-disabled`, not `disabled`: the focus stays on the button while the page loads. The accessible name stays\n \"load more\" and `aria-busy` tells that it is working, so no extra text has to be translated by the application. -->\n <button\n type=\"button\"\n data-load-more=\"button\"\n variant=\"none\"\n class=\"mt-1 flex w-full shrink-0 cursor-pointer items-center justify-center rounded-(--radius-xs) px-2 py-1.5 text-(--font-primary-muted) underline underline-offset-4 hover:bg-(--bg-primary-lighter) hover:text-(--font-primary-base) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--stroke-focus) focus-visible:outline-solid aria-busy:cursor-progress aria-busy:no-underline aria-busy:hover:bg-transparent\"\n [attr.aria-label]=\"'loadMore' | transloco\"\n [attr.aria-busy]=\"loading() ? 'true' : null\"\n [attr.aria-disabled]=\"loading() ? 'true' : null\"\n (click)=\"requestMore()\">\n <!-- Both states share one grid cell: the button keeps its width and height when the text gives way to the dots. -->\n <span class=\"inline-grid\">\n <span class=\"col-start-1 row-start-1\" [class.invisible]=\"loading()\">\n {{ \"loadMore\" | transloco }}\n </span>\n @if (loading()) {\n <loading-dots size=\"sm\" class=\"col-start-1 row-start-1\" />\n }\n </span>\n </button>\n }\n </div>\n</ng-template>\n\n@if (headless()) {\n <div class=\"flex min-h-0 flex-1 flex-col p-0.5\">\n <ng-container [ngTemplateOutlet]=\"bodySlot\" />\n </div>\n} @else {\n <details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group flex min-h-0 flex-1 flex-col p-0.5 details-content:flex details-content:min-h-0 details-content:flex-col\"\n (toggle)=\"onToggle($event)\">\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible() && !isEmpty()\"\n [class]=\"\n (isEmpty() ? 'text-(--font-neutral-muted) ' : '') + rowStateClass\n \"\n class=\"m-0 flex h-8 w-full shrink-0 scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-container [ngTemplateOutlet]=\"labelSlot\" />\n <ng-container [ngTemplateOutlet]=\"headerActionsSlot\" />\n </summary>\n <ng-container [ngTemplateOutlet]=\"bodySlot\" />\n </details>\n}\n",
27582
+ template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion): a facet is\n a disclosure, not a grid row. They used to sit in an `@angular/aria` grid so ArrowUp/ArrowDown could\n jump from one facet header to the next, but a grid without headers reads as a layout table\n (RGAA 5.3). The `<summary>` itself is `tabindex=\"-1\"`: Tab walks the header's chevron, never the header row, and the\n chevron is the keyboard way to collapse. Select all, clear and apply are not in the header: they are in the body, in a\n bar under the search field. Label, header actions (the count and the chevron) and body are `<ng-template>`s: `<ng-content select=\"…\">` can only be\n declared once, and the body is projected content. -->\n\n<ng-template #searchToggleSlot>\n <button\n #searchToggle\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"md\"\n class=\"shrink-0 cursor-pointer\"\n [attr.aria-expanded]=\"showSearch()\"\n [attr.aria-controls]=\"searchId\"\n [tooltip]=\"\n 'filters.searchInFacet'\n | transloco: { facet: (aggregation()?.display | syslang | transloco) }\n \"\n (click)=\"toggleSearch($event)\">\n <search-icon class=\"shrink-0\" />\n <span class=\"sr-only\">{{\n \"filters.searchInFacet\"\n | transloco: { facet: (aggregation()?.display | syslang | transloco) }\n }}</span>\n </button>\n</ng-template>\n\n<ng-template #labelSlot>\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n</ng-template>\n\n<ng-template #headerActionsSlot>\n @if (showFiltersCount() && filtersCount() > 0) {\n <Badge variant=\"number\" fill=\"primary\" scheme=\"sage\" size=\"xs\" class=\"ml-1\">\n {{ filtersCount() }}\n </Badge>\n }\n @if (canSearch() && !isCollapsed()) {\n <!-- The magnifier opens the search field of the facet (and closes it): the field is not on screen until then. It is\n in the header, next to the chevron, and only while the facet is open — there is nothing to search in a closed\n one. Interactive content in a summary does not toggle the details by itself, and the click is stopped anyway. -->\n <ng-container [ngTemplateOutlet]=\"searchToggleSlot\" />\n }\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"md\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n <span class=\"sr-only\">{{ \"filters.toggle\" | transloco }}</span>\n </button>\n }\n</ng-template>\n\n<ng-template #bodySlot>\n <div class=\"flex min-h-0 flex-1 flex-col\">\n @if (canSearch()) {\n <!-- The field is always in the DOM and folded to nothing until it is asked for: a grid row that goes from 0fr to 1fr is\n the one height animation that needs no measuring. `inert` takes the folded field out of the tab order and of the\n accessibility tree. The margins live in the inner box: a padding on the folded element itself would keep it\n from reaching zero.\n\n No `hotkey`: this field is one of many facet search boxes on the page, so it must not advertise a global shortcut.\n A placeholder is not a label (RGAA 11.1): the name says WHICH facet this box filters — the one thing that tells a\n dozen identical fields apart. SearchInput hands its class to its inner field, so a margin on it would count twice,\n which is one more reason for the padding on the box around it. -->\n <div\n [id]=\"searchId\"\n class=\"grid shrink-0 transition-[grid-template-rows,opacity] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none\"\n [class]=\"\n showSearch()\n ? 'grid-rows-[1fr] opacity-100'\n : 'grid-rows-[0fr] opacity-0'\n \"\n [inert]=\"!showSearch()\">\n <!-- `px-0.5`: the box clips what is outside it, and the focus ring of the field is drawn outside the field (1px past its\n border), so it needs a little room on each side. No negative margin to take it back: it pushed that room OUTSIDE\n the box, into the clipping of the popover or the drawer around, and the ring was cut on both sides. -->\n <div class=\"min-h-0 overflow-hidden px-0.5\">\n <div [class]=\"headless() ? 'pt-0.5 pb-1' : 'pt-2 pb-1'\">\n <SearchInput\n [placeholder]=\"'search' | transloco\"\n [clearLabel]=\"'filters.clearSearch' | transloco\"\n [aria-label]=\"\n 'filters.searchInFacet'\n | transloco\n : { facet: (aggregation()?.display | syslang | transloco) }\n \"\n [(value)]=\"searchText\"\n (keydown.escape)=\"onSearchEscape($event)\">\n <ng-content select=\"[search-addon]\" />\n </SearchInput>\n </div>\n </div>\n </div>\n }\n\n @if (itemsLength() > 0 || hasFilters() || selection()) {\n <!-- The bar under the search field, in a popover and in the filters drawer alike: clear and apply, and nothing else, so\n both keep their full text in the little room there is. Clear is a button with a text, not an icon, and stays in\n place (disabled) when there is nothing to clear. Apply only shows once something is pending, as it always did, and\n sits at the same level. -->\n <div\n data-bar=\"actions\"\n class=\"mb-1 flex shrink-0 items-center justify-end gap-1\">\n @if (headless() && canSearch()) {\n <ng-container [ngTemplateOutlet]=\"searchToggleSlot\" />\n }\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"lg\"\n class=\"shrink-0\"\n [disabled]=\"!hasFilters() && selectedCount() === 0\"\n [attr.aria-label]=\"'filters.clearFilters' | transloco\"\n (click)=\"cleared.emit()\">\n {{ \"filters.clearAll\" | transloco }}\n </button>\n @if (selection()) {\n <!-- No icon: the room is short, and the word says it. -->\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"lg\"\n class=\"shrink-0 overflow-hidden\"\n (click)=\"applied.emit()\">\n <span class=\"truncate\">{{ \"filters.apply\" | transloco }}</span>\n </button>\n }\n </div>\n }\n\n @if (itemsLength() > 0) {\n <!-- Select all is the first row of the list, written like one: the box lines up with the boxes of the items it drives and\n carries its own text, which a bare box next to two buttons never did. The text lines up with the items' too: same gap\n after the box (`gap-2`) and same size (`text-sm`, where the checkbox's own label would be 12px). The whole row is the label, so it is as easy\n to hit as an item. The count of checked items is part of that same label (\"Select all (3)\"), so the two can never\n overlap: when the room is short the label is cut with an ellipsis as a whole. It stays out of the scroller of the list, so it does not scroll away with the items. -->\n <div\n data-bar=\"select-all\"\n class=\"mb-1 flex shrink-0 items-center pr-3 pl-1.5 text-sm\">\n <checkbox\n class=\"min-w-0 flex-1 items-center gap-2 py-1.5 [&_.checkbox-label]:truncate [&_.checkbox-label]:text-base [&_.checkbox-label]:leading-6 [&_.checkbox-text-block]:min-w-0\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"selectedCount() > 0 && !isAllSelected()\"\n (checkedChange)=\"$event ? allSelected.emit() : allUnselected.emit()\">\n {{\n (isAllSelected() ? \"filters.unselectAll\" : \"filters.selectAll\")\n | transloco\n }}\n @if (selectedCount() > 0) {\n <span class=\"text-(--font-primary-muted)\"\n >({{ selectedCount() }})</span\n >\n }\n </checkbox>\n <span class=\"sr-only\" aria-live=\"polite\">{{\n \"filters.selectedCount\" | transloco: { count: selectedCount() }\n }}</span>\n </div>\n }\n\n <ng-content />\n\n @if (hasMore() && searchedItemsLength() === 0) {\n <!-- The placeholder rows of a loading page are not here: they are rows of the scrolling area, in the list and the tree\n (AggregationLoadMoreSkeleton). Here, between the list and the button, they would shorten the list. -->\n <!-- `variant=\"none\"`: galactik's ButtonComponent matches every <button> and would otherwise dress this one as a primary\n button (filled, rounded, 36px high). `none` leaves the classes below as the only style. -->\n <!-- `aria-disabled`, not `disabled`: the focus stays on the button while the page loads. The accessible name stays\n \"load more\" and `aria-busy` tells that it is working, so no extra text has to be translated by the application. -->\n <button\n type=\"button\"\n data-load-more=\"button\"\n variant=\"none\"\n class=\"mt-1 flex w-full shrink-0 cursor-pointer items-center justify-center rounded-(--radius-xs) px-2 py-1.5 text-(--font-primary-muted) underline underline-offset-4 hover:bg-(--bg-primary-lighter) hover:text-(--font-primary-base) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--stroke-focus) focus-visible:outline-solid aria-busy:cursor-progress aria-busy:no-underline aria-busy:hover:bg-transparent\"\n [attr.aria-label]=\"'loadMore' | transloco\"\n [attr.aria-busy]=\"loading() ? 'true' : null\"\n [attr.aria-disabled]=\"loading() ? 'true' : null\"\n (click)=\"requestMore()\">\n <!-- Both states share one grid cell: the button keeps its width and height when the text gives way to the dots. -->\n <span class=\"inline-grid\">\n <span class=\"col-start-1 row-start-1\" [class.invisible]=\"loading()\">\n {{ \"loadMore\" | transloco }}\n </span>\n @if (loading()) {\n <loading-dots size=\"sm\" class=\"col-start-1 row-start-1\" />\n }\n </span>\n </button>\n }\n </div>\n</ng-template>\n\n@if (headless()) {\n <div class=\"flex min-h-0 flex-1 flex-col p-0.5\">\n <ng-container [ngTemplateOutlet]=\"bodySlot\" />\n </div>\n} @else {\n <details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group flex min-h-0 flex-1 flex-col p-0.5 details-content:flex details-content:min-h-0 details-content:flex-col\"\n (toggle)=\"onToggle($event)\">\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible() && !isEmpty()\"\n [class]=\"\n (isEmpty() ? 'text-(--font-neutral-muted) ' : '') + rowStateClass\n \"\n class=\"m-0 flex h-8 w-full shrink-0 scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-container [ngTemplateOutlet]=\"labelSlot\" />\n <ng-container [ngTemplateOutlet]=\"headerActionsSlot\" />\n </summary>\n <ng-container [ngTemplateOutlet]=\"bodySlot\" />\n </details>\n}\n",
26970
27583
  styles: [":host{interpolate-size:allow-keywords}details::details-content{flex:1 1 auto;block-size:0;overflow:clip;overflow-clip-margin:.25rem;transition:block-size .2s cubic-bezier(.32,.72,0,1),content-visibility .2s allow-discrete}details[open]::details-content{block-size:auto}@media(prefers-reduced-motion:reduce){details::details-content{transition:none}}\n"]
26971
27584
  }]
26972
27585
  }],
@@ -27849,7 +28462,7 @@ var AggregationTreeComponent = class AggregationTreeComponent {
27849
28462
  isSignal: true
27850
28463
  }],
27851
28464
  ngImport: i0,
27852
- template: "@if (!aggregation()?.isTree) {\n <div role=\"alert\" class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregationTree component does not support list aggregations. Please use\n the &lt;Aggregation /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [headless]=\"headless()\"\n [selectedCount]=\"selected().length\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [loading]=\"loadingMore()\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div class=\"min-h-0 w-full flex-1 scrollbar-thin overflow-auto pb-3\">\n <ul\n ngTree\n #tree=\"ngTree\"\n [multi]=\"true\"\n [(value)]=\"selected\"\n (valueChange)=\"onSelectionChange($event)\"\n /* Translated like the flat list's own label (aggregation-list.html): this was the raw\n `display`, so the accessible name of a whole facet came out in the source language.\n The pipes apply to the whole nullish chain, `|` being the loosest operator Angular\n parses — no parentheses needed. */\n [attr.aria-label]=\"aggregation()?.display ?? name() | syslang | transloco\"\n class=\"basic-tree m-1\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: nodes(), parent: tree }\" />\n </ul>\n @if (loadingMore() && searchedItems().length === 0) {\n <AggregationLoadMoreSkeleton variant=\"tree\" [showCount]=\"showCount()\" />\n }\n </div>\n</aggregation-panel>\n\n<ng-template #treeNodes let-nodes=\"nodes\" let-parent=\"parent\">\n @for (node of nodes; track node.$path) {\n @let label = node.display ?? node.value ?? \"\";\n <li\n ngTreeItem\n #treeItem=\"ngTreeItem\"\n [parent]=\"parent\"\n [value]=\"node.$path\"\n [label]=\"label\"\n [disabled]=\"node.disabled\"\n [(expanded)]=\"node.$opened\"\n (expandedChange)=\"onExpandedChange(node, $event)\"\n [attr.aria-label]=\"label\"\n [attr.aria-describedby]=\"\n nodeDescription(node) ? descriptionId(node.$path) : null\n \"\n [class]=\"cn('m-0 block w-full p-0', itemStateClass)\"\n (keydown.enter)=\"onTreeItemEnterKey(node, $event)\"\n (keydown.space)=\"onTreeItemSpaceKey(node, $event)\">\n <!-- 2. Ce div prend tout l'espace et intercepte tous les clics de la ligne. `ps-0.5`: the checkbox of a list option sits\n 2px further in than a tree's (the option has its own padding), so the checkboxes of the two facets, and the select-all of\n the bar of a popover, are on the same vertical line. -->\n <div\n class=\"flex w-full cursor-pointer items-center gap-2 py-0.5 ps-0.5 text-sm leading-6\"\n (click)=\"onTreeItemRowClick(node, $event)\"\n (mousedown)=\"$event.stopPropagation()\"\n (pointerdown)=\"$event.stopPropagation()\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n a role=\"treeitem\" admits no focusable descendant, and the click must reach\n the row handler above. Selection is announced by the <li>'s aria-selected. -->\n <!-- --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. Set as a custom property rather than a utility\n class: the CVA already sets gap-(--spacing-xs), and two same-specificity\n classes would be resolved by stylesheet order, not by us. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"treeItem.selected() || node.$selectedVisually || false\"\n [disabled]=\"node.disabled\"\n [class]=\"\n cn(\n 'pointer-events-none',\n (treeItem.selected() || node.$selectedVisually) &&\n 'selected-icon'\n )\n \" />\n </span>\n }\n <!-- Depth indent sits on the chevron, after the checkbox: the checkboxes of every\n level stay flush left (aligned with a flat list's), and since this adds no extra\n flex child, the step between consecutive levels is exactly one indent. -->\n @let depthIndent =\n \"calc(var(--agg-tree-indent, 0.5rem) * \" +\n (treeItem.level() - 1) +\n \")\";\n @if (node.hasChildren) {\n <!-- Expand/collapse affordance for the mouse only, hence a <span> and not a <button>:\n the keyboard already expands with ArrowRight / ArrowLeft, handled by ngTreeItem, and\n a role=\"treeitem\" admits no focusable descendant — so this was a <button> nobody\n could reach, already carrying aria-hidden and tabindex=\"-1\" to say so. Every audit\n reads it as a button with no accessible name (RGAA 7.1, 11.9). Same shape as the leaf\n branch below. -->\n <span\n aria-hidden=\"true\"\n (click)=\"toggleTreeNode(treeItem, $event)\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex cursor-pointer items-center justify-center\">\n <ChevronRightIcon\n [class]=\"\n cn('expand-icon shrink-0', node.$opened && 'rotate-90')\n \" />\n <FolderIcon class=\"shrink-0\" />\n </span>\n } @else {\n <span\n aria-hidden=\"true\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex items-center justify-center\">\n <!-- Reserves the chevron's own width (`.expand-icon` is a styling hook with\n no rule of its own), so a leaf lines up with its parent nodes. -->\n <span class=\"expand-icon w-4 shrink-0\"></span>\n <FileIcon class=\"size-4 shrink-0\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <!-- Cut off, the label shows whole in a tooltip; a label that fits needs none. -->\n <span\n class=\"grow truncate select-none\"\n truncated\n #cutCustom=\"truncated\"\n [tooltip]=\"label\"\n [tooltipDisabled]=\"!cutCustom.isTruncated()\"\n (click)=\"onTreeItemTextClick(node, $event)\">\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: node,\n field: aggregation()?.column,\n selected: treeItem.selected() ?? false,\n count: node.count,\n name: label,\n searchText: searchText(),\n level: node.$level ?? 0,\n expanded: node.$opened ?? false,\n hasChildren: node.hasChildren ?? false\n }\" />\n </span>\n } @else {\n <!-- ce span est essentiel pour le style -->\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <!-- Cut off, the label shows whole in a tooltip (`truncated` measures it); a label that fits has none. -->\n <span\n [class]=\"\n cn(\n 'grow truncate select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n truncated\n #cut=\"truncated\"\n [tooltip]=\"label\"\n [tooltipDisabled]=\"!cut.isTruncated()\"\n (click)=\"onTreeItemTextClick(node, $event)\">\n @for (\n chunk of label | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && node.count > 0) {\n <span class=\"ml-auto px-1 text-xs\" aria-hidden=\"true\">{{\n node.count\n }}</span>\n }\n }\n @let description = nodeDescription(node);\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the treeitem's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(node.$path)\">{{ description }}</span>\n }\n </div>\n </li>\n @if (node.hasChildren) {\n <!-- No indentation here: it would shift the checkbox too. Depth is applied\n inside the row, after the checkbox, so every checkbox stays flush left. -->\n <!-- The <ul> sits INSIDE the deferred content, not around it: `ngTreeItemGroup` only\n instantiates its view when the node opens, so a <ul> declared outside stayed in the DOM,\n empty, for every collapsed node — a structural tag with nothing to structure (RGAA 8.9).\n The @if cannot move onto the <ul> either: the directive registers itself with its tree\n item on init and has to exist for as long as the item does. -->\n <ng-template\n ngTreeItemGroup\n [ownedBy]=\"treeItem\"\n #group=\"ngTreeItemGroup\">\n <ul role=\"group\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: node.items, parent: group }\" />\n </ul>\n </ng-template>\n }\n }\n</ng-template>\n",
28465
+ template: "@if (!aggregation()?.isTree) {\n <div role=\"alert\" class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregationTree component does not support list aggregations. Please use\n the &lt;Aggregation /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [headless]=\"headless()\"\n [selectedCount]=\"selected().length\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [loading]=\"loadingMore()\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div class=\"min-h-0 w-full flex-1 scrollbar-thin overflow-auto pb-3\">\n <ul\n ngTree\n #tree=\"ngTree\"\n [multi]=\"true\"\n [(value)]=\"selected\"\n (valueChange)=\"onSelectionChange($event)\"\n /* Translated like the flat list's own label (aggregation-list.html): this was the raw\n `display`, so the accessible name of a whole facet came out in the source language.\n The pipes apply to the whole nullish chain, `|` being the loosest operator Angular\n parses — no parentheses needed. */\n [attr.aria-label]=\"aggregation()?.display ?? name() | syslang | transloco\"\n class=\"basic-tree m-1\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: nodes(), parent: tree }\" />\n </ul>\n @if (loadingMore() && searchedItems().length === 0) {\n <AggregationLoadMoreSkeleton variant=\"tree\" [showCount]=\"showCount()\" />\n }\n </div>\n</aggregation-panel>\n\n<ng-template #treeNodes let-nodes=\"nodes\" let-parent=\"parent\">\n @for (node of nodes; track node.$path) {\n @let label = node.display ?? node.value ?? \"\";\n <li\n ngTreeItem\n #treeItem=\"ngTreeItem\"\n [parent]=\"parent\"\n [value]=\"node.$path\"\n [label]=\"label\"\n [disabled]=\"node.disabled\"\n [(expanded)]=\"node.$opened\"\n (expandedChange)=\"onExpandedChange(node, $event)\"\n [attr.aria-label]=\"label\"\n [attr.aria-describedby]=\"\n nodeDescription(node) ? descriptionId(node.$path) : null\n \"\n [class]=\"cn('m-0 block w-full p-0', itemStateClass)\"\n (keydown.enter)=\"onTreeItemEnterKey(node, $event)\"\n (keydown.space)=\"onTreeItemSpaceKey(node, $event)\">\n <!-- 2. Ce div prend tout l'espace et intercepte tous les clics de la ligne. `ps-0.5`: the checkbox of a list option sits\n 2px further in than a tree's (the option has its own padding), so the checkboxes of the two facets, and the select-all of\n the bar of a popover, are on the same vertical line. -->\n <div\n class=\"flex w-full cursor-pointer items-center gap-2 py-1.5 ps-0.5 text-base leading-6\"\n (click)=\"onTreeItemRowClick(node, $event)\"\n (mousedown)=\"$event.stopPropagation()\"\n (pointerdown)=\"$event.stopPropagation()\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n a role=\"treeitem\" admits no focusable descendant, and the click must reach\n the row handler above. Selection is announced by the <li>'s aria-selected. -->\n <!-- --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. Set as a custom property rather than a utility\n class: the CVA already sets gap-(--spacing-xs), and two same-specificity\n classes would be resolved by stylesheet order, not by us. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"treeItem.selected() || node.$selectedVisually || false\"\n [disabled]=\"node.disabled\"\n [class]=\"\n cn(\n 'pointer-events-none',\n (treeItem.selected() || node.$selectedVisually) &&\n 'selected-icon'\n )\n \" />\n </span>\n }\n <!-- Depth indent sits on the chevron, after the checkbox: the checkboxes of every\n level stay flush left (aligned with a flat list's), and since this adds no extra\n flex child, the step between consecutive levels is exactly one indent. -->\n @let depthIndent =\n \"calc(var(--agg-tree-indent, 0.5rem) * \" +\n (treeItem.level() - 1) +\n \")\";\n @if (node.hasChildren) {\n <!-- Expand/collapse affordance for the mouse only, hence a <span> and not a <button>:\n the keyboard already expands with ArrowRight / ArrowLeft, handled by ngTreeItem, and\n a role=\"treeitem\" admits no focusable descendant — so this was a <button> nobody\n could reach, already carrying aria-hidden and tabindex=\"-1\" to say so. Every audit\n reads it as a button with no accessible name (RGAA 7.1, 11.9). Same shape as the leaf\n branch below. -->\n <span\n aria-hidden=\"true\"\n (click)=\"toggleTreeNode(treeItem, $event)\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex cursor-pointer items-center justify-center\">\n <ChevronRightIcon\n [class]=\"\n cn('expand-icon shrink-0', node.$opened && 'rotate-90')\n \" />\n <FolderIcon class=\"shrink-0\" />\n </span>\n } @else {\n <span\n aria-hidden=\"true\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex items-center justify-center\">\n <!-- Reserves the chevron's own width (`.expand-icon` is a styling hook with\n no rule of its own), so a leaf lines up with its parent nodes. -->\n <span class=\"expand-icon w-4 shrink-0\"></span>\n <FileIcon class=\"size-4 shrink-0\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <!-- Cut off, the label shows whole in a tooltip; a label that fits needs none. -->\n <span\n class=\"grow truncate select-none\"\n truncated\n #cutCustom=\"truncated\"\n [tooltip]=\"label\"\n [tooltipDisabled]=\"!cutCustom.isTruncated()\"\n (click)=\"onTreeItemTextClick(node, $event)\">\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: node,\n field: aggregation()?.column,\n selected: treeItem.selected() ?? false,\n count: node.count,\n name: label,\n searchText: searchText(),\n level: node.$level ?? 0,\n expanded: node.$opened ?? false,\n hasChildren: node.hasChildren ?? false\n }\" />\n </span>\n } @else {\n <!-- ce span est essentiel pour le style -->\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <!-- Cut off, the label shows whole in a tooltip (`truncated` measures it); a label that fits has none. -->\n <span\n [class]=\"\n cn(\n 'grow truncate select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n truncated\n #cut=\"truncated\"\n [tooltip]=\"label\"\n [tooltipDisabled]=\"!cut.isTruncated()\"\n (click)=\"onTreeItemTextClick(node, $event)\">\n @for (\n chunk of label | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && node.count > 0) {\n <span class=\"ml-auto px-1 text-sm\" aria-hidden=\"true\">{{\n node.count\n }}</span>\n }\n }\n @let description = nodeDescription(node);\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the treeitem's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(node.$path)\">{{ description }}</span>\n }\n </div>\n </li>\n @if (node.hasChildren) {\n <!-- No indentation here: it would shift the checkbox too. Depth is applied\n inside the row, after the checkbox, so every checkbox stays flush left. -->\n <!-- The <ul> sits INSIDE the deferred content, not around it: `ngTreeItemGroup` only\n instantiates its view when the node opens, so a <ul> declared outside stayed in the DOM,\n empty, for every collapsed node — a structural tag with nothing to structure (RGAA 8.9).\n The @if cannot move onto the <ul> either: the directive registers itself with its tree\n item on init and has to exist for as long as the item does. -->\n <ng-template\n ngTreeItemGroup\n [ownedBy]=\"treeItem\"\n #group=\"ngTreeItemGroup\">\n <ul role=\"group\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: node.items, parent: group }\" />\n </ul>\n </ng-template>\n }\n }\n</ng-template>\n",
27853
28466
  dependencies: [
27854
28467
  {
27855
28468
  kind: "directive",
@@ -28069,7 +28682,7 @@ i0.ɵɵngDeclareClassMetadata({
28069
28682
  AggregationLoadMoreSkeletonComponent
28070
28683
  ],
28071
28684
  host: { "[class]": "cn(\"flex h-[inherit] max-h-[inherit] flex-col\", class(), (isMissing() || (hideWhenEmpty() && isHidden())) && \"hidden\")" },
28072
- template: "@if (!aggregation()?.isTree) {\n <div role=\"alert\" class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregationTree component does not support list aggregations. Please use\n the &lt;Aggregation /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [headless]=\"headless()\"\n [selectedCount]=\"selected().length\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [loading]=\"loadingMore()\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div class=\"min-h-0 w-full flex-1 scrollbar-thin overflow-auto pb-3\">\n <ul\n ngTree\n #tree=\"ngTree\"\n [multi]=\"true\"\n [(value)]=\"selected\"\n (valueChange)=\"onSelectionChange($event)\"\n /* Translated like the flat list's own label (aggregation-list.html): this was the raw\n `display`, so the accessible name of a whole facet came out in the source language.\n The pipes apply to the whole nullish chain, `|` being the loosest operator Angular\n parses — no parentheses needed. */\n [attr.aria-label]=\"aggregation()?.display ?? name() | syslang | transloco\"\n class=\"basic-tree m-1\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: nodes(), parent: tree }\" />\n </ul>\n @if (loadingMore() && searchedItems().length === 0) {\n <AggregationLoadMoreSkeleton variant=\"tree\" [showCount]=\"showCount()\" />\n }\n </div>\n</aggregation-panel>\n\n<ng-template #treeNodes let-nodes=\"nodes\" let-parent=\"parent\">\n @for (node of nodes; track node.$path) {\n @let label = node.display ?? node.value ?? \"\";\n <li\n ngTreeItem\n #treeItem=\"ngTreeItem\"\n [parent]=\"parent\"\n [value]=\"node.$path\"\n [label]=\"label\"\n [disabled]=\"node.disabled\"\n [(expanded)]=\"node.$opened\"\n (expandedChange)=\"onExpandedChange(node, $event)\"\n [attr.aria-label]=\"label\"\n [attr.aria-describedby]=\"\n nodeDescription(node) ? descriptionId(node.$path) : null\n \"\n [class]=\"cn('m-0 block w-full p-0', itemStateClass)\"\n (keydown.enter)=\"onTreeItemEnterKey(node, $event)\"\n (keydown.space)=\"onTreeItemSpaceKey(node, $event)\">\n <!-- 2. Ce div prend tout l'espace et intercepte tous les clics de la ligne. `ps-0.5`: the checkbox of a list option sits\n 2px further in than a tree's (the option has its own padding), so the checkboxes of the two facets, and the select-all of\n the bar of a popover, are on the same vertical line. -->\n <div\n class=\"flex w-full cursor-pointer items-center gap-2 py-0.5 ps-0.5 text-sm leading-6\"\n (click)=\"onTreeItemRowClick(node, $event)\"\n (mousedown)=\"$event.stopPropagation()\"\n (pointerdown)=\"$event.stopPropagation()\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n a role=\"treeitem\" admits no focusable descendant, and the click must reach\n the row handler above. Selection is announced by the <li>'s aria-selected. -->\n <!-- --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. Set as a custom property rather than a utility\n class: the CVA already sets gap-(--spacing-xs), and two same-specificity\n classes would be resolved by stylesheet order, not by us. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"treeItem.selected() || node.$selectedVisually || false\"\n [disabled]=\"node.disabled\"\n [class]=\"\n cn(\n 'pointer-events-none',\n (treeItem.selected() || node.$selectedVisually) &&\n 'selected-icon'\n )\n \" />\n </span>\n }\n <!-- Depth indent sits on the chevron, after the checkbox: the checkboxes of every\n level stay flush left (aligned with a flat list's), and since this adds no extra\n flex child, the step between consecutive levels is exactly one indent. -->\n @let depthIndent =\n \"calc(var(--agg-tree-indent, 0.5rem) * \" +\n (treeItem.level() - 1) +\n \")\";\n @if (node.hasChildren) {\n <!-- Expand/collapse affordance for the mouse only, hence a <span> and not a <button>:\n the keyboard already expands with ArrowRight / ArrowLeft, handled by ngTreeItem, and\n a role=\"treeitem\" admits no focusable descendant — so this was a <button> nobody\n could reach, already carrying aria-hidden and tabindex=\"-1\" to say so. Every audit\n reads it as a button with no accessible name (RGAA 7.1, 11.9). Same shape as the leaf\n branch below. -->\n <span\n aria-hidden=\"true\"\n (click)=\"toggleTreeNode(treeItem, $event)\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex cursor-pointer items-center justify-center\">\n <ChevronRightIcon\n [class]=\"\n cn('expand-icon shrink-0', node.$opened && 'rotate-90')\n \" />\n <FolderIcon class=\"shrink-0\" />\n </span>\n } @else {\n <span\n aria-hidden=\"true\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex items-center justify-center\">\n <!-- Reserves the chevron's own width (`.expand-icon` is a styling hook with\n no rule of its own), so a leaf lines up with its parent nodes. -->\n <span class=\"expand-icon w-4 shrink-0\"></span>\n <FileIcon class=\"size-4 shrink-0\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <!-- Cut off, the label shows whole in a tooltip; a label that fits needs none. -->\n <span\n class=\"grow truncate select-none\"\n truncated\n #cutCustom=\"truncated\"\n [tooltip]=\"label\"\n [tooltipDisabled]=\"!cutCustom.isTruncated()\"\n (click)=\"onTreeItemTextClick(node, $event)\">\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: node,\n field: aggregation()?.column,\n selected: treeItem.selected() ?? false,\n count: node.count,\n name: label,\n searchText: searchText(),\n level: node.$level ?? 0,\n expanded: node.$opened ?? false,\n hasChildren: node.hasChildren ?? false\n }\" />\n </span>\n } @else {\n <!-- ce span est essentiel pour le style -->\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <!-- Cut off, the label shows whole in a tooltip (`truncated` measures it); a label that fits has none. -->\n <span\n [class]=\"\n cn(\n 'grow truncate select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n truncated\n #cut=\"truncated\"\n [tooltip]=\"label\"\n [tooltipDisabled]=\"!cut.isTruncated()\"\n (click)=\"onTreeItemTextClick(node, $event)\">\n @for (\n chunk of label | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && node.count > 0) {\n <span class=\"ml-auto px-1 text-xs\" aria-hidden=\"true\">{{\n node.count\n }}</span>\n }\n }\n @let description = nodeDescription(node);\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the treeitem's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(node.$path)\">{{ description }}</span>\n }\n </div>\n </li>\n @if (node.hasChildren) {\n <!-- No indentation here: it would shift the checkbox too. Depth is applied\n inside the row, after the checkbox, so every checkbox stays flush left. -->\n <!-- The <ul> sits INSIDE the deferred content, not around it: `ngTreeItemGroup` only\n instantiates its view when the node opens, so a <ul> declared outside stayed in the DOM,\n empty, for every collapsed node — a structural tag with nothing to structure (RGAA 8.9).\n The @if cannot move onto the <ul> either: the directive registers itself with its tree\n item on init and has to exist for as long as the item does. -->\n <ng-template\n ngTreeItemGroup\n [ownedBy]=\"treeItem\"\n #group=\"ngTreeItemGroup\">\n <ul role=\"group\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: node.items, parent: group }\" />\n </ul>\n </ng-template>\n }\n }\n</ng-template>\n"
28685
+ template: "@if (!aggregation()?.isTree) {\n <div role=\"alert\" class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregationTree component does not support list aggregations. Please use\n the &lt;Aggregation /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [headless]=\"headless()\"\n [selectedCount]=\"selected().length\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [loading]=\"loadingMore()\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div class=\"min-h-0 w-full flex-1 scrollbar-thin overflow-auto pb-3\">\n <ul\n ngTree\n #tree=\"ngTree\"\n [multi]=\"true\"\n [(value)]=\"selected\"\n (valueChange)=\"onSelectionChange($event)\"\n /* Translated like the flat list's own label (aggregation-list.html): this was the raw\n `display`, so the accessible name of a whole facet came out in the source language.\n The pipes apply to the whole nullish chain, `|` being the loosest operator Angular\n parses — no parentheses needed. */\n [attr.aria-label]=\"aggregation()?.display ?? name() | syslang | transloco\"\n class=\"basic-tree m-1\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: nodes(), parent: tree }\" />\n </ul>\n @if (loadingMore() && searchedItems().length === 0) {\n <AggregationLoadMoreSkeleton variant=\"tree\" [showCount]=\"showCount()\" />\n }\n </div>\n</aggregation-panel>\n\n<ng-template #treeNodes let-nodes=\"nodes\" let-parent=\"parent\">\n @for (node of nodes; track node.$path) {\n @let label = node.display ?? node.value ?? \"\";\n <li\n ngTreeItem\n #treeItem=\"ngTreeItem\"\n [parent]=\"parent\"\n [value]=\"node.$path\"\n [label]=\"label\"\n [disabled]=\"node.disabled\"\n [(expanded)]=\"node.$opened\"\n (expandedChange)=\"onExpandedChange(node, $event)\"\n [attr.aria-label]=\"label\"\n [attr.aria-describedby]=\"\n nodeDescription(node) ? descriptionId(node.$path) : null\n \"\n [class]=\"cn('m-0 block w-full p-0', itemStateClass)\"\n (keydown.enter)=\"onTreeItemEnterKey(node, $event)\"\n (keydown.space)=\"onTreeItemSpaceKey(node, $event)\">\n <!-- 2. Ce div prend tout l'espace et intercepte tous les clics de la ligne. `ps-0.5`: the checkbox of a list option sits\n 2px further in than a tree's (the option has its own padding), so the checkboxes of the two facets, and the select-all of\n the bar of a popover, are on the same vertical line. -->\n <div\n class=\"flex w-full cursor-pointer items-center gap-2 py-1.5 ps-0.5 text-base leading-6\"\n (click)=\"onTreeItemRowClick(node, $event)\"\n (mousedown)=\"$event.stopPropagation()\"\n (pointerdown)=\"$event.stopPropagation()\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n a role=\"treeitem\" admits no focusable descendant, and the click must reach\n the row handler above. Selection is announced by the <li>'s aria-selected. -->\n <!-- --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. Set as a custom property rather than a utility\n class: the CVA already sets gap-(--spacing-xs), and two same-specificity\n classes would be resolved by stylesheet order, not by us. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"treeItem.selected() || node.$selectedVisually || false\"\n [disabled]=\"node.disabled\"\n [class]=\"\n cn(\n 'pointer-events-none',\n (treeItem.selected() || node.$selectedVisually) &&\n 'selected-icon'\n )\n \" />\n </span>\n }\n <!-- Depth indent sits on the chevron, after the checkbox: the checkboxes of every\n level stay flush left (aligned with a flat list's), and since this adds no extra\n flex child, the step between consecutive levels is exactly one indent. -->\n @let depthIndent =\n \"calc(var(--agg-tree-indent, 0.5rem) * \" +\n (treeItem.level() - 1) +\n \")\";\n @if (node.hasChildren) {\n <!-- Expand/collapse affordance for the mouse only, hence a <span> and not a <button>:\n the keyboard already expands with ArrowRight / ArrowLeft, handled by ngTreeItem, and\n a role=\"treeitem\" admits no focusable descendant — so this was a <button> nobody\n could reach, already carrying aria-hidden and tabindex=\"-1\" to say so. Every audit\n reads it as a button with no accessible name (RGAA 7.1, 11.9). Same shape as the leaf\n branch below. -->\n <span\n aria-hidden=\"true\"\n (click)=\"toggleTreeNode(treeItem, $event)\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex cursor-pointer items-center justify-center\">\n <ChevronRightIcon\n [class]=\"\n cn('expand-icon shrink-0', node.$opened && 'rotate-90')\n \" />\n <FolderIcon class=\"shrink-0\" />\n </span>\n } @else {\n <span\n aria-hidden=\"true\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex items-center justify-center\">\n <!-- Reserves the chevron's own width (`.expand-icon` is a styling hook with\n no rule of its own), so a leaf lines up with its parent nodes. -->\n <span class=\"expand-icon w-4 shrink-0\"></span>\n <FileIcon class=\"size-4 shrink-0\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <!-- Cut off, the label shows whole in a tooltip; a label that fits needs none. -->\n <span\n class=\"grow truncate select-none\"\n truncated\n #cutCustom=\"truncated\"\n [tooltip]=\"label\"\n [tooltipDisabled]=\"!cutCustom.isTruncated()\"\n (click)=\"onTreeItemTextClick(node, $event)\">\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: node,\n field: aggregation()?.column,\n selected: treeItem.selected() ?? false,\n count: node.count,\n name: label,\n searchText: searchText(),\n level: node.$level ?? 0,\n expanded: node.$opened ?? false,\n hasChildren: node.hasChildren ?? false\n }\" />\n </span>\n } @else {\n <!-- ce span est essentiel pour le style -->\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <!-- Cut off, the label shows whole in a tooltip (`truncated` measures it); a label that fits has none. -->\n <span\n [class]=\"\n cn(\n 'grow truncate select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n truncated\n #cut=\"truncated\"\n [tooltip]=\"label\"\n [tooltipDisabled]=\"!cut.isTruncated()\"\n (click)=\"onTreeItemTextClick(node, $event)\">\n @for (\n chunk of label | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && node.count > 0) {\n <span class=\"ml-auto px-1 text-sm\" aria-hidden=\"true\">{{\n node.count\n }}</span>\n }\n }\n @let description = nodeDescription(node);\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the treeitem's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(node.$path)\">{{ description }}</span>\n }\n </div>\n </li>\n @if (node.hasChildren) {\n <!-- No indentation here: it would shift the checkbox too. Depth is applied\n inside the row, after the checkbox, so every checkbox stays flush left. -->\n <!-- The <ul> sits INSIDE the deferred content, not around it: `ngTreeItemGroup` only\n instantiates its view when the node opens, so a <ul> declared outside stayed in the DOM,\n empty, for every collapsed node — a structural tag with nothing to structure (RGAA 8.9).\n The @if cannot move onto the <ul> either: the directive registers itself with its tree\n item on init and has to exist for as long as the item does. -->\n <ng-template\n ngTreeItemGroup\n [ownedBy]=\"treeItem\"\n #group=\"ngTreeItemGroup\">\n <ul role=\"group\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: node.items, parent: group }\" />\n </ul>\n </ng-template>\n }\n }\n</ng-template>\n"
28073
28686
  }]
28074
28687
  }],
28075
28688
  ctorParameters: () => [],
@@ -28263,6 +28876,7 @@ i0.ɵɵngDeclareClassMetadata({
28263
28876
  }]
28264
28877
  }
28265
28878
  });
28879
+ const ROW_ESTIMATE = 36;
28266
28880
  let nextDescriptionScope = 0;
28267
28881
  var AggregationListComponent = class AggregationListComponent {
28268
28882
  cn = cn;
@@ -28376,7 +28990,7 @@ var AggregationListComponent = class AggregationListComponent {
28376
28990
  cdr = inject(ChangeDetectorRef);
28377
28991
  virtualizer = injectVirtualizer(() => ({
28378
28992
  count: this.items().length,
28379
- estimateSize: () => 28,
28993
+ estimateSize: () => ROW_ESTIMATE,
28380
28994
  gap: 2,
28381
28995
  scrollElement: this.scrollElement()
28382
28996
  }));
@@ -28781,7 +29395,7 @@ var AggregationListComponent = class AggregationListComponent {
28781
29395
  }
28782
29396
  ],
28783
29397
  ngImport: i0,
28784
- template: "@if (aggregation()?.isTree) {\n <div class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregation component no longer supports tree aggregations. Please use\n the &lt;AggregationTree /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [headless]=\"headless()\"\n [selectedCount]=\"selectedValues().length\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [loading]=\"loadingMore()\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div\n #scrollElement\n class=\"min-h-0 w-full flex-1 scrollbar-thin overflow-auto px-1 pt-1 pb-3\">\n <div\n ngListbox\n multi\n selectionMode=\"explicit\"\n [value]=\"selectedValues()\"\n (valueChange)=\"syncListboxValue($event)\"\n [wrap]=\"false\"\n [softDisabled]=\"false\"\n class=\"relative w-full\"\n [style.height]=\"virtualizer.getTotalSize() + 'px'\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n [attr.aria-label]=\"aggregation()?.display | syslang | transloco\">\n @for (vItem of virtualizer.getVirtualItems(); track vItem.index) {\n @let item = items()[vItem.index];\n @let description = rowDescription(item);\n <div\n #row\n ngOption\n [attr.aria-describedby]=\"\n description ? descriptionId(vItem.index) : null\n \"\n [attr.data-index]=\"vItem.index\"\n [value]=\"item.value\"\n [disabled]=\"item.count === 0\"\n [class]=\"cn('absolute w-full', menuItemBaseClass)\"\n (keydown.enter)=\"onOptionEnterKey(item, $event)\"\n (keydown.space)=\"\n onListItemRowClick(item, $event); $event.preventDefault()\n \"\n [style.transform]=\"'translateY(' + vItem.start + 'px)'\"\n role=\"option\">\n <ng-container\n [ngTemplateOutlet]=\"listItemTpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: aggregation()?.column\n }\">\n </ng-container>\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the option's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(vItem.index)\">{{\n description\n }}</span>\n }\n </div>\n }\n </div>\n @if (loadingMore() && searchedItems().length === 0) {\n <AggregationLoadMoreSkeleton [showCount]=\"showCount()\" />\n }\n </div>\n</aggregation-panel>\n\n<ng-template #listItemTpl let-item let-field=\"field\">\n <a\n [attr.aria-label]=\"listItemName(item) | syslang\"\n [class]=\"\n cn(\n '-m-1.5 flex min-w-0 grow items-center gap-2 leading-6',\n item.count === 0 && 'pointer-events-none text-(--font-neutral-muted)'\n )\n \"\n (click)=\"onListItemRowClick(item, $event)\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n keyboard navigation belongs to ngListbox/ngOption (roving tabindex), the\n selection is already exposed by role=\"option\" + aria-selected, and the click\n must reach the <a> above. Same treatment as the tree.\n --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"item.$selected ?? false\"\n [disabled]=\"item.count === 0\"\n [class]=\"'pointer-events-none'\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: field,\n selected: item.$selected ?? false,\n count: item.count,\n name: (listItemName(item) | syslang) ?? '',\n searchText: searchText()\n }\" />\n } @else {\n @let icon = $any(item).icon;\n @if (icon) {\n <FaIcon\n [faClass]=\"icon\"\n class=\"shrink-0 self-center justify-self-center\" />\n }\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <!-- Cut off, the label shows whole in a tooltip (`truncated` measures it); a label that fits has none. -->\n <span\n [class]=\"\n cn(\n 'line-clamp-1 cursor-pointer break-all text-ellipsis select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n truncated\n #cut=\"truncated\"\n [tooltip]=\"listItemName(item) | syslang\"\n [tooltipDisabled]=\"!cut.isTruncated()\"\n (click)=\"onListItemTextClick(item, $event)\">\n @for (\n chunk of (listItemName(item) | syslang) ?? \"\"\n | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && item.count > 0) {\n <span class=\"ml-auto px-1 text-xs empty:hidden\" aria-hidden=\"true\">{{\n item.count\n }}</span>\n }\n }\n </a>\n</ng-template>\n",
29398
+ template: "@if (aggregation()?.isTree) {\n <div class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregation component no longer supports tree aggregations. Please use\n the &lt;AggregationTree /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [headless]=\"headless()\"\n [selectedCount]=\"selectedValues().length\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [loading]=\"loadingMore()\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div\n #scrollElement\n class=\"min-h-0 w-full flex-1 scrollbar-thin overflow-auto px-1 pt-1 pb-3\">\n <div\n ngListbox\n multi\n selectionMode=\"explicit\"\n [value]=\"selectedValues()\"\n (valueChange)=\"syncListboxValue($event)\"\n [wrap]=\"false\"\n [softDisabled]=\"false\"\n class=\"relative w-full\"\n [style.height]=\"virtualizer.getTotalSize() + 'px'\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n [attr.aria-label]=\"aggregation()?.display | syslang | transloco\">\n @for (vItem of virtualizer.getVirtualItems(); track vItem.index) {\n @let item = items()[vItem.index];\n @let description = rowDescription(item);\n <div\n #row\n ngOption\n [attr.aria-describedby]=\"\n description ? descriptionId(vItem.index) : null\n \"\n [attr.data-index]=\"vItem.index\"\n [value]=\"item.value\"\n [disabled]=\"item.count === 0\"\n [class]=\"cn('absolute w-full', menuItemBaseClass)\"\n (keydown.enter)=\"onOptionEnterKey(item, $event)\"\n (keydown.space)=\"\n onListItemRowClick(item, $event); $event.preventDefault()\n \"\n [style.transform]=\"'translateY(' + vItem.start + 'px)'\"\n role=\"option\">\n <ng-container\n [ngTemplateOutlet]=\"listItemTpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: aggregation()?.column\n }\">\n </ng-container>\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the option's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(vItem.index)\">{{\n description\n }}</span>\n }\n </div>\n }\n </div>\n @if (loadingMore() && searchedItems().length === 0) {\n <AggregationLoadMoreSkeleton [showCount]=\"showCount()\" />\n }\n </div>\n</aggregation-panel>\n\n<ng-template #listItemTpl let-item let-field=\"field\">\n <a\n [attr.aria-label]=\"listItemName(item) | syslang\"\n [class]=\"\n cn(\n '-m-1.5 flex min-h-8 min-w-0 grow items-center gap-2 text-base leading-6',\n item.count === 0 && 'pointer-events-none text-(--font-neutral-muted)'\n )\n \"\n (click)=\"onListItemRowClick(item, $event)\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n keyboard navigation belongs to ngListbox/ngOption (roving tabindex), the\n selection is already exposed by role=\"option\" + aria-selected, and the click\n must reach the <a> above. Same treatment as the tree.\n --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"item.$selected ?? false\"\n [disabled]=\"item.count === 0\"\n [class]=\"'pointer-events-none'\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: field,\n selected: item.$selected ?? false,\n count: item.count,\n name: (listItemName(item) | syslang) ?? '',\n searchText: searchText()\n }\" />\n } @else {\n @let icon = $any(item).icon;\n @if (icon) {\n <FaIcon\n [faClass]=\"icon\"\n class=\"shrink-0 self-center justify-self-center\" />\n }\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <!-- Cut off, the label shows whole in a tooltip (`truncated` measures it); a label that fits has none. -->\n <span\n [class]=\"\n cn(\n 'line-clamp-1 cursor-pointer break-all text-ellipsis select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n truncated\n #cut=\"truncated\"\n [tooltip]=\"listItemName(item) | syslang\"\n [tooltipDisabled]=\"!cut.isTruncated()\"\n (click)=\"onListItemTextClick(item, $event)\">\n @for (\n chunk of (listItemName(item) | syslang) ?? \"\"\n | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && item.count > 0) {\n <span class=\"ml-auto px-1 text-sm empty:hidden\" aria-hidden=\"true\">{{\n item.count\n }}</span>\n }\n }\n </a>\n</ng-template>\n",
28785
29399
  dependencies: [
28786
29400
  {
28787
29401
  kind: "directive",
@@ -28970,7 +29584,7 @@ i0.ɵɵngDeclareClassMetadata({
28970
29584
  "[class]": "cn(\"flex h-[inherit] max-h-[inherit] flex-col\", class(), (isMissing() || (hideWhenEmpty() && isEmpty())) && \"hidden\")",
28971
29585
  "(keydown.enter)": "onHostEnterKey($event)"
28972
29586
  },
28973
- template: "@if (aggregation()?.isTree) {\n <div class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregation component no longer supports tree aggregations. Please use\n the &lt;AggregationTree /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [headless]=\"headless()\"\n [selectedCount]=\"selectedValues().length\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [loading]=\"loadingMore()\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div\n #scrollElement\n class=\"min-h-0 w-full flex-1 scrollbar-thin overflow-auto px-1 pt-1 pb-3\">\n <div\n ngListbox\n multi\n selectionMode=\"explicit\"\n [value]=\"selectedValues()\"\n (valueChange)=\"syncListboxValue($event)\"\n [wrap]=\"false\"\n [softDisabled]=\"false\"\n class=\"relative w-full\"\n [style.height]=\"virtualizer.getTotalSize() + 'px'\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n [attr.aria-label]=\"aggregation()?.display | syslang | transloco\">\n @for (vItem of virtualizer.getVirtualItems(); track vItem.index) {\n @let item = items()[vItem.index];\n @let description = rowDescription(item);\n <div\n #row\n ngOption\n [attr.aria-describedby]=\"\n description ? descriptionId(vItem.index) : null\n \"\n [attr.data-index]=\"vItem.index\"\n [value]=\"item.value\"\n [disabled]=\"item.count === 0\"\n [class]=\"cn('absolute w-full', menuItemBaseClass)\"\n (keydown.enter)=\"onOptionEnterKey(item, $event)\"\n (keydown.space)=\"\n onListItemRowClick(item, $event); $event.preventDefault()\n \"\n [style.transform]=\"'translateY(' + vItem.start + 'px)'\"\n role=\"option\">\n <ng-container\n [ngTemplateOutlet]=\"listItemTpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: aggregation()?.column\n }\">\n </ng-container>\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the option's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(vItem.index)\">{{\n description\n }}</span>\n }\n </div>\n }\n </div>\n @if (loadingMore() && searchedItems().length === 0) {\n <AggregationLoadMoreSkeleton [showCount]=\"showCount()\" />\n }\n </div>\n</aggregation-panel>\n\n<ng-template #listItemTpl let-item let-field=\"field\">\n <a\n [attr.aria-label]=\"listItemName(item) | syslang\"\n [class]=\"\n cn(\n '-m-1.5 flex min-w-0 grow items-center gap-2 leading-6',\n item.count === 0 && 'pointer-events-none text-(--font-neutral-muted)'\n )\n \"\n (click)=\"onListItemRowClick(item, $event)\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n keyboard navigation belongs to ngListbox/ngOption (roving tabindex), the\n selection is already exposed by role=\"option\" + aria-selected, and the click\n must reach the <a> above. Same treatment as the tree.\n --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"item.$selected ?? false\"\n [disabled]=\"item.count === 0\"\n [class]=\"'pointer-events-none'\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: field,\n selected: item.$selected ?? false,\n count: item.count,\n name: (listItemName(item) | syslang) ?? '',\n searchText: searchText()\n }\" />\n } @else {\n @let icon = $any(item).icon;\n @if (icon) {\n <FaIcon\n [faClass]=\"icon\"\n class=\"shrink-0 self-center justify-self-center\" />\n }\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <!-- Cut off, the label shows whole in a tooltip (`truncated` measures it); a label that fits has none. -->\n <span\n [class]=\"\n cn(\n 'line-clamp-1 cursor-pointer break-all text-ellipsis select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n truncated\n #cut=\"truncated\"\n [tooltip]=\"listItemName(item) | syslang\"\n [tooltipDisabled]=\"!cut.isTruncated()\"\n (click)=\"onListItemTextClick(item, $event)\">\n @for (\n chunk of (listItemName(item) | syslang) ?? \"\"\n | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && item.count > 0) {\n <span class=\"ml-auto px-1 text-xs empty:hidden\" aria-hidden=\"true\">{{\n item.count\n }}</span>\n }\n }\n </a>\n</ng-template>\n"
29587
+ template: "@if (aggregation()?.isTree) {\n <div class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregation component no longer supports tree aggregations. Please use\n the &lt;AggregationTree /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [headless]=\"headless()\"\n [selectedCount]=\"selectedValues().length\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [loading]=\"loadingMore()\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div\n #scrollElement\n class=\"min-h-0 w-full flex-1 scrollbar-thin overflow-auto px-1 pt-1 pb-3\">\n <div\n ngListbox\n multi\n selectionMode=\"explicit\"\n [value]=\"selectedValues()\"\n (valueChange)=\"syncListboxValue($event)\"\n [wrap]=\"false\"\n [softDisabled]=\"false\"\n class=\"relative w-full\"\n [style.height]=\"virtualizer.getTotalSize() + 'px'\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n [attr.aria-label]=\"aggregation()?.display | syslang | transloco\">\n @for (vItem of virtualizer.getVirtualItems(); track vItem.index) {\n @let item = items()[vItem.index];\n @let description = rowDescription(item);\n <div\n #row\n ngOption\n [attr.aria-describedby]=\"\n description ? descriptionId(vItem.index) : null\n \"\n [attr.data-index]=\"vItem.index\"\n [value]=\"item.value\"\n [disabled]=\"item.count === 0\"\n [class]=\"cn('absolute w-full', menuItemBaseClass)\"\n (keydown.enter)=\"onOptionEnterKey(item, $event)\"\n (keydown.space)=\"\n onListItemRowClick(item, $event); $event.preventDefault()\n \"\n [style.transform]=\"'translateY(' + vItem.start + 'px)'\"\n role=\"option\">\n <ng-container\n [ngTemplateOutlet]=\"listItemTpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: aggregation()?.column\n }\">\n </ng-container>\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the option's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(vItem.index)\">{{\n description\n }}</span>\n }\n </div>\n }\n </div>\n @if (loadingMore() && searchedItems().length === 0) {\n <AggregationLoadMoreSkeleton [showCount]=\"showCount()\" />\n }\n </div>\n</aggregation-panel>\n\n<ng-template #listItemTpl let-item let-field=\"field\">\n <a\n [attr.aria-label]=\"listItemName(item) | syslang\"\n [class]=\"\n cn(\n '-m-1.5 flex min-h-8 min-w-0 grow items-center gap-2 text-base leading-6',\n item.count === 0 && 'pointer-events-none text-(--font-neutral-muted)'\n )\n \"\n (click)=\"onListItemRowClick(item, $event)\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n keyboard navigation belongs to ngListbox/ngOption (roving tabindex), the\n selection is already exposed by role=\"option\" + aria-selected, and the click\n must reach the <a> above. Same treatment as the tree.\n --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"item.$selected ?? false\"\n [disabled]=\"item.count === 0\"\n [class]=\"'pointer-events-none'\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: field,\n selected: item.$selected ?? false,\n count: item.count,\n name: (listItemName(item) | syslang) ?? '',\n searchText: searchText()\n }\" />\n } @else {\n @let icon = $any(item).icon;\n @if (icon) {\n <FaIcon\n [faClass]=\"icon\"\n class=\"shrink-0 self-center justify-self-center\" />\n }\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <!-- Cut off, the label shows whole in a tooltip (`truncated` measures it); a label that fits has none. -->\n <span\n [class]=\"\n cn(\n 'line-clamp-1 cursor-pointer break-all text-ellipsis select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n truncated\n #cut=\"truncated\"\n [tooltip]=\"listItemName(item) | syslang\"\n [tooltipDisabled]=\"!cut.isTruncated()\"\n (click)=\"onListItemTextClick(item, $event)\">\n @for (\n chunk of (listItemName(item) | syslang) ?? \"\"\n | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && item.count > 0) {\n <span class=\"ml-auto px-1 text-sm empty:hidden\" aria-hidden=\"true\">{{\n item.count\n }}</span>\n }\n }\n </a>\n</ng-template>\n"
28974
29588
  }]
28975
29589
  }],
28976
29590
  ctorParameters: () => [],
@@ -33205,7 +33819,7 @@ var FiltersBarComponent = class FiltersBarComponent {
33205
33819
  class="shrink-0 size-9 bg-(--bg-error-base) text-(--font-neutral-white) hover:bg-(--bg-error-base-alt) focus-visible:bg-(--bg-error-base-alt)"
33206
33820
  [title]="'filters.clearAllFilters' | transloco"
33207
33821
  (click)="clearFilters()">
33208
- <trash-can-icon />
33822
+ <x-mark-icon />
33209
33823
  </button>
33210
33824
  }
33211
33825
 
@@ -33350,8 +33964,8 @@ var FiltersBarComponent = class FiltersBarComponent {
33350
33964
  },
33351
33965
  {
33352
33966
  kind: "component",
33353
- type: TrashCanIcon,
33354
- selector: "trash-can, trash-can-icon, TrashCan, TrashCanIcon",
33967
+ type: XMarkIcon,
33968
+ selector: "x-mark, x-mark-icon, XMark, XMarkIcon",
33355
33969
  inputs: ["class"]
33356
33970
  },
33357
33971
  {
@@ -33385,7 +33999,7 @@ i0.ɵɵngDeclareClassMetadata({
33385
33999
  OverflowItemDirective,
33386
34000
  OverflowStopDirective,
33387
34001
  TranslocoPipe,
33388
- TrashCanIcon,
34002
+ XMarkIcon,
33389
34003
  InboxIcon
33390
34004
  ],
33391
34005
  providers: [provideTranslocoScope("filters")],
@@ -33405,7 +34019,7 @@ i0.ɵɵngDeclareClassMetadata({
33405
34019
  class="shrink-0 size-9 bg-(--bg-error-base) text-(--font-neutral-white) hover:bg-(--bg-error-base-alt) focus-visible:bg-(--bg-error-base-alt)"
33406
34020
  [title]="'filters.clearAllFilters' | transloco"
33407
34021
  (click)="clearFilters()">
33408
- <trash-can-icon />
34022
+ <x-mark-icon />
33409
34023
  </button>
33410
34024
  }
33411
34025
 
@@ -33703,6 +34317,7 @@ var QuickFiltersListComponent = class QuickFiltersListComponent {
33703
34317
  transloco = inject(TranslocoService);
33704
34318
  host = inject(ElementRef);
33705
34319
  injector = inject(Injector);
34320
+ isTouch = inject(BreakpointObserverService).isTouch;
33706
34321
  deleteKeys = isApplePlatform() ? ["Delete", "Backspace"] : ["Delete"];
33707
34322
  quickFilters = computed(() => this.userSettingsStore.quickFilters() ?? [], ...ngDevMode ? [{ debugName: "quickFilters" }] : /* istanbul ignore next */ []);
33708
34323
  query = signal("", ...ngDevMode ? [{ debugName: "query" }] : /* istanbul ignore next */ []);
@@ -33715,6 +34330,7 @@ var QuickFiltersListComponent = class QuickFiltersListComponent {
33715
34330
  detailsCard = viewChild("detailsCard", ...ngDevMode ? [{ debugName: "detailsCard" }] : /* istanbul ignore next */ []);
33716
34331
  hideTimer;
33717
34332
  deleting = signal(false, ...ngDevMode ? [{ debugName: "deleting" }] : /* istanbul ignore next */ []);
34333
+ restoringFocus = false;
33718
34334
  constructor() {
33719
34335
  injectFloatingPanel({
33720
34336
  panel: () => this.detailsCard()?.nativeElement,
@@ -33743,6 +34359,26 @@ var QuickFiltersListComponent = class QuickFiltersListComponent {
33743
34359
  anchor
33744
34360
  });
33745
34361
  }
34362
+ onRowFocus(quickFilter, anchor) {
34363
+ if (!this.restoringFocus && !this.isTouch()) this.showDetails(quickFilter, anchor);
34364
+ }
34365
+ onCountEnter(quickFilter, anchor) {
34366
+ if (!this.isTouch()) this.showDetails(quickFilter, anchor);
34367
+ }
34368
+ toggleDetails(quickFilter, anchor, event) {
34369
+ event.stopPropagation();
34370
+ if (this.details()?.quickFilter === quickFilter) {
34371
+ this.cancelHideDetails();
34372
+ this.details.set(null);
34373
+ } else this.showDetails(quickFilter, anchor);
34374
+ }
34375
+ onDocumentPointerDown(event) {
34376
+ if (!this.details()) return;
34377
+ const target = event.target;
34378
+ if (this.detailsCard()?.nativeElement.contains(target) || target?.closest("[data-details-toggle]")) return;
34379
+ this.cancelHideDetails();
34380
+ this.details.set(null);
34381
+ }
33746
34382
  scheduleHideDetails() {
33747
34383
  this.cancelHideDetails();
33748
34384
  this.hideTimer = setTimeout(() => this.details.set(null), 100);
@@ -33769,7 +34405,7 @@ var QuickFiltersListComponent = class QuickFiltersListComponent {
33769
34405
  this.applied.emit(quickFilter);
33770
34406
  }
33771
34407
  async remove(quickFilter, index, event) {
33772
- event.stopPropagation();
34408
+ event?.stopPropagation();
33773
34409
  if (this.deleting()) return;
33774
34410
  this.deleting.set(true);
33775
34411
  this.cancelHideDetails();
@@ -33783,7 +34419,12 @@ var QuickFiltersListComponent = class QuickFiltersListComponent {
33783
34419
  this.deleted.emit(quickFilter);
33784
34420
  if (hadFocus) afterNextRender(() => {
33785
34421
  const rows = this.host.nativeElement.querySelectorAll("[role='option']");
33786
- (rows.length ? rows[Math.min(index, rows.length - 1)] : this.host.nativeElement).focus();
34422
+ this.restoringFocus = true;
34423
+ try {
34424
+ (rows.length ? rows[Math.min(index, rows.length - 1)] : this.host.nativeElement).focus();
34425
+ } finally {
34426
+ this.restoringFocus = false;
34427
+ }
33787
34428
  }, { injector: this.injector });
33788
34429
  } finally {
33789
34430
  this.deleting.set(false);
@@ -33832,7 +34473,10 @@ var QuickFiltersListComponent = class QuickFiltersListComponent {
33832
34473
  },
33833
34474
  host: {
33834
34475
  attributes: { "tabindex": "-1" },
33835
- listeners: { "keydown.escape": "onEscape($event)" },
34476
+ listeners: {
34477
+ "keydown.escape": "onEscape($event)",
34478
+ "document:pointerdown": "onDocumentPointerDown($event)"
34479
+ },
33836
34480
  classAttribute: "flex min-h-0 flex-col gap-(--spacing-xs) outline-none"
33837
34481
  },
33838
34482
  providers: [provideTranslocoScope("quick-filters")],
@@ -33867,10 +34511,17 @@ var QuickFiltersListComponent = class QuickFiltersListComponent {
33867
34511
  [value]="picked()"
33868
34512
  (valueChange)="pick($event)">
33869
34513
  @for (quickFilter of filtered(); track quickFilter.id; let index = $index) {
34514
+ <!-- Touch: drag the row to the left to delete it, which replaces the cross (hidden there, see the row's class). -->
34515
+ <SwipeAction endEnabled [disabled]="!deletable()" endTone="error" (swipedEnd)="remove(quickFilter, index)">
34516
+ <span swipeActionEnd class="flex items-center gap-2">
34517
+ {{ "quickFilters.swipeDelete" | transloco }}
34518
+ <x-mark-icon class="size-4" />
34519
+ </span>
33870
34520
  <ListItem
33871
34521
  [value]="quickFilter"
33872
34522
  [label]="quickFilter.name"
33873
- (focusin)="showDetails(quickFilter, count)"
34523
+ class="pointer-coarse:[&_.list-item-icon-right]:hidden!"
34524
+ (focusin)="onRowFocus(quickFilter, count)"
33874
34525
  (focusout)="scheduleHideDetails()">
33875
34526
  <ListItemLeft>
33876
34527
  <ListItemIcon><quick-filter-icon /></ListItemIcon>
@@ -33883,11 +34534,20 @@ var QuickFiltersListComponent = class QuickFiltersListComponent {
33883
34534
  #count
33884
34535
  data-count
33885
34536
  class="list-item-subtext block w-fit! decoration-dotted underline-offset-2 hover:underline"
33886
- (mouseenter)="showDetails(quickFilter, count)"
34537
+ (mouseenter)="onCountEnter(quickFilter, count)"
33887
34538
  (mouseleave)="scheduleHideDetails()">
33888
34539
  {{ (quickFilter.filters.length === 1 ? "quickFilters.countOne" : "quickFilters.countOther") | transloco: { count: quickFilter.filters.length } }}
33889
34540
  </span>
33890
34541
  </ListItemText>
34542
+ <!-- Touch only: there is no hover to show what the quick filter holds, and a tap on the row applies it. This opens the card
34543
+ instead. Not a button (a listbox option holds no control, see ListItemAction): the keyboard has the focus of the row. -->
34544
+ <span
34545
+ data-details-toggle
34546
+ aria-hidden="true"
34547
+ class="hidden size-8 shrink-0 cursor-pointer items-center justify-center rounded-(--radius-xs) text-(--font-neutral-muted) pointer-coarse:inline-flex active:bg-(--bg-neutral-base)"
34548
+ (click)="toggleDetails(quickFilter, count, $event)">
34549
+ <info-circle-icon class="size-5" />
34550
+ </span>
33891
34551
  </ListItemLeft>
33892
34552
  @if (deletable()) {
33893
34553
  <ListItemAction
@@ -33900,6 +34560,7 @@ var QuickFiltersListComponent = class QuickFiltersListComponent {
33900
34560
  </ListItemAction>
33901
34561
  }
33902
34562
  </ListItem>
34563
+ </SwipeAction>
33903
34564
  }
33904
34565
  </List>
33905
34566
 
@@ -33949,6 +34610,12 @@ var QuickFiltersListComponent = class QuickFiltersListComponent {
33949
34610
  selector: "fa-icon, FaIcon",
33950
34611
  inputs: ["faClass", "class"]
33951
34612
  },
34613
+ {
34614
+ kind: "component",
34615
+ type: InfoCircleIcon,
34616
+ selector: "info-circle, info-circle-icon, InfoCircle, InfoCircleIcon",
34617
+ inputs: ["class"]
34618
+ },
33952
34619
  {
33953
34620
  kind: "directive",
33954
34621
  type: ListComponent,
@@ -34015,6 +34682,22 @@ var QuickFiltersListComponent = class QuickFiltersListComponent {
34015
34682
  type: SearchInputComponent,
34016
34683
  selector: "SearchInput:not([suggestions]), search-input:not([suggestions]), searchinput:not([suggestions])"
34017
34684
  },
34685
+ {
34686
+ kind: "component",
34687
+ type: SwipeActionComponent,
34688
+ selector: "swipe-action, SwipeAction",
34689
+ inputs: [
34690
+ "class",
34691
+ "contentClass",
34692
+ "startEnabled",
34693
+ "endEnabled",
34694
+ "startTone",
34695
+ "endTone",
34696
+ "disabled",
34697
+ "threshold"
34698
+ ],
34699
+ outputs: ["swipedStart", "swipedEnd"]
34700
+ },
34018
34701
  {
34019
34702
  kind: "directive",
34020
34703
  type: TooltipDirective,
@@ -34074,6 +34757,7 @@ i0.ɵɵngDeclareClassMetadata({
34074
34757
  imports: [
34075
34758
  TranslocoPipe,
34076
34759
  FaIconComponent,
34760
+ InfoCircleIcon,
34077
34761
  ListComponent,
34078
34762
  ListItemComponent,
34079
34763
  ListItemActionComponent,
@@ -34082,6 +34766,7 @@ i0.ɵɵngDeclareClassMetadata({
34082
34766
  ListItemTextComponent,
34083
34767
  QuickFilterIcon,
34084
34768
  SearchInputComponent,
34769
+ SwipeActionComponent,
34085
34770
  TooltipDirective,
34086
34771
  TruncatedDirective,
34087
34772
  XMarkIcon
@@ -34090,7 +34775,8 @@ i0.ɵɵngDeclareClassMetadata({
34090
34775
  host: {
34091
34776
  class: "flex min-h-0 flex-col gap-(--spacing-xs) outline-none",
34092
34777
  tabindex: "-1",
34093
- "(keydown.escape)": "onEscape($event)"
34778
+ "(keydown.escape)": "onEscape($event)",
34779
+ "(document:pointerdown)": "onDocumentPointerDown($event)"
34094
34780
  },
34095
34781
  template: `
34096
34782
  @if (searchable() && quickFilters().length > 0) {
@@ -34115,10 +34801,17 @@ i0.ɵɵngDeclareClassMetadata({
34115
34801
  [value]="picked()"
34116
34802
  (valueChange)="pick($event)">
34117
34803
  @for (quickFilter of filtered(); track quickFilter.id; let index = $index) {
34804
+ <!-- Touch: drag the row to the left to delete it, which replaces the cross (hidden there, see the row's class). -->
34805
+ <SwipeAction endEnabled [disabled]="!deletable()" endTone="error" (swipedEnd)="remove(quickFilter, index)">
34806
+ <span swipeActionEnd class="flex items-center gap-2">
34807
+ {{ "quickFilters.swipeDelete" | transloco }}
34808
+ <x-mark-icon class="size-4" />
34809
+ </span>
34118
34810
  <ListItem
34119
34811
  [value]="quickFilter"
34120
34812
  [label]="quickFilter.name"
34121
- (focusin)="showDetails(quickFilter, count)"
34813
+ class="pointer-coarse:[&_.list-item-icon-right]:hidden!"
34814
+ (focusin)="onRowFocus(quickFilter, count)"
34122
34815
  (focusout)="scheduleHideDetails()">
34123
34816
  <ListItemLeft>
34124
34817
  <ListItemIcon><quick-filter-icon /></ListItemIcon>
@@ -34131,11 +34824,20 @@ i0.ɵɵngDeclareClassMetadata({
34131
34824
  #count
34132
34825
  data-count
34133
34826
  class="list-item-subtext block w-fit! decoration-dotted underline-offset-2 hover:underline"
34134
- (mouseenter)="showDetails(quickFilter, count)"
34827
+ (mouseenter)="onCountEnter(quickFilter, count)"
34135
34828
  (mouseleave)="scheduleHideDetails()">
34136
34829
  {{ (quickFilter.filters.length === 1 ? "quickFilters.countOne" : "quickFilters.countOther") | transloco: { count: quickFilter.filters.length } }}
34137
34830
  </span>
34138
34831
  </ListItemText>
34832
+ <!-- Touch only: there is no hover to show what the quick filter holds, and a tap on the row applies it. This opens the card
34833
+ instead. Not a button (a listbox option holds no control, see ListItemAction): the keyboard has the focus of the row. -->
34834
+ <span
34835
+ data-details-toggle
34836
+ aria-hidden="true"
34837
+ class="hidden size-8 shrink-0 cursor-pointer items-center justify-center rounded-(--radius-xs) text-(--font-neutral-muted) pointer-coarse:inline-flex active:bg-(--bg-neutral-base)"
34838
+ (click)="toggleDetails(quickFilter, count, $event)">
34839
+ <info-circle-icon class="size-5" />
34840
+ </span>
34139
34841
  </ListItemLeft>
34140
34842
  @if (deletable()) {
34141
34843
  <ListItemAction
@@ -34148,6 +34850,7 @@ i0.ɵɵngDeclareClassMetadata({
34148
34850
  </ListItemAction>
34149
34851
  }
34150
34852
  </ListItem>
34853
+ </SwipeAction>
34151
34854
  }
34152
34855
  </List>
34153
34856
 
@@ -34969,6 +35672,8 @@ var MultiSelectLabelsComponent = class MultiSelectLabelsComponent {
34969
35672
  "fieldLabelLang",
34970
35673
  "placeholder",
34971
35674
  "ariaLabel",
35675
+ "hideKeyboardLabel",
35676
+ "keyboardLabel",
34972
35677
  "noResultsText",
34973
35678
  "searchingText",
34974
35679
  "errorText",
@@ -35411,7 +36116,8 @@ i0.ɵɵngDeclareClassMetadata({
35411
36116
  }]
35412
36117
  });
35413
36118
  const ROW_BUTTON = "flex size-5 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full border-0 bg-transparent p-0 transition-[max-width,opacity,background-color,color] duration-150 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-(--stroke-focus) focus-visible:-outline-offset-2";
35414
- const ROW_BUTTON_HIDDEN = "max-w-0 opacity-0 group-hover/row:max-w-5 group-hover/row:opacity-100 group-focus-within/row:max-w-5 group-focus-within/row:opacity-100";
36119
+ const ROW_BUTTON_HIDDEN = "max-w-0 opacity-0 group-hover/row:max-w-5 group-hover/row:opacity-100 group-focus-within/row:max-w-5 group-focus-within/row:opacity-100 pointer-coarse:sr-only";
36120
+ const INFO_BUTTON = "hidden size-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0 text-(--history-panel-icon,var(--font-neutral-muted)) active:bg-(--history-panel-active,var(--bg-neutral-hovered)) focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-(--stroke-focus) focus-visible:-outline-offset-2 pointer-coarse:flex";
35415
36121
  const SHEET_CLASSES = [
35416
36122
  "bg-(--history-panel-bg,var(--layer-page-layer)) text-(--history-panel-fg,var(--layer-page-font))",
35417
36123
  "[&_.modal-title]:text-(--history-panel-fg,var(--layer-page-font))",
@@ -35432,6 +36138,7 @@ var HistoryPanelComponent = class HistoryPanelComponent {
35432
36138
  sheetClasses = SHEET_CLASSES;
35433
36139
  rowButton = ROW_BUTTON;
35434
36140
  rowButtonHidden = ROW_BUTTON_HIDDEN;
36141
+ infoButton = INFO_BUTTON;
35435
36142
  asideClasses = computed(() => cn("flex min-h-0 shrink-0 flex-col overflow-hidden whitespace-normal rounded-(--radius-lg) bg-(--history-panel-bg,var(--layer-page-layer)) text-(--history-panel-fg,var(--layer-page-font))", "transition-[width,padding,margin] duration-350 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none", this.open() ? "w-(--history-panel-width,15.625rem) p-(--spacing-xs) ms-(--history-panel-gap,0px)" : "w-0 p-0 ms-0", this.class()), ...ngDevMode ? [{ debugName: "asideClasses" }] : /* istanbul ignore next */ []);
35436
36143
  searches = computed(() => {
35437
36144
  const recent = this.userSettingsStore.recentSearches() ?? [];
@@ -35452,6 +36159,10 @@ var HistoryPanelComponent = class HistoryPanelComponent {
35452
36159
  onTab(value) {
35453
36160
  if (value[0] === "all" || value[0] === "favorites") this.tab.set(value[0]);
35454
36161
  }
36162
+ toggleTitle(tip) {
36163
+ if (tip.isOpen()) tip.hide();
36164
+ else tip.show();
36165
+ }
35455
36166
  onEscape(event) {
35456
36167
  if (!this.open()) return;
35457
36168
  event.stopPropagation();
@@ -35572,9 +36283,26 @@ var HistoryPanelComponent = class HistoryPanelComponent {
35572
36283
  </h3>
35573
36284
  <ul class="m-0 flex list-none flex-col gap-(--spacing-3xs) p-0">
35574
36285
  @for (search of group.items; track search.url) {
35575
- <li
35576
- historyTitleMarquee
35577
- class="group/row flex min-h-8 min-w-0 items-center gap-(--spacing-3xs) rounded-(--radius-sm) hover:bg-(--history-panel-hover,var(--bg-neutral-base)) focus-within:bg-(--history-panel-hover,var(--bg-neutral-base)) active:bg-(--history-panel-active,var(--bg-neutral-hovered))">
36286
+ <li historyTitleMarquee class="group/row min-w-0 rounded-(--radius-sm)">
36287
+ <!-- Touch: drag the row to the left to delete it, to the right to (un)favorite it. The buttons below are
36288
+ then out of sight but still there for a screen reader or a switch (see ROW_BUTTON_HIDDEN). -->
36289
+ <SwipeAction
36290
+ class="rounded-(--radius-sm)"
36291
+ contentClass="flex min-h-8 min-w-0 items-center gap-(--spacing-3xs) rounded-(--radius-sm) hover:bg-(--history-panel-hover,var(--bg-neutral-base)) focus-within:bg-(--history-panel-hover,var(--bg-neutral-base)) active:bg-(--history-panel-active,var(--bg-neutral-hovered))"
36292
+ startEnabled
36293
+ endEnabled
36294
+ startTone="warning"
36295
+ endTone="error"
36296
+ (swipedStart)="toggleFavorite(search)"
36297
+ (swipedEnd)="remove(search)">
36298
+ <span swipeActionStart class="flex items-center gap-2">
36299
+ <star-icon class="size-4" [solid]="!isFavorite(search)" />
36300
+ {{ (isFavorite(search) ? 'searches.history.removeFavorite' : 'searches.history.addFavorite') | transloco }}
36301
+ </span>
36302
+ <span swipeActionEnd class="flex items-center gap-2">
36303
+ {{ 'searches.recent.remove' | transloco }}
36304
+ <x-mark-icon class="size-4" />
36305
+ </span>
35578
36306
  <button
35579
36307
  type="button"
35580
36308
  class="flex min-w-0 flex-1 cursor-pointer items-center gap-(--spacing-xs) rounded-(--radius-sm) border-0 bg-transparent py-1.5 ps-(--spacing-2xs) pe-(--spacing-3xs) text-left text-inherit focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-(--stroke-focus) focus-visible:-outline-offset-2"
@@ -35586,12 +36314,22 @@ var HistoryPanelComponent = class HistoryPanelComponent {
35586
36314
  data-title-box
35587
36315
  truncated
35588
36316
  #cut="truncated"
36317
+ #tip="tooltip"
35589
36318
  [tooltip]="titleOf(search)"
35590
36319
  [tooltipDisabled]="!cut.isTruncated()"
35591
36320
  class="block min-w-0 flex-1 overflow-hidden text-sm leading-[18px] whitespace-nowrap text-(--history-panel-fg,var(--layer-page-font)) [mask-image:linear-gradient(to_right,black_85%,transparent_100%)]">
35592
36321
  <span class="inline-block whitespace-nowrap">{{ titleOf(search) }}</span>
35593
36322
  </span>
35594
36323
  </button>
36324
+ @if (cut.isTruncated()) {
36325
+ <button
36326
+ type="button"
36327
+ [class]="infoButton"
36328
+ [attr.aria-label]="'searches.history.showFullTitle' | transloco"
36329
+ (click)="toggleTitle(tip)">
36330
+ <info-circle-icon class="size-4" />
36331
+ </button>
36332
+ }
35595
36333
  <span class="flex shrink-0 items-center gap-0.5 pe-(--spacing-2xs)">
35596
36334
  <button
35597
36335
  type="button"
@@ -35609,6 +36347,7 @@ var HistoryPanelComponent = class HistoryPanelComponent {
35609
36347
  <star-icon class="size-3.5" [solid]="isFavorite(search)" />
35610
36348
  </button>
35611
36349
  </span>
36350
+ </SwipeAction>
35612
36351
  </li>
35613
36352
  }
35614
36353
  </ul>
@@ -35685,6 +36424,12 @@ var HistoryPanelComponent = class HistoryPanelComponent {
35685
36424
  type: HistoryTitleMarqueeDirective,
35686
36425
  selector: "[historyTitleMarquee]"
35687
36426
  },
36427
+ {
36428
+ kind: "component",
36429
+ type: InfoCircleIcon,
36430
+ selector: "info-circle, info-circle-icon, InfoCircle, InfoCircleIcon",
36431
+ inputs: ["class"]
36432
+ },
35688
36433
  {
35689
36434
  kind: "component",
35690
36435
  type: ListPanelEmptyComponent,
@@ -35732,6 +36477,22 @@ var HistoryPanelComponent = class HistoryPanelComponent {
35732
36477
  selector: "star, star-icon, Star, StarIcon",
35733
36478
  inputs: ["class", "solid"]
35734
36479
  },
36480
+ {
36481
+ kind: "component",
36482
+ type: SwipeActionComponent,
36483
+ selector: "swipe-action, SwipeAction",
36484
+ inputs: [
36485
+ "class",
36486
+ "contentClass",
36487
+ "startEnabled",
36488
+ "endEnabled",
36489
+ "startTone",
36490
+ "endTone",
36491
+ "disabled",
36492
+ "threshold"
36493
+ ],
36494
+ outputs: ["swipedStart", "swipedEnd"]
36495
+ },
35735
36496
  {
35736
36497
  kind: "directive",
35737
36498
  type: ToggleGroupComponent,
@@ -35821,12 +36582,14 @@ i0.ɵɵngDeclareClassMetadata({
35821
36582
  DialogTitleComponent,
35822
36583
  HistoryIcon,
35823
36584
  HistoryTitleMarqueeDirective,
36585
+ InfoCircleIcon,
35824
36586
  ListPanelEmptyComponent,
35825
36587
  MagnifyingGlassIcon,
35826
36588
  SheetBodyComponent,
35827
36589
  SheetComponent,
35828
36590
  SheetHeaderComponent,
35829
36591
  StarIcon,
36592
+ SwipeActionComponent,
35830
36593
  ToggleGroupComponent,
35831
36594
  ToggleItemComponent,
35832
36595
  TooltipDirective,
@@ -35879,9 +36642,26 @@ i0.ɵɵngDeclareClassMetadata({
35879
36642
  </h3>
35880
36643
  <ul class="m-0 flex list-none flex-col gap-(--spacing-3xs) p-0">
35881
36644
  @for (search of group.items; track search.url) {
35882
- <li
35883
- historyTitleMarquee
35884
- class="group/row flex min-h-8 min-w-0 items-center gap-(--spacing-3xs) rounded-(--radius-sm) hover:bg-(--history-panel-hover,var(--bg-neutral-base)) focus-within:bg-(--history-panel-hover,var(--bg-neutral-base)) active:bg-(--history-panel-active,var(--bg-neutral-hovered))">
36645
+ <li historyTitleMarquee class="group/row min-w-0 rounded-(--radius-sm)">
36646
+ <!-- Touch: drag the row to the left to delete it, to the right to (un)favorite it. The buttons below are
36647
+ then out of sight but still there for a screen reader or a switch (see ROW_BUTTON_HIDDEN). -->
36648
+ <SwipeAction
36649
+ class="rounded-(--radius-sm)"
36650
+ contentClass="flex min-h-8 min-w-0 items-center gap-(--spacing-3xs) rounded-(--radius-sm) hover:bg-(--history-panel-hover,var(--bg-neutral-base)) focus-within:bg-(--history-panel-hover,var(--bg-neutral-base)) active:bg-(--history-panel-active,var(--bg-neutral-hovered))"
36651
+ startEnabled
36652
+ endEnabled
36653
+ startTone="warning"
36654
+ endTone="error"
36655
+ (swipedStart)="toggleFavorite(search)"
36656
+ (swipedEnd)="remove(search)">
36657
+ <span swipeActionStart class="flex items-center gap-2">
36658
+ <star-icon class="size-4" [solid]="!isFavorite(search)" />
36659
+ {{ (isFavorite(search) ? 'searches.history.removeFavorite' : 'searches.history.addFavorite') | transloco }}
36660
+ </span>
36661
+ <span swipeActionEnd class="flex items-center gap-2">
36662
+ {{ 'searches.recent.remove' | transloco }}
36663
+ <x-mark-icon class="size-4" />
36664
+ </span>
35885
36665
  <button
35886
36666
  type="button"
35887
36667
  class="flex min-w-0 flex-1 cursor-pointer items-center gap-(--spacing-xs) rounded-(--radius-sm) border-0 bg-transparent py-1.5 ps-(--spacing-2xs) pe-(--spacing-3xs) text-left text-inherit focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-(--stroke-focus) focus-visible:-outline-offset-2"
@@ -35893,12 +36673,22 @@ i0.ɵɵngDeclareClassMetadata({
35893
36673
  data-title-box
35894
36674
  truncated
35895
36675
  #cut="truncated"
36676
+ #tip="tooltip"
35896
36677
  [tooltip]="titleOf(search)"
35897
36678
  [tooltipDisabled]="!cut.isTruncated()"
35898
36679
  class="block min-w-0 flex-1 overflow-hidden text-sm leading-[18px] whitespace-nowrap text-(--history-panel-fg,var(--layer-page-font)) [mask-image:linear-gradient(to_right,black_85%,transparent_100%)]">
35899
36680
  <span class="inline-block whitespace-nowrap">{{ titleOf(search) }}</span>
35900
36681
  </span>
35901
36682
  </button>
36683
+ @if (cut.isTruncated()) {
36684
+ <button
36685
+ type="button"
36686
+ [class]="infoButton"
36687
+ [attr.aria-label]="'searches.history.showFullTitle' | transloco"
36688
+ (click)="toggleTitle(tip)">
36689
+ <info-circle-icon class="size-4" />
36690
+ </button>
36691
+ }
35902
36692
  <span class="flex shrink-0 items-center gap-0.5 pe-(--spacing-2xs)">
35903
36693
  <button
35904
36694
  type="button"
@@ -35916,6 +36706,7 @@ i0.ɵɵngDeclareClassMetadata({
35916
36706
  <star-icon class="size-3.5" [solid]="isFavorite(search)" />
35917
36707
  </button>
35918
36708
  </span>
36709
+ </SwipeAction>
35919
36710
  </li>
35920
36711
  }
35921
36712
  </ul>
@@ -38772,6 +39563,10 @@ const userProfileSchema = (editing) => schema((p) => {
38772
39563
  applyEach(p.customData, (field) => readonly(field, { when: ({ key }) => `customData.${key()}` !== editing() }));
38773
39564
  });
38774
39565
  var UserProfileFormComponent = class UserProfileFormComponent {
39566
+ padded = input(true, {
39567
+ ...ngDevMode ? { debugName: "padded" } : /* istanbul ignore next */ {},
39568
+ transform: booleanAttribute
39569
+ });
38775
39570
  principalStore = inject(PrincipalStore);
38776
39571
  userProfileService = inject(UserProfileService);
38777
39572
  appStore = inject(AppStore);
@@ -39116,10 +39911,17 @@ var UserProfileFormComponent = class UserProfileFormComponent {
39116
39911
  type: UserProfileFormComponent,
39117
39912
  isStandalone: true,
39118
39913
  selector: "user-profile-form, UserProfileForm, userprofileform",
39914
+ inputs: { padded: {
39915
+ classPropertyName: "padded",
39916
+ publicName: "padded",
39917
+ isSignal: true,
39918
+ isRequired: false,
39919
+ transformFunction: null
39920
+ } },
39119
39921
  host: { classAttribute: "flex flex-col h-full overflow-hidden" },
39120
39922
  providers: [provideTranslocoScope("user-profile", "login")],
39121
39923
  ngImport: i0,
39122
- template: "@if (userProfileResource.hasValue()) {\n @let profile = user();\n <div class=\"flex min-h-0 flex-1 flex-col gap-2 p-4\">\n <div class=\"mb-4 flex\">\n @if (allowProfilePhoto()) {\n @let profilePhoto = profile?.data?.profilePhoto;\n <!-- AVATAR — a real <button>: the avatar opens the file picker, so it has to be reachable and\n activatable with the keyboard, which a click handler on a <div> never is. -->\n <button\n type=\"button\"\n variant=\"none\"\n class=\"cursor-pointer rounded-full\"\n [title]=\"'userProfile.changePhoto' | transloco\"\n [attr.aria-label]=\"'userProfile.changePhoto' | transloco\"\n (click)=\"avatarInput.click()\">\n <Avatar fill=\"secondary\" class=\"size-14\">\n @if (profilePhoto) {\n <!-- alt=\"\": the button already carries the accessible name, the image would repeat it. -->\n <AvatarImage [src]=\"profilePhoto\" width=\"44\" height=\"44\" alt=\"\" />\n }\n <AvatarFallback class=\"size-full text-lg\">\n @if (initials()) {\n <span>{{ initials() }}</span>\n } @else {\n <UserIcon class=\"size-7\" />\n }\n </AvatarFallback>\n </Avatar>\n </button>\n @if (profilePhoto) {\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n [title]=\"'userProfile.deleteProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco\n : { property: 'userProfile.data.profilePhoto' | transloco }\n \"\n (click)=\"onDeleteData('data', 'profilePhoto')\">\n <TrashIcon />\n </button>\n }\n }\n <div class=\"grow\"></div>\n <div class=\"flex flex-col gap-4\">\n <!-- CHANGE PASSWORD -->\n @if (allowChangePassword()) {\n <button\n type=\"button\"\n [disabled]=\"changingPassword()\"\n (click)=\"changingPassword.set(true)\">\n {{ \"login.changePassword\" | transloco }}\n </button>\n }\n </div>\n </div>\n @if (changingPassword()) {\n <!-- ChangePassword renders its own <form [formRoot]>: it stays in this branch, mutually\n exclusive with the profile form below, so the two never nest. -->\n <ChangePassword\n [username]=\"username()\"\n [redirectAfterSuccess]=\"false\"\n [redirectAfterCancel]=\"false\"\n (cancel)=\"changingPassword.set(false)\"\n (success)=\"changingPassword.set(false)\" />\n } @else {\n <!-- p: the focus outline of the fields is drawn *outside* their box, and this is the\n scrolling ancestor — without a gutter it clips the ring on every edge it touches\n (left for all of them, bottom for the last one, top for the first). -->\n <div class=\"min-h-0 flex-1 overflow-auto p-(--spacing-3xs)\">\n <!--\n [formRoot] pose novalidate, intercepte submit et appelle preventDefault() avant de déclencher\n submit() sur le field tree. Un seul bouton type=\"submit\" est rendu à la fois — celui de la\n ligne en cours d'édition — et l'action de soumission persiste cette seule propriété.\n -->\n <!-- aria-label: a <form> with no accessible name is not exposed as a form landmark, so the\n fields could not be reached as a whole. aria-busy holds the announcement back while a\n property is being persisted. -->\n <form\n class=\"@container\"\n [formRoot]=\"profileForm\"\n [attr.aria-label]=\"'userProfile.profileFields' | transloco\"\n [attr.aria-busy]=\"profileForm().submitting() ? 'true' : null\">\n @for (section of sections(); track section.id) {\n <section\n [attr.aria-labelledby]=\"'user-profile-section-' + section.id\"\n class=\"mb-(--spacing-md) flex flex-col gap-(--spacing-sm) rounded-(--radius-lg) border border-solid border-(--stroke-primary-lighter) p-(--spacing-md)\">\n <div>\n <h3\n [id]=\"'user-profile-section-' + section.id\"\n class=\"m-0 text-(length:--size-base) font-semibold\">\n {{ \"userProfile.sections.\" + section.id | transloco }}\n </h3>\n <p\n class=\"m-0 text-(length:--size-sm) text-(--font-neutral-muted)\">\n {{\n \"userProfile.sections.\" + section.id + \"Hint\" | transloco\n }}\n </p>\n </div>\n <div\n class=\"grid grid-cols-1 gap-x-(--spacing-md) gap-y-(--spacing-sm) @lg:grid-cols-2\">\n @for (\n property of section.rows;\n track property.category + \".\" + property.key\n ) {\n @let category = property.category;\n @let key = property.key;\n @let field = profileForm[category][key];\n @let label = labelFor(category, key);\n @let editing = isEditing(category, key);\n @let invalid = showError(field);\n @let boolean = isBoolean(category, key);\n @let readOnlyValue = isReadOnlyValue(category, key);\n @let leaf = isLeaf(category, key);\n @let saved = isSaved(category, key);\n <!-- role=\"group\" named by the label: without it the action buttons of a row are\n announced out of context, as a flat run of \"Edit …\" / \"Delete …\" with nothing tying\n them to the property they belong to. A switch, and a value shown but not edited,\n take the whole width of the grid: they are not a field to line up with another. -->\n <div\n role=\"group\"\n [attr.aria-labelledby]=\"labelId(category, key)\"\n class=\"flex min-w-0 flex-col gap-(--spacing-3xs)\"\n [class.col-span-full]=\"boolean || readOnlyValue\">\n @if (boolean) {\n <!-- A switch is not a labelable text field: the name goes through aria-labelledby. The\n value is saved as soon as it is flipped, so there is no edit mode. -->\n <div\n class=\"flex flex-row items-center gap-(--spacing-sm) rounded-(--radius-md) border border-solid border-(--stroke-primary-lighter) px-(--spacing-md) py-(--spacing-xs)\">\n <span\n [id]=\"labelId(category, key)\"\n class=\"grow\"\n [class]=\"labelClass\"\n >{{ label }}</span\n >\n @if (saved) {\n <span\n class=\"flex items-center gap-(--spacing-3xs) text-(length:--size-sm) text-(--font-success-base)\">\n <CircleCheckIcon aria-hidden=\"true\" />\n {{ \"userProfile.saved\" | transloco }}\n </span>\n } @else if (isDefault(category, key)) {\n <span [class]=\"chipClass\">{{\n \"userProfile.defaultValue\" | transloco\n }}</span>\n }\n <switch\n #toggle\n [checked]=\"booleanValue(category, key)\"\n [disabled]=\"profileForm().submitting()\"\n [aria-labelledby]=\"labelId(category, key)\"\n (checkedChange)=\"\n onToggleBoolean(category, key, $event, toggle)\n \" />\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n [title]=\"'userProfile.deleteProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco: { property: label }\n \"\n [disabled]=\"!hasStoredValue(category, key)\"\n (click)=\"onDeleteData(category, key)\">\n <TrashIcon />\n </button>\n </div>\n } @else if (readOnlyValue) {\n <!-- An array is shown, never edited: its shape is the administrator's. A galactik\n TagInput in read-only mode, which is what it is for; its inner text field is named\n after the property, as there is no <label for> to reach it. -->\n <span\n [id]=\"labelId(category, key)\"\n [class]=\"labelClass\"\n >{{ label }}</span\n >\n <tag-input\n class=\"[&_tag-remove]:hidden\"\n [value]=\"arrayTags(category, key)\"\n [readonly]=\"true\"\n [aria-label]=\"label\"\n [addLabel]=\"label\" />\n } @else {\n <label\n [id]=\"labelId(category, key)\"\n [for]=\"fieldId(category, key)\"\n [class]=\"labelClass\"\n >{{ label }}</label\n >\n <!-- The actions sit inside the box: galactik styles it from the control with has-[]\n selectors, which match descendants, so the focus ring and the read-only look\n keep working with the buttons next to the input. At rest the box is tinted,\n which is what tells a field from its label; it is white while edited. -->\n <!-- scheme: galactik colours the field itself for an error and for a save that just\n went through. The tint of a read-only field must not hide the success one, hence\n the data attribute and its more specific variant. -->\n <input-group\n class=\"has-[[readonly]]:bg-(--bg-primary-lighter) data-[state=success]:has-[[readonly]]:bg-(--bg-success-base-alt)\"\n [attr.data-state]=\"saved ? 'success' : null\"\n [scheme]=\"\n invalid ? 'error' : saved ? 'success' : 'default'\n \">\n <!-- No [attr.readonly] here: [formField] writes the readonly attribute itself from\n the field state, which the schema's readonly() rule drives. The native\n readonly attribute is what exposes aria-readonly — do not duplicate it. -->\n <input\n input-control\n type=\"text\"\n [id]=\"fieldId(category, key)\"\n [formField]=\"field\"\n [attr.placeholder]=\"\n 'userProfile.notDefined' | transloco\n \"\n [attr.aria-invalid]=\"invalid ? 'true' : null\"\n [attr.aria-describedby]=\"\n invalid ? errorId(category, key) : null\n \"\n [attr.aria-keyshortcuts]=\"editing ? 'Escape' : null\"\n (focus)=\"onFieldFocus($event)\"\n (keydown.escape)=\"onEscape($event, category, key)\" />\n <!-- Always visible: hiding them until hover made them unreachable on a touch screen\n and left nothing to discover. -->\n @if (!leaf) {\n <div class=\"flex shrink-0 flex-row items-center\">\n @if (editing) {\n <button\n type=\"submit\"\n variant=\"primary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"cursor-pointer\"\n [disabled]=\"profileForm().submitting()\"\n [title]=\"'userProfile.saveProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.savePropertyNamed'\n | transloco: { property: label }\n \">\n <save-icon />\n </button>\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"cursor-pointer\"\n [title]=\"'userProfile.cancelEdit' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.cancelEditNamed'\n | transloco: { property: label }\n \"\n (click)=\"onCancelEdit(category, key)\">\n <UndoIcon />\n </button>\n } @else {\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n [id]=\"editButtonId(category, key)\"\n class=\"cursor-pointer\"\n [title]=\"'userProfile.editProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.editPropertyNamed'\n | transloco: { property: label }\n \"\n (click)=\"onEdit(category, key)\">\n <EditIcon />\n </button>\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n [title]=\"\n 'userProfile.deleteProperty' | transloco\n \"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco: { property: label }\n \"\n [disabled]=\"!hasStoredValue(category, key)\"\n (click)=\"onDeleteData(category, key)\">\n <TrashIcon />\n </button>\n }\n </div>\n }\n </input-group>\n }\n <!-- Under the field, one message at a time: the error first, then the confirmation of a\n save, then what is worth knowing about the value. -->\n @if (invalid) {\n <p\n [id]=\"errorId(category, key)\"\n role=\"alert\"\n [class]=\"errorClass\">\n {{ errorKey(field) | transloco }}\n </p>\n } @else if (!boolean) {\n @if (saved) {\n <p\n class=\"m-0 flex items-center gap-(--spacing-3xs) text-(length:--size-sm) text-(--font-success-base)\">\n <CircleCheckIcon aria-hidden=\"true\" />\n {{ \"userProfile.saved\" | transloco }}\n </p>\n } @else if (readOnlyValue || leaf) {\n <p\n class=\"m-0 flex items-center gap-(--spacing-3xs) text-(length:--size-sm) text-(--font-neutral-muted)\">\n <LockIcon />\n {{ \"userProfile.lockedByAdmin\" | transloco }}\n </p>\n } @else if (editing) {\n <p\n class=\"m-0 text-(length:--size-sm) text-(--font-neutral-muted)\">\n {{ \"userProfile.editHint\" | transloco }}\n </p>\n } @else if (isDefault(category, key)) {\n <p class=\"m-0\">\n <span [class]=\"chipClass\">{{\n \"userProfile.defaultValue\" | transloco\n }}</span>\n </p>\n }\n }\n </div>\n }\n </div>\n </section>\n }\n </form>\n <!-- Always in the DOM, and only its text changes: a live region inserted at the same time as\n its content is not reliably announced. `sr-only` — the transitions it describes are\n already visible on screen. -->\n <p class=\"sr-only\" role=\"status\" aria-live=\"polite\">\n {{ statusMessage() }}\n </p>\n </div>\n }\n </div>\n} @else if (userProfileResource.isLoading()) {\n <p role=\"status\">{{ \"userProfile.loading\" | transloco }}</p>\n} @else if (userProfileResource.error()) {\n <p role=\"alert\">{{ \"userProfile.loadError\" | transloco }}</p>\n}\n\n<!-- AVATAR UPLOAD — `hidden` keeps it out of both the tab order and the accessibility tree, which is\n intended: it is opened by the avatar button above. It also sits outside the <form>. -->\n<input\n #avatarInput\n class=\"hidden\"\n type=\"file\"\n accept=\"image/*\"\n (change)=\"uploadAvatar($event)\" />\n",
39924
+ template: "@if (userProfileResource.hasValue()) {\n @let profile = user();\n <div class=\"flex min-h-0 flex-1 flex-col gap-2\" [class.p-4]=\"padded()\">\n <div class=\"mb-4 flex\">\n @if (allowProfilePhoto()) {\n @let profilePhoto = profile?.data?.profilePhoto;\n <!-- AVATAR — a real <button>: the avatar opens the file picker, so it has to be reachable and\n activatable with the keyboard, which a click handler on a <div> never is. -->\n <button\n type=\"button\"\n variant=\"none\"\n class=\"cursor-pointer rounded-full\"\n [title]=\"'userProfile.changePhoto' | transloco\"\n [attr.aria-label]=\"'userProfile.changePhoto' | transloco\"\n (click)=\"avatarInput.click()\">\n <Avatar fill=\"secondary\" class=\"size-14\">\n @if (profilePhoto) {\n <!-- alt=\"\": the button already carries the accessible name, the image would repeat it. -->\n <AvatarImage [src]=\"profilePhoto\" width=\"44\" height=\"44\" alt=\"\" />\n }\n <AvatarFallback class=\"size-full text-lg\">\n @if (initials()) {\n <span>{{ initials() }}</span>\n } @else {\n <UserIcon class=\"size-7\" />\n }\n </AvatarFallback>\n </Avatar>\n </button>\n @if (profilePhoto) {\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n [title]=\"'userProfile.deleteProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco\n : { property: 'userProfile.data.profilePhoto' | transloco }\n \"\n (click)=\"onDeleteData('data', 'profilePhoto')\">\n <XMarkIcon />\n </button>\n }\n }\n <div class=\"grow\"></div>\n <div class=\"flex flex-col gap-4\">\n <!-- CHANGE PASSWORD -->\n @if (allowChangePassword()) {\n <button\n type=\"button\"\n [disabled]=\"changingPassword()\"\n (click)=\"changingPassword.set(true)\">\n {{ \"login.changePassword\" | transloco }}\n </button>\n }\n </div>\n </div>\n @if (changingPassword()) {\n <!-- ChangePassword renders its own <form [formRoot]>: it stays in this branch, mutually\n exclusive with the profile form below, so the two never nest. -->\n <ChangePassword\n [username]=\"username()\"\n [redirectAfterSuccess]=\"false\"\n [redirectAfterCancel]=\"false\"\n (cancel)=\"changingPassword.set(false)\"\n (success)=\"changingPassword.set(false)\" />\n } @else {\n <!-- p: the focus outline of the fields is drawn *outside* their box, and this is the\n scrolling ancestor — without a gutter it clips the ring on every edge it touches\n (left for all of them, bottom for the last one, top for the first). -->\n <div class=\"min-h-0 flex-1 overflow-auto p-(--spacing-3xs)\">\n <!--\n [formRoot] pose novalidate, intercepte submit et appelle preventDefault() avant de déclencher\n submit() sur le field tree. Un seul bouton type=\"submit\" est rendu à la fois — celui de la\n ligne en cours d'édition — et l'action de soumission persiste cette seule propriété.\n -->\n <!-- aria-label: a <form> with no accessible name is not exposed as a form landmark, so the\n fields could not be reached as a whole. aria-busy holds the announcement back while a\n property is being persisted. -->\n <form\n class=\"@container\"\n [formRoot]=\"profileForm\"\n [attr.aria-label]=\"'userProfile.profileFields' | transloco\"\n [attr.aria-busy]=\"profileForm().submitting() ? 'true' : null\">\n @for (section of sections(); track section.id) {\n <section\n [attr.aria-labelledby]=\"'user-profile-section-' + section.id\"\n class=\"mb-(--spacing-md) flex flex-col gap-(--spacing-sm) rounded-(--radius-lg) border border-solid border-(--stroke-primary-lighter) p-(--spacing-md)\">\n <div>\n <h3\n [id]=\"'user-profile-section-' + section.id\"\n class=\"m-0 text-(length:--size-base) font-semibold\">\n {{ \"userProfile.sections.\" + section.id | transloco }}\n </h3>\n <p\n class=\"m-0 text-(length:--size-sm) text-(--font-neutral-muted)\">\n {{\n \"userProfile.sections.\" + section.id + \"Hint\" | transloco\n }}\n </p>\n </div>\n <div\n class=\"grid grid-cols-1 gap-x-(--spacing-md) gap-y-(--spacing-sm) @lg:grid-cols-2\">\n @for (\n property of section.rows;\n track property.category + \".\" + property.key\n ) {\n @let category = property.category;\n @let key = property.key;\n @let field = profileForm[category][key];\n @let label = labelFor(category, key);\n @let editing = isEditing(category, key);\n @let invalid = showError(field);\n @let boolean = isBoolean(category, key);\n @let readOnlyValue = isReadOnlyValue(category, key);\n @let leaf = isLeaf(category, key);\n @let saved = isSaved(category, key);\n <!-- role=\"group\" named by the label: without it the action buttons of a row are\n announced out of context, as a flat run of \"Edit …\" / \"Delete …\" with nothing tying\n them to the property they belong to. A switch, and a value shown but not edited,\n take the whole width of the grid: they are not a field to line up with another. -->\n <div\n role=\"group\"\n [attr.aria-labelledby]=\"labelId(category, key)\"\n class=\"flex min-w-0 flex-col gap-(--spacing-3xs)\"\n [class.col-span-full]=\"boolean || readOnlyValue\">\n @if (boolean) {\n <!-- A switch is not a labelable text field: the name goes through aria-labelledby. The\n value is saved as soon as it is flipped, so there is no edit mode. -->\n <div\n class=\"flex flex-row items-center gap-(--spacing-sm) rounded-(--radius-md) border border-solid border-(--stroke-primary-lighter) px-(--spacing-md) py-(--spacing-xs)\">\n <span\n [id]=\"labelId(category, key)\"\n class=\"grow\"\n [class]=\"labelClass\"\n >{{ label }}</span\n >\n @if (saved) {\n <span\n class=\"flex items-center gap-(--spacing-3xs) text-(length:--size-sm) text-(--font-success-base)\">\n <CircleCheckIcon aria-hidden=\"true\" />\n {{ \"userProfile.saved\" | transloco }}\n </span>\n } @else if (isDefault(category, key)) {\n <span [class]=\"chipClass\">{{\n \"userProfile.defaultValue\" | transloco\n }}</span>\n }\n <switch\n #toggle\n [checked]=\"booleanValue(category, key)\"\n [disabled]=\"profileForm().submitting()\"\n [aria-labelledby]=\"labelId(category, key)\"\n (checkedChange)=\"\n onToggleBoolean(category, key, $event, toggle)\n \" />\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n [title]=\"'userProfile.deleteProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco: { property: label }\n \"\n [disabled]=\"!hasStoredValue(category, key)\"\n (click)=\"onDeleteData(category, key)\">\n <XMarkIcon />\n </button>\n </div>\n } @else if (readOnlyValue) {\n <!-- An array is shown, never edited: its shape is the administrator's. A galactik\n TagInput in read-only mode, which is what it is for; its inner text field is named\n after the property, as there is no <label for> to reach it. -->\n <span\n [id]=\"labelId(category, key)\"\n [class]=\"labelClass\"\n >{{ label }}</span\n >\n <tag-input\n class=\"[&_tag-remove]:hidden\"\n [value]=\"arrayTags(category, key)\"\n [readonly]=\"true\"\n [aria-label]=\"label\"\n [addLabel]=\"label\" />\n } @else {\n <label\n [id]=\"labelId(category, key)\"\n [for]=\"fieldId(category, key)\"\n [class]=\"labelClass\"\n >{{ label }}</label\n >\n <!-- The actions sit inside the box: galactik styles it from the control with has-[]\n selectors, which match descendants, so the focus ring and the read-only look\n keep working with the buttons next to the input. At rest the box is tinted,\n which is what tells a field from its label; it is white while edited. -->\n <!-- scheme: galactik colours the field itself for an error and for a save that just\n went through. The tint of a read-only field must not hide the success one, hence\n the data attribute and its more specific variant. -->\n <input-group\n class=\"has-[[readonly]]:bg-(--bg-primary-lighter) data-[state=success]:has-[[readonly]]:bg-(--bg-success-base-alt)\"\n [attr.data-state]=\"saved ? 'success' : null\"\n [scheme]=\"\n invalid ? 'error' : saved ? 'success' : 'default'\n \">\n <!-- No [attr.readonly] here: [formField] writes the readonly attribute itself from\n the field state, which the schema's readonly() rule drives. The native\n readonly attribute is what exposes aria-readonly — do not duplicate it. -->\n <input\n input-control\n type=\"text\"\n [id]=\"fieldId(category, key)\"\n [formField]=\"field\"\n [attr.placeholder]=\"\n 'userProfile.notDefined' | transloco\n \"\n [attr.aria-invalid]=\"invalid ? 'true' : null\"\n [attr.aria-describedby]=\"\n invalid ? errorId(category, key) : null\n \"\n [attr.aria-keyshortcuts]=\"editing ? 'Escape' : null\"\n (focus)=\"onFieldFocus($event)\"\n (keydown.escape)=\"onEscape($event, category, key)\" />\n <!-- Always visible: hiding them until hover made them unreachable on a touch screen\n and left nothing to discover. -->\n @if (!leaf) {\n <div class=\"flex shrink-0 flex-row items-center\">\n @if (editing) {\n <button\n type=\"submit\"\n variant=\"primary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"cursor-pointer\"\n [disabled]=\"profileForm().submitting()\"\n [title]=\"'userProfile.saveProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.savePropertyNamed'\n | transloco: { property: label }\n \">\n <save-icon />\n </button>\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"cursor-pointer\"\n [title]=\"'userProfile.cancelEdit' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.cancelEditNamed'\n | transloco: { property: label }\n \"\n (click)=\"onCancelEdit(category, key)\">\n <UndoIcon />\n </button>\n } @else {\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n [id]=\"editButtonId(category, key)\"\n class=\"cursor-pointer\"\n [title]=\"'userProfile.editProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.editPropertyNamed'\n | transloco: { property: label }\n \"\n (click)=\"onEdit(category, key)\">\n <EditIcon />\n </button>\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n [title]=\"\n 'userProfile.deleteProperty' | transloco\n \"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco: { property: label }\n \"\n [disabled]=\"!hasStoredValue(category, key)\"\n (click)=\"onDeleteData(category, key)\">\n <XMarkIcon />\n </button>\n }\n </div>\n }\n </input-group>\n }\n <!-- Under the field, one message at a time: the error first, then the confirmation of a\n save, then what is worth knowing about the value. -->\n @if (invalid) {\n <p\n [id]=\"errorId(category, key)\"\n role=\"alert\"\n [class]=\"errorClass\">\n {{ errorKey(field) | transloco }}\n </p>\n } @else if (!boolean) {\n @if (saved) {\n <p\n class=\"m-0 flex items-center gap-(--spacing-3xs) text-(length:--size-sm) text-(--font-success-base)\">\n <CircleCheckIcon aria-hidden=\"true\" />\n {{ \"userProfile.saved\" | transloco }}\n </p>\n } @else if (readOnlyValue || leaf) {\n <p\n class=\"m-0 flex items-center gap-(--spacing-3xs) text-(length:--size-sm) text-(--font-neutral-muted)\">\n <LockIcon />\n {{ \"userProfile.lockedByAdmin\" | transloco }}\n </p>\n } @else if (editing) {\n <p\n class=\"m-0 text-(length:--size-sm) text-(--font-neutral-muted)\">\n {{ \"userProfile.editHint\" | transloco }}\n </p>\n } @else if (isDefault(category, key)) {\n <p class=\"m-0\">\n <span [class]=\"chipClass\">{{\n \"userProfile.defaultValue\" | transloco\n }}</span>\n </p>\n }\n }\n </div>\n }\n </div>\n </section>\n }\n </form>\n <!-- Always in the DOM, and only its text changes: a live region inserted at the same time as\n its content is not reliably announced. `sr-only` — the transitions it describes are\n already visible on screen. -->\n <p class=\"sr-only\" role=\"status\" aria-live=\"polite\">\n {{ statusMessage() }}\n </p>\n </div>\n }\n </div>\n} @else if (userProfileResource.isLoading()) {\n <p role=\"status\">{{ \"userProfile.loading\" | transloco }}</p>\n} @else if (userProfileResource.error()) {\n <p role=\"alert\">{{ \"userProfile.loadError\" | transloco }}</p>\n}\n\n<!-- AVATAR UPLOAD — `hidden` keeps it out of both the tab order and the accessibility tree, which is\n intended: it is opened by the avatar button above. It also sits outside the <form>. -->\n<input\n #avatarInput\n class=\"hidden\"\n type=\"file\"\n accept=\"image/*\"\n (change)=\"uploadAvatar($event)\" />\n",
39123
39925
  dependencies: [
39124
39926
  {
39125
39927
  kind: "directive",
@@ -39136,8 +39938,8 @@ var UserProfileFormComponent = class UserProfileFormComponent {
39136
39938
  },
39137
39939
  {
39138
39940
  kind: "component",
39139
- type: TrashIcon,
39140
- selector: "trash, trash-icon, Trash, TrashIcon",
39941
+ type: XMarkIcon,
39942
+ selector: "x-mark, x-mark-icon, XMark, XMarkIcon",
39141
39943
  inputs: ["class"]
39142
39944
  },
39143
39945
  {
@@ -39323,7 +40125,7 @@ i0.ɵɵngDeclareClassMetadata({
39323
40125
  FormRoot,
39324
40126
  FormField,
39325
40127
  TranslocoPipe,
39326
- TrashIcon,
40128
+ XMarkIcon,
39327
40129
  EditIcon,
39328
40130
  UndoIcon,
39329
40131
  SaveIcon,
@@ -39341,10 +40143,18 @@ i0.ɵɵngDeclareClassMetadata({
39341
40143
  ChangePasswordComponent
39342
40144
  ],
39343
40145
  providers: [provideTranslocoScope("user-profile", "login")],
39344
- template: "@if (userProfileResource.hasValue()) {\n @let profile = user();\n <div class=\"flex min-h-0 flex-1 flex-col gap-2 p-4\">\n <div class=\"mb-4 flex\">\n @if (allowProfilePhoto()) {\n @let profilePhoto = profile?.data?.profilePhoto;\n <!-- AVATAR — a real <button>: the avatar opens the file picker, so it has to be reachable and\n activatable with the keyboard, which a click handler on a <div> never is. -->\n <button\n type=\"button\"\n variant=\"none\"\n class=\"cursor-pointer rounded-full\"\n [title]=\"'userProfile.changePhoto' | transloco\"\n [attr.aria-label]=\"'userProfile.changePhoto' | transloco\"\n (click)=\"avatarInput.click()\">\n <Avatar fill=\"secondary\" class=\"size-14\">\n @if (profilePhoto) {\n <!-- alt=\"\": the button already carries the accessible name, the image would repeat it. -->\n <AvatarImage [src]=\"profilePhoto\" width=\"44\" height=\"44\" alt=\"\" />\n }\n <AvatarFallback class=\"size-full text-lg\">\n @if (initials()) {\n <span>{{ initials() }}</span>\n } @else {\n <UserIcon class=\"size-7\" />\n }\n </AvatarFallback>\n </Avatar>\n </button>\n @if (profilePhoto) {\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n [title]=\"'userProfile.deleteProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco\n : { property: 'userProfile.data.profilePhoto' | transloco }\n \"\n (click)=\"onDeleteData('data', 'profilePhoto')\">\n <TrashIcon />\n </button>\n }\n }\n <div class=\"grow\"></div>\n <div class=\"flex flex-col gap-4\">\n <!-- CHANGE PASSWORD -->\n @if (allowChangePassword()) {\n <button\n type=\"button\"\n [disabled]=\"changingPassword()\"\n (click)=\"changingPassword.set(true)\">\n {{ \"login.changePassword\" | transloco }}\n </button>\n }\n </div>\n </div>\n @if (changingPassword()) {\n <!-- ChangePassword renders its own <form [formRoot]>: it stays in this branch, mutually\n exclusive with the profile form below, so the two never nest. -->\n <ChangePassword\n [username]=\"username()\"\n [redirectAfterSuccess]=\"false\"\n [redirectAfterCancel]=\"false\"\n (cancel)=\"changingPassword.set(false)\"\n (success)=\"changingPassword.set(false)\" />\n } @else {\n <!-- p: the focus outline of the fields is drawn *outside* their box, and this is the\n scrolling ancestor — without a gutter it clips the ring on every edge it touches\n (left for all of them, bottom for the last one, top for the first). -->\n <div class=\"min-h-0 flex-1 overflow-auto p-(--spacing-3xs)\">\n <!--\n [formRoot] pose novalidate, intercepte submit et appelle preventDefault() avant de déclencher\n submit() sur le field tree. Un seul bouton type=\"submit\" est rendu à la fois — celui de la\n ligne en cours d'édition — et l'action de soumission persiste cette seule propriété.\n -->\n <!-- aria-label: a <form> with no accessible name is not exposed as a form landmark, so the\n fields could not be reached as a whole. aria-busy holds the announcement back while a\n property is being persisted. -->\n <form\n class=\"@container\"\n [formRoot]=\"profileForm\"\n [attr.aria-label]=\"'userProfile.profileFields' | transloco\"\n [attr.aria-busy]=\"profileForm().submitting() ? 'true' : null\">\n @for (section of sections(); track section.id) {\n <section\n [attr.aria-labelledby]=\"'user-profile-section-' + section.id\"\n class=\"mb-(--spacing-md) flex flex-col gap-(--spacing-sm) rounded-(--radius-lg) border border-solid border-(--stroke-primary-lighter) p-(--spacing-md)\">\n <div>\n <h3\n [id]=\"'user-profile-section-' + section.id\"\n class=\"m-0 text-(length:--size-base) font-semibold\">\n {{ \"userProfile.sections.\" + section.id | transloco }}\n </h3>\n <p\n class=\"m-0 text-(length:--size-sm) text-(--font-neutral-muted)\">\n {{\n \"userProfile.sections.\" + section.id + \"Hint\" | transloco\n }}\n </p>\n </div>\n <div\n class=\"grid grid-cols-1 gap-x-(--spacing-md) gap-y-(--spacing-sm) @lg:grid-cols-2\">\n @for (\n property of section.rows;\n track property.category + \".\" + property.key\n ) {\n @let category = property.category;\n @let key = property.key;\n @let field = profileForm[category][key];\n @let label = labelFor(category, key);\n @let editing = isEditing(category, key);\n @let invalid = showError(field);\n @let boolean = isBoolean(category, key);\n @let readOnlyValue = isReadOnlyValue(category, key);\n @let leaf = isLeaf(category, key);\n @let saved = isSaved(category, key);\n <!-- role=\"group\" named by the label: without it the action buttons of a row are\n announced out of context, as a flat run of \"Edit …\" / \"Delete …\" with nothing tying\n them to the property they belong to. A switch, and a value shown but not edited,\n take the whole width of the grid: they are not a field to line up with another. -->\n <div\n role=\"group\"\n [attr.aria-labelledby]=\"labelId(category, key)\"\n class=\"flex min-w-0 flex-col gap-(--spacing-3xs)\"\n [class.col-span-full]=\"boolean || readOnlyValue\">\n @if (boolean) {\n <!-- A switch is not a labelable text field: the name goes through aria-labelledby. The\n value is saved as soon as it is flipped, so there is no edit mode. -->\n <div\n class=\"flex flex-row items-center gap-(--spacing-sm) rounded-(--radius-md) border border-solid border-(--stroke-primary-lighter) px-(--spacing-md) py-(--spacing-xs)\">\n <span\n [id]=\"labelId(category, key)\"\n class=\"grow\"\n [class]=\"labelClass\"\n >{{ label }}</span\n >\n @if (saved) {\n <span\n class=\"flex items-center gap-(--spacing-3xs) text-(length:--size-sm) text-(--font-success-base)\">\n <CircleCheckIcon aria-hidden=\"true\" />\n {{ \"userProfile.saved\" | transloco }}\n </span>\n } @else if (isDefault(category, key)) {\n <span [class]=\"chipClass\">{{\n \"userProfile.defaultValue\" | transloco\n }}</span>\n }\n <switch\n #toggle\n [checked]=\"booleanValue(category, key)\"\n [disabled]=\"profileForm().submitting()\"\n [aria-labelledby]=\"labelId(category, key)\"\n (checkedChange)=\"\n onToggleBoolean(category, key, $event, toggle)\n \" />\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n [title]=\"'userProfile.deleteProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco: { property: label }\n \"\n [disabled]=\"!hasStoredValue(category, key)\"\n (click)=\"onDeleteData(category, key)\">\n <TrashIcon />\n </button>\n </div>\n } @else if (readOnlyValue) {\n <!-- An array is shown, never edited: its shape is the administrator's. A galactik\n TagInput in read-only mode, which is what it is for; its inner text field is named\n after the property, as there is no <label for> to reach it. -->\n <span\n [id]=\"labelId(category, key)\"\n [class]=\"labelClass\"\n >{{ label }}</span\n >\n <tag-input\n class=\"[&_tag-remove]:hidden\"\n [value]=\"arrayTags(category, key)\"\n [readonly]=\"true\"\n [aria-label]=\"label\"\n [addLabel]=\"label\" />\n } @else {\n <label\n [id]=\"labelId(category, key)\"\n [for]=\"fieldId(category, key)\"\n [class]=\"labelClass\"\n >{{ label }}</label\n >\n <!-- The actions sit inside the box: galactik styles it from the control with has-[]\n selectors, which match descendants, so the focus ring and the read-only look\n keep working with the buttons next to the input. At rest the box is tinted,\n which is what tells a field from its label; it is white while edited. -->\n <!-- scheme: galactik colours the field itself for an error and for a save that just\n went through. The tint of a read-only field must not hide the success one, hence\n the data attribute and its more specific variant. -->\n <input-group\n class=\"has-[[readonly]]:bg-(--bg-primary-lighter) data-[state=success]:has-[[readonly]]:bg-(--bg-success-base-alt)\"\n [attr.data-state]=\"saved ? 'success' : null\"\n [scheme]=\"\n invalid ? 'error' : saved ? 'success' : 'default'\n \">\n <!-- No [attr.readonly] here: [formField] writes the readonly attribute itself from\n the field state, which the schema's readonly() rule drives. The native\n readonly attribute is what exposes aria-readonly — do not duplicate it. -->\n <input\n input-control\n type=\"text\"\n [id]=\"fieldId(category, key)\"\n [formField]=\"field\"\n [attr.placeholder]=\"\n 'userProfile.notDefined' | transloco\n \"\n [attr.aria-invalid]=\"invalid ? 'true' : null\"\n [attr.aria-describedby]=\"\n invalid ? errorId(category, key) : null\n \"\n [attr.aria-keyshortcuts]=\"editing ? 'Escape' : null\"\n (focus)=\"onFieldFocus($event)\"\n (keydown.escape)=\"onEscape($event, category, key)\" />\n <!-- Always visible: hiding them until hover made them unreachable on a touch screen\n and left nothing to discover. -->\n @if (!leaf) {\n <div class=\"flex shrink-0 flex-row items-center\">\n @if (editing) {\n <button\n type=\"submit\"\n variant=\"primary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"cursor-pointer\"\n [disabled]=\"profileForm().submitting()\"\n [title]=\"'userProfile.saveProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.savePropertyNamed'\n | transloco: { property: label }\n \">\n <save-icon />\n </button>\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"cursor-pointer\"\n [title]=\"'userProfile.cancelEdit' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.cancelEditNamed'\n | transloco: { property: label }\n \"\n (click)=\"onCancelEdit(category, key)\">\n <UndoIcon />\n </button>\n } @else {\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n [id]=\"editButtonId(category, key)\"\n class=\"cursor-pointer\"\n [title]=\"'userProfile.editProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.editPropertyNamed'\n | transloco: { property: label }\n \"\n (click)=\"onEdit(category, key)\">\n <EditIcon />\n </button>\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n [title]=\"\n 'userProfile.deleteProperty' | transloco\n \"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco: { property: label }\n \"\n [disabled]=\"!hasStoredValue(category, key)\"\n (click)=\"onDeleteData(category, key)\">\n <TrashIcon />\n </button>\n }\n </div>\n }\n </input-group>\n }\n <!-- Under the field, one message at a time: the error first, then the confirmation of a\n save, then what is worth knowing about the value. -->\n @if (invalid) {\n <p\n [id]=\"errorId(category, key)\"\n role=\"alert\"\n [class]=\"errorClass\">\n {{ errorKey(field) | transloco }}\n </p>\n } @else if (!boolean) {\n @if (saved) {\n <p\n class=\"m-0 flex items-center gap-(--spacing-3xs) text-(length:--size-sm) text-(--font-success-base)\">\n <CircleCheckIcon aria-hidden=\"true\" />\n {{ \"userProfile.saved\" | transloco }}\n </p>\n } @else if (readOnlyValue || leaf) {\n <p\n class=\"m-0 flex items-center gap-(--spacing-3xs) text-(length:--size-sm) text-(--font-neutral-muted)\">\n <LockIcon />\n {{ \"userProfile.lockedByAdmin\" | transloco }}\n </p>\n } @else if (editing) {\n <p\n class=\"m-0 text-(length:--size-sm) text-(--font-neutral-muted)\">\n {{ \"userProfile.editHint\" | transloco }}\n </p>\n } @else if (isDefault(category, key)) {\n <p class=\"m-0\">\n <span [class]=\"chipClass\">{{\n \"userProfile.defaultValue\" | transloco\n }}</span>\n </p>\n }\n }\n </div>\n }\n </div>\n </section>\n }\n </form>\n <!-- Always in the DOM, and only its text changes: a live region inserted at the same time as\n its content is not reliably announced. `sr-only` — the transitions it describes are\n already visible on screen. -->\n <p class=\"sr-only\" role=\"status\" aria-live=\"polite\">\n {{ statusMessage() }}\n </p>\n </div>\n }\n </div>\n} @else if (userProfileResource.isLoading()) {\n <p role=\"status\">{{ \"userProfile.loading\" | transloco }}</p>\n} @else if (userProfileResource.error()) {\n <p role=\"alert\">{{ \"userProfile.loadError\" | transloco }}</p>\n}\n\n<!-- AVATAR UPLOAD — `hidden` keeps it out of both the tab order and the accessibility tree, which is\n intended: it is opened by the avatar button above. It also sits outside the <form>. -->\n<input\n #avatarInput\n class=\"hidden\"\n type=\"file\"\n accept=\"image/*\"\n (change)=\"uploadAvatar($event)\" />\n"
40146
+ template: "@if (userProfileResource.hasValue()) {\n @let profile = user();\n <div class=\"flex min-h-0 flex-1 flex-col gap-2\" [class.p-4]=\"padded()\">\n <div class=\"mb-4 flex\">\n @if (allowProfilePhoto()) {\n @let profilePhoto = profile?.data?.profilePhoto;\n <!-- AVATAR — a real <button>: the avatar opens the file picker, so it has to be reachable and\n activatable with the keyboard, which a click handler on a <div> never is. -->\n <button\n type=\"button\"\n variant=\"none\"\n class=\"cursor-pointer rounded-full\"\n [title]=\"'userProfile.changePhoto' | transloco\"\n [attr.aria-label]=\"'userProfile.changePhoto' | transloco\"\n (click)=\"avatarInput.click()\">\n <Avatar fill=\"secondary\" class=\"size-14\">\n @if (profilePhoto) {\n <!-- alt=\"\": the button already carries the accessible name, the image would repeat it. -->\n <AvatarImage [src]=\"profilePhoto\" width=\"44\" height=\"44\" alt=\"\" />\n }\n <AvatarFallback class=\"size-full text-lg\">\n @if (initials()) {\n <span>{{ initials() }}</span>\n } @else {\n <UserIcon class=\"size-7\" />\n }\n </AvatarFallback>\n </Avatar>\n </button>\n @if (profilePhoto) {\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n [title]=\"'userProfile.deleteProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco\n : { property: 'userProfile.data.profilePhoto' | transloco }\n \"\n (click)=\"onDeleteData('data', 'profilePhoto')\">\n <XMarkIcon />\n </button>\n }\n }\n <div class=\"grow\"></div>\n <div class=\"flex flex-col gap-4\">\n <!-- CHANGE PASSWORD -->\n @if (allowChangePassword()) {\n <button\n type=\"button\"\n [disabled]=\"changingPassword()\"\n (click)=\"changingPassword.set(true)\">\n {{ \"login.changePassword\" | transloco }}\n </button>\n }\n </div>\n </div>\n @if (changingPassword()) {\n <!-- ChangePassword renders its own <form [formRoot]>: it stays in this branch, mutually\n exclusive with the profile form below, so the two never nest. -->\n <ChangePassword\n [username]=\"username()\"\n [redirectAfterSuccess]=\"false\"\n [redirectAfterCancel]=\"false\"\n (cancel)=\"changingPassword.set(false)\"\n (success)=\"changingPassword.set(false)\" />\n } @else {\n <!-- p: the focus outline of the fields is drawn *outside* their box, and this is the\n scrolling ancestor — without a gutter it clips the ring on every edge it touches\n (left for all of them, bottom for the last one, top for the first). -->\n <div class=\"min-h-0 flex-1 overflow-auto p-(--spacing-3xs)\">\n <!--\n [formRoot] pose novalidate, intercepte submit et appelle preventDefault() avant de déclencher\n submit() sur le field tree. Un seul bouton type=\"submit\" est rendu à la fois — celui de la\n ligne en cours d'édition — et l'action de soumission persiste cette seule propriété.\n -->\n <!-- aria-label: a <form> with no accessible name is not exposed as a form landmark, so the\n fields could not be reached as a whole. aria-busy holds the announcement back while a\n property is being persisted. -->\n <form\n class=\"@container\"\n [formRoot]=\"profileForm\"\n [attr.aria-label]=\"'userProfile.profileFields' | transloco\"\n [attr.aria-busy]=\"profileForm().submitting() ? 'true' : null\">\n @for (section of sections(); track section.id) {\n <section\n [attr.aria-labelledby]=\"'user-profile-section-' + section.id\"\n class=\"mb-(--spacing-md) flex flex-col gap-(--spacing-sm) rounded-(--radius-lg) border border-solid border-(--stroke-primary-lighter) p-(--spacing-md)\">\n <div>\n <h3\n [id]=\"'user-profile-section-' + section.id\"\n class=\"m-0 text-(length:--size-base) font-semibold\">\n {{ \"userProfile.sections.\" + section.id | transloco }}\n </h3>\n <p\n class=\"m-0 text-(length:--size-sm) text-(--font-neutral-muted)\">\n {{\n \"userProfile.sections.\" + section.id + \"Hint\" | transloco\n }}\n </p>\n </div>\n <div\n class=\"grid grid-cols-1 gap-x-(--spacing-md) gap-y-(--spacing-sm) @lg:grid-cols-2\">\n @for (\n property of section.rows;\n track property.category + \".\" + property.key\n ) {\n @let category = property.category;\n @let key = property.key;\n @let field = profileForm[category][key];\n @let label = labelFor(category, key);\n @let editing = isEditing(category, key);\n @let invalid = showError(field);\n @let boolean = isBoolean(category, key);\n @let readOnlyValue = isReadOnlyValue(category, key);\n @let leaf = isLeaf(category, key);\n @let saved = isSaved(category, key);\n <!-- role=\"group\" named by the label: without it the action buttons of a row are\n announced out of context, as a flat run of \"Edit …\" / \"Delete …\" with nothing tying\n them to the property they belong to. A switch, and a value shown but not edited,\n take the whole width of the grid: they are not a field to line up with another. -->\n <div\n role=\"group\"\n [attr.aria-labelledby]=\"labelId(category, key)\"\n class=\"flex min-w-0 flex-col gap-(--spacing-3xs)\"\n [class.col-span-full]=\"boolean || readOnlyValue\">\n @if (boolean) {\n <!-- A switch is not a labelable text field: the name goes through aria-labelledby. The\n value is saved as soon as it is flipped, so there is no edit mode. -->\n <div\n class=\"flex flex-row items-center gap-(--spacing-sm) rounded-(--radius-md) border border-solid border-(--stroke-primary-lighter) px-(--spacing-md) py-(--spacing-xs)\">\n <span\n [id]=\"labelId(category, key)\"\n class=\"grow\"\n [class]=\"labelClass\"\n >{{ label }}</span\n >\n @if (saved) {\n <span\n class=\"flex items-center gap-(--spacing-3xs) text-(length:--size-sm) text-(--font-success-base)\">\n <CircleCheckIcon aria-hidden=\"true\" />\n {{ \"userProfile.saved\" | transloco }}\n </span>\n } @else if (isDefault(category, key)) {\n <span [class]=\"chipClass\">{{\n \"userProfile.defaultValue\" | transloco\n }}</span>\n }\n <switch\n #toggle\n [checked]=\"booleanValue(category, key)\"\n [disabled]=\"profileForm().submitting()\"\n [aria-labelledby]=\"labelId(category, key)\"\n (checkedChange)=\"\n onToggleBoolean(category, key, $event, toggle)\n \" />\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n [title]=\"'userProfile.deleteProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco: { property: label }\n \"\n [disabled]=\"!hasStoredValue(category, key)\"\n (click)=\"onDeleteData(category, key)\">\n <XMarkIcon />\n </button>\n </div>\n } @else if (readOnlyValue) {\n <!-- An array is shown, never edited: its shape is the administrator's. A galactik\n TagInput in read-only mode, which is what it is for; its inner text field is named\n after the property, as there is no <label for> to reach it. -->\n <span\n [id]=\"labelId(category, key)\"\n [class]=\"labelClass\"\n >{{ label }}</span\n >\n <tag-input\n class=\"[&_tag-remove]:hidden\"\n [value]=\"arrayTags(category, key)\"\n [readonly]=\"true\"\n [aria-label]=\"label\"\n [addLabel]=\"label\" />\n } @else {\n <label\n [id]=\"labelId(category, key)\"\n [for]=\"fieldId(category, key)\"\n [class]=\"labelClass\"\n >{{ label }}</label\n >\n <!-- The actions sit inside the box: galactik styles it from the control with has-[]\n selectors, which match descendants, so the focus ring and the read-only look\n keep working with the buttons next to the input. At rest the box is tinted,\n which is what tells a field from its label; it is white while edited. -->\n <!-- scheme: galactik colours the field itself for an error and for a save that just\n went through. The tint of a read-only field must not hide the success one, hence\n the data attribute and its more specific variant. -->\n <input-group\n class=\"has-[[readonly]]:bg-(--bg-primary-lighter) data-[state=success]:has-[[readonly]]:bg-(--bg-success-base-alt)\"\n [attr.data-state]=\"saved ? 'success' : null\"\n [scheme]=\"\n invalid ? 'error' : saved ? 'success' : 'default'\n \">\n <!-- No [attr.readonly] here: [formField] writes the readonly attribute itself from\n the field state, which the schema's readonly() rule drives. The native\n readonly attribute is what exposes aria-readonly — do not duplicate it. -->\n <input\n input-control\n type=\"text\"\n [id]=\"fieldId(category, key)\"\n [formField]=\"field\"\n [attr.placeholder]=\"\n 'userProfile.notDefined' | transloco\n \"\n [attr.aria-invalid]=\"invalid ? 'true' : null\"\n [attr.aria-describedby]=\"\n invalid ? errorId(category, key) : null\n \"\n [attr.aria-keyshortcuts]=\"editing ? 'Escape' : null\"\n (focus)=\"onFieldFocus($event)\"\n (keydown.escape)=\"onEscape($event, category, key)\" />\n <!-- Always visible: hiding them until hover made them unreachable on a touch screen\n and left nothing to discover. -->\n @if (!leaf) {\n <div class=\"flex shrink-0 flex-row items-center\">\n @if (editing) {\n <button\n type=\"submit\"\n variant=\"primary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"cursor-pointer\"\n [disabled]=\"profileForm().submitting()\"\n [title]=\"'userProfile.saveProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.savePropertyNamed'\n | transloco: { property: label }\n \">\n <save-icon />\n </button>\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"cursor-pointer\"\n [title]=\"'userProfile.cancelEdit' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.cancelEditNamed'\n | transloco: { property: label }\n \"\n (click)=\"onCancelEdit(category, key)\">\n <UndoIcon />\n </button>\n } @else {\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n [id]=\"editButtonId(category, key)\"\n class=\"cursor-pointer\"\n [title]=\"'userProfile.editProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.editPropertyNamed'\n | transloco: { property: label }\n \"\n (click)=\"onEdit(category, key)\">\n <EditIcon />\n </button>\n <button\n type=\"button\"\n variant=\"tertiary\"\n size=\"sm\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n [title]=\"\n 'userProfile.deleteProperty' | transloco\n \"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco: { property: label }\n \"\n [disabled]=\"!hasStoredValue(category, key)\"\n (click)=\"onDeleteData(category, key)\">\n <XMarkIcon />\n </button>\n }\n </div>\n }\n </input-group>\n }\n <!-- Under the field, one message at a time: the error first, then the confirmation of a\n save, then what is worth knowing about the value. -->\n @if (invalid) {\n <p\n [id]=\"errorId(category, key)\"\n role=\"alert\"\n [class]=\"errorClass\">\n {{ errorKey(field) | transloco }}\n </p>\n } @else if (!boolean) {\n @if (saved) {\n <p\n class=\"m-0 flex items-center gap-(--spacing-3xs) text-(length:--size-sm) text-(--font-success-base)\">\n <CircleCheckIcon aria-hidden=\"true\" />\n {{ \"userProfile.saved\" | transloco }}\n </p>\n } @else if (readOnlyValue || leaf) {\n <p\n class=\"m-0 flex items-center gap-(--spacing-3xs) text-(length:--size-sm) text-(--font-neutral-muted)\">\n <LockIcon />\n {{ \"userProfile.lockedByAdmin\" | transloco }}\n </p>\n } @else if (editing) {\n <p\n class=\"m-0 text-(length:--size-sm) text-(--font-neutral-muted)\">\n {{ \"userProfile.editHint\" | transloco }}\n </p>\n } @else if (isDefault(category, key)) {\n <p class=\"m-0\">\n <span [class]=\"chipClass\">{{\n \"userProfile.defaultValue\" | transloco\n }}</span>\n </p>\n }\n }\n </div>\n }\n </div>\n </section>\n }\n </form>\n <!-- Always in the DOM, and only its text changes: a live region inserted at the same time as\n its content is not reliably announced. `sr-only` — the transitions it describes are\n already visible on screen. -->\n <p class=\"sr-only\" role=\"status\" aria-live=\"polite\">\n {{ statusMessage() }}\n </p>\n </div>\n }\n </div>\n} @else if (userProfileResource.isLoading()) {\n <p role=\"status\">{{ \"userProfile.loading\" | transloco }}</p>\n} @else if (userProfileResource.error()) {\n <p role=\"alert\">{{ \"userProfile.loadError\" | transloco }}</p>\n}\n\n<!-- AVATAR UPLOAD — `hidden` keeps it out of both the tab order and the accessibility tree, which is\n intended: it is opened by the avatar button above. It also sits outside the <form>. -->\n<input\n #avatarInput\n class=\"hidden\"\n type=\"file\"\n accept=\"image/*\"\n (change)=\"uploadAvatar($event)\" />\n"
39345
40147
  }]
39346
40148
  }],
39347
- ctorParameters: () => []
40149
+ ctorParameters: () => [],
40150
+ propDecorators: { padded: [{
40151
+ type: i0.Input,
40152
+ args: [{
40153
+ isSignal: true,
40154
+ alias: "padded",
40155
+ required: false
40156
+ }]
40157
+ }] }
39348
40158
  });
39349
40159
  function toRecord(keys, source) {
39350
40160
  return Object.fromEntries(keys.map((key) => [key, toFieldText(source?.[key])]));
@@ -39417,7 +40227,7 @@ var SettingsAccountPanelComponent = class SettingsAccountPanelComponent {
39417
40227
  <p class="text-(--font-primary-muted)">{{ "settings.account.description" | transloco }}</p>
39418
40228
 
39419
40229
  @if (profileEnabled()) {
39420
- <user-profile-form class="mt-(--spacing-md) min-h-0 flex-1" />
40230
+ <user-profile-form class="mt-(--spacing-md) min-h-0 flex-1" [padded]="false" />
39421
40231
  } @else if (allowChangePassword()) {
39422
40232
  <!-- No profile to show: the password is all the account offers, as it was in the user menu. -->
39423
40233
  <change-password
@@ -39488,7 +40298,8 @@ var SettingsAccountPanelComponent = class SettingsAccountPanelComponent {
39488
40298
  {
39489
40299
  kind: "component",
39490
40300
  type: UserProfileFormComponent,
39491
- selector: "user-profile-form, UserProfileForm, userprofileform"
40301
+ selector: "user-profile-form, UserProfileForm, userprofileform",
40302
+ inputs: ["padded"]
39492
40303
  },
39493
40304
  {
39494
40305
  kind: "component",
@@ -39537,7 +40348,7 @@ i0.ɵɵngDeclareClassMetadata({
39537
40348
  <p class="text-(--font-primary-muted)">{{ "settings.account.description" | transloco }}</p>
39538
40349
 
39539
40350
  @if (profileEnabled()) {
39540
- <user-profile-form class="mt-(--spacing-md) min-h-0 flex-1" />
40351
+ <user-profile-form class="mt-(--spacing-md) min-h-0 flex-1" [padded]="false" />
39541
40352
  } @else if (allowChangePassword()) {
39542
40353
  <!-- No profile to show: the password is all the account offers, as it was in the user menu. -->
39543
40354
  <change-password
@@ -40014,6 +40825,17 @@ i0.ɵɵngDeclareClassMetadata({
40014
40825
  }]
40015
40826
  }]
40016
40827
  });
40828
+ function revealInStrip(strip, item, behavior = "smooth") {
40829
+ const stripBox = strip.getBoundingClientRect();
40830
+ const itemBox = item.getBoundingClientRect();
40831
+ const scale = strip.offsetWidth ? stripBox.width / strip.offsetWidth : 1;
40832
+ const delta = (itemBox.left - stripBox.left - (stripBox.width - itemBox.width) / 2) / scale;
40833
+ if (Math.abs(delta) < 1) return;
40834
+ strip.scrollBy({
40835
+ left: delta,
40836
+ behavior
40837
+ });
40838
+ }
40017
40839
  var SettingsShortcutsPanelComponent = class SettingsShortcutsPanelComponent {
40018
40840
  static ɵfac = i0.ɵɵngDeclareFactory({
40019
40841
  minVersion: "12.0.0",
@@ -40071,6 +40893,8 @@ let active;
40071
40893
  var SettingsDialogComponent = class SettingsDialogComponent {
40072
40894
  call = injectCallRef();
40073
40895
  dialog = viewChild(DialogComponent, ...ngDevMode ? [{ debugName: "dialog" }] : /* istanbul ignore next */ []);
40896
+ strip = viewChild("strip", ...ngDevMode ? [{ debugName: "strip" }] : /* istanbul ignore next */ []);
40897
+ stripPositioned = false;
40074
40898
  items = [
40075
40899
  {
40076
40900
  id: "account",
@@ -40090,7 +40914,26 @@ var SettingsDialogComponent = class SettingsDialogComponent {
40090
40914
  }
40091
40915
  ];
40092
40916
  panel = signal(this.call.props()?.panel ?? "account", ...ngDevMode ? [{ debugName: "panel" }] : /* istanbul ignore next */ []);
40917
+ keyboardAvailable = injectKeyboardAvailable();
40918
+ visibleItems = computed(() => this.items.filter((item) => item.id !== "shortcuts" || this.keyboardAvailable() || this.panel() === "shortcuts"), ...ngDevMode ? [{ debugName: "visibleItems" }] : /* istanbul ignore next */ []);
40093
40919
  navigated = signal(false, ...ngDevMode ? [{ debugName: "navigated" }] : /* istanbul ignore next */ []);
40920
+ resizeObserver;
40921
+ reveal(strip, current) {
40922
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
40923
+ revealInStrip(strip, current, this.stripPositioned && !reduced ? "smooth" : "auto");
40924
+ this.stripPositioned = true;
40925
+ }
40926
+ revealWhenLaidOut(strip) {
40927
+ if (this.resizeObserver || typeof ResizeObserver === "undefined") return;
40928
+ this.resizeObserver = new ResizeObserver(() => {
40929
+ if (!strip.clientWidth) return;
40930
+ this.resizeObserver?.disconnect();
40931
+ this.resizeObserver = void 0;
40932
+ const current = strip.querySelector("[aria-current=\"page\"]");
40933
+ if (current) this.reveal(strip, current);
40934
+ });
40935
+ this.resizeObserver.observe(strip);
40936
+ }
40094
40937
  navigate(id) {
40095
40938
  this.navigated.set(true);
40096
40939
  this.panel.set(id);
@@ -40103,7 +40946,18 @@ var SettingsDialogComponent = class SettingsDialogComponent {
40103
40946
  return;
40104
40947
  }
40105
40948
  active = this;
40949
+ afterRenderEffect({ read: () => {
40950
+ this.panel();
40951
+ this.visibleItems();
40952
+ if (!this.dialog()?.isOpen()) return;
40953
+ const strip = this.strip()?.nativeElement;
40954
+ const current = strip?.querySelector("[aria-current=\"page\"]");
40955
+ if (!strip || !current) return;
40956
+ if (!strip.clientWidth) this.revealWhenLaidOut(strip);
40957
+ else this.reveal(strip, current);
40958
+ } });
40106
40959
  inject(DestroyRef).onDestroy(() => {
40960
+ this.resizeObserver?.disconnect();
40107
40961
  if (active === this) active = void 0;
40108
40962
  });
40109
40963
  afterNextRender(() => this.dialog()?.open());
@@ -40129,6 +40983,12 @@ var SettingsDialogComponent = class SettingsDialogComponent {
40129
40983
  predicate: DialogComponent,
40130
40984
  descendants: true,
40131
40985
  isSignal: true
40986
+ }, {
40987
+ propertyName: "strip",
40988
+ first: true,
40989
+ predicate: ["strip"],
40990
+ descendants: true,
40991
+ isSignal: true
40132
40992
  }],
40133
40993
  ngImport: i0,
40134
40994
  template: `
@@ -40136,12 +40996,14 @@ var SettingsDialogComponent = class SettingsDialogComponent {
40136
40996
  <div dialog #dialog="dialog" [closeLabel]="'settings.close' | transloco" (closed)="call.end($event)">
40137
40997
  <!-- The inset layout of the sidebar, in a dialog: the navigation sits on the tinted surface of the
40138
40998
  dialog itself and the panel is a white card set into it. -->
40139
- <DialogContent size="xl" class="h-[min(48rem,calc(100dvh-2rem))] flex-row! items-stretch! bg-(--bg-primary-lighter)">
40140
- <div class="flex w-64 shrink-0 flex-col gap-(--spacing-3xs) p-(--spacing-sm)">
40999
+ <!-- Under md (a phone) the navigation does not fit beside the panel, which was left 120px wide: it becomes a strip on top, that scrolls
41000
+ sideways, and the panel takes the rest of the height. -->
41001
+ <DialogContent size="xl" class="h-[min(48rem,calc(100dvh-2rem))] flex-row! items-stretch! bg-(--bg-primary-lighter) max-md:flex-col!">
41002
+ <div class="flex w-64 shrink-0 flex-col gap-(--spacing-3xs) p-(--spacing-sm) max-md:w-full max-md:flex-row max-md:items-center max-md:gap-(--spacing-2xs) max-md:p-(--spacing-xs)">
40141
41003
  <button
40142
41004
  type="button"
40143
41005
  variant="none"
40144
- class="modal-close mb-(--spacing-sm)"
41006
+ class="modal-close mb-(--spacing-sm) max-md:mb-0 max-md:shrink-0"
40145
41007
  [attr.aria-label]="'settings.close' | transloco"
40146
41008
  (click)="dialog.close()">
40147
41009
  <XMarkIcon aria-hidden="true" />
@@ -40149,12 +41011,15 @@ var SettingsDialogComponent = class SettingsDialogComponent {
40149
41011
 
40150
41012
  <!-- Panels are a navigation, not a tablist: each one is a button, the current one is
40151
41013
  flagged with aria-current, which is what a screen reader reads out. -->
40152
- <nav [attr.aria-label]="'settings.title' | transloco" class="flex flex-col gap-(--spacing-3xs)">
40153
- @for (item of items; track item.id) {
41014
+ <nav
41015
+ #strip
41016
+ [attr.aria-label]="'settings.title' | transloco"
41017
+ class="flex flex-col gap-(--spacing-3xs) max-md:min-w-0 max-md:flex-1 max-md:flex-row max-md:overflow-x-auto max-md:[scrollbar-width:none]">
41018
+ @for (item of visibleItems(); track item.id) {
40154
41019
  <button
40155
41020
  type="button"
40156
41021
  variant="none"
40157
- class="flex h-(--row-height-md) w-full cursor-pointer items-center justify-start gap-(--spacing-xs) rounded-(--radius-sm) border-0 bg-transparent px-(--spacing-xs) text-left text-(--layer-page-font) transition-colors hover:bg-(--bg-primary-light) focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-(--stroke-focus) aria-[current=page]:bg-(--bg-primary-base) aria-[current=page]:text-(--font-neutral-white) aria-[current=page]:hover:bg-(--bg-primary-base)"
41022
+ class="flex h-(--row-height-md) w-full max-md:w-auto max-md:shrink-0 cursor-pointer items-center justify-start gap-(--spacing-xs) rounded-(--radius-sm) border-0 bg-transparent px-(--spacing-xs) text-left text-(--layer-page-font) transition-colors hover:bg-(--bg-primary-light) focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-(--stroke-focus) aria-[current=page]:bg-(--bg-primary-base) aria-[current=page]:text-(--font-neutral-white) aria-[current=page]:hover:bg-(--bg-primary-base)"
40158
41023
  [attr.aria-current]="panel() === item.id ? 'page' : null"
40159
41024
  (click)="navigate(item.id)">
40160
41025
  @switch (item.id) {
@@ -40177,7 +41042,7 @@ var SettingsDialogComponent = class SettingsDialogComponent {
40177
41042
  </nav>
40178
41043
  </div>
40179
41044
 
40180
- <div sidebarInset class="my-(--spacing-xs) mr-(--spacing-xs) overflow-y-auto rounded-(--radius-2xl) bg-(--bg-neutral-white) p-(--spacing-xl) shadow-none">
41045
+ <div sidebarInset class="my-(--spacing-xs) mr-(--spacing-xs) min-h-0 overflow-y-auto max-md:m-(--spacing-xs) max-md:mt-0 max-md:min-w-0 max-md:flex-1 rounded-(--radius-2xl) bg-(--bg-neutral-white) p-(--spacing-xl) shadow-none">
40181
41046
  @switch (panel()) {
40182
41047
  @case ("account") {
40183
41048
  <settings-account-panel class="flex min-h-0 flex-1 flex-col" [focusOnOpen]="!navigated()" />
@@ -40326,12 +41191,14 @@ i0.ɵɵngDeclareClassMetadata({
40326
41191
  <div dialog #dialog="dialog" [closeLabel]="'settings.close' | transloco" (closed)="call.end($event)">
40327
41192
  <!-- The inset layout of the sidebar, in a dialog: the navigation sits on the tinted surface of the
40328
41193
  dialog itself and the panel is a white card set into it. -->
40329
- <DialogContent size="xl" class="h-[min(48rem,calc(100dvh-2rem))] flex-row! items-stretch! bg-(--bg-primary-lighter)">
40330
- <div class="flex w-64 shrink-0 flex-col gap-(--spacing-3xs) p-(--spacing-sm)">
41194
+ <!-- Under md (a phone) the navigation does not fit beside the panel, which was left 120px wide: it becomes a strip on top, that scrolls
41195
+ sideways, and the panel takes the rest of the height. -->
41196
+ <DialogContent size="xl" class="h-[min(48rem,calc(100dvh-2rem))] flex-row! items-stretch! bg-(--bg-primary-lighter) max-md:flex-col!">
41197
+ <div class="flex w-64 shrink-0 flex-col gap-(--spacing-3xs) p-(--spacing-sm) max-md:w-full max-md:flex-row max-md:items-center max-md:gap-(--spacing-2xs) max-md:p-(--spacing-xs)">
40331
41198
  <button
40332
41199
  type="button"
40333
41200
  variant="none"
40334
- class="modal-close mb-(--spacing-sm)"
41201
+ class="modal-close mb-(--spacing-sm) max-md:mb-0 max-md:shrink-0"
40335
41202
  [attr.aria-label]="'settings.close' | transloco"
40336
41203
  (click)="dialog.close()">
40337
41204
  <XMarkIcon aria-hidden="true" />
@@ -40339,12 +41206,15 @@ i0.ɵɵngDeclareClassMetadata({
40339
41206
 
40340
41207
  <!-- Panels are a navigation, not a tablist: each one is a button, the current one is
40341
41208
  flagged with aria-current, which is what a screen reader reads out. -->
40342
- <nav [attr.aria-label]="'settings.title' | transloco" class="flex flex-col gap-(--spacing-3xs)">
40343
- @for (item of items; track item.id) {
41209
+ <nav
41210
+ #strip
41211
+ [attr.aria-label]="'settings.title' | transloco"
41212
+ class="flex flex-col gap-(--spacing-3xs) max-md:min-w-0 max-md:flex-1 max-md:flex-row max-md:overflow-x-auto max-md:[scrollbar-width:none]">
41213
+ @for (item of visibleItems(); track item.id) {
40344
41214
  <button
40345
41215
  type="button"
40346
41216
  variant="none"
40347
- class="flex h-(--row-height-md) w-full cursor-pointer items-center justify-start gap-(--spacing-xs) rounded-(--radius-sm) border-0 bg-transparent px-(--spacing-xs) text-left text-(--layer-page-font) transition-colors hover:bg-(--bg-primary-light) focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-(--stroke-focus) aria-[current=page]:bg-(--bg-primary-base) aria-[current=page]:text-(--font-neutral-white) aria-[current=page]:hover:bg-(--bg-primary-base)"
41217
+ class="flex h-(--row-height-md) w-full max-md:w-auto max-md:shrink-0 cursor-pointer items-center justify-start gap-(--spacing-xs) rounded-(--radius-sm) border-0 bg-transparent px-(--spacing-xs) text-left text-(--layer-page-font) transition-colors hover:bg-(--bg-primary-light) focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-(--stroke-focus) aria-[current=page]:bg-(--bg-primary-base) aria-[current=page]:text-(--font-neutral-white) aria-[current=page]:hover:bg-(--bg-primary-base)"
40348
41218
  [attr.aria-current]="panel() === item.id ? 'page' : null"
40349
41219
  (click)="navigate(item.id)">
40350
41220
  @switch (item.id) {
@@ -40367,7 +41237,7 @@ i0.ɵɵngDeclareClassMetadata({
40367
41237
  </nav>
40368
41238
  </div>
40369
41239
 
40370
- <div sidebarInset class="my-(--spacing-xs) mr-(--spacing-xs) overflow-y-auto rounded-(--radius-2xl) bg-(--bg-neutral-white) p-(--spacing-xl) shadow-none">
41240
+ <div sidebarInset class="my-(--spacing-xs) mr-(--spacing-xs) min-h-0 overflow-y-auto max-md:m-(--spacing-xs) max-md:mt-0 max-md:min-w-0 max-md:flex-1 rounded-(--radius-2xl) bg-(--bg-neutral-white) p-(--spacing-xl) shadow-none">
40371
41241
  @switch (panel()) {
40372
41242
  @case ("account") {
40373
41243
  <settings-account-panel class="flex min-h-0 flex-1 flex-col" [focusOnOpen]="!navigated()" />
@@ -40390,10 +41260,16 @@ i0.ɵɵngDeclareClassMetadata({
40390
41260
  }]
40391
41261
  }],
40392
41262
  ctorParameters: () => [],
40393
- propDecorators: { dialog: [{
40394
- type: i0.ViewChild,
40395
- args: [i0.forwardRef(() => DialogComponent), { isSignal: true }]
40396
- }] }
41263
+ propDecorators: {
41264
+ dialog: [{
41265
+ type: i0.ViewChild,
41266
+ args: [i0.forwardRef(() => DialogComponent), { isSignal: true }]
41267
+ }],
41268
+ strip: [{
41269
+ type: i0.ViewChild,
41270
+ args: ["strip", { isSignal: true }]
41271
+ }]
41272
+ }
40397
41273
  });
40398
41274
  const Settings = createCallable(SettingsDialogComponent);
40399
41275
  const auditInterceptorFn = (request, next) => {