mn-angular-lib 1.0.155 → 1.0.156

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.
@@ -11,6 +11,7 @@ import * as i1$1 from '@angular/forms';
11
11
  import { NgControl, Validators, FormsModule, NG_VALUE_ACCESSOR, FormBuilder, ReactiveFormsModule } from '@angular/forms';
12
12
  import JSON5 from 'json5';
13
13
  import { LucideFile, LucideImagePlus, LucideTrash2, LucideUpload, LucideX, LucideCalendarDays, LucideChevronLeft, LucideChevronRight, LucideChevronDown, LucideEllipsisVertical, LucideSearchX, LucideDynamicIcon, LucideArrowLeft, LucideArrowRight, LucideCheck, LucideInbox, LucideFilter, LucideFunnel, LucideTriangleAlert, LucideCircleAlert } from '@lucide/angular';
14
+ import { Router, ActivatedRoute } from '@angular/router';
14
15
  import { DomSanitizer } from '@angular/platform-browser';
15
16
 
16
17
  // projects/mn-angular-lib/src/lib/mn-mn-alert/mn-mn-alert.tokens.ts
@@ -5460,11 +5461,11 @@ class MnKeyboard {
5460
5461
  this.valueChange.emit(clamped);
5461
5462
  }
5462
5463
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnKeyboard, deps: [], target: i0.ɵɵFactoryTarget.Component });
5463
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnKeyboard, isStandalone: true, selector: "mn-keyboard", inputs: { value: "value", layout: "layout", presentation: "presentation", labels: "labels", uppercase: "uppercase", allowSpace: "allowSpace", showSubmit: "showSubmit", submitDisabled: "submitDisabled", maxLength: "maxLength", sheetMaxHeightVh: "sheetMaxHeightVh" }, outputs: { valueChange: "valueChange", submitted: "submitted", dismissed: "dismissed" }, host: { properties: { "class": "this.hostClasses" } }, ngImport: i0, template: "<!-- The key block. Rendered directly for `inline`, or projected into the sheet below. -->\n<ng-template #keys>\n <div [attr.aria-label]=\"labels.keyboard || null\" class=\"mn-keyboard-keys flex flex-col gap-2 p-3\" role=\"group\">\n @for (row of rows; track $index) {\n <div class=\"flex justify-center gap-2\">\n @for (key of row; track key) {\n <button\n (click)=\"press(key)\"\n class=\"mn-keyboard-key flex-1 rounded-lg bg-base-200 py-4 text-xl font-semibold text-base-content transition-colors hover:bg-base-300 active:bg-base-300\"\n type=\"button\"\n >\n {{ key }}\n </button>\n }\n </div>\n }\n\n <div class=\"flex justify-center gap-2\">\n <button\n (click)=\"backspace()\"\n [attr.aria-label]=\"labels.backspace\"\n class=\"mn-keyboard-key mn-keyboard-key-action flex-1 rounded-lg bg-base-200 py-4 text-base font-medium text-base-content transition-colors hover:bg-base-300\"\n type=\"button\"\n >\n {{ labels.backspace }}\n </button>\n\n @if (spaceVisible) {\n <button\n (click)=\"pressSpace()\"\n [attr.aria-label]=\"labels.space\"\n class=\"mn-keyboard-key mn-keyboard-key-space flex-1 rounded-lg bg-base-200 py-4 text-base font-medium text-base-content transition-colors hover:bg-base-300\"\n style=\"flex-grow: 3\"\n type=\"button\"\n >\n {{ labels.space }}\n </button>\n }\n\n <button\n (click)=\"clear()\"\n [attr.aria-label]=\"labels.clear\"\n class=\"mn-keyboard-key mn-keyboard-key-action flex-1 rounded-lg bg-base-200 py-4 text-base font-medium text-base-content transition-colors hover:bg-base-300\"\n type=\"button\"\n >\n {{ labels.clear }}\n </button>\n\n @if (showSubmit) {\n <button\n (click)=\"submit()\"\n [attr.aria-label]=\"labels.submit\"\n [disabled]=\"submitDisabled\"\n class=\"mn-keyboard-key mn-keyboard-key-submit flex-1 rounded-lg bg-primary py-4 text-base font-semibold text-primary-content transition-colors hover:opacity-90 disabled:opacity-50\"\n style=\"flex-grow: 2\"\n type=\"button\"\n >\n {{ labels.submit }}\n </button>\n }\n </div>\n </div>\n</ng-template>\n\n@if (presentation === 'sheet') {\n <mn-bottom-sheet\n (dismiss)=\"onDismiss()\"\n [ariaLabel]=\"labels.keyboard || undefined\"\n [maxHeightVh]=\"sheetMaxHeightVh\"\n >\n <ng-container [ngTemplateOutlet]=\"keys\"></ng-container>\n </mn-bottom-sheet>\n} @else {\n <ng-container [ngTemplateOutlet]=\"keys\"></ng-container>\n}\n", styles: [":host{display:block}.mn-keyboard-key{-webkit-tap-highlight-color:transparent;-webkit-user-select:none;user-select:none;touch-action:manipulation}\n"], dependencies: [{ kind: "component", type: MnBottomSheet, selector: "mn-bottom-sheet", inputs: ["showBackdrop", "showGrabber", "dismissible", "minHeightPx", "maxHeightVh", "containerClass", "ariaLabel", "ariaLabelledby", "growWithKeyboard", "dismissGuard"], outputs: ["dismiss"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
5464
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnKeyboard, isStandalone: true, selector: "mn-keyboard", inputs: { value: "value", layout: "layout", presentation: "presentation", labels: "labels", uppercase: "uppercase", allowSpace: "allowSpace", showSubmit: "showSubmit", submitDisabled: "submitDisabled", maxLength: "maxLength", sheetMaxHeightVh: "sheetMaxHeightVh" }, outputs: { valueChange: "valueChange", submitted: "submitted", dismissed: "dismissed" }, host: { properties: { "class": "this.hostClasses" } }, ngImport: i0, template: "<!-- The key block. Rendered directly for `inline`, or projected into the sheet below. -->\n<ng-template #keys>\n <div [attr.aria-label]=\"labels.keyboard || null\" class=\"mn-keyboard-keys flex flex-col gap-2 p-3\" role=\"group\">\n @for (row of rows; track $index) {\n <div class=\"flex justify-center gap-2\">\n @for (key of row; track key) {\n <button\n (click)=\"press(key)\"\n class=\"mn-keyboard-key flex-1 rounded-lg border border-base-300 bg-base-100 py-4 text-xl font-semibold text-base-content shadow-sm transition-colors hover:bg-base-200 active:bg-base-300\"\n type=\"button\"\n >\n {{ key }}\n </button>\n }\n </div>\n }\n\n <div class=\"flex justify-center gap-2\">\n <button\n (click)=\"backspace()\"\n [attr.aria-label]=\"labels.backspace\"\n class=\"mn-keyboard-key mn-keyboard-key-action flex-1 rounded-lg border border-base-300 bg-base-200 py-4 text-base font-medium text-base-content shadow-sm transition-colors hover:bg-base-300\"\n type=\"button\"\n >\n {{ labels.backspace }}\n </button>\n\n @if (spaceVisible) {\n <button\n (click)=\"pressSpace()\"\n [attr.aria-label]=\"labels.space\"\n class=\"mn-keyboard-key mn-keyboard-key-space flex-1 rounded-lg border border-base-300 bg-base-200 py-4 text-base font-medium text-base-content shadow-sm transition-colors hover:bg-base-300\"\n style=\"flex-grow: 3\"\n type=\"button\"\n >\n {{ labels.space }}\n </button>\n }\n\n <button\n (click)=\"clear()\"\n [attr.aria-label]=\"labels.clear\"\n class=\"mn-keyboard-key mn-keyboard-key-action flex-1 rounded-lg border border-base-300 bg-base-200 py-4 text-base font-medium text-base-content shadow-sm transition-colors hover:bg-base-300\"\n type=\"button\"\n >\n {{ labels.clear }}\n </button>\n\n @if (showSubmit) {\n <button\n (click)=\"submit()\"\n [attr.aria-label]=\"labels.submit\"\n [disabled]=\"submitDisabled\"\n class=\"mn-keyboard-key mn-keyboard-key-submit flex-1 rounded-lg bg-primary py-4 text-base font-semibold text-primary-content shadow-sm transition-colors hover:opacity-90 disabled:opacity-50\"\n style=\"flex-grow: 2\"\n type=\"button\"\n >\n {{ labels.submit }}\n </button>\n }\n </div>\n </div>\n</ng-template>\n\n@if (presentation === 'sheet') {\n <mn-bottom-sheet\n (dismiss)=\"onDismiss()\"\n [ariaLabel]=\"labels.keyboard || undefined\"\n [maxHeightVh]=\"sheetMaxHeightVh\"\n >\n <ng-container [ngTemplateOutlet]=\"keys\"></ng-container>\n </mn-bottom-sheet>\n} @else {\n <ng-container [ngTemplateOutlet]=\"keys\"></ng-container>\n}\n", styles: [":host{display:block}.mn-keyboard-key{-webkit-tap-highlight-color:transparent;-webkit-user-select:none;user-select:none;touch-action:manipulation}\n"], dependencies: [{ kind: "component", type: MnBottomSheet, selector: "mn-bottom-sheet", inputs: ["showBackdrop", "showGrabber", "dismissible", "minHeightPx", "maxHeightVh", "containerClass", "ariaLabel", "ariaLabelledby", "growWithKeyboard", "dismissGuard"], outputs: ["dismiss"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
5464
5465
  }
5465
5466
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnKeyboard, decorators: [{
5466
5467
  type: Component,
5467
- args: [{ selector: 'mn-keyboard', standalone: true, imports: [MnBottomSheet, NgTemplateOutlet], template: "<!-- The key block. Rendered directly for `inline`, or projected into the sheet below. -->\n<ng-template #keys>\n <div [attr.aria-label]=\"labels.keyboard || null\" class=\"mn-keyboard-keys flex flex-col gap-2 p-3\" role=\"group\">\n @for (row of rows; track $index) {\n <div class=\"flex justify-center gap-2\">\n @for (key of row; track key) {\n <button\n (click)=\"press(key)\"\n class=\"mn-keyboard-key flex-1 rounded-lg bg-base-200 py-4 text-xl font-semibold text-base-content transition-colors hover:bg-base-300 active:bg-base-300\"\n type=\"button\"\n >\n {{ key }}\n </button>\n }\n </div>\n }\n\n <div class=\"flex justify-center gap-2\">\n <button\n (click)=\"backspace()\"\n [attr.aria-label]=\"labels.backspace\"\n class=\"mn-keyboard-key mn-keyboard-key-action flex-1 rounded-lg bg-base-200 py-4 text-base font-medium text-base-content transition-colors hover:bg-base-300\"\n type=\"button\"\n >\n {{ labels.backspace }}\n </button>\n\n @if (spaceVisible) {\n <button\n (click)=\"pressSpace()\"\n [attr.aria-label]=\"labels.space\"\n class=\"mn-keyboard-key mn-keyboard-key-space flex-1 rounded-lg bg-base-200 py-4 text-base font-medium text-base-content transition-colors hover:bg-base-300\"\n style=\"flex-grow: 3\"\n type=\"button\"\n >\n {{ labels.space }}\n </button>\n }\n\n <button\n (click)=\"clear()\"\n [attr.aria-label]=\"labels.clear\"\n class=\"mn-keyboard-key mn-keyboard-key-action flex-1 rounded-lg bg-base-200 py-4 text-base font-medium text-base-content transition-colors hover:bg-base-300\"\n type=\"button\"\n >\n {{ labels.clear }}\n </button>\n\n @if (showSubmit) {\n <button\n (click)=\"submit()\"\n [attr.aria-label]=\"labels.submit\"\n [disabled]=\"submitDisabled\"\n class=\"mn-keyboard-key mn-keyboard-key-submit flex-1 rounded-lg bg-primary py-4 text-base font-semibold text-primary-content transition-colors hover:opacity-90 disabled:opacity-50\"\n style=\"flex-grow: 2\"\n type=\"button\"\n >\n {{ labels.submit }}\n </button>\n }\n </div>\n </div>\n</ng-template>\n\n@if (presentation === 'sheet') {\n <mn-bottom-sheet\n (dismiss)=\"onDismiss()\"\n [ariaLabel]=\"labels.keyboard || undefined\"\n [maxHeightVh]=\"sheetMaxHeightVh\"\n >\n <ng-container [ngTemplateOutlet]=\"keys\"></ng-container>\n </mn-bottom-sheet>\n} @else {\n <ng-container [ngTemplateOutlet]=\"keys\"></ng-container>\n}\n", styles: [":host{display:block}.mn-keyboard-key{-webkit-tap-highlight-color:transparent;-webkit-user-select:none;user-select:none;touch-action:manipulation}\n"] }]
5468
+ args: [{ selector: 'mn-keyboard', standalone: true, imports: [MnBottomSheet, NgTemplateOutlet], template: "<!-- The key block. Rendered directly for `inline`, or projected into the sheet below. -->\n<ng-template #keys>\n <div [attr.aria-label]=\"labels.keyboard || null\" class=\"mn-keyboard-keys flex flex-col gap-2 p-3\" role=\"group\">\n @for (row of rows; track $index) {\n <div class=\"flex justify-center gap-2\">\n @for (key of row; track key) {\n <button\n (click)=\"press(key)\"\n class=\"mn-keyboard-key flex-1 rounded-lg border border-base-300 bg-base-100 py-4 text-xl font-semibold text-base-content shadow-sm transition-colors hover:bg-base-200 active:bg-base-300\"\n type=\"button\"\n >\n {{ key }}\n </button>\n }\n </div>\n }\n\n <div class=\"flex justify-center gap-2\">\n <button\n (click)=\"backspace()\"\n [attr.aria-label]=\"labels.backspace\"\n class=\"mn-keyboard-key mn-keyboard-key-action flex-1 rounded-lg border border-base-300 bg-base-200 py-4 text-base font-medium text-base-content shadow-sm transition-colors hover:bg-base-300\"\n type=\"button\"\n >\n {{ labels.backspace }}\n </button>\n\n @if (spaceVisible) {\n <button\n (click)=\"pressSpace()\"\n [attr.aria-label]=\"labels.space\"\n class=\"mn-keyboard-key mn-keyboard-key-space flex-1 rounded-lg border border-base-300 bg-base-200 py-4 text-base font-medium text-base-content shadow-sm transition-colors hover:bg-base-300\"\n style=\"flex-grow: 3\"\n type=\"button\"\n >\n {{ labels.space }}\n </button>\n }\n\n <button\n (click)=\"clear()\"\n [attr.aria-label]=\"labels.clear\"\n class=\"mn-keyboard-key mn-keyboard-key-action flex-1 rounded-lg border border-base-300 bg-base-200 py-4 text-base font-medium text-base-content shadow-sm transition-colors hover:bg-base-300\"\n type=\"button\"\n >\n {{ labels.clear }}\n </button>\n\n @if (showSubmit) {\n <button\n (click)=\"submit()\"\n [attr.aria-label]=\"labels.submit\"\n [disabled]=\"submitDisabled\"\n class=\"mn-keyboard-key mn-keyboard-key-submit flex-1 rounded-lg bg-primary py-4 text-base font-semibold text-primary-content shadow-sm transition-colors hover:opacity-90 disabled:opacity-50\"\n style=\"flex-grow: 2\"\n type=\"button\"\n >\n {{ labels.submit }}\n </button>\n }\n </div>\n </div>\n</ng-template>\n\n@if (presentation === 'sheet') {\n <mn-bottom-sheet\n (dismiss)=\"onDismiss()\"\n [ariaLabel]=\"labels.keyboard || undefined\"\n [maxHeightVh]=\"sheetMaxHeightVh\"\n >\n <ng-container [ngTemplateOutlet]=\"keys\"></ng-container>\n </mn-bottom-sheet>\n} @else {\n <ng-container [ngTemplateOutlet]=\"keys\"></ng-container>\n}\n", styles: [":host{display:block}.mn-keyboard-key{-webkit-tap-highlight-color:transparent;-webkit-user-select:none;user-select:none;touch-action:manipulation}\n"] }]
5468
5469
  }], propDecorators: { value: [{
5469
5470
  type: Input
5470
5471
  }], layout: [{
@@ -10991,6 +10992,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
10991
10992
  */
10992
10993
  class CalendarMonthComponent {
10993
10994
  lang = inject(MnLanguageService);
10995
+ cdr = inject(ChangeDetectorRef);
10994
10996
  /**
10995
10997
  * Accessible name for this control. Resolved through the conventional
10996
10998
  * `mnCalendar.monthView` key so an app can translate it, falling back to English when the
@@ -11026,16 +11028,22 @@ class CalendarMonthComponent {
11026
11028
  this.weekdayLabels = resolved.shortDayNames;
11027
11029
  this.moreEventsLabel = resolved.moreEventsLabel;
11028
11030
  this.buildMonth();
11031
+ // Both subscriptions mark the view: the grid is rebuilt into plain fields, so in a zoneless
11032
+ // app nothing else tells Angular this component has to be re-rendered. Without it a month
11033
+ // whose events arrive from a stream — a fetch, a parent seeding its list — stays blank until
11034
+ // some unrelated interaction happens to trigger change detection.
11029
11035
  if (this.eventsChanged) {
11030
11036
  this.eventsChanged.pipe(takeUntil(this.destroy$)).subscribe(events => {
11031
11037
  this.events = events;
11032
11038
  this.buildMonth();
11039
+ this.cdr.markForCheck();
11033
11040
  });
11034
11041
  }
11035
11042
  if (this.focusDayChanged) {
11036
11043
  this.focusDayChanged.pipe(takeUntil(this.destroy$)).subscribe(date => {
11037
11044
  this.focusDay = date;
11038
11045
  this.buildMonth();
11046
+ this.cdr.markForCheck();
11039
11047
  });
11040
11048
  }
11041
11049
  }
@@ -11845,6 +11853,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
11845
11853
  */
11846
11854
  class UpcomingEventsComponent {
11847
11855
  lang = inject(MnLanguageService);
11856
+ cdr = inject(ChangeDetectorRef);
11848
11857
  /**
11849
11858
  * Accessible name for this control. Resolved through the conventional
11850
11859
  * `mnCalendar.upcomingEvents` key so an app can translate it, falling back to English when the
@@ -11879,6 +11888,9 @@ class UpcomingEventsComponent {
11879
11888
  const resolved = this.config ? resolveCalendarConfig(this.config) : { ...DEFAULT_CALENDAR_CONFIG };
11880
11889
  this.title = resolved.upcomingEventsTitle;
11881
11890
  this.noEventsMessage = resolved.noUpcomingEvents;
11891
+ // Marked because the list is a plain field: in a zoneless app a stream emission is not by
11892
+ // itself a reason for Angular to re-render, so the sidebar would keep showing the events it
11893
+ // was first given.
11882
11894
  if (this.eventsChanged) {
11883
11895
  this.eventsChanged.pipe(takeUntil(this.destroy$)).subscribe(events => {
11884
11896
  const now = new Date();
@@ -11886,6 +11898,7 @@ class UpcomingEventsComponent {
11886
11898
  .filter(e => e.endTime > now)
11887
11899
  .sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
11888
11900
  .slice(0, 10);
11901
+ this.cdr.markForCheck();
11889
11902
  });
11890
11903
  }
11891
11904
  }
@@ -11979,6 +11992,7 @@ class CalendarViewComponent {
11979
11992
  mnConfigRef;
11980
11993
  destroyRef = inject(DestroyRef);
11981
11994
  lang = inject(MnLanguageService);
11995
+ cdr = inject(ChangeDetectorRef);
11982
11996
  constructor() {
11983
11997
  const mnConfig = inject(MN_CALENDAR_CONFIG, { optional: true });
11984
11998
  const legacyConfig = inject(CALENDAR_CONFIG, { optional: true });
@@ -11993,9 +12007,12 @@ class CalendarViewComponent {
11993
12007
  }
11994
12008
  ngOnInit() {
11995
12009
  this.rebuildFromConfig();
11996
- // Re-resolve config when locale changes (supports $translate in mn-config).
12010
+ // Re-resolve config when locale changes (supports $translate in mn-config). Marked because
12011
+ // the labels it rebuilds are plain fields: a locale switch arrives on a stream, which in a
12012
+ // zoneless app is not by itself a reason for Angular to re-render the toolbar.
11997
12013
  const sub = this.lang.locale$.pipe(skip(1)).subscribe(() => {
11998
12014
  this.rebuildFromConfig();
12015
+ this.cdr.markForCheck();
11999
12016
  });
12000
12017
  this.destroyRef.onDestroy(() => sub.unsubscribe());
12001
12018
  this.checkMobileView();
@@ -12200,11 +12217,59 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
12200
12217
 
12201
12218
  /** Fallback number of skeleton tabs when no items are known and no count is given. */
12202
12219
  const DEFAULT_SKELETON_TAB_COUNT = 3;
12220
+ /** Query parameter the active tab is mirrored in unless the data source names another. */
12221
+ const DEFAULT_TAB_URL_PARAM = 'tab';
12222
+ /** Key used for a label that slugs to nothing (e.g. punctuation only). */
12223
+ const FALLBACK_TAB_URL_KEY = 'tab';
12224
+ /**
12225
+ * Slugs a tab label into the value it takes in the URL: the last segment of a
12226
+ * translation key, kebab-cased. `matches.hub.tab.entrants` → `entrants`,
12227
+ * `members.tabMembers` → `tab-members`.
12228
+ * @param label - The tab's label or translation key.
12229
+ */
12230
+ function tabUrlKey(label) {
12231
+ const segment = label.split('.').pop() ?? label;
12232
+ const slug = segment
12233
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
12234
+ .replace(/[^a-zA-Z0-9]+/g, '-')
12235
+ .replace(/^-+|-+$/g, '')
12236
+ .toLowerCase();
12237
+ return slug || FALLBACK_TAB_URL_KEY;
12238
+ }
12203
12239
  /**
12204
12240
  * Tab component that renders a horizontal tab bar.
12205
12241
  * Supports translation keys for labels via MnTranslatePipe.
12242
+ *
12243
+ * The active tab is mirrored in the URL query string by default, so a reload,
12244
+ * a back button or a shared link lands on the same tab; see
12245
+ * {@link MnTabDataSource.urlParam} to rename that parameter or switch it off.
12206
12246
  */
12207
12247
  class MnTabComponent {
12248
+ /**
12249
+ * Router the active tab is written to. Optional: a tab bar used outside a
12250
+ * routed application still works, it just has no URL to mirror into.
12251
+ */
12252
+ router = inject(Router, { optional: true });
12253
+ /** Route the tab value is read back from; absent for the same reason as {@link router}. */
12254
+ route = inject(ActivatedRoute, { optional: true });
12255
+ /** Watches the URL so a deep link, a back button or an in-app link moves the tab bar. */
12256
+ queryParamsSub;
12257
+ /**
12258
+ * URL keys of the current items, memoised on the items array so a slug is
12259
+ * computed once per tab set rather than on every change-detection pass.
12260
+ */
12261
+ urlKeyCache;
12262
+ /** URL key of {@link currentActive}, so a rebuilt tab set can be recognised as the same tab. */
12263
+ currentKey;
12264
+ /**
12265
+ * Key of a selection whose URL write has not landed yet. Navigation is
12266
+ * asynchronous, so a consumer that rebuilds its tabs in response to the click
12267
+ * can be resolved against a URL that still names the previous tab; until the
12268
+ * write completes, this is the truth about what the user picked.
12269
+ */
12270
+ pendingKey;
12271
+ /** Set on destroy so a deferred restore can't announce a tab nobody is showing. */
12272
+ destroyed = false;
12208
12273
  /** The horizontally-scrolling wrapper the edge fade is painted onto. */
12209
12274
  scrollContainer;
12210
12275
  /** The tab row; queried for the active tab so the indicator can measure it. */
@@ -12268,6 +12333,16 @@ class MnTabComponent {
12268
12333
  (this.dataSource.items.length || DEFAULT_SKELETON_TAB_COUNT);
12269
12334
  return Array.from({ length: count }, (_, index) => index);
12270
12335
  }
12336
+ constructor() {
12337
+ // The URL is a second source of truth for the selection: a deep link, an
12338
+ // in-app link into another tab of the page already on screen, or the back
12339
+ // button all change it without a click landing on this component.
12340
+ this.queryParamsSub = this.route?.queryParamMap.subscribe((params) => {
12341
+ const param = this.urlParam();
12342
+ if (param)
12343
+ this.activateUrlKey(params.get(param));
12344
+ });
12345
+ }
12271
12346
  /**
12272
12347
  * Re-resolves the active tab on every change-detection pass.
12273
12348
  *
@@ -12307,6 +12382,8 @@ class MnTabComponent {
12307
12382
  this.updateIndicator(false);
12308
12383
  }
12309
12384
  ngOnDestroy() {
12385
+ this.destroyed = true;
12386
+ this.queryParamsSub?.unsubscribe();
12310
12387
  this.resizeObserver?.disconnect();
12311
12388
  if (this.indicatorFrame !== undefined)
12312
12389
  cancelAnimationFrame(this.indicatorFrame);
@@ -12340,16 +12417,28 @@ class MnTabComponent {
12340
12417
  el.style.setProperty('-webkit-mask-image', mask);
12341
12418
  }
12342
12419
  /**
12343
- * Sets the given tab item as active, invoking deactivate/activate callbacks.
12420
+ * Sets the given tab item as active, invoking deactivate/activate callbacks,
12421
+ * and records the selection in the URL so it survives a reload or a share.
12344
12422
  * @param item - The tab item to activate.
12345
12423
  */
12346
12424
  setActive(item) {
12347
12425
  if (this.currentActive === item) {
12348
12426
  return;
12349
12427
  }
12428
+ this.activate(item);
12429
+ this.writeUrl(item);
12430
+ }
12431
+ /**
12432
+ * Moves the selection to `item` and tells the consumer about it: the
12433
+ * deactivate/activate/emit sequence a click produces, shared by the click
12434
+ * path and the URL-driven ones (deep link, back button), which owe the
12435
+ * consumer the same notifications.
12436
+ * @param item - The tab item to activate.
12437
+ */
12438
+ activate(item) {
12350
12439
  this.currentActive?.onDeactivate?.();
12351
12440
  item.onClick?.();
12352
- this.currentActive = item;
12441
+ this.select(item);
12353
12442
  this.activeChange.emit(item);
12354
12443
  // Slide the underline to the new tab. Measure on the next frame, after
12355
12444
  // change detection has applied the active tab's `font-bold` (which widens
@@ -12425,14 +12514,16 @@ class MnTabComponent {
12425
12514
  }
12426
12515
  /**
12427
12516
  * Ensures {@link currentActive} references a tab that still exists in the data
12428
- * source, falling back to the configured default tab when the current
12429
- * selection is missing or stale (e.g. after the items array is replaced).
12517
+ * source, preferring the tab named in the URL and falling back to the
12518
+ * configured default tab when the current selection is missing or stale
12519
+ * (e.g. after the items array is replaced).
12430
12520
  */
12431
12521
  syncActiveTab() {
12432
12522
  const items = this.dataSource?.items;
12433
12523
  if (!items || items.length === 0) {
12434
12524
  if (this.currentActive !== undefined) {
12435
12525
  this.currentActive = undefined;
12526
+ this.currentKey = undefined;
12436
12527
  this.scheduleIndicator(false);
12437
12528
  }
12438
12529
  return;
@@ -12442,9 +12533,131 @@ class MnTabComponent {
12442
12533
  }
12443
12534
  const defaultIndex = this.dataSource.defaultActive;
12444
12535
  const index = defaultIndex >= 0 && defaultIndex < items.length ? defaultIndex : 0;
12445
- this.currentActive = items[index];
12536
+ const fallback = items[index];
12537
+ const restored = this.itemFromUrl(items);
12538
+ const previousKey = this.currentKey;
12539
+ this.select(restored ?? fallback);
12446
12540
  // Selection resolved from data (not a user click): snap, don't slide.
12447
12541
  this.scheduleIndicator(false);
12542
+ if (restored && restored !== fallback && this.currentKey !== previousKey) {
12543
+ // The URL asks for a tab the consumer has not rendered, so this selection
12544
+ // has to be announced like a click's would be — but only the first time,
12545
+ // or a consumer that rebuilds its items array would re-run the tab's
12546
+ // callbacks on every rebuild. Deferred out of the change-detection pass
12547
+ // that resolved it: the consumer will flip its own state in response, and
12548
+ // doing that mid-pass writes to bindings that have already been checked.
12549
+ queueMicrotask(() => this.announceRestored(restored));
12550
+ }
12551
+ }
12552
+ /**
12553
+ * Records `item` as the selection, remembering its URL key so the same tab is
12554
+ * recognised after the consumer rebuilds the items array.
12555
+ * @param item - The newly selected tab.
12556
+ */
12557
+ select(item) {
12558
+ const items = this.dataSource.items;
12559
+ this.currentActive = item;
12560
+ this.currentKey = this.urlKeys(items)[items.indexOf(item)];
12561
+ }
12562
+ /**
12563
+ * Runs the restored tab's callbacks a change-detection pass later, unless the
12564
+ * selection moved on in the meantime (a click, or another tab set arriving).
12565
+ * @param item - The tab restored from the URL.
12566
+ */
12567
+ announceRestored(item) {
12568
+ if (this.destroyed || this.currentActive !== item)
12569
+ return;
12570
+ item.onClick?.();
12571
+ this.activeChange.emit(item);
12572
+ }
12573
+ /**
12574
+ * Activates the tab a URL value names, when it is not the tab already on
12575
+ * screen. Values naming no known tab are ignored: another tab bar on the page
12576
+ * may own that parameter, and a stale link should leave the default standing.
12577
+ * @param key - The value read from the query parameter, if any.
12578
+ */
12579
+ activateUrlKey(key) {
12580
+ const items = this.dataSource?.items;
12581
+ if (!key || !items?.length)
12582
+ return;
12583
+ const item = items[this.urlKeys(items).indexOf(key)];
12584
+ if (!item || item === this.currentActive)
12585
+ return;
12586
+ this.activate(item);
12587
+ }
12588
+ /**
12589
+ * The query parameter this tab bar mirrors into, or undefined when there is
12590
+ * nothing to mirror into (no router) or the consumer switched it off.
12591
+ */
12592
+ urlParam() {
12593
+ if (!this.router || !this.route)
12594
+ return undefined;
12595
+ const param = this.dataSource?.urlParam ?? DEFAULT_TAB_URL_PARAM;
12596
+ return param === false || param === '' ? undefined : param;
12597
+ }
12598
+ /**
12599
+ * The tab the current URL asks for, if it names one of `items`.
12600
+ * @param items - The tab set to resolve the URL value against.
12601
+ */
12602
+ itemFromUrl(items) {
12603
+ const param = this.urlParam();
12604
+ if (!param)
12605
+ return undefined;
12606
+ const key = this.pendingKey ?? this.route?.snapshot.queryParamMap.get(param);
12607
+ if (!key)
12608
+ return undefined;
12609
+ const index = this.urlKeys(items).indexOf(key);
12610
+ return index === -1 ? undefined : items[index];
12611
+ }
12612
+ /**
12613
+ * Records the active tab in the query string, replacing the current history
12614
+ * entry: switching tabs is not a navigation to walk back through, and back
12615
+ * should leave the page rather than retrace its tabs.
12616
+ * @param item - The tab that just became active.
12617
+ */
12618
+ writeUrl(item) {
12619
+ const param = this.urlParam();
12620
+ if (!param || !this.router)
12621
+ return;
12622
+ const items = this.dataSource.items;
12623
+ const key = this.urlKeys(items)[items.indexOf(item)];
12624
+ if (!key)
12625
+ return;
12626
+ this.pendingKey = key;
12627
+ // No path commands and no `relativeTo`, so only the query string changes.
12628
+ // That holds wherever the tab bar sits, including a modal body, which has
12629
+ // no route of its own to be relative to.
12630
+ void this.router
12631
+ .navigate([], {
12632
+ queryParams: { [param]: key },
12633
+ queryParamsHandling: 'merge',
12634
+ replaceUrl: true,
12635
+ })
12636
+ .catch(() => undefined)
12637
+ .then(() => {
12638
+ // Only clear our own write; a later click already owns the pending key.
12639
+ if (this.pendingKey === key)
12640
+ this.pendingKey = undefined;
12641
+ });
12642
+ }
12643
+ /**
12644
+ * The URL key of every tab, in item order: the item's `id`, else a slug of
12645
+ * its label. Repeats are numbered so each tab still round-trips through the
12646
+ * URL; give such tabs an explicit `id` to choose the value yourself.
12647
+ * @param items - The tab set to key.
12648
+ */
12649
+ urlKeys(items) {
12650
+ if (this.urlKeyCache?.items === items)
12651
+ return this.urlKeyCache.keys;
12652
+ const used = new Map();
12653
+ const keys = items.map((item) => {
12654
+ const base = item.id ?? tabUrlKey(item.label);
12655
+ const taken = used.get(base) ?? 0;
12656
+ used.set(base, taken + 1);
12657
+ return taken === 0 ? base : `${base}-${taken + 1}`;
12658
+ });
12659
+ this.urlKeyCache = { items, keys };
12660
+ return keys;
12448
12661
  }
12449
12662
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnTabComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
12450
12663
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnTabComponent, isStandalone: true, selector: "mn-tab", inputs: { dataSource: "dataSource", scrollable: "scrollable", justified: "justified" }, outputs: { activeChange: "activeChange" }, viewQueries: [{ propertyName: "scrollContainer", first: true, predicate: ["scrollContainer"], descendants: true }, { propertyName: "tabList", first: true, predicate: ["tabList"], descendants: true }, { propertyName: "indicator", first: true, predicate: ["indicator"], descendants: true }], ngImport: i0, template: "<div class=\"mb-10\">\n <div\n #scrollContainer\n (scroll)=\"updateEdgeFades()\"\n class=\"flex justify-start scrollbar-hide\"\n [class.overflow-x-auto]=\"scrollable\"\n [class.overflow-y-hidden]=\"scrollable\"\n >\n <div\n #tabList\n role=\"tablist\"\n class=\"tabs relative flex flex-nowrap -mb-[1px] border-b border-base-300\"\n [class.w-full]=\"justified\"\n >\n <!--\n Shared sliding indicator: a single underline that travels to the active\n tab, rather than each tab flipping its own border on/off (which snaps).\n Pinned to left-0/top-auto so offsetLeft maps 1:1 regardless of the\n flex justify-content or the justified full-width layout; position and\n width are measured and set from TS. Sits on the same baseline as the\n hover ::after so hover \u2192 select reads as one continuous underline.\n -->\n <div\n #indicator\n aria-hidden=\"true\"\n class=\"pointer-events-none absolute left-0 top-auto bottom-0 h-[2px] w-0 bg-primary opacity-0 transition-[transform,width] duration-300 ease-out motion-reduce:transition-none\"\n ></div>\n @if (isLoadingState) {\n @for (i of skeletonTabs; track i) {\n <div\n [class.flex-1]=\"justified\"\n class=\"tab px-4 py-2 border-b-2 border-transparent flex items-center justify-center\"\n >\n <mn-skeleton [data]=\"{ shape: 'rectangle', width: '4.5rem', height: '1rem' }\"></mn-skeleton>\n </div>\n }\n } @else {\n @for (item of dataSource.items; track item.label) {\n <div\n (click)=\"setActive(item)\"\n (keyup.enter)=\"setActive(item)\"\n (keyup.space)=\"setActive(item)\"\n [attr.aria-selected]=\"currentActive === item\"\n [class.hover:after:scale-x-100]=\"currentActive !== item\"\n [class.flex-1]=\"justified\"\n [class.font-bold]=\"currentActive === item\"\n [class.text-base-content]=\"currentActive !== item\"\n [class.text-primary]=\"currentActive === item\"\n class=\"tab relative px-4 py-2 border-b-2 border-transparent cursor-pointer select-none transition-colors whitespace-nowrap text-center flex items-center gap-2 after:content-[''] after:absolute after:inset-x-0 after:-bottom-[2px] after:h-[2px] after:bg-primary/60 after:origin-center after:scale-x-0 after:transition-transform after:duration-300 after:ease-out\"\n role=\"tab\"\n tabindex=\"0\"\n >\n {{ item.label | mnTranslate }}\n @let badge = getBadge(item);\n @if (badge && badge > 0) {\n <span [data]=\"{ size: 'sm', color: 'accent', variant: 'fill' }\" mnBadge>{{ badge }}</span>\n }\n </div>\n }\n }\n </div>\n </div>\n</div>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: MnBadge, selector: "span[mnBadge]", inputs: ["data"] }, { kind: "component", type: MnSkeleton, selector: "mn-skeleton", inputs: ["data"] }, { kind: "pipe", type: MnTranslatePipe, name: "mnTranslate" }] });
@@ -12452,7 +12665,7 @@ class MnTabComponent {
12452
12665
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnTabComponent, decorators: [{
12453
12666
  type: Component,
12454
12667
  args: [{ selector: 'mn-tab', standalone: true, imports: [MnTranslatePipe, CommonModule, MnBadge, MnSkeleton], template: "<div class=\"mb-10\">\n <div\n #scrollContainer\n (scroll)=\"updateEdgeFades()\"\n class=\"flex justify-start scrollbar-hide\"\n [class.overflow-x-auto]=\"scrollable\"\n [class.overflow-y-hidden]=\"scrollable\"\n >\n <div\n #tabList\n role=\"tablist\"\n class=\"tabs relative flex flex-nowrap -mb-[1px] border-b border-base-300\"\n [class.w-full]=\"justified\"\n >\n <!--\n Shared sliding indicator: a single underline that travels to the active\n tab, rather than each tab flipping its own border on/off (which snaps).\n Pinned to left-0/top-auto so offsetLeft maps 1:1 regardless of the\n flex justify-content or the justified full-width layout; position and\n width are measured and set from TS. Sits on the same baseline as the\n hover ::after so hover \u2192 select reads as one continuous underline.\n -->\n <div\n #indicator\n aria-hidden=\"true\"\n class=\"pointer-events-none absolute left-0 top-auto bottom-0 h-[2px] w-0 bg-primary opacity-0 transition-[transform,width] duration-300 ease-out motion-reduce:transition-none\"\n ></div>\n @if (isLoadingState) {\n @for (i of skeletonTabs; track i) {\n <div\n [class.flex-1]=\"justified\"\n class=\"tab px-4 py-2 border-b-2 border-transparent flex items-center justify-center\"\n >\n <mn-skeleton [data]=\"{ shape: 'rectangle', width: '4.5rem', height: '1rem' }\"></mn-skeleton>\n </div>\n }\n } @else {\n @for (item of dataSource.items; track item.label) {\n <div\n (click)=\"setActive(item)\"\n (keyup.enter)=\"setActive(item)\"\n (keyup.space)=\"setActive(item)\"\n [attr.aria-selected]=\"currentActive === item\"\n [class.hover:after:scale-x-100]=\"currentActive !== item\"\n [class.flex-1]=\"justified\"\n [class.font-bold]=\"currentActive === item\"\n [class.text-base-content]=\"currentActive !== item\"\n [class.text-primary]=\"currentActive === item\"\n class=\"tab relative px-4 py-2 border-b-2 border-transparent cursor-pointer select-none transition-colors whitespace-nowrap text-center flex items-center gap-2 after:content-[''] after:absolute after:inset-x-0 after:-bottom-[2px] after:h-[2px] after:bg-primary/60 after:origin-center after:scale-x-0 after:transition-transform after:duration-300 after:ease-out\"\n role=\"tab\"\n tabindex=\"0\"\n >\n {{ item.label | mnTranslate }}\n @let badge = getBadge(item);\n @if (badge && badge > 0) {\n <span [data]=\"{ size: 'sm', color: 'accent', variant: 'fill' }\" mnBadge>{{ badge }}</span>\n }\n </div>\n }\n }\n </div>\n </div>\n</div>\n" }]
12455
- }], propDecorators: { scrollContainer: [{
12668
+ }], ctorParameters: () => [], propDecorators: { scrollContainer: [{
12456
12669
  type: ViewChild,
12457
12670
  args: ['scrollContainer']
12458
12671
  }], tabList: [{