From 10a784c7b63fd198520ea306f52f76ea653dc03b Mon Sep 17 00:00:00 2001
From: Martin <git@mail.de>
Date: Tue, 18 Mar 2025 09:49:49 +0100
Subject: [PATCH] OZG-7591 add validation error id for aria-describedby

---
 .../user-form-roles.component.html            |  1 +
 .../user-form-roles.component.ts              |  3 +-
 .../checkbox-editor.component.html            |  2 ++
 .../checkbox-editor.component.ts              |  4 ++-
 .../text-editor/text-editor.component.html    |  2 ++
 .../form/text-editor/text-editor.component.ts |  4 ++-
 .../textarea-editor.component.html            |  2 ++
 .../textarea-editor.component.ts              |  4 ++-
 .../validation-error.component.html           |  2 +-
 .../validation-error.component.ts             |  1 +
 .../lib/form/checkbox/checkbox.component.ts   |  4 ++-
 .../error-message/error-message.component.ts  |  4 ++-
 .../form/text-input/text-input.component.ts   |  4 ++-
 .../lib/form/textarea/textarea.component.ts   |  4 ++-
 .../lib/validation/tech.validation.util.ts    | 36 ++++++-------------
 15 files changed, 43 insertions(+), 34 deletions(-)

diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html
index 38380b3fa1..f410d02855 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html
@@ -1,5 +1,6 @@
 <h2 class="heading-2 mt-4">Rollen für OZG-Cloud *</h2>
 <ods-validation-error
+  [id]="validationErrorId"
   [invalidParams]="invalidParams$ | async"
   label="Rollen"
   data-test-id="rollen-error"
diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
index 70a8bdfdcc..37336816f4 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
@@ -1,4 +1,4 @@
-import { InvalidParam } from '@alfa-client/tech-shared';
+import { generateValidationErrorId, InvalidParam } from '@alfa-client/tech-shared';
 import { AsyncPipe } from '@angular/common';
 import { Component, Input, OnInit } from '@angular/core';
 import { AbstractControl, FormControlStatus, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
@@ -26,6 +26,7 @@ export class UserFormRolesComponent implements OnInit {
   public invalidParams$: Observable<InvalidParam[]> = of([]);
 
   public readonly UserFormService = UserFormService;
+  public readonly validationErrorId: string = generateValidationErrorId();
 
   ngOnInit(): void {
     const control: AbstractControl = this.formGroupParent.controls[UserFormService.CLIENT_ROLES];
diff --git a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.html b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.html
index 2533dc1ed4..1cc8b07470 100644
--- a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.html
+++ b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.html
@@ -29,9 +29,11 @@
   [label]="label"
   [disabled]="control.disabled"
   [hasError]="hasError"
+  [ariaDescribedBy]="validationErrorId"
 >
   <ods-validation-error
     error
+    [id]="validationErrorId"
     [invalidParams]="invalidParams"
     [label]="label"
     [attr.data-test-id]="(label | convertForDataTest) + '-checkbox-editor-error'"
diff --git a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.ts b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.ts
index dfab859671..bbb9f693fc 100644
--- a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.ts
+++ b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.ts
@@ -21,7 +21,7 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
-import { ConvertForDataTestPipe } from '@alfa-client/tech-shared';
+import { ConvertForDataTestPipe, generateValidationErrorId } from '@alfa-client/tech-shared';
 import { Component, Input } from '@angular/core';
 import { ReactiveFormsModule } from '@angular/forms';
 import { CheckboxComponent } from '@ods/system';
@@ -38,6 +38,8 @@ export class CheckboxEditorComponent extends FormControlEditorAbstractComponent
   @Input() inputId: string;
   @Input() label: string;
 
+  public readonly validationErrorId: string = generateValidationErrorId();
+
   get hasError(): boolean {
     return this.invalidParams.length > 0;
   }
diff --git a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.html b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.html
index c4c0091074..3c116fb0b2 100644
--- a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.html
+++ b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.html
@@ -34,9 +34,11 @@
   [required]="isRequired"
   [focus]="focus"
   [showLabel]="showLabel"
+  [ariaDescribedBy]="validationErrorId"
 >
   <ods-validation-error
     error
+    [id]="validationErrorId"
     [invalidParams]="invalidParams"
     [label]="label"
     [attr.data-test-id]="dataTestId + '-text-editor-error'"
diff --git a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.ts b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.ts
index 45f7c42c8f..19ae4d4d43 100644
--- a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.ts
+++ b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.ts
@@ -21,7 +21,7 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
-import { ConvertForDataTestPipe } from '@alfa-client/tech-shared';
+import { ConvertForDataTestPipe, generateValidationErrorId } from '@alfa-client/tech-shared';
 import { CommonModule } from '@angular/common';
 import { Component, Input } from '@angular/core';
 import { ReactiveFormsModule } from '@angular/forms';
@@ -44,6 +44,8 @@ export class TextEditorComponent extends FormControlEditorAbstractComponent {
   @Input() showLabel: boolean = true;
   @Input() dataTestId: string;
 
+  public readonly validationErrorId: string = generateValidationErrorId();
+
   get variant(): string {
     return this.invalidParams.length > 0 ? 'error' : 'default';
   }
diff --git a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.html b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.html
index 39ac310964..753a2c385a 100644
--- a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.html
+++ b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.html
@@ -34,9 +34,11 @@
   [focus]="focus"
   [isResizable]="isResizable"
   [showLabel]="showLabel"
+  [ariaDescribedBy]="validationErrorId"
 >
   <ods-validation-error
     error
+    [id]="validationErrorId"
     [invalidParams]="invalidParams"
     [label]="label"
     [attr.data-test-id]="(label | convertForDataTest) + '-textarea-editor-error'"
diff --git a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.ts b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.ts
index c7032ef03b..9203da053f 100644
--- a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.ts
+++ b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.ts
@@ -21,7 +21,7 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
-import { ConvertForDataTestPipe } from '@alfa-client/tech-shared';
+import { ConvertForDataTestPipe, generateValidationErrorId } from '@alfa-client/tech-shared';
 import { CommonModule } from '@angular/common';
 import { Component, Input } from '@angular/core';
 import { ReactiveFormsModule } from '@angular/forms';
@@ -44,6 +44,8 @@ export class TextareaEditorComponent extends FormControlEditorAbstractComponent
   @Input() isResizable: boolean = true;
   @Input() showLabel: boolean = true;
 
+  public readonly validationErrorId: string = generateValidationErrorId();
+
   get variant(): string {
     return this.invalidParams.length > 0 ? 'error' : 'default';
   }
diff --git a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.html b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.html
index 224dbe8cc5..cb566bafe0 100644
--- a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.html
+++ b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.html
@@ -24,5 +24,5 @@
 
 -->
 <ng-container *ngFor="let invalidParam of invalidParams"
-  ><ods-error-message [text]="message(invalidParam)"></ods-error-message
+  ><ods-error-message [id]="id" [text]="message(invalidParam)"></ods-error-message
 ></ng-container>
diff --git a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts
index 8844b8075d..fca8d2a2ec 100644
--- a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts
+++ b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts
@@ -33,6 +33,7 @@ import { ErrorMessageComponent } from '@ods/system';
   templateUrl: './validation-error.component.html',
 })
 export class ValidationErrorComponent {
+  @Input({ required: true }) id: string;
   @Input() label: string;
   @Input() invalidParams: InvalidParam[];
 
diff --git a/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.ts b/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.ts
index ffadf796c9..3b3750bd31 100644
--- a/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.ts
+++ b/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.ts
@@ -21,7 +21,7 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
-import { ConvertForDataTestPipe } from '@alfa-client/tech-shared';
+import { ConvertForDataTestPipe, EMPTY_STRING } from '@alfa-client/tech-shared';
 import { CommonModule } from '@angular/common';
 import { Component, Input } from '@angular/core';
 import { FormControl, ReactiveFormsModule } from '@angular/forms';
@@ -45,6 +45,7 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms';
           [attr.id]="inputId"
           [attr.disabled]="disabled ? true : null"
           [attr.data-test-id]="(label | convertForDataTest) + '-checkbox-editor'"
+          [attr.aria-describedby]="ariaDescribedBy"
         />
         <label class="leading-5 text-text" [attr.for]="inputId">{{ label }}</label>
         <svg
@@ -69,4 +70,5 @@ export class CheckboxComponent {
   @Input() label: string;
   @Input() disabled: boolean = false;
   @Input() hasError: boolean = false;
+  @Input() ariaDescribedBy: string = EMPTY_STRING;
 }
diff --git a/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts b/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts
index 66ed08e7f9..8c33dd8452 100644
--- a/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts
+++ b/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts
@@ -21,6 +21,7 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
+import { EMPTY_STRING } from '@alfa-client/tech-shared';
 import { CommonModule } from '@angular/common';
 import { Component, Input } from '@angular/core';
 import { ExclamationIconComponent } from '../../icons/exclamation-icon/exclamation-icon.component';
@@ -31,13 +32,14 @@ import { ExclamationIconComponent } from '../../icons/exclamation-icon/exclamati
   imports: [CommonModule, ExclamationIconComponent],
   styles: [':host {@apply flex text-error my-2 text-sm items-center font-medium}'],
   template: `<ods-exclamation-icon class="mr-1"></ods-exclamation-icon>
-    <div class="flex-grow break-all">
+    <div class="flex-grow break-all" [id]="id">
       {{ text }}
       <br *ngIf="subText" aria-hidden="true" />
       {{ subText }}
     </div> `,
 })
 export class ErrorMessageComponent {
+  @Input() id: string = EMPTY_STRING;
   @Input({ required: true }) text!: string;
   @Input() subText: string = '';
 }
diff --git a/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts b/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts
index cd01ba0190..4a8ddff099 100644
--- a/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts
+++ b/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts
@@ -62,6 +62,7 @@ type TextInputVariants = VariantProps<typeof textInputVariants>;
           <ng-content select="[prefix]" />
         </div>
         <input
+          #inputElement
           type="text"
           [id]="id"
           [formControl]="fieldControl"
@@ -71,8 +72,8 @@ type TextInputVariants = VariantProps<typeof textInputVariants>;
           [attr.aria-required]="required"
           [attr.aria-invalid]="variant === 'error'"
           [attr.data-test-id]="_dataTestId + '-text-input'"
+          [attr.aria-describedby]="ariaDescribedBy"
           (click)="clickEmitter.emit()"
-          #inputElement
         />
         <div *ngIf="withSuffix" class="absolute bottom-2 right-2 flex size-6 items-center justify-center">
           <ng-content select="[suffix]" />
@@ -102,6 +103,7 @@ export class TextInputComponent implements AfterViewInit {
   @Input() set dataTestId(value: string) {
     if (isNotUndefined(value)) this._dataTestId = value;
   }
+  @Input() ariaDescribedBy: string = EMPTY_STRING;
 
   @Output() clickEmitter: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>();
 
diff --git a/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts b/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts
index f3eb84af9d..d3c04b6c82 100644
--- a/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts
+++ b/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts
@@ -57,6 +57,7 @@ type TextareaVariants = VariantProps<typeof textareaVariants>;
         {{ inputLabel }}<ng-container *ngIf="required"><i aria-hidden="true">*</i></ng-container>
       </label>
       <textarea
+        #textAreaElement
         [id]="id"
         [formControl]="fieldControl"
         [rows]="rows"
@@ -66,7 +67,7 @@ type TextareaVariants = VariantProps<typeof textareaVariants>;
         [attr.aria-required]="required"
         [attr.aria-invalid]="variant === 'error'"
         [attr.data-test-id]="(inputLabel | convertForDataTest) + '-textarea'"
-        #textAreaElement
+        [attr.aria-describedby]="ariaDescribedBy"
       ></textarea>
       <ng-content select="[error]"></ng-content>
     </div>
@@ -94,6 +95,7 @@ export class TextareaComponent {
       this.textAreaElement.nativeElement.focus();
     }
   }
+  @Input() ariaDescribedBy: string = EMPTY_STRING;
 
   inputLabel: string;
   id: string;
diff --git a/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.util.ts b/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.util.ts
index b0cb78f8a1..7f8f416bf9 100644
--- a/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.util.ts
+++ b/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.util.ts
@@ -22,7 +22,7 @@
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
 import { AbstractControl, UntypedFormGroup } from '@angular/forms';
-import { isEmpty, isNil } from 'lodash-es';
+import { isEmpty, isNil, uniqueId } from 'lodash-es';
 import { ApiError, InvalidParam, Issue, IssueParam, ProblemDetail } from '../tech.model';
 import { replacePlaceholder } from '../tech.util';
 import { VALIDATION_MESSAGES, ValidationMessageCode } from './tech.validation.messages';
@@ -31,28 +31,18 @@ export function isValidationError(issue: Issue): boolean {
   return issue.messageCode.includes('javax.validation.constraints');
 }
 
-export function setIssueValidationError(
-  form: UntypedFormGroup,
-  issue: Issue,
-  pathPrefix?: string,
-): void {
+export function setIssueValidationError(form: UntypedFormGroup, issue: Issue, pathPrefix?: string): void {
   const control: AbstractControl = getControlForIssue(form, issue, pathPrefix);
 
   control.setErrors({ [issue.messageCode]: issue });
   control.markAsTouched();
 }
 
-export function getControlForIssue(
-  form: UntypedFormGroup,
-  issue: Issue,
-  pathPrefix?: string,
-): AbstractControl {
+export function getControlForIssue(form: UntypedFormGroup, issue: Issue, pathPrefix?: string): AbstractControl {
   const fieldPath: string = getFieldPath(issue.field, pathPrefix);
 
   let curControl: AbstractControl = form;
-  fieldPath
-    .split('.')
-    .forEach((field) => (curControl = (<UntypedFormGroup>curControl).controls[field]));
+  fieldPath.split('.').forEach((field) => (curControl = (<UntypedFormGroup>curControl).controls[field]));
 
   return curControl;
 }
@@ -66,9 +56,7 @@ export function getMessageForIssue(label: string, issue: Issue): string {
   }
 
   msg = replacePlaceholder(msg, 'field', label);
-  issue.parameters.forEach(
-    (param: IssueParam) => (msg = replacePlaceholder(msg, param.name, param.value)),
-  );
+  issue.parameters.forEach((param: IssueParam) => (msg = replacePlaceholder(msg, param.name, param.value)));
   return msg;
 }
 
@@ -84,11 +72,7 @@ export function getMessageCode(apiError: ApiError): string {
   return apiError.issues[0].messageCode;
 }
 
-export function setInvalidParamValidationError(
-  form: UntypedFormGroup,
-  invalidParam: InvalidParam,
-  pathPrefix?: string,
-): void {
+export function setInvalidParamValidationError(form: UntypedFormGroup, invalidParam: InvalidParam, pathPrefix?: string): void {
   const control: AbstractControl = getControlForInvalidParam(form, invalidParam, pathPrefix);
 
   control.setErrors({ [invalidParam.reason]: invalidParam });
@@ -112,9 +96,7 @@ export function getMessageForInvalidParam(label: string, invalidParam: InvalidPa
   }
 
   msg = replacePlaceholder(msg, 'field', label);
-  invalidParam.constraintParameters.forEach(
-    (param: IssueParam) => (msg = replacePlaceholder(msg, param.name, param.value)),
-  );
+  invalidParam.constraintParameters.forEach((param: IssueParam) => (msg = replacePlaceholder(msg, param.name, param.value)));
   return msg;
 }
 
@@ -126,3 +108,7 @@ export function getFieldPath(name: string, pathPrefix: string): string {
   const indexOfField = name.lastIndexOf(pathPrefix) + pathPrefix.length + 1;
   return name.slice(indexOfField);
 }
+
+export function generateValidationErrorId(): string {
+  return `${uniqueId()}-validation-error`;
+}
-- 
GitLab