File

projects/isy-angular-widgets/src/lib/form-wrapper/form-wrapper.component.ts

Description

Wraps a form field together with its label, required marker, validation message and the accessibility wiring between them. It is used with reactive forms and supports native fields such as input, textarea and select as well as complex components through an adapter concept.

Required inputs are label, fieldId and control.

Native fields

Marking the field with isyFormWrapperField is recommended. The wrapper then maintains id, aria-describedby, aria-invalid and aria-errormessage automatically. Without the directive the wrapper falls back to searching for a native input, textarea or select in its content.

Complex components

Complex components such as p-select are not covered by the native fallback. Either the component manages accessibility itself, or a dedicated adapter directive is provided.

Example :
A native field with validator specific messages:
```html
<form [formGroup]="myForm">
  <isy-form-wrapper
    label="E-Mail"
    fieldId="email"
    [control]="myForm.controls.email | formControl"
    [validationMessages]="{
      required: 'E-Mail ist erforderlich',
      email: 'Bitte geben Sie eine gültige E-Mail-Adresse ein'
    }"
  >
    <input isyFormWrapperField type="email" pInputText formControlName="email" />
  </isy-form-wrapper>
</form>
Example :
A complex component wired up manually:
```html
<isy-form-wrapper
  label="Geschlecht"
  labelId="label-gender"
  fieldId="gender"
  [control]="form.controls.gender | formControl"
>
  <p-select
    inputId="gender"
    ariaLabelledBy="label-gender"
    formControlName="gender"
    [options]="genderOptions"
  ></p-select>
</isy-form-wrapper>

Implements

OnInit OnChanges AfterContentInit

Metadata

Index

Properties
Methods
Inputs
Accessors

Inputs

control
Type : FormControl<unknown>
Required :  true
describedbyId
Type : string

Id of an additional description or help text that is announced together with the field.

fieldId
Type : string
Required :  true

Id of the wrapped form field. Used to link label, field and validation message.

ifta
Type : boolean
Default value : false

Renders the label as an in-field top-aligned label (PrimeNG IftaLabel) instead of a static label.

label
Type : string
Required :  true

Visible label of the form field.

labelId
Type : string

Custom id for the label element. Falls back to <fieldId>-label when not set.

validationMessages
Type : Record<string | string>
Default value : {}

Validator specific error messages, keyed by the validation error name such as required. The first matching entry in insertion order is displayed; without a match a generic message from the WidgetsConfigService is used.

Methods

ngAfterContentInit
ngAfterContentInit()
Returns : void
ngOnChanges
ngOnChanges(changes: SimpleChanges)
Parameters :
Name Type Optional
changes SimpleChanges No
Returns : void
ngOnInit
ngOnInit()
Returns : void

Properties

Readonly configService
Type : unknown
Default value : inject(WidgetsConfigService)
Optional formFieldAdapter
Type : FormWrapperFieldAdapter
Decorators :
@ContentChild(FORM_WRAPPER_FIELD_ADAPTER, {read: FORM_WRAPPER_FIELD_ADAPTER})
Readonly requiredTranslation$
Type : unknown
Default value : this.configService.getTranslation$('formWrapper.required')

Accessors

control
getcontrol()
setcontrol(value: FormControl)
Parameters :
Name Type Optional
value FormControl<unknown> No
Returns : void
computedLabelId
getcomputedLabelId()
errorId
geterrorId()
errorMessage
geterrorMessage()
labelOptionClass
getlabelOptionClass()
labelFilledClass
getlabelFilledClass()

Returns the CSS class for the label based on the value of the control. If the control value is truthy, it returns 'label-filled', otherwise it returns ''.

