From e9a7b186bc58a42138df7d39139fef618d094164 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Tue, 2 Jun 2026 21:44:30 -0400 Subject: [PATCH 1/7] Use isSignal() instead of typeof check in readSignalOrValue --- .../src/utils/readSignalOrValue/readSignalOrValue.test.ts | 7 +++++++ .../src/utils/readSignalOrValue/readSignalOrValue.ts | 7 ++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frameworks/angular/src/utils/readSignalOrValue/readSignalOrValue.test.ts b/frameworks/angular/src/utils/readSignalOrValue/readSignalOrValue.test.ts index f13394cc..b8a868e4 100644 --- a/frameworks/angular/src/utils/readSignalOrValue/readSignalOrValue.test.ts +++ b/frameworks/angular/src/utils/readSignalOrValue/readSignalOrValue.test.ts @@ -11,6 +11,13 @@ describe('readSignalOrValue', () => { expect(readSignalOrValue(obj)).toBe(obj); }); + test('should return a plain function as-is when TValue is a function type', () => { + // When TValue is itself a function, the old `typeof === 'function'` check would + // incorrectly invoke it. isSignal() correctly treats it as a plain value. + const fn = (): string => 'result'; + expect(readSignalOrValue(fn)).toBe(fn); + }); + test('should read the signal when a signal is passed', () => { expect(readSignalOrValue(signal(42))).toBe(42); const obj = { a: 1 }; diff --git a/frameworks/angular/src/utils/readSignalOrValue/readSignalOrValue.ts b/frameworks/angular/src/utils/readSignalOrValue/readSignalOrValue.ts index 9dead025..14c35fc3 100644 --- a/frameworks/angular/src/utils/readSignalOrValue/readSignalOrValue.ts +++ b/frameworks/angular/src/utils/readSignalOrValue/readSignalOrValue.ts @@ -1,4 +1,4 @@ -import type { Signal } from '@angular/core'; +import { isSignal } from '@angular/core'; import type { SignalOrValue } from '../../types/index.ts'; /** @@ -10,8 +10,5 @@ import type { SignalOrValue } from '../../types/index.ts'; */ export function readSignalOrValue(value: SignalOrValue): TValue; export function readSignalOrValue(value: SignalOrValue): unknown { - if (typeof value === 'function') { - return (value as Signal)(); - } - return value; + return isSignal(value) ? value() : value; } From d1022c06fdb32fba6d5282f105f7121f5ff3e4f6 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Tue, 2 Jun 2026 22:18:39 -0400 Subject: [PATCH 2/7] Replace props object and ref callback with Angular-native directive The props object and ref callback were React concepts with no equivalent in Angular templates. Remove FieldElementProps and the props sub-object from FieldStore. Move name, autofocus, onFocus, onChange, and onBlur directly onto FieldStore so Angular developers access field state without a props namespace wrapper. Remove the ref callback. Angular templates have no mechanism to call a ref callback on a native element, so the internal elements array was never populated and focus management silently did nothing. Add registerElement(element): () => void to FieldStore as the replacement registration API. It pushes the element into the internal store and returns a per-element cleanup function, replacing the coarse isConnected filter that ran on component destroy. Add FormischFieldElementDirective ([formischFieldElement]). This is the Angular-native integration point, equivalent to [formControl] or Angular CDK directives like [cdkDrag]. Applied to any focusable element inside a formisch-field template, it: - Sets [attr.name] automatically via a host binding - Delegates (focus), (input), (change), and (blur) DOM events to field.onFocus, field.onChange, and field.onBlur via host bindings, covering both text inputs (input event) and checkboxes/selects (change event) without any manual template wiring - Registers the element via registerElement in the afterNextRender write phase and cleans up precisely via DestroyRef when destroyed With the directive a bare input only needs value bound explicitly: For custom wrapper components the flat API is used directly: [name]="field.name" (fieldFocus)="field.onFocus($event)" (fieldChange)="field.onChange($event)" (fieldBlur)="field.onBlur($event)" Update all playground pages, tests, and type tests to use the flat API. --- .../FormischField/FormischField.test.ts | 2 +- .../components/FormischField/FormischField.ts | 5 +- .../FormischFieldElement.ts | 58 +++++++++++++++ .../directives/FormischFieldElement/index.ts | 1 + frameworks/angular/src/directives/index.ts | 1 + .../injectField/injectField.test-d.ts | 18 ++++- .../functions/injectField/injectField.test.ts | 4 +- .../src/functions/injectField/injectField.ts | 63 ++++++++-------- frameworks/angular/src/index.ts | 1 + frameworks/angular/src/types/field.ts | 60 ++++++++-------- .../src/routes/login/login.component.ts | 16 ++--- .../src/routes/nested/nested.component.ts | 16 ++--- .../src/routes/payment/payment.component.ts | 40 +++++------ .../src/routes/special/special.component.ts | 72 +++++++++---------- .../src/routes/todos/todos.component.ts | 24 +++---- 15 files changed, 224 insertions(+), 157 deletions(-) create mode 100644 frameworks/angular/src/directives/FormischFieldElement/FormischFieldElement.ts create mode 100644 frameworks/angular/src/directives/FormischFieldElement/index.ts create mode 100644 frameworks/angular/src/directives/index.ts diff --git a/frameworks/angular/src/components/FormischField/FormischField.test.ts b/frameworks/angular/src/components/FormischField/FormischField.test.ts index 42dcb0f7..61f21368 100644 --- a/frameworks/angular/src/components/FormischField/FormischField.test.ts +++ b/frameworks/angular/src/components/FormischField/FormischField.test.ts @@ -23,7 +23,7 @@ describe('FormischField', () => { template: ` - + {{ field.errors() }} diff --git a/frameworks/angular/src/components/FormischField/FormischField.ts b/frameworks/angular/src/components/FormischField/FormischField.ts index e3f5f952..85d8477b 100644 --- a/frameworks/angular/src/components/FormischField/FormischField.ts +++ b/frameworks/angular/src/components/FormischField/FormischField.ts @@ -24,10 +24,7 @@ import type { FieldStore, FormStore } from '../../types/index.ts'; * ```html * * - * + * * * * ``` diff --git a/frameworks/angular/src/directives/FormischFieldElement/FormischFieldElement.ts b/frameworks/angular/src/directives/FormischFieldElement/FormischFieldElement.ts new file mode 100644 index 00000000..8dd43e20 --- /dev/null +++ b/frameworks/angular/src/directives/FormischFieldElement/FormischFieldElement.ts @@ -0,0 +1,58 @@ +import { + afterNextRender, + DestroyRef, + Directive, + ElementRef, + inject, + input, + type InputSignal, +} from '@angular/core'; +import type { FieldElement } from '@formisch/core/angular'; +import type { FieldStore } from '../../types/index.ts'; + +/** + * Registers a focusable element with its field store and wires all event + * handlers automatically. + * + * Apply this directive to any focusable native element inside a + * `` template. It sets the `name` attribute, handles + * `focus`, `input`, `change`, and `blur` events, and registers the element + * for focus management methods such as `focusField`. + * + * @example + * ```html + * + * + * + * + * + * ``` + */ +@Directive({ + selector: '[formischFieldElement]', + standalone: true, + host: { + '[attr.name]': 'formischFieldElement().name', + '(focus)': 'formischFieldElement().onFocus($event)', + '(input)': 'formischFieldElement().onChange($event)', + '(change)': 'formischFieldElement().onChange($event)', + '(blur)': 'formischFieldElement().onBlur($event)', + }, +}) +export class FormischFieldElementDirective { + readonly formischFieldElement: InputSignal = + input.required(); + + constructor() { + const el = inject>(ElementRef); + const destroyRef = inject(DestroyRef); + const fieldSignal = this.formischFieldElement; + + afterNextRender({ + write: () => { + const cleanup = fieldSignal().registerElement(el.nativeElement); + destroyRef.onDestroy(cleanup); + }, + }); + } +} diff --git a/frameworks/angular/src/directives/FormischFieldElement/index.ts b/frameworks/angular/src/directives/FormischFieldElement/index.ts new file mode 100644 index 00000000..df457949 --- /dev/null +++ b/frameworks/angular/src/directives/FormischFieldElement/index.ts @@ -0,0 +1 @@ +export * from './FormischFieldElement.ts'; diff --git a/frameworks/angular/src/directives/index.ts b/frameworks/angular/src/directives/index.ts new file mode 100644 index 00000000..6351b57d --- /dev/null +++ b/frameworks/angular/src/directives/index.ts @@ -0,0 +1 @@ +export * from './FormischFieldElement/index.ts'; diff --git a/frameworks/angular/src/functions/injectField/injectField.test-d.ts b/frameworks/angular/src/functions/injectField/injectField.test-d.ts index bb1809bd..2b58332e 100644 --- a/frameworks/angular/src/functions/injectField/injectField.test-d.ts +++ b/frameworks/angular/src/functions/injectField/injectField.test-d.ts @@ -63,13 +63,25 @@ describe('injectField', () => { expectTypeOf(field.isValid).toEqualTypeOf>(); }); - test('should expose props with name and autofocus', () => { + test('should expose flat name, autofocus, and event handlers', () => { const schema = v.object({ email: v.pipe(v.string(), v.email()) }); const form = injectForm({ schema }); const field = injectField(form, { path: ['email'] }); - expectTypeOf(field.props.name).toEqualTypeOf(); - expectTypeOf(field.props.autofocus).toEqualTypeOf(); + expectTypeOf(field.name).toEqualTypeOf(); + expectTypeOf(field.autofocus).toEqualTypeOf(); + expectTypeOf(field.onFocus).toEqualTypeOf<(event: FocusEvent) => void>(); + expectTypeOf(field.onChange).toEqualTypeOf<(event: Event) => void>(); + expectTypeOf(field.onBlur).toEqualTypeOf<(event: FocusEvent) => void>(); + }); + + test('should expose registerElement returning a cleanup function', () => { + const schema = v.object({ email: v.pipe(v.string(), v.email()) }); + const form = injectForm({ schema }); + const field = injectField(form, { path: ['email'] }); + + expectTypeOf(field.registerElement).toBeFunction(); + expectTypeOf(field.registerElement).returns.toEqualTypeOf<() => void>(); }); test('should reject invalid paths', () => { diff --git a/frameworks/angular/src/functions/injectField/injectField.test.ts b/frameworks/angular/src/functions/injectField/injectField.test.ts index dcb324f8..57c3dfbb 100644 --- a/frameworks/angular/src/functions/injectField/injectField.test.ts +++ b/frameworks/angular/src/functions/injectField/injectField.test.ts @@ -60,8 +60,8 @@ describe('injectField', () => { expect(field.input()).toBe('test@example.com'); }); - it('exposes a name prop as JSON-stringified path', () => { + it('exposes name as JSON-stringified path', () => { const { field } = setup(); - expect(field.props.name).toBe('["email"]'); + expect(field.name).toBe('["email"]'); }); }); diff --git a/frameworks/angular/src/functions/injectField/injectField.ts b/frameworks/angular/src/functions/injectField/injectField.ts index 518a1b58..82b967ce 100644 --- a/frameworks/angular/src/functions/injectField/injectField.ts +++ b/frameworks/angular/src/functions/injectField/injectField.ts @@ -48,7 +48,7 @@ export interface InjectFieldConfig< * @param form The form store instance. * @param config The field configuration. * - * @returns The field store with reactive Signal properties and element props. + * @returns The field store with reactive Signal properties. */ export function injectField< TSchema extends FormSchema, @@ -91,37 +91,38 @@ export function injectField( setFieldInput(internalFormStore(), path(), value); validateIfRequired(internalFormStore(), internalFieldStore(), 'input'); }, - props: { - get name() { - return internalFieldStore().name; - }, - get autofocus() { - return !!internalFieldStore().errors.value; - }, - ref(element) { - if (element) { - internalFieldStore().elements.push(element); - } - }, - onFocus() { - setFieldBool(internalFieldStore(), 'isTouched', true); - validateIfRequired(internalFormStore(), internalFieldStore(), 'touch'); - }, - onChange(event) { - setFieldInput( - internalFormStore(), - path(), - getElementInput( - event.currentTarget as FieldElement, - internalFieldStore() - ) + registerElement(element) { + internalFieldStore().elements.push(element); + return () => { + internalFieldStore().elements = internalFieldStore().elements.filter( + (el) => el !== element ); - validateIfRequired(internalFormStore(), internalFieldStore(), 'input'); - validateIfRequired(internalFormStore(), internalFieldStore(), 'change'); - }, - onBlur() { - validateIfRequired(internalFormStore(), internalFieldStore(), 'blur'); - }, + }; + }, + get name() { + return internalFieldStore().name; + }, + get autofocus() { + return !!internalFieldStore().errors.value; + }, + onFocus() { + setFieldBool(internalFieldStore(), 'isTouched', true); + validateIfRequired(internalFormStore(), internalFieldStore(), 'touch'); + }, + onChange(event) { + setFieldInput( + internalFormStore(), + path(), + getElementInput( + event.currentTarget as FieldElement, + internalFieldStore() + ) + ); + validateIfRequired(internalFormStore(), internalFieldStore(), 'input'); + validateIfRequired(internalFormStore(), internalFieldStore(), 'change'); + }, + onBlur() { + validateIfRequired(internalFormStore(), internalFieldStore(), 'blur'); }, }; } diff --git a/frameworks/angular/src/index.ts b/frameworks/angular/src/index.ts index 7085ea3f..f7d5136f 100644 --- a/frameworks/angular/src/index.ts +++ b/frameworks/angular/src/index.ts @@ -15,5 +15,6 @@ export type { } from '@formisch/core/angular'; export * from '@formisch/methods/angular'; export * from './components/index.ts'; +export * from './directives/index.ts'; export * from './functions/index.ts'; export * from './types/index.ts'; diff --git a/frameworks/angular/src/types/field.ts b/frameworks/angular/src/types/field.ts index 524709f3..2c18e700 100644 --- a/frameworks/angular/src/types/field.ts +++ b/frameworks/angular/src/types/field.ts @@ -10,36 +10,6 @@ import type { } from '@formisch/core/angular'; import type * as v from 'valibot'; -/** - * Field element props interface. - */ -export interface FieldElementProps { - /** - * The name attribute of the field element. - */ - readonly name: string; - /** - * Whether to autofocus the field element when there are errors. - */ - readonly autofocus: boolean; - /** - * The ref callback to register the field element. - */ - readonly ref: (element: FieldElement | null) => void; - /** - * The focus event handler of the field element. - */ - readonly onFocus: (event: FocusEvent) => void; - /** - * The change event handler of the field element. - */ - readonly onChange: (event: Event) => void; - /** - * The blur event handler of the field element. - */ - readonly onBlur: (event: FocusEvent) => void; -} - /** * Field store interface. */ @@ -80,9 +50,35 @@ export interface FieldStore< value: PartialValues, TFieldPath>> ) => void; /** - * The props to spread onto the field element for integration. + * Registers a DOM element with the field for focus management. + * Apply the `formischFieldElement` directive to a focusable element instead + * of calling this directly. + * + * @param element The field element to register. + * + * @returns A cleanup function that unregisters the element. + */ + readonly registerElement: (element: FieldElement) => () => void; + /** + * The name attribute value derived from the field path. + */ + readonly name: string; + /** + * Whether the field element should be autofocused (true when the field has errors). + */ + readonly autofocus: boolean; + /** + * Marks the field as touched and triggers touch-mode validation. + */ + readonly onFocus: (event: FocusEvent) => void; + /** + * Updates the field value from a DOM event and triggers input and change validation. */ - readonly props: FieldElementProps; + readonly onChange: (event: Event) => void; + /** + * Triggers blur-mode validation. + */ + readonly onBlur: (event: FocusEvent) => void; } /** diff --git a/playgrounds/angular/src/routes/login/login.component.ts b/playgrounds/angular/src/routes/login/login.component.ts index 7120ca90..4bd6db73 100644 --- a/playgrounds/angular/src/routes/login/login.component.ts +++ b/playgrounds/angular/src/routes/login/login.component.ts @@ -40,16 +40,16 @@ const LoginSchema = v.object({ @@ -57,16 +57,16 @@ const LoginSchema = v.object({ diff --git a/playgrounds/angular/src/routes/nested/nested.component.ts b/playgrounds/angular/src/routes/nested/nested.component.ts index d48995f1..ace8b947 100644 --- a/playgrounds/angular/src/routes/nested/nested.component.ts +++ b/playgrounds/angular/src/routes/nested/nested.component.ts @@ -67,15 +67,15 @@ const NestedFormSchema = v.object({ > @@ -117,15 +117,15 @@ const NestedFormSchema = v.object({ > diff --git a/playgrounds/angular/src/routes/payment/payment.component.ts b/playgrounds/angular/src/routes/payment/payment.component.ts index 533f51e1..44307d25 100644 --- a/playgrounds/angular/src/routes/payment/payment.component.ts +++ b/playgrounds/angular/src/routes/payment/payment.component.ts @@ -77,16 +77,16 @@ const PaymentSchema = v.intersect([ @@ -94,16 +94,16 @@ const PaymentSchema = v.intersect([ @@ -112,16 +112,16 @@ const PaymentSchema = v.intersect([ @@ -129,16 +129,16 @@ const PaymentSchema = v.intersect([ @@ -148,16 +148,16 @@ const PaymentSchema = v.intersect([ diff --git a/playgrounds/angular/src/routes/special/special.component.ts b/playgrounds/angular/src/routes/special/special.component.ts index e0d9768c..3d3eb120 100644 --- a/playgrounds/angular/src/routes/special/special.component.ts +++ b/playgrounds/angular/src/routes/special/special.component.ts @@ -55,14 +55,14 @@ const SpecialFormSchema = v.object({ @@ -70,13 +70,13 @@ const SpecialFormSchema = v.object({ @@ -94,15 +94,15 @@ const SpecialFormSchema = v.object({ @@ -112,13 +112,13 @@ const SpecialFormSchema = v.object({ @@ -126,14 +126,14 @@ const SpecialFormSchema = v.object({ @@ -141,15 +141,15 @@ const SpecialFormSchema = v.object({ @@ -157,14 +157,14 @@ const SpecialFormSchema = v.object({ @@ -172,14 +172,14 @@ const SpecialFormSchema = v.object({ @@ -187,13 +187,13 @@ const SpecialFormSchema = v.object({ diff --git a/playgrounds/angular/src/routes/todos/todos.component.ts b/playgrounds/angular/src/routes/todos/todos.component.ts index 49e5b28a..76ad1d94 100644 --- a/playgrounds/angular/src/routes/todos/todos.component.ts +++ b/playgrounds/angular/src/routes/todos/todos.component.ts @@ -69,16 +69,16 @@ const TodosSchema = v.object({ @@ -105,16 +105,16 @@ const TodosSchema = v.object({ > @@ -126,15 +126,15 @@ const TodosSchema = v.object({ > From 0cfde04831c18b76f57cd8dd56cdc292f2ea321a Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Tue, 2 Jun 2026 22:22:06 -0400 Subject: [PATCH 3/7] Test validate initial triggers validation after first render --- .../functions/injectForm/injectForm.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/frameworks/angular/src/functions/injectForm/injectForm.test.ts b/frameworks/angular/src/functions/injectForm/injectForm.test.ts index 59dd0734..16b9dfd3 100644 --- a/frameworks/angular/src/functions/injectForm/injectForm.test.ts +++ b/frameworks/angular/src/functions/injectForm/injectForm.test.ts @@ -1,4 +1,7 @@ -import { provideExperimentalZonelessChangeDetection } from '@angular/core'; +import { + Component, + provideExperimentalZonelessChangeDetection, +} from '@angular/core'; import { TestBed } from '@angular/core/testing'; import * as v from 'valibot'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -53,4 +56,17 @@ describe('injectForm', () => { const form = setup(); expect(form.errors()).toBeNull(); }); + + it('triggers validation after initial render when validate is initial', async () => { + @Component({ standalone: true, template: '' }) + class TestComponent { + readonly form = injectForm({ schema: Schema, validate: 'initial' }); + } + + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.form.isValid()).toBe(false); + }); }); From dde151e0678c8c22b80682596773482f7ee851c6 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Tue, 2 Jun 2026 22:31:27 -0400 Subject: [PATCH 4/7] Use viewChild and afterRenderEffect for FormischForm class forwarding Two issues existed in the previous implementation: The inner
element was located via querySelector on the host, which is fragile and bypasses Angular's view query system. Replace this with a viewChild.required reference using a #formEl template variable so the reference is typed, direct, and managed by Angular. Class forwarding ran only once inside afterNextRender. If a parent bound classes dynamically to after the initial render, those changes were never picked up. Replace the class-forwarding portion with afterRenderEffect using the earlyRead/write phase split: - earlyRead reads the host element's className from the DOM without touching it, returning it as the phase result. - write receives that result as a Signal, reads it, and if non-empty applies it to the inner element and clears the host attribute. Using a dedicated write phase prevents layout thrashing by keeping DOM reads and writes in separate phases. afterRenderEffect runs after every render cycle, so class changes applied by Angular's change detection are always forwarded. Once the host class is cleared the write phase becomes a no-op on subsequent renders unless the parent re-binds a class. Element registration (writing the form element reference into the internal store) remains in afterNextRender with a write phase since the element reference never changes after the first render. Capture hostEl, formEl, and ofSignal as local constants before the callbacks to avoid this references inside effect closures, consistent with the pattern used in FormischFieldElementDirective. Add a dedicated FormischForm class forwarding describe block to the test suite that verifies classes are moved from the host to the inner form element and removed from the host after render. --- .../FormischForm/FormischForm.test.ts | 41 +++++++++++++++++++ .../components/FormischForm/FormischForm.ts | 39 ++++++++++++------ 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/frameworks/angular/src/components/FormischForm/FormischForm.test.ts b/frameworks/angular/src/components/FormischForm/FormischForm.test.ts index 3fee0ec1..8cf838fb 100644 --- a/frameworks/angular/src/components/FormischForm/FormischForm.test.ts +++ b/frameworks/angular/src/components/FormischForm/FormischForm.test.ts @@ -9,6 +9,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { injectForm } from '../../functions/index.ts'; import { loadDistComponent } from '../../vitest/loadDistComponent.ts'; + const Schema = v.object({ email: v.pipe(v.string(), v.email()) }); let TestHost: Type; @@ -72,3 +73,43 @@ describe('FormischForm', () => { expect(internalStore.element).toBeInstanceOf(HTMLFormElement); }); }); + +describe('FormischForm class forwarding', () => { + let TestHost: Type; + + beforeAll(async () => { + const FormischForm = await loadDistComponent('FormischForm'); + + @Component({ + standalone: true, + imports: [FormischForm], + template: ` + + + + `, + }) + class TestHostComponent { + form = injectForm({ schema: v.object({ email: v.string() }) }); + handleSubmit = vi.fn(); + } + + TestHost = TestHostComponent; + }); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHost], + providers: [provideExperimentalZonelessChangeDetection()], + }); + }); + + it('moves classes from the host element to the inner form element', async () => { + const fixture = TestBed.createComponent(TestHost); + fixture.detectChanges(); + await fixture.whenStable(); + const host = fixture.nativeElement as HTMLElement; + expect(host.querySelector('form')?.className).toBe('foo bar'); + expect(host.querySelector('formisch-form')?.getAttribute('class')).toBeNull(); + }); +}); diff --git a/frameworks/angular/src/components/FormischForm/FormischForm.ts b/frameworks/angular/src/components/FormischForm/FormischForm.ts index 75af9517..bb357d96 100644 --- a/frameworks/angular/src/components/FormischForm/FormischForm.ts +++ b/frameworks/angular/src/components/FormischForm/FormischForm.ts @@ -1,10 +1,12 @@ import { afterNextRender, + afterRenderEffect, Component, ElementRef, inject, input, type InputSignal, + viewChild, } from '@angular/core'; import { type FormSchema, @@ -31,7 +33,7 @@ import type { FormStore } from '../../types/index.ts'; // Render no box of its own so the inner `` becomes the effective // element in the layout, matching the other framework wrappers. styles: ':host { display: contents; }', - template: ` + template: ` `, }) @@ -42,21 +44,32 @@ export class FormischForm { input.required>(); private readonly hostEl = inject>(ElementRef); + private readonly formEl = viewChild.required>('formEl'); constructor() { - afterNextRender(() => { - const hostElement = this.hostEl.nativeElement; - const formElement = hostElement.querySelector('form'); - if (formElement) { - this.of()[INTERNAL].element = formElement; - // Forward classes from the host to the actual `
` element so - // layout utilities (e.g. `space-y`) apply to the form's children - // instead of the host, which renders no box of its own. - if (hostElement.className) { - formElement.className = hostElement.className; - hostElement.removeAttribute('class'); + const ofSignal = this.of; + const hostEl = this.hostEl; + const formEl = this.formEl; + + // Register the inner form element with the store once after first render. + afterNextRender({ + write: () => { + ofSignal()[INTERNAL].element = formEl().nativeElement; + }, + }); + + // Forward classes from the host to the inner after every render so + // layout utilities (e.g. Tailwind space-y-*) applied to + // affect the form's children rather than the invisible host element. + afterRenderEffect({ + earlyRead: () => hostEl.nativeElement.className, + write: (classNameSignal) => { + const className = classNameSignal(); + if (className) { + formEl().nativeElement.className = className; + hostEl.nativeElement.removeAttribute('class'); } - } + }, }); } From 1dfad2796676f17d7bf120de316addde2ebd5bf1 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Tue, 2 Jun 2026 22:33:45 -0400 Subject: [PATCH 5/7] Add ngTemplateContextGuard to FormischField and FormischFieldArray Without a type guard, the let-field and let-fieldArray variables in ng-template blocks are typed as any by Angular's template type checker. This means users get no autocompletion on field.input(), field.errors(), fieldArray.items(), or any other FieldStore and FieldArrayStore members. Add a static ngTemplateContextGuard method to both components. Angular's template type checker calls this method to narrow the $implicit context type when a consumer writes let-field or let-fieldArray on an ng-template. The guard propagates the component's TSchema and TFieldPath/TFieldArrayPath generics into the template variable type, so the IDE can provide accurate completions and type errors for the full FieldStore and FieldArrayStore surfaces. Export FormischFieldContext and FormischFieldArrayContext interfaces alongside the components so consumers can reference the context type directly if needed (e.g. when manually typing a TemplateRef). --- .../components/FormischField/FormischField.ts | 21 +++++++++++++++++++ .../FormischFieldArray/FormischFieldArray.ts | 21 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/frameworks/angular/src/components/FormischField/FormischField.ts b/frameworks/angular/src/components/FormischField/FormischField.ts index 85d8477b..d7a16cd8 100644 --- a/frameworks/angular/src/components/FormischField/FormischField.ts +++ b/frameworks/angular/src/components/FormischField/FormischField.ts @@ -16,6 +16,17 @@ import type * as v from 'valibot'; import { injectField } from '../../functions/index.ts'; import type { FieldStore, FormStore } from '../../types/index.ts'; +/** + * Template context type for the FormischField content template. + * Provides type information for the `let-field` template variable. + */ +export interface FormischFieldContext< + TSchema extends FormSchema = FormSchema, + TFieldPath extends RequiredPath = RequiredPath, +> { + $implicit: FieldStore; +} + /** * Headless field component that provides reactive field state via an Angular template. * Uses ContentChild TemplateRef pattern to pass the FieldStore as $implicit context. @@ -60,4 +71,14 @@ export class FormischField< TSchema, TFieldPath >(this.of, { path: this.path }); + + static ngTemplateContextGuard< + TSchema extends FormSchema, + TFieldPath extends RequiredPath, + >( + _dir: FormischField, + ctx: unknown + ): ctx is FormischFieldContext { + return true; + } } diff --git a/frameworks/angular/src/components/FormischFieldArray/FormischFieldArray.ts b/frameworks/angular/src/components/FormischFieldArray/FormischFieldArray.ts index 62829f94..e07fa69c 100644 --- a/frameworks/angular/src/components/FormischFieldArray/FormischFieldArray.ts +++ b/frameworks/angular/src/components/FormischFieldArray/FormischFieldArray.ts @@ -16,6 +16,17 @@ import type * as v from 'valibot'; import { injectFieldArray } from '../../functions/index.ts'; import type { FieldArrayStore, FormStore } from '../../types/index.ts'; +/** + * Template context type for the FormischFieldArray content template. + * Provides type information for the `let-fieldArray` template variable. + */ +export interface FormischFieldArrayContext< + TSchema extends FormSchema = FormSchema, + TFieldArrayPath extends RequiredPath = RequiredPath, +> { + $implicit: FieldArrayStore; +} + /** * Headless field array component that provides reactive field array state via an Angular template. * Uses ContentChild TemplateRef pattern to pass the FieldArrayStore as $implicit context. @@ -63,4 +74,14 @@ export class FormischFieldArray< injectFieldArray(this.of, { path: this.path, }); + + static ngTemplateContextGuard< + TSchema extends FormSchema, + TFieldArrayPath extends RequiredPath, + >( + _dir: FormischFieldArray, + ctx: unknown + ): ctx is FormischFieldArrayContext { + return true; + } } From 899302c6abf0d7307b8cfc5ae73d5c3afa2000aa Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Tue, 2 Jun 2026 22:36:51 -0400 Subject: [PATCH 6/7] Fix subscription leak, setTimeout, and location.pathname in AppComponent Three Angular-nativeness issues existed in the root app component. The router.events subscription was never unsubscribed, leaking memory whenever the component is destroyed (e.g. in tests or SSR). Pipe it through takeUntilDestroyed() from @angular/core/rxjs-interop, which automatically unsubscribes when the component's DestroyRef fires. Also add a filter() operator to narrow the event type to NavigationEnd so the subscribe callback is typed and the instanceof check is gone. setTimeout was used to defer the DOM read after navigation so that Angular could finish updating routerLinkActive state before reading offsetLeft and offsetWidth. Replace it with afterNextRender({ read }, { injector }) which is the Angular-native equivalent: it schedules the callback in the read phase after Angular has completed its next render cycle, using the component's injector so the ref is scoped correctly. location.pathname referenced the global browser Location object directly, which would throw in SSR and bypasses Angular's routing abstractions. Replace it with Angular's Location service from @angular/common injected via inject(Location), using this.location.path() to get the current path. Also replace the @HostListener decorator on onResize with a host property binding on the @Component decorator. The Angular style guide marks @HostListener as legacy and recommends the host object for new code. --- playgrounds/angular/src/app.component.ts | 31 ++++++++++++++++++------ 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/playgrounds/angular/src/app.component.ts b/playgrounds/angular/src/app.component.ts index 46c95922..abc9a759 100644 --- a/playgrounds/angular/src/app.component.ts +++ b/playgrounds/angular/src/app.component.ts @@ -1,11 +1,14 @@ +import { Location } from '@angular/common'; import { + afterNextRender, Component, ElementRef, - HostListener, inject, + Injector, signal, viewChild, } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router, @@ -13,6 +16,7 @@ import { RouterLinkActive, RouterOutlet, } from '@angular/router'; +import { filter } from 'rxjs'; import { disableTransitions } from './utils/disable-transitions.ts'; interface IndicatorStyle { @@ -27,6 +31,9 @@ interface IndicatorStyle { selector: 'app-root', standalone: true, imports: [RouterOutlet, RouterLink, RouterLinkActive], + host: { + '(window:resize)': 'onResize()', + }, template: `
>('navEl'); + private readonly injector = inject(Injector); + private readonly location = inject(Location); protected readonly indicatorStyle = signal( undefined ); constructor() { const router = inject(Router); - router.events.subscribe((event) => { - if (event instanceof NavigationEnd) { - setTimeout(() => this.updateIndicatorStyle()); - } - }); + + router.events + .pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed() + ) + .subscribe(() => { + afterNextRender( + { read: () => this.updateIndicatorStyle() }, + { injector: this.injector } + ); + }); } - @HostListener('window:resize') onResize(): void { disableTransitions(); this.updateIndicatorStyle(); @@ -98,7 +113,7 @@ export class AppComponent { const nav = this.navEl()?.nativeElement; if (!nav) return; const activeEl = [...nav.children].find((el) => - (el as HTMLAnchorElement).href?.endsWith(location.pathname) + (el as HTMLAnchorElement).href?.endsWith(this.location.path()) ) as HTMLAnchorElement | undefined; this.indicatorStyle.set( activeEl From 381e1be67843a65da067897315e10fe5978c7dd3 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Tue, 2 Jun 2026 22:42:56 -0400 Subject: [PATCH 7/7] Add directive tests and use phased afterNextRender in injectForm Add FormischFieldElementDirective.test.ts covering the four host bindings: name attribute is set from the field path, focus event marks the field as touched, input event updates the field value, and change event also updates the field value. All other exported APIs have runtime tests; this fills the gap for the new directive. Switch the validate-initial path in injectForm from the plain afterNextRender callback to the phased write form, consistent with every other afterNextRender call in the package. --- .../FormischFieldElement.test.ts | 97 +++++++++++++++++++ .../src/functions/injectForm/injectForm.ts | 6 +- 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 frameworks/angular/src/directives/FormischFieldElement/FormischFieldElement.test.ts diff --git a/frameworks/angular/src/directives/FormischFieldElement/FormischFieldElement.test.ts b/frameworks/angular/src/directives/FormischFieldElement/FormischFieldElement.test.ts new file mode 100644 index 00000000..eab907b1 --- /dev/null +++ b/frameworks/angular/src/directives/FormischFieldElement/FormischFieldElement.test.ts @@ -0,0 +1,97 @@ +import { + Component, + provideExperimentalZonelessChangeDetection, + type Type, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import * as v from 'valibot'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { injectField, injectForm } from '../../functions/index.ts'; +import { loadDistComponent } from '../../vitest/loadDistComponent.ts'; + +const Schema = v.object({ email: v.pipe(v.string(), v.email()) }); + +let TestHost: Type; + +describe('FormischFieldElementDirective', () => { + beforeAll(async () => { + const FormischFieldElementDirective = await loadDistComponent( + 'FormischFieldElementDirective' + ); + + @Component({ + standalone: true, + imports: [FormischFieldElementDirective], + template: ``, + }) + class TestHostComponent { + readonly form = injectForm({ schema: Schema }); + readonly field = injectField(this.form, { path: ['email'] }); + } + + TestHost = TestHostComponent; + }); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHost], + providers: [provideExperimentalZonelessChangeDetection()], + }); + }); + + function getInput(fixture: ReturnType): HTMLInputElement { + const input = (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="input"]' + ); + if (!input) throw new Error('Expected input element to render.'); + return input; + } + + function getField(fixture: ReturnType) { + return (fixture.componentInstance as { field: ReturnType> }).field; + } + + it('sets the name attribute derived from the field path', async () => { + const fixture = TestBed.createComponent(TestHost); + fixture.detectChanges(); + await fixture.whenStable(); + expect(getInput(fixture).getAttribute('name')).not.toBeNull(); + }); + + it('marks the field as touched when the element receives focus', async () => { + const fixture = TestBed.createComponent(TestHost); + fixture.detectChanges(); + await fixture.whenStable(); + + getInput(fixture).dispatchEvent(new FocusEvent('focus')); + fixture.detectChanges(); + + expect(getField(fixture).isTouched()).toBe(true); + }); + + it('updates the field value when the element receives an input event', async () => { + const fixture = TestBed.createComponent(TestHost); + fixture.detectChanges(); + await fixture.whenStable(); + const input = getInput(fixture); + + input.value = 'test@example.com'; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + expect(getField(fixture).input()).toBe('test@example.com'); + }); + + it('updates the field value when the element receives a change event', async () => { + const fixture = TestBed.createComponent(TestHost); + fixture.detectChanges(); + await fixture.whenStable(); + const input = getInput(fixture); + + input.value = 'change@example.com'; + input.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + expect(getField(fixture).input()).toBe('change@example.com'); + }); +}); diff --git a/frameworks/angular/src/functions/injectForm/injectForm.ts b/frameworks/angular/src/functions/injectForm/injectForm.ts index 545402c9..46b24614 100644 --- a/frameworks/angular/src/functions/injectForm/injectForm.ts +++ b/frameworks/angular/src/functions/injectForm/injectForm.ts @@ -37,8 +37,10 @@ export function injectForm(config: FormConfig): FormStore { ); if (config.validate === 'initial') { - afterNextRender(() => { - void validateFormInput(internalFormStore); + afterNextRender({ + write: () => { + void validateFormInput(internalFormStore); + }, }); }