mn-angular-lib 1.0.154 → 1.0.155
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 +357 -14
- package/fesm2022/mn-angular-lib.mjs.map +1 -1
- package/package.json +1 -1
- package/src/lib/features/mn-alert/mn-alert-outlet/mn-alert-outlet.css +38 -0
- package/src/lib/features/mn-keyboard/mn-keyboard.component.css +14 -0
- package/types/mn-angular-lib.d.ts +241 -6
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken,
|
|
2
|
+
import { InjectionToken, inject, Injectable, HostBinding, Input, Component, ApplicationRef, APP_INITIALIZER, Pipe, DestroyRef, signal, ChangeDetectionStrategy, Optional, SkipSelf, Attribute, Directive, ElementRef, EventEmitter, computed, Output, input, output, Injector, viewChildren, linkedSignal, afterNextRender, ChangeDetectorRef, viewChild, Renderer2, HostListener, ViewChild, TemplateRef, ViewContainerRef, forwardRef, afterEveryRender, ViewChildren, EnvironmentInjector, createComponent, isSignal, effect, untracked, ViewEncapsulation } from '@angular/core';
|
|
3
3
|
export { TemplateRef, Type } from '@angular/core';
|
|
4
4
|
import { BehaviorSubject, firstValueFrom, skip, Subject, debounceTime, of, takeUntil, map, catchError } from 'rxjs';
|
|
5
5
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
@@ -26,6 +26,7 @@ const DEFAULT_MN_ALERT_CONFIG = {
|
|
|
26
26
|
},
|
|
27
27
|
icons: {},
|
|
28
28
|
fallbackDuration: 4000,
|
|
29
|
+
maxVisible: 3,
|
|
29
30
|
finalize: (a) => a
|
|
30
31
|
};
|
|
31
32
|
|
|
@@ -45,25 +46,55 @@ const uid = () => `mn_${++COUNTER}`;
|
|
|
45
46
|
class MnAlertStore {
|
|
46
47
|
_alerts$ = new BehaviorSubject([]);
|
|
47
48
|
alerts$ = this._alerts$.asObservable();
|
|
49
|
+
/** Host configuration, when the app provided one. Only `maxVisible` is read here — the
|
|
50
|
+
* per-kind durations are resolved by {@link MnAlertService} before an alert reaches the store. */
|
|
51
|
+
cfg = inject(MN_ALERT_CONFIG, { optional: true });
|
|
52
|
+
/** In-flight auto-dismiss timers keyed by alert id, so an alert that leaves early (dismissed
|
|
53
|
+
* by hand, or pushed out by the visible cap) never fires a stale timeout later. */
|
|
54
|
+
timers = new Map();
|
|
55
|
+
/** How many alerts stay on screen at once; showing more drops the oldest ones. */
|
|
56
|
+
get maxVisible() {
|
|
57
|
+
const configured = this.cfg?.maxVisible;
|
|
58
|
+
return typeof configured === 'number' && configured > 0
|
|
59
|
+
? configured
|
|
60
|
+
: DEFAULT_MN_ALERT_CONFIG.maxVisible;
|
|
61
|
+
}
|
|
48
62
|
show(partial) {
|
|
49
63
|
// Ensure every alert has a numeric duration: use provided or fall back to per-kind default
|
|
50
64
|
const computedDuration = partial.duration ?? DEFAULT_MN_ALERT_CONFIG.durations[partial.kind ?? ''] ?? DEFAULT_MN_ALERT_CONFIG.fallbackDuration;
|
|
51
65
|
const a = { id: uid(), ...partial, duration: computedDuration };
|
|
52
|
-
|
|
66
|
+
// Newest alert last; anything beyond the cap is trimmed off the front (the oldest still
|
|
67
|
+
// visible), so a burst of alerts scrolls rather than piling up.
|
|
68
|
+
const queued = [...this._alerts$.value, a];
|
|
69
|
+
const overflow = queued.length - this.maxVisible;
|
|
70
|
+
if (overflow > 0) {
|
|
71
|
+
queued.splice(0, overflow).forEach(dropped => this.clearTimer(dropped.id));
|
|
72
|
+
}
|
|
73
|
+
this._alerts$.next(queued);
|
|
53
74
|
if (typeof a.duration === 'number' && a.duration > 0) {
|
|
54
|
-
setTimeout(() => this.dismiss(a.id), a.duration);
|
|
75
|
+
this.timers.set(a.id, setTimeout(() => this.dismiss(a.id), a.duration));
|
|
55
76
|
}
|
|
56
77
|
return a.id;
|
|
57
78
|
}
|
|
58
79
|
dismiss(id) {
|
|
59
80
|
const list = this._alerts$.value;
|
|
60
81
|
if (list.some(x => x.id === id)) {
|
|
82
|
+
this.clearTimer(id);
|
|
61
83
|
this._alerts$.next(list.filter(x => x.id !== id));
|
|
62
84
|
}
|
|
63
85
|
}
|
|
64
86
|
clear() {
|
|
87
|
+
this.timers.forEach(t => clearTimeout(t));
|
|
88
|
+
this.timers.clear();
|
|
65
89
|
this._alerts$.next([]);
|
|
66
90
|
}
|
|
91
|
+
clearTimer(id) {
|
|
92
|
+
const timer = this.timers.get(id);
|
|
93
|
+
if (timer !== undefined) {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
this.timers.delete(id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
67
98
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnAlertStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
68
99
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnAlertStore, providedIn: 'root' });
|
|
69
100
|
}
|
|
@@ -749,6 +780,17 @@ class MnAlertOutletComponent {
|
|
|
749
780
|
getAlertClasses(a) {
|
|
750
781
|
return mnAlertVariants({ kind: a.kind, variant: a.variant });
|
|
751
782
|
}
|
|
783
|
+
/**
|
|
784
|
+
* The lifetime (ms) to run the countdown bar over, or null when no bar should show: an alert
|
|
785
|
+
* that never auto-dismisses has nothing to count down, and one already leaving would only
|
|
786
|
+
* restart the animation mid-exit.
|
|
787
|
+
*/
|
|
788
|
+
countdownDuration(v) {
|
|
789
|
+
if (v.leaving)
|
|
790
|
+
return null;
|
|
791
|
+
const duration = v.alert.duration;
|
|
792
|
+
return typeof duration === 'number' && duration > 0 ? duration : null;
|
|
793
|
+
}
|
|
752
794
|
contextFor(a) {
|
|
753
795
|
return {
|
|
754
796
|
$implicit: a,
|
|
@@ -924,11 +966,11 @@ class MnAlertOutletComponent {
|
|
|
924
966
|
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
925
967
|
}
|
|
926
968
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnAlertOutletComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
927
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnAlertOutletComponent, isStandalone: true, selector: "mn-alert-outlet", inputs: { swipeAxis: "swipeAxis", template: "template" }, ngImport: i0, template: "<div class=\"fixed top-4 left-0 right-0 z-[9999] flex flex-col w-full px-2 sm:px-0 sm:left-auto sm:right-4 sm:max-w-sm\">\n @for (v of views(); track trackById($index, v)) {\n <div\n class=\"mn-alert-item\"\n [class.dragging]=\"v.dragging\"\n [class.leaving]=\"v.leaving\"\n [style.touch-action]=\"touchAction\"\n [style.transform]=\"itemTransform(v)\"\n [style.opacity]=\"itemOpacity(v)\"\n (pointerdown)=\"onPointerDown($event, v)\"\n (pointermove)=\"onPointerMove($event, v)\"\n (pointerup)=\"onPointerUp($event, v)\"\n (pointercancel)=\"onPointerUp($event, v)\"\n >\n <div class=\"mn-alert-inner\">\n @if (template) {\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"contextFor(v.alert)\">\n </ng-container>\n } @else {\n <div [class]=\"
|
|
969
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnAlertOutletComponent, isStandalone: true, selector: "mn-alert-outlet", inputs: { swipeAxis: "swipeAxis", template: "template" }, ngImport: i0, template: "<div class=\"fixed top-4 left-0 right-0 z-[9999] flex flex-col w-full px-2 sm:px-0 sm:left-auto sm:right-4 sm:max-w-sm\">\n @for (v of views(); track trackById($index, v)) {\n <div\n class=\"mn-alert-item\"\n [class.dragging]=\"v.dragging\"\n [class.leaving]=\"v.leaving\"\n [style.touch-action]=\"touchAction\"\n [style.transform]=\"itemTransform(v)\"\n [style.opacity]=\"itemOpacity(v)\"\n (pointerdown)=\"onPointerDown($event, v)\"\n (pointermove)=\"onPointerMove($event, v)\"\n (pointerup)=\"onPointerUp($event, v)\"\n (pointercancel)=\"onPointerUp($event, v)\"\n >\n <div class=\"mn-alert-inner\">\n @if (template) {\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"contextFor(v.alert)\">\n </ng-container>\n } @else {\n <div [class.extra]=\"v.alert.cssClass\" [class]=\"getAlertClasses(v.alert)\" class=\"relative overflow-hidden\">\n <div class=\"flex-1 min-w-0 pr-8\">\n <h4 class=\"font-semibold text-sm\">{{ v.alert.title }}</h4>\n @if (v.alert.subTitle) {\n <p class=\"text-sm mt-1 opacity-90 leading-tight\">{{ v.alert.subTitle }}</p>\n }\n </div>\n <button\n [attr.aria-label]=\"closeLabel\"\n mnButton\n [data]=\"{ size: 'md', variant: 'text' }\"\n (click)=\"dismissAlert(v.alert.id)\"\n class=\"absolute top-2 right-2 shrink-0 !text-current hover:!bg-current/10\"\n type=\"button\"\n >\n ×\n </button>\n @if (countdownDuration(v); as ms) {\n <span aria-hidden=\"true\" class=\"mn-alert-progress\">\n <span [style.animation-duration.ms]=\"ms\" class=\"mn-alert-progress-bar\"></span>\n </span>\n }\n </div>\n }\n </div>\n </div>\n }\n</div>\n", styles: [":host{--mn-alert-exit: .32s}.mn-alert-item{display:grid;grid-template-rows:1fr;margin-bottom:.75rem;touch-action:pan-y;cursor:grab;transition:transform .28s cubic-bezier(.22,1,.36,1),opacity var(--mn-alert-exit) ease,grid-template-rows var(--mn-alert-exit) cubic-bezier(.4,0,.2,1),margin-bottom var(--mn-alert-exit) cubic-bezier(.4,0,.2,1)}.mn-alert-item:last-child{margin-bottom:0}.mn-alert-item.dragging{transition:none;cursor:grabbing;-webkit-user-select:none;user-select:none}.mn-alert-inner{min-height:0}.mn-alert-item.leaving{grid-template-rows:0fr;opacity:0;margin-bottom:0;pointer-events:none}.mn-alert-item.leaving .mn-alert-inner{overflow:hidden}.mn-alert-progress{position:absolute;left:0;right:0;bottom:0;height:3px;overflow:hidden;background-color:color-mix(in srgb,currentColor 18%,transparent);pointer-events:none}.mn-alert-progress-bar{display:block;height:100%;background-color:currentColor;opacity:.55;transform-origin:left center;animation-name:mn-alert-countdown;animation-timing-function:linear;animation-fill-mode:forwards}@keyframes mn-alert-countdown{0%{transform:scaleX(1)}to{transform:scaleX(0)}}@media(prefers-reduced-motion:reduce){.mn-alert-item{transition:none}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
928
970
|
}
|
|
929
971
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnAlertOutletComponent, decorators: [{
|
|
930
972
|
type: Component,
|
|
931
|
-
args: [{ selector: 'mn-alert-outlet', standalone: true, imports: [CommonModule, MnButton], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"fixed top-4 left-0 right-0 z-[9999] flex flex-col w-full px-2 sm:px-0 sm:left-auto sm:right-4 sm:max-w-sm\">\n @for (v of views(); track trackById($index, v)) {\n <div\n class=\"mn-alert-item\"\n [class.dragging]=\"v.dragging\"\n [class.leaving]=\"v.leaving\"\n [style.touch-action]=\"touchAction\"\n [style.transform]=\"itemTransform(v)\"\n [style.opacity]=\"itemOpacity(v)\"\n (pointerdown)=\"onPointerDown($event, v)\"\n (pointermove)=\"onPointerMove($event, v)\"\n (pointerup)=\"onPointerUp($event, v)\"\n (pointercancel)=\"onPointerUp($event, v)\"\n >\n <div class=\"mn-alert-inner\">\n @if (template) {\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"contextFor(v.alert)\">\n </ng-container>\n } @else {\n <div [class]=\"
|
|
973
|
+
args: [{ selector: 'mn-alert-outlet', standalone: true, imports: [CommonModule, MnButton], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"fixed top-4 left-0 right-0 z-[9999] flex flex-col w-full px-2 sm:px-0 sm:left-auto sm:right-4 sm:max-w-sm\">\n @for (v of views(); track trackById($index, v)) {\n <div\n class=\"mn-alert-item\"\n [class.dragging]=\"v.dragging\"\n [class.leaving]=\"v.leaving\"\n [style.touch-action]=\"touchAction\"\n [style.transform]=\"itemTransform(v)\"\n [style.opacity]=\"itemOpacity(v)\"\n (pointerdown)=\"onPointerDown($event, v)\"\n (pointermove)=\"onPointerMove($event, v)\"\n (pointerup)=\"onPointerUp($event, v)\"\n (pointercancel)=\"onPointerUp($event, v)\"\n >\n <div class=\"mn-alert-inner\">\n @if (template) {\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"contextFor(v.alert)\">\n </ng-container>\n } @else {\n <div [class.extra]=\"v.alert.cssClass\" [class]=\"getAlertClasses(v.alert)\" class=\"relative overflow-hidden\">\n <div class=\"flex-1 min-w-0 pr-8\">\n <h4 class=\"font-semibold text-sm\">{{ v.alert.title }}</h4>\n @if (v.alert.subTitle) {\n <p class=\"text-sm mt-1 opacity-90 leading-tight\">{{ v.alert.subTitle }}</p>\n }\n </div>\n <button\n [attr.aria-label]=\"closeLabel\"\n mnButton\n [data]=\"{ size: 'md', variant: 'text' }\"\n (click)=\"dismissAlert(v.alert.id)\"\n class=\"absolute top-2 right-2 shrink-0 !text-current hover:!bg-current/10\"\n type=\"button\"\n >\n ×\n </button>\n @if (countdownDuration(v); as ms) {\n <span aria-hidden=\"true\" class=\"mn-alert-progress\">\n <span [style.animation-duration.ms]=\"ms\" class=\"mn-alert-progress-bar\"></span>\n </span>\n }\n </div>\n }\n </div>\n </div>\n }\n</div>\n", styles: [":host{--mn-alert-exit: .32s}.mn-alert-item{display:grid;grid-template-rows:1fr;margin-bottom:.75rem;touch-action:pan-y;cursor:grab;transition:transform .28s cubic-bezier(.22,1,.36,1),opacity var(--mn-alert-exit) ease,grid-template-rows var(--mn-alert-exit) cubic-bezier(.4,0,.2,1),margin-bottom var(--mn-alert-exit) cubic-bezier(.4,0,.2,1)}.mn-alert-item:last-child{margin-bottom:0}.mn-alert-item.dragging{transition:none;cursor:grabbing;-webkit-user-select:none;user-select:none}.mn-alert-inner{min-height:0}.mn-alert-item.leaving{grid-template-rows:0fr;opacity:0;margin-bottom:0;pointer-events:none}.mn-alert-item.leaving .mn-alert-inner{overflow:hidden}.mn-alert-progress{position:absolute;left:0;right:0;bottom:0;height:3px;overflow:hidden;background-color:color-mix(in srgb,currentColor 18%,transparent);pointer-events:none}.mn-alert-progress-bar{display:block;height:100%;background-color:currentColor;opacity:.55;transform-origin:left center;animation-name:mn-alert-countdown;animation-timing-function:linear;animation-fill-mode:forwards}@keyframes mn-alert-countdown{0%{transform:scaleX(1)}to{transform:scaleX(0)}}@media(prefers-reduced-motion:reduce){.mn-alert-item{transition:none}}\n"] }]
|
|
932
974
|
}], ctorParameters: () => [], propDecorators: { swipeAxis: [{
|
|
933
975
|
type: Input
|
|
934
976
|
}], template: [{
|
|
@@ -2342,10 +2384,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
|
|
|
2342
2384
|
* Mirrors the styling vocabulary of {@link mnInputFieldVariants} (size,
|
|
2343
2385
|
* borderRadius, shadow, fullWidth, disabled) so a file input visually matches the
|
|
2344
2386
|
* rest of the input family, and adds a `dropzone` toggle for the large dashed
|
|
2345
|
-
* drop area used by the default display mode
|
|
2387
|
+
* drop area used by the default display mode plus a `dragging` toggle for the
|
|
2388
|
+
* "release to drop" state while files hover over that dropzone.
|
|
2346
2389
|
*/
|
|
2347
2390
|
const mnFileInputVariants = tv({
|
|
2348
|
-
base: 'bg-base-100 border-1 border-base-300 text-base-content text-sm outline-none transition-
|
|
2391
|
+
base: 'bg-base-100 border-1 border-base-300 text-base-content text-sm outline-none transition-all duration-300 ease-in-out',
|
|
2349
2392
|
variants: {
|
|
2350
2393
|
/** Inner padding scale of the clickable control. */
|
|
2351
2394
|
size: {
|
|
@@ -2377,6 +2420,10 @@ const mnFileInputVariants = tv({
|
|
|
2377
2420
|
dropzone: {
|
|
2378
2421
|
true: 'flex flex-col items-center justify-center gap-2 p-6 border-2 border-dashed text-center cursor-pointer hover:border-primary',
|
|
2379
2422
|
},
|
|
2423
|
+
/** Highlighted "release to drop" appearance while files hover the dropzone. */
|
|
2424
|
+
dragging: {
|
|
2425
|
+
true: 'border-primary bg-primary/10 ring-4 ring-primary/20 scale-[1.01] duration-150',
|
|
2426
|
+
},
|
|
2380
2427
|
/** Dimmed, non-interactive appearance. */
|
|
2381
2428
|
disabled: {
|
|
2382
2429
|
true: 'opacity-50 cursor-not-allowed pointer-events-none',
|
|
@@ -2396,6 +2443,10 @@ const mnFileInputVariants = tv({
|
|
|
2396
2443
|
* (and a file icon + name for non-images), supports single or multiple selection,
|
|
2397
2444
|
* several display layouts, and client-side `accept` / `maxSize` / `maxFiles` limits.
|
|
2398
2445
|
*
|
|
2446
|
+
* Every display mode but `compact` is also a real drop target: dragging files
|
|
2447
|
+
* over it switches the area to a highlighted "release to drop" state, and
|
|
2448
|
+
* dropping runs the files through the same validation as the file picker.
|
|
2449
|
+
*
|
|
2399
2450
|
* The form control value is the plain selection: `File | null` (single) or
|
|
2400
2451
|
* `File[]` (multiple). An optional `currentUrl`/`currentUrls` renders an
|
|
2401
2452
|
* already-saved image; removing it leaves the value untouched and emits `cleared`.
|
|
@@ -2421,6 +2472,8 @@ class MnFileInput {
|
|
|
2421
2472
|
uiConfig = {};
|
|
2422
2473
|
/** Currently selected files (always an array internally). */
|
|
2423
2474
|
files = signal([], ...(ngDevMode ? [{ debugName: "files" }] : []));
|
|
2475
|
+
/** True while files are dragged over the dropzone ("release to drop" state). */
|
|
2476
|
+
isDragging = signal(false, ...(ngDevMode ? [{ debugName: "isDragging" }] : []));
|
|
2424
2477
|
/** Transient message for a rejected selection (accept/maxSize/maxFiles). */
|
|
2425
2478
|
internalError = signal(null, ...(ngDevMode ? [{ debugName: "internalError" }] : []));
|
|
2426
2479
|
configService = inject(MnConfigService);
|
|
@@ -2463,6 +2516,11 @@ class MnFileInput {
|
|
|
2463
2516
|
}, ...(ngDevMode ? [{ debugName: "displayItems" }] : []));
|
|
2464
2517
|
/** Disabled state pushed by the forms API. */
|
|
2465
2518
|
formDisabled = false;
|
|
2519
|
+
/**
|
|
2520
|
+
* Nesting depth of the current drag, so that moving across child elements of
|
|
2521
|
+
* the dropzone does not flicker {@link isDragging} off and on again.
|
|
2522
|
+
*/
|
|
2523
|
+
dragDepth = 0;
|
|
2466
2524
|
/**
|
|
2467
2525
|
* Built-in default error messages in English.
|
|
2468
2526
|
* Used when `useBuiltInErrorMessages` is true (default); overridable per-field.
|
|
@@ -2482,6 +2540,13 @@ class MnFileInput {
|
|
|
2482
2540
|
get displayMode() {
|
|
2483
2541
|
return this.props.displayMode ?? 'dropzone';
|
|
2484
2542
|
}
|
|
2543
|
+
/**
|
|
2544
|
+
* Whether the current display mode acts as a drop target. `compact` is an
|
|
2545
|
+
* inline button sized for a form row, too small to aim a drag at.
|
|
2546
|
+
*/
|
|
2547
|
+
get supportsDrop() {
|
|
2548
|
+
return this.displayMode !== 'compact';
|
|
2549
|
+
}
|
|
2485
2550
|
/** Whether the control is disabled (via props or the forms API). */
|
|
2486
2551
|
get isDisabled() {
|
|
2487
2552
|
return this.formDisabled || !!this.props.disabled;
|
|
@@ -2507,6 +2572,7 @@ class MnFileInput {
|
|
|
2507
2572
|
shadow: this.props.shadow,
|
|
2508
2573
|
fullWidth: this.props.fullWidth ?? (this.displayMode !== 'compact'),
|
|
2509
2574
|
dropzone: this.displayMode === 'dropzone',
|
|
2575
|
+
dragging: this.isDragging(),
|
|
2510
2576
|
disabled: this.isDisabled,
|
|
2511
2577
|
});
|
|
2512
2578
|
}
|
|
@@ -2585,6 +2651,57 @@ class MnFileInput {
|
|
|
2585
2651
|
return;
|
|
2586
2652
|
this.addFiles(incoming);
|
|
2587
2653
|
}
|
|
2654
|
+
/**
|
|
2655
|
+
* Arms the "release to drop" state when a file drag enters the dropzone.
|
|
2656
|
+
* @param event The native dragenter event.
|
|
2657
|
+
*/
|
|
2658
|
+
onDragEnter(event) {
|
|
2659
|
+
if (!this.acceptsDrag(event))
|
|
2660
|
+
return;
|
|
2661
|
+
event.preventDefault();
|
|
2662
|
+
this.dragDepth++;
|
|
2663
|
+
this.isDragging.set(true);
|
|
2664
|
+
}
|
|
2665
|
+
/**
|
|
2666
|
+
* Keeps the drop target alive; without a prevented dragover the browser never
|
|
2667
|
+
* fires a drop event.
|
|
2668
|
+
* @param event The native dragover event.
|
|
2669
|
+
*/
|
|
2670
|
+
onDragOver(event) {
|
|
2671
|
+
if (!this.acceptsDrag(event))
|
|
2672
|
+
return;
|
|
2673
|
+
event.preventDefault();
|
|
2674
|
+
if (event.dataTransfer)
|
|
2675
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
2676
|
+
this.isDragging.set(true);
|
|
2677
|
+
}
|
|
2678
|
+
/**
|
|
2679
|
+
* Disarms the "release to drop" state once the drag has left the dropzone
|
|
2680
|
+
* entirely (and not merely crossed into one of its children).
|
|
2681
|
+
* @param event The native dragleave event.
|
|
2682
|
+
*/
|
|
2683
|
+
onDragLeave(event) {
|
|
2684
|
+
if (!this.isDragging())
|
|
2685
|
+
return;
|
|
2686
|
+
event.preventDefault();
|
|
2687
|
+
this.dragDepth = Math.max(0, this.dragDepth - 1);
|
|
2688
|
+
if (this.dragDepth === 0)
|
|
2689
|
+
this.isDragging.set(false);
|
|
2690
|
+
}
|
|
2691
|
+
/**
|
|
2692
|
+
* Accepts the dropped files through the same validation as the file picker.
|
|
2693
|
+
* @param event The native drop event.
|
|
2694
|
+
*/
|
|
2695
|
+
onDrop(event) {
|
|
2696
|
+
if (!this.acceptsDrag(event))
|
|
2697
|
+
return;
|
|
2698
|
+
event.preventDefault();
|
|
2699
|
+
this.resetDrag();
|
|
2700
|
+
const dropped = Array.from(event.dataTransfer?.files ?? []);
|
|
2701
|
+
if (dropped.length === 0)
|
|
2702
|
+
return;
|
|
2703
|
+
this.addFiles(dropped);
|
|
2704
|
+
}
|
|
2588
2705
|
/**
|
|
2589
2706
|
* Removes a newly-selected file by index.
|
|
2590
2707
|
* @param index Index into the current selection.
|
|
@@ -2636,6 +2753,7 @@ class MnFileInput {
|
|
|
2636
2753
|
const resolved = this.configService.resolve('mn-file-input', this.sectionPath, instanceId);
|
|
2637
2754
|
const builtIn = {
|
|
2638
2755
|
dropzoneHint: 'Click to upload or drag and drop',
|
|
2756
|
+
dropActiveHint: 'Release to drop',
|
|
2639
2757
|
replaceLabel: 'Replace',
|
|
2640
2758
|
removeLabel: 'Remove',
|
|
2641
2759
|
};
|
|
@@ -2644,6 +2762,8 @@ class MnFileInput {
|
|
|
2644
2762
|
this.uiConfig = { ...this.uiConfig, label: this.props.label };
|
|
2645
2763
|
if (this.props.dropzoneHint)
|
|
2646
2764
|
this.uiConfig = { ...this.uiConfig, dropzoneHint: this.props.dropzoneHint };
|
|
2765
|
+
if (this.props.dropActiveHint)
|
|
2766
|
+
this.uiConfig = { ...this.uiConfig, dropActiveHint: this.props.dropActiveHint };
|
|
2647
2767
|
if (this.props.replaceLabel)
|
|
2648
2768
|
this.uiConfig = { ...this.uiConfig, replaceLabel: this.props.replaceLabel };
|
|
2649
2769
|
if (this.props.removeLabel)
|
|
@@ -2748,6 +2868,23 @@ class MnFileInput {
|
|
|
2748
2868
|
}
|
|
2749
2869
|
return msgDef;
|
|
2750
2870
|
}
|
|
2871
|
+
/**
|
|
2872
|
+
* Whether a drag event should be treated as a file drop on this control.
|
|
2873
|
+
* Ignores disabled controls, modes without a drop target, and drags that
|
|
2874
|
+
* carry something other than files (selected text, a link, …) so the page
|
|
2875
|
+
* keeps its default behaviour.
|
|
2876
|
+
*/
|
|
2877
|
+
acceptsDrag(event) {
|
|
2878
|
+
if (this.isDisabled || !this.supportsDrop)
|
|
2879
|
+
return false;
|
|
2880
|
+
const types = event.dataTransfer?.types;
|
|
2881
|
+
return !types || Array.from(types).includes('Files');
|
|
2882
|
+
}
|
|
2883
|
+
/** Clears the drag state and its nesting counter. */
|
|
2884
|
+
resetDrag() {
|
|
2885
|
+
this.dragDepth = 0;
|
|
2886
|
+
this.isDragging.set(false);
|
|
2887
|
+
}
|
|
2751
2888
|
/** Checks a file against the configured `accept` filter (extensions and MIME globs). */
|
|
2752
2889
|
matchesAccept(file) {
|
|
2753
2890
|
const accept = this.props.accept;
|
|
@@ -2781,11 +2918,11 @@ class MnFileInput {
|
|
|
2781
2918
|
return segment || 'image';
|
|
2782
2919
|
}
|
|
2783
2920
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnFileInput, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2784
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnFileInput, isStandalone: true, selector: "mn-lib-file-input", inputs: { props: "props" }, outputs: { filesChange: "filesChange", cleared: "cleared" }, ngImport: i0, template: "<div [class.w-full]=\"props.fullWidth !== false && displayMode !== 'compact'\" class=\"flex flex-col\">\n <!-- Label -->\n @if (uiConfig.label) {\n <label [attr.for]=\"resolvedId\" class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\">\n <p>{{ uiConfig.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Hidden native file input shared by every trigger -->\n <input\n #fileInput\n (change)=\"onFileSelected($event)\"\n [accept]=\"acceptAttr\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || null\"\n [attr.multiple]=\"props.multiple || null\"\n [attr.name]=\"resolvedName\"\n [disabled]=\"isDisabled\"\n [id]=\"resolvedId\"\n class=\"hidden\"\n type=\"file\"\n />\n\n @switch (displayMode) {\n <!-- Compact: inline button + filename chips -->\n @case ('compact') {\n <div class=\"flex flex-wrap items-center gap-2\">\n <button\n (click)=\"fileInput.click()\"\n [disabled]=\"isDisabled\"\n [ngClass]=\"controlClasses\"\n class=\"inline-flex items-center gap-2 cursor-pointer hover:bg-base-200\"\n type=\"button\">\n <svg lucideUpload [size]=\"18\"></svg>\n <span class=\"text-sm\">{{ uiConfig.replaceLabel }}</span>\n </button>\n @for (item of displayItems(); track itemKey(item)) {\n <span class=\"inline-flex items-center gap-1 text-sm text-base-content/70\">\n @if (item.isImage && item.previewUrl) {\n <img [src]=\"item.previewUrl\" alt=\"\" class=\"h-6 w-6 rounded object-cover\"/>\n } @else {\n <svg lucideFile [size]=\"16\" class=\"text-base-content/50\"></svg>\n }\n <span class=\"max-w-40 truncate\">{{ item.name }}</span>\n <button\n (click)=\"item.existing ? removeExisting(item.index) : removeFile(item.index)\"\n [attr.aria-label]=\"uiConfig.removeLabel\"\n class=\"text-base-content/50 hover:text-error cursor-pointer\"\n type=\"button\">\n <svg lucideX [size]=\"14\"></svg>\n </button>\n </span>\n }\n </div>\n }\n\n <!-- List: compact rows of file icon + name + size -->\n @case ('list') {\n <button\n (click)=\"fileInput.click()\"\n [disabled]=\"isDisabled\"\n [ngClass]=\"controlClasses\"\n class=\"flex flex-row items-center justify-center gap-2 cursor-pointer hover:bg-base-200\"\n type=\"button\">\n <svg lucideUpload [size]=\"18\"></svg>\n <span class=\"text-sm\">{{ uiConfig.replaceLabel }}</span>\n </button>\n @if (displayItems().length > 0) {\n <div class=\"mt-2 flex flex-col gap-1\">\n @for (item of displayItems(); track itemKey(item)) {\n <div class=\"flex items-center gap-2 rounded-lg bg-base-200 px-3 py-1.5 text-sm\">\n <svg lucideFile [size]=\"16\" class=\"text-base-content/50\"></svg>\n <span class=\"flex-1 truncate text-base-content\">{{ item.name }}</span>\n @if (item.sizeLabel) {\n <span class=\"text-base-content/50\">{{ item.sizeLabel }}</span>\n }\n <button\n (click)=\"item.existing ? removeExisting(item.index) : removeFile(item.index)\"\n [attr.aria-label]=\"uiConfig.removeLabel\"\n class=\"text-base-content/50 hover:text-error cursor-pointer\"\n type=\"button\">\n <svg lucideX [size]=\"16\"></svg>\n </button>\n </div>\n }\n </div>\n }\n }\n\n <!-- Thumbnail: grid of tiles with overlay remove + add tile -->\n @case ('thumbnail') {\n <div class=\"flex flex-wrap gap-3\">\n @for (item of displayItems(); track itemKey(item)) {\n <div class=\"relative h-24 w-24 overflow-hidden rounded-xl border border-base-300 bg-base-100\">\n @if (item.isImage && item.previewUrl) {\n <img [src]=\"item.previewUrl\" alt=\"\" class=\"h-full w-full object-cover\"/>\n } @else {\n <div class=\"flex h-full w-full flex-col items-center justify-center gap-1 p-1 text-center\">\n <svg lucideFile [size]=\"22\" class=\"text-base-content/50\"></svg>\n <span class=\"w-full truncate text-xs text-base-content/60\">{{ item.name }}</span>\n </div>\n }\n <button\n (click)=\"item.existing ? removeExisting(item.index) : removeFile(item.index)\"\n [attr.aria-label]=\"uiConfig.removeLabel\"\n class=\"inline-flex h-6 w-6 items-center justify-center rounded-full cursor-pointer transition-colors absolute top-1 right-1 border-none bg-black/60 text-white hover:bg-black/80\"\n type=\"button\">\n <svg lucideX [size]=\"14\"></svg>\n </button>\n </div>\n }\n @if (props.multiple || displayItems().length === 0) {\n <button\n (click)=\"fileInput.click()\"\n [disabled]=\"isDisabled\"\n class=\"flex h-24 w-24 flex-col items-center justify-center gap-1 rounded-xl border-2 border-dashed border-base-300 text-base-content/50 hover:border-primary cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed\"\n type=\"button\">\n <svg lucideImagePlus [size]=\"22\"></svg>\n </button>\n }\n </div>\n }\n\n <!-- Dropzone (default): large dashed area + preview rows -->\n @default {\n <button\n (click)=\"fileInput.click()\"\n [disabled]=\"isDisabled\"\n [ngClass]=\"controlClasses\"\n type=\"button\">\n <svg lucideImagePlus [size]=\"28\" class=\"text-base-content/40\"></svg>\n <span class=\"text-sm text-base-content/60\">{{ uiConfig.dropzoneHint }}</span>\n </button>\n @if (displayItems().length > 0) {\n <div class=\"mt-2 flex flex-col gap-2\">\n @for (item of displayItems(); track itemKey(item)) {\n <div class=\"flex items-center gap-3 rounded-lg bg-base-200 p-2\">\n @if (item.isImage && item.previewUrl) {\n <img [src]=\"item.previewUrl\" alt=\"\" class=\"h-12 w-12 rounded-md object-cover\"/>\n } @else {\n <svg lucideFile [size]=\"24\" class=\"text-base-content/50\"></svg>\n }\n <span class=\"flex-1 truncate text-sm text-base-content\">{{ item.name }}</span>\n @if (item.sizeLabel) {\n <span class=\"text-sm text-base-content/50\">{{ item.sizeLabel }}</span>\n }\n <button\n (click)=\"item.existing ? removeExisting(item.index) : removeFile(item.index)\"\n [attr.aria-label]=\"uiConfig.removeLabel\"\n class=\"inline-flex h-8 w-8 items-center justify-center rounded-full cursor-pointer transition-colors hover:bg-base-200 text-base-content/60 hover:text-error\"\n type=\"button\">\n <svg lucideTrash2 [size]=\"16\"></svg>\n </button>\n </div>\n }\n </div>\n }\n }\n }\n\n <!-- Selection-limit error (accept / maxSize / maxFiles) -->\n @if (internalError(); as msg) {\n <mn-error-message [errorMessage]=\"msg\" [id]=\"resolvedId + '-selection'\"></mn-error-message>\n }\n\n <!-- Control validation errors -->\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n</div>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }, { kind: "component", type: LucideFile, selector: "svg[lucideFile]" }, { kind: "component", type: LucideImagePlus, selector: "svg[lucideImagePlus]" }, { kind: "component", type: LucideTrash2, selector: "svg[lucideTrash2]" }, { kind: "component", type: LucideUpload, selector: "svg[lucideUpload]" }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }] });
|
|
2921
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnFileInput, isStandalone: true, selector: "mn-lib-file-input", inputs: { props: "props" }, outputs: { filesChange: "filesChange", cleared: "cleared" }, ngImport: i0, template: "<!--\n Drag handling sits on the wrapper so a drop anywhere inside the control counts.\n onDragEnter/onDragLeave balance out as the drag crosses children, and\n acceptsDrag() ignores the modes that have no drop target.\n-->\n<div\n (dragenter)=\"onDragEnter($event)\"\n (dragleave)=\"onDragLeave($event)\"\n (dragover)=\"onDragOver($event)\"\n (drop)=\"onDrop($event)\"\n [class.w-full]=\"props.fullWidth !== false && displayMode !== 'compact'\"\n class=\"flex flex-col\">\n <!-- Label -->\n @if (uiConfig.label) {\n <label [attr.for]=\"resolvedId\" class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\">\n <p>{{ uiConfig.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Hidden native file input shared by every trigger -->\n <input\n #fileInput\n (change)=\"onFileSelected($event)\"\n [accept]=\"acceptAttr\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || null\"\n [attr.multiple]=\"props.multiple || null\"\n [attr.name]=\"resolvedName\"\n [disabled]=\"isDisabled\"\n [id]=\"resolvedId\"\n class=\"hidden\"\n type=\"file\"\n />\n\n @switch (displayMode) {\n <!-- Compact: inline button + filename chips -->\n @case ('compact') {\n <div class=\"flex flex-wrap items-center gap-2\">\n <button\n (click)=\"fileInput.click()\"\n [disabled]=\"isDisabled\"\n [ngClass]=\"controlClasses\"\n class=\"inline-flex items-center gap-2 cursor-pointer hover:bg-base-200\"\n type=\"button\">\n <svg lucideUpload [size]=\"18\"></svg>\n <span class=\"text-sm\">{{ uiConfig.replaceLabel }}</span>\n </button>\n @for (item of displayItems(); track itemKey(item)) {\n <span class=\"inline-flex items-center gap-1 text-sm text-base-content/70\">\n @if (item.isImage && item.previewUrl) {\n <img [src]=\"item.previewUrl\" alt=\"\" class=\"h-6 w-6 rounded object-cover\"/>\n } @else {\n <svg lucideFile [size]=\"16\" class=\"text-base-content/50\"></svg>\n }\n <span class=\"max-w-40 truncate\">{{ item.name }}</span>\n <button\n (click)=\"item.existing ? removeExisting(item.index) : removeFile(item.index)\"\n [attr.aria-label]=\"uiConfig.removeLabel\"\n class=\"text-base-content/50 hover:text-error cursor-pointer\"\n type=\"button\">\n <svg lucideX [size]=\"14\"></svg>\n </button>\n </span>\n }\n </div>\n }\n\n <!-- List: compact rows of file icon + name + size -->\n @case ('list') {\n <div class=\"relative flex flex-col\">\n <button\n (click)=\"fileInput.click()\"\n [disabled]=\"isDisabled\"\n [ngClass]=\"controlClasses\"\n class=\"flex flex-row items-center justify-center gap-2 cursor-pointer hover:bg-base-200\"\n type=\"button\">\n <svg lucideUpload [size]=\"18\"></svg>\n <span class=\"text-sm\">{{ uiConfig.replaceLabel }}</span>\n </button>\n @if (displayItems().length > 0) {\n <div class=\"mt-2 flex flex-col gap-1\">\n @for (item of displayItems(); track itemKey(item)) {\n <div class=\"flex items-center gap-2 rounded-lg bg-base-200 px-3 py-1.5 text-sm\">\n <svg lucideFile [size]=\"16\" class=\"text-base-content/50\"></svg>\n <span class=\"flex-1 truncate text-base-content\">{{ item.name }}</span>\n @if (item.sizeLabel) {\n <span class=\"text-base-content/50\">{{ item.sizeLabel }}</span>\n }\n <button\n (click)=\"item.existing ? removeExisting(item.index) : removeFile(item.index)\"\n [attr.aria-label]=\"uiConfig.removeLabel\"\n class=\"text-base-content/50 hover:text-error cursor-pointer\"\n type=\"button\">\n <svg lucideX [size]=\"16\"></svg>\n </button>\n </div>\n }\n </div>\n }\n @if (isDragging()) {\n <ng-container [ngTemplateOutlet]=\"dropOverlay\"></ng-container>\n }\n </div>\n }\n\n <!-- Thumbnail: grid of tiles with overlay remove + add tile -->\n @case ('thumbnail') {\n <div class=\"relative flex flex-wrap gap-3\">\n @for (item of displayItems(); track itemKey(item)) {\n <div class=\"relative h-24 w-24 overflow-hidden rounded-xl border border-base-300 bg-base-100\">\n @if (item.isImage && item.previewUrl) {\n <img [src]=\"item.previewUrl\" alt=\"\" class=\"h-full w-full object-cover\"/>\n } @else {\n <div class=\"flex h-full w-full flex-col items-center justify-center gap-1 p-1 text-center\">\n <svg lucideFile [size]=\"22\" class=\"text-base-content/50\"></svg>\n <span class=\"w-full truncate text-xs text-base-content/60\">{{ item.name }}</span>\n </div>\n }\n <button\n (click)=\"item.existing ? removeExisting(item.index) : removeFile(item.index)\"\n [attr.aria-label]=\"uiConfig.removeLabel\"\n class=\"inline-flex h-6 w-6 items-center justify-center rounded-full cursor-pointer transition-colors absolute top-1 right-1 border-none bg-black/60 text-white hover:bg-black/80\"\n type=\"button\">\n <svg lucideX [size]=\"14\"></svg>\n </button>\n </div>\n }\n @if (props.multiple || displayItems().length === 0) {\n <button\n (click)=\"fileInput.click()\"\n [disabled]=\"isDisabled\"\n class=\"flex h-24 w-24 flex-col items-center justify-center gap-1 rounded-xl border-2 border-dashed border-base-300 text-base-content/50 hover:border-primary cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed\"\n type=\"button\">\n <svg lucideImagePlus [size]=\"22\"></svg>\n </button>\n }\n @if (isDragging()) {\n <ng-container [ngTemplateOutlet]=\"dropOverlay\"></ng-container>\n }\n </div>\n }\n\n <!-- Dropzone (default): large dashed area + preview rows -->\n @default {\n <button\n (click)=\"fileInput.click()\"\n [disabled]=\"isDisabled\"\n [ngClass]=\"controlClasses\"\n type=\"button\">\n <!-- Inert so a drag crossing the icon/hint never reads as leaving the zone -->\n <span aria-live=\"polite\" class=\"pointer-events-none flex flex-col items-center gap-2\">\n @if (isDragging()) {\n <svg [size]=\"28\" class=\"animate-bounce text-primary\" lucideUpload></svg>\n <span class=\"text-sm font-medium text-primary\">{{ uiConfig.dropActiveHint }}</span>\n } @else {\n <svg [size]=\"28\" class=\"text-base-content/40\" lucideImagePlus></svg>\n <span class=\"text-sm text-base-content/60\">{{ uiConfig.dropzoneHint }}</span>\n }\n </span>\n </button>\n @if (displayItems().length > 0) {\n <div class=\"mt-2 flex flex-col gap-2\">\n @for (item of displayItems(); track itemKey(item)) {\n <div class=\"flex items-center gap-3 rounded-lg bg-base-200 p-2\">\n @if (item.isImage && item.previewUrl) {\n <img [src]=\"item.previewUrl\" alt=\"\" class=\"h-12 w-12 rounded-md object-cover\"/>\n } @else {\n <svg lucideFile [size]=\"24\" class=\"text-base-content/50\"></svg>\n }\n <span class=\"flex-1 truncate text-sm text-base-content\">{{ item.name }}</span>\n @if (item.sizeLabel) {\n <span class=\"text-sm text-base-content/50\">{{ item.sizeLabel }}</span>\n }\n <button\n (click)=\"item.existing ? removeExisting(item.index) : removeFile(item.index)\"\n [attr.aria-label]=\"uiConfig.removeLabel\"\n class=\"inline-flex h-8 w-8 items-center justify-center rounded-full cursor-pointer transition-colors text-error hover:bg-error/10\"\n type=\"button\">\n <svg lucideTrash2 [size]=\"16\"></svg>\n </button>\n </div>\n }\n </div>\n }\n }\n }\n\n <!-- Selection-limit error (accept / maxSize / maxFiles) -->\n @if (internalError(); as msg) {\n <mn-error-message [errorMessage]=\"msg\" [id]=\"resolvedId + '-selection'\"></mn-error-message>\n }\n\n <!-- Control validation errors -->\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n</div>\n\n<!--\n \"Release to drop\" cover for the modes that render their own layout instead of a\n dropzone. Absolutely positioned so arming it never shifts the layout, and inert\n so the drag keeps reaching the elements underneath.\n-->\n<ng-template #dropOverlay>\n <div\n aria-live=\"polite\"\n class=\"pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary bg-base-100/90 text-center\">\n <svg [size]=\"24\" class=\"animate-bounce text-primary\" lucideUpload></svg>\n <span class=\"px-2 text-sm font-medium text-primary\">{{ uiConfig.dropActiveHint }}</span>\n </div>\n</ng-template>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }, { kind: "component", type: LucideFile, selector: "svg[lucideFile]" }, { kind: "component", type: LucideImagePlus, selector: "svg[lucideImagePlus]" }, { kind: "component", type: LucideTrash2, selector: "svg[lucideTrash2]" }, { kind: "component", type: LucideUpload, selector: "svg[lucideUpload]" }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }] });
|
|
2785
2922
|
}
|
|
2786
2923
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnFileInput, decorators: [{
|
|
2787
2924
|
type: Component,
|
|
2788
|
-
args: [{ selector: 'mn-lib-file-input', standalone: true, imports: [CommonModule, NgClass, MnErrorMessage, LucideFile, LucideImagePlus, LucideTrash2, LucideUpload, LucideX], template: "<div
|
|
2925
|
+
args: [{ selector: 'mn-lib-file-input', standalone: true, imports: [CommonModule, NgClass, MnErrorMessage, LucideFile, LucideImagePlus, LucideTrash2, LucideUpload, LucideX], template: "<!--\n Drag handling sits on the wrapper so a drop anywhere inside the control counts.\n onDragEnter/onDragLeave balance out as the drag crosses children, and\n acceptsDrag() ignores the modes that have no drop target.\n-->\n<div\n (dragenter)=\"onDragEnter($event)\"\n (dragleave)=\"onDragLeave($event)\"\n (dragover)=\"onDragOver($event)\"\n (drop)=\"onDrop($event)\"\n [class.w-full]=\"props.fullWidth !== false && displayMode !== 'compact'\"\n class=\"flex flex-col\">\n <!-- Label -->\n @if (uiConfig.label) {\n <label [attr.for]=\"resolvedId\" class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\">\n <p>{{ uiConfig.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Hidden native file input shared by every trigger -->\n <input\n #fileInput\n (change)=\"onFileSelected($event)\"\n [accept]=\"acceptAttr\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || null\"\n [attr.multiple]=\"props.multiple || null\"\n [attr.name]=\"resolvedName\"\n [disabled]=\"isDisabled\"\n [id]=\"resolvedId\"\n class=\"hidden\"\n type=\"file\"\n />\n\n @switch (displayMode) {\n <!-- Compact: inline button + filename chips -->\n @case ('compact') {\n <div class=\"flex flex-wrap items-center gap-2\">\n <button\n (click)=\"fileInput.click()\"\n [disabled]=\"isDisabled\"\n [ngClass]=\"controlClasses\"\n class=\"inline-flex items-center gap-2 cursor-pointer hover:bg-base-200\"\n type=\"button\">\n <svg lucideUpload [size]=\"18\"></svg>\n <span class=\"text-sm\">{{ uiConfig.replaceLabel }}</span>\n </button>\n @for (item of displayItems(); track itemKey(item)) {\n <span class=\"inline-flex items-center gap-1 text-sm text-base-content/70\">\n @if (item.isImage && item.previewUrl) {\n <img [src]=\"item.previewUrl\" alt=\"\" class=\"h-6 w-6 rounded object-cover\"/>\n } @else {\n <svg lucideFile [size]=\"16\" class=\"text-base-content/50\"></svg>\n }\n <span class=\"max-w-40 truncate\">{{ item.name }}</span>\n <button\n (click)=\"item.existing ? removeExisting(item.index) : removeFile(item.index)\"\n [attr.aria-label]=\"uiConfig.removeLabel\"\n class=\"text-base-content/50 hover:text-error cursor-pointer\"\n type=\"button\">\n <svg lucideX [size]=\"14\"></svg>\n </button>\n </span>\n }\n </div>\n }\n\n <!-- List: compact rows of file icon + name + size -->\n @case ('list') {\n <div class=\"relative flex flex-col\">\n <button\n (click)=\"fileInput.click()\"\n [disabled]=\"isDisabled\"\n [ngClass]=\"controlClasses\"\n class=\"flex flex-row items-center justify-center gap-2 cursor-pointer hover:bg-base-200\"\n type=\"button\">\n <svg lucideUpload [size]=\"18\"></svg>\n <span class=\"text-sm\">{{ uiConfig.replaceLabel }}</span>\n </button>\n @if (displayItems().length > 0) {\n <div class=\"mt-2 flex flex-col gap-1\">\n @for (item of displayItems(); track itemKey(item)) {\n <div class=\"flex items-center gap-2 rounded-lg bg-base-200 px-3 py-1.5 text-sm\">\n <svg lucideFile [size]=\"16\" class=\"text-base-content/50\"></svg>\n <span class=\"flex-1 truncate text-base-content\">{{ item.name }}</span>\n @if (item.sizeLabel) {\n <span class=\"text-base-content/50\">{{ item.sizeLabel }}</span>\n }\n <button\n (click)=\"item.existing ? removeExisting(item.index) : removeFile(item.index)\"\n [attr.aria-label]=\"uiConfig.removeLabel\"\n class=\"text-base-content/50 hover:text-error cursor-pointer\"\n type=\"button\">\n <svg lucideX [size]=\"16\"></svg>\n </button>\n </div>\n }\n </div>\n }\n @if (isDragging()) {\n <ng-container [ngTemplateOutlet]=\"dropOverlay\"></ng-container>\n }\n </div>\n }\n\n <!-- Thumbnail: grid of tiles with overlay remove + add tile -->\n @case ('thumbnail') {\n <div class=\"relative flex flex-wrap gap-3\">\n @for (item of displayItems(); track itemKey(item)) {\n <div class=\"relative h-24 w-24 overflow-hidden rounded-xl border border-base-300 bg-base-100\">\n @if (item.isImage && item.previewUrl) {\n <img [src]=\"item.previewUrl\" alt=\"\" class=\"h-full w-full object-cover\"/>\n } @else {\n <div class=\"flex h-full w-full flex-col items-center justify-center gap-1 p-1 text-center\">\n <svg lucideFile [size]=\"22\" class=\"text-base-content/50\"></svg>\n <span class=\"w-full truncate text-xs text-base-content/60\">{{ item.name }}</span>\n </div>\n }\n <button\n (click)=\"item.existing ? removeExisting(item.index) : removeFile(item.index)\"\n [attr.aria-label]=\"uiConfig.removeLabel\"\n class=\"inline-flex h-6 w-6 items-center justify-center rounded-full cursor-pointer transition-colors absolute top-1 right-1 border-none bg-black/60 text-white hover:bg-black/80\"\n type=\"button\">\n <svg lucideX [size]=\"14\"></svg>\n </button>\n </div>\n }\n @if (props.multiple || displayItems().length === 0) {\n <button\n (click)=\"fileInput.click()\"\n [disabled]=\"isDisabled\"\n class=\"flex h-24 w-24 flex-col items-center justify-center gap-1 rounded-xl border-2 border-dashed border-base-300 text-base-content/50 hover:border-primary cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed\"\n type=\"button\">\n <svg lucideImagePlus [size]=\"22\"></svg>\n </button>\n }\n @if (isDragging()) {\n <ng-container [ngTemplateOutlet]=\"dropOverlay\"></ng-container>\n }\n </div>\n }\n\n <!-- Dropzone (default): large dashed area + preview rows -->\n @default {\n <button\n (click)=\"fileInput.click()\"\n [disabled]=\"isDisabled\"\n [ngClass]=\"controlClasses\"\n type=\"button\">\n <!-- Inert so a drag crossing the icon/hint never reads as leaving the zone -->\n <span aria-live=\"polite\" class=\"pointer-events-none flex flex-col items-center gap-2\">\n @if (isDragging()) {\n <svg [size]=\"28\" class=\"animate-bounce text-primary\" lucideUpload></svg>\n <span class=\"text-sm font-medium text-primary\">{{ uiConfig.dropActiveHint }}</span>\n } @else {\n <svg [size]=\"28\" class=\"text-base-content/40\" lucideImagePlus></svg>\n <span class=\"text-sm text-base-content/60\">{{ uiConfig.dropzoneHint }}</span>\n }\n </span>\n </button>\n @if (displayItems().length > 0) {\n <div class=\"mt-2 flex flex-col gap-2\">\n @for (item of displayItems(); track itemKey(item)) {\n <div class=\"flex items-center gap-3 rounded-lg bg-base-200 p-2\">\n @if (item.isImage && item.previewUrl) {\n <img [src]=\"item.previewUrl\" alt=\"\" class=\"h-12 w-12 rounded-md object-cover\"/>\n } @else {\n <svg lucideFile [size]=\"24\" class=\"text-base-content/50\"></svg>\n }\n <span class=\"flex-1 truncate text-sm text-base-content\">{{ item.name }}</span>\n @if (item.sizeLabel) {\n <span class=\"text-sm text-base-content/50\">{{ item.sizeLabel }}</span>\n }\n <button\n (click)=\"item.existing ? removeExisting(item.index) : removeFile(item.index)\"\n [attr.aria-label]=\"uiConfig.removeLabel\"\n class=\"inline-flex h-8 w-8 items-center justify-center rounded-full cursor-pointer transition-colors text-error hover:bg-error/10\"\n type=\"button\">\n <svg lucideTrash2 [size]=\"16\"></svg>\n </button>\n </div>\n }\n </div>\n }\n }\n }\n\n <!-- Selection-limit error (accept / maxSize / maxFiles) -->\n @if (internalError(); as msg) {\n <mn-error-message [errorMessage]=\"msg\" [id]=\"resolvedId + '-selection'\"></mn-error-message>\n }\n\n <!-- Control validation errors -->\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n</div>\n\n<!--\n \"Release to drop\" cover for the modes that render their own layout instead of a\n dropzone. Absolutely positioned so arming it never shifts the layout, and inert\n so the drag keeps reaching the elements underneath.\n-->\n<ng-template #dropOverlay>\n <div\n aria-live=\"polite\"\n class=\"pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary bg-base-100/90 text-center\">\n <svg [size]=\"24\" class=\"animate-bounce text-primary\" lucideUpload></svg>\n <span class=\"px-2 text-sm font-medium text-primary\">{{ uiConfig.dropActiveHint }}</span>\n </div>\n</ng-template>\n" }]
|
|
2789
2926
|
}], ctorParameters: () => [], propDecorators: { props: [{
|
|
2790
2927
|
type: Input,
|
|
2791
2928
|
args: [{ required: true }]
|
|
@@ -4005,8 +4142,14 @@ class MnMultiSelect {
|
|
|
4005
4142
|
/** Layout classes for the anchored popover panel. The mobile sheet is rendered by
|
|
4006
4143
|
* mn-bottom-sheet instead, so it no longer needs a branch here. */
|
|
4007
4144
|
panelClasses = 'fixed z-9999 bg-base-100 border border-base-300 rounded-md shadow-lg max-h-60 overflow-auto';
|
|
4145
|
+
/** Layout classes for the invisible click shield rendered under the anchored panel.
|
|
4146
|
+
* One step below the panel's z-index so the panel itself stays clickable, and above
|
|
4147
|
+
* any modal/drawer chrome (which tops out well under 9998). */
|
|
4148
|
+
shieldClasses = 'fixed inset-0 z-9998';
|
|
4008
4149
|
/** The anchored popover panel currently moved into `document.body`, if any. */
|
|
4009
4150
|
movedPanel = null;
|
|
4151
|
+
/** The click shield currently moved into `document.body`, if any. */
|
|
4152
|
+
movedShield = null;
|
|
4010
4153
|
/** Option count at which the search input auto-enables when `searchable` is unset. */
|
|
4011
4154
|
static DEFAULT_SEARCH_THRESHOLD = 8;
|
|
4012
4155
|
/** Tailwind's `sm` breakpoint — below this the panel renders as a bottom sheet.
|
|
@@ -4052,6 +4195,13 @@ class MnMultiSelect {
|
|
|
4052
4195
|
set dropdownRef(ref) {
|
|
4053
4196
|
this.movedPanel = this.portal(ref?.nativeElement ?? null, this.movedPanel);
|
|
4054
4197
|
}
|
|
4198
|
+
/**
|
|
4199
|
+
* The click shield sitting under the anchored panel, portalled alongside it for the same
|
|
4200
|
+
* reason: `position: fixed` must resolve against the viewport, not a transformed ancestor.
|
|
4201
|
+
*/
|
|
4202
|
+
set shieldRef(ref) {
|
|
4203
|
+
this.movedShield = this.portal(ref?.nativeElement ?? null, this.movedShield);
|
|
4204
|
+
}
|
|
4055
4205
|
/** Currently selected values */
|
|
4056
4206
|
selectedValues = [];
|
|
4057
4207
|
isOpen = false;
|
|
@@ -4125,6 +4275,7 @@ class MnMultiSelect {
|
|
|
4125
4275
|
this.unlockBodyScroll();
|
|
4126
4276
|
// Guarantee the portalled elements never outlive the component.
|
|
4127
4277
|
this.movedPanel = this.portal(null, this.movedPanel);
|
|
4278
|
+
this.movedShield = this.portal(null, this.movedShield);
|
|
4128
4279
|
this.movedSheet = this.portal(null, this.movedSheet);
|
|
4129
4280
|
});
|
|
4130
4281
|
}
|
|
@@ -4183,6 +4334,19 @@ class MnMultiSelect {
|
|
|
4183
4334
|
const threshold = this.props.searchThreshold ?? MnMultiSelect.DEFAULT_SEARCH_THRESHOLD;
|
|
4184
4335
|
return this.props.options.length >= threshold;
|
|
4185
4336
|
}
|
|
4337
|
+
/**
|
|
4338
|
+
* Dismisses the anchored panel from a shield click, and stops the event there.
|
|
4339
|
+
*
|
|
4340
|
+
* Swallowing it is the point: the shield spans the viewport, so the click would otherwise
|
|
4341
|
+
* land on whatever the panel was floating over. Inside a modal that is the modal's own
|
|
4342
|
+
* backdrop, and "close the dropdown" would double as "throw away the modal". A first click
|
|
4343
|
+
* that only dismisses the overlay is also how native selects and menus behave.
|
|
4344
|
+
*/
|
|
4345
|
+
onShieldClick(event) {
|
|
4346
|
+
event.stopPropagation();
|
|
4347
|
+
event.preventDefault();
|
|
4348
|
+
this.close();
|
|
4349
|
+
}
|
|
4186
4350
|
onDocumentClick(event) {
|
|
4187
4351
|
const target = event.target;
|
|
4188
4352
|
// The panel lives at the body root once open, so it is not a descendant of the
|
|
@@ -4510,11 +4674,11 @@ class MnMultiSelect {
|
|
|
4510
4674
|
});
|
|
4511
4675
|
}
|
|
4512
4676
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnMultiSelect, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
4513
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnMultiSelect, isStandalone: true, selector: "mn-lib-multi-select", inputs: { props: "props" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "onEscape()", "window:scroll": "onWindowScrollOrResize()", "window:resize": "onWindowScrollOrResize()" } }, viewQueries: [{ propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true }, { propertyName: "dropdownRef", first: true, predicate: ["dropdown"], descendants: true }, { propertyName: "sheetRef", first: true, predicate: ["sheet"], descendants: true, read: ElementRef }], ngImport: i0, template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ uiConfig.placeholder || props.placeholder || 'Select...' }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span\n class=\"inline-flex items-center gap-x-1 max-w-full min-w-0 bg-base-200 border border-accent text-base-content text-xs pl-2 py-0.5 rounded-md cursor-pointer\"\n (click)=\"removeOption(opt, $event)\">\n <span [attr.title]=\"opt.label\" class=\"truncate\">{{ opt.label }}</span>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary', hover: false }\"\n type=\"button\"\n class=\"text-base-content/50 cursor-pointer shrink-0\"\n (click)=\"removeOption(opt, $event)\"\n [attr.aria-label]=\"'Remove ' + opt.label\"\n ><svg lucideX [size]=\"18\"></svg></button>\n </span>\n }\n }\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n @if (isSheet) {\n <!-- On mobile the panel is presented as a shared bottom sheet: the sheet chrome\n (backdrop, grabber, swipe/flick-to-dismiss, slide animation) lives in\n mn-bottom-sheet; this component only projects the field's content into it.\n The sheet host is portalled to document.body (see the `sheet` ViewChild) so\n its `position: fixed` anchors to the viewport, not a transformed ancestor. -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"uiConfig.ariaLabel || uiConfig.label || props.label || uiConfig.placeholder || props.placeholder\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n class=\"flex flex-col flex-1 min-h-0 overflow-hidden\"\n role=\"listbox\"\n >\n <!-- The sheet covers its own trigger, so it needs a header to name the field and\n an explicit way out \u2014 tapping the trigger again is not reachable here. -->\n <div class=\"flex items-center justify-between gap-x-2 px-4 pt-1 pb-2 shrink-0\">\n <p class=\"text-base font-medium text-base-content truncate\">\n {{ uiConfig.label || props.label || uiConfig.placeholder || props.placeholder || '' }}\n </p>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"shrink-0 cursor-pointer\"\n (click)=\"close()\"\n [attr.aria-label]=\"uiConfig.closeLabel || 'Close'\"\n ><svg lucideX [size]=\"20\"></svg></button>\n </div>\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n </mn-bottom-sheet>\n } @else {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n (click)=\"$event.stopPropagation()\"\n [id]=\"resolvedId + '-listbox'\"\n [ngClass]=\"panelClasses\"\n [style.left]=\"dropdownStyle.left\"\n [style.top]=\"dropdownStyle.top\"\n [style.width]=\"dropdownStyle.width\"\n aria-multiselectable=\"true\"\n role=\"listbox\"\n >\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n }\n }\n\n <!-- The search box + option list, shared verbatim by the sheet and the anchored\n popover. `isSheet` only tunes spacing/sizing and which element scrolls: in sheet\n mode the list is the flex scroller; anchored, the popover itself scrolls. -->\n <ng-template #panelBody>\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n (click)=\"$event.stopPropagation()\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n class=\"border-b border-base-300 shrink-0\"\n >\n <mn-lib-input-field\n (ngModelChange)=\"onSearch($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [ngModel]=\"searchTerm\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm'\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\n class=\"flex items-center gap-x-2 cursor-pointer text-base-content hover:bg-base-200\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n class=\"w-4 h-4 accent-primary pointer-events-none shrink-0\"\n tabindex=\"-1\"\n type=\"checkbox\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\" class=\"text-base-content/50\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </ng-template>\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n", styles: [""], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnBottomSheet, selector: "mn-bottom-sheet", inputs: ["showBackdrop", "showGrabber", "dismissible", "minHeightPx", "maxHeightVh", "containerClass", "ariaLabel", "ariaLabelledby", "growWithKeyboard", "dismissGuard"], outputs: ["dismiss"] }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideChevronDown, selector: "svg[lucideChevronDown]" }] });
|
|
4677
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnMultiSelect, isStandalone: true, selector: "mn-lib-multi-select", inputs: { props: "props" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "onEscape()", "window:scroll": "onWindowScrollOrResize()", "window:resize": "onWindowScrollOrResize()" } }, viewQueries: [{ propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true }, { propertyName: "dropdownRef", first: true, predicate: ["dropdown"], descendants: true }, { propertyName: "shieldRef", first: true, predicate: ["shield"], descendants: true }, { propertyName: "sheetRef", first: true, predicate: ["sheet"], descendants: true, read: ElementRef }], ngImport: i0, template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ uiConfig.placeholder || props.placeholder || 'Select...' }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- Only the \u00D7 removes. The chip body deliberately carries no handler, so a click\n anywhere on it bubbles to the trigger and just opens/closes the panel \u2014 clicking\n the trigger to dismiss the dropdown must never silently delete a selection. -->\n <span\n class=\"inline-flex items-center gap-x-1 max-w-full min-w-0 bg-base-200 border border-accent text-base-content text-xs pl-2 py-0.5 rounded-md\">\n <span [attr.title]=\"opt.label\" class=\"truncate\">{{ opt.label }}</span>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary', hover: false }\"\n type=\"button\"\n class=\"text-base-content/50 cursor-pointer shrink-0\"\n (click)=\"removeOption(opt, $event)\"\n [attr.aria-label]=\"'Remove ' + opt.label\"\n ><svg lucideX [size]=\"18\"></svg></button>\n </span>\n }\n }\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n @if (isSheet) {\n <!-- On mobile the panel is presented as a shared bottom sheet: the sheet chrome\n (backdrop, grabber, swipe/flick-to-dismiss, slide animation) lives in\n mn-bottom-sheet; this component only projects the field's content into it.\n The sheet host is portalled to document.body (see the `sheet` ViewChild) so\n its `position: fixed` anchors to the viewport, not a transformed ancestor. -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"uiConfig.ariaLabel || uiConfig.label || props.label || uiConfig.placeholder || props.placeholder\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n class=\"flex flex-col flex-1 min-h-0 overflow-hidden\"\n role=\"listbox\"\n >\n <!-- The sheet covers its own trigger, so it needs a header to name the field and\n an explicit way out \u2014 tapping the trigger again is not reachable here. -->\n <div class=\"flex items-center justify-between gap-x-2 px-4 pt-1 pb-2 shrink-0\">\n <p class=\"text-base font-medium text-base-content truncate\">\n {{ uiConfig.label || props.label || uiConfig.placeholder || props.placeholder || '' }}\n </p>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"shrink-0 cursor-pointer\"\n (click)=\"close()\"\n [attr.aria-label]=\"uiConfig.closeLabel || 'Close'\"\n ><svg lucideX [size]=\"20\"></svg></button>\n </div>\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n </mn-bottom-sheet>\n } @else {\n <!-- A transparent full-viewport shield behind the panel, so \"click anywhere to\n dismiss\" is literally true. It also *consumes* that click: without it the click\n reaches whatever sits underneath \u2014 inside a modal that is the modal's own\n backdrop, so dismissing the dropdown would tear down the whole modal with it.\n Portalled to document.body for the same reason the panel is. It is aria-hidden\n and unfocusable: the keyboard equivalent of this click is Escape. -->\n <div\n #shield\n (click)=\"onShieldClick($event)\"\n [id]=\"resolvedId + '-shield'\"\n [ngClass]=\"shieldClasses\"\n aria-hidden=\"true\"\n ></div>\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n (click)=\"$event.stopPropagation()\"\n [id]=\"resolvedId + '-listbox'\"\n [ngClass]=\"panelClasses\"\n [style.left]=\"dropdownStyle.left\"\n [style.top]=\"dropdownStyle.top\"\n [style.width]=\"dropdownStyle.width\"\n aria-multiselectable=\"true\"\n role=\"listbox\"\n >\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n }\n }\n\n <!-- The search box + option list, shared verbatim by the sheet and the anchored\n popover. `isSheet` only tunes spacing/sizing and which element scrolls: in sheet\n mode the list is the flex scroller; anchored, the popover itself scrolls. -->\n <ng-template #panelBody>\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n (click)=\"$event.stopPropagation()\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n class=\"border-b border-base-300 shrink-0\"\n >\n <mn-lib-input-field\n (ngModelChange)=\"onSearch($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [ngModel]=\"searchTerm\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm'\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\n class=\"flex items-center gap-x-2 cursor-pointer text-base-content hover:bg-base-200\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n class=\"w-4 h-4 accent-primary pointer-events-none shrink-0\"\n tabindex=\"-1\"\n type=\"checkbox\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\" class=\"text-base-content/50\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </ng-template>\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n", styles: [""], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnBottomSheet, selector: "mn-bottom-sheet", inputs: ["showBackdrop", "showGrabber", "dismissible", "minHeightPx", "maxHeightVh", "containerClass", "ariaLabel", "ariaLabelledby", "growWithKeyboard", "dismissGuard"], outputs: ["dismiss"] }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideChevronDown, selector: "svg[lucideChevronDown]" }] });
|
|
4514
4678
|
}
|
|
4515
4679
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnMultiSelect, decorators: [{
|
|
4516
4680
|
type: Component,
|
|
4517
|
-
args: [{ selector: 'mn-lib-multi-select', standalone: true, imports: [NgClass, NgTemplateOutlet, FormsModule, MnErrorMessage, MnButton, MnInputField, MnBottomSheet, LucideX, LucideChevronDown], template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ uiConfig.placeholder || props.placeholder || 'Select...' }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!--
|
|
4681
|
+
args: [{ selector: 'mn-lib-multi-select', standalone: true, imports: [NgClass, NgTemplateOutlet, FormsModule, MnErrorMessage, MnButton, MnInputField, MnBottomSheet, LucideX, LucideChevronDown], template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ uiConfig.placeholder || props.placeholder || 'Select...' }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- Only the \u00D7 removes. The chip body deliberately carries no handler, so a click\n anywhere on it bubbles to the trigger and just opens/closes the panel \u2014 clicking\n the trigger to dismiss the dropdown must never silently delete a selection. -->\n <span\n class=\"inline-flex items-center gap-x-1 max-w-full min-w-0 bg-base-200 border border-accent text-base-content text-xs pl-2 py-0.5 rounded-md\">\n <span [attr.title]=\"opt.label\" class=\"truncate\">{{ opt.label }}</span>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary', hover: false }\"\n type=\"button\"\n class=\"text-base-content/50 cursor-pointer shrink-0\"\n (click)=\"removeOption(opt, $event)\"\n [attr.aria-label]=\"'Remove ' + opt.label\"\n ><svg lucideX [size]=\"18\"></svg></button>\n </span>\n }\n }\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n @if (isSheet) {\n <!-- On mobile the panel is presented as a shared bottom sheet: the sheet chrome\n (backdrop, grabber, swipe/flick-to-dismiss, slide animation) lives in\n mn-bottom-sheet; this component only projects the field's content into it.\n The sheet host is portalled to document.body (see the `sheet` ViewChild) so\n its `position: fixed` anchors to the viewport, not a transformed ancestor. -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"uiConfig.ariaLabel || uiConfig.label || props.label || uiConfig.placeholder || props.placeholder\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n class=\"flex flex-col flex-1 min-h-0 overflow-hidden\"\n role=\"listbox\"\n >\n <!-- The sheet covers its own trigger, so it needs a header to name the field and\n an explicit way out \u2014 tapping the trigger again is not reachable here. -->\n <div class=\"flex items-center justify-between gap-x-2 px-4 pt-1 pb-2 shrink-0\">\n <p class=\"text-base font-medium text-base-content truncate\">\n {{ uiConfig.label || props.label || uiConfig.placeholder || props.placeholder || '' }}\n </p>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"shrink-0 cursor-pointer\"\n (click)=\"close()\"\n [attr.aria-label]=\"uiConfig.closeLabel || 'Close'\"\n ><svg lucideX [size]=\"20\"></svg></button>\n </div>\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n </mn-bottom-sheet>\n } @else {\n <!-- A transparent full-viewport shield behind the panel, so \"click anywhere to\n dismiss\" is literally true. It also *consumes* that click: without it the click\n reaches whatever sits underneath \u2014 inside a modal that is the modal's own\n backdrop, so dismissing the dropdown would tear down the whole modal with it.\n Portalled to document.body for the same reason the panel is. It is aria-hidden\n and unfocusable: the keyboard equivalent of this click is Escape. -->\n <div\n #shield\n (click)=\"onShieldClick($event)\"\n [id]=\"resolvedId + '-shield'\"\n [ngClass]=\"shieldClasses\"\n aria-hidden=\"true\"\n ></div>\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n (click)=\"$event.stopPropagation()\"\n [id]=\"resolvedId + '-listbox'\"\n [ngClass]=\"panelClasses\"\n [style.left]=\"dropdownStyle.left\"\n [style.top]=\"dropdownStyle.top\"\n [style.width]=\"dropdownStyle.width\"\n aria-multiselectable=\"true\"\n role=\"listbox\"\n >\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n }\n }\n\n <!-- The search box + option list, shared verbatim by the sheet and the anchored\n popover. `isSheet` only tunes spacing/sizing and which element scrolls: in sheet\n mode the list is the flex scroller; anchored, the popover itself scrolls. -->\n <ng-template #panelBody>\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n (click)=\"$event.stopPropagation()\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n class=\"border-b border-base-300 shrink-0\"\n >\n <mn-lib-input-field\n (ngModelChange)=\"onSearch($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [ngModel]=\"searchTerm\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm'\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\n class=\"flex items-center gap-x-2 cursor-pointer text-base-content hover:bg-base-200\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n class=\"w-4 h-4 accent-primary pointer-events-none shrink-0\"\n tabindex=\"-1\"\n type=\"checkbox\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\" class=\"text-base-content/50\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </ng-template>\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n" }]
|
|
4518
4682
|
}], ctorParameters: () => [], propDecorators: { props: [{
|
|
4519
4683
|
type: Input,
|
|
4520
4684
|
args: [{ required: true }]
|
|
@@ -4524,6 +4688,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
|
|
|
4524
4688
|
}], dropdownRef: [{
|
|
4525
4689
|
type: ViewChild,
|
|
4526
4690
|
args: ['dropdown', { static: false }]
|
|
4691
|
+
}], shieldRef: [{
|
|
4692
|
+
type: ViewChild,
|
|
4693
|
+
args: ['shield', { static: false }]
|
|
4527
4694
|
}], sheetRef: [{
|
|
4528
4695
|
type: ViewChild,
|
|
4529
4696
|
args: ['sheet', { static: false, read: ElementRef }]
|
|
@@ -5153,6 +5320,182 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
|
|
|
5153
5320
|
args: ['window:resize', []]
|
|
5154
5321
|
}] } });
|
|
5155
5322
|
|
|
5323
|
+
/** QWERTY letter rows, lower-case. Rendered upper-case when {@link MnKeyboard.uppercase} is set. */
|
|
5324
|
+
const ALPHA_ROWS = [
|
|
5325
|
+
['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
|
|
5326
|
+
['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
|
|
5327
|
+
['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
|
5328
|
+
];
|
|
5329
|
+
/** The digit row that sits above the letters on an alphanumeric keyboard. */
|
|
5330
|
+
const DIGIT_ROW = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
|
|
5331
|
+
/** Phone-style pad rows for the numeric layout. */
|
|
5332
|
+
const NUMERIC_ROWS = [
|
|
5333
|
+
['1', '2', '3'],
|
|
5334
|
+
['4', '5', '6'],
|
|
5335
|
+
['7', '8', '9'],
|
|
5336
|
+
['0'],
|
|
5337
|
+
];
|
|
5338
|
+
/**
|
|
5339
|
+
* An on-screen keyboard for touch devices that have no keyboard of their own.
|
|
5340
|
+
*
|
|
5341
|
+
* It exists for unattended screens — a tablet on a wall, a kiosk by a door — where
|
|
5342
|
+
* the OS keyboard is either unavailable or unwanted, and where the same field may
|
|
5343
|
+
* need to take a membership number *or* a name. Hence one component with a
|
|
5344
|
+
* {@link layout} switch rather than a separate number pad and text pad.
|
|
5345
|
+
*
|
|
5346
|
+
* It is a controlled component: it never owns the text. The host passes {@link value}
|
|
5347
|
+
* and reacts to {@link valueChange}, exactly like a form control, so the same value
|
|
5348
|
+
* can also be filled by a barcode scanner or a real keyboard without this component
|
|
5349
|
+
* fighting it.
|
|
5350
|
+
*
|
|
5351
|
+
* With {@link presentation} set to `sheet` it mounts inside an {@link MnBottomSheet},
|
|
5352
|
+
* rising from the bottom of the screen and swipeable away — the shape a kiosk wants
|
|
5353
|
+
* so the keys do not permanently occupy half the display.
|
|
5354
|
+
*
|
|
5355
|
+
* The library ships no user-facing English, so every named key takes its caption
|
|
5356
|
+
* from {@link labels}.
|
|
5357
|
+
*/
|
|
5358
|
+
class MnKeyboard {
|
|
5359
|
+
/** The current text. This component never mutates it — see {@link valueChange}. */
|
|
5360
|
+
value = '';
|
|
5361
|
+
/** Which keys to render. */
|
|
5362
|
+
layout = 'alphanumeric';
|
|
5363
|
+
/** Whether the keys sit in the page or rise from the bottom as a sheet. */
|
|
5364
|
+
presentation = 'inline';
|
|
5365
|
+
/**
|
|
5366
|
+
* Captions and accessible names for the named keys.
|
|
5367
|
+
*
|
|
5368
|
+
* The defaults are language-neutral glyphs rather than words, so a consumer that
|
|
5369
|
+
* forgets to translate still ships no English (or Dutch) from the library. The
|
|
5370
|
+
* accessible name for the keyboard as a whole has no neutral glyph, so it
|
|
5371
|
+
* defaults to empty and the attribute is simply omitted until a host supplies one.
|
|
5372
|
+
*/
|
|
5373
|
+
labels = {
|
|
5374
|
+
backspace: '⌫',
|
|
5375
|
+
clear: '⨯',
|
|
5376
|
+
space: '␣',
|
|
5377
|
+
submit: '⏎',
|
|
5378
|
+
keyboard: '',
|
|
5379
|
+
};
|
|
5380
|
+
/** Whether letters are rendered (and typed) upper-case. */
|
|
5381
|
+
uppercase = false;
|
|
5382
|
+
/** Whether to offer a space key. Off for a field that can never contain one. */
|
|
5383
|
+
allowSpace = true;
|
|
5384
|
+
/** Whether to offer a submit key. */
|
|
5385
|
+
showSubmit = true;
|
|
5386
|
+
/** Whether the submit key is currently actionable. */
|
|
5387
|
+
submitDisabled = false;
|
|
5388
|
+
/** Hard cap on the text length, or null for none. */
|
|
5389
|
+
maxLength = null;
|
|
5390
|
+
/** Cap on the sheet height as a fraction of the viewport, in vh (sheet presentation only). */
|
|
5391
|
+
sheetMaxHeightVh = 60;
|
|
5392
|
+
/** Emits the full text after every key press, so the host can drive its own field. */
|
|
5393
|
+
valueChange = new EventEmitter();
|
|
5394
|
+
/** Emits when the submit key is pressed. */
|
|
5395
|
+
submitted = new EventEmitter();
|
|
5396
|
+
/** Emits when a sheet-presented keyboard is swiped or tapped away. */
|
|
5397
|
+
dismissed = new EventEmitter();
|
|
5398
|
+
get hostClasses() {
|
|
5399
|
+
return `mn-keyboard mn-keyboard-${this.layout}`;
|
|
5400
|
+
}
|
|
5401
|
+
/** The character rows to render, driven by {@link layout}. */
|
|
5402
|
+
get rows() {
|
|
5403
|
+
if (this.layout === 'numeric') {
|
|
5404
|
+
return NUMERIC_ROWS;
|
|
5405
|
+
}
|
|
5406
|
+
const letters = this.uppercase
|
|
5407
|
+
? ALPHA_ROWS.map((row) => row.map((key) => key.toUpperCase()))
|
|
5408
|
+
: ALPHA_ROWS;
|
|
5409
|
+
return this.layout === 'alpha' ? letters : [[...DIGIT_ROW], ...letters];
|
|
5410
|
+
}
|
|
5411
|
+
/** Whether the space key should be offered (never on a digits-only pad). */
|
|
5412
|
+
get spaceVisible() {
|
|
5413
|
+
return this.allowSpace && this.layout !== 'numeric';
|
|
5414
|
+
}
|
|
5415
|
+
/**
|
|
5416
|
+
* Appends a character, respecting {@link maxLength}.
|
|
5417
|
+
* @param key The character pressed.
|
|
5418
|
+
*/
|
|
5419
|
+
press(key) {
|
|
5420
|
+
this.emit(this.value + key);
|
|
5421
|
+
}
|
|
5422
|
+
/** Appends a space. */
|
|
5423
|
+
pressSpace() {
|
|
5424
|
+
this.emit(this.value + ' ');
|
|
5425
|
+
}
|
|
5426
|
+
/** Removes the last character. */
|
|
5427
|
+
backspace() {
|
|
5428
|
+
this.emit(this.value.slice(0, -1));
|
|
5429
|
+
}
|
|
5430
|
+
/** Empties the field. */
|
|
5431
|
+
clear() {
|
|
5432
|
+
this.emit('');
|
|
5433
|
+
}
|
|
5434
|
+
/** Reports a submit press. The host decides what submitting means. */
|
|
5435
|
+
submit() {
|
|
5436
|
+
if (this.submitDisabled) {
|
|
5437
|
+
return;
|
|
5438
|
+
}
|
|
5439
|
+
this.submitted.emit(this.value);
|
|
5440
|
+
}
|
|
5441
|
+
/** Reports that a sheet-presented keyboard was dismissed. */
|
|
5442
|
+
onDismiss() {
|
|
5443
|
+
this.dismissed.emit();
|
|
5444
|
+
}
|
|
5445
|
+
/**
|
|
5446
|
+
* Emits a new value, clamped to {@link maxLength}.
|
|
5447
|
+
*
|
|
5448
|
+
* Clamping happens here rather than in each key handler so a paste-like burst
|
|
5449
|
+
* from a scanner and a tapped key are bounded the same way.
|
|
5450
|
+
* @param next The candidate text.
|
|
5451
|
+
*/
|
|
5452
|
+
emit(next) {
|
|
5453
|
+
const clamped = this.maxLength !== null && next.length > this.maxLength
|
|
5454
|
+
? next.slice(0, this.maxLength)
|
|
5455
|
+
: next;
|
|
5456
|
+
if (clamped === this.value) {
|
|
5457
|
+
return;
|
|
5458
|
+
}
|
|
5459
|
+
this.value = clamped;
|
|
5460
|
+
this.valueChange.emit(clamped);
|
|
5461
|
+
}
|
|
5462
|
+
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
|
+
}
|
|
5465
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnKeyboard, decorators: [{
|
|
5466
|
+
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
|
+
}], propDecorators: { value: [{
|
|
5469
|
+
type: Input
|
|
5470
|
+
}], layout: [{
|
|
5471
|
+
type: Input
|
|
5472
|
+
}], presentation: [{
|
|
5473
|
+
type: Input
|
|
5474
|
+
}], labels: [{
|
|
5475
|
+
type: Input
|
|
5476
|
+
}], uppercase: [{
|
|
5477
|
+
type: Input
|
|
5478
|
+
}], allowSpace: [{
|
|
5479
|
+
type: Input
|
|
5480
|
+
}], showSubmit: [{
|
|
5481
|
+
type: Input
|
|
5482
|
+
}], submitDisabled: [{
|
|
5483
|
+
type: Input
|
|
5484
|
+
}], maxLength: [{
|
|
5485
|
+
type: Input
|
|
5486
|
+
}], sheetMaxHeightVh: [{
|
|
5487
|
+
type: Input
|
|
5488
|
+
}], valueChange: [{
|
|
5489
|
+
type: Output
|
|
5490
|
+
}], submitted: [{
|
|
5491
|
+
type: Output
|
|
5492
|
+
}], dismissed: [{
|
|
5493
|
+
type: Output
|
|
5494
|
+
}], hostClasses: [{
|
|
5495
|
+
type: HostBinding,
|
|
5496
|
+
args: ['class']
|
|
5497
|
+
}] } });
|
|
5498
|
+
|
|
5156
5499
|
const mnSelectVariants = tv({
|
|
5157
5500
|
base: 'bg-base-100 border-1 border-base-300 text-base-content text-sm cursor-pointer hover:bg-base-200 transition-colors duration-300',
|
|
5158
5501
|
variants: {
|
|
@@ -9006,11 +9349,11 @@ class MnFormBodyComponent {
|
|
|
9006
9349
|
}
|
|
9007
9350
|
}
|
|
9008
9351
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnFormBodyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
9009
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnFormBodyComponent, isStandalone: true, selector: "mn-form-body", inputs: { config: "config", modalRef: "modalRef", hideFooter: "hideFooter", hideCustomBody: "hideCustomBody" }, outputs: { formStatusChange: "formStatusChange" }, viewQueries: [{ propertyName: "inputFields", predicate: MnInputField, descendants: true }, { propertyName: "textareas", predicate: MnTextarea, descendants: true }], ngImport: i0, template: "@if ((config.component || config.template) && !hideCustomBody) {\n <mn-custom-body-host\n [config]=\"asAny(config)\"\n [modalRef]=\"asAny(modalRef)\"\n class=\"mb-6 block\"\n ></mn-custom-body-host>\n}\n\n<form (ngSubmit)=\"submit()\" [formGroup]=\"form\" class=\"flex flex-col gap-6 h-full\">\n <!-- Shared field rendering template (must be inside form for formControlName) -->\n <ng-template #fieldTemplate let-rowField>\n @switch (rowField.field.kind) {\n @case (FieldKind.TEXT) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'text',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n mask: asField(rowField.field).mask,\n autocomplete: asField(rowField.field).autocomplete,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.NUMBER) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'number',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.PASSWORD) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'password',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.SELECT) {\n <div class=\"flex flex-col\">\n <mn-lib-select\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"getSelectProps(rowField.field)\"\n ></mn-lib-select>\n @if (isFieldLoading(asKey(rowField.field.key))) {\n <div class=\"flex items-center gap-1 pl-2 pt-1\">\n <div class=\"w-4 h-4 border-2 border-base-300 border-t-blue-500 rounded-full animate-spin\"></div>\n </div>\n }\n @if (getFieldError(asKey(rowField.field.key))) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ getFieldError(asKey(rowField.field.key)) }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.CHECKBOX) {\n <div>\n <mn-lib-checkbox\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n disabled: isFieldDisabled(rowField.field) || isFieldReadOnly(rowField.field)\n })\"\n ></mn-lib-checkbox>\n </div>\n }\n\n @case (FieldKind.DATE) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'date',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n startDate: asField(rowField.field).minDate,\n endDate: asField(rowField.field).maxDate,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.DATETIME) {\n <div>\n <mn-lib-datetime\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n mode: asField(rowField.field).mode || 'datetime-local',\n min: asField(rowField.field).min,\n max: asField(rowField.field).max,\n step: asField(rowField.field).step,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-datetime>\n </div>\n }\n\n @case (FieldKind.TEXTAREA) {\n <div>\n <mn-lib-textarea\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n rows: asField(rowField.field).rows || 4,\n resize: 'vertical',\n autocomplete: asField(rowField.field).autocomplete,\n readonly: isFieldReadOnly(rowField.field)\n })\"\n ></mn-lib-textarea>\n </div>\n }\n\n @case (FieldKind.MULTI_SELECT) {\n <div>\n @if (isFieldLoading(asKey(rowField.field.key))) {\n <div class=\"flex items-center gap-2 py-2 text-sm text-base-content/50\">\n <div class=\"w-4 h-4 border-2 border-base-300 border-t-blue-500 rounded-full animate-spin\"></div>\n {{ labels.loadingOptions }}\n </div>\n }\n @if (!isFieldLoading(asKey(rowField.field.key))) {\n <mn-lib-multi-select\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n options: getFieldOptions(rowField.field),\n placeholder: asField(rowField.field).placeholder,\n searchable: asField(rowField.field).searchable,\n searchPlaceholder: asField(rowField.field).searchPlaceholder,\n maxSelections: asField(rowField.field).maxSelections,\n collapseThreshold: asField(rowField.field).collapseThreshold,\n collapsePlaceholder: asField(rowField.field).collapsePlaceholder,\n allSelectedPlaceholder: asField(rowField.field).allSelectedPlaceholder,\n disabled: isFieldDisabled(rowField.field) || isFieldReadOnly(rowField.field)\n })\"\n ></mn-lib-multi-select>\n }\n @if (getFieldError(asKey(rowField.field.key))) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ getFieldError(asKey(rowField.field.key)) }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.MULTI_SELECT_TABLE) {\n <ng-container [ngTemplateOutlet]=\"selectTableTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n }\n\n @case (FieldKind.SINGLE_SELECT_TABLE) {\n <ng-container [ngTemplateOutlet]=\"selectTableTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n }\n\n @case (FieldKind.COLOR) {\n <div class=\"flex flex-col gap-1\">\n <label [for]=\"'field-' + asKey(rowField.field.key)\"\n class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n <div class=\"flex items-center gap-3\">\n <input\n type=\"color\"\n [id]=\"'field-' + asKey(rowField.field.key)\"\n class=\"w-10 h-10 rounded-lg border border-base-300 cursor-pointer p-0.5\"\n [value]=\"getColorValue(rowField.field)\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (input)=\"onColorChange(rowField.field, $event)\"\n />\n <span class=\"text-sm text-base-content/60 font-mono\">{{ getColorValue(rowField.field) }}</span>\n </div>\n @if (asField(rowField.field).swatches) {\n <div class=\"flex gap-1.5 mt-1\">\n @for (swatch of asField(rowField.field).swatches; track swatch) {\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"w-6 h-6 rounded-md border border-base-300 cursor-pointer transition-transform hover:scale-110\"\n [style.background-color]=\"swatch\"\n [class.ring-2]=\"getColorValue(rowField.field) === swatch\"\n [class.ring-blue-500]=\"getColorValue(rowField.field) === swatch\"\n (click)=\"setColorFromSwatch(rowField.field, swatch)\"\n [attr.aria-label]=\"'Select color ' + swatch\"\n ></button>\n }\n </div>\n }\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.RATING) {\n <div class=\"flex flex-col gap-1\">\n <span [id]=\"'rating-label-' + asKey(rowField.field.key)\"\n class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </span>\n <div [attr.aria-labelledby]=\"'rating-label-' + asKey(rowField.field.key)\" class=\"flex items-center gap-1\"\n role=\"group\">\n @for (star of getRatingRange(rowField.field); track star) {\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"text-2xl cursor-pointer transition-colors focus:outline-none\"\n [class.text-yellow-400]=\"star <= getRatingValue(rowField.field)\"\n [class.text-base-300]=\"star > getRatingValue(rowField.field)\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (click)=\"setRating(rowField.field, star)\"\n >\n ★\n </button>\n }\n <span class=\"text-sm text-base-content/50 ml-2\">{{ getRatingValue(rowField.field) }} / {{ asField(rowField.field).max || 5 }}</span>\n </div>\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.SLIDER) {\n <div class=\"flex flex-col gap-1\">\n <label [for]=\"'field-' + asKey(rowField.field.key)\"\n class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n <div class=\"flex items-center gap-3\">\n <input\n type=\"range\"\n [id]=\"'field-' + asKey(rowField.field.key)\"\n class=\"flex-1 h-2 bg-base-200 rounded-lg appearance-none cursor-pointer accent-blue-500\"\n [attr.min]=\"asField(rowField.field).min ?? 0\"\n [attr.max]=\"asField(rowField.field).max ?? 100\"\n [attr.step]=\"asField(rowField.field).step ?? 1\"\n [value]=\"getSliderValue(rowField.field)\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (input)=\"onSliderChange(rowField.field, $event)\"\n />\n @if (asField(rowField.field).showValue !== false) {\n <span class=\"text-sm text-base-content/60 min-w-[3rem] text-right\">\n {{ getSliderValue(rowField.field) }}{{ asField(rowField.field).unit || '' }}\n </span>\n }\n </div>\n <div class=\"flex justify-between text-xs text-base-content/40 px-1\">\n <span>{{ asField(rowField.field).min ?? 0 }}</span>\n <span>{{ asField(rowField.field).max ?? 100 }}</span>\n </div>\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.FILE) {\n <div>\n <mn-lib-file-input\n (cleared)=\"onFileCleared(rowField.field)\"\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n accept: asField(rowField.field).accept,\n multiple: asField(rowField.field).multiple,\n maxFiles: asField(rowField.field).maxFiles,\n maxSize: asField(rowField.field).maxSize,\n displayMode: asField(rowField.field).displayMode,\n dropzoneHint: asField(rowField.field).dropzoneHint,\n replaceLabel: asField(rowField.field).replaceLabel,\n removeLabel: asField(rowField.field).removeLabel,\n currentUrl: asField(rowField.field).currentUrl,\n currentUrls: asField(rowField.field).currentUrls,\n disabled: isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\n })\"\n ></mn-lib-file-input>\n </div>\n }\n\n @case (FieldKind.CUSTOM) {\n <div>\n <ng-container\n mnCustomFieldHost\n [component]=\"asField(rowField.field).component\"\n [inputs]=\"asField(rowField.field).inputs\"\n [formControlName]=\"asKey(rowField.field.key)\"\n ></ng-container>\n </div>\n }\n\n @default {\n <div></div>\n }\n }\n\n <!-- Show cross-field error below any field that has one -->\n @if (getFieldError(asKey(rowField.field.key)) && rowField.field.kind !== FieldKind.SELECT && rowField.field.kind !== FieldKind.MULTI_SELECT) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ getFieldError(asKey(rowField.field.key)) }}\n </div>\n }\n </ng-template>\n\n <!-- Shared template for MULTI_SELECT_TABLE and SINGLE_SELECT_TABLE -->\n <ng-template #selectTableTemplate let-rowField>\n <div class=\"flex flex-col gap-1\">\n @if (asField(rowField.field).label) {\n <span class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </span>\n }\n <mn-table\n [dataSource]=\"tableDataSources[asKey(rowField.field.key)]\"\n [attr.aria-label]=\"asField(rowField.field).label || null\"\n (selectionChange)=\"onTableSelectionChange(rowField.field, $event)\"\n ></mn-table>\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n </ng-template>\n\n <!-- Field Groups (sections with headers) -->\n @if (fieldGroups.length > 0) {\n <div class=\"flex flex-col gap-6\">\n @for (group of fieldGroups; track group.title) {\n <div class=\"flex flex-col gap-4\" [style.display]=\"isGroupVisible(group) ? '' : 'none'\">\n <div class=\"border-b border-base-300 pb-2\">\n <h3 class=\"text-base font-semibold text-base-content\">{{ group.title }}</h3>\n @if (group.description) {\n <p class=\"text-sm text-base-content/50 mt-0.5\">{{ group.description }}</p>\n }\n </div>\n @for (row of group.rows; track $index) {\n <div [style.--mn-form-cols]=\"row.columns || 1\" class=\"grid gap-4 mn-form-row\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div [style.--mn-form-span]=\"rowField.span || 1\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\"\n class=\"flex flex-col gap-2 mn-form-cell\">\n <ng-container [ngTemplateOutlet]=\"fieldTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n </div>\n }\n </div>\n }\n </div>\n }\n </div>\n }\n\n <!-- Standard rows (no groups) -->\n @if (rows.length > 0) {\n <div class=\"flex flex-col gap-4\">\n @for (row of rows; track $index) {\n <div [style.--mn-form-cols]=\"row.columns || 1\" class=\"grid gap-4 mn-form-row\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div [style.--mn-form-span]=\"rowField.span || 1\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\"\n class=\"flex flex-col gap-2 mn-form-cell\">\n <ng-container [ngTemplateOutlet]=\"fieldTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n </div>\n }\n </div>\n }\n </div>\n }\n\n <!-- Form-level errors (not tied to a specific field) -->\n @if (formErrors['_form']) {\n <div class=\"text-red-500 text-sm px-2 py-1 bg-red-50 rounded-md\">\n {{ formErrors['_form'] }}\n </div>\n }\n\n @if (!hideFooter) {\n <div class=\"flex gap-3 pt-4 pb-6 border-t border-base-300 mt-auto sticky bottom-0 bg-base-100 z-10\">\n <button\n mnButton\n type=\"button\"\n [data]=\"{ variant: 'outline', color: 'secondary' }\"\n (mousedown)=\"modalRef.dismiss(ModalCloseReason.CANCELLED)\"\n >\n @if (cancelIcon; as icon) {\n <svg [lucideIcon]=\"icon\" [size]=\"actionIconSize\" class=\"mr-2\"></svg>\n }\n {{ labels.cancel }}\n </button>\n\n <div class=\"flex-1\"></div>\n\n <button\n mnButton\n type=\"submit\"\n [data]=\"{ variant: 'fill', color: 'primary', disabled: form.invalid || isSubmitting }\"\n [disabled]=\"form.invalid || isSubmitting\"\n >\n @if (submitIcon; as icon) {\n <svg [lucideIcon]=\"icon\" [size]=\"actionIconSize\" class=\"mr-2\"></svg>\n }\n {{ isSubmitting ? labels.submitting : labels.submit }}\n </button>\n </div>\n }\n</form>\n", styles: [".select-arrow{background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 8L1 3h10z'/%3E%3C/svg%3E\");background-repeat:no-repeat;background-position:right .75rem center}.mn-form-row{grid-template-columns:repeat(var(--mn-form-cols, 1),minmax(0,1fr))}.mn-form-cell{grid-column:span var(--mn-form-span, 1)}@media(max-width:639.98px){.mn-form-row{grid-template-columns:1fr}.mn-form-cell{grid-column:auto}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnCheckbox, selector: "mn-lib-checkbox", inputs: ["props", "checked"], outputs: ["checkedChange"] }, { kind: "component", type: MnDatetime, selector: "mn-lib-datetime", inputs: ["props"] }, { kind: "component", type: MnMultiSelect, selector: "mn-lib-multi-select", inputs: ["props"] }, { kind: "component", type: MnTextarea, selector: "mn-lib-textarea", inputs: ["props"] }, { kind: "component", type: MnFileInput, selector: "mn-lib-file-input", inputs: ["props"], outputs: ["filesChange", "cleared"] }, { kind: "component", type: MnSelect, selector: "mn-lib-select", inputs: ["props"] }, { kind: "directive", type: MnCustomFieldHostDirective, selector: "[mnCustomFieldHost]", inputs: ["component", "inputs"] }, { kind: "component", type: MnTable, selector: "mn-table", outputs: ["sortChange", "rowClick"] }, { kind: "component", type: MnCustomBodyHostComponent, selector: "mn-custom-body-host", inputs: ["config", "modalRef"] }, { kind: "component", type: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }] });
|
|
9352
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnFormBodyComponent, isStandalone: true, selector: "mn-form-body", inputs: { config: "config", modalRef: "modalRef", hideFooter: "hideFooter", hideCustomBody: "hideCustomBody" }, outputs: { formStatusChange: "formStatusChange" }, viewQueries: [{ propertyName: "inputFields", predicate: MnInputField, descendants: true }, { propertyName: "textareas", predicate: MnTextarea, descendants: true }], ngImport: i0, template: "@if ((config.component || config.template) && !hideCustomBody) {\n <mn-custom-body-host\n [config]=\"asAny(config)\"\n [modalRef]=\"asAny(modalRef)\"\n class=\"mb-6 block\"\n ></mn-custom-body-host>\n}\n\n<form (ngSubmit)=\"submit()\" [formGroup]=\"form\" class=\"flex flex-col gap-6 h-full\">\n <!-- Shared field rendering template (must be inside form for formControlName) -->\n <ng-template #fieldTemplate let-rowField>\n @switch (rowField.field.kind) {\n @case (FieldKind.TEXT) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'text',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n mask: asField(rowField.field).mask,\n autocomplete: asField(rowField.field).autocomplete,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.NUMBER) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'number',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.PASSWORD) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'password',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.SELECT) {\n <div class=\"flex flex-col\">\n <mn-lib-select\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"getSelectProps(rowField.field)\"\n ></mn-lib-select>\n @if (isFieldLoading(asKey(rowField.field.key))) {\n <div class=\"flex items-center gap-1 pl-2 pt-1\">\n <div class=\"w-4 h-4 border-2 border-base-300 border-t-blue-500 rounded-full animate-spin\"></div>\n </div>\n }\n @if (getFieldError(asKey(rowField.field.key))) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ getFieldError(asKey(rowField.field.key)) }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.CHECKBOX) {\n <div>\n <mn-lib-checkbox\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n disabled: isFieldDisabled(rowField.field) || isFieldReadOnly(rowField.field)\n })\"\n ></mn-lib-checkbox>\n </div>\n }\n\n @case (FieldKind.DATE) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'date',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n startDate: asField(rowField.field).minDate,\n endDate: asField(rowField.field).maxDate,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.DATETIME) {\n <div>\n <mn-lib-datetime\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n mode: asField(rowField.field).mode || 'datetime-local',\n min: asField(rowField.field).min,\n max: asField(rowField.field).max,\n step: asField(rowField.field).step,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-datetime>\n </div>\n }\n\n @case (FieldKind.TEXTAREA) {\n <div>\n <mn-lib-textarea\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n rows: asField(rowField.field).rows || 4,\n resize: 'vertical',\n autocomplete: asField(rowField.field).autocomplete,\n readonly: isFieldReadOnly(rowField.field)\n })\"\n ></mn-lib-textarea>\n </div>\n }\n\n @case (FieldKind.MULTI_SELECT) {\n <div>\n @if (isFieldLoading(asKey(rowField.field.key))) {\n <div class=\"flex items-center gap-2 py-2 text-sm text-base-content/50\">\n <div class=\"w-4 h-4 border-2 border-base-300 border-t-blue-500 rounded-full animate-spin\"></div>\n {{ labels.loadingOptions }}\n </div>\n }\n @if (!isFieldLoading(asKey(rowField.field.key))) {\n <mn-lib-multi-select\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n options: getFieldOptions(rowField.field),\n placeholder: asField(rowField.field).placeholder,\n searchable: asField(rowField.field).searchable,\n searchPlaceholder: asField(rowField.field).searchPlaceholder,\n maxSelections: asField(rowField.field).maxSelections,\n collapseThreshold: asField(rowField.field).collapseThreshold,\n collapsePlaceholder: asField(rowField.field).collapsePlaceholder,\n allSelectedPlaceholder: asField(rowField.field).allSelectedPlaceholder,\n disabled: isFieldDisabled(rowField.field) || isFieldReadOnly(rowField.field)\n })\"\n ></mn-lib-multi-select>\n }\n @if (getFieldError(asKey(rowField.field.key))) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ getFieldError(asKey(rowField.field.key)) }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.MULTI_SELECT_TABLE) {\n <ng-container [ngTemplateOutlet]=\"selectTableTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n }\n\n @case (FieldKind.SINGLE_SELECT_TABLE) {\n <ng-container [ngTemplateOutlet]=\"selectTableTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n }\n\n @case (FieldKind.COLOR) {\n <div class=\"flex flex-col gap-1\">\n <label [for]=\"'field-' + asKey(rowField.field.key)\"\n class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n <div class=\"flex items-center gap-3\">\n <input\n type=\"color\"\n [id]=\"'field-' + asKey(rowField.field.key)\"\n class=\"w-10 h-10 rounded-lg border border-base-300 cursor-pointer p-0.5\"\n [value]=\"getColorValue(rowField.field)\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (input)=\"onColorChange(rowField.field, $event)\"\n />\n <span class=\"text-sm text-base-content/60 font-mono\">{{ getColorValue(rowField.field) }}</span>\n </div>\n @if (asField(rowField.field).swatches) {\n <div class=\"flex gap-1.5 mt-1\">\n @for (swatch of asField(rowField.field).swatches; track swatch) {\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"w-6 h-6 rounded-md border border-base-300 cursor-pointer transition-transform hover:scale-110\"\n [style.background-color]=\"swatch\"\n [class.ring-2]=\"getColorValue(rowField.field) === swatch\"\n [class.ring-blue-500]=\"getColorValue(rowField.field) === swatch\"\n (click)=\"setColorFromSwatch(rowField.field, swatch)\"\n [attr.aria-label]=\"'Select color ' + swatch\"\n ></button>\n }\n </div>\n }\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.RATING) {\n <div class=\"flex flex-col gap-1\">\n <span [id]=\"'rating-label-' + asKey(rowField.field.key)\"\n class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </span>\n <div [attr.aria-labelledby]=\"'rating-label-' + asKey(rowField.field.key)\" class=\"flex items-center gap-1\"\n role=\"group\">\n @for (star of getRatingRange(rowField.field); track star) {\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"text-2xl cursor-pointer transition-colors focus:outline-none\"\n [class.text-yellow-400]=\"star <= getRatingValue(rowField.field)\"\n [class.text-base-300]=\"star > getRatingValue(rowField.field)\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (click)=\"setRating(rowField.field, star)\"\n >\n ★\n </button>\n }\n <span class=\"text-sm text-base-content/50 ml-2\">{{ getRatingValue(rowField.field) }} / {{ asField(rowField.field).max || 5 }}</span>\n </div>\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.SLIDER) {\n <div class=\"flex flex-col gap-1\">\n <label [for]=\"'field-' + asKey(rowField.field.key)\"\n class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n <div class=\"flex items-center gap-3\">\n <input\n type=\"range\"\n [id]=\"'field-' + asKey(rowField.field.key)\"\n class=\"flex-1 h-2 bg-base-200 rounded-lg appearance-none cursor-pointer accent-blue-500\"\n [attr.min]=\"asField(rowField.field).min ?? 0\"\n [attr.max]=\"asField(rowField.field).max ?? 100\"\n [attr.step]=\"asField(rowField.field).step ?? 1\"\n [value]=\"getSliderValue(rowField.field)\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (input)=\"onSliderChange(rowField.field, $event)\"\n />\n @if (asField(rowField.field).showValue !== false) {\n <span class=\"text-sm text-base-content/60 min-w-[3rem] text-right\">\n {{ getSliderValue(rowField.field) }}{{ asField(rowField.field).unit || '' }}\n </span>\n }\n </div>\n <div class=\"flex justify-between text-xs text-base-content/40 px-1\">\n <span>{{ asField(rowField.field).min ?? 0 }}</span>\n <span>{{ asField(rowField.field).max ?? 100 }}</span>\n </div>\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.FILE) {\n <div>\n <mn-lib-file-input\n (cleared)=\"onFileCleared(rowField.field)\"\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n accept: asField(rowField.field).accept,\n multiple: asField(rowField.field).multiple,\n maxFiles: asField(rowField.field).maxFiles,\n maxSize: asField(rowField.field).maxSize,\n displayMode: asField(rowField.field).displayMode,\n dropzoneHint: asField(rowField.field).dropzoneHint,\n dropActiveHint: asField(rowField.field).dropActiveHint,\n replaceLabel: asField(rowField.field).replaceLabel,\n removeLabel: asField(rowField.field).removeLabel,\n currentUrl: asField(rowField.field).currentUrl,\n currentUrls: asField(rowField.field).currentUrls,\n disabled: isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\n })\"\n ></mn-lib-file-input>\n </div>\n }\n\n @case (FieldKind.CUSTOM) {\n <div>\n <ng-container\n mnCustomFieldHost\n [component]=\"asField(rowField.field).component\"\n [inputs]=\"asField(rowField.field).inputs\"\n [formControlName]=\"asKey(rowField.field.key)\"\n ></ng-container>\n </div>\n }\n\n @default {\n <div></div>\n }\n }\n\n <!-- Show cross-field error below any field that has one -->\n @if (getFieldError(asKey(rowField.field.key)) && rowField.field.kind !== FieldKind.SELECT && rowField.field.kind !== FieldKind.MULTI_SELECT) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ getFieldError(asKey(rowField.field.key)) }}\n </div>\n }\n </ng-template>\n\n <!-- Shared template for MULTI_SELECT_TABLE and SINGLE_SELECT_TABLE -->\n <ng-template #selectTableTemplate let-rowField>\n <div class=\"flex flex-col gap-1\">\n @if (asField(rowField.field).label) {\n <span class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </span>\n }\n <mn-table\n [dataSource]=\"tableDataSources[asKey(rowField.field.key)]\"\n [attr.aria-label]=\"asField(rowField.field).label || null\"\n (selectionChange)=\"onTableSelectionChange(rowField.field, $event)\"\n ></mn-table>\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n </ng-template>\n\n <!-- Field Groups (sections with headers) -->\n @if (fieldGroups.length > 0) {\n <div class=\"flex flex-col gap-6\">\n @for (group of fieldGroups; track group.title) {\n <div class=\"flex flex-col gap-4\" [style.display]=\"isGroupVisible(group) ? '' : 'none'\">\n <div class=\"border-b border-base-300 pb-2\">\n <h3 class=\"text-base font-semibold text-base-content\">{{ group.title }}</h3>\n @if (group.description) {\n <p class=\"text-sm text-base-content/50 mt-0.5\">{{ group.description }}</p>\n }\n </div>\n @for (row of group.rows; track $index) {\n <div [style.--mn-form-cols]=\"row.columns || 1\" class=\"grid gap-4 mn-form-row\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div [style.--mn-form-span]=\"rowField.span || 1\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\"\n class=\"flex flex-col gap-2 mn-form-cell\">\n <ng-container [ngTemplateOutlet]=\"fieldTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n </div>\n }\n </div>\n }\n </div>\n }\n </div>\n }\n\n <!-- Standard rows (no groups) -->\n @if (rows.length > 0) {\n <div class=\"flex flex-col gap-4\">\n @for (row of rows; track $index) {\n <div [style.--mn-form-cols]=\"row.columns || 1\" class=\"grid gap-4 mn-form-row\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div [style.--mn-form-span]=\"rowField.span || 1\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\"\n class=\"flex flex-col gap-2 mn-form-cell\">\n <ng-container [ngTemplateOutlet]=\"fieldTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n </div>\n }\n </div>\n }\n </div>\n }\n\n <!-- Form-level errors (not tied to a specific field) -->\n @if (formErrors['_form']) {\n <div class=\"text-red-500 text-sm px-2 py-1 bg-red-50 rounded-md\">\n {{ formErrors['_form'] }}\n </div>\n }\n\n @if (!hideFooter) {\n <div class=\"flex gap-3 pt-4 pb-6 border-t border-base-300 mt-auto sticky bottom-0 bg-base-100 z-10\">\n <button\n mnButton\n type=\"button\"\n [data]=\"{ variant: 'outline', color: 'secondary' }\"\n (mousedown)=\"modalRef.dismiss(ModalCloseReason.CANCELLED)\"\n >\n @if (cancelIcon; as icon) {\n <svg [lucideIcon]=\"icon\" [size]=\"actionIconSize\" class=\"mr-2\"></svg>\n }\n {{ labels.cancel }}\n </button>\n\n <div class=\"flex-1\"></div>\n\n <button\n mnButton\n type=\"submit\"\n [data]=\"{ variant: 'fill', color: 'primary', disabled: form.invalid || isSubmitting }\"\n [disabled]=\"form.invalid || isSubmitting\"\n >\n @if (submitIcon; as icon) {\n <svg [lucideIcon]=\"icon\" [size]=\"actionIconSize\" class=\"mr-2\"></svg>\n }\n {{ isSubmitting ? labels.submitting : labels.submit }}\n </button>\n </div>\n }\n</form>\n", styles: [".select-arrow{background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 8L1 3h10z'/%3E%3C/svg%3E\");background-repeat:no-repeat;background-position:right .75rem center}.mn-form-row{grid-template-columns:repeat(var(--mn-form-cols, 1),minmax(0,1fr))}.mn-form-cell{grid-column:span var(--mn-form-span, 1)}@media(max-width:639.98px){.mn-form-row{grid-template-columns:1fr}.mn-form-cell{grid-column:auto}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnCheckbox, selector: "mn-lib-checkbox", inputs: ["props", "checked"], outputs: ["checkedChange"] }, { kind: "component", type: MnDatetime, selector: "mn-lib-datetime", inputs: ["props"] }, { kind: "component", type: MnMultiSelect, selector: "mn-lib-multi-select", inputs: ["props"] }, { kind: "component", type: MnTextarea, selector: "mn-lib-textarea", inputs: ["props"] }, { kind: "component", type: MnFileInput, selector: "mn-lib-file-input", inputs: ["props"], outputs: ["filesChange", "cleared"] }, { kind: "component", type: MnSelect, selector: "mn-lib-select", inputs: ["props"] }, { kind: "directive", type: MnCustomFieldHostDirective, selector: "[mnCustomFieldHost]", inputs: ["component", "inputs"] }, { kind: "component", type: MnTable, selector: "mn-table", outputs: ["sortChange", "rowClick"] }, { kind: "component", type: MnCustomBodyHostComponent, selector: "mn-custom-body-host", inputs: ["config", "modalRef"] }, { kind: "component", type: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }] });
|
|
9010
9353
|
}
|
|
9011
9354
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnFormBodyComponent, decorators: [{
|
|
9012
9355
|
type: Component,
|
|
9013
|
-
args: [{ selector: 'mn-form-body', standalone: true, imports: [CommonModule, ReactiveFormsModule, MnButton, MnInputField, MnCheckbox, MnDatetime, MnMultiSelect, MnTextarea, MnFileInput, MnSelect, MnCustomFieldHostDirective, MnTable, MnCustomBodyHostComponent, LucideDynamicIcon], template: "@if ((config.component || config.template) && !hideCustomBody) {\n <mn-custom-body-host\n [config]=\"asAny(config)\"\n [modalRef]=\"asAny(modalRef)\"\n class=\"mb-6 block\"\n ></mn-custom-body-host>\n}\n\n<form (ngSubmit)=\"submit()\" [formGroup]=\"form\" class=\"flex flex-col gap-6 h-full\">\n <!-- Shared field rendering template (must be inside form for formControlName) -->\n <ng-template #fieldTemplate let-rowField>\n @switch (rowField.field.kind) {\n @case (FieldKind.TEXT) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'text',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n mask: asField(rowField.field).mask,\n autocomplete: asField(rowField.field).autocomplete,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.NUMBER) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'number',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.PASSWORD) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'password',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.SELECT) {\n <div class=\"flex flex-col\">\n <mn-lib-select\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"getSelectProps(rowField.field)\"\n ></mn-lib-select>\n @if (isFieldLoading(asKey(rowField.field.key))) {\n <div class=\"flex items-center gap-1 pl-2 pt-1\">\n <div class=\"w-4 h-4 border-2 border-base-300 border-t-blue-500 rounded-full animate-spin\"></div>\n </div>\n }\n @if (getFieldError(asKey(rowField.field.key))) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ getFieldError(asKey(rowField.field.key)) }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.CHECKBOX) {\n <div>\n <mn-lib-checkbox\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n disabled: isFieldDisabled(rowField.field) || isFieldReadOnly(rowField.field)\n })\"\n ></mn-lib-checkbox>\n </div>\n }\n\n @case (FieldKind.DATE) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'date',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n startDate: asField(rowField.field).minDate,\n endDate: asField(rowField.field).maxDate,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.DATETIME) {\n <div>\n <mn-lib-datetime\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n mode: asField(rowField.field).mode || 'datetime-local',\n min: asField(rowField.field).min,\n max: asField(rowField.field).max,\n step: asField(rowField.field).step,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-datetime>\n </div>\n }\n\n @case (FieldKind.TEXTAREA) {\n <div>\n <mn-lib-textarea\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n rows: asField(rowField.field).rows || 4,\n resize: 'vertical',\n autocomplete: asField(rowField.field).autocomplete,\n readonly: isFieldReadOnly(rowField.field)\n })\"\n ></mn-lib-textarea>\n </div>\n }\n\n @case (FieldKind.MULTI_SELECT) {\n <div>\n @if (isFieldLoading(asKey(rowField.field.key))) {\n <div class=\"flex items-center gap-2 py-2 text-sm text-base-content/50\">\n <div class=\"w-4 h-4 border-2 border-base-300 border-t-blue-500 rounded-full animate-spin\"></div>\n {{ labels.loadingOptions }}\n </div>\n }\n @if (!isFieldLoading(asKey(rowField.field.key))) {\n <mn-lib-multi-select\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n options: getFieldOptions(rowField.field),\n placeholder: asField(rowField.field).placeholder,\n searchable: asField(rowField.field).searchable,\n searchPlaceholder: asField(rowField.field).searchPlaceholder,\n maxSelections: asField(rowField.field).maxSelections,\n collapseThreshold: asField(rowField.field).collapseThreshold,\n collapsePlaceholder: asField(rowField.field).collapsePlaceholder,\n allSelectedPlaceholder: asField(rowField.field).allSelectedPlaceholder,\n disabled: isFieldDisabled(rowField.field) || isFieldReadOnly(rowField.field)\n })\"\n ></mn-lib-multi-select>\n }\n @if (getFieldError(asKey(rowField.field.key))) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ getFieldError(asKey(rowField.field.key)) }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.MULTI_SELECT_TABLE) {\n <ng-container [ngTemplateOutlet]=\"selectTableTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n }\n\n @case (FieldKind.SINGLE_SELECT_TABLE) {\n <ng-container [ngTemplateOutlet]=\"selectTableTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n }\n\n @case (FieldKind.COLOR) {\n <div class=\"flex flex-col gap-1\">\n <label [for]=\"'field-' + asKey(rowField.field.key)\"\n class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n <div class=\"flex items-center gap-3\">\n <input\n type=\"color\"\n [id]=\"'field-' + asKey(rowField.field.key)\"\n class=\"w-10 h-10 rounded-lg border border-base-300 cursor-pointer p-0.5\"\n [value]=\"getColorValue(rowField.field)\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (input)=\"onColorChange(rowField.field, $event)\"\n />\n <span class=\"text-sm text-base-content/60 font-mono\">{{ getColorValue(rowField.field) }}</span>\n </div>\n @if (asField(rowField.field).swatches) {\n <div class=\"flex gap-1.5 mt-1\">\n @for (swatch of asField(rowField.field).swatches; track swatch) {\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"w-6 h-6 rounded-md border border-base-300 cursor-pointer transition-transform hover:scale-110\"\n [style.background-color]=\"swatch\"\n [class.ring-2]=\"getColorValue(rowField.field) === swatch\"\n [class.ring-blue-500]=\"getColorValue(rowField.field) === swatch\"\n (click)=\"setColorFromSwatch(rowField.field, swatch)\"\n [attr.aria-label]=\"'Select color ' + swatch\"\n ></button>\n }\n </div>\n }\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.RATING) {\n <div class=\"flex flex-col gap-1\">\n <span [id]=\"'rating-label-' + asKey(rowField.field.key)\"\n class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </span>\n <div [attr.aria-labelledby]=\"'rating-label-' + asKey(rowField.field.key)\" class=\"flex items-center gap-1\"\n role=\"group\">\n @for (star of getRatingRange(rowField.field); track star) {\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"text-2xl cursor-pointer transition-colors focus:outline-none\"\n [class.text-yellow-400]=\"star <= getRatingValue(rowField.field)\"\n [class.text-base-300]=\"star > getRatingValue(rowField.field)\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (click)=\"setRating(rowField.field, star)\"\n >\n ★\n </button>\n }\n <span class=\"text-sm text-base-content/50 ml-2\">{{ getRatingValue(rowField.field) }} / {{ asField(rowField.field).max || 5 }}</span>\n </div>\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.SLIDER) {\n <div class=\"flex flex-col gap-1\">\n <label [for]=\"'field-' + asKey(rowField.field.key)\"\n class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n <div class=\"flex items-center gap-3\">\n <input\n type=\"range\"\n [id]=\"'field-' + asKey(rowField.field.key)\"\n class=\"flex-1 h-2 bg-base-200 rounded-lg appearance-none cursor-pointer accent-blue-500\"\n [attr.min]=\"asField(rowField.field).min ?? 0\"\n [attr.max]=\"asField(rowField.field).max ?? 100\"\n [attr.step]=\"asField(rowField.field).step ?? 1\"\n [value]=\"getSliderValue(rowField.field)\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (input)=\"onSliderChange(rowField.field, $event)\"\n />\n @if (asField(rowField.field).showValue !== false) {\n <span class=\"text-sm text-base-content/60 min-w-[3rem] text-right\">\n {{ getSliderValue(rowField.field) }}{{ asField(rowField.field).unit || '' }}\n </span>\n }\n </div>\n <div class=\"flex justify-between text-xs text-base-content/40 px-1\">\n <span>{{ asField(rowField.field).min ?? 0 }}</span>\n <span>{{ asField(rowField.field).max ?? 100 }}</span>\n </div>\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.FILE) {\n <div>\n <mn-lib-file-input\n (cleared)=\"onFileCleared(rowField.field)\"\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n accept: asField(rowField.field).accept,\n multiple: asField(rowField.field).multiple,\n maxFiles: asField(rowField.field).maxFiles,\n maxSize: asField(rowField.field).maxSize,\n displayMode: asField(rowField.field).displayMode,\n dropzoneHint: asField(rowField.field).dropzoneHint,\n replaceLabel: asField(rowField.field).replaceLabel,\n removeLabel: asField(rowField.field).removeLabel,\n currentUrl: asField(rowField.field).currentUrl,\n currentUrls: asField(rowField.field).currentUrls,\n disabled: isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\n })\"\n ></mn-lib-file-input>\n </div>\n }\n\n @case (FieldKind.CUSTOM) {\n <div>\n <ng-container\n mnCustomFieldHost\n [component]=\"asField(rowField.field).component\"\n [inputs]=\"asField(rowField.field).inputs\"\n [formControlName]=\"asKey(rowField.field.key)\"\n ></ng-container>\n </div>\n }\n\n @default {\n <div></div>\n }\n }\n\n <!-- Show cross-field error below any field that has one -->\n @if (getFieldError(asKey(rowField.field.key)) && rowField.field.kind !== FieldKind.SELECT && rowField.field.kind !== FieldKind.MULTI_SELECT) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ getFieldError(asKey(rowField.field.key)) }}\n </div>\n }\n </ng-template>\n\n <!-- Shared template for MULTI_SELECT_TABLE and SINGLE_SELECT_TABLE -->\n <ng-template #selectTableTemplate let-rowField>\n <div class=\"flex flex-col gap-1\">\n @if (asField(rowField.field).label) {\n <span class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </span>\n }\n <mn-table\n [dataSource]=\"tableDataSources[asKey(rowField.field.key)]\"\n [attr.aria-label]=\"asField(rowField.field).label || null\"\n (selectionChange)=\"onTableSelectionChange(rowField.field, $event)\"\n ></mn-table>\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n </ng-template>\n\n <!-- Field Groups (sections with headers) -->\n @if (fieldGroups.length > 0) {\n <div class=\"flex flex-col gap-6\">\n @for (group of fieldGroups; track group.title) {\n <div class=\"flex flex-col gap-4\" [style.display]=\"isGroupVisible(group) ? '' : 'none'\">\n <div class=\"border-b border-base-300 pb-2\">\n <h3 class=\"text-base font-semibold text-base-content\">{{ group.title }}</h3>\n @if (group.description) {\n <p class=\"text-sm text-base-content/50 mt-0.5\">{{ group.description }}</p>\n }\n </div>\n @for (row of group.rows; track $index) {\n <div [style.--mn-form-cols]=\"row.columns || 1\" class=\"grid gap-4 mn-form-row\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div [style.--mn-form-span]=\"rowField.span || 1\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\"\n class=\"flex flex-col gap-2 mn-form-cell\">\n <ng-container [ngTemplateOutlet]=\"fieldTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n </div>\n }\n </div>\n }\n </div>\n }\n </div>\n }\n\n <!-- Standard rows (no groups) -->\n @if (rows.length > 0) {\n <div class=\"flex flex-col gap-4\">\n @for (row of rows; track $index) {\n <div [style.--mn-form-cols]=\"row.columns || 1\" class=\"grid gap-4 mn-form-row\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div [style.--mn-form-span]=\"rowField.span || 1\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\"\n class=\"flex flex-col gap-2 mn-form-cell\">\n <ng-container [ngTemplateOutlet]=\"fieldTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n </div>\n }\n </div>\n }\n </div>\n }\n\n <!-- Form-level errors (not tied to a specific field) -->\n @if (formErrors['_form']) {\n <div class=\"text-red-500 text-sm px-2 py-1 bg-red-50 rounded-md\">\n {{ formErrors['_form'] }}\n </div>\n }\n\n @if (!hideFooter) {\n <div class=\"flex gap-3 pt-4 pb-6 border-t border-base-300 mt-auto sticky bottom-0 bg-base-100 z-10\">\n <button\n mnButton\n type=\"button\"\n [data]=\"{ variant: 'outline', color: 'secondary' }\"\n (mousedown)=\"modalRef.dismiss(ModalCloseReason.CANCELLED)\"\n >\n @if (cancelIcon; as icon) {\n <svg [lucideIcon]=\"icon\" [size]=\"actionIconSize\" class=\"mr-2\"></svg>\n }\n {{ labels.cancel }}\n </button>\n\n <div class=\"flex-1\"></div>\n\n <button\n mnButton\n type=\"submit\"\n [data]=\"{ variant: 'fill', color: 'primary', disabled: form.invalid || isSubmitting }\"\n [disabled]=\"form.invalid || isSubmitting\"\n >\n @if (submitIcon; as icon) {\n <svg [lucideIcon]=\"icon\" [size]=\"actionIconSize\" class=\"mr-2\"></svg>\n }\n {{ isSubmitting ? labels.submitting : labels.submit }}\n </button>\n </div>\n }\n</form>\n", styles: [".select-arrow{background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 8L1 3h10z'/%3E%3C/svg%3E\");background-repeat:no-repeat;background-position:right .75rem center}.mn-form-row{grid-template-columns:repeat(var(--mn-form-cols, 1),minmax(0,1fr))}.mn-form-cell{grid-column:span var(--mn-form-span, 1)}@media(max-width:639.98px){.mn-form-row{grid-template-columns:1fr}.mn-form-cell{grid-column:auto}}\n"] }]
|
|
9356
|
+
args: [{ selector: 'mn-form-body', standalone: true, imports: [CommonModule, ReactiveFormsModule, MnButton, MnInputField, MnCheckbox, MnDatetime, MnMultiSelect, MnTextarea, MnFileInput, MnSelect, MnCustomFieldHostDirective, MnTable, MnCustomBodyHostComponent, LucideDynamicIcon], template: "@if ((config.component || config.template) && !hideCustomBody) {\n <mn-custom-body-host\n [config]=\"asAny(config)\"\n [modalRef]=\"asAny(modalRef)\"\n class=\"mb-6 block\"\n ></mn-custom-body-host>\n}\n\n<form (ngSubmit)=\"submit()\" [formGroup]=\"form\" class=\"flex flex-col gap-6 h-full\">\n <!-- Shared field rendering template (must be inside form for formControlName) -->\n <ng-template #fieldTemplate let-rowField>\n @switch (rowField.field.kind) {\n @case (FieldKind.TEXT) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'text',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n mask: asField(rowField.field).mask,\n autocomplete: asField(rowField.field).autocomplete,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.NUMBER) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'number',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.PASSWORD) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'password',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.SELECT) {\n <div class=\"flex flex-col\">\n <mn-lib-select\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"getSelectProps(rowField.field)\"\n ></mn-lib-select>\n @if (isFieldLoading(asKey(rowField.field.key))) {\n <div class=\"flex items-center gap-1 pl-2 pt-1\">\n <div class=\"w-4 h-4 border-2 border-base-300 border-t-blue-500 rounded-full animate-spin\"></div>\n </div>\n }\n @if (getFieldError(asKey(rowField.field.key))) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ getFieldError(asKey(rowField.field.key)) }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.CHECKBOX) {\n <div>\n <mn-lib-checkbox\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n disabled: isFieldDisabled(rowField.field) || isFieldReadOnly(rowField.field)\n })\"\n ></mn-lib-checkbox>\n </div>\n }\n\n @case (FieldKind.DATE) {\n <div>\n <mn-lib-input-field\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n type: 'date',\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n startDate: asField(rowField.field).minDate,\n endDate: asField(rowField.field).maxDate,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-input-field>\n </div>\n }\n\n @case (FieldKind.DATETIME) {\n <div>\n <mn-lib-datetime\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n mode: asField(rowField.field).mode || 'datetime-local',\n min: asField(rowField.field).min,\n max: asField(rowField.field).max,\n step: asField(rowField.field).step,\n readonly: isFieldReadOnly(rowField.field),\n fullWidth: true\n })\"\n ></mn-lib-datetime>\n </div>\n }\n\n @case (FieldKind.TEXTAREA) {\n <div>\n <mn-lib-textarea\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n placeholder: asField(rowField.field).placeholder,\n rows: asField(rowField.field).rows || 4,\n resize: 'vertical',\n autocomplete: asField(rowField.field).autocomplete,\n readonly: isFieldReadOnly(rowField.field)\n })\"\n ></mn-lib-textarea>\n </div>\n }\n\n @case (FieldKind.MULTI_SELECT) {\n <div>\n @if (isFieldLoading(asKey(rowField.field.key))) {\n <div class=\"flex items-center gap-2 py-2 text-sm text-base-content/50\">\n <div class=\"w-4 h-4 border-2 border-base-300 border-t-blue-500 rounded-full animate-spin\"></div>\n {{ labels.loadingOptions }}\n </div>\n }\n @if (!isFieldLoading(asKey(rowField.field.key))) {\n <mn-lib-multi-select\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n options: getFieldOptions(rowField.field),\n placeholder: asField(rowField.field).placeholder,\n searchable: asField(rowField.field).searchable,\n searchPlaceholder: asField(rowField.field).searchPlaceholder,\n maxSelections: asField(rowField.field).maxSelections,\n collapseThreshold: asField(rowField.field).collapseThreshold,\n collapsePlaceholder: asField(rowField.field).collapsePlaceholder,\n allSelectedPlaceholder: asField(rowField.field).allSelectedPlaceholder,\n disabled: isFieldDisabled(rowField.field) || isFieldReadOnly(rowField.field)\n })\"\n ></mn-lib-multi-select>\n }\n @if (getFieldError(asKey(rowField.field.key))) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ getFieldError(asKey(rowField.field.key)) }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.MULTI_SELECT_TABLE) {\n <ng-container [ngTemplateOutlet]=\"selectTableTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n }\n\n @case (FieldKind.SINGLE_SELECT_TABLE) {\n <ng-container [ngTemplateOutlet]=\"selectTableTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n }\n\n @case (FieldKind.COLOR) {\n <div class=\"flex flex-col gap-1\">\n <label [for]=\"'field-' + asKey(rowField.field.key)\"\n class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n <div class=\"flex items-center gap-3\">\n <input\n type=\"color\"\n [id]=\"'field-' + asKey(rowField.field.key)\"\n class=\"w-10 h-10 rounded-lg border border-base-300 cursor-pointer p-0.5\"\n [value]=\"getColorValue(rowField.field)\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (input)=\"onColorChange(rowField.field, $event)\"\n />\n <span class=\"text-sm text-base-content/60 font-mono\">{{ getColorValue(rowField.field) }}</span>\n </div>\n @if (asField(rowField.field).swatches) {\n <div class=\"flex gap-1.5 mt-1\">\n @for (swatch of asField(rowField.field).swatches; track swatch) {\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"w-6 h-6 rounded-md border border-base-300 cursor-pointer transition-transform hover:scale-110\"\n [style.background-color]=\"swatch\"\n [class.ring-2]=\"getColorValue(rowField.field) === swatch\"\n [class.ring-blue-500]=\"getColorValue(rowField.field) === swatch\"\n (click)=\"setColorFromSwatch(rowField.field, swatch)\"\n [attr.aria-label]=\"'Select color ' + swatch\"\n ></button>\n }\n </div>\n }\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.RATING) {\n <div class=\"flex flex-col gap-1\">\n <span [id]=\"'rating-label-' + asKey(rowField.field.key)\"\n class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </span>\n <div [attr.aria-labelledby]=\"'rating-label-' + asKey(rowField.field.key)\" class=\"flex items-center gap-1\"\n role=\"group\">\n @for (star of getRatingRange(rowField.field); track star) {\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"text-2xl cursor-pointer transition-colors focus:outline-none\"\n [class.text-yellow-400]=\"star <= getRatingValue(rowField.field)\"\n [class.text-base-300]=\"star > getRatingValue(rowField.field)\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (click)=\"setRating(rowField.field, star)\"\n >\n ★\n </button>\n }\n <span class=\"text-sm text-base-content/50 ml-2\">{{ getRatingValue(rowField.field) }} / {{ asField(rowField.field).max || 5 }}</span>\n </div>\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.SLIDER) {\n <div class=\"flex flex-col gap-1\">\n <label [for]=\"'field-' + asKey(rowField.field.key)\"\n class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n <div class=\"flex items-center gap-3\">\n <input\n type=\"range\"\n [id]=\"'field-' + asKey(rowField.field.key)\"\n class=\"flex-1 h-2 bg-base-200 rounded-lg appearance-none cursor-pointer accent-blue-500\"\n [attr.min]=\"asField(rowField.field).min ?? 0\"\n [attr.max]=\"asField(rowField.field).max ?? 100\"\n [attr.step]=\"asField(rowField.field).step ?? 1\"\n [value]=\"getSliderValue(rowField.field)\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (input)=\"onSliderChange(rowField.field, $event)\"\n />\n @if (asField(rowField.field).showValue !== false) {\n <span class=\"text-sm text-base-content/60 min-w-[3rem] text-right\">\n {{ getSliderValue(rowField.field) }}{{ asField(rowField.field).unit || '' }}\n </span>\n }\n </div>\n <div class=\"flex justify-between text-xs text-base-content/40 px-1\">\n <span>{{ asField(rowField.field).min ?? 0 }}</span>\n <span>{{ asField(rowField.field).max ?? 100 }}</span>\n </div>\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n }\n\n @case (FieldKind.FILE) {\n <div>\n <mn-lib-file-input\n (cleared)=\"onFileCleared(rowField.field)\"\n [formControlName]=\"asKey(rowField.field.key)\"\n [props]=\"asAny({\n id: asKey(rowField.field.key),\n label: asField(rowField.field).label,\n accept: asField(rowField.field).accept,\n multiple: asField(rowField.field).multiple,\n maxFiles: asField(rowField.field).maxFiles,\n maxSize: asField(rowField.field).maxSize,\n displayMode: asField(rowField.field).displayMode,\n dropzoneHint: asField(rowField.field).dropzoneHint,\n dropActiveHint: asField(rowField.field).dropActiveHint,\n replaceLabel: asField(rowField.field).replaceLabel,\n removeLabel: asField(rowField.field).removeLabel,\n currentUrl: asField(rowField.field).currentUrl,\n currentUrls: asField(rowField.field).currentUrls,\n disabled: isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\n })\"\n ></mn-lib-file-input>\n </div>\n }\n\n @case (FieldKind.CUSTOM) {\n <div>\n <ng-container\n mnCustomFieldHost\n [component]=\"asField(rowField.field).component\"\n [inputs]=\"asField(rowField.field).inputs\"\n [formControlName]=\"asKey(rowField.field.key)\"\n ></ng-container>\n </div>\n }\n\n @default {\n <div></div>\n }\n }\n\n <!-- Show cross-field error below any field that has one -->\n @if (getFieldError(asKey(rowField.field.key)) && rowField.field.kind !== FieldKind.SELECT && rowField.field.kind !== FieldKind.MULTI_SELECT) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ getFieldError(asKey(rowField.field.key)) }}\n </div>\n }\n </ng-template>\n\n <!-- Shared template for MULTI_SELECT_TABLE and SINGLE_SELECT_TABLE -->\n <ng-template #selectTableTemplate let-rowField>\n <div class=\"flex flex-col gap-1\">\n @if (asField(rowField.field).label) {\n <span class=\"pl-2 pb-1 flex flex-row gap-0.5 text-base font-medium text-base-content\">\n {{ asField(rowField.field).label }}\n @if (hasRequiredValidator(rowField.field)) {\n <span class=\"text-red-500\">*</span>\n }\n </span>\n }\n <mn-table\n [dataSource]=\"tableDataSources[asKey(rowField.field.key)]\"\n [attr.aria-label]=\"asField(rowField.field).label || null\"\n (selectionChange)=\"onTableSelectionChange(rowField.field, $event)\"\n ></mn-table>\n @if (form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched) {\n <div class=\"text-red-500 text-xs pl-2 pt-1\">\n {{ labels.fieldRequired }}\n </div>\n }\n </div>\n </ng-template>\n\n <!-- Field Groups (sections with headers) -->\n @if (fieldGroups.length > 0) {\n <div class=\"flex flex-col gap-6\">\n @for (group of fieldGroups; track group.title) {\n <div class=\"flex flex-col gap-4\" [style.display]=\"isGroupVisible(group) ? '' : 'none'\">\n <div class=\"border-b border-base-300 pb-2\">\n <h3 class=\"text-base font-semibold text-base-content\">{{ group.title }}</h3>\n @if (group.description) {\n <p class=\"text-sm text-base-content/50 mt-0.5\">{{ group.description }}</p>\n }\n </div>\n @for (row of group.rows; track $index) {\n <div [style.--mn-form-cols]=\"row.columns || 1\" class=\"grid gap-4 mn-form-row\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div [style.--mn-form-span]=\"rowField.span || 1\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\"\n class=\"flex flex-col gap-2 mn-form-cell\">\n <ng-container [ngTemplateOutlet]=\"fieldTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n </div>\n }\n </div>\n }\n </div>\n }\n </div>\n }\n\n <!-- Standard rows (no groups) -->\n @if (rows.length > 0) {\n <div class=\"flex flex-col gap-4\">\n @for (row of rows; track $index) {\n <div [style.--mn-form-cols]=\"row.columns || 1\" class=\"grid gap-4 mn-form-row\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div [style.--mn-form-span]=\"rowField.span || 1\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\"\n class=\"flex flex-col gap-2 mn-form-cell\">\n <ng-container [ngTemplateOutlet]=\"fieldTemplate\" [ngTemplateOutletContext]=\"{ $implicit: rowField }\"></ng-container>\n </div>\n }\n </div>\n }\n </div>\n }\n\n <!-- Form-level errors (not tied to a specific field) -->\n @if (formErrors['_form']) {\n <div class=\"text-red-500 text-sm px-2 py-1 bg-red-50 rounded-md\">\n {{ formErrors['_form'] }}\n </div>\n }\n\n @if (!hideFooter) {\n <div class=\"flex gap-3 pt-4 pb-6 border-t border-base-300 mt-auto sticky bottom-0 bg-base-100 z-10\">\n <button\n mnButton\n type=\"button\"\n [data]=\"{ variant: 'outline', color: 'secondary' }\"\n (mousedown)=\"modalRef.dismiss(ModalCloseReason.CANCELLED)\"\n >\n @if (cancelIcon; as icon) {\n <svg [lucideIcon]=\"icon\" [size]=\"actionIconSize\" class=\"mr-2\"></svg>\n }\n {{ labels.cancel }}\n </button>\n\n <div class=\"flex-1\"></div>\n\n <button\n mnButton\n type=\"submit\"\n [data]=\"{ variant: 'fill', color: 'primary', disabled: form.invalid || isSubmitting }\"\n [disabled]=\"form.invalid || isSubmitting\"\n >\n @if (submitIcon; as icon) {\n <svg [lucideIcon]=\"icon\" [size]=\"actionIconSize\" class=\"mr-2\"></svg>\n }\n {{ isSubmitting ? labels.submitting : labels.submit }}\n </button>\n </div>\n }\n</form>\n", styles: [".select-arrow{background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 8L1 3h10z'/%3E%3C/svg%3E\");background-repeat:no-repeat;background-position:right .75rem center}.mn-form-row{grid-template-columns:repeat(var(--mn-form-cols, 1),minmax(0,1fr))}.mn-form-cell{grid-column:span var(--mn-form-span, 1)}@media(max-width:639.98px){.mn-form-row{grid-template-columns:1fr}.mn-form-cell{grid-column:auto}}\n"] }]
|
|
9014
9357
|
}], propDecorators: { config: [{
|
|
9015
9358
|
type: Input
|
|
9016
9359
|
}], modalRef: [{
|
|
@@ -13107,5 +13450,5 @@ function enableMnPreviewMode(configService, langService, allowedOrigins) {
|
|
|
13107
13450
|
* Generated bundle index. Do not edit.
|
|
13108
13451
|
*/
|
|
13109
13452
|
|
|
13110
|
-
export { API_BASE_URL, ActionStyle, BackdropMode, BaseModalBuilder, CALENDAR_CONFIG, CALENDAR_DATE_FORMATTER, CalendarDayComponent, CalendarEventComponent, CalendarEventDefaultComponent, CalendarEventLayoutService, CalendarMonthComponent, CalendarUtility, CalendarView, CalendarViewComponent, CalendarWeekComponent, CloseMode, ColumnSortType, ConfirmationModalBuilder, ConfirmationTone, CrudService, CustomModalBuilder, DEFAULT_CALENDAR_CONFIG, DEFAULT_MN_ALERT_CONFIG, DefaultCalendarDateFormatter, FieldAppearance, FieldKind, FormLayoutMode, FormModalBuilder, KeyboardMode, MN_ALERT_CONFIG, MN_CALENDAR_COMPONENT_NAME, MN_CALENDAR_CONFIG, MN_CHECKBOX_CONFIG, MN_DATETIME_CONFIG, MN_DROPDOWN_CONFIG, MN_HAPTICS, MN_ICON_MAP, MN_INPUT_FIELD_CONFIG, MN_INSTANCE_ID, MN_LIB_DUAL_HORIZONTAL_IMAGE, MN_MODAL_ACTION_ICONS, MN_MULTI_SELECT_CONFIG, MN_SECTION_PATH, MN_SELECT_CONFIG, MN_TEXTAREA_CONFIG, MODAL_ACTION_ICON_SIZE, MODAL_ACTION_ICON_SIZE_SM, MnAlertOutletComponent, MnAlertService, MnAlertStore, MnBadge, MnBottomSheet, MnBreadcrumbs, MnButton, MnCheckbox, MnCollectionBase, MnCollectionPagination, MnCollectionState, MnConfigService, MnConfirmationBodyComponent, MnCustomBodyHostComponent, MnDateSelectorBar, MnDatetime, MnDropdown, MnDualHorizontalImage, MnFileInput, MnFormBodyComponent, MnGrid, MnHiddenBelowDirective, MnHttpService, MnIcon, MnIconAttributes, MnInformationCard, MnInputField, MnInstanceDirective, MnLanguageService, MnList, MnModalRef, MnModalService, MnModalShellComponent, MnMultiSelect, MnRichTextEditor, MnSectionDirective, MnSelect, MnSelectableCollectionBase, MnShowAboveDirective, MnShowBelowDirective, MnSkeleton, MnTabComponent, MnTable, MnTextarea, MnTranslatePipe, MnWizardBodyComponent, ModalBuilder, ModalCloseReason, ModalIntent, ModalKind, ModalSize, NavigationDirection, OptionState, SelectionMode, StepBuilder, StepState, SubmitMode, UpcomingEventRowComponent, UpcomingEventsComponent, ValidationCode, ValidationStatus, WizardFlowMode, WizardModalBuilder, dateTimeAdapter, defaultFilterPredicate, defaultIconForStyle, defaultTextAdapter, emptyFilterValue, enableMnPreviewMode, isFilterValueActive, isTranslatable, matchesColumnFilter, mnAlertVariants, mnBadgeVariants, mnBreadcrumbsVariants, mnButtonVariants, mnCheckboxVariants, mnCheckboxWrapperVariants, mnDatetimeVariants, mnDropdownTriggerVariants, mnFileInputVariants, mnIconVariants, mnInformationCardVariants, mnInputFieldVariants, mnMultiSelectVariants, mnSelectVariants, mnSkeletonVariants, mnTextareaVariants, numberAdapter, pickAdapter, provideMnAlerts, provideMnCalendarConfig, provideMnComponentConfig, provideMnConfig, provideMnLanguage, resolveCalendarConfig, resolveFilterableValue };
|
|
13453
|
+
export { API_BASE_URL, ActionStyle, BackdropMode, BaseModalBuilder, CALENDAR_CONFIG, CALENDAR_DATE_FORMATTER, CalendarDayComponent, CalendarEventComponent, CalendarEventDefaultComponent, CalendarEventLayoutService, CalendarMonthComponent, CalendarUtility, CalendarView, CalendarViewComponent, CalendarWeekComponent, CloseMode, ColumnSortType, ConfirmationModalBuilder, ConfirmationTone, CrudService, CustomModalBuilder, DEFAULT_CALENDAR_CONFIG, DEFAULT_MN_ALERT_CONFIG, DefaultCalendarDateFormatter, FieldAppearance, FieldKind, FormLayoutMode, FormModalBuilder, KeyboardMode, MN_ALERT_CONFIG, MN_CALENDAR_COMPONENT_NAME, MN_CALENDAR_CONFIG, MN_CHECKBOX_CONFIG, MN_DATETIME_CONFIG, MN_DROPDOWN_CONFIG, MN_HAPTICS, MN_ICON_MAP, MN_INPUT_FIELD_CONFIG, MN_INSTANCE_ID, MN_LIB_DUAL_HORIZONTAL_IMAGE, MN_MODAL_ACTION_ICONS, MN_MULTI_SELECT_CONFIG, MN_SECTION_PATH, MN_SELECT_CONFIG, MN_TEXTAREA_CONFIG, MODAL_ACTION_ICON_SIZE, MODAL_ACTION_ICON_SIZE_SM, MnAlertOutletComponent, MnAlertService, MnAlertStore, MnBadge, MnBottomSheet, MnBreadcrumbs, MnButton, MnCheckbox, MnCollectionBase, MnCollectionPagination, MnCollectionState, MnConfigService, MnConfirmationBodyComponent, MnCustomBodyHostComponent, MnDateSelectorBar, MnDatetime, MnDropdown, MnDualHorizontalImage, MnFileInput, MnFormBodyComponent, MnGrid, MnHiddenBelowDirective, MnHttpService, MnIcon, MnIconAttributes, MnInformationCard, MnInputField, MnInstanceDirective, MnKeyboard, MnLanguageService, MnList, MnModalRef, MnModalService, MnModalShellComponent, MnMultiSelect, MnRichTextEditor, MnSectionDirective, MnSelect, MnSelectableCollectionBase, MnShowAboveDirective, MnShowBelowDirective, MnSkeleton, MnTabComponent, MnTable, MnTextarea, MnTranslatePipe, MnWizardBodyComponent, ModalBuilder, ModalCloseReason, ModalIntent, ModalKind, ModalSize, NavigationDirection, OptionState, SelectionMode, StepBuilder, StepState, SubmitMode, UpcomingEventRowComponent, UpcomingEventsComponent, ValidationCode, ValidationStatus, WizardFlowMode, WizardModalBuilder, dateTimeAdapter, defaultFilterPredicate, defaultIconForStyle, defaultTextAdapter, emptyFilterValue, enableMnPreviewMode, isFilterValueActive, isTranslatable, matchesColumnFilter, mnAlertVariants, mnBadgeVariants, mnBreadcrumbsVariants, mnButtonVariants, mnCheckboxVariants, mnCheckboxWrapperVariants, mnDatetimeVariants, mnDropdownTriggerVariants, mnFileInputVariants, mnIconVariants, mnInformationCardVariants, mnInputFieldVariants, mnMultiSelectVariants, mnSelectVariants, mnSkeletonVariants, mnTextareaVariants, numberAdapter, pickAdapter, provideMnAlerts, provideMnCalendarConfig, provideMnComponentConfig, provideMnConfig, provideMnLanguage, resolveCalendarConfig, resolveFilterableValue };
|
|
13111
13454
|
//# sourceMappingURL=mn-angular-lib.mjs.map
|