207 lines
9.1 KiB
HTML
207 lines
9.1 KiB
HTML
@if (state()) {
|
|
<div class="fixed inset-0 z-(--z-dialog-over-modal)">
|
|
<div class="absolute inset-0 bg-black/50" (click)="cancel($event)"></div>
|
|
|
|
<div class="absolute left-1/2 top-1/2 flex max-h-[min(80vh,720px)] w-[min(92vw,560px)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-hidden rounded-2xl border border-(--color-border) bg-white p-6 shadow-(--shadow-modal)">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold text-slate-900">{{ state()!.title }}</h3>
|
|
<button type="button" mat-stroked-button (click)="cancel($event)">Close</button>
|
|
</div>
|
|
|
|
<div class="grid gap-4 overflow-auto pr-1">
|
|
@for (field of ungroupedFields(); track field.key) {
|
|
<ng-container *ngTemplateOutlet="fieldControl; context: { $implicit: field }"></ng-container>
|
|
}
|
|
|
|
@for (group of fieldGroups(); track group.name) {
|
|
<section class="rounded-md border border-slate-200">
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-slate-600 hover:text-slate-900"
|
|
[attr.aria-expanded]="isGroupOpen(group.name)"
|
|
(click)="toggleGroup(group.name, $event)">
|
|
<mat-icon [fontIcon]="isGroupOpen(group.name) ? 'expand_less' : 'expand_more'" class="!h-4 !w-4 !text-base !leading-4"></mat-icon>
|
|
<span>{{ group.name }}</span>
|
|
@if (groupErrorCount(group.fields) > 0) {
|
|
<span class="ml-auto rounded-full bg-red-50 px-2 text-[10px] font-bold text-red-700">
|
|
{{ groupErrorCount(group.fields) }} to fix
|
|
</span>
|
|
} @else if (!isGroupOpen(group.name) && groupSetCount(group.fields) > 0) {
|
|
<span class="ml-auto rounded-full bg-indigo-50 px-2 text-[10px] font-bold text-indigo-700">
|
|
{{ groupSetCount(group.fields) }} set
|
|
</span>
|
|
}
|
|
</button>
|
|
@if (isGroupOpen(group.name)) {
|
|
<div class="grid gap-4 border-t border-slate-200 p-3">
|
|
@for (field of group.fields; track field.key) {
|
|
<ng-container *ngTemplateOutlet="fieldControl; context: { $implicit: field }"></ng-container>
|
|
}
|
|
</div>
|
|
}
|
|
</section>
|
|
}
|
|
</div>
|
|
|
|
<div class="mt-auto flex justify-end gap-2">
|
|
@if (isPreviewOnly()) {
|
|
<button type="button" mat-stroked-button (click)="cancel($event)">Close</button>
|
|
} @else {
|
|
<button type="button" mat-stroked-button (click)="cancel($event)">Cancel</button>
|
|
<button type="button" mat-flat-button [disabled]="hasInvalidField()" (click)="save($event)">Save</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- One field control, so the plain list and the collapsible sections cannot drift apart. -->
|
|
<ng-template #fieldControl let-field>
|
|
<div class="text-sm font-medium text-slate-700">
|
|
<!--
|
|
The control and its reset on one row: the button keeps its place and greys out rather than
|
|
appearing when a value is typed, so the field never changes height and the option is
|
|
visible before there is anything to reset.
|
|
-->
|
|
<div class="flex items-start gap-2">
|
|
<div class="min-w-0 grow">
|
|
@switch (field.type) {
|
|
@case ('display') {
|
|
<fieldset class="mt-1 border border-slate-200 rounded-md bg-slate-50 p-3">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<legend class="px-1 text-xs font-semibold text-slate-600">{{ field.label }}</legend>
|
|
@if (field.copyable) {
|
|
<button
|
|
type="button"
|
|
mat-stroked-button
|
|
class="!min-w-0 !px-2 !py-0 !text-[11px]"
|
|
matTooltip="Copy"
|
|
aria-label="Copy value"
|
|
(click)="copyFieldValue(field.key, $event)">
|
|
<mat-icon fontIcon="content_copy"></mat-icon>
|
|
</button>
|
|
}
|
|
</div>
|
|
<div class="text-sm text-slate-800 whitespace-pre-wrap break-words">{{ draft[field.key] }}</div>
|
|
</fieldset>
|
|
}
|
|
@case ('textarea') {
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mt-1">
|
|
<mat-label>{{ field.label }}</mat-label>
|
|
<textarea
|
|
matInput
|
|
[rows]="field.rows ?? 8"
|
|
[placeholder]="field.placeholder ?? ''"
|
|
[readonly]="field.readonly === true"
|
|
[attr.autofocus]="field.autofocus ? '' : null"
|
|
[attr.data-autofocus]="field.autofocus ? 'true' : null"
|
|
[ngModel]="draft[field.key]"
|
|
(ngModelChange)="setFieldValue(field.key, $event)">
|
|
</textarea>
|
|
</mat-form-field>
|
|
}
|
|
@case ('select') {
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mt-1">
|
|
<mat-label>{{ field.label }}</mat-label>
|
|
<mat-select
|
|
[disabled]="field.readonly === true"
|
|
[attr.autofocus]="field.autofocus ? '' : null"
|
|
[attr.data-autofocus]="field.autofocus ? 'true' : null"
|
|
[ngModel]="draft[field.key]"
|
|
(ngModelChange)="setFieldValue(field.key, $event)">
|
|
@for (option of field.options ?? []; track option.value) {
|
|
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
|
}
|
|
</mat-select>
|
|
</mat-form-field>
|
|
}
|
|
@case ('checkbox') {
|
|
<div class="mt-2">
|
|
<mat-checkbox
|
|
[disabled]="field.readonly === true"
|
|
[attr.data-autofocus]="field.autofocus ? 'true' : null"
|
|
[ngModel]="draft[field.key]"
|
|
(ngModelChange)="setFieldValue(field.key, $event)">
|
|
{{ field.label }}
|
|
</mat-checkbox>
|
|
</div>
|
|
}
|
|
@case ('number') {
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mt-1">
|
|
<mat-label>{{ field.label }}</mat-label>
|
|
<input
|
|
matInput
|
|
type="number"
|
|
[placeholder]="field.placeholder ?? ''"
|
|
[attr.min]="field.min ?? null"
|
|
[attr.max]="field.max ?? null"
|
|
[attr.step]="field.stepIncrement ?? field.step ?? null"
|
|
[readonly]="field.readonly === true"
|
|
[ngModel]="draft[field.key]"
|
|
(ngModelChange)="setFieldValue(field.key, $event)" />
|
|
</mat-form-field>
|
|
}
|
|
@default {
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mt-1">
|
|
<mat-label>{{ field.label }}</mat-label>
|
|
<input
|
|
matInput
|
|
[type]="field.type === 'password' && !isPasswordVisible(field.key) ? 'password' : 'text'"
|
|
[placeholder]="field.placeholder ?? ''"
|
|
[attr.maxlength]="field.maxLength ?? null"
|
|
[readonly]="field.readonly === true"
|
|
[attr.autofocus]="field.autofocus ? '' : null"
|
|
[attr.data-autofocus]="field.autofocus ? 'true' : null"
|
|
[ngModel]="draft[field.key]"
|
|
(ngModelChange)="setFieldValue(field.key, $event)" />
|
|
@if (field.type === 'password') {
|
|
<button
|
|
type="button"
|
|
mat-icon-button
|
|
matSuffix
|
|
[attr.aria-label]="isPasswordVisible(field.key) ? 'Hide password' : 'Show password'"
|
|
(click)="togglePasswordVisibility(field.key, $event)">
|
|
<mat-icon [fontIcon]="isPasswordVisible(field.key) ? 'visibility_off' : 'visibility'"></mat-icon>
|
|
</button>
|
|
}
|
|
</mat-form-field>
|
|
}
|
|
}
|
|
|
|
</div>
|
|
|
|
@if (showUseDefault(field)) {
|
|
<button
|
|
type="button"
|
|
mat-stroked-button
|
|
class="mt-2 shrink-0 !min-h-0 !min-w-0 !px-2 !py-0.5 !text-[11px] !leading-4"
|
|
[disabled]="!canUseDefault(field)"
|
|
[matTooltip]="usesDefault(field) ? 'Already using the default' : 'Clear this field so the default applies'"
|
|
(click)="useDefault(field, $event)">
|
|
Use default
|
|
</button>
|
|
}
|
|
</div>
|
|
|
|
<!--
|
|
What is under the control, ranked: what is wrong, then what the field is doing, then what
|
|
it is for. Outside the switch so every control type reports the same way, select included.
|
|
The tip used to be rendered twice - once inside the form field as a mat-hint, once here in
|
|
error red - so an ordinary description read as a failure and overlapped the button.
|
|
-->
|
|
@if (hasFieldMeta(field)) {
|
|
<div class="mt-1.5 text-[11px] leading-4">
|
|
@if (fieldError(field); as error) {
|
|
<span class="font-semibold text-red-700">{{ error }}</span>
|
|
} @else if (defaultHint(field); as hint) {
|
|
<span class="font-normal text-slate-500">{{ hint }}</span>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@if (field.tip) {
|
|
<p class="mt-1 text-[11px] font-normal leading-4 text-slate-500">{{ field.tip }}</p>
|
|
}
|
|
</div>
|
|
</ng-template>
|