@sinequa/atomic-angular 2.5.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";
@@ -214,47 +214,55 @@ function withAppFeatures() {
214
214
  data: {}
215
215
  }), withMethods((store) => {
216
216
  const client = inject(ATOMIC_CLIENT);
217
- return {
218
- async initialize() {
219
- let app;
220
- try {
221
- app = await client.api.app.get();
222
- } catch (error) {
223
- console.error("Error fetching app data:", error);
224
- throw error;
225
- }
226
- patchState(store, app);
227
- const columnMap = {};
228
- Object.values(extraColumns).forEach((col) => {
229
- columnMap[col.name.toLocaleLowerCase()] = col;
217
+ const hydrate = (app) => {
218
+ patchState(store, app);
219
+ const columnMap = {};
220
+ Object.values(extraColumns).forEach((col) => {
221
+ columnMap[col.name.toLocaleLowerCase()] = col;
222
+ if (col.aliases) col.aliases.forEach((alias) => {
223
+ columnMap[alias.toLocaleLowerCase()] = col;
224
+ });
225
+ });
226
+ const schema = app.indexes._?.columns;
227
+ if (schema) {
228
+ Object.keys(schema).forEach((schemaKey) => {
229
+ const col = schema[schemaKey.toLocaleLowerCase()];
230
+ if (!col) return;
231
+ columnMap[schemaKey.toLocaleLowerCase()] = col;
230
232
  if (col.aliases) col.aliases.forEach((alias) => {
231
233
  columnMap[alias.toLocaleLowerCase()] = col;
232
234
  });
233
235
  });
234
- const schema = app.indexes._?.columns;
235
- if (schema) {
236
- Object.keys(schema).forEach((schemaKey) => {
237
- const col = schema[schemaKey.toLocaleLowerCase()];
236
+ Object.keys(app.queries).forEach((queryKey) => {
237
+ const query = app.queries[queryKey];
238
+ if (query.columnsInfo?.columns) Object.values(query.columnsInfo.columns).forEach((c) => {
239
+ const col = schema[c.name.toLocaleLowerCase()];
238
240
  if (!col) return;
239
- columnMap[schemaKey.toLocaleLowerCase()] = col;
240
- if (col.aliases) col.aliases.forEach((alias) => {
241
+ columnMap[c.name.toLocaleLowerCase()] = col;
242
+ if (c.aliases) c.aliases.split(/[,;|]/).map((a) => a.trim()).forEach((alias) => {
241
243
  columnMap[alias.toLocaleLowerCase()] = col;
242
244
  });
243
245
  });
244
- Object.keys(app.queries).forEach((queryKey) => {
245
- const query = app.queries[queryKey];
246
- if (query.columnsInfo?.columns) Object.values(query.columnsInfo.columns).forEach((c) => {
247
- const col = schema[c.name.toLocaleLowerCase()];
248
- if (!col) return;
249
- columnMap[c.name.toLocaleLowerCase()] = col;
250
- if (c.aliases) c.aliases.split(/[,;|]/).map((a) => a.trim()).forEach((alias) => {
251
- columnMap[alias.toLocaleLowerCase()] = col;
252
- });
253
- });
254
- });
255
- patchState(store, { columnMap });
246
+ });
247
+ patchState(store, { columnMap });
248
+ }
249
+ };
250
+ const fail = (error) => {
251
+ console.error("Error fetching app data:", error);
252
+ throw error;
253
+ };
254
+ return {
255
+ async initialize() {
256
+ let app;
257
+ try {
258
+ app = await client.api.app.get();
259
+ } catch (error) {
260
+ return fail(error);
256
261
  }
262
+ hydrate(app);
257
263
  },
264
+ hydrate,
265
+ fail,
258
266
  async initializeWithAppName(appName) {
259
267
  const app = await client.api.app.get(appName);
260
268
  patchState(store, app);
@@ -288,8 +296,9 @@ function withAppFeatures() {
288
296
  };
289
297
  },
290
298
  getQueryByName(name) {
299
+ if (!name) return void 0;
291
300
  const queries = store.queries();
292
- return Object.values(queries).find((query) => query.name.toLowerCase() === name.toLowerCase());
301
+ return Object.values(queries).find((query) => query.name?.toLowerCase() === name.toLowerCase());
293
302
  },
294
303
  getQueryByIndex(index) {
295
304
  const queries = store.queries();
@@ -500,7 +509,23 @@ const PrincipalStore = signalStore({ providedIn: "root" }, withDevtools("Princip
500
509
  })), withMethods((store) => {
501
510
  const client = inject(ATOMIC_CLIENT);
502
511
  let pendingInitialization = null;
512
+ const hydrate = (principal) => {
513
+ const { userOverrideActive = false } = client.config;
514
+ patchState(store, {
515
+ ...initialPrincipal,
516
+ ...principal,
517
+ userOverrideActive,
518
+ state: "loaded"
519
+ });
520
+ };
521
+ const fail = (error) => {
522
+ console.error("Principal fetch failed", error);
523
+ patchState(store, { state: "error" });
524
+ throw error;
525
+ };
503
526
  return {
527
+ hydrate,
528
+ fail,
504
529
  principal() {
505
530
  const { userOverrideActive: _, ...rest } = getState(store);
506
531
  return Object.entries(rest).reduce((acc, [k, v]) => {
@@ -513,18 +538,9 @@ const PrincipalStore = signalStore({ providedIn: "root" }, withDevtools("Princip
513
538
  pendingInitialization = (async () => {
514
539
  patchState(store, { state: "loading" });
515
540
  try {
516
- const principal = await client.api.principal.get();
517
- const { userOverrideActive = false } = client.config;
518
- patchState(store, {
519
- ...initialPrincipal,
520
- ...principal,
521
- userOverrideActive,
522
- state: "loaded"
523
- });
541
+ hydrate(await client.api.principal.get());
524
542
  } catch (error) {
525
- console.error("Principal fetch failed", error);
526
- patchState(store, { state: "error" });
527
- throw error;
543
+ fail(error);
528
544
  } finally {
529
545
  pendingInitialization = null;
530
546
  }
@@ -790,6 +806,9 @@ var QueryService = class QueryService {
790
806
  result = signal({}, ...ngDevMode ? [{ debugName: "result" }] : /* istanbul ignore next */ []);
791
807
  failure = computed(() => this.result().$error, ...ngDevMode ? [{ debugName: "failure" }] : /* istanbul ignore next */ []);
792
808
  audit;
809
+ warnEmptySearch() {
810
+ firstValueFrom(this.transloco.selectTranslate("emptySearch", {}, "searches")).catch(() => "Please enter a search query.").then((message) => notify.warning(message));
811
+ }
793
812
  search(q, includeQueryParams = true, audit) {
794
813
  const $auditRecord = { auditEvents: [audit ?? this.audit ?? {
795
814
  type: "Search_Text",
@@ -805,7 +824,7 @@ var QueryService = class QueryService {
805
824
  if (!((this.queryParamsStore.filters?.() || []).length > 0) && !query.basket && query.action !== "open") {
806
825
  if (this.appStore.allowEmptySearch(query?.name || "") === false && query?.text === "") {
807
826
  warn("QueryService.search: Empty search query is not allowed. Returning empty result.");
808
- notify.warning(this.transloco.translate("searches.emptySearch"));
827
+ this.warnEmptySearch();
809
828
  const emptyResult = { records: [] };
810
829
  this.result.set(emptyResult);
811
830
  return of(emptyResult);
@@ -1256,6 +1275,16 @@ function AuthGuard() {
1256
1275
  return true;
1257
1276
  };
1258
1277
  }
1278
+ const STARTUP_DATA_SOURCE = new InjectionToken("STARTUP_DATA_SOURCE", { factory: () => null });
1279
+ function provideStartupAggregation(endpoint = "bff/bootstrap") {
1280
+ return makeEnvironmentProviders([{
1281
+ provide: STARTUP_DATA_SOURCE,
1282
+ useFactory: () => {
1283
+ const client = inject(ATOMIC_CLIENT);
1284
+ return () => client.http.get(endpoint);
1285
+ }
1286
+ }]);
1287
+ }
1259
1288
  var UserSettingsPersistence = class UserSettingsPersistence {
1260
1289
  loaded = signal(false, ...ngDevMode ? [{ debugName: "loaded" }] : /* istanbul ignore next */ []);
1261
1290
  isLoaded = this.loaded.asReadonly();
@@ -1735,18 +1764,25 @@ function withUserSettingsFeatures() {
1735
1764
  }), withMethods((store) => {
1736
1765
  const client = inject(ATOMIC_CLIENT);
1737
1766
  const persistence = inject(UserSettingsPersistence);
1767
+ const hydrate = (settings) => {
1768
+ patchState(store, {
1769
+ ...USER_SETTINGS_DEFAULTS,
1770
+ ...settings
1771
+ });
1772
+ persistence.markLoaded();
1773
+ };
1774
+ const fail = (err) => {
1775
+ error("Error fetching user settings:", err);
1776
+ persistence.markUnloaded();
1777
+ };
1738
1778
  return {
1779
+ hydrate,
1780
+ fail,
1739
1781
  async initialize() {
1740
1782
  try {
1741
- const settings = await client.api.userSettings.get();
1742
- patchState(store, {
1743
- ...USER_SETTINGS_DEFAULTS,
1744
- ...settings
1745
- });
1746
- persistence.markLoaded();
1783
+ hydrate(await client.api.userSettings.get());
1747
1784
  } catch (err) {
1748
- error("Error fetching user settings:", err);
1749
- persistence.markUnloaded();
1785
+ fail(err);
1750
1786
  }
1751
1787
  },
1752
1788
  async reset() {
@@ -1776,6 +1812,14 @@ function withAgentsFeatures() {
1776
1812
  }));
1777
1813
  }
1778
1814
  const UserSettingsStore = signalStore({ providedIn: "root" }, withDevtools("UserSettings"), withBookmarkFeatures(), withRecentSearchesFeatures(), withSavedSearchesFeatures(), withBasketsFeatures(), withAssistantFeatures(), withUserSettingsFeatures(), withAlertsFeatures(), withAgentsFeatures(), withDarkModeFeatures(), withHighlightCategoriesFeatures());
1815
+ const toPart = (result) => result.status === "fulfilled" ? {
1816
+ ok: true,
1817
+ value: result.value
1818
+ } : {
1819
+ ok: false,
1820
+ error: result.reason
1821
+ };
1822
+ const provided = (part) => part && typeof part === "object" && part.ok === true ? Promise.resolve(part.value) : void 0;
1779
1823
  const ROUTE_COMPONENTS = new InjectionToken("ROUTE_COMPONENTS", { factory: () => [] });
1780
1824
  var ApplicationService = class ApplicationService {
1781
1825
  userSettingsStore = inject(UserSettingsStore);
@@ -1785,6 +1829,7 @@ var ApplicationService = class ApplicationService {
1785
1829
  router = inject(Router);
1786
1830
  titleService = inject(Title);
1787
1831
  client = inject(ATOMIC_CLIENT);
1832
+ startupDataSource = inject(STARTUP_DATA_SOURCE);
1788
1833
  components = inject(ROUTE_COMPONENTS);
1789
1834
  defaultComponent = computed(() => this.components.find((c) => c.path === "all")?.component, ...ngDevMode ? [{ debugName: "defaultComponent" }] : /* istanbul ignore next */ []);
1790
1835
  defaultLayoutComponent = computed(() => this.components.find((c) => c.isRoot)?.component, ...ngDevMode ? [{ debugName: "defaultLayoutComponent" }] : /* istanbul ignore next */ []);
@@ -1807,18 +1852,46 @@ var ApplicationService = class ApplicationService {
1807
1852
  }
1808
1853
  async initializeStores(options) {
1809
1854
  try {
1810
- await this.appStore.initialize();
1855
+ const data = await this.loadStartupData();
1856
+ this.apply(data.app, this.appStore);
1811
1857
  info("appStore", getState(this.appStore));
1812
- await this.principalStore.initialize();
1858
+ this.apply(data.principal, this.principalStore);
1813
1859
  info("principalStore", getState(this.principalStore));
1814
1860
  this.queryParamsStore.reset({ syncUrl: options?.syncUrl ?? !options?.isBootstrap });
1815
- await this.userSettingsStore.initialize();
1861
+ this.apply(data.userSettings, this.userSettingsStore);
1816
1862
  info("userSettingsStore", getState(this.userSettingsStore));
1817
1863
  } catch (err) {
1818
1864
  error("Error during application initialization", err);
1819
1865
  throw err;
1820
1866
  }
1821
1867
  }
1868
+ async loadStartupData() {
1869
+ const source = await this.readStartupDataSource();
1870
+ const [app, principal, userSettings] = await Promise.allSettled([
1871
+ provided(source?.app) ?? this.client.api.app.get(),
1872
+ provided(source?.principal) ?? this.client.api.principal.get(),
1873
+ provided(source?.userSettings) ?? this.client.api.userSettings.get()
1874
+ ]);
1875
+ return {
1876
+ app: toPart(app),
1877
+ principal: toPart(principal),
1878
+ userSettings: toPart(userSettings)
1879
+ };
1880
+ }
1881
+ async readStartupDataSource() {
1882
+ if (!this.startupDataSource) return void 0;
1883
+ try {
1884
+ const data = await this.startupDataSource();
1885
+ if (data && typeof data === "object") return data;
1886
+ warn("Start-up aggregation answered no data, reading each part instead", data);
1887
+ } catch (err) {
1888
+ warn("Start-up aggregation failed, reading each part instead", err);
1889
+ }
1890
+ }
1891
+ apply(part, store) {
1892
+ if (part.ok) store.hydrate(part.value);
1893
+ else store.fail(part.error);
1894
+ }
1822
1895
  createRoutes() {
1823
1896
  const { queries, data: custom, customJSONs = [] } = getState(this.appStore);
1824
1897
  const queriesMap = Object.entries(queries).map(([key, value]) => ({
@@ -3174,11 +3247,13 @@ i0.ɵɵngDeclareClassMetadata({
3174
3247
  });
3175
3248
  var UserProfileService = class UserProfileService {
3176
3249
  client = inject(ATOMIC_CLIENT);
3250
+ injector = inject(Injector);
3177
3251
  resources = /* @__PURE__ */ new WeakMap();
3178
3252
  getUserProfile(userId) {
3179
3253
  let resourceRef = this.resources.get(userId);
3180
3254
  if (!resourceRef) {
3181
3255
  resourceRef = resource({
3256
+ injector: this.injector,
3182
3257
  params: () => {
3183
3258
  const id = userId();
3184
3259
  return id ? { id } : void 0;
@@ -3357,17 +3432,61 @@ const QUERY_PARAMS_DEFAULTS = {
3357
3432
  neuralSearch: void 0,
3358
3433
  aggregation_overrides: void 0
3359
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
+ }
3360
3473
  const QueryParamsStore = signalStore({ providedIn: "root" }, withDevtools("QueryParams"), withQueryParamsFeatures());
3361
3474
  function withQueryParamsFeatures() {
3362
3475
  return signalStoreFeature(withState(QUERY_PARAMS_DEFAULTS), withMethods((store, injector = inject(Injector), router = inject(Router), route = inject(ActivatedRoute), appStore = inject(AppStore)) => ({
3363
3476
  setFromUrl(href) {
3364
3477
  if (!href) return;
3365
- const path = new URL(href, window.location.origin).hash.split("?")[0].replace(/^#/, "");
3366
- const { q: text, f, id, p, s: sort, c, t: tab, basket, n: name } = queryParamsFromUrl(href);
3367
- const filters = f ? JSON.parse(decodeURIComponent(f)) : [];
3368
- const spellingCorrectionMode = c;
3369
- let page;
3370
- 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;
3371
3490
  patchState(store, (state) => {
3372
3491
  return {
3373
3492
  ...state,
@@ -4865,14 +4984,15 @@ var LoadingComponent = class LoadingComponent {
4865
4984
  clearTimeout(timeout);
4866
4985
  setTimeout(() => {
4867
4986
  const url = this.route.snapshot.queryParams["returnUrl"] || null;
4868
- if (!url) {
4869
- this.router.navigateByUrl(url);
4987
+ if (url) {
4870
4988
  this.queryParamsStore.setFromUrl(url);
4989
+ this.router.navigateByUrl(url);
4871
4990
  } else this.router.navigate(["/"]);
4872
4991
  }, 700);
4873
4992
  }
4874
4993
  });
4875
- if (!this.client.auth.isAuthenticated()) {
4994
+ if (this.client.auth.isAuthenticated()) this.application.updateReadyState(true);
4995
+ else {
4876
4996
  this.application.updateReadyState(false);
4877
4997
  this.client.auth.login().then(() => {
4878
4998
  this.application.updateReadyState(true);
@@ -6652,7 +6772,9 @@ var MultiSelectionToolbarComponent = class MultiSelectionToolbarComponent {
6652
6772
  }
6653
6773
  return `search-results-assistant`;
6654
6774
  }, ...ngDevMode ? [{ debugName: "instanceId" }] : /* istanbul ignore next */ []);
6655
- enabledUserInput = computed(() => this.appStore.assistants()[this.instanceId()]?.modeSettings.enabledUserInput === true, ...ngDevMode ? [{ debugName: "enabledUserInput" }] : /* istanbul ignore next */ []);
6775
+ enabledUserInput = computed(() => this.appStore.assistants()[this.instanceId()]?.modeSettings?.enabledUserInput === true, ...ngDevMode ? [{ debugName: "enabledUserInput" }] : /* istanbul ignore next */ []);
6776
+ allowAI = input(false, ...ngDevMode ? [{ debugName: "allowAI" }] : /* istanbul ignore next */ []);
6777
+ showAssistantAction = computed(() => this.allowAI() && this.appStore.isAssistantAllowed(this.instanceId()) && this.enabledUserInput(), ...ngDevMode ? [{ debugName: "showAssistantAction" }] : /* istanbul ignore next */ []);
6656
6778
  showHint = signal(false, ...ngDevMode ? [{ debugName: "showHint" }] : /* istanbul ignore next */ []);
6657
6779
  armed = true;
6658
6780
  constructor() {
@@ -6717,6 +6839,13 @@ var MultiSelectionToolbarComponent = class MultiSelectionToolbarComponent {
6717
6839
  isSignal: true,
6718
6840
  isRequired: false,
6719
6841
  transformFunction: null
6842
+ },
6843
+ allowAI: {
6844
+ classPropertyName: "allowAI",
6845
+ publicName: "allowAI",
6846
+ isSignal: true,
6847
+ isRequired: false,
6848
+ transformFunction: null
6720
6849
  }
6721
6850
  },
6722
6851
  outputs: {
@@ -6775,7 +6904,7 @@ var MultiSelectionToolbarComponent = class MultiSelectionToolbarComponent {
6775
6904
  </button>
6776
6905
  </div>
6777
6906
 
6778
- @if (enabledUserInput()) {
6907
+ @if (showAssistantAction()) {
6779
6908
  <div class="pe-0!">
6780
6909
  <button class="cursor-pointer inline-flex items-center gap-1" (click)="attachToAssistant()">
6781
6910
  <sparkles-icon aria-hidden="true" />
@@ -6879,7 +7008,7 @@ i0.ɵɵngDeclareClassMetadata({
6879
7008
  </button>
6880
7009
  </div>
6881
7010
 
6882
- @if (enabledUserInput()) {
7011
+ @if (showAssistantAction()) {
6883
7012
  <div class="pe-0!">
6884
7013
  <button class="cursor-pointer inline-flex items-center gap-1" (click)="attachToAssistant()">
6885
7014
  <sparkles-icon aria-hidden="true" />
@@ -6929,6 +7058,14 @@ i0.ɵɵngDeclareClassMetadata({
6929
7058
  cleared: [{
6930
7059
  type: i0.Output,
6931
7060
  args: ["cleared"]
7061
+ }],
7062
+ allowAI: [{
7063
+ type: i0.Input,
7064
+ args: [{
7065
+ isSignal: true,
7066
+ alias: "allowAI",
7067
+ required: false
7068
+ }]
6932
7069
  }]
6933
7070
  }
6934
7071
  });
@@ -7117,17 +7254,18 @@ var InfinityScrollDirective = class InfinityScrollDirective {
7117
7254
  alias: "scroll-options"
7118
7255
  });
7119
7256
  onScroll = output();
7120
- observer = new IntersectionObserver(([entry]) => {
7121
- if (entry.isIntersecting) this.onScroll.emit();
7122
- }, this.options());
7257
+ observer;
7123
7258
  el = inject(ElementRef);
7124
7259
  constructor() {
7125
7260
  afterNextRender(() => {
7261
+ this.observer = new IntersectionObserver(([entry]) => {
7262
+ if (entry.isIntersecting) this.onScroll.emit();
7263
+ }, this.options());
7126
7264
  this.observer.observe(this.el.nativeElement);
7127
7265
  });
7128
7266
  }
7129
7267
  ngOnDestroy() {
7130
- this.observer.disconnect();
7268
+ this.observer?.disconnect();
7131
7269
  }
7132
7270
  static ɵfac = i0.ɵɵngDeclareFactory({
7133
7271
  minVersion: "12.0.0",
@@ -18606,7 +18744,6 @@ var BookmarkButtonComponent = class BookmarkButtonComponent {
18606
18744
  isBookmarked = computed(() => {
18607
18745
  return this.userSettingsStore.isBookmarked(this.article());
18608
18746
  }, ...ngDevMode ? [{ debugName: "isBookmarked" }] : /* istanbul ignore next */ []);
18609
- title = computed(() => this.transloco.translate("bookmarks.bookmarkDocument"), ...ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []);
18610
18747
  async bookmark(e) {
18611
18748
  e.stopPropagation();
18612
18749
  const article = this.article();
@@ -18670,11 +18807,11 @@ var BookmarkButtonComponent = class BookmarkButtonComponent {
18670
18807
  properties: { "class.visible": "isBookmarked()" },
18671
18808
  classAttribute: "cursor-pointer"
18672
18809
  },
18673
- providers: [provideTranslocoScope("bookmark")],
18810
+ providers: [provideTranslocoScope("bookmarks")],
18674
18811
  ngImport: i0,
18675
18812
  template: `
18676
18813
  <button [variant]="variant()" [size]="size()" [iconOnly]="true"
18677
- [tooltip]="title()"
18814
+ [tooltip]="'bookmarks.bookmarkDocument' | transloco"
18678
18815
  [attr.aria-pressed]="isBookmarked()"
18679
18816
  [attr.tabindex]="tabIndex()"
18680
18817
  (click)="bookmark($event)">
@@ -18684,7 +18821,7 @@ var BookmarkButtonComponent = class BookmarkButtonComponent {
18684
18821
  @else {
18685
18822
  <bookmark-icon />
18686
18823
  }
18687
- <span class="sr-only">{{ title() }}</span>
18824
+ <span class="sr-only">{{ 'bookmarks.bookmarkDocument' | transloco }}</span>
18688
18825
  </button>
18689
18826
  `,
18690
18827
  isInline: true,
@@ -18732,6 +18869,11 @@ var BookmarkButtonComponent = class BookmarkButtonComponent {
18732
18869
  "tooltip-offset"
18733
18870
  ],
18734
18871
  exportAs: ["tooltip"]
18872
+ },
18873
+ {
18874
+ kind: "pipe",
18875
+ type: TranslocoPipe,
18876
+ name: "transloco"
18735
18877
  }
18736
18878
  ]
18737
18879
  });
@@ -18748,12 +18890,13 @@ i0.ɵɵngDeclareClassMetadata({
18748
18890
  imports: [
18749
18891
  BookmarkIcon,
18750
18892
  ButtonComponent,
18751
- TooltipDirective
18893
+ TooltipDirective,
18894
+ TranslocoPipe
18752
18895
  ],
18753
- providers: [provideTranslocoScope("bookmark")],
18896
+ providers: [provideTranslocoScope("bookmarks")],
18754
18897
  template: `
18755
18898
  <button [variant]="variant()" [size]="size()" [iconOnly]="true"
18756
- [tooltip]="title()"
18899
+ [tooltip]="'bookmarks.bookmarkDocument' | transloco"
18757
18900
  [attr.aria-pressed]="isBookmarked()"
18758
18901
  [attr.tabindex]="tabIndex()"
18759
18902
  (click)="bookmark($event)">
@@ -18763,7 +18906,7 @@ i0.ɵɵngDeclareClassMetadata({
18763
18906
  @else {
18764
18907
  <bookmark-icon />
18765
18908
  }
18766
- <span class="sr-only">{{ title() }}</span>
18909
+ <span class="sr-only">{{ 'bookmarks.bookmarkDocument' | transloco }}</span>
18767
18910
  </button>
18768
18911
  `,
18769
18912
  host: {
@@ -21804,7 +21947,7 @@ var DidYouMeanComponent = class DidYouMeanComponent {
21804
21947
  <p>
21805
21948
  <span class="me-1">{{ 'didYouMean.didYouMean' | transloco }}</span>
21806
21949
  <a class="text-(--font-primary-base) hover:underline cursor-pointer" role="button" (click)="selectCorrected()">{{ correction() }}</a>
21807
- <span>?</span>
21950
+ <span>{{ 'didYouMean.didYouMeanSuffix' | transloco }}</span>
21808
21951
  </p>
21809
21952
  }
21810
21953
  @case ('correction') {
@@ -21854,7 +21997,7 @@ i0.ɵɵngDeclareClassMetadata({
21854
21997
  <p>
21855
21998
  <span class="me-1">{{ 'didYouMean.didYouMean' | transloco }}</span>
21856
21999
  <a class="text-(--font-primary-base) hover:underline cursor-pointer" role="button" (click)="selectCorrected()">{{ correction() }}</a>
21857
- <span>?</span>
22000
+ <span>{{ 'didYouMean.didYouMeanSuffix' | transloco }}</span>
21858
22001
  </p>
21859
22002
  }
21860
22003
  @case ('correction') {
@@ -23021,6 +23164,7 @@ var AggregationDateComponent = class AggregationDateComponent {
23021
23164
  uid = nextAggregationDateId++;
23022
23165
  startId = `datepicker-range-start-${this.uid}`;
23023
23166
  endId = `datepicker-range-end-${this.uid}`;
23167
+ class = input("", ...ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []);
23024
23168
  name = input(null, ...ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []);
23025
23169
  column = input.required(...ngDevMode ? [{ debugName: "column" }] : /* istanbul ignore next */ []);
23026
23170
  id = input(null, ...ngDevMode ? [{ debugName: "id" }] : /* istanbul ignore next */ []);
@@ -23507,6 +23651,13 @@ var AggregationDateComponent = class AggregationDateComponent {
23507
23651
  isStandalone: true,
23508
23652
  selector: "aggregation-date, AggregationDate, aggregationdate",
23509
23653
  inputs: {
23654
+ class: {
23655
+ classPropertyName: "class",
23656
+ publicName: "class",
23657
+ isSignal: true,
23658
+ isRequired: false,
23659
+ transformFunction: null
23660
+ },
23510
23661
  name: {
23511
23662
  classPropertyName: "name",
23512
23663
  publicName: "name",
@@ -23622,10 +23773,10 @@ var AggregationDateComponent = class AggregationDateComponent {
23622
23773
  aggregationOverridesChange: "aggregationOverridesChange",
23623
23774
  searchText: "searchTextChange"
23624
23775
  },
23625
- host: { properties: { "class": "cn(\"@container block\", isEmpty() && \"hidden\")" } },
23776
+ host: { properties: { "class": "cn(\"@container flex h-[inherit] max-h-[inherit] flex-col\", class(), isEmpty() && \"hidden\")" } },
23626
23777
  providers: [provideTranslocoScope("filters")],
23627
23778
  ngImport: i0,
23628
- template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion). A facet is a\n disclosure, not a grid row: see `aggregation-panel.html` for the why (same pattern, duplicated here\n since this facet type doesn't go through `AggregationPanelComponent`). The `<summary>` itself is\n `tabindex=\"-1\"`: the header's buttons (apply, clear, chevron) are the keyboard stops, see the\n chevron below. -->\n<details [attr.open]=\"expanded()\" [attr.name]=\"id()\" class=\"group space-y-2\">\n <!-- scroll-mb-3/scroll-mt-16: see aggregation-panel-row.html for the full rationale\n (bottom margin against the container's edge, much bigger top margin to also clear\n <More>'s sticky facet-name search box when navigating ArrowUp). -->\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible()\"\n [class]=\"rowStateClass\"\n class=\"m-0 flex h-8 w-full scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n\n @if (hasFilters()) {\n @let clearLabel = \"filters.clearFilters\" | transloco;\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n class=\"shrink-0\"\n [aria-label]=\"clearLabel\"\n [tooltip]=\"clearLabel\"\n (click)=\"$event.stopPropagation(); clear()\">\n <filter-x-icon class=\"shrink-0\" />\n </button>\n }\n\n @if (\n (!isControlled() || emitOn() === \"apply\") &&\n selection() &&\n validSelection()\n ) {\n @let label = \"filters.apply\" | transloco;\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"sm\"\n class=\"overflow-hidden\"\n [aria-label]=\"label\"\n [tooltip]=\"label\"\n (click)=\"$event.stopPropagation(); apply()\">\n <FilterIcon class=\"shrink-0\" />\n <span class=\"truncate\">{{ label }}</span>\n </button>\n }\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n [aria-label]=\"'filters.toggle' | transloco\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n </button>\n }\n </summary>\n\n <!-- content wrapper -->\n <!-- RGAA 11.5 / WCAG 1.3.1: the radios below all share one `name`, so they are one set\n and have to be exposed as one. `role=\"group\"` here rather than `role=\"radiogroup\"`\n on the list: a radiogroup owns its radios directly, and the <li> in between would be\n invalid owned children — the list would also stop announcing how many options it\n holds. The name is the aggregation's own title, the words the header already shows;\n with no title the group stays unnamed rather than carrying an empty label. -->\n @let groupName = aggregation()?.display;\n <form (keydown.enter)=\"onOptionEnter($event)\">\n <div\n role=\"group\"\n [attr.aria-label]=\"groupName ? (groupName | syslang | transloco) : null\">\n <ul\n class=\"flex max-h-(--scroll-height,20rem) snap-y snap-start scrollbar-thin flex-col gap-0.5 overflow-auto pt-2\"\n role=\"list\">\n @for (option of dateOptions(); track option.display) {\n <li\n role=\"listitem\"\n [attr.aria-label]=\"\n maskedLabel(option) ?? (option.display | syslang | transloco)\n \"\n [class]=\"\n cn(\n itemStateClass,\n 'flex cursor-pointer items-center p-2 text-sm text-(--font-neutral-base) select-none',\n dateModel().option === option.display &&\n 'bg-(--bg-primary-lighter)',\n option.hidden && 'hidden',\n option.disabled && 'pointer-events-none opacity-40'\n )\n \"\n [attr.aria-hidden]=\"option.disabled\">\n <radio\n name=\"date-filter\"\n class=\"grow\"\n [checked]=\"dateModel().option === option.display\"\n [disabled]=\"option.disabled\"\n (checkedChange)=\"selectOption(option.display)\">\n {{\n maskedLabel(option) ?? (option.display | syslang | transloco)\n }}\n </radio>\n </li>\n }\n\n @if (allowCustomRange) {\n <li\n role=\"listitem\"\n aria-label=\"open date range picker\"\n [class]=\"\n cn(\n itemStateClass,\n 'flex cursor-pointer items-center gap-(--spacing-xs) p-2 text-sm text-(--font-neutral-base) select-none',\n dateModel().option === 'custom-range' &&\n 'bg-(--bg-primary-lighter)'\n )\n \">\n <radio\n name=\"date-filter\"\n class=\"grow items-center\"\n [checked]=\"dateModel().option === 'custom-range'\"\n (checkedChange)=\"selectOption('custom-range')\">\n <div class=\"flex flex-wrap items-center gap-x-1 gap-y-1\">\n <div class=\"flex items-center gap-1\">\n <label\n [attr.for]=\"startId\"\n class=\"radio-label min-w-8 truncate\"\n >{{ \"filters.from\" | transloco }}</label\n >\n <input-group\n class=\"h-8 max-w-[18ch] min-w-[18ch] has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"startId\"\n name=\"start\"\n type=\"text\"\n readonly\n [value]=\"customRangeFrom()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n <div class=\"flex items-center gap-1\">\n <label\n [attr.for]=\"endId\"\n class=\"radio-label min-w-8 truncate text-right\"\n >{{ \"filters.to\" | transloco }}</label\n >\n <input-group\n class=\"h-8 max-w-[18ch] min-w-[18ch] has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"endId\"\n name=\"end\"\n type=\"text\"\n readonly\n [value]=\"customRangeTo()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n </div>\n </radio>\n </li>\n }\n </ul>\n </div>\n </form>\n</details>\n",
23779
+ template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion). A facet is a\n disclosure, not a grid row: see `aggregation-panel.html` for the why (same pattern, duplicated here\n since this facet type doesn't go through `AggregationPanelComponent`). The `<summary>` itself is\n `tabindex=\"-1\"`: the header's buttons (apply, clear, chevron) are the keyboard stops, see the\n chevron below. -->\n<details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group flex min-h-0 flex-1 flex-col space-y-2 details-content:flex details-content:min-h-0 details-content:flex-1 details-content:flex-col\">\n <!-- scroll-mb-3/scroll-mt-16: see aggregation-panel-row.html for the full rationale\n (bottom margin against the container's edge, much bigger top margin to also clear\n <More>'s sticky facet-name search box when navigating ArrowUp). -->\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible()\"\n [class]=\"rowStateClass\"\n class=\"m-0 flex h-8 w-full shrink-0 scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n\n @if (hasFilters()) {\n @let clearLabel = \"filters.clearFilters\" | transloco;\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n class=\"shrink-0\"\n [aria-label]=\"clearLabel\"\n [tooltip]=\"clearLabel\"\n (click)=\"$event.stopPropagation(); clear()\">\n <filter-x-icon class=\"shrink-0\" />\n </button>\n }\n\n @if (\n (!isControlled() || emitOn() === \"apply\") &&\n selection() &&\n validSelection()\n ) {\n @let label = \"filters.apply\" | transloco;\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"sm\"\n class=\"overflow-hidden\"\n [aria-label]=\"label\"\n [tooltip]=\"label\"\n (click)=\"$event.stopPropagation(); apply()\">\n <FilterIcon class=\"shrink-0\" />\n <span class=\"truncate\">{{ label }}</span>\n </button>\n }\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n [aria-label]=\"'filters.toggle' | transloco\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n </button>\n }\n </summary>\n\n <!-- content wrapper -->\n <!-- RGAA 11.5 / WCAG 1.3.1: the radios below all share one `name`, so they are one set\n and have to be exposed as one. `role=\"group\"` here rather than `role=\"radiogroup\"`\n on the list: a radiogroup owns its radios directly, and the <li> in between would be\n invalid owned children — the list would also stop announcing how many options it\n holds. The name is the aggregation's own title, the words the header already shows;\n with no title the group stays unnamed rather than carrying an empty label. -->\n @let groupName = aggregation()?.display;\n <form\n class=\"flex min-h-0 flex-1 flex-col\"\n (keydown.enter)=\"onOptionEnter($event)\">\n <div\n class=\"flex min-h-0 flex-1 flex-col\"\n role=\"group\"\n [attr.aria-label]=\"groupName ? (groupName | syslang | transloco) : null\">\n <ul\n class=\"flex min-h-0 flex-1 snap-y snap-start scrollbar-thin flex-col gap-0.5 overflow-auto pt-2\"\n role=\"list\">\n @for (option of dateOptions(); track option.display) {\n <li\n role=\"listitem\"\n [attr.aria-label]=\"\n maskedLabel(option) ?? (option.display | syslang | transloco)\n \"\n [class]=\"\n cn(\n itemStateClass,\n 'flex cursor-pointer items-center p-2 text-sm text-(--font-neutral-base) select-none',\n dateModel().option === option.display &&\n 'bg-(--bg-primary-lighter)',\n option.hidden && 'hidden',\n option.disabled && 'pointer-events-none opacity-40'\n )\n \"\n [attr.aria-hidden]=\"option.disabled\">\n <radio\n name=\"date-filter\"\n class=\"grow\"\n [checked]=\"dateModel().option === option.display\"\n [disabled]=\"option.disabled\"\n (checkedChange)=\"selectOption(option.display)\">\n {{\n maskedLabel(option) ?? (option.display | syslang | transloco)\n }}\n </radio>\n </li>\n }\n\n @if (allowCustomRange) {\n <li\n role=\"listitem\"\n aria-label=\"open date range picker\"\n [class]=\"\n cn(\n itemStateClass,\n 'flex cursor-pointer items-center gap-(--spacing-xs) p-2 text-sm text-(--font-neutral-base) select-none',\n dateModel().option === 'custom-range' &&\n 'bg-(--bg-primary-lighter)'\n )\n \">\n <radio\n name=\"date-filter\"\n class=\"grow items-center\"\n [checked]=\"dateModel().option === 'custom-range'\"\n (checkedChange)=\"selectOption('custom-range')\">\n <div class=\"flex flex-wrap items-center gap-x-1 gap-y-1\">\n <div class=\"flex items-center gap-1\">\n <label\n [attr.for]=\"startId\"\n class=\"radio-label min-w-8 truncate\"\n >{{ \"filters.from\" | transloco }}</label\n >\n <input-group\n class=\"h-8 max-w-[18ch] min-w-[18ch] has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"startId\"\n name=\"start\"\n type=\"text\"\n readonly\n [value]=\"customRangeFrom()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n <div class=\"flex items-center gap-1\">\n <label\n [attr.for]=\"endId\"\n class=\"radio-label min-w-8 truncate text-right\"\n >{{ \"filters.to\" | transloco }}</label\n >\n <input-group\n class=\"h-8 max-w-[18ch] min-w-[18ch] has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"endId\"\n name=\"end\"\n type=\"text\"\n readonly\n [value]=\"customRangeTo()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n </div>\n </radio>\n </li>\n }\n </ul>\n </div>\n </form>\n</details>\n",
23629
23780
  styles: [":host{min-width:200px}ul[role=list]{scrollbar-width:thin}\n"],
23630
23781
  dependencies: [
23631
23782
  {
@@ -23759,13 +23910,21 @@ i0.ɵɵngDeclareClassMetadata({
23759
23910
  RadioComponent,
23760
23911
  TooltipDirective
23761
23912
  ],
23762
- host: { "[class]": "cn(\"@container block\", isEmpty() && \"hidden\")" },
23763
- template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion). A facet is a\n disclosure, not a grid row: see `aggregation-panel.html` for the why (same pattern, duplicated here\n since this facet type doesn't go through `AggregationPanelComponent`). The `<summary>` itself is\n `tabindex=\"-1\"`: the header's buttons (apply, clear, chevron) are the keyboard stops, see the\n chevron below. -->\n<details [attr.open]=\"expanded()\" [attr.name]=\"id()\" class=\"group space-y-2\">\n <!-- scroll-mb-3/scroll-mt-16: see aggregation-panel-row.html for the full rationale\n (bottom margin against the container's edge, much bigger top margin to also clear\n <More>'s sticky facet-name search box when navigating ArrowUp). -->\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible()\"\n [class]=\"rowStateClass\"\n class=\"m-0 flex h-8 w-full scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n\n @if (hasFilters()) {\n @let clearLabel = \"filters.clearFilters\" | transloco;\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n class=\"shrink-0\"\n [aria-label]=\"clearLabel\"\n [tooltip]=\"clearLabel\"\n (click)=\"$event.stopPropagation(); clear()\">\n <filter-x-icon class=\"shrink-0\" />\n </button>\n }\n\n @if (\n (!isControlled() || emitOn() === \"apply\") &&\n selection() &&\n validSelection()\n ) {\n @let label = \"filters.apply\" | transloco;\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"sm\"\n class=\"overflow-hidden\"\n [aria-label]=\"label\"\n [tooltip]=\"label\"\n (click)=\"$event.stopPropagation(); apply()\">\n <FilterIcon class=\"shrink-0\" />\n <span class=\"truncate\">{{ label }}</span>\n </button>\n }\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n [aria-label]=\"'filters.toggle' | transloco\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n </button>\n }\n </summary>\n\n <!-- content wrapper -->\n <!-- RGAA 11.5 / WCAG 1.3.1: the radios below all share one `name`, so they are one set\n and have to be exposed as one. `role=\"group\"` here rather than `role=\"radiogroup\"`\n on the list: a radiogroup owns its radios directly, and the <li> in between would be\n invalid owned children — the list would also stop announcing how many options it\n holds. The name is the aggregation's own title, the words the header already shows;\n with no title the group stays unnamed rather than carrying an empty label. -->\n @let groupName = aggregation()?.display;\n <form (keydown.enter)=\"onOptionEnter($event)\">\n <div\n role=\"group\"\n [attr.aria-label]=\"groupName ? (groupName | syslang | transloco) : null\">\n <ul\n class=\"flex max-h-(--scroll-height,20rem) snap-y snap-start scrollbar-thin flex-col gap-0.5 overflow-auto pt-2\"\n role=\"list\">\n @for (option of dateOptions(); track option.display) {\n <li\n role=\"listitem\"\n [attr.aria-label]=\"\n maskedLabel(option) ?? (option.display | syslang | transloco)\n \"\n [class]=\"\n cn(\n itemStateClass,\n 'flex cursor-pointer items-center p-2 text-sm text-(--font-neutral-base) select-none',\n dateModel().option === option.display &&\n 'bg-(--bg-primary-lighter)',\n option.hidden && 'hidden',\n option.disabled && 'pointer-events-none opacity-40'\n )\n \"\n [attr.aria-hidden]=\"option.disabled\">\n <radio\n name=\"date-filter\"\n class=\"grow\"\n [checked]=\"dateModel().option === option.display\"\n [disabled]=\"option.disabled\"\n (checkedChange)=\"selectOption(option.display)\">\n {{\n maskedLabel(option) ?? (option.display | syslang | transloco)\n }}\n </radio>\n </li>\n }\n\n @if (allowCustomRange) {\n <li\n role=\"listitem\"\n aria-label=\"open date range picker\"\n [class]=\"\n cn(\n itemStateClass,\n 'flex cursor-pointer items-center gap-(--spacing-xs) p-2 text-sm text-(--font-neutral-base) select-none',\n dateModel().option === 'custom-range' &&\n 'bg-(--bg-primary-lighter)'\n )\n \">\n <radio\n name=\"date-filter\"\n class=\"grow items-center\"\n [checked]=\"dateModel().option === 'custom-range'\"\n (checkedChange)=\"selectOption('custom-range')\">\n <div class=\"flex flex-wrap items-center gap-x-1 gap-y-1\">\n <div class=\"flex items-center gap-1\">\n <label\n [attr.for]=\"startId\"\n class=\"radio-label min-w-8 truncate\"\n >{{ \"filters.from\" | transloco }}</label\n >\n <input-group\n class=\"h-8 max-w-[18ch] min-w-[18ch] has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"startId\"\n name=\"start\"\n type=\"text\"\n readonly\n [value]=\"customRangeFrom()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n <div class=\"flex items-center gap-1\">\n <label\n [attr.for]=\"endId\"\n class=\"radio-label min-w-8 truncate text-right\"\n >{{ \"filters.to\" | transloco }}</label\n >\n <input-group\n class=\"h-8 max-w-[18ch] min-w-[18ch] has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"endId\"\n name=\"end\"\n type=\"text\"\n readonly\n [value]=\"customRangeTo()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n </div>\n </radio>\n </li>\n }\n </ul>\n </div>\n </form>\n</details>\n",
23913
+ host: { "[class]": "cn(\"@container flex h-[inherit] max-h-[inherit] flex-col\", class(), isEmpty() && \"hidden\")" },
23914
+ template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion). A facet is a\n disclosure, not a grid row: see `aggregation-panel.html` for the why (same pattern, duplicated here\n since this facet type doesn't go through `AggregationPanelComponent`). The `<summary>` itself is\n `tabindex=\"-1\"`: the header's buttons (apply, clear, chevron) are the keyboard stops, see the\n chevron below. -->\n<details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group flex min-h-0 flex-1 flex-col space-y-2 details-content:flex details-content:min-h-0 details-content:flex-1 details-content:flex-col\">\n <!-- scroll-mb-3/scroll-mt-16: see aggregation-panel-row.html for the full rationale\n (bottom margin against the container's edge, much bigger top margin to also clear\n <More>'s sticky facet-name search box when navigating ArrowUp). -->\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible()\"\n [class]=\"rowStateClass\"\n class=\"m-0 flex h-8 w-full shrink-0 scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n\n @if (hasFilters()) {\n @let clearLabel = \"filters.clearFilters\" | transloco;\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n class=\"shrink-0\"\n [aria-label]=\"clearLabel\"\n [tooltip]=\"clearLabel\"\n (click)=\"$event.stopPropagation(); clear()\">\n <filter-x-icon class=\"shrink-0\" />\n </button>\n }\n\n @if (\n (!isControlled() || emitOn() === \"apply\") &&\n selection() &&\n validSelection()\n ) {\n @let label = \"filters.apply\" | transloco;\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"sm\"\n class=\"overflow-hidden\"\n [aria-label]=\"label\"\n [tooltip]=\"label\"\n (click)=\"$event.stopPropagation(); apply()\">\n <FilterIcon class=\"shrink-0\" />\n <span class=\"truncate\">{{ label }}</span>\n </button>\n }\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n [aria-label]=\"'filters.toggle' | transloco\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n </button>\n }\n </summary>\n\n <!-- content wrapper -->\n <!-- RGAA 11.5 / WCAG 1.3.1: the radios below all share one `name`, so they are one set\n and have to be exposed as one. `role=\"group\"` here rather than `role=\"radiogroup\"`\n on the list: a radiogroup owns its radios directly, and the <li> in between would be\n invalid owned children — the list would also stop announcing how many options it\n holds. The name is the aggregation's own title, the words the header already shows;\n with no title the group stays unnamed rather than carrying an empty label. -->\n @let groupName = aggregation()?.display;\n <form\n class=\"flex min-h-0 flex-1 flex-col\"\n (keydown.enter)=\"onOptionEnter($event)\">\n <div\n class=\"flex min-h-0 flex-1 flex-col\"\n role=\"group\"\n [attr.aria-label]=\"groupName ? (groupName | syslang | transloco) : null\">\n <ul\n class=\"flex min-h-0 flex-1 snap-y snap-start scrollbar-thin flex-col gap-0.5 overflow-auto pt-2\"\n role=\"list\">\n @for (option of dateOptions(); track option.display) {\n <li\n role=\"listitem\"\n [attr.aria-label]=\"\n maskedLabel(option) ?? (option.display | syslang | transloco)\n \"\n [class]=\"\n cn(\n itemStateClass,\n 'flex cursor-pointer items-center p-2 text-sm text-(--font-neutral-base) select-none',\n dateModel().option === option.display &&\n 'bg-(--bg-primary-lighter)',\n option.hidden && 'hidden',\n option.disabled && 'pointer-events-none opacity-40'\n )\n \"\n [attr.aria-hidden]=\"option.disabled\">\n <radio\n name=\"date-filter\"\n class=\"grow\"\n [checked]=\"dateModel().option === option.display\"\n [disabled]=\"option.disabled\"\n (checkedChange)=\"selectOption(option.display)\">\n {{\n maskedLabel(option) ?? (option.display | syslang | transloco)\n }}\n </radio>\n </li>\n }\n\n @if (allowCustomRange) {\n <li\n role=\"listitem\"\n aria-label=\"open date range picker\"\n [class]=\"\n cn(\n itemStateClass,\n 'flex cursor-pointer items-center gap-(--spacing-xs) p-2 text-sm text-(--font-neutral-base) select-none',\n dateModel().option === 'custom-range' &&\n 'bg-(--bg-primary-lighter)'\n )\n \">\n <radio\n name=\"date-filter\"\n class=\"grow items-center\"\n [checked]=\"dateModel().option === 'custom-range'\"\n (checkedChange)=\"selectOption('custom-range')\">\n <div class=\"flex flex-wrap items-center gap-x-1 gap-y-1\">\n <div class=\"flex items-center gap-1\">\n <label\n [attr.for]=\"startId\"\n class=\"radio-label min-w-8 truncate\"\n >{{ \"filters.from\" | transloco }}</label\n >\n <input-group\n class=\"h-8 max-w-[18ch] min-w-[18ch] has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"startId\"\n name=\"start\"\n type=\"text\"\n readonly\n [value]=\"customRangeFrom()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n <div class=\"flex items-center gap-1\">\n <label\n [attr.for]=\"endId\"\n class=\"radio-label min-w-8 truncate text-right\"\n >{{ \"filters.to\" | transloco }}</label\n >\n <input-group\n class=\"h-8 max-w-[18ch] min-w-[18ch] has-[[readonly]]:border-(--stroke-primary-muted)\">\n <input\n input-control\n [id]=\"endId\"\n name=\"end\"\n type=\"text\"\n readonly\n [value]=\"customRangeTo()\"\n (click)=\"selectAndOpenDialog()\"\n (keydown.enter)=\"onRangeInputEnter($event)\" />\n </input-group>\n </div>\n </div>\n </radio>\n </li>\n }\n </ul>\n </div>\n </form>\n</details>\n",
23764
23915
  styles: [":host{min-width:200px}ul[role=list]{scrollbar-width:thin}\n"]
23765
23916
  }]
23766
23917
  }],
23767
23918
  ctorParameters: () => [],
23768
23919
  propDecorators: {
23920
+ class: [{
23921
+ type: i0.Input,
23922
+ args: [{
23923
+ isSignal: true,
23924
+ alias: "class",
23925
+ required: false
23926
+ }]
23927
+ }],
23769
23928
  name: [{
23770
23929
  type: i0.Input,
23771
23930
  args: [{
@@ -24091,6 +24250,7 @@ var AggregationPanelComponent = class AggregationPanelComponent {
24091
24250
  allUnselected: "allUnselected",
24092
24251
  loadedMore: "loadedMore"
24093
24252
  },
24253
+ host: { classAttribute: "flex min-h-0 flex-1 flex-col" },
24094
24254
  providers: [provideTranslocoScope("filters")],
24095
24255
  viewQueries: [{
24096
24256
  propertyName: "searchBox",
@@ -24100,7 +24260,7 @@ var AggregationPanelComponent = class AggregationPanelComponent {
24100
24260
  isSignal: true
24101
24261
  }],
24102
24262
  ngImport: i0,
24103
- template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion): a facet is\n a disclosure, not a grid row. They used to sit in an `@angular/aria` grid so ArrowUp/ArrowDown could\n jump from one facet header to the next, but a grid without headers reads as a layout table\n (RGAA 5.3). The `<summary>` itself is `tabindex=\"-1\"`: Tab walks the header's buttons (apply, clear,\n select-all, chevron), never the header row, and the chevron is the keyboard way to collapse.\n Label, header actions and body are `<ng-template>`s: `<ng-content select=\"…\">` can only be\n declared once, and the body is projected content. -->\n\n<ng-template #labelSlot>\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n</ng-template>\n\n<ng-template #headerActionsSlot>\n @if (showFiltersCount() && filtersCount() > 0) {\n <Badge variant=\"number\" fill=\"primary\" scheme=\"sage\" size=\"xs\" class=\"ml-1\">\n {{ filtersCount() }}\n </Badge>\n }\n @if (!isCollapsed()) {\n @if (hasFilters()) {\n @let label = \"filters.clearFilters\" | transloco;\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n class=\"shrink-0\"\n [aria-label]=\"label\"\n [tooltip]=\"label\"\n (click)=\"$event.stopPropagation(); cleared.emit()\">\n <filter-x-icon class=\"shrink-0\" />\n </button>\n }\n @if (selection()) {\n @let label = \"filters.apply\" | transloco;\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"sm\"\n class=\"overflow-hidden\"\n [aria-label]=\"label\"\n [tooltip]=\"label\"\n (click)=\"$event.stopPropagation(); applied.emit()\">\n <FilterIcon class=\"shrink-0\" />\n <span class=\"truncate\">{{ label }}</span>\n </button>\n }\n\n <!-- Nothing is projected into this box, so it had no accessible name at all (RGAA 11.1) —\n only its position next to the facet's title said what it selects. The name is stable\n whichever way it is about to act: `aria-checked` (true / mixed / false) is what tells the\n user where the selection stands. -->\n <checkbox\n class=\"shrink-0\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"selection() && !isAllSelected()\"\n [aria-label]=\"'filters.selectAllFilters' | transloco\"\n (checkedChange)=\"$event ? allSelected.emit() : allUnselected.emit()\" />\n }\n\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n <span class=\"sr-only\">{{ \"filters.toggle\" | transloco }}</span>\n </button>\n }\n</ng-template>\n\n<ng-template #bodySlot>\n <div>\n @if (aggregation()?.searchable && itemsLength() > 0) {\n <!-- No `hotkey`: this field is one of many facet search boxes on the page, so it must not\n advertise a global shortcut. It used to pass `''` to neutralize SearchInput's old\n `\"shift+/\"` default, which left an empty `aria-keyshortcuts` attribute behind; the default\n is gone, so omitting the input is enough. -->\n <!-- A placeholder is not a label (RGAA 11.1): it goes away on the first keystroke, and it is\n the same word in every facet panel of the page. The name says WHICH facet this box\n filters — the one thing that tells a dozen identical fields apart. -->\n <!-- mt-2: without it, the search box sits flush under <summary>, and the header's focus ring reads as touching it. -->\n <SearchInput\n class=\"mt-2 mb-3\"\n [placeholder]=\"'search' | transloco\"\n [clearLabel]=\"'filters.clearSearch' | transloco\"\n [aria-label]=\"\n 'filters.searchInFacet'\n | transloco\n : { facet: (aggregation()?.display | syslang | transloco) }\n \"\n [(value)]=\"searchText\">\n <ng-content select=\"[search-addon]\" />\n </SearchInput>\n }\n\n <ng-content />\n\n @if (hasMore() && searchedItemsLength() === 0) {\n <button\n type=\"button\"\n class=\"mt-1 flex w-full justify-center\"\n [attr.aria-label]=\"'loadMore' | transloco\"\n (click)=\"loadedMore.emit()\">\n {{ \"loadMore\" | transloco }}\n </button>\n }\n </div>\n</ng-template>\n\n<details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group p-0.5\"\n (toggle)=\"onToggle($event)\">\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible() && !isEmpty()\"\n [class]=\"(isEmpty() ? 'text-(--font-neutral-muted) ' : '') + rowStateClass\"\n class=\"m-0 flex h-8 w-full scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-container [ngTemplateOutlet]=\"labelSlot\" />\n <ng-container [ngTemplateOutlet]=\"headerActionsSlot\" />\n </summary>\n <ng-container [ngTemplateOutlet]=\"bodySlot\" />\n</details>\n",
24263
+ template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion): a facet is\n a disclosure, not a grid row. They used to sit in an `@angular/aria` grid so ArrowUp/ArrowDown could\n jump from one facet header to the next, but a grid without headers reads as a layout table\n (RGAA 5.3). The `<summary>` itself is `tabindex=\"-1\"`: Tab walks the header's buttons (apply, clear,\n select-all, chevron), never the header row, and the chevron is the keyboard way to collapse.\n Label, header actions and body are `<ng-template>`s: `<ng-content select=\"…\">` can only be\n declared once, and the body is projected content. -->\n\n<ng-template #labelSlot>\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n</ng-template>\n\n<ng-template #headerActionsSlot>\n @if (showFiltersCount() && filtersCount() > 0) {\n <Badge variant=\"number\" fill=\"primary\" scheme=\"sage\" size=\"xs\" class=\"ml-1\">\n {{ filtersCount() }}\n </Badge>\n }\n @if (!isCollapsed()) {\n @if (hasFilters()) {\n @let label = \"filters.clearFilters\" | transloco;\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n class=\"shrink-0\"\n [aria-label]=\"label\"\n [tooltip]=\"label\"\n (click)=\"$event.stopPropagation(); cleared.emit()\">\n <filter-x-icon class=\"shrink-0\" />\n </button>\n }\n @if (selection()) {\n @let label = \"filters.apply\" | transloco;\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"sm\"\n class=\"overflow-hidden\"\n [aria-label]=\"label\"\n [tooltip]=\"label\"\n (click)=\"$event.stopPropagation(); applied.emit()\">\n <FilterIcon class=\"shrink-0\" />\n <span class=\"truncate\">{{ label }}</span>\n </button>\n }\n\n <!-- Nothing is projected into this box, so it had no accessible name at all (RGAA 11.1) —\n only its position next to the facet's title said what it selects. The name is stable\n whichever way it is about to act: `aria-checked` (true / mixed / false) is what tells the\n user where the selection stands. -->\n <checkbox\n class=\"shrink-0\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"selection() && !isAllSelected()\"\n [aria-label]=\"'filters.selectAllFilters' | transloco\"\n (checkedChange)=\"$event ? allSelected.emit() : allUnselected.emit()\" />\n }\n\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n <span class=\"sr-only\">{{ \"filters.toggle\" | transloco }}</span>\n </button>\n }\n</ng-template>\n\n<ng-template #bodySlot>\n <div class=\"flex min-h-0 flex-1 flex-col\">\n @if (aggregation()?.searchable && itemsLength() > 0) {\n <!-- No `hotkey`: this field is one of many facet search boxes on the page, so it must not\n advertise a global shortcut. It used to pass `''` to neutralize SearchInput's old\n `\"shift+/\"` default, which left an empty `aria-keyshortcuts` attribute behind; the default\n is gone, so omitting the input is enough. -->\n <!-- A placeholder is not a label (RGAA 11.1): it goes away on the first keystroke, and it is\n the same word in every facet panel of the page. The name says WHICH facet this box\n filters — the one thing that tells a dozen identical fields apart. -->\n <!-- mt-2: without it, the search box sits flush under <summary>, and the header's focus ring reads as touching it. -->\n <SearchInput\n class=\"mt-2 mb-3 shrink-0\"\n [placeholder]=\"'search' | transloco\"\n [clearLabel]=\"'filters.clearSearch' | transloco\"\n [aria-label]=\"\n 'filters.searchInFacet'\n | transloco\n : { facet: (aggregation()?.display | syslang | transloco) }\n \"\n [(value)]=\"searchText\">\n <ng-content select=\"[search-addon]\" />\n </SearchInput>\n }\n\n <ng-content />\n\n @if (hasMore() && searchedItemsLength() === 0) {\n <button\n type=\"button\"\n class=\"mt-1 flex w-full shrink-0 justify-center\"\n [attr.aria-label]=\"'loadMore' | transloco\"\n (click)=\"loadedMore.emit()\">\n {{ \"loadMore\" | transloco }}\n </button>\n }\n </div>\n</ng-template>\n\n<details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group flex min-h-0 flex-1 flex-col p-0.5 details-content:flex details-content:min-h-0 details-content:flex-1 details-content:flex-col\"\n (toggle)=\"onToggle($event)\">\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible() && !isEmpty()\"\n [class]=\"(isEmpty() ? 'text-(--font-neutral-muted) ' : '') + rowStateClass\"\n class=\"m-0 flex h-8 w-full shrink-0 scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-container [ngTemplateOutlet]=\"labelSlot\" />\n <ng-container [ngTemplateOutlet]=\"headerActionsSlot\" />\n </summary>\n <ng-container [ngTemplateOutlet]=\"bodySlot\" />\n</details>\n",
24104
24264
  dependencies: [
24105
24265
  {
24106
24266
  kind: "directive",
@@ -24243,7 +24403,8 @@ i0.ɵɵngDeclareClassMetadata({
24243
24403
  TooltipDirective
24244
24404
  ],
24245
24405
  providers: [provideTranslocoScope("filters")],
24246
- template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion): a facet is\n a disclosure, not a grid row. They used to sit in an `@angular/aria` grid so ArrowUp/ArrowDown could\n jump from one facet header to the next, but a grid without headers reads as a layout table\n (RGAA 5.3). The `<summary>` itself is `tabindex=\"-1\"`: Tab walks the header's buttons (apply, clear,\n select-all, chevron), never the header row, and the chevron is the keyboard way to collapse.\n Label, header actions and body are `<ng-template>`s: `<ng-content select=\"…\">` can only be\n declared once, and the body is projected content. -->\n\n<ng-template #labelSlot>\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n</ng-template>\n\n<ng-template #headerActionsSlot>\n @if (showFiltersCount() && filtersCount() > 0) {\n <Badge variant=\"number\" fill=\"primary\" scheme=\"sage\" size=\"xs\" class=\"ml-1\">\n {{ filtersCount() }}\n </Badge>\n }\n @if (!isCollapsed()) {\n @if (hasFilters()) {\n @let label = \"filters.clearFilters\" | transloco;\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n class=\"shrink-0\"\n [aria-label]=\"label\"\n [tooltip]=\"label\"\n (click)=\"$event.stopPropagation(); cleared.emit()\">\n <filter-x-icon class=\"shrink-0\" />\n </button>\n }\n @if (selection()) {\n @let label = \"filters.apply\" | transloco;\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"sm\"\n class=\"overflow-hidden\"\n [aria-label]=\"label\"\n [tooltip]=\"label\"\n (click)=\"$event.stopPropagation(); applied.emit()\">\n <FilterIcon class=\"shrink-0\" />\n <span class=\"truncate\">{{ label }}</span>\n </button>\n }\n\n <!-- Nothing is projected into this box, so it had no accessible name at all (RGAA 11.1) —\n only its position next to the facet's title said what it selects. The name is stable\n whichever way it is about to act: `aria-checked` (true / mixed / false) is what tells the\n user where the selection stands. -->\n <checkbox\n class=\"shrink-0\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"selection() && !isAllSelected()\"\n [aria-label]=\"'filters.selectAllFilters' | transloco\"\n (checkedChange)=\"$event ? allSelected.emit() : allUnselected.emit()\" />\n }\n\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n <span class=\"sr-only\">{{ \"filters.toggle\" | transloco }}</span>\n </button>\n }\n</ng-template>\n\n<ng-template #bodySlot>\n <div>\n @if (aggregation()?.searchable && itemsLength() > 0) {\n <!-- No `hotkey`: this field is one of many facet search boxes on the page, so it must not\n advertise a global shortcut. It used to pass `''` to neutralize SearchInput's old\n `\"shift+/\"` default, which left an empty `aria-keyshortcuts` attribute behind; the default\n is gone, so omitting the input is enough. -->\n <!-- A placeholder is not a label (RGAA 11.1): it goes away on the first keystroke, and it is\n the same word in every facet panel of the page. The name says WHICH facet this box\n filters — the one thing that tells a dozen identical fields apart. -->\n <!-- mt-2: without it, the search box sits flush under <summary>, and the header's focus ring reads as touching it. -->\n <SearchInput\n class=\"mt-2 mb-3\"\n [placeholder]=\"'search' | transloco\"\n [clearLabel]=\"'filters.clearSearch' | transloco\"\n [aria-label]=\"\n 'filters.searchInFacet'\n | transloco\n : { facet: (aggregation()?.display | syslang | transloco) }\n \"\n [(value)]=\"searchText\">\n <ng-content select=\"[search-addon]\" />\n </SearchInput>\n }\n\n <ng-content />\n\n @if (hasMore() && searchedItemsLength() === 0) {\n <button\n type=\"button\"\n class=\"mt-1 flex w-full justify-center\"\n [attr.aria-label]=\"'loadMore' | transloco\"\n (click)=\"loadedMore.emit()\">\n {{ \"loadMore\" | transloco }}\n </button>\n }\n </div>\n</ng-template>\n\n<details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group p-0.5\"\n (toggle)=\"onToggle($event)\">\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible() && !isEmpty()\"\n [class]=\"(isEmpty() ? 'text-(--font-neutral-muted) ' : '') + rowStateClass\"\n class=\"m-0 flex h-8 w-full scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-container [ngTemplateOutlet]=\"labelSlot\" />\n <ng-container [ngTemplateOutlet]=\"headerActionsSlot\" />\n </summary>\n <ng-container [ngTemplateOutlet]=\"bodySlot\" />\n</details>\n"
24406
+ host: { class: "flex min-h-0 flex-1 flex-col" },
24407
+ template: "<!-- `<details>`/`<summary>` stay native (open/close, the `name`-based exclusive accordion): a facet is\n a disclosure, not a grid row. They used to sit in an `@angular/aria` grid so ArrowUp/ArrowDown could\n jump from one facet header to the next, but a grid without headers reads as a layout table\n (RGAA 5.3). The `<summary>` itself is `tabindex=\"-1\"`: Tab walks the header's buttons (apply, clear,\n select-all, chevron), never the header row, and the chevron is the keyboard way to collapse.\n Label, header actions and body are `<ng-template>`s: `<ng-content select=\"…\">` can only be\n declared once, and the body is projected content. -->\n\n<ng-template #labelSlot>\n <ng-content select=\"label\">\n @let icon = aggregation()?.icon;\n @if (icon) {\n <FaIcon [faClass]=\"icon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n</ng-template>\n\n<ng-template #headerActionsSlot>\n @if (showFiltersCount() && filtersCount() > 0) {\n <Badge variant=\"number\" fill=\"primary\" scheme=\"sage\" size=\"xs\" class=\"ml-1\">\n {{ filtersCount() }}\n </Badge>\n }\n @if (!isCollapsed()) {\n @if (hasFilters()) {\n @let label = \"filters.clearFilters\" | transloco;\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n class=\"shrink-0\"\n [aria-label]=\"label\"\n [tooltip]=\"label\"\n (click)=\"$event.stopPropagation(); cleared.emit()\">\n <filter-x-icon class=\"shrink-0\" />\n </button>\n }\n @if (selection()) {\n @let label = \"filters.apply\" | transloco;\n <button\n type=\"button\"\n variant=\"accent\"\n size=\"sm\"\n class=\"overflow-hidden\"\n [aria-label]=\"label\"\n [tooltip]=\"label\"\n (click)=\"$event.stopPropagation(); applied.emit()\">\n <FilterIcon class=\"shrink-0\" />\n <span class=\"truncate\">{{ label }}</span>\n </button>\n }\n\n <!-- Nothing is projected into this box, so it had no accessible name at all (RGAA 11.1) —\n only its position next to the facet's title said what it selects. The name is stable\n whichever way it is about to act: `aria-checked` (true / mixed / false) is what tells the\n user where the selection stands. -->\n <checkbox\n class=\"shrink-0\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"selection() && !isAllSelected()\"\n [aria-label]=\"'filters.selectAllFilters' | transloco\"\n (checkedChange)=\"$event ? allSelected.emit() : allUnselected.emit()\" />\n }\n\n @if (collapsible()) {\n <!-- The `<summary>` is out of the tab order (`tabindex=\"-1\"`), so this button is the keyboard way to\n collapse the facet. Its click bubbles to the `<summary>`, whose handler does the toggle, and it\n exposes `aria-expanded`. -->\n <button\n type=\"button\"\n variant=\"tertiary\"\n [iconOnly]=\"true\"\n size=\"sm\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n [tooltip]=\"'filters.toggle' | transloco\"\n class=\"shrink-0 cursor-pointer [&_svg]:transition-transform [&_svg]:duration-150 group-open:[&_svg]:rotate-90\">\n <chevron-right class=\"shrink-0\" />\n <span class=\"sr-only\">{{ \"filters.toggle\" | transloco }}</span>\n </button>\n }\n</ng-template>\n\n<ng-template #bodySlot>\n <div class=\"flex min-h-0 flex-1 flex-col\">\n @if (aggregation()?.searchable && itemsLength() > 0) {\n <!-- No `hotkey`: this field is one of many facet search boxes on the page, so it must not\n advertise a global shortcut. It used to pass `''` to neutralize SearchInput's old\n `\"shift+/\"` default, which left an empty `aria-keyshortcuts` attribute behind; the default\n is gone, so omitting the input is enough. -->\n <!-- A placeholder is not a label (RGAA 11.1): it goes away on the first keystroke, and it is\n the same word in every facet panel of the page. The name says WHICH facet this box\n filters — the one thing that tells a dozen identical fields apart. -->\n <!-- mt-2: without it, the search box sits flush under <summary>, and the header's focus ring reads as touching it. -->\n <SearchInput\n class=\"mt-2 mb-3 shrink-0\"\n [placeholder]=\"'search' | transloco\"\n [clearLabel]=\"'filters.clearSearch' | transloco\"\n [aria-label]=\"\n 'filters.searchInFacet'\n | transloco\n : { facet: (aggregation()?.display | syslang | transloco) }\n \"\n [(value)]=\"searchText\">\n <ng-content select=\"[search-addon]\" />\n </SearchInput>\n }\n\n <ng-content />\n\n @if (hasMore() && searchedItemsLength() === 0) {\n <button\n type=\"button\"\n class=\"mt-1 flex w-full shrink-0 justify-center\"\n [attr.aria-label]=\"'loadMore' | transloco\"\n (click)=\"loadedMore.emit()\">\n {{ \"loadMore\" | transloco }}\n </button>\n }\n </div>\n</ng-template>\n\n<details\n [attr.open]=\"expanded()\"\n [attr.name]=\"id()\"\n class=\"group flex min-h-0 flex-1 flex-col p-0.5 details-content:flex details-content:min-h-0 details-content:flex-1 details-content:flex-col\"\n (toggle)=\"onToggle($event)\">\n <summary\n tabindex=\"-1\"\n [class.cursor-pointer]=\"collapsible() && !isEmpty()\"\n [class]=\"(isEmpty() ? 'text-(--font-neutral-muted) ' : '') + rowStateClass\"\n class=\"m-0 flex h-8 w-full shrink-0 scroll-mt-16 scroll-mb-3 items-center gap-1 pl-1 font-semibold select-none\"\n (click)=\"onHeaderClick($event)\">\n <ng-container [ngTemplateOutlet]=\"labelSlot\" />\n <ng-container [ngTemplateOutlet]=\"headerActionsSlot\" />\n </summary>\n <ng-container [ngTemplateOutlet]=\"bodySlot\" />\n</details>\n"
24247
24408
  }]
24248
24409
  }],
24249
24410
  ctorParameters: () => [],
@@ -25074,7 +25235,7 @@ var AggregationTreeComponent = class AggregationTreeComponent {
25074
25235
  filtersChange: "filtersChange",
25075
25236
  searchText: "searchTextChange"
25076
25237
  },
25077
- host: { properties: { "class": "cn(\"block h-[inherit] max-h-[inherit]\", class(), (isMissing() || (hideWhenEmpty() && isHidden())) && \"hidden\")" } },
25238
+ host: { properties: { "class": "cn(\"flex h-[inherit] max-h-[inherit] flex-col\", class(), (isMissing() || (hideWhenEmpty() && isHidden())) && \"hidden\")" } },
25078
25239
  queries: [{
25079
25240
  propertyName: "itemDirective",
25080
25241
  first: true,
@@ -25083,7 +25244,7 @@ var AggregationTreeComponent = class AggregationTreeComponent {
25083
25244
  isSignal: true
25084
25245
  }],
25085
25246
  ngImport: i0,
25086
- template: "@if (!aggregation()?.isTree) {\n <div role=\"alert\" class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregationTree component does not support list aggregations. Please use\n the &lt;Aggregation /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div\n class=\"max-h-(--scroll-height,20rem) w-full scrollbar-thin overflow-auto pb-3\">\n <ul\n ngTree\n #tree=\"ngTree\"\n [multi]=\"true\"\n [(value)]=\"selected\"\n (valueChange)=\"onSelectionChange($event)\"\n /* Translated like the flat list's own label (aggregation-list.html): this was the raw\n `display`, so the accessible name of a whole facet came out in the source language.\n The pipes apply to the whole nullish chain, `|` being the loosest operator Angular\n parses — no parentheses needed. */\n [attr.aria-label]=\"aggregation()?.display ?? name() | syslang | transloco\"\n class=\"basic-tree m-1\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: nodes(), parent: tree }\" />\n </ul>\n </div>\n</aggregation-panel>\n\n<ng-template #treeNodes let-nodes=\"nodes\" let-parent=\"parent\">\n @for (node of nodes; track node.$path) {\n @let label = node.display ?? node.value ?? \"\";\n <li\n ngTreeItem\n #treeItem=\"ngTreeItem\"\n [parent]=\"parent\"\n [value]=\"node.$path\"\n [label]=\"label\"\n [disabled]=\"node.disabled\"\n [(expanded)]=\"node.$opened\"\n (expandedChange)=\"onExpandedChange(node, $event)\"\n [attr.aria-label]=\"label\"\n [attr.aria-describedby]=\"\n nodeDescription(node) ? descriptionId(node.$path) : null\n \"\n [class]=\"cn('m-0 block w-full p-0', itemStateClass)\"\n (keydown.enter)=\"onTreeItemEnterKey(node, $event)\"\n (keydown.space)=\"onTreeItemSpaceKey(node, $event)\">\n <!-- 2. Ce div prend tout l'espace et intercepte tous les clics de la ligne -->\n <div\n class=\"flex w-full cursor-pointer items-center gap-2 py-0.5 text-sm leading-6\"\n (click)=\"onTreeItemRowClick(node, $event)\"\n (mousedown)=\"$event.stopPropagation()\"\n (pointerdown)=\"$event.stopPropagation()\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n a role=\"treeitem\" admits no focusable descendant, and the click must reach\n the row handler above. Selection is announced by the <li>'s aria-selected. -->\n <!-- --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. Set as a custom property rather than a utility\n class: the CVA already sets gap-(--spacing-xs), and two same-specificity\n classes would be resolved by stylesheet order, not by us. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"treeItem.selected() || node.$selectedVisually || false\"\n [disabled]=\"node.disabled\"\n [class]=\"\n cn(\n 'pointer-events-none',\n (treeItem.selected() || node.$selectedVisually) &&\n 'selected-icon'\n )\n \" />\n </span>\n }\n <!-- Depth indent sits on the chevron, after the checkbox: the checkboxes of every\n level stay flush left (aligned with a flat list's), and since this adds no extra\n flex child, the step between consecutive levels is exactly one indent. -->\n @let depthIndent =\n \"calc(var(--agg-tree-indent, 0.5rem) * \" +\n (treeItem.level() - 1) +\n \")\";\n @if (node.hasChildren) {\n <!-- Expand/collapse affordance for the mouse only, hence a <span> and not a <button>:\n the keyboard already expands with ArrowRight / ArrowLeft, handled by ngTreeItem, and\n a role=\"treeitem\" admits no focusable descendant — so this was a <button> nobody\n could reach, already carrying aria-hidden and tabindex=\"-1\" to say so. Every audit\n reads it as a button with no accessible name (RGAA 7.1, 11.9). Same shape as the leaf\n branch below. -->\n <span\n aria-hidden=\"true\"\n (click)=\"toggleTreeNode(treeItem, $event)\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex cursor-pointer items-center justify-center\">\n <ChevronRightIcon\n [class]=\"\n cn('expand-icon shrink-0', node.$opened && 'rotate-90')\n \" />\n <FolderIcon class=\"shrink-0\" />\n </span>\n } @else {\n <span\n aria-hidden=\"true\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex items-center justify-center\">\n <!-- Reserves the chevron's own width (`.expand-icon` is a styling hook with\n no rule of its own), so a leaf lines up with its parent nodes. -->\n <span class=\"expand-icon w-4 shrink-0\"></span>\n <FileIcon class=\"size-4 shrink-0\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <span\n class=\"grow truncate select-none\"\n (click)=\"onTreeItemTextClick(node, $event)\">\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: node,\n field: aggregation()?.column,\n selected: treeItem.selected() ?? false,\n count: node.count,\n name: label,\n searchText: searchText(),\n level: node.$level ?? 0,\n expanded: node.$opened ?? false,\n hasChildren: node.hasChildren ?? false\n }\" />\n </span>\n } @else {\n <!-- ce span est essentiel pour le style -->\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <span\n [class]=\"\n cn(\n 'grow truncate select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n [title]=\"\n appliesOnLabelClick\n ? ((isTreeItemFiltered(node)\n ? 'filters.removeFilter'\n : 'filters.addFilter'\n ) | transloco) +\n ': ' +\n label\n : label\n \"\n (click)=\"onTreeItemTextClick(node, $event)\">\n @for (\n chunk of label | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && node.count > 0) {\n <span class=\"ml-auto px-1 text-xs\" aria-hidden=\"true\">{{\n node.count\n }}</span>\n }\n }\n @let description = nodeDescription(node);\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the treeitem's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(node.$path)\">{{ description }}</span>\n }\n </div>\n </li>\n @if (node.hasChildren) {\n <!-- No indentation here: it would shift the checkbox too. Depth is applied\n inside the row, after the checkbox, so every checkbox stays flush left. -->\n <!-- The <ul> sits INSIDE the deferred content, not around it: `ngTreeItemGroup` only\n instantiates its view when the node opens, so a <ul> declared outside stayed in the DOM,\n empty, for every collapsed node — a structural tag with nothing to structure (RGAA 8.9).\n The @if cannot move onto the <ul> either: the directive registers itself with its tree\n item on init and has to exist for as long as the item does. -->\n <ng-template\n ngTreeItemGroup\n [ownedBy]=\"treeItem\"\n #group=\"ngTreeItemGroup\">\n <ul role=\"group\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: node.items, parent: group }\" />\n </ul>\n </ng-template>\n }\n }\n</ng-template>\n",
25247
+ template: "@if (!aggregation()?.isTree) {\n <div role=\"alert\" class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregationTree component does not support list aggregations. Please use\n the &lt;Aggregation /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div class=\"min-h-0 w-full flex-1 scrollbar-thin overflow-auto pb-3\">\n <ul\n ngTree\n #tree=\"ngTree\"\n [multi]=\"true\"\n [(value)]=\"selected\"\n (valueChange)=\"onSelectionChange($event)\"\n /* Translated like the flat list's own label (aggregation-list.html): this was the raw\n `display`, so the accessible name of a whole facet came out in the source language.\n The pipes apply to the whole nullish chain, `|` being the loosest operator Angular\n parses — no parentheses needed. */\n [attr.aria-label]=\"aggregation()?.display ?? name() | syslang | transloco\"\n class=\"basic-tree m-1\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: nodes(), parent: tree }\" />\n </ul>\n </div>\n</aggregation-panel>\n\n<ng-template #treeNodes let-nodes=\"nodes\" let-parent=\"parent\">\n @for (node of nodes; track node.$path) {\n @let label = node.display ?? node.value ?? \"\";\n <li\n ngTreeItem\n #treeItem=\"ngTreeItem\"\n [parent]=\"parent\"\n [value]=\"node.$path\"\n [label]=\"label\"\n [disabled]=\"node.disabled\"\n [(expanded)]=\"node.$opened\"\n (expandedChange)=\"onExpandedChange(node, $event)\"\n [attr.aria-label]=\"label\"\n [attr.aria-describedby]=\"\n nodeDescription(node) ? descriptionId(node.$path) : null\n \"\n [class]=\"cn('m-0 block w-full p-0', itemStateClass)\"\n (keydown.enter)=\"onTreeItemEnterKey(node, $event)\"\n (keydown.space)=\"onTreeItemSpaceKey(node, $event)\">\n <!-- 2. Ce div prend tout l'espace et intercepte tous les clics de la ligne -->\n <div\n class=\"flex w-full cursor-pointer items-center gap-2 py-0.5 text-sm leading-6\"\n (click)=\"onTreeItemRowClick(node, $event)\"\n (mousedown)=\"$event.stopPropagation()\"\n (pointerdown)=\"$event.stopPropagation()\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n a role=\"treeitem\" admits no focusable descendant, and the click must reach\n the row handler above. Selection is announced by the <li>'s aria-selected. -->\n <!-- --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. Set as a custom property rather than a utility\n class: the CVA already sets gap-(--spacing-xs), and two same-specificity\n classes would be resolved by stylesheet order, not by us. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"treeItem.selected() || node.$selectedVisually || false\"\n [disabled]=\"node.disabled\"\n [class]=\"\n cn(\n 'pointer-events-none',\n (treeItem.selected() || node.$selectedVisually) &&\n 'selected-icon'\n )\n \" />\n </span>\n }\n <!-- Depth indent sits on the chevron, after the checkbox: the checkboxes of every\n level stay flush left (aligned with a flat list's), and since this adds no extra\n flex child, the step between consecutive levels is exactly one indent. -->\n @let depthIndent =\n \"calc(var(--agg-tree-indent, 0.5rem) * \" +\n (treeItem.level() - 1) +\n \")\";\n @if (node.hasChildren) {\n <!-- Expand/collapse affordance for the mouse only, hence a <span> and not a <button>:\n the keyboard already expands with ArrowRight / ArrowLeft, handled by ngTreeItem, and\n a role=\"treeitem\" admits no focusable descendant — so this was a <button> nobody\n could reach, already carrying aria-hidden and tabindex=\"-1\" to say so. Every audit\n reads it as a button with no accessible name (RGAA 7.1, 11.9). Same shape as the leaf\n branch below. -->\n <span\n aria-hidden=\"true\"\n (click)=\"toggleTreeNode(treeItem, $event)\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex cursor-pointer items-center justify-center\">\n <ChevronRightIcon\n [class]=\"\n cn('expand-icon shrink-0', node.$opened && 'rotate-90')\n \" />\n <FolderIcon class=\"shrink-0\" />\n </span>\n } @else {\n <span\n aria-hidden=\"true\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex items-center justify-center\">\n <!-- Reserves the chevron's own width (`.expand-icon` is a styling hook with\n no rule of its own), so a leaf lines up with its parent nodes. -->\n <span class=\"expand-icon w-4 shrink-0\"></span>\n <FileIcon class=\"size-4 shrink-0\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <span\n class=\"grow truncate select-none\"\n (click)=\"onTreeItemTextClick(node, $event)\">\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: node,\n field: aggregation()?.column,\n selected: treeItem.selected() ?? false,\n count: node.count,\n name: label,\n searchText: searchText(),\n level: node.$level ?? 0,\n expanded: node.$opened ?? false,\n hasChildren: node.hasChildren ?? false\n }\" />\n </span>\n } @else {\n <!-- ce span est essentiel pour le style -->\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <span\n [class]=\"\n cn(\n 'grow truncate select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n [title]=\"\n appliesOnLabelClick\n ? ((isTreeItemFiltered(node)\n ? 'filters.removeFilter'\n : 'filters.addFilter'\n ) | transloco) +\n ': ' +\n label\n : label\n \"\n (click)=\"onTreeItemTextClick(node, $event)\">\n @for (\n chunk of label | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && node.count > 0) {\n <span class=\"ml-auto px-1 text-xs\" aria-hidden=\"true\">{{\n node.count\n }}</span>\n }\n }\n @let description = nodeDescription(node);\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the treeitem's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(node.$path)\">{{ description }}</span>\n }\n </div>\n </li>\n @if (node.hasChildren) {\n <!-- No indentation here: it would shift the checkbox too. Depth is applied\n inside the row, after the checkbox, so every checkbox stays flush left. -->\n <!-- The <ul> sits INSIDE the deferred content, not around it: `ngTreeItemGroup` only\n instantiates its view when the node opens, so a <ul> declared outside stayed in the DOM,\n empty, for every collapsed node — a structural tag with nothing to structure (RGAA 8.9).\n The @if cannot move onto the <ul> either: the directive registers itself with its tree\n item on init and has to exist for as long as the item does. -->\n <ng-template\n ngTreeItemGroup\n [ownedBy]=\"treeItem\"\n #group=\"ngTreeItemGroup\">\n <ul role=\"group\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: node.items, parent: group }\" />\n </ul>\n </ng-template>\n }\n }\n</ng-template>\n",
25087
25248
  dependencies: [
25088
25249
  {
25089
25250
  kind: "directive",
@@ -25257,8 +25418,8 @@ i0.ɵɵngDeclareClassMetadata({
25257
25418
  TreeItemGroup,
25258
25419
  AggregationPanelComponent
25259
25420
  ],
25260
- host: { "[class]": "cn(\"block h-[inherit] max-h-[inherit]\", class(), (isMissing() || (hideWhenEmpty() && isHidden())) && \"hidden\")" },
25261
- template: "@if (!aggregation()?.isTree) {\n <div role=\"alert\" class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregationTree component does not support list aggregations. Please use\n the &lt;Aggregation /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div\n class=\"max-h-(--scroll-height,20rem) w-full scrollbar-thin overflow-auto pb-3\">\n <ul\n ngTree\n #tree=\"ngTree\"\n [multi]=\"true\"\n [(value)]=\"selected\"\n (valueChange)=\"onSelectionChange($event)\"\n /* Translated like the flat list's own label (aggregation-list.html): this was the raw\n `display`, so the accessible name of a whole facet came out in the source language.\n The pipes apply to the whole nullish chain, `|` being the loosest operator Angular\n parses — no parentheses needed. */\n [attr.aria-label]=\"aggregation()?.display ?? name() | syslang | transloco\"\n class=\"basic-tree m-1\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: nodes(), parent: tree }\" />\n </ul>\n </div>\n</aggregation-panel>\n\n<ng-template #treeNodes let-nodes=\"nodes\" let-parent=\"parent\">\n @for (node of nodes; track node.$path) {\n @let label = node.display ?? node.value ?? \"\";\n <li\n ngTreeItem\n #treeItem=\"ngTreeItem\"\n [parent]=\"parent\"\n [value]=\"node.$path\"\n [label]=\"label\"\n [disabled]=\"node.disabled\"\n [(expanded)]=\"node.$opened\"\n (expandedChange)=\"onExpandedChange(node, $event)\"\n [attr.aria-label]=\"label\"\n [attr.aria-describedby]=\"\n nodeDescription(node) ? descriptionId(node.$path) : null\n \"\n [class]=\"cn('m-0 block w-full p-0', itemStateClass)\"\n (keydown.enter)=\"onTreeItemEnterKey(node, $event)\"\n (keydown.space)=\"onTreeItemSpaceKey(node, $event)\">\n <!-- 2. Ce div prend tout l'espace et intercepte tous les clics de la ligne -->\n <div\n class=\"flex w-full cursor-pointer items-center gap-2 py-0.5 text-sm leading-6\"\n (click)=\"onTreeItemRowClick(node, $event)\"\n (mousedown)=\"$event.stopPropagation()\"\n (pointerdown)=\"$event.stopPropagation()\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n a role=\"treeitem\" admits no focusable descendant, and the click must reach\n the row handler above. Selection is announced by the <li>'s aria-selected. -->\n <!-- --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. Set as a custom property rather than a utility\n class: the CVA already sets gap-(--spacing-xs), and two same-specificity\n classes would be resolved by stylesheet order, not by us. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"treeItem.selected() || node.$selectedVisually || false\"\n [disabled]=\"node.disabled\"\n [class]=\"\n cn(\n 'pointer-events-none',\n (treeItem.selected() || node.$selectedVisually) &&\n 'selected-icon'\n )\n \" />\n </span>\n }\n <!-- Depth indent sits on the chevron, after the checkbox: the checkboxes of every\n level stay flush left (aligned with a flat list's), and since this adds no extra\n flex child, the step between consecutive levels is exactly one indent. -->\n @let depthIndent =\n \"calc(var(--agg-tree-indent, 0.5rem) * \" +\n (treeItem.level() - 1) +\n \")\";\n @if (node.hasChildren) {\n <!-- Expand/collapse affordance for the mouse only, hence a <span> and not a <button>:\n the keyboard already expands with ArrowRight / ArrowLeft, handled by ngTreeItem, and\n a role=\"treeitem\" admits no focusable descendant — so this was a <button> nobody\n could reach, already carrying aria-hidden and tabindex=\"-1\" to say so. Every audit\n reads it as a button with no accessible name (RGAA 7.1, 11.9). Same shape as the leaf\n branch below. -->\n <span\n aria-hidden=\"true\"\n (click)=\"toggleTreeNode(treeItem, $event)\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex cursor-pointer items-center justify-center\">\n <ChevronRightIcon\n [class]=\"\n cn('expand-icon shrink-0', node.$opened && 'rotate-90')\n \" />\n <FolderIcon class=\"shrink-0\" />\n </span>\n } @else {\n <span\n aria-hidden=\"true\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex items-center justify-center\">\n <!-- Reserves the chevron's own width (`.expand-icon` is a styling hook with\n no rule of its own), so a leaf lines up with its parent nodes. -->\n <span class=\"expand-icon w-4 shrink-0\"></span>\n <FileIcon class=\"size-4 shrink-0\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <span\n class=\"grow truncate select-none\"\n (click)=\"onTreeItemTextClick(node, $event)\">\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: node,\n field: aggregation()?.column,\n selected: treeItem.selected() ?? false,\n count: node.count,\n name: label,\n searchText: searchText(),\n level: node.$level ?? 0,\n expanded: node.$opened ?? false,\n hasChildren: node.hasChildren ?? false\n }\" />\n </span>\n } @else {\n <!-- ce span est essentiel pour le style -->\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <span\n [class]=\"\n cn(\n 'grow truncate select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n [title]=\"\n appliesOnLabelClick\n ? ((isTreeItemFiltered(node)\n ? 'filters.removeFilter'\n : 'filters.addFilter'\n ) | transloco) +\n ': ' +\n label\n : label\n \"\n (click)=\"onTreeItemTextClick(node, $event)\">\n @for (\n chunk of label | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && node.count > 0) {\n <span class=\"ml-auto px-1 text-xs\" aria-hidden=\"true\">{{\n node.count\n }}</span>\n }\n }\n @let description = nodeDescription(node);\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the treeitem's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(node.$path)\">{{ description }}</span>\n }\n </div>\n </li>\n @if (node.hasChildren) {\n <!-- No indentation here: it would shift the checkbox too. Depth is applied\n inside the row, after the checkbox, so every checkbox stays flush left. -->\n <!-- The <ul> sits INSIDE the deferred content, not around it: `ngTreeItemGroup` only\n instantiates its view when the node opens, so a <ul> declared outside stayed in the DOM,\n empty, for every collapsed node — a structural tag with nothing to structure (RGAA 8.9).\n The @if cannot move onto the <ul> either: the directive registers itself with its tree\n item on init and has to exist for as long as the item does. -->\n <ng-template\n ngTreeItemGroup\n [ownedBy]=\"treeItem\"\n #group=\"ngTreeItemGroup\">\n <ul role=\"group\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: node.items, parent: group }\" />\n </ul>\n </ng-template>\n }\n }\n</ng-template>\n"
25421
+ host: { "[class]": "cn(\"flex h-[inherit] max-h-[inherit] flex-col\", class(), (isMissing() || (hideWhenEmpty() && isHidden())) && \"hidden\")" },
25422
+ template: "@if (!aggregation()?.isTree) {\n <div role=\"alert\" class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregationTree component does not support list aggregations. Please use\n the &lt;Aggregation /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div class=\"min-h-0 w-full flex-1 scrollbar-thin overflow-auto pb-3\">\n <ul\n ngTree\n #tree=\"ngTree\"\n [multi]=\"true\"\n [(value)]=\"selected\"\n (valueChange)=\"onSelectionChange($event)\"\n /* Translated like the flat list's own label (aggregation-list.html): this was the raw\n `display`, so the accessible name of a whole facet came out in the source language.\n The pipes apply to the whole nullish chain, `|` being the loosest operator Angular\n parses — no parentheses needed. */\n [attr.aria-label]=\"aggregation()?.display ?? name() | syslang | transloco\"\n class=\"basic-tree m-1\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: nodes(), parent: tree }\" />\n </ul>\n </div>\n</aggregation-panel>\n\n<ng-template #treeNodes let-nodes=\"nodes\" let-parent=\"parent\">\n @for (node of nodes; track node.$path) {\n @let label = node.display ?? node.value ?? \"\";\n <li\n ngTreeItem\n #treeItem=\"ngTreeItem\"\n [parent]=\"parent\"\n [value]=\"node.$path\"\n [label]=\"label\"\n [disabled]=\"node.disabled\"\n [(expanded)]=\"node.$opened\"\n (expandedChange)=\"onExpandedChange(node, $event)\"\n [attr.aria-label]=\"label\"\n [attr.aria-describedby]=\"\n nodeDescription(node) ? descriptionId(node.$path) : null\n \"\n [class]=\"cn('m-0 block w-full p-0', itemStateClass)\"\n (keydown.enter)=\"onTreeItemEnterKey(node, $event)\"\n (keydown.space)=\"onTreeItemSpaceKey(node, $event)\">\n <!-- 2. Ce div prend tout l'espace et intercepte tous les clics de la ligne -->\n <div\n class=\"flex w-full cursor-pointer items-center gap-2 py-0.5 text-sm leading-6\"\n (click)=\"onTreeItemRowClick(node, $event)\"\n (mousedown)=\"$event.stopPropagation()\"\n (pointerdown)=\"$event.stopPropagation()\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n a role=\"treeitem\" admits no focusable descendant, and the click must reach\n the row handler above. Selection is announced by the <li>'s aria-selected. -->\n <!-- --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. Set as a custom property rather than a utility\n class: the CVA already sets gap-(--spacing-xs), and two same-specificity\n classes would be resolved by stylesheet order, not by us. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"treeItem.selected() || node.$selectedVisually || false\"\n [disabled]=\"node.disabled\"\n [class]=\"\n cn(\n 'pointer-events-none',\n (treeItem.selected() || node.$selectedVisually) &&\n 'selected-icon'\n )\n \" />\n </span>\n }\n <!-- Depth indent sits on the chevron, after the checkbox: the checkboxes of every\n level stay flush left (aligned with a flat list's), and since this adds no extra\n flex child, the step between consecutive levels is exactly one indent. -->\n @let depthIndent =\n \"calc(var(--agg-tree-indent, 0.5rem) * \" +\n (treeItem.level() - 1) +\n \")\";\n @if (node.hasChildren) {\n <!-- Expand/collapse affordance for the mouse only, hence a <span> and not a <button>:\n the keyboard already expands with ArrowRight / ArrowLeft, handled by ngTreeItem, and\n a role=\"treeitem\" admits no focusable descendant — so this was a <button> nobody\n could reach, already carrying aria-hidden and tabindex=\"-1\" to say so. Every audit\n reads it as a button with no accessible name (RGAA 7.1, 11.9). Same shape as the leaf\n branch below. -->\n <span\n aria-hidden=\"true\"\n (click)=\"toggleTreeNode(treeItem, $event)\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex cursor-pointer items-center justify-center\">\n <ChevronRightIcon\n [class]=\"\n cn('expand-icon shrink-0', node.$opened && 'rotate-90')\n \" />\n <FolderIcon class=\"shrink-0\" />\n </span>\n } @else {\n <span\n aria-hidden=\"true\"\n [style.marginInlineStart]=\"depthIndent\"\n class=\"inline-flex items-center justify-center\">\n <!-- Reserves the chevron's own width (`.expand-icon` is a styling hook with\n no rule of its own), so a leaf lines up with its parent nodes. -->\n <span class=\"expand-icon w-4 shrink-0\"></span>\n <FileIcon class=\"size-4 shrink-0\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <span\n class=\"grow truncate select-none\"\n (click)=\"onTreeItemTextClick(node, $event)\">\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: node,\n field: aggregation()?.column,\n selected: treeItem.selected() ?? false,\n count: node.count,\n name: label,\n searchText: searchText(),\n level: node.$level ?? 0,\n expanded: node.$opened ?? false,\n hasChildren: node.hasChildren ?? false\n }\" />\n </span>\n } @else {\n <!-- ce span est essentiel pour le style -->\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <span\n [class]=\"\n cn(\n 'grow truncate select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n [title]=\"\n appliesOnLabelClick\n ? ((isTreeItemFiltered(node)\n ? 'filters.removeFilter'\n : 'filters.addFilter'\n ) | transloco) +\n ': ' +\n label\n : label\n \"\n (click)=\"onTreeItemTextClick(node, $event)\">\n @for (\n chunk of label | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && node.count > 0) {\n <span class=\"ml-auto px-1 text-xs\" aria-hidden=\"true\">{{\n node.count\n }}</span>\n }\n }\n @let description = nodeDescription(node);\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the treeitem's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(node.$path)\">{{ description }}</span>\n }\n </div>\n </li>\n @if (node.hasChildren) {\n <!-- No indentation here: it would shift the checkbox too. Depth is applied\n inside the row, after the checkbox, so every checkbox stays flush left. -->\n <!-- The <ul> sits INSIDE the deferred content, not around it: `ngTreeItemGroup` only\n instantiates its view when the node opens, so a <ul> declared outside stayed in the DOM,\n empty, for every collapsed node — a structural tag with nothing to structure (RGAA 8.9).\n The @if cannot move onto the <ul> either: the directive registers itself with its tree\n item on init and has to exist for as long as the item does. -->\n <ng-template\n ngTreeItemGroup\n [ownedBy]=\"treeItem\"\n #group=\"ngTreeItemGroup\">\n <ul role=\"group\">\n <ng-template\n [ngTemplateOutlet]=\"treeNodes\"\n [ngTemplateOutletContext]=\"{ nodes: node.items, parent: group }\" />\n </ul>\n </ng-template>\n }\n }\n</ng-template>\n"
25262
25423
  }]
25263
25424
  }],
25264
25425
  ctorParameters: () => [],
@@ -25915,7 +26076,7 @@ var AggregationListComponent = class AggregationListComponent {
25915
26076
  },
25916
26077
  host: {
25917
26078
  listeners: { "keydown.enter": "onHostEnterKey($event)" },
25918
- properties: { "class": "cn(\"block h-[inherit] max-h-[inherit]\", class(), (isMissing() || (hideWhenEmpty() && isEmpty())) && \"hidden\")" }
26079
+ properties: { "class": "cn(\"flex h-[inherit] max-h-[inherit] flex-col\", class(), (isMissing() || (hideWhenEmpty() && isEmpty())) && \"hidden\")" }
25919
26080
  },
25920
26081
  queries: [{
25921
26082
  propertyName: "itemDirective",
@@ -25947,7 +26108,7 @@ var AggregationListComponent = class AggregationListComponent {
25947
26108
  }
25948
26109
  ],
25949
26110
  ngImport: i0,
25950
- template: "@if (aggregation()?.isTree) {\n <div class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregation component no longer supports tree aggregations. Please use\n the &lt;AggregationTree /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div\n #scrollElement\n class=\"max-h-(--scroll-height,20rem) w-full scrollbar-thin overflow-auto px-1 pt-1 pb-3\">\n <div\n ngListbox\n multi\n selectionMode=\"explicit\"\n [value]=\"selectedValues()\"\n (valueChange)=\"syncListboxValue($event)\"\n [wrap]=\"false\"\n [softDisabled]=\"false\"\n class=\"relative w-full\"\n [style.height]=\"virtualizer.getTotalSize() + 'px'\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n [attr.aria-label]=\"aggregation()?.display | syslang | transloco\">\n @for (vItem of virtualizer.getVirtualItems(); track vItem.index) {\n @let item = items()[vItem.index];\n @let description = rowDescription(item);\n <div\n #row\n ngOption\n [attr.aria-describedby]=\"\n description ? descriptionId(vItem.index) : null\n \"\n [attr.data-index]=\"vItem.index\"\n [value]=\"item.value\"\n [disabled]=\"item.count === 0\"\n [class]=\"cn('absolute w-full', menuItemBaseClass)\"\n (keydown.enter)=\"onOptionEnterKey(item, $event)\"\n (keydown.space)=\"\n onListItemRowClick(item, $event); $event.preventDefault()\n \"\n [style.transform]=\"'translateY(' + vItem.start + 'px)'\"\n role=\"option\">\n <ng-container\n [ngTemplateOutlet]=\"listItemTpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: aggregation()?.column\n }\">\n </ng-container>\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the option's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(vItem.index)\">{{\n description\n }}</span>\n }\n </div>\n }\n </div>\n </div>\n</aggregation-panel>\n\n<ng-template #listItemTpl let-item let-field=\"field\">\n <a\n [attr.aria-label]=\"listItemName(item) | syslang\"\n [class]=\"\n cn(\n '-m-1.5 flex min-w-0 grow items-center gap-2 leading-6',\n item.count === 0 && 'pointer-events-none text-(--font-neutral-muted)'\n )\n \"\n (click)=\"onListItemRowClick(item, $event)\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n keyboard navigation belongs to ngListbox/ngOption (roving tabindex), the\n selection is already exposed by role=\"option\" + aria-selected, and the click\n must reach the <a> above. Same treatment as the tree.\n --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"item.$selected ?? false\"\n [disabled]=\"item.count === 0\"\n [class]=\"'pointer-events-none'\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: field,\n selected: item.$selected ?? false,\n count: item.count,\n name: (listItemName(item) | syslang) ?? '',\n searchText: searchText()\n }\" />\n } @else {\n @let icon = $any(item).icon;\n @if (icon) {\n <FaIcon\n [faClass]=\"icon\"\n class=\"shrink-0 self-center justify-self-center\" />\n }\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <span\n [class]=\"\n cn(\n 'line-clamp-1 cursor-pointer break-all text-ellipsis select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n [title]=\"\n appliesOnLabelClick\n ? ((isListItemFiltered(item, field)\n ? 'filters.removeFilter'\n : 'filters.addFilter'\n ) | transloco) +\n ': ' +\n (listItemName(item) | syslang)\n : (listItemName(item) | syslang)\n \"\n (click)=\"onListItemTextClick(item, $event)\">\n @for (\n chunk of (listItemName(item) | syslang) ?? \"\"\n | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && item.count > 0) {\n <span class=\"ml-auto px-1 text-xs empty:hidden\" aria-hidden=\"true\">{{\n item.count\n }}</span>\n }\n }\n </a>\n</ng-template>\n",
26111
+ template: "@if (aggregation()?.isTree) {\n <div class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregation component no longer supports tree aggregations. Please use\n the &lt;AggregationTree /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div\n #scrollElement\n class=\"min-h-0 w-full flex-1 scrollbar-thin overflow-auto px-1 pt-1 pb-3\">\n <div\n ngListbox\n multi\n selectionMode=\"explicit\"\n [value]=\"selectedValues()\"\n (valueChange)=\"syncListboxValue($event)\"\n [wrap]=\"false\"\n [softDisabled]=\"false\"\n class=\"relative w-full\"\n [style.height]=\"virtualizer.getTotalSize() + 'px'\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n [attr.aria-label]=\"aggregation()?.display | syslang | transloco\">\n @for (vItem of virtualizer.getVirtualItems(); track vItem.index) {\n @let item = items()[vItem.index];\n @let description = rowDescription(item);\n <div\n #row\n ngOption\n [attr.aria-describedby]=\"\n description ? descriptionId(vItem.index) : null\n \"\n [attr.data-index]=\"vItem.index\"\n [value]=\"item.value\"\n [disabled]=\"item.count === 0\"\n [class]=\"cn('absolute w-full', menuItemBaseClass)\"\n (keydown.enter)=\"onOptionEnterKey(item, $event)\"\n (keydown.space)=\"\n onListItemRowClick(item, $event); $event.preventDefault()\n \"\n [style.transform]=\"'translateY(' + vItem.start + 'px)'\"\n role=\"option\">\n <ng-container\n [ngTemplateOutlet]=\"listItemTpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: aggregation()?.column\n }\">\n </ng-container>\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the option's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(vItem.index)\">{{\n description\n }}</span>\n }\n </div>\n }\n </div>\n </div>\n</aggregation-panel>\n\n<ng-template #listItemTpl let-item let-field=\"field\">\n <a\n [attr.aria-label]=\"listItemName(item) | syslang\"\n [class]=\"\n cn(\n '-m-1.5 flex min-w-0 grow items-center gap-2 leading-6',\n item.count === 0 && 'pointer-events-none text-(--font-neutral-muted)'\n )\n \"\n (click)=\"onListItemRowClick(item, $event)\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n keyboard navigation belongs to ngListbox/ngOption (roving tabindex), the\n selection is already exposed by role=\"option\" + aria-selected, and the click\n must reach the <a> above. Same treatment as the tree.\n --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"item.$selected ?? false\"\n [disabled]=\"item.count === 0\"\n [class]=\"'pointer-events-none'\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: field,\n selected: item.$selected ?? false,\n count: item.count,\n name: (listItemName(item) | syslang) ?? '',\n searchText: searchText()\n }\" />\n } @else {\n @let icon = $any(item).icon;\n @if (icon) {\n <FaIcon\n [faClass]=\"icon\"\n class=\"shrink-0 self-center justify-self-center\" />\n }\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <span\n [class]=\"\n cn(\n 'line-clamp-1 cursor-pointer break-all text-ellipsis select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n [title]=\"\n appliesOnLabelClick\n ? ((isListItemFiltered(item, field)\n ? 'filters.removeFilter'\n : 'filters.addFilter'\n ) | transloco) +\n ': ' +\n (listItemName(item) | syslang)\n : (listItemName(item) | syslang)\n \"\n (click)=\"onListItemTextClick(item, $event)\">\n @for (\n chunk of (listItemName(item) | syslang) ?? \"\"\n | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && item.count > 0) {\n <span class=\"ml-auto px-1 text-xs empty:hidden\" aria-hidden=\"true\">{{\n item.count\n }}</span>\n }\n }\n </a>\n</ng-template>\n",
25951
26112
  dependencies: [
25952
26113
  {
25953
26114
  kind: "directive",
@@ -26088,10 +26249,10 @@ i0.ɵɵngDeclareClassMetadata({
26088
26249
  CheckboxComponent
26089
26250
  ],
26090
26251
  host: {
26091
- "[class]": "cn(\"block h-[inherit] max-h-[inherit]\", class(), (isMissing() || (hideWhenEmpty() && isEmpty())) && \"hidden\")",
26252
+ "[class]": "cn(\"flex h-[inherit] max-h-[inherit] flex-col\", class(), (isMissing() || (hideWhenEmpty() && isEmpty())) && \"hidden\")",
26092
26253
  "(keydown.enter)": "onHostEnterKey($event)"
26093
26254
  },
26094
- template: "@if (aggregation()?.isTree) {\n <div class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregation component no longer supports tree aggregations. Please use\n the &lt;AggregationTree /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div\n #scrollElement\n class=\"max-h-(--scroll-height,20rem) w-full scrollbar-thin overflow-auto px-1 pt-1 pb-3\">\n <div\n ngListbox\n multi\n selectionMode=\"explicit\"\n [value]=\"selectedValues()\"\n (valueChange)=\"syncListboxValue($event)\"\n [wrap]=\"false\"\n [softDisabled]=\"false\"\n class=\"relative w-full\"\n [style.height]=\"virtualizer.getTotalSize() + 'px'\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n [attr.aria-label]=\"aggregation()?.display | syslang | transloco\">\n @for (vItem of virtualizer.getVirtualItems(); track vItem.index) {\n @let item = items()[vItem.index];\n @let description = rowDescription(item);\n <div\n #row\n ngOption\n [attr.aria-describedby]=\"\n description ? descriptionId(vItem.index) : null\n \"\n [attr.data-index]=\"vItem.index\"\n [value]=\"item.value\"\n [disabled]=\"item.count === 0\"\n [class]=\"cn('absolute w-full', menuItemBaseClass)\"\n (keydown.enter)=\"onOptionEnterKey(item, $event)\"\n (keydown.space)=\"\n onListItemRowClick(item, $event); $event.preventDefault()\n \"\n [style.transform]=\"'translateY(' + vItem.start + 'px)'\"\n role=\"option\">\n <ng-container\n [ngTemplateOutlet]=\"listItemTpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: aggregation()?.column\n }\">\n </ng-container>\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the option's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(vItem.index)\">{{\n description\n }}</span>\n }\n </div>\n }\n </div>\n </div>\n</aggregation-panel>\n\n<ng-template #listItemTpl let-item let-field=\"field\">\n <a\n [attr.aria-label]=\"listItemName(item) | syslang\"\n [class]=\"\n cn(\n '-m-1.5 flex min-w-0 grow items-center gap-2 leading-6',\n item.count === 0 && 'pointer-events-none text-(--font-neutral-muted)'\n )\n \"\n (click)=\"onListItemRowClick(item, $event)\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n keyboard navigation belongs to ngListbox/ngOption (roving tabindex), the\n selection is already exposed by role=\"option\" + aria-selected, and the click\n must reach the <a> above. Same treatment as the tree.\n --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"item.$selected ?? false\"\n [disabled]=\"item.count === 0\"\n [class]=\"'pointer-events-none'\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: field,\n selected: item.$selected ?? false,\n count: item.count,\n name: (listItemName(item) | syslang) ?? '',\n searchText: searchText()\n }\" />\n } @else {\n @let icon = $any(item).icon;\n @if (icon) {\n <FaIcon\n [faClass]=\"icon\"\n class=\"shrink-0 self-center justify-self-center\" />\n }\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <span\n [class]=\"\n cn(\n 'line-clamp-1 cursor-pointer break-all text-ellipsis select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n [title]=\"\n appliesOnLabelClick\n ? ((isListItemFiltered(item, field)\n ? 'filters.removeFilter'\n : 'filters.addFilter'\n ) | transloco) +\n ': ' +\n (listItemName(item) | syslang)\n : (listItemName(item) | syslang)\n \"\n (click)=\"onListItemTextClick(item, $event)\">\n @for (\n chunk of (listItemName(item) | syslang) ?? \"\"\n | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && item.count > 0) {\n <span class=\"ml-auto px-1 text-xs empty:hidden\" aria-hidden=\"true\">{{\n item.count\n }}</span>\n }\n }\n </a>\n</ng-template>\n"
26255
+ template: "@if (aggregation()?.isTree) {\n <div class=\"p-2 text-sm text-red-500\">\n <triangle-alert-icon class=\"mr-1 shrink-0\" />\n The aggregation component no longer supports tree aggregations. Please use\n the &lt;AggregationTree /&gt; component instead.\n </div>\n}\n<aggregation-panel\n [id]=\"id()\"\n [collapsible]=\"collapsible()\"\n [collapsed]=\"collapsed()\"\n [isEmpty]=\"isEmpty()\"\n [isDate]=\"isDate()\"\n [aggregation]=\"aggregation()\"\n [showFiltersCount]=\"showFiltersCount()\"\n [filtersCount]=\"filtersCount()\"\n [hasFilters]=\"hasFilters()\"\n [selection]=\"selection()\"\n [isAllSelected]=\"isAllSelected()\"\n [(searchText)]=\"searchText\"\n [itemsLength]=\"items().length\"\n [hasMore]=\"aggregation()?.$hasMore ?? false\"\n [searchedItemsLength]=\"searchedItems().length\"\n (cleared)=\"clear()\"\n (applied)=\"apply()\"\n (allSelected)=\"selectAll()\"\n (allUnselected)=\"unselectAll()\"\n (loadedMore)=\"loadMore()\">\n <!-- Relay the host's `label` projection into the panel's `label` slot. When the\n host projects nothing, the local <ng-content> falls back to the icon + display\n (mirrors AggregationPanel's own fallback), so the header is never empty. -->\n <ng-container ngProjectAs=\"label\">\n <ng-content select=\"label\">\n @let labelIcon = aggregation()?.icon;\n @if (labelIcon) {\n <FaIcon [faClass]=\"labelIcon\" class=\"mr-1 shrink-0\" />\n }\n <span class=\"grow truncate\">{{\n aggregation()?.display | syslang | transloco\n }}</span>\n </ng-content>\n </ng-container>\n <div\n #scrollElement\n class=\"min-h-0 w-full flex-1 scrollbar-thin overflow-auto px-1 pt-1 pb-3\">\n <div\n ngListbox\n multi\n selectionMode=\"explicit\"\n [value]=\"selectedValues()\"\n (valueChange)=\"syncListboxValue($event)\"\n [wrap]=\"false\"\n [softDisabled]=\"false\"\n class=\"relative w-full\"\n [style.height]=\"virtualizer.getTotalSize() + 'px'\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n [attr.aria-label]=\"aggregation()?.display | syslang | transloco\">\n @for (vItem of virtualizer.getVirtualItems(); track vItem.index) {\n @let item = items()[vItem.index];\n @let description = rowDescription(item);\n <div\n #row\n ngOption\n [attr.aria-describedby]=\"\n description ? descriptionId(vItem.index) : null\n \"\n [attr.data-index]=\"vItem.index\"\n [value]=\"item.value\"\n [disabled]=\"item.count === 0\"\n [class]=\"cn('absolute w-full', menuItemBaseClass)\"\n (keydown.enter)=\"onOptionEnterKey(item, $event)\"\n (keydown.space)=\"\n onListItemRowClick(item, $event); $event.preventDefault()\n \"\n [style.transform]=\"'translateY(' + vItem.start + 'px)'\"\n role=\"option\">\n <ng-container\n [ngTemplateOutlet]=\"listItemTpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: aggregation()?.column\n }\">\n </ng-container>\n @if (description) {\n <!-- `hidden`, not `sr-only`: read through aria-describedby only. A visually-hidden\n span would also be counted in the option's name (computed from its content). -->\n <span hidden [id]=\"descriptionId(vItem.index)\">{{\n description\n }}</span>\n }\n </div>\n }\n </div>\n </div>\n</aggregation-panel>\n\n<ng-template #listItemTpl let-item let-field=\"field\">\n <a\n [attr.aria-label]=\"listItemName(item) | syslang\"\n [class]=\"\n cn(\n '-m-1.5 flex min-w-0 grow items-center gap-2 leading-6',\n item.count === 0 && 'pointer-events-none text-(--font-neutral-muted)'\n )\n \"\n (click)=\"onListItemRowClick(item, $event)\">\n @if (showCheckbox()) {\n <!-- `inert` keeps the native input out of the tab order and out of hit-testing:\n keyboard navigation belongs to ngListbox/ngOption (roving tabindex), the\n selection is already exposed by role=\"option\" + aria-selected, and the click\n must reach the <a> above. Same treatment as the tree.\n --spacing-xs: 0 kills the gap the checkbox keeps between its box and its\n (here empty) text block. -->\n <span\n inert\n aria-hidden=\"true\"\n class=\"inline-flex shrink-0\"\n style=\"--spacing-xs: 0\">\n <checkbox\n [checked]=\"item.$selected ?? false\"\n [disabled]=\"item.count === 0\"\n [class]=\"'pointer-events-none'\" />\n </span>\n }\n @if (resolvedItemTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{\n $implicit: item,\n field: field,\n selected: item.$selected ?? false,\n count: item.count,\n name: (listItemName(item) | syslang) ?? '',\n searchText: searchText()\n }\" />\n } @else {\n @let icon = $any(item).icon;\n @if (icon) {\n <FaIcon\n [faClass]=\"icon\"\n class=\"shrink-0 self-center justify-self-center\" />\n }\n @let appliesOnLabelClick = quickFilter() || applyOnSelect();\n <span\n [class]=\"\n cn(\n 'line-clamp-1 cursor-pointer break-all text-ellipsis select-none',\n appliesOnLabelClick && 'hover:underline'\n )\n \"\n [title]=\"\n appliesOnLabelClick\n ? ((isListItemFiltered(item, field)\n ? 'filters.removeFilter'\n : 'filters.addFilter'\n ) | transloco) +\n ': ' +\n (listItemName(item) | syslang)\n : (listItemName(item) | syslang)\n \"\n (click)=\"onListItemTextClick(item, $event)\">\n @for (\n chunk of (listItemName(item) | syslang) ?? \"\"\n | highlightWord: searchText() : 10;\n track $index\n ) {\n <span [class]=\"{ 'font-bold': chunk.match }\" aria-hidden=\"true\">{{\n chunk.text\n }}</span>\n }\n </span>\n @if (showCount() && item.count > 0) {\n <span class=\"ml-auto px-1 text-xs empty:hidden\" aria-hidden=\"true\">{{\n item.count\n }}</span>\n }\n }\n </a>\n</ng-template>\n"
26095
26256
  }]
26096
26257
  }],
26097
26258
  ctorParameters: () => [],
@@ -26538,7 +26699,7 @@ var AggregationComponent = class AggregationComponent {
26538
26699
  selectionChange: "selectionChange",
26539
26700
  filtersChange: "filtersChange"
26540
26701
  },
26541
- host: { properties: { "class": "cn(\"block h-[inherit] max-h-[inherit]\",class(), (isMissing() || (hideWhenEmpty() && isEmpty())) && \"hidden\")" } },
26702
+ host: { properties: { "class": "cn(\"flex h-[inherit] max-h-[inherit] flex-col\",class(), (isMissing() || (hideWhenEmpty() && isEmpty())) && \"hidden\")" } },
26542
26703
  queries: [{
26543
26704
  propertyName: "itemDirective",
26544
26705
  first: true,
@@ -26574,6 +26735,7 @@ var AggregationComponent = class AggregationComponent {
26574
26735
  @switch (resolvedType()) {
26575
26736
  @case ("date") {
26576
26737
  <AggregationDate
26738
+ class="min-h-0 flex-1"
26577
26739
  [id]="id()"
26578
26740
  [name]="name()"
26579
26741
  [column]="column()"
@@ -26595,6 +26757,7 @@ var AggregationComponent = class AggregationComponent {
26595
26757
  }
26596
26758
  @case ("tree") {
26597
26759
  <AggregationTree
26760
+ class="min-h-0 w-auto flex-1"
26598
26761
  [id]="id()"
26599
26762
  [name]="name()"
26600
26763
  [column]="column()"
@@ -26621,6 +26784,7 @@ var AggregationComponent = class AggregationComponent {
26621
26784
  }
26622
26785
  @default {
26623
26786
  <AggregationList
26787
+ class="min-h-0 flex-1"
26624
26788
  [id]="id()"
26625
26789
  [name]="name()"
26626
26790
  [column]="column()"
@@ -26720,6 +26884,7 @@ var AggregationComponent = class AggregationComponent {
26720
26884
  type: AggregationDateComponent,
26721
26885
  selector: "aggregation-date, AggregationDate, aggregationdate",
26722
26886
  inputs: [
26887
+ "class",
26723
26888
  "name",
26724
26889
  "column",
26725
26890
  "id",
@@ -26767,6 +26932,7 @@ i0.ɵɵngDeclareClassMetadata({
26767
26932
  @switch (resolvedType()) {
26768
26933
  @case ("date") {
26769
26934
  <AggregationDate
26935
+ class="min-h-0 flex-1"
26770
26936
  [id]="id()"
26771
26937
  [name]="name()"
26772
26938
  [column]="column()"
@@ -26788,6 +26954,7 @@ i0.ɵɵngDeclareClassMetadata({
26788
26954
  }
26789
26955
  @case ("tree") {
26790
26956
  <AggregationTree
26957
+ class="min-h-0 w-auto flex-1"
26791
26958
  [id]="id()"
26792
26959
  [name]="name()"
26793
26960
  [column]="column()"
@@ -26814,6 +26981,7 @@ i0.ɵɵngDeclareClassMetadata({
26814
26981
  }
26815
26982
  @default {
26816
26983
  <AggregationList
26984
+ class="min-h-0 flex-1"
26817
26985
  [id]="id()"
26818
26986
  [name]="name()"
26819
26987
  [column]="column()"
@@ -26839,7 +27007,7 @@ i0.ɵɵngDeclareClassMetadata({
26839
27007
  }
26840
27008
  }
26841
27009
  `,
26842
- host: { "[class]": "cn(\"block h-[inherit] max-h-[inherit]\",class(), (isMissing() || (hideWhenEmpty() && isEmpty())) && \"hidden\")" }
27010
+ host: { "[class]": "cn(\"flex h-[inherit] max-h-[inherit] flex-col\",class(), (isMissing() || (hideWhenEmpty() && isEmpty())) && \"hidden\")" }
26843
27011
  }]
26844
27012
  }],
26845
27013
  propDecorators: {
@@ -27699,6 +27867,10 @@ var FilterButtonComponent = class FilterButtonComponent {
27699
27867
  ...ngDevMode ? { debugName: "expandedLevel" } : /* istanbul ignore next */ {},
27700
27868
  transform: numberAttribute
27701
27869
  });
27870
+ overflowed = input(false, {
27871
+ ...ngDevMode ? { debugName: "overflowed" } : /* istanbul ignore next */ {},
27872
+ transform: booleanAttribute
27873
+ });
27702
27874
  variant = signal("tertiary", ...ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []);
27703
27875
  filter = signal({
27704
27876
  name: "",
@@ -27713,6 +27885,7 @@ var FilterButtonComponent = class FilterButtonComponent {
27713
27885
  popoverId = `filter-popover-${popoverUid++}`;
27714
27886
  popoverRef = viewChild(PopoverDirective, ...ngDevMode ? [{ debugName: "popoverRef" }] : /* istanbul ignore next */ []);
27715
27887
  isOpen = computed(() => this.popoverRef()?.state() === "open", ...ngDevMode ? [{ debugName: "isOpen" }] : /* istanbul ignore next */ []);
27888
+ toolbar = inject(Toolbar, { optional: true });
27716
27889
  nativeElement = inject(ElementRef).nativeElement;
27717
27890
  aggregationsStore = inject(AggregationsStore);
27718
27891
  queryParamsStore = inject(QueryParamsStore);
@@ -27825,6 +27998,13 @@ var FilterButtonComponent = class FilterButtonComponent {
27825
27998
  isSignal: true,
27826
27999
  isRequired: false,
27827
28000
  transformFunction: null
28001
+ },
28002
+ overflowed: {
28003
+ classPropertyName: "overflowed",
28004
+ publicName: "overflowed",
28005
+ isSignal: true,
28006
+ isRequired: false,
28007
+ transformFunction: null
27828
28008
  }
27829
28009
  },
27830
28010
  host: {
@@ -27846,13 +28026,7 @@ var FilterButtonComponent = class FilterButtonComponent {
27846
28026
  }],
27847
28027
  ngImport: i0,
27848
28028
  template: `
27849
- <button
27850
- [variant]="variant()"
27851
- [attr.popovertarget]="popoverId"
27852
- [class]="isOpen() ? 'border border-(--stroke-neutral-dark)/50' : ''"
27853
- [attr.data-disabled]="filter().disabled"
27854
- [disabled]="filter().disabled || null"
27855
- >
28029
+ <ng-template #label>
27856
28030
  <!-- display the filter icon if set -->
27857
28031
  @let icon = filter().icon;
27858
28032
  @if (icon) {
@@ -27872,7 +28046,35 @@ var FilterButtonComponent = class FilterButtonComponent {
27872
28046
  @if (filter().count > 1) {
27873
28047
  <Badge variant="number" fill="secondary" scheme="sage" size="xs">+{{ filter().count - 1 }}</Badge>
27874
28048
  }
27875
- </button>
28049
+ </ng-template>
28050
+
28051
+ @if (toolbar) {
28052
+ <!-- Inside a toolbar the BUTTON is the roving widget, not the host: the host is an element with
28053
+ no role of its own, so making it the widget gave two tab stops — an inert host that the
28054
+ roving focused, then this button with its own stop. A native disabled attribute would also make a
28055
+ widget unfocusable, so the toolbar's own disabled input (soft: still focusable, aria-disabled)
28056
+ takes over and the popover trigger is simply not wired. The same disabled state removes a filter
28057
+ hidden by the overflow manager (display: none) from the roving: arrow keys must not spend
28058
+ presses on filters nobody can see. -->
28059
+ <button
28060
+ ngToolbarWidget
28061
+ [variant]="variant()"
28062
+ [attr.popovertarget]="filter().disabled ? null : popoverId"
28063
+ [class]="isOpen() ? 'border border-(--stroke-neutral-dark)/50' : ''"
28064
+ [attr.data-disabled]="filter().disabled"
28065
+ [disabled]="filter().disabled || overflowed()">
28066
+ <ng-container *ngTemplateOutlet="label" />
28067
+ </button>
28068
+ } @else {
28069
+ <button
28070
+ [variant]="variant()"
28071
+ [attr.popovertarget]="popoverId"
28072
+ [class]="isOpen() ? 'border border-(--stroke-neutral-dark)/50' : ''"
28073
+ [attr.data-disabled]="filter().disabled"
28074
+ [disabled]="filter().disabled || null">
28075
+ <ng-container *ngTemplateOutlet="label" />
28076
+ </button>
28077
+ }
27876
28078
 
27877
28079
  <!-- Native HTML Popover (top-layer). The popover content stays a DOM
27878
28080
  descendant of the surrounding @angular/aria toolbar, so pointer/click
@@ -27886,8 +28088,10 @@ var FilterButtonComponent = class FilterButtonComponent {
27886
28088
  trigger-width matching so the panel sizes to its content. overflow-hidden
27887
28089
  resets the browser's default overflow:auto on [popover] elements, which
27888
28090
  would otherwise scroll on its own in addition to Aggregation's own
27889
- scroll-height-capped list (a double scrollbar) once its fit-content
27890
- height would otherwise exceed the viewport. -->
28091
+ facet list (a double scrollbar) once its fit-content height would
28092
+ otherwise exceed the viewport. The facet list is the only thing that
28093
+ scrolls: the facet itself is capped by the room the popover has (see
28094
+ below). -->
27891
28095
  <div
27892
28096
  popover
27893
28097
  [id]="popoverId"
@@ -27905,21 +28109,23 @@ var FilterButtonComponent = class FilterButtonComponent {
27905
28109
  rings (2px outlines) of the facet header and its buttons sit flush against the wrapper's edges —
27906
28110
  they were cut off on three sides. The 4px of padding gives them room, the matching negative
27907
28111
  margin leaves the layout unchanged. Width and max-height grow by the 0.5rem of padding
27908
- (15.5rem = the former w-60 + 0.5rem, 1rem = the former 1.5rem - 0.5rem) because box-sizing is
27909
- border-box here: the facet keeps its 15rem and the popover its size. -->
27910
- <!-- Real max-height + overflow-y-auto on a plain wrapper, kept off <Aggregation> itself:
27911
- size() (added to injectPopover) exposes the actual room available in the chosen
27912
- direction as the --popover-available-height CSS var. flip()/shift() alone only
27913
- reposition the popover, they never shrink it, so a facet with many values could
27914
- otherwise overflow past the viewport with nothing to scroll it back into view. The
27915
- 1.5rem subtracted is this popover's own vertical padding (p-3), which eats into that
27916
- same available space. Same pattern as more-button.component.ts's own wrapper. -->
27917
- <div class="-m-1 max-h-[calc(var(--popover-available-height,55vh)_-_1rem)] w-[15.5rem] overflow-y-auto overscroll-contain p-1">
28112
+ (15.5rem = the former w-60 + 0.5rem) because box-sizing is border-box here: the facet keeps
28113
+ its 15rem and the popover its size. The wrapper no longer scrolls (and no longer clips the
28114
+ rings): the facet's own list is the one scrollbar. -->
28115
+ <!-- The facet is capped by the smaller of two heights: its comfortable size (19 lines of list +
28116
+ 7rem of header, search box and paddings), and the room the popover really has.
28117
+ size() (added to injectPopover) exposes that room, in the chosen direction, as the
28118
+ --popover-available-height CSS var — flip()/shift() alone only reposition the popover, they
28119
+ never shrink it. The 1.5rem subtracted is this popover's own vertical padding (p-3), which
28120
+ eats into that same available space (this wrapper's p-1 / -m-1 cancel out). The facet is a
28121
+ flex column whose list is min-h-0 flex-1, so it is the list that shrinks and scrolls — a
28122
+ capped wrapper with overflow-y-auto around it would add a second, outer scrollbar. -->
28123
+ <div class="-m-1 w-[15.5rem] p-1">
27918
28124
  @let name = filter().name;
27919
28125
  @let column = filter().column;
27920
28126
  @if (name) {
27921
28127
  <Aggregation
27922
- class="block w-full [--scroll-height:19lh]"
28128
+ class="w-full max-h-[min(calc(19lh+7rem),calc(var(--popover-available-height,55vh)_-_1.5rem))]"
27923
28129
  id="filter-{{ filter().column }}"
27924
28130
  [name]="name"
27925
28131
  [column]="column"
@@ -27938,6 +28144,23 @@ var FilterButtonComponent = class FilterButtonComponent {
27938
28144
  `,
27939
28145
  isInline: true,
27940
28146
  dependencies: [
28147
+ {
28148
+ kind: "directive",
28149
+ type: NgTemplateOutlet,
28150
+ selector: "[ngTemplateOutlet]",
28151
+ inputs: [
28152
+ "ngTemplateOutletContext",
28153
+ "ngTemplateOutlet",
28154
+ "ngTemplateOutletInjector"
28155
+ ]
28156
+ },
28157
+ {
28158
+ kind: "directive",
28159
+ type: ToolbarWidget,
28160
+ selector: "[ngToolbarWidget]",
28161
+ inputs: ["id", "disabled"],
28162
+ exportAs: ["ngToolbarWidget"]
28163
+ },
27941
28164
  {
27942
28165
  kind: "directive",
27943
28166
  type: ButtonComponent,
@@ -28045,6 +28268,8 @@ i0.ɵɵngDeclareClassMetadata({
28045
28268
  args: [{
28046
28269
  selector: "filter-button, FilterButton",
28047
28270
  imports: [
28271
+ NgTemplateOutlet,
28272
+ ToolbarWidget,
28048
28273
  ButtonComponent,
28049
28274
  PopoverDirective,
28050
28275
  OperatorPipe,
@@ -28055,13 +28280,7 @@ i0.ɵɵngDeclareClassMetadata({
28055
28280
  FaIconComponent
28056
28281
  ],
28057
28282
  template: `
28058
- <button
28059
- [variant]="variant()"
28060
- [attr.popovertarget]="popoverId"
28061
- [class]="isOpen() ? 'border border-(--stroke-neutral-dark)/50' : ''"
28062
- [attr.data-disabled]="filter().disabled"
28063
- [disabled]="filter().disabled || null"
28064
- >
28283
+ <ng-template #label>
28065
28284
  <!-- display the filter icon if set -->
28066
28285
  @let icon = filter().icon;
28067
28286
  @if (icon) {
@@ -28081,7 +28300,35 @@ i0.ɵɵngDeclareClassMetadata({
28081
28300
  @if (filter().count > 1) {
28082
28301
  <Badge variant="number" fill="secondary" scheme="sage" size="xs">+{{ filter().count - 1 }}</Badge>
28083
28302
  }
28084
- </button>
28303
+ </ng-template>
28304
+
28305
+ @if (toolbar) {
28306
+ <!-- Inside a toolbar the BUTTON is the roving widget, not the host: the host is an element with
28307
+ no role of its own, so making it the widget gave two tab stops — an inert host that the
28308
+ roving focused, then this button with its own stop. A native disabled attribute would also make a
28309
+ widget unfocusable, so the toolbar's own disabled input (soft: still focusable, aria-disabled)
28310
+ takes over and the popover trigger is simply not wired. The same disabled state removes a filter
28311
+ hidden by the overflow manager (display: none) from the roving: arrow keys must not spend
28312
+ presses on filters nobody can see. -->
28313
+ <button
28314
+ ngToolbarWidget
28315
+ [variant]="variant()"
28316
+ [attr.popovertarget]="filter().disabled ? null : popoverId"
28317
+ [class]="isOpen() ? 'border border-(--stroke-neutral-dark)/50' : ''"
28318
+ [attr.data-disabled]="filter().disabled"
28319
+ [disabled]="filter().disabled || overflowed()">
28320
+ <ng-container *ngTemplateOutlet="label" />
28321
+ </button>
28322
+ } @else {
28323
+ <button
28324
+ [variant]="variant()"
28325
+ [attr.popovertarget]="popoverId"
28326
+ [class]="isOpen() ? 'border border-(--stroke-neutral-dark)/50' : ''"
28327
+ [attr.data-disabled]="filter().disabled"
28328
+ [disabled]="filter().disabled || null">
28329
+ <ng-container *ngTemplateOutlet="label" />
28330
+ </button>
28331
+ }
28085
28332
 
28086
28333
  <!-- Native HTML Popover (top-layer). The popover content stays a DOM
28087
28334
  descendant of the surrounding @angular/aria toolbar, so pointer/click
@@ -28095,8 +28342,10 @@ i0.ɵɵngDeclareClassMetadata({
28095
28342
  trigger-width matching so the panel sizes to its content. overflow-hidden
28096
28343
  resets the browser's default overflow:auto on [popover] elements, which
28097
28344
  would otherwise scroll on its own in addition to Aggregation's own
28098
- scroll-height-capped list (a double scrollbar) once its fit-content
28099
- height would otherwise exceed the viewport. -->
28345
+ facet list (a double scrollbar) once its fit-content height would
28346
+ otherwise exceed the viewport. The facet list is the only thing that
28347
+ scrolls: the facet itself is capped by the room the popover has (see
28348
+ below). -->
28100
28349
  <div
28101
28350
  popover
28102
28351
  [id]="popoverId"
@@ -28114,21 +28363,23 @@ i0.ɵɵngDeclareClassMetadata({
28114
28363
  rings (2px outlines) of the facet header and its buttons sit flush against the wrapper's edges —
28115
28364
  they were cut off on three sides. The 4px of padding gives them room, the matching negative
28116
28365
  margin leaves the layout unchanged. Width and max-height grow by the 0.5rem of padding
28117
- (15.5rem = the former w-60 + 0.5rem, 1rem = the former 1.5rem - 0.5rem) because box-sizing is
28118
- border-box here: the facet keeps its 15rem and the popover its size. -->
28119
- <!-- Real max-height + overflow-y-auto on a plain wrapper, kept off <Aggregation> itself:
28120
- size() (added to injectPopover) exposes the actual room available in the chosen
28121
- direction as the --popover-available-height CSS var. flip()/shift() alone only
28122
- reposition the popover, they never shrink it, so a facet with many values could
28123
- otherwise overflow past the viewport with nothing to scroll it back into view. The
28124
- 1.5rem subtracted is this popover's own vertical padding (p-3), which eats into that
28125
- same available space. Same pattern as more-button.component.ts's own wrapper. -->
28126
- <div class="-m-1 max-h-[calc(var(--popover-available-height,55vh)_-_1rem)] w-[15.5rem] overflow-y-auto overscroll-contain p-1">
28366
+ (15.5rem = the former w-60 + 0.5rem) because box-sizing is border-box here: the facet keeps
28367
+ its 15rem and the popover its size. The wrapper no longer scrolls (and no longer clips the
28368
+ rings): the facet's own list is the one scrollbar. -->
28369
+ <!-- The facet is capped by the smaller of two heights: its comfortable size (19 lines of list +
28370
+ 7rem of header, search box and paddings), and the room the popover really has.
28371
+ size() (added to injectPopover) exposes that room, in the chosen direction, as the
28372
+ --popover-available-height CSS var — flip()/shift() alone only reposition the popover, they
28373
+ never shrink it. The 1.5rem subtracted is this popover's own vertical padding (p-3), which
28374
+ eats into that same available space (this wrapper's p-1 / -m-1 cancel out). The facet is a
28375
+ flex column whose list is min-h-0 flex-1, so it is the list that shrinks and scrolls — a
28376
+ capped wrapper with overflow-y-auto around it would add a second, outer scrollbar. -->
28377
+ <div class="-m-1 w-[15.5rem] p-1">
28127
28378
  @let name = filter().name;
28128
28379
  @let column = filter().column;
28129
28380
  @if (name) {
28130
28381
  <Aggregation
28131
- class="block w-full [--scroll-height:19lh]"
28382
+ class="w-full max-h-[min(calc(19lh+7rem),calc(var(--popover-available-height,55vh)_-_1.5rem))]"
28132
28383
  id="filter-{{ filter().column }}"
28133
28384
  [name]="name"
28134
28385
  [column]="column"
@@ -28205,6 +28456,14 @@ i0.ɵɵngDeclareClassMetadata({
28205
28456
  required: false
28206
28457
  }]
28207
28458
  }],
28459
+ overflowed: [{
28460
+ type: i0.Input,
28461
+ args: [{
28462
+ isSignal: true,
28463
+ alias: "overflowed",
28464
+ required: false
28465
+ }]
28466
+ }],
28208
28467
  popoverRef: [{
28209
28468
  type: i0.ViewChild,
28210
28469
  args: [i0.forwardRef(() => PopoverDirective), { isSignal: true }]
@@ -28328,7 +28587,7 @@ var MoreComponent = class MoreComponent {
28328
28587
  <div role="group" [attr.aria-label]="'filters.moreFilters' | transloco" class="divide-y divide-(--font-neutral-muted)/18 pt-1 pb-1">
28329
28588
  @for (filter of filteredFilters(); track filter.name) {
28330
28589
  <Aggregation
28331
- class="w-full px-1 [--scroll-height:15lh]"
28590
+ class="w-full px-1 max-h-[calc(15lh+7rem)]"
28332
28591
  id="more-filters"
28333
28592
  [attr.title]="'filters.openFilter' | transloco: { filter: filter.name }"
28334
28593
  showFiltersCount
@@ -28431,7 +28690,7 @@ i0.ɵɵngDeclareClassMetadata({
28431
28690
  <div role="group" [attr.aria-label]="'filters.moreFilters' | transloco" class="divide-y divide-(--font-neutral-muted)/18 pt-1 pb-1">
28432
28691
  @for (filter of filteredFilters(); track filter.name) {
28433
28692
  <Aggregation
28434
- class="w-full px-1 [--scroll-height:15lh]"
28693
+ class="w-full px-1 max-h-[calc(15lh+7rem)]"
28435
28694
  id="more-filters"
28436
28695
  [attr.title]="'filters.openFilter' | transloco: { filter: filter.name }"
28437
28696
  showFiltersCount
@@ -28519,6 +28778,15 @@ var MoreButtonComponent = class MoreButtonComponent {
28519
28778
  nativeElement = inject(ElementRef).nativeElement;
28520
28779
  breakpointObserver = inject(BreakpointObserverService);
28521
28780
  aggregationsService = inject(AggregationsService);
28781
+ toolbar = inject(Toolbar, { optional: true });
28782
+ disabled = input(false, {
28783
+ ...ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {},
28784
+ transform: booleanAttribute
28785
+ });
28786
+ triggerClass = "gap-1 truncate font-semibold";
28787
+ openTriggerClass = "gap-1 truncate font-semibold border border-(--stroke-neutral-dark)/18";
28788
+ openSheet = () => this.sheetRef()?.open();
28789
+ openDialog = () => this.dialogRef()?.showModal();
28522
28790
  count = input(2, {
28523
28791
  ...ngDevMode ? { debugName: "count" } : /* istanbul ignore next */ {},
28524
28792
  transform: numberAttribute
@@ -28572,6 +28840,13 @@ var MoreButtonComponent = class MoreButtonComponent {
28572
28840
  isStandalone: true,
28573
28841
  selector: "more-button, MoreButton",
28574
28842
  inputs: {
28843
+ disabled: {
28844
+ classPropertyName: "disabled",
28845
+ publicName: "disabled",
28846
+ isSignal: true,
28847
+ isRequired: false,
28848
+ transformFunction: null
28849
+ },
28575
28850
  count: {
28576
28851
  classPropertyName: "count",
28577
28852
  publicName: "count",
@@ -28673,10 +28948,24 @@ var MoreButtonComponent = class MoreButtonComponent {
28673
28948
  }
28674
28949
  </ng-template>
28675
28950
 
28951
+ <!-- The trigger button, shared by the three variants below. Inside a toolbar the BUTTON is the
28952
+ roving widget and not the host: the host is an element with no role of its own and no key
28953
+ handler, so an arrow key landed on a dead element, then the button was a second tab stop.
28954
+ Outside a toolbar it is a plain button. -->
28955
+ <ng-template #button let-onClick="onClick" let-popoverTarget="popoverTarget" let-cls="cls">
28956
+ @if (toolbar) {
28957
+ <button ngToolbarWidget variant="tertiary" [attr.popovertarget]="popoverTarget" [class]="cls" aria-label="more filters" [disabled]="disabled()" (click)="onClick?.()">
28958
+ <ng-container *ngTemplateOutlet="trigger" />
28959
+ </button>
28960
+ } @else {
28961
+ <button variant="tertiary" [attr.popovertarget]="popoverTarget" [class]="cls" aria-label="more filters" [disabled]="disabled() || null" (click)="onClick?.()">
28962
+ <ng-container *ngTemplateOutlet="trigger" />
28963
+ </button>
28964
+ }
28965
+ </ng-template>
28966
+
28676
28967
  @if (breakpointObserver.isMobile()) {
28677
- <button variant="tertiary" class="gap-1 truncate font-semibold" aria-label="more filters" (click)="sheetRef()?.open()">
28678
- <ng-container *ngTemplateOutlet="trigger" />
28679
- </button>
28968
+ <ng-container *ngTemplateOutlet="button; context: { onClick: openSheet, cls: triggerClass }" />
28680
28969
 
28681
28970
  <Sheet side="bottom" size="lg">
28682
28971
  <SheetHeader [closeLabel]="'filters.close' | transloco">
@@ -28696,9 +28985,7 @@ var MoreButtonComponent = class MoreButtonComponent {
28696
28985
  </SheetBody>
28697
28986
  </Sheet>
28698
28987
  } @else if (isEnriched()) {
28699
- <button variant="tertiary" class="gap-1 truncate font-semibold" aria-label="more filters" (click)="dialogRef()?.showModal()">
28700
- <ng-container *ngTemplateOutlet="trigger" />
28701
- </button>
28988
+ <ng-container *ngTemplateOutlet="button; context: { onClick: openDialog, cls: triggerClass }" />
28702
28989
 
28703
28990
  <dialog>
28704
28991
  <DialogContent size="md">
@@ -28718,13 +29005,7 @@ var MoreButtonComponent = class MoreButtonComponent {
28718
29005
  </DialogContent>
28719
29006
  </dialog>
28720
29007
  } @else {
28721
- <button
28722
- variant="tertiary"
28723
- [attr.popovertarget]="popoverId"
28724
- [class]="isOpen() ? 'gap-1 truncate font-semibold border border-(--stroke-neutral-dark)/18' : 'gap-1 truncate font-semibold'"
28725
- aria-label="more filters">
28726
- <ng-container *ngTemplateOutlet="trigger" />
28727
- </button>
29008
+ <ng-container *ngTemplateOutlet="button; context: { popoverTarget: popoverId, cls: isOpen() ? openTriggerClass : triggerClass }" />
28728
29009
 
28729
29010
  <!-- Native HTML Popover (top-layer). Fully isolate pointer/click events from
28730
29011
  the surrounding @angular/aria toolbar (see FilterButton for the rationale:
@@ -28774,6 +29055,13 @@ var MoreButtonComponent = class MoreButtonComponent {
28774
29055
  "ngTemplateOutletInjector"
28775
29056
  ]
28776
29057
  },
29058
+ {
29059
+ kind: "directive",
29060
+ type: ToolbarWidget,
29061
+ selector: "[ngToolbarWidget]",
29062
+ inputs: ["id", "disabled"],
29063
+ exportAs: ["ngToolbarWidget"]
29064
+ },
28777
29065
  {
28778
29066
  kind: "directive",
28779
29067
  type: ButtonComponent,
@@ -28919,6 +29207,7 @@ i0.ɵɵngDeclareClassMetadata({
28919
29207
  selector: "more-button, MoreButton",
28920
29208
  imports: [
28921
29209
  NgTemplateOutlet,
29210
+ ToolbarWidget,
28922
29211
  ButtonComponent,
28923
29212
  PopoverDirective,
28924
29213
  TranslocoPipe,
@@ -28946,10 +29235,24 @@ i0.ɵɵngDeclareClassMetadata({
28946
29235
  }
28947
29236
  </ng-template>
28948
29237
 
29238
+ <!-- The trigger button, shared by the three variants below. Inside a toolbar the BUTTON is the
29239
+ roving widget and not the host: the host is an element with no role of its own and no key
29240
+ handler, so an arrow key landed on a dead element, then the button was a second tab stop.
29241
+ Outside a toolbar it is a plain button. -->
29242
+ <ng-template #button let-onClick="onClick" let-popoverTarget="popoverTarget" let-cls="cls">
29243
+ @if (toolbar) {
29244
+ <button ngToolbarWidget variant="tertiary" [attr.popovertarget]="popoverTarget" [class]="cls" aria-label="more filters" [disabled]="disabled()" (click)="onClick?.()">
29245
+ <ng-container *ngTemplateOutlet="trigger" />
29246
+ </button>
29247
+ } @else {
29248
+ <button variant="tertiary" [attr.popovertarget]="popoverTarget" [class]="cls" aria-label="more filters" [disabled]="disabled() || null" (click)="onClick?.()">
29249
+ <ng-container *ngTemplateOutlet="trigger" />
29250
+ </button>
29251
+ }
29252
+ </ng-template>
29253
+
28949
29254
  @if (breakpointObserver.isMobile()) {
28950
- <button variant="tertiary" class="gap-1 truncate font-semibold" aria-label="more filters" (click)="sheetRef()?.open()">
28951
- <ng-container *ngTemplateOutlet="trigger" />
28952
- </button>
29255
+ <ng-container *ngTemplateOutlet="button; context: { onClick: openSheet, cls: triggerClass }" />
28953
29256
 
28954
29257
  <Sheet side="bottom" size="lg">
28955
29258
  <SheetHeader [closeLabel]="'filters.close' | transloco">
@@ -28969,9 +29272,7 @@ i0.ɵɵngDeclareClassMetadata({
28969
29272
  </SheetBody>
28970
29273
  </Sheet>
28971
29274
  } @else if (isEnriched()) {
28972
- <button variant="tertiary" class="gap-1 truncate font-semibold" aria-label="more filters" (click)="dialogRef()?.showModal()">
28973
- <ng-container *ngTemplateOutlet="trigger" />
28974
- </button>
29275
+ <ng-container *ngTemplateOutlet="button; context: { onClick: openDialog, cls: triggerClass }" />
28975
29276
 
28976
29277
  <dialog>
28977
29278
  <DialogContent size="md">
@@ -28991,13 +29292,7 @@ i0.ɵɵngDeclareClassMetadata({
28991
29292
  </DialogContent>
28992
29293
  </dialog>
28993
29294
  } @else {
28994
- <button
28995
- variant="tertiary"
28996
- [attr.popovertarget]="popoverId"
28997
- [class]="isOpen() ? 'gap-1 truncate font-semibold border border-(--stroke-neutral-dark)/18' : 'gap-1 truncate font-semibold'"
28998
- aria-label="more filters">
28999
- <ng-container *ngTemplateOutlet="trigger" />
29000
- </button>
29295
+ <ng-container *ngTemplateOutlet="button; context: { popoverTarget: popoverId, cls: isOpen() ? openTriggerClass : triggerClass }" />
29001
29296
 
29002
29297
  <!-- Native HTML Popover (top-layer). Fully isolate pointer/click events from
29003
29298
  the surrounding @angular/aria toolbar (see FilterButton for the rationale:
@@ -29039,6 +29334,14 @@ i0.ɵɵngDeclareClassMetadata({
29039
29334
  }]
29040
29335
  }],
29041
29336
  propDecorators: {
29337
+ disabled: [{
29338
+ type: i0.Input,
29339
+ args: [{
29340
+ isSignal: true,
29341
+ alias: "disabled",
29342
+ required: false
29343
+ }]
29344
+ }],
29042
29345
  count: [{
29043
29346
  type: i0.Input,
29044
29347
  args: [{
@@ -29353,7 +29656,7 @@ var FiltersBarComponent = class FiltersBarComponent {
29353
29656
  tabindex with it: the keyboard stopped on an invisible strip of zero height, announced
29354
29657
  "Filter controls" and disabled at once. -->
29355
29658
  @if (hasFilters() || currentBasket() || hasAggregations()) {
29356
- <div ngToolbar [attr.aria-label]="ariaLabel() ?? ('filters.filterControls' | transloco)" overflowManager [direction]="direction()" [reserveStop]="hasCappedFilters()" (count)="adjustFiltersCount($event)" class="flex items-end gap-2 rounded-[inherit] bg-inherit">
29659
+ <div ngToolbar [softDisabled]="false" [attr.aria-label]="ariaLabel() ?? ('filters.filterControls' | transloco)" overflowManager [direction]="direction()" [reserveStop]="hasCappedFilters()" (count)="adjustFiltersCount($event)" class="flex items-end gap-2 rounded-[inherit] bg-inherit">
29357
29660
  @if (hasFilters()) {
29358
29661
  <button
29359
29662
  ngToolbarWidget
@@ -29391,10 +29694,13 @@ var FiltersBarComponent = class FiltersBarComponent {
29391
29694
  : 'flex-row'
29392
29695
  )">
29393
29696
 
29394
- @for (filter of authorizedFilters(); track filter.name) {
29697
+ @for (filter of authorizedFilters(); track filter.name; let i = $index) {
29698
+ <!-- No ngToolbarWidget here: FilterButton makes its own inner button the roving widget, so the
29699
+ bar has one tab stop per filter and not two (host, then button). A filter hidden by the
29700
+ overflow manager is disabled, hence skipped by the roving (the toolbar is not soft-disabled). -->
29395
29701
  <FilterButton
29396
- ngToolbarWidget
29397
29702
  overflowItem
29703
+ [overflowed]="i >= visibleFiltersCount()"
29398
29704
  [name]="filter.name"
29399
29705
  [column]="filter.column"
29400
29706
  [position]="position()"
@@ -29410,8 +29716,8 @@ var FiltersBarComponent = class FiltersBarComponent {
29410
29716
  forbids the attribute. The name belongs to the <button> inside MoreButton, which
29411
29717
  already carries it. -->
29412
29718
  <MoreButton
29413
- ngToolbarWidget
29414
29719
  overflowStop
29720
+ [disabled]="hasMoreFilters().length === 0"
29415
29721
  [class]="cn('shrink-0', hasMoreFilters().length === 0 && 'invisible pointer-events-none')"
29416
29722
  [count]="visibleFiltersCount()"
29417
29723
  [position]="morePosition()"
@@ -29444,6 +29750,7 @@ var FiltersBarComponent = class FiltersBarComponent {
29444
29750
  type: MoreButtonComponent,
29445
29751
  selector: "more-button, MoreButton",
29446
29752
  inputs: [
29753
+ "disabled",
29447
29754
  "count",
29448
29755
  "position",
29449
29756
  "lockPosition",
@@ -29465,7 +29772,8 @@ var FiltersBarComponent = class FiltersBarComponent {
29465
29772
  "position",
29466
29773
  "offset",
29467
29774
  "lockPosition",
29468
- "expandedLevel"
29775
+ "expandedLevel",
29776
+ "overflowed"
29469
29777
  ]
29470
29778
  },
29471
29779
  {
@@ -29557,7 +29865,7 @@ i0.ɵɵngDeclareClassMetadata({
29557
29865
  tabindex with it: the keyboard stopped on an invisible strip of zero height, announced
29558
29866
  "Filter controls" and disabled at once. -->
29559
29867
  @if (hasFilters() || currentBasket() || hasAggregations()) {
29560
- <div ngToolbar [attr.aria-label]="ariaLabel() ?? ('filters.filterControls' | transloco)" overflowManager [direction]="direction()" [reserveStop]="hasCappedFilters()" (count)="adjustFiltersCount($event)" class="flex items-end gap-2 rounded-[inherit] bg-inherit">
29868
+ <div ngToolbar [softDisabled]="false" [attr.aria-label]="ariaLabel() ?? ('filters.filterControls' | transloco)" overflowManager [direction]="direction()" [reserveStop]="hasCappedFilters()" (count)="adjustFiltersCount($event)" class="flex items-end gap-2 rounded-[inherit] bg-inherit">
29561
29869
  @if (hasFilters()) {
29562
29870
  <button
29563
29871
  ngToolbarWidget
@@ -29595,10 +29903,13 @@ i0.ɵɵngDeclareClassMetadata({
29595
29903
  : 'flex-row'
29596
29904
  )">
29597
29905
 
29598
- @for (filter of authorizedFilters(); track filter.name) {
29906
+ @for (filter of authorizedFilters(); track filter.name; let i = $index) {
29907
+ <!-- No ngToolbarWidget here: FilterButton makes its own inner button the roving widget, so the
29908
+ bar has one tab stop per filter and not two (host, then button). A filter hidden by the
29909
+ overflow manager is disabled, hence skipped by the roving (the toolbar is not soft-disabled). -->
29599
29910
  <FilterButton
29600
- ngToolbarWidget
29601
29911
  overflowItem
29912
+ [overflowed]="i >= visibleFiltersCount()"
29602
29913
  [name]="filter.name"
29603
29914
  [column]="filter.column"
29604
29915
  [position]="position()"
@@ -29614,8 +29925,8 @@ i0.ɵɵngDeclareClassMetadata({
29614
29925
  forbids the attribute. The name belongs to the <button> inside MoreButton, which
29615
29926
  already carries it. -->
29616
29927
  <MoreButton
29617
- ngToolbarWidget
29618
29928
  overflowStop
29929
+ [disabled]="hasMoreFilters().length === 0"
29619
29930
  [class]="cn('shrink-0', hasMoreFilters().length === 0 && 'invisible pointer-events-none')"
29620
29931
  [count]="visibleFiltersCount()"
29621
29932
  [position]="morePosition()"
@@ -31365,6 +31676,36 @@ i0.ɵɵngDeclareClassMetadata({
31365
31676
  }]
31366
31677
  }] }
31367
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;
31368
31709
  const sameKeys = (a, b) => a.length === b.length && a.every((key, index) => key === b[index]);
31369
31710
  const userProfileSchema = (editing) => schema((p) => {
31370
31711
  applyEach(p.data, (field) => readonly(field, { when: ({ key }) => `data.${key()}` !== editing() }));
@@ -31378,9 +31719,13 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31378
31719
  transloco = inject(TranslocoService);
31379
31720
  injector = inject(Injector);
31380
31721
  document = inject(DOCUMENT);
31722
+ destroyRef = inject(DestroyRef);
31381
31723
  labelClass = FORM_LABEL_CLASS;
31382
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)";
31383
31726
  statusMessage = signal("", ...ngDevMode ? [{ debugName: "statusMessage" }] : /* istanbul ignore next */ []);
31727
+ savedProperty = signal(void 0, ...ngDevMode ? [{ debugName: "savedProperty" }] : /* istanbul ignore next */ []);
31728
+ savedTimer;
31384
31729
  dataKeys = [
31385
31730
  "fullName",
31386
31731
  "mail",
@@ -31403,10 +31748,29 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31403
31748
  equal: sameKeys
31404
31749
  });
31405
31750
  allowProfilePhoto = computed(() => this.keys().some((key) => key === "profilePhoto"), ...ngDevMode ? [{ debugName: "allowProfilePhoto" }] : /* istanbul ignore next */ []);
31406
- 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), {
31407
31770
  ...ngDevMode ? { debugName: "customData" } : /* istanbul ignore next */ {},
31408
31771
  equal: sameKeys
31409
31772
  });
31773
+ customRowIndex = computed(() => new Map(this.customRows().map((row) => [row.key, row])), ...ngDevMode ? [{ debugName: "customRowIndex" }] : /* istanbul ignore next */ []);
31410
31774
  properties = computed(() => [...this.formKeys().map((key) => ({
31411
31775
  category: "data",
31412
31776
  key,
@@ -31416,6 +31780,13 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31416
31780
  key,
31417
31781
  translated: false
31418
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 */ []);
31419
31790
  initials = computed(() => this.principalStore.initials(), ...ngDevMode ? [{ debugName: "initials" }] : /* istanbul ignore next */ []);
31420
31791
  username = computed(() => this.principalStore.name() || this.principalStore.email() || null, ...ngDevMode ? [{ debugName: "username" }] : /* istanbul ignore next */ []);
31421
31792
  propertyToEdit = signal(void 0, ...ngDevMode ? [{ debugName: "propertyToEdit" }] : /* istanbul ignore next */ []);
@@ -31441,7 +31812,7 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31441
31812
  const profile = this.user();
31442
31813
  return {
31443
31814
  data: toRecord(this.formKeys(), profile?.data),
31444
- customData: toRecord(this.customData(), profile?.customData)
31815
+ customData: Object.fromEntries(this.customData().map((key) => [key, toFieldText(this.effectiveValue("customData", key))]))
31445
31816
  };
31446
31817
  }, ...ngDevMode ? [{ debugName: "profileModel" }] : /* istanbul ignore next */ []);
31447
31818
  profileForm = form(this.profileModel, userProfileSchema(this.propertyToEdit), {
@@ -31452,6 +31823,7 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31452
31823
  effect(() => {
31453
31824
  if (this.userProfileResource.error()?.status === 404) this.createUserProfile();
31454
31825
  });
31826
+ this.destroyRef.onDestroy(() => clearTimeout(this.savedTimer));
31455
31827
  }
31456
31828
  fieldId(category, key) {
31457
31829
  return `user-profile-${category}-${key.replace(/[^\w-]+/g, "-")}`;
@@ -31480,6 +31852,33 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31480
31852
  getDataValue(category, key) {
31481
31853
  return this.user()?.[category]?.[key];
31482
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
+ }
31483
31882
  onEdit(category, key) {
31484
31883
  this.propertyToEdit.set(`${category}.${key}`);
31485
31884
  this.fieldFor(category, key)?.().focusBoundControl();
@@ -31489,19 +31888,43 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31489
31888
  const field = event.target;
31490
31889
  field.setSelectionRange(field.value.length, field.value.length);
31491
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
+ }
31492
31897
  onCancelEdit(category, key) {
31493
31898
  if (!this.isEditing(category, key)) return;
31494
31899
  const state = this.fieldFor(category, key)?.();
31495
- state?.value.set(this.getDataValue(category, key) ?? "");
31900
+ state?.value.set(toFieldText(this.effectiveValue(category, key)));
31496
31901
  state?.reset();
31497
31902
  this.leaveEditMode(category, key);
31498
31903
  this.announce("editCancelled", category, key);
31499
31904
  }
31500
- propertyLabel(category, key) {
31501
- 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);
31502
31924
  }
31503
31925
  announce(kind, category, key) {
31504
- 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);
31505
31928
  }
31506
31929
  leaveEditMode(category, key) {
31507
31930
  this.propertyToEdit.set(void 0);
@@ -31535,6 +31958,7 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31535
31958
  }
31536
31959
  }
31537
31960
  async onDeleteData(category, key) {
31961
+ if (!this.hasStoredValue(category, key)) return;
31538
31962
  try {
31539
31963
  const response = await this.client.api.userProfile.deleteProperty(this.user(), category, key);
31540
31964
  if (!response) {
@@ -31542,14 +31966,46 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31542
31966
  return;
31543
31967
  }
31544
31968
  info("Deleted user profile property", response);
31545
- const userProfile = JSON.parse(JSON.stringify(this.user()));
31546
- userProfile[category][key] = "";
31547
- this.userProfileResource.set(userProfile);
31548
- this.announce("deleted", category, key);
31969
+ this.forgetProperty(category, key);
31549
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
+ }
31550
31979
  notify.error(err.message);
31551
31980
  }
31552
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
+ }
31553
32009
  async patchProperty(category, key, value) {
31554
32010
  const userProfile = JSON.parse(JSON.stringify(this.user()));
31555
32011
  userProfile[category] = {
@@ -31603,7 +32059,7 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31603
32059
  host: { classAttribute: "flex flex-col h-full overflow-hidden" },
31604
32060
  providers: [provideTranslocoScope("user-profile", "login")],
31605
32061
  ngImport: i0,
31606
- 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",
31607
32063
  dependencies: [
31608
32064
  {
31609
32065
  kind: "directive",
@@ -31696,24 +32152,75 @@ var UserProfileFormComponent = class UserProfileFormComponent {
31696
32152
  "size"
31697
32153
  ]
31698
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
+ },
31699
32206
  {
31700
32207
  kind: "directive",
31701
- type: TextareaComponent,
31702
- selector: "textarea-group, textareagroup, TextareaGroup",
32208
+ type: InputComponent,
32209
+ selector: "input-group, inputgroup, InputGroup",
31703
32210
  inputs: [
31704
32211
  "class",
31705
32212
  "size",
31706
32213
  "scheme",
31707
- "resize",
31708
32214
  "filled",
31709
- "minRows",
31710
- "maxRows"
32215
+ "numbers",
32216
+ "autoHeight",
32217
+ "textSize"
31711
32218
  ]
31712
32219
  },
31713
32220
  {
31714
32221
  kind: "directive",
31715
- type: TextareaControlDirective,
31716
- selector: "textarea[textarea-control], textarea[textareaControl]",
32222
+ type: InputControlDirective,
32223
+ selector: "input[input-control], input[inputControl]",
31717
32224
  inputs: ["class"]
31718
32225
  },
31719
32226
  {
@@ -31765,18 +32272,22 @@ i0.ɵɵngDeclareClassMetadata({
31765
32272
  UserIcon,
31766
32273
  AvatarImageComponent,
31767
32274
  ButtonComponent,
31768
- TextareaComponent,
31769
- TextareaControlDirective,
32275
+ CircleCheckIcon,
32276
+ LockIcon,
32277
+ SwitchComponent,
32278
+ TagInputComponent,
32279
+ InputComponent,
32280
+ InputControlDirective,
31770
32281
  ChangePasswordComponent
31771
32282
  ],
31772
32283
  providers: [provideTranslocoScope("user-profile", "login")],
31773
- 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"
31774
32285
  }]
31775
32286
  }],
31776
32287
  ctorParameters: () => []
31777
32288
  });
31778
32289
  function toRecord(keys, source) {
31779
- return Object.fromEntries(keys.map((key) => [key, String(source?.[key] ?? "")]));
32290
+ return Object.fromEntries(keys.map((key) => [key, toFieldText(source?.[key])]));
31780
32291
  }
31781
32292
  function splitPropertyId(id) {
31782
32293
  const separator = id.indexOf(".");
@@ -32418,8 +32929,10 @@ var SettingsDialogComponent = class SettingsDialogComponent {
32418
32929
  template: `
32419
32930
  @if (!duplicate) {
32420
32931
  <div dialog #dialog="dialog" [closeLabel]="'settings.close' | transloco" (closed)="call.end($event)">
32421
- <DialogContent size="xl" class="h-[min(48rem,calc(100dvh-2rem))] flex-row! items-stretch!">
32422
- <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)">
32423
32936
  <button
32424
32937
  type="button"
32425
32938
  variant="none"
@@ -32456,7 +32969,7 @@ var SettingsDialogComponent = class SettingsDialogComponent {
32456
32969
  </nav>
32457
32970
  </div>
32458
32971
 
32459
- <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">
32460
32973
  @switch (panel()) {
32461
32974
  @case ("account") {
32462
32975
  <settings-account-panel class="flex min-h-0 flex-1 flex-col" [focusOnOpen]="!navigated()" />
@@ -32529,6 +33042,12 @@ var SettingsDialogComponent = class SettingsDialogComponent {
32529
33042
  selector: "x-mark, x-mark-icon, XMark, XMarkIcon",
32530
33043
  inputs: ["class"]
32531
33044
  },
33045
+ {
33046
+ kind: "directive",
33047
+ type: SidebarInsetDirective,
33048
+ selector: "[sidebarInset]",
33049
+ inputs: ["class"]
33050
+ },
32532
33051
  {
32533
33052
  kind: "component",
32534
33053
  type: SettingsAccountPanelComponent,
@@ -32571,6 +33090,7 @@ i0.ɵɵngDeclareClassMetadata({
32571
33090
  PaletteIcon,
32572
33091
  KeyboardIcon,
32573
33092
  XMarkIcon,
33093
+ SidebarInsetDirective,
32574
33094
  SettingsAccountPanelComponent,
32575
33095
  SettingsAppearancePanelComponent,
32576
33096
  SettingsShortcutsPanelComponent
@@ -32579,8 +33099,10 @@ i0.ɵɵngDeclareClassMetadata({
32579
33099
  template: `
32580
33100
  @if (!duplicate) {
32581
33101
  <div dialog #dialog="dialog" [closeLabel]="'settings.close' | transloco" (closed)="call.end($event)">
32582
- <DialogContent size="xl" class="h-[min(48rem,calc(100dvh-2rem))] flex-row! items-stretch!">
32583
- <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)">
32584
33106
  <button
32585
33107
  type="button"
32586
33108
  variant="none"
@@ -32617,7 +33139,7 @@ i0.ɵɵngDeclareClassMetadata({
32617
33139
  </nav>
32618
33140
  </div>
32619
33141
 
32620
- <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">
32621
33143
  @switch (panel()) {
32622
33144
  @case ("account") {
32623
33145
  <settings-account-panel class="flex min-h-0 flex-1 flex-col" [focusOnOpen]="!navigated()" />
@@ -32975,10 +33497,12 @@ const SCOPE = "session-expiry";
32975
33497
  const SCOPE_ALIAS = "sessionExpiry";
32976
33498
  const FALLBACK = {
32977
33499
  expiringTitle: "Your session is about to expire",
32978
- expiringDescription: "It expires in about {{minutes}} min. Save your work or sign in again.",
33500
+ expiringDescription: "It expires in about {{minutes}} min. Extend your session to keep it open.",
32979
33501
  expiredTitle: "Your session has expired",
32980
33502
  expiredDescription: "Sign in again to continue.",
32981
- reload: "Sign in again"
33503
+ reload: "Sign in again",
33504
+ extend: "Extend session",
33505
+ extendedTitle: "Your session has been extended"
32982
33506
  };
32983
33507
  function provideSessionExpiry(options = {}) {
32984
33508
  const warnBeforeMs = options.warnBeforeMs ?? DEFAULT_WARN_BEFORE_MS;
@@ -33016,14 +33540,36 @@ function provideSessionExpiry(options = {}) {
33016
33540
  await settled;
33017
33541
  if (!stillDue()) return;
33018
33542
  const minutes = Math.max(1, Math.ceil((expiresAt - Date.now()) / 6e4));
33019
- const [title, description] = await Promise.all([translate("expiringTitle"), translate("expiringDescription", { minutes })]);
33543
+ const [title, description, extendLabel] = await Promise.all([
33544
+ translate("expiringTitle"),
33545
+ translate("expiringDescription", { minutes }),
33546
+ translate("extend")
33547
+ ]);
33020
33548
  if (!stillDue()) return;
33021
33549
  notify.warning(title, {
33022
33550
  description,
33023
33551
  closeButton: true,
33024
- important: true
33552
+ important: true,
33553
+ action: {
33554
+ label: extendLabel,
33555
+ onClick: () => void extend()
33556
+ }
33025
33557
  });
33026
33558
  };
33559
+ const extend = async () => {
33560
+ if (options.onExtend) return options.onExtend();
33561
+ const before = expiresAt;
33562
+ try {
33563
+ await client.api.principal.get();
33564
+ } catch {
33565
+ return;
33566
+ }
33567
+ if (before !== null && expiresAt !== null && expiresAt > before) {
33568
+ notify.success(await translate("extendedTitle"), { closeButton: true });
33569
+ return;
33570
+ }
33571
+ onReconnect();
33572
+ };
33027
33573
  const check = () => {
33028
33574
  if (expiresAt === null || warned) return;
33029
33575
  const delay = expiresAt - warnBeforeMs - Date.now();
@@ -33093,6 +33639,6 @@ function provideSessionExpiry(options = {}) {
33093
33639
  const queryNameResolver = () => {
33094
33640
  return inject(AppStore).getDefaultQuery()?.name || "";
33095
33641
  };
33096
- export { AGGREGATIONS_NAMES, AGGREGATIONS_NAMES_PRESET_DEFAULT, APP_FEATURES, ATOMIC_CLIENT, ATOMIC_SCOPED_PROVIDERS, AddToCollection, AdvancedFiltersBodyComponent, AdvancedFiltersComponent, AdvancedSearch, AdvancedSearchTabs, AggregationComponent, AggregationDateComponent, AggregationDateRange, AggregationItemDirective, AggregationListComponent, AggregationPanelComponent, AggregationTreeComponent, AggregationsService, AggregationsStore, Alert, AlertDialog, AlertForm, AlertsComponent, AppService, AppStore, ApplicationService, ApplicationStore, AppliedFiltersComponent, ArticleEntities, ArticleEntitiesTabPanel, ArticleExtracts, ArticleExtractsTabPanel, ArticleHighlights, ArticleLabels, ArticleLabelsTabPanel, ArticleMatchLocations, ArticleMatchLocationsTabPanel, ArticleSimilarDocuments, ArticleSimilarDocumentsTabPanel, AsideFiltersComponent, AuditFeedbackType, AuditService, AuthGuard, AuthPageComponent, AutocompleteService, BOOKMARKS_CONFIG, BOOKMARKS_OPTIONS, BackdropComponent, BackdropService, BookmarkButtonComponent, BookmarksComponent, COLLECTIONS_CONFIG, COLLECTIONS_OPTIONS, COMPONENTS_FOR_DOCUMENT_TYPE, ChangePasswordComponent, ChildMarkerDirective, CollectionsComponent, CollectionsDialog, ConverterSelectComponent, DeleteCollection, DeleteCollectionDialog, DidYouMeanComponent, DocumentLocatorComponent, EditLabels, ErrorComponent, Export, ExportDialog, ExportService, FILTERS_BREAKPOINT, FILTER_DATE_ALLOW_CUSTOM_RANGE, FORM_ALERT_CLASS, FORM_BARE_CLASS, FORM_ERROR_CLASS, FORM_FIELD_CLASS, FORM_FOOTER_CLASS, FORM_LABEL_CLASS, FORM_SURFACE_CLASS, FaIconComponent, FeatureFlags, FeatureFlagsDialogComponent, Feedback, FeedbackDialogComponent, FileSizePipe, FilterButtonComponent, FiltersBarComponent, FloatingToolbarComponent, ForgotPasswordComponent, HIGHLIGHTS, HeatmapComponent, HighlightWordPipe, InfinityScrollDirective, InlineWorker, JsonMethodPluginService, KeyboardShortcutsDialog, KeyboardShortcutsDialogComponent, KeyboardShortcutsListComponent, KeyboardShortcutsService, LabelService, LabelsEditDialog, ListPanelItemDirective, LoadingComponent, MarkdownPipe, MarkdownRendererService, MetadataComponent, MissingTermsComponent, MoreButtonComponent, MoreComponent, MultiSelectLabelsComponent, MultiSelectionToolbarComponent, NON_SEARCHABLE_COLUMNS, NON_SEARCHABLE_DEFAULTS, NavbarTabsComponent, NavigationService, NoResultComponent, OpenArticleOnCtrlEnterDirective, OperatorPipe, OverflowItemDirective, OverflowManagerDirective, OverflowStopDirective, OverrideUser, OverrideUserDialogComponent, PREVIEW_ASSISTANT_COMPONENT, PREVIEW_CONFIG, PagerComponent, PreviewActionsComponent, PreviewContentComponent, PreviewHeaderComponent, PreviewNavigator, PreviewService, PrincipalService, PrincipalStore, QueryParamsStore, QueryService, RECENT_SEARCHES_CONFIG, RECENT_SEARCHES_OPTIONS, ROUTE_COMPONENTS, RecentSearchesComponent, ResetUserSettings, ResetUserSettingsDialogComponent, SAVED_SEARCHES_CONFIG, SAVED_SEARCHES_OPTIONS, SavedSearch, SavedSearchDialog, SavedSearchPopover, SavedSearchesComponent, SavedSearchesService, SearchFeedbackComponent, SearchInputFooter, SearchService, SelectArticleDirective, SelectionService, SelectionStore, Settings, SettingsAccountPanelComponent, SettingsAppearancePanelComponent, SettingsDialogComponent, SettingsShortcutsPanelComponent, ShortcutDirective, ShowBookmarkDirective, SignInComponent, SignedOutComponent, SortSelectorComponent, SourceComponent, SourceIconPipe, SponsoredResultsComponent, SyslangPipe, THEMES, TagCloudComponent, TextChunkService, ThemeProviderDirective, ThemeSelectorComponent, ThemeStore, ThemeToggleComponent, TranslocoDateImpurePipe, UserOverrideService, UserProfileFormComponent, UserProfileService, UserSettingsPersistence, UserSettingsStore, WEIGHT_STEPS, WordCloudComponent, ZoomControlsComponent, __resetSignInMutexForTests, activateShortcutTarget, applyThemeToNativeElement, atomicClientProvider, auditInterceptorFn, authInterceptorFn, bodyInterceptorFn, bootstrapApp, buildHeatmap, buildQuery, buildTagCloud, candidatesFor, canonicalize, debouncedSignal, errorInterceptorFn, fieldErrorKey, fieldShowsError, filterKey, filterLeaves, filterValues, findAggregation, flagsFromWeekdays, focusWhenReady, galactikPrimitive, galactikSemantic, getCurrentPath, getCurrentQueryName, getQueryNameFromRoute, injectAdvancedFilters, injectAtomicClient, injectRouteNavigation, injectUserSettingsPatch, isAuthRoute, isSignInInFlight, itemLabel, layoutWordCloud, legacyFilterToFilter, makeWeightScale, multiSelectionToolbarVariants, paletteColor, processCssVars, provideAtomicClient, provideAtomicNotifications, provideAtomicScope, provideDocumentLang, provideKeyboardShortcuts, provideSessionExpiry, quantileScale, queryNameResolver, rebuildWithout, seriesPalette, signIn, themeColorNameToCssVariable, themeColorsToCssVariables, timelinePalette, toAriaKeyshortcuts, toDisplayParts, toPercentages, toSeries, toastInterceptorFn, weekdaysFromFlags, withAggregationsFeatures, withAlertsFeatures, withAlpha, withAppFeatures, withApplicationFeatures, withAssistantFeatures, withBasketsFeatures, withBookmarkFeatures, withBootstrapApp, withExtractsFeatures, withFetch, withMultiSelectionFeatures, withQueryParamsFeatures, withRecentSearchesFeatures, withSavedSearchesFeatures, withSelectionFeatures, withThemeBodyHook, withThemes, withThemesFeatures, withUserSettingsFeatures };
33642
+ export { AGGREGATIONS_NAMES, AGGREGATIONS_NAMES_PRESET_DEFAULT, APP_FEATURES, ATOMIC_CLIENT, ATOMIC_SCOPED_PROVIDERS, AddToCollection, AdvancedFiltersBodyComponent, AdvancedFiltersComponent, AdvancedSearch, AdvancedSearchTabs, AggregationComponent, AggregationDateComponent, AggregationDateRange, AggregationItemDirective, AggregationListComponent, AggregationPanelComponent, AggregationTreeComponent, AggregationsService, AggregationsStore, Alert, AlertDialog, AlertForm, AlertsComponent, AppService, AppStore, ApplicationService, ApplicationStore, AppliedFiltersComponent, ArticleEntities, ArticleEntitiesTabPanel, ArticleExtracts, ArticleExtractsTabPanel, ArticleHighlights, ArticleLabels, ArticleLabelsTabPanel, ArticleMatchLocations, ArticleMatchLocationsTabPanel, ArticleSimilarDocuments, ArticleSimilarDocumentsTabPanel, AsideFiltersComponent, AuditFeedbackType, AuditService, AuthGuard, AuthPageComponent, AutocompleteService, BOOKMARKS_CONFIG, BOOKMARKS_OPTIONS, BackdropComponent, BackdropService, BookmarkButtonComponent, BookmarksComponent, COLLECTIONS_CONFIG, COLLECTIONS_OPTIONS, COMPONENTS_FOR_DOCUMENT_TYPE, ChangePasswordComponent, ChildMarkerDirective, CollectionsComponent, CollectionsDialog, ConverterSelectComponent, DeleteCollection, DeleteCollectionDialog, DidYouMeanComponent, DocumentLocatorComponent, EditLabels, ErrorComponent, Export, ExportDialog, ExportService, FILTERS_BREAKPOINT, FILTER_DATE_ALLOW_CUSTOM_RANGE, FORM_ALERT_CLASS, FORM_BARE_CLASS, FORM_ERROR_CLASS, FORM_FIELD_CLASS, FORM_FOOTER_CLASS, FORM_LABEL_CLASS, FORM_SURFACE_CLASS, FaIconComponent, FeatureFlags, FeatureFlagsDialogComponent, Feedback, FeedbackDialogComponent, FileSizePipe, FilterButtonComponent, FiltersBarComponent, FloatingToolbarComponent, ForgotPasswordComponent, HIGHLIGHTS, HeatmapComponent, HighlightWordPipe, InfinityScrollDirective, InlineWorker, JsonMethodPluginService, KeyboardShortcutsDialog, KeyboardShortcutsDialogComponent, KeyboardShortcutsListComponent, KeyboardShortcutsService, LabelService, LabelsEditDialog, ListPanelItemDirective, LoadingComponent, MarkdownPipe, MarkdownRendererService, MetadataComponent, MissingTermsComponent, MoreButtonComponent, MoreComponent, MultiSelectLabelsComponent, MultiSelectionToolbarComponent, NON_SEARCHABLE_COLUMNS, NON_SEARCHABLE_DEFAULTS, NavbarTabsComponent, NavigationService, NoResultComponent, OpenArticleOnCtrlEnterDirective, OperatorPipe, OverflowItemDirective, OverflowManagerDirective, OverflowStopDirective, OverrideUser, OverrideUserDialogComponent, PREVIEW_ASSISTANT_COMPONENT, PREVIEW_CONFIG, PagerComponent, PreviewActionsComponent, PreviewContentComponent, PreviewHeaderComponent, PreviewNavigator, PreviewService, PrincipalService, PrincipalStore, QueryParamsStore, QueryService, RECENT_SEARCHES_CONFIG, RECENT_SEARCHES_OPTIONS, ROUTE_COMPONENTS, RecentSearchesComponent, ResetUserSettings, ResetUserSettingsDialogComponent, SAVED_SEARCHES_CONFIG, SAVED_SEARCHES_OPTIONS, STARTUP_DATA_SOURCE, SavedSearch, SavedSearchDialog, SavedSearchPopover, SavedSearchesComponent, SavedSearchesService, SearchFeedbackComponent, SearchInputFooter, SearchService, SelectArticleDirective, SelectionService, SelectionStore, Settings, SettingsAccountPanelComponent, SettingsAppearancePanelComponent, SettingsDialogComponent, SettingsShortcutsPanelComponent, ShortcutDirective, ShowBookmarkDirective, SignInComponent, SignedOutComponent, SortSelectorComponent, SourceComponent, SourceIconPipe, SponsoredResultsComponent, SyslangPipe, THEMES, TagCloudComponent, TextChunkService, ThemeProviderDirective, ThemeSelectorComponent, ThemeStore, ThemeToggleComponent, TranslocoDateImpurePipe, UserOverrideService, UserProfileFormComponent, UserProfileService, UserSettingsPersistence, UserSettingsStore, WEIGHT_STEPS, WordCloudComponent, ZoomControlsComponent, __resetSignInMutexForTests, activateShortcutTarget, applyThemeToNativeElement, atomicClientProvider, auditInterceptorFn, authInterceptorFn, bodyInterceptorFn, bootstrapApp, buildHeatmap, buildQuery, buildTagCloud, candidatesFor, canonicalize, debouncedSignal, errorInterceptorFn, fieldErrorKey, fieldShowsError, filterKey, filterLeaves, filterValues, findAggregation, flagsFromWeekdays, focusWhenReady, galactikPrimitive, galactikSemantic, getCurrentPath, getCurrentQueryName, getQueryNameFromRoute, injectAdvancedFilters, injectAtomicClient, injectRouteNavigation, injectUserSettingsPatch, isAuthRoute, isSignInInFlight, itemLabel, layoutWordCloud, legacyFilterToFilter, makeWeightScale, multiSelectionToolbarVariants, paletteColor, processCssVars, provideAtomicClient, provideAtomicNotifications, provideAtomicScope, provideDocumentLang, provideKeyboardShortcuts, provideSessionExpiry, provideStartupAggregation, quantileScale, queryNameResolver, rebuildWithout, seriesPalette, signIn, themeColorNameToCssVariable, themeColorsToCssVariables, timelinePalette, toAriaKeyshortcuts, toDisplayParts, toPercentages, toSeries, toastInterceptorFn, weekdaysFromFlags, withAggregationsFeatures, withAlertsFeatures, withAlpha, withAppFeatures, withApplicationFeatures, withAssistantFeatures, withBasketsFeatures, withBookmarkFeatures, withBootstrapApp, withExtractsFeatures, withFetch, withMultiSelectionFeatures, withQueryParamsFeatures, withRecentSearchesFeatures, withSavedSearchesFeatures, withSelectionFeatures, withThemeBodyHook, withThemes, withThemesFeatures, withUserSettingsFeatures };
33097
33643
 
33098
33644
  //# sourceMappingURL=sinequa-atomic-angular.mjs.map