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..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. @@ -24,10 +35,7 @@ import type { FieldStore, FormStore } from '../../types/index.ts'; * ```html * * - * + * * * * ``` @@ -63,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; + } } 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'); } - } + }, }); } 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/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/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); + }); }); 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); + }, }); } 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/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; } 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 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({ >