|
| 1 | +/** |
| 2 | + * @license |
| 3 | + * Copyright Google LLC All Rights Reserved. |
| 4 | + * |
| 5 | + * Use of this source code is governed by an MIT-style license that can be |
| 6 | + * found in the LICENSE file at https://angular.dev/license |
| 7 | + */ |
| 8 | + |
| 9 | +import { |
| 10 | + inject, |
| 11 | + linkedSignal, |
| 12 | + type ModelSignal, |
| 13 | + type Signal, |
| 14 | + signal, |
| 15 | + type WritableSignal, |
| 16 | +} from '@angular/core'; |
| 17 | +import {FORM_FIELD_PARSE_ERRORS} from '../directive/parse_errors'; |
| 18 | +import type {ValidationError} from './rules'; |
| 19 | + |
| 20 | +/** |
| 21 | + * Options for `transformedValue`. |
| 22 | + * |
| 23 | + * @experimental 21.2.0 |
| 24 | + */ |
| 25 | +export interface TransformedValueOptions<TValue, TRaw> { |
| 26 | + /** |
| 27 | + * Parse the raw value into the model value. |
| 28 | + * |
| 29 | + * Should return an object containing the parsed result, which may contain: |
| 30 | + * - `value`: The parsed model value. If `undefined`, the model will not be updated. |
| 31 | + * - `errors`: Any parse errors encountered. If `undefined`, no errors are reported. |
| 32 | + */ |
| 33 | + parse: (rawValue: TRaw) => {value?: TValue; errors?: readonly ValidationError.WithoutFieldTree[]}; |
| 34 | + |
| 35 | + /** |
| 36 | + * Format the model value into the raw value. |
| 37 | + */ |
| 38 | + format: (value: TValue) => TRaw; |
| 39 | +} |
| 40 | + |
| 41 | +/** |
| 42 | + * A writable signal representing a "raw" UI value that is synchronized with a model signal |
| 43 | + * via parse/format transformations. |
| 44 | + * |
| 45 | + * @category control |
| 46 | + * @experimental 21.2.0 |
| 47 | + */ |
| 48 | +export interface TransformedValueSignal<TRaw> extends WritableSignal<TRaw> { |
| 49 | + /** |
| 50 | + * The current parse errors resulting from the last transformation. |
| 51 | + */ |
| 52 | + readonly parseErrors: Signal<readonly ValidationError.WithoutFieldTree[]>; |
| 53 | +} |
| 54 | + |
| 55 | +/** |
| 56 | + * Creates a writable signal representing a "raw" UI value that is transformed to/from a model |
| 57 | + * value via `parse` and `format` functions. |
| 58 | + * |
| 59 | + * This utility simplifies the creation of custom form controls that parse a user-facing value |
| 60 | + * representation into an underlying model value. For example, a numeric input that displays and |
| 61 | + * accepts string values but stores a number. |
| 62 | + * |
| 63 | + * @param value The model signal to synchronize with. |
| 64 | + * @param options Configuration including `parse` and `format` functions. |
| 65 | + * @returns A `TransformedValueSignal` representing the raw value with parse error tracking. |
| 66 | + * @experimental 21.2.0 |
| 67 | + * |
| 68 | + * @example |
| 69 | + * ```ts |
| 70 | + * @Component({ |
| 71 | + * selector: 'number-input', |
| 72 | + * template: `<input [value]="rawValue()" (input)="rawValue.set($event.target.value)" />`, |
| 73 | + * }) |
| 74 | + * export class NumberInput implements FormValueControl<number | null> { |
| 75 | + * readonly value = model.required<number | null>(); |
| 76 | + * |
| 77 | + * protected readonly rawValue = transformedValue(this.value, { |
| 78 | + * parse: (val) => { |
| 79 | + * if (val === '') return {value: null}; |
| 80 | + * const num = Number(val); |
| 81 | + * if (Number.isNaN(num)) { |
| 82 | + * return {errors: [{kind: 'parse', message: `${val} is not numeric`}]}; |
| 83 | + * } |
| 84 | + * return {value: num}; |
| 85 | + * }, |
| 86 | + * format: (val) => val?.toString() ?? '', |
| 87 | + * }); |
| 88 | + * } |
| 89 | + * ``` |
| 90 | + */ |
| 91 | +export function transformedValue<TValue, TRaw>( |
| 92 | + value: ModelSignal<TValue>, |
| 93 | + options: TransformedValueOptions<TValue, TRaw>, |
| 94 | +): TransformedValueSignal<TRaw> { |
| 95 | + const {parse, format} = options; |
| 96 | + |
| 97 | + const parseErrors = signal<readonly ValidationError.WithoutFieldTree[]>([]); |
| 98 | + const rawValue = linkedSignal(() => format(value())); |
| 99 | + |
| 100 | + const formFieldParseErrors = inject(FORM_FIELD_PARSE_ERRORS, {self: true, optional: true}); |
| 101 | + if (formFieldParseErrors) { |
| 102 | + formFieldParseErrors.set(parseErrors); |
| 103 | + } |
| 104 | + |
| 105 | + // Create the result signal with overridden set/update and a `parseErrors` property. |
| 106 | + const result = rawValue as WritableSignal<TRaw> & { |
| 107 | + parseErrors: Signal<readonly ValidationError.WithoutFieldTree[]>; |
| 108 | + }; |
| 109 | + const originalSet = result.set.bind(result); |
| 110 | + |
| 111 | + result.set = (newRawValue: TRaw) => { |
| 112 | + const result = parse(newRawValue); |
| 113 | + parseErrors.set(result.errors ?? []); |
| 114 | + if (result.value !== undefined) { |
| 115 | + value.set(result.value); |
| 116 | + } |
| 117 | + originalSet(newRawValue); |
| 118 | + }; |
| 119 | + |
| 120 | + result.update = (updateFn: (value: TRaw) => TRaw) => { |
| 121 | + result.set(updateFn(rawValue())); |
| 122 | + }; |
| 123 | + |
| 124 | + result.parseErrors = parseErrors.asReadonly(); |
| 125 | + |
| 126 | + return result; |
| 127 | +} |
0 commit comments