mn-angular-lib 1.0.155 → 1.0.157

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: [{
@@ -8701,6 +8702,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
8701
8702
 
8702
8703
  class MnFormBodyComponent {
8703
8704
  fb = inject(FormBuilder);
8705
+ /**
8706
+ * Change detector used to announce state this component mutates outside an Angular
8707
+ * event handler — after an `await`, or from an RxJS subscription. Consuming apps run
8708
+ * zoneless, where such a write notifies nothing: the view is never marked dirty, so it
8709
+ * keeps rendering the stale value and `checkNoChanges` then reports it as NG0100
8710
+ * (e.g. the submit button staying disabled on "Submitting..." after a failed submit).
8711
+ * Every async mutation of {@link isSubmitting}, {@link fieldLoading}, {@link fieldOptions}
8712
+ * and {@link formErrors} must be followed by `markForCheck()`.
8713
+ */
8714
+ cdr = inject(ChangeDetectorRef);
8704
8715
  config;
8705
8716
  modalRef;
8706
8717
  hideFooter = false;
@@ -9131,6 +9142,7 @@ class MnFormBodyComponent {
9131
9142
  }
9132
9143
  async loadFieldOptions(key, dataSource, formValue) {
9133
9144
  this.fieldLoading[key] = true;
9145
+ this.cdr.markForCheck();
9134
9146
  try {
9135
9147
  this.fieldOptions[key] = await dataSource.load(formValue);
9136
9148
  }
@@ -9139,7 +9151,10 @@ class MnFormBodyComponent {
9139
9151
  this.fieldOptions[key] = [];
9140
9152
  }
9141
9153
  finally {
9154
+ // Resolved after an `await`, so nothing has told Angular the spinner can go and the
9155
+ // options have arrived. Without this the field renders "Loading options..." forever.
9142
9156
  this.fieldLoading[key] = false;
9157
+ this.cdr.markForCheck();
9143
9158
  }
9144
9159
  }
9145
9160
  // =========================
@@ -9280,6 +9295,10 @@ class MnFormBodyComponent {
9280
9295
  this.reloadDependentDataSources(formValue);
9281
9296
  // Emit status change
9282
9297
  this.formStatusChange.emit(this.form.status);
9298
+ // `valueChanges` can fire from outside an Angular event handler (a CVA writing on
9299
+ // init, or a caller patching the form), and the callbacks above rewrite visibility,
9300
+ // required-state and cross-field errors that the template reads.
9301
+ this.cdr.markForCheck();
9283
9302
  });
9284
9303
  }
9285
9304
  previousFormValue = {};
@@ -9343,8 +9362,11 @@ class MnFormBodyComponent {
9343
9362
  this.modalRef.close(formValue);
9344
9363
  }
9345
9364
  catch (error) {
9346
- // Always allow retry on error, regardless of submit mode
9365
+ // Always allow retry on error, regardless of submit mode. This runs after an `await`,
9366
+ // so it must announce itself: otherwise the button stays disabled on "Submitting..."
9367
+ // and the user can never retry the action that just failed.
9347
9368
  this.isSubmitting = false;
9369
+ this.cdr.markForCheck();
9348
9370
  console.error('Form submission error:', error);
9349
9371
  }
9350
9372
  }
@@ -10991,6 +11013,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
10991
11013
  */
