mn-angular-lib 1.0.89 → 1.0.90
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 +604 -190
- package/fesm2022/mn-angular-lib.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mn-angular-lib.d.ts +421 -12
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken, Injectable, inject, HostBinding, Input, Component, ChangeDetectionStrategy, ApplicationRef, APP_INITIALIZER, Pipe, signal, DestroyRef, Optional, SkipSelf, Attribute, Directive, ElementRef, EventEmitter, Output, HostListener, ViewChild, ViewContainerRef, forwardRef, Renderer2, ChangeDetectorRef, afterEveryRender, TemplateRef, ViewChildren, EnvironmentInjector, createComponent, isSignal } from '@angular/core';
|
|
2
|
+
import { InjectionToken, Injectable, inject, HostBinding, Input, Component, ChangeDetectionStrategy, ApplicationRef, APP_INITIALIZER, Pipe, signal, DestroyRef, Optional, SkipSelf, Attribute, Directive, ElementRef, EventEmitter, computed, Output, HostListener, ViewChild, ViewContainerRef, forwardRef, Renderer2, ChangeDetectorRef, afterEveryRender, TemplateRef, ViewChildren, EnvironmentInjector, createComponent, isSignal } 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 * as i1 from '@angular/common';
|
|
@@ -9,8 +9,8 @@ import * as i2 from '@angular/forms';
|
|
|
9
9
|
import { NgControl, Validators, FormsModule, NG_VALUE_ACCESSOR, FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
|
10
10
|
import { HttpClient, HttpErrorResponse, HttpStatusCode, HttpParams } from '@angular/common/http';
|
|
11
11
|
import JSON5 from 'json5';
|
|
12
|
-
import { LucideX } from '@lucide/angular';
|
|
13
12
|
import { DomSanitizer } from '@angular/platform-browser';
|
|
13
|
+
import { LucideX } from '@lucide/angular';
|
|
14
14
|
|
|
15
15
|
// projects/mn-angular-lib/src/lib/mn-mn-alert/mn-mn-alert.tokens.ts
|
|
16
16
|
const MN_ALERT_CONFIG = new InjectionToken('MN_ALERT_CONFIG');
|
|
@@ -2009,6 +2009,596 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
|
|
|
2009
2009
|
args: [{ required: true }]
|
|
2010
2010
|
}] } });
|
|
2011
2011
|
|
|
2012
|
+
/**
|
|
2013
|
+
* Tailwind-variants definition for the MnFileInput component.
|
|
2014
|
+
*
|
|
2015
|
+
* Mirrors the styling vocabulary of {@link mnInputFieldVariants} (size,
|
|
2016
|
+
* borderRadius, shadow, fullWidth, disabled) so a file input visually matches the
|
|
2017
|
+
* rest of the input family, and adds a `dropzone` toggle for the large dashed
|
|
2018
|
+
* drop area used by the default display mode.
|
|
2019
|
+
*/
|
|
2020
|
+
const mnFileInputVariants = tv({
|
|
2021
|
+
base: 'bg-base-100 border-1 border-base-300 text-base-content text-sm outline-none transition-colors duration-300 ease-in-out',
|
|
2022
|
+
variants: {
|
|
2023
|
+
/** Inner padding scale of the clickable control. */
|
|
2024
|
+
size: {
|
|
2025
|
+
sm: 'p-2',
|
|
2026
|
+
md: 'p-3',
|
|
2027
|
+
lg: 'p-4',
|
|
2028
|
+
},
|
|
2029
|
+
/** Corner rounding of the control. */
|
|
2030
|
+
borderRadius: {
|
|
2031
|
+
none: 'rounded-none',
|
|
2032
|
+
xs: 'rounded-xs',
|
|
2033
|
+
sm: 'rounded-sm',
|
|
2034
|
+
md: 'rounded-md',
|
|
2035
|
+
lg: 'rounded-lg',
|
|
2036
|
+
xl: 'rounded-xl',
|
|
2037
|
+
two_xl: 'rounded-2xl',
|
|
2038
|
+
three_xl: 'rounded-3xl',
|
|
2039
|
+
four_xl: 'rounded-4xl',
|
|
2040
|
+
},
|
|
2041
|
+
/** Drop shadow toggle. */
|
|
2042
|
+
shadow: {
|
|
2043
|
+
true: 'shadow-lg',
|
|
2044
|
+
},
|
|
2045
|
+
/** Stretch the control to the full width of its container. */
|
|
2046
|
+
fullWidth: {
|
|
2047
|
+
true: 'w-full',
|
|
2048
|
+
},
|
|
2049
|
+
/** Renders the control as a large dashed dropzone with a hover accent. */
|
|
2050
|
+
dropzone: {
|
|
2051
|
+
true: 'flex flex-col items-center justify-center gap-2 p-6 border-2 border-dashed text-center cursor-pointer hover:border-primary',
|
|
2052
|
+
},
|
|
2053
|
+
/** Dimmed, non-interactive appearance. */
|
|
2054
|
+
disabled: {
|
|
2055
|
+
true: 'opacity-50 cursor-not-allowed pointer-events-none',
|
|
2056
|
+
},
|
|
2057
|
+
},
|
|
2058
|
+
defaultVariants: {
|
|
2059
|
+
size: 'md',
|
|
2060
|
+
borderRadius: 'lg',
|
|
2061
|
+
},
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
const mnIconVariants = tv({
|
|
2065
|
+
base: 'inline-flex shrink-0',
|
|
2066
|
+
variants: {
|
|
2067
|
+
color: {
|
|
2068
|
+
current: 'text-current',
|
|
2069
|
+
primary: 'text-primary',
|
|
2070
|
+
secondary: 'text-neutral',
|
|
2071
|
+
danger: 'text-error',
|
|
2072
|
+
warning: 'text-warning',
|
|
2073
|
+
success: 'text-success',
|
|
2074
|
+
accent: 'text-accent',
|
|
2075
|
+
},
|
|
2076
|
+
},
|
|
2077
|
+
defaultVariants: {
|
|
2078
|
+
color: 'current',
|
|
2079
|
+
},
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
// Inline SVGs sourced from the .svg files in the ./icons/ directory
|
|
2083
|
+
const pistolSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.72 14.56 9 10h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v4c2.5 0 1 4 1 4-4 6-1 6-1 6h3.38a1 1 0 0 0 .89-.55za1 1 0 0 1 .9-.56H13a2 2 0 0 0 2-2v-1a1 1 0 0 1 1-1" /></svg>`;
|
|
2084
|
+
const pendingSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v5l4 2" /><path d="M14 20.775A9 9 0 0112 21" /><path d="M19 17.656a9 9 0 01-1.5 1.456" /><path d="M21 12a9 9 0 01-.228 2" /><path d="M21 8h-5" /><path d="M7.5 19.794c-6-3.464-6-12.124 0-15.588A9 9 0 0112 3a9.75 9.75 0 016.74 2.74L21 8V3" /></svg>`;
|
|
2085
|
+
// Lucide icons (https://lucide.dev) — inner paths; mn-icon wraps them in a sized svg.
|
|
2086
|
+
const imagePlusSvg = `<path d="M16 5h6" /><path d="M19 2v6" /><path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5" /><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" /><circle cx="9" cy="9" r="2" />`;
|
|
2087
|
+
const uploadSvg = `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" x2="12" y1="3" y2="15" />`;
|
|
2088
|
+
const fileSvg = `<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />`;
|
|
2089
|
+
const trash2Svg = `<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /><line x1="10" x2="10" y1="11" y2="17" /><line x1="14" x2="14" y1="11" y2="17" />`;
|
|
2090
|
+
const xSvg = `<path d="M18 6 6 18" /><path d="m6 6 12 12" />`;
|
|
2091
|
+
const MN_ICON_MAP = {
|
|
2092
|
+
pistol: pistolSvg,
|
|
2093
|
+
pending: pendingSvg,
|
|
2094
|
+
imagePlus: imagePlusSvg,
|
|
2095
|
+
upload: uploadSvg,
|
|
2096
|
+
file: fileSvg,
|
|
2097
|
+
trash2: trash2Svg,
|
|
2098
|
+
x: xSvg,
|
|
2099
|
+
};
|
|
2100
|
+
|
|
2101
|
+
class MnIcon {
|
|
2102
|
+
data = {};
|
|
2103
|
+
sanitizer = inject(DomSanitizer);
|
|
2104
|
+
el = inject(ElementRef);
|
|
2105
|
+
svgContent = '';
|
|
2106
|
+
get iconSize() {
|
|
2107
|
+
return this.data.size ?? 24;
|
|
2108
|
+
}
|
|
2109
|
+
get hostClasses() {
|
|
2110
|
+
return mnIconVariants({
|
|
2111
|
+
color: this.data.color,
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
resolvedName = null;
|
|
2115
|
+
ngOnInit() {
|
|
2116
|
+
const attrs = this.el.nativeElement.attributes;
|
|
2117
|
+
for (const item of Array.from(attrs)) {
|
|
2118
|
+
const attr = item.name.toLowerCase();
|
|
2119
|
+
if (attr.startsWith('mnicon')) {
|
|
2120
|
+
const iconKey = attr.slice('mnicon'.length);
|
|
2121
|
+
for (const key of Object.keys(MN_ICON_MAP)) {
|
|
2122
|
+
if (key.toLowerCase() === iconKey) {
|
|
2123
|
+
this.resolvedName = key;
|
|
2124
|
+
break;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
if (this.resolvedName)
|
|
2128
|
+
break;
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
this.updateContent();
|
|
2132
|
+
}
|
|
2133
|
+
ngOnChanges() {
|
|
2134
|
+
this.updateContent();
|
|
2135
|
+
}
|
|
2136
|
+
updateContent() {
|
|
2137
|
+
const name = this.data.name ?? this.resolvedName;
|
|
2138
|
+
if (name) {
|
|
2139
|
+
const raw = MN_ICON_MAP[name];
|
|
2140
|
+
if (raw) {
|
|
2141
|
+
const size = this.iconSize;
|
|
2142
|
+
const isFullSvg = raw.trim().startsWith('<svg');
|
|
2143
|
+
if (isFullSvg) {
|
|
2144
|
+
const sized = raw.replace(/<svg([^>]*)>/, (_match, attrs) => {
|
|
2145
|
+
let updated = attrs.replace(/width="[^"]*"/, `width="${size}"`);
|
|
2146
|
+
updated = updated.replace(/height="[^"]*"/, `height="${size}"`);
|
|
2147
|
+
if (!attrs.includes('width='))
|
|
2148
|
+
updated += ` width="${size}"`;
|
|
2149
|
+
if (!attrs.includes('height='))
|
|
2150
|
+
updated += ` height="${size}"`;
|
|
2151
|
+
return `<svg${updated}>`;
|
|
2152
|
+
});
|
|
2153
|
+
this.svgContent = this.sanitizer.bypassSecurityTrustHtml(sized);
|
|
2154
|
+
}
|
|
2155
|
+
else {
|
|
2156
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${raw}</svg>`;
|
|
2157
|
+
this.svgContent = this.sanitizer.bypassSecurityTrustHtml(svg);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
else {
|
|
2161
|
+
this.svgContent = '';
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
else {
|
|
2165
|
+
this.svgContent = '';
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnIcon, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2169
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.1.3", type: MnIcon, isStandalone: true, selector: "mn-icon", inputs: { data: "data" }, host: { properties: { "class": "this.hostClasses" } }, usesOnChanges: true, ngImport: i0, template: "<span\n [style.width.px]=\"iconSize\"\n [style.height.px]=\"iconSize\"\n style=\"display: inline-flex; align-items: center; justify-content: center;\"\n [innerHTML]=\"svgContent\"\n></span>\n" });
|
|
2170
|
+
}
|
|
2171
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnIcon, decorators: [{
|
|
2172
|
+
type: Component,
|
|
2173
|
+
args: [{ selector: 'mn-icon', standalone: true, template: "<span\n [style.width.px]=\"iconSize\"\n [style.height.px]=\"iconSize\"\n style=\"display: inline-flex; align-items: center; justify-content: center;\"\n [innerHTML]=\"svgContent\"\n></span>\n" }]
|
|
2174
|
+
}], propDecorators: { data: [{
|
|
2175
|
+
type: Input
|
|
2176
|
+
}], hostClasses: [{
|
|
2177
|
+
type: HostBinding,
|
|
2178
|
+
args: ['class']
|
|
2179
|
+
}] } });
|
|
2180
|
+
|
|
2181
|
+
// AUTO-GENERATED — do not edit manually.
|
|
2182
|
+
// Run `npm run generate:icons` to regenerate after modifying MN_ICON_MAP.
|
|
2183
|
+
class MnIconAttributes {
|
|
2184
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnIconAttributes, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
2185
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.1.3", type: MnIconAttributes, isStandalone: true, selector: "mn-icon[mnIconPistol], mn-icon[mnIconPending], mn-icon[mnIconImagePlus], mn-icon[mnIconUpload], mn-icon[mnIconFile], mn-icon[mnIconTrash2], mn-icon[mnIconX]", ngImport: i0 });
|
|
2186
|
+
}
|
|
2187
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnIconAttributes, decorators: [{
|
|
2188
|
+
type: Directive,
|
|
2189
|
+
args: [{
|
|
2190
|
+
selector: 'mn-icon[mnIconPistol], mn-icon[mnIconPending], mn-icon[mnIconImagePlus], mn-icon[mnIconUpload], mn-icon[mnIconFile], mn-icon[mnIconTrash2], mn-icon[mnIconX]',
|
|
2191
|
+
standalone: true,
|
|
2192
|
+
}]
|
|
2193
|
+
}] });
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* MnFileInput Component
|
|
2197
|
+
*
|
|
2198
|
+
* A generic, accessible file input that implements Angular's ControlValueAccessor.
|
|
2199
|
+
* It styles selection to match the rest of the input family, shows image previews
|
|
2200
|
+
* (and a file icon + name for non-images), supports single or multiple selection,
|
|
2201
|
+
* several display layouts, and client-side `accept` / `maxSize` / `maxFiles` limits.
|
|
2202
|
+
*
|
|
2203
|
+
* The form control value is the plain selection: `File | null` (single) or
|
|
2204
|
+
* `File[]` (multiple). An optional `currentUrl`/`currentUrls` renders an
|
|
2205
|
+
* already-saved image; removing it leaves the value untouched and emits `cleared`.
|
|
2206
|
+
*
|
|
2207
|
+
* @example
|
|
2208
|
+
* ```html
|
|
2209
|
+
* <mn-lib-file-input
|
|
2210
|
+
* formControlName="image"
|
|
2211
|
+
* [props]="{ id: 'image', label: 'Cover', accept: 'image/*', displayMode: 'dropzone' }"
|
|
2212
|
+
* (cleared)="onRemoveExisting()">
|
|
2213
|
+
* </mn-lib-file-input>
|
|
2214
|
+
* ```
|
|
2215
|
+
*/
|
|
2216
|
+
class MnFileInput {
|
|
2217
|
+
ngControl = inject(NgControl, { optional: true, self: true });
|
|
2218
|
+
/** Configuration properties for the file input. */
|
|
2219
|
+
props;
|
|
2220
|
+
/** Emits whenever the selected file(s) change (in addition to the form control). */
|
|
2221
|
+
filesChange = new EventEmitter();
|
|
2222
|
+
/** Emits when the user removes an already-saved image (`currentUrl(s)`). */
|
|
2223
|
+
cleared = new EventEmitter();
|
|
2224
|
+
/** Resolved UI configuration for the file input. */
|
|
2225
|
+
uiConfig = {};
|
|
2226
|
+
/** Currently selected files (always an array internally). */
|
|
2227
|
+
files = signal([], ...(ngDevMode ? [{ debugName: "files" }] : []));
|
|
2228
|
+
/** Transient message for a rejected selection (accept/maxSize/maxFiles). */
|
|
2229
|
+
internalError = signal(null, ...(ngDevMode ? [{ debugName: "internalError" }] : []));
|
|
2230
|
+
configService = inject(MnConfigService);
|
|
2231
|
+
sectionPath = inject(MN_SECTION_PATH, { optional: true }) ?? [];
|
|
2232
|
+
explicitInstanceId = inject(MN_INSTANCE_ID, { optional: true });
|
|
2233
|
+
lang = inject(MnLanguageService);
|
|
2234
|
+
destroyRef = inject(DestroyRef);
|
|
2235
|
+
/** Object-URL previews aligned to {@link files}; null for non-image entries. */
|
|
2236
|
+
previewUrls = signal([], ...(ngDevMode ? [{ debugName: "previewUrls" }] : []));
|
|
2237
|
+
/** True once the user removed the single existing image. */
|
|
2238
|
+
currentCleared = signal(false, ...(ngDevMode ? [{ debugName: "currentCleared" }] : []));
|
|
2239
|
+
/** Indices of removed existing images (multiple mode). */
|
|
2240
|
+
removedExisting = signal(new Set(), ...(ngDevMode ? [{ debugName: "removedExisting" }] : []));
|
|
2241
|
+
/** Renderable entries: existing images (when nothing newer hides them) then new files. */
|
|
2242
|
+
displayItems = computed(() => {
|
|
2243
|
+
const items = [];
|
|
2244
|
+
if (this.props.multiple) {
|
|
2245
|
+
const urls = this.props.currentUrls ?? [];
|
|
2246
|
+
const removed = this.removedExisting();
|
|
2247
|
+
urls.forEach((url, i) => {
|
|
2248
|
+
if (!removed.has(i))
|
|
2249
|
+
items.push(this.existingItem(url, i));
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
else if (this.files().length === 0 && this.props.currentUrl && !this.currentCleared()) {
|
|
2253
|
+
items.push(this.existingItem(this.props.currentUrl, 0));
|
|
2254
|
+
}
|
|
2255
|
+
const previews = this.previewUrls();
|
|
2256
|
+
this.files().forEach((file, i) => {
|
|
2257
|
+
items.push({
|
|
2258
|
+
name: file.name,
|
|
2259
|
+
isImage: this.isImage(file),
|
|
2260
|
+
previewUrl: previews[i] ?? null,
|
|
2261
|
+
sizeLabel: this.humanFileSize(file.size),
|
|
2262
|
+
index: i,
|
|
2263
|
+
existing: false,
|
|
2264
|
+
});
|
|
2265
|
+
});
|
|
2266
|
+
return items;
|
|
2267
|
+
}, ...(ngDevMode ? [{ debugName: "displayItems" }] : []));
|
|
2268
|
+
/** Disabled state pushed by the forms API. */
|
|
2269
|
+
formDisabled = false;
|
|
2270
|
+
/**
|
|
2271
|
+
* Built-in default error messages in English.
|
|
2272
|
+
* Used when `useBuiltInErrorMessages` is true (default); overridable per-field.
|
|
2273
|
+
*/
|
|
2274
|
+
builtInErrorMessages = {
|
|
2275
|
+
required: 'This field is required',
|
|
2276
|
+
accept: 'This file type is not allowed',
|
|
2277
|
+
maxSize: (args) => `File is too large (max ${args.max})`,
|
|
2278
|
+
maxFiles: (args) => `Too many files (max ${args.max})`,
|
|
2279
|
+
};
|
|
2280
|
+
/** Registers this component as the ControlValueAccessor for the injected control. */
|
|
2281
|
+
constructor() {
|
|
2282
|
+
if (this.ngControl)
|
|
2283
|
+
this.ngControl.valueAccessor = this;
|
|
2284
|
+
}
|
|
2285
|
+
/** The effective display mode. */
|
|
2286
|
+
get displayMode() {
|
|
2287
|
+
return this.props.displayMode ?? 'dropzone';
|
|
2288
|
+
}
|
|
2289
|
+
/** Whether the control is disabled (via props or the forms API). */
|
|
2290
|
+
get isDisabled() {
|
|
2291
|
+
return this.formDisabled || !!this.props.disabled;
|
|
2292
|
+
}
|
|
2293
|
+
/** Native `accept` attribute value, or null for no restriction when unset. */
|
|
2294
|
+
get acceptAttr() {
|
|
2295
|
+
return this.props.accept ?? null;
|
|
2296
|
+
}
|
|
2297
|
+
// ========== ControlValueAccessor Implementation ==========
|
|
2298
|
+
/** Resolved id for the file input element. */
|
|
2299
|
+
get resolvedId() {
|
|
2300
|
+
return this.props.id;
|
|
2301
|
+
}
|
|
2302
|
+
/** Resolved name attribute for the file input element. */
|
|
2303
|
+
get resolvedName() {
|
|
2304
|
+
return this.props.name ?? null;
|
|
2305
|
+
}
|
|
2306
|
+
/** Tailwind-variant classes for the clickable control. */
|
|
2307
|
+
get controlClasses() {
|
|
2308
|
+
return mnFileInputVariants({
|
|
2309
|
+
size: this.props.size,
|
|
2310
|
+
borderRadius: this.props.borderRadius,
|
|
2311
|
+
shadow: this.props.shadow,
|
|
2312
|
+
fullWidth: this.props.fullWidth ?? (this.displayMode !== 'compact'),
|
|
2313
|
+
dropzone: this.displayMode === 'dropzone',
|
|
2314
|
+
disabled: this.isDisabled,
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
/** The attached form control, if any. */
|
|
2318
|
+
get control() {
|
|
2319
|
+
return this.ngControl?.control ?? null;
|
|
2320
|
+
}
|
|
2321
|
+
// ========== Selection handling ==========
|
|
2322
|
+
/** Whether to show control validation errors. */
|
|
2323
|
+
get showError() {
|
|
2324
|
+
const c = this.control;
|
|
2325
|
+
return !!c && c.invalid && (c.touched || c.dirty);
|
|
2326
|
+
}
|
|
2327
|
+
/** All control error messages (used when `showAllErrors` is true). */
|
|
2328
|
+
get errorMessages() {
|
|
2329
|
+
const errors = this.control?.errors;
|
|
2330
|
+
if (!errors)
|
|
2331
|
+
return [];
|
|
2332
|
+
return Object.keys(errors).map((key) => this.resolveControlError(key, errors));
|
|
2333
|
+
}
|
|
2334
|
+
/** Single control error message (priority-aware). */
|
|
2335
|
+
get errorMessage() {
|
|
2336
|
+
const errors = this.control?.errors;
|
|
2337
|
+
if (!errors)
|
|
2338
|
+
return null;
|
|
2339
|
+
return this.resolveControlError(this.pickErrorKey(errors), errors);
|
|
2340
|
+
}
|
|
2341
|
+
ngOnInit() {
|
|
2342
|
+
this.resolveConfig();
|
|
2343
|
+
const sub = this.lang.locale$.pipe(skip(1)).subscribe(() => this.resolveConfig());
|
|
2344
|
+
this.destroyRef.onDestroy(() => sub.unsubscribe());
|
|
2345
|
+
this.destroyRef.onDestroy(() => this.revokeAll());
|
|
2346
|
+
}
|
|
2347
|
+
/**
|
|
2348
|
+
* Writes a value from the form into the control.
|
|
2349
|
+
* @param val A `File`, an array of `File`, or null/undefined.
|
|
2350
|
+
*/
|
|
2351
|
+
writeValue(val) {
|
|
2352
|
+
const next = Array.isArray(val) ? val.filter((f) => f instanceof File)
|
|
2353
|
+
: val instanceof File ? [val]
|
|
2354
|
+
: [];
|
|
2355
|
+
this.setFiles(next);
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Registers the form's change callback.
|
|
2359
|
+
* @param fn Callback invoked with the new value.
|
|
2360
|
+
*/
|
|
2361
|
+
registerOnChange(fn) {
|
|
2362
|
+
this.onChange = fn;
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Registers the form's touched callback.
|
|
2366
|
+
* @param fn Callback invoked when the control is touched.
|
|
2367
|
+
*/
|
|
2368
|
+
registerOnTouched(fn) {
|
|
2369
|
+
this.onTouched = fn;
|
|
2370
|
+
}
|
|
2371
|
+
// ========== Derived view state ==========
|
|
2372
|
+
/**
|
|
2373
|
+
* Sets the disabled state of the control.
|
|
2374
|
+
* @param isDisabled Whether the control should be disabled.
|
|
2375
|
+
*/
|
|
2376
|
+
setDisabledState(isDisabled) {
|
|
2377
|
+
this.formDisabled = isDisabled;
|
|
2378
|
+
}
|
|
2379
|
+
/**
|
|
2380
|
+
* Handles a file-picker change: validates the incoming files against the
|
|
2381
|
+
* configured limits and updates the selection.
|
|
2382
|
+
* @param event The native change event from the hidden file input.
|
|
2383
|
+
*/
|
|
2384
|
+
onFileSelected(event) {
|
|
2385
|
+
const input = event.target;
|
|
2386
|
+
const incoming = Array.from(input.files ?? []);
|
|
2387
|
+
input.value = '';
|
|
2388
|
+
if (incoming.length === 0)
|
|
2389
|
+
return;
|
|
2390
|
+
this.addFiles(incoming);
|
|
2391
|
+
}
|
|
2392
|
+
/**
|
|
2393
|
+
* Removes a newly-selected file by index.
|
|
2394
|
+
* @param index Index into the current selection.
|
|
2395
|
+
*/
|
|
2396
|
+
removeFile(index) {
|
|
2397
|
+
const next = this.files().filter((_, i) => i !== index);
|
|
2398
|
+
this.internalError.set(null);
|
|
2399
|
+
this.setFiles(next);
|
|
2400
|
+
this.emit();
|
|
2401
|
+
}
|
|
2402
|
+
/**
|
|
2403
|
+
* Removes an already-saved image and notifies the consumer via `cleared`.
|
|
2404
|
+
* @param index Index of the existing image (0 in single mode).
|
|
2405
|
+
*/
|
|
2406
|
+
removeExisting(index) {
|
|
2407
|
+
if (this.props.multiple) {
|
|
2408
|
+
const set = new Set(this.removedExisting());
|
|
2409
|
+
set.add(index);
|
|
2410
|
+
this.removedExisting.set(set);
|
|
2411
|
+
}
|
|
2412
|
+
else {
|
|
2413
|
+
this.currentCleared.set(true);
|
|
2414
|
+
}
|
|
2415
|
+
this.cleared.emit();
|
|
2416
|
+
this.onTouched();
|
|
2417
|
+
}
|
|
2418
|
+
/** Whether the attached control carries a `required` validator. */
|
|
2419
|
+
isRequired() {
|
|
2420
|
+
return this.control?.hasValidator(Validators.required) ?? false;
|
|
2421
|
+
}
|
|
2422
|
+
/** Stable track key for a display item across renders. */
|
|
2423
|
+
itemKey(item) {
|
|
2424
|
+
return `${item.existing ? 'e' : 'f'}-${item.index}`;
|
|
2425
|
+
}
|
|
2426
|
+
/** Whether a file should render as an image. */
|
|
2427
|
+
isImage(file) {
|
|
2428
|
+
return (file.type ?? '').startsWith('image/');
|
|
2429
|
+
}
|
|
2430
|
+
/** Callback to notify Angular forms of value changes. */
|
|
2431
|
+
onChange = () => {
|
|
2432
|
+
};
|
|
2433
|
+
// ========== Error handling (control validators) ==========
|
|
2434
|
+
/** Callback to notify Angular forms when the control is touched. */
|
|
2435
|
+
onTouched = () => {
|
|
2436
|
+
};
|
|
2437
|
+
/** Resolves UI strings from config, layering built-in defaults and prop overrides. */
|
|
2438
|
+
resolveConfig() {
|
|
2439
|
+
const instanceId = this.explicitInstanceId || `mn-file-input-${this.props.id}`;
|
|
2440
|
+
const resolved = this.configService.resolve('mn-file-input', this.sectionPath, instanceId);
|
|
2441
|
+
const builtIn = {
|
|
2442
|
+
dropzoneHint: 'Click to upload or drag and drop',
|
|
2443
|
+
replaceLabel: 'Replace',
|
|
2444
|
+
removeLabel: 'Remove',
|
|
2445
|
+
};
|
|
2446
|
+
this.uiConfig = { ...builtIn, ...resolved };
|
|
2447
|
+
if (this.props.label)
|
|
2448
|
+
this.uiConfig = { ...this.uiConfig, label: this.props.label };
|
|
2449
|
+
if (this.props.dropzoneHint)
|
|
2450
|
+
this.uiConfig = { ...this.uiConfig, dropzoneHint: this.props.dropzoneHint };
|
|
2451
|
+
if (this.props.replaceLabel)
|
|
2452
|
+
this.uiConfig = { ...this.uiConfig, replaceLabel: this.props.replaceLabel };
|
|
2453
|
+
if (this.props.removeLabel)
|
|
2454
|
+
this.uiConfig = { ...this.uiConfig, removeLabel: this.props.removeLabel };
|
|
2455
|
+
}
|
|
2456
|
+
/**
|
|
2457
|
+
* Validates and merges newly-picked files into the current selection.
|
|
2458
|
+
* @param incoming The files chosen by the user.
|
|
2459
|
+
*/
|
|
2460
|
+
addFiles(incoming) {
|
|
2461
|
+
this.internalError.set(null);
|
|
2462
|
+
let errorKey = null;
|
|
2463
|
+
let errorArgs = {};
|
|
2464
|
+
let accepted = incoming.filter((f) => this.matchesAccept(f));
|
|
2465
|
+
if (accepted.length < incoming.length)
|
|
2466
|
+
errorKey = 'accept';
|
|
2467
|
+
if (this.props.maxSize != null) {
|
|
2468
|
+
const max = this.props.maxSize;
|
|
2469
|
+
const withinSize = accepted.filter((f) => f.size <= max);
|
|
2470
|
+
if (withinSize.length < accepted.length) {
|
|
2471
|
+
errorKey = 'maxSize';
|
|
2472
|
+
errorArgs = { max: this.humanFileSize(max) };
|
|
2473
|
+
}
|
|
2474
|
+
accepted = withinSize;
|
|
2475
|
+
}
|
|
2476
|
+
let next = this.props.multiple ? [...this.files(), ...accepted] : accepted.slice(-1);
|
|
2477
|
+
if (this.props.multiple && this.props.maxFiles != null && next.length > this.props.maxFiles) {
|
|
2478
|
+
next = next.slice(0, this.props.maxFiles);
|
|
2479
|
+
errorKey = 'maxFiles';
|
|
2480
|
+
errorArgs = { max: this.props.maxFiles };
|
|
2481
|
+
}
|
|
2482
|
+
if (errorKey)
|
|
2483
|
+
this.internalError.set(this.resolveMessage(errorKey, errorArgs));
|
|
2484
|
+
this.setFiles(next);
|
|
2485
|
+
this.emit();
|
|
2486
|
+
}
|
|
2487
|
+
/** Replaces the internal selection and rebuilds image previews. */
|
|
2488
|
+
setFiles(next) {
|
|
2489
|
+
this.revokeAll();
|
|
2490
|
+
this.files.set(next);
|
|
2491
|
+
this.previewUrls.set(next.map((f) => (this.isImage(f) ? URL.createObjectURL(f) : null)));
|
|
2492
|
+
}
|
|
2493
|
+
/** Emits the current value to the form and any listeners. */
|
|
2494
|
+
emit() {
|
|
2495
|
+
const value = this.props.multiple ? this.files() : (this.files()[0] ?? null);
|
|
2496
|
+
this.onChange(value);
|
|
2497
|
+
this.onTouched();
|
|
2498
|
+
this.filesChange.emit(value);
|
|
2499
|
+
}
|
|
2500
|
+
/** Revokes any outstanding object-URL previews to avoid leaks. */
|
|
2501
|
+
revokeAll() {
|
|
2502
|
+
for (const url of this.previewUrls()) {
|
|
2503
|
+
if (url)
|
|
2504
|
+
URL.revokeObjectURL(url);
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
/** Builds a display item for an already-saved image. */
|
|
2508
|
+
existingItem(url, index) {
|
|
2509
|
+
return {
|
|
2510
|
+
name: this.fileNameFromUrl(url),
|
|
2511
|
+
isImage: true,
|
|
2512
|
+
previewUrl: url,
|
|
2513
|
+
sizeLabel: null,
|
|
2514
|
+
index,
|
|
2515
|
+
existing: true,
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
/** Picks which control error key to display. */
|
|
2519
|
+
pickErrorKey(errors) {
|
|
2520
|
+
if (this.props.errorPriority) {
|
|
2521
|
+
for (const key of this.props.errorPriority) {
|
|
2522
|
+
if (errors[key] !== undefined)
|
|
2523
|
+
return key;
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
return Object.keys(errors)[0];
|
|
2527
|
+
}
|
|
2528
|
+
// ========== Helpers ==========
|
|
2529
|
+
/** Resolves a control error key to a message, interpolating its args. */
|
|
2530
|
+
resolveControlError(key, errors) {
|
|
2531
|
+
return this.resolveMessage(key, errors[key]);
|
|
2532
|
+
}
|
|
2533
|
+
/**
|
|
2534
|
+
* Resolves a message for an error key using the same precedence as the other
|
|
2535
|
+
* inputs: custom props > config > built-in > fallback > default.
|
|
2536
|
+
*/
|
|
2537
|
+
resolveMessage(key, args) {
|
|
2538
|
+
const customMsg = this.props.errorMessages?.[key];
|
|
2539
|
+
const configMsg = this.uiConfig.errorMessages?.[key];
|
|
2540
|
+
const useBuiltIn = this.props.useBuiltInErrorMessages !== false;
|
|
2541
|
+
const builtInMsg = useBuiltIn ? this.builtInErrorMessages[key] : undefined;
|
|
2542
|
+
const fallbackMsg = this.props.defaultErrorMessage;
|
|
2543
|
+
const msgDef = customMsg ?? configMsg ?? builtInMsg ?? fallbackMsg ?? 'Invalid input';
|
|
2544
|
+
if (typeof msgDef === 'function') {
|
|
2545
|
+
return msgDef(args ?? {}, {});
|
|
2546
|
+
}
|
|
2547
|
+
if (args && typeof args === 'object') {
|
|
2548
|
+
return msgDef.replace(/\{\{(\w+)}}/g, (_match, token) => {
|
|
2549
|
+
const value = args[token];
|
|
2550
|
+
return value !== undefined ? String(value) : `{{${token}}}`;
|
|
2551
|
+
});
|
|
2552
|
+
}
|
|
2553
|
+
return msgDef;
|
|
2554
|
+
}
|
|
2555
|
+
/** Checks a file against the configured `accept` filter (extensions and MIME globs). */
|
|
2556
|
+
matchesAccept(file) {
|
|
2557
|
+
const accept = this.props.accept;
|
|
2558
|
+
if (!accept)
|
|
2559
|
+
return true;
|
|
2560
|
+
const tokens = accept.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
2561
|
+
if (tokens.length === 0)
|
|
2562
|
+
return true;
|
|
2563
|
+
const name = file.name.toLowerCase();
|
|
2564
|
+
const type = (file.type ?? '').toLowerCase();
|
|
2565
|
+
return tokens.some((token) => {
|
|
2566
|
+
if (token.startsWith('.'))
|
|
2567
|
+
return name.endsWith(token);
|
|
2568
|
+
if (token.endsWith('/*'))
|
|
2569
|
+
return type.startsWith(token.slice(0, -1));
|
|
2570
|
+
return type === token;
|
|
2571
|
+
});
|
|
2572
|
+
}
|
|
2573
|
+
/** Formats a byte count as a human-readable size. */
|
|
2574
|
+
humanFileSize(bytes) {
|
|
2575
|
+
if (bytes < 1024)
|
|
2576
|
+
return `${bytes} B`;
|
|
2577
|
+
if (bytes < 1024 * 1024)
|
|
2578
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
2579
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
2580
|
+
}
|
|
2581
|
+
/** Derives a display name from a URL (last path segment). */
|
|
2582
|
+
fileNameFromUrl(url) {
|
|
2583
|
+
const clean = url.split('?')[0].split('#')[0];
|
|
2584
|
+
const segment = clean.substring(clean.lastIndexOf('/') + 1);
|
|
2585
|
+
return segment || 'image';
|
|
2586
|
+
}
|
|
2587
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnFileInput, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2588
|
+
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 <mn-icon [data]=\"{ size: 18 }\" mnIconUpload></mn-icon>\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 <mn-icon [data]=\"{ size: 16 }\" class=\"text-base-content/50\" mnIconFile></mn-icon>\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 <mn-icon [data]=\"{ size: 14 }\" mnIconX></mn-icon>\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 <mn-icon [data]=\"{ size: 18 }\" mnIconUpload></mn-icon>\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 <mn-icon [data]=\"{ size: 16 }\" class=\"text-base-content/50\" mnIconFile></mn-icon>\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 <mn-icon [data]=\"{ size: 16 }\" mnIconX></mn-icon>\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 <mn-icon [data]=\"{ size: 22 }\" class=\"text-base-content/50\" mnIconFile></mn-icon>\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=\"btn btn-circle btn-xs absolute top-1 right-1 border-none bg-black/60 text-white hover:bg-black/80\"\n type=\"button\">\n <mn-icon [data]=\"{ size: 14 }\" mnIconX></mn-icon>\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 <mn-icon [data]=\"{ size: 22 }\" mnIconImagePlus></mn-icon>\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 <mn-icon [data]=\"{ size: 28 }\" class=\"text-base-content/40\" mnIconImagePlus></mn-icon>\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 <mn-icon [data]=\"{ size: 24 }\" class=\"text-base-content/50\" mnIconFile></mn-icon>\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=\"btn btn-circle btn-sm btn-ghost text-base-content/60 hover:text-error\"\n type=\"button\">\n <mn-icon [data]=\"{ size: 16 }\" mnIconTrash2></mn-icon>\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: MnIcon, selector: "mn-icon", inputs: ["data"] }, { kind: "directive", type: MnIconAttributes, selector: "mn-icon[mnIconPistol], mn-icon[mnIconPending], mn-icon[mnIconImagePlus], mn-icon[mnIconUpload], mn-icon[mnIconFile], mn-icon[mnIconTrash2], mn-icon[mnIconX]" }] });
|
|
2589
|
+
}
|
|
2590
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnFileInput, decorators: [{
|
|
2591
|
+
type: Component,
|
|
2592
|
+
args: [{ selector: 'mn-lib-file-input', standalone: true, imports: [CommonModule, NgClass, MnErrorMessage, MnIcon, MnIconAttributes], 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 <mn-icon [data]=\"{ size: 18 }\" mnIconUpload></mn-icon>\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 <mn-icon [data]=\"{ size: 16 }\" class=\"text-base-content/50\" mnIconFile></mn-icon>\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 <mn-icon [data]=\"{ size: 14 }\" mnIconX></mn-icon>\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 <mn-icon [data]=\"{ size: 18 }\" mnIconUpload></mn-icon>\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 <mn-icon [data]=\"{ size: 16 }\" class=\"text-base-content/50\" mnIconFile></mn-icon>\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 <mn-icon [data]=\"{ size: 16 }\" mnIconX></mn-icon>\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 <mn-icon [data]=\"{ size: 22 }\" class=\"text-base-content/50\" mnIconFile></mn-icon>\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=\"btn btn-circle btn-xs absolute top-1 right-1 border-none bg-black/60 text-white hover:bg-black/80\"\n type=\"button\">\n <mn-icon [data]=\"{ size: 14 }\" mnIconX></mn-icon>\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 <mn-icon [data]=\"{ size: 22 }\" mnIconImagePlus></mn-icon>\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 <mn-icon [data]=\"{ size: 28 }\" class=\"text-base-content/40\" mnIconImagePlus></mn-icon>\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 <mn-icon [data]=\"{ size: 24 }\" class=\"text-base-content/50\" mnIconFile></mn-icon>\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=\"btn btn-circle btn-sm btn-ghost text-base-content/60 hover:text-error\"\n type=\"button\">\n <mn-icon [data]=\"{ size: 16 }\" mnIconTrash2></mn-icon>\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" }]
|
|
2593
|
+
}], ctorParameters: () => [], propDecorators: { props: [{
|
|
2594
|
+
type: Input,
|
|
2595
|
+
args: [{ required: true }]
|
|
2596
|
+
}], filesChange: [{
|
|
2597
|
+
type: Output
|
|
2598
|
+
}], cleared: [{
|
|
2599
|
+
type: Output
|
|
2600
|
+
}] } });
|
|
2601
|
+
|
|
2012
2602
|
const mnCheckboxVariants = tv({
|
|
2013
2603
|
base: 'mn-checkbox',
|
|
2014
2604
|
variants: {
|
|
@@ -4031,7 +4621,7 @@ class MnCollectionBase {
|
|
|
4031
4621
|
/**
|
|
4032
4622
|
* Measured pixel height of one full page, applied as a persistent `min-height` floor
|
|
4033
4623
|
* while paginated so a short page (the last page, or after a row is removed/filtered)
|
|
4034
|
-
* can't collapse the body and jump the layout below it. Blank space fills the
|
|
4624
|
+
* can't collapse the body and jump the layout below it. Blank space fills the remaindernpm
|
|
4035
4625
|
* at the bottom. Captured once a full page is actually on screen; `0` means unmeasured.
|
|
4036
4626
|
* Distinct from the transient {@link lockedMinHeight} reload lock — both combine in
|
|
4037
4627
|
* {@link bodyMinHeight} via `Math.max`.
|
|
@@ -5388,70 +5978,14 @@ class MnFormBodyComponent {
|
|
|
5388
5978
|
// =========================
|
|
5389
5979
|
// Feature: File Upload Fields
|
|
5390
5980
|
// =========================
|
|
5391
|
-
/**
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
if (fileField.maxSize) {
|
|
5400
|
-
const oversized = files.filter(f => f.size > fileField.maxSize);
|
|
5401
|
-
if (oversized.length > 0) {
|
|
5402
|
-
// Remove oversized files
|
|
5403
|
-
const valid = files.filter(f => f.size <= fileField.maxSize);
|
|
5404
|
-
this.fileSelections[key] = fileField.multiple
|
|
5405
|
-
? [...(this.fileSelections[key] || []), ...valid]
|
|
5406
|
-
: valid.slice(0, 1);
|
|
5407
|
-
}
|
|
5408
|
-
else {
|
|
5409
|
-
this.fileSelections[key] = fileField.multiple
|
|
5410
|
-
? [...(this.fileSelections[key] || []), ...files]
|
|
5411
|
-
: files.slice(0, 1);
|
|
5412
|
-
}
|
|
5413
|
-
}
|
|
5414
|
-
else {
|
|
5415
|
-
this.fileSelections[key] = fileField.multiple
|
|
5416
|
-
? [...(this.fileSelections[key] || []), ...files]
|
|
5417
|
-
: files.slice(0, 1);
|
|
5418
|
-
}
|
|
5419
|
-
// Enforce maxFiles
|
|
5420
|
-
if (fileField.maxFiles && this.fileSelections[key].length > fileField.maxFiles) {
|
|
5421
|
-
this.fileSelections[key] = this.fileSelections[key].slice(0, fileField.maxFiles);
|
|
5422
|
-
}
|
|
5423
|
-
const control = this.form.get(key);
|
|
5424
|
-
if (control) {
|
|
5425
|
-
control.setValue(this.fileSelections[key]);
|
|
5426
|
-
control.markAsTouched();
|
|
5427
|
-
}
|
|
5428
|
-
// Reset input so same file can be re-selected
|
|
5429
|
-
input.value = '';
|
|
5430
|
-
}
|
|
5431
|
-
removeFile(key, index) {
|
|
5432
|
-
this.fileSelections[key] = (this.fileSelections[key] || []).filter((_, i) => i !== index);
|
|
5433
|
-
const control = this.form.get(key);
|
|
5434
|
-
if (control) {
|
|
5435
|
-
control.setValue(this.fileSelections[key].length > 0 ? this.fileSelections[key] : null);
|
|
5436
|
-
}
|
|
5437
|
-
}
|
|
5438
|
-
getSelectedFiles(key) {
|
|
5439
|
-
return this.fileSelections[key] || [];
|
|
5440
|
-
}
|
|
5441
|
-
formatFileSize(bytes) {
|
|
5442
|
-
if (bytes < 1024)
|
|
5443
|
-
return bytes + ' B';
|
|
5444
|
-
if (bytes < 1024 * 1024)
|
|
5445
|
-
return (bytes / 1024).toFixed(1) + ' KB';
|
|
5446
|
-
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
5447
|
-
}
|
|
5448
|
-
getFileError(field) {
|
|
5449
|
-
const control = this.form.get(field.key);
|
|
5450
|
-
if (!control?.errors)
|
|
5451
|
-
return 'This field is required';
|
|
5452
|
-
if (control.errors['required'])
|
|
5453
|
-
return 'Please select a file';
|
|
5454
|
-
return 'Invalid file';
|
|
5981
|
+
/**
|
|
5982
|
+
* Invokes a FILE field's `onClear` callback when its existing image is removed.
|
|
5983
|
+
* FILE fields render {@link MnFileInput}, which owns selection/validation and
|
|
5984
|
+
* writes the value (`File | File[] | null`) straight to the form control.
|
|
5985
|
+
* @param field The field whose existing image was cleared.
|
|
5986
|
+
*/
|
|
5987
|
+
onFileCleared(field) {
|
|
5988
|
+
field.onClear?.();
|
|
5455
5989
|
}
|
|
5456
5990
|
async submit() {
|
|
5457
5991
|
// Run cross-field validators before submit
|
|
@@ -5477,11 +6011,11 @@ class MnFormBodyComponent {
|
|
|
5477
6011
|
}
|
|
5478
6012
|
}
|
|
5479
6013
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnFormBodyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
5480
|
-
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 })\"\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 })\"\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 })\"\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 })\"\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 })\"\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 searchable: asField(rowField.field).searchable,\n searchPlaceholder: asField(rowField.field).searchPlaceholder,\n maxSelections: asField(rowField.field).maxSelections,\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 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\n class=\"relative border-2 border-dashed border-base-300 rounded-xl p-6 text-center transition-colors hover:border-blue-400 hover:bg-blue-50/30\"\n [class.border-red-300]=\"form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched\"\n [class.opacity-50]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n >\n <input\n type=\"file\"\n [id]=\"'field-' + asKey(rowField.field.key)\"\n class=\"absolute inset-0 w-full h-full opacity-0 cursor-pointer\"\n [attr.accept]=\"asField(rowField.field).accept || null\"\n [attr.multiple]=\"asField(rowField.field).multiple || null\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (change)=\"onFileChange(rowField.field, $event)\"\n />\n <div class=\"flex flex-col items-center gap-2\">\n <svg class=\"w-8 h-8 text-base-content/40\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5\" />\n </svg>\n <p class=\"text-sm text-base-content/50\">{{ labels.fileUploadPrompt }}</p>\n @if (asField(rowField.field).accept) {\n <p class=\"text-xs text-base-content/40\">{{ labels.accepted }} {{ asField(rowField.field).accept }}</p>\n }\n @if (asField(rowField.field).maxSize) {\n <p class=\"text-xs text-base-content/40\">{{ labels.maxSize }} {{ formatFileSize(asField(rowField.field).maxSize) }}</p>\n }\n </div>\n </div>\n <!-- Selected files list -->\n @if (getSelectedFiles(asKey(rowField.field.key)).length > 0) {\n <div class=\"flex flex-col gap-1 mt-2\">\n @for (file of getSelectedFiles(asKey(rowField.field.key)); track file.name; let i = $index) {\n <div class=\"flex items-center justify-between px-3 py-1.5 bg-base-200 rounded-lg text-sm\">\n <span class=\"text-base-content truncate\">{{ file.name }} ({{ formatFileSize(file.size) }})</span>\n <button mnButton [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\" type=\"button\" class=\"text-base-content/40 hover:text-red-500 ml-2 cursor-pointer\" (click)=\"removeFile(asKey(rowField.field.key), i)\">×</button>\n </div>\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 {{ getFileError(rowField.field) }}\n </div>\n }\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 class=\"grid gap-4\" [style.grid-template-columns]=\"getGridColumns(row)\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div class=\"flex flex-col gap-2\" [style.grid-column]=\"getGridSpan(rowField)\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\">\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 class=\"grid gap-4\" [style.grid-template-columns]=\"getGridColumns(row)\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div class=\"flex flex-col gap-2\" [style.grid-column]=\"getGridSpan(rowField)\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\">\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 {{ 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 {{ 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}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i2.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i2.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i2.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: 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"] }] });
|
|
6014
|
+
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 })\"\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 })\"\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 })\"\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 })\"\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 })\"\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 searchable: asField(rowField.field).searchable,\n searchPlaceholder: asField(rowField.field).searchPlaceholder,\n maxSelections: asField(rowField.field).maxSelections,\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 class=\"grid gap-4\" [style.grid-template-columns]=\"getGridColumns(row)\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div class=\"flex flex-col gap-2\" [style.grid-column]=\"getGridSpan(rowField)\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\">\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 class=\"grid gap-4\" [style.grid-template-columns]=\"getGridColumns(row)\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div class=\"flex flex-col gap-2\" [style.grid-column]=\"getGridSpan(rowField)\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\">\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 {{ 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 {{ 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}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i2.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i2.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i2.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"] }] });
|
|
5481
6015
|
}
|
|
5482
6016
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnFormBodyComponent, decorators: [{
|
|
5483
6017
|
type: Component,
|
|
5484
|
-
args: [{ selector: 'mn-form-body', standalone: true, imports: [CommonModule, ReactiveFormsModule, MnButton, MnInputField, MnCheckbox, MnDatetime, MnMultiSelect, MnTextarea, MnSelect, MnCustomFieldHostDirective, MnTable, MnCustomBodyHostComponent], 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 })\"\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 })\"\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 })\"\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 })\"\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 })\"\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 searchable: asField(rowField.field).searchable,\n searchPlaceholder: asField(rowField.field).searchPlaceholder,\n maxSelections: asField(rowField.field).maxSelections,\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 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\n class=\"relative border-2 border-dashed border-base-300 rounded-xl p-6 text-center transition-colors hover:border-blue-400 hover:bg-blue-50/30\"\n [class.border-red-300]=\"form.get(asKey(rowField.field.key))?.invalid && form.get(asKey(rowField.field.key))?.touched\"\n [class.opacity-50]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n >\n <input\n type=\"file\"\n [id]=\"'field-' + asKey(rowField.field.key)\"\n class=\"absolute inset-0 w-full h-full opacity-0 cursor-pointer\"\n [attr.accept]=\"asField(rowField.field).accept || null\"\n [attr.multiple]=\"asField(rowField.field).multiple || null\"\n [disabled]=\"isFieldReadOnly(rowField.field) || isFieldDisabled(rowField.field)\"\n (change)=\"onFileChange(rowField.field, $event)\"\n />\n <div class=\"flex flex-col items-center gap-2\">\n <svg class=\"w-8 h-8 text-base-content/40\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5\" />\n </svg>\n <p class=\"text-sm text-base-content/50\">{{ labels.fileUploadPrompt }}</p>\n @if (asField(rowField.field).accept) {\n <p class=\"text-xs text-base-content/40\">{{ labels.accepted }} {{ asField(rowField.field).accept }}</p>\n }\n @if (asField(rowField.field).maxSize) {\n <p class=\"text-xs text-base-content/40\">{{ labels.maxSize }} {{ formatFileSize(asField(rowField.field).maxSize) }}</p>\n }\n </div>\n </div>\n <!-- Selected files list -->\n @if (getSelectedFiles(asKey(rowField.field.key)).length > 0) {\n <div class=\"flex flex-col gap-1 mt-2\">\n @for (file of getSelectedFiles(asKey(rowField.field.key)); track file.name; let i = $index) {\n <div class=\"flex items-center justify-between px-3 py-1.5 bg-base-200 rounded-lg text-sm\">\n <span class=\"text-base-content truncate\">{{ file.name }} ({{ formatFileSize(file.size) }})</span>\n <button mnButton [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\" type=\"button\" class=\"text-base-content/40 hover:text-red-500 ml-2 cursor-pointer\" (click)=\"removeFile(asKey(rowField.field.key), i)\">×</button>\n </div>\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 {{ getFileError(rowField.field) }}\n </div>\n }\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 class=\"grid gap-4\" [style.grid-template-columns]=\"getGridColumns(row)\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div class=\"flex flex-col gap-2\" [style.grid-column]=\"getGridSpan(rowField)\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\">\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 class=\"grid gap-4\" [style.grid-template-columns]=\"getGridColumns(row)\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div class=\"flex flex-col gap-2\" [style.grid-column]=\"getGridSpan(rowField)\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\">\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 {{ 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 {{ 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}\n"] }]
|
|
6018
|
+
args: [{ selector: 'mn-form-body', standalone: true, imports: [CommonModule, ReactiveFormsModule, MnButton, MnInputField, MnCheckbox, MnDatetime, MnMultiSelect, MnTextarea, MnFileInput, MnSelect, MnCustomFieldHostDirective, MnTable, MnCustomBodyHostComponent], 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 })\"\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 })\"\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 })\"\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 })\"\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 })\"\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 searchable: asField(rowField.field).searchable,\n searchPlaceholder: asField(rowField.field).searchPlaceholder,\n maxSelections: asField(rowField.field).maxSelections,\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 class=\"grid gap-4\" [style.grid-template-columns]=\"getGridColumns(row)\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div class=\"flex flex-col gap-2\" [style.grid-column]=\"getGridSpan(rowField)\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\">\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 class=\"grid gap-4\" [style.grid-template-columns]=\"getGridColumns(row)\">\n @for (rowField of row.fields; track rowField.field.key) {\n <div class=\"flex flex-col gap-2\" [style.grid-column]=\"getGridSpan(rowField)\" [style.display]=\"isFieldVisible(rowField.field) ? '' : 'none'\">\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 {{ 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 {{ 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}\n"] }]
|
|
5485
6019
|
}], propDecorators: { config: [{
|
|
5486
6020
|
type: Input
|
|
5487
6021
|
}], modalRef: [{
|
|
@@ -8001,126 +8535,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
|
|
|
8001
8535
|
type: Output
|
|
8002
8536
|
}] } });
|
|
8003
8537
|
|
|
8004
|
-
const mnIconVariants = tv({
|
|
8005
|
-
base: 'inline-flex shrink-0',
|
|
8006
|
-
variants: {
|
|
8007
|
-
color: {
|
|
8008
|
-
current: 'text-current',
|
|
8009
|
-
primary: 'text-primary',
|
|
8010
|
-
secondary: 'text-neutral',
|
|
8011
|
-
danger: 'text-error',
|
|
8012
|
-
warning: 'text-warning',
|
|
8013
|
-
success: 'text-success',
|
|
8014
|
-
accent: 'text-accent',
|
|
8015
|
-
},
|
|
8016
|
-
},
|
|
8017
|
-
defaultVariants: {
|
|
8018
|
-
color: 'current',
|
|
8019
|
-
},
|
|
8020
|
-
});
|
|
8021
|
-
|
|
8022
|
-
// Inline SVGs sourced from the .svg files in the ./icons/ directory
|
|
8023
|
-
const pistolSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.72 14.56 9 10h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v4c2.5 0 1 4 1 4-4 6-1 6-1 6h3.38a1 1 0 0 0 .89-.55za1 1 0 0 1 .9-.56H13a2 2 0 0 0 2-2v-1a1 1 0 0 1 1-1" /></svg>`;
|
|
8024
|
-
const pendingSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v5l4 2" /><path d="M14 20.775A9 9 0 0112 21" /><path d="M19 17.656a9 9 0 01-1.5 1.456" /><path d="M21 12a9 9 0 01-.228 2" /><path d="M21 8h-5" /><path d="M7.5 19.794c-6-3.464-6-12.124 0-15.588A9 9 0 0112 3a9.75 9.75 0 016.74 2.74L21 8V3" /></svg>`;
|
|
8025
|
-
const MN_ICON_MAP = {
|
|
8026
|
-
pistol: pistolSvg,
|
|
8027
|
-
pending: pendingSvg,
|
|
8028
|
-
};
|
|
8029
|
-
|
|
8030
|
-
class MnIcon {
|
|
8031
|
-
data = {};
|
|
8032
|
-
sanitizer = inject(DomSanitizer);
|
|
8033
|
-
el = inject(ElementRef);
|
|
8034
|
-
svgContent = '';
|
|
8035
|
-
get iconSize() {
|
|
8036
|
-
return this.data.size ?? 24;
|
|
8037
|
-
}
|
|
8038
|
-
get hostClasses() {
|
|
8039
|
-
return mnIconVariants({
|
|
8040
|
-
color: this.data.color,
|
|
8041
|
-
});
|
|
8042
|
-
}
|
|
8043
|
-
resolvedName = null;
|
|
8044
|
-
ngOnInit() {
|
|
8045
|
-
const attrs = this.el.nativeElement.attributes;
|
|
8046
|
-
for (const item of Array.from(attrs)) {
|
|
8047
|
-
const attr = item.name.toLowerCase();
|
|
8048
|
-
if (attr.startsWith('mnicon')) {
|
|
8049
|
-
const iconKey = attr.slice('mnicon'.length);
|
|
8050
|
-
for (const key of Object.keys(MN_ICON_MAP)) {
|
|
8051
|
-
if (key.toLowerCase() === iconKey) {
|
|
8052
|
-
this.resolvedName = key;
|
|
8053
|
-
break;
|
|
8054
|
-
}
|
|
8055
|
-
}
|
|
8056
|
-
if (this.resolvedName)
|
|
8057
|
-
break;
|
|
8058
|
-
}
|
|
8059
|
-
}
|
|
8060
|
-
this.updateContent();
|
|
8061
|
-
}
|
|
8062
|
-
ngOnChanges() {
|
|
8063
|
-
this.updateContent();
|
|
8064
|
-
}
|
|
8065
|
-
updateContent() {
|
|
8066
|
-
const name = this.data.name ?? this.resolvedName;
|
|
8067
|
-
if (name) {
|
|
8068
|
-
const raw = MN_ICON_MAP[name];
|
|
8069
|
-
if (raw) {
|
|
8070
|
-
const size = this.iconSize;
|
|
8071
|
-
const isFullSvg = raw.trim().startsWith('<svg');
|
|
8072
|
-
if (isFullSvg) {
|
|
8073
|
-
const sized = raw.replace(/<svg([^>]*)>/, (_match, attrs) => {
|
|
8074
|
-
let updated = attrs.replace(/width="[^"]*"/, `width="${size}"`);
|
|
8075
|
-
updated = updated.replace(/height="[^"]*"/, `height="${size}"`);
|
|
8076
|
-
if (!attrs.includes('width='))
|
|
8077
|
-
updated += ` width="${size}"`;
|
|
8078
|
-
if (!attrs.includes('height='))
|
|
8079
|
-
updated += ` height="${size}"`;
|
|
8080
|
-
return `<svg${updated}>`;
|
|
8081
|
-
});
|
|
8082
|
-
this.svgContent = this.sanitizer.bypassSecurityTrustHtml(sized);
|
|
8083
|
-
}
|
|
8084
|
-
else {
|
|
8085
|
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${raw}</svg>`;
|
|
8086
|
-
this.svgContent = this.sanitizer.bypassSecurityTrustHtml(svg);
|
|
8087
|
-
}
|
|
8088
|
-
}
|
|
8089
|
-
else {
|
|
8090
|
-
this.svgContent = '';
|
|
8091
|
-
}
|
|
8092
|
-
}
|
|
8093
|
-
else {
|
|
8094
|
-
this.svgContent = '';
|
|
8095
|
-
}
|
|
8096
|
-
}
|
|
8097
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnIcon, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
8098
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.1.3", type: MnIcon, isStandalone: true, selector: "mn-icon", inputs: { data: "data" }, host: { properties: { "class": "this.hostClasses" } }, usesOnChanges: true, ngImport: i0, template: "<span\n [style.width.px]=\"iconSize\"\n [style.height.px]=\"iconSize\"\n style=\"display: inline-flex; align-items: center; justify-content: center;\"\n [innerHTML]=\"svgContent\"\n></span>\n" });
|
|
8099
|
-
}
|
|
8100
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnIcon, decorators: [{
|
|
8101
|
-
type: Component,
|
|
8102
|
-
args: [{ selector: 'mn-icon', standalone: true, template: "<span\n [style.width.px]=\"iconSize\"\n [style.height.px]=\"iconSize\"\n style=\"display: inline-flex; align-items: center; justify-content: center;\"\n [innerHTML]=\"svgContent\"\n></span>\n" }]
|
|
8103
|
-
}], propDecorators: { data: [{
|
|
8104
|
-
type: Input
|
|
8105
|
-
}], hostClasses: [{
|
|
8106
|
-
type: HostBinding,
|
|
8107
|
-
args: ['class']
|
|
8108
|
-
}] } });
|
|
8109
|
-
|
|
8110
|
-
// AUTO-GENERATED — do not edit manually.
|
|
8111
|
-
// Run `npm run generate:icons` to regenerate after modifying MN_ICON_MAP.
|
|
8112
|
-
class MnIconAttributes {
|
|
8113
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnIconAttributes, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
8114
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.1.3", type: MnIconAttributes, isStandalone: true, selector: "mn-icon[mnIconPistol], mn-icon[mnIconPending]", ngImport: i0 });
|
|
8115
|
-
}
|
|
8116
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnIconAttributes, decorators: [{
|
|
8117
|
-
type: Directive,
|
|
8118
|
-
args: [{
|
|
8119
|
-
selector: 'mn-icon[mnIconPistol], mn-icon[mnIconPending]',
|
|
8120
|
-
standalone: true,
|
|
8121
|
-
}]
|
|
8122
|
-
}] });
|
|
8123
|
-
|
|
8124
8538
|
/**
|
|
8125
8539
|
* Injection token for the base URL used by all CRUD service requests.
|
|
8126
8540
|
*
|
|
@@ -8622,5 +9036,5 @@ function enableMnPreviewMode(configService, langService, allowedOrigins) {
|
|
|
8622
9036
|
* Generated bundle index. Do not edit.
|
|
8623
9037
|
*/
|
|
8624
9038
|
|
|
8625
|
-
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_ICON_MAP, MN_INPUT_FIELD_CONFIG, MN_INSTANCE_ID, MN_LIB_DUAL_HORIZONTAL_IMAGE, MN_MULTI_SELECT_CONFIG, MN_SECTION_PATH, MN_SELECT_CONFIG, MN_TEXTAREA_CONFIG, MnAlertOutletComponent, MnAlertService, MnAlertStore, MnBadge, MnButton, MnCheckbox, MnCollectionBase, MnCollectionPagination, MnConfigService, MnConfirmationBodyComponent, MnCustomBodyHostComponent, MnDatetime, MnDualHorizontalImage, MnFormBodyComponent, MnGrid, MnHiddenBelowDirective, MnHttpService, MnIcon, MnIconAttributes, MnInformationCard, MnInputField, MnInstanceDirective, MnLanguageService, MnList, MnModalRef, MnModalService, MnModalShellComponent, MnMultiSelect, 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, defaultTextAdapter, enableMnPreviewMode, isTranslatable, mnAlertVariants, mnBadgeVariants, mnButtonVariants, mnCheckboxVariants, mnCheckboxWrapperVariants, mnDatetimeVariants, mnIconVariants, mnInformationCardVariants, mnInputFieldVariants, mnMultiSelectVariants, mnSelectVariants, mnSkeletonVariants, mnTextareaVariants, numberAdapter, pickAdapter, provideMnAlerts, provideMnCalendarConfig, provideMnComponentConfig, provideMnConfig, provideMnLanguage, resolveCalendarConfig };
|
|
9039
|
+
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_ICON_MAP, MN_INPUT_FIELD_CONFIG, MN_INSTANCE_ID, MN_LIB_DUAL_HORIZONTAL_IMAGE, MN_MULTI_SELECT_CONFIG, MN_SECTION_PATH, MN_SELECT_CONFIG, MN_TEXTAREA_CONFIG, MnAlertOutletComponent, MnAlertService, MnAlertStore, MnBadge, MnButton, MnCheckbox, MnCollectionBase, MnCollectionPagination, MnConfigService, MnConfirmationBodyComponent, MnCustomBodyHostComponent, MnDatetime, MnDualHorizontalImage, MnFileInput, MnFormBodyComponent, MnGrid, MnHiddenBelowDirective, MnHttpService, MnIcon, MnIconAttributes, MnInformationCard, MnInputField, MnInstanceDirective, MnLanguageService, MnList, MnModalRef, MnModalService, MnModalShellComponent, MnMultiSelect, 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, defaultTextAdapter, enableMnPreviewMode, isTranslatable, mnAlertVariants, mnBadgeVariants, mnButtonVariants, mnCheckboxVariants, mnCheckboxWrapperVariants, mnDatetimeVariants, mnFileInputVariants, mnIconVariants, mnInformationCardVariants, mnInputFieldVariants, mnMultiSelectVariants, mnSelectVariants, mnSkeletonVariants, mnTextareaVariants, numberAdapter, pickAdapter, provideMnAlerts, provideMnCalendarConfig, provideMnComponentConfig, provideMnConfig, provideMnLanguage, resolveCalendarConfig };
|
|
8626
9040
|
//# sourceMappingURL=mn-angular-lib.mjs.map
|