From dbd905b02f285eefbde67bd2e6cb51715b1af5fe Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Wed, 15 Jul 2026 08:01:54 +0400 Subject: [PATCH] fix(design-system): wire label/hint/error accessibility from FormField to Input FormFieldComponent's label had [for]=fieldId but nothing ever gave the projected control a matching id, and aria-describedby was set on a wrapper div instead of the actual input - so screen readers announced neither the label association nor the hint/error text. Added FormFieldContext, an injectable abstract class FormFieldComponent provides via its own providers array (visible to content-projected children, same mechanism Angular Material's mat-form-field/matInput relationship relies on). InputComponent optionally injects it and self-applies id and aria-describedby when nested inside app-form-field. This retroactively fixes every form built across Sprint 3-5 (Static Pages editor, Media Manager, Products List/Form, Project Editor sections) with no template changes needed anywhere else. Verified in browser: input id matches label's for attribute, and aria-describedby correctly points to the rendered hint/error text. Completes Sprint 5 (UI/UX Polish): Admin Products List, Admin Product Form, Project Editor sections (9 of 11), and this accessibility fix. --- .../ui/form-field/form-field.component.html | 5 +---- .../ui/form-field/form-field.component.ts | 20 +++++++++++++++---- src/app/shared/ui/input/input.component.html | 2 ++ src/app/shared/ui/input/input.component.ts | 5 ++++- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/app/shared/ui/form-field/form-field.component.html b/src/app/shared/ui/form-field/form-field.component.html index 08ef6c5..04d604c 100644 --- a/src/app/shared/ui/form-field/form-field.component.html +++ b/src/app/shared/ui/form-field/form-field.component.html @@ -8,10 +8,7 @@ } -
+
diff --git a/src/app/shared/ui/form-field/form-field.component.ts b/src/app/shared/ui/form-field/form-field.component.ts index 6f9dd21..3603568 100644 --- a/src/app/shared/ui/form-field/form-field.component.ts +++ b/src/app/shared/ui/form-field/form-field.component.ts @@ -1,21 +1,33 @@ -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; let nextFormFieldId = 0; +/** + * Injected by form controls (e.g. app-input) nested inside app-form-field so they can + * self-apply `id` and `aria-describedby` without the parent template wiring them manually. + */ +export abstract class FormFieldContext { + abstract readonly fieldId: string; + abstract readonly describedBy: () => string | null; +} + @Component({ selector: 'app-form-field', standalone: true, templateUrl: './form-field.component.html', styleUrl: './form-field.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ provide: FormFieldContext, useExisting: FormFieldComponent }] }) -export class FormFieldComponent { +export class FormFieldComponent implements FormFieldContext { readonly label = input(null); readonly hint = input(null); readonly error = input(null); readonly required = input(false); - protected readonly fieldId = `app-form-field-${++nextFormFieldId}`; + readonly fieldId = `app-form-field-${++nextFormFieldId}`; protected readonly hintId = `${this.fieldId}-hint`; protected readonly errorId = `${this.fieldId}-error`; + + readonly describedBy = computed(() => this.error() ? this.errorId : (this.hint() ? this.hintId : null)); } diff --git a/src/app/shared/ui/input/input.component.html b/src/app/shared/ui/input/input.component.html index 78fc576..8ef7c93 100644 --- a/src/app/shared/ui/input/input.component.html +++ b/src/app/shared/ui/input/input.component.html @@ -8,8 +8,10 @@ [type]="type()" [placeholder]="placeholder()" [disabled]="isDisabled || disabled()" + [attr.id]="formField?.fieldId ?? null" [attr.aria-label]="ariaLabel()" [attr.aria-invalid]="state() === 'error' || null" + [attr.aria-describedby]="formField?.describedBy() ?? null" [value]="value" (input)="handleInput($event)" (blur)="handleBlur()" diff --git a/src/app/shared/ui/input/input.component.ts b/src/app/shared/ui/input/input.component.ts index 69708aa..f8bd9c9 100644 --- a/src/app/shared/ui/input/input.component.ts +++ b/src/app/shared/ui/input/input.component.ts @@ -1,5 +1,6 @@ -import { ChangeDetectionStrategy, Component, forwardRef, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef, inject, input } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { FormFieldContext } from '../form-field/form-field.component'; export type InputSize = 'sm' | 'md' | 'lg'; export type InputState = 'default' | 'error' | 'success'; @@ -22,6 +23,8 @@ export type InputState = 'default' | 'error' | 'success'; } }) export class InputComponent implements ControlValueAccessor { + protected readonly formField = inject(FormFieldContext, { optional: true }); + readonly type = input<'text' | 'email' | 'password' | 'number' | 'search' | 'tel' | 'url'>('text'); readonly size = input('md'); readonly state = input('default');