10992
11014
  class CalendarMonthComponent {
10993
11015
  lang = inject(MnLanguageService);
11016
+ cdr = inject(ChangeDetectorRef);
10994
11017
  /**
10995
11018
  * Accessible name for this control. Resolved through the conventional
10996
11019
  * `mnCalendar.monthView` key so an app can translate it, falling back to English when the
@@ -11026,16 +11049,22 @@ class CalendarMonthComponent {
11026
11049
  this.weekdayLabels = resolved.shortDayNames;
11027
11050
  this.moreEventsLabel = resolved.moreEventsLabel;
11028
11051
  this.buildMonth();
11052
+ // Both subscriptions mark the view: the grid is rebuilt into plain fields, so in a zoneless
11053
+ // app nothing else tells Angular this component has to be re-rendered. Without it a month
11054
+ // whose events arrive from a stream — a fetch, a parent seeding its list — stays blank until
11055
+ // some unrelated interaction happens to trigger change detection.
11029
11056
  if (this.eventsChanged) {
11030
11057
  this.eventsChanged.pipe(takeUntil(this.destroy$)).subscribe(events => {
11031
11058
  this.events = events;
11032
11059
  this.buildMonth();
11060
+ this.cdr.markForCheck();
11033
11061
  });
11034
11062
  }
11035
11063
  if (this.focusDayChanged) {
11036
11064
  this.focusDayChanged.pipe(takeUntil(this.destroy$)).subscribe(date => {
11037
11065
  this.focusDay = date;
11038
11066
  this.buildMonth();
11067
+ this.cdr.markForCheck();
11039
11068
  });
11040
11069
  }
11041
11070
  }
@@ -11845,6 +11874,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
11845
11874
  */
11846
11875
  class UpcomingEventsComponent {
11847
11876
  lang = inject(MnLanguageService);
11877
+ cdr = inject(ChangeDetectorRef);
11848
11878
  /**
11849
11879
  * Accessible name for this control. Resolved through the conventional
11850
11880
  * `mnCalendar.upcomingEvents` key so an app can translate it, falling back to English when the
@@ -11879,6 +11909,9 @@ class UpcomingEventsComponent {
11879
11909
  const resolved = this.config ? resolveCalendarConfig(this.config) : { ...DEFAULT_CALENDAR_CONFIG };
11880
11910
  this.title = resolved.upcomingEventsTitle;
11881
11911
  this.noEventsMessage = resolved.noUpcomingEvents;
11912
+ // Marked because the list is a plain field: in a zoneless app a stream emission is not by
11913
+ // itself a reason for Angular to re-render, so the sidebar would keep showing the events it
11914
+ // was first given.
11882
11915
  if (this.eventsChanged) {
11883
11916
  this.eventsChanged.pipe(takeUntil(this.destroy$)).subscribe(events => {
11884
11917
  const now = new Date();
@@ -11886,6 +11919,7 @@ class UpcomingEventsComponent {
11886
11919
  .filter(e => e.endTime > now)
11887
11920
  .sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
11888
11921
  .slice(0, 10);
11922
+ this.cdr.markForCheck();
11889
11923
  });
11890
11924
  }
11891
11925
  }
@@ -11979,6 +12013,7 @@ class CalendarViewComponent {
11979
12013
  mnConfigRef;
11980
12014
  destroyRef = inject(DestroyRef);
11981
12015
  lang = inject(MnLanguageService);
12016
+ cdr = inject(ChangeDetectorRef);
11982
12017
  constructor() {
11983
12018
  const mnConfig = inject(MN_CALENDAR_CONFIG, { optional: true });
11984
12019
  const legacyConfig = inject(CALENDAR_CONFIG, { optional: true });
@@ -11993,9 +12028,12 @@ class CalendarViewComponent {
11993
12028
  }
11994
12029
  ngOnInit() {
11995
12030
  this.rebuildFromConfig();
11996
- // Re-resolve config when locale changes (supports $translate in mn-config).
12031
+ // Re-resolve config when locale changes (supports $translate in mn-config). Marked because
12032
+ // the labels it rebuilds are plain fields: a locale switch arrives on a stream, which in a
12033
+ // zoneless app is not by itself a reason for Angular to re-render the toolbar.
11997
12034
  const sub = this.lang.locale$.pipe(skip(1)).subscribe(() => {
11998
12035
  this.rebuildFromConfig();
12036
+ this.cdr.markForCheck();
11999
12037
  });
12000
12038
  this.destroyRef.onDestroy(() => sub.unsubscribe());
12001
12039
  this.checkMobileView();
@@ -12200,11 +12238,59 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
12200
12238
 
12201
12239
  /** Fallback number of skeleton tabs when no items are known and no count is given. */
12202
12240
  const DEFAULT_SKELETON_TAB_COUNT = 3;
12241
+ /** Query parameter the active tab is mirrored in unless the data source names another. */
12242
+ const DEFAULT_TAB_URL_PARAM = 'tab';
12243
+ /** Key used for a label that slugs to nothing (e.g. punctuation only). */
12244
+ const FALLBACK_TAB_URL_KEY = 'tab';
12245
+ /**
12246
+ * Slugs a tab label into the value it takes in the URL: the last segment of a
12247
+ * translation key, kebab-cased. `matches.hub.tab.entrants` → `entrants`,
12248
+ * `members.tabMembers` → `tab-members`.
12249
+ * @param label - The tab's label or translation key.
12250
+ */
12251
+ function tabUrlKey(label) {
12252
+ const segment = label.split('.').pop() ?? label;
12253
+ const slug = segment
12254
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
12255
+ .replace(/[^a-zA-Z0-9]+/g, '-')
12256
+ .replace(/^-+|-+$/g, '')
12257
+ .toLowerCase();
12258
+ return slug || FALLBACK_TAB_URL_KEY;
12259
+ }
12203
12260
  /**
12204
12261
  * Tab component that renders a horizontal tab bar.
12205
12262
  * Supports translation keys for labels via MnTranslatePipe.
12263
+ *
12264
+ * The active tab is mirrored in the URL query string by default, so a reload,
12265
+ * a back button or a shared link lands on the same tab; see
12266
+ * {@link MnTabDataSource.urlParam} to rename that parameter or switch it off.
12206
12267
  */
12207
12268
  class MnTabComponent {
12269
+ /**
12270
+ * Router the active tab is written to. Optional: a tab bar used outside a
12271
+ * routed application still works, it just has no URL to mirror into.
12272
+ */
12273
+ router = inject(Router, { optional: true });
12274
+ /** Route the tab value is read back from; absent for the same reason as {@link router}. */
12275
+ route = inject(ActivatedRoute, { optional: true });
12276
+ /** Watches the URL so a deep link, a back button or an in-app link moves the tab bar. */
12277
+ queryParamsSub;
12278
+ /**
12279
+ * URL keys of the current items, memoised on the items array so a slug is
12280
+ * computed once per tab set rather than on every change-detection pass.
12281
+ */
12282
+ urlKeyCache;
12283
+ /** URL key of {@link currentActive}, so a rebuilt tab set can be recognised as the same tab. */
12284
+ currentKey;
12285
+ /**
12286
+ * Key of a selection whose URL write has not landed yet. Navigation is
12287
+ * asynchronous, so a consumer that rebuilds its tabs in response to the click
12288
+ * can be resolved against a URL that still names the previous tab; until the
12289
+ * write completes, this is the truth about what the user picked.
12290
+ */
12291
+ pendingKey;
12292
+ /** Set on destroy so a deferred restore can't announce a tab nobody is showing. */
12293
+ destroyed = false;
12208
12294
  /** The horizontally-scrolling wrapper the edge fade is painted onto. */
12209
12295
  scrollContainer;
12210
12296
  /** The tab row; queried for the active tab so the indicator can measure it. */
@@ -12268,6 +12354,16 @@ class MnTabComponent {
12268
12354
  (this.dataSource.items.length || DEFAULT_SKELETON_TAB_COUNT);
12269
12355
  return Array.from({ length: count }, (_, index) => index);
12270
12356
  }
12357
+ constructor() {
12358
+ // The URL is a second source of truth for the selection: a deep link, an
12359
+ // in-app link into another tab of the page already on screen, or the back
12360
+ // button all change it without a click landing on this component.
12361
+ this.queryParamsSub = this.route?.queryParamMap.subscribe((params) => {
12362
+ const param = this.urlParam();
12363
+ if (param)
12364
+ this.activateUrlKey(params.get(param));
12365
+ });
12366
+ }
12271
12367
  /**
12272
12368
  * Re-resolves the active tab on every change-detection pass.
12273
12369
  *
@@ -12307,6 +12403,8 @@ class MnTabComponent {
12307
12403
  this.updateIndicator(false);
12308
12404
  }
12309
12405
  ngOnDestroy() {
12406
+ this.destroyed = true;
12407
+ this.queryParamsSub?.unsubscribe();
12310
12408
  this.resizeObserver?.disconnect();
12311
12409
  if (this.indicatorFrame !== undefined)
12312
12410
  cancelAnimationFrame(this.indicatorFrame);
@@ -12340,16 +12438,28 @@ class MnTabComponent {
12340
12438
  el.style.setProperty('-webkit-mask-image', mask);
12341
12439
  }
12342
12440
  /**
12343
- * Sets the given tab item as active, invoking deactivate/activate callbacks.
12441
+ * Sets the given tab item as active, invoking deactivate/activate callbacks,
12442
+ * and records the selection in the URL so it survives a reload or a share.
12344
12443
  * @param item - The tab item to activate.
12345
12444
  */
12346
12445
  setActive(item) {
12347
12446
  if (this.currentActive === item) {
12348
12447
  return;
12349
12448
  }
12449
+ this.activate(item);
12450
+ this.writeUrl(item);
12451
+ }
12452
+ /**
12453
+ * Moves the selection to `item` and tells the consumer about it: the
12454
+ * deactivate/activate/emit sequence a click produces, shared by the click
12455
+ * path and the URL-driven ones (deep link, back button), which owe the
12456
+ * consumer the same notifications.
12457
+ * @param item - The tab item to activate.
12458
+ */
12459
+ activate(item) {
12350
12460
  this.currentActive?.onDeactivate?.();
12351
12461
  item.onClick?.();
12352
- this.currentActive = item;
12462
+ this.select(item);
12353
12463
  this.activeChange.emit(item);
12354
12464
  // Slide the underline to the new tab. Measure on the next frame, after
12355
12465
  // change detection has applied the active tab's `font-bold` (which widens
@@ -12425,14 +12535,16 @@ class MnTabComponent {
12425
12535
  }
12426
12536
  /**
12427
12537
  * 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).
12538
+ * source, preferring the tab named in the URL and falling back to the
12539
+ * configured default tab when the current selection is missing or stale
12540
+ * (e.g. after the items array is replaced).
12430
12541
  */
12431
12542
  syncActiveTab() {
12432
12543
  const items = this.dataSource?.items;
12433
12544
  if (!items || items.length === 0) {
12434
12545
  if (this.currentActive !== undefined) {
12435
12546
  this.currentActive = undefined;
12547
+ this.currentKey = undefined;
12436
12548
  this.scheduleIndicator(false);
12437
12549
  }
12438
12550
  return;
@@ -12442,9 +12554,131 @@ class MnTabComponent {
12442
12554
  }
12443
12555
  const defaultIndex = this.dataSource.defaultActive;
12444
12556
  const index = defaultIndex >= 0 && defaultIndex < items.length ? defaultIndex : 0;
12445
- this.currentActive = items[index];
12557
+ const fallback = items[index];
12558
+ const restored = this.itemFromUrl(items);
12559
+ const previousKey = this.currentKey;
12560
+ this.select(restored ?? fallback);
12446
12561
  // Selection resolved from data (not a user click): snap, don't slide.
12447
12562
  this.scheduleIndicator(false);
12563
+ if (restored && restored !== fallback && this.currentKey !== previousKey) {
12564
+ // The URL asks for a tab the consumer has not rendered, so this selection
12565
+ // has to be announced like a click's would be — but only the first time,
12566
+ // or a consumer that rebuilds its items array would re-run the tab's
12567
+ // callbacks on every rebuild. Deferred out of the change-detection pass
12568
+ // that resolved it: the consumer will flip its own state in response, and
12569
+ // doing that mid-pass writes to bindings that have already been checked.
12570
+ queueMicrotask(() => this.announceRestored(restored));
12571
+ }
12572
+ }
12573
+ /**
12574
+ * Records `item` as the selection, remembering its URL key so the same tab is
12575
+ * recognised after the consumer rebuilds the items array.
12576
+ * @param item - The newly selected tab.
12577
+ */
12578
+ select(item) {
12579
+ const items = this.dataSource.items;
12580
+ this.currentActive = item;
12581
+ this.currentKey = this.urlKeys(items)[items.indexOf(item)];
12582
+ }
12583
+ /**
12584
+ * Runs the restored tab's callbacks a change-detection pass later, unless the
12585
+ * selection moved on in the meantime (a click, or another tab set arriving).
12586
+ * @param item - The tab restored from the URL.
12587
+ */
12588
+ announceRestored(item) {
12589
+ if (this.destroyed || this.currentActive !== item)
12590
+ return;
12591
+ item.onClick?.();
12592
+ this.activeChange.emit(item);
12593
+ }
12594
+ /**
12595
+ * Activates the tab a URL value names, when it is not the tab already on
12596
+ * screen. Values naming no known tab are ignored: another tab bar on the page
12597
+ * may own that parameter, and a stale link should leave the default standing.
12598
+ * @param key - The value read from the query parameter, if any.
12599
+ */
12600
+ activateUrlKey(key) {
12601
+ const items = this.dataSource?.items;
12602
+ if (!key || !items?.length)
12603
+ return;
12604
+ const item = items[this.urlKeys(items).indexOf(key)];
12605
+ if (!item || item === this.currentActive)
12606
+ return;
12607
+ this.activate(item);
12608
+ }
12609
+ /**
12610
+ * The query parameter this tab bar mirrors into, or undefined when there is
12611
+ * nothing to mirror into (no router) or the consumer switched it off.
12612
+ */
12613
+ urlParam() {
12614
+ if (!this.router || !this.route)
12615
+ return undefined;
12616
+ const param = this.dataSource?.urlParam ?? DEFAULT_TAB_URL_PARAM;
12617
+ return param === false || param === '' ? undefined : param;
12618
+ }
12619
+ /**
12620
+ * The tab the current URL asks for, if it names one of `items`.
12621
+ * @param items - The tab set to resolve the URL value against.
12622
+ */
12623
+ itemFromUrl(items) {
12624
+ const param = this.urlParam();
12625
+ if (!param)
12626
+ return undefined;
12627
+ const key = this.pendingKey ?? this.route?.snapshot.queryParamMap.get(param);
12628
+ if (!key)
12629
+ return undefined;
12630
+ const index = this.urlKeys(items).indexOf(key);
12631
+ return index === -1 ? undefined : items[index];
12632
+ }
12633
+ /**
12634
+ * Records the active tab in the query string, replacing the current history
12635
+ * entry: switching tabs is not a navigation to walk back through, and back
12636
+ * should leave the page rather than retrace its tabs.
12637
+ * @param item - The tab that just became active.
12638
+ */
12639
+ writeUrl(item) {
12640
+ const param = this.urlParam();
12641
+ if (!param || !this.router)
12642
+ return;
12643
+ const items = this.dataSource.items;
12644
+ const key = this.urlKeys(items)[items.indexOf(item)];
12645
+ if (!key)
12646
+ return;
12647
+ this.pendingKey = key;
12648
+ // No path commands and no `relativeTo`, so only the query string changes.
12649
+ // That holds wherever the tab bar sits, including a modal body, which has
12650
+ // no route of its own to be relative to.
12651
+ void this.router
12652
+ .navigate([], {
12653
+ queryParams: { [param]: key },
12654
+ queryParamsHandling: 'merge',
12655
+ replaceUrl: true,
12656
+ })
12657
+ .catch(() => undefined)
12658
+ .then(() => {
12659
+ // Only clear our own write; a later click already owns the pending key.
12660
+ if (this.pendingKey === key)
12661
+ this.pendingKey = undefined;
12662
+ });
12663
+ }
12664
+ /**
12665
+ * The URL key of every tab, in item order: the item's `id`, else a slug of
12666
+ * its label. Repeats are numbered so each tab still round-trips through the
12667
+ * URL; give such tabs an explicit `id` to choose the value yourself.
12668
+ * @param items - The tab set to key.
12669
+ */
12670
+ urlKeys(items) {
12671
+ if (this.urlKeyCache?.items === items)
12672
+ return this.urlKeyCache.keys;
12673
+ const used = new Map();
12674
+ const keys = items.map((item) => {
12675
+ const base = item.id ?? tabUrlKey(item.label);
12676
+ const taken = used.get(base) ?? 0;
12677
+ used.set(base, taken + 1);
12678
+ return taken === 0 ? base : `${base}-${taken + 1}`;
12679
+ });
12680
+ this.urlKeyCache = { items, keys };
12681
+ return keys;
12448
12682
  }
12449
12683
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnTabComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
12450
12684
  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 +12686,7 @@ class MnTabComponent {
12452
12686
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnTabComponent, decorators: [{
12453
12687
  type: Component,
12454
12688
  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: [{
12689
+ }], ctorParameters: () => [], propDecorators: { scrollContainer: [{
12456
12690
  type: ViewChild,
12457
12691
  args: ['scrollContainer']
12458
12692
  }], tabList: [{