@sinequa/atomic-angular 2.6.0 → 2.8.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,11 +2,11 @@ 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, 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, 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, BookmarkIcon, BreakpointObserverService, ButtonComponent, CalendarDayIcon, CheckIcon, CheckboxComponent, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon, CircleCheckIcon, CommentIcon, CommentsIcon, DIALOG_REF, DesktopIcon, DialogBodyComponent, DialogComponent, DialogContentComponent, DialogDescriptionComponent, DialogFooterComponent, DialogHeaderComponent, DialogTitleComponent, DropdownDirective, EditIcon, EllipsisIcon, EllipsisVerticalIcon, EyeIcon, EyeSlashIcon, FileHTMLIcon, FileIcon, FileOutputIcon, FileSpreadsheetIcon, FileTextIcon, FilterIcon, FilterXIcon, FlagEnglishIcon, FlagFrenchIcon, FlagGermanIcon, FlashlightIcon, FolderIcon, FolderOpenIcon, FrownIcon, GearIcon, GitForkIcon, GlobeIcon, HighlighterIcon, HistoryIcon, HomeIcon, ImageIcon, InboxIcon, InfoCircleIcon, InputComponent, InputControlDirective, InputIconRightDirective, KbdComponent, KbdGroupComponent, KeyIcon, KeyboardIcon, LanguageIcon, LightbulbIcon, LightbulbSlashIcon, LinkComponent, ListComponent, ListFilterIcon, ListItemActionComponent, ListItemAvatarDirective, ListItemComponent, ListItemLeftDirective, ListItemTextComponent, LoadingCircleIcon, MagnifyingGlassIcon, MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon, MenuComponent, MenuContentComponent, MenuItemComponent, MenuTriggerDirective, MoonIcon, PaletteIcon, PaperclipIcon, PenToSquareIcon, PlusIcon, PopoverDirective, PresentationIcon, QuestionCircleIcon, RadioComponent, RobotIcon, SaveIcon, SearchBarComponent, SearchInputComponent, SelectComponent, SelectTriggerDirective, SendIcon, SheetBodyComponent, SheetComponent, SheetHeaderComponent, SlashIcon, SlidersIcon, SparklesIcon, SpinnerIcon, SquareCheckIcon, SquareIcon, SquareMinusIcon, StarIcon, SunBrightIcon, SwitchComponent, SyncIcon, TabComponent, TabListComponent, TabPanelComponent, TabsComponent, TagComponent, TagIcon, TagsIcon, TextareaComponent, TextareaControlDirective, ThumbsDownIcon, ThumbsUpIcon, ToggleComponent, ToggleGroupComponent, ToggleItemComponent, ToggleLeftIcon, ToggleRightIcon, TooltipDirective, TrashCanIcon, TrashIcon, TreeComponent, TreeItemComponent, TreeItemExpanderComponent, TreeItemGroupComponent, TriangleAlertIcon, UndoIcon, UserIcon, UserSecretIcon, VideoIcon, XMarkIcon, createCallable, injectCallRef, 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, ButtonComponent, CalendarDayIcon, CheckIcon, CheckboxComponent, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon, CircleCheckIcon, CommentIcon, CommentsIcon, DIALOG_REF, DesktopIcon, DialogBodyComponent, DialogComponent, DialogContentComponent, DialogDescriptionComponent, DialogFooterComponent, DialogHeaderComponent, DialogTitleComponent, DropdownDirective, EditIcon, EllipsisIcon, EllipsisVerticalIcon, EyeIcon, EyeSlashIcon, FileHTMLIcon, FileIcon, FileOutputIcon, FileSpreadsheetIcon, FileTextIcon, FilterIcon, FilterXIcon, 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, ListComponent, ListFilterIcon, ListItemActionComponent, ListItemAvatarDirective, ListItemComponent, ListItemLeftDirective, ListItemTextComponent, LoadingCircleIcon, LockIcon, MagnifyingGlassIcon, MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon, MenuComponent, MenuContentComponent, MenuItemComponent, MenuTriggerDirective, MoonIcon, PaletteIcon, PaperclipIcon, PenToSquareIcon, PlusIcon, PopoverDirective, PresentationIcon, QuestionCircleIcon, RadioComponent, RobotIcon, SaveIcon, SearchBarComponent, 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, ToggleComponent, ToggleGroupComponent, ToggleItemComponent, ToggleLeftIcon, ToggleRightIcon, TooltipDirective, TrashCanIcon, TrashIcon, TreeComponent, TreeItemComponent, TreeItemExpanderComponent, TreeItemGroupComponent, TriangleAlertIcon, TruncatedDirective, UndoIcon, UserIcon, UserSecretIcon, VideoIcon, XMarkIcon, createCallable, injectCallRef, isApplePlatform } from "@sinequa/galactik";
6
6
  import { withDevtools } from "@angular-architects/ngrx-toolkit";
7
7
  import { getState, patchState, signalStore, signalStoreFeature, withComputed, withMethods, withState } from "@ngrx/signals";
8
8
  import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterModule } from "@angular/router";
9
- import { Audit, AuditEventType, EngineType, addAuditAdditionalInfo, addConcepts, aggItemRegex, bisect, buildPathsAndLevels, createAtomicClient, createHeaders, debug, error, escapeExpr, expiresSoon, extraColumns, filter as filter$1, flattenLegacyFilterValues, getDefaultClient, getMetadata, getQueryParamsFromUrl, getRelativeDate, getToken, getUrlParamsFromQueryParams, globalConfig, info, isExpired, isExternalAuthMode, isJsonable, isNotInputEvent, isObject, notify, parseValueAndOperatorFromItem, queryParamsFromUrl, resolveBearerToken, resolveOAuthProvider, resolveSAMLProvider, resultPages, setToken, suggestionsToTreeAggregationNodes, sysLang, translateAggregationToDateOptions, warn } from "@sinequa/atomic";
9
+ import { Audit, AuditEventType, EngineType, addAuditAdditionalInfo, addConcepts, aggItemRegex, bisect, buildPathsAndLevels, createAtomicClient, createHeaders, debug, error, escapeExpr, expiresSoon, extraColumns, filter as filter$1, flattenLegacyFilterValues, getDefaultClient, getMetadata, getQueryParamsFromUrl, getRelativeDate, getToken, getUrlParamsFromQueryParams, globalConfig, info, isExpired, isExternalAuthMode, isJsonable, isNotInputEvent, isObject, notify, parseValueAndOperatorFromItem, resolveBearerToken, resolveOAuthProvider, resolveSAMLProvider, resultPages, setToken, suggestionsToTreeAggregationNodes, sysLang, translateAggregationToDateOptions, warn } from "@sinequa/atomic";
10
10
  import { DomSanitizer, Title } from "@angular/platform-browser";
11
11
  import MarkdownIt from "markdown-it";
12
12
  import { DATE_PIPE_DEFAULT_OPTIONS, DATE_PIPE_DEFAULT_TIMEZONE, DatePipe, Location, NgComponentOutlet, NgStyle, NgTemplateOutlet } from "@angular/common";
@@ -18,8 +18,6 @@ import { FormField, FormRoot, applyEach, form, readonly, required, schema, submi
18
18
  import { injectVirtualizer } from "@tanstack/angular-virtual";
19
19
  import { catchError as catchError$1, filter as filter$2, map as map$1, switchMap as switchMap$1 } from "rxjs/operators";
20
20
  import { Tab } from "@angular/aria/tabs";
21
- import * as i1 from "@angular/cdk/drag-drop";
22
- import { DragDropModule } from "@angular/cdk/drag-drop";
23
21
  import { A11yModule } from "@angular/cdk/a11y";
24
22
  import { Calendar, months } from "vanilla-calendar-pro";
25
23
  import { getDate, getDateString } from "vanilla-calendar-pro/utils";
@@ -296,8 +294,9 @@ function withAppFeatures() {
296
294
  };
297
295
  },
298
296
  getQueryByName(name) {
297
+ if (!name) return void 0;
299
298
  const queries = store.queries();
300
- return Object.values(queries).find((query) => query.name.toLowerCase() === name.toLowerCase());
299
+ return Object.values(queries).find((query) => query.name?.toLowerCase() === name.toLowerCase());
301
300
  },
