@mmstack/form-adapters
Version:
Provides a collection of **headless, reusable state adapters** for common form field types. Built upon [@mmstack/form-core](https://www.npmjs.com/package/@mmstack/form-core) and integrating with [@mmstack/form-validation](https://www.npmjs.com/package/@mm
1,079 lines (1,070 loc) • 93.1 kB
JavaScript
import * as i0 from '@angular/core';
import { computed, inject, LOCALE_ID, isSignal, signal, untracked, input, effect, Directive } from '@angular/core';
import { formControl, derived, formGroup } from '@mmstack/form-core';
import { injectValidators, defaultToDate } from '@mmstack/form-validation';
import { toFakeDerivation, debounced } from '@mmstack/primitives';
import { NG_VALIDATORS } from '@angular/forms';
function tooltip(message, providedMaxLen) {
const maxLen = computed(() => providedMaxLen?.() ?? 40, ...(ngDevMode ? [{ debugName: "maxLen" }] : []));
const resolved = computed(() => {
const max = maxLen();
const m = message();
if (m.length <= maxLen()) {
return { value: m, tooltip: '' };
}
return {
value: `${m.slice(0, max)}...`,
tooltip: m,
};
}, ...(ngDevMode ? [{ debugName: "resolved" }] : []));
return {
shortened: computed(() => resolved().value),
tooltip: computed(() => resolved().tooltip),
};
}
/**
* Creates the reactive state object (`BooleanState`) for a boolean form control
* without relying on Angular's dependency injection for validation setup.
*
* Use this function directly if:
* - You don't need validation or are providing a pre-built `validator` function manually.
* - You are creating state outside of an Angular injection context.
*
* For easier integration with `@mmstack/form-validation`, prefer `injectCreateBooleanState`.
*
* @template TParent The type of the parent form group's value, if applicable.
* @param value The initial boolean value, or a `DerivedSignal` linking it to a parent state.
* @param opt Optional configuration (`BooleanStateOptions`), potentially including a `validator` function.
* @returns A `BooleanState` instance managing the control's reactive state.
* @see injectCreateBooleanState
*/
function createBooleanState(value, opt) {
const state = formControl(value, opt);
const { shortened: error, tooltip: errorTooltip } = tooltip(state.error, opt?.maxErrorHintLength);
const { shortened: hint, tooltip: hintTooltip } = tooltip(state.hint, opt?.maxErrorHintLength);
return {
...state,
hint,
hintTooltip,
error,
errorTooltip,
type: 'boolean',
};
}
/**
* Factory function (returned by `injectCreateBooleanState`) that creates `BooleanState`.
* Integrates with `@mmstack/form-validation` via DI to apply validation rules.
*
* @template TParent The type of the parent form group's value, if applicable.
* @param value The initial boolean value, or a `DerivedSignal` linking it to a parent state.
* @param opt Configuration options specific to this injected factory, defined by
* the `InjectedBooleanStateOptions` type, including the `validation` property.
* @returns A `BooleanState` instance managing the control's reactive state.
*/
function injectCreateBooleanState() {
const validators = injectValidators();
/**
* Factory function (returned by `injectCreateBooleanState`) that creates `BooleanState`.
* Integrates with `@mmstack/form-validation` via DI.
*
* @template TParent The type of the parent form group's value, if applicable.
* @param value The initial boolean value, or a `DerivedSignal` linking it to a parent state.
* @param opt Configuration options, excluding `validator` but adding a `validation` property.
* @param opt.validation Optional configuration for boolean-specific validation rules.
* @param opt.validation.requireTrue If `true`, applies the `validators.boolean.mustBeTrue()` validator.
* @returns A `BooleanState` instance managing the control's reactive state.
*/
return (value, opt) => {
const validation = computed(() => ({
requireTrue: false,
...opt?.validation?.(),
}), ...(ngDevMode ? [{ debugName: "validation" }] : []));
const validator = computed(() => {
if (validation().requireTrue)
return validators.boolean.mustBeTrue();
return () => '';
}, ...(ngDevMode ? [{ debugName: "validator" }] : []));
return createBooleanState(value, { ...opt, validator });
};
}
/**
* Creates the reactive state object (`ToggleState`) for a toggle switch form control
* without relying on Angular's dependency injection for validation setup.
*
* This function wraps `createBooleanState` and simply overrides the `type` property
* to `'toggle'`. Use this function if creating state outside an injection context
* or providing a manual `validator` function via the options.
*
* For easier validation integration (like `requireTrue`), prefer `injectCreateToggleState`.
*
* @template TParent The type of the parent form group's value, if applicable.
* @param value The initial boolean value (`true`/`false`), or a `DerivedSignal` linking it to a parent state.
* @param opt Optional configuration (`ToggleStateOptions`, alias for `BooleanStateOptions`).
* @returns A `ToggleState` object managing the toggle's reactive state.
* @see createBooleanState
* @see injectCreateToggleState
*/
function createToggleState(value, opt) {
return {
...createBooleanState(value, opt),
type: 'toggle',
};
}
/**
* Creates and returns a factory function for generating `ToggleState` instances.
*
* This factory utilizes Angular's dependency injection by wrapping the factory
* returned from `injectCreateBooleanState`. It simplifies validation integration
* (e.g., setting `requireTrue` via the `validation` option).
*
* This is the **recommended** way to create `ToggleState` when working within
* an Angular injection context, especially if validation is needed.
*
* @returns A factory function: `(value: boolean | DerivedSignal<TParent, boolean>, opt?: InjectedToggleStateOptions) => ToggleState<TParent>`.
* @see injectCreateBooleanState
* @see InjectedToggleStateOptions
* @example
* // Within an Angular injection context (component, service, etc.):
* const createToggle = injectCreateToggleState(); // Get the factory
*
* // Create state for an optional dark mode toggle
* const darkModeState = createToggle(false, { label: () => 'Dark Mode' });
*
* // Create state for a toggle that must be enabled
* const enableAnalyticsState = createToggle(false, {
* label: () => 'Enable Analytics',
* validation: () => ({ requireTrue: true }) // Use validation option
* });
*/
function injectCreateToggleState() {
const factory = injectCreateBooleanState();
/**
* Factory function (returned by `injectCreateToggleState`) that creates `ToggleState`.
* It wraps the factory from `injectCreateBooleanState` and sets the `type` to `'toggle'`.
*
* @template TParent The type of the parent form group's value, if applicable.
* @param value The initial boolean value, or a `DerivedSignal` linking it to a parent state.
* @param opt Configuration options (`InjectedToggleStateOptions`), including the `validation` property.
* @returns A `ToggleState` instance managing the toggle's reactive state.
*/
return (value, opt) => {
return {
...factory(value, opt),
type: 'toggle',
};
};
}
/**
* Creates the reactive state object (`DateState`) for a date form control
* without relying on Angular's dependency injection for validation or locale.
* Includes computed signals for `min` and `max` date constraints based directly on the provided options.
*
* Use this function directly only if creating state outside an injection context
* or providing a fully custom `validator`, `locale`, `min`, and `max` manually via `opt`.
* Prefer `injectCreateDateState` for standard usage within Angular applications.
*
* Note: The `errorTooltip` signal returned by this function will initially be empty.
* Enhanced tooltip generation based on multiple errors is handled by `injectCreateDateState`.
*
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
* @template TDate The type used for date values. Defaults to `Date`.
* @param value The initial date value (`TDate | null`), or a `DerivedSignal` linking it to a parent state.
* @param opt Configuration options (`DateStateOptions`), requires `locale`, optionally `validator`, `placeholder`, `min`, `max`.
* @returns A `DateState` instance managing the control's reactive state, including `min` and `max` signals.
* @see injectCreateDateState
*/
function createDateState(value, opt) {
const state = formControl(value, opt);
const { shortened: error, tooltip: errorTooltip } = tooltip(state.error, opt.maxErrorHintLength);
const { shortened: hint, tooltip: hintTooltip } = tooltip(state.hint, opt.maxErrorHintLength);
return {
...state,
min: computed(() => {
const min = opt.min?.();
if (!min)
return null;
return typeof min === 'string' ? new Date(min) : min;
}, {
equal: (a, b) => a?.getTime() === b?.getTime(),
}),
max: computed(() => {
const max = opt.max?.();
if (!max)
return null;
return typeof max === 'string' ? new Date(max) : max;
}),
placeholder: computed(() => opt.placeholder?.() ?? ''),
error,
errorTooltip,
hint,
hintTooltip,
type: 'date',
};
}
/**
* Creates and returns a factory function for generating `DateState` instances.
*
* This factory utilizes Angular's dependency injection (`injectValidators`, `LOCALE_ID`)
* to automatically handle:
* - Validation configuration via `DateValidatorOptions` (passed to the `validation` option).
* - Localization for default validation error messages.
* - Enhanced error message formatting (splitting merged errors into `error` and `errorTooltip` signals).
* - Populating the `min` and `max` signals on `DateState` based on the constraints specified
* within the `validation` options object.
* - Configuration of date handling based on `provideValidatorConfig`.
*
* This is the **recommended** way to create `DateState` within an Angular application.
*
* @returns A factory function: `(value: TDate | null | DerivedSignal<TParent, TDate | null>, opt?: InjectedDateStateOptions<TDate>) => DateState<TParent, TDate>`.
* @template TDate The type used for date values passed to the factory (e.g., `Date`, Luxon `DateTime`).
* Must match the `TDate` used during `provideValidatorConfig` if custom date handling is required. Defaults to `Date`.
*
* @example
* // Within an injection context:
* const createDate = injectCreateDateState();
* // If using Luxon: const createDate = injectCreateDateState<DateTime>();
*
* const eventDateState = createDate(null, {
* label: () => 'Event Date',
* placeholder: () => 'Select event date',
* validation: () => ({ // Provide DateValidatorOptions here
* required: true,
* min: new Date(), // Sets min validation AND state.min() signal
* max: '2099-12-31' // Sets max validation AND state.max() signal
* })
* });
*
* // Template can use min/max signals for datepicker limits:
* // <mat-datepicker-toggle [for]="picker" [disabled]="eventDateState.disabled()"></mat-datepicker-toggle>
* // <input matInput [matDatepicker]="picker"
* // [min]="eventDateState.min()"
* // [max]="eventDateState.max()"
* // [(ngModel)]="eventDateState.value" ... >
* // <mat-datepicker #picker></mat-datepicker>
* // <mat-error><span [matTooltip]="eventDateState.errorTooltip()">{{ eventDateState.error() }}</span></mat-error>
*/
function injectCreateDateState() {
const validators = injectValidators();
const locale = inject(LOCALE_ID);
/**
* Factory function (returned by `injectCreateDateState`) that creates `DateState`.
* Integrates with `@mmstack/form-validation` via DI for validation and localization.
* Handles splitting of multiple validation errors into `error` and `errorTooltip`.
* Derives `min`/`max` state signals from `validation` options.
*
* @template TDate The type for date values used by this control. Defaults to `Date`.
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
* @param value The initial date value (`TDate | null`), or a `DerivedSignal` linking it to a parent state.
* @param opt Configuration options (`InjectedDateStateOptions`), including the `validation` property
* which accepts `DateValidatorOptions` (used for both validation rules and setting state's `min`/`max` signals).
* @returns A `DateState` instance managing the control's reactive state.
*/
return (value, opt) => {
const validationOptions = computed(() => ({
messageOptions: {
label: opt?.label?.(),
},
...opt?.validation?.(),
}), ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
const mergedValidator = computed(() => validators.date.all(validationOptions()), ...(ngDevMode ? [{ debugName: "mergedValidator" }] : []));
const validator = computed(() => {
const merged = mergedValidator();
return (value) => {
return merged(value);
};
}, ...(ngDevMode ? [{ debugName: "validator" }] : []));
const state = createDateState(value, {
...opt,
locale,
min: computed(() => validationOptions().min ?? null),
max: computed(() => validationOptions().max ?? null),
required: computed(() => validationOptions().required ?? false),
validator,
});
const resolvedError = computed(() => {
const merger = mergedValidator();
return merger.resolve(state.errorTooltip() || state.error());
}, ...(ngDevMode ? [{ debugName: "resolvedError" }] : []));
return {
...state,
error: computed(() => resolvedError().error),
errorTooltip: computed(() => resolvedError().tooltip),
};
};
}
function equalDate(a, b) {
if (a === b)
return true;
if (!a || !b)
return false;
return a.getTime() === b.getTime();
}
/**
* Creates the reactive state object (`DateRangeState`) for a date range form control
* without relying on Angular's dependency injection for validation or locale.
*
* Internally creates a `formGroup` with `start` and `end` `DateState` children
* using the non-DI `createDateState` function. It computes overall `min`/`max` signals
* based directly on the provided `opt.min` and `opt.max` options.
*
* Prefer `injectCreateDateRangeState` for standard usage within Angular applications to leverage
* automatic validation integration, localization, and enhanced error display.
*
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
* @template TDate The type used for date values. Defaults to `Date`.
* @param value The initial date range value (`DateRange<TDate>`, e.g., `{ start: TDate|null, end: TDate|null }`),
* or a `DerivedSignal` linking it to a parent state.
* @param opt Configuration options (`DateRangeStateOptions`). Requires `locale`. Allows specifying options
* for the child `start`/`end` inputs via `opt.start`/`opt.end`.
* @returns A `DateRangeState` instance managing the control's reactive state.
* @see injectCreateDateRangeState
* @see DateRangeStateOptions
* @see formGroup
* @see createDateState
*/
function createDateRangeState(value, opt) {
const valueSignal = isSignal(value) ? value : signal(value);
const min = computed(() => {
const m = opt.min?.();
if (typeof m === 'string')
return new Date(m);
if (m instanceof Date)
return m;
return null;
}, ...(ngDevMode ? [{ debugName: "min", equal: equalDate }] : [{
equal: equalDate,
}]));
const max = computed(() => {
const m = opt.max?.();
if (typeof m === 'string')
return new Date(m);
if (m instanceof Date)
return m;
return null;
}, ...(ngDevMode ? [{ debugName: "max", equal: equalDate }] : [{
equal: equalDate,
}]));
const children = {
start: createDateState(derived(valueSignal, 'start'), {
...opt.start,
locale: opt.locale,
}),
end: createDateState(derived(valueSignal, 'end'), {
...opt.end,
locale: opt.locale,
}),
};
const state = formGroup(valueSignal, children, opt);
const { shortened: error, tooltip: errorTooltip } = tooltip(state.error, opt.maxErrorHintLength);
const { shortened: hint, tooltip: hintTooltip } = tooltip(state.hint, opt.maxErrorHintLength);
return {
...state,
min,
max,
error,
errorTooltip,
hint,
hintTooltip,
type: 'date-range',
};
}
/**
* Creates and returns a factory function for generating `DateRangeState` instances.
*
* This factory utilizes Angular's dependency injection (`injectValidators`, `LOCALE_ID`)
* to automatically handle validation configuration (expecting range-specific rules like start <= end
* via `validators.dateRange.all` interpreting `DateValidatorOptions`), localization, and enhanced
* error message formatting (`error`/`errorTooltip`). The overall `min`/`max` signals on the state
* are also automatically derived from the `min`/`max` constraints provided within the `validation` options.
*
* This is the **recommended** way to create `DateRangeState` within an Angular application.
*
* @returns A factory function: `(value: DateRange<TDate> | DerivedSignal<TParent, DateRange<TDate>>, opt?: InjectedDateRangeStateOptions<TDate>) => DateRangeState<TParent, TDate>`.
* @template TDate The type used for date values. Defaults to `Date`. Must match type used in `provideValidatorConfig`.
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
*
* @example
* // Within an injection context:
* const createDateRange = injectCreateDateRangeState();
* // Assuming DateRange = { start: Date | null, end: Date | null }
*
* const vacationDatesState = createDateRange({ start: null, end: null }, {
* label: () => 'Vacation Dates',
* start: { placeholder: () => 'Departure Date' }, // Child control options
* end: { placeholder: () => 'Return Date' },
* validation: () => ({ // Validation for the range
* required: true, // Requires both start and end
* min: new Date(), // Overall minimum date for picker & validation
* // Assumes validation library has a rule triggered by DateValidatorOptions
* // that checks if start <= end, potentially enabled by default or specific flag
* })
* });
*
* // Template binds to child states for inputs:
* // <mat-date-range-input [formGroup]="vacationDatesFormGroup"> * // <input matStartDate [(ngModel)]="vacationDatesState.children().start.value" [placeholder]="vacationDatesState.children().start.placeholder()">
* // <input matEndDate [(ngModel)]="vacationDatesState.children().end.value" [placeholder]="vacationDatesState.children().end.placeholder()">
* // </mat-date-range-input>
* // <mat-date-range-picker [min]="vacationDatesState.min()" [max]="vacationDatesState.max()"></mat-date-range-picker>
*/
function injectCreateDateRangeState() {
const locale = inject(LOCALE_ID);
const v = injectValidators();
/**
* Factory function (returned by `injectCreateDateRangeState`) that creates `DateRangeState`.
* Integrates with `@mmstack/form-validation` via DI for validation (using `validators.dateRange.all`),
* localization, and enhanced error display. Derives overall `min`/`max` signals from validation options.
*
* @template TDate The type for date values used by this control. Defaults to `Date`.
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
* @param value The initial date range value (`DateRange<TDate>`), or a `DerivedSignal` linking it.
* @param opt Configuration options (`InjectedDateRangeStateOptions`), including options for child
* `start`/`end` controls and the `validation` property (accepting `DateValidatorOptions`
* to be interpreted for range validation by `validators.dateRange.all`).
* @returns A `DateRangeState` instance managing the control's reactive state.
*/
return (value, opt) => {
const validators = v;
const validationOptions = computed(() => ({
messageOptions: {
label: opt?.label?.(),
},
...opt?.validation?.(),
}), ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
const min = computed(() => validationOptions().min ?? null, ...(ngDevMode ? [{ debugName: "min" }] : []));
const max = computed(() => validationOptions().max ?? null, ...(ngDevMode ? [{ debugName: "max" }] : []));
const validator = computed(() => validators.dateRange.all(validationOptions()), ...(ngDevMode ? [{ debugName: "validator" }] : []));
const state = createDateRangeState(value, {
...opt,
locale,
min,
max,
validator,
required: computed(() => validationOptions().required ?? false),
});
const resolvedError = computed(() => {
const merger = validator();
return merger.resolve(state.errorTooltip() || state.error());
}, ...(ngDevMode ? [{ debugName: "resolvedError" }] : []));
return {
...state,
error: computed(() => resolvedError().error),
errorTooltip: computed(() => resolvedError().tooltip),
};
};
}
function setDay(date, extractFrom) {
if (!date)
return null;
const next = new Date(extractFrom || Date.now());
next.setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds());
return next;
}
/**
* Creates the reactive state object (`TimeState`) for a time form control
* without relying on Angular's dependency injection for validation or locale.
* Includes computed signals for `min` and `max` date constraints based directly on the provided options.
* If provided the day will shift to the current values date, in order to only validate the time part.
* Angular defaults to today's date, but varies the time if no date is provided.
*
* Use this function directly only if creating state outside an injection context
* or providing a fully custom `validator`, `locale`, `min`, and `max` manually via `opt`.
* Prefer `injectCreateTimeState` for standard usage within Angular applications.
*
* Note: The `errorTooltip` signal returned by this function will initially be empty.
* Enhanced tooltip generation based on multiple errors is handled by `injectCreateTimeState`.
*
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
* @template TDate The type used for date values. Defaults to `Date`.
* @param value The initial date value (`TDate | null`), or a `DerivedSignal` linking it to a parent state.
* @param opt Configuration options (`TimeStateOptions`), requires `locale`, optionally `validator`, `placeholder`, `min`, `max`.
* @returns A `TimeState` instance managing the control's reactive state, including `min` and `max` signals.
* @see injectCreateTimeState
* @see createDateState
*/
function createTimeState(value, opt) {
const dateState = createDateState(value, opt);
const toDate = opt.toDate ?? defaultToDate;
const dateValue = computed(() => toDate(dateState.value()), ...(ngDevMode ? [{ debugName: "dateValue", equal: (a, b) => {
if (!a && !b)
return true;
if (!a || !b)
return false;
return a.getTime() === b.getTime();
} }] : [{
equal: (a, b) => {
if (!a && !b)
return true;
if (!a || !b)
return false;
return a.getTime() === b.getTime();
},
}]));
return {
...dateState,
min: computed(() => setDay(dateState.min(), dateValue())),
max: computed(() => setDay(dateState.max(), dateValue())),
type: 'time',
};
}
/**
* Creates and returns a factory function for generating `TimeState` instances.
*
* This factory utilizes Angular's dependency injection (`injectValidators`, `LOCALE_ID`)
* to automatically handle:
* - Validation configuration via `DateValidatorOptions` (passed to the `validation` option).
* - Localization for default validation error messages.
* - Enhanced error message formatting (splitting merged errors into `error` and `errorTooltip` signals).
* - Populating the `min` and `max` signals on `TimeState` based on the constraints specified
* within the `validation` options object.
* - Configuration of date handling based on `provideValidatorConfig`.
*
* This is the **recommended** way to create `TimeState` within an Angular application.
*
* @returns A factory function: `(value: TDate | null | DerivedSignal<TParent, TDate | null>, opt?: InjectedTimeStateOptions<TDate>) => TimeState<TParent, TDate>`.
* @template TDate The type used for date values passed to the factory (e.g., `Date`, Luxon `DateTime`).
* Must match the `TDate` used during `provideValidatorConfig` if custom date handling is required. Defaults to `Date`.
*
* @example
* // Within an injection context:
* const createTime = injectCreateTimeState();
* // If using Luxon: const createTime = injectCreateTimeState<DateTime>();
*
* const eventTimeState = createTime(null, {
* label: () => 'Event Time',
* placeholder: () => 'Select event time',
* validation: () => ({ // Provide DateValidatorOptions here
* required: true,
* min: new Date(), // Sets min validation AND state.min() signal
* })
* });
*
* // Template can use min/max signals for datepicker limits:
* // <mat-timepicker-toggle [for]="picker" [disabled]="eventTimeState.disabled()"></mat-datepicker-toggle>
* // <input matInput [matTimepicker]="picker"
* // [min]="eventTimeState.min()"
* // [max]="eventTimeState.max()"
* // [(ngModel)]="eventTimeState.value" ... >
* // <mat-timepicker #picker></mat-datepicker>
* // <mat-error><span [matTooltip]="eventTimeState.errorTooltip()">{{ eventTimeState.error() }}</span></mat-error>
*/
function injectCreateTimeState() {
const v = injectValidators();
const locale = inject(LOCALE_ID);
/**
* Factory function (returned by `injectCreateTimeState`) that creates `TimeState`.
* Integrates with `@mmstack/form-validation` via DI for validation and localization.
* Handles splitting of multiple validation errors into `error` and `errorTooltip`.
* Derives `min`/`max` state signals from `validation` options.
*
* @template TDate The type for date values used by this control. Defaults to `Date`.
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
* @param value The initial date value (`TDate | null`), or a `DerivedSignal` linking it to a parent state.
* @param opt Configuration options (`InjectedTimeStateOptions`), including the `validation` property
* which accepts `DateValidatorOptions` (used for both validation rules and setting state's `min`/`max` signals).
* @returns A `TimeState` instance managing the control's reactive state.
*/
return (value, opt) => {
const validators = v;
const validationOptions = computed(() => ({
messageOptions: {
label: opt?.label?.(),
},
...opt?.validation?.(),
}), ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
const validator = computed(() => validators.date.all(validationOptions()), ...(ngDevMode ? [{ debugName: "validator" }] : []));
const state = createTimeState(value, {
...opt,
toDate: (value) => {
if (!value)
return null;
return validators.date.util.toDate(value);
},
locale,
min: computed(() => validationOptions().min ?? null),
max: computed(() => validationOptions().max ?? null),
required: computed(() => validationOptions().required ?? false),
validator,
});
const resolvedError = computed(() => {
const merger = validator();
return merger.resolve(state.errorTooltip() || state.error());
}, ...(ngDevMode ? [{ debugName: "resolvedError" }] : []));
return {
...state,
error: computed(() => resolvedError().error),
errorTooltip: computed(() => resolvedError().tooltip),
};
};
}
/**
* Creates the reactive state object (`DateTimeState`) for a time form control
* without relying on Angular's dependency injection for validation or locale.
* Includes computed signals for `min` and `max` date constraints based directly on the provided options.
* If provided the day will shift to the current values date, in order to only validate the time part.
* Angular defaults to today's date, but varies the time if no date is provided.
*
* Use this function directly only if creating state outside an injection context
* or providing a fully custom `validator`, `locale`, `min`, and `max` manually via `opt`.
* Prefer `injectCreateDateTimeState` for standard usage within Angular applications.
*
* Note: The `errorTooltip` signal returned by this function will initially be empty.
* Enhanced tooltip generation based on multiple errors is handled by `injectCreateDateTimeState`.
*
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
* @template TDate The type used for date values. Defaults to `Date`.
* @param value The initial date value (`TDate | null`), or a `DerivedSignal` linking it to a parent state.
* @param opt Configuration options (`DateTimeStateOptions`), requires `locale`, optionally `validator`, `placeholder`, `min`, `max`.
* @returns A `DateTimeState` instance managing the control's reactive state, including `min` and `max` signals.
* @see injectCreateDateTimeState
* @see createDateState
*/
function createDateTimeState(initial, opt) {
const value = isSignal(initial)
? initial
: toFakeDerivation(initial);
const dateState = createDateState(value, opt);
const timeState = createTimeState(value, {
...opt,
label: opt?.timeLabel,
hint: opt?.timeHint,
placeholder: opt?.timePlaceholder,
});
return {
...dateState,
dateControl: dateState,
timeControl: timeState,
type: 'datetime',
};
}
/**
* Creates and returns a factory function for generating `DateTimeState` instances.
*
* This factory utilizes Angular's dependency injection (`injectValidators`, `LOCALE_ID`)
* to automatically handle:
* - Validation configuration via `DateValidatorOptions` (passed to the `validation` option).
* - Localization for default validation error messages.
* - Enhanced error message formatting (splitting merged errors into `error` and `errorTooltip` signals).
* - Populating the `min` and `max` signals on `DateTimeState` based on the constraints specified
* within the `validation` options object.
* - Configuration of date handling based on `provideValidatorConfig`.
*
* This is the **recommended** way to create `DateTimeState` within an Angular application.
*
* @returns A factory function: `(value: TDate | null | DerivedSignal<TParent, TDate | null>, opt?: InjectedDateTimeStateOptions<TDate>) => DateTimeState<TParent, TDate>`.
* @template TDate The type used for date values passed to the factory (e.g., `Date`, Luxon `DateTime`).
* Must match the `TDate` used during `provideValidatorConfig` if custom date handling is required. Defaults to `Date`.
*
*/
function injectCreateDateTimeState() {
const d = injectCreateDateState();
const t = injectCreateTimeState();
/**
* Factory function (returned by `injectCreateDateTimeState`) that creates `DateTimeState`.
* Integrates with `@mmstack/form-validation` via DI for validation and localization.
* Handles splitting of multiple validation errors into `error` and `errorTooltip`.
* Derives `min`/`max` state signals from `validation` options.
*
* @template TDate The type for date values used by this control. Defaults to `Date`.
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
* @param value The initial date value (`TDate | null`), or a `DerivedSignal` linking it to a parent state.
* @param opt Configuration options (`InjectedDateTimeStateOptions`), including the `validation` property
* which accepts `DateValidatorOptions` (used for both validation rules and setting state's `min`/`max` signals).
* @returns A `DateTimeState` instance managing the control's reactive state.
*/
return (initial, opt) => {
const value = isSignal(initial)
? initial
: toFakeDerivation(initial);
const dateState = d(value, opt);
const timeState = t(value, {
...opt,
label: opt?.timeLabel,
hint: opt?.timeHint,
placeholder: opt?.timePlaceholder,
});
return {
...dateState,
dateControl: dateState,
timeControl: timeState,
type: 'datetime',
};
};
}
const ALLOWED_SEPARATOR_CODES = ['Comma', 'Period', 'Decimal', 'NumpadDecimal'];
const ALLOWED_DIGIT_CODES = [
'Numpad',
'Minus',
'Digit0',
'Digit1',
'Digit2',
'Digit3',
'Digit4',
'Digit5',
'Digit6',
'Digit7',
'Digit8',
'Digit9',
'Numpad0',
'Numpad1',
'Numpad2',
'Numpad3',
'Numpad4',
'Numpad5',
'Numpad6',
'Numpad7',
'Numpad8',
'Numpad9',
];
const ALLOWED_EDIT_CODES = ['Backspace', 'Delete'];
const ALLOWED_CONTROL_CODES = [
'Tab',
'Enter',
'Escape',
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
];
const SHORTCUT_CODES = ['KeyC', 'KeyX', 'KeyV', 'KeyA', 'KeyZ', 'KeyY'];
const ALLOWED_NON_META_CODES = new Set([
...ALLOWED_SEPARATOR_CODES,
...ALLOWED_DIGIT_CODES,
...ALLOWED_EDIT_CODES,
...ALLOWED_CONTROL_CODES,
]);
const ALLOWED_SHORTCUT_CODES = new Set(SHORTCUT_CODES);
function isAllowedKey(e) {
if (!e || !e.code)
return false;
if (ALLOWED_NON_META_CODES.has(e.code))
return true;
return (e.metaKey || e.ctrlKey) && ALLOWED_SHORTCUT_CODES.has(e.code);
}
const DEFAULT_ISO_DECIMAL_SEPARATOR = '.';
function isIsoDecimalSeparator(sep) {
return sep === DEFAULT_ISO_DECIMAL_SEPARATOR;
}
function getDecimalSeparator(locale) {
if (!locale)
return '.';
const numberFormat = new Intl.NumberFormat(locale);
const parts = numberFormat.formatToParts(1.1);
const decimalPart = parts.find((part) => part.type === 'decimal');
return decimalPart ? decimalPart.value : '.';
}
/**
* Creates the reactive state object (`NumberState`) for a number form control
* without relying on Angular's dependency injection for validation or localization.
*
* Use this function directly only if creating state outside an injection context, providing
* a fully custom `validator`, or needing to manually specify the `decimalSeparator`.
* Prefer `injectCreateNumberState` for standard usage within Angular applications, as it
* integrates validation, locale-based formatting, and enhanced error display.
*
* Note: The `errorTooltip` signal returned by this function will initially be empty.
*
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
* @param value The initial number value (`number | null`), or a `DerivedSignal` linking it to a parent state.
* @param opt Optional configuration options (`NumberStateOptions`), potentially including `validator`, `decimalSeparator`, `step`, `placeholder`.
* @returns A `NumberState` instance managing the control's reactive state.
* @see injectCreateNumberState
*/
function createNumberState(value, opt) {
const decimal = computed(() => opt?.decimalSeparator?.() ?? DEFAULT_ISO_DECIMAL_SEPARATOR, ...(ngDevMode ? [{ debugName: "decimal" }] : []));
const state = formControl(value, opt);
const step = computed(() => opt?.step?.() ?? 1, ...(ngDevMode ? [{ debugName: "step" }] : []));
const isIsoSeparator = computed(() => isIsoDecimalSeparator(decimal()), ...(ngDevMode ? [{ debugName: "isIsoSeparator" }] : []));
const arrowFns = computed(() => {
const stp = step();
return {
inc: () => {
state.value.update((cur) => {
if (cur === null)
return 0;
return cur + stp;
});
},
dec: () => {
state.value.update((cur) => {
if (cur === null)
return 0;
return cur - stp;
});
},
};
}, ...(ngDevMode ? [{ debugName: "arrowFns" }] : []));
const { shortened: error, tooltip: errorTooltip } = tooltip(state.error, opt?.maxErrorHintLength);
const { shortened: hint, tooltip: hintTooltip } = tooltip(state.hint, opt?.maxErrorHintLength);
return {
...state,
placeholder: computed(() => opt?.placeholder?.() ?? ''),
step,
error,
errorTooltip,
hint,
hintTooltip,
localizedValue: computed(() => {
const v = state.value();
if (isIsoSeparator())
return v;
if (v === null)
return null;
return String(v).replace(DEFAULT_ISO_DECIMAL_SEPARATOR, decimal());
}),
setLocalizedValue: (value) => {
if (untracked(state.disabled) || untracked(state.readonly))
return;
if (value === null || value === '')
return state.value.set(null);
if (typeof value === 'number')
return state.value.set(value);
if (typeof value !== 'string')
return;
const sep = untracked(decimal);
const parsed = Number(value.replace(sep, DEFAULT_ISO_DECIMAL_SEPARATOR));
if (isNaN(parsed))
return state.value.set(null);
return state.value.set(parsed);
},
keydownHandler: computed(() => {
if (isIsoSeparator() || state.disabled() || state.readonly()) {
return () => {
// noop
};
}
const { inc, dec } = arrowFns();
return (e) => {
if (!isAllowedKey(e) || !e?.isTrusted)
return e?.preventDefault();
if (e?.code === 'ArrowUp') {
e.preventDefault();
return inc();
}
if (e?.code === 'ArrowDown') {
e.preventDefault();
return dec();
}
};
}),
inputType: computed(() => (isIsoSeparator() ? 'number' : 'string')),
type: 'number',
};
}
/**
* Creates and returns a factory function for generating `NumberState` instances.
*
* This factory leverages Angular's dependency injection (`injectValidators`, `LOCALE_ID`)
* to seamlessly integrate:
* - Validation configuration via `NumberValidatorOptions`.
* - Optional localization for decimal separators using the `localizeDecimal` option.
* - Enhanced error message formatting (splitting merged errors into `error` and `errorTooltip`).
*
* This is the **recommended** way to create `NumberState` within an Angular application.
*
* @returns A factory function: `(value: number | null | DerivedSignal<TParent, number | null>, opt?: InjectedNumberStateOptions) => NumberState<TParent>`.
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
*
* @example
* // Within an injection context:
* const createNumber = injectCreateNumberState();
*
* const quantityState = createNumber(1, {
* label: () => 'Qty',
* step: () => 1,
* validation: () => ({ required: true, min: 1, integer: true })
* });
*
* const localPriceState = createNumber(null, {
* label: () => 'Price',
* placeholder: () => '0,00', // Example for comma locale
* localizeDecimal: () => true, // Use locale separator (e.g., ',')
* validation: () => ({ required: true, min: 0, multipleOf: 0.01 })
* });
*
* // Template usage requires handling localized input if localizeDecimal is not false/undefined:
* // <input
* // [type]="localPriceState.inputType()" // Will be 'string' if locale uses ','
* // [value]="localPriceState.localizedValue()" // Formats number with ','
* // (input)="localPriceState.setLocalizedValue($any($event).target.value)" // Parses input with ','
* // (keydown)="localPriceState.keydownHandler()($event)" // Restricts keys for string input
* // ... />
*/
function injectCreateNumberState() {
const validators = injectValidators();
const locale = inject(LOCALE_ID, { optional: true }) ?? 'en-US';
/**
* Factory function (returned by `injectCreateNumberState`) that creates `NumberState`.
* Integrates with `@mmstack/form-validation` via DI for validation and localization.
* Handles decimal separator localization and enhanced error display.
*
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
* @param value The initial number value (`number | null`), or a `DerivedSignal` linking it to a parent state.
* @param opt Configuration options (`InjectedNumberStateOptions`), including `validation` (using `NumberValidatorOptions`)
* and the `localizeDecimal` flag/string.
* @returns A `NumberState` instance managing the control's reactive state, including helpers for
* localized input handling and separate `error`/`errorTooltip` signals.
*/
return (value, opt) => {
const validationOptions = computed(() => ({
messageOptions: {
label: opt?.label?.(),
},
...opt?.validation?.(),
}), ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
const decimal = computed(() => {
const localizeDec = opt?.localizeDecimal?.();
if (!localizeDec)
return DEFAULT_ISO_DECIMAL_SEPARATOR;
return typeof localizeDec === 'string'
? localizeDec
: getDecimalSeparator(locale);
}, ...(ngDevMode ? [{ debugName: "decimal" }] : []));
const mergedValidator = computed(() => validators.number.all(validationOptions()), ...(ngDevMode ? [{ debugName: "mergedValidator" }] : []));
const validator = computed(() => {
const merged = mergedValidator();
return (value) => {
return merged(value);
};
}, ...(ngDevMode ? [{ debugName: "validator" }] : []));
const state = createNumberState(value, {
...opt,
decimalSeparator: decimal,
required: computed(() => opt?.validation?.()?.required ?? false),
validator,
});
const resolvedError = computed(() => {
const merger = mergedValidator();
return merger.resolve(state.errorTooltip() || state.error());
}, ...(ngDevMode ? [{ debugName: "resolvedError" }] : []));
return {
...state,
error: computed(() => resolvedError().error),
errorTooltip: computed(() => resolvedError().tooltip),
};
};
}
/**
* Creates the reactive state object (`SelectState`) for a single-select form control
* without relying on Angular's dependency injection for validation.
*
* Handles the logic for identifying options, generating display labels, managing disabled
* states, and ensuring the selected value is always represented in the options list.
*
* Use this function directly only if creating state outside an injection context or
* providing a fully custom `validator` manually via `opt`. Prefer `injectCreateSelectState`
* for standard usage, especially for easy `required` validation.
*
* @template T The type of the individual option values.
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
* @param value The initial selected value (`T`), or a `DerivedSignal` linking it to a parent state.
* Should typically be non-nullable unless `null` represents a valid selection state.
* @param opt Configuration options (`SelectStateOptions`). **Note:** This parameter (and specifically `opt.options`) is required.
* @returns A `SelectState` instance managing the control's reactive state.
* @see injectCreateSelectState
* @see SelectStateOptions
*/
function createSelectState(value, opt) {
const identify = computed(() => opt.identify?.() ??
((v) => {
if (v === null || v === undefined)
return '';
return `${v}`;
}), ...(ngDevMode ? [{ debugName: "identify" }] : []));
const equal = (a, b) => {
return identify()(a) === identify()(b);
};
const state = formControl(value, {
...opt,
equal: opt.equal ?? equal,
});
const display = computed(() => opt.display?.() ?? ((v) => `${v}`), ...(ngDevMode ? [{ debugName: "display" }] : []));
const disableOption = computed(() => opt.disableOption?.() ?? (() => false), ...(ngDevMode ? [{ debugName: "disableOption" }] : []));
const valueId = computed(() => identify()(state.value()), ...(ngDevMode ? [{ debugName: "valueId" }] : []));
const valueLabel = computed(() => display()(state.value()), ...(ngDevMode ? [{ debugName: "valueLabel" }] : []));
const identifiedOptions = computed(() => {
const identityFn = identify();
return opt.options().map((value) => ({
value,
id: identityFn(value),
}));
}, ...(ngDevMode ? [{ debugName: "identifiedOptions" }] : []));
const allOptions = computed(() => {
return identifiedOptions().map((o) => ({
...o,
label: computed(() => display()(o.value)),
disabled: computed(() => {
if (valueId() === o.id)
return false;
return state.disabled() || state.readonly() || disableOption()(o.value);
}),
}));
}, ...(ngDevMode ? [{ debugName: "allOptions" }] : []));
const options = computed(() => {
const currentId = valueId();
const opt = allOptions();
if (!currentId)
return opt;
if (opt.length && opt.some((o) => o.id === currentId))
return opt;
return [
...opt,
{
id: currentId,
value: state.value(),
label: valueLabel,
disabled: computed(() => false),
},
];
}, ...(ngDevMode ? [{ debugName: "options" }] : []));
const { shortened: error, tooltip: errorTooltip } = tooltip(state.error, opt.maxErrorHintLength);
const { shortened: hint, tooltip: hintTooltip } = tooltip(state.hint, opt.maxErrorHintLength);
return {
...state,
valueLabel,
options,
equal,
placeholder: computed(() => opt.placeholder?.() ?? ''),
error,
errorTooltip,
hint,
hintTooltip,
type: 'select',
};
}
/**
* Creates and returns a factory function for generating `SelectState` instances.
*
* This factory utilizes Angular's dependency injection (`injectValidators`) primarily
* to simplify the application of basic `required` validation via the `validation` option.
* It passes other configuration options (`options`, `identify`, `display`, etc.)
* through to the underlying `createSelectState` function.
*
* This is the **recommended** way to create `SelectState` within an Angular application.
*
* @returns A factory function: `(value: T | DerivedSignal<TParent, T>, opt: InjectedSelectStateOptions<T>) => SelectState<T, TParent>`.
* @template T The type of the individual option values used by the factory.
* @template TParent The type of the parent form group's value, if applicable. Defaults to `undefined`.
*
* @example
* // Within an injection context:
* const createSelect = injectCreateSelectState();
*
* // Example with simple string options
* const themeOptions = ['light', 'dark', 'auto'] as const;
* type Theme = typeof themeOptions[number];
* const themeState = createSelect<Theme>('auto', { // Explicit T = Theme
* label: () => 'Color Theme',
* options: () => [...themeOptions], // Provide the options array
* // No validation needed
* });
*
* // Example with objects and required validation
* type User = { id: string; displayName: string };
* const userList: User[] = [{ id: 'u1', displayName: 'Alice' }, { id: 'u2', displayName: 'Bob' }];
* const assigneeState = createSelect<User | null>(null, { // Explicit T = User | null
* label: () => 'Assignee',
* placeholder: () => 'Select an assignee',
* options: () => userList,
* identify: () => user => user?.id ?? '', // Use id for comparison
* display: () => user => user?.displayName ?? 'None', // Use name for display
* validation: () => ({ required: true }) //