UNPKG

@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
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 }) //