Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('FormischField', () => {
template: `
<formisch-field [of]="form" [path]="['email']">
<ng-template let-field>
<input [name]="field.props.name" data-testid="input" />
<input [name]="field.name" data-testid="input" />
<span data-testid="errors">{{ field.errors() }}</span>
</ng-template>
</formisch-field>
Expand Down
26 changes: 22 additions & 4 deletions frameworks/angular/src/components/FormischField/FormischField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TSchema, TFieldPath>;
}

/**
* Headless field component that provides reactive field state via an Angular template.
* Uses ContentChild TemplateRef pattern to pass the FieldStore as $implicit context.
Expand All @@ -24,10 +35,7 @@ import type { FieldStore, FormStore } from '../../types/index.ts';
* ```html
* <formisch-field [of]="form" [path]="['email']">
* <ng-template let-field>
* <input [name]="field.props.name" [value]="field.input()"
* (focus)="field.props.onFocus($event)"
* (change)="field.props.onChange($event)"
* (blur)="field.props.onBlur($event)" />
* <input [formischFieldElement]="field" [value]="field.input()" />
* </ng-template>
* </formisch-field>
* ```
Expand Down Expand Up @@ -63,4 +71,14 @@ export class FormischField<
TSchema,
TFieldPath
>(this.of, { path: this.path });

static ngTemplateContextGuard<
TSchema extends FormSchema,
TFieldPath extends RequiredPath,
>(
_dir: FormischField<TSchema, TFieldPath>,
ctx: unknown
): ctx is FormischFieldContext<TSchema, TFieldPath> {
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<TSchema, TFieldArrayPath>;
}

/**
* 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.
Expand Down Expand Up @@ -63,4 +74,14 @@ export class FormischFieldArray<
injectFieldArray<TSchema, TFieldArrayPath>(this.of, {
path: this.path,
});

static ngTemplateContextGuard<
TSchema extends FormSchema,
TFieldArrayPath extends RequiredPath,
>(
_dir: FormischFieldArray<TSchema, TFieldArrayPath>,
ctx: unknown
): ctx is FormischFieldArrayContext<TSchema, TFieldArrayPath> {
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;
Expand Down Expand Up @@ -72,3 +73,43 @@ describe('FormischForm', () => {
expect(internalStore.element).toBeInstanceOf(HTMLFormElement);
});
});

describe('FormischForm class forwarding', () => {
let TestHost: Type<unknown>;

beforeAll(async () => {
const FormischForm = await loadDistComponent('FormischForm');

@Component({
standalone: true,
imports: [FormischForm],
template: `
<formisch-form [of]="form" [submitFn]="handleSubmit" class="foo bar">
<button type="submit">Submit</button>
</formisch-form>
`,
})
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();
});
});
39 changes: 26 additions & 13 deletions frameworks/angular/src/components/FormischForm/FormischForm.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
afterNextRender,
afterRenderEffect,
Component,
ElementRef,
inject,
input,
type InputSignal,
viewChild,
} from '@angular/core';
import {
type FormSchema,
Expand All @@ -31,7 +33,7 @@ import type { FormStore } from '../../types/index.ts';
// Render no box of its own so the inner `<form>` becomes the effective
// element in the layout, matching the other framework wrappers.
styles: ':host { display: contents; }',
template: `<form novalidate (submit)="handleFormSubmit($event)">
template: `<form #formEl novalidate (submit)="handleFormSubmit($event)">
<ng-content />
</form>`,
})
Expand All @@ -42,21 +44,32 @@ export class FormischForm<TSchema extends FormSchema = FormSchema> {
input.required<SubmitEventHandler<TSchema>>();

private readonly hostEl = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly formEl = viewChild.required<ElementRef<HTMLFormElement>>('formEl');

constructor() {
afterNextRender(() => {
const hostElement = this.hostEl.nativeElement;
const formElement = hostElement.querySelector<HTMLFormElement>('form');
if (formElement) {
this.of()[INTERNAL].element = formElement;
// Forward classes from the host to the actual `<form>` 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 <form> after every render so
// layout utilities (e.g. Tailwind space-y-*) applied to <formisch-form>
// 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');
}
}
},
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<unknown>;

describe('FormischFieldElementDirective', () => {
beforeAll(async () => {
const FormischFieldElementDirective = await loadDistComponent(
'FormischFieldElementDirective'
);

@Component({
standalone: true,
imports: [FormischFieldElementDirective],
template: `<input [formischFieldElement]="field" data-testid="input" />`,
})
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<typeof TestBed.createComponent>): HTMLInputElement {
const input = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>(
'[data-testid="input"]'
);
if (!input) throw new Error('Expected input element to render.');
return input;
}

function getField(fixture: ReturnType<typeof TestBed.createComponent>) {
return (fixture.componentInstance as { field: ReturnType<typeof injectField<typeof Schema, ['email']>> }).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');
});
});
Original file line number Diff line number Diff line change
@@ -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
* `<formisch-field>` 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
* <formisch-field [of]="form" [path]="['email']">
* <ng-template let-field>
* <input [formischFieldElement]="field" [value]="field.input()" />
* </ng-template>
* </formisch-field>
* ```
*/
@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<FieldStore> =
input.required<FieldStore>();

constructor() {
const el = inject<ElementRef<FieldElement>>(ElementRef);
const destroyRef = inject(DestroyRef);
const fieldSignal = this.formischFieldElement;

afterNextRender({
write: () => {
const cleanup = fieldSignal().registerElement(el.nativeElement);
destroyRef.onDestroy(cleanup);
},
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './FormischFieldElement.ts';
1 change: 1 addition & 0 deletions frameworks/angular/src/directives/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './FormischFieldElement/index.ts';
18 changes: 15 additions & 3 deletions frameworks/angular/src/functions/injectField/injectField.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,25 @@ describe('injectField', () => {
expectTypeOf(field.isValid).toEqualTypeOf<Signal<boolean>>();
});

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<string>();
expectTypeOf(field.props.autofocus).toEqualTypeOf<boolean>();
expectTypeOf(field.name).toEqualTypeOf<string>();
expectTypeOf(field.autofocus).toEqualTypeOf<boolean>();
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', () => {
Expand Down
Loading