302
301
  getQueryByIndex(index) {
303
302
  const queries = store.queries();
@@ -805,6 +804,9 @@ var QueryService = class QueryService {
805
804
  result = signal({}, ...ngDevMode ? [{ debugName: "result" }] : /* istanbul ignore next */ []);
806
805
  failure = computed(() => this.result().$error, ...ngDevMode ? [{ debugName: "failure" }] : /* istanbul ignore next */ []);
807
806
  audit;
807
+ warnEmptySearch() {
808
+ firstValueFrom(this.transloco.selectTranslate("emptySearch", {}, "searches")).catch(() => "Please enter a search query.").then((message) => notify.warning(message));
809
+ }
808
810
  search(q, includeQueryParams = true, audit) {
809
811
  const $auditRecord = { auditEvents: [audit ?? this.audit ?? {
810
812
  type: "Search_Text",
@@ -820,7 +822,7 @@ var QueryService = class QueryService {
820
822
  if (!((this.queryParamsStore.filters?.() || []).length > 0) && !query.basket && query.action !== "open") {
821
823
  if (this.appStore.allowEmptySearch(query?.name || "") === false && query?.text === "") {
822
824
  warn("QueryService.search: Empty search query is not allowed. Returning empty result.");
823
- notify.warning(this.transloco.translate("searches.emptySearch"));
825
+ this.warnEmptySearch();
824
826
  const emptyResult = { records: [] };
825
827
  this.result.set(emptyResult);
826
828
  return of(emptyResult);
@@ -1370,9 +1372,8 @@ function withAlertsFeatures() {
1370
1372
  });
1371
1373
  },
1372
1374
  async updateAlert(alert, index) {
1373
- const alerts = store.alerts();
1374
- if (index < 0 || index >= alerts.length) return;
1375
- alerts.splice(index, 1, alert);
1375
+ if (index < 0 || index >= store.alerts().length) return;
1376
+ const alerts = store.alerts().with(index, alert);
1376
1377
  await patch({ alerts }, {
1377
1378
  type: "Alert_Update",
1378
1379
  detail: { alert: alert.name }
@@ -1386,8 +1387,8 @@ function withAlertsFeatures() {
1386
1387
  },
1387
1388
  async updateAlerts(alerts) {
1388
1389
  await patch({ alerts }, {
1389
- type: "Alert_Update",
1390
- detail: { alert: alert.name }
1390
+ type: "Alerts_Update",
1391
+ detail: { alerts: alerts.map((a) => a.name).join(", ") }
1391
1392
  });
1392
1393
  patchState(store, (state) => {
1393
1394
  return {
@@ -3243,11 +3244,13 @@ i0.ɵɵngDeclareClassMetadata({
3243
3244
  });
3244
3245
  var UserProfileService = class UserProfileService {
3245
3246
  client = inject(ATOMIC_CLIENT);
3247
+ injector = inject(Injector);
3246
3248
  resources = /* @__PURE__ */ new WeakMap();
3247
3249
  getUserProfile(userId) {
3248
3250
  let resourceRef = this.resources.get(userId);
3249
3251
  if (!resourceRef) {
3250
3252
  resourceRef = resource({
3253
+ injector: this.injector,
3251
3254
  params: () => {
3252
3255
  const id = userId();
3253
3256
  return id ? { id } : void 0;
@@ -3426,17 +3429,61 @@ const QUERY_PARAMS_DEFAULTS = {
3426
3429
  neuralSearch: void 0,
3427
3430
  aggregation_overrides: void 0
3428
3431
  };
3432
+ function parseHref(href) {
3433
+ let url;
3434
+ try {
3435
+ url = new URL(href, window.location.origin);
3436
+ } catch {
3437
+ return {
3438
+ path: "",
3439
+ params: new URLSearchParams()
3440
+ };
3441
+ }
3442
+ if (url.hash.startsWith("#/")) {
3443
+ const route = url.hash.slice(1);
3444
+ const queryIndex = route.indexOf("?");
3445
+ const path = queryIndex === -1 ? route : route.slice(0, queryIndex);
3446
+ const search = queryIndex === -1 ? "" : route.slice(queryIndex + 1);
3447
+ return {
3448
+ path,
3449
+ params: new URLSearchParams(search)
3450
+ };
3451
+ }
3452
+ return {
3453
+ path: "",
3454
+ params: url.searchParams
3455
+ };
3456
+ }
3457
+ function parseFilters(raw) {
3458
+ if (!raw) return [];
3459
+ const candidates = [raw];
3460
+ try {
3461
+ const decoded = decodeURIComponent(raw);
3462
+ if (decoded !== raw) candidates.push(decoded);
3463
+ } catch {}
3464
+ for (const candidate of candidates) try {
3465
+ const parsed = JSON.parse(candidate);
3466
+ if (Array.isArray(parsed)) return parsed;
3467
+ } catch {}
3468
+ return [];
3469
+ }
3429
3470
  const QueryParamsStore = signalStore({ providedIn: "root" }, withDevtools("QueryParams"), withQueryParamsFeatures());
3430
3471
  function withQueryParamsFeatures() {
3431
3472
  return signalStoreFeature(withState(QUERY_PARAMS_DEFAULTS), withMethods((store, injector = inject(Injector), router = inject(Router), route = inject(ActivatedRoute), appStore = inject(AppStore)) => ({
3432
3473
  setFromUrl(href) {
3433
3474
  if (!href) return;
3434
- const path = new URL(href, window.location.origin).hash.split("?")[0].replace(/^#/, "");
3435
- const { q: text, f, id, p, s: sort, c, t: tab, basket, n: name } = queryParamsFromUrl(href);
3436
- const filters = f ? JSON.parse(decodeURIComponent(f)) : [];
3437
- const spellingCorrectionMode = c;
3438
- let page;
3439
- if (p) page = parseInt(p, 10);
3475
+ const { path, params } = parseHref(href);
3476
+ const text = params.get("q") ?? "";
3477
+ const filters = parseFilters(params.get("f"));
3478
+ const id = params.get("id") ?? "";
3479
+ const sort = params.get("s") ?? "";
3480
+ const tab = params.get("t") ?? "";
3481
+ const basket = params.get("b") ?? "";
3482
+ const name = params.get("n") ?? "";
3483
+ const spellingCorrectionMode = params.get("c") || void 0;
3484
+ const p = params.get("p");
3485
+ const parsedPage = p ? parseInt(p, 10) : NaN;
3486
+ const page = Number.isNaN(parsedPage) ? void 0 : parsedPage;
3440
3487
  patchState(store, (state) => {
3441
3488
  return {
3442
3489
  ...state,
@@ -4934,14 +4981,15 @@ var LoadingComponent = class LoadingComponent {
4934
4981
  clearTimeout(timeout);
4935
4982
  setTimeout(() => {
4936
4983
  const url = this.route.snapshot.queryParams["returnUrl"] || null;
4937
- if (!url) {
4938
- this.router.navigateByUrl(url);
4984
+ if (url) {
4939
4985
  this.queryParamsStore.setFromUrl(url);
4986
+ this.router.navigateByUrl(url);
4940
4987
  } else this.router.navigate(["/"]);
4941
4988
  }, 700);
4942
4989
  }
4943
4990
  });
4944
- if (!this.client.auth.isAuthenticated()) {
4991
+ if (this.client.auth.isAuthenticated()) this.application.updateReadyState(true);
4992
+ else {
4945
4993
  this.application.updateReadyState(false);
4946
4994
  this.client.auth.login().then(() => {
4947
4995
  this.application.updateReadyState(true);
@@ -5694,6 +5742,86 @@ i0.ɵɵngDeclareClassMetadata({
5694
5742
  }]
5695
5743
  }] }
5696
5744
  });
5745
+ const COLOR_CLASSES = {
5746
+ yellow: "bg-(--bg-yellow-base-alt) text-(--font-yellow-muted)",
5747
+ blue: "bg-(--bg-info-base-alt) text-(--font-info-muted)",
5748
+ pink: "bg-(--bg-accent-base-alt) text-(--font-accent-muted)",
5749
+ indigo: "bg-(--bg-indigo-base-alt) text-(--font-indigo-muted)",
5750
+ green: "bg-(--bg-success-base-alt) text-(--font-success-muted)",
5751
+ orange: "bg-(--bg-warning-base-alt) text-(--font-warning-muted)"
5752
+ };
5753
+ var ListPanelEmptyComponent = class ListPanelEmptyComponent {
5754
+ color = input("blue", ...ngDevMode ? [{ debugName: "color" }] : /* istanbul ignore next */ []);
5755
+ tileClasses = computed(() => `inline-flex size-12 shrink-0 items-center justify-center rounded-(--radius-md) [&_svg]:size-6 [&_svg]:opacity-80 ${COLOR_CLASSES[this.color()]}`, ...ngDevMode ? [{ debugName: "tileClasses" }] : /* istanbul ignore next */ []);
5756
+ static ɵfac = i0.ɵɵngDeclareFactory({
5757
+ minVersion: "12.0.0",
5758
+ version: "22.2.1",
5759
+ ngImport: i0,
5760
+ type: ListPanelEmptyComponent,
5761
+ deps: [],
5762
+ target: i0.ɵɵFactoryTarget.Component
5763
+ });
5764
+ static ɵcmp = i0.ɵɵngDeclareComponent({
5765
+ minVersion: "17.1.0",
5766
+ version: "22.2.1",
5767
+ type: ListPanelEmptyComponent,
5768
+ isStandalone: true,
5769
+ selector: "list-panel-empty, ListPanelEmpty",
5770
+ inputs: { color: {
5771
+ classPropertyName: "color",
5772
+ publicName: "color",
5773
+ isSignal: true,
5774
+ isRequired: false,
5775
+ transformFunction: null
5776
+ } },
5777
+ host: { classAttribute: "flex flex-col items-center gap-(--spacing-xs) py-(--spacing-xs) text-center [&>[listPanelEmptyHint]]:m-0 [&>[listPanelEmptyHint]]:-mt-(--spacing-2xs) [&>[listPanelEmptyHint]]:max-w-prose [&>[listPanelEmptyHint]]:text-(length:--size-sm) [&>[listPanelEmptyHint]]:leading-(--line-height-2) [&>[listPanelEmptyHint]]:text-(--font-neutral-muted)" },
5778
+ ngImport: i0,
5779
+ template: `
5780
+ <span [class]="tileClasses()" aria-hidden="true">
5781
+ <ng-content select="[listPanelEmptyIcon]" />
5782
+ </span>
5783
+ <p class="m-0 text-(length:--size-base) leading-(--line-height-3) font-semibold text-(--font-neutral-base)">
5784
+ <ng-content />
5785
+ </p>
5786
+ <ng-content select="[listPanelEmptyHint]" />
5787
+ <ng-content select="[listPanelEmptyAction]" />
5788
+ `,
5789
+ isInline: true,
5790
+ changeDetection: i0.ChangeDetectionStrategy.OnPush
5791
+ });
5792
+ };
5793
+ i0.ɵɵngDeclareClassMetadata({
5794
+ minVersion: "12.0.0",
5795
+ version: "22.2.1",
5796
+ ngImport: i0,
5797
+ type: ListPanelEmptyComponent,
5798
+ decorators: [{
5799
+ type: Component,
5800
+ args: [{
5801
+ selector: "list-panel-empty, ListPanelEmpty",
5802
+ changeDetection: ChangeDetectionStrategy.OnPush,
5803
+ host: { class: "flex flex-col items-center gap-(--spacing-xs) py-(--spacing-xs) text-center [&>[listPanelEmptyHint]]:m-0 [&>[listPanelEmptyHint]]:-mt-(--spacing-2xs) [&>[listPanelEmptyHint]]:max-w-prose [&>[listPanelEmptyHint]]:text-(length:--size-sm) [&>[listPanelEmptyHint]]:leading-(--line-height-2) [&>[listPanelEmptyHint]]:text-(--font-neutral-muted)" },
5804
+ template: `
5805
+ <span [class]="tileClasses()" aria-hidden="true">
5806
+ <ng-content select="[listPanelEmptyIcon]" />
5807
+ </span>
5808
+ <p class="m-0 text-(length:--size-base) leading-(--line-height-3) font-semibold text-(--font-neutral-base)">
5809
+ <ng-content />
5810
+ </p>
5811
+ <ng-content select="[listPanelEmptyHint]" />
5812
+ <ng-content select="[listPanelEmptyAction]" />
5813
+ `
5814
+ }]
5815
+ }],
5816
+ propDecorators: { color: [{
5817
+ type: i0.Input,
5818
+ args: [{
5819
+ isSignal: true,
5820
+ alias: "color",
5821
+ required: false
5822
+ }]
5823
+ }] }
5824
+ });
5697
5825
  var CollectionsDialog = class CollectionsDialog {
5698
5826
  call = injectCallRef();
5699
5827
  createInputElement = viewChild("createInput", ...ngDevMode ? [{ debugName: "createInputElement" }] : /* istanbul ignore next */ []);
@@ -5869,9 +5997,21 @@ var CollectionsDialog = class CollectionsDialog {
5869
5997
  }
5870
5998
  </div>
5871
5999
  } @else {
5872
- <p class="py-4 text-center text-neutral-500">
5873
- {{ (searchText().trim() ? 'collections.noMatch' : 'collections.noCollections') | transloco }}
5874
- </p>
6000
+ <!-- One ListPanelEmpty per branch: an @if holding the icon and the text would be projected as a
6001
+ whole into the default slot, and the icon would not reach the tile. -->
6002
+ @if (searchText().trim()) {
6003
+ <ListPanelEmpty color="indigo">
6004
+ <search-icon listPanelEmptyIcon />
6005
+ {{ 'collections.noMatch' | transloco }}
6006
+ <p listPanelEmptyHint>{{ 'collections.noMatchHint' | transloco }}</p>
6007
+ </ListPanelEmpty>
6008
+ } @else {
6009
+ <ListPanelEmpty color="indigo">
6010
+ <inbox-icon listPanelEmptyIcon />
6011
+ {{ 'collections.noCollections' | transloco }}
6012
+ <p listPanelEmptyHint>{{ 'collections.noCollectionsDialogHint' | transloco }}</p>
6013
+ </ListPanelEmpty>
6014
+ }
5875
6015
  }
5876
6016
  </div>
5877
6017
  } @else {
@@ -6008,6 +6148,24 @@ var CollectionsDialog = class CollectionsDialog {
6008
6148
  ],
6009
6149
  outputs: ["checkedChange"]
6010
6150
  },
6151
+ {
6152
+ kind: "component",
6153
+ type: ListPanelEmptyComponent,
6154
+ selector: "list-panel-empty, ListPanelEmpty",
6155
+ inputs: ["color"]
6156
+ },
6157
+ {
6158
+ kind: "component",
6159
+ type: InboxIcon,
6160
+ selector: "inbox, inbox-icon, Inbox, InboxIcon",
6161
+ inputs: ["class"]
6162
+ },
6163
+ {
6164
+ kind: "component",
6165
+ type: SearchIcon,
6166
+ selector: "search, search-icon, Search, SearchIcon",
6167
+ inputs: ["class"]
6168
+ },
6011
6169
  {
6012
6170
  kind: "pipe",
6013
6171
  type: TranslocoPipe,
@@ -6037,7 +6195,10 @@ i0.ɵɵngDeclareClassMetadata({
6037
6195
  DialogFooterComponent,
6038
6196
  InputComponent,
6039
6197
  InputControlDirective,
6040
- CheckboxComponent
6198
+ CheckboxComponent,
6199
+ ListPanelEmptyComponent,
6200
+ InboxIcon,
6201
+ SearchIcon
6041
6202
  ],
6042
6203
  providers: [provideTranslocoScope("collections")],
6043
6204
  template: `
@@ -6086,9 +6247,21 @@ i0.ɵɵngDeclareClassMetadata({
6086
6247
  }
6087
6248
  </div>
6088
6249
  } @else {
6089
- <p class="py-4 text-center text-neutral-500">
6090
- {{ (searchText().trim() ? 'collections.noMatch' : 'collections.noCollections') | transloco }}
6091
- </p>
6250
+ <!-- One ListPanelEmpty per branch: an @if holding the icon and the text would be projected as a
6251
+ whole into the default slot, and the icon would not reach the tile. -->
6252
+ @if (searchText().trim()) {
6253
+ <ListPanelEmpty color="indigo">
6254
+ <search-icon listPanelEmptyIcon />
6255
+ {{ 'collections.noMatch' | transloco }}
6256
+ <p listPanelEmptyHint>{{ 'collections.noMatchHint' | transloco }}</p>
6257
+ </ListPanelEmpty>
6258
+ } @else {
6259
+ <ListPanelEmpty color="indigo">
6260
+ <inbox-icon listPanelEmptyIcon />
6261
+ {{ 'collections.noCollections' | transloco }}
6262
+ <p listPanelEmptyHint>{{ 'collections.noCollectionsDialogHint' | transloco }}</p>
6263
+ </ListPanelEmpty>
6264
+ }
6092
6265
  }
6093
6266
  </div>
6094
6267
  } @else {
@@ -7203,17 +7376,18 @@ var InfinityScrollDirective = class InfinityScrollDirective {
7203
7376
  alias: "scroll-options"
7204
7377
  });
7205
7378
  onScroll = output();
7206
- observer = new IntersectionObserver(([entry]) => {
7207
- if (entry.isIntersecting) this.onScroll.emit();
7208
- }, this.options());
7379
+ observer;
7209
7380
  el = inject(ElementRef);
7210
7381
  constructor() {
7211
7382
  afterNextRender(() => {
7383
+ this.observer = new IntersectionObserver(([entry]) => {
7384
+ if (entry.isIntersecting) this.onScroll.emit();
7385
+ }, this.options());
7212
7386
  this.observer.observe(this.el.nativeElement);
7213
7387
  });
7214
7388
  }
7215
7389
  ngOnDestroy() {
7216
- this.observer.disconnect();
7390
+ this.observer?.disconnect();
7217
7391
  }
7218
7392
  static ɵfac = i0.ɵɵngDeclareFactory({
7219
7393
  minVersion: "12.0.0",
@@ -16044,6 +16218,70 @@ var Alert;
16044
16218
  })(Alert.Days || (Alert.Days = {}));
16045
16219
  })(Alert || (Alert = {}));
16046
16220
  const COMPONENTS_FOR_DOCUMENT_TYPE = new InjectionToken("COMPONENTS_FOR_DOCUMENT_TYPE");
16221
+ const WEEKDAY_FLAGS = [
16222
+ Alert.Days.Monday,
16223
+ Alert.Days.Tuesday,
16224
+ Alert.Days.Wednesday,
16225
+ Alert.Days.Thursday,
16226
+ Alert.Days.Friday,
16227
+ Alert.Days.Saturday,
16228
+ Alert.Days.Sunday
16229
+ ];
16230
+ const TIMED_FREQUENCIES = [
16231
+ Alert.Frequency.Daily,
16232
+ Alert.Frequency.Weekly,
16233
+ Alert.Frequency.Monthly
16234
+ ];
16235
+ function weekdayNames(lang) {
16236
+ const format = new Intl.DateTimeFormat(lang, { weekday: "short" });
16237
+ return WEEKDAY_FLAGS.map((_, offset) => format.format(new Date(2024, 0, 1 + offset)));
16238
+ }
16239
+ function formatDays(days, lang) {
16240
+ const names = weekdayNames(lang);
16241
+ const selected = WEEKDAY_FLAGS.map((flag) => (days & flag) !== 0);
16242
+ const parts = [];
16243
+ let start = 0;
16244
+ while (start < selected.length) {
16245
+ if (!selected[start]) {
16246
+ start++;
16247
+ continue;
16248
+ }
16249
+ let end = start;
16250
+ while (end + 1 < selected.length && selected[end + 1]) end++;
16251
+ if (end - start >= 2) parts.push(`${names[start]}–${names[end]}`);
16252
+ else for (let i = start; i <= end; i++) parts.push(names[i]);
16253
+ start = end + 1;
16254
+ }
16255
+ return parts.join(", ");
16256
+ }
16257
+ function alertSummary(lang, alert) {
16258
+ const parts = [];
16259
+ if (!alert.active) parts.push({ key: "alerts.paused" });
16260
+ if (alert.frequency !== void 0 && Alert.Frequency[alert.frequency] !== void 0) parts.push({ key: `alerts.frequency.${Alert.Frequency[alert.frequency]}` });
16261
+ if (alert.times && TIMED_FREQUENCIES.includes(alert.frequency)) parts.push({ text: alert.times });
16262
+ const days = alert.days ?? Alert.Days.None;
16263
+ if (alert.frequency !== Alert.Frequency.Immediate && days !== Alert.Days.None && (days & Alert.Days.AllDays) !== Alert.Days.AllDays) parts.push({ text: formatDays(days, lang) });
16264
+ return parts;
16265
+ }
16266
+ const ALREADY_NOTIFIED = [
16267
+ 400,
16268
+ 403,
16269
+ 500,
16270
+ 503
16271
+ ];
16272
+ function saveErrorDetail(error) {
16273
+ const { errorMessage, message } = error ?? {};
16274
+ return errorMessage ?? message;
16275
+ }
16276
+ function notifySaveFailed(transloco, error) {
16277
+ const { status } = error ?? {};
16278
+ if (status !== void 0 && ALREADY_NOTIFIED.includes(status)) return;
16279
+ notify.error(transloco.translate("alerts.saveFailed"), {
16280
+ description: saveErrorDetail(error),
16281
+ closeButton: true,
16282
+ duration: 5e3
16283
+ });
16284
+ }
16047
16285
  const WEEKDAYS = [
16048
16286
  {
16049
16287
  key: "monday",
@@ -16118,6 +16356,7 @@ var AlertDialog = class AlertDialog {
16118
16356
  alert = signal(void 0, ...ngDevMode ? [{ debugName: "alert" }] : /* istanbul ignore next */ []);
16119
16357
  canUpdateQuery = signal(false, ...ngDevMode ? [{ debugName: "canUpdateQuery" }] : /* istanbul ignore next */ []);
16120
16358
  updateStatus = signal("idle", ...ngDevMode ? [{ debugName: "updateStatus" }] : /* istanbul ignore next */ []);
16359
+ saveError = signal(void 0, ...ngDevMode ? [{ debugName: "saveError" }] : /* istanbul ignore next */ []);
16121
16360
  alertModel = signal(emptyAlertModel(), ...ngDevMode ? [{ debugName: "alertModel" }] : /* istanbul ignore next */ []);
16122
16361
  alertForm = form(this.alertModel, alertSchema, {
16123
16362
  name: "alert",
@@ -16146,8 +16385,14 @@ var AlertDialog = class AlertDialog {
16146
16385
  this.dialog()?.open();
16147
16386
  }
16148
16387
  async persist() {
16149
- if (this.alert()) await this.update();
16150
- else await this.create();
16388
+ this.saveError.set(void 0);
16389
+ try {
16390
+ if (this.alert()) await this.update();
16391
+ else await this.create();
16392
+ } catch (error) {
16393
+ this.saveError.set({ detail: saveErrorDetail(error) });
16394
+ return;
16395
+ }
16151
16396
  this.dialog()?.close("dialog-confirm");
16152
16397
  }
16153
16398
  async create() {
@@ -16166,19 +16411,21 @@ var AlertDialog = class AlertDialog {
16166
16411
  combine: true,
16167
16412
  respectTabSelection: false
16168
16413
  };
16169
- this.userSettingsStore.createAlert(alert);
16414
+ await this.userSettingsStore.createAlert(alert);
16170
16415
  }
16171
16416
  async update() {
16172
16417
  const alert = this.alert();
16173
16418
  const index = this.index();
16174
16419
  if (!alert || index === void 0) return;
16175
16420
  const model = this.alertModel();
16176
- alert.name = model.name.trim();
16177
- alert.frequency = model.frequency ?? Alert.Frequency.Daily;
16178
- alert.days = flagsFromWeekdays(model.days);
16179
- alert.times = model.time;
16180
- alert.active = model.active;
16181
- this.userSettingsStore.updateAlert(alert, index);
16421
+ await this.userSettingsStore.updateAlert({
16422
+ ...alert,
16423
+ name: model.name.trim(),
16424
+ frequency: model.frequency ?? Alert.Frequency.Daily,
16425
+ days: flagsFromWeekdays(model.days),
16426
+ times: model.time,
16427
+ active: model.active
16428
+ }, index);
16182
16429
  }
16183
16430
  showError(field) {
16184
16431
  return fieldShowsError(field);
@@ -16190,9 +16437,12 @@ var AlertDialog = class AlertDialog {
16190
16437
  const alert = this.alert();
16191
16438
  const index = this.index();
16192
16439
  if (!alert || index === void 0) return;
16440
+ this.saveError.set(void 0);
16193
16441
  this.updateStatus.set("updating");
16194
- alert.query = this.queryParamsStore.getQuery();
16195
- this.userSettingsStore.updateAlert(alert, index).then(() => {
16442
+ this.userSettingsStore.updateAlert({
16443
+ ...alert,
16444
+ query: this.queryParamsStore.getQuery()
16445
+ }, index).then(() => {
16196
16446
  setTimeout(() => {
16197
16447
  this.updateStatus.set("updated");
16198
16448
  }, 500);
@@ -16200,6 +16450,9 @@ var AlertDialog = class AlertDialog {
16200
16450
  setTimeout(() => {
16201
16451
  this.updateStatus.set("idle");
16202
16452
  }, 2e3);
16453
+ }).catch((error) => {
16454
+ this.updateStatus.set("idle");
16455
+ this.saveError.set({ detail: saveErrorDetail(error) });
16203
16456
  });
16204
16457
  }
16205
16458
  execute() {
@@ -16352,6 +16605,25 @@ var AlertDialog = class AlertDialog {
16352
16605
  </DialogBody>
16353
16606
 
16354
16607
  <DialogFooter class="flex-col">
16608
+ <!-- Shown here and not as a toast: the dialog is modal, so it is in the top layer, above any toast. -->
16609
+ @if (saveError(); as failure) {
16610
+ <div class="save-error w-full">
16611
+ <div class="min-h-0 overflow-hidden">
16612
+ <div
16613
+ id="alertSaveError"
16614
+ role="alert"
16615
+ class="flex w-full items-start gap-(--spacing-sm) rounded-(--radius-md) border border-(--stroke-error-base) bg-(--bg-error-lightest) p-(--spacing-md) text-(--font-error-base)">
16616
+ <TriangleAlert class="mt-0.5 size-4 shrink-0" width="16" height="16" aria-hidden="true" />
16617
+ <div class="flex min-w-0 flex-col gap-(--spacing-2xs)">
16618
+ <p class="m-0 font-medium">{{ 'alerts.saveFailed' | transloco }}</p>
16619
+ @if (failure.detail) {
16620
+ <p class="m-0 text-(length:--size-sm) wrap-anywhere opacity-80">{{ failure.detail }}</p>
16621
+ }
16622
+ </div>
16623
+ </div>
16624
+ </div>
16625
+ </div>
16626
+ }
16355
16627
  @if (alert() || canUpdateQuery()) {
16356
16628
  <div class="flex w-full flex-col gap-2">
16357
16629
  @if (alert()) {
@@ -16398,7 +16670,7 @@ var AlertDialog = class AlertDialog {
16398
16670
  </dialog>
16399
16671
  `,
16400
16672
  isInline: true,
16401
- styles: [".weekdays-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr))}\n"],
16673
+ styles: [".weekdays-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr))}.save-error{display:grid;animation:save-error-in .22s ease-out}.save-error>div>div{animation:save-error-card-in .22s ease-out}@keyframes save-error-in{0%{grid-template-rows:0fr}to{grid-template-rows:1fr}}@keyframes save-error-card-in{0%{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}@media(prefers-reduced-motion:reduce){.save-error,.save-error>div>div{animation:none}}\n"],
16402
16674
  dependencies: [
16403
16675
  {
16404
16676
  kind: "directive",
@@ -16558,6 +16830,12 @@ var AlertDialog = class AlertDialog {
16558
16830
  selector: "circle-check, circle-check-icon, CircleCheck, CircleCheckIcon",
16559
16831
  inputs: ["class"]
16560
16832
  },
16833
+ {
16834
+ kind: "component",
16835
+ type: TriangleAlertIcon,
16836
+ selector: "triangle-alert, triangle-alert-icon, TriangleAlert, TriangleAlertIcon",
16837
+ inputs: ["class"]
16838
+ },
16561
16839
  {
16562
16840
  kind: "pipe",
16563
16841
  type: TranslocoPipe,
@@ -16594,7 +16872,8 @@ i0.ɵɵngDeclareClassMetadata({
16594
16872
  ChevronDownIcon,
16595
16873
  CheckIcon,
16596
16874
  LoadingCircleIcon,
16597
- CircleCheckIcon
16875
+ CircleCheckIcon,
16876
+ TriangleAlertIcon
16598
16877
  ],
16599
16878
  providers: [provideTranslocoScope("alerts")],
16600
16879
  template: `
@@ -16709,6 +16988,25 @@ i0.ɵɵngDeclareClassMetadata({
16709
16988
  </DialogBody>
16710
16989
 
16711
16990
  <DialogFooter class="flex-col">
16991
+ <!-- Shown here and not as a toast: the dialog is modal, so it is in the top layer, above any toast. -->
16992
+ @if (saveError(); as failure) {
16993
+ <div class="save-error w-full">
16994
+ <div class="min-h-0 overflow-hidden">
16995
+ <div
16996
+ id="alertSaveError"
16997
+ role="alert"
16998
+ class="flex w-full items-start gap-(--spacing-sm) rounded-(--radius-md) border border-(--stroke-error-base) bg-(--bg-error-lightest) p-(--spacing-md) text-(--font-error-base)">
16999
+ <TriangleAlert class="mt-0.5 size-4 shrink-0" width="16" height="16" aria-hidden="true" />
17000
+ <div class="flex min-w-0 flex-col gap-(--spacing-2xs)">
17001
+ <p class="m-0 font-medium">{{ 'alerts.saveFailed' | transloco }}</p>
17002
+ @if (failure.detail) {
17003
+ <p class="m-0 text-(length:--size-sm) wrap-anywhere opacity-80">{{ failure.detail }}</p>
17004
+ }
17005
+ </div>
17006
+ </div>
17007
+ </div>
17008
+ </div>
17009
+ }
16712
17010
  @if (alert() || canUpdateQuery()) {
16713
17011
  <div class="flex w-full flex-col gap-2">
16714
17012
  @if (alert()) {
@@ -16754,7 +17052,7 @@ i0.ɵɵngDeclareClassMetadata({
16754
17052
  </DialogContent>
16755
17053
  </dialog>
16756
17054
  `,
16757
- styles: [".weekdays-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr))}\n"]
17055
+ styles: [".weekdays-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr))}.save-error{display:grid;animation:save-error-in .22s ease-out}.save-error>div>div{animation:save-error-card-in .22s ease-out}@keyframes save-error-in{0%{grid-template-rows:0fr}to{grid-template-rows:1fr}}@keyframes save-error-card-in{0%{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}@media(prefers-reduced-motion:reduce){.save-error,.save-error>div>div{animation:none}}\n"]
16758
17056
  }]
16759
17057
  }],
16760
17058
  ctorParameters: () => [],
@@ -16764,6 +17062,7 @@ i0.ɵɵngDeclareClassMetadata({
16764
17062
  }] }
16765
17063
  });
16766
17064
  const AlertForm = createCallable(AlertDialog);
17065
+ const UNDO_DELAY = 6e3;
16767
17066
  var AlertsComponent = class AlertsComponent {
16768
17067
  floating = inject(PopoverDirective, {
16769
17068
  skipSelf: true,
@@ -16771,17 +17070,42 @@ var AlertsComponent = class AlertsComponent {
16771
17070
  });
16772
17071
  userSettingsStore = inject(UserSettingsStore);
16773
17072
  injector = inject(Injector);
16774
- reordering = signal(false, ...ngDevMode ? [{ debugName: "reordering" }] : /* istanbul ignore next */ []);
16775
- alerts = computed(() => this.userSettingsStore.alerts(), ...ngDevMode ? [{ debugName: "alerts" }] : /* istanbul ignore next */ []);
16776
- tmpAlerts = [];
17073
+ transloco = inject(TranslocoService);
17074
+ lang = toSignal(this.transloco.langChanges$);
16777
17075
  query;
17076
+ alerts = computed(() => this.userSettingsStore.alerts() ?? [], ...ngDevMode ? [{ debugName: "alerts" }] : /* istanbul ignore next */ []);
17077
+ order = linkedSignal(() => [...this.alerts()], ...ngDevMode ? [{ debugName: "order" }] : /* istanbul ignore next */ []);
17078
+ hidden = signal(/* @__PURE__ */ new Set(), ...ngDevMode ? [{ debugName: "hidden" }] : /* istanbul ignore next */ []);
17079
+ pendingDeletion = signal(void 0, ...ngDevMode ? [{ debugName: "pendingDeletion" }] : /* istanbul ignore next */ []);
17080
+ undoTimer;
17081
+ visible = computed(() => {
17082
+ const hidden = this.hidden();
17083
+ return this.order().filter((alert) => !hidden.has(alert));
17084
+ }, ...ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []);
17085
+ summaries = computed(() => {
17086
+ const lang = this.lang() ?? this.transloco.getActiveLang();
17087
+ return new Map(this.order().map((alert) => [alert, alertSummary(lang, alert)]));
17088
+ }, ...ngDevMode ? [{ debugName: "summaries" }] : /* istanbul ignore next */ []);
17089
+ summaryOf = (alert) => this.summaries().get(alert) ?? [];
17090
+ announceMove = (alert, position, total) => this.transloco.translate("alerts.moved", {
17091
+ name: alert.name,
17092
+ position,
17093
+ total
17094
+ });
16778
17095
  constructor() {
16779
17096
  this.query = buildQuery();
16780
- effect(() => {
16781
- this.tmpAlerts = this.alerts()?.map((a) => Object.assign({}, a));
16782
- });
17097
+ inject(DestroyRef).onDestroy(() => this.commitDeletion());
17098
+ }
17099
+ onRowClick(event, alert) {
17100
+ if (event.target.closest("[data-row-action]")) return;
17101
+ this.edit(alert);
16783
17102
  }
16784
- onClick(index) {
17103
+ onRowEnter(event, alert) {
17104
+ if (event.target === event.currentTarget) this.edit(alert);
17105
+ }
17106
+ edit(alert) {
17107
+ const index = this.alerts().indexOf(alert);
17108
+ if (index < 0) return;
16785
17109
  this.floating?.close();
16786
17110
  AlertForm.call(index, { injector: this.injector });
16787
17111
  }
@@ -16789,19 +17113,73 @@ var AlertsComponent = class AlertsComponent {
16789
17113
  this.floating?.close();
16790
17114
  AlertForm.call(void 0, { injector: this.injector });
16791
17115
  }
16792
- deleteAlert(event, index) {
16793
- event.stopPropagation();
16794
- this.userSettingsStore.deleteAlert(index);
17116
+ pendingActive = signal(/* @__PURE__ */ new Map(), ...ngDevMode ? [{ debugName: "pendingActive" }] : /* istanbul ignore next */ []);
17117
+ isActive(alert) {
17118
+ return this.pendingActive().get(alert) ?? alert.active;
17119
+ }
17120
+ async toggle(alert, active) {
17121
+ const index = this.alerts().indexOf(alert);
17122
+ if (index < 0) return;
17123
+ this.pendingActive.update((pending) => new Map(pending).set(alert, active));
17124
+ try {
17125
+ await this.userSettingsStore.updateAlert({
17126
+ ...alert,
17127
+ active
17128
+ }, index);
17129
+ } catch (error) {
17130
+ notifySaveFailed(this.transloco, error);
17131
+ } finally {
17132
+ this.pendingActive.update((pending) => {
17133
+ const next = new Map(pending);
17134
+ next.delete(alert);
17135
+ return next;
17136
+ });
17137
+ }
16795
17138
  }
16796
- async reorder() {
16797
- if (this.reordering()) {
16798
- await this.userSettingsStore.updateAlerts(this.tmpAlerts);
16799
- this.reordering.set(false);
16800
- } else this.reordering.set(true);
17139
+ deleteAlert(alert) {
17140
+ this.commitDeletion();
17141
+ this.hidden.update((hidden) => new Set(hidden).add(alert));
17142
+ this.pendingDeletion.set(alert);
17143
+ this.undoTimer = setTimeout(() => this.commitDeletion(), UNDO_DELAY);
16801
17144
  }
16802
- dropped(drop) {
16803
- if (drop.currentIndex === drop.previousIndex) return;
16804
- this.tmpAlerts.splice(drop.currentIndex, 0, this.tmpAlerts.splice(drop.previousIndex, 1)[0]);
17145
+ undoDeletion() {
17146
+ clearTimeout(this.undoTimer);
17147
+ const alert = this.pendingDeletion();
17148
+ if (!alert) return;
17149
+ this.pendingDeletion.set(void 0);
17150
+ this.unhide(alert);
17151
+ }
17152
+ commitDeletion() {
17153
+ clearTimeout(this.undoTimer);
17154
+ const alert = this.pendingDeletion();
17155
+ if (!alert) return;
17156
+ this.pendingDeletion.set(void 0);
17157
+ const index = this.alerts().indexOf(alert);
17158
+ if (index < 0) {
17159
+ this.unhide(alert);
17160
+ return;
17161
+ }
17162
+ this.userSettingsStore.deleteAlert(index).catch((error) => notifySaveFailed(this.transloco, error)).finally(() => this.unhide(alert));
17163
+ }
17164
+ unhide(alert) {
17165
+ this.hidden.update((hidden) => {
17166
+ const next = new Set(hidden);
17167
+ next.delete(alert);
17168
+ return next;
17169
+ });
17170
+ }
17171
+ async saveOrder(shown) {
17172
+ const hidden = this.hidden();
17173
+ const previous = this.order();
17174
+ const next = [...shown, ...previous.filter((alert) => hidden.has(alert))];
17175
+ if (next.every((alert, index) => alert === previous[index])) return;
17176
+ this.order.set(next);
17177
+ try {
17178
+ await this.userSettingsStore.updateAlerts(next);
17179
+ } catch (error) {
17180
+ this.order.set(previous);
17181
+ notifySaveFailed(this.transloco, error);
17182
+ }
16805
17183
  }
16806
17184
  static ɵfac = i0.ɵɵngDeclareFactory({
16807
17185
  minVersion: "12.0.0",
@@ -16819,7 +17197,7 @@ var AlertsComponent = class AlertsComponent {
16819
17197
  selector: "Alerts",
16820
17198
  providers: [provideTranslocoScope("alerts")],
16821
17199
  ngImport: i0,
16822
- template: "<ul\n role=\"list\"\n class=\"flex min-w-80 flex-col p-2\"\n cdkDropList\n [cdkDropListData]=\"tmpAlerts\"\n [cdkDropListDisabled]=\"!reordering()\"\n (cdkDropListDropped)=\"dropped($event)\">\n @if (floating) {\n <label class=\"text-xl font-bold\">{{ \"alerts.label\" | transloco }}</label>\n <hr\n class=\"my-(--spacing-3xs) h-px w-full shrink-0 border-0 bg-(--stroke-neutral-disabled)\" />\n }\n @for (alert of tmpAlerts; track $index) {\n <li\n class=\"group box-border flex h-10 w-full cursor-pointer items-center gap-(--spacing-2xs) rounded-(--radius-sm) bg-transparent px-(--spacing-2xs) py-(--spacing-3xs) 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)\"\n tabindex=\"0\"\n cdkDrag\n (click)=\"onClick($index)\"\n (keydown.enter)=\"onClick($index)\">\n <bell-icon class=\"shrink-0\" />\n <p class=\"line-clamp-1\">{{ alert.name }}</p>\n @if (!reordering()) {\n @let openBookmark = \"bookmarks.openBookmark\" | transloco;\n <button\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n title=\"{{ openBookmark }}\"\n class=\"text-destructive ms-auto group-hover:visible\"\n [attr.title]=\"openBookmark\"\n [attr.aria-label]=\"openBookmark\"\n (click)=\"deleteAlert($event, $index)\">\n <TrashIcon />\n </button>\n } @else {\n <bars-icon class=\"ms-auto\" />\n }\n </li>\n } @empty {\n <span class=\"py-4 text-center text-neutral-500\">\n {{ \"alerts.noAlerts\" | transloco }}\n </span>\n }\n</ul>\n<div class=\"flex flex-col gap-2 p-2\">\n <button\n variant=\"secondary\"\n class=\"w-full\"\n tabindex=\"0\"\n [attr.title]=\"'alerts.manageAlerts' | transloco\"\n [disabled]=\"!alerts().length\"\n (click)=\"reorder()\">\n {{ (reordering() ? \"save\" : \"alerts.manageAlerts\") | transloco }}\n </button>\n <button\n variant=\"secondary\"\n class=\"w-full\"\n [attr.title]=\"'alerts.createAlert' | transloco\"\n (click)=\"createAlert()\">\n {{ \"alerts.createAlert\" | transloco }}\n </button>\n</div>\n",
17200
+ 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 <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",
16823
17201
  dependencies: [
16824
17202
  {
16825
17203
  kind: "directive",
@@ -16834,63 +17212,39 @@ var AlertsComponent = class AlertsComponent {
16834
17212
  ]
16835
17213
  },
16836
17214
  {
16837
- kind: "ngmodule",
16838
- type: DragDropModule
17215
+ kind: "component",
17216
+ type: SwitchComponent,
17217
+ selector: "switch, Switch",
17218
+ inputs: [
17219
+ "checked",
17220
+ "disabled",
17221
+ "size",
17222
+ "class",
17223
+ "aria-label",
17224
+ "aria-labelledby",
17225
+ "presentational",
17226
+ "compact",
17227
+ "tabIndex"
17228
+ ],
17229
+ outputs: ["checkedChange"]
16839
17230
  },
16840
17231
  {
16841
17232
  kind: "directive",
16842
- type: i1.CdkDropList,
16843
- selector: "[cdkDropList], cdk-drop-list",
17233
+ type: SortableDirective,
17234
+ selector: "[sortable]",
16844
17235
  inputs: [
16845
- "cdkDropListConnectedTo",
16846
- "cdkDropListData",
16847
- "cdkDropListOrientation",
16848
- "id",
16849
- "cdkDropListLockAxis",
16850
- "cdkDropListDisabled",
16851
- "cdkDropListSortingDisabled",
16852
- "cdkDropListEnterPredicate",
16853
- "cdkDropListSortPredicate",
16854
- "cdkDropListAutoScrollDisabled",
16855
- "cdkDropListAutoScrollStep",
16856
- "cdkDropListElementContainer",
16857
- "cdkDropListHasAnchor"
17236
+ "sortable",
17237
+ "sortableDisabled",
17238
+ "sortableAnnounce"
16858
17239
  ],
16859
- outputs: [
16860
- "cdkDropListDropped",
16861
- "cdkDropListEntered",
16862
- "cdkDropListExited",
16863
- "cdkDropListSorted"
16864
- ],
16865
- exportAs: ["cdkDropList"]
17240
+ outputs: ["reorder"],
17241
+ exportAs: ["sortable"]
16866
17242
  },
16867
17243
  {
16868
17244
  kind: "directive",
16869
- type: i1.CdkDrag,
16870
- selector: "[cdkDrag]",
16871
- inputs: [
16872
- "cdkDragData",
16873
- "cdkDragLockAxis",
16874
- "cdkDragRootElement",
16875
- "cdkDragBoundary",
16876
- "cdkDragStartDelay",
16877
- "cdkDragFreeDragPosition",
16878
- "cdkDragDisabled",
16879
- "cdkDragConstrainPosition",
16880
- "cdkDragPreviewClass",
16881
- "cdkDragPreviewContainer",
16882
- "cdkDragScale"
16883
- ],
16884
- outputs: [
16885
- "cdkDragStarted",
16886
- "cdkDragReleased",
16887
- "cdkDragEnded",
16888
- "cdkDragEntered",
16889
- "cdkDragExited",
16890
- "cdkDragDropped",
16891
- "cdkDragMoved"
16892
- ],
16893
- exportAs: ["cdkDrag"]
17245
+ type: SortableItemDirective,
17246
+ selector: "[sortableItem]",
17247
+ inputs: ["sortableItem", "sortableItemDisabled"]
16894
17248
  },
16895
17249
  {
16896
17250
  kind: "component",
@@ -16898,6 +17252,24 @@ var AlertsComponent = class AlertsComponent {
16898
17252
  selector: "bell, bell-icon, Bell, BellIcon",
16899
17253
  inputs: ["class"]
16900
17254
  },
17255
+ {
17256
+ kind: "component",
17257
+ type: BellSlashIcon,
17258
+ selector: "bell-slash, bell-slash-icon, BellSlash, BellSlashIcon",
17259
+ inputs: ["class"]
17260
+ },
17261
+ {
17262
+ kind: "component",
17263
+ type: GripVerticalIcon,
17264
+ selector: "grip-vertical, grip-vertical-icon, GripVertical, GripVerticalIcon",
17265
+ inputs: ["class"]
17266
+ },
17267
+ {
17268
+ kind: "component",
17269
+ type: PlusIcon,
17270
+ selector: "plus, plus-icon, Plus, PlusIcon",
17271
+ inputs: ["class"]
17272
+ },
16901
17273
  {
16902
17274
  kind: "component",
16903
17275
  type: TrashIcon,
@@ -16906,9 +17278,9 @@ var AlertsComponent = class AlertsComponent {
16906
17278
  },
16907
17279
  {
16908
17280
  kind: "component",
16909
- type: BarsIcon,
16910
- selector: "bars, bars-icon, Bars, BarsIcon",
16911
- inputs: ["class"]
17281
+ type: ListPanelEmptyComponent,
17282
+ selector: "list-panel-empty, ListPanelEmpty",
17283
+ inputs: ["color"]
16912
17284
  },
16913
17285
  {
16914
17286
  kind: "pipe",
@@ -16930,13 +17302,18 @@ i0.ɵɵngDeclareClassMetadata({
16930
17302
  imports: [
16931
17303
  TranslocoPipe,
16932
17304
  ButtonComponent,
16933
- DragDropModule,
17305
+ SwitchComponent,
17306
+ SortableDirective,
17307
+ SortableItemDirective,
16934
17308
  BellIcon,
17309
+ BellSlashIcon,
17310
+ GripVerticalIcon,
17311
+ PlusIcon,
16935
17312
  TrashIcon,
16936
- BarsIcon
17313
+ ListPanelEmptyComponent
16937
17314
  ],
16938
17315
  providers: [provideTranslocoScope("alerts")],
16939
- template: "<ul\n role=\"list\"\n class=\"flex min-w-80 flex-col p-2\"\n cdkDropList\n [cdkDropListData]=\"tmpAlerts\"\n [cdkDropListDisabled]=\"!reordering()\"\n (cdkDropListDropped)=\"dropped($event)\">\n @if (floating) {\n <label class=\"text-xl font-bold\">{{ \"alerts.label\" | transloco }}</label>\n <hr\n class=\"my-(--spacing-3xs) h-px w-full shrink-0 border-0 bg-(--stroke-neutral-disabled)\" />\n }\n @for (alert of tmpAlerts; track $index) {\n <li\n class=\"group box-border flex h-10 w-full cursor-pointer items-center gap-(--spacing-2xs) rounded-(--radius-sm) bg-transparent px-(--spacing-2xs) py-(--spacing-3xs) 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)\"\n tabindex=\"0\"\n cdkDrag\n (click)=\"onClick($index)\"\n (keydown.enter)=\"onClick($index)\">\n <bell-icon class=\"shrink-0\" />\n <p class=\"line-clamp-1\">{{ alert.name }}</p>\n @if (!reordering()) {\n @let openBookmark = \"bookmarks.openBookmark\" | transloco;\n <button\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n title=\"{{ openBookmark }}\"\n class=\"text-destructive ms-auto group-hover:visible\"\n [attr.title]=\"openBookmark\"\n [attr.aria-label]=\"openBookmark\"\n (click)=\"deleteAlert($event, $index)\">\n <TrashIcon />\n </button>\n } @else {\n <bars-icon class=\"ms-auto\" />\n }\n </li>\n } @empty {\n <span class=\"py-4 text-center text-neutral-500\">\n {{ \"alerts.noAlerts\" | transloco }}\n </span>\n }\n</ul>\n<div class=\"flex flex-col gap-2 p-2\">\n <button\n variant=\"secondary\"\n class=\"w-full\"\n tabindex=\"0\"\n [attr.title]=\"'alerts.manageAlerts' | transloco\"\n [disabled]=\"!alerts().length\"\n (click)=\"reorder()\">\n {{ (reordering() ? \"save\" : \"alerts.manageAlerts\") | transloco }}\n </button>\n <button\n variant=\"secondary\"\n class=\"w-full\"\n [attr.title]=\"'alerts.createAlert' | transloco\"\n (click)=\"createAlert()\">\n {{ \"alerts.createAlert\" | transloco }}\n </button>\n</div>\n"
17316
+ 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 <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"
16940
17317
  }]
16941
17318
  }],
16942
17319
  ctorParameters: () => []
@@ -18692,7 +19069,6 @@ var BookmarkButtonComponent = class BookmarkButtonComponent {
18692
19069
  isBookmarked = computed(() => {
18693
19070
  return this.userSettingsStore.isBookmarked(this.article());
18694
19071
  }, ...ngDevMode ? [{ debugName: "isBookmarked" }] : /* istanbul ignore next */ []);
18695
- title = computed(() => this.transloco.translate("bookmarks.bookmarkDocument"), ...ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []);
18696
19072
  async bookmark(e) {
18697
19073
  e.stopPropagation();
18698
19074
  const article = this.article();
@@ -18756,11 +19132,11 @@ var BookmarkButtonComponent = class BookmarkButtonComponent {
18756
19132
  properties: { "class.visible": "isBookmarked()" },
18757
19133
  classAttribute: "cursor-pointer"
18758
19134
  },
18759
- providers: [provideTranslocoScope("bookmark")],
19135
+ providers: [provideTranslocoScope("bookmarks")],
18760
19136
  ngImport: i0,
18761
19137
  template: `
18762
19138
  <button [variant]="variant()" [size]="size()" [iconOnly]="true"
18763
- [tooltip]="title()"
19139
+ [tooltip]="'bookmarks.bookmarkDocument' | transloco"
18764
19140
  [attr.aria-pressed]="isBookmarked()"
18765
19141
  [attr.tabindex]="tabIndex()"
18766
19142
  (click)="bookmark($event)">
@@ -18770,7 +19146,7 @@ var BookmarkButtonComponent = class BookmarkButtonComponent {
18770
19146
  @else {
18771
19147
  <bookmark-icon />
18772
19148
  }
18773
- <span class="sr-only">{{ title() }}</span>
19149
+ <span class="sr-only">{{ 'bookmarks.bookmarkDocument' | transloco }}</span>
18774
19150
  </button>
18775
19151
  `,
18776
19152
  isInline: true,
@@ -18818,6 +19194,11 @@ var BookmarkButtonComponent = class BookmarkButtonComponent {
18818
19194
  "tooltip-offset"
18819
19195
  ],
18820
19196
  exportAs: ["tooltip"]
19197
+ },
19198
+ {
19199
+ kind: "pipe",
19200
+ type: TranslocoPipe,
19201
+ name: "transloco"
18821
19202
  }
18822
19203
  ]
18823
19204
  });
@@ -18834,12 +19215,13 @@ i0.ɵɵngDeclareClassMetadata({
18834
19215
  imports: [
18835
19216
  BookmarkIcon,
18836
19217
  ButtonComponent,
18837
- TooltipDirective
19218
+ TooltipDirective,
19219
+ TranslocoPipe
18838
19220
  ],
18839
- providers: [provideTranslocoScope("bookmark")],
19221
+ providers: [provideTranslocoScope("bookmarks")],
18840
19222
  template: `
18841
19223
  <button [variant]="variant()" [size]="size()" [iconOnly]="true"
18842
- [tooltip]="title()"
19224
+ [tooltip]="'bookmarks.bookmarkDocument' | transloco"
18843
19225
  [attr.aria-pressed]="isBookmarked()"
18844
19226
  [attr.tabindex]="tabIndex()"
18845
19227
  (click)="bookmark($event)">
@@ -18849,7 +19231,7 @@ i0.ɵɵngDeclareClassMetadata({
18849
19231
  @else {
18850
19232
  <bookmark-icon />
18851
19233
  }
18852
- <span class="sr-only">{{ title() }}</span>
19234
+ <span class="sr-only">{{ 'bookmarks.bookmarkDocument' | transloco }}</span>
18853
19235
  </button>
18854
19236
  `,
18855
19237
  host: {
@@ -19048,9 +19430,9 @@ var ListPanelComponent = class ListPanelComponent {
19048
19430
  }
19049
19431
  </ListItem>
19050
19432
  } @empty {
19051
- <span class="py-4 text-center text-neutral-500">
19433
+ <div class="py-(--spacing-xs) text-center text-(--font-neutral-base)">
19052
19434
  <ng-content />
19053
- </span>
19435
+ </div>
19054
19436
  }
19055
19437
  </List>
19056
19438
  `,
@@ -19155,9 +19537,9 @@ i0.ɵɵngDeclareClassMetadata({
19155
19537
  }
19156
19538
  </ListItem>
19157
19539
  } @empty {
19158
- <span class="py-4 text-center text-neutral-500">
19540
+ <div class="py-(--spacing-xs) text-center text-(--font-neutral-base)">
19159
19541
  <ng-content />
19160
- </span>
19542
+ </div>
19161
19543
  }
19162
19544
  </List>
19163
19545
  `,
@@ -19300,7 +19682,7 @@ var ListPanelFooterComponent = class ListPanelFooterComponent {
19300
19682
  }
19301
19683
  },
19302
19684
  outputs: { loadMore: "loadMore" },
19303
- host: { properties: { "class": "cn('flex flex-col px-2 mt-2 py-(--spacing-xs)', class())" } },
19685
+ host: { properties: { "class": "cn('flex flex-col gap-(--spacing-xs) px-2 mt-2 py-(--spacing-xs)', class())" } },
19304
19686
  providers: [provideTranslocoScope("list-panel")],
19305
19687
  ngImport: i0,
19306
19688
  template: `
@@ -19452,7 +19834,7 @@ i0.ɵɵngDeclareClassMetadata({
19452
19834
  </button>
19453
19835
  </div>
19454
19836
  `,
19455
- host: { "[class]": "cn('flex flex-col px-2 mt-2 py-(--spacing-xs)', class())" }
19837
+ host: { "[class]": "cn('flex flex-col gap-(--spacing-xs) px-2 mt-2 py-(--spacing-xs)', class())" }
19456
19838
  }]
19457
19839
  }],
19458
19840
  propDecorators: {
@@ -19531,8 +19913,8 @@ var ListPanelHeaderComponent = class ListPanelHeaderComponent {
19531
19913
  template: `
19532
19914
  @if (floating()) {
19533
19915
  <div class="p-2">
19534
- <label class="text-xl font-bold">{{ label() }}</label>
19535
- <hr class="my-(--spacing-3xs) h-px w-full shrink-0 border-0 bg-(--stroke-neutral-disabled)" />
19916
+ <!-- Not an <h2>: a page's prose styles put a rule and margins on every h2, even inside a popover. -->
19917
+ <span role="heading" aria-level="2" class="block text-xl leading-(--line-height-4) font-bold">{{ label() }}</span>
19536
19918
  </div>
19537
19919
  }
19538
19920
  `,
@@ -19551,8 +19933,8 @@ i0.ɵɵngDeclareClassMetadata({
19551
19933
  template: `
19552
19934
  @if (floating()) {
19553
19935
  <div class="p-2">
19554
- <label class="text-xl font-bold">{{ label() }}</label>
19555
- <hr class="my-(--spacing-3xs) h-px w-full shrink-0 border-0 bg-(--stroke-neutral-disabled)" />
19936
+ <!-- Not an <h2>: a page's prose styles put a rule and margins on every h2, even inside a popover. -->
19937
+ <span role="heading" aria-level="2" class="block text-xl leading-(--line-height-4) font-bold">{{ label() }}</span>
19556
19938
  </div>
19557
19939
  }
19558
19940
  `
@@ -19676,7 +20058,7 @@ var BookmarksComponent = class BookmarksComponent {
19676
20058
  } },
19677
20059
  providers: [provideTranslocoScope("bookmarks")],
19678
20060
  ngImport: i0,
19679
- template: "<ListPanelHeader\n [label]=\"'bookmarks.label' | transloco\"\n [floating]=\"!!floating\" />\n\n<ListPanel\n #panel\n [items]=\"paginatedBookmarks()\"\n [getValue]=\"idOf\"\n [getLabel]=\"labelOf\"\n deleteButtonTitle=\"bookmarks.removeBookmark\"\n [ariaLabel]=\"'bookmarks.label' | transloco\"\n (itemClick)=\"onClick($event)\"\n (delete)=\"onDelete($event)\">\n <ng-template listPanelItem let-bookmark>\n <ListItemLeft>\n <BookmarkIcon solid class=\"shrink-0\" />\n <ListItemText>{{ bookmark.label }}</ListItemText>\n </ListItemLeft>\n\n @if (bookmark.author) {\n <Tag variant=\"primary\" scheme=\"sage\" size=\"xs\" class=\"gap-1\">\n <UserIcon class=\"size-3\" />\n <span class=\"min-w-0 truncate\">{{ bookmark.author }}</span>\n </Tag>\n }\n @if (bookmark.parentFolder) {\n <Tag variant=\"primary\" scheme=\"sage\" size=\"xs\" class=\"gap-1\">\n <FolderIcon class=\"size-3\" />\n <span class=\"min-w-0 truncate\">{{ bookmark.parentFolder }}</span>\n </Tag>\n }\n </ng-template>\n\n {{ \"bookmarks.noBookmarks\" | transloco }}\n</ListPanel>\n\n@if (paginatedBookmarks().length > 0) {\n <ListPanelFooter\n [navigationHint]=\"panel.keyboardFocused()\"\n [hasMore]=\"hasMore()\"\n [config]=\"config\"\n (loadMore)=\"loadMore()\" />\n}\n",
20061
+ template: "<ListPanelHeader\n [label]=\"'bookmarks.label' | transloco\"\n [floating]=\"!!floating\" />\n\n<ListPanel\n #panel\n [items]=\"paginatedBookmarks()\"\n [getValue]=\"idOf\"\n [getLabel]=\"labelOf\"\n deleteButtonTitle=\"bookmarks.removeBookmark\"\n [ariaLabel]=\"'bookmarks.label' | transloco\"\n (itemClick)=\"onClick($event)\"\n (delete)=\"onDelete($event)\">\n <ng-template listPanelItem let-bookmark>\n <ListItemLeft>\n <BookmarkIcon solid class=\"shrink-0\" />\n <div class=\"flex min-w-0 flex-1 flex-col gap-(--spacing-3xs)\">\n <ListItemText\n truncated=\".list-item-text\"\n #labelCut=\"truncated\"\n [tooltip]=\"bookmark.label\"\n [tooltipDisabled]=\"!labelCut.isTruncated()\"\n tooltipPosition=\"top\"\n class=\"[&_.list-item-text]:line-clamp-2 in-focus-visible:[&_.list-item-text]:line-clamp-none\">\n {{ bookmark.label }}\n </ListItemText>\n\n @if (bookmark.author || bookmark.parentFolder) {\n <div class=\"flex min-w-0 flex-wrap gap-(--spacing-3xs)\">\n @if (bookmark.author) {\n <Tag\n variant=\"primary\"\n scheme=\"sage\"\n size=\"xs\"\n class=\"max-w-full gap-1\">\n <UserIcon class=\"size-3 shrink-0\" />\n <span\n truncated\n #authorCut=\"truncated\"\n [tooltip]=\"bookmark.author\"\n [tooltipDisabled]=\"!authorCut.isTruncated()\"\n tooltipPosition=\"top\"\n class=\"min-w-0 truncate in-focus-visible:break-words in-focus-visible:whitespace-normal\">\n {{ bookmark.author }}\n </span>\n </Tag>\n }\n @if (bookmark.parentFolder) {\n <Tag\n variant=\"primary\"\n scheme=\"sage\"\n size=\"xs\"\n class=\"max-w-full gap-1\">\n <FolderIcon class=\"size-3 shrink-0\" />\n <span\n truncated\n #folderCut=\"truncated\"\n [tooltip]=\"bookmark.parentFolder\"\n [tooltipDisabled]=\"!folderCut.isTruncated()\"\n tooltipPosition=\"top\"\n class=\"min-w-0 truncate in-focus-visible:break-words in-focus-visible:whitespace-normal\">\n {{ bookmark.parentFolder }}\n </span>\n </Tag>\n }\n </div>\n }\n </div>\n </ListItemLeft>\n </ng-template>\n\n <ListPanelEmpty color=\"pink\">\n <BookmarkIcon listPanelEmptyIcon solid />\n {{ \"bookmarks.noBookmarks\" | transloco }}\n <p listPanelEmptyHint>{{ \"bookmarks.noBookmarksHint\" | transloco }}</p>\n </ListPanelEmpty>\n</ListPanel>\n\n@if (paginatedBookmarks().length > 0) {\n <ListPanelFooter\n [navigationHint]=\"panel.keyboardFocused()\"\n [hasMore]=\"hasMore()\"\n [config]=\"config\"\n (loadMore)=\"loadMore()\" />\n}\n",
19680
20062
  dependencies: [
19681
20063
  {
19682
20064
  kind: "component",
@@ -19699,6 +20081,12 @@ var BookmarksComponent = class BookmarksComponent {
19699
20081
  ],
19700
20082
  outputs: ["itemClick", "delete"]
19701
20083
  },
20084
+ {
20085
+ kind: "component",
20086
+ type: ListPanelEmptyComponent,
20087
+ selector: "list-panel-empty, ListPanelEmpty",
20088
+ inputs: ["color"]
20089
+ },
19702
20090
  {
19703
20091
  kind: "component",
19704
20092
  type: ListPanelFooterComponent,
@@ -19758,6 +20146,39 @@ var BookmarksComponent = class BookmarksComponent {
19758
20146
  "size"
19759
20147
  ]
19760
20148
  },
20149
+ {
20150
+ kind: "directive",
20151
+ type: TooltipDirective,
20152
+ selector: "[tooltip]",
20153
+ inputs: [
20154
+ "tooltip",
20155
+ "tooltipPosition",
20156
+ "tooltipEvent",
20157
+ "showDelay",
20158
+ "hideDelay",
20159
+ "life",
20160
+ "tooltipOffset",
20161
+ "autoHide",
20162
+ "hideOnEscape",
20163
+ "escape",
20164
+ "tooltipDisabled",
20165
+ "tooltipArrow",
20166
+ "tooltipStyleClass",
20167
+ "tooltipOptions",
20168
+ "tooltip-position",
20169
+ "tooltip-delay",
20170
+ "tooltip-duration",
20171
+ "tooltip-offset"
20172
+ ],
20173
+ exportAs: ["tooltip"]
20174
+ },
20175
+ {
20176
+ kind: "directive",
20177
+ type: TruncatedDirective,
20178
+ selector: "[truncated]",
20179
+ inputs: ["truncated"],
20180
+ exportAs: ["truncated"]
20181
+ },
19761
20182
  {
19762
20183
  kind: "pipe",
19763
20184
  type: TranslocoPipe,
@@ -19779,6 +20200,7 @@ i0.ɵɵngDeclareClassMetadata({
19779
20200
  TranslocoPipe,
19780
20201
  ListPanelHeaderComponent,
19781
20202
  ListPanelComponent,
20203
+ ListPanelEmptyComponent,
19782
20204
  ListPanelFooterComponent,
19783
20205
  ListPanelItemDirective,
19784
20206
  BookmarkIcon,
@@ -19786,10 +20208,12 @@ i0.ɵɵngDeclareClassMetadata({
19786
20208
  FolderIcon,
19787
20209
  ListItemLeftDirective,
19788
20210
  ListItemTextComponent,
19789
- TagComponent
20211
+ TagComponent,
20212
+ TooltipDirective,
20213
+ TruncatedDirective
19790
20214
  ],
19791
20215
  providers: [provideTranslocoScope("bookmarks")],
19792
- template: "<ListPanelHeader\n [label]=\"'bookmarks.label' | transloco\"\n [floating]=\"!!floating\" />\n\n<ListPanel\n #panel\n [items]=\"paginatedBookmarks()\"\n [getValue]=\"idOf\"\n [getLabel]=\"labelOf\"\n deleteButtonTitle=\"bookmarks.removeBookmark\"\n [ariaLabel]=\"'bookmarks.label' | transloco\"\n (itemClick)=\"onClick($event)\"\n (delete)=\"onDelete($event)\">\n <ng-template listPanelItem let-bookmark>\n <ListItemLeft>\n <BookmarkIcon solid class=\"shrink-0\" />\n <ListItemText>{{ bookmark.label }}</ListItemText>\n </ListItemLeft>\n\n @if (bookmark.author) {\n <Tag variant=\"primary\" scheme=\"sage\" size=\"xs\" class=\"gap-1\">\n <UserIcon class=\"size-3\" />\n <span class=\"min-w-0 truncate\">{{ bookmark.author }}</span>\n </Tag>\n }\n @if (bookmark.parentFolder) {\n <Tag variant=\"primary\" scheme=\"sage\" size=\"xs\" class=\"gap-1\">\n <FolderIcon class=\"size-3\" />\n <span class=\"min-w-0 truncate\">{{ bookmark.parentFolder }}</span>\n </Tag>\n }\n </ng-template>\n\n {{ \"bookmarks.noBookmarks\" | transloco }}\n</ListPanel>\n\n@if (paginatedBookmarks().length > 0) {\n <ListPanelFooter\n [navigationHint]=\"panel.keyboardFocused()\"\n [hasMore]=\"hasMore()\"\n [config]=\"config\"\n (loadMore)=\"loadMore()\" />\n}\n"
20216
+ template: "<ListPanelHeader\n [label]=\"'bookmarks.label' | transloco\"\n [floating]=\"!!floating\" />\n\n<ListPanel\n #panel\n [items]=\"paginatedBookmarks()\"\n [getValue]=\"idOf\"\n [getLabel]=\"labelOf\"\n deleteButtonTitle=\"bookmarks.removeBookmark\"\n [ariaLabel]=\"'bookmarks.label' | transloco\"\n (itemClick)=\"onClick($event)\"\n (delete)=\"onDelete($event)\">\n <ng-template listPanelItem let-bookmark>\n <ListItemLeft>\n <BookmarkIcon solid class=\"shrink-0\" />\n <div class=\"flex min-w-0 flex-1 flex-col gap-(--spacing-3xs)\">\n <ListItemText\n truncated=\".list-item-text\"\n #labelCut=\"truncated\"\n [tooltip]=\"bookmark.label\"\n [tooltipDisabled]=\"!labelCut.isTruncated()\"\n tooltipPosition=\"top\"\n class=\"[&_.list-item-text]:line-clamp-2 in-focus-visible:[&_.list-item-text]:line-clamp-none\">\n {{ bookmark.label }}\n </ListItemText>\n\n @if (bookmark.author || bookmark.parentFolder) {\n <div class=\"flex min-w-0 flex-wrap gap-(--spacing-3xs)\">\n @if (bookmark.author) {\n <Tag\n variant=\"primary\"\n scheme=\"sage\"\n size=\"xs\"\n class=\"max-w-full gap-1\">\n <UserIcon class=\"size-3 shrink-0\" />\n <span\n truncated\n #authorCut=\"truncated\"\n [tooltip]=\"bookmark.author\"\n [tooltipDisabled]=\"!authorCut.isTruncated()\"\n tooltipPosition=\"top\"\n class=\"min-w-0 truncate in-focus-visible:break-words in-focus-visible:whitespace-normal\">\n {{ bookmark.author }}\n </span>\n </Tag>\n }\n @if (bookmark.parentFolder) {\n <Tag\n variant=\"primary\"\n scheme=\"sage\"\n size=\"xs\"\n class=\"max-w-full gap-1\">\n <FolderIcon class=\"size-3 shrink-0\" />\n <span\n truncated\n #folderCut=\"truncated\"\n [tooltip]=\"bookmark.parentFolder\"\n [tooltipDisabled]=\"!folderCut.isTruncated()\"\n tooltipPosition=\"top\"\n class=\"min-w-0 truncate in-focus-visible:break-words in-focus-visible:whitespace-normal\">\n {{ bookmark.parentFolder }}\n </span>\n </Tag>\n }\n </div>\n }\n </div>\n </ListItemLeft>\n </ng-template>\n\n <ListPanelEmpty color=\"pink\">\n <BookmarkIcon listPanelEmptyIcon solid />\n {{ \"bookmarks.noBookmarks\" | transloco }}\n <p listPanelEmptyHint>{{ \"bookmarks.noBookmarksHint\" | transloco }}</p>\n </ListPanelEmpty>\n</ListPanel>\n\n@if (paginatedBookmarks().length > 0) {\n <ListPanelFooter\n [navigationHint]=\"panel.keyboardFocused()\"\n [hasMore]=\"hasMore()\"\n [config]=\"config\"\n (loadMore)=\"loadMore()\" />\n}\n"
19793
20217
  }]
19794
20218
  }],
19795
20219
  ctorParameters: () => [],
@@ -20045,7 +20469,7 @@ var CollectionsComponent = class CollectionsComponent {
20045
20469
  } },
20046
20470
  providers: [provideTranslocoScope("collections")],
20047
20471
  ngImport: i0,
20048
- template: "<ListPanelHeader\n [label]=\"'collections.label' | transloco\"\n [floating]=\"!!floating\" />\n\n<ListPanel\n #panel\n [items]=\"paginatedCollections()\"\n [getValue]=\"nameOf\"\n [getLabel]=\"nameOf\"\n deleteButtonTitle=\"collections.deleteCollection\"\n [ariaLabel]=\"'collections.label' | transloco\"\n (itemClick)=\"onClick($event)\"\n (delete)=\"onDelete($event)\">\n <ng-template listPanelItem let-collection>\n <ListItemLeft>\n <inbox-icon />\n <ListItemText>{{ collection.name }}</ListItemText>\n </ListItemLeft>\n </ng-template>\n\n {{ \"collections.noCollections\" | transloco }}\n</ListPanel>\n\n@if (paginatedCollections().length > 0) {\n <ListPanelFooter\n [navigationHint]=\"panel.keyboardFocused()\"\n [hasMore]=\"hasMore()\"\n [config]=\"config\"\n (loadMore)=\"loadMore()\" />\n}\n",
20472
+ template: "<ListPanelHeader\n [label]=\"'collections.label' | transloco\"\n [floating]=\"!!floating\" />\n\n<ListPanel\n #panel\n [items]=\"paginatedCollections()\"\n [getValue]=\"nameOf\"\n [getLabel]=\"nameOf\"\n deleteButtonTitle=\"collections.deleteCollection\"\n [ariaLabel]=\"'collections.label' | transloco\"\n (itemClick)=\"onClick($event)\"\n (delete)=\"onDelete($event)\">\n <ng-template listPanelItem let-collection>\n <ListItemLeft>\n <inbox-icon />\n <ListItemText>{{ collection.name }}</ListItemText>\n </ListItemLeft>\n </ng-template>\n\n <ListPanelEmpty color=\"indigo\">\n <inbox-icon listPanelEmptyIcon />\n {{ \"collections.noCollections\" | transloco }}\n <p listPanelEmptyHint>{{ \"collections.noCollectionsHint\" | transloco }}</p>\n </ListPanelEmpty>\n</ListPanel>\n\n@if (paginatedCollections().length > 0) {\n <ListPanelFooter\n [navigationHint]=\"panel.keyboardFocused()\"\n [hasMore]=\"hasMore()\"\n [config]=\"config\"\n (loadMore)=\"loadMore()\" />\n}\n",
20049
20473
  dependencies: [
20050
20474
  {
20051
20475
  kind: "component",
@@ -20068,6 +20492,12 @@ var CollectionsComponent = class CollectionsComponent {
20068
20492
  ],
20069
20493
  outputs: ["itemClick", "delete"]
20070
20494
  },
20495
+ {
20496
+ kind: "component",
20497
+ type: ListPanelEmptyComponent,
20498
+ selector: "list-panel-empty, ListPanelEmpty",
20499
+ inputs: ["color"]
20500
+ },
20071
20501
  {
20072
20502
  kind: "component",
20073
20503
  type: ListPanelFooterComponent,
@@ -20125,6 +20555,7 @@ i0.ɵɵngDeclareClassMetadata({
20125
20555
  TranslocoPipe,
20126
20556
  ListPanelHeaderComponent,
20127
20557
  ListPanelComponent,
20558
+ ListPanelEmptyComponent,
20128
20559
  ListPanelFooterComponent,
20129
20560
  ListPanelItemDirective,
20130
20561
  InboxIcon,
@@ -20132,7 +20563,7 @@ i0.ɵɵngDeclareClassMetadata({
20132
20563
  ListItemTextComponent
20133
20564
  ],
20134
20565
  providers: [provideTranslocoScope("collections")],
20135
- template: "<ListPanelHeader\n [label]=\"'collections.label' | transloco\"\n [floating]=\"!!floating\" />\n\n<ListPanel\n #panel\n [items]=\"paginatedCollections()\"\n [getValue]=\"nameOf\"\n [getLabel]=\"nameOf\"\n deleteButtonTitle=\"collections.deleteCollection\"\n [ariaLabel]=\"'collections.label' | transloco\"\n (itemClick)=\"onClick($event)\"\n (delete)=\"onDelete($event)\">\n <ng-template listPanelItem let-collection>\n <ListItemLeft>\n <inbox-icon />\n <ListItemText>{{ collection.name }}</ListItemText>\n </ListItemLeft>\n </ng-template>\n\n {{ \"collections.noCollections\" | transloco }}\n</ListPanel>\n\n@if (paginatedCollections().length > 0) {\n <ListPanelFooter\n [navigationHint]=\"panel.keyboardFocused()\"\n [hasMore]=\"hasMore()\"\n [config]=\"config\"\n (loadMore)=\"loadMore()\" />\n}\n"
20566
+ template: "<ListPanelHeader\n [label]=\"'collections.label' | transloco\"\n [floating]=\"!!floating\" />\n\n<ListPanel\n #panel\n [items]=\"paginatedCollections()\"\n [getValue]=\"nameOf\"\n [getLabel]=\"nameOf\"\n deleteButtonTitle=\"collections.deleteCollection\"\n [ariaLabel]=\"'collections.label' | transloco\"\n (itemClick)=\"onClick($event)\"\n (delete)=\"onDelete($event)\">\n <ng-template listPanelItem let-collection>\n <ListItemLeft>\n <inbox-icon />\n <ListItemText>{{ collection.name }}</ListItemText>\n </ListItemLeft>\n </ng-template>\n\n <ListPanelEmpty color=\"indigo\">\n <inbox-icon listPanelEmptyIcon />\n {{ \"collections.noCollections\" | transloco }}\n <p listPanelEmptyHint>{{ \"collections.noCollectionsHint\" | transloco }}</p>\n </ListPanelEmpty>\n</ListPanel>\n\n@if (paginatedCollections().length > 0) {\n <ListPanelFooter\n [navigationHint]=\"panel.keyboardFocused()\"\n [hasMore]=\"hasMore()\"\n [config]=\"config\"\n (loadMore)=\"loadMore()\" />\n}\n"
20136
20567
  }]
20137
20568
  }],
20138
20569
  ctorParameters: () => [],
@@ -21890,7 +22321,7 @@ var DidYouMeanComponent = class DidYouMeanComponent {
21890
22321
  <p>
21891
22322
  <span class="me-1">{{ 'didYouMean.didYouMean' | transloco }}</span>
21892
22323
  <a class="text-(--font-primary-base) hover:underline cursor-pointer" role="button" (click)="selectCorrected()">{{ correction() }}</a>
21893
- <span>?</span>
22324
+ <span>{{ 'didYouMean.didYouMeanSuffix' | transloco }}</span>
21894
22325
  </p>
21895
22326
  }
21896
22327
  @case ('correction') {
@@ -21940,7 +22371,7 @@ i0.ɵɵngDeclareClassMetadata({
21940
22371
  <p>
21941
22372
  <span class="me-1">{{ 'didYouMean.didYouMean' | transloco }}</span>
21942
22373
  <a class="text-(--font-primary-base) hover:underline cursor-pointer" role="button" (click)="selectCorrected()">{{ correction() }}</a>
21943
- <span>?</span>
22374
+ <span>{{ 'didYouMean.didYouMeanSuffix' | transloco }}</span>
21944
22375
  </p>
21945
22376
  }
21946
22377
  @case ('correction') {
@@ -30613,6 +31044,14 @@ function navigateToSearch(router, search) {
30613
31044
  if (search.queryParams?.filters && search.queryParams?.filters?.length > 0) queryParams.f = JSON.stringify(search.queryParams?.filters);
30614
31045
  router.navigate([search.queryParams?.path], { queryParams });
30615
31046
  }
31047
+ function searchItemSubtext(transloco, search) {
31048
+ const parts = [];
31049
+ const filterCount = search.filterCount || 0;
31050
+ if (filterCount > 0) parts.push(transloco.translate("searches.filterCount", { count: filterCount }));
31051
+ if (search.date) parts.push(getRelativeDate(transloco.getActiveLang(), search.date));
31052
+ const text = parts.join(" · ");
31053
+ return text ? text.charAt(0).toUpperCase() + text.slice(1) : void 0;
31054
+ }
30616
31055
  const RECENT_SEARCHES_OPTIONS = {
30617
31056
  itemsPerPage: 10,
30618
31057
  showLoadMore: true,
@@ -30629,7 +31068,7 @@ var RecentSearchesComponent = class RecentSearchesComponent {
30629
31068
  userSettingsStore = inject(UserSettingsStore);
30630
31069
  transloco = inject(TranslocoService);
30631
31070
  router = inject(Router);
30632
- getRelativeDate = getRelativeDate;
31071
+ subtextOf = (search) => searchItemSubtext(this.transloco, search);
30633
31072
  urlOf = (search) => search.url;
30634
31073
  labelOf = (search) => search.display || search.label || search.url;
30635
31074
  range = signal(10, ...ngDevMode ? [{ debugName: "range" }] : /* istanbul ignore next */ []);
@@ -30702,24 +31141,15 @@ var RecentSearchesComponent = class RecentSearchesComponent {
30702
31141
  <ng-template listPanelItem let-search>
30703
31142
  <ListItemLeft>
30704
31143
  <history-icon />
30705
- <ListItemText>{{ search.display || search.label }}</ListItemText>
31144
+ <ListItemText [subtext]="subtextOf(search)">{{ search.display || search.label }}</ListItemText>
30706
31145
  </ListItemLeft>
30707
-
30708
- @let filterCount = search.filterCount || 0;
30709
- @if (filterCount > 0) {
30710
- <Badge variant="number" fill="secondary" scheme="grey" size="xs" class="flex items-center gap-1" aria-hidden="true">
30711
- <FilterIcon />
30712
- {{ 'searches.filterCount' | transloco: { count: filterCount } }}
30713
- </Badge>
30714
- }
30715
-
30716
- @let date = search.date;
30717
- @if (date) {
30718
- <Tag variant="primary" scheme="sage" size="xs" class="ms-auto shrink-0 first-letter:capitalize">{{ getRelativeDate(transloco.getActiveLang(), date) }}</Tag>
30719
- }
30720
31146
  </ng-template>
30721
31147
 
30722
- {{ "searches.recent.noRecentSearches" | transloco }}
31148
+ <ListPanelEmpty color="blue">
31149
+ <history-icon listPanelEmptyIcon />
31150
+ {{ "searches.recent.noRecentSearches" | transloco }}
31151
+ <p listPanelEmptyHint>{{ "searches.recent.noRecentSearchesHint" | transloco }}</p>
31152
+ </ListPanelEmpty>
30723
31153
  </ListPanel>
30724
31154
 
30725
31155
  @if (paginatedRecentSearches().length > 0) {
@@ -30737,36 +31167,6 @@ var RecentSearchesComponent = class RecentSearchesComponent {
30737
31167
  selector: "history, history-icon, History, HistoryIcon",
30738
31168
  inputs: ["class"]
30739
31169
  },
30740
- {
30741
- kind: "component",
30742
- type: FilterIcon,
30743
- selector: "filter, filter-icon, Filter, FilterIcon",
30744
- inputs: ["class"]
30745
- },
30746
- {
30747
- kind: "directive",
30748
- type: BadgeComponent,
30749
- selector: "badge, Badge",
30750
- inputs: [
30751
- "class",
30752
- "variant",
30753
- "fill",
30754
- "scheme",
30755
- "size",
30756
- "aria-label"
30757
- ]
30758
- },
30759
- {
30760
- kind: "directive",
30761
- type: TagComponent,
30762
- selector: "tag, Tag",
30763
- inputs: [
30764
- "class",
30765
- "variant",
30766
- "scheme",
30767
- "size"
30768
- ]
30769
- },
30770
31170
  {
30771
31171
  kind: "directive",
30772
31172
  type: ListItemLeftDirective,
@@ -30806,6 +31206,12 @@ var RecentSearchesComponent = class RecentSearchesComponent {
30806
31206
  ],
30807
31207
  outputs: ["itemClick", "delete"]
30808
31208
  },
31209
+ {
31210
+ kind: "component",
31211
+ type: ListPanelEmptyComponent,
31212
+ selector: "list-panel-empty, ListPanelEmpty",
31213
+ inputs: ["color"]
31214
+ },
30809
31215
  {
30810
31216
  kind: "component",
30811
31217
  type: ListPanelFooterComponent,
@@ -30839,14 +31245,12 @@ i0.ɵɵngDeclareClassMetadata({
30839
31245
  imports: [
30840
31246
  TranslocoPipe,
30841
31247
  HistoryIcon,
30842
- FilterIcon,
30843
- BadgeComponent,
30844
- TagComponent,
30845
31248
  ListItemLeftDirective,
30846
31249
  ListItemTextComponent,
30847
31250
  ListPanelItemDirective,
30848
31251
  ListPanelHeaderComponent,
30849
31252
  ListPanelComponent,
31253
+ ListPanelEmptyComponent,
30850
31254
  ListPanelFooterComponent
30851
31255
  ],
30852
31256
  template: `
@@ -30863,24 +31267,15 @@ i0.ɵɵngDeclareClassMetadata({
30863
31267
  <ng-template listPanelItem let-search>
30864
31268
  <ListItemLeft>
30865
31269
  <history-icon />
30866
- <ListItemText>{{ search.display || search.label }}</ListItemText>
31270
+ <ListItemText [subtext]="subtextOf(search)">{{ search.display || search.label }}</ListItemText>
30867
31271
  </ListItemLeft>
30868
-
30869
- @let filterCount = search.filterCount || 0;
30870
- @if (filterCount > 0) {
30871
- <Badge variant="number" fill="secondary" scheme="grey" size="xs" class="flex items-center gap-1" aria-hidden="true">
30872
- <FilterIcon />
30873
- {{ 'searches.filterCount' | transloco: { count: filterCount } }}
30874
- </Badge>
30875
- }
30876
-
30877
- @let date = search.date;
30878
- @if (date) {
30879
- <Tag variant="primary" scheme="sage" size="xs" class="ms-auto shrink-0 first-letter:capitalize">{{ getRelativeDate(transloco.getActiveLang(), date) }}</Tag>
30880
- }
30881
31272
  </ng-template>
30882
31273
 
30883
- {{ "searches.recent.noRecentSearches" | transloco }}
31274
+ <ListPanelEmpty color="blue">
31275
+ <history-icon listPanelEmptyIcon />
31276
+ {{ "searches.recent.noRecentSearches" | transloco }}
31277
+ <p listPanelEmptyHint>{{ "searches.recent.noRecentSearchesHint" | transloco }}</p>
31278
+ </ListPanelEmpty>
30884
31279
  </ListPanel>
30885
31280
 
30886
31281
  @if (paginatedRecentSearches().length > 0) {
@@ -31352,7 +31747,7 @@ var SavedSearchesComponent = class SavedSearchesComponent {
31352
31747
  userSettingsStore = inject(UserSettingsStore);
31353
31748
  transloco = inject(TranslocoService);
31354
31749
  router = inject(Router);
31355
- getRelativeDate = getRelativeDate;
31750
+ subtextOf = (search) => searchItemSubtext(this.transloco, search);
31356
31751
  urlOf = (search) => search.url;
31357
31752
  labelOf = (search) => search.display || search.label || search.url;
31358
31753
  range = signal(10, ...ngDevMode ? [{ debugName: "range" }] : /* istanbul ignore next */ []);
@@ -31425,24 +31820,15 @@ var SavedSearchesComponent = class SavedSearchesComponent {
31425
31820
  <ng-template listPanelItem let-search>
31426
31821
  <ListItemLeft>
31427
31822
  <star-icon [solid]="true" />
31428
- <ListItemText>{{ search.display || search.label }}</ListItemText>
31823
+ <ListItemText [subtext]="subtextOf(search)">{{ search.display || search.label }}</ListItemText>
31429
31824
  </ListItemLeft>
31430
-
31431
- @let filterCount = search.filterCount || 0;
31432
- @if (filterCount > 0) {
31433
- <Badge variant="number" fill="secondary" scheme="grey" size="xs" class="flex items-center gap-1" aria-hidden="true">
31434
- <FilterIcon />
31435
- {{ 'searches.filterCount' | transloco: { count: filterCount } }}
31436
- </Badge>
31437
- }
31438
-
31439
- @let date = search.date;
31440
- @if (date) {
31441
- <Tag variant="primary" scheme="sage" size="xs" class="ms-auto shrink-0 first-letter:capitalize">{{ getRelativeDate(transloco.getActiveLang(), date) }}</Tag>
31442
- }
31443
31825
  </ng-template>
31444
31826
 
31445
- {{ 'searches.saved.noSavedSearches' | transloco }}
31827
+ <ListPanelEmpty color="yellow">
31828
+ <star-icon listPanelEmptyIcon [solid]="true" />
31829
+ {{ 'searches.saved.noSavedSearches' | transloco }}
31830
+ <p listPanelEmptyHint>{{ 'searches.saved.noSavedSearchesHint' | transloco }}</p>
31831
+ </ListPanelEmpty>
31446
31832
  </ListPanel>
31447
31833
 
31448
31834
  @if(paginatedSearches().length > 0) {
@@ -31457,36 +31843,6 @@ var SavedSearchesComponent = class SavedSearchesComponent {
31457
31843
  selector: "star, star-icon, Star, StarIcon",
31458
31844
  inputs: ["class", "solid"]
31459
31845
  },
31460
- {
31461
- kind: "component",
31462
- type: FilterIcon,
31463
- selector: "filter, filter-icon, Filter, FilterIcon",
31464
- inputs: ["class"]
31465
- },
31466
- {
31467
- kind: "directive",
31468
- type: BadgeComponent,
31469
- selector: "badge, Badge",
31470
- inputs: [
31471
- "class",
31472
- "variant",
31473
- "fill",
31474
- "scheme",
31475
- "size",
31476
- "aria-label"
31477
- ]
31478
- },
31479
- {
31480
- kind: "directive",
31481
- type: TagComponent,
31482
- selector: "tag, Tag",
31483
- inputs: [
31484
- "class",
31485
- "variant",
31486
- "scheme",
31487
- "size"
31488
- ]
31489
- },
31490
31846
  {
31491
31847
  kind: "directive",
31492
31848
  type: ListItemLeftDirective,
@@ -31526,6 +31882,12 @@ var SavedSearchesComponent = class SavedSearchesComponent {
31526
31882
  ],
31527
31883
  outputs: ["itemClick", "delete"]
31528
31884
  },
31885
+ {
31886
+ kind: "component",
31887
+ type: ListPanelEmptyComponent,
31888
+ selector: "list-panel-empty, ListPanelEmpty",
31889
+ inputs: ["color"]
31890
+ },
31529
31891
  {
31530
31892
  kind: "component",
31531
31893
  type: ListPanelFooterComponent,
@@ -31559,14 +31921,12 @@ i0.ɵɵngDeclareClassMetadata({
31559
31921
  imports: [
31560
31922
  TranslocoPipe,
31561
31923
  StarIcon,
31562
- FilterIcon,
31563
- BadgeComponent,
31564
- TagComponent,
31565
31924
  ListItemLeftDirective,
31566
31925
  ListItemTextComponent,
31567
31926
  ListPanelItemDirective,
31568
31927
  ListPanelHeaderComponent,
31569
31928
  ListPanelComponent,
31929
+ ListPanelEmptyComponent,
31570
31930
  ListPanelFooterComponent
31571
31931
  ],
31572
31932
  template: `
@@ -31583,24 +31943,15 @@ i0.ɵɵngDeclareClassMetadata({
31583
31943
  <ng-template listPanelItem let-search>
31584
31944
  <ListItemLeft>
31585
31945
  <star-icon [solid]="true" />
31586
- <ListItemText>{{ search.display || search.label }}</ListItemText>
31946
+ <ListItemText [subtext]="subtextOf(search)">{{ search.display || search.label }}</ListItemText>
31587
31947
  </ListItemLeft>
31588
-
31589
- @let filterCount = search.filterCount || 0;
31590
- @if (filterCount > 0) {
31591
- <Badge variant="number" fill="secondary" scheme="grey" size="xs" class="flex items-center gap-1" aria-hidden="true">
31592
- <FilterIcon />
31593
- {{ 'searches.filterCount' | transloco: { count: filterCount } }}
31594
- </Badge>
31595
- }
31596
-
31597
- @let date = search.date;
31598
- @if (date) {
31599
- <Tag variant="primary" scheme="sage" size="xs" class="ms-auto shrink-0 first-letter:capitalize">{{ getRelativeDate(transloco.getActiveLang(), date) }}</Tag>
31600
- }
31601
31948
  </ng-template>
31602
31949
 
31603
- {{ 'searches.saved.noSavedSearches' | transloco }}
31950
+ <ListPanelEmpty color="yellow">
31951
+ <star-icon listPanelEmptyIcon [solid]="true" />
31952
+ {{ 'searches.saved.noSavedSearches' | transloco }}
31953
+ <p listPanelEmptyHint>{{ 'searches.saved.noSavedSearchesHint' | transloco }}</p>
31954
+ </ListPanelEmpty>
31604
31955
  </ListPanel>
31605
31956
 
31606
31957
  @if(paginatedSearches().length > 0) {
@@ -31619,6 +31970,36 @@ i0.ɵɵngDeclareClassMetadata({
31619
31970
  }]
31620
31971
  }] }
31621
31972
  });
31973
+ const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
31974
+ const LEAF_SEPARATOR = " › ";
31975
+ function flattenLeaves(source, prefix = []) {
31976
+ return Object.entries(source).flatMap(([name, value]) => isPlainObject(value) ? flattenLeaves(value, [...prefix, name]) : [{
31977
+ path: [...prefix, name],
31978
+ value
31979
+ }]);
31980
+ }
31981
+ function valueAtPath(source, path) {
31982
+ return path.reduce((node, name) => isPlainObject(node) ? node[name] : void 0, source);
31983
+ }
31984
+ const toFieldText = (value) => {
31985
+ if (value === void 0 || value === null) return "";
31986
+ return Array.isArray(value) ? JSON.stringify(value) : String(value);
31987
+ };
31988
+ function normalizeCustomData(entries) {
31989
+ const properties = /* @__PURE__ */ new Map();
31990
+ const add = (key, value) => {
31991
+ if (!key || properties.has(key)) return;
31992
+ properties.set(key, value === void 0 || value === null ? { key } : {
31993
+ key,
31994
+ defaultValue: value
31995
+ });
31996
+ };
31997
+ for (const entry of Array.isArray(entries) ? entries : []) if (typeof entry === "string") add(entry);
31998
+ else if (entry && typeof entry === "object" && !Array.isArray(entry)) Object.entries(entry).forEach(([key, value]) => add(key, value));
31999
+ else console.warn("[user-profile] customData: entry ignored, expected a string or an object.", entry);
32000
+ return [...properties.values()];
32001
+ }
32002
+ const SAVED_FLASH_MS = 3e3;
31622
32003
  const sameKeys = (a, b) => a.length === b.length && a.every((key, index) => key === b[index]);
31623
32004
  const userProfileSchema = (editing) => schema((p) => {
31624
32005
  applyEach(p.data, (field) => readonly(field, { when: ({ key }) => `data.${key()}` !== editing() }));
@@ -31632,9 +32013,13 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31632
32013
  transloco = inject(TranslocoService);
31633
32014
  injector = inject(Injector);
31634
32015
  document = inject(DOCUMENT);
32016
+ destroyRef = inject(DestroyRef);
31635
32017
  labelClass = FORM_LABEL_CLASS;
31636
32018
  errorClass = FORM_ERROR_CLASS;
32019
+ chipClass = "inline-flex items-center rounded-(--radius-rounded) bg-(--bg-accent-base-alt) px-(--spacing-2xs) text-(length:--size-sm) font-medium text-(--font-accent-base)";
31637
32020
  statusMessage = signal("", ...ngDevMode ? [{ debugName: "statusMessage" }] : /* istanbul ignore next */ []);
32021
+ savedProperty = signal(void 0, ...ngDevMode ? [{ debugName: "savedProperty" }] : /* istanbul ignore next */ []);
32022
+ savedTimer;
31638
32023
  dataKeys = [
31639
32024
  "fullName",
31640
32025
  "mail",
@@ -31657,10 +32042,29 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31657
32042
  equal: sameKeys
31658
32043
  });
31659
32044
  allowProfilePhoto = computed(() => this.keys().some((key) => key === "profilePhoto"), ...ngDevMode ? [{ debugName: "allowProfilePhoto" }] : /* istanbul ignore next */ []);
31660
- customData = computed(() => (this.appStore.general()?.features?.userProfile?.customData || []).filter((key) => !this.keys().some((k) => k.toLowerCase() === key.toLowerCase())), {
32045
+ customDataConfig = computed(() => normalizeCustomData(this.appStore.general()?.features?.userProfile?.customData).filter(({ key }) => !this.keys().some((k) => k.toLowerCase() === key.toLowerCase())), ...ngDevMode ? [{ debugName: "customDataConfig" }] : /* istanbul ignore next */ []);
32046
+ customRows = computed(() => {
32047
+ const stored = this.user()?.customData;
32048
+ return this.customDataConfig().flatMap(({ key, defaultValue }) => {
32049
+ const shape = isPlainObject(defaultValue) ? defaultValue : isPlainObject(stored?.[key]) ? stored[key] : void 0;
32050
+ if (!shape) return [{
32051
+ key,
32052
+ root: key,
32053
+ defaultValue
32054
+ }];
32055
+ return flattenLeaves(shape).map(({ path, value }) => ({
32056
+ key: [key, ...path].join(LEAF_SEPARATOR),
32057
+ root: key,
32058
+ path: [key, ...path],
32059
+ defaultValue: isPlainObject(defaultValue) ? value : void 0
32060
+ }));
32061
+ });
32062
+ }, ...ngDevMode ? [{ debugName: "customRows" }] : /* istanbul ignore next */ []);
32063
+ customData = computed(() => this.customRows().map(({ key }) => key), {
31661
32064
  ...ngDevMode ? { debugName: "customData" } : /* istanbul ignore next */ {},
31662
32065
  equal: sameKeys
31663
32066
  });
32067
+ customRowIndex = computed(() => new Map(this.customRows().map((row) => [row.key, row])), ...ngDevMode ? [{ debugName: "customRowIndex" }] : /* istanbul ignore next */ []);
31664
32068
  properties = computed(() => [...this.formKeys().map((key) => ({
31665
32069
  category: "data",
31666
32070
  key,
@@ -31670,6 +32074,13 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31670
32074
  key,
31671
32075
  translated: false
31672
32076
  }))], ...ngDevMode ? [{ debugName: "properties" }] : /* istanbul ignore next */ []);
32077
+ sections = computed(() => {
32078
+ const rows = this.properties();
32079
+ return ["data", "customData"].map((category) => ({
32080
+ id: category === "data" ? "information" : "preferences",
32081
+ rows: rows.filter((row) => row.category === category)
32082
+ })).filter((section) => section.rows.length > 0);
32083
+ }, ...ngDevMode ? [{ debugName: "sections" }] : /* istanbul ignore next */ []);
31673
32084
  initials = computed(() => this.principalStore.initials(), ...ngDevMode ? [{ debugName: "initials" }] : /* istanbul ignore next */ []);
31674
32085
  username = computed(() => this.principalStore.name() || this.principalStore.email() || null, ...ngDevMode ? [{ debugName: "username" }] : /* istanbul ignore next */ []);
31675
32086
  propertyToEdit = signal(void 0, ...ngDevMode ? [{ debugName: "propertyToEdit" }] : /* istanbul ignore next */ []);
@@ -31695,7 +32106,7 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31695
32106
  const profile = this.user();
31696
32107
  return {
31697
32108
  data: toRecord(this.formKeys(), profile?.data),
31698
- customData: toRecord(this.customData(), profile?.customData)
32109
+ customData: Object.fromEntries(this.customData().map((key) => [key, toFieldText(this.effectiveValue("customData", key))]))
31699
32110
  };
31700
32111
  }, ...ngDevMode ? [{ debugName: "profileModel" }] : /* istanbul ignore next */ []);
31701
32112
  profileForm = form(this.profileModel, userProfileSchema(this.propertyToEdit), {
@@ -31706,6 +32117,7 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31706
32117
  effect(() => {
31707
32118
  if (this.userProfileResource.error()?.status === 404) this.createUserProfile();
31708
32119
  });
32120
+ this.destroyRef.onDestroy(() => clearTimeout(this.savedTimer));
31709
32121
  }
31710
32122
  fieldId(category, key) {
31711
32123
  return `user-profile-${category}-${key.replace(/[^\w-]+/g, "-")}`;
@@ -31734,6 +32146,33 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31734
32146
  getDataValue(category, key) {
31735
32147
  return this.user()?.[category]?.[key];
31736
32148
  }
32149
+ defaultValue(category, key) {
32150
+ return category === "customData" ? this.customRowIndex().get(key)?.defaultValue : void 0;
32151
+ }
32152
+ isLeaf(category, key) {
32153
+ return category === "customData" && this.customRowIndex().get(key)?.path !== void 0;
32154
+ }
32155
+ storedValue(category, key) {
32156
+ const bucket = this.user()?.[category];
32157
+ const path = category === "customData" ? this.customRowIndex().get(key)?.path : void 0;
32158
+ return path ? valueAtPath(bucket, path) : bucket?.[key];
32159
+ }
32160
+ isBoolean(category, key) {
32161
+ return !this.isLeaf(category, key) && typeof this.effectiveValue(category, key) === "boolean";
32162
+ }
32163
+ booleanValue(category, key) {
32164
+ return this.effectiveValue(category, key) === true;
32165
+ }
32166
+ isReadOnlyValue(category, key) {
32167
+ return Array.isArray(this.effectiveValue(category, key));
32168
+ }
32169
+ arrayTags(category, key) {
32170
+ const value = this.effectiveValue(category, key);
32171
+ return [...new Set((Array.isArray(value) ? value : []).map((item) => typeof item === "string" ? item : JSON.stringify(item)))];
32172
+ }
32173
+ effectiveValue(category, key) {
32174
+ return this.storedValue(category, key) ?? this.defaultValue(category, key);
32175
+ }
31737
32176
  onEdit(category, key) {
31738
32177
  this.propertyToEdit.set(`${category}.${key}`);
31739
32178
  this.fieldFor(category, key)?.().focusBoundControl();
@@ -31743,19 +32182,43 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31743
32182
  const field = event.target;
31744
32183
  field.setSelectionRange(field.value.length, field.value.length);
31745
32184
  }
32185
+ onEscape(event, category, key) {
32186
+ if (!this.isEditing(category, key)) return;
32187
+ event.preventDefault();
32188
+ event.stopPropagation();
32189
+ this.onCancelEdit(category, key);
32190
+ }
31746
32191
  onCancelEdit(category, key) {
31747
32192
  if (!this.isEditing(category, key)) return;
31748
32193
  const state = this.fieldFor(category, key)?.();
31749
- state?.value.set(this.getDataValue(category, key) ?? "");
32194
+ state?.value.set(toFieldText(this.effectiveValue(category, key)));
31750
32195
  state?.reset();
31751
32196
  this.leaveEditMode(category, key);
31752
32197
  this.announce("editCancelled", category, key);
31753
32198
  }
31754
- propertyLabel(category, key) {
31755
- return category === "data" ? this.transloco.translate(`userProfile.data.${key}`) : key;
32199
+ labelFor(category, key) {
32200
+ if (category === "data") return this.transloco.translate(`userProfile.data.${key}`);
32201
+ const leafPath = this.customRowIndex().get(key)?.path;
32202
+ if (leafPath) return [this.labelFor(category, leafPath[0]), ...leafPath.slice(1)].join(LEAF_SEPARATOR);
32203
+ const path = `userProfile.customData.${key}`;
32204
+ const translated = this.transloco.translate(path);
32205
+ return translated && translated !== path ? translated : key;
32206
+ }
32207
+ isDefault(category, key) {
32208
+ const stored = this.storedValue(category, key);
32209
+ return (stored === void 0 || stored === null) && this.defaultValue(category, key) !== void 0;
32210
+ }
32211
+ isSaved(category, key) {
32212
+ return this.savedProperty() === `${category}.${key}`;
32213
+ }
32214
+ flashSaved(category, key) {
32215
+ this.savedProperty.set(`${category}.${key}`);
32216
+ clearTimeout(this.savedTimer);
32217
+ this.savedTimer = setTimeout(() => this.savedProperty.set(void 0), SAVED_FLASH_MS);
31756
32218
  }
31757
32219
  announce(kind, category, key) {
31758
- this.statusMessage.set(this.transloco.translate(`userProfile.status.${kind}`, { property: this.propertyLabel(category, key) }));
32220
+ this.statusMessage.set(this.transloco.translate(`userProfile.status.${kind}`, { property: this.labelFor(category, key) }));
32221
+ if (kind === "saved") this.flashSaved(category, key);
31759
32222
  }
31760
32223
  leaveEditMode(category, key) {
31761
32224
  this.propertyToEdit.set(void 0);
@@ -31789,6 +32252,7 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31789
32252
  }
31790
32253
  }
31791
32254
  async onDeleteData(category, key) {
32255
+ if (!this.hasStoredValue(category, key)) return;
31792
32256
  try {
31793
32257
  const response = await this.client.api.userProfile.deleteProperty(this.user(), category, key);
31794
32258
  if (!response) {
@@ -31796,14 +32260,46 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31796
32260
  return;
31797
32261
  }
31798
32262
  info("Deleted user profile property", response);
31799
- const userProfile = JSON.parse(JSON.stringify(this.user()));
31800
- userProfile[category][key] = "";
31801
- this.userProfileResource.set(userProfile);
31802
- this.announce("deleted", category, key);
32263
+ this.forgetProperty(category, key);
31803
32264
  } catch (err) {
32265
+ if (err?.status === 404) {
32266
+ info("User profile property already absent", {
32267
+ category,
32268
+ key
32269
+ });
32270
+ this.forgetProperty(category, key);
32271
+ return;
32272
+ }
31804
32273
  notify.error(err.message);
31805
32274
  }
31806
32275
  }
32276
+ hasStoredValue(category, key) {
32277
+ const stored = this.storedValue(category, key);
32278
+ return stored !== void 0 && stored !== null;
32279
+ }
32280
+ forgetProperty(category, key) {
32281
+ const userProfile = JSON.parse(JSON.stringify(this.user()));
32282
+ if (this.defaultValue(category, key) === void 0) userProfile[category][key] = "";
32283
+ else delete userProfile[category][key];
32284
+ this.userProfileResource.set(userProfile);
32285
+ this.announce("deleted", category, key);
32286
+ }
32287
+ async onToggleBoolean(category, key, checked, toggle) {
32288
+ const previous = this.user();
32289
+ if (!previous || this.booleanValue(category, key) === checked) return;
32290
+ this.userProfileResource.set({
32291
+ ...previous,
32292
+ [category]: {
32293
+ ...previous[category],
32294
+ [key]: checked
32295
+ }
32296
+ });
32297
+ if (await this.patchProperty(category, key, checked)) this.announce("saved", category, key);
32298
+ else {
32299
+ this.userProfileResource.set(previous);
32300
+ toggle.checked.set(!checked);
32301
+ }
32302
+ }
31807
32303
  async patchProperty(category, key, value) {
31808
32304
  const userProfile = JSON.parse(JSON.stringify(this.user()));
31809
32305
  userProfile[category] = {
@@ -31857,7 +32353,7 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31857
32353
  host: { classAttribute: "flex flex-col h-full overflow-hidden" },
31858
32354
  providers: [provideTranslocoScope("user-profile", "login")],
31859
32355
  ngImport: i0,
31860
- 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 [formRoot]=\"profileForm\"\n [attr.aria-label]=\"'userProfile.profileFields' | transloco\"\n [attr.aria-busy]=\"profileForm().submitting() ? 'true' : null\">\n @for (\n property of properties();\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 =\n property.translated\n ? (\"userProfile.data.\" + key | transloco)\n : key;\n @let editing = isEditing(category, key);\n @let invalid = showError(field);\n <!-- role=\"group\" named by the label: without it the four 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. -->\n <div\n role=\"group\"\n [attr.aria-labelledby]=\"labelId(category, key)\"\n class=\"flex flex-col gap-(--spacing-3xs)\">\n <label\n [id]=\"labelId(category, key)\"\n [for]=\"fieldId(category, key)\"\n [class]=\"labelClass\"\n >{{ label }}</label\n >\n <div class=\"group flex flex-row\">\n <div class=\"min-w-0 grow\">\n <textarea-group\n resize=\"auto\"\n [maxRows]=\"3\"\n [scheme]=\"invalid ? 'error' : 'default'\">\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 <textarea\n textarea-control\n rows=\"1\"\n [id]=\"fieldId(category, key)\"\n [formField]=\"field\"\n [attr.placeholder]=\"'userProfile.notDefined' | transloco\"\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)=\"onCancelEdit(category, key)\"></textarea>\n </textarea-group>\n </div>\n <!-- group-focus-within, and not only group-hover: `invisible` takes the buttons out of\n the tab order, so without it editing or deleting a property is unreachable with a\n keyboard. Focusing the field of the row reveals its actions. -->\n @if (editing) {\n <button\n type=\"submit\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-focus-within:visible sm:group-hover:visible\"\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 [iconOnly]=\"true\"\n class=\"visible cursor-pointer sm:invisible sm:group-focus-within:visible sm:group-hover:visible\"\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 [iconOnly]=\"true\"\n [id]=\"editButtonId(category, key)\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-focus-within:visible sm:group-hover:visible\"\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 [iconOnly]=\"true\"\n class=\"text-destructive visible cursor-pointer sm:invisible sm:group-focus-within:visible sm:group-hover:visible\"\n [title]=\"'userProfile.deleteProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco: { property: label }\n \"\n (click)=\"onDeleteData(category, key)\">\n <TrashIcon />\n </button>\n }\n </div>\n @if (invalid) {\n <p\n [id]=\"errorId(category, key)\"\n role=\"alert\"\n [class]=\"errorClass\">\n {{ errorKey(field) | transloco }}\n </p>\n }\n </div>\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",
32356
+ 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",
31861
32357
  dependencies: [
31862
32358
  {
31863
32359
  kind: "directive",
@@ -31950,24 +32446,75 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31950
32446
  "size"
31951
32447
  ]
31952
32448
  },
32449
+ {
32450
+ kind: "component",
32451
+ type: CircleCheckIcon,
32452
+ selector: "circle-check, circle-check-icon, CircleCheck, CircleCheckIcon",
32453
+ inputs: ["class"]
32454
+ },
32455
+ {
32456
+ kind: "component",
32457
+ type: LockIcon,
32458
+ selector: "lock, lock-icon, Lock, LockIcon",
32459
+ inputs: ["class"]
32460
+ },
32461
+ {
32462
+ kind: "component",
32463
+ type: SwitchComponent,
32464
+ selector: "switch, Switch",
32465
+ inputs: [
32466
+ "checked",
32467
+ "disabled",
32468
+ "size",
32469
+ "class",
32470
+ "aria-label",
32471
+ "aria-labelledby",
32472
+ "presentational",
32473
+ "compact",
32474
+ "tabIndex"
32475
+ ],
32476
+ outputs: ["checkedChange"]
32477
+ },
32478
+ {
32479
+ kind: "component",
32480
+ type: TagInputComponent,
32481
+ selector: "TagInput, tag-input, taginput",
32482
+ inputs: [
32483
+ "value",
32484
+ "disabled",
32485
+ "readonly",
32486
+ "placeholder",
32487
+ "separator",
32488
+ "addOnBlur",
32489
+ "size",
32490
+ "scheme",
32491
+ "class",
32492
+ "aria-label",
32493
+ "addLabel",
32494
+ "removeLabel",
32495
+ "addedLabel",
32496
+ "removedLabel"
32497
+ ],
32498
+ outputs: ["valueChange", "touch"]
32499
+ },
31953
32500
  {
31954
32501
  kind: "directive",
31955
- type: TextareaComponent,
31956
- selector: "textarea-group, textareagroup, TextareaGroup",
32502
+ type: InputComponent,
32503
+ selector: "input-group, inputgroup, InputGroup",
31957
32504
  inputs: [
31958
32505
  "class",
31959
32506
  "size",
31960
32507
  "scheme",
31961
- "resize",
31962
32508
  "filled",
31963
- "minRows",
31964
- "maxRows"
32509
+ "numbers",
32510
+ "autoHeight",
32511
+ "textSize"
31965
32512
  ]
31966
32513
  },
31967
32514
  {
31968
32515
  kind: "directive",
31969
- type: TextareaControlDirective,
31970
- selector: "textarea[textarea-control], textarea[textareaControl]",
32516
+ type: InputControlDirective,
32517
+ selector: "input[input-control], input[inputControl]",
31971
32518
  inputs: ["class"]
31972
32519
  },
31973
32520
  {
@@ -32019,18 +32566,22 @@ i0.ɵɵngDeclareClassMetadata({
32019
32566
  UserIcon,
32020
32567
  AvatarImageComponent,
32021
32568
  ButtonComponent,
32022
- TextareaComponent,
32023
- TextareaControlDirective,
32569
+ CircleCheckIcon,
32570
+ LockIcon,
32571
+ SwitchComponent,
32572
+ TagInputComponent,
32573
+ InputComponent,
32574
+ InputControlDirective,
32024
32575
  ChangePasswordComponent
32025
32576
  ],
32026
32577
  providers: [provideTranslocoScope("user-profile", "login")],
32027
- 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 [formRoot]=\"profileForm\"\n [attr.aria-label]=\"'userProfile.profileFields' | transloco\"\n [attr.aria-busy]=\"profileForm().submitting() ? 'true' : null\">\n @for (\n property of properties();\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 =\n property.translated\n ? (\"userProfile.data.\" + key | transloco)\n : key;\n @let editing = isEditing(category, key);\n @let invalid = showError(field);\n <!-- role=\"group\" named by the label: without it the four 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. -->\n <div\n role=\"group\"\n [attr.aria-labelledby]=\"labelId(category, key)\"\n class=\"flex flex-col gap-(--spacing-3xs)\">\n <label\n [id]=\"labelId(category, key)\"\n [for]=\"fieldId(category, key)\"\n [class]=\"labelClass\"\n >{{ label }}</label\n >\n <div class=\"group flex flex-row\">\n <div class=\"min-w-0 grow\">\n <textarea-group\n resize=\"auto\"\n [maxRows]=\"3\"\n [scheme]=\"invalid ? 'error' : 'default'\">\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 <textarea\n textarea-control\n rows=\"1\"\n [id]=\"fieldId(category, key)\"\n [formField]=\"field\"\n [attr.placeholder]=\"'userProfile.notDefined' | transloco\"\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)=\"onCancelEdit(category, key)\"></textarea>\n </textarea-group>\n </div>\n <!-- group-focus-within, and not only group-hover: `invisible` takes the buttons out of\n the tab order, so without it editing or deleting a property is unreachable with a\n keyboard. Focusing the field of the row reveals its actions. -->\n @if (editing) {\n <button\n type=\"submit\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-focus-within:visible sm:group-hover:visible\"\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 [iconOnly]=\"true\"\n class=\"visible cursor-pointer sm:invisible sm:group-focus-within:visible sm:group-hover:visible\"\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 [iconOnly]=\"true\"\n [id]=\"editButtonId(category, key)\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-focus-within:visible sm:group-hover:visible\"\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 [iconOnly]=\"true\"\n class=\"text-destructive visible cursor-pointer sm:invisible sm:group-focus-within:visible sm:group-hover:visible\"\n [title]=\"'userProfile.deleteProperty' | transloco\"\n [attr.aria-label]=\"\n 'userProfile.deletePropertyNamed'\n | transloco: { property: label }\n \"\n (click)=\"onDeleteData(category, key)\">\n <TrashIcon />\n </button>\n }\n </div>\n @if (invalid) {\n <p\n [id]=\"errorId(category, key)\"\n role=\"alert\"\n [class]=\"errorClass\">\n {{ errorKey(field) | transloco }}\n </p>\n }\n </div>\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"
32578
+ 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"
32028
32579
  }]
32029
32580
  }],
32030
32581
  ctorParameters: () => []
32031
32582
  });
32032
32583
  function toRecord(keys, source) {
32033
- return Object.fromEntries(keys.map((key) => [key, String(source?.[key] ?? "")]));
32584
+ return Object.fromEntries(keys.map((key) => [key, toFieldText(source?.[key])]));
32034
32585
  }
32035
32586
  function splitPropertyId(id) {
32036
32587
  const separator = id.indexOf(".");
@@ -32672,8 +33223,10 @@ var SettingsDialogComponent = class SettingsDialogComponent {
32672
33223
  template: `
32673
33224
  @if (!duplicate) {
32674
33225
  <div dialog #dialog="dialog" [closeLabel]="'settings.close' | transloco" (closed)="call.end($event)">
32675
- <DialogContent size="xl" class="h-[min(48rem,calc(100dvh-2rem))] flex-row! items-stretch!">
32676
- <div class="flex w-64 shrink-0 flex-col gap-(--spacing-3xs) bg-(--layer-page-on-layer) p-(--spacing-sm)">
33226
+ <!-- The inset layout of the sidebar, in a dialog: the navigation sits on the tinted surface of the
33227
+ dialog itself and the panel is a white card set into it. -->
33228
+ <DialogContent size="xl" class="h-[min(48rem,calc(100dvh-2rem))] flex-row! items-stretch! bg-(--bg-primary-lighter)">
33229
+ <div class="flex w-64 shrink-0 flex-col gap-(--spacing-3xs) p-(--spacing-sm)">
32677
33230
  <button
32678
33231
  type="button"
32679
33232
  variant="none"
@@ -32710,7 +33263,7 @@ var SettingsDialogComponent = class SettingsDialogComponent {
32710
33263
  </nav>
32711
33264
  </div>
32712
33265
 
32713
- <div class="m-(--spacing-xs) flex min-w-0 flex-1 flex-col overflow-y-auto rounded-(--radius-lg) bg-(--layer-page-layer) p-(--spacing-xl)">
33266
+ <div sidebarInset class="my-(--spacing-xs) mr-(--spacing-xs) overflow-y-auto rounded-(--radius-2xl) bg-(--bg-neutral-white) p-(--spacing-xl) shadow-none">
32714
33267
  @switch (panel()) {
32715
33268
  @case ("account") {
32716
33269
  <settings-account-panel class="flex min-h-0 flex-1 flex-col" [focusOnOpen]="!navigated()" />
@@ -32783,6 +33336,12 @@ var SettingsDialogComponent = class SettingsDialogComponent {
32783
33336
  selector: "x-mark, x-mark-icon, XMark, XMarkIcon",
32784
33337
  inputs: ["class"]
32785
33338
  },
33339
+ {
33340
+ kind: "directive",
33341
+ type: SidebarInsetDirective,
33342
+ selector: "[sidebarInset]",
33343
+ inputs: ["class"]
33344
+ },
32786
33345
  {
32787
33346
  kind: "component",
32788
33347
  type: SettingsAccountPanelComponent,
@@ -32825,6 +33384,7 @@ i0.ɵɵngDeclareClassMetadata({
32825
33384
  PaletteIcon,
32826
33385
  KeyboardIcon,
32827
33386
  XMarkIcon,
33387
+ SidebarInsetDirective,
32828
33388
  SettingsAccountPanelComponent,
32829
33389
  SettingsAppearancePanelComponent,
32830
33390
  SettingsShortcutsPanelComponent
@@ -32833,8 +33393,10 @@ i0.ɵɵngDeclareClassMetadata({
32833
33393
  template: `
32834
33394
  @if (!duplicate) {
32835
33395
  <div dialog #dialog="dialog" [closeLabel]="'settings.close' | transloco" (closed)="call.end($event)">
32836
- <DialogContent size="xl" class="h-[min(48rem,calc(100dvh-2rem))] flex-row! items-stretch!">
32837
- <div class="flex w-64 shrink-0 flex-col gap-(--spacing-3xs) bg-(--layer-page-on-layer) p-(--spacing-sm)">
33396
+ <!-- The inset layout of the sidebar, in a dialog: the navigation sits on the tinted surface of the
33397
+ dialog itself and the panel is a white card set into it. -->
33398
+ <DialogContent size="xl" class="h-[min(48rem,calc(100dvh-2rem))] flex-row! items-stretch! bg-(--bg-primary-lighter)">
33399
+ <div class="flex w-64 shrink-0 flex-col gap-(--spacing-3xs) p-(--spacing-sm)">
32838
33400
  <button
32839
33401
  type="button"
32840
33402
  variant="none"
@@ -32871,7 +33433,7 @@ i0.ɵɵngDeclareClassMetadata({
32871
33433
  </nav>
32872
33434
  </div>
32873
33435
 
32874
- <div class="m-(--spacing-xs) flex min-w-0 flex-1 flex-col overflow-y-auto rounded-(--radius-lg) bg-(--layer-page-layer) p-(--spacing-xl)">
33436
+ <div sidebarInset class="my-(--spacing-xs) mr-(--spacing-xs) overflow-y-auto rounded-(--radius-2xl) bg-(--bg-neutral-white) p-(--spacing-xl) shadow-none">
32875
33437
  @switch (panel()) {
32876
33438
  @case ("account") {
32877
33439
  <settings-account-panel class="flex min-h-0 flex-1 flex-col" [focusOnOpen]="!navigated()" />