fix(design-system): wire label/hint/error accessibility from FormField to Input
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

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.
This commit is contained in:
sdarbinyan
2026-07-15 08:01:54 +04:00
parent f82344e0dd
commit dbd905b02f
4 changed files with 23 additions and 9 deletions

View File

@@ -8,10 +8,7 @@
</label>
}
<div
class="app-form-field__control"
[attr.aria-describedby]="error() ? errorId : (hint() ? hintId : null)"
>
<div class="app-form-field__control">
<ng-content />
</div>

View File

@@ -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<string | null>(null);
readonly hint = input<string | null>(null);
readonly error = input<string | null>(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));
}

View File

@@ -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()"

View File

@@ -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<InputSize>('md');
readonly state = input<InputState>('default');