fix(design-system): wire label/hint/error accessibility from FormField to Input
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user