@sinequa/atomic-angular 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, 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, LockIcon, 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, SidebarInsetDirective, SlashIcon, SlidersIcon, 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, 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";
@@ -296,8 +296,9 @@ function withAppFeatures() {
296
296
  };
297
297
  },
298
298
  getQueryByName(name) {
299
+ if (!name) return void 0;
299
300
  const queries = store.queries();
300
- return Object.values(queries).find((query) => query.name.toLowerCase() === name.toLowerCase());
301
+ return Object.values(queries).find((query) => query.name?.toLowerCase() === name.toLowerCase());
301
302
  },
302
303
  getQueryByIndex(index) {
303
304
  const queries = store.queries();
@@ -805,6 +806,9 @@ var QueryService = class QueryService {
805
806
  result = signal({}, ...ngDevMode ? [{ debugName: "result" }] : /* istanbul ignore next */ []);
806
807
  failure = computed(() => this.result().$error, ...ngDevMode ? [{ debugName: "failure" }] : /* istanbul ignore next */ []);
807
808
  audit;
809
+ warnEmptySearch() {
810
+ firstValueFrom(this.transloco.selectTranslate("emptySearch", {}, "searches")).catch(() => "Please enter a search query.").then((message) => notify.warning(message));
811
+ }
808
812
  search(q, includeQueryParams = true, audit) {
809
813
  const $auditRecord = { auditEvents: [audit ?? this.audit ?? {
810
814
  type: "Search_Text",
@@ -820,7 +824,7 @@ var QueryService = class QueryService {
820
824
  if (!((this.queryParamsStore.filters?.() || []).length > 0) && !query.basket && query.action !== "open") {
821
825
  if (this.appStore.allowEmptySearch(query?.name || "") === false && query?.text === "") {
822
826
  warn("QueryService.search: Empty search query is not allowed. Returning empty result.");
823
- notify.warning(this.transloco.translate("searches.emptySearch"));
827
+ this.warnEmptySearch();
824
828
  const emptyResult = { records: [] };
825
829
  this.result.set(emptyResult);
826
830
  return of(emptyResult);
@@ -3243,11 +3247,13 @@ i0.ɵɵngDeclareClassMetadata({
3243
3247
  });
3244
3248
  var UserProfileService = class UserProfileService {
3245
3249
  client = inject(ATOMIC_CLIENT);
3250
+ injector = inject(Injector);
3246
3251
  resources = /* @__PURE__ */ new WeakMap();
3247
3252
  getUserProfile(userId) {
3248
3253
  let resourceRef = this.resources.get(userId);
3249
3254
  if (!resourceRef) {
3250
3255
  resourceRef = resource({
3256
+ injector: this.injector,
3251
3257
  params: () => {
3252
3258
  const id = userId();
3253
3259
  return id ? { id } : void 0;
@@ -3426,17 +3432,61 @@ const QUERY_PARAMS_DEFAULTS = {
3426
3432
  neuralSearch: void 0,
3427
3433
  aggregation_overrides: void 0
3428
3434
  };
3435
+ function parseHref(href) {
3436
+ let url;
3437
+ try {
3438
+ url = new URL(href, window.location.origin);
3439
+ } catch {
3440
+ return {
3441
+ path: "",
3442
+ params: new URLSearchParams()
3443
+ };
3444
+ }
3445
+ if (url.hash.startsWith("#/")) {
3446
+ const route = url.hash.slice(1);
3447
+ const queryIndex = route.indexOf("?");
3448
+ const path = queryIndex === -1 ? route : route.slice(0, queryIndex);
3449
+ const search = queryIndex === -1 ? "" : route.slice(queryIndex + 1);
3450
+ return {
3451
+ path,
3452
+ params: new URLSearchParams(search)
3453
+ };
3454
+ }
3455
+ return {
3456
+ path: "",
3457
+ params: url.searchParams
3458
+ };
3459
+ }
3460
+ function parseFilters(raw) {
3461
+ if (!raw) return [];
3462
+ const candidates = [raw];
3463
+ try {
3464
+ const decoded = decodeURIComponent(raw);
3465
+ if (decoded !== raw) candidates.push(decoded);
3466
+ } catch {}
3467
+ for (const candidate of candidates) try {
3468
+ const parsed = JSON.parse(candidate);
3469
+ if (Array.isArray(parsed)) return parsed;
3470
+ } catch {}
3471
+ return [];
3472
+ }
3429
3473
  const QueryParamsStore = signalStore({ providedIn: "root" }, withDevtools("QueryParams"), withQueryParamsFeatures());
3430
3474
  function withQueryParamsFeatures() {
3431
3475
  return signalStoreFeature(withState(QUERY_PARAMS_DEFAULTS), withMethods((store, injector = inject(Injector), router = inject(Router), route = inject(ActivatedRoute), appStore = inject(AppStore)) => ({
3432
3476
  setFromUrl(href) {
3433
3477
  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);
3478
+ const { path, params } = parseHref(href);
3479
+ const text = params.get("q") ?? "";
3480
+ const filters = parseFilters(params.get("f"));
3481
+ const id = params.get("id") ?? "";
3482
+ const sort = params.get("s") ?? "";
3483
+ const tab = params.get("t") ?? "";
3484
+ const basket = params.get("b") ?? "";
3485
+ const name = params.get("n") ?? "";
3486
+ const spellingCorrectionMode = params.get("c") || void 0;
3487
+ const p = params.get("p");
3488
+ const parsedPage = p ? parseInt(p, 10) : NaN;
3489
+ const page = Number.isNaN(parsedPage) ? void 0 : parsedPage;
3440
3490
  patchState(store, (state) => {
3441
3491
  return {
3442
3492
  ...state,
@@ -4934,14 +4984,15 @@ var LoadingComponent = class LoadingComponent {
4934
4984
  clearTimeout(timeout);
4935
4985
  setTimeout(() => {
4936
4986
  const url = this.route.snapshot.queryParams["returnUrl"] || null;
4937
- if (!url) {
4938
- this.router.navigateByUrl(url);
4987
+ if (url) {
4939
4988
  this.queryParamsStore.setFromUrl(url);
4989
+ this.router.navigateByUrl(url);
4940
4990
  } else this.router.navigate(["/"]);
4941
4991
  }, 700);
4942
4992
  }
4943
4993
  });
4944
- if (!this.client.auth.isAuthenticated()) {
4994
+ if (this.client.auth.isAuthenticated()) this.application.updateReadyState(true);
4995
+ else {
4945
4996
  this.application.updateReadyState(false);
4946
4997
  this.client.auth.login().then(() => {
4947
4998
  this.application.updateReadyState(true);
@@ -7203,17 +7254,18 @@ var InfinityScrollDirective = class InfinityScrollDirective {
7203
7254
  alias: "scroll-options"
7204
7255
  });
7205
7256
  onScroll = output();
7206
- observer = new IntersectionObserver(([entry]) => {
7207
- if (entry.isIntersecting) this.onScroll.emit();
7208
- }, this.options());
7257
+ observer;
7209
7258
  el = inject(ElementRef);
7210
7259
  constructor() {
7211
7260
  afterNextRender(() => {
7261
+ this.observer = new IntersectionObserver(([entry]) => {
7262
+ if (entry.isIntersecting) this.onScroll.emit();
7263
+ }, this.options());
7212
7264
  this.observer.observe(this.el.nativeElement);
7213
7265
  });
7214
7266
  }
7215
7267
  ngOnDestroy() {
7216
- this.observer.disconnect();
7268
+ this.observer?.disconnect();
7217
7269
  }
7218
7270
  static ɵfac = i0.ɵɵngDeclareFactory({
7219
7271
  minVersion: "12.0.0",
@@ -18692,7 +18744,6 @@ var BookmarkButtonComponent = class BookmarkButtonComponent {
18692
18744
  isBookmarked = computed(() => {
18693
18745
  return this.userSettingsStore.isBookmarked(this.article());
18694
18746
  }, ...ngDevMode ? [{ debugName: "isBookmarked" }] : /* istanbul ignore next */ []);
18695
- title = computed(() => this.transloco.translate("bookmarks.bookmarkDocument"), ...ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []);
18696
18747
  async bookmark(e) {
18697
18748
  e.stopPropagation();
18698
18749
  const article = this.article();
@@ -18756,11 +18807,11 @@ var BookmarkButtonComponent = class BookmarkButtonComponent {
18756
18807
  properties: { "class.visible": "isBookmarked()" },
18757
18808
  classAttribute: "cursor-pointer"
18758
18809
  },
18759
- providers: [provideTranslocoScope("bookmark")],
18810
+ providers: [provideTranslocoScope("bookmarks")],
18760
18811
  ngImport: i0,
18761
18812
  template: `
18762
18813
  <button [variant]="variant()" [size]="size()" [iconOnly]="true"
18763
- [tooltip]="title()"
18814
+ [tooltip]="'bookmarks.bookmarkDocument' | transloco"
18764
18815
  [attr.aria-pressed]="isBookmarked()"
18765
18816
  [attr.tabindex]="tabIndex()"
18766
18817
  (click)="bookmark($event)">
@@ -18770,7 +18821,7 @@ var BookmarkButtonComponent = class BookmarkButtonComponent {
18770
18821
  @else {
18771
18822
  <bookmark-icon />
18772
18823
  }
18773
- <span class="sr-only">{{ title() }}</span>
18824
+ <span class="sr-only">{{ 'bookmarks.bookmarkDocument' | transloco }}</span>
18774
18825
  </button>
18775
18826
  `,
18776
18827
  isInline: true,
@@ -18818,6 +18869,11 @@ var BookmarkButtonComponent = class BookmarkButtonComponent {
18818
18869
  "tooltip-offset"
18819
18870
  ],
18820
18871
  exportAs: ["tooltip"]
18872
+ },
18873
+ {
18874
+ kind: "pipe",
18875
+ type: TranslocoPipe,
18876
+ name: "transloco"
18821
18877
  }
18822
18878
  ]
18823
18879
  });
@@ -18834,12 +18890,13 @@ i0.ɵɵngDeclareClassMetadata({
18834
18890
  imports: [
18835
18891
  BookmarkIcon,
18836
18892
  ButtonComponent,
18837
- TooltipDirective
18893
+ TooltipDirective,
18894
+ TranslocoPipe
18838
18895
  ],
18839
- providers: [provideTranslocoScope("bookmark")],
18896
+ providers: [provideTranslocoScope("bookmarks")],
18840
18897
  template: `
18841
18898
  <button [variant]="variant()" [size]="size()" [iconOnly]="true"
18842
- [tooltip]="title()"
18899
+ [tooltip]="'bookmarks.bookmarkDocument' | transloco"
18843
18900
  [attr.aria-pressed]="isBookmarked()"
18844
18901
  [attr.tabindex]="tabIndex()"
18845
18902
  (click)="bookmark($event)">
@@ -18849,7 +18906,7 @@ i0.ɵɵngDeclareClassMetadata({
18849
18906
  @else {
18850
18907
  <bookmark-icon />
18851
18908
  }
18852
- <span class="sr-only">{{ title() }}</span>
18909
+ <span class="sr-only">{{ 'bookmarks.bookmarkDocument' | transloco }}</span>
18853
18910
  </button>
18854
18911
  `,
18855
18912
  host: {
@@ -21890,7 +21947,7 @@ var DidYouMeanComponent = class DidYouMeanComponent {
21890
21947
  <p>
21891
21948
  <span class="me-1">{{ 'didYouMean.didYouMean' | transloco }}</span>
21892
21949
  <a class="text-(--font-primary-base) hover:underline cursor-pointer" role="button" (click)="selectCorrected()">{{ correction() }}</a>
21893
- <span>?</span>
21950
+ <span>{{ 'didYouMean.didYouMeanSuffix' | transloco }}</span>
21894
21951
  </p>
21895
21952
  }
21896
21953
  @case ('correction') {
@@ -21940,7 +21997,7 @@ i0.ɵɵngDeclareClassMetadata({
21940
21997
  <p>
21941
21998
  <span class="me-1">{{ 'didYouMean.didYouMean' | transloco }}</span>
21942
21999
  <a class="text-(--font-primary-base) hover:underline cursor-pointer" role="button" (click)="selectCorrected()">{{ correction() }}</a>
21943
- <span>?</span>
22000
+ <span>{{ 'didYouMean.didYouMeanSuffix' | transloco }}</span>
21944
22001
  </p>
21945
22002
  }
21946
22003
  @case ('correction') {
@@ -31619,6 +31676,36 @@ i0.ɵɵngDeclareClassMetadata({
31619
31676
  }]
31620
31677
  }] }
31621
31678
  });
31679
+ const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
31680
+ const LEAF_SEPARATOR = " › ";
31681
+ function flattenLeaves(source, prefix = []) {
31682
+ return Object.entries(source).flatMap(([name, value]) => isPlainObject(value) ? flattenLeaves(value, [...prefix, name]) : [{
31683
+ path: [...prefix, name],
31684
+ value
31685
+ }]);
31686
+ }
31687
+ function valueAtPath(source, path) {
31688
+ return path.reduce((node, name) => isPlainObject(node) ? node[name] : void 0, source);
31689
+ }
31690
+ const toFieldText = (value) => {
31691
+ if (value === void 0 || value === null) return "";
31692
+ return Array.isArray(value) ? JSON.stringify(value) : String(value);
31693
+ };
31694
+ function normalizeCustomData(entries) {
31695
+ const properties = /* @__PURE__ */ new Map();
31696
+ const add = (key, value) => {
31697
+ if (!key || properties.has(key)) return;
31698
+ properties.set(key, value === void 0 || value === null ? { key } : {
31699
+ key,
31700
+ defaultValue: value
31701
+ });
31702
+ };
31703
+ for (const entry of Array.isArray(entries) ? entries : []) if (typeof entry === "string") add(entry);
31704
+ else if (entry && typeof entry === "object" && !Array.isArray(entry)) Object.entries(entry).forEach(([key, value]) => add(key, value));
31705
+ else console.warn("[user-profile] customData: entry ignored, expected a string or an object.", entry);
31706
+ return [...properties.values()];
31707
+ }
31708
+ const SAVED_FLASH_MS = 3e3;
31622
31709
  const sameKeys = (a, b) => a.length === b.length && a.every((key, index) => key === b[index]);
31623
31710
  const userProfileSchema = (editing) => schema((p) => {
31624
31711
  applyEach(p.data, (field) => readonly(field, { when: ({ key }) => `data.${key()}` !== editing() }));
@@ -31632,9 +31719,13 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31632
31719
  transloco = inject(TranslocoService);
31633
31720
  injector = inject(Injector);
31634
31721
  document = inject(DOCUMENT);
31722
+ destroyRef = inject(DestroyRef);
31635
31723
  labelClass = FORM_LABEL_CLASS;
31636
31724
  errorClass = FORM_ERROR_CLASS;
31725
+ 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
31726
  statusMessage = signal("", ...ngDevMode ? [{ debugName: "statusMessage" }] : /* istanbul ignore next */ []);
31727
+ savedProperty = signal(void 0, ...ngDevMode ? [{ debugName: "savedProperty" }] : /* istanbul ignore next */ []);
31728
+ savedTimer;
31638
31729
  dataKeys = [
31639
31730
  "fullName",
31640
31731
  "mail",
@@ -31657,10 +31748,29 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31657
31748
  equal: sameKeys
31658
31749
  });
31659
31750
  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())), {
31751
+ 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 */ []);
31752
+ customRows = computed(() => {
31753
+ const stored = this.user()?.customData;
31754
+ return this.customDataConfig().flatMap(({ key, defaultValue }) => {
31755
+ const shape = isPlainObject(defaultValue) ? defaultValue : isPlainObject(stored?.[key]) ? stored[key] : void 0;
31756
+ if (!shape) return [{
31757
+ key,
31758
+ root: key,
31759
+ defaultValue
31760
+ }];
31761
+ return flattenLeaves(shape).map(({ path, value }) => ({
31762
+ key: [key, ...path].join(LEAF_SEPARATOR),
31763
+ root: key,
31764
+ path: [key, ...path],
31765
+ defaultValue: isPlainObject(defaultValue) ? value : void 0
31766
+ }));
31767
+ });
31768
+ }, ...ngDevMode ? [{ debugName: "customRows" }] : /* istanbul ignore next */ []);
31769
+ customData = computed(() => this.customRows().map(({ key }) => key), {
31661
31770
  ...ngDevMode ? { debugName: "customData" } : /* istanbul ignore next */ {},
31662
31771
  equal: sameKeys
31663
31772
  });
31773
+ customRowIndex = computed(() => new Map(this.customRows().map((row) => [row.key, row])), ...ngDevMode ? [{ debugName: "customRowIndex" }] : /* istanbul ignore next */ []);
31664
31774
  properties = computed(() => [...this.formKeys().map((key) => ({
31665
31775
  category: "data",
31666
31776
  key,
@@ -31670,6 +31780,13 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31670
31780
  key,
31671
31781
  translated: false
31672
31782
  }))], ...ngDevMode ? [{ debugName: "properties" }] : /* istanbul ignore next */ []);
31783
+ sections = computed(() => {
31784
+ const rows = this.properties();
31785
+ return ["data", "customData"].map((category) => ({
31786
+ id: category === "data" ? "information" : "preferences",
31787
+ rows: rows.filter((row) => row.category === category)
31788
+ })).filter((section) => section.rows.length > 0);
31789
+ }, ...ngDevMode ? [{ debugName: "sections" }] : /* istanbul ignore next */ []);
31673
31790
  initials = computed(() => this.principalStore.initials(), ...ngDevMode ? [{ debugName: "initials" }] : /* istanbul ignore next */ []);
31674
31791
  username = computed(() => this.principalStore.name() || this.principalStore.email() || null, ...ngDevMode ? [{ debugName: "username" }] : /* istanbul ignore next */ []);
31675
31792
  propertyToEdit = signal(void 0, ...ngDevMode ? [{ debugName: "propertyToEdit" }] : /* istanbul ignore next */ []);
@@ -31695,7 +31812,7 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31695
31812
  const profile = this.user();
31696
31813
  return {
31697
31814
  data: toRecord(this.formKeys(), profile?.data),
31698
- customData: toRecord(this.customData(), profile?.customData)
31815
+ customData: Object.fromEntries(this.customData().map((key) => [key, toFieldText(this.effectiveValue("customData", key))]))
31699
31816
  };
31700
31817
  }, ...ngDevMode ? [{ debugName: "profileModel" }] : /* istanbul ignore next */ []);
31701
31818
  profileForm = form(this.profileModel, userProfileSchema(this.propertyToEdit), {
@@ -31706,6 +31823,7 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31706
31823
  effect(() => {
31707
31824
  if (this.userProfileResource.error()?.status === 404) this.createUserProfile();
31708
31825
  });
31826
+ this.destroyRef.onDestroy(() => clearTimeout(this.savedTimer));
31709
31827
  }
31710
31828
  fieldId(category, key) {
31711
31829
  return `user-profile-${category}-${key.replace(/[^\w-]+/g, "-")}`;
@@ -31734,6 +31852,33 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31734
31852
  getDataValue(category, key) {
31735
31853
  return this.user()?.[category]?.[key];
31736
31854
  }
31855
+ defaultValue(category, key) {
31856
+ return category === "customData" ? this.customRowIndex().get(key)?.defaultValue : void 0;
31857
+ }
31858
+ isLeaf(category, key) {
31859
+ return category === "customData" && this.customRowIndex().get(key)?.path !== void 0;
31860
+ }
31861
+ storedValue(category, key) {
31862
+ const bucket = this.user()?.[category];
31863
+ const path = category === "customData" ? this.customRowIndex().get(key)?.path : void 0;
31864
+ return path ? valueAtPath(bucket, path) : bucket?.[key];
31865
+ }
31866
+ isBoolean(category, key) {
31867
+ return !this.isLeaf(category, key) && typeof this.effectiveValue(category, key) === "boolean";
31868
+ }
31869
+ booleanValue(category, key) {
31870
+ return this.effectiveValue(category, key) === true;
31871
+ }
31872
+ isReadOnlyValue(category, key) {
31873
+ return Array.isArray(this.effectiveValue(category, key));
31874
+ }
31875
+ arrayTags(category, key) {
31876
+ const value = this.effectiveValue(category, key);
31877
+ return [...new Set((Array.isArray(value) ? value : []).map((item) => typeof item === "string" ? item : JSON.stringify(item)))];
31878
+ }
31879
+ effectiveValue(category, key) {
31880
+ return this.storedValue(category, key) ?? this.defaultValue(category, key);
31881
+ }
31737
31882
  onEdit(category, key) {
31738
31883
  this.propertyToEdit.set(`${category}.${key}`);
31739
31884
  this.fieldFor(category, key)?.().focusBoundControl();
@@ -31743,19 +31888,43 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31743
31888
  const field = event.target;
31744
31889
  field.setSelectionRange(field.value.length, field.value.length);
31745
31890
  }
31891
+ onEscape(event, category, key) {
31892
+ if (!this.isEditing(category, key)) return;
31893
+ event.preventDefault();
31894
+ event.stopPropagation();
31895
+ this.onCancelEdit(category, key);
31896
+ }
31746
31897
  onCancelEdit(category, key) {
31747
31898
  if (!this.isEditing(category, key)) return;
31748
31899
  const state = this.fieldFor(category, key)?.();
31749
- state?.value.set(this.getDataValue(category, key) ?? "");
31900
+ state?.value.set(toFieldText(this.effectiveValue(category, key)));
31750
31901
  state?.reset();
31751
31902
  this.leaveEditMode(category, key);
31752
31903
  this.announce("editCancelled", category, key);
31753
31904
  }
31754
- propertyLabel(category, key) {
31755
- return category === "data" ? this.transloco.translate(`userProfile.data.${key}`) : key;
31905
+ labelFor(category, key) {
31906
+ if (category === "data") return this.transloco.translate(`userProfile.data.${key}`);
31907
+ const leafPath = this.customRowIndex().get(key)?.path;
31908
+ if (leafPath) return [this.labelFor(category, leafPath[0]), ...leafPath.slice(1)].join(LEAF_SEPARATOR);
31909
+ const path = `userProfile.customData.${key}`;
31910
+ const translated = this.transloco.translate(path);
31911
+ return translated && translated !== path ? translated : key;
31912
+ }
31913
+ isDefault(category, key) {
31914
+ const stored = this.storedValue(category, key);
31915
+ return (stored === void 0 || stored === null) && this.defaultValue(category, key) !== void 0;
31916
+ }
31917
+ isSaved(category, key) {
31918
+ return this.savedProperty() === `${category}.${key}`;
31919
+ }
31920
+ flashSaved(category, key) {
31921
+ this.savedProperty.set(`${category}.${key}`);
31922
+ clearTimeout(this.savedTimer);
31923
+ this.savedTimer = setTimeout(() => this.savedProperty.set(void 0), SAVED_FLASH_MS);
31756
31924
  }
31757
31925
  announce(kind, category, key) {
31758
- this.statusMessage.set(this.transloco.translate(`userProfile.status.${kind}`, { property: this.propertyLabel(category, key) }));
31926
+ this.statusMessage.set(this.transloco.translate(`userProfile.status.${kind}`, { property: this.labelFor(category, key) }));
31927
+ if (kind === "saved") this.flashSaved(category, key);
31759
31928
  }
31760
31929
  leaveEditMode(category, key) {
31761
31930
  this.propertyToEdit.set(void 0);
@@ -31789,6 +31958,7 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31789
31958
  }
31790
31959
  }
31791
31960
  async onDeleteData(category, key) {
31961
+ if (!this.hasStoredValue(category, key)) return;
31792
31962
  try {
31793
31963
  const response = await this.client.api.userProfile.deleteProperty(this.user(), category, key);
31794
31964
  if (!response) {
@@ -31796,14 +31966,46 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31796
31966
  return;
31797
31967
  }
31798
31968
  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);
31969
+ this.forgetProperty(category, key);
31803
31970
  } catch (err) {
31971
+ if (err?.status === 404) {
31972
+ info("User profile property already absent", {
31973
+ category,
31974
+ key
31975
+ });
31976
+ this.forgetProperty(category, key);
31977
+ return;
31978
+ }
31804
31979
  notify.error(err.message);
31805
31980
  }
31806
31981
  }
31982
+ hasStoredValue(category, key) {
31983
+ const stored = this.storedValue(category, key);
31984
+ return stored !== void 0 && stored !== null;
31985
+ }
31986
+ forgetProperty(category, key) {
31987
+ const userProfile = JSON.parse(JSON.stringify(this.user()));
31988
+ if (this.defaultValue(category, key) === void 0) userProfile[category][key] = "";
31989
+ else delete userProfile[category][key];
31990
+ this.userProfileResource.set(userProfile);
31991
+ this.announce("deleted", category, key);
31992
+ }
31993
+ async onToggleBoolean(category, key, checked, toggle) {
31994
+ const previous = this.user();
31995
+ if (!previous || this.booleanValue(category, key) === checked) return;
31996
+ this.userProfileResource.set({
31997
+ ...previous,
31998
+ [category]: {
31999
+ ...previous[category],
32000
+ [key]: checked
32001
+ }
32002
+ });
32003
+ if (await this.patchProperty(category, key, checked)) this.announce("saved", category, key);
32004
+ else {
32005
+ this.userProfileResource.set(previous);
32006
+ toggle.checked.set(!checked);
32007
+ }
32008
+ }
31807
32009
  async patchProperty(category, key, value) {
31808
32010
  const userProfile = JSON.parse(JSON.stringify(this.user()));
31809
32011
  userProfile[category] = {
@@ -31857,7 +32059,7 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31857
32059
  host: { classAttribute: "flex flex-col h-full overflow-hidden" },
31858
32060
  providers: [provideTranslocoScope("user-profile", "login")],
31859
32061
  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",
32062
+ 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
32063
  dependencies: [
31862
32064
  {
31863
32065
  kind: "directive",
@@ -31950,24 +32152,75 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31950
32152
  "size"
31951
32153
  ]
31952
32154
  },
32155
+ {
32156
+ kind: "component",
32157
+ type: CircleCheckIcon,
32158
+ selector: "circle-check, circle-check-icon, CircleCheck, CircleCheckIcon",
32159
+ inputs: ["class"]
32160
+ },
32161
+ {
32162
+ kind: "component",
32163
+ type: LockIcon,
32164
+ selector: "lock, lock-icon, Lock, LockIcon",
32165
+ inputs: ["class"]
32166
+ },
32167
+ {
32168
+ kind: "component",
32169
+ type: SwitchComponent,
32170
+ selector: "switch, Switch",
32171
+ inputs: [
32172
+ "checked",
32173
+ "disabled",
32174
+ "size",
32175
+ "class",
32176
+ "aria-label",
32177
+ "aria-labelledby",
32178
+ "presentational",
32179
+ "compact",
32180
+ "tabIndex"
32181
+ ],
32182
+ outputs: ["checkedChange"]
32183
+ },
32184
+ {
32185
+ kind: "component",
32186
+ type: TagInputComponent,
32187
+ selector: "TagInput, tag-input, taginput",
32188
+ inputs: [
32189
+ "value",
32190
+ "disabled",
32191
+ "readonly",
32192
+ "placeholder",
32193
+ "separator",
32194
+ "addOnBlur",
32195
+ "size",
32196
+ "scheme",
32197
+ "class",
32198
+ "aria-label",
32199
+ "addLabel",
32200
+ "removeLabel",
32201
+ "addedLabel",
32202
+ "removedLabel"
32203
+ ],
32204
+ outputs: ["valueChange", "touch"]
32205
+ },
31953
32206
  {
31954
32207
  kind: "directive",
31955
- type: TextareaComponent,
31956
- selector: "textarea-group, textareagroup, TextareaGroup",
32208
+ type: InputComponent,
32209
+ selector: "input-group, inputgroup, InputGroup",
31957
32210
  inputs: [
31958
32211
  "class",
31959
32212
  "size",
31960
32213
  "scheme",
31961
- "resize",
31962
32214
  "filled",
31963
- "minRows",
31964
- "maxRows"
32215
+ "numbers",
32216
+ "autoHeight",
32217
+ "textSize"
31965
32218
  ]
31966
32219
  },
31967
32220
  {
31968
32221
  kind: "directive",
31969
- type: TextareaControlDirective,
31970
- selector: "textarea[textarea-control], textarea[textareaControl]",
32222
+ type: InputControlDirective,
32223
+ selector: "input[input-control], input[inputControl]",
31971
32224
  inputs: ["class"]
31972
32225
  },
31973
32226
  {
@@ -32019,18 +32272,22 @@ i0.ɵɵngDeclareClassMetadata({
32019
32272
  UserIcon,
32020
32273
  AvatarImageComponent,
32021
32274
  ButtonComponent,
32022
- TextareaComponent,
32023
- TextareaControlDirective,
32275
+ CircleCheckIcon,
32276
+ LockIcon,
32277
+ SwitchComponent,
32278
+ TagInputComponent,
32279
+ InputComponent,
32280
+ InputControlDirective,
32024
32281
  ChangePasswordComponent
32025
32282
  ],
32026
32283
  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"
32284
+ 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
32285
  }]
32029
32286
  }],
32030
32287
  ctorParameters: () => []
32031
32288
  });
32032
32289
  function toRecord(keys, source) {
32033
- return Object.fromEntries(keys.map((key) => [key, String(source?.[key] ?? "")]));
32290
+ return Object.fromEntries(keys.map((key) => [key, toFieldText(source?.[key])]));
32034
32291
  }
32035
32292
  function splitPropertyId(id) {
32036
32293
  const separator = id.indexOf(".");
@@ -32672,8 +32929,10 @@ var SettingsDialogComponent = class SettingsDialogComponent {
32672
32929
  template: `
32673
32930
  @if (!duplicate) {
32674
32931
  <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)">
32932
+ <!-- The inset layout of the sidebar, in a dialog: the navigation sits on the tinted surface of the
32933
+ dialog itself and the panel is a white card set into it. -->
32934
+ <DialogContent size="xl" class="h-[min(48rem,calc(100dvh-2rem))] flex-row! items-stretch! bg-(--bg-primary-lighter)">
32935
+ <div class="flex w-64 shrink-0 flex-col gap-(--spacing-3xs) p-(--spacing-sm)">
32677
32936
  <button
32678
32937
  type="button"
32679
32938
  variant="none"
@@ -32710,7 +32969,7 @@ var SettingsDialogComponent = class SettingsDialogComponent {
32710
32969
  </nav>
32711
32970
  </div>
32712
32971
 
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)">
32972
+ <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
32973
  @switch (panel()) {
32715
32974
  @case ("account") {
32716
32975
  <settings-account-panel class="flex min-h-0 flex-1 flex-col" [focusOnOpen]="!navigated()" />
@@ -32783,6 +33042,12 @@ var SettingsDialogComponent = class SettingsDialogComponent {
32783
33042
  selector: "x-mark, x-mark-icon, XMark, XMarkIcon",
32784
33043
  inputs: ["class"]
32785
33044
  },
33045
+ {
33046
+ kind: "directive",
33047
+ type: SidebarInsetDirective,
33048
+ selector: "[sidebarInset]",
33049
+ inputs: ["class"]
33050
+ },
32786
33051
  {
32787
33052
  kind: "component",
32788
33053
  type: SettingsAccountPanelComponent,
@@ -32825,6 +33090,7 @@ i0.ɵɵngDeclareClassMetadata({
32825
33090
  PaletteIcon,
32826
33091
  KeyboardIcon,
32827
33092
  XMarkIcon,
33093
+ SidebarInsetDirective,
32828
33094
  SettingsAccountPanelComponent,
32829
33095
  SettingsAppearancePanelComponent,
32830
33096
  SettingsShortcutsPanelComponent
@@ -32833,8 +33099,10 @@ i0.ɵɵngDeclareClassMetadata({
32833
33099
  template: `
32834
33100
  @if (!duplicate) {
32835
33101
  <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)">
33102
+ <!-- The inset layout of the sidebar, in a dialog: the navigation sits on the tinted surface of the
33103
+ dialog itself and the panel is a white card set into it. -->
33104
+ <DialogContent size="xl" class="h-[min(48rem,calc(100dvh-2rem))] flex-row! items-stretch! bg-(--bg-primary-lighter)">
33105
+ <div class="flex w-64 shrink-0 flex-col gap-(--spacing-3xs) p-(--spacing-sm)">
32838
33106
  <button
32839
33107
  type="button"
32840
33108
  variant="none"
@@ -32871,7 +33139,7 @@ i0.ɵɵngDeclareClassMetadata({
32871
33139
  </nav>
32872
33140
  </div>
32873
33141
 
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)">
33142
+ <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
33143
  @switch (panel()) {
32876
33144
  @case ("account") {
32877
33145
  <settings-account-panel class="flex min-h-0 flex-1 flex-col" [focusOnOpen]="!navigated()" />