diff --git a/angular.json b/angular.json
index c82615ad..34422ddd 100644
--- a/angular.json
+++ b/angular.json
@@ -404,7 +404,7 @@
"projectType": "library",
"root": "projects/ppwcode/ng-forms",
"sourceRoot": "projects/ppwcode/ng-forms/src",
- "prefix": "lib",
+ "prefix": "ppw",
"architect": {
"build": {
"builder": "@angular/build:ng-packagr",
diff --git a/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.html b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.html
new file mode 100644
index 00000000..38c75501
--- /dev/null
+++ b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.html
@@ -0,0 +1,3 @@
+@if (showError()) {
+ {{ displayedMessage() }}
+}
diff --git a/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.spec.ts b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.spec.ts
new file mode 100644
index 00000000..a14aad28
--- /dev/null
+++ b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.spec.ts
@@ -0,0 +1,86 @@
+import { inject, InjectionToken, signal } from '@angular/core'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { FieldTree, form, required, validate, ValidationError } from '@angular/forms/signals'
+import { FieldErrorsErrorTranslator, providePpwcodeNgForms } from '../provider/provider'
+import { FieldErrorsComponent } from './field-errors.component'
+
+describe('FieldErrorsComponent', () => {
+ const translationPrefix = new InjectionToken('Field error translation prefix')
+
+ let component: FieldErrorsComponent
+ let fixture: ComponentFixture
+ let errorTranslator: ReturnType>
+
+ beforeEach(async () => {
+ errorTranslator = vi.fn((error) => `${inject(translationPrefix)}${error.message ?? error.kind}`)
+
+ await TestBed.configureTestingModule({
+ imports: [FieldErrorsComponent],
+ providers: [
+ { provide: translationPrefix, useValue: 'Translated: ' },
+ providePpwcodeNgForms({ errorTranslator })
+ ]
+ }).compileComponents()
+
+ fixture = TestBed.createComponent(FieldErrorsComponent)
+ component = fixture.componentInstance
+ })
+
+ const createRequiredField = (value: string): FieldTree =>
+ TestBed.runInInjectionContext(() =>
+ form(signal(value), (path) => required(path, { message: 'A username is required.' }))
+ )
+
+ const render = async (field: FieldTree): Promise => {
+ fixture.componentRef.setInput('field', field)
+ fixture.detectChanges()
+ await fixture.whenStable()
+ }
+
+ it('creates without displaying an error for a valid field', async () => {
+ await render(createRequiredField('Ada'))
+
+ expect(component).toBeTruthy()
+ expect(component.showError()).toBe(false)
+ expect(component.displayedMessage()).toBeUndefined()
+ expect(fixture.nativeElement.querySelector('span')).toBeNull()
+ expect(errorTranslator).not.toHaveBeenCalled()
+ })
+
+ it('displays the translated first validation error', async () => {
+ const field = TestBed.runInInjectionContext(() =>
+ form(signal('invalid'), (path) =>
+ validate(
+ path,
+ (): Array => [
+ { kind: 'first', message: 'First error' },
+ { kind: 'second', message: 'Second error' }
+ ]
+ )
+ )
+ )
+
+ await render(field)
+
+ expect(component.showError()).toBe(true)
+ expect(component.displayedMessage()).toBe('Translated: First error')
+ expect(fixture.nativeElement.querySelector('span')?.textContent).toContain('Translated: First error')
+ expect(errorTranslator).toHaveBeenCalledOnce()
+ expect(errorTranslator.mock.calls[0][0]).toMatchObject({ kind: 'first', message: 'First error' })
+ })
+
+ it('reactively clears the error when the field becomes valid', async () => {
+ const field = createRequiredField('')
+ await render(field)
+
+ expect(fixture.nativeElement.querySelector('span')).not.toBeNull()
+
+ field().value.set('Ada')
+ fixture.detectChanges()
+ await fixture.whenStable()
+
+ expect(component.showError()).toBe(false)
+ expect(component.displayedMessage()).toBeUndefined()
+ expect(fixture.nativeElement.querySelector('span')).toBeNull()
+ })
+})
diff --git a/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.ts b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.ts
new file mode 100644
index 00000000..bd5f37d5
--- /dev/null
+++ b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.ts
@@ -0,0 +1,91 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ inject,
+ Injector,
+ input,
+ runInInjectionContext
+} from '@angular/core'
+import { FieldTree } from '@angular/forms/signals'
+import { FIELD_ERRORS_ERROR_TRANSLATOR } from '../provider/provider'
+
+/**
+ * A component responsible for displaying validation error messages for a specific field.
+ *
+ * This component is designed to work with a field tree structure for form validation. It
+ * observes the field's validation errors and computes the first error to be displayed.
+ * Additionally, it provides a mechanism to translate error messages dynamically using an
+ * external translator function.
+ *
+ * Features:
+ * - Observes a field for validation errors.
+ * - Computes the first error to display.
+ * - Dynamically translates error messages using the provided translator.
+ * - Controls visibility of the error message based on the presence of errors.
+ *
+ * Dependencies:
+ * - Requires a `FieldTree` input to represent the form field and its associated errors.
+ * - Relies on an injected translator function to handle the translation of error messages.
+ *
+ * @example
+ * ```html
+ *
+ *
+ *
+ *
+ *
+ * Username
+ *
+ *
+ *
+ * ```
+ *
+ * Relies on the presence of a provided translator function.
+ * ```ts
+ * // app.config.ts
+ * providePpwcodeNgForms({
+ * errorTranslator: (error: ValidationError.WithFieldTree) => {
+ * const translate = inject(TranslateService)
+ * const _language = translate.currentLang() // acts as a trigger for recalculation
+ * const key = error.message ?? error.kind ?? null
+ *
+ * return key ? translate.instant(key) : ''
+ * })
+ * })
+ * ```
+ */
+@Component({
+ selector: 'ppw-field-errors',
+ imports: [],
+ templateUrl: './field-errors.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class FieldErrorsComponent {
+ readonly #injector = inject(Injector)
+ readonly #translator = inject(FIELD_ERRORS_ERROR_TRANSLATOR)
+
+ public readonly field = input.required>()
+
+ readonly #firstError = computed(() => {
+ // This looks weird because of the double invocation, but it really is correct.
+ // - The first brackets are used to get the FieldTree from the input binding.
+ // - The second brackets are invoking the FieldTree itself to get access to methods for that field.
+ return this.field()().errors()[0]
+ })
+
+ public readonly showError = computed(() => !!this.#firstError())
+
+ public readonly displayedMessage = computed(() => {
+ const firstError = this.#firstError()
+ if (!firstError) {
+ return
+ }
+
+ // Running this within the injection context allows the developer to use `inject` within its translator function.
+ // This is necessary, so they have access to the TranslateService of ngx-translate, for example.
+ // We have chosen this way of implementation so that the ng-forms package doesn't get an explicit depencency
+ // on translation packages like ngx-translate.
+ return runInInjectionContext(this.#injector, () => this.#translator(firstError))
+ })
+}
diff --git a/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.stories.ts b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.stories.ts
new file mode 100644
index 00000000..c655faf1
--- /dev/null
+++ b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.stories.ts
@@ -0,0 +1,73 @@
+import { ChangeDetectionStrategy, Component, signal } from '@angular/core'
+import { form, FormField, minLength, required, ValidationError } from '@angular/forms/signals'
+import { MatFormFieldModule } from '@angular/material/form-field'
+import { MatInputModule } from '@angular/material/input'
+import { applicationConfig, Meta, moduleMetadata, StoryObj } from '@storybook/angular'
+import { providePpwcodeNgForms } from '../provider/provider'
+import { FieldErrorsComponent } from './field-errors.component'
+
+@Component({
+ selector: 'ppw-field-errors-story',
+ imports: [FieldErrorsComponent, FormField, MatFormFieldModule, MatInputModule],
+ template: `
+
+ Username
+
+
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+class FieldErrorsStoryComponent {
+ readonly #model = signal({ username: '' })
+ protected readonly form = form(this.#model, (path) => {
+ required(path.username, { message: () => 'This field is required' })
+ minLength(path.username, 3)
+ })
+}
+
+const translateFieldError = (error: ValidationError.WithFieldTree): string => {
+ if (error.message) {
+ // When a message is explicitly set by the validator, show that message.
+ // required(path.username, { message: () => 'This field is required' })
+ return error.message
+ }
+
+ // The kind property contains the kind of error (required, maxLength, minLength, ...), you can use that
+ // for generic error messages using ngx-translate:
+ // `return inject(TranslateService).instant('validation' + error.kind)`
+ return `Validation failed: ${error.kind}`
+}
+
+const meta: Meta = {
+ title: 'ng-forms/FieldErrors',
+ component: FieldErrorsStoryComponent,
+ subcomponents: { FieldErrorsComponent },
+ decorators: [
+ moduleMetadata({
+ imports: [FieldErrorsStoryComponent]
+ }),
+ applicationConfig({
+ providers: [
+ providePpwcodeNgForms({
+ errorTranslator: translateFieldError
+ })
+ ]
+ })
+ ],
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Displays the first validation error for an Angular signal-forms field. The message is resolved by the error translator configured through `providePpwcodeNgForms`.'
+ }
+ }
+ },
+ argTypes: {}
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
diff --git a/projects/ppwcode/ng-forms/src/lib/provider/provider.ts b/projects/ppwcode/ng-forms/src/lib/provider/provider.ts
new file mode 100644
index 00000000..6e3d1583
--- /dev/null
+++ b/projects/ppwcode/ng-forms/src/lib/provider/provider.ts
@@ -0,0 +1,16 @@
+import { ValidationError } from '@angular/forms/signals'
+import { InjectionToken } from '@angular/core'
+
+export declare type FieldErrorsErrorTranslator = (firstError: ValidationError.WithFieldTree) => string
+
+export interface PpwcodeNgFormsProviderOptions {
+ errorTranslator: FieldErrorsErrorTranslator
+}
+
+export const FIELD_ERRORS_ERROR_TRANSLATOR = new InjectionToken(
+ 'Field errors error translator'
+)
+
+export const providePpwcodeNgForms = ({ errorTranslator }: PpwcodeNgFormsProviderOptions) => {
+ return [{ provide: FIELD_ERRORS_ERROR_TRANSLATOR, useValue: errorTranslator }]
+}
diff --git a/projects/ppwcode/ng-forms/src/public-api.ts b/projects/ppwcode/ng-forms/src/public-api.ts
index e1062824..09d5cdbc 100644
--- a/projects/ppwcode/ng-forms/src/public-api.ts
+++ b/projects/ppwcode/ng-forms/src/public-api.ts
@@ -8,3 +8,5 @@ export * from './lib/generators'
export * from './lib/controls-of'
export * from './lib/form-changes-detection'
export * from './lib/signal-form-change-detection'
+export * from './lib/field-errors/field-errors.component'
+export * from './lib/provider/provider'