signal-template-forms
Version:
A powerful, type-safe Angular forms library built with signals, providing reactive form management with excellent developer experience and performance.
4,625 lines • 331 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, inject, Injector, signal, effect, input, computed, ElementRef, Renderer2, HostListener, HostBinding, Directive, output, viewChildren, ChangeDetectionStrategy, Component, ApplicationRef, InjectionToken, Inject, Optional, viewChild, DestroyRef, ViewEncapsulation, model } from '@angular/core';
import { timer, firstValueFrom, from, fromEvent, tap as tap$1, isObservable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import * as i1 from 'lucide-angular';
import { CircleCheck, CircleX, LucideAngularModule, PlusCircleIcon, MinusCircleIcon, ChevronDownCircleIcon, ChevronUpCircleIcon, SquareCheck } from 'lucide-angular';
import * as i2 from '@angular/common';
import { DOCUMENT, NgClass, CommonModule, NgComponentOutlet, NgTemplateOutlet } from '@angular/common';
import { trigger, state, transition, style, animate } from '@angular/animations';
var FormFieldType;
(function (FormFieldType) {
FormFieldType["AUTOCOMPLETE"] = "autocomplete";
FormFieldType["CHECKBOX"] = "checkbox";
FormFieldType["CHECKBOX_GROUP"] = "checkbox-group";
FormFieldType["CHIPLIST"] = "chiplist";
FormFieldType["COLOR"] = "color";
FormFieldType["DATETIME"] = "datetime";
FormFieldType["FILE"] = "file";
FormFieldType["MULTISELECT"] = "multiselect";
FormFieldType["NUMBER"] = "number";
FormFieldType["PASSWORD"] = "password";
FormFieldType["RADIO"] = "radio";
FormFieldType["RATING"] = "rating";
FormFieldType["REPEATABLE_GROUP"] = "repeatable-group";
FormFieldType["SELECT"] = "select";
FormFieldType["SLIDER"] = "slider";
FormFieldType["SWITCH"] = "switch";
FormFieldType["TEXT"] = "text";
FormFieldType["TEXTAREA"] = "textarea";
})(FormFieldType || (FormFieldType = {}));
class ValueHelper {
static isNull(value) {
return value === null || value === undefined;
}
static isString(value) {
return typeof value === 'string';
}
static isNumber(value) {
return typeof value === 'number' && !isNaN(value);
}
static isBoolean(value) {
return typeof value === 'boolean';
}
static isDate(value) {
return value instanceof Date && !isNaN(value.getTime());
}
static isObject(value) {
return typeof value === 'object' && value !== null;
}
static hasValueProperty(value) {
return ValueHelper.isObject(value) && 'value' in value;
}
static isCheckboxInput(element) {
return element instanceof HTMLInputElement && element.type === 'checkbox';
}
static isDateInput(element) {
return element instanceof HTMLInputElement && element.type === 'date';
}
static isNumberInput(element) {
return element instanceof HTMLInputElement && element.type === 'number';
}
static isComboboxDiv(element) {
return (element instanceof HTMLDivElement &&
element.getAttribute('role') === 'combobox');
}
static isFormElement(element) {
return (element instanceof HTMLInputElement ||
element instanceof HTMLSelectElement ||
element instanceof HTMLTextAreaElement);
}
static extractValueString(value) {
if (ValueHelper.isNull(value)) {
return '';
}
if (ValueHelper.isString(value)) {
return value;
}
if (ValueHelper.isNumber(value) || ValueHelper.isBoolean(value))
return String(value);
if (ValueHelper.hasValueProperty(value)) {
return String(value.label);
}
return String(value);
}
}
class FieldRoleAttributesService {
getAttributesForField(field) {
const baseAttributes = this.getBaseAttributes(field);
const roleAttributes = this.getRoleSpecificAttributes(field);
const typeAttributes = this.getTypeSpecificAttributes(field);
return {
role: roleAttributes.role,
ariaAttributes: {
...baseAttributes.ariaAttributes,
...roleAttributes.ariaAttributes,
...typeAttributes.ariaAttributes,
},
inputAttributes: {
...baseAttributes.inputAttributes,
...roleAttributes.inputAttributes,
...typeAttributes.inputAttributes,
},
};
}
getBaseAttributes(field) {
const name = String(field.name);
return {
ariaAttributes: {
'aria-invalid': field.error() ? 'true' : 'false',
'aria-describedby': field.error() ? `${name}-error` : null,
},
inputAttributes: {
disabled: field.isDisabled() ? true : null,
placeholder: this.getPlaceholder(field),
},
};
}
/**
* Generates intelligent default placeholders based on field type and name
*/
getPlaceholder(field) {
// If user provided a custom placeholder, use it
if (field.config?.placeholder) {
return field.config.placeholder;
}
// Generate smart defaults based on field type
const fieldName = String(field.name);
const label = field.label || fieldName;
switch (field.type) {
case FormFieldType.TEXT:
case FormFieldType.PASSWORD:
case FormFieldType.TEXTAREA:
case FormFieldType.NUMBER:
return `Type ${label.toLowerCase()} here`;
case FormFieldType.SELECT:
case FormFieldType.AUTOCOMPLETE:
return `Select a ${label.toLowerCase()}`;
case FormFieldType.MULTISELECT:
case FormFieldType.CHIPLIST:
return `Select ${label.toLowerCase()}`;
case FormFieldType.DATETIME:
return `Select ${label.toLowerCase()}`;
case FormFieldType.FILE:
return `Choose ${label.toLowerCase()} file`;
case FormFieldType.COLOR:
return `Pick a color for ${label.toLowerCase()}`;
// These field types don't typically use placeholders
case FormFieldType.CHECKBOX:
case FormFieldType.CHECKBOX_GROUP:
case FormFieldType.RADIO:
case FormFieldType.SWITCH:
case FormFieldType.SLIDER:
case FormFieldType.RATING:
return null;
default:
return `Enter ${label.toLowerCase()}`;
}
}
getRoleSpecificAttributes(field) {
const name = String(field.name);
switch (field.type) {
case FormFieldType.SELECT:
case FormFieldType.AUTOCOMPLETE:
return {
role: 'combobox',
ariaAttributes: {
'aria-owns': `${name}-listbox`,
'aria-controls': `${name}-listbox`,
'aria-haspopup': 'listbox',
},
inputAttributes: {},
};
case FormFieldType.CHECKBOX_GROUP:
return {
role: 'group',
ariaAttributes: {
'aria-labelledby': `${name}-legend`,
},
inputAttributes: {},
};
case FormFieldType.RADIO:
return {
role: 'radiogroup',
ariaAttributes: {
'aria-labelledby': `${name}-legend`,
},
inputAttributes: {},
};
case FormFieldType.SWITCH:
const fieldValue = field.value();
let ariaChecked = null;
if (fieldValue === null || fieldValue === undefined) {
ariaChecked = null;
}
else if (typeof fieldValue === 'boolean') {
ariaChecked = fieldValue ? 'true' : 'false';
}
else if (fieldValue) {
ariaChecked = String(fieldValue);
}
else {
ariaChecked = String(fieldValue);
}
return {
role: 'switch',
ariaAttributes: {
'aria-checked': ariaChecked,
},
inputAttributes: {
checked: Boolean(fieldValue),
},
};
case FormFieldType.SLIDER:
const min = String(field.config?.min ?? 0);
const max = String(field.config?.max ?? 100);
const step = String(field.config?.step ?? 1);
const value = String(field.value() ?? 0);
return {
role: 'slider',
ariaAttributes: {
'aria-valuemin': min,
'aria-valuemax': max,
'aria-valuenow': value,
},
inputAttributes: {
min,
max,
step,
},
};
case FormFieldType.RATING:
return {
role: 'radiogroup',
ariaAttributes: {
'aria-valuemin': '0',
'aria-valuemax': field.config?.max?.toString() ?? '5',
'aria-valuenow': field.value()?.toString() ?? '0',
'aria-label': field.label,
'aria-invalid': field.error() ? 'true' : 'false',
'aria-describedby': field.error()
? `error-${String(field.name)}`
: `hint-${String(field.name)}`,
},
inputAttributes: {},
};
case FormFieldType.FILE:
const accept = Array.isArray(field.config?.accept)
? field.config.accept.join(',')
: null;
return {
role: 'button',
ariaAttributes: {
'aria-label': field.config?.uploadText ?? 'Click or drag a file to upload',
},
inputAttributes: {
accept,
},
};
case FormFieldType.MULTISELECT:
return {
role: 'listbox',
ariaAttributes: {
'aria-label': field.label,
'aria-multiselectable': 'true',
},
inputAttributes: {},
};
default:
return {
ariaAttributes: {},
inputAttributes: {},
};
}
}
getTypeSpecificAttributes(field) {
switch (field.type) {
case FormFieldType.PASSWORD:
return {
ariaAttributes: {
'aria-label': `Password field for ${field.label}`,
},
inputAttributes: {
autocomplete: 'current-password',
},
};
case FormFieldType.COLOR:
return {
ariaAttributes: {
'aria-label': `Color picker for ${field.label}`,
},
inputAttributes: {},
};
case FormFieldType.DATETIME:
return {
ariaAttributes: {},
inputAttributes: {},
};
default:
return {
ariaAttributes: {},
inputAttributes: {},
};
}
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: FieldRoleAttributesService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: FieldRoleAttributesService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: FieldRoleAttributesService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}] });
class ValidationService {
injector = inject(Injector);
validatedFields = new Map();
// Blur refresh signal to track blur events
blurRefresh = signal(null);
constructor() {
this.setupSyncValidationEffect();
this.setupBlurValidationEffect();
}
/**
* Trigger blur validation for a specific field
* This creates a refresh signal that triggers validation
*/
triggerBlurValidation(fieldPath) {
this.blurRefresh.set(fieldPath);
// Reset after a short delay to allow for multiple triggers
setTimeout(() => {
if (this.blurRefresh() === fieldPath) {
this.blurRefresh.set(null);
}
}, 50);
}
setupSyncValidationEffect() {
effect(() => {
this.validatedFields.forEach(({ field, form, config }) => {
const value = field.value();
if (config.trigger === 'change') {
this.runSyncValidation(field, value, form);
}
if (config.trigger === 'change' && field.asyncValidators?.length) {
timer(config.debounceMs || 300).subscribe(() => {
this.runAsyncValidation(field, value, form);
});
}
});
}, { injector: this.injector });
}
setupBlurValidationEffect() {
effect(() => {
const blurredFieldPath = this.blurRefresh();
if (!blurredFieldPath) {
return;
}
const fieldData = this.validatedFields.get(blurredFieldPath);
if (!fieldData) {
return;
}
const { field, form, config } = fieldData;
if (config.trigger === 'blur') {
// Mark as touched since blur occurred
field.touched.set(true);
// Run validation
this.runSyncValidation(field, field.value(), form);
if (field.asyncValidators?.length) {
this.runAsyncValidation(field, field.value(), form);
}
}
}, { injector: this.injector });
}
/**
* Register a field for validation
*/
setupFieldValidation(field, form) {
const fieldPath = field.path;
const config = this.getValidationConfig(field);
if (this.validatedFields.has(fieldPath)) {
return;
}
this.validatedFields.set(fieldPath, { field, form, config });
if (config.validateAsyncOnInit && field.asyncValidators?.length) {
timer(0).subscribe(() => {
this.runAsyncValidation(field, field.value(), form);
});
}
}
/**
* Get the effective validation configuration for a field
*/
getValidationConfig(field) {
const fieldConfig = field.validationConfig;
const configFromFieldConfig = field.config?.validation;
return {
trigger: fieldConfig?.trigger || configFromFieldConfig?.trigger || 'change',
debounceMs: fieldConfig?.debounceMs || configFromFieldConfig?.debounceMs || 300,
validateAsyncOnInit: fieldConfig?.validateAsyncOnInit ||
configFromFieldConfig?.validateAsyncOnInit ||
false,
};
}
/**
* Run synchronous validation
*/
runSyncValidation(field, value, form) {
const validators = field.validators ?? [];
for (const validator of validators) {
const error = validator(value, form);
if (error) {
field.error.set(error);
return;
}
}
field.error.set(null);
}
/**
* Run asynchronous validation
*/
runAsyncValidation(field, value, form) {
const asyncValidators = field.asyncValidators ?? [];
if (asyncValidators.length === 0) {
return;
}
field.validating.set(true);
field.asyncError.set(null);
// Run all async validators in parallel
const validationPromises = asyncValidators.map((validator) => {
const result = validator(value, form);
return result instanceof Promise ? result : firstValueFrom(result);
});
from(Promise.all(validationPromises))
.pipe(tap((results) => {
// Find the first error
const firstError = results.find((result) => result !== null);
field.asyncError.set(firstError || null);
field.validating.set(false);
}))
.subscribe({
error: (error) => {
console.error('Async validation error:', error);
field.asyncError.set('Validation failed');
field.validating.set(false);
},
});
}
/**
* Manually trigger validation for a field
*/
triggerValidation(field, trigger = 'submit') {
const value = field.value();
const fieldData = this.validatedFields.get(field.path);
if (fieldData) {
if (trigger === 'submit' || fieldData.config.trigger === 'change') {
this.runSyncValidation(field, value, fieldData.form);
}
if (trigger === 'submit' && field.asyncValidators?.length) {
this.runAsyncValidation(field, value, fieldData.form);
}
}
}
/**
* Get the combined error for a field (sync + async)
*/
getCombinedError(field) {
const hasAsyncValidation = 'asyncError' in field && 'validating' in field;
if (hasAsyncValidation && 'error' in field) {
const syncError = field.error();
const asyncError = field.asyncError();
return syncError || asyncError;
}
if ('error' in field) {
const error = field.error();
if (typeof error === 'boolean') {
return error ? 'Field has errors' : null;
}
return error;
}
return null;
}
/**
* Check if a field is currently validating or has any errors
*/
isFieldInvalid(field) {
// Check if field has async validation properties
const hasAsyncValidation = 'asyncError' in field && 'validating' in field;
if (hasAsyncValidation && 'error' in field) {
const error = field.error();
const asyncError = field.asyncError();
const validating = field.validating();
return !!(error || asyncError || validating);
}
// For fields without async validation, just check sync error
if ('error' in field) {
const error = field.error();
// Handle both string | null and boolean error types
if (typeof error === 'boolean') {
return error;
}
return !!error;
}
return false;
}
/**
* Validate all fields for submit trigger and return if form is valid
*/
validateFormForSubmit(fields, form) {
let valid = true;
for (const field of fields) {
field.touched.set(true);
if (this.isFieldWithForm(field)) {
const nestedValid = field.form.validateForm();
valid = valid && nestedValid;
continue;
}
if (this.isRepeatableField(field)) {
const nestedForms = field.repeatableForms();
const allValid = nestedForms.every((form) => form.validateForm());
valid = valid && allValid;
continue;
}
const fieldData = this.validatedFields.get(field.path);
if (fieldData) {
this.runSyncValidation(field, field.value(), form);
if (field.error()) {
valid = false;
}
}
else {
const validators = field.validators ?? [];
for (const validator of validators) {
const error = validator(field.value(), form);
if (error) {
field.error.set(error);
valid = false;
break;
}
else {
field.error.set(null);
}
}
}
}
return valid;
}
isFieldWithForm(field) {
return 'form' in field && field.form !== undefined;
}
isRepeatableField(field) {
return 'repeatableForms' in field && field.repeatableForms !== undefined;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: ValidationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: ValidationService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: ValidationService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}], ctorParameters: () => [] });
/**
* Signal-based form field directive that handles two-way data binding, validation,
* and accessibility attributes for form elements
*
* @template TModel - The form model type
* @template K - The field key type
* @template TFieldType - The form field type enum
*/
class SignalModelDirective {
signalModel = input.required();
initialValue = signal(null);
hasCapturedInitial = signal(false);
roleAttributes = computed(() => this.roleService.getAttributesForField(this.signalModel()));
elementRef = inject((ElementRef));
renderer = inject(Renderer2);
injector = inject(Injector);
roleService = inject(FieldRoleAttributesService);
validationService = inject(ValidationService);
form = computed(() => this.signalModel().getForm());
/**
* Binds the field name as the element's name attribute
* @returns The field name as a string
*/
get name() {
return String(this.signalModel().name);
}
/**
* Binds the field name as the element's id attribute
* @returns The field name as a string
*/
get id() {
const baseName = String(this.signalModel().name);
// For radio buttons, we need unique IDs within the group
if (this.elementRef.nativeElement.type === 'radio') {
return this.generateUniqueRadioId(baseName);
}
return baseName;
}
/**
* Generates a unique ID for radio buttons by checking existing IDs in the DOM
*/
generateUniqueRadioId(baseName) {
// Check if any other radio with the same name already exists
const existingRadios = document.querySelectorAll(`input[name="${baseName}"]`);
const existingIds = Array.from(existingRadios).map((radio) => radio.id);
// Generate ID with index
let index = 0;
let candidateId = `${baseName}_${index}`;
while (existingIds.includes(candidateId)) {
index++;
candidateId = `${baseName}_${index}`;
}
return candidateId;
}
/**
* Initializes the directive by setting up all reactive effects
*/
constructor() {
this.setupInitialValueEffect();
this.setupFocusEffect();
this.setupValueSyncEffect();
this.setupAttributesEffect();
}
/**
* Angular lifecycle hook that sets the initial element value
*/
ngOnInit() {
this.setInitialValue();
}
/**
* Sets up effect to capture the initial field value for dirty state tracking
*/
setupInitialValueEffect() {
effect(() => {
if (!this.hasCapturedInitial() && this.signalModel().value()) {
this.initialValue.set(this.signalModel().value());
this.hasCapturedInitial.set(true);
}
}, { injector: this.injector });
}
/**
* Sets up effect to handle focus highlighting behavior and auto-focus
*/
setupFocusEffect() {
effect(() => {
if (!this.signalModel().focus())
return;
const native = this.elementRef.nativeElement;
native.focus?.();
this.renderer.addClass(native, 'form-error-highlight');
setTimeout(() => {
this.renderer.removeClass(native, 'form-error-highlight');
this.signalModel().focus.set(false);
}, 800);
}, { injector: this.injector });
}
/**
* Sets up effect to sync element value with model value changes
*/
setupValueSyncEffect() {
effect(() => {
const value = this.signalModel().value();
this.setElementValue(this.elementRef.nativeElement, value);
}, { injector: this.injector });
}
/**
* Sets up effect to dynamically apply all field attributes (role, aria, input)
*/
setupAttributesEffect() {
effect(() => {
const attrs = this.roleAttributes();
const el = this.elementRef.nativeElement;
if (attrs.role) {
this.renderer.setAttribute(el, 'role', attrs.role);
}
Object.entries(attrs.ariaAttributes).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
this.renderer.setAttribute(el, key, String(value));
return;
}
this.renderer.removeAttribute(el, key);
});
Object.entries(attrs.inputAttributes).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
this.renderer.setAttribute(el, key, String(value));
return;
}
this.renderer.removeAttribute(el, key);
});
const fieldValue = this.getFieldValue();
if (fieldValue !== null) {
this.renderer.setAttribute(el, 'value', fieldValue);
}
}, { injector: this.injector });
}
/**
* Sets the initial element value from the field model
*/
setInitialValue() {
this.setElementValue(this.elementRef.nativeElement, this.signalModel().value());
}
/**
* Extracts the field value as a display string
* @returns The field value formatted as a string, or null if no value
*/
getFieldValue() {
const value = this.signalModel().value();
return ValueHelper.extractValueString(value);
}
/**
* Handles blur events by marking the field as touched and triggering blur validation
*/
onBlur() {
const field = this.signalModel();
field.touched.set(true);
// Trigger blur validation through the validation service
this.validationService.triggerBlurValidation(field.path);
}
/**
* Handles focus events by updating the field focus state
*/
onFocus() {
this.signalModel().focus.set(true);
}
/**
* Handles input events and updates the model value with parsed data
*/
onInput(event) {
const target = event.target;
const raw = this.extractValue(target);
const parsed = this.signalModel().parser?.(raw) ?? raw;
this.signalModel().value.set(parsed);
const dirty = JSON.stringify(parsed) !== JSON.stringify(this.initialValue());
this.signalModel().dirty.set(dirty);
this.signalModel().touched.set(true);
}
/**
* Extracts the raw value from different types of form elements
* @param target - The form element to extract value from
* @returns The extracted value based on element type
*/
extractValue(target) {
if (target instanceof HTMLInputElement) {
const raw = target.value;
switch (target.type) {
case 'checkbox':
return target.checked;
case 'number':
case 'range':
const parsed = parseFloat(raw);
return isNaN(parsed) ? 0 : parsed;
case 'date':
return target.valueAsDate;
case 'radio':
if (this.signalModel().type === FormFieldType.RADIO) {
const config = this.signalModel().config;
if (config?.valueType === 'number') {
return parseFloat(raw);
}
if (config?.valueType === 'boolean') {
return raw === 'true';
}
}
return raw;
default:
return raw;
}
}
if (target instanceof HTMLSelectElement ||
this.signalModel().type === FormFieldType.AUTOCOMPLETE) {
const raw = target.value;
const field = this.signalModel();
if (field.type === FormFieldType.SELECT) {
if ('options' in field) {
const foundOption = field.options().find((opt) => String(opt.label) === raw);
if (foundOption) {
return foundOption.label;
}
}
const config = field.config;
if (config?.valueType === 'number') {
return parseFloat(raw);
}
if (config?.valueType === 'boolean') {
return raw === 'true';
}
}
return raw;
}
if (target instanceof HTMLTextAreaElement) {
return target.value;
}
return null;
}
/**
* Sets the value of a form element based on its type
* @param element - The form element to update
* @param value - The value to set
*/
setElementValue(element, value) {
if (ValueHelper.isCheckboxInput(element)) {
this.setCheckboxValue(element, Boolean(value));
return;
}
if (ValueHelper.isDateInput(element) && ValueHelper.isDate(value)) {
this.setDateValue(element, value);
return;
}
if (ValueHelper.isNumberInput(element) && ValueHelper.isNumber(value)) {
this.setNumberValue(element, value);
return;
}
if (ValueHelper.isFormElement(element)) {
this.setStandardValue(element, value);
return;
}
if (ValueHelper.isComboboxDiv(element)) {
this.setComboboxValue(element, value);
return;
}
}
/**
* Sets the checked state of a checkbox input
* @param element - The checkbox input element
* @param value - The boolean value to set
*/
setCheckboxValue(element, value) {
element.checked = value;
}
/**
* Sets the date value of a date input
* @param element - The date input element
* @param value - The Date object to set
*/
setDateValue(element, value) {
element.valueAsDate = value;
}
/**
* Sets the numeric value of a number input
* @param element - The number input element
* @param value - The number value to set
*/
setNumberValue(element, value) {
element.valueAsNumber = value;
}
/**
* Sets the string value of standard form elements
* @param element - The form element (input, select, textarea)
* @param value - The value to convert to string and set
*/
setStandardValue(element, value) {
element.value = ValueHelper.extractValueString(value);
}
/**
* Sets accessibility attributes for custom combobox elements
* @param element - The div element with combobox role
* @param value - The value to set as aria-valuenow
*/
setComboboxValue(element, value) {
const valueString = ValueHelper.extractValueString(value);
if (valueString) {
this.renderer.setAttribute(element, 'aria-valuenow', valueString);
}
else {
this.renderer.removeAttribute(element, 'aria-valuenow');
}
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.14", type: SignalModelDirective, isStandalone: true, selector: "[signalModel]", inputs: { signalModel: { classPropertyName: "signalModel", publicName: "signalModel", isSignal: true, isRequired: true, transformFunction: null } }, host: { listeners: { "blur": "onBlur()", "focus": "onFocus()", "input": "onInput($event)" }, properties: { "attr.name": "this.name", "attr.id": "this.id" } }, ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalModelDirective, decorators: [{
type: Directive,
args: [{
selector: '[signalModel]',
standalone: true,
}]
}], ctorParameters: () => [], propDecorators: { name: [{
type: HostBinding,
args: ['attr.name']
}], id: [{
type: HostBinding,
args: ['attr.id']
}], onBlur: [{
type: HostListener,
args: ['blur']
}], onFocus: [{
type: HostListener,
args: ['focus']
}], onInput: [{
type: HostListener,
args: ['input', ['$event']]
}] } });
var FormStatus;
(function (FormStatus) {
FormStatus["Error"] = "Error";
FormStatus["Idle"] = "Idle";
FormStatus["Submitting"] = "Submitting";
FormStatus["Success"] = "Success";
})(FormStatus || (FormStatus = {}));
var NumberInputType;
(function (NumberInputType) {
NumberInputType["STANDARD"] = "standard";
NumberInputType["CURRENCY"] = "currency";
NumberInputType["PERCENTAGE"] = "percentage";
NumberInputType["DECIMAL"] = "decimal";
NumberInputType["INTEGER"] = "integer";
NumberInputType["UNIT_CONVERSION"] = "unitConversion";
})(NumberInputType || (NumberInputType = {}));
class FormEngine {
static validateForm(fields, form, validationService) {
return () => {
if (validationService &&
typeof validationService.validateFormForSubmit === 'function') {
let valid = true;
for (const field of fields) {
if (this.isFieldWithForm(field)) {
const nestedValid = field.form.validateForm();
valid = valid && nestedValid;
continue;
}
if (this.isRepeatableField(field)) {
const nestedForms = field.repeatableForms();
const allValid = nestedForms.every((form) => form.validateForm());
valid = valid && allValid;
continue;
}
}
const fieldsValid = validationService.validateFormForSubmit(fields, form);
return valid && fieldsValid;
}
let valid = true;
for (const field of fields) {
if (this.isFieldWithForm(field)) {
const nestedValid = field.form.validateForm();
valid = valid && nestedValid;
continue;
}
if (this.isRepeatableField(field)) {
const nestedForms = field.repeatableForms();
const allValid = nestedForms.every((form) => form.validateForm());
valid = valid && allValid;
continue;
}
field.touched.set(true);
const validators = field.validators ?? [];
for (const validator of validators) {
const error = validator(field.value(), form);
if (error) {
field.error.set(error);
valid = false;
break;
}
else {
field.error.set(null);
}
}
}
return valid;
};
}
static resetForm(fields, initialModel) {
return () => {
for (const field of fields) {
if (this.isFieldWithForm(field)) {
field.form.reset();
continue;
}
const initialValue = initialModel[field.name];
field.value.set(initialValue);
field.touched.set(false);
field.dirty.set(false);
field.error.set(null);
field.focus.set(false);
}
};
}
static patchForm(fields) {
return (patch) => {
for (const field of fields) {
const fieldName = field.name;
const newValue = patch[fieldName];
if (newValue === undefined)
continue;
if (this.isFieldWithForm(field) && typeof newValue === 'object') {
field.form.patchValue(newValue);
}
else {
field.value.set(newValue);
field.dirty.set(true);
}
}
};
}
static setFormValue(fields) {
return (value) => {
for (const field of fields) {
const fieldName = field.name;
const newValue = value[fieldName];
if (this.isFieldWithForm(field) && typeof newValue === 'object') {
field.form.setValue(newValue);
}
else {
field.value.set(newValue);
field.dirty.set(true);
}
}
};
}
static getErrors(fields) {
return () => {
const errors = [];
for (const field of fields) {
if (this.isFieldWithForm(field)) {
const nestedErrors = field.form.getErrors();
const updatedNestedErrors = nestedErrors.map((err) => ({
...err,
path: `${field.path}.${err.path}`,
}));
errors.push(...updatedNestedErrors);
continue;
}
if (this.isRepeatableField(field)) {
const nestedForms = field.repeatableForms();
nestedForms.forEach((form) => {
const nestedErrors = form.getErrors();
const updatedNestedErrors = nestedErrors.map((err) => ({
name: err.name,
message: err.message,
path: err.path,
field: err.field,
focusField: err.focusField,
trigger: err.trigger,
}));
errors.push(...updatedNestedErrors);
});
continue;
}
if (field.error()) {
const trigger = field.validationConfig?.trigger ||
field.config?.validation?.trigger ||
'change';
errors.push({
name: field.name,
message: field.error() ?? '',
path: field.path,
field: field,
trigger,
focusField: () => {
if (field.focus) {
field.focus.set(true);
}
},
});
}
}
return errors;
};
}
static getValueFromFields(fields, form) {
return fields.reduce((acc, field) => {
const name = field.name;
const disabled = typeof field.disabled === 'function'
? field.disabled(form)
: field.disabled;
if (!disabled) {
acc[name] = this.getFieldOutputValue(field);
}
return acc;
}, {});
}
static getFieldOutputValue(field) {
if (this.isRepeatableField(field)) {
return field.repeatableForms().map((form) => form.getValue());
}
if (this.isCheckboxGroupField(field)) {
const val = field.value();
const valueType = field.valueType ?? 'array';
if (valueType === 'map') {
return val;
}
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
return Object.entries(val)
.filter(([, checked]) => checked)
.map(([key]) => key);
}
return val;
}
if (this.isFieldWithForm(field)) {
return field.form.getValue();
}
return field.value();
}
static getValue(fields) {
return () => fields.reduce((acc, field) => {
acc[field.name] = field.value();
return acc;
}, {});
}
static getField(fields) {
return (key) => {
const field = fields.find((f) => f.name === key);
if (!field) {
throw new Error(`Field ${String(key)} not found`);
}
// Handle repeatable fields
if (this.isRepeatableField(field)) {
return field;
}
// Handle nested form fields
if (this.isFieldWithForm(field)) {
return field;
}
// Handle regular fields
return field;
};
}
static getRawValue(fields) {
return computed(() => {
return fields.reduce((acc, field) => {
acc[field.name] = field.value();
return acc;
}, {});
});
}
static runSaveHandler(fields, status, form, onSave) {
return () => {
const isValid = this.validateForm(fields, form);
if (!isValid()) {
status.set(FormStatus.Error);
return;
}
status.set(FormStatus.Submitting);
try {
const getValue = this.getValue(fields);
onSave?.(getValue());
status.set(FormStatus.Success);
for (const field of fields) {
if (this.isFieldWithForm(field)) {
field.form.save();
}
else {
field.touched.set(false);
field.dirty.set(false);
if (form.config?.disableUponComplete) {
field.disabled = () => true;
}
}
}
if (!form.config?.disableUponComplete) {
setTimeout(() => status.set(FormStatus.Idle), 2000);
}
}
catch {
status.set(FormStatus.Error);
}
};
}
static isFieldWithForm(field) {
return 'form' in field && field.form !== undefined;
}
static isRepeatableField(field) {
return 'repeatableForms' in field && field.repeatableForms !== undefined;
}
static isCheckboxGroupField(field) {
return field.type === FormFieldType.CHECKBOX_GROUP;
}
}
class FieldUtils {
/**
* Creates default form configuration with theme settings
* @param config - Optional user configuration to merge with defaults
* @returns Complete configuration with defaults applied
*/
static createDefaultConfig(config) {
const defaults = {
layout: 'flex',
theme: 'light',
allowDarkMode: false,
};
if (!config) {
return defaults;
}
// Apply theme defaults based on allowDarkMode setting
const themeDefaults = {
theme: config.allowDarkMode ? 'auto' : 'light',
allowDarkMode: config.allowDarkMode ?? defaults.allowDarkMode,
};
return {
...defaults,
...config,
...themeDefaults,
};
}
static anyTouched(fields) {
return computed(() => {
return fields.some((field) => {
if (this.isFieldWithForm(field)) {
return field.form.anyTouched();
}
if (this.isRepeatableField(field)) {
if (field.touched()) {
return true;
}
return field.repeatableForms().some((form) => form.anyTouched());
}
return field.touched();
});
});
}
static hasSaved(form) {
return computed(() => {
return (!form.anyTouched() &&
!form.anyDirty() &&
form.status() === FormStatus.Success);
});
}
static anyDirty(fields) {
return computed(() => {
return fields.some((field) => {
if (this.isFieldWithForm(field)) {
return field.form.anyDirty();
}
if (this.isRepeatableField(field)) {
if (field.dirty()) {
return true;
}
return field.repeatableForms().some((form) => form.anyDirty());
}
return field.dirty();
});
});
}
static isFieldWithForm(field) {
return 'form' in field && field.form !== undefined;
}
static isRepeatableField(field) {
return 'repeatableForms' in field && field.repeatableForms !== undefined;
}
}
// field-factory.ts
/**
* FieldFactory - Factory class for building Signal Form fields
*
* Responsible for converting field builder configurations into fully
* initialized SignalFormField instances with reactive state management.
* Handles different field types including nested forms, repeatable groups,
* and fields with static or computed options.
*
* Features:
* - Type-safe field construction with TypeScript generics
* - Automatic reactive state binding (value, error, validation, etc.)
* - Support for nested form groups and repeatable field arrays
* - Dynamic and computed options for select-type fields
* - Path tracking for nested field references
*
* @example
* ```typescript
* const textField = FieldFactory.build(
* { name: 'email', type: FormFieldType.TEXT, label: 'Email' },
* { email: '' },
* formContainer,
* 'user'
* );
* ```
*/
class FieldFactory {
/**
* Builds a complete SignalFormField from a field configuration
*
* Converts field builder input into a reactive field with full state management.
* Automatically detects field type (normal, nested group, or repeatable group)
* and creates appropriate field structure with computed properties and signals.
*
* @template TModel - The TypeScript type of the form data model
* @param field - Field configuration from form builder
* @param model - Current form data model containing initial values
* @param formRef - Reference to the parent form container
* @param parentPath - Path prefix for nested fields (e.g., "user.address")
* @returns Fully initialized SignalFormField with reactive state
*
* @example
* ```typescript
* // Build a simple text field
* const nameField = FieldFactory.build(
* { name: 'firstName', type: FormFieldType.TEXT, label: 'First Name' },
* { firstName: 'John' },
* formContainer
* );
*
* // Build a select field with options
* const countryField = FieldFactory.build(
* {
* name: 'country',
* type: FormFieldType.SELECT,
* label: 'Country',
* options: [{ value: 'US', label: 'United States' }]
* },
* { country: 'US' },
* formContainer
* );
* ```
*/
static build(field, model, formRef, parentPath = '') {
const rawValue = model[field.name];
const referencePath = parentPath
? `${parentPath}.${String(field.name)}`
: `${String(field.name)}`;
// Create base reactive state that all fields share
const baseFieldState = {
path: referencePath,
error: signal(null),
asyncError: signal(null),
validating: signal(false),
touched: signal(false),
dirty: signal(false),
focus: signal(false),
value: signal(rawValue),
getForm: () => formRef,
isDisabled: computed(() => typeof field.disabled === 'function'
? field.disabled(formRef)
: (field.disabled ?? false)),
isHidden: computed(() => typeof field.hidden === 'function'
? field.hidden(formRef)
: (field.hidden ?? false)),
};
// Handle Repeatable Group fields (arrays of forms)
if (this.isRepeatableGroupField(field)) {
return this.buildRepeatableGroup({ ...field, path: referencePath }, rawValue, formRef, referencePath, baseFieldState);
}
// Handle Nested Form Group fields (nested forms)
if (this.isNestedGroupField(field)) {
return this.buildNestedForm({ ...field, path: referencePath }, rawValue, formRef, referencePath, baseFieldState);
}
// Handle normal fields (text, select, etc.)
let baseField = {
...field,
...baseFieldState,
path: referencePath,
};
// Handle options for fields that support them (select, radio, etc.)
if (!this.hasOptions(field)) {
return baseField;
}
if (this.hasComputedOptions(field)) {
// Create computed options that react to form state changes
const fieldWithComputedOptions = field;
const computedOptionsSignal = computed(() => {
const sourceValue = fieldWithComputedOptions.computedOptions.source(formRef);
return fieldWithComputedOptions.computedOptions.filterFn(sourceValue, fieldWithComputedOptions.options, baseField.value());
});
return {
...baseField,
options: computedOptionsSignal,
};
}
// Use static options wrapped in a signal
return {
...baseField,
options: signal(field.options),
};
}
/**
* Type guard to check if a field is a repeatable group field
* Repeatable groups contain arrays of form items that can be added/removed
*
* @template TModel - The form model type
* @param field - Field configuration to check
* @returns True if field is a repeatable group, false otherwise
* @private
*/
static isRepeatableGroupField(field) {
return 'type' in field && field.type === FormFieldType.REPEATABLE_GROUP;
}
/**
* Type guard to check if a field is a nested group field
* Nested groups contain sub-forms with their own field collections
*
* @template TModel - The form model type
* @param field - Field configuration to check
* @returns True if field is a nested group, false otherwise
* @private
*/
static isNestedGroupField(field) {
return (!('type' in field) && 'fields' in field && Array.isArray(field.fields));
}
/**
* Type guard to check if a field supports options (select, radio, etc.)
* Uses Extract to get only field types that have options property
*
* @template TModel - The form model type
* @param field - Field configuration to check
* @returns True if field supports options, false otherwise
* @private
*/
static hasOptions(field) {
return ('type' in field &&
'options' in field &&
Array.isArray(field.options) &&
[
FormFieldType.SELECT,
FormFieldType.RADIO,
FormFieldType.CHECKBOX,
FormFieldType.CHECKBOX_GROUP,
FormFieldType.MULTISELECT,
FormFieldType.CHIPLIST,
].includes(field.type));
}
/**
* Checks if a field has computed/dynamic options configuration
* Computed options change based on form state or other reactive values
*
* @template TModel - The form model type
* @param field - Field configuration to check
* @returns True if field has computed options, false otherwise
* @private
*/
static hasComputedOptions(field) {
return ('computedOptions' in field && field.computedOptions !== undefined);
}
/**
* Builds a repeatable group field that manages an array of sub-forms
*
* Creates a field that contains multiple instances of the same form structure,
* allowing users to add and remove items dynamically. Each item is a complete
* form with its own validation and state management.
*
* @template TModel - The form model type
* @param field - Repeatable group field configuration
* @param rawValue - Current array value from the model
* @param parentForm - Reference to the parent form container
* @param referencePath - Path to this field for nested references
* @param baseFieldState - Base reactive state for the field
* @returns Configured repeatable group field with add/remove functionality
* @private
*/
static buildRepeatableGroup(field, rawValue, parentForm, referencePath, baseFieldState) {
const items = Array.isArray(rawValue) ? rawValue : [];
// Create individual forms for each array item
const repeatableForms = signal(items.map((item, index) => SignalFormBuilder.createForm({
model: item,
fields: field.fields,
config: FieldUtils.createDefaultConfig(field.config),
parentForm: parentForm,
parentPath: `${referencePath}[${index}]`,
})));
const repeatableField = {
...field,
error: signal(false),
touched: signal(false),
dirty: signal(false),
value: computed(() => repeatableForms().map((f) => f.getValue())),
repeatableForms,
addItem: (initial = {}) => {
const currentForms = repeatableForms();
const newIndex = currentForms.length;
const newForm = SignalFormBuilder.createForm({
model: initial,
fields: field.fields,
config: FieldUtils.createDefaultConfig(field.config),
parentForm: parentForm,
parentPath: `${referencePath}[${newIndex}]`,
});
repeatableForms.update((forms) => [...forms, newForm]);
repeatableField.dirty.set(true);
repeatableField.touched.set(true);
},
removeItem: (index) => {
repeatableForms.update((forms) => forms.filter((_, i) => i !== index));
repeatableField.dirty.set(true);
repeatableField.touched.set(true);
},
};
return repeatableField;
}
/**
* Builds a nested form group field that contains a sub-form
*
* Creates a field that embeds another complete form within the current form.
* The nested form has its own fields, validation, and state management while
* being part of the parent form's structure.
*
* @template TModel - The form model type
* @param field - Nested group field configuration
* @param rawValue - Current nested object value from the model
* @param parentForm - Reference to the parent form container
* @param referencePath - Path to this field for nested references
* @param baseFieldState - Base reactive state for the field
* @returns Configured nested form field with embedded sub-form
* @private
*/
static buildNestedForm(field, rawValue, parentForm, referencePath, baseFieldState) {
// Create embedded form for the nested object
const nestedForm = SignalFormBuilder.createForm({
model: rawValue,
fields: field.fields,
config: FieldUtils.createDefaultConfig(field.config),
parentForm: parentForm,
parentPath: referencePath,
});
const nestedField = {
...field,
...baseFieldState,
form: nestedForm,
fields: nestedForm.fields,
};
return nestedField;
}
}
// # region
// #endregion
/**
* SignalFormBuilder - Main factory class for creating Signal Forms
*
* Provides static methods to create different types of forms:
* - Single forms with standard fields
* - Stepped/wizard forms with multiple pages
* - Array forms for managing collections of items
*
* Features:
* - Type-safe form creation with TypeScript generics
* - Automatic field binding and validation setup
* - Reactive computed properties for form state
* - Built-in save/reset functionality
* - Parent-child form relationships
*
* @example
* ```typescript
* const userForm = SignalFormBuilder.createForm({
* model: { name: '', email: '' },
* fields: [
* { name: 'name', type: FormFieldType.TEXT, label: 'Name' },
* { name: 'email', type: FormFieldType.TEXT, label: 'Email' }
* ],
* onSave: (data) => console.log('Saved:', data)
* });
* ```
*/
class SignalFormBuilder {
/**
* Creates a standard single-page form with specified fields
*
* Builds a complete form container with reactive state management,
* validation, and save functionality. The form automatically binds
* to the provided model and creates appropriate field components.
*
* @template TModel - The TypeScript type of the form data model
* @param args - Configuration object for the form
* @param args.model - Initial data model for the form
* @param args.fields - Array of field configurations
* @param args.title - Optional form title
* @param args.config - Layout and behavior configuration
* @param args.onSave - Callback function when form is saved
* @param args.parentForm - Parent form if this is a nested form
* @param args.parentPath - Path prefix for nested form fields
* @returns Complete form container with reactive state and methods
*
* @example
* ```typescript
* const contactForm = SignalFormBuilder.createForm({
* model: { name: '', email: '', phone: '' },
* fields: [
* { name: 'name', type: FormFieldType.TEXT, label: 'Full Name' },
* { name: 'email', type: FormFieldType.TEXT, label: 'Email' },
* { name: 'phone', type: FormFieldType.TEXT, label: 'Phone' }
* ],
* config: { layout: 'flex', view: 'stacked' },
* onSave: (data) => saveContact(data)
* });
* ```
*/
static createForm(args) {
const form = {};
const status = signal(FormStatus.Idle);
// Create all field instances using the factory
const fields = args.fields.map((field) => FieldFactory.build(field, args.model, form, args.parentPath));
// Assemble the complete form container with all methods and reactive properties
Object.assign(form, {
title: args.title,
status,
fields,
getField: FormEngine.getField(fields),
anyTouched: FieldUtils.anyTouched(fields),
anyDirty: FieldUtils.anyDirty(fields),
value: computed(() => FormEngine.getValueFromFields(fields, form)),
rawValue: FormEngine.getRawValue(fields),
getValue: () => form.value(),
getRawValue: () => form.rawValue(),
hasSaved: () => FieldUtils.hasSaved(form),
validateForm: FormEngine.validateForm(fields, form),
reset: FormEngine.resetForm(fields, { ...args.model }),
getErrors: FormEngine.getErrors(fields),
config: FieldUtils.createDefaultConfig(args.config),
patchValue: FormEngine.patchForm(fields),
setValue: FormEngine.setFormValue(fields),
save: FormEngine.runSaveHandler(fields, status, form, args.onSave),
getParent: () => args.parentForm,
parentForm: computed(() => args.parentForm),
saveButtonDisabled: computed(() => {
const errors = FormEngine.getErrors(fields)();
const hasErrors = errors.length > 0;
if (!hasErrors) {
return false; // No errors, button should be enabled
}
// If all errors are submit-triggered, allow the save button to be enabled
// so users can re-submit to correct submit-only validation errors
const allErrorsAreSubmitOnly = errors.every((error) => error.trigger === 'submit');
return !allErrorsAreSubmitOnly; // Disable only if there are non-submit errors
}),
});
return form;
}
/**
* Creates a multi-step form (wizard) with separate pages/steps
*
* Builds a stepped form container that manages multiple form pages,
* allowing users to navigate between steps while maintaining state.
* Each step is a complete form with its own fields and validation.
*
* @template TModel - The TypeScript type of the form data model
* @param args - Configuration object for the stepped form
* @param args.model - Initial data model shared across all steps
* @param args.steps - Array of step configurations, each with fields
* @param args.onSave - Callback function when entire form is saved
* @param args.config - Global configuration for the stepped form
* @param args.config.canSkipIncompleteSteps - Allow navigation to incomplete steps
* @returns Stepped form container with navigation and validation methods
*
* @example
* ```typescript
* const wizardForm = SignalFormBuilder.createSteppedForm({
* model: { personal: {}, contact: {}, preferences: {} },
* steps: [
* {
* title: 'Personal Info',
* fields: [
* { name: 'firstName', type: FormFieldType.TEXT, label: 'First Name' }
* ]
* },
* {
* title: 'Contact Info',
* fields: [
* { name: 'email', type: FormFieldType.TEXT, label: 'Email' }
* ]
* }
* ],
* config: { canSkipIncompleteSteps: false }
* });
* ```
*/
static createSteppedForm(args) {
const currentStep = signal(0);
const status = signal(FormStatus.Idle);
// Create individual form containers for each step
const steps = args.steps.map((step) => SignalFormBuilder.createForm({
model: args.model,
fields: step.fields,
config: step.config,
}));
// Computed value that merges data from all steps
const value = computed(() => steps.reduce((acc, step) => ({ ...acc, ...step.getValue() }), {}));
/** Validates only the current step */
const validateStep = () => steps[currentStep()].validateForm();
/** Checks if current step has no validation errors */
const isValidStep = () => steps[currentStep()].fields.every((f) => !f.error());
/** Validates all steps in the form */
const validateAll = () => steps.every((step) => step.validateForm());
/** Gets all validation errors from all steps */
const getErrors = () => steps.flatMap((step) => step.getErrors());
/**
* Retrieves a specific field by name from any step
* @param key - The field name to search for
* @returns The field instance
* @throws Error if field is not found in any step
*/
const getField = (key) => {
const allFields = steps.flatMap((step) => step.fields);
const field = allFields.find((f) => f.name === key);
if (!field) {
throw new Error(`Field ${String(key)} not found in form`);
}
return field;
};
/** Resets all steps to their initial state */
const reset = () => steps.forEach((step) => step.reset());
// Computed reactive properties for form state
const anyTouched = computed(() => steps.some((step) => step.anyTouched()));
const anyDirty = computed(() => steps.some((step) => step.anyDirty()));
const hasSaved = computed(() => !anyDirty() && !anyTouched() && status() === FormStatus.Success);
// Create virtual form for save handler with all fields
const allFields = steps.flatMap((s) => s.fields);
const virtualForm = {
...steps[0],
fields: allFields,
getValue: () => value(),
anyDirty: computed(() => steps.some((s) => s.anyDirty())),
anyTouched: computed(() => steps.some((s) => s.anyTouched())),
config: FieldUtils.createDefaultConfig(args.config?.form),
};
const saveButtonDisabled = computed(() => {
const errors = getErrors();
const hasErrors = errors.length > 0;
if (!hasErrors) {
return false; // No errors, button should be enabled
}
// If all errors are submit-triggered, allow the save button to be enabled
// so users can re-submit to correct submit-only validation errors
const allErrorsAreSubmitOnly = errors.every((error) => error.trigger === 'submit');
return !allErrorsAreSubmitOnly; // Disable only if there are non-submit errors
});
return {
anyTouched,
anyDirty,
steps,
currentStep,
value,
getValue: () => value(),
validateStep,
validateAll,
isValidStep,
getErrors,
getField,
hasSaved,
reset,
save: FormEngine.runSaveHandler(allFields, status, virtualForm, args.onSave),
status,
config: {
...args.config,
canSkipIncompleteSteps: args.config?.canSkipIncompleteSteps ?? false,
},
saveButtonDisabled,
};
}
/**
* Creates a form for managing an array/collection of items
*
* Builds a dynamic form container that manages multiple instances
* of the same form structure. Useful for managing lists of items
* where users can add, remove, and edit multiple entries.
*
* @template TModel - The TypeScript type of individual items in the array
* @param args - Configuration object for the array form
* @param args.model - Initial array of items
* @param args.fields - Field configuration shared by all items
* @param args.title - Optional form title
* @param args.config - Layout and behavior configuration
* @param args.onSave - Callback when entire array is saved
* @param args.onItemAdd - Callback when new item is added
* @param args.onItemRemove - Callback when item is removed
* @param args.defaultItem - Default values for new items
* @param args.parentForm - Parent form if this is nested
* @param args.parentPath - Path prefix for nested forms
* @returns Array form container with add/remove/manage functionality
*
* @example
* ```typescript
* const contactsForm = SignalFormBuilder.createFormFromArray({
* model: [{ name: '', email: '' }],
* fields: [
* { name: 'name', type: FormFieldType.TEXT, label: 'Name' },
* { name: 'email', type: FormFieldType.TEXT, label: 'Email' }
* ],
* defaultItem: { name: '', email: '' },
* onItemAdd: (item) => console.log('Added:', item),
* onItemRemove: (index) => console.log('Removed index:', index)
* });
* ```
*/
static createFormFromArray(args) {
const formsSignal = signal([]);
const status = signal(FormStatus.Idle);
// Create initial forms from the model array
const initialForms = args.model.map((item, index) => SignalFormBuilder.createForm({
model: item,
fields: args.fields,
config: args.config,
parentForm: args.parentForm,
parentPath: args.parentPath
? `${args.parentPath}[${index}]`
: `[${index}]`,
}));
formsSignal.set(initialForms);
/**
* Adds a new item form to the array
* @param item - Optional partial data for the new item
*/
const addItem = (item) => {
const currentForms = formsSignal();
const defaultItemMerged = { ...args.defaultItem, ...item };
const newIndex = currentForms.length;
const newForm = SignalFormBuilder.createForm({
model: defaultItemMerged,
fields: args.fields,
config: args.config,
parentForm: args.parentForm,
parentPath: args.parentPath
? `${args.parentPath}[${newIndex}]`
: `[${newIndex}]`,
});
formsSignal.update((forms) => [...forms, newForm]);
args.onItemAdd?.(defaultItemMerged);
};
/**
* Removes an item form from the array at the specified index
* @param index - The index of the item to remove
*/
const removeItem = (index) => {
formsSignal.update((forms) => forms.filter((_, i) => i !== index));
args.onItemRemove?.(index);
};
/**
* Gets the current values from all item forms in the array
* @returns Array of all current form values
*/
const getValue = () => {
return formsSignal().map((form) => form.getValue());
};
/**
* Validates all item forms in the array
* @returns True if all forms are valid, false otherwise
*/
const validateAll = () => {
return formsSignal().every((form) => form.validateForm());
};
/**
* Gets all validation errors from all item forms
* Prefixes each error path with the array index
* @returns Array of all validation errors with indexed paths
*/
const getErrors = () => {
return formsSignal().flatMap((form, index) => form.getErrors().map((error) => ({
...error,
path: `[${index}].${error.path}`,
})));
};
/**
* Saves all item forms if validation passes
* Updates status to Success or Error based on validation
*/
const save = () => {
if (validateAll()) {
status.set(FormStatus.Success);
args.onSave?.(getValue());
}
else {
status.set(FormStatus.Error);
}
};
/**
* Resets all item forms to their original model values
* Recreates all forms from the initial model array
*/
const reset = () => {
const resetForms = args.model.map((item, index) => SignalFormBuilder.createForm({
model: item,
fields: args.fields,
config: args.config,
parentForm: args.parentForm,
parentPath: args.parentPath
? `${args.parentPath}[${index}]`
: `[${index}]`,
}));
formsSignal.set(resetForms);
status.set(FormStatus.Idle);
};
/**
* Checks if any item form in the array has been touched
* @returns True if any form has been touched by user interaction
*/
const anyTouched = () => {
return formsSignal().some((form) => form.anyTouched());
};
/**
* Checks if any item form in the array has been modified
* @returns True if any form has dirty (changed) values
*/
const anyDirty = () => {
return formsSignal().some((form) => form.anyDirty());
};
/**
* Determines if the save button should be disabled based on validation errors
* @returns True if save button should be disabled
*/
const saveButtonDisabled = () => {
const errors = getErrors();
const hasErrors = errors.length > 0;
if (!hasErrors) {
return false; // No errors, button should be enabled
}
// If all errors are submit-triggered, allow the save button to be enabled
// so users can re-submit to correct submit-only validation errors
const allErrorsAreSubmitOnly = errors.every((error) => error.trigger === 'submit');
return !allErrorsAreSubmitOnly; // Disable only if there are non-submit errors
};
return {
title: args.title,
forms: formsSignal,
value: getValue,
addItem,
removeItem,
validateAll,
getErrors,
save,
reset,
anyTouched,
anyDirty,
status: () => status(),
saveButtonDisabled,
};
}
}
function isRequired(field) {
return (field.validators?.some((v) => v.__meta?.required) ?? false);
}
function withMeta(fn, meta) {
fn.__meta = meta;
return fn;
}
// Common conversion utilities
class ConversionUtils {
/**
* Weight conversions (base unit: kg)
*/
static weight = {
kg: {
label: 'Kilograms',
convert: (value, fromUnit) => {
switch (fromUnit) {
case 'lbs':
return value * 0.453592;
case 'oz':
return value * 0.0283495;
case 'g':
return value / 1000;
case 'kg':
return value;
default:
return value;
}
},
},
lbs: {
label: 'Pounds',
convert: (value, fromUnit) => {
switch (fromUnit) {
case 'kg':
return value * 2.20462;
case 'oz':
return value / 16;
case 'g':
return value * 0.00220462;
case 'lbs':
return value;
default:
return value;
}
},
},
oz: {
label: 'Ounces',
convert: (value, fromUnit) => {
switch (fromUnit) {
case 'kg':
return value * 35.274;
case 'lbs':
return value * 16;
case 'g':
return value * 0.035274;
case 'oz':
return value;
default:
return value;
}
},
},
g: {
label: 'Grams',
convert: (value, fromUnit) => {
switch (fromUnit) {
case 'kg':
return value * 1000;
case 'lbs':
return value * 453.592;
case 'oz':
return value * 28.3495;
case 'g':
return value;
default:
return value;
}
},
},
};
/**
* Length conversions (base unit: m)
*/
static length = {
m: {
label: 'Meters',
convert: (value, fromUnit) => {
switch (fromUnit) {
case 'ft':
return value * 0.3048;
case 'in':
return value * 0.0254;
case 'cm':
return value / 100;
case 'mm':
return value / 1000;
case 'm':
return value;
default:
return value;
}
},
},
ft: {
label: 'Feet',
convert: (value, fromUnit) => {
switch (fromUnit) {
case 'm':
return value * 3.28084;
case 'in':
return value / 12;
case 'cm':
return value * 0.0328084;
case 'mm':
return value * 0.00328084;
case 'ft':
return value;
default:
return value;
}
},
},
in: {
label: 'Inches',
convert: (value, fromUnit) => {
switch (fromUnit) {
case 'm':
return value * 39.3701;
case 'ft':
return value * 12;
case 'cm':
return value * 0.393701;
case 'mm':
return value * 0.0393701;
case 'in':
return value;
default:
return value;
}
},
},
cm: {
label: 'Centimeters',
convert: (value, fromUnit) => {
switch (fromUnit) {
case 'm':
return value * 100;
case 'ft':
return value * 30.48;
case 'in':
return value * 2.54;
case 'mm':
return value / 10;
case 'cm':
return value;
default:
return value;
}
},
},
mm: {
label: 'Millimeters',
convert: (value, fromUnit) => {
switch (fromUnit) {
case 'm':
return value * 1000;
case 'ft':
return value * 304.8;
case 'in':
return value * 25.4;
case 'cm':
return value * 10;
case 'mm':
return value;
default:
return value;
}
},
},
};
/**
* Temperature conversions (base unit: celsius)
*/
static temperature = {
celsius: {
label: 'Celsius',
convert: (value, fromUnit) => {
switch (fromUnit) {
case 'fahrenheit':
return ((value - 32) * 5) / 9;
case 'kelvin':
return value - 273.15;
case 'celsius':
return value;
default:
return value;
}
},
},
fahrenheit: {
label: 'Fahrenheit',
convert: (value, fromUnit) => {
switch (fromUnit) {
case 'celsius':
return (value * 9) / 5 + 32;
case 'kelvin':
return ((value - 273.15) * 9) / 5 + 32;
case 'fahrenheit':
return value;
default:
return value;
}
},
},
kelvin: {
label: 'Kelvin',
convert: (value, fromUnit) => {
switch (fromUnit) {
case 'celsius':
return value + 273.15;
case 'fahrenheit':
return ((value - 32) * 5) / 9 + 273.15;
case 'kelvin':
return value;
default:
return value;
}
},
},
};
/**
* Common parsers for different number formats
*/
static parsers = {
currency: (value, locale = 'en-US', currency = 'USD') => new Intl.NumberFormat(locale, { style: 'currency', currency }).format(value),
percentage: (value, locale = 'en-US') => new Intl.NumberFormat(locale, {
style: 'percent',
minimumFractionDigits: 1,
}).format(value / 100),
decimal: (value, locale = 'en-US', fractionDigits = 2) => new Intl.NumberFormat(locale, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(value),
integer: (value, locale = 'en-US') => new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(value),
};
}
// Export all from unit-conversion.model.ts
class FormDropdownOverlayComponent {
renderer;
elementRef;
destroyRef;
injector;
options = input.required();
multiselect = input(false);
triggerElement = input();
initialSelection = input();
ariaListboxId = input.required();
select = output();
close = output();
optionElements = viewChildren('optionElement', { read: ElementRef });
focusedIndex = signal(-1);
selectedOptions = signal([]);
checkIcon = CircleCheck;
clearIcon = CircleX;
constructor(renderer, elementRef, destroyRef, injector) {
this.renderer = renderer;
this.elementRef = elementRef;
this.destroyRef = destroyRef;
this.injector = injector;
}
ngOnInit() {
this.handleSingleOrMultiSelect();
this.listenToDocumentMouseClicks();
this.listenToTriggerElementKeyDown();
this.listenToOptionElementForView();
}
handleSingleOrMultiSelect() {
const selection = this.initialSelection();
if (!selection) {
return;
}
if (this.multiselect() && Array.isArray(selection)) {
this.selectedOptions.set(selection);
return;
}
this.selectedOptions.set([selection]);
}
selectOption(option) {
if (this.multiselect()) {
const current = this.selectedOptions() ?? [];
const exists = current.some((o) => o.value === option.value);
const updated = exists
? current.filter((o) => o.value !== option.value)
: [...current, option];
this.selectedOptions.set(updated);
this.select.emit(updated);
}
else {
this.selectedOptions.set([option]);
this.emitSelection();
}
}
emitSelection() {
const result = this.multiselect()
? this.selectedOptions()
: (this.selectedOptions()[0] ?? null);
this.select.emit(result);
}
toggleOption(option) {
if (this.multiselect()) {
this.selectedOptions.update((current) => {
const exists = current.some((o) => o.value === option.value);
return exists
? current.filter((o) => o.value !== option.value)
: [...current, option];
});
}
else {
this.selectedOptions.set([option]);
this.emitSelection();
}
}
setPosition({ top, left, width, }) {
const el = this.elementRef.nativeElement;
const widthPx = typeof width === 'string' ? width : `${width}px`;
Object.entries({
position: 'absolute',
top: `${top}px`,
left: `${left}px`,
width: widthPx,
zIndex: 1000,
}).forEach(([key, val]) => this.renderer.setStyle(el, key, val));
}
isSelected(formOption) {
const selectedOptions = this.selectedOptions();
if (!this.selectedOptions()) {
return false;
}
return selectedOptions.some((option) => option.value === formOption.value);
}
listenToDocumentMouseClicks() {
fromEvent(document, 'click')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((event) => {
const target = event.target;
const clickedInsideOverlay = this.elementRef.nativeElement.contains(target);
const clickedOnTrigger = this.triggerElement()?.contains(target);
if (!clickedInsideOverlay && !clickedOnTrigger) {
this.close.emit();
}
});
}
listenToTriggerElementKeyDown() {
if (!this.triggerElement()) {
return;
}
fromEvent(this.triggerElement(), 'keydown')
.pipe(tap$1((event) => this.handleKeydown(event)), takeUntilDestroyed(this.destroyRef))
.subscribe();
}
handleKeydown(event) {
const options = this.options();
const max = options.length - 1;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.focusedIndex.update((i) => (i + 1 > max ? 0 : i + 1));
break;
case 'ArrowUp':
event.preventDefault();
this.focusedIndex.update((i) => (i - 1 < 0 ? max : i - 1));
break;
case 'Enter':
event.preventDefault();
const selected = options[this.focusedIndex()];
if (selected)
this.selectOption(selected);
break;
case 'Escape':
this.close.emit();
break;
}
}
listenToOptionElementForView() {
effect(() => {
const index = this.focusedIndex();
if (index === -1) {
return;
}
const el = this.optionElements().at(index)?.nativeElement;
if (el) {
el.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}, {
injector: this.injector,
});
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: FormDropdownOverlayComponent, deps: [{ token: i0.Renderer2 }, { token: i0.ElementRef }, { token: i0.DestroyRef }, { token: i0.Injector }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: FormDropdownOverlayComponent, isStandalone: true, selector: "form-dropdown-overlay", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: true, transformFunction: null }, multiselect: { classPropertyName: "multiselect", publicName: "multiselect", isSignal: true, isRequired: false, transformFunction: null }, triggerElement: { classPropertyName: "triggerElement", publicName: "triggerElement", isSignal: true, isRequired: false, transformFunction: null }, initialSelection: { classPropertyName: "initialSelection", publicName: "initialSelection", isSignal: true, isRequired: false, transformFunction: null }, ariaListboxId: { classPropertyName: "ariaListboxId", publicName: "ariaListboxId", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { select: "select", close: "close" }, viewQueries: [{ propertyName: "optionElements", predicate: ["optionElement"], descendants: true, read: ElementRef, isSignal: true }], ngImport: i0, template: "<ul role=\"listbox\" [id]=\"ariaListboxId()\" class=\"dropdown-options\">\n @for (option of options(); track option.value) {\n <li\n class=\"dropdown-option\"\n [class.selected]=\"isSelected(option)\"\n [class.focused]=\"focusedIndex() === $index\"\n [attr.data-index]=\"$index\"\n [attr.aria-selected]=\"focusedIndex() === $index\"\n [id]=\"'option-' + i\"\n (click)=\"selectOption(option)\"\n #optionElement\n >\n {{ option.label }}\n @if (isSelected(option) && multiselect()) {\n @if (focusedIndex() === $index) {\n <lucide-icon class=\"selected-icon\" [img]=\"clearIcon\" />\n } @else {\n <lucide-icon class=\"selected-icon\" [img]=\"checkIcon\" />\n }\n }\n </li>\n }\n</ul>\n", styles: [".dropdown-options{position:absolute;background:var(--signal-form-input-bg);border:1px solid var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-sm);box-shadow:var(--signal-form-shadow-lg);max-height:240px;overflow-y:auto;width:100%;list-style:none;padding:.25rem;margin:0;z-index:1000}.dropdown-options .dropdown-option{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;cursor:pointer;border-radius:var(--signal-form-border-radius-sm);font-size:var(--signal-form-font-size-base);color:var(--signal-form-input-text);transition:var(--signal-form-transition);margin-bottom:.125rem}.dropdown-options .dropdown-option:last-child{margin-bottom:0}.dropdown-options .dropdown-option:hover{background:var(--signal-form-dropdown-hover)}.dropdown-options .dropdown-option.focused{background:var(--signal-form-dropdown-selected)}.dropdown-options .dropdown-option.focused.selected{background:var(--signal-form-dropdown-selected-hover);color:var(--signal-forms-neutral-0)}.dropdown-options .dropdown-option.focused.selected .selected-icon{color:var(--signal-forms-neutral-0)}.dropdown-options .dropdown-option.selected{background:var(--signal-form-dropdown-selected);color:var(--signal-form-outline-focus);font-weight:500}.dropdown-options .dropdown-option.selected .selected-icon{color:var(--signal-form-outline-focus)}.dropdown-options .dropdown-option .selected-icon{width:1rem;height:1rem;flex-shrink:0;margin-left:.5rem;display:flex;align-items:center;justify-content:center}.dropdown-options::-webkit-scrollbar{width:6px}.dropdown-options::-webkit-scrollbar-track{background:var(--signal-forms-neutral-100);border-radius:3px}.dropdown-options::-webkit-scrollbar-thumb{background:var(--signal-forms-neutral-300);border-radius:3px}.dropdown-options::-webkit-scrollbar-thumb:hover{background:var(--signal-forms-neutral-400)}\n"], dependencies: [{ kind: "ngmodule", type: LucideAngularModule }, { kind: "component", type: i1.LucideAngularComponent, selector: "lucide-angular, lucide-icon, i-lucide, span-lucide", inputs: ["class", "name", "img", "color", "absoluteStrokeWidth", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: FormDropdownOverlayComponent, decorators: [{
type: Component,
args: [{ selector: 'form-dropdown-overlay', standalone: true, imports: [LucideAngularModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<ul role=\"listbox\" [id]=\"ariaListboxId()\" class=\"dropdown-options\">\n @for (option of options(); track option.value) {\n <li\n class=\"dropdown-option\"\n [class.selected]=\"isSelected(option)\"\n [class.focused]=\"focusedIndex() === $index\"\n [attr.data-index]=\"$index\"\n [attr.aria-selected]=\"focusedIndex() === $index\"\n [id]=\"'option-' + i\"\n (click)=\"selectOption(option)\"\n #optionElement\n >\n {{ option.label }}\n @if (isSelected(option) && multiselect()) {\n @if (focusedIndex() === $index) {\n <lucide-icon class=\"selected-icon\" [img]=\"clearIcon\" />\n } @else {\n <lucide-icon class=\"selected-icon\" [img]=\"checkIcon\" />\n }\n }\n </li>\n }\n</ul>\n", styles: [".dropdown-options{position:absolute;background:var(--signal-form-input-bg);border:1px solid var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-sm);box-shadow:var(--signal-form-shadow-lg);max-height:240px;overflow-y:auto;width:100%;list-style:none;padding:.25rem;margin:0;z-index:1000}.dropdown-options .dropdown-option{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;cursor:pointer;border-radius:var(--signal-form-border-radius-sm);font-size:var(--signal-form-font-size-base);color:var(--signal-form-input-text);transition:var(--signal-form-transition);margin-bottom:.125rem}.dropdown-options .dropdown-option:last-child{margin-bottom:0}.dropdown-options .dropdown-option:hover{background:var(--signal-form-dropdown-hover)}.dropdown-options .dropdown-option.focused{background:var(--signal-form-dropdown-selected)}.dropdown-options .dropdown-option.focused.selected{background:var(--signal-form-dropdown-selected-hover);color:var(--signal-forms-neutral-0)}.dropdown-options .dropdown-option.focused.selected .selected-icon{color:var(--signal-forms-neutral-0)}.dropdown-options .dropdown-option.selected{background:var(--signal-form-dropdown-selected);color:var(--signal-form-outline-focus);font-weight:500}.dropdown-options .dropdown-option.selected .selected-icon{color:var(--signal-form-outline-focus)}.dropdown-options .dropdown-option .selected-icon{width:1rem;height:1rem;flex-shrink:0;margin-left:.5rem;display:flex;align-items:center;justify-content:center}.dropdown-options::-webkit-scrollbar{width:6px}.dropdown-options::-webkit-scrollbar-track{background:var(--signal-forms-neutral-100);border-radius:3px}.dropdown-options::-webkit-scrollbar-thumb{background:var(--signal-forms-neutral-300);border-radius:3px}.dropdown-options::-webkit-scrollbar-thumb:hover{background:var(--signal-forms-neutral-400)}\n"] }]
}], ctorParameters: () => [{ type: i0.Renderer2 }, { type: i0.ElementRef }, { type: i0.DestroyRef }, { type: i0.Injector }] });
class FormDropdownService {
dropdownRef;
appRef = inject(ApplicationRef);
injector = inject(Injector);
openDropdown(config) {
this.destroyDropdown();
this.dropdownRef = config.viewContainerRef.createComponent(FormDropdownOverlayComponent, {
environmentInjector: this.appRef.injector,
injector: this.injector,
});
const instance = this.dropdownRef.instance;
this.dropdownRef.setInput('options', config.options);
this.dropdownRef.setInput('triggerElement', config.reference);
this.dropdownRef.setInput('multiselect', config.multiselect);
this.dropdownRef.setInput('ariaListboxId', config.ariaListboxId);
this.dropdownRef.setInput('initialSelection', config.initialSelection);
instance.select.subscribe((option) => {
config.onSelect(option);
if (!config.multiselect) {
this.destroyDropdown();
}
});
instance.close.subscribe(() => {
config.onClose?.();
this.destroyDropdown();
});
requestAnimationFrame(() => {
const rect = config.reference.getBoundingClientRect();
instance.setPosition({
top: rect.height,
left: 0,
width: '100%',
});
});
}
destroyDropdown() {
this.dropdownRef?.destroy();
this.dropdownRef = undefined;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: FormDropdownService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: FormDropdownService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: FormDropdownService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
const SIGNAL_FORMS_THEME_CONFIG = new InjectionToken('SIGNAL_FORMS_THEME_CONFIG');
/**
* Provider function for Signal Forms theme configuration
* @param config - Theme configuration options
* @returns Provider array for Angular DI
*/
function provideSignalFormsTheme(config = {}) {
return [
{
provide: SIGNAL_FORMS_THEME_CONFIG,
useValue: config,
},
SignalFormThemeService,
];
}
/**
* Theme Service for Signal Forms
*
* Provides centralized theme management for the form system.
* Manages global theme state and applies theme classes to the HTML element.
*
* This service is a singleton that coordinates theme across all forms in the app.
* When any form requests a theme check, it determines the root form's theme
* and applies the appropriate class to the HTML element.
*/
class SignalFormThemeService {
document;
config;
_theme = signal('auto');
_isDarkMode = signal(false);
/**
* Current theme mode setting for the service
*/
theme = this._theme.asReadonly();
/**
* Whether dark mode is currently active based on system preference
*/
isDarkMode = this._isDarkMode.asReadonly();
constructor(document, config) {
this.document = document;
this.config = config;
// Use default empty config if none provided
const themeConfig = this.config || {};
// Set initial theme from config
if (themeConfig.defaultTheme) {
this._theme.set(themeConfig.defaultTheme);
}
// Initialize dark mode based on system preference
if (typeof window !== 'undefined') {
this._isDarkMode.set(window.matchMedia('(prefers-color-scheme: dark)').matches);
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
this._isDarkMode.set(e.matches);
});
}
// Auto-apply dark mode if configured
if (themeConfig.darkMode) {
effect(() => {
this.updateGlobalDarkMode();
});
}
}
/**
* Set the global theme mode
* @param theme - 'light', 'dark', or 'auto'
*/
setTheme(theme) {
this._theme.set(theme);
}
/**
* Toggle between light and dark theme
*/
toggleTheme() {
const current = this._theme();
if (current === 'auto') {
// If auto, switch to opposite of current system preference
this.setTheme(this._isDarkMode() ? 'light' : 'dark');
}
else {
// Toggle between light and dark
this.setTheme(current === 'dark' ? 'light' : 'dark');
}
}
/**
* Check theme for any form configuration and trigger HTML class updates
* This method determines the effective theme but doesn't return it for binding
* Instead, it triggers side effects to update the global HTML classes
*
* @param formOrConfig - Form container or form config
*/
checkTheme(formOrConfig) {
const effectiveTheme = this.getEffectiveTheme(formOrConfig);
this.updateHtmlClasses(effectiveTheme);
}
/**
* Apply global dark mode based on service theme setting
* Used when darkMode is enabled in provider config
*/
updateGlobalDarkMode() {
const effectiveTheme = this.getServiceEffectiveTheme();
this.updateHtmlClasses(effectiveTheme);
}
/**
* Get the effective theme for any form configuration
* Recursively finds the root form and uses its theme setting
*
* @param formOrConfig - Form container or form config
* @returns 'light' or 'dark'
*/
getEffectiveTheme(formOrConfig) {
let config;
// Handle both form containers and configs
if ('config' in formOrConfig) {
// It's a form container - check if it has a parent
const form = formOrConfig;
const parentForm = form.parentForm?.();
if (parentForm) {
// Not root, recurse to parent
return this.getEffectiveTheme(parentForm);
}
config = form.config;
}
else {
// It's a config object
config = formOrConfig;
}
// We're at the root - determine theme
const themeMode = config.theme ?? 'light';
if (themeMode === 'dark') {
return 'dark';
}
if (themeMode === 'light') {
return 'light';
}
// Auto mode - use service theme or fall back to system preference
return this.getServiceEffectiveTheme();
}
/**
* Get the effective theme from the service (resolves 'auto' to 'light' or 'dark')
*/
getServiceEffectiveTheme() {
const theme = this._theme();
if (theme === 'auto') {
return this._isDarkMode() ? 'dark' : 'light';
}
return theme;
}
/**
* Update HTML classes based on effective theme
* @param effectiveTheme - The theme to apply
*/
updateHtmlClasses(effectiveTheme) {
// Safe to use injected document - will be null in headless environments
if (!this.document?.documentElement) {
return;
}
const html = this.document.documentElement;
// Remove existing theme classes
html.classList.remove('dark-themed-forms', 'light-themed-forms');
// Add new theme class
if (effectiveTheme === 'dark') {
html.classList.add('dark-themed-forms');
}
else {
html.classList.add('light-themed-forms');
}
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormThemeService, deps: [{ token: DOCUMENT }, { token: SIGNAL_FORMS_THEME_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormThemeService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormThemeService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}], ctorParameters: () => [{ type: Document, decorators: [{
type: Inject,
args: [DOCUMENT]
}] }, { type: undefined, decorators: [{
type: Optional
}, {
type: Inject,
args: [SIGNAL_FORMS_THEME_CONFIG]
}] }] });
const unsavedChangesGuard = (component) => {
if (component.hasUnsavedChanges()) {
return confirm('You have unsaved changes. Are you sure you want to leave this page?');
}
return true;
};
class WordCountService {
/**
* Counts words, characters, and lines in the given text
*/
getWordCount(text) {
if (!text) {
return {
characters: 0,
words: 0,
charactersWithSpaces: 0,
lines: 0,
};
}
const charactersWithSpaces = text.length;
const characters = text.replace(/\s/g, '').length;
const lines = text.split('\n').length;
// Count words by splitting on whitespace and filtering empty strings
const words = text
.trim()
.split(/\s+/)
.filter((word) => word.length > 0).length;
return {
characters,
words,
charactersWithSpaces,
lines,
};
}
/**
* Formats word count for display
*/
formatWordCount(stats, format = 'words') {
switch (format) {
case 'words':
return `${stats.words} word${stats.words !== 1 ? 's' : ''}`;
case 'characters':
return `${stats.characters} character${stats.characters !== 1 ? 's' : ''}`;
case 'both':
return `${stats.words} word${stats.words !== 1 ? 's' : ''}, ${stats.characters} character${stats.characters !== 1 ? 's' : ''}`;
case 'detailed':
return `${stats.words} words | ${stats.characters} chars | ${stats.lines} lines`;
default:
return `${stats.words} word${stats.words !== 1 ? 's' : ''}`;
}
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: WordCountService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: WordCountService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: WordCountService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}] });
class SignalValidators {
static required(message = 'This field is required') {
return withMeta((val) => (val == null || val === '' ? message : null), {
required: true,
});
}
static min(min, msg) {
return (val) => !!val && val < min ? (msg ?? `Must be at least ${min}`) : null;
}
static max(max, msg) {
return (val) => (val > max ? (msg ?? `Must be no more than ${max}`) : null);
}
static minLength(min, msg) {
return (val) => val.length < min ? (msg ?? `Minimum ${min} characters`) : null;
}
static maxLength(max, msg) {
return (val) => val.length > max ? (msg ?? `Maximum ${max} characters`) : null;
}
static isPositive(msg = 'Must be a positive number') {
return (val) => (val <= 0 ? msg : null);
}
static hasValue(fieldName) {
return (_, form) => form.getField(fieldName).value()
? null
: `${String(fieldName)} is required`;
}
static matchField(otherKey, msg = 'Fields do not match') {
return (val, form) => val !== form.getField(otherKey)?.value?.() ? msg : null;
}
}
/**
* Variant for guaranteed signal access (same-step dependencies), with full access to the form context.
*/
function withSignalValidation(getDepSignal, validate) {
return (val, form) => {
const depValue = getDepSignal()();
return validate(val, depValue, form);
};
}
// #region
// #endregion
/**
* Base directive for form input components that provides common functionality
* such as validation, computed values, and options filtering.
*
* This directive should be extended by all form field components to provide
* consistent behavior across the form system.
*
* @template TField - The specific field type extending RuntimeFields
* @template TModel - The form model type
* @template K - The field key type
*
* @example
* ```typescript
* export class FormTextFieldComponent extends BaseInputDirective<
* RuntimeTextSignalField<TModel, K>,
* TModel,
* K
* > {
* // Component implementation
* }
* ```
*/
class BaseInputDirective {
/**
* The field configuration and state
*/
field = input.required();
/**
* Computed form reference obtained from the field
*/
form = computed(() => this.field().getForm());
/**
* Angular injector for effect management
*/
injector = inject(Injector);
/**
* Computed property indicating if the field is required based on validators
*/
isRequired = computed(() => (this.field().validators ?? []).some((validator) => validator.__meta?.required));
/**
* Computed property indicating if the field should be hidden
*/
isHidden = computed(() => {
const { hidden } = this.field();
return typeof hidden === 'function' ? hidden(this.form()) : !!hidden;
});
/**
* Computed property indicating if the field should be disabled
*/
isDisabled = computed(() => {
const { disabled } = this.field();
return typeof disabled === 'function' ? disabled(this.form()) : !!disabled;
});
/**
* Computed property that returns filtered options for fields that support options.
* For fields without options, returns an empty array.
*
* Applies dynamic options filtering if a dynamicOptions function is provided.
*/
filteredOptions = computed(() => {
const field = this.field();
const form = this.form();
// Check if field has options property
if (!('options' in field) || typeof field.options !== 'function') {
return [];
}
const options = field.options();
const dynamicOptionsFn = field.dynamicOptions;
if (typeof dynamicOptionsFn !== 'function') {
return options;
}
return dynamicOptionsFn(form, options, field.value());
});
/**
* Initializes the directive by setting up reactive effects for
* computed values and value watching
*/
constructor() {
this.initializeComputedValueEffect();
this.watchComputedValueEffect();
}
/**
* Sets up an effect to initialize computed values when the field is first loaded.
* This only runs once to set the initial computed value.
*
* @private
*/
initializeComputedValueEffect() {
effect(() => {
const field = this.field();
if (!field.computedValue)
return;
const initialValue = field.computedValue(this.form());
this.setValue(initialValue, false);
}, { injector: this.injector });
}
/**
* Sets up an effect to watch for changes in computed values and update
* the field value accordingly. This runs whenever dependencies change.
*
* @private
*/
watchComputedValueEffect() {
effect(() => {
const field = this.field();
if (!field.computedValue)
return;
const newValue = field.computedValue(this.form());
this.setValue(newValue, false);
}, { injector: this.injector });
}
/**
* Updates the field value and optionally marks it as touched and dirty.
*
* @param value - The new value to set
* @param markTouched - Whether to mark the field as touched and dirty (default: true)
*
* @protected
*/
setValue(value, markTouched = true) {
const field = this.field();
if (markTouched) {
field.touched.set(true);
field.dirty.set(true);
}
field.value.set(value);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: BaseInputDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.14", type: BaseInputDirective, isStandalone: true, inputs: { field: { classPropertyName: "field", publicName: "field", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: BaseInputDirective, decorators: [{
type: Directive
}], ctorParameters: () => [] });
class SignalFormHostDirective {
viewContainerRef;
constructor(viewContainerRef) {
this.viewContainerRef = viewContainerRef;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormHostDirective, deps: [{ token: i0.ViewContainerRef }], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.14", type: SignalFormHostDirective, isStandalone: true, selector: "[signalFormHost]", ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormHostDirective, decorators: [{
type: Directive,
args: [{
selector: '[signalFormHost]',
standalone: true,
}]
}], ctorParameters: () => [{ type: i0.ViewContainerRef }] });
class SignalFormAutocompleteFieldComponent extends BaseInputDirective {
signalFormHostDirective;
inputRef = viewChild('autocompleteInputWrapper');
search = signal('');
loadedOptions = signal([]);
showDropdown = signal(false);
lastQuery = signal('');
cachedOptions = signal([]);
destroyRef = inject(DestroyRef);
dropdownService = inject(FormDropdownService);
constructor(signalFormHostDirective) {
super();
this.signalFormHostDirective = signalFormHostDirective;
this.searchQueryChangeEffect();
this.optionsOverlayEffect();
}
get hostViewContainerRef() {
return this.signalFormHostDirective?.viewContainerRef ?? null;
}
optionsOverlayEffect() {
effect(() => {
if (!this.showDropdown() ||
!this.loadedOptions().length ||
!this.inputRef()) {
return;
}
this.dropdownService.openDropdown({
options: this.loadedOptions(),
ariaListboxId: `${String(this.field().name)}-listbox`,
reference: this.inputRef().nativeElement,
viewContainerRef: this.hostViewContainerRef,
onSelect: (option) => {
if (option) {
this.updateValue(option);
}
this.field().touched.set(true);
this.showDropdown.set(false);
this.dropdownService.destroyDropdown();
},
onClose: () => {
this.showDropdown.set(false);
},
multiselect: false,
});
}, { injector: this.injector });
}
updateValue(val) {
super.setValue(val);
this.field().dirty.set(true);
}
onSelect(option) {
this.updateValue(option);
this.showDropdown.set(false);
}
handleFocus() {
const query = this.lastQuery();
if (!!query && this.cachedOptions().length) {
this.showDropdown.set(true);
this.loadedOptions.set(this.cachedOptions());
}
}
searchQueryChangeEffect() {
effect(() => {
const query = this.search();
this.lastQuery.set(query);
if (this.field().config?.minChars &&
query.length < (this.field().config?.minChars ?? 0)) {
this.loadedOptions.set([]);
return;
}
const loader = this.field().loadOptions;
if (!loader || !query) {
this.loadedOptions.set([]);
return;
}
const source = loader(query);
const obs$ = isObservable(source) ? source : from(source);
const debounced$ = obs$.pipe(takeUntilDestroyed(this.destroyRef));
const timeout = setTimeout(() => {
debounced$.subscribe((result) => {
this.cachedOptions.set(result); // ✅ cache
this.loadedOptions.set(result);
this.showDropdown.set(true);
});
}, this.field().config?.debounceMs ?? 300);
return () => clearTimeout(timeout);
}, { injector: this.injector });
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormAutocompleteFieldComponent, deps: [{ token: SignalFormHostDirective, optional: true }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "19.2.14", type: SignalFormAutocompleteFieldComponent, isStandalone: true, selector: "signal-form-autocomplete-field", viewQueries: [{ propertyName: "inputRef", first: true, predicate: ["autocompleteInputWrapper"], descendants: true, isSignal: true }], usesInheritance: true, hostDirectives: [{ directive: SignalFormHostDirective }], ngImport: i0, template: "<div\n class=\"form-input-wrapper\"\n [ngClass]=\"showDropdown() ? 'dropdown-open' : ''\"\n #autocompleteInputWrapper\n>\n <input\n class=\"form-input\"\n type=\"text\"\n role=\"combobox\"\n aria-autocomplete=\"list\"\n [signalModel]=\"field()\"\n [value]=\"field().value()?.label ?? ''\"\n (input)=\"search.set($event.target.value)\"\n (focus)=\"handleFocus()\"\n />\n</div>\n", styles: [".autocomplete-dropdown{position:absolute;z-index:10;background:#fff;border:1px solid #ccc;width:100%;max-height:200px;overflow-y:auto;list-style:none;padding:unset;margin:0;top:38px;flex-direction:column}.autocomplete-dropdown li{padding:.5rem;cursor:pointer}.autocomplete-dropdown li:hover{background:#f4f4f4}.dropdown-open{border-bottom-left-radius:0;border-bottom-right-radius:0}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormAutocompleteFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-autocomplete-field', standalone: true, imports: [NgClass, SignalModelDirective], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [SignalFormHostDirective], template: "<div\n class=\"form-input-wrapper\"\n [ngClass]=\"showDropdown() ? 'dropdown-open' : ''\"\n #autocompleteInputWrapper\n>\n <input\n class=\"form-input\"\n type=\"text\"\n role=\"combobox\"\n aria-autocomplete=\"list\"\n [signalModel]=\"field()\"\n [value]=\"field().value()?.label ?? ''\"\n (input)=\"search.set($event.target.value)\"\n (focus)=\"handleFocus()\"\n />\n</div>\n", styles: [".autocomplete-dropdown{position:absolute;z-index:10;background:#fff;border:1px solid #ccc;width:100%;max-height:200px;overflow-y:auto;list-style:none;padding:unset;margin:0;top:38px;flex-direction:column}.autocomplete-dropdown li{padding:.5rem;cursor:pointer}.autocomplete-dropdown li:hover{background:#f4f4f4}.dropdown-open{border-bottom-left-radius:0;border-bottom-right-radius:0}\n"] }]
}], ctorParameters: () => [{ type: SignalFormHostDirective, decorators: [{
type: Optional
}] }] });
class SignalFormCheckboxFieldComponent extends BaseInputDirective {
label = input('');
extractValue(element) {
return element.checked;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormCheckboxFieldComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.2.14", type: SignalFormCheckboxFieldComponent, isStandalone: true, selector: "signal-form-checkbox-field", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null } }, usesInheritance: true, ngImport: i0, template: "<label class=\"form-checkbox-wrapper\" [attr.for]=\"field().name\">\n <input class=\"checkbox\" type=\"checkbox\" [signalModel]=\"field()\" />\n <span class=\"form-checkbox-label\">{{ field().label }}</span>\n</label>\n", styles: [".form-checkbox-label{font-size:var(--signal-form-font-size-xs);color:var(--signal-form-input-text)}.form-checkbox-wrapper{display:flex;justify-content:flex-start;align-items:center;gap:4px}.form-checkbox .checkbox{accent-color:var(--signal-form-outline-focus)}\n"], dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormCheckboxFieldComponent, decorators: [{
type: Component,
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'signal-form-checkbox-field', standalone: true, imports: [SignalModelDirective], template: "<label class=\"form-checkbox-wrapper\" [attr.for]=\"field().name\">\n <input class=\"checkbox\" type=\"checkbox\" [signalModel]=\"field()\" />\n <span class=\"form-checkbox-label\">{{ field().label }}</span>\n</label>\n", styles: [".form-checkbox-label{font-size:var(--signal-form-font-size-xs);color:var(--signal-form-input-text)}.form-checkbox-wrapper{display:flex;justify-content:flex-start;align-items:center;gap:4px}.form-checkbox .checkbox{accent-color:var(--signal-form-outline-focus)}\n"] }]
}] });
class SignalFormCheckboxGroupFieldComponent extends BaseInputDirective {
layoutClass = computed(() => this.field().config?.layout === 'inline'
? 'checkbox-group-inline'
: 'checkbox-group-stacked');
isChecked = (option) => {
const val = this.field().value();
const key = option.value;
if (this.field().config?.valueType === 'map') {
const record = val;
return record ? record[key] : false;
}
return val?.includes(key);
};
toggleOption(option) {
const key = option.value;
const valueType = this.field().config?.valueType ?? 'array';
let val = this.field().value();
// Normalize value
if (!val) {
val = valueType === 'map' ? {} : [];
}
if (valueType === 'map') {
const current = (typeof val === 'object' && !Array.isArray(val) ? { ...val } : {});
// Flip the selected value
current[key] = !current[key];
// Normalize to include all options with explicit true/false
const fullRecord = {};
for (const opt of this.field().options()) {
const optKey = opt.value;
fullRecord[optKey] = !!current[optKey];
}
this.setValue(fullRecord);
}
else {
const arr = Array.isArray(val) ? val : [];
this.setValue(arr.includes(key) ? arr.filter((v) => v !== key) : [...arr, key]);
}
this.field().touched.set(true);
this.field().dirty.set(true);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormCheckboxGroupFieldComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormCheckboxGroupFieldComponent, isStandalone: true, selector: "signal-form-checkbox-group-field", usesInheritance: true, ngImport: i0, template: "<fieldset\n class=\"form-checkbox-group\"\n [ngClass]=\"layoutClass()\"\n [attr.aria-invalid]=\"field().error() ? 'true' : 'false'\"\n role=\"group\"\n>\n <legend class=\"sr-only\">{{ field().label }}</legend>\n\n @for (option of field().options(); track option.value) {\n <label class=\"checkbox-group-option\">\n <input\n type=\"checkbox\"\n class=\"checkbox\"\n [value]=\"option.value\"\n [checked]=\"isChecked(option)\"\n (change)=\"toggleOption(option)\"\n />\n <span class=\"label-text\">{{ option.label }}</span>\n </label>\n }\n</fieldset>\n", styles: [".form-checkbox-group{display:flex;flex-direction:column;gap:.5rem}.form-checkbox-group.checkbox-group-inline{flex-direction:row;flex-wrap:wrap}.form-checkbox-group.checkbox-group-inline .checkbox-group-option{margin-right:1.5rem}.form-checkbox-group .checkbox-group-option{display:flex;align-items:center;gap:.5rem;font-size:var(--signal-form-font-size-sm);cursor:pointer}.form-checkbox-group .checkbox-group-option .checkbox{width:16px;height:16px;accent-color:var(--signal-form-outline-focus)}.form-checkbox-group .checkbox-group-option .label-text{color:var(--signal-form-input-text)}.form-checkbox-group .checkbox-group-option:has(.checkbox:disabled){opacity:.5}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormCheckboxGroupFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-checkbox-group-field', standalone: true, imports: [NgClass], changeDetection: ChangeDetectionStrategy.OnPush, template: "<fieldset\n class=\"form-checkbox-group\"\n [ngClass]=\"layoutClass()\"\n [attr.aria-invalid]=\"field().error() ? 'true' : 'false'\"\n role=\"group\"\n>\n <legend class=\"sr-only\">{{ field().label }}</legend>\n\n @for (option of field().options(); track option.value) {\n <label class=\"checkbox-group-option\">\n <input\n type=\"checkbox\"\n class=\"checkbox\"\n [value]=\"option.value\"\n [checked]=\"isChecked(option)\"\n (change)=\"toggleOption(option)\"\n />\n <span class=\"label-text\">{{ option.label }}</span>\n </label>\n }\n</fieldset>\n", styles: [".form-checkbox-group{display:flex;flex-direction:column;gap:.5rem}.form-checkbox-group.checkbox-group-inline{flex-direction:row;flex-wrap:wrap}.form-checkbox-group.checkbox-group-inline .checkbox-group-option{margin-right:1.5rem}.form-checkbox-group .checkbox-group-option{display:flex;align-items:center;gap:.5rem;font-size:var(--signal-form-font-size-sm);cursor:pointer}.form-checkbox-group .checkbox-group-option .checkbox{width:16px;height:16px;accent-color:var(--signal-form-outline-focus)}.form-checkbox-group .checkbox-group-option .label-text{color:var(--signal-form-input-text)}.form-checkbox-group .checkbox-group-option:has(.checkbox:disabled){opacity:.5}\n"] }]
}] });
class SignalFormChipListFieldComponent extends BaseInputDirective {
selectedValues = computed(() => this.field().value() ?? []);
isSelected = (option) => !!this.selectedValues().find((val) => val.value === option.value);
toggleOption(option) {
const current = this.selectedValues();
const updated = this.isSelected(option)
? current.filter((val) => val.value !== option.value)
: [...current, option];
this.setValue(updated);
this.field().touched.set(true);
this.field().dirty.set(true);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormChipListFieldComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormChipListFieldComponent, isStandalone: true, selector: "signal-form-chip-list-field", usesInheritance: true, ngImport: i0, template: "<div\n class=\"chip-option-list\"\n role=\"group\"\n [signalModel]=\"field()\"\n [attr.aria-labelledby]=\"field().name + '-label'\"\n>\n @for (option of field().options(); track option.value) {\n <div\n class=\"chip-option\"\n role=\"checkbox\"\n tabindex=\"0\"\n [class.selected]=\"isSelected(option)\"\n [attr.aria-checked]=\"isSelected(option)\"\n (click)=\"toggleOption(option)\"\n (keydown.enter)=\"toggleOption(option)\"\n (keydown.space)=\"toggleOption(option); $event.preventDefault()\"\n >\n {{ option.label }}\n </div>\n }\n</div>\n", styles: [".chip-option-list{display:flex;flex-wrap:wrap;gap:var(--signal-form-gap-sm);padding:var(--signal-form-padding-md);border:1px solid var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-sm);background:var(--signal-form-bg);box-shadow:var(--signal-form-shadow);transition:var(--signal-form-transition)}.chip-option-list .chip-option{display:inline-flex;align-items:center;padding:var(--signal-form-padding-sm) var(--signal-form-padding-lg);border-radius:var(--signal-form-border-radius-sm);border:1px solid var(--signal-form-border-color);background:var(--signal-form-bg);color:var(--signal-form-text);cursor:pointer;font-size:var(--signal-form-font-size-sm);font-weight:var(--signal-form-font-weight-medium);transition:var(--signal-form-transition);-webkit-user-select:none;user-select:none}.chip-option-list .chip-option:hover{border-color:var(--signal-form-outline-focus);background:var(--signal-form-input-hover-bg)}.chip-option-list .chip-option:focus{outline:none;border-color:var(--signal-form-outline-focus);box-shadow:0 0 0 2px var(--signal-form-outline-focus-shadow)}.chip-option-list .chip-option.selected{background:var(--signal-form-button-primary-bg);color:var(--signal-form-button-text);border-color:var(--signal-form-button-primary-bg)}.chip-option-list .chip-option.selected:hover{background:var(--signal-form-button-primary-bg-hover);border-color:var(--signal-form-button-primary-bg-hover)}.selected-chips{display:flex;flex-wrap:wrap;gap:var(--signal-form-gap-xs);flex:1;min-width:0}.chip{display:inline-flex;align-items:center;gap:var(--signal-form-gap-xs);padding:var(--signal-form-padding-xs) var(--signal-form-padding-sm);background:var(--signal-form-button-primary-bg);color:var(--signal-form-button-text);border-radius:var(--signal-form-border-radius-sm);font-size:var(--signal-form-font-size-sm);font-weight:var(--signal-form-font-weight-medium);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:var(--signal-form-transition)}.chip .chip-remove{display:inline-flex;align-items:center;justify-content:center;width:1rem;height:1rem;padding:0;border:none;background:#fff3;border-radius:var(--signal-form-radius-full);color:var(--signal-form-button-text);cursor:pointer;font-size:var(--signal-form-font-size-xs);transition:var(--signal-form-transition)}.chip .chip-remove:hover{background:#ffffff4d;transform:scale(1.1)}.chip .chip-remove:focus{outline:none;background:#fff6}.dropdown-trigger{display:inline-flex;align-items:center;gap:var(--signal-form-gap-sm);padding:var(--signal-form-padding-sm) var(--signal-form-padding-md);border:1px solid var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-sm);background:var(--signal-form-bg);color:var(--signal-form-text);cursor:pointer;font-size:var(--signal-form-font-size-sm);min-width:120px;transition:var(--signal-form-transition)}.dropdown-trigger:hover{border-color:var(--signal-form-outline-focus);background:var(--signal-form-input-hover-bg)}.dropdown-trigger:focus{outline:none;border-color:var(--signal-form-outline-focus);box-shadow:0 0 0 2px var(--signal-form-outline-focus-shadow)}.dropdown-trigger .dropdown-arrow{opacity:.5;transition:transform .2s}.dropdown-trigger[aria-expanded=true] .dropdown-arrow{transform:rotate(180deg)}\n"], dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormChipListFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-chip-list-field', standalone: true, imports: [SignalModelDirective], template: "<div\n class=\"chip-option-list\"\n role=\"group\"\n [signalModel]=\"field()\"\n [attr.aria-labelledby]=\"field().name + '-label'\"\n>\n @for (option of field().options(); track option.value) {\n <div\n class=\"chip-option\"\n role=\"checkbox\"\n tabindex=\"0\"\n [class.selected]=\"isSelected(option)\"\n [attr.aria-checked]=\"isSelected(option)\"\n (click)=\"toggleOption(option)\"\n (keydown.enter)=\"toggleOption(option)\"\n (keydown.space)=\"toggleOption(option); $event.preventDefault()\"\n >\n {{ option.label }}\n </div>\n }\n</div>\n", styles: [".chip-option-list{display:flex;flex-wrap:wrap;gap:var(--signal-form-gap-sm);padding:var(--signal-form-padding-md);border:1px solid var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-sm);background:var(--signal-form-bg);box-shadow:var(--signal-form-shadow);transition:var(--signal-form-transition)}.chip-option-list .chip-option{display:inline-flex;align-items:center;padding:var(--signal-form-padding-sm) var(--signal-form-padding-lg);border-radius:var(--signal-form-border-radius-sm);border:1px solid var(--signal-form-border-color);background:var(--signal-form-bg);color:var(--signal-form-text);cursor:pointer;font-size:var(--signal-form-font-size-sm);font-weight:var(--signal-form-font-weight-medium);transition:var(--signal-form-transition);-webkit-user-select:none;user-select:none}.chip-option-list .chip-option:hover{border-color:var(--signal-form-outline-focus);background:var(--signal-form-input-hover-bg)}.chip-option-list .chip-option:focus{outline:none;border-color:var(--signal-form-outline-focus);box-shadow:0 0 0 2px var(--signal-form-outline-focus-shadow)}.chip-option-list .chip-option.selected{background:var(--signal-form-button-primary-bg);color:var(--signal-form-button-text);border-color:var(--signal-form-button-primary-bg)}.chip-option-list .chip-option.selected:hover{background:var(--signal-form-button-primary-bg-hover);border-color:var(--signal-form-button-primary-bg-hover)}.selected-chips{display:flex;flex-wrap:wrap;gap:var(--signal-form-gap-xs);flex:1;min-width:0}.chip{display:inline-flex;align-items:center;gap:var(--signal-form-gap-xs);padding:var(--signal-form-padding-xs) var(--signal-form-padding-sm);background:var(--signal-form-button-primary-bg);color:var(--signal-form-button-text);border-radius:var(--signal-form-border-radius-sm);font-size:var(--signal-form-font-size-sm);font-weight:var(--signal-form-font-weight-medium);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:var(--signal-form-transition)}.chip .chip-remove{display:inline-flex;align-items:center;justify-content:center;width:1rem;height:1rem;padding:0;border:none;background:#fff3;border-radius:var(--signal-form-radius-full);color:var(--signal-form-button-text);cursor:pointer;font-size:var(--signal-form-font-size-xs);transition:var(--signal-form-transition)}.chip .chip-remove:hover{background:#ffffff4d;transform:scale(1.1)}.chip .chip-remove:focus{outline:none;background:#fff6}.dropdown-trigger{display:inline-flex;align-items:center;gap:var(--signal-form-gap-sm);padding:var(--signal-form-padding-sm) var(--signal-form-padding-md);border:1px solid var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-sm);background:var(--signal-form-bg);color:var(--signal-form-text);cursor:pointer;font-size:var(--signal-form-font-size-sm);min-width:120px;transition:var(--signal-form-transition)}.dropdown-trigger:hover{border-color:var(--signal-form-outline-focus);background:var(--signal-form-input-hover-bg)}.dropdown-trigger:focus{outline:none;border-color:var(--signal-form-outline-focus);box-shadow:0 0 0 2px var(--signal-form-outline-focus-shadow)}.dropdown-trigger .dropdown-arrow{opacity:.5;transition:transform .2s}.dropdown-trigger[aria-expanded=true] .dropdown-arrow{transform:rotate(180deg)}\n"] }]
}] });
class SignalFormColorFieldComponent extends BaseInputDirective {
fallbackColor = '#000000';
currentColor = computed(() => this.field().value() ?? this.fallbackColor);
isSwatchOnly = computed(() => {
return this.field().config?.view === 'swatch';
});
inputValue = computed(() => this.field().value() ?? this.fallbackColor);
constructor() {
super();
effect(() => {
if (!CSS.supports('color', this.inputValue())) {
this.field().error.set('unsupported Color value!');
}
});
}
onTextInputChange(val) {
this.setValue(val);
}
onColorInputChange(event) {
const value = event.target.value;
this.setValue(value);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormColorFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormColorFieldComponent, isStandalone: true, selector: "signal-form-color-field", usesInheritance: true, ngImport: i0, template: "<div class=\"color-field-wrapper\">\n @if (isSwatchOnly()) {\n <label class=\"color-swatch\">\n <input type=\"color\" class=\"sr-only\" [signalModel]=\"field()\" />\n <span\n class=\"swatch-display\"\n [style.background]=\"currentColor()\"\n aria-hidden=\"true\"\n ></span>\n </label>\n } @else {\n <div class=\"color-picker-full\">\n <input type=\"color\" class=\"color-input\" [signalModel]=\"field()\" />\n\n <input\n type=\"text\"\n class=\"hex-input\"\n [value]=\"inputValue()\"\n [attr.aria-label]=\"'Hex color code for ' + field().name\"\n (input)=\"onTextInputChange($event.target.value)\"\n />\n </div>\n }\n</div>\n", styles: [".color-field-wrapper{display:flex;flex-direction:column;gap:8px}.color-swatch{position:relative;display:flex;align-items:center}.color-swatch input[type=color]{opacity:0;width:40px;height:40px;position:absolute;left:0;top:0;cursor:pointer}.color-swatch .swatch-display{width:40px;height:40px;border-radius:50%;border:2px solid var(--signal-form-border-color);box-shadow:var(--signal-form-shadow);cursor:pointer}.color-picker-full{display:flex;align-items:center;gap:12px}.color-picker-full .color-input{width:40px;height:40px;padding:0;border:none;background:none}.color-picker-full .hex-input{flex:1;padding:var(--signal-form-field-padding);border-radius:var(--signal-form-border-radius-sm);border:1px solid var(--signal-form-border-color);background-color:var(--signal-form-input-bg);color:var(--signal-form-input-text);font-family:monospace;transition:var(--signal-form-transition)}.color-picker-full .hex-input:focus{border-color:var(--signal-form-outline-focus);box-shadow:0 0 0 2px var(--signal-form-outline-focus-shadow)}\n"], dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormColorFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-color-field', standalone: true, imports: [SignalModelDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"color-field-wrapper\">\n @if (isSwatchOnly()) {\n <label class=\"color-swatch\">\n <input type=\"color\" class=\"sr-only\" [signalModel]=\"field()\" />\n <span\n class=\"swatch-display\"\n [style.background]=\"currentColor()\"\n aria-hidden=\"true\"\n ></span>\n </label>\n } @else {\n <div class=\"color-picker-full\">\n <input type=\"color\" class=\"color-input\" [signalModel]=\"field()\" />\n\n <input\n type=\"text\"\n class=\"hex-input\"\n [value]=\"inputValue()\"\n [attr.aria-label]=\"'Hex color code for ' + field().name\"\n (input)=\"onTextInputChange($event.target.value)\"\n />\n </div>\n }\n</div>\n", styles: [".color-field-wrapper{display:flex;flex-direction:column;gap:8px}.color-swatch{position:relative;display:flex;align-items:center}.color-swatch input[type=color]{opacity:0;width:40px;height:40px;position:absolute;left:0;top:0;cursor:pointer}.color-swatch .swatch-display{width:40px;height:40px;border-radius:50%;border:2px solid var(--signal-form-border-color);box-shadow:var(--signal-form-shadow);cursor:pointer}.color-picker-full{display:flex;align-items:center;gap:12px}.color-picker-full .color-input{width:40px;height:40px;padding:0;border:none;background:none}.color-picker-full .hex-input{flex:1;padding:var(--signal-form-field-padding);border-radius:var(--signal-form-border-radius-sm);border:1px solid var(--signal-form-border-color);background-color:var(--signal-form-input-bg);color:var(--signal-form-input-text);font-family:monospace;transition:var(--signal-form-transition)}.color-picker-full .hex-input:focus{border-color:var(--signal-form-outline-focus);box-shadow:0 0 0 2px var(--signal-form-outline-focus-shadow)}\n"] }]
}], ctorParameters: () => [] });
class SignalFormDatetimeFieldComponent extends BaseInputDirective {
formattedValue = computed(() => {
const date = this.field().value();
const format = this.field().config?.format;
const formatter = this.field().config?.formatterFn;
if (!date || !(date instanceof Date)) {
return '';
}
return formatter ? formatter(date) : this.defaultFormat(date, format);
});
defaultFormat(date, format) {
// fallback formatting – keep it native or simple
return date.toISOString().slice(0, 16); // e.g., '2025-06-19T12:34'
}
extractValue(el) {
return new Date(el.value);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormDatetimeFieldComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.14", type: SignalFormDatetimeFieldComponent, isStandalone: true, selector: "signal-form-datetime-field", usesInheritance: true, ngImport: i0, template: "<input class=\"form-input\" type=\"datetime-local\" [signalModel]=\"field()\" />\n", styles: [""], dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormDatetimeFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-datetime-field', standalone: true, imports: [SignalModelDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: "<input class=\"form-input\" type=\"datetime-local\" [signalModel]=\"field()\" />\n" }]
}] });
class SignalFormFileFieldComponent extends BaseInputDirective {
isDragging = signal(false);
uploadProgress = signal(null);
imagePreview = signal(null);
maxSizeBytes = computed(() => (this.field().config?.maxSizeMb ?? 5) * 1024 * 1024);
isImageFile = computed(() => {
const file = this.field().value();
return file && file.type.startsWith('image/');
});
fileName = computed(() => {
const file = this.field().value();
return file?.name || '';
});
onFileChange(event) {
const input = event.target;
const file = input.files?.[0];
if (!file) {
return;
}
const maxSize = this.maxSizeBytes();
if (file.size > maxSize) {
this.field().error.set(`File exceeds max size of ${this.field().config?.maxSizeMb ?? 5}MB`);
return;
}
this.field().error.set('');
this.setValue(file);
this.uploadProgress.set(0);
this.simulateUpload(file);
this.field().touched.set(true);
if (file.type.startsWith('image/')) {
this.createImagePreview(file);
}
else {
this.imagePreview.set(null);
}
}
createImagePreview(file) {
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreview.set(e.target?.result);
};
reader.readAsDataURL(file);
}
simulateUpload(file) {
let progress = 0;
const interval = setInterval(() => {
progress += 10;
this.uploadProgress.set(progress);
if (progress >= 100) {
clearInterval(interval);
this.uploadProgress.set(null);
}
}, 100);
}
onDrop(event) {
event.preventDefault();
this.isDragging.set(false);
const file = event.dataTransfer?.files?.[0];
if (file) {
const fakeEvent = {
target: { files: [file] },
};
this.onFileChange(fakeEvent);
}
}
onDragOver(event) {
event.preventDefault();
this.isDragging.set(true);
}
onDragLeave() {
this.isDragging.set(false);
}
openFileDialog(input) {
input.click();
}
removeFile() {
this.setValue(null);
this.imagePreview.set(null);
this.field().touched.set(true);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormFileFieldComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormFileFieldComponent, isStandalone: true, selector: "signal-form-file-field", usesInheritance: true, hostDirectives: [{ directive: SignalFormHostDirective }], ngImport: i0, template: "<div\n class=\"file-upload-dropzone\"\n role=\"button\"\n [signalModel]=\"field()\"\n [class.dragging]=\"isDragging()\"\n [class.has-file]=\"!!field().value()\"\n (drop)=\"onDrop($event)\"\n (dragover)=\"onDragOver($event)\"\n (dragleave)=\"onDragLeave()\"\n (click)=\"openFileDialog(fileInput)\"\n (keydown.enter)=\"openFileDialog(fileInput)\"\n (keydown.space)=\"openFileDialog(fileInput); $event.preventDefault()\"\n>\n @if (imagePreview()) {\n <div class=\"image-preview-container\">\n <img [src]=\"imagePreview()\" alt=\"Preview\" class=\"image-preview\" />\n <button\n type=\"button\"\n class=\"remove-file-btn\"\n (click)=\"removeFile(); $event.stopPropagation()\"\n aria-label=\"Remove file\"\n >\n \u2715\n </button>\n </div>\n } @else if (field().value()) {\n <div class=\"uploaded-file\">\n <span class=\"file-name\">{{ fileName() }}</span>\n <button\n type=\"button\"\n class=\"remove-file-btn\"\n (click)=\"removeFile(); $event.stopPropagation()\"\n aria-label=\"Remove file\"\n >\n \u2715\n </button>\n </div>\n } @else {\n <div class=\"upload-icon\" aria-hidden=\"true\">\u2601\uFE0F</div>\n <div class=\"upload-text\">\n {{ field().config?.uploadText || \"Click or drag a file to upload\" }}\n </div>\n }\n\n <input\n #fileInput\n class=\"sr-only\"\n type=\"file\"\n [accept]=\"field().config?.accept || '*/*'\"\n (change)=\"onFileChange($event)\"\n />\n\n @if (uploadProgress() !== null) {\n <div\n class=\"progress-bar\"\n role=\"progressbar\"\n [attr.aria-valuenow]=\"uploadProgress()\"\n aria-valuemin=\"0\"\n aria-valuemax=\"100\"\n >\n <div class=\"progress\" [style.width.%]=\"uploadProgress()\"></div>\n </div>\n }\n</div>\n", styles: [".file-upload-dropzone{border:2px dashed var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-md);padding:2rem;text-align:center;cursor:pointer;transition:var(--signal-form-transition);background:var(--signal-form-input-bg);color:var(--signal-form-input-text);position:relative;min-height:120px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem}.file-upload-dropzone:hover{border-color:var(--signal-form-outline-focus);background:var(--signal-form-input-hover-bg)}.file-upload-dropzone.dragging{border-color:var(--signal-form-outline-focus);background:var(--signal-forms-primary-50)}.file-upload-dropzone.has-file{border-color:var(--signal-forms-success-500);background:var(--signal-forms-success-50)}.image-preview-container{position:relative;display:flex;flex-direction:column;align-items:center;gap:.5rem}.image-preview{max-width:200px;max-height:200px;border-radius:var(--signal-form-border-radius-md);box-shadow:var(--signal-form-shadow-lg);object-fit:cover}.uploaded-file{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;background:var(--signal-form-input-bg);border-radius:var(--signal-form-border-radius-sm);box-shadow:var(--signal-form-shadow)}.file-name{font-weight:500;color:var(--signal-form-input-text)}.remove-file-btn{background:var(--signal-forms-danger-500);color:var(--signal-forms-neutral-0);border:none;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:12px;transition:var(--signal-form-transition)}.remove-file-btn:hover{background:var(--signal-forms-danger-600)}.upload-icon{font-size:2rem;margin-bottom:.5rem}.upload-text{color:var(--signal-form-muted);font-size:.875rem}.progress-bar{width:100%;height:4px;background:var(--signal-forms-neutral-200);border-radius:2px;overflow:hidden;margin-top:1rem}.progress{height:100%;background:var(--signal-form-outline-focus);transition:width .3s ease}\n"], dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormFileFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-file-field', standalone: true, imports: [SignalModelDirective], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [SignalFormHostDirective], template: "<div\n class=\"file-upload-dropzone\"\n role=\"button\"\n [signalModel]=\"field()\"\n [class.dragging]=\"isDragging()\"\n [class.has-file]=\"!!field().value()\"\n (drop)=\"onDrop($event)\"\n (dragover)=\"onDragOver($event)\"\n (dragleave)=\"onDragLeave()\"\n (click)=\"openFileDialog(fileInput)\"\n (keydown.enter)=\"openFileDialog(fileInput)\"\n (keydown.space)=\"openFileDialog(fileInput); $event.preventDefault()\"\n>\n @if (imagePreview()) {\n <div class=\"image-preview-container\">\n <img [src]=\"imagePreview()\" alt=\"Preview\" class=\"image-preview\" />\n <button\n type=\"button\"\n class=\"remove-file-btn\"\n (click)=\"removeFile(); $event.stopPropagation()\"\n aria-label=\"Remove file\"\n >\n \u2715\n </button>\n </div>\n } @else if (field().value()) {\n <div class=\"uploaded-file\">\n <span class=\"file-name\">{{ fileName() }}</span>\n <button\n type=\"button\"\n class=\"remove-file-btn\"\n (click)=\"removeFile(); $event.stopPropagation()\"\n aria-label=\"Remove file\"\n >\n \u2715\n </button>\n </div>\n } @else {\n <div class=\"upload-icon\" aria-hidden=\"true\">\u2601\uFE0F</div>\n <div class=\"upload-text\">\n {{ field().config?.uploadText || \"Click or drag a file to upload\" }}\n </div>\n }\n\n <input\n #fileInput\n class=\"sr-only\"\n type=\"file\"\n [accept]=\"field().config?.accept || '*/*'\"\n (change)=\"onFileChange($event)\"\n />\n\n @if (uploadProgress() !== null) {\n <div\n class=\"progress-bar\"\n role=\"progressbar\"\n [attr.aria-valuenow]=\"uploadProgress()\"\n aria-valuemin=\"0\"\n aria-valuemax=\"100\"\n >\n <div class=\"progress\" [style.width.%]=\"uploadProgress()\"></div>\n </div>\n }\n</div>\n", styles: [".file-upload-dropzone{border:2px dashed var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-md);padding:2rem;text-align:center;cursor:pointer;transition:var(--signal-form-transition);background:var(--signal-form-input-bg);color:var(--signal-form-input-text);position:relative;min-height:120px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem}.file-upload-dropzone:hover{border-color:var(--signal-form-outline-focus);background:var(--signal-form-input-hover-bg)}.file-upload-dropzone.dragging{border-color:var(--signal-form-outline-focus);background:var(--signal-forms-primary-50)}.file-upload-dropzone.has-file{border-color:var(--signal-forms-success-500);background:var(--signal-forms-success-50)}.image-preview-container{position:relative;display:flex;flex-direction:column;align-items:center;gap:.5rem}.image-preview{max-width:200px;max-height:200px;border-radius:var(--signal-form-border-radius-md);box-shadow:var(--signal-form-shadow-lg);object-fit:cover}.uploaded-file{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;background:var(--signal-form-input-bg);border-radius:var(--signal-form-border-radius-sm);box-shadow:var(--signal-form-shadow)}.file-name{font-weight:500;color:var(--signal-form-input-text)}.remove-file-btn{background:var(--signal-forms-danger-500);color:var(--signal-forms-neutral-0);border:none;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:12px;transition:var(--signal-form-transition)}.remove-file-btn:hover{background:var(--signal-forms-danger-600)}.upload-icon{font-size:2rem;margin-bottom:.5rem}.upload-text{color:var(--signal-form-muted);font-size:.875rem}.progress-bar{width:100%;height:4px;background:var(--signal-forms-neutral-200);border-radius:2px;overflow:hidden;margin-top:1rem}.progress{height:100%;background:var(--signal-form-outline-focus);transition:width .3s ease}\n"] }]
}] });
class SignalFormMultiselectFieldComponent extends BaseInputDirective {
showDropdown = signal(false);
dropdownService = inject(FormDropdownService);
host = inject(SignalFormHostDirective);
constructor() {
super();
this.dropdownOverlayEffect();
}
dropdownOverlayEffect() {
effect(() => {
if (!this.showDropdown()) {
return;
}
const reference = this.host.viewContainerRef.element.nativeElement;
this.dropdownService.openDropdown({
options: this.field().options(),
reference,
viewContainerRef: this.host.viewContainerRef,
multiselect: true,
ariaListboxId: `${String(this.field().name)}-listbox`,
initialSelection: this.field().value(),
onSelect: (selected) => {
this.setValue(selected);
this.field().touched.set(true);
},
onClose: () => {
this.showDropdown.set(false);
},
});
}, {
injector: this.injector,
});
}
removeChip(option) {
this.field().value.update((vals) => (vals ?? []).filter((o) => o.value !== option.value));
this.field().touched.set(true);
}
toggleDropdown() {
this.showDropdown.update((show) => !show);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormMultiselectFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormMultiselectFieldComponent, isStandalone: true, selector: "signal-form-multiselect-field", usesInheritance: true, hostDirectives: [{ directive: SignalFormHostDirective }], ngImport: i0, template: "<div\n class=\"multi-select-wrapper\"\n #multiselectWrapper\n role=\"combobox\"\n [signalModel]=\"field()\"\n [attr.aria-expanded]=\"showDropdown()\"\n [attr.aria-multiselectable]=\"true\"\n [attr.aria-haspopup]=\"'listbox'\"\n (keydown.enter)=\"toggleDropdown()\"\n>\n <div\n class=\"chip-list\"\n role=\"list\"\n [attr.aria-label]=\"'Selected ' + field().label + ' items'\"\n (click)=\"toggleDropdown()\"\n >\n @for (item of field().value() ?? []; track item.value) {\n <div class=\"chip\" role=\"listitem\">\n {{ item.label }}\n <button\n type=\"button\"\n class=\"remove-btn\"\n (click)=\"removeChip(item); $event.stopPropagation()\"\n (keydown.enter)=\"removeChip(item)\"\n [attr.aria-label]=\"'Remove ' + item.label\"\n >\n \u00D7\n </button>\n </div>\n }\n </div>\n\n @if (!field().value()?.length) {\n <div class=\"placeholder\">\n {{ field().config?.placeholder }}\n </div>\n }\n</div>\n", styles: [".multi-select-wrapper{font-size:var(--signal-form-font-size-base);color:var(--signal-form-input-text);border:1px solid var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-sm);box-shadow:var(--signal-form-shadow);transition:var(--signal-form-transition);background:var(--signal-form-input-bg);cursor:pointer;display:flex;flex-wrap:wrap;gap:var(--signal-form-gap-xs);padding:var(--signal-form-padding-xs) var(--signal-form-padding-sm);min-height:var(--signal-form-input-height);position:relative}.multi-select-wrapper:focus-within{border-color:var(--signal-form-outline-focus);box-shadow:0 0 0 2px var(--signal-form-outline-focus-shadow)}.multi-select-wrapper:hover{border-color:var(--signal-forms-neutral-400)}.multi-select-wrapper .chip-list{display:flex;flex-wrap:wrap;gap:var(--signal-form-gap-xs);align-items:center;flex:1}.multi-select-wrapper .chip{display:inline-flex;align-items:center;gap:var(--signal-form-gap-xs);padding:var(--signal-form-padding-xs) var(--signal-form-padding-sm);background:var(--signal-form-button-primary-bg);color:var(--signal-form-button-text);border-radius:var(--signal-form-border-radius-sm);font-size:var(--signal-form-font-size-sm);font-weight:var(--signal-form-font-weight-medium);line-height:1.25;transition:var(--signal-form-transition)}.multi-select-wrapper .chip:hover{background:var(--signal-form-button-primary-bg-hover)}.multi-select-wrapper .chip .remove-btn{display:flex;align-items:center;justify-content:center;width:1rem;height:1rem;border:none;background:#fff3;color:var(--signal-form-button-text);border-radius:var(--signal-form-radius-full);cursor:pointer;font-size:var(--signal-form-font-size-xs);font-weight:var(--signal-form-font-weight-semibold);line-height:1;transition:var(--signal-form-transition)}.multi-select-wrapper .chip .remove-btn:hover{background:#ffffff4d;transform:scale(1.1)}.multi-select-wrapper .chip .remove-btn:focus{outline:none;background:#fff6}.multi-select-wrapper .placeholder{color:var(--signal-form-placeholder-color);font-size:var(--signal-form-font-size-base);font-style:italic;pointer-events:none;position:absolute;top:50%;left:var(--signal-form-padding-md);transform:translateY(-50%);z-index:1;transition:var(--signal-form-transition)}.chip-list:not(:empty)~.multi-select-wrapper .placeholder{display:none}.multi-select-wrapper:has(.chip) .placeholder{display:none}.multi-select-wrapper:empty .placeholder{display:block}.multi-select-wrapper[aria-disabled=true]{background-color:var(--signal-form-disabled-bg);color:var(--signal-form-muted);cursor:not-allowed}.multi-select-wrapper[aria-disabled=true] .chip{background:var(--signal-forms-neutral-200);color:var(--signal-form-muted)}.multi-select-wrapper[aria-disabled=true] .chip .remove-btn{cursor:not-allowed;opacity:.5}.multiselect-container{display:flex;flex-wrap:wrap;gap:var(--signal-form-gap-xs);min-height:var(--signal-form-input-height);padding:var(--signal-form-padding-xs) var(--signal-form-padding-sm);border:1px solid var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-sm);background:var(--signal-form-bg);cursor:text;transition:var(--signal-form-transition)}.multiselect-container:focus-within{border-color:var(--signal-form-outline-focus);box-shadow:0 0 0 2px var(--signal-form-outline-focus-shadow)}.selected-values{display:flex;flex-wrap:wrap;gap:var(--signal-form-gap-xs);flex:1;min-width:0}.value-tag{display:inline-flex;align-items:center;gap:var(--signal-form-gap-xs);padding:var(--signal-form-padding-xs) var(--signal-form-padding-sm);background:var(--signal-form-button-primary-bg);color:var(--signal-form-button-text);border-radius:var(--signal-form-border-radius-sm);font-size:var(--signal-form-font-size-sm);font-weight:var(--signal-form-font-weight-medium);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:var(--signal-form-transition)}.value-tag .remove-button{display:inline-flex;align-items:center;justify-content:center;width:1rem;height:1rem;padding:0;border:none;background:#fff3;border-radius:var(--signal-form-radius-full);color:var(--signal-form-button-text);cursor:pointer;font-size:var(--signal-form-font-size-xs);font-weight:var(--signal-form-font-weight-semibold);transition:var(--signal-form-transition)}.value-tag .remove-button:hover{background:#ffffff4d;transform:scale(1.1)}.value-tag .remove-button:focus{outline:none;background:#fff6}.placeholder{color:var(--signal-form-muted);font-style:italic}.dropdown-indicator{position:absolute;right:var(--signal-form-padding-md);top:50%;transform:translateY(-50%);pointer-events:none;color:var(--signal-form-muted);transition:var(--signal-form-transition)}\n"], dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormMultiselectFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-multiselect-field', standalone: true, imports: [SignalModelDirective], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [SignalFormHostDirective], template: "<div\n class=\"multi-select-wrapper\"\n #multiselectWrapper\n role=\"combobox\"\n [signalModel]=\"field()\"\n [attr.aria-expanded]=\"showDropdown()\"\n [attr.aria-multiselectable]=\"true\"\n [attr.aria-haspopup]=\"'listbox'\"\n (keydown.enter)=\"toggleDropdown()\"\n>\n <div\n class=\"chip-list\"\n role=\"list\"\n [attr.aria-label]=\"'Selected ' + field().label + ' items'\"\n (click)=\"toggleDropdown()\"\n >\n @for (item of field().value() ?? []; track item.value) {\n <div class=\"chip\" role=\"listitem\">\n {{ item.label }}\n <button\n type=\"button\"\n class=\"remove-btn\"\n (click)=\"removeChip(item); $event.stopPropagation()\"\n (keydown.enter)=\"removeChip(item)\"\n [attr.aria-label]=\"'Remove ' + item.label\"\n >\n \u00D7\n </button>\n </div>\n }\n </div>\n\n @if (!field().value()?.length) {\n <div class=\"placeholder\">\n {{ field().config?.placeholder }}\n </div>\n }\n</div>\n", styles: [".multi-select-wrapper{font-size:var(--signal-form-font-size-base);color:var(--signal-form-input-text);border:1px solid var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-sm);box-shadow:var(--signal-form-shadow);transition:var(--signal-form-transition);background:var(--signal-form-input-bg);cursor:pointer;display:flex;flex-wrap:wrap;gap:var(--signal-form-gap-xs);padding:var(--signal-form-padding-xs) var(--signal-form-padding-sm);min-height:var(--signal-form-input-height);position:relative}.multi-select-wrapper:focus-within{border-color:var(--signal-form-outline-focus);box-shadow:0 0 0 2px var(--signal-form-outline-focus-shadow)}.multi-select-wrapper:hover{border-color:var(--signal-forms-neutral-400)}.multi-select-wrapper .chip-list{display:flex;flex-wrap:wrap;gap:var(--signal-form-gap-xs);align-items:center;flex:1}.multi-select-wrapper .chip{display:inline-flex;align-items:center;gap:var(--signal-form-gap-xs);padding:var(--signal-form-padding-xs) var(--signal-form-padding-sm);background:var(--signal-form-button-primary-bg);color:var(--signal-form-button-text);border-radius:var(--signal-form-border-radius-sm);font-size:var(--signal-form-font-size-sm);font-weight:var(--signal-form-font-weight-medium);line-height:1.25;transition:var(--signal-form-transition)}.multi-select-wrapper .chip:hover{background:var(--signal-form-button-primary-bg-hover)}.multi-select-wrapper .chip .remove-btn{display:flex;align-items:center;justify-content:center;width:1rem;height:1rem;border:none;background:#fff3;color:var(--signal-form-button-text);border-radius:var(--signal-form-radius-full);cursor:pointer;font-size:var(--signal-form-font-size-xs);font-weight:var(--signal-form-font-weight-semibold);line-height:1;transition:var(--signal-form-transition)}.multi-select-wrapper .chip .remove-btn:hover{background:#ffffff4d;transform:scale(1.1)}.multi-select-wrapper .chip .remove-btn:focus{outline:none;background:#fff6}.multi-select-wrapper .placeholder{color:var(--signal-form-placeholder-color);font-size:var(--signal-form-font-size-base);font-style:italic;pointer-events:none;position:absolute;top:50%;left:var(--signal-form-padding-md);transform:translateY(-50%);z-index:1;transition:var(--signal-form-transition)}.chip-list:not(:empty)~.multi-select-wrapper .placeholder{display:none}.multi-select-wrapper:has(.chip) .placeholder{display:none}.multi-select-wrapper:empty .placeholder{display:block}.multi-select-wrapper[aria-disabled=true]{background-color:var(--signal-form-disabled-bg);color:var(--signal-form-muted);cursor:not-allowed}.multi-select-wrapper[aria-disabled=true] .chip{background:var(--signal-forms-neutral-200);color:var(--signal-form-muted)}.multi-select-wrapper[aria-disabled=true] .chip .remove-btn{cursor:not-allowed;opacity:.5}.multiselect-container{display:flex;flex-wrap:wrap;gap:var(--signal-form-gap-xs);min-height:var(--signal-form-input-height);padding:var(--signal-form-padding-xs) var(--signal-form-padding-sm);border:1px solid var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-sm);background:var(--signal-form-bg);cursor:text;transition:var(--signal-form-transition)}.multiselect-container:focus-within{border-color:var(--signal-form-outline-focus);box-shadow:0 0 0 2px var(--signal-form-outline-focus-shadow)}.selected-values{display:flex;flex-wrap:wrap;gap:var(--signal-form-gap-xs);flex:1;min-width:0}.value-tag{display:inline-flex;align-items:center;gap:var(--signal-form-gap-xs);padding:var(--signal-form-padding-xs) var(--signal-form-padding-sm);background:var(--signal-form-button-primary-bg);color:var(--signal-form-button-text);border-radius:var(--signal-form-border-radius-sm);font-size:var(--signal-form-font-size-sm);font-weight:var(--signal-form-font-weight-medium);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:var(--signal-form-transition)}.value-tag .remove-button{display:inline-flex;align-items:center;justify-content:center;width:1rem;height:1rem;padding:0;border:none;background:#fff3;border-radius:var(--signal-form-radius-full);color:var(--signal-form-button-text);cursor:pointer;font-size:var(--signal-form-font-size-xs);font-weight:var(--signal-form-font-weight-semibold);transition:var(--signal-form-transition)}.value-tag .remove-button:hover{background:#ffffff4d;transform:scale(1.1)}.value-tag .remove-button:focus{outline:none;background:#fff6}.placeholder{color:var(--signal-form-muted);font-style:italic}.dropdown-indicator{position:absolute;right:var(--signal-form-padding-md);top:50%;transform:translateY(-50%);pointer-events:none;color:var(--signal-form-muted);transition:var(--signal-form-transition)}\n"] }]
}], ctorParameters: () => [] });
class SignalFormNumberFieldComponent extends BaseInputDirective {
NumberInputType = NumberInputType;
currentUnit = signal('');
isEditing = signal(false);
inputType = computed(() => this.field().config?.inputType ?? NumberInputType.STANDARD);
hasUnitConversion = computed(() => this.inputType() === NumberInputType.UNIT_CONVERSION &&
!!this.field().config?.unitConversions);
unitConfig = computed(() => this.field().config?.unitConversions ?? null);
availableUnits = computed(() => {
const config = this.unitConfig();
return config
? Object.keys(config.unitConversions).map((key) => ({
key,
...config.unitConversions[key],
}))
: [];
});
unitPosition = computed(() => this.unitConfig()?.unitPosition ?? 'suffix');
inputStep = computed(() => {
const config = this.field().config;
if (config?.step !== undefined) {
return config.step;
}
switch (this.inputType()) {
case NumberInputType.CURRENCY:
return 0.01;
case NumberInputType.PERCENTAGE:
return 0.1;
case NumberInputType.INTEGER:
return 1;
default:
return 'any';
}
});
inputMin = computed(() => this.field().config?.min);
inputMax = computed(() => this.field().config?.max);
formattedValue = computed(() => {
const value = this.field().value();
if (value === null || value === undefined || isNaN(value)) {
return '';
}
const config = this.field().config;
const inputType = this.inputType();
const locale = config?.locale ?? 'en-US';
// Check for unit-specific parser first
if (this.hasUnitConversion()) {
const unitConfig = this.unitConfig();
const currentUnitKey = this.currentUnit();
const unitData = unitConfig?.unitConversions[currentUnitKey];
if (unitData?.parser) {
return unitData.parser(value);
}
}
// Check for field-level custom parser
if (config?.parser) {
return config.parser(value);
}
// Apply built-in formatting based on inputType
switch (inputType) {
case NumberInputType.CURRENCY:
const currencyCode = config?.currencyCode ?? 'USD';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
}).format(value);
case NumberInputType.PERCENTAGE:
return new Intl.NumberFormat(locale, {
style: 'percent',
minimumFractionDigits: 1,
}).format(value / 100);
case NumberInputType.DECIMAL:
const fractionDigits = config?.decimalPlaces ?? 2;
return new Intl.NumberFormat(locale, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(value);
case NumberInputType.INTEGER:
return new Intl.NumberFormat(locale, {
maximumFractionDigits: 0,
}).format(value);
default:
return value.toString();
}
});
showFormattedValue = computed(() => {
return (this.inputType() !== NumberInputType.STANDARD &&
!this.isEditing() &&
!!this.field().value() &&
this.field().value() !== 0);
});
rawValue = computed(() => {
const value = this.field().value();
return value?.toString() ?? '';
});
onFocusFormattedDisplay() {
// Switch to raw input mode
this.isEditing.set(true);
// Use setTimeout to ensure the input is rendered before focusing
setTimeout(() => {
const input = document.querySelector('.form-input[type="number"]');
if (input) {
input.focus();
input.select(); // Select all text for easy editing
}
}, 0);
}
onBlurRawInput() {
// Switch back to formatted display mode with a small delay
// to prevent immediate blur when switching elements
setTimeout(() => {
this.isEditing.set(false);
}, 100);
}
ngOnInit() {
// Initialize unit for unit conversion fields
if (this.hasUnitConversion()) {
const defaultUnit = this.unitConfig()?.defaultUnit;
if (defaultUnit) {
this.currentUnit.set(defaultUnit);
}
}
}
onUnitChange(newUnit) {
if (!this.hasUnitConversion())
return;
const currentValue = this.field().value();
if (currentValue === null ||
currentValue === undefined ||
isNaN(currentValue)) {
this.currentUnit.set(newUnit);
return;
}
const unitConfig = this.unitConfig();
if (!unitConfig)
return;
const oldUnit = this.currentUnit();
const newUnitConverter = unitConfig.unitConversions[newUnit]?.convert;
if (newUnitConverter && oldUnit !== newUnit) {
const convertedValue = newUnitConverter(currentValue, oldUnit);
// Apply precision rounding if specified
const precision = unitConfig.precision ?? 2;
const roundedValue = Math.round(convertedValue * Math.pow(10, precision)) /
Math.pow(10, precision);
this.field().value.set(roundedValue);
this.currentUnit.set(newUnit);
}
}
extractValue(el) {
const raw = el.value;
const parsed = parseFloat(raw);
return isNaN(parsed) ? 0 : parsed;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormNumberFieldComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormNumberFieldComponent, isStandalone: true, selector: "signal-form-number-field", usesInheritance: true, ngImport: i0, template: "<div class=\"form-input-wrapper\">\n <!-- Unit dropdown (prefix position) -->\n @if (hasUnitConversion() && unitPosition() === \"prefix\") {\n <select\n class=\"form-select form-select-unit form-select-prefix\"\n [value]=\"currentUnit()\"\n (change)=\"onUnitChange($any($event.target).value)\"\n >\n @for (unit of availableUnits(); track unit.key) {\n <option [value]=\"unit.key\">{{ unit.label }}</option>\n }\n </select>\n }\n\n <!-- Number input with conditional formatting -->\n @if (showFormattedValue()) {\n <!-- Show formatted value when not focused -->\n <div\n class=\"form-input form-input-formatted\"\n tabindex=\"0\"\n (focus)=\"onFocusFormattedDisplay()\"\n (click)=\"onFocusFormattedDisplay()\"\n >\n {{ formattedValue() }}\n </div>\n } @else {\n <!-- Show raw input when focused or no formatting needed -->\n <input\n type=\"number\"\n class=\"form-input\"\n [signalModel]=\"field()\"\n [step]=\"inputStep()\"\n [min]=\"inputMin()\"\n [max]=\"inputMax()\"\n (blur)=\"onBlurRawInput()\"\n />\n }\n\n <!-- Unit dropdown (suffix position) -->\n @if (hasUnitConversion() && unitPosition() === \"suffix\") {\n <select\n class=\"form-select form-select-unit form-select-suffix\"\n [value]=\"currentUnit()\"\n (change)=\"onUnitChange($any($event.target).value)\"\n >\n @for (unit of availableUnits(); track unit.key) {\n <option [value]=\"unit.key\">{{ unit.label }}</option>\n }\n </select>\n }\n</div>\n", styles: [".unit-display{padding:0 .75rem;font-size:var(--signal-form-font-size-sm);color:var(--signal-form-muted);background-color:var(--signal-forms-neutral-100);border-left:1px solid var(--signal-form-border-color);display:flex;align-items:center}\n"], dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }, { kind: "ngmodule", type: CommonModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormNumberFieldComponent, decorators: [{
type: Component,
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'signal-form-number-field', standalone: true, imports: [SignalModelDirective, CommonModule], template: "<div class=\"form-input-wrapper\">\n <!-- Unit dropdown (prefix position) -->\n @if (hasUnitConversion() && unitPosition() === \"prefix\") {\n <select\n class=\"form-select form-select-unit form-select-prefix\"\n [value]=\"currentUnit()\"\n (change)=\"onUnitChange($any($event.target).value)\"\n >\n @for (unit of availableUnits(); track unit.key) {\n <option [value]=\"unit.key\">{{ unit.label }}</option>\n }\n </select>\n }\n\n <!-- Number input with conditional formatting -->\n @if (showFormattedValue()) {\n <!-- Show formatted value when not focused -->\n <div\n class=\"form-input form-input-formatted\"\n tabindex=\"0\"\n (focus)=\"onFocusFormattedDisplay()\"\n (click)=\"onFocusFormattedDisplay()\"\n >\n {{ formattedValue() }}\n </div>\n } @else {\n <!-- Show raw input when focused or no formatting needed -->\n <input\n type=\"number\"\n class=\"form-input\"\n [signalModel]=\"field()\"\n [step]=\"inputStep()\"\n [min]=\"inputMin()\"\n [max]=\"inputMax()\"\n (blur)=\"onBlurRawInput()\"\n />\n }\n\n <!-- Unit dropdown (suffix position) -->\n @if (hasUnitConversion() && unitPosition() === \"suffix\") {\n <select\n class=\"form-select form-select-unit form-select-suffix\"\n [value]=\"currentUnit()\"\n (change)=\"onUnitChange($any($event.target).value)\"\n >\n @for (unit of availableUnits(); track unit.key) {\n <option [value]=\"unit.key\">{{ unit.label }}</option>\n }\n </select>\n }\n</div>\n", styles: [".unit-display{padding:0 .75rem;font-size:var(--signal-form-font-size-sm);color:var(--signal-form-muted);background-color:var(--signal-forms-neutral-100);border-left:1px solid var(--signal-form-border-color);display:flex;align-items:center}\n"] }]
}] });
class SignalFormPasswordFieldComponent extends BaseInputDirective {
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormPasswordFieldComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.14", type: SignalFormPasswordFieldComponent, isStandalone: true, selector: "signal-form-password-field", usesInheritance: true, ngImport: i0, template: "<div class=\"form-input-wrapper\">\n <input class=\"form-input\" type=\"password\" [signalModel]=\"field()\" />\n</div>\n", styles: [""], dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormPasswordFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-password-field', standalone: true, imports: [SignalModelDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"form-input-wrapper\">\n <input class=\"form-input\" type=\"password\" [signalModel]=\"field()\" />\n</div>\n" }]
}] });
class SignalFormRadioFieldComponent extends BaseInputDirective {
/**
* Convert option value to string for radio input value attribute
*/
valueToString(value) {
return String(value);
}
/**
* Check if any options have icons to determine layout style
*/
hasIcons() {
return this.field()
.options()
.some((option) => option.icon);
}
/**
* Check if an option is currently selected
*/
isOptionSelected(optionValue) {
return this.field().value() === optionValue;
}
/**
* Check if icon is a Lucide icon data (prioritized check)
*/
isLucideIcon(icon) {
return Array.isArray(icon) && icon.length > 0;
}
/**
* Check if icon is a string (emoji/unicode)
*/
isStringIcon(icon) {
return typeof icon === 'string';
}
/**
* Check if icon is a component type (fallback)
*/
isComponentIcon(icon) {
return typeof icon === 'function' && !this.isLucideIcon(icon);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormRadioFieldComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormRadioFieldComponent, isStandalone: true, selector: "signal-form-radio-field", usesInheritance: true, ngImport: i0, template: "<fieldset\n class=\"form-radio-group\"\n role=\"radiogroup\"\n [attr.name]=\"field().name\"\n [attr.aria-invalid]=\"field().error() ? 'true' : 'false'\"\n [class.card-style]=\"hasIcons()\"\n>\n <legend class=\"sr-only\">{{ field().label }}</legend>\n\n @for (option of field().options(); track option.value) {\n <label class=\"form-radio-option\" [class.card-option]=\"hasIcons()\">\n <input\n type=\"radio\"\n [signalModel]=\"field()\"\n [value]=\"valueToString(option.value)\"\n [checked]=\"isOptionSelected(option.value)\"\n [attr.name]=\"field().name\"\n class=\"radio-input\"\n />\n\n @if (hasIcons()) {\n <div class=\"card-content\">\n <div class=\"radio-indicator\"></div>\n @if (option.icon) {\n <div class=\"option-icon\">\n @if (isLucideIcon(option.icon)) {\n <lucide-icon [img]=\"option.icon\" size=\"32\" />\n } @else if (isStringIcon(option.icon)) {\n {{ option.icon }}\n } @else if (isComponentIcon(option.icon)) {\n <ng-container *ngComponentOutlet=\"option.icon\"></ng-container>\n }\n </div>\n }\n <span class=\"option-label\">{{ option.label }}</span>\n </div>\n } @else {\n {{ option.label }}\n }\n </label>\n }\n</fieldset>\n", styles: ["@charset \"UTF-8\";.form-radio-group{display:flex;flex-direction:column;gap:.5rem}.form-radio-group .form-radio-option{display:flex;align-items:center;gap:.5rem;padding:.75rem;border:1px solid var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-sm);background:var(--signal-form-input-bg);cursor:pointer;transition:var(--signal-form-transition);font-size:.875rem;color:var(--signal-form-input-text)}.form-radio-group .form-radio-option:hover{border-color:var(--signal-form-outline-focus);background:var(--signal-form-input-hover-bg)}.form-radio-group .form-radio-option .radio-input{margin:0;width:1rem;height:1rem;accent-color:var(--signal-form-button-primary-bg)}.form-radio-group.card-style{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:1rem}.form-radio-group.card-style .form-radio-option.card-option{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:1.5rem 1rem;min-height:120px;border:1px solid var(--signal-form-border-color);border-radius:12px;background:var(--signal-form-input-bg);cursor:pointer;transition:all .25s ease;box-shadow:var(--signal-form-shadow)}.form-radio-group.card-style .form-radio-option.card-option:hover{border-color:var(--signal-form-outline-focus);background:var(--signal-form-input-hover-bg);transform:translateY(-1px);box-shadow:var(--signal-form-shadow-lg)}.form-radio-group.card-style .form-radio-option.card-option .radio-input{position:absolute;opacity:0;width:1px;height:1px;top:0;left:0}.form-radio-group.card-style .form-radio-option.card-option:has(.radio-input:checked){border-color:var(--signal-form-button-primary-bg);background:var(--signal-forms-primary-50)}.form-radio-group.card-style .form-radio-option.card-option:has(.radio-input:checked) .card-content{color:var(--signal-form-button-primary-bg)}.form-radio-group.card-style .form-radio-option.card-option:has(.radio-input:checked) .card-content .radio-indicator{background:var(--signal-form-button-primary-bg);border-color:var(--signal-form-button-primary-bg)}.form-radio-group.card-style .form-radio-option.card-option:has(.radio-input:checked) .card-content .radio-indicator:after{opacity:1;transform:scale(1)}.form-radio-group.card-style .form-radio-option.card-option:has(.radio-input:checked) .card-content .option-icon{transform:scale(1.05)}.form-radio-group.card-style .form-radio-option.card-option:has(.radio-input:focus){outline:2px solid var(--signal-form-outline-focus);outline-offset:2px}.form-radio-group.card-style .form-radio-option.card-option .card-content{display:flex;flex-direction:column;align-items:center;gap:.75rem;text-align:center;width:100%;transition:var(--signal-form-transition);position:relative}.form-radio-group.card-style .form-radio-option.card-option .card-content .radio-indicator{position:absolute;top:-.5rem;left:-.5rem;width:1.25rem;height:1.25rem;border:2px solid var(--signal-form-border-color);border-radius:50%;background:var(--signal-form-input-bg);display:flex;align-items:center;justify-content:center;transition:var(--signal-form-transition);box-shadow:var(--signal-form-shadow)}.form-radio-group.card-style .form-radio-option.card-option .card-content .radio-indicator:after{content:\"\\2713\";color:var(--signal-forms-neutral-0);font-size:.75rem;font-weight:700;opacity:0;transform:scale(.5);transition:all .2s ease}.form-radio-group.card-style .form-radio-option.card-option .card-content .option-icon{font-size:2.5rem;line-height:1;opacity:.85;transition:all .3s ease;margin-bottom:.25rem}.form-radio-group.card-style .form-radio-option.card-option .card-content .option-label{font-size:.875rem;font-weight:600;line-height:1.3;color:inherit;transition:var(--signal-form-transition)}@media (max-width: 768px){.form-radio-group.card-style{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.75rem}.form-radio-group.card-style .form-radio-option.card-option{min-height:110px;padding:1.25rem .75rem}.form-radio-group.card-style .form-radio-option.card-option .card-content{gap:.5rem}.form-radio-group.card-style .form-radio-option.card-option .card-content .option-icon{font-size:2rem}.form-radio-group.card-style .form-radio-option.card-option .card-content .option-label{font-size:.8rem}}@media (max-width: 480px){.form-radio-group.card-style{grid-template-columns:1fr 1fr}.form-radio-group.card-style .form-radio-option.card-option{min-height:100px;padding:1rem .5rem}}\n"], dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"], exportAs: ["ngComponentOutlet"] }, { kind: "ngmodule", type: LucideAngularModule }, { kind: "component", type: i1.LucideAngularComponent, selector: "lucide-angular, lucide-icon, i-lucide, span-lucide", inputs: ["class", "name", "img", "color", "absoluteStrokeWidth", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormRadioFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-radio-field', standalone: true, imports: [SignalModelDirective, NgComponentOutlet, LucideAngularModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<fieldset\n class=\"form-radio-group\"\n role=\"radiogroup\"\n [attr.name]=\"field().name\"\n [attr.aria-invalid]=\"field().error() ? 'true' : 'false'\"\n [class.card-style]=\"hasIcons()\"\n>\n <legend class=\"sr-only\">{{ field().label }}</legend>\n\n @for (option of field().options(); track option.value) {\n <label class=\"form-radio-option\" [class.card-option]=\"hasIcons()\">\n <input\n type=\"radio\"\n [signalModel]=\"field()\"\n [value]=\"valueToString(option.value)\"\n [checked]=\"isOptionSelected(option.value)\"\n [attr.name]=\"field().name\"\n class=\"radio-input\"\n />\n\n @if (hasIcons()) {\n <div class=\"card-content\">\n <div class=\"radio-indicator\"></div>\n @if (option.icon) {\n <div class=\"option-icon\">\n @if (isLucideIcon(option.icon)) {\n <lucide-icon [img]=\"option.icon\" size=\"32\" />\n } @else if (isStringIcon(option.icon)) {\n {{ option.icon }}\n } @else if (isComponentIcon(option.icon)) {\n <ng-container *ngComponentOutlet=\"option.icon\"></ng-container>\n }\n </div>\n }\n <span class=\"option-label\">{{ option.label }}</span>\n </div>\n } @else {\n {{ option.label }}\n }\n </label>\n }\n</fieldset>\n", styles: ["@charset \"UTF-8\";.form-radio-group{display:flex;flex-direction:column;gap:.5rem}.form-radio-group .form-radio-option{display:flex;align-items:center;gap:.5rem;padding:.75rem;border:1px solid var(--signal-form-border-color);border-radius:var(--signal-form-border-radius-sm);background:var(--signal-form-input-bg);cursor:pointer;transition:var(--signal-form-transition);font-size:.875rem;color:var(--signal-form-input-text)}.form-radio-group .form-radio-option:hover{border-color:var(--signal-form-outline-focus);background:var(--signal-form-input-hover-bg)}.form-radio-group .form-radio-option .radio-input{margin:0;width:1rem;height:1rem;accent-color:var(--signal-form-button-primary-bg)}.form-radio-group.card-style{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:1rem}.form-radio-group.card-style .form-radio-option.card-option{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:1.5rem 1rem;min-height:120px;border:1px solid var(--signal-form-border-color);border-radius:12px;background:var(--signal-form-input-bg);cursor:pointer;transition:all .25s ease;box-shadow:var(--signal-form-shadow)}.form-radio-group.card-style .form-radio-option.card-option:hover{border-color:var(--signal-form-outline-focus);background:var(--signal-form-input-hover-bg);transform:translateY(-1px);box-shadow:var(--signal-form-shadow-lg)}.form-radio-group.card-style .form-radio-option.card-option .radio-input{position:absolute;opacity:0;width:1px;height:1px;top:0;left:0}.form-radio-group.card-style .form-radio-option.card-option:has(.radio-input:checked){border-color:var(--signal-form-button-primary-bg);background:var(--signal-forms-primary-50)}.form-radio-group.card-style .form-radio-option.card-option:has(.radio-input:checked) .card-content{color:var(--signal-form-button-primary-bg)}.form-radio-group.card-style .form-radio-option.card-option:has(.radio-input:checked) .card-content .radio-indicator{background:var(--signal-form-button-primary-bg);border-color:var(--signal-form-button-primary-bg)}.form-radio-group.card-style .form-radio-option.card-option:has(.radio-input:checked) .card-content .radio-indicator:after{opacity:1;transform:scale(1)}.form-radio-group.card-style .form-radio-option.card-option:has(.radio-input:checked) .card-content .option-icon{transform:scale(1.05)}.form-radio-group.card-style .form-radio-option.card-option:has(.radio-input:focus){outline:2px solid var(--signal-form-outline-focus);outline-offset:2px}.form-radio-group.card-style .form-radio-option.card-option .card-content{display:flex;flex-direction:column;align-items:center;gap:.75rem;text-align:center;width:100%;transition:var(--signal-form-transition);position:relative}.form-radio-group.card-style .form-radio-option.card-option .card-content .radio-indicator{position:absolute;top:-.5rem;left:-.5rem;width:1.25rem;height:1.25rem;border:2px solid var(--signal-form-border-color);border-radius:50%;background:var(--signal-form-input-bg);display:flex;align-items:center;justify-content:center;transition:var(--signal-form-transition);box-shadow:var(--signal-form-shadow)}.form-radio-group.card-style .form-radio-option.card-option .card-content .radio-indicator:after{content:\"\\2713\";color:var(--signal-forms-neutral-0);font-size:.75rem;font-weight:700;opacity:0;transform:scale(.5);transition:all .2s ease}.form-radio-group.card-style .form-radio-option.card-option .card-content .option-icon{font-size:2.5rem;line-height:1;opacity:.85;transition:all .3s ease;margin-bottom:.25rem}.form-radio-group.card-style .form-radio-option.card-option .card-content .option-label{font-size:.875rem;font-weight:600;line-height:1.3;color:inherit;transition:var(--signal-form-transition)}@media (max-width: 768px){.form-radio-group.card-style{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.75rem}.form-radio-group.card-style .form-radio-option.card-option{min-height:110px;padding:1.25rem .75rem}.form-radio-group.card-style .form-radio-option.card-option .card-content{gap:.5rem}.form-radio-group.card-style .form-radio-option.card-option .card-content .option-icon{font-size:2rem}.form-radio-group.card-style .form-radio-option.card-option .card-content .option-label{font-size:.8rem}}@media (max-width: 480px){.form-radio-group.card-style{grid-template-columns:1fr 1fr}.form-radio-group.card-style .form-radio-option.card-option{min-height:100px;padding:1rem .5rem}}\n"] }]
}] });
class SignalFormRatingFieldComponent extends BaseInputDirective {
minValue = computed(() => this.field().config?.min ?? 1);
maxValue = computed(() => this.field().config?.max ?? 5);
stars = computed(() => {
const min = this.minValue();
const max = this.maxValue();
return Array.from({ length: max - min + 1 }, (_, i) => min + i);
});
setRating(star) {
this.setValue(star);
this.field().touched.set(true);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormRatingFieldComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormRatingFieldComponent, isStandalone: true, selector: "signal-form-rating-field", usesInheritance: true, ngImport: i0, template: "<div class=\"form-rating\" role=\"radiogroup\" [signalModel]=\"field()\">\n <div class=\"rating-stars\">\n @for (star of stars(); track star) {\n <button\n class=\"star\"\n type=\"button\"\n role=\"radio\"\n [class.filled]=\"field().value() >= star\"\n [attr.aria-label]=\"'Rate ' + star + ' star'\"\n [attr.aria-checked]=\"field().value() === star\"\n (click)=\"setRating(star)\"\n >\n \u2605\n </button>\n }\n </div>\n</div>\n", styles: [".rating-stars{display:flex;align-items:center;justify-content:flex-start;gap:8px}.rating-stars .star{color:var(--signal-forms-neutral-300);appearance:none;border:none;outline:none;padding:0!important;width:fit-content;background:transparent;transition:var(--signal-form-transition)}.rating-stars .star.filled{color:var(--signal-forms-warning-500)}.rating-stars .star:hover{color:var(--signal-forms-warning-400)}\n"], dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormRatingFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-rating-field', standalone: true, imports: [SignalModelDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"form-rating\" role=\"radiogroup\" [signalModel]=\"field()\">\n <div class=\"rating-stars\">\n @for (star of stars(); track star) {\n <button\n class=\"star\"\n type=\"button\"\n role=\"radio\"\n [class.filled]=\"field().value() >= star\"\n [attr.aria-label]=\"'Rate ' + star + ' star'\"\n [attr.aria-checked]=\"field().value() === star\"\n (click)=\"setRating(star)\"\n >\n \u2605\n </button>\n }\n </div>\n</div>\n", styles: [".rating-stars{display:flex;align-items:center;justify-content:flex-start;gap:8px}.rating-stars .star{color:var(--signal-forms-neutral-300);appearance:none;border:none;outline:none;padding:0!important;width:fit-content;background:transparent;transition:var(--signal-form-transition)}.rating-stars .star.filled{color:var(--signal-forms-warning-500)}.rating-stars .star:hover{color:var(--signal-forms-warning-400)}\n"] }]
}] });
/**
* Base skeleton component for form fields
*/
class FormFieldSkeletonComponent {
/**
* The field name for accessibility
*/
name = input('field');
/**
* The type of skeleton to render
*/
skeletonType = input('input');
/**
* Whether this is a checkbox field (affects label positioning)
*/
isCheckbox = input(false);
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: FormFieldSkeletonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: FormFieldSkeletonComponent, isStandalone: true, selector: "form-field-skeleton", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, skeletonType: { classPropertyName: "skeletonType", publicName: "skeletonType", isSignal: true, isRequired: false, transformFunction: null }, isCheckbox: { classPropertyName: "isCheckbox", publicName: "isCheckbox", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<div class=\"form-control\" [attr.data-form-field]=\"name()\">\n @if (!isCheckbox()) {\n <div class=\"skeleton-label\"></div>\n }\n\n @switch (skeletonType()) {\n @case (\"input\") {\n <div class=\"skeleton-input\"></div>\n }\n @case (\"textarea\") {\n <div class=\"skeleton-textarea\"></div>\n }\n @case (\"select\") {\n <div class=\"skeleton-select\">\n <div class=\"skeleton-select-arrow\"></div>\n </div>\n }\n @case (\"checkbox\") {\n <div class=\"skeleton-checkbox-wrapper\">\n <div class=\"skeleton-checkbox\"></div>\n <div class=\"skeleton-checkbox-label\"></div>\n </div>\n }\n @case (\"radio-group\") {\n <div class=\"skeleton-radio-group\">\n @for (i of [1, 2, 3]; track i) {\n <div class=\"skeleton-radio-option\">\n <div class=\"skeleton-radio\"></div>\n <div class=\"skeleton-radio-label\"></div>\n </div>\n }\n </div>\n }\n @case (\"multiselect\") {\n <div class=\"skeleton-multiselect\">\n <div class=\"skeleton-chip-container\">\n @for (i of [1, 2]; track i) {\n <div class=\"skeleton-chip\"></div>\n }\n </div>\n </div>\n }\n @case (\"rating\") {\n <div class=\"skeleton-rating\">\n @for (i of [1, 2, 3, 4, 5]; track i) {\n <div class=\"skeleton-star\"></div>\n }\n </div>\n }\n @case (\"slider\") {\n <div class=\"skeleton-slider\">\n <div class=\"skeleton-slider-track\">\n <div class=\"skeleton-slider-thumb\"></div>\n </div>\n </div>\n }\n @case (\"file\") {\n <div class=\"skeleton-file-upload\">\n <div class=\"skeleton-upload-icon\"></div>\n <div class=\"skeleton-upload-text\"></div>\n </div>\n }\n @default {\n <div class=\"skeleton-input\"></div>\n }\n }\n\n <div class=\"skeleton-hint\"></div>\n</div>\n", styles: ["@keyframes shimmer{0%{background-position:-468px 0}to{background-position:468px 0}}.skeleton-hint,.skeleton-upload-text,.skeleton-upload-icon,.skeleton-file-upload,.skeleton-slider-thumb,.skeleton-slider-track,.skeleton-star,.skeleton-chip,.skeleton-multiselect,.skeleton-radio-label,.skeleton-radio,.skeleton-checkbox-label,.skeleton-checkbox,.skeleton-textarea,.skeleton-select-arrow,.skeleton-input,.skeleton-select,.skeleton-label{background:linear-gradient(90deg,var(--signal-forms-neutral-200) 25%,var(--signal-forms-neutral-300) 50%,var(--signal-forms-neutral-200) 75%);background-size:400% 100%;animation:shimmer 1.2s ease-in-out infinite;border-radius:4px}.form-control{display:flex;flex-direction:column;gap:8px;margin-bottom:16px}.skeleton-label{height:16px;width:120px;margin-bottom:4px}.skeleton-input,.skeleton-select{height:40px;width:100%;position:relative}.skeleton-select{display:flex;align-items:center;padding-right:40px}.skeleton-select-arrow{position:absolute;right:12px;width:16px;height:16px}.skeleton-textarea{height:80px;width:100%}.skeleton-checkbox-wrapper{display:flex;align-items:center;gap:8px}.skeleton-checkbox{width:20px;height:20px;flex-shrink:0}.skeleton-checkbox-label{height:16px;width:100px}.skeleton-radio-group{display:flex;flex-direction:column;gap:12px}.skeleton-radio-option{display:flex;align-items:center;gap:8px}.skeleton-radio{width:20px;height:20px;border-radius:50%;flex-shrink:0}.skeleton-radio-label{height:16px;width:80px}.skeleton-multiselect{min-height:40px;width:100%;padding:8px}.skeleton-chip-container{display:flex;gap:8px;flex-wrap:wrap}.skeleton-chip{height:24px;width:60px;border-radius:12px}.skeleton-rating{display:flex;gap:4px;align-items:center}.skeleton-star{width:24px;height:24px}.skeleton-slider{padding:12px 0}.skeleton-slider-track{height:8px;width:100%;position:relative}.skeleton-slider-thumb{position:absolute;top:-6px;left:30%;width:20px;height:20px;border-radius:50%}.skeleton-file-upload{height:120px;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;border:2px dashed var(--signal-forms-neutral-300);background:var(--signal-forms-neutral-100)}.skeleton-upload-icon{width:40px;height:40px;border-radius:50%}.skeleton-upload-text{height:16px;width:160px}.skeleton-hint{height:14px;width:200px;opacity:.7}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: FormFieldSkeletonComponent, decorators: [{
type: Component,
args: [{ selector: 'form-field-skeleton', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"form-control\" [attr.data-form-field]=\"name()\">\n @if (!isCheckbox()) {\n <div class=\"skeleton-label\"></div>\n }\n\n @switch (skeletonType()) {\n @case (\"input\") {\n <div class=\"skeleton-input\"></div>\n }\n @case (\"textarea\") {\n <div class=\"skeleton-textarea\"></div>\n }\n @case (\"select\") {\n <div class=\"skeleton-select\">\n <div class=\"skeleton-select-arrow\"></div>\n </div>\n }\n @case (\"checkbox\") {\n <div class=\"skeleton-checkbox-wrapper\">\n <div class=\"skeleton-checkbox\"></div>\n <div class=\"skeleton-checkbox-label\"></div>\n </div>\n }\n @case (\"radio-group\") {\n <div class=\"skeleton-radio-group\">\n @for (i of [1, 2, 3]; track i) {\n <div class=\"skeleton-radio-option\">\n <div class=\"skeleton-radio\"></div>\n <div class=\"skeleton-radio-label\"></div>\n </div>\n }\n </div>\n }\n @case (\"multiselect\") {\n <div class=\"skeleton-multiselect\">\n <div class=\"skeleton-chip-container\">\n @for (i of [1, 2]; track i) {\n <div class=\"skeleton-chip\"></div>\n }\n </div>\n </div>\n }\n @case (\"rating\") {\n <div class=\"skeleton-rating\">\n @for (i of [1, 2, 3, 4, 5]; track i) {\n <div class=\"skeleton-star\"></div>\n }\n </div>\n }\n @case (\"slider\") {\n <div class=\"skeleton-slider\">\n <div class=\"skeleton-slider-track\">\n <div class=\"skeleton-slider-thumb\"></div>\n </div>\n </div>\n }\n @case (\"file\") {\n <div class=\"skeleton-file-upload\">\n <div class=\"skeleton-upload-icon\"></div>\n <div class=\"skeleton-upload-text\"></div>\n </div>\n }\n @default {\n <div class=\"skeleton-input\"></div>\n }\n }\n\n <div class=\"skeleton-hint\"></div>\n</div>\n", styles: ["@keyframes shimmer{0%{background-position:-468px 0}to{background-position:468px 0}}.skeleton-hint,.skeleton-upload-text,.skeleton-upload-icon,.skeleton-file-upload,.skeleton-slider-thumb,.skeleton-slider-track,.skeleton-star,.skeleton-chip,.skeleton-multiselect,.skeleton-radio-label,.skeleton-radio,.skeleton-checkbox-label,.skeleton-checkbox,.skeleton-textarea,.skeleton-select-arrow,.skeleton-input,.skeleton-select,.skeleton-label{background:linear-gradient(90deg,var(--signal-forms-neutral-200) 25%,var(--signal-forms-neutral-300) 50%,var(--signal-forms-neutral-200) 75%);background-size:400% 100%;animation:shimmer 1.2s ease-in-out infinite;border-radius:4px}.form-control{display:flex;flex-direction:column;gap:8px;margin-bottom:16px}.skeleton-label{height:16px;width:120px;margin-bottom:4px}.skeleton-input,.skeleton-select{height:40px;width:100%;position:relative}.skeleton-select{display:flex;align-items:center;padding-right:40px}.skeleton-select-arrow{position:absolute;right:12px;width:16px;height:16px}.skeleton-textarea{height:80px;width:100%}.skeleton-checkbox-wrapper{display:flex;align-items:center;gap:8px}.skeleton-checkbox{width:20px;height:20px;flex-shrink:0}.skeleton-checkbox-label{height:16px;width:100px}.skeleton-radio-group{display:flex;flex-direction:column;gap:12px}.skeleton-radio-option{display:flex;align-items:center;gap:8px}.skeleton-radio{width:20px;height:20px;border-radius:50%;flex-shrink:0}.skeleton-radio-label{height:16px;width:80px}.skeleton-multiselect{min-height:40px;width:100%;padding:8px}.skeleton-chip-container{display:flex;gap:8px;flex-wrap:wrap}.skeleton-chip{height:24px;width:60px;border-radius:12px}.skeleton-rating{display:flex;gap:4px;align-items:center}.skeleton-star{width:24px;height:24px}.skeleton-slider{padding:12px 0}.skeleton-slider-track{height:8px;width:100%;position:relative}.skeleton-slider-thumb{position:absolute;top:-6px;left:30%;width:20px;height:20px;border-radius:50%}.skeleton-file-upload{height:120px;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;border:2px dashed var(--signal-forms-neutral-300);background:var(--signal-forms-neutral-100)}.skeleton-upload-icon{width:40px;height:40px;border-radius:50%}.skeleton-upload-text{height:16px;width:160px}.skeleton-hint{height:14px;width:200px;opacity:.7}\n"] }]
}] });
/**
* Maps form field types to appropriate skeleton UI
*/
class FormFieldSkeletonMapperComponent {
/**
* The form field type to create skeleton for
*/
fieldType = input.required();
/**
* The field name for accessibility
*/
fieldName = input('field');
/**
* Computed skeleton type based on field type
*/
skeletonType = computed(() => {
const type = this.fieldType();
switch (type) {
case FormFieldType.TEXT:
case FormFieldType.PASSWORD:
case FormFieldType.NUMBER:
case FormFieldType.COLOR:
case FormFieldType.DATETIME:
return 'input';
case FormFieldType.TEXTAREA:
return 'textarea';
case FormFieldType.SELECT:
case FormFieldType.AUTOCOMPLETE:
return 'select';
case FormFieldType.CHECKBOX:
return 'checkbox';
case FormFieldType.CHECKBOX_GROUP:
case FormFieldType.RADIO:
return 'radio-group';
case FormFieldType.MULTISELECT:
case FormFieldType.CHIPLIST:
return 'multiselect';
case FormFieldType.RATING:
return 'rating';
case FormFieldType.SLIDER:
return 'slider';
case FormFieldType.FILE:
return 'file';
case FormFieldType.SWITCH:
return 'checkbox';
default:
return 'input';
}
});
/**
* Whether this is a checkbox-style field
*/
isCheckboxType = computed(() => {
const type = this.fieldType();
return type === FormFieldType.CHECKBOX || type === FormFieldType.SWITCH;
});
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: FormFieldSkeletonMapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.2.14", type: FormFieldSkeletonMapperComponent, isStandalone: true, selector: "form-field-skeleton-mapper", inputs: { fieldType: { classPropertyName: "fieldType", publicName: "fieldType", isSignal: true, isRequired: true, transformFunction: null }, fieldName: { classPropertyName: "fieldName", publicName: "fieldName", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
<form-field-skeleton
[name]="fieldName()"
[skeletonType]="skeletonType()"
[isCheckbox]="isCheckboxType()"
/>
`, isInline: true, dependencies: [{ kind: "component", type: FormFieldSkeletonComponent, selector: "form-field-skeleton", inputs: ["name", "skeletonType", "isCheckbox"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: FormFieldSkeletonMapperComponent, decorators: [{
type: Component,
args: [{
selector: 'form-field-skeleton-mapper',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormFieldSkeletonComponent],
template: `
<form-field-skeleton
[name]="fieldName()"
[skeletonType]="skeletonType()"
[isCheckbox]="isCheckboxType()"
/>
`,
}]
}] });
/**
* Signal Form Input Item Component
*
* Renders individual form fields with support for custom styling, validation,
* and various field types. This component handles the wrapper, label, input,
* error messages, and hint text for each field.
*
* Features:
* - Custom styling via modifierClass, stylesFn, and inlineStylesFn
* - Dynamic validation state styling
* - Part-specific styling (wrapper, label, input, error, hint)
* - Reactive computed values
* - Focus management and error highlighting
* - ViewEncapsulation.None for external styling support
*
* @template TModel - The type of the form model this field belongs to
*/
class SignalFormInputItemComponent {
renderer;
host;
injector;
/** The field configuration and state for this input item */
field = input.required();
/** The parent form container that this field belongs to */
form = input.required();
/** Optional index for repeatable fields (used in field naming) */
index = input();
/** Form field type constants for template use */
FormFieldType = FormFieldType;
/** Tracks whether the component has been initialized with computed values */
initialized = signal(false);
/** Service for handling field validation */
validationService = inject(ValidationService);
/**
* Computed name for the field, including index if applicable
* Used for HTML id, for attributes, and accessibility
*/
name = computed(() => {
if (typeof this.index() === 'number') {
return `${String(this.field().name)}-${this.index()}`;
}
return `${String(this.field().name)}`;
});
/**
* Grid area binding for CSS Grid layouts
* Uses the field name as the grid area identifier
*/
get gridArea() {
return this.field()?.name?.toString() ?? null;
}
/**
* Host class binding that applies wrapper-level styling
* Combines static modifier classes and dynamic styling functions
* as well as theme based classes too
*/
get hostClasses() {
return this.computeWrapperClasses();
}
/**
* Host style binding that applies wrapper-level inline styles
* Uses the inlineStylesFn to generate dynamic styles
*/
get hostStyles() {
return this.computeWrapperStyles();
}
/**
* Computes CSS classes for the field wrapper element
* Handles static modifierClass and dynamic stylesFn results
*
* @returns Space-separated string of CSS classes
* @private
*/
computeWrapperClasses() {
const classes = [];
const field = this.field();
const styling = field.styling;
if (!styling) {
return '';
}
if (styling.modifierClass) {
classes.push(...styling.modifierClass);
}
if (!styling.stylesFn) {
return classes.join(' ');
}
const result = styling.stylesFn(field, this.form());
if (typeof result === 'string') {
classes.push(result);
return classes.join(' ');
}
if (Array.isArray(result)) {
classes.push(...result);
return classes.join(' ');
}
if (result && typeof result === 'object') {
const wrapperClasses = result.wrapper;
if (typeof wrapperClasses === 'string') {
classes.push(wrapperClasses);
}
else if (Array.isArray(wrapperClasses)) {
classes.push(...wrapperClasses);
}
}
return classes.join(' ');
}
/**
* Computes inline styles for the field wrapper element
*
* @returns Object containing CSS property-value pairs
* @private
*/
computeWrapperStyles() {
const field = this.field();
const styling = field.styling;
if (!styling?.inlineStylesFn) {
return {};
}
const result = styling.inlineStylesFn(field, this.form());
return result.wrapper || {};
}
/**
* Extracts CSS classes for specific field parts (label, input, error, hint)
*
* @param part - The field part to get classes for
* @returns Space-separated string of CSS classes for the specified part
* @private
*/
getPartClasses(part) {
const styling = this.field().styling;
if (!styling?.stylesFn) {
return '';
}
const result = styling.stylesFn(this.field(), this.form());
if (!result || typeof result !== 'object' || Array.isArray(result)) {
return '';
}
const partClasses = result[part];
if (typeof partClasses === 'string') {
return partClasses;
}
if (Array.isArray(partClasses)) {
return partClasses.join(' ');
}
return '';
}
/**
* Extracts inline styles for specific field parts (label, input, error, hint)
*
* @param part - The field part to get styles for
* @returns Object containing CSS property-value pairs for the specified part
* @private
*/
getPartStyles(part) {
const styling = this.field().styling;
if (!styling?.inlineStylesFn) {
return {};
}
const result = styling.inlineStylesFn(this.field(), this.form());
return result[part] || {};
}
/** Computed CSS classes for the field label */
labelClasses = computed(() => this.getPartClasses('label'));
/** Computed CSS classes for the field input wrapper */
inputClasses = computed(() => this.getPartClasses('input'));
/** Computed CSS classes for the field error message */
errorClasses = computed(() => this.getPartClasses('error'));
/** Computed CSS classes for the field hint text */
hintClasses = computed(() => this.getPartClasses('hint'));
/** Computed inline styles for the field label */
labelStyles = computed(() => this.getPartStyles('label'));
/** Computed inline styles for the field input wrapper */
inputStyles = computed(() => this.getPartStyles('input'));
/** Computed inline styles for the field error message */
errorStyles = computed(() => this.getPartStyles('error'));
/** Computed inline styles for the field hint text */
hintStyles = computed(() => this.getPartStyles('hint'));
/**
* Component constructor
* Initializes all reactive effects for computed values, validation, and focus management
*
* @param renderer - Angular Renderer2 for DOM manipulation
* @param host - Reference to the component's host element
* @param injector - Angular injector for effect contexts
*/
constructor(renderer, host, injector) {
this.renderer = renderer;
this.host = host;
this.injector = injector;
this.initializeComputedValueEffect();
this.initializeFormOptionsEffect();
this.watchComputedValueEffect();
this.setupValidation();
this.focusEffect();
}
/**
* Determines if the field is required based on its validators
*
* @returns True if the field has required validators
* @protected
*/
isRequired() {
return isRequired({ validators: this.field().validators });
}
/**
* Computed property that returns the combined error message for the field
* Combines synchronous and asynchronous validation errors
*/
hasError = computed(() => {
const field = this.field();
return this.validationService.getCombinedError(field);
});
/**
* Computed property that determines if the field is currently validating
* Checks for async validation in progress
*/
isValidating = computed(() => {
const field = this.field();
// Check if field has validating property (not all field types have async validation)
if ('validating' in field &&
typeof field.validating === 'function') {
return field.validating();
}
// For fields without async validation, they're never validating
return false;
});
/**
* Placeholder for form options initialization
* Reserved for future functionality
*
* @private
*/
initializeFormOptionsEffect() { }
/**
* Effect that initializes computed field values on component setup
* Runs once when the component first loads if the field has a computedValue function
*
* @private
*/
initializeComputedValueEffect() {
effect(() => {
const field = this.field();
if (!field.computedValue || this.initialized())
return;
const initialValue = field.computedValue(this.form());
this.setValue(initialValue, false);
this.initialized.set(true);
}, { injector: this.injector });
}
/**
* Effect that watches for changes to computed field values
* Updates the field value when the computed value function result changes
*
* @private
*/
watchComputedValueEffect() {
effect(() => {
const field = this.field();
if (!field.computedValue || !this.initialized())
return;
const newValue = field.computedValue(this.form());
this.setValue(newValue, false);
}, { injector: this.injector });
}
/**
* Effect that sets up validation for the field
* Configures validation triggers, debouncing, and async validation
*
* @private
*/
setupValidation() {
effect(() => {
const field = this.field();
const form = this.form();
// Set up validation for this field
this.validationService.setupFieldValidation(field, form);
}, { injector: this.injector });
}
/**
* Effect that handles field focus behavior
* Scrolls to and highlights fields when focus is requested,
* typically when navigating to validation errors
*
* @private
*/
focusEffect() {
effect(() => {
const field = this.field();
if (!field.focus()) {
return;
}
const el = this.host.nativeElement;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
const input = el.querySelector('input');
input?.focus?.();
this.renderer.addClass(el, 'form-error-highlight');
setTimeout(() => {
this.renderer.removeClass(el, 'form-error-highlight');
this.field().focus.set(false);
}, 1000);
}, { injector: this.injector });
}
/**
* Computed property that determines if the field should be hidden
* Supports both boolean and function-based hidden conditions
*/
isHidden = computed(() => {
const { hidden } = this.field();
return typeof hidden === 'function' ? hidden(this.form()) : !!hidden;
});
/**
* Computed property that determines if the field should be disabled
* Supports both boolean and function-based disabled conditions
*/
isDisabled = computed(() => {
const { disabled } = this.field();
return typeof disabled === 'function' ? disabled(this.form()) : !!disabled;
});
/**
* Sets the field value with proper type handling
* Handles different value types (string, number, boolean, Date, object)
* and updates the field's touched and dirty states
*
* @param value - The value to set on the field
* @param markTouched - Whether to mark the field as touched and dirty (default: true)
* @private
*/
setValue(value, markTouched = true) {
if (markTouched) {
this.field().touched.set(true);
this.field().dirty.set(true);
}
if (typeof value === 'number') {
this.field().value.set(value);
return;
}
if (typeof value === 'boolean') {
this.field().value.set(value);
return;
}
if (value instanceof Date) {
this.field().value.set(value);
return;
}
if (typeof value === 'object' && !!value) {
this.field().value.set(value);
return;
}
const val = value;
this.field().value.set(val);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormInputItemComponent, deps: [{ token: i0.Renderer2 }, { token: i0.ElementRef }, { token: i0.Injector }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormInputItemComponent, isStandalone: true, selector: "signal-form-input-item", inputs: { field: { classPropertyName: "field", publicName: "field", isSignal: true, isRequired: true, transformFunction: null }, form: { classPropertyName: "form", publicName: "form", isSignal: true, isRequired: true, transformFunction: null }, index: { classPropertyName: "index", publicName: "index", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style.gridArea": "this.gridArea", "class": "this.hostClasses", "style": "this.hostStyles" } }, hostDirectives: [{ directive: SignalFormHostDirective }], ngImport: i0, template: "@if (!isHidden()) {\n <div\n class=\"form-control\"\n [class.has-error]=\"hasError()\"\n [class.is-validating]=\"isValidating()\"\n [class.is-success]=\"!hasError() && !isValidating() && field().value()\"\n [attr.data-form-field]=\"name()\"\n #formControl\n >\n @defer (on viewport(formControl)) {\n @if (field().type !== FormFieldType.CHECKBOX) {\n <label\n class=\"form-label\"\n [class]=\"labelClasses()\"\n [ngStyle]=\"labelStyles()\"\n [for]=\"name()\"\n >\n {{ field().label }}\n @if (isRequired()) {\n <span class=\"required-asterisk\">*</span>\n }\n @if (isValidating()) {\n <span class=\"validation-spinner\">\u27F3</span>\n }\n </label>\n }\n\n <div\n class=\"form-field-wrapper\"\n [class]=\"inputClasses()\"\n [ngStyle]=\"inputStyles()\"\n >\n @switch (field().type) {\n @case (FormFieldType.TEXT) {\n <signal-form-text-field [field]=\"field()\" />\n }\n @case (FormFieldType.PASSWORD) {\n <signal-form-password-field [field]=\"field()\" />\n }\n @case (FormFieldType.CHECKBOX) {\n <signal-form-checkbox-field [field]=\"field()\" />\n }\n @case (FormFieldType.CHECKBOX_GROUP) {\n <signal-form-checkbox-group-field [field]=\"field()\" />\n }\n @case (FormFieldType.NUMBER) {\n <signal-form-number-field [field]=\"field()\" />\n }\n @case (FormFieldType.AUTOCOMPLETE) {\n <signal-form-autocomplete-field [field]=\"field()\" />\n }\n @case (FormFieldType.TEXTAREA) {\n <signal-form-textarea-field [field]=\"field()\" />\n }\n @case (FormFieldType.SELECT) {\n <signal-form-select-field [field]=\"field()\" />\n }\n @case (FormFieldType.RADIO) {\n <signal-form-radio-field [field]=\"field()\" />\n }\n @case (FormFieldType.DATETIME) {\n <signal-form-datetime-field [field]=\"field()\" />\n }\n @case (FormFieldType.MULTISELECT) {\n <signal-form-multiselect-field [field]=\"field()\" />\n }\n @case (FormFieldType.CHIPLIST) {\n <signal-form-chip-list-field [field]=\"field()\" />\n }\n @case (FormFieldType.FILE) {\n <signal-form-file-field [field]=\"field()\" />\n }\n @case (FormFieldType.COLOR) {\n <signal-form-color-field [field]=\"field()\" />\n }\n @case (FormFieldType.RATING) {\n <signal-form-rating-field [field]=\"field()\" />\n }\n @case (FormFieldType.SLIDER) {\n <signal-form-slider-field [field]=\"field()\" />\n }\n @case (FormFieldType.SWITCH) {\n <signal-form-switch-field [field]=\"field()\" />\n }\n }\n </div>\n\n <div class=\"form-caption\">\n @if (hasError()) {\n <span\n class=\"form-error\"\n [class]=\"errorClasses()\"\n [ngStyle]=\"errorStyles()\"\n role=\"alert\"\n [id]=\"'error-' + name()\"\n >\n {{ hasError() }}\n </span>\n } @else if (isValidating()) {\n <span\n class=\"form-validating\"\n role=\"status\"\n [id]=\"'validating-' + name()\"\n >\n Validating...\n </span>\n } @else if (field().config?.hint) {\n <span\n class=\"form-hint\"\n [class]=\"hintClasses()\"\n [ngStyle]=\"hintStyles()\"\n role=\"alert\"\n [id]=\"'hint-' + name()\"\n >\n {{ field().config.hint }}\n </span>\n }\n </div>\n } @loading (minimum 100ms) {\n <form-field-skeleton-mapper\n [fieldType]=\"field().type\"\n [fieldName]=\"name()\"\n />\n } @placeholder {\n <form-field-skeleton-mapper\n [fieldType]=\"field().type\"\n [fieldName]=\"name()\"\n />\n }\n </div>\n}\n", styles: ["signal-form-input-item .form-control.is-validating .form-label{opacity:var(--signal-form-validating-opacity)}signal-form-input-item .form-control.is-validating input,signal-form-input-item .form-control.is-validating textarea,signal-form-input-item .form-control.is-validating select{border-color:var(--signal-form-validating-color);background-color:var(--signal-form-validating-bg)}signal-form-input-item .form-control.premium-field{border:var(--signal-form-premium-border-width) solid var(--signal-form-premium-color);background:var(--signal-form-premium-bg);border-radius:var(--signal-form-premium-radius)}signal-form-input-item .form-control.compact-field .form-field-wrapper{gap:var(--signal-form-field-wrapper-gap-minimal)}signal-form-input-item .form-control.compact-field .form-label{font-size:var(--signal-form-label-subtle-size);margin-bottom:var(--signal-form-field-wrapper-gap-minimal)}signal-form-input-item .form-control.highlighted-field{box-shadow:var(--signal-form-highlight-shadow);border-color:var(--signal-form-highlight-border)}signal-form-input-item .form-field-wrapper{display:flex;align-items:stretch;width:100%}signal-form-input-item .form-field-wrapper.compact{gap:var(--signal-form-field-wrapper-gap-compact)}signal-form-input-item .form-field-wrapper.spaced{gap:var(--signal-form-field-wrapper-gap-spaced)}signal-form-input-item .form-field-wrapper.fullwidth{width:100%}signal-form-input-item .form-field-wrapper>*{flex:1}signal-form-input-item .form-field-wrapper.custom-input-wrapper{background:var(--input-wrapper-bg);border:var(--input-wrapper-border);border-radius:var(--input-wrapper-radius);padding:var(--input-wrapper-padding)}signal-form-input-item .form-field-wrapper:has(>form-dropdown-overlay){position:relative}signal-form-input-item .form-label.label-prominent{font-weight:var(--signal-form-label-prominent-weight);color:var(--signal-form-label-prominent-color);font-size:var(--signal-form-label-prominent-size)}signal-form-input-item .form-label.label-subtle{font-weight:var(--signal-form-label-subtle-weight);color:var(--signal-form-label-subtle-color);font-size:var(--signal-form-label-subtle-size)}signal-form-input-item .form-error.error-prominent{font-weight:var(--signal-form-error-prominent-weight);color:var(--signal-form-error-prominent-color);padding:var(--signal-form-error-prominent-padding);background:var(--signal-form-error-prominent-bg);border-radius:var(--signal-form-border-radius-sm)}signal-form-input-item .form-error.error-subtle{font-weight:var(--signal-form-error-subtle-weight);color:var(--signal-form-error-subtle-color);font-size:var(--signal-form-error-subtle-size)}signal-form-input-item .form-hint.hint-prominent{font-weight:var(--signal-form-hint-prominent-weight);color:var(--signal-form-hint-prominent-color)}signal-form-input-item .form-hint.hint-subtle{font-weight:var(--signal-form-hint-subtle-weight);color:var(--signal-form-hint-subtle-color);font-size:var(--signal-form-hint-subtle-size)}signal-form-input-item .validation-spinner{display:inline-block;margin-left:var(--signal-form-spinner-margin);animation:spin 1s linear infinite;color:var(--signal-form-validating-color)}signal-form-input-item .form-validating{color:var(--signal-form-validating-color);font-size:var(--signal-form-validating-font-size);font-style:italic}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: FormFieldSkeletonMapperComponent, selector: "form-field-skeleton-mapper", inputs: ["fieldType", "fieldName"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None, deferBlockDependencies: [() => [i2.NgStyle, Promise.resolve().then(function () { return index; }).then(m => m.SignalFormAutocompleteFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormTextFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormPasswordFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormTextareaFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormNumberFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormSelectFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormRadioFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormCheckboxFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormCheckboxGroupFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormDatetimeFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormColorFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormSwitchFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormSliderFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormFileFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormRatingFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormMultiselectFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormChipListFieldComponent)]] });
}
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "19.2.14", ngImport: i0, type: SignalFormInputItemComponent, resolveDeferredDeps: () => [Promise.resolve().then(function () { return index; }).then(m => m.SignalFormAutocompleteFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormTextFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormPasswordFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormTextareaFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormNumberFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormSelectFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormRadioFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormCheckboxFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormCheckboxGroupFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormDatetimeFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormColorFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormSwitchFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormSliderFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormFileFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormRatingFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormMultiselectFieldComponent), Promise.resolve().then(function () { return index; }).then(m => m.SignalFormChipListFieldComponent)], resolveMetadata: (SignalFormAutocompleteFieldComponent, SignalFormTextFieldComponent, SignalFormPasswordFieldComponent, SignalFormTextareaFieldComponent, SignalFormNumberFieldComponent, SignalFormSelectFieldComponent, SignalFormRadioFieldComponent, SignalFormCheckboxFieldComponent, SignalFormCheckboxGroupFieldComponent, SignalFormDatetimeFieldComponent, SignalFormColorFieldComponent, SignalFormSwitchFieldComponent, SignalFormSliderFieldComponent, SignalFormFileFieldComponent, SignalFormRatingFieldComponent, SignalFormMultiselectFieldComponent, SignalFormChipListFieldComponent) => ({ decorators: [{
type: Component,
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, selector: 'signal-form-input-item', imports: [
CommonModule,
SignalFormAutocompleteFieldComponent,
SignalFormTextFieldComponent,
SignalFormPasswordFieldComponent,
SignalFormTextareaFieldComponent,
SignalFormNumberFieldComponent,
SignalFormSelectFieldComponent,
SignalFormRadioFieldComponent,
SignalFormCheckboxFieldComponent,
SignalFormCheckboxGroupFieldComponent,
SignalFormDatetimeFieldComponent,
SignalFormColorFieldComponent,
SignalFormSwitchFieldComponent,
SignalFormSliderFieldComponent,
SignalFormFileFieldComponent,
SignalFormRatingFieldComponent,
SignalFormMultiselectFieldComponent,
SignalFormChipListFieldComponent,
FormFieldSkeletonMapperComponent,
], hostDirectives: [SignalFormHostDirective], encapsulation: ViewEncapsulation.None, template: "@if (!isHidden()) {\n <div\n class=\"form-control\"\n [class.has-error]=\"hasError()\"\n [class.is-validating]=\"isValidating()\"\n [class.is-success]=\"!hasError() && !isValidating() && field().value()\"\n [attr.data-form-field]=\"name()\"\n #formControl\n >\n @defer (on viewport(formControl)) {\n @if (field().type !== FormFieldType.CHECKBOX) {\n <label\n class=\"form-label\"\n [class]=\"labelClasses()\"\n [ngStyle]=\"labelStyles()\"\n [for]=\"name()\"\n >\n {{ field().label }}\n @if (isRequired()) {\n <span class=\"required-asterisk\">*</span>\n }\n @if (isValidating()) {\n <span class=\"validation-spinner\">\u27F3</span>\n }\n </label>\n }\n\n <div\n class=\"form-field-wrapper\"\n [class]=\"inputClasses()\"\n [ngStyle]=\"inputStyles()\"\n >\n @switch (field().type) {\n @case (FormFieldType.TEXT) {\n <signal-form-text-field [field]=\"field()\" />\n }\n @case (FormFieldType.PASSWORD) {\n <signal-form-password-field [field]=\"field()\" />\n }\n @case (FormFieldType.CHECKBOX) {\n <signal-form-checkbox-field [field]=\"field()\" />\n }\n @case (FormFieldType.CHECKBOX_GROUP) {\n <signal-form-checkbox-group-field [field]=\"field()\" />\n }\n @case (FormFieldType.NUMBER) {\n <signal-form-number-field [field]=\"field()\" />\n }\n @case (FormFieldType.AUTOCOMPLETE) {\n <signal-form-autocomplete-field [field]=\"field()\" />\n }\n @case (FormFieldType.TEXTAREA) {\n <signal-form-textarea-field [field]=\"field()\" />\n }\n @case (FormFieldType.SELECT) {\n <signal-form-select-field [field]=\"field()\" />\n }\n @case (FormFieldType.RADIO) {\n <signal-form-radio-field [field]=\"field()\" />\n }\n @case (FormFieldType.DATETIME) {\n <signal-form-datetime-field [field]=\"field()\" />\n }\n @case (FormFieldType.MULTISELECT) {\n <signal-form-multiselect-field [field]=\"field()\" />\n }\n @case (FormFieldType.CHIPLIST) {\n <signal-form-chip-list-field [field]=\"field()\" />\n }\n @case (FormFieldType.FILE) {\n <signal-form-file-field [field]=\"field()\" />\n }\n @case (FormFieldType.COLOR) {\n <signal-form-color-field [field]=\"field()\" />\n }\n @case (FormFieldType.RATING) {\n <signal-form-rating-field [field]=\"field()\" />\n }\n @case (FormFieldType.SLIDER) {\n <signal-form-slider-field [field]=\"field()\" />\n }\n @case (FormFieldType.SWITCH) {\n <signal-form-switch-field [field]=\"field()\" />\n }\n }\n </div>\n\n <div class=\"form-caption\">\n @if (hasError()) {\n <span\n class=\"form-error\"\n [class]=\"errorClasses()\"\n [ngStyle]=\"errorStyles()\"\n role=\"alert\"\n [id]=\"'error-' + name()\"\n >\n {{ hasError() }}\n </span>\n } @else if (isValidating()) {\n <span\n class=\"form-validating\"\n role=\"status\"\n [id]=\"'validating-' + name()\"\n >\n Validating...\n </span>\n } @else if (field().config?.hint) {\n <span\n class=\"form-hint\"\n [class]=\"hintClasses()\"\n [ngStyle]=\"hintStyles()\"\n role=\"alert\"\n [id]=\"'hint-' + name()\"\n >\n {{ field().config.hint }}\n </span>\n }\n </div>\n } @loading (minimum 100ms) {\n <form-field-skeleton-mapper\n [fieldType]=\"field().type\"\n [fieldName]=\"name()\"\n />\n } @placeholder {\n <form-field-skeleton-mapper\n [fieldType]=\"field().type\"\n [fieldName]=\"name()\"\n />\n }\n </div>\n}\n", styles: ["signal-form-input-item .form-control.is-validating .form-label{opacity:var(--signal-form-validating-opacity)}signal-form-input-item .form-control.is-validating input,signal-form-input-item .form-control.is-validating textarea,signal-form-input-item .form-control.is-validating select{border-color:var(--signal-form-validating-color);background-color:var(--signal-form-validating-bg)}signal-form-input-item .form-control.premium-field{border:var(--signal-form-premium-border-width) solid var(--signal-form-premium-color);background:var(--signal-form-premium-bg);border-radius:var(--signal-form-premium-radius)}signal-form-input-item .form-control.compact-field .form-field-wrapper{gap:var(--signal-form-field-wrapper-gap-minimal)}signal-form-input-item .form-control.compact-field .form-label{font-size:var(--signal-form-label-subtle-size);margin-bottom:var(--signal-form-field-wrapper-gap-minimal)}signal-form-input-item .form-control.highlighted-field{box-shadow:var(--signal-form-highlight-shadow);border-color:var(--signal-form-highlight-border)}signal-form-input-item .form-field-wrapper{display:flex;align-items:stretch;width:100%}signal-form-input-item .form-field-wrapper.compact{gap:var(--signal-form-field-wrapper-gap-compact)}signal-form-input-item .form-field-wrapper.spaced{gap:var(--signal-form-field-wrapper-gap-spaced)}signal-form-input-item .form-field-wrapper.fullwidth{width:100%}signal-form-input-item .form-field-wrapper>*{flex:1}signal-form-input-item .form-field-wrapper.custom-input-wrapper{background:var(--input-wrapper-bg);border:var(--input-wrapper-border);border-radius:var(--input-wrapper-radius);padding:var(--input-wrapper-padding)}signal-form-input-item .form-field-wrapper:has(>form-dropdown-overlay){position:relative}signal-form-input-item .form-label.label-prominent{font-weight:var(--signal-form-label-prominent-weight);color:var(--signal-form-label-prominent-color);font-size:var(--signal-form-label-prominent-size)}signal-form-input-item .form-label.label-subtle{font-weight:var(--signal-form-label-subtle-weight);color:var(--signal-form-label-subtle-color);font-size:var(--signal-form-label-subtle-size)}signal-form-input-item .form-error.error-prominent{font-weight:var(--signal-form-error-prominent-weight);color:var(--signal-form-error-prominent-color);padding:var(--signal-form-error-prominent-padding);background:var(--signal-form-error-prominent-bg);border-radius:var(--signal-form-border-radius-sm)}signal-form-input-item .form-error.error-subtle{font-weight:var(--signal-form-error-subtle-weight);color:var(--signal-form-error-subtle-color);font-size:var(--signal-form-error-subtle-size)}signal-form-input-item .form-hint.hint-prominent{font-weight:var(--signal-form-hint-prominent-weight);color:var(--signal-form-hint-prominent-color)}signal-form-input-item .form-hint.hint-subtle{font-weight:var(--signal-form-hint-subtle-weight);color:var(--signal-form-hint-subtle-color);font-size:var(--signal-form-hint-subtle-size)}signal-form-input-item .validation-spinner{display:inline-block;margin-left:var(--signal-form-spinner-margin);animation:spin 1s linear infinite;color:var(--signal-form-validating-color)}signal-form-input-item .form-validating{color:var(--signal-form-validating-color);font-size:var(--signal-form-validating-font-size);font-style:italic}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}\n"] }]
}], ctorParameters: () => [{ type: i0.Renderer2 }, { type: i0.ElementRef }, { type: i0.Injector }], propDecorators: { gridArea: [{
type: HostBinding,
args: ['style.gridArea']
}], hostClasses: [{
type: HostBinding,
args: ['class']
}], hostStyles: [{
type: HostBinding,
args: ['style']
}] } }) });
class SignalFormRepeatableFieldComponent {
repeatableForms = input.required();
heading = input('');
fields = input.required();
addItem = output();
removeItem = output();
formsList = computed(() => this.repeatableForms());
plus = PlusCircleIcon;
minus = MinusCircleIcon;
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormRepeatableFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormRepeatableFieldComponent, isStandalone: true, selector: "signal-form-repeatable-field", inputs: { repeatableForms: { classPropertyName: "repeatableForms", publicName: "repeatableForms", isSignal: true, isRequired: true, transformFunction: null }, heading: { classPropertyName: "heading", publicName: "heading", isSignal: true, isRequired: false, transformFunction: null }, fields: { classPropertyName: "fields", publicName: "fields", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { addItem: "addItem", removeItem: "removeItem" }, ngImport: i0, template: "<section class=\"repeatable-form-group\">\n <h3 class=\"form-group-heading\">{{ heading() }}</h3>\n\n @for (groupForm of repeatableForms(); track $index; let i = $index) {\n <section class=\"repeatable-forms-row-container\">\n <section class=\"repeatable-forms-row\">\n @for (field of groupForm.fields; track $index; let j = $index) {\n <signal-form-input-item\n [field]=\"field\"\n [form]=\"groupForm\"\n [index]=\"i\"\n />\n }\n </section>\n <aside class=\"repeatable-forms-actions-container\">\n <lucide-icon\n class=\"row-action\"\n [img]=\"minus\"\n [size]=\"16\"\n (click)=\"removeItem.emit(i)\"\n (keydown.enter)=\"removeItem.emit(i)\"\n tabindex=\"0\"\n />\n <lucide-icon\n class=\"row-action\"\n [img]=\"plus\"\n [size]=\"16\"\n (click)=\"addItem.emit()\"\n (keydown.enter)=\"addItem.emit()\"\n tabindex=\"0\"\n />\n </aside>\n </section>\n } @empty {\n <lucide-icon\n class=\"row-action\"\n [img]=\"plus\"\n [size]=\"16\"\n (click)=\"addItem.emit()\"\n (keydown.enter)=\"addItem.emit()\"\n tabindex=\"0\"\n />\n }\n</section>\n", styles: [".repeatable-form-group{padding-bottom:1rem;border-top:1px solid #e2efef;border-bottom:1px solid #e2efef}.repeatable-form-group:has(+.repeatable-form-group){border-bottom:none}.form-group-heading{margin:0;text-transform:capitalize;color:var(--signal-form-heading-color);font-size:var(--signal-form-heading-font-size);line-height:var(--signal-form-heading-line-height)}.repeatable-forms-row-container{display:flex;gap:8px}.repeatable-forms-row{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-start;align-items:flex-start}.repeatable-forms-actions-container{display:flex;gap:8px;align-items:center;justify-content:center}.repeatable-forms-actions-container .row-action{color:var(--signal-form-text);cursor:pointer;margin-top:16px}.repeatable-forms-actions-container .row-action:hover{color:var(--signal-form-primary-700)}\n"], dependencies: [{ kind: "component", type: SignalFormInputItemComponent, selector: "signal-form-input-item", inputs: ["field", "form", "index"] }, { kind: "ngmodule", type: LucideAngularModule }, { kind: "component", type: i1.LucideAngularComponent, selector: "lucide-angular, lucide-icon, i-lucide, span-lucide", inputs: ["class", "name", "img", "color", "absoluteStrokeWidth", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormRepeatableFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-repeatable-field', standalone: true, imports: [SignalFormInputItemComponent, LucideAngularModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<section class=\"repeatable-form-group\">\n <h3 class=\"form-group-heading\">{{ heading() }}</h3>\n\n @for (groupForm of repeatableForms(); track $index; let i = $index) {\n <section class=\"repeatable-forms-row-container\">\n <section class=\"repeatable-forms-row\">\n @for (field of groupForm.fields; track $index; let j = $index) {\n <signal-form-input-item\n [field]=\"field\"\n [form]=\"groupForm\"\n [index]=\"i\"\n />\n }\n </section>\n <aside class=\"repeatable-forms-actions-container\">\n <lucide-icon\n class=\"row-action\"\n [img]=\"minus\"\n [size]=\"16\"\n (click)=\"removeItem.emit(i)\"\n (keydown.enter)=\"removeItem.emit(i)\"\n tabindex=\"0\"\n />\n <lucide-icon\n class=\"row-action\"\n [img]=\"plus\"\n [size]=\"16\"\n (click)=\"addItem.emit()\"\n (keydown.enter)=\"addItem.emit()\"\n tabindex=\"0\"\n />\n </aside>\n </section>\n } @empty {\n <lucide-icon\n class=\"row-action\"\n [img]=\"plus\"\n [size]=\"16\"\n (click)=\"addItem.emit()\"\n (keydown.enter)=\"addItem.emit()\"\n tabindex=\"0\"\n />\n }\n</section>\n", styles: [".repeatable-form-group{padding-bottom:1rem;border-top:1px solid #e2efef;border-bottom:1px solid #e2efef}.repeatable-form-group:has(+.repeatable-form-group){border-bottom:none}.form-group-heading{margin:0;text-transform:capitalize;color:var(--signal-form-heading-color);font-size:var(--signal-form-heading-font-size);line-height:var(--signal-form-heading-line-height)}.repeatable-forms-row-container{display:flex;gap:8px}.repeatable-forms-row{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-start;align-items:flex-start}.repeatable-forms-actions-container{display:flex;gap:8px;align-items:center;justify-content:center}.repeatable-forms-actions-container .row-action{color:var(--signal-form-text);cursor:pointer;margin-top:16px}.repeatable-forms-actions-container .row-action:hover{color:var(--signal-form-primary-700)}\n"] }]
}] });
class SignalFormSelectFieldComponent extends BaseInputDirective {
showDropdown = signal(false);
displayText = computed(() => this.displayValue());
dropdownService = inject(FormDropdownService);
host = inject(SignalFormHostDirective);
constructor() {
super();
this.dropdownOverlayEffect();
}
dropdownOverlayEffect() {
effect(() => {
if (!this.showDropdown()) {
return;
}
const reference = this.host.viewContainerRef.element.nativeElement;
const currentValue = this.field().value();
const currentOption = this.findOptionByValue(currentValue);
this.dropdownService.openDropdown({
options: this.field().options(),
reference,
viewContainerRef: this.host.viewContainerRef,
ariaListboxId: `${String(this.field().name)}-listbox`,
multiselect: false,
initialSelection: currentOption,
onSelect: (selected) => {
this.setValue(selected);
this.field().touched.set(true);
this.showDropdown.set(false);
},
onClose: () => {
this.showDropdown.set(false);
},
});
}, {
injector: this.injector,
});
}
toggleDropdown() {
this.showDropdown.update((show) => !show);
}
findOptionByValue(value) {
if (!value) {
return undefined;
}
const options = this.field().options();
if (typeof value === 'object' && 'value' in value) {
// If value is already a FormOption, find by value property
return options.find((opt) => opt.value === value.value);
}
// Otherwise find by raw value
return options.find((opt) => opt.value === value);
}
displayValue() {
const value = this.field().value();
if (!value) {
return this.field().config?.placeholder ?? '';
}
if (typeof value === 'object' && 'label' in value) {
return value.label;
}
const option = this.findOptionByValue(value);
return option?.label ?? this.field().config?.placeholder ?? '';
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormSelectFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.14", type: SignalFormSelectFieldComponent, isStandalone: true, selector: "signal-form-select-field", usesInheritance: true, hostDirectives: [{ directive: SignalFormHostDirective }], ngImport: i0, template: "<div\n class=\"form-select-wrapper\"\n role=\"combobox\"\n [signalModel]=\"field()\"\n [attr.aria-expanded]=\"showDropdown()\"\n (click)=\"toggleDropdown()\"\n (keydown.enter)=\"toggleDropdown()\"\n>\n <div class=\"form-select-display\">\n {{ displayValue() || field().config?.placeholder }}\n </div>\n <div class=\"form-select-arrow\">\u25BE</div>\n</div>\n", styles: [".form-select-wrapper{border:1px solid var(--signal-form-border-color);padding:var(--signal-form-field-padding);border-radius:var(--signal-form-border-radius-sm);cursor:pointer;display:flex;justify-content:space-between;align-items:center;min-height:40px;background:var(--signal-form-select-bg);color:var(--signal-form-input-text);transition:var(--signal-form-transition);box-shadow:var(--signal-form-shadow)}.form-select-wrapper:hover{border-color:var(--signal-forms-neutral-400)}.form-select-wrapper:focus-within{border-color:var(--signal-form-outline-focus);box-shadow:0 0 0 2px var(--signal-form-outline-focus-shadow)}.form-select-display{flex-grow:1;color:var(--signal-form-input-text)}.form-select-arrow{margin-left:8px;color:var(--signal-form-muted)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormSelectFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-select-field', standalone: true, imports: [CommonModule, SignalModelDirective], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [SignalFormHostDirective], template: "<div\n class=\"form-select-wrapper\"\n role=\"combobox\"\n [signalModel]=\"field()\"\n [attr.aria-expanded]=\"showDropdown()\"\n (click)=\"toggleDropdown()\"\n (keydown.enter)=\"toggleDropdown()\"\n>\n <div class=\"form-select-display\">\n {{ displayValue() || field().config?.placeholder }}\n </div>\n <div class=\"form-select-arrow\">\u25BE</div>\n</div>\n", styles: [".form-select-wrapper{border:1px solid var(--signal-form-border-color);padding:var(--signal-form-field-padding);border-radius:var(--signal-form-border-radius-sm);cursor:pointer;display:flex;justify-content:space-between;align-items:center;min-height:40px;background:var(--signal-form-select-bg);color:var(--signal-form-input-text);transition:var(--signal-form-transition);box-shadow:var(--signal-form-shadow)}.form-select-wrapper:hover{border-color:var(--signal-forms-neutral-400)}.form-select-wrapper:focus-within{border-color:var(--signal-form-outline-focus);box-shadow:0 0 0 2px var(--signal-form-outline-focus-shadow)}.form-select-display{flex-grow:1;color:var(--signal-form-input-text)}.form-select-arrow{margin-left:8px;color:var(--signal-form-muted)}\n"] }]
}], ctorParameters: () => [] });
class SignalFormSliderFieldComponent extends BaseInputDirective {
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormSliderFieldComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.14", type: SignalFormSliderFieldComponent, isStandalone: true, selector: "signal-form-slider-field", usesInheritance: true, ngImport: i0, template: "<div class=\"slider-wrapper\">\n <input type=\"range\" class=\"slider\" [signalModel]=\"field()\" />\n {{ field().value() }}\n</div>\n", styles: [""], dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormSliderFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-slider-field', standalone: true, imports: [SignalModelDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"slider-wrapper\">\n <input type=\"range\" class=\"slider\" [signalModel]=\"field()\" />\n {{ field().value() }}\n</div>\n" }]
}] });
class SignalFormSwitchFieldComponent extends BaseInputDirective {
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormSwitchFieldComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.14", type: SignalFormSwitchFieldComponent, isStandalone: true, selector: "signal-form-switch-field", usesInheritance: true, ngImport: i0, template: "<label class=\"switch-wrapper\">\n <input\n type=\"checkbox\"\n role=\"switch\"\n class=\"switch-input\"\n [signalModel]=\"field()\"\n />\n <span class=\"switch-slider\" aria-hidden=\"true\"></span>\n</label>\n", styles: [".switch-wrapper{position:relative;display:inline-block;width:44px;height:24px}.switch-wrapper .switch-input{opacity:0;width:0;height:0}.switch-wrapper .switch-slider{position:absolute;cursor:pointer;background-color:var(--signal-forms-neutral-300);border-radius:34px;inset:0;transition:.3s}.switch-wrapper .switch-slider:before{position:absolute;content:\"\";height:18px;width:18px;left:3px;bottom:3px;background-color:var(--signal-form-input-bg);border-radius:50%;transition:.3s}.switch-wrapper .switch-input:checked+.switch-slider{background-color:var(--signal-forms-success-500)}.switch-wrapper .switch-input:checked+.switch-slider:before{transform:translate(20px)}.switch-wrapper .switch-input:disabled+.switch-slider{background-color:var(--signal-form-disabled-bg);cursor:not-allowed}\n"], dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormSwitchFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-switch-field', standalone: true, imports: [SignalModelDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: "<label class=\"switch-wrapper\">\n <input\n type=\"checkbox\"\n role=\"switch\"\n class=\"switch-input\"\n [signalModel]=\"field()\"\n />\n <span class=\"switch-slider\" aria-hidden=\"true\"></span>\n</label>\n", styles: [".switch-wrapper{position:relative;display:inline-block;width:44px;height:24px}.switch-wrapper .switch-input{opacity:0;width:0;height:0}.switch-wrapper .switch-slider{position:absolute;cursor:pointer;background-color:var(--signal-forms-neutral-300);border-radius:34px;inset:0;transition:.3s}.switch-wrapper .switch-slider:before{position:absolute;content:\"\";height:18px;width:18px;left:3px;bottom:3px;background-color:var(--signal-form-input-bg);border-radius:50%;transition:.3s}.switch-wrapper .switch-input:checked+.switch-slider{background-color:var(--signal-forms-success-500)}.switch-wrapper .switch-input:checked+.switch-slider:before{transform:translate(20px)}.switch-wrapper .switch-input:disabled+.switch-slider{background-color:var(--signal-form-disabled-bg);cursor:not-allowed}\n"] }]
}] });
class WordCountComponent {
/** The text to count words for */
text = input('');
/** Display format for the word count */
format = input('words');
/** Whether to show compact styling */
compact = input(false);
stats = computed(() => this.wordCountService.getWordCount(this.text()));
formattedCount = computed(() => this.wordCountService.formatWordCount(this.stats(), this.format()));
wordCountService = inject(WordCountService);
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: WordCountComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.2.14", type: WordCountComponent, isStandalone: true, selector: "signal-form-word-count", inputs: { text: { classPropertyName: "text", publicName: "text", isSignal: true, isRequired: false, transformFunction: null }, format: { classPropertyName: "format", publicName: "format", isSignal: true, isRequired: false, transformFunction: null }, compact: { classPropertyName: "compact", publicName: "compact", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
<div class="word-count" [class.word-count-compact]="compact()">
<span class="word-count-text">{{ formattedCount() }}</span>
</div>
`, isInline: true, styles: [".word-count{display:flex;align-items:center;justify-content:flex-end;margin-top:.25rem}.word-count .word-count-text{font-size:.75rem;color:#6b7280;font-weight:400;-webkit-user-select:none;user-select:none}.word-count.word-count-compact{margin-top:.125rem}.word-count.word-count-compact .word-count-text{font-size:.6875rem}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: WordCountComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-word-count', standalone: true, imports: [CommonModule], template: `
<div class="word-count" [class.word-count-compact]="compact()">
<span class="word-count-text">{{ formattedCount() }}</span>
</div>
`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [".word-count{display:flex;align-items:center;justify-content:flex-end;margin-top:.25rem}.word-count .word-count-text{font-size:.75rem;color:#6b7280;font-weight:400;-webkit-user-select:none;user-select:none}.word-count.word-count-compact{margin-top:.125rem}.word-count.word-count-compact .word-count-text{font-size:.6875rem}\n"] }]
}] });
class SignalFormTextFieldComponent extends BaseInputDirective {
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormTextFieldComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormTextFieldComponent, isStandalone: true, selector: "signal-form-text-field", usesInheritance: true, ngImport: i0, template: "<div class=\"form-input-wrapper\">\n <input type=\"text\" class=\"form-input\" [signalModel]=\"field()\" />\n</div>\n\n@if (field().config?.wordCount) {\n <signal-form-word-count\n [text]=\"field().value()\"\n format=\"characters\"\n [compact]=\"true\"\n />\n}\n", dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }, { kind: "component", type: WordCountComponent, selector: "signal-form-word-count", inputs: ["text", "format", "compact"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormTextFieldComponent, decorators: [{
type: Component,
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'signal-form-text-field', standalone: true, imports: [SignalModelDirective, WordCountComponent], template: "<div class=\"form-input-wrapper\">\n <input type=\"text\" class=\"form-input\" [signalModel]=\"field()\" />\n</div>\n\n@if (field().config?.wordCount) {\n <signal-form-word-count\n [text]=\"field().value()\"\n format=\"characters\"\n [compact]=\"true\"\n />\n}\n" }]
}] });
class SignalFormTextareaFieldComponent extends BaseInputDirective {
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormTextareaFieldComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormTextareaFieldComponent, isStandalone: true, selector: "signal-form-textarea-field", usesInheritance: true, ngImport: i0, template: "<div class=\"form-input-wrapper\">\n <textarea class=\"form-input\" [signalModel]=\"field()\">{{\n field().value()\n }}</textarea>\n</div>\n\n@if (field().config?.wordCount) {\n <signal-form-word-count\n [text]=\"field().value()\"\n format=\"both\"\n [compact]=\"false\"\n />\n}\n", styles: ["textarea.form-input{min-height:80px;resize:vertical;background-color:var(--signal-form-textarea-bg)}\n"], dependencies: [{ kind: "directive", type: SignalModelDirective, selector: "[signalModel]", inputs: ["signalModel"] }, { kind: "component", type: WordCountComponent, selector: "signal-form-word-count", inputs: ["text", "format", "compact"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormTextareaFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'signal-form-textarea-field', standalone: true, imports: [SignalModelDirective, WordCountComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"form-input-wrapper\">\n <textarea class=\"form-input\" [signalModel]=\"field()\">{{\n field().value()\n }}</textarea>\n</div>\n\n@if (field().config?.wordCount) {\n <signal-form-word-count\n [text]=\"field().value()\"\n format=\"both\"\n [compact]=\"false\"\n />\n}\n", styles: ["textarea.form-input{min-height:80px;resize:vertical;background-color:var(--signal-form-textarea-bg)}\n"] }]
}] });
var index = /*#__PURE__*/Object.freeze({
__proto__: null,
SignalFormAutocompleteFieldComponent: SignalFormAutocompleteFieldComponent,
SignalFormCheckboxFieldComponent: SignalFormCheckboxFieldComponent,
SignalFormCheckboxGroupFieldComponent: SignalFormCheckboxGroupFieldComponent,
SignalFormChipListFieldComponent: SignalFormChipListFieldComponent,
SignalFormColorFieldComponent: SignalFormColorFieldComponent,
SignalFormDatetimeFieldComponent: SignalFormDatetimeFieldComponent,
SignalFormFileFieldComponent: SignalFormFileFieldComponent,
SignalFormMultiselectFieldComponent: SignalFormMultiselectFieldComponent,
SignalFormNumberFieldComponent: SignalFormNumberFieldComponent,
SignalFormPasswordFieldComponent: SignalFormPasswordFieldComponent,
SignalFormRadioFieldComponent: SignalFormRadioFieldComponent,
SignalFormRatingFieldComponent: SignalFormRatingFieldComponent,
SignalFormRepeatableFieldComponent: SignalFormRepeatableFieldComponent,
SignalFormSelectFieldComponent: SignalFormSelectFieldComponent,
SignalFormSliderFieldComponent: SignalFormSliderFieldComponent,
SignalFormSwitchFieldComponent: SignalFormSwitchFieldComponent,
SignalFormTextFieldComponent: SignalFormTextFieldComponent,
SignalFormTextareaFieldComponent: SignalFormTextareaFieldComponent
});
class FieldTraversalUtils {
static findFieldByPath(form, path) {
const segments = this.parsePath(path);
let currentField;
let currentForm = form;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (segment.type === 'field') {
currentField = currentForm.fields.find((f) => f.name === segment.name);
if (!currentField) {
return undefined;
}
if (i < segments.length - 1) {
if (this.isFieldWithForm(currentField)) {
currentForm = currentField.form;
}
else if (this.isRepeatableField(currentField)) {
continue;
}
else {
return undefined;
}
}
}
else if (segment.type === 'index') {
if (currentField && this.isRepeatableField(currentField)) {
const forms = currentField.repeatableForms();
if (Array.isArray(forms) && forms[segment.index]) {
currentForm = forms[segment.index];
}
else {
return undefined;
}
}
else {
return undefined;
}
}
}
return currentField;
}
static parsePath(path) {
const segments = [];
const parts = path.split(/[\.\[\]]+/).filter(Boolean);
let i = 0;
while (i < parts.length) {
const part = parts[i];
if (!isNaN(Number(part))) {
segments.push({ type: 'index', index: Number(part) });
}
else {
segments.push({ type: 'field', name: part });
}
i++;
}
return segments;
}
static isFieldWithForm(field) {
return 'form' in field && this.isSignalFormContainer(field.form);
}
static isRepeatableField(field) {
return ('repeatableForms' in field &&
typeof field.repeatableForms === 'function');
}
static isSignalFormContainer(value) {
return (value !== null &&
typeof value === 'object' &&
'fields' in value &&
Array.isArray(value.fields) &&
'getField' in value &&
typeof value.getField === 'function');
}
}
class SignalFormErrorSummaryComponent {
form = input.required();
fieldsEls = viewChildren('formFields');
currentIndex = signal(0);
injector = inject(Injector);
errors = computed(() => {
if ('steps' in this.form()) {
return this.form().steps.flatMap((step) => step.getErrors());
}
return this.form().getErrors();
});
currentError = computed(() => {
const all = this.errors();
return all.length ? all[this.currentIndex() % all.length] : null;
});
focusNext() {
if (!this.errors().length) {
return;
}
this.currentIndex.update((i) => (i + 1) % this.errors().length);
this.focusCurrent();
}
focusPrevious() {
const errs = this.errors();
if (!errs.length) {
return;
}
this.currentIndex.update((i) => (i - 1 + errs.length) % errs.length);
this.focusCurrent();
}
focusCurrent() {
const err = this.currentError();
if (!err) {
return;
}
const isStepped = 'steps' in this.form();
if (isStepped) {
this.focusSteppedError(err);
}
else {
this.focusNestedError(err);
}
}
focusSteppedError(err) {
const steppedForm = this.form();
// Find the step that contains a field matching the full path
const stepIndex = steppedForm.steps.findIndex((step) => FieldTraversalUtils.findFieldByPath(step, err.path));
if (stepIndex !== -1) {
steppedForm.currentStep.set(stepIndex);
effect(() => {
this.focusNestedError(err, steppedForm.steps[stepIndex]);
}, { injector: this.injector });
}
}
focusNestedError(err, form = this.form()) {
if (err.focusField) {
err.focusField();
return;
}
const field = FieldTraversalUtils.findFieldByPath(form, err.path);
if (field?.focus) {
field.focus.set(true);
}
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormErrorSummaryComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormErrorSummaryComponent, isStandalone: true, selector: "signal-form-error-summary", inputs: { form: { classPropertyName: "form", publicName: "form", isSignal: true, isRequired: true, transformFunction: null } }, viewQueries: [{ propertyName: "fieldsEls", predicate: ["formFields"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (currentError()) {\n <div class=\"form-error-summary\">\n <button type=\"button\" (click)=\"focusPrevious()\">◀</button>\n <span\n tabindex=\"0\"\n class=\"form-error-summary-message\"\n (click)=\"focusCurrent()\"\n (keydown.enter)=\"focusCurrent()\"\n >{{ currentError()?.message ?? currentError() }}</span\n >\n <button type=\"button\" (click)=\"focusNext()\">▶</button>\n </div>\n}\n", styles: [".form-error-summary{background-color:var(--signal-form-error-bg);color:var(--signal-form-error-text);padding:var(--signal-form-error-summary-padding);display:flex;align-items:center;justify-content:space-between;gap:var(--signal-form-error-summary-gap);border-radius:var(--signal-form-border-radius);font-weight:var(--signal-form-error-summary-font-weight);max-width:100%;margin-bottom:var(--signal-form-error-summary-margin-bottom)}.form-error-summary-message{cursor:pointer}.form-error-summary button{appearance:none!important;border:none;background:transparent;color:var(--signal-form-error-text);outline:none;cursor:pointer}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormErrorSummaryComponent, decorators: [{
type: Component,
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'signal-form-error-summary', standalone: true, template: "@if (currentError()) {\n <div class=\"form-error-summary\">\n <button type=\"button\" (click)=\"focusPrevious()\">◀</button>\n <span\n tabindex=\"0\"\n class=\"form-error-summary-message\"\n (click)=\"focusCurrent()\"\n (keydown.enter)=\"focusCurrent()\"\n >{{ currentError()?.message ?? currentError() }}</span\n >\n <button type=\"button\" (click)=\"focusNext()\">▶</button>\n </div>\n}\n", styles: [".form-error-summary{background-color:var(--signal-form-error-bg);color:var(--signal-form-error-text);padding:var(--signal-form-error-summary-padding);display:flex;align-items:center;justify-content:space-between;gap:var(--signal-form-error-summary-gap);border-radius:var(--signal-form-border-radius);font-weight:var(--signal-form-error-summary-font-weight);max-width:100%;margin-bottom:var(--signal-form-error-summary-margin-bottom)}.form-error-summary-message{cursor:pointer}.form-error-summary button{appearance:none!important;border:none;background:transparent;color:var(--signal-form-error-text);outline:none;cursor:pointer}\n"] }]
}] });
const expandCollapse = trigger('expandCollapse', [
state('open', style({
height: '*',
opacity: 1,
overflow: 'hidden',
})),
state('closed', style({
height: '0px',
opacity: 0,
overflow: 'hidden',
})),
transition('open <=> closed', animate('600ms ease-in')),
transition('closed <=> open', animate('600ms ease-out')),
]);
const growFadeIn = trigger('growFadeIn', [
transition(':enter', [
style({ opacity: 0, transform: 'scaleY(0.95)', height: 0 }),
animate('350ms ease-out', style({ opacity: 1, transform: 'scaleY(1)', height: '*' })),
]),
]);
const fadeInOut = trigger('fadeInOut', [
transition(':enter', [
style({ opacity: 0 }),
animate('150ms ease-out', style({ opacity: 1 })),
]),
transition(':leave', [animate('150ms ease-in', style({ opacity: 0 }))]),
]);
class CollapsableSectionComponent {
collapsedInitially = input(false);
bodyTemplate = input();
summaryTemplate = input();
bodyTemplateContext = input();
summaryTemplateContext = input();
collapsing = signal(false);
collapsed = signal(false);
chevronDown = ChevronDownCircleIcon;
chevronUp = ChevronUpCircleIcon;
constructor() {
// Initialize collapsed state from input
effect(() => {
this.collapsed.set(this.collapsedInitially());
});
}
toggle() {
if (!this.collapsed()) {
this.collapsing.set(true);
return;
}
this.collapsed.set(false);
this.collapsing.set(false);
}
shouldShow = computed(() => !this.collapsed() || this.collapsing());
onDone(event) {
if (event.toState === 'closed') {
this.collapsed.set(true);
this.collapsing.set(false);
}
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: CollapsableSectionComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: CollapsableSectionComponent, isStandalone: true, selector: "collapsable-section", inputs: { collapsedInitially: { classPropertyName: "collapsedInitially", publicName: "collapsedInitially", isSignal: true, isRequired: false, transformFunction: null }, bodyTemplate: { classPropertyName: "bodyTemplate", publicName: "bodyTemplate", isSignal: true, isRequired: false, transformFunction: null }, summaryTemplate: { classPropertyName: "summaryTemplate", publicName: "summaryTemplate", isSignal: true, isRequired: false, transformFunction: null }, bodyTemplateContext: { classPropertyName: "bodyTemplateContext", publicName: "bodyTemplateContext", isSignal: true, isRequired: false, transformFunction: null }, summaryTemplateContext: { classPropertyName: "summaryTemplateContext", publicName: "summaryTemplateContext", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<section class=\"collapsable-header\">\n <div class=\"collapsable-header-content\">\n @if (summaryTemplate?.(); as summaryTemplate) {\n <ng-container\n *ngTemplateOutlet=\"summaryTemplate; context: summaryTemplateContext?.()\"\n />\n } @else {\n <ng-content select=\"[summary]\" />\n }\n </div>\n <aside class=\"collapsable-button-container\">\n <lucide-icon\n class=\"collapsable-button\"\n [img]=\"shouldShow() ? chevronDown : chevronUp\"\n (click)=\"toggle()\"\n />\n </aside>\n</section>\n@if (shouldShow()) {\n <div\n [@expandCollapse]=\"collapsing() ? 'closed' : 'open'\"\n (@expandCollapse.done)=\"onDone($event)\"\n >\n <div @growFadeIn>\n @if (bodyTemplate?.(); as bodyTemplate) {\n <ng-container\n *ngTemplateOutlet=\"bodyTemplate; context: bodyTemplateContext?.()\"\n />\n } @else {\n <ng-content select=\"[body]\" />\n }\n </div>\n </div>\n}\n", styles: [".collapsable-header{display:flex}.collapsable-header-content{flex:1}.collapsable-header .collapsable-button-container{flex:0}.collapsable-button-container{display:flex;justify-content:center;align-items:center;padding:8px 12px}.collapsable-button{cursor:pointer;color:#828689}.collapsable-button:hover{color:#00aeff}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: LucideAngularModule }, { kind: "component", type: i1.LucideAngularComponent, selector: "lucide-angular, lucide-icon, i-lucide, span-lucide", inputs: ["class", "name", "img", "color", "absoluteStrokeWidth", "size", "strokeWidth"] }], animations: [expandCollapse, growFadeIn], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: CollapsableSectionComponent, decorators: [{
type: Component,
args: [{ selector: 'collapsable-section', standalone: true, imports: [NgTemplateOutlet, LucideAngularModule], animations: [expandCollapse, growFadeIn], changeDetection: ChangeDetectionStrategy.OnPush, template: "<section class=\"collapsable-header\">\n <div class=\"collapsable-header-content\">\n @if (summaryTemplate?.(); as summaryTemplate) {\n <ng-container\n *ngTemplateOutlet=\"summaryTemplate; context: summaryTemplateContext?.()\"\n />\n } @else {\n <ng-content select=\"[summary]\" />\n }\n </div>\n <aside class=\"collapsable-button-container\">\n <lucide-icon\n class=\"collapsable-button\"\n [img]=\"shouldShow() ? chevronDown : chevronUp\"\n (click)=\"toggle()\"\n />\n </aside>\n</section>\n@if (shouldShow()) {\n <div\n [@expandCollapse]=\"collapsing() ? 'closed' : 'open'\"\n (@expandCollapse.done)=\"onDone($event)\"\n >\n <div @growFadeIn>\n @if (bodyTemplate?.(); as bodyTemplate) {\n <ng-container\n *ngTemplateOutlet=\"bodyTemplate; context: bodyTemplateContext?.()\"\n />\n } @else {\n <ng-content select=\"[body]\" />\n }\n </div>\n </div>\n}\n", styles: [".collapsable-header{display:flex}.collapsable-header-content{flex:1}.collapsable-header .collapsable-button-container{flex:0}.collapsable-button-container{display:flex;justify-content:center;align-items:center;padding:8px 12px}.collapsable-button{cursor:pointer;color:#828689}.collapsable-button:hover{color:#00aeff}\n"] }]
}], ctorParameters: () => [] });
class SignalFormFieldsComponent {
fields = input.required();
form = input.required();
index = input(null);
isRoot = input(true);
signalFormParent = input(false);
// Required theme service injection
signalFormThemeService = inject(SignalFormThemeService);
formFieldType = FormFieldType;
visibleFields = computed(() => this.fields().filter((f) => !f.isHidden?.()));
isGridAreaConfig(config) {
if (!config) {
return false;
}
return config?.layout === 'grid-area' || 'gridArea' in config;
}
gridTemplateAreas = computed(() => {
const config = this.form().config;
if (!this.isGridAreaConfig(config)) {
return null;
}
return config?.gridArea.map((row) => `"${row.join(' ')}"`).join(' ');
});
isGridLayout() {
return this.isGridAreaConfig(this.form().config);
}
formLayoutClass = computed(() => {
switch (this.form()?.config?.view) {
case 'collapsable':
return 'form-group-collapsable';
case 'row':
return 'form-group-row';
default:
return 'form-group-stacked';
}
});
get hostClass() {
return this.formLayoutClass();
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormFieldsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormFieldsComponent, isStandalone: true, selector: "signal-form-fields", inputs: { fields: { classPropertyName: "fields", publicName: "fields", isSignal: true, isRequired: true, transformFunction: null }, form: { classPropertyName: "form", publicName: "form", isSignal: true, isRequired: true, transformFunction: null }, index: { classPropertyName: "index", publicName: "index", isSignal: true, isRequired: false, transformFunction: null }, isRoot: { classPropertyName: "isRoot", publicName: "isRoot", isSignal: true, isRequired: false, transformFunction: null }, signalFormParent: { classPropertyName: "signalFormParent", publicName: "signalFormParent", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style.grid-template-areas": "gridTemplateAreas()", "class.grid": "isGridLayout()", "class": "this.hostClass" } }, hostDirectives: [{ directive: SignalFormHostDirective }], ngImport: i0, template: "@if (isRoot() && !signalFormParent() && form().config?.view === \"collapsable\") {\n <collapsable-section class=\"collapsable-fields\">\n <ng-container summary>\n {{ form().title }}\n </ng-container>\n <ng-container body *ngTemplateOutlet=\"fieldBlockTemplate\" />\n </collapsable-section>\n} @else {\n <ng-container *ngTemplateOutlet=\"fieldBlockTemplate\" />\n}\n\n<ng-template #fieldBlockTemplate>\n @for (field of visibleFields?.(); track field.name) {\n @if (field.type === formFieldType.REPEATABLE_GROUP) {\n <signal-form-repeatable-field\n [repeatableForms]=\"field.repeatableForms()\"\n [fields]=\"field.fields\"\n [heading]=\"field.heading\"\n [style.gridArea]=\"field.name\"\n (addItem)=\"field.addItem()\"\n (removeItem)=\"field.removeItem($event)\"\n />\n } @else {\n @if (field.fields?.length) {\n <section\n class=\"nested-form-section\"\n [class]=\"'form-group-' + (field.config?.view ?? 'stacked')\"\n [style.gridArea]=\"field.name\"\n >\n @if (field.config?.view === \"collapsable\") {\n <collapsable-section\n [summaryTemplate]=\"titleTemplate\"\n [bodyTemplate]=\"fieldsTemplate\"\n [summaryTemplateContext]=\"{ $implicit: field, i: index() }\"\n [bodyTemplateContext]=\"{ $implicit: field, i: index() }\"\n />\n } @else {\n <ng-container\n *ngTemplateOutlet=\"\n titleTemplate;\n context: { $implicit: field, i: index() }\n \"\n />\n <ng-container\n *ngTemplateOutlet=\"\n fieldsTemplate;\n context: { $implicit: field, i: index() }\n \"\n />\n }\n </section>\n } @else {\n <signal-form-input-item\n [field]=\"field\"\n [form]=\"form()\"\n [index]=\"index()\"\n />\n }\n }\n }\n</ng-template>\n\n<ng-template #titleTemplate let-field>\n <div class=\"nested-form-title\">\n <h2 class=\"nested-form-heading\">{{ field.heading }}</h2>\n <h4 class=\"nested-form-subheading\">{{ field.subheading }}</h4>\n </div>\n</ng-template>\n\n<ng-template #fieldsTemplate let-field let-i=\"i\">\n <div class=\"nested-form-fields\">\n <signal-form-fields\n [form]=\"field.form\"\n [fields]=\"field.fields\"\n [index]=\"i\"\n [isRoot]=\"false\"\n />\n </div>\n</ng-template>\n", styles: [":host.form-group-row{flex-direction:row}:host.form-group-row>signal-form-input-item{flex:1 1}:host.grid{display:grid;width:100%;grid-template-columns:minmax(0,1fr)}.nested-form-section{border-top:1px solid var(--signal-form-section-border);border-bottom:1px solid var(--signal-form-section-border);display:grid;width:100%;padding:var(--signal-form-nested-padding);content-visibility:auto;contain-intrinsic-size:0 200px}.nested-form-section:has(+.nested-form-section){border-bottom:none}.nested-form-title{display:flex;flex-direction:column;gap:var(--signal-form-nested-title-gap);margin-bottom:var(--signal-form-nested-margin-bottom)}.nested-form-heading{margin:0;text-transform:capitalize;color:var(--signal-form-heading-color);font-size:var(--signal-form-heading-font-size);line-height:var(--signal-form-heading-line-height)}.nested-form-subheading{margin:0;font-weight:400;color:var(--signal-form-subheading-color);font-size:var(--signal-form-subheading-font-size);text-transform:capitalize}.nested-form-fields{display:flex;width:100%}.nested-form-fields>signal-form-fields{width:100%}.form-group-stacked{content-visibility:auto;contain-intrinsic-size:0 100px}.form-group-stacked .nested-form-title{margin-bottom:var(--signal-form-group-gap)}.form-group-stacked .nested-form-fields{display:flex;flex-direction:column;gap:var(--signal-form-group-gap-lg)}.form-group-row{display:flex;gap:var(--signal-form-group-gap-xl);content-visibility:auto;contain-intrinsic-size:0 100px}.form-group-row .nested-form-section .nested-form-title{flex:0 0 var(--signal-form-nested-title-width)}.form-group-row .nested-form-section:has(>.nested-form-fields .form-group-stacked) .nested-form-title{flex:1 1 50%}.form-group-row .nested-form-fields{display:flex;flex-wrap:wrap;flex:1;gap:var(--signal-form-group-gap) var(--signal-form-group-gap-xl)}.collapsable-fields{content-visibility:auto;contain-intrinsic-size:0 300px;width:100%}\n"], dependencies: [{ kind: "component", type: SignalFormFieldsComponent, selector: "signal-form-fields", inputs: ["fields", "form", "index", "isRoot", "signalFormParent"] }, { kind: "component", type: SignalFormInputItemComponent, selector: "signal-form-input-item", inputs: ["field", "form", "index"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: CollapsableSectionComponent, selector: "collapsable-section", inputs: ["collapsedInitially", "bodyTemplate", "summaryTemplate", "bodyTemplateContext", "summaryTemplateContext"] }, { kind: "component", type: SignalFormRepeatableFieldComponent, selector: "signal-form-repeatable-field", inputs: ["repeatableForms", "heading", "fields"], outputs: ["addItem", "removeItem"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormFieldsComponent, decorators: [{
type: Component,
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'signal-form-fields', imports: [
SignalFormInputItemComponent,
NgTemplateOutlet,
CollapsableSectionComponent,
SignalFormRepeatableFieldComponent,
], standalone: true, hostDirectives: [SignalFormHostDirective], host: {
'[style.grid-template-areas]': 'gridTemplateAreas()',
'[class.grid]': 'isGridLayout()',
}, template: "@if (isRoot() && !signalFormParent() && form().config?.view === \"collapsable\") {\n <collapsable-section class=\"collapsable-fields\">\n <ng-container summary>\n {{ form().title }}\n </ng-container>\n <ng-container body *ngTemplateOutlet=\"fieldBlockTemplate\" />\n </collapsable-section>\n} @else {\n <ng-container *ngTemplateOutlet=\"fieldBlockTemplate\" />\n}\n\n<ng-template #fieldBlockTemplate>\n @for (field of visibleFields?.(); track field.name) {\n @if (field.type === formFieldType.REPEATABLE_GROUP) {\n <signal-form-repeatable-field\n [repeatableForms]=\"field.repeatableForms()\"\n [fields]=\"field.fields\"\n [heading]=\"field.heading\"\n [style.gridArea]=\"field.name\"\n (addItem)=\"field.addItem()\"\n (removeItem)=\"field.removeItem($event)\"\n />\n } @else {\n @if (field.fields?.length) {\n <section\n class=\"nested-form-section\"\n [class]=\"'form-group-' + (field.config?.view ?? 'stacked')\"\n [style.gridArea]=\"field.name\"\n >\n @if (field.config?.view === \"collapsable\") {\n <collapsable-section\n [summaryTemplate]=\"titleTemplate\"\n [bodyTemplate]=\"fieldsTemplate\"\n [summaryTemplateContext]=\"{ $implicit: field, i: index() }\"\n [bodyTemplateContext]=\"{ $implicit: field, i: index() }\"\n />\n } @else {\n <ng-container\n *ngTemplateOutlet=\"\n titleTemplate;\n context: { $implicit: field, i: index() }\n \"\n />\n <ng-container\n *ngTemplateOutlet=\"\n fieldsTemplate;\n context: { $implicit: field, i: index() }\n \"\n />\n }\n </section>\n } @else {\n <signal-form-input-item\n [field]=\"field\"\n [form]=\"form()\"\n [index]=\"index()\"\n />\n }\n }\n }\n</ng-template>\n\n<ng-template #titleTemplate let-field>\n <div class=\"nested-form-title\">\n <h2 class=\"nested-form-heading\">{{ field.heading }}</h2>\n <h4 class=\"nested-form-subheading\">{{ field.subheading }}</h4>\n </div>\n</ng-template>\n\n<ng-template #fieldsTemplate let-field let-i=\"i\">\n <div class=\"nested-form-fields\">\n <signal-form-fields\n [form]=\"field.form\"\n [fields]=\"field.fields\"\n [index]=\"i\"\n [isRoot]=\"false\"\n />\n </div>\n</ng-template>\n", styles: [":host.form-group-row{flex-direction:row}:host.form-group-row>signal-form-input-item{flex:1 1}:host.grid{display:grid;width:100%;grid-template-columns:minmax(0,1fr)}.nested-form-section{border-top:1px solid var(--signal-form-section-border);border-bottom:1px solid var(--signal-form-section-border);display:grid;width:100%;padding:var(--signal-form-nested-padding);content-visibility:auto;contain-intrinsic-size:0 200px}.nested-form-section:has(+.nested-form-section){border-bottom:none}.nested-form-title{display:flex;flex-direction:column;gap:var(--signal-form-nested-title-gap);margin-bottom:var(--signal-form-nested-margin-bottom)}.nested-form-heading{margin:0;text-transform:capitalize;color:var(--signal-form-heading-color);font-size:var(--signal-form-heading-font-size);line-height:var(--signal-form-heading-line-height)}.nested-form-subheading{margin:0;font-weight:400;color:var(--signal-form-subheading-color);font-size:var(--signal-form-subheading-font-size);text-transform:capitalize}.nested-form-fields{display:flex;width:100%}.nested-form-fields>signal-form-fields{width:100%}.form-group-stacked{content-visibility:auto;contain-intrinsic-size:0 100px}.form-group-stacked .nested-form-title{margin-bottom:var(--signal-form-group-gap)}.form-group-stacked .nested-form-fields{display:flex;flex-direction:column;gap:var(--signal-form-group-gap-lg)}.form-group-row{display:flex;gap:var(--signal-form-group-gap-xl);content-visibility:auto;contain-intrinsic-size:0 100px}.form-group-row .nested-form-section .nested-form-title{flex:0 0 var(--signal-form-nested-title-width)}.form-group-row .nested-form-section:has(>.nested-form-fields .form-group-stacked) .nested-form-title{flex:1 1 50%}.form-group-row .nested-form-fields{display:flex;flex-wrap:wrap;flex:1;gap:var(--signal-form-group-gap) var(--signal-form-group-gap-xl)}.collapsable-fields{content-visibility:auto;contain-intrinsic-size:0 300px;width:100%}\n"] }]
}], propDecorators: { hostClass: [{
type: HostBinding,
args: ['class']
}] } });
var signalFormFields_component = /*#__PURE__*/Object.freeze({
__proto__: null,
SignalFormFieldsComponent: SignalFormFieldsComponent
});
class SignalFormSaveButtonComponent {
form = input.required();
submitButtonText = input('Save');
showReset = input(false);
onSave = output();
buttonText = computed(() => {
const status = this.form().status();
switch (status) {
case FormStatus.Submitting:
return 'Saving...';
case FormStatus.Success:
return 'Saved successfully';
default:
return this.submitButtonText();
}
});
isDisabled = computed(() => {
const status = this.form().status();
return !this.form().anyTouched() || status === FormStatus.Submitting;
});
buttonClass = computed(() => {
const status = this.form().status();
const classes = ['form-button'];
if (status === FormStatus.Submitting) {
classes.push('saving');
}
else if (status === FormStatus.Success) {
classes.push('success');
}
else if (this.form().hasSaved()) {
classes.push('saved');
}
return classes.join(' ');
});
save() {
if (!this.form().validateForm()) {
return;
}
this.form().save();
this.onSave.emit(this.form().getValue());
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormSaveButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormSaveButtonComponent, isStandalone: true, selector: "signal-form-save-button", inputs: { form: { classPropertyName: "form", publicName: "form", isSignal: true, isRequired: true, transformFunction: null }, submitButtonText: { classPropertyName: "submitButtonText", publicName: "submitButtonText", isSignal: true, isRequired: false, transformFunction: null }, showReset: { classPropertyName: "showReset", publicName: "showReset", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { onSave: "onSave" }, ngImport: i0, template: "<section class=\"form-button-row\">\n @if (showReset()) {\n <button class=\"form-button\" type=\"button\" (click)=\"form().reset()\">\n Reset\n </button>\n }\n\n <button\n type=\"submit\"\n [disabled]=\"isDisabled()\"\n [class]=\"buttonClass()\"\n (click)=\"save()\"\n >\n {{ buttonText() }}\n </button>\n</section>\n", styles: [".form-button.saving{background-color:var(--signal-form-button-primary-bg);cursor:wait;position:relative}.form-button.saving:after{content:\"\";position:absolute;left:8px;top:50%;transform:translateY(-50%);width:12px;height:12px;border:2px solid transparent;border-top:2px solid currentColor;border-radius:50%;animation:spin 1s linear infinite}.form-button.success{background-color:var(--signal-form-success-color);color:var(--signal-forms-neutral-50)}.form-button.success:hover{background-color:var(--signal-form-success-color)}.form-button.saved{opacity:.8}@keyframes spin{0%{transform:translateY(-50%) rotate(0)}to{transform:translateY(-50%) rotate(360deg)}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormSaveButtonComponent, decorators: [{
type: Component,
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'signal-form-save-button', standalone: true, template: "<section class=\"form-button-row\">\n @if (showReset()) {\n <button class=\"form-button\" type=\"button\" (click)=\"form().reset()\">\n Reset\n </button>\n }\n\n <button\n type=\"submit\"\n [disabled]=\"isDisabled()\"\n [class]=\"buttonClass()\"\n (click)=\"save()\"\n >\n {{ buttonText() }}\n </button>\n</section>\n", styles: [".form-button.saving{background-color:var(--signal-form-button-primary-bg);cursor:wait;position:relative}.form-button.saving:after{content:\"\";position:absolute;left:8px;top:50%;transform:translateY(-50%);width:12px;height:12px;border:2px solid transparent;border-top:2px solid currentColor;border-radius:50%;animation:spin 1s linear infinite}.form-button.success{background-color:var(--signal-form-success-color);color:var(--signal-forms-neutral-50)}.form-button.success:hover{background-color:var(--signal-form-success-color)}.form-button.saved{opacity:.8}@keyframes spin{0%{transform:translateY(-50%) rotate(0)}to{transform:translateY(-50%) rotate(360deg)}}\n"] }]
}] });
var StepStatus;
(function (StepStatus) {
StepStatus["Complete"] = "complete";
StepStatus["Error"] = "error";
StepStatus["InComplete"] = "incomplete";
})(StepStatus || (StepStatus = {}));
class SignalFormStepperNavComponent {
hasSaved = input();
steps = input.required();
currentStep = model.required();
doesStepHaveErrors = signal([]);
isStepComplete = signal([]);
requiredPerStepMap = signal({});
stepStatus = StepStatus;
squareCheckIcon = SquareCheck;
injector = inject(Injector);
constructor() {
this.isStepCompleteOrErrorEffect();
this.requiredPerStepMapEffect();
}
goToStep(index) {
const steps = this.steps();
const canSkip = true;
if (index <= this.currentStep() ||
canSkip ||
steps[this.currentStep()].validateForm()) {
this.currentStep.set(index);
}
}
isStepCompleteOrErrorEffect() {
effect(() => {
const validatedMap = this.steps().map((step) => step.validateForm());
this.isStepComplete.set(validatedMap);
const errorsMap = this.steps().map((step) => step.getErrors().length > 0);
this.doesStepHaveErrors.set(errorsMap);
}, { injector: this.injector });
}
requiredPerStepMapEffect() {
effect(() => {
if (this.steps()) {
this.initRequiredPerStepMap();
}
}, { injector: this.injector });
}
initRequiredPerStepMap() {
const requiredPerStepMap = this.steps().reduce((prev, step, i) => {
const requiredPerStep = step.fields.filter((field) => field.validators?.some((validator) => validator.__meta
?.required));
const stepStatus = requiredPerStep.map((step) => {
if (!!step.error()) {
return StepStatus.Error;
}
if (!!step.value()) {
return StepStatus.Complete;
}
return StepStatus.InComplete;
});
return {
...prev,
[i]: computed(() => stepStatus),
};
}, {});
this.requiredPerStepMap.set(requiredPerStepMap);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormStepperNavComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormStepperNavComponent, isStandalone: true, selector: "signal-form-stepper-nav", inputs: { hasSaved: { classPropertyName: "hasSaved", publicName: "hasSaved", isSignal: true, isRequired: false, transformFunction: null }, steps: { classPropertyName: "steps", publicName: "steps", isSignal: true, isRequired: true, transformFunction: null }, currentStep: { classPropertyName: "currentStep", publicName: "currentStep", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { currentStep: "currentStepChange" }, ngImport: i0, template: "<div class=\"stepper-nav-ui\">\n <div class=\"stepper-track\">\n @for (step of steps(); track $index) {\n <div class=\"stepper-node\">\n <button\n class=\"step-circle\"\n [class.completed]=\"isStepComplete()[$index]\"\n [class.error]=\"doesStepHaveErrors()[$index]\"\n [class.active]=\"$index === currentStep()\"\n (click)=\"currentStep.set($index)\"\n >\n @if (isStepComplete()[$index]) {\n \u2713\n } @else {\n {{ $index + 1 }}\n }\n </button>\n\n <aside class=\"step-line-container\">\n @for (stepsStatus of requiredPerStepMap?.()[$index](); track $index) {\n <div\n class=\"step-line\"\n [class.filled]=\"isStepComplete()[$index]\"\n [ngClass]=\"stepsStatus\"\n ></div>\n }\n </aside>\n\n @if ($last) {\n <lucide-icon\n [class.completed-form]=\"hasSaved()\"\n class=\"finish-form\"\n [img]=\"squareCheckIcon\"\n [size]=\"28\"\n />\n }\n </div>\n }\n </div>\n</div>\n", styles: [".stepper-nav-ui{display:flex;justify-content:center;margin-bottom:var(--signal-form-stepper-margin-bottom)}.stepper-nav-ui .stepper-track,.stepper-nav-ui .stepper-track .stepper-node{display:flex;align-items:center;gap:var(--signal-form-stepper-gap)}.stepper-nav-ui .stepper-track .stepper-node .step-circle{width:var(--signal-form-stepper-step-size);height:var(--signal-form-stepper-step-size);border-radius:var(--signal-form-stepper-step-border-radius);border:var(--signal-form-stepper-step-border-width) solid var(--signal-form-stepper-default-border);background-color:var(--signal-form-stepper-default-bg);display:flex;align-items:center;justify-content:center;font-weight:var(--signal-form-stepper-step-font-weight);transition:var(--signal-form-stepper-transition);cursor:pointer}.stepper-nav-ui .stepper-track .stepper-node .step-circle.active{border-color:var(--signal-form-stepper-active-border)!important}.stepper-nav-ui .stepper-track .stepper-node .step-circle.completed{background-color:var(--signal-form-stepper-completed-bg);color:var(--signal-form-stepper-completed-text);border-color:var(--signal-form-stepper-completed-border)}.stepper-nav-ui .stepper-track .stepper-node .step-circle.error{border-color:var(--signal-form-stepper-error-border);color:var(--signal-form-stepper-error-text)}.stepper-nav-ui .stepper-track .stepper-node .step-line{width:var(--signal-form-stepper-line-width);height:var(--signal-form-stepper-line-height);background:var(--signal-form-stepper-line-default)}.stepper-nav-ui .stepper-track .stepper-node .step-line-container{display:flex;gap:var(--signal-form-stepper-line-gap)}.stepper-nav-ui .stepper-track .stepper-node .step-line.complete{background:var(--signal-form-stepper-line-complete)}.stepper-nav-ui .stepper-track .stepper-node .step-line.error{background:var(--signal-form-stepper-line-error)}.stepper-nav-ui .stepper-track .stepper-node .finish-form{display:flex;justify-content:center;align-items:center}.stepper-nav-ui .stepper-track .stepper-node .finish-form.completed-form{color:var(--signal-form-stepper-completed-bg)}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "ngmodule", type: LucideAngularModule }, { kind: "component", type: i1.LucideAngularComponent, selector: "lucide-angular, lucide-icon, i-lucide, span-lucide", inputs: ["class", "name", "img", "color", "absoluteStrokeWidth", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormStepperNavComponent, decorators: [{
type: Component,
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgClass, LucideAngularModule], selector: 'signal-form-stepper-nav', standalone: true, template: "<div class=\"stepper-nav-ui\">\n <div class=\"stepper-track\">\n @for (step of steps(); track $index) {\n <div class=\"stepper-node\">\n <button\n class=\"step-circle\"\n [class.completed]=\"isStepComplete()[$index]\"\n [class.error]=\"doesStepHaveErrors()[$index]\"\n [class.active]=\"$index === currentStep()\"\n (click)=\"currentStep.set($index)\"\n >\n @if (isStepComplete()[$index]) {\n \u2713\n } @else {\n {{ $index + 1 }}\n }\n </button>\n\n <aside class=\"step-line-container\">\n @for (stepsStatus of requiredPerStepMap?.()[$index](); track $index) {\n <div\n class=\"step-line\"\n [class.filled]=\"isStepComplete()[$index]\"\n [ngClass]=\"stepsStatus\"\n ></div>\n }\n </aside>\n\n @if ($last) {\n <lucide-icon\n [class.completed-form]=\"hasSaved()\"\n class=\"finish-form\"\n [img]=\"squareCheckIcon\"\n [size]=\"28\"\n />\n }\n </div>\n }\n </div>\n</div>\n", styles: [".stepper-nav-ui{display:flex;justify-content:center;margin-bottom:var(--signal-form-stepper-margin-bottom)}.stepper-nav-ui .stepper-track,.stepper-nav-ui .stepper-track .stepper-node{display:flex;align-items:center;gap:var(--signal-form-stepper-gap)}.stepper-nav-ui .stepper-track .stepper-node .step-circle{width:var(--signal-form-stepper-step-size);height:var(--signal-form-stepper-step-size);border-radius:var(--signal-form-stepper-step-border-radius);border:var(--signal-form-stepper-step-border-width) solid var(--signal-form-stepper-default-border);background-color:var(--signal-form-stepper-default-bg);display:flex;align-items:center;justify-content:center;font-weight:var(--signal-form-stepper-step-font-weight);transition:var(--signal-form-stepper-transition);cursor:pointer}.stepper-nav-ui .stepper-track .stepper-node .step-circle.active{border-color:var(--signal-form-stepper-active-border)!important}.stepper-nav-ui .stepper-track .stepper-node .step-circle.completed{background-color:var(--signal-form-stepper-completed-bg);color:var(--signal-form-stepper-completed-text);border-color:var(--signal-form-stepper-completed-border)}.stepper-nav-ui .stepper-track .stepper-node .step-circle.error{border-color:var(--signal-form-stepper-error-border);color:var(--signal-form-stepper-error-text)}.stepper-nav-ui .stepper-track .stepper-node .step-line{width:var(--signal-form-stepper-line-width);height:var(--signal-form-stepper-line-height);background:var(--signal-form-stepper-line-default)}.stepper-nav-ui .stepper-track .stepper-node .step-line-container{display:flex;gap:var(--signal-form-stepper-line-gap)}.stepper-nav-ui .stepper-track .stepper-node .step-line.complete{background:var(--signal-form-stepper-line-complete)}.stepper-nav-ui .stepper-track .stepper-node .step-line.error{background:var(--signal-form-stepper-line-error)}.stepper-nav-ui .stepper-track .stepper-node .finish-form{display:flex;justify-content:center;align-items:center}.stepper-nav-ui .stepper-track .stepper-node .finish-form.completed-form{color:var(--signal-form-stepper-completed-bg)}\n"] }]
}], ctorParameters: () => [] });
class SignalFormStepperComponent {
form = input.required();
onSave = output();
afterSaveCompletes = output();
saveDisabled = computed(() => !(this.form().anyDirty() && this.form().anyTouched()));
submitButtonText = computed(() => {
const status = this.form().status();
switch (status) {
case FormStatus.Submitting:
return 'Saving...';
case FormStatus.Success:
return 'Saved successfully';
default:
return 'Submit';
}
});
submitButtonClass = computed(() => {
const status = this.form().status();
const classes = ['form-button'];
if (status === FormStatus.Submitting) {
classes.push('saving');
}
else if (status === FormStatus.Success) {
classes.push('success');
}
else if (this.form().hasSaved()) {
classes.push('saved');
}
return classes.join(' ');
});
constructor() {
effect(() => {
if (this.form().hasSaved()) {
this.afterSaveCompletes.emit();
}
});
}
next() {
if (this.form().validateStep() &&
this.form().currentStep() < this.form().steps.length - 1) {
this.form().currentStep.set(this.form().currentStep() + 1);
}
}
previous() {
this.form().currentStep.set(this.form().currentStep() - 1);
}
save() {
this.form().save();
this.onSave.emit(this.form().value());
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormStepperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: SignalFormStepperComponent, isStandalone: true, selector: "signal-form-stepper", inputs: { form: { classPropertyName: "form", publicName: "form", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { onSave: "onSave", afterSaveCompletes: "afterSaveCompletes" }, host: { properties: { "class.signal-form-container": "true" } }, ngImport: i0, template: "@if (form(); as form) {\n <form onsubmit=\"event.preventDefault();\">\n <signal-form-stepper-nav\n [steps]=\"form.steps\"\n [hasSaved]=\"form.hasSaved()\"\n [(currentStep)]=\"form.currentStep\"\n />\n\n <div class=\"stepper-body\">\n @for (step of form.steps; track $index) {\n <div [hidden]=\"$index !== form.currentStep()\">\n @defer (on viewport; prefetch on idle) {\n <signal-form-fields\n [form]=\"step\"\n [fields]=\"step.fields\"\n #formFields\n />\n } @placeholder {\n <div></div>\n }\n </div>\n }\n </div>\n\n <signal-form-error-summary [form]=\"form\" />\n\n <div class=\"stepper-actions\">\n <button\n class=\"form-button\"\n (click)=\"previous()\"\n [disabled]=\"form.currentStep() === 0\"\n >\n Previous\n </button>\n @if (form.currentStep() < form.steps.length - 1) {\n <button\n class=\"form-button\"\n (click)=\"next()\"\n [disabled]=\"!form.isValidStep()\"\n >\n Next\n </button>\n }\n @if (form.currentStep() === form.steps.length - 1) {\n <button\n [class]=\"submitButtonClass()\"\n [disabled]=\"!!form.getErrors().length\"\n (click)=\"save()\"\n >\n {{ submitButtonText() }}\n </button>\n }\n </div>\n </form>\n}\n", styles: [".stepper-actions{display:flex;gap:.5rem}.stepper-body{margin-bottom:1rem}\n"], dependencies: [{ kind: "component", type: SignalFormStepperNavComponent, selector: "signal-form-stepper-nav", inputs: ["hasSaved", "steps", "currentStep"], outputs: ["currentStepChange"] }, { kind: "component", type: SignalFormErrorSummaryComponent, selector: "signal-form-error-summary", inputs: ["form"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [Promise.resolve().then(function () { return signalFormFields_component; }).then(m => m.SignalFormFieldsComponent)]] });
}
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "19.2.14", ngImport: i0, type: SignalFormStepperComponent, resolveDeferredDeps: () => [Promise.resolve().then(function () { return signalFormFields_component; }).then(m => m.SignalFormFieldsComponent)], resolveMetadata: SignalFormFieldsComponent => ({ decorators: [{
type: Component,
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'signal-form-stepper', imports: [
SignalFormFieldsComponent,
SignalFormStepperNavComponent,
SignalFormErrorSummaryComponent,
], host: {
'[class.signal-form-container]': 'true',
}, template: "@if (form(); as form) {\n <form onsubmit=\"event.preventDefault();\">\n <signal-form-stepper-nav\n [steps]=\"form.steps\"\n [hasSaved]=\"form.hasSaved()\"\n [(currentStep)]=\"form.currentStep\"\n />\n\n <div class=\"stepper-body\">\n @for (step of form.steps; track $index) {\n <div [hidden]=\"$index !== form.currentStep()\">\n @defer (on viewport; prefetch on idle) {\n <signal-form-fields\n [form]=\"step\"\n [fields]=\"step.fields\"\n #formFields\n />\n } @placeholder {\n <div></div>\n }\n </div>\n }\n </div>\n\n <signal-form-error-summary [form]=\"form\" />\n\n <div class=\"stepper-actions\">\n <button\n class=\"form-button\"\n (click)=\"previous()\"\n [disabled]=\"form.currentStep() === 0\"\n >\n Previous\n </button>\n @if (form.currentStep() < form.steps.length - 1) {\n <button\n class=\"form-button\"\n (click)=\"next()\"\n [disabled]=\"!form.isValidStep()\"\n >\n Next\n </button>\n }\n @if (form.currentStep() === form.steps.length - 1) {\n <button\n [class]=\"submitButtonClass()\"\n [disabled]=\"!!form.getErrors().length\"\n (click)=\"save()\"\n >\n {{ submitButtonText() }}\n </button>\n }\n </div>\n </form>\n}\n", styles: [".stepper-actions{display:flex;gap:.5rem}.stepper-body{margin-bottom:1rem}\n"] }]
}], ctorParameters: () => [], propDecorators: null }) });
class SignalFormComponent {
form = input.required();
submitButtonText = input('Save');
formSubmit = output();
disabled = computed(() => this.form().saveButtonDisabled());
buttonText = computed(() => {
const status = this.form().status();
switch (status) {
case FormStatus.Submitting:
return 'Saving...';
case FormStatus.Success:
return 'Saved successfully';
default:
return this.submitButtonText();
}
});
buttonClass = computed(() => {
const status = this.form().status();
const classes = ['form-button'];
if (status === FormStatus.Submitting) {
classes.push('saving');
}
else if (status === FormStatus.Success) {
classes.push('success');
}
else if (this.form().hasSaved()) {
classes.push('saved');
}
return classes.join(' ');
});
hasUnsavedChanges() {
return this.form().anyDirty();
}
onBeforeUnload(event) {
if (this.hasUnsavedChanges()) {
event.preventDefault();
}
}
submitForm() {
const isValid = this.form().validateForm();
if (isValid) {
this.form().save();
this.formSubmit.emit(this.form());
}
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.2.14", type: SignalFormComponent, isStandalone: true, selector: "signal-form", inputs: { form: { classPropertyName: "form", publicName: "form", isSignal: true, isRequired: true, transformFunction: null }, submitButtonText: { classPropertyName: "submitButtonText", publicName: "submitButtonText", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { formSubmit: "formSubmit" }, host: { listeners: { "window:beforeunload": "onBeforeUnload($event)" } }, ngImport: i0, template: "<div class=\"signal-form-wrapper\">\n <h2 class=\"signal-form-title\">{{ form().title }}</h2>\n <form\n (submit)=\"submitForm(); $event.preventDefault()\"\n class=\"signal-form-content\"\n >\n <div class=\"form-fields-container\">\n <signal-form-fields\n [fields]=\"form().fields\"\n [form]=\"form()\"\n [signalFormParent]=\"true\"\n />\n </div>\n\n <div class=\"form-bottom-sticky\">\n <signal-form-error-summary [form]=\"form()\" />\n <button type=\"submit\" [disabled]=\"disabled()\" [class]=\"buttonClass()\">\n {{ buttonText() }}\n </button>\n </div>\n </form>\n</div>\n", styles: [".signal-form-wrapper{display:flex;flex-direction:column;min-height:var(--signal-form-min-height);max-height:var(--signal-form-max-height);height:100%;width:100%;max-width:var(--signal-form-max-width);background:var(--signal-form-bg);border-radius:var(--signal-form-border-radius-md);box-shadow:var(--signal-form-shadow-lg);overflow:hidden}.signal-form-wrapper.full-page{height:var(--signal-form-min-height)}.signal-form-title{font-size:var(--signal-form-title-font-size);font-weight:var(--signal-form-title-font-weight);margin:var(--signal-form-title-margin);padding:var(--signal-form-title-padding);color:var(--signal-form-title-color)}.signal-form-content{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden}.form-fields-container{flex:1;overflow-y:auto;padding:var(--signal-form-padding);display:flex;flex-direction:column;gap:var(--signal-form-fields-gap);content-visibility:auto;contain-intrinsic-size:0 500px}.form-bottom-sticky{--sticky-footer-height: var(--sticky-footer-height);position:sticky;bottom:0;width:100%;min-height:var(--sticky-footer-height);background:var(--signal-form-bg);border-top:1px solid var(--signal-form-border-top-color);padding:var(--signal-form-padding);box-shadow:var(--signal-form-shadow-top);display:flex;flex-direction:column;gap:var(--signal-form-bottom-gap)}.form-button{width:100%;padding:var(--signal-form-button-padding-full);background:var(--signal-form-button-primary-bg);color:var(--signal-form-button-text);border:none;border-radius:var(--signal-form-border-radius-md);font-weight:var(--signal-form-button-font-weight);cursor:pointer;transition:var(--signal-form-transition-bg)}.form-button:hover:not(:disabled){background:var(--signal-form-button-primary-bg-hover)}.form-button:disabled{background:var(--signal-form-button-disabled-bg);cursor:not-allowed}\n"], dependencies: [{ kind: "component", type: SignalFormFieldsComponent, selector: "signal-form-fields", inputs: ["fields", "form", "index", "isRoot", "signalFormParent"] }, { kind: "component", type: SignalFormErrorSummaryComponent, selector: "signal-form-error-summary", inputs: ["form"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: SignalFormComponent, decorators: [{
type: Component,
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'signal-form', imports: [SignalFormFieldsComponent, SignalFormErrorSummaryComponent], standalone: true, template: "<div class=\"signal-form-wrapper\">\n <h2 class=\"signal-form-title\">{{ form().title }}</h2>\n <form\n (submit)=\"submitForm(); $event.preventDefault()\"\n class=\"signal-form-content\"\n >\n <div class=\"form-fields-container\">\n <signal-form-fields\n [fields]=\"form().fields\"\n [form]=\"form()\"\n [signalFormParent]=\"true\"\n />\n </div>\n\n <div class=\"form-bottom-sticky\">\n <signal-form-error-summary [form]=\"form()\" />\n <button type=\"submit\" [disabled]=\"disabled()\" [class]=\"buttonClass()\">\n {{ buttonText() }}\n </button>\n </div>\n </form>\n</div>\n", styles: [".signal-form-wrapper{display:flex;flex-direction:column;min-height:var(--signal-form-min-height);max-height:var(--signal-form-max-height);height:100%;width:100%;max-width:var(--signal-form-max-width);background:var(--signal-form-bg);border-radius:var(--signal-form-border-radius-md);box-shadow:var(--signal-form-shadow-lg);overflow:hidden}.signal-form-wrapper.full-page{height:var(--signal-form-min-height)}.signal-form-title{font-size:var(--signal-form-title-font-size);font-weight:var(--signal-form-title-font-weight);margin:var(--signal-form-title-margin);padding:var(--signal-form-title-padding);color:var(--signal-form-title-color)}.signal-form-content{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden}.form-fields-container{flex:1;overflow-y:auto;padding:var(--signal-form-padding);display:flex;flex-direction:column;gap:var(--signal-form-fields-gap);content-visibility:auto;contain-intrinsic-size:0 500px}.form-bottom-sticky{--sticky-footer-height: var(--sticky-footer-height);position:sticky;bottom:0;width:100%;min-height:var(--sticky-footer-height);background:var(--signal-form-bg);border-top:1px solid var(--signal-form-border-top-color);padding:var(--signal-form-padding);box-shadow:var(--signal-form-shadow-top);display:flex;flex-direction:column;gap:var(--signal-form-bottom-gap)}.form-button{width:100%;padding:var(--signal-form-button-padding-full);background:var(--signal-form-button-primary-bg);color:var(--signal-form-button-text);border:none;border-radius:var(--signal-form-border-radius-md);font-weight:var(--signal-form-button-font-weight);cursor:pointer;transition:var(--signal-form-transition-bg)}.form-button:hover:not(:disabled){background:var(--signal-form-button-primary-bg-hover)}.form-button:disabled{background:var(--signal-form-button-disabled-bg);cursor:not-allowed}\n"] }]
}], propDecorators: { onBeforeUnload: [{
type: HostListener,
args: ['window:beforeunload', ['$event']]
}] } });
/*
* Public API Surface of signal-template-forms
*/
// Core functionality
/**
* Generated bundle index. Do not edit.
*/
export { BaseInputDirective, CollapsableSectionComponent, ConversionUtils, FieldFactory, FieldRoleAttributesService, FieldUtils, FormDropdownOverlayComponent, FormDropdownService, FormFieldSkeletonComponent, FormFieldSkeletonMapperComponent, FormFieldType, FormStatus, NumberInputType, SIGNAL_FORMS_THEME_CONFIG, SignalFormAutocompleteFieldComponent, SignalFormBuilder, SignalFormCheckboxFieldComponent, SignalFormCheckboxGroupFieldComponent, SignalFormChipListFieldComponent, SignalFormColorFieldComponent, SignalFormComponent, SignalFormDatetimeFieldComponent, SignalFormErrorSummaryComponent, SignalFormFieldsComponent, SignalFormFileFieldComponent, SignalFormHostDirective, SignalFormInputItemComponent, SignalFormMultiselectFieldComponent, SignalFormNumberFieldComponent, SignalFormPasswordFieldComponent, SignalFormRadioFieldComponent, SignalFormRatingFieldComponent, SignalFormRepeatableFieldComponent, SignalFormSaveButtonComponent, SignalFormSelectFieldComponent, SignalFormSliderFieldComponent, SignalFormStepperComponent, SignalFormStepperNavComponent, SignalFormSwitchFieldComponent, SignalFormTextFieldComponent, SignalFormTextareaFieldComponent, SignalFormThemeService, SignalModelDirective, SignalValidators, ValidationService, WordCountComponent, WordCountService, isRequired, provideSignalFormsTheme, unsavedChangesGuard, withMeta, withSignalValidation };
//# sourceMappingURL=signal-template-forms.mjs.map