diff --git a/config/config.example.yml b/config/config.example.yml index 0692d29afc1..ebbc23bd3b8 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -234,6 +234,11 @@ submission: - value: default style: text-muted icon: fa-circle-xmark + # If set to true avoid setting placeholder for simple fields, where the placeholder would be the same as the label. + # The default is set to true as placeholders that do not provide additional information to the field are to be avoided as they could cause accessibility issue. + # More info on the topic can be found at https://www.deque.com/blog/accessible-forms-the-problem-with-placeholders/ + omitSimpleFieldPlaceholders: + # Default Language in which the UI will be rendered if the user's browser language is not an active language defaultLanguage: en diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index c4c1d79c294..74e6238d24c 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -1,14 +1,14 @@
- @if (!isCheckbox && hasLabel) { + [formGroup]="group" + [ngClass]="[getClass('element', 'container'), getClass('grid', 'container')]"> + @if (!isCheckbox && hasLabel && !isDateField) { + [ngClass]="[getClass('element', 'label'), getClass('grid', 'label')]"> } @@ -20,8 +20,7 @@
@if (hasHint && (formBuilderService.hasArrayGroupValue(model) || (!model.repeatable && (isRelationship === false || value?.value === null)) || (model.repeatable === true && context?.index === context?.context?.groups?.length - 1)) && (!showErrorMessages || errorMessages.length === 0)) { - + } @if (context?.parent?.groups?.length > 1 && (!showErrorMessages || errorMessages.length === 0)) { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index 60a61584d22..4b524053c38 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -206,6 +206,8 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { const testItem: Item = new Item(); const testWSI: WorkspaceItem = new WorkspaceItem(); testWSI.item = of(createSuccessfulRemoteDataObject(testItem)); + const renderer = jasmine.createSpyObj('Renderer2', ['setAttribute']); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -268,6 +270,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { }); fixture.detectChanges(); + renderer.setAttribute.calls.reset(); testElement = debugElement.query(By.css(`input[id='${testModel.id}']`)); })); @@ -380,4 +383,43 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { expect(testFn(formModel[25])).toEqual(DsDynamicFormGroupComponent); }); + it('should not show a label if is a checkbox or a date field', () => { + const checkboxLabel = fixture.debugElement.query(By.css('#label_' + formModel[0].id)); + const dsDatePickerLabel = fixture.debugElement.query(By.css('#label_' + formModel[22].id)); + + expect(checkboxLabel).toBeNull(); + expect(dsDatePickerLabel).toBeNull(); + }); + + it('should not call handleAriaLabelForLibraryComponents if is SSR', () => { + (component as any).platformId = 'server'; + (component as any).componentRef = { + instance: new DynamicNGBootstrapInputComponent(null, null), + location: { nativeElement: document.createElement('div') }, + } as any; + fixture.detectChanges(); + + (component as any).handleAriaLabelForLibraryComponents(); + + expect(renderer.setAttribute).not.toHaveBeenCalled(); + }); + + it('should set aria-label when valid input and additional property ariaLabel exist and is on browser', () => { + (component as any).platformId = 'browser'; + const inputEl = document.createElement('input'); + const hostEl = { + querySelector: jasmine.createSpy('querySelector').and.returnValue(inputEl), + }; + + (component as any).componentRef = { + instance: new DynamicNGBootstrapInputComponent(null, null), + location: { nativeElement: hostEl }, + } as any; + (component as any).renderer = renderer; + component.model = { additional: { ariaLabel: 'Accessible Label' } } as any; + fixture.detectChanges(); + (component as any).handleAriaLabelForLibraryComponents(); + expect(renderer.setAttribute).toHaveBeenCalledWith(inputEl, 'aria-label', 'Accessible Label'); + }); + }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index 426560ca2e7..9b225ed17b7 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -1,5 +1,6 @@ import { AsyncPipe, + isPlatformBrowser, NgClass, NgTemplateOutlet, } from '@angular/common'; @@ -18,7 +19,9 @@ import { OnDestroy, OnInit, Output, + PLATFORM_ID, QueryList, + Renderer2, SimpleChanges, Type, ViewChild, @@ -77,10 +80,12 @@ import { import { DYNAMIC_FORM_CONTROL_MAP_FN, DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX, + DynamicFormArrayComponent, DynamicFormArrayGroupModel, DynamicFormArrayModel, DynamicFormComponentService, DynamicFormControl, + DynamicFormControlComponent, DynamicFormControlContainerComponent, DynamicFormControlEvent, DynamicFormControlEventType, @@ -120,6 +125,7 @@ import { DsDynamicTypeBindRelationService } from './ds-dynamic-type-bind-relatio import { ExistingMetadataListElementComponent } from './existing-metadata-list-element/existing-metadata-list-element.component'; import { ExistingRelationListElementComponent } from './existing-relation-list-element/existing-relation-list-element.component'; import { DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH } from './models/custom-switch/custom-switch.model'; +import { DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER } from './models/date-picker/date-picker.model'; import { DsDynamicLookupRelationModalComponent } from './relation-lookup-modal/dynamic-lookup-relation-modal.component'; import { NameVariantService } from './relation-lookup-modal/name-variant.service'; @@ -211,6 +217,8 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo protected metadataService: MetadataService, @Inject(APP_CONFIG) protected appConfig: AppConfig, @Inject(DYNAMIC_FORM_CONTROL_MAP_FN) protected dynamicFormControlFn: DynamicFormControlMapFn, + protected renderer: Renderer2, + @Inject(PLATFORM_ID) protected platformId: string, ) { super(ref, componentFactoryResolver, layoutService, validationService, dynamicFormComponentService, relationService); this.fetchThumbnail = this.appConfig.browseBy.showThumbnails; @@ -300,6 +308,11 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo return this.model.type === DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX || this.model.type === DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH; } + + get isDateField(): boolean { + return this.model.type === DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER; + } + ngOnChanges(changes: SimpleChanges) { if (changes && !this.isRelationship && hasValue(this.group.get(this.model.id))) { super.ngOnChanges(changes); @@ -321,6 +334,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo ngAfterViewInit() { this.showErrorMessagesPreviousStage = this.showErrorMessages; + this.handleAriaLabelForLibraryComponents(); } protected createFormControlComponent(): void { @@ -462,4 +476,22 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo this.subs.push(collection$.subscribe((collection) => this.collection = collection)); } + + private handleAriaLabelForLibraryComponents(): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + if ((this.componentRef.instance instanceof DynamicFormControlComponent) && + !(this.componentRef.instance instanceof DynamicFormArrayComponent) && + this.componentRef.location.nativeElement) { + const inputEl: HTMLElement | null = + this.componentRef.location.nativeElement.querySelector('input,textarea,select,[role="textbox"]'); + + + if (inputEl && this.model?.additional?.ariaLabel) { + this.renderer.setAttribute(inputEl, 'aria-label', this.model.additional.ariaLabel); + } + } + } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html index 5faf188165d..3e22c1539f0 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html @@ -2,7 +2,7 @@
@if (!model.repeatable) { - {{model.placeholder}} @if (model.required) { + {{model.label}} @if (model.required) { * } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html index da6de76594c..a9beff40dcd 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html @@ -41,7 +41,7 @@ class="form-control" [attr.aria-labelledby]="'label_' + model.id" [attr.autoComplete]="model.autoComplete" - [attr.aria-label]="model.label | translate" + [attr.aria-label]="(model.label || model?.additional?.ariaLabel) | translate" [class.is-invalid]="showErrorMessages" [id]="model.id" [inputFormatter]="formatter" diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html index b60c93bb87f..c2a7fbf4dca 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html @@ -14,7 +14,7 @@ } + [attr.aria-label]="model.label | translate">
{ expect(fieldModel.value).toEqual(expectedValue); }); + + it('should skip setting the placeholder when ignore omitSimpleFieldPlaceholders is true', () => { + const parser = new DateFieldParser(submissionId, field, initFormValues, parserOptions, translateService); + parser.omitSimpleFieldPlaceholders = true; + const fieldModel = parser.parse(); + + expect(fieldModel.placeholder).toBeNull(); + }); }); diff --git a/src/app/shared/form/builder/parsers/date-field-parser.ts b/src/app/shared/form/builder/parsers/date-field-parser.ts index 0e0f3efd5bc..77cd0872ea0 100644 --- a/src/app/shared/form/builder/parsers/date-field-parser.ts +++ b/src/app/shared/form/builder/parsers/date-field-parser.ts @@ -12,8 +12,8 @@ export class DateFieldParser extends FieldParser { public modelFactory(fieldValue?: FormFieldMetadataValueObject, label?: boolean): any { let malformedDate = false; - const inputDateModelConfig: DynamicDsDateControlModelConfig = this.initModel(null, false, true); - inputDateModelConfig.legend = this.configData.label; + const inputDateModelConfig: DynamicDsDateControlModelConfig = this.initModel(null, label, true); + inputDateModelConfig.legend = this.configData.repeatable ? null : this.configData.label; inputDateModelConfig.disabled = inputDateModelConfig.readOnly; inputDateModelConfig.toggleIcon = 'fas fa-calendar'; this.setValues(inputDateModelConfig as any, fieldValue); diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index be849852e50..4c98947dc7a 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -19,6 +19,7 @@ import { import { DynamicFormControlLayout } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; import uniqueId from 'lodash/uniqueId'; +import { environment } from 'src/environments/environment'; import { DsDynamicInputModel, @@ -53,6 +54,8 @@ export abstract class FieldParser { */ protected typeField: string; + omitSimpleFieldPlaceholders: boolean; + constructor( @Inject(SUBMISSION_ID) protected submissionId: string, @Inject(CONFIG_DATA) protected configData: FormFieldModel, @@ -60,6 +63,7 @@ export abstract class FieldParser { @Inject(PARSER_OPTIONS) protected parserOptions: ParserOptions, protected translate: TranslateService, ) { + this.omitSimpleFieldPlaceholders = environment.submission.omitSimpleFieldPlaceholders; } public abstract modelFactory(fieldValue?: FormFieldMetadataValueObject, label?: boolean): any; @@ -306,7 +310,11 @@ export abstract class FieldParser { if (hint) { controlModel.hint = this.configData.hints || ' '; } - controlModel.placeholder = this.configData.label; + if (!this.omitSimpleFieldPlaceholders) { + controlModel.placeholder = this.configData.label; + } else { + controlModel.additional = { ...controlModel.additional, ariaLabel: this.configData.label }; + } if (this.configData.mandatory && setErrors) { this.markAsRequired(controlModel); diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index f3692a3fa7c..fe8deadccc6 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -253,6 +253,7 @@ export class DefaultAppConfig implements AppConfig { ], }, }, + omitSimpleFieldPlaceholders: true, }; // Default Language in which the UI will be rendered if the user's browser language is not an active language diff --git a/src/config/submission-config.interface.ts b/src/config/submission-config.interface.ts index afc81a39e25..842dc868fd9 100644 --- a/src/config/submission-config.interface.ts +++ b/src/config/submission-config.interface.ts @@ -36,4 +36,5 @@ export interface SubmissionConfig extends Config { duplicateDetection: DuplicateDetectionConfig; typeBind: TypeBindConfig; icons: IconsConfig; + omitSimpleFieldPlaceholders?: boolean; } diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index eb9f754c8e6..5bc0e458a9f 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -194,6 +194,7 @@ export const environment: BuildConfig = { ], }, }, + omitSimpleFieldPlaceholders: false, }, // NOTE: will log all redux actions and transfers in console