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.
- package/fesm2022/mn-angular-lib.mjs +244 -10
- package/fesm2022/mn-angular-lib.mjs.map +1 -1
- package/package.json +2 -1
- package/types/mn-angular-lib.d.ts +127 -3
|
@@ -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-
|
|
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-
|
|
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.
|
|
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,
|
|
12429
|
-
*
|
|
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
|
-
|
|
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: [{
|