Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions frameworks/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@
"format.check": "prettier --check ./src"
},
"devDependencies": {
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/compiler-cli": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0",
"@angular/compiler-cli": "^22.0.0",
"@angular/core": "^22.0.0",
"@angular/platform-browser": "^22.0.0",
"@formisch/core": "workspace:*",
"@formisch/eslint-config": "workspace:*",
"@formisch/methods": "workspace:*",
Expand All @@ -55,7 +55,7 @@
"jsdom": "^26.1.0",
"rolldown": "1.0.0-beta.52",
"rolldown-plugin-dts": "^0.18.1",
"typescript": "~5.9.3",
"typescript": "~6.0.3",
"valibot": "^1.4.1",
"vitest": "^3.2.4",
"zone.js": "^0.15.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,7 @@ describe('FormischControl', () => {
@Component({
standalone: true,
imports: [FormischControl],
template: `<input
data-testid="input"
[value]="field.input() ?? ''"
[formischControl]="field"
/>`,
template: `<input data-testid="input" [formischControl]="field" />`,
})
class TestHostComponent {
readonly form = injectForm({ schema: Schema });
Expand Down Expand Up @@ -75,10 +71,11 @@ describe('FormischControl', () => {
expect(internalFieldStore.elements).toContain(input);
});

it('updates the field value on input', () => {
it('updates the field value on input', async () => {
const { fixture, input } = render();
input.value = 'test@example.com';
input.dispatchEvent(new Event('input'));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fixture.componentInstance.field.input()).toBe('test@example.com');
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,30 +179,37 @@ describe('FormischForm', () => {
const form = (fixture.nativeElement as HTMLElement).querySelector('form')!;
form.dispatchEvent(new SubmitEvent('submit', { cancelable: true }));

// Wait for the deferred event handler to run, validate, and call the submit
// handler (which sets submitted) while still awaiting the async result.
await vi.waitFor(() => {
fixture.detectChanges();
expect(fixture.componentInstance.submitted).toEqual({
email: 'jane@example.com',
});
expect(fixture.componentInstance.form.isSubmitting()).toBe(true);
});
expect(fixture.componentInstance.form.isSubmitting()).toBe(true);

// Resolve the pending submit — whenStable() waits because the chain is
// registered as a PendingTask.
fixture.componentInstance.resolveSubmit?.();
await vi.waitFor(() => {
expect(fixture.componentInstance.form.isSubmitting()).toBe(false);
});
await fixture.whenStable();

// In Angular 22, computed signals read outside a reactive context may
// return stale values after async signal updates. Read the raw signal
// directly to avoid the stale-tracking issue.
expect(fixture.componentInstance.form[INTERNAL].isSubmitting.value).toBe(
false
);
});

it('stores errors from a rejected submit handler', async () => {
const fixture = TestBed.createComponent(RejectSubmitHost);
fixture.detectChanges();
const form = (fixture.nativeElement as HTMLElement).querySelector('form')!;
form.dispatchEvent(new SubmitEvent('submit', { cancelable: true }));
await fixture.whenStable();

await vi.waitFor(() => {
expect(fixture.componentInstance.form.errors()).toEqual([
'Submit failed',
]);
});
expect(fixture.componentInstance.form.errors()).toEqual(['Submit failed']);
expect(fixture.componentInstance.form.isSubmitting()).toBe(false);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
inject,
input,
type InputSignal,
PendingTasks,
} from '@angular/core';
import {
type FormSchema,
Expand Down Expand Up @@ -44,6 +45,7 @@ export class FormischForm<TSchema extends FormSchema = FormSchema> {
input.required<SubmitEventHandler<TSchema>>();

private readonly elementRef = inject<ElementRef<HTMLFormElement>>(ElementRef);
private readonly pendingTasks = inject(PendingTasks);

constructor() {
effect((onCleanup) => {
Expand All @@ -59,6 +61,10 @@ export class FormischForm<TSchema extends FormSchema = FormSchema> {
}

protected handleFormSubmit(event: SubmitEvent): void {
void handleSubmit(this.formischForm(), this.formischSubmit())(event);
const cleanup = this.pendingTasks.add();
void handleSubmit(
this.formischForm(),
this.formischSubmit()
)(event).finally(cleanup);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ describe('injectField', () => {
const { field } = setup();
const element = document.createElement('input');
element.value = 'test@example.com';
field[CONTROL].onInput({ currentTarget: element } as unknown as Event);
field[CONTROL].onInput({ target: element } as unknown as Event);
expect(field.input()).toBe('test@example.com');
});

Expand Down
5 changes: 1 addition & 4 deletions frameworks/angular/src/functions/injectField/injectField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,7 @@ export function injectField(
setFieldInput(
internalFormStore(),
path(),
getElementInput(
event.currentTarget as FieldElement,
internalFieldStore()
)
getElementInput(event.target as FieldElement, internalFieldStore())
);
validateIfRequired(internalFormStore(), internalFieldStore(), 'input');
},
Expand Down
22 changes: 21 additions & 1 deletion frameworks/angular/src/functions/injectForm/injectForm.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { Component, provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import * as v from 'valibot';
import { beforeEach, describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -53,4 +53,24 @@ 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();

// validateFormInput is async and void-ed, so Angular's scheduler does not
// track it. Give the microtask queue one turn to let the validation
// promise resolve and the signals update before asserting.
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
await fixture.whenStable();

expect(fixture.componentInstance.form.isValid()).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fn>(fn)).toBe(fn);
});

test('should read the signal when a signal is passed', () => {
expect(readSignalOrValue(signal(42))).toBe(42);
const obj = { a: 1 };
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Signal } from '@angular/core';
import { isSignal } from '@angular/core';
import type { SignalOrValue } from '../../types/index.ts';

/**
Expand All @@ -10,8 +10,5 @@ import type { SignalOrValue } from '../../types/index.ts';
*/
export function readSignalOrValue<TValue>(value: SignalOrValue<TValue>): TValue;
export function readSignalOrValue(value: SignalOrValue<unknown>): unknown {
if (typeof value === 'function') {
return (value as Signal<unknown>)();
}
return value;
return isSignal(value) ? value() : value;
}
16 changes: 8 additions & 8 deletions playgrounds/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
"preview": "vite preview"
},
"dependencies": {
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0",
"@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0",
"@formisch/angular": "workspace:*",
"@formkit/auto-animate": "^0.8.4",
"@tailwindcss/vite": "^4.1.17",
Expand All @@ -22,11 +22,11 @@
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "^2.5.2",
"@angular/build": "^21.0.0",
"@angular/compiler-cli": "^21.0.0",
"@angular/build": "^22.0.0",
"@angular/compiler-cli": "^22.0.0",
"@types/node": "^24.10.1",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript": "~6.0.3",
"vite": "^7.2.4"
}
}
31 changes: 23 additions & 8 deletions playgrounds/angular/src/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
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,
RouterLink,
RouterLinkActive,
RouterOutlet,
} from '@angular/router';
import { filter } from 'rxjs';
import { disableTransitions } from './utils/disable-transitions.ts';

interface IndicatorStyle {
Expand All @@ -27,6 +31,9 @@ interface IndicatorStyle {
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
host: {
'(window:resize)': 'onResize()',
},
template: `
<div
class="scrollbar-none flex scroll-px-8 overflow-x-auto scroll-smooth px-8"
Expand Down Expand Up @@ -75,20 +82,28 @@ export class AppComponent {
];

private readonly navEl = viewChild<ElementRef<HTMLElement>>('navEl');
private readonly injector = inject(Injector);
private readonly location = inject(Location);
protected readonly indicatorStyle = signal<IndicatorStyle | undefined>(
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();
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion playgrounds/angular/src/components/checkbox.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { InputErrorsComponent } from './input-errors.component.ts';
`,
})
export class CheckboxComponent {
readonly field = input.required<FieldStore<any, any>>();
readonly field = input.required<FieldStore>();
readonly label = input<string>('');
readonly value = input<string>();
readonly required = input<boolean>(false);
Expand Down
2 changes: 1 addition & 1 deletion playgrounds/angular/src/components/file-input.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { InputLabelComponent } from './input-label.component.ts';
`,
})
export class FileInputComponent {
readonly field = input.required<FieldStore<any, any>>();
readonly field = input.required<FieldStore>();
readonly label = input<string>();
readonly accept = input<string>();
readonly multiple = input<boolean>(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ interface RadioOption {
`,
})
export class RadioGroupComponent {
readonly field = input.required<FieldStore<any, any>>();
readonly field = input.required<FieldStore>();
readonly label = input<string>();
readonly options = input.required<RadioOption[]>();
readonly required = input<boolean>(false);
Expand Down
2 changes: 1 addition & 1 deletion playgrounds/angular/src/components/radio.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { type FieldStore, FormischControl } from '@formisch/angular';
`,
})
export class RadioComponent {
readonly field = input.required<FieldStore<any, any>>();
readonly field = input.required<FieldStore>();
readonly label = input.required<string>();
readonly value = input.required<string>();

Expand Down
2 changes: 1 addition & 1 deletion playgrounds/angular/src/components/select.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ interface SelectOption {
`,
})
export class SelectComponent {
readonly field = input.required<FieldStore<any, any>>();
readonly field = input.required<FieldStore>();
readonly label = input<string>();
readonly options = input.required<SelectOption[]>();
readonly multiple = input<boolean>(false);
Expand Down
2 changes: 1 addition & 1 deletion playgrounds/angular/src/components/slider.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { InputLabelComponent } from './input-label.component.ts';
`,
})
export class SliderComponent {
readonly field = input.required<FieldStore<any, any>>();
readonly field = input.required<FieldStore>();
readonly label = input<string>();
readonly min = input<number>();
readonly max = input<number>();
Expand Down
2 changes: 1 addition & 1 deletion playgrounds/angular/src/components/text-input.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { InputLabelComponent } from './input-label.component.ts';
`,
})
export class TextInputComponent {
readonly field = input.required<FieldStore<any, any>>();
readonly field = input.required<FieldStore>();
readonly type = input<string>('text');
readonly label = input<string>();
readonly placeholder = input<string>();
Expand Down
Loading
Loading