Returns : string
showError
getshowError()
required
getrequired()
ariaDescribedBy
getariaDescribedBy()
ariaInvalid
getariaInvalid()
ariaErrorMessage
getariaErrorMessage()
import {
  AfterContentInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChild,
  DestroyRef,
  ElementRef,
  Input,
  OnChanges,
  OnInit,
  SimpleChanges,
  inject
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {
  FormControl,
  PristineChangeEvent,
  ReactiveFormsModule,
  StatusChangeEvent,
  TouchedChangeEvent,
  Validators,
  ValueChangeEvent
} from '@angular/forms';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {ReplaySubject} from 'rxjs';
import {filter, startWith, switchMap} from 'rxjs/operators';
import {IftaLabelModule} from 'primeng/iftalabel';
import {MessageModule} from 'primeng/message';
import {WidgetsConfigService} from '../i18n/widgets-config.service';
import {FORM_WRAPPER_FIELD_ADAPTER, FormWrapperFieldAdapter} from './form-wrapper-field-adapter';

type FormWrapperFieldElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;

/**
 * Wraps a form field together with its label, required marker, validation message and the
 * accessibility wiring between them. It is used with reactive forms and supports native fields
 * such as `input`, `textarea` and `select` as well as complex components through an adapter concept.
 *
 * Required inputs are `label`, `fieldId` and `control`.
 *
 * ## Native fields
 *
 * Marking the field with `isyFormWrapperField` is recommended. The wrapper then maintains `id`,
 * `aria-describedby`, `aria-invalid` and `aria-errormessage` automatically. Without the directive
 * the wrapper falls back to searching for a native `input`, `textarea` or `select` in its content.
 *
 * ## Complex components
 *
 * Complex components such as `p-select` are not covered by the native fallback. Either the
 * component manages accessibility itself, or a dedicated adapter directive is provided.
 * @example
 * A native field with validator specific messages:
 * ```html
 * <form [formGroup]="myForm">
 *   <isy-form-wrapper
 *     label="E-Mail"
 *     fieldId="email"
 *     [control]="myForm.controls.email | formControl"
 *     [validationMessages]="{
 *       required: 'E-Mail ist erforderlich',
 *       email: 'Bitte geben Sie eine gültige E-Mail-Adresse ein'
 *     }"
 *   >
 *     <input isyFormWrapperField type="email" pInputText formControlName="email" />
 *   </isy-form-wrapper>
 * </form>
 * ```
 * @example
 * A complex component wired up manually:
 * ```html
 * <isy-form-wrapper
 *   label="Geschlecht"
 *   labelId="label-gender"
 *   fieldId="gender"
 *   [control]="form.controls.gender | formControl"
 * >
 *   <p-select
 *     inputId="gender"
 *     ariaLabelledBy="label-gender"
 *     formControlName="gender"
 *     [options]="genderOptions"
 *   ></p-select>
 * </isy-form-wrapper>
 * ```
 */
@Component({
  standalone: true,
  selector: 'isy-form-wrapper',
  templateUrl: './form-wrapper.component.html',
  styleUrls: ['./form-wrapper.component.scss'],
  imports: [CommonModule, ReactiveFormsModule, IftaLabelModule, MessageModule],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class FormWrapperComponent implements OnInit, OnChanges, AfterContentInit {
  /** Visible label of the form field. */
  @Input({required: true}) label!: string;
  /** Custom id for the label element. Falls back to `<fieldId>-label` when not set. */
  @Input() labelId?: string;
  /** Id of the wrapped form field. Used to link label, field and validation message. */
  @Input({required: true}) fieldId!: string;
  /** Id of an additional description or help text that is announced together with the field. */
  @Input() describedbyId?: string;
  /**
   * Validator specific error messages, keyed by the validation error name such as `required`.
   * The first matching entry in insertion order is displayed; without a match a generic
   * message from the {@link WidgetsConfigService} is used.
   */
  @Input() validationMessages: Record<string, string> = {};
  /** Renders the label as an in-field top-aligned label (PrimeNG IftaLabel) instead of a static label. */
  @Input() ifta = false;

  @ContentChild(FORM_WRAPPER_FIELD_ADAPTER, {read: FORM_WRAPPER_FIELD_ADAPTER})
  formFieldAdapter?: FormWrapperFieldAdapter;

  private readonly hostEl = inject<ElementRef<HTMLElement>>(ElementRef);
  private readonly cdr = inject(ChangeDetectorRef);
  private readonly destroyRef = inject(DestroyRef);

  readonly configService = inject(WidgetsConfigService);
  readonly requiredTranslation$ = this.configService.getTranslation$('formWrapper.required');

  private readonly controlSource = new ReplaySubject<FormControl<unknown>>(1);
  private _control!: FormControl<unknown>;

  @Input({required: true})
  set control(value: FormControl<unknown>) {
    if (!(value instanceof FormControl)) {
      throw new TypeError('control input is required and must be an instance of FormControl');
    }

    this._control = value;
    this.controlSource.next(value);
  }

  get control(): FormControl<unknown> {
    return this._control;
  }

  ngOnInit(): void {
    this.controlSource
      .pipe(
        switchMap((control) =>
          control.events.pipe(
            filter(
              (event) =>
                event instanceof ValueChangeEvent ||
                event instanceof StatusChangeEvent ||
                event instanceof TouchedChangeEvent ||
                event instanceof PristineChangeEvent
            ),
            startWith(null)
          )
        ),
        takeUntilDestroyed(this.destroyRef)
      )
      .subscribe(() => this.refreshView());
  }

  ngOnChanges(changes: SimpleChanges): void {
    if (changes.fieldId || changes.describedbyId || changes.validationMessages || changes.ifta) {
      queueMicrotask(() => this.refreshView());
    }
  }

  ngAfterContentInit(): void {
    queueMicrotask(() => this.syncFieldAttributes());
  }

  get computedLabelId(): string {
    return this.labelId ?? `${this.fieldId}-label`;
  }

  get errorId(): string {
    return `${this.fieldId}-error`;
  }

  get errorMessage(): string | null {
    const errors = this.control.errors;
    if (!errors) {
      return null;
    }

    const preferredOrder = Object.keys(this.validationMessages);
    const matchingKey = preferredOrder.find((key) => errors[key] != null) ?? Object.keys(errors)[0];

    return matchingKey
      ? (this.validationMessages[matchingKey] ?? this.configService.getTranslation('formWrapper.invalid'))
      : null;
  }

  get labelOptionClass(): string {
    return this.ifta ? 'ifta' : 'static-label';
  }

  /**
   * Returns the CSS class for the label based on the value of the control.
   * If the control value is truthy, it returns 'label-filled',
   * otherwise it returns ''.
   * @returns The CSS class for the label.
   */
  get labelFilledClass(): string {
    return this.ifta && !!this.control.value ? 'label-filled' : '';
  }

  get showError(): boolean {
    return !!this.errorMessage && this.control.invalid && (this.control.touched || this.control.dirty);
  }

  get required(): boolean {
    return this.control.hasValidator(Validators.required) || this.control.hasValidator(Validators.requiredTrue);
  }

  get ariaDescribedBy(): string | null {
    const ids: string[] = [];

    if (this.describedbyId) {
      ids.push(this.describedbyId);
    }

    if (this.showError) {
      ids.push(this.errorId);
    }

    return ids.length > 0 ? ids.join(' ') : null;
  }

  get ariaInvalid(): 'true' | null {
    return this.showError ? 'true' : null;
  }

  get ariaErrorMessage(): string | null {
    return this.showError ? this.errorId : null;
  }

  private refreshView(): void {
    this.syncFieldAttributes();
    this.cdr.markForCheck();
  }

  private syncFieldAttributes(): void {
    if (this.formFieldAdapter) {
      this.formFieldAdapter.setFieldId(this.fieldId);
      this.formFieldAdapter.setAriaDescribedBy(this.ariaDescribedBy);
      this.formFieldAdapter.setAriaInvalid(this.ariaInvalid);
      this.formFieldAdapter.setAriaErrorMessage(this.ariaErrorMessage);
      return;
    }

    const field = this.resolveFallbackFieldElement();
    if (!field) {
      return;
    }

    if (field.id !== this.fieldId) {
      field.id = this.fieldId;
    }

    this.setOrRemoveAttribute(field, 'aria-describedby', this.ariaDescribedBy);
    this.setOrRemoveAttribute(field, 'aria-invalid', this.ariaInvalid);
    this.setOrRemoveAttribute(field, 'aria-errormessage', this.ariaErrorMessage);
  }

  private setOrRemoveAttribute(element: Element, name: string, value: string | null): void {
    if (value) {
      element.setAttribute(name, value);
    } else {
      element.removeAttribute(name);
    }
  }

  private resolveFallbackFieldElement(): FormWrapperFieldElement | null {
    const container = this.hostEl.nativeElement.querySelector('[data-form-wrapper-field-container]');
    if (!(container instanceof HTMLElement)) {
      return null;
    }

    const candidate: Element | null = container.querySelector('input, textarea, select');

    if (
      candidate instanceof HTMLInputElement ||
      candidate instanceof HTMLTextAreaElement ||
      candidate instanceof HTMLSelectElement
    ) {
      return candidate;
    }

    return null;
  }
}
<div class="flex flex-column gap-2" [ngClass]="labelOptionClass">
  <label [attr.id]="computedLabelId" [attr.for]="fieldId" class="label" [ngClass]="labelFilledClass">
    {{ label }}

    @if (required) {
      <span aria-hidden="true">*</span>
      <span class="visually-hidden">{{ requiredTranslation$ | async }}</span>
    }
  </label>

  <div class="flex-container" data-form-wrapper-field-container>
    <ng-content></ng-content>
  </div>

  @if (showError) {
    <div [attr.id]="errorId">
      <p-message severity="error" variant="simple">
        {{ errorMessage }}
      </p-message>
    </div>
  }
</div>

./form-wrapper.component.scss

.ifta {
  position: relative;
}

.ifta input,
.ifta textarea {
  padding-top: var(--isyfact-iftalabel-input-padding-top);
}

.ifta label {
  position: absolute;
  top: 2px;
  left: 13px;
  pointer-events: none;
  transition: all 0.2s ease-out;
  font-size: 0.7rem;
}

.label-filled {
  color: var(--p-charcoal);
}

.ifta .p-button.p-button-icon-only {
  padding: var(--isyfact-ifta-icon-button-padding-y) 0;
}

.flex-container:has(isy-input-char) {
  display: inline-flex;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""