diff --git a/apps/code-examples/src/app/code-examples/colorpicker/colorpicker/basic/demo.component.html b/apps/code-examples/src/app/code-examples/colorpicker/colorpicker/basic/demo.component.html index 1134cb52ad..c8f7a4fee6 100644 --- a/apps/code-examples/src/app/code-examples/colorpicker/colorpicker/basic/demo.component.html +++ b/apps/code-examples/src/app/code-examples/colorpicker/colorpicker/basic/demo.component.html @@ -15,6 +15,12 @@ [skyColorpickerInput]="colorPicker" #colorInput="skyId" /> + + Color must have at least 80% opacity. + diff --git a/apps/code-examples/src/app/code-examples/colorpicker/colorpicker/basic/demo.component.ts b/apps/code-examples/src/app/code-examples/colorpicker/colorpicker/basic/demo.component.ts index 9ea4a439ed..b73ba591c5 100644 --- a/apps/code-examples/src/app/code-examples/colorpicker/colorpicker/basic/demo.component.ts +++ b/apps/code-examples/src/app/code-examples/colorpicker/colorpicker/basic/demo.component.ts @@ -1,9 +1,12 @@ +import { CommonModule } from '@angular/common'; import { Component, inject } from '@angular/core'; import { + AbstractControl, FormBuilder, - FormControl, FormGroup, ReactiveFormsModule, + UntypedFormControl, + ValidationErrors, } from '@angular/forms'; import { SkyColorpickerModule, SkyColorpickerOutput } from '@skyux/colorpicker'; import { SkyIdModule } from '@skyux/core'; @@ -12,10 +15,16 @@ import { SkyIdModule } from '@skyux/core'; standalone: true, selector: 'app-demo', templateUrl: './demo.component.html', - imports: [ReactiveFormsModule, SkyColorpickerModule, SkyIdModule], + imports: [ + CommonModule, + ReactiveFormsModule, + SkyColorpickerModule, + SkyIdModule, + ], }) export class DemoComponent { protected formGroup: FormGroup; + protected favoriteColor: UntypedFormControl; protected swatches: string[] = [ '#BD4040', @@ -27,8 +36,18 @@ export class DemoComponent { ]; constructor() { + this.favoriteColor = new UntypedFormControl('#f00', [ + (control: AbstractControl): ValidationErrors | null => { + if (control.value?.rgba?.alpha < 0.8) { + return { opaque: true }; + } + + return null; + }, + ]); + this.formGroup = inject(FormBuilder).group({ - favoriteColor: new FormControl('#f00'), + favoriteColor: this.favoriteColor, }); } diff --git a/apps/code-examples/src/app/code-examples/forms/checkbox/basic/demo.component.html b/apps/code-examples/src/app/code-examples/forms/checkbox/basic/demo.component.html index ce2d79e0d7..3d7f01475f 100644 --- a/apps/code-examples/src/app/code-examples/forms/checkbox/basic/demo.component.html +++ b/apps/code-examples/src/app/code-examples/forms/checkbox/basic/demo.component.html @@ -1,22 +1,24 @@
+
+
+ +
diff --git a/apps/code-examples/src/app/code-examples/forms/checkbox/basic/demo.component.ts b/apps/code-examples/src/app/code-examples/forms/checkbox/basic/demo.component.ts index 47a8452783..bb0471a9c5 100644 --- a/apps/code-examples/src/app/code-examples/forms/checkbox/basic/demo.component.ts +++ b/apps/code-examples/src/app/code-examples/forms/checkbox/basic/demo.component.ts @@ -22,10 +22,13 @@ export class DemoComponent { email: new FormControl(false), phone: new FormControl(false), text: new FormControl(false), + terms: new FormControl(false), }); } protected onSubmit(): void { + this.formGroup.markAllAsTouched(); + console.log(this.formGroup.value); } } diff --git a/apps/code-examples/src/app/code-examples/forms/input-box/basic/demo.component.html b/apps/code-examples/src/app/code-examples/forms/input-box/basic/demo.component.html index 2f5f2f99d3..46842d9304 100644 --- a/apps/code-examples/src/app/code-examples/forms/input-box/basic/demo.component.html +++ b/apps/code-examples/src/app/code-examples/forms/input-box/basic/demo.component.html @@ -16,8 +16,17 @@

New member form

- - + + @@ -69,18 +78,15 @@

New member form

- + - -
- - {{ colorError.message }} - -
+ + + Blur is not a color. + diff --git a/apps/code-examples/src/app/code-examples/forms/input-box/basic/demo.component.spec.ts b/apps/code-examples/src/app/code-examples/forms/input-box/basic/demo.component.spec.ts index a348799fb8..a6690a2409 100644 --- a/apps/code-examples/src/app/code-examples/forms/input-box/basic/demo.component.spec.ts +++ b/apps/code-examples/src/app/code-examples/forms/input-box/basic/demo.component.spec.ts @@ -1,6 +1,7 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SkyAppTestUtility } from '@skyux-sdk/testing'; import { SkyInputBoxHarness } from '@skyux/forms/testing'; import { DemoComponent } from './demo.component'; @@ -40,6 +41,22 @@ describe('Basic input box demo', () => { }); }); + describe('last name field', () => { + it('should have last name required', async () => { + const harness = await setupTest({ + dataSkyId: 'input-box-last-name', + }); + const inputEl = document.querySelector( + 'input.last-name-input-box', + ) as HTMLInputElement; + inputEl.value = ''; + SkyAppTestUtility.fireDomEvent(inputEl, 'input'); + SkyAppTestUtility.fireDomEvent(inputEl, 'blur'); + + await expectAsync(harness.hasRequiredError()).toBeResolvedTo(true); + }); + }); + describe('bio field', () => { it('should have a character limit of 250', async () => { const harness = await setupTest({ @@ -71,7 +88,7 @@ describe('Basic input box demo', () => { }); describe('favorite color field', () => { - it('should not allow bird to be selected', async () => { + it('should not allow blur to be selected', async () => { const harness = await setupTest({ dataSkyId: 'input-box-favorite-color', }); @@ -80,19 +97,11 @@ describe('Basic input box demo', () => { '.input-box-favorite-color-select', ) as HTMLSelectElement; - selectEl.value = 'bird'; + selectEl.value = 'blur'; selectEl.dispatchEvent(new Event('change')); - const customErrors = await harness.getCustomErrors(); - - expect(customErrors.length).toBe(1); - - const birdError = customErrors[0]; - - await expectAsync(birdError.getDescriptionType()).toBeResolvedTo('error'); - await expectAsync(birdError.getIndicatorType()).toBeResolvedTo('danger'); - await expectAsync(birdError.getText()).toBeResolvedTo( - 'Bird is not a color.', + await expectAsync(harness.hasCustomFormError('color')).toBeResolvedTo( + true, ); }); }); diff --git a/apps/code-examples/src/app/code-examples/forms/input-box/basic/demo.component.ts b/apps/code-examples/src/app/code-examples/forms/input-box/basic/demo.component.ts index c6f2f3abc0..17dbd1c267 100644 --- a/apps/code-examples/src/app/code-examples/forms/input-box/basic/demo.component.ts +++ b/apps/code-examples/src/app/code-examples/forms/input-box/basic/demo.component.ts @@ -44,13 +44,8 @@ export class DemoComponent { constructor() { this.favoriteColor = new FormControl('none', [ (control): ValidationErrors | null => { - if (control.value === 'bird') { - return { - color: { - invalid: true, - message: 'Bird is not a color.', - }, - }; + if (control.value === 'blur') { + return { color: true }; } return null; diff --git a/apps/code-examples/src/app/code-examples/modals/modal/modal.component.html b/apps/code-examples/src/app/code-examples/modals/modal/modal.component.html index 2de0d1ef8c..7e224a2c35 100644 --- a/apps/code-examples/src/app/code-examples/modals/modal/modal.component.html +++ b/apps/code-examples/src/app/code-examples/modals/modal/modal.component.html @@ -2,9 +2,14 @@ Modal title - + + + + I agree to terms and conditions + + diff --git a/apps/code-examples/src/app/code-examples/modals/modal/modal.component.ts b/apps/code-examples/src/app/code-examples/modals/modal/modal.component.ts index deb4cabb74..ef40028169 100644 --- a/apps/code-examples/src/app/code-examples/modals/modal/modal.component.ts +++ b/apps/code-examples/src/app/code-examples/modals/modal/modal.component.ts @@ -1,6 +1,6 @@ import { Component, inject } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { SkyInputBoxModule } from '@skyux/forms'; +import { SkyCheckboxModule, SkyInputBoxModule } from '@skyux/forms'; import { SkyWaitService } from '@skyux/indicators'; import { SkyModalInstance, SkyModalModule } from '@skyux/modals'; @@ -11,11 +11,17 @@ import { ModalDemoDataService } from './data.service'; standalone: true, selector: 'app-modal', templateUrl: './modal.component.html', - imports: [ReactiveFormsModule, SkyInputBoxModule, SkyModalModule], + imports: [ + ReactiveFormsModule, + SkyInputBoxModule, + SkyModalModule, + SkyCheckboxModule, + ], }) export class ModalComponent { protected demoForm: FormGroup<{ value1: FormControl; + checkbox: FormControl; }>; readonly #context = inject(ModalDemoContext); @@ -26,18 +32,23 @@ export class ModalComponent { constructor() { this.demoForm = new FormGroup({ value1: new FormControl(this.#context.data.value1), + checkbox: new FormControl(false), }); } protected saveForm(): void { - // Use the data service to save the data. + // Mark all fields as touched to ensure validation has run + this.demoForm.markAllAsTouched(); - this.#waitSvc - .blockingWrap(this.#dataSvc.save(this.demoForm.value)) - .subscribe((data) => { - // Notify the modal instance that data was saved and return the saved data. - this.#instance.save(data); - }); + // Use the data service to save the data. + if (this.demoForm.valid) { + this.#waitSvc + .blockingWrap(this.#dataSvc.save(this.demoForm.value)) + .subscribe((data) => { + // Notify the modal instance that data was saved and return the saved data. + this.#instance.save(data); + }); + } } protected cancelForm(): void { diff --git a/apps/e2e/forms-storybook-e2e/src/e2e/checkbox.component.cy.ts b/apps/e2e/forms-storybook-e2e/src/e2e/checkbox.component.cy.ts index dc9709ca2b..4f07c25794 100644 --- a/apps/e2e/forms-storybook-e2e/src/e2e/checkbox.component.cy.ts +++ b/apps/e2e/forms-storybook-e2e/src/e2e/checkbox.component.cy.ts @@ -13,6 +13,8 @@ describe('forms-storybook - checkbox', () => { cy.get('app-checkbox') .should('exist') .should('be.visible') + .get('#touched-required-checkbox') + .dblclick() .get('#standard-checkboxes') .should('exist') .should('be.visible') diff --git a/apps/e2e/forms-storybook/src/app/checkbox/checkbox.component.html b/apps/e2e/forms-storybook/src/app/checkbox/checkbox.component.html index 608834aa42..b319ad2437 100644 --- a/apps/e2e/forms-storybook/src/app/checkbox/checkbox.component.html +++ b/apps/e2e/forms-storybook/src/app/checkbox/checkbox.component.html @@ -48,6 +48,17 @@ + + + + + diff --git a/apps/e2e/forms-storybook/src/app/checkbox/checkbox.module.ts b/apps/e2e/forms-storybook/src/app/checkbox/checkbox.module.ts index d6dd5a7b69..8f29ef2f90 100644 --- a/apps/e2e/forms-storybook/src/app/checkbox/checkbox.module.ts +++ b/apps/e2e/forms-storybook/src/app/checkbox/checkbox.module.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { RouterModule, Routes } from '@angular/router'; import { SkyCheckboxModule } from '@skyux/forms'; import { SkyHelpInlineModule } from '@skyux/indicators'; @@ -16,6 +17,7 @@ const routes: Routes = [{ path: '', component: CheckboxComponent }]; SkyCheckboxModule, SkyFluidGridModule, SkyHelpInlineModule, + FormsModule, RouterModule.forChild(routes), ], exports: [CheckboxComponent], diff --git a/apps/e2e/pages-storybook/src/app/action-hub/settings-modal.component.html b/apps/e2e/pages-storybook/src/app/action-hub/settings-modal.component.html index 86373467b9..c79c01656c 100644 --- a/apps/e2e/pages-storybook/src/app/action-hub/settings-modal.component.html +++ b/apps/e2e/pages-storybook/src/app/action-hub/settings-modal.component.html @@ -11,10 +11,10 @@
-
diff --git a/apps/playground/src/app/components/colorpicker/colorpicker/colorpicker.component.html b/apps/playground/src/app/components/colorpicker/colorpicker/colorpicker.component.html index ec52714db7..4f9331deb1 100644 --- a/apps/playground/src/app/components/colorpicker/colorpicker/colorpicker.component.html +++ b/apps/playground/src/app/components/colorpicker/colorpicker/colorpicker.component.html @@ -1,12 +1,10 @@
- + + + Color must have at least 80% opacity. + + +
+ + + + + + + + + + + + + + +
Touched{{ favoriteColor.touched }}
Pristine{{ favoriteColor.pristine }}
Valid{{ favoriteColor.valid }}
+ +
+ +
diff --git a/apps/playground/src/app/components/colorpicker/colorpicker/colorpicker.component.ts b/apps/playground/src/app/components/colorpicker/colorpicker/colorpicker.component.ts index 8a75549297..d6c5a6d496 100644 --- a/apps/playground/src/app/components/colorpicker/colorpicker/colorpicker.component.ts +++ b/apps/playground/src/app/components/colorpicker/colorpicker/colorpicker.component.ts @@ -1,8 +1,10 @@ import { Component } from '@angular/core'; import { + AbstractControl, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, + ValidationErrors, } from '@angular/forms'; import { SkyColorpickerOutput } from '@skyux/colorpicker'; @@ -12,6 +14,8 @@ import { SkyColorpickerOutput } from '@skyux/colorpicker'; }) export class ColorpickerComponent { public reactiveForm: UntypedFormGroup; + public favoriteColor: UntypedFormControl; + public secondFavoriteColor: UntypedFormControl; public swatches: string[] = [ '#BD4040', @@ -23,14 +27,26 @@ export class ColorpickerComponent { ]; constructor(formBuilder: UntypedFormBuilder) { + this.favoriteColor = new UntypedFormControl('#f00', [ + (control: AbstractControl): ValidationErrors | null => { + if (control.value?.rgba?.alpha < 0.8) { + return { opaque: true }; + } + + return null; + }, + ]); + + this.secondFavoriteColor = new UntypedFormControl('#f00'); + this.reactiveForm = formBuilder.group({ - favoriteColor: new UntypedFormControl('#f00'), + favoriteColor: this.favoriteColor, + secondFavoriteColor: this.secondFavoriteColor, }); } public onSelectedColorChanged(args: SkyColorpickerOutput): void { console.log('Reactive form color changed:', args); - // this.reactiveForm.setValue({ favoriteColor: args }); } public submit(): void { diff --git a/apps/playground/src/app/components/forms/checkbox/checkbox.component.html b/apps/playground/src/app/components/forms/checkbox/checkbox.component.html index 3d69089a5e..a331b8b72b 100644 --- a/apps/playground/src/app/components/forms/checkbox/checkbox.component.html +++ b/apps/playground/src/app/components/forms/checkbox/checkbox.component.html @@ -152,16 +152,9 @@

Template-driven checkbox

ngModel [indeterminate]="indeterminate" [required]="required" + labelText="Check me" #templateDrivenCheckbox="ngModel" - > - - Check me - - - + />

@@ -187,16 +180,9 @@

Reactive checkbox

- - Check me - - - + />
diff --git a/apps/playground/src/app/components/forms/checkbox/checkbox.component.ts b/apps/playground/src/app/components/forms/checkbox/checkbox.component.ts index ce44f26b4f..16339e6270 100644 --- a/apps/playground/src/app/components/forms/checkbox/checkbox.component.ts +++ b/apps/playground/src/app/components/forms/checkbox/checkbox.component.ts @@ -1,5 +1,9 @@ import { Component, OnInit } from '@angular/core'; -import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { + FormControl, + UntypedFormBuilder, + UntypedFormGroup, +} from '@angular/forms'; @Component({ selector: 'app-checkbox', @@ -28,7 +32,7 @@ export class CheckboxComponent implements OnInit { public ngOnInit(): void { this.reactiveFormGroup = this.#formBuilder.group({ - reactiveCheckbox: [undefined], + reactiveCheckbox: new FormControl(undefined), }); } diff --git a/apps/playground/src/app/components/forms/input-box/input-box.component.html b/apps/playground/src/app/components/forms/input-box/input-box.component.html index 7e113fea6c..2a5f1e10e7 100644 --- a/apps/playground/src/app/components/forms/input-box/input-box.component.html +++ b/apps/playground/src/app/components/forms/input-box/input-box.component.html @@ -245,6 +245,16 @@
+ + + + Input must be blue. + + { + console.log(control.value); + if (control.value !== 'blue') { + return { blue: true }; + } + return null; + }, + Validators.required, + Validators.maxLength(1), + ]); + this.errorField.markAsTouched(); this.errorForm = new UntypedFormGroup({ errorFormField: new UntypedFormControl('', [Validators.required]), + customError: this.customError, }); this.errorAutofillForm = new UntypedFormGroup({ errorAutofillFormField: new UntypedFormControl('', [ diff --git a/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker-input.directive.ts b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker-input.directive.ts index f363dd82fc..1fa5d6a234 100644 --- a/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker-input.directive.ts +++ b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker-input.directive.ts @@ -10,6 +10,7 @@ import { OnInit, Renderer2, forwardRef, + inject, } from '@angular/core'; import { ControlValueAccessor, @@ -19,10 +20,12 @@ import { ValidationErrors, Validator, } from '@angular/forms'; +import { SkyIdService } from '@skyux/core'; import { SkyLibResourcesService } from '@skyux/i18n'; -import { Subscription } from 'rxjs'; +import { Subject, distinctUntilChanged, takeUntil } from 'rxjs'; +import { SkyColorpickerInputService } from './colorpicker-input.service'; import { SkyColorpickerComponent } from './colorpicker.component'; import { SkyColorpickerService } from './colorpicker.service'; import { SkyColorpickerOutput } from './types/colorpicker-output'; @@ -132,7 +135,6 @@ export class SkyColorpickerInputDirective } #modelValue: SkyColorpickerOutput | undefined; - #pickerChangedSubscription: Subscription | undefined; #elementRef: ElementRef; #renderer: Renderer2; #svc: SkyColorpickerService; @@ -142,6 +144,11 @@ export class SkyColorpickerInputDirective #_disabled: boolean | undefined; #_initialColor: string | undefined; + #colorpickerInputSvc = inject(SkyColorpickerInputService); + #idSvc = inject(SkyIdService); + + #ngUnsubscribe = new Subject(); + constructor( elementRef: ElementRef, renderer: Renderer2, @@ -173,23 +180,51 @@ export class SkyColorpickerInputDirective public ngOnInit(): void { const element = this.#elementRef.nativeElement; + let id = element.id; + + if (!id) { + id = this.#idSvc.generateId(); + this.#renderer.setAttribute(element, 'id', id); + } + + this.#colorpickerInputSvc.inputId.next(id); + + this.#colorpickerInputSvc.ariaError + .pipe( + distinctUntilChanged((a, b) => { + return a.hasError === b.hasError && a.errorId === b.errorId; + }), + takeUntil(this.#ngUnsubscribe), + ) + .subscribe((errorState) => { + if (errorState.hasError) { + this.#renderer.setAttribute(element, 'aria-invalid', 'true'); + this.#renderer.setAttribute( + element, + 'aria-errormessage', + errorState.errorId, + ); + } else { + this.#renderer.removeAttribute(element, 'aria-invalid'); + this.#renderer.removeAttribute(element, 'aria-errormessage'); + } + }); this.#renderer.addClass(element, 'sky-form-control'); this.skyColorpickerInput.initialColor = this.initialColor; this.skyColorpickerInput.returnFormat = this.returnFormat; - this.#pickerChangedSubscription = - this.skyColorpickerInput.selectedColorChanged.subscribe( - (newColor: SkyColorpickerOutput) => { - /* istanbul ignore else */ - if (newColor) { - this.#modelValue = this.#formatter(newColor); - - // Write the new value to the reactive form control, which will update the template model - this.writeValue(newColor); - } - }, - ); + this.skyColorpickerInput.selectedColorChanged + .pipe(takeUntil(this.#ngUnsubscribe)) + .subscribe((newColor: SkyColorpickerOutput) => { + /* istanbul ignore else */ + if (newColor) { + this.#modelValue = this.#formatter(newColor); + + // Write the new value to the reactive form control, which will update the template model + this.writeValue(newColor); + } + }); this.skyColorpickerInput.updatePickerValues(this.initialColor); @@ -217,9 +252,8 @@ export class SkyColorpickerInputDirective } public ngOnDestroy(): void { - if (this.#pickerChangedSubscription) { - this.#pickerChangedSubscription.unsubscribe(); - } + this.#ngUnsubscribe.next(); + this.#ngUnsubscribe.complete(); } public setColorPickerDefaults(): void { diff --git a/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker-input.service.ts b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker-input.service.ts new file mode 100644 index 0000000000..2bb55d8d84 --- /dev/null +++ b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker-input.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; + +import { ReplaySubject } from 'rxjs'; + +/** + * @internal + */ +@Injectable() +export class SkyColorpickerInputService { + public inputId = new ReplaySubject(1); + public ariaError = new ReplaySubject<{ hasError: boolean; errorId: string }>( + 1, + ); +} diff --git a/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.html b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.html index e78b19e9ad..89488a28df 100644 --- a/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.html +++ b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.html @@ -1,3 +1,7 @@ + +
+ +
+ + + +
diff --git a/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.spec.ts b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.spec.ts index d1b76ab436..7b4b6c57cc 100644 --- a/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.spec.ts +++ b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.spec.ts @@ -41,14 +41,16 @@ describe('Colorpicker Component', () => { return document.querySelector('.sky-colorpicker-container') as HTMLElement; } - function openColorpicker(element: HTMLElement): void { + function openColorpicker(element: HTMLElement, className?: string): void { tick(); fixture.detectChanges(); verifyMenuVisibility(false); - const buttonElem = element.querySelector( - '.sky-colorpicker-button', - ) as HTMLElement; + const buttonSelector = className + ? `.${className} .sky-colorpicker-button` + : '.sky-colorpicker-button'; + const buttonElem = element.querySelector(buttonSelector) as HTMLElement; + buttonElem.click(); tick(); fixture.detectChanges(); @@ -390,6 +392,18 @@ describe('Colorpicker Component', () => { ).toBeNull(); })); + it('should add a label if labelText is provided', () => { + const labelText = 'Label Text'; + component.labelText = labelText; + + fixture.detectChanges(); + + const label = fixture.nativeElement.querySelector('.sky-control-label'); + + expect(label).toBeVisible(); + expect(label.textContent).toBe(labelText); + }); + it('should add icon overlay', fakeAsync(() => { const icon = getColorpickerIcon(); expect(icon).toBeNull(); @@ -1192,17 +1206,18 @@ describe('Colorpicker Component', () => { it('should toggle reset button via messageStream.', fakeAsync(() => { fixture.detectChanges(); tick(); - expect(getResetButton().length).toEqual(1); + expect(getResetButton().length).toEqual(2); component.sendMessage(SkyColorpickerMessageType.ToggleResetButton); tick(); fixture.detectChanges(); tick(); - expect(getResetButton().length).toEqual(0); + // There are 2 colorpicker components and only one is using the message stream + expect(getResetButton().length).toEqual(1); component.sendMessage(SkyColorpickerMessageType.ToggleResetButton); tick(); fixture.detectChanges(); tick(); - expect(getResetButton().length).toEqual(1); + expect(getResetButton().length).toEqual(2); })); it('should only emit the form control valueChanged event once per change', (done) => { @@ -1297,6 +1312,71 @@ describe('Colorpicker Component', () => { expect(outermostDiv).not.toHaveCssClass('sky-colorpicker-disabled'); }); + + it('should render an error message if the form control set via name has an error', fakeAsync(() => { + component.labelText = 'Label Text'; + + fixture.detectChanges(); + + let inputElement: HTMLInputElement | null = + nativeElement.querySelector('input'); + + expect(inputElement?.getAttribute('aria-invalid')).toBeNull(); + expect(inputElement?.getAttribute('aria-errormessage')).toBeNull(); + + openColorpicker(nativeElement); + setInputElementValue(nativeElement, 'red', '163'); + setInputElementValue(nativeElement, 'green', '19'); + setInputElementValue(nativeElement, 'blue', '84'); + setInputElementValue(nativeElement, 'alpha', '0.5'); + applyColorpicker(); + + fixture.detectChanges(); + + inputElement = nativeElement.querySelector('input'); + + expect(inputElement?.getAttribute('aria-invalid')).toBe('true'); + expect(inputElement?.getAttribute('aria-errormessage')).toBeDefined(); + + const errorMessage = nativeElement.querySelector( + '.sky-form-error-indicator', + ); + + expect(errorMessage).toBeVisible(); + })); + + it('should render an error message if the form control has an error set via form control', fakeAsync(() => { + fixture.detectChanges(); + + let inputElement: HTMLInputElement | null = nativeElement.querySelector( + '.colorpicker-form-control input', + ); + + expect(inputElement?.getAttribute('aria-invalid')).toBeNull(); + expect(inputElement?.getAttribute('aria-errormessage')).toBeNull(); + + openColorpicker(nativeElement, 'colorpicker-form-control'); + setInputElementValue(nativeElement, 'red', '163'); + setInputElementValue(nativeElement, 'green', '19'); + setInputElementValue(nativeElement, 'blue', '84'); + setInputElementValue(nativeElement, 'alpha', '0.5'); + applyColorpicker(); + + fixture.detectChanges(); + + inputElement = nativeElement.querySelector( + '.colorpicker-form-control input', + ); + + expect(inputElement?.getAttribute('aria-invalid')).toBe('true'); + expect(inputElement?.getAttribute('aria-errormessage')).toBeDefined(); + + const errorMessage = nativeElement.querySelector( + '.sky-form-error-indicator', + ); + + expect(errorMessage).toBeVisible(); + })); }); describe('accessibility', () => { diff --git a/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.ts b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.ts index 3709d6cf66..eb0a2a0639 100644 --- a/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.ts +++ b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.ts @@ -1,6 +1,8 @@ import { + AfterContentChecked, ChangeDetectorRef, Component, + ContentChild, ElementRef, EnvironmentInjector, EventEmitter, @@ -14,14 +16,22 @@ import { ViewEncapsulation, inject, } from '@angular/core'; +import { + AbstractControlDirective, + FormControlDirective, + FormControlName, + NgModel, +} from '@angular/forms'; import { SkyAffixAutoFitContext, SkyAffixService, SkyAffixer, SkyCoreAdapterService, + SkyIdService, SkyOverlayInstance, SkyOverlayService, } from '@skyux/core'; +import { FORM_ERRORS } from '@skyux/forms'; import { SkyIconType } from '@skyux/indicators'; import { SkyThemeService } from '@skyux/theme'; @@ -29,6 +39,7 @@ import { Subject, fromEvent } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { SliderDimension, SliderPosition } from './colorpicker-classes'; +import { SkyColorpickerInputService } from './colorpicker-input.service'; import { SkyColorpickerService } from './colorpicker.service'; import { SkyColorpickerChangeAxis } from './types/colorpicker-axis'; import { SkyColorpickerChangeColor } from './types/colorpicker-color'; @@ -50,10 +61,19 @@ let componentIdIndex = 0; selector: 'sky-colorpicker', templateUrl: './colorpicker.component.html', styleUrls: ['./colorpicker.component.scss'], - providers: [SkyColorpickerService], + providers: [ + SkyColorpickerInputService, + SkyColorpickerService, + { + provide: FORM_ERRORS, + useValue: true, + }, + ], encapsulation: ViewEncapsulation.None, }) -export class SkyColorpickerComponent implements OnInit, OnDestroy { +export class SkyColorpickerComponent + implements OnInit, OnDestroy, AfterContentChecked +{ /** * The name of the [Font Awesome 4.7](https://fontawesome.com/v4.7/icons/) icon to overlay on top of the picker. Do not specify the `fa fa-` classes. * @internal @@ -87,6 +107,13 @@ export class SkyColorpickerComponent implements OnInit, OnDestroy { @Input() public labelledBy: string | undefined; + /** + * The text to display as the colorpicker's label. Use this instead of a `label` element when the label is text-only. + * Specifying `labelText` also enables automatic error message handling for standard colorpicker errors. + */ + @Input() + public labelText: string | undefined; + /** * Fires when users select a color in the colorpicker. */ @@ -265,6 +292,39 @@ export class SkyColorpickerComponent implements OnInit, OnDestroy { return this.#_colorpickerRef; } + @ContentChild(FormControlDirective) + protected set formControl(value: FormControlDirective | undefined) { + if (value) { + this.ngControl = value; + this.#changeDetector.markForCheck(); + } + } + + @ContentChild(FormControlName) + protected set formControlByName(value: FormControlName | undefined) { + if (value) { + this.ngControl = value; + this.#changeDetector.markForCheck(); + } + } + + @ContentChild(NgModel) + protected set ngModel(value: NgModel | undefined) { + if (value) { + this.ngControl = value; + this.#changeDetector.markForCheck(); + } + } + + protected get inputId(): string | undefined { + return this.#_inputId; + } + + protected set inputId(value: string | undefined) { + this.#_inputId = value; + this.#changeDetector.markForCheck(); + } + protected colorpickerId: string; protected isOpen = false; protected triggerButtonId: string; @@ -282,6 +342,7 @@ export class SkyColorpickerComponent implements OnInit, OnDestroy { protected selectedColor: SkyColorpickerOutput | undefined; protected iconColor: string | undefined; protected isPickerVisible: boolean | undefined; + protected ngControl: AbstractControlDirective | undefined; #idIndex: number; #alphaChannel: string | undefined; @@ -302,9 +363,15 @@ export class SkyColorpickerComponent implements OnInit, OnDestroy { #svc: SkyColorpickerService; #themeSvc: SkyThemeService | undefined; + #idSvc = inject(SkyIdService); + #colorpickerInputSvc = inject(SkyColorpickerInputService); + #_backgroundColorForDisplay: string | undefined; #_colorpickerRef: ElementRef | undefined; #_disabled = false; + #_inputId: string | undefined; + + protected readonly errorId = this.#idSvc.generateId(); constructor( affixSvc: SkyAffixService, @@ -366,6 +433,12 @@ export class SkyColorpickerComponent implements OnInit, OnDestroy { this.#handleIncomingMessages(message); }); + this.#colorpickerInputSvc.inputId + .pipe(takeUntil(this.#ngUnsubscribe)) + .subscribe((id) => { + this.inputId = id; + }); + this.#addTriggerButtonEventListeners(); /* istanbul ignore else */ @@ -388,6 +461,15 @@ export class SkyColorpickerComponent implements OnInit, OnDestroy { } } + public ngAfterContentChecked(): void { + if (this.labelText) { + this.#colorpickerInputSvc.ariaError.next({ + hasError: !this.ngControl?.valid, + errorId: this.errorId, + }); + } + } + public ngOnDestroy(): void { this.#ngUnsubscribe.next(); this.#ngUnsubscribe.complete(); @@ -397,6 +479,7 @@ export class SkyColorpickerComponent implements OnInit, OnDestroy { } public onTriggerButtonClick(): void { + this.ngControl?.control?.markAsTouched(); this.#sendMessage(SkyColorpickerMessageType.Open); } diff --git a/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.module.ts b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.module.ts index 9caa3816a7..71a8e7f76c 100644 --- a/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.module.ts +++ b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.module.ts @@ -1,7 +1,11 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { SkyAffixModule } from '@skyux/core'; -import { SkyInputBoxModule } from '@skyux/forms'; +import { + SkyFormErrorComponent, + SkyFormErrorsModule, + SkyInputBoxModule, +} from '@skyux/forms'; import { SkyIconModule } from '@skyux/indicators'; import { SkyThemeModule } from '@skyux/theme'; @@ -26,7 +30,13 @@ import { SkyColorpickerComponent } from './colorpicker.component'; SkyIconModule, SkyInputBoxModule, SkyThemeModule, + SkyFormErrorsModule, + SkyFormErrorComponent, + ], + exports: [ + SkyColorpickerComponent, + SkyColorpickerInputDirective, + SkyFormErrorComponent, ], - exports: [SkyColorpickerComponent, SkyColorpickerInputDirective], }) export class SkyColorpickerModule {} diff --git a/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-component.fixture.html b/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-component.fixture.html index c16caeffdf..8f1e4b71a6 100644 --- a/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-component.fixture.html +++ b/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-component.fixture.html @@ -5,6 +5,7 @@ [pickerButtonIconType]="pickerButtonIconType" [label]="label" [labelledBy]="labelledBy" + [labelText]="labelText" [messageStream]="colorpickerController" #colorPickerTest > diff --git a/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-component.fixture.ts b/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-component.fixture.ts index 018c510938..3d90b979a5 100644 --- a/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-component.fixture.ts +++ b/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-component.fixture.ts @@ -16,6 +16,7 @@ export class ColorpickerTestComponent { public pickerButtonIconType: string | undefined; public label: string | undefined; public labelledBy: string | undefined; + public labelText: string | undefined; public selectedHexType = 'hex6'; public selectedColor: string | undefined = '#2889e5'; public selectedOutputFormat = 'rgba'; diff --git a/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-reactive-component.fixture.html b/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-reactive-component.fixture.html index c9573670ec..18c1ca97b4 100644 --- a/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-reactive-component.fixture.html +++ b/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-reactive-component.fixture.html @@ -1,5 +1,9 @@ - + + + Color must have at least 80% opacity. + - - +
+ + + Color must have at least 80% opacity. + - - - - - - - +
diff --git a/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-reactive-component.fixture.ts b/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-reactive-component.fixture.ts index 56e28b74d9..9e25873773 100644 --- a/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-reactive-component.fixture.ts +++ b/libs/components/colorpicker/src/lib/modules/colorpicker/fixtures/colorpicker-reactive-component.fixture.ts @@ -1,5 +1,10 @@ import { Component, ViewChild } from '@angular/core'; -import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { + AbstractControl, + UntypedFormControl, + UntypedFormGroup, + ValidationErrors, +} from '@angular/forms'; import { Subject } from 'rxjs'; @@ -30,6 +35,7 @@ export class ColorpickerReactiveTestComponent { '#68AFEF', ]; public inputType = 'text'; + public labelText: string | undefined; @ViewChild('colorPickerTest', { static: true, @@ -37,23 +43,34 @@ export class ColorpickerReactiveTestComponent { public colorpickerComponent!: SkyColorpickerComponent; public colorpickerController = new Subject(); - public showMultiple = false; public newValues = { colorModel: '#000', colorModel2: '#111', - colorModel3: '#222', - colorModel4: '#333', }; - public colorControl = new UntypedFormControl('#00f'); - public colorControl2 = new UntypedFormControl('#aaa'); - public colorControl3 = new UntypedFormControl('#bbb'); - public colorControl4 = new UntypedFormControl('#ccc'); + public colorControl = new UntypedFormControl('#00f', [ + (control: AbstractControl): ValidationErrors | null => { + if (control.value?.rgba?.alpha < 0.8) { + return { opaque: true }; + } + + return null; + }, + ]); + + public colorControl2 = new UntypedFormControl('#00f', [ + (control: AbstractControl): ValidationErrors | null => { + if (control.value?.rgba?.alpha < 0.8) { + return { opaque: true }; + } + + return null; + }, + ]); + public colorForm = new UntypedFormGroup({ colorModel: this.colorControl, colorModel2: this.colorControl2, - colorModel3: this.colorControl3, - colorModel4: this.colorControl4, }); public sendMessage(type: SkyColorpickerMessageType) { diff --git a/libs/components/forms/src/assets/locales/resources_en_US.json b/libs/components/forms/src/assets/locales/resources_en_US.json index 0558fe7047..84c971b1c3 100644 --- a/libs/components/forms/src/assets/locales/resources_en_US.json +++ b/libs/components/forms/src/assets/locales/resources_en_US.json @@ -7,10 +7,6 @@ "_description": "Screen reader only label for character count over limit symbol", "message": "You are over the character limit." }, - "skyux_form_error_character_count": { - "_description": "Error message for a field with a value that exceeds the character count limit", - "message": "Limit {0} to {1} character(s)." - }, "skyux_form_error_date": { "_description": "Error message for a field with an invalid date value", "message": "Select or enter a valid date." @@ -150,5 +146,9 @@ "skyux_input_box_help_inline_aria_label": { "_description": "The accessible label for an input box help inline button", "message": "Show help content for {0}" + }, + "skyux_checkbox_required_label_text": { + "_description": "The label text portion of the required validation message", + "message": "This selection" } } diff --git a/libs/components/forms/src/index.ts b/libs/components/forms/src/index.ts index 5f047eecc3..f4aa170c35 100644 --- a/libs/components/forms/src/index.ts +++ b/libs/components/forms/src/index.ts @@ -29,6 +29,9 @@ export { SkySelectionBoxGridAlignItemsType } from './lib/modules/selection-box/t export { SkyToggleSwitchModule } from './lib/modules/toggle-switch/toggle-switch.module'; export { SkyToggleSwitchChange } from './lib/modules/toggle-switch/types/toggle-switch-change'; +export { SkyFormErrorComponent } from './lib/modules/form-error/form-error.component'; +export { FORM_ERRORS } from './lib/modules/form-error/form-errors-token'; + // Components and directives must be exported to support Angular's "partial" Ivy compiler. // Obscure names are used to indicate types are not part of the public API. diff --git a/libs/components/forms/src/lib/modules/checkbox/checkbox.component.html b/libs/components/forms/src/lib/modules/checkbox/checkbox.component.html index 1c91c06109..318e50cb1c 100644 --- a/libs/components/forms/src/lib/modules/checkbox/checkbox.component.html +++ b/libs/components/forms/src/lib/modules/checkbox/checkbox.component.html @@ -17,6 +17,10 @@ [attr.aria-label]="label" [attr.aria-labelledby]="labelledBy" [attr.aria-required]="required ? true : null" + [attr.aria-invalid]="!!ngControl?.errors" + [attr.aria-errormessage]=" + labelText && ngControl?.errors ? errorId : undefined + " (blur)="onInputBlur()" (change)="onInteractionEvent($event)" #inputEl @@ -51,5 +55,20 @@ /> - + + {{ labelText }} + + + + +
+ + + +
diff --git a/libs/components/forms/src/lib/modules/checkbox/checkbox.component.spec.ts b/libs/components/forms/src/lib/modules/checkbox/checkbox.component.spec.ts index 58781e3f1f..ba27f796b4 100644 --- a/libs/components/forms/src/lib/modules/checkbox/checkbox.component.spec.ts +++ b/libs/components/forms/src/lib/modules/checkbox/checkbox.component.spec.ts @@ -36,6 +36,7 @@ import { SkyCheckboxModule } from './checkbox.module'; [disabled]="isDisabled" [icon]="icon" [id]="id" + [labelText]="labelText" [(indeterminate)]="indeterminate" (change)="checkboxChange($event)" > @@ -54,6 +55,7 @@ class SingleCheckboxComponent implements AfterViewInit { public isChecked: boolean | undefined = false; public isDisabled = false; public showInlineHelp = false; + public labelText: string | undefined; @ViewChild(SkyCheckboxComponent) public checkboxComponent: SkyCheckboxComponent | undefined; @@ -92,7 +94,12 @@ class CheckboxWithFormDirectivesComponent { template: `
- + Be good Help inline @@ -105,6 +112,7 @@ class CheckboxWithFormDirectivesComponent { class CheckboxWithRequiredInputComponent { public required = true; public showInlineHelp = false; + public labelText: string | undefined; } /** Simple component for testing a required template-driven checkbox. */ @@ -152,6 +160,7 @@ class CheckboxWithReactiveFormComponent { name="cb" formControlName="checkbox1" [required]="required" + [labelText]="labelText" #wut > Be good @@ -164,6 +173,7 @@ class CheckboxWithReactiveFormRequiredInputComponent { public checkbox1: UntypedFormControl = new UntypedFormControl(false); public checkboxForm = new UntypedFormGroup({ checkbox1: this.checkbox1 }); public required = true; + public labelText: string | undefined; } /** Simple component for testing a reactive form checkbox with required validator. */ @@ -429,12 +439,26 @@ describe('Checkbox component', () => { }); it('should project the checkbox content into the label element', () => { + fixture.detectChanges(); const label = checkboxNativeElement?.querySelector( '.sky-checkbox-wrapper sky-checkbox-label', ); + expect(label?.textContent?.trim()).toBe('Simple checkbox'); }); + it('should render the labelText when provided', () => { + const labelText = 'Label text'; + testComponent.labelText = labelText; + fixture.detectChanges(); + + const label = checkboxNativeElement?.querySelector( + '.sky-checkbox-wrapper sky-checkbox-label', + ); + + expect(label?.textContent?.trim()).toBe(labelText); + }); + it('should make the host element a tab stop', () => { expect(inputElement?.tabIndex).toBe(0); }); @@ -798,6 +822,21 @@ describe('Checkbox component', () => { expect(labelElement).toHaveCssClass('sky-control-label-required'); }); + it('should display a required error message when labelText is set and the checkbox is touched or dirty but not selected', () => { + testComponent.labelText = 'Label'; + fixture.detectChanges(); + + labelElement?.click(); + labelElement?.click(); + + fixture.detectChanges(); + + const errorMessage = checkboxNativeElement?.querySelector( + '.sky-form-error-required', + ); + expect(errorMessage).toBeVisible(); + }); + it('should not have required and aria-required attributes when input is false', async () => { fixture.detectChanges(); testComponent.required = false; @@ -1100,6 +1139,21 @@ describe('Checkbox component', () => { labelElement?.click(); expect(formControl.valid).toBe(false); }); + + it('should display a required error message when labelText is set and the checkbox is touched or dirty but not selected', () => { + testComponent.labelText = 'Label'; + fixture.detectChanges(); + + labelElement?.click(); + labelElement?.click(); + + fixture.detectChanges(); + + const errorMessage = checkboxNativeElement?.querySelector( + '.sky-form-error-required', + ); + expect(errorMessage).toBeVisible(); + }); }); describe('with name attribute', () => { diff --git a/libs/components/forms/src/lib/modules/checkbox/checkbox.component.ts b/libs/components/forms/src/lib/modules/checkbox/checkbox.component.ts index d743f943ad..e99e0fb060 100644 --- a/libs/components/forms/src/lib/modules/checkbox/checkbox.component.ts +++ b/libs/components/forms/src/lib/modules/checkbox/checkbox.component.ts @@ -14,6 +14,7 @@ import { SkyIdService, SkyLogService } from '@skyux/core'; import { BehaviorSubject, Observable } from 'rxjs'; +import { FORM_ERRORS } from '../form-error/form-errors-token'; import { SkyFormsUtility } from '../shared/forms-utility'; import { SkyCheckboxChange } from './checkbox-change'; @@ -26,6 +27,12 @@ import { SkyCheckboxChange } from './checkbox-change'; selector: 'sky-checkbox', templateUrl: './checkbox.component.html', styleUrls: ['./checkbox.component.scss'], + providers: [ + { + provide: FORM_ERRORS, + useValue: true, + }, + ], }) export class SkyCheckboxComponent implements ControlValueAccessor, OnInit { /** @@ -198,6 +205,13 @@ export class SkyCheckboxComponent implements ControlValueAccessor, OnInit { return this.#_required; } + /** + * The text to display as the checkbox's label. Use this instead of the `sky-checkbox-label` when the label is text-only. + * Specifying `labelText` also enables automatic error message handling for checkbox. + */ + @Input() + public labelText: string | undefined; + /** * Fires when users select or deselect the checkbox. */ @@ -253,13 +267,18 @@ export class SkyCheckboxComponent implements ControlValueAccessor, OnInit { #_required = false; #changeDetector = inject(ChangeDetectorRef); - #defaultId = inject(SkyIdService).generateId(); + #idSvc = inject(SkyIdService); + #defaultId = this.#idSvc.generateId(); #logger = inject(SkyLogService); - #ngControl = inject(NgControl, { optional: true, self: true }); + + protected ngControl = inject(NgControl, { optional: true, self: true }); + + public readonly errorId = this.#idSvc.generateId(); + public errorLabelText: string | undefined; constructor() { - if (this.#ngControl) { - this.#ngControl.valueAccessor = this; + if (this.ngControl) { + this.ngControl.valueAccessor = this; } this.#checkedChange = new BehaviorSubject(this.checked); @@ -275,10 +294,10 @@ export class SkyCheckboxComponent implements ControlValueAccessor, OnInit { } public ngOnInit(): void { - if (this.#ngControl) { + if (this.ngControl) { // Backwards compatibility support for anyone still using Validators.Required. this.required = - this.required || SkyFormsUtility.hasRequiredValidation(this.#ngControl); + this.required || SkyFormsUtility.hasRequiredValidation(this.ngControl); } } @@ -354,16 +373,16 @@ export class SkyCheckboxComponent implements ControlValueAccessor, OnInit { #setValidators(): void { if ( this.required && - !this.#ngControl?.control?.hasValidator(Validators.requiredTrue) + !this.ngControl?.control?.hasValidator(Validators.requiredTrue) ) { - this.#ngControl?.control?.addValidators(Validators.requiredTrue); - this.#ngControl?.control?.updateValueAndValidity(); + this.ngControl?.control?.addValidators(Validators.requiredTrue); + this.ngControl?.control?.updateValueAndValidity(); } else if ( !this.required && - this.#ngControl?.control?.hasValidator(Validators.requiredTrue) + this.ngControl?.control?.hasValidator(Validators.requiredTrue) ) { - this.#ngControl.control.removeValidators(Validators.requiredTrue); - this.#ngControl.control?.updateValueAndValidity(); + this.ngControl.control.removeValidators(Validators.requiredTrue); + this.ngControl.control?.updateValueAndValidity(); } } } diff --git a/libs/components/forms/src/lib/modules/checkbox/checkbox.module.ts b/libs/components/forms/src/lib/modules/checkbox/checkbox.module.ts index 9eef50c8f1..2486ca5d92 100644 --- a/libs/components/forms/src/lib/modules/checkbox/checkbox.module.ts +++ b/libs/components/forms/src/lib/modules/checkbox/checkbox.module.ts @@ -4,12 +4,28 @@ import { FormsModule } from '@angular/forms'; import { SkyTrimModule } from '@skyux/core'; import { SkyIconModule } from '@skyux/indicators'; +import { SkyFormErrorComponent } from '../form-error/form-error.component'; +import { SkyFormErrorsModule } from '../form-error/form-errors.module'; +import { SkyFormsResourcesModule } from '../shared/sky-forms-resources.module'; + import { SkyCheckboxLabelComponent } from './checkbox-label.component'; import { SkyCheckboxComponent } from './checkbox.component'; @NgModule({ declarations: [SkyCheckboxComponent, SkyCheckboxLabelComponent], - imports: [CommonModule, FormsModule, SkyIconModule, SkyTrimModule], - exports: [SkyCheckboxComponent, SkyCheckboxLabelComponent], + imports: [ + CommonModule, + FormsModule, + SkyFormErrorComponent, + SkyFormErrorsModule, + SkyFormsResourcesModule, + SkyIconModule, + SkyTrimModule, + ], + exports: [ + SkyCheckboxComponent, + SkyCheckboxLabelComponent, + SkyFormErrorComponent, + ], }) export class SkyCheckboxModule {} diff --git a/libs/components/forms/src/lib/modules/form-error/form-error.component.ts b/libs/components/forms/src/lib/modules/form-error/form-error.component.ts index 1aae6fd575..679f12e147 100644 --- a/libs/components/forms/src/lib/modules/form-error/form-error.component.ts +++ b/libs/components/forms/src/lib/modules/form-error/form-error.component.ts @@ -1,15 +1,26 @@ -import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + HostBinding, + Input, + inject, +} from '@angular/core'; import { SkyStatusIndicatorModule } from '@skyux/indicators'; +import { FORM_ERRORS } from './form-errors-token'; + /** * @internal */ @Component({ selector: 'sky-form-error', standalone: true, - imports: [SkyStatusIndicatorModule], + imports: [SkyStatusIndicatorModule, CommonModule], template: ` ('FORM_ERRORS'); diff --git a/libs/components/forms/src/lib/modules/form-error/form-errors.component.html b/libs/components/forms/src/lib/modules/form-error/form-errors.component.html index 43003248ee..850a0bda11 100644 --- a/libs/components/forms/src/lib/modules/form-error/form-errors.component.html +++ b/libs/components/forms/src/lib/modules/form-error/form-errors.component.html @@ -1,11 +1,11 @@ - + {{ 'skyux_form_error_required' | skyLibResources : labelText }} {{ 'skyux_form_error_maxlength' @@ -15,7 +15,7 @@ {{ 'skyux_form_error_minlength' @@ -23,33 +23,23 @@ }} - - {{ - 'skyux_form_error_character_count' - | skyLibResources : labelText : characterCounterError.limit - }} - - - + {{ 'skyux_form_error_date' | skyLibResources }} - + {{ 'skyux_form_error_email' | skyLibResources }} - + {{ 'skyux_form_error_phone' | skyLibResources }} - + {{ 'skyux_form_error_time' | skyLibResources }} - + {{ 'skyux_form_error_url' | skyLibResources }} diff --git a/libs/components/forms/src/lib/modules/form-error/form-errors.component.scss b/libs/components/forms/src/lib/modules/form-error/form-errors.component.scss index de61745539..5f35216baf 100644 --- a/libs/components/forms/src/lib/modules/form-error/form-errors.component.scss +++ b/libs/components/forms/src/lib/modules/form-error/form-errors.component.scss @@ -1,4 +1,5 @@ :host, sky-status-indicator { display: block; + line-height: normal; } diff --git a/libs/components/forms/src/lib/modules/input-box/input-box.component.html b/libs/components/forms/src/lib/modules/input-box/input-box.component.html index 477d94e91c..633402a7d4 100644 --- a/libs/components/forms/src/lib/modules/input-box/input-box.component.html +++ b/libs/components/forms/src/lib/modules/input-box/input-box.component.html @@ -176,6 +176,7 @@ [showErrors]="controlDir?.touched || controlDir?.dirty" > + diff --git a/libs/components/forms/src/lib/modules/input-box/input-box.component.ts b/libs/components/forms/src/lib/modules/input-box/input-box.component.ts index ac67a3b24d..f0c47a4ca6 100644 --- a/libs/components/forms/src/lib/modules/input-box/input-box.component.ts +++ b/libs/components/forms/src/lib/modules/input-box/input-box.component.ts @@ -31,6 +31,8 @@ import { SkyContentInfoProvider, SkyIdService } from '@skyux/core'; import { ReplaySubject } from 'rxjs'; +import { FORM_ERRORS } from '../form-error/form-errors-token'; + import { SkyInputBoxAdapterService } from './input-box-adapter.service'; import { SkyInputBoxControlDirective } from './input-box-control.directive'; import { SkyInputBoxHostService } from './input-box-host.service'; @@ -47,6 +49,10 @@ import { SkyInputBoxPopulateArgs } from './input-box-populate-args'; SkyContentInfoProvider, SkyInputBoxAdapterService, SkyInputBoxHostService, + { + provide: FORM_ERRORS, + useValue: true, + }, ], // Note that change detection is not set to OnPush; default change detection allows the // invalid CSS class to be added when the content control's invalid/dirty state changes. diff --git a/libs/components/forms/src/lib/modules/input-box/input-box.module.ts b/libs/components/forms/src/lib/modules/input-box/input-box.module.ts index a07ab8ddca..768718ea68 100644 --- a/libs/components/forms/src/lib/modules/input-box/input-box.module.ts +++ b/libs/components/forms/src/lib/modules/input-box/input-box.module.ts @@ -3,6 +3,7 @@ import { NgModule } from '@angular/core'; import { SkyThemeModule } from '@skyux/theme'; import { SkyCharacterCounterModule } from '../character-counter/character-counter.module'; +import { SkyFormErrorComponent } from '../form-error/form-error.component'; import { SkyFormErrorsComponent } from '../form-error/form-errors.component'; import { SkyInputBoxControlDirective } from './input-box-control.directive'; @@ -14,11 +15,16 @@ import { SkyInputBoxComponent } from './input-box.component'; imports: [ CommonModule, SkyCharacterCounterModule, + SkyFormErrorComponent, SkyFormErrorsComponent, SkyInputBoxControlDirective, SkyInputBoxHelpInlineComponent, SkyThemeModule, ], - exports: [SkyInputBoxComponent, SkyInputBoxControlDirective], + exports: [ + SkyInputBoxComponent, + SkyInputBoxControlDirective, + SkyFormErrorComponent, + ], }) export class SkyInputBoxModule {} diff --git a/libs/components/forms/src/lib/modules/shared/sky-forms-resources.module.ts b/libs/components/forms/src/lib/modules/shared/sky-forms-resources.module.ts index 176f00ca26..fb14f8a03f 100644 --- a/libs/components/forms/src/lib/modules/shared/sky-forms-resources.module.ts +++ b/libs/components/forms/src/lib/modules/shared/sky-forms-resources.module.ts @@ -23,9 +23,6 @@ const RESOURCES: { [locale: string]: SkyLibResources } = { skyux_character_count_over_limit: { message: 'You are over the character limit.', }, - skyux_form_error_character_count: { - message: 'Limit {0} to {1} character(s).', - }, skyux_form_error_date: { message: 'Select or enter a valid date.' }, skyux_form_error_email: { message: 'Enter an email address with a valid format.', @@ -96,6 +93,7 @@ const RESOURCES: { [locale: string]: SkyLibResources } = { skyux_input_box_help_inline_aria_label: { message: 'Show help content for {0}', }, + skyux_checkbox_required_label_text: { message: 'This selection' }, }, }; diff --git a/libs/components/forms/testing/src/checkbox/checkbox-harness.spec.ts b/libs/components/forms/testing/src/checkbox/checkbox-harness.spec.ts index abf0c51218..347d21857a 100644 --- a/libs/components/forms/testing/src/checkbox/checkbox-harness.spec.ts +++ b/libs/components/forms/testing/src/checkbox/checkbox-harness.spec.ts @@ -107,6 +107,15 @@ describe('Checkbox harness', () => { await expectAsync(checkboxHarness.getLabelText()).toBeResolvedTo(undefined); }); + it('should get the label when specified via labelText input', async () => { + const { checkboxHarness } = await setupTest({ + dataSkyId: 'my-phone-checkbox', + hideEmailLabel: true, + }); + + await expectAsync(checkboxHarness.getLabelText()).toBeResolvedTo('Phone'); + }); + it('should get the checkbox name and value', async () => { const { checkboxHarness } = await setupTest({ dataSkyId: 'my-email-checkbox', @@ -131,4 +140,39 @@ describe('Checkbox harness', () => { 'Could not toggle the checkbox because it is disabled.', ); }); + + it('should display a required error message when there is an error', async () => { + const { checkboxHarness } = await setupTest({ + dataSkyId: 'my-phone-checkbox', + }); + + await checkboxHarness.check(); + await checkboxHarness.uncheck(); + + await expectAsync(checkboxHarness.hasRequiredError()).toBeResolvedTo(true); + }); + + it('should display a custom error message when there is a custom validation error', async () => { + const { checkboxHarness } = await setupTest({ + dataSkyId: 'my-mail-checkbox', + }); + + await checkboxHarness.check(); + await checkboxHarness.uncheck(); + await checkboxHarness.check(); + + await expectAsync( + checkboxHarness.hasCustomError('requiredFalse'), + ).toBeResolvedTo(true); + }); + + it('should throw an error if no form error is found', async () => { + const { checkboxHarness } = await setupTest({ + dataSkyId: 'my-email-checkbox', + }); + + await expectAsync(checkboxHarness.hasRequiredError()).toBeRejectedWithError( + 'No error found', + ); + }); }); diff --git a/libs/components/forms/testing/src/checkbox/checkbox-harness.ts b/libs/components/forms/testing/src/checkbox/checkbox-harness.ts index 6f9eb6fd02..c155c66693 100644 --- a/libs/components/forms/testing/src/checkbox/checkbox-harness.ts +++ b/libs/components/forms/testing/src/checkbox/checkbox-harness.ts @@ -1,6 +1,8 @@ import { HarnessPredicate } from '@angular/cdk/testing'; import { SkyComponentHarness } from '@skyux/core/testing'; +import { SkyFormErrorsHarness } from '../public-api'; + import { SkyCheckboxHarnessFilters } from './checkbox-harness-filters'; import { SkyCheckboxLabelHarness } from './checkbox-label-harness'; @@ -18,6 +20,16 @@ export class SkyCheckboxHarness extends SkyComponentHarness { #getLabel = this.locatorForOptional(SkyCheckboxLabelHarness); + async #getFormErrors(): Promise { + const harness = await this.locatorForOptional(SkyFormErrorsHarness)(); + + if (harness) { + return harness; + } + + throw Error('No error found'); + } + /** * Gets a `HarnessPredicate` that can be used to search for a * `SkyCheckboxHarness` that meets certain criteria. @@ -129,6 +141,14 @@ export class SkyCheckboxHarness extends SkyComponentHarness { } } + public async hasRequiredError(): Promise { + return (await this.#getFormErrors()).hasError('required'); + } + + public async hasCustomError(errorName: string): Promise { + return (await this.#getFormErrors()).hasError(errorName); + } + async #toggle(): Promise { if (await this.isDisabled()) { throw new Error('Could not toggle the checkbox because it is disabled.'); diff --git a/libs/components/forms/testing/src/checkbox/fixtures/checkbox-harness-test.component.html b/libs/components/forms/testing/src/checkbox/fixtures/checkbox-harness-test.component.html index 889ad13aff..27e4a25b7d 100644 --- a/libs/components/forms/testing/src/checkbox/fixtures/checkbox-harness-test.component.html +++ b/libs/components/forms/testing/src/checkbox/fixtures/checkbox-harness-test.component.html @@ -15,12 +15,26 @@
  • +
  • +
  • + - Phone + + This checkbox must be unchecked +
  • diff --git a/libs/components/forms/testing/src/checkbox/fixtures/checkbox-harness-test.component.ts b/libs/components/forms/testing/src/checkbox/fixtures/checkbox-harness-test.component.ts index 15e3741616..b8840529c9 100644 --- a/libs/components/forms/testing/src/checkbox/fixtures/checkbox-harness-test.component.ts +++ b/libs/components/forms/testing/src/checkbox/fixtures/checkbox-harness-test.component.ts @@ -1,9 +1,10 @@ import { Component } from '@angular/core'; import { + AbstractControl, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, - Validators, + ValidationErrors, } from '@angular/forms'; @Component({ @@ -13,15 +14,27 @@ import { export class CheckboxHarnessTestComponent { public myForm: UntypedFormGroup; public hideEmailLabel = false; + public mailControl: UntypedFormControl; #formBuilder: UntypedFormBuilder; constructor(formBuilder: UntypedFormBuilder) { this.#formBuilder = formBuilder; + this.mailControl = new UntypedFormControl(false, [ + (control: AbstractControl): ValidationErrors | null => { + if (control.value) { + return { requiredFalse: true }; + } + + return null; + }, + ]); + this.myForm = this.#formBuilder.group({ email: new UntypedFormControl(false), - phone: new UntypedFormControl(false, [Validators.required]), + phone: new UntypedFormControl(false), + mail: this.mailControl, }); } diff --git a/libs/components/forms/testing/src/form-error/form-error-harness.ts b/libs/components/forms/testing/src/form-error/form-error-harness.ts index cc6c5ada58..7dcada5095 100644 --- a/libs/components/forms/testing/src/form-error/form-error-harness.ts +++ b/libs/components/forms/testing/src/form-error/form-error-harness.ts @@ -19,15 +19,10 @@ export class SkyFormErrorHarness extends SkyComponentHarness { return SkyFormErrorHarness.getDataSkyIdPredicate(filters); } - async #getFormErrorClasses(): Promise { - const formErrorClasses = await (await this.host()).getProperty('classList'); - return Array.from(formErrorClasses); - } - /* - * Gets the error class that signifies which error has fired. + * Gets the error name. */ - public async getFirstClassError(): Promise { - return (await this.#getFormErrorClasses())[0]; + public async getErrorName(): Promise { + return (await this.host()).getAttribute('errorName'); } } diff --git a/libs/components/forms/testing/src/form-error/form-errors-harness.spec.ts b/libs/components/forms/testing/src/form-error/form-errors-harness.spec.ts index e7d9e626c6..a7692d5c44 100644 --- a/libs/components/forms/testing/src/form-error/form-errors-harness.spec.ts +++ b/libs/components/forms/testing/src/form-error/form-errors-harness.spec.ts @@ -4,13 +4,23 @@ import { Component } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ValidationErrors } from '@angular/forms'; // eslint-disable-next-line @nx/enforce-module-boundaries -import { SkyFormErrorsModule } from '@skyux/forms'; +import { + FORM_ERRORS, + SkyFormErrorComponent, + SkyFormErrorsModule, +} from '@skyux/forms'; import { SkyFormErrorsHarness } from './form-errors-harness'; //#region Test component @Component({ selector: 'sky-form-errors-test', + providers: [ + { + provide: FORM_ERRORS, + useValue: true, + }, + ], template: ` { }> { await TestBed.configureTestingModule({ declarations: [TestComponent], - imports: [SkyFormErrorsModule], + imports: [SkyFormErrorsModule, SkyFormErrorComponent], }).compileComponents(); const fixture = TestBed.createComponent(TestComponent); @@ -53,60 +63,28 @@ describe('Form errors harness', () => { dataSkyId: 'other-error', }); + fixture.detectChanges(); fixture.detectChanges(); - await expectAsync(formErrorsHarness.getNumberOfErrors()).toBeResolvedTo(1); - await expectAsync(formErrorsHarness.hasRequiredError()).toBeResolvedTo( + await expectAsync(formErrorsHarness.hasError('required')).toBeResolvedTo( true, ); + await expectAsync(formErrorsHarness.getFormErrors()).toBeResolvedTo([ + { errorName: 'required' }, + ]); }); - it('should get number of errors', async () => { - const { formErrorsHarness, fixture } = await setupTest(); - - fixture.detectChanges(); - - await expectAsync(formErrorsHarness.getNumberOfErrors()).toBeResolvedTo(0); - - fixture.componentInstance.errors = { - required: true, - minlength: true, - }; - fixture.detectChanges(); - - await expectAsync(formErrorsHarness.getNumberOfErrors()).toBeResolvedTo(2); - }); - - it('should return whether first class errors are fired', async () => { + it('should return an array of current errors', async () => { const { formErrorsHarness, fixture } = await setupTest(); fixture.detectChanges(); - await expectAsync(formErrorsHarness.hasRequiredError()).toBeResolvedTo( - false, - ); - await expectAsync(formErrorsHarness.hasMaxLengthError()).toBeResolvedTo( - false, - ); - await expectAsync(formErrorsHarness.hasMinLengthError()).toBeResolvedTo( - false, - ); - await expectAsync( - formErrorsHarness.hasCharacterCountError(), - ).toBeResolvedTo(false); - await expectAsync(formErrorsHarness.hasDateError()).toBeResolvedTo(false); - await expectAsync(formErrorsHarness.hasEmailError()).toBeResolvedTo(false); - await expectAsync(formErrorsHarness.hasPhoneFieldError()).toBeResolvedTo( - false, - ); - await expectAsync(formErrorsHarness.hasTimeError()).toBeResolvedTo(false); - await expectAsync(formErrorsHarness.hasUrlError()).toBeResolvedTo(false); + await expectAsync(formErrorsHarness.getFormErrors()).toBeResolvedTo([]); fixture.componentInstance.errors = { required: true, minlength: true, maxlength: true, - skyCharacterCounter: true, skyDate: true, skyEmail: true, skyPhoneField: true, @@ -115,24 +93,15 @@ describe('Form errors harness', () => { }; fixture.detectChanges(); - await expectAsync(formErrorsHarness.hasRequiredError()).toBeResolvedTo( - true, - ); - await expectAsync(formErrorsHarness.hasMaxLengthError()).toBeResolvedTo( - true, - ); - await expectAsync(formErrorsHarness.hasMinLengthError()).toBeResolvedTo( - true, - ); - await expectAsync( - formErrorsHarness.hasCharacterCountError(), - ).toBeResolvedTo(true); - await expectAsync(formErrorsHarness.hasDateError()).toBeResolvedTo(true); - await expectAsync(formErrorsHarness.hasEmailError()).toBeResolvedTo(true); - await expectAsync(formErrorsHarness.hasPhoneFieldError()).toBeResolvedTo( - true, - ); - await expectAsync(formErrorsHarness.hasTimeError()).toBeResolvedTo(true); - await expectAsync(formErrorsHarness.hasUrlError()).toBeResolvedTo(true); + await expectAsync(formErrorsHarness.getFormErrors()).toBeResolvedTo([ + { errorName: 'required' }, + { errorName: 'maxlength' }, + { errorName: 'minlength' }, + { errorName: 'date' }, + { errorName: 'email' }, + { errorName: 'phone' }, + { errorName: 'time' }, + { errorName: 'url' }, + ]); }); }); diff --git a/libs/components/forms/testing/src/form-error/form-errors-harness.ts b/libs/components/forms/testing/src/form-error/form-errors-harness.ts index 462e5d3575..c72fe0a17e 100644 --- a/libs/components/forms/testing/src/form-error/form-errors-harness.ts +++ b/libs/components/forms/testing/src/form-error/form-errors-harness.ts @@ -10,20 +10,6 @@ export class SkyFormErrorsHarness extends SkyComponentHarness { */ public static hostSelector = 'sky-form-errors'; - #getFormError = this.locatorForAll('sky-form-error'); - - async #getFormErrorsClasses(): Promise { - const formErrorHarnesses = await this.locatorForAll( - SkyFormErrorHarness.with({}), - )(); - - return Promise.all( - formErrorHarnesses.map((formError) => { - return formError.getFirstClassError(); - }), - ); - } - /** * Gets a `HarnessPredicate` that can be used to search for a * `SkyFormErrorsHarness` that meets certain criteria @@ -34,85 +20,28 @@ export class SkyFormErrorsHarness extends SkyComponentHarness { return SkyFormErrorsHarness.getDataSkyIdPredicate(filters); } - /* - * Gets total number of errors. - */ - public async getNumberOfErrors(): Promise { - return (await this.#getFormError()).length; - } - - /* - * Gets if the required error has triggered. - */ - public async hasRequiredError(): Promise { - return (await this.#getFormErrorsClasses()).includes( - 'sky-form-error-required', - ); - } - - /* - * Gets if the maximum length error has triggered. - */ - public async hasMaxLengthError(): Promise { - return (await this.#getFormErrorsClasses()).includes( - 'sky-form-error-maxlength', - ); - } - - /* - * Gets if the minimum length has triggered. - */ - public async hasMinLengthError(): Promise { - return (await this.#getFormErrorsClasses()).includes( - 'sky-form-error-minlength', - ); - } - - /* - * Gets if the character count error has triggered. - */ - public async hasCharacterCountError(): Promise { - return (await this.#getFormErrorsClasses()).includes( - 'sky-form-error-character-counter', - ); - } - - /* - * Gets if the date error has triggered. - */ - public async hasDateError(): Promise { - return (await this.#getFormErrorsClasses()).includes('sky-form-error-date'); - } - - /* - * Gets if the email error has triggered. + /** + * Gets an array of all errors fired. */ - public async hasEmailError(): Promise { - return (await this.#getFormErrorsClasses()).includes( - 'sky-form-error-email', - ); - } + public async getFormErrors(): Promise<{ errorName: string | null }[]> { + const formErrorHarnesses = await this.locatorForAll( + SkyFormErrorHarness.with({}), + )(); - /* - * Gets if the phone field error has triggered. - */ - public async hasPhoneFieldError(): Promise { - return (await this.#getFormErrorsClasses()).includes( - 'sky-form-error-phone', + return Promise.all( + formErrorHarnesses.map(async (formError) => { + return { errorName: await formError.getErrorName() }; + }), ); } - /* - * Gets if the time field error has triggered. - */ - public async hasTimeError(): Promise { - return (await this.#getFormErrorsClasses()).includes('sky-form-error-time'); - } - - /* - * Gets if the URL error has triggered. + /** + * Whether an error with the given name has fired. */ - public async hasUrlError(): Promise { - return (await this.#getFormErrorsClasses()).includes('sky-form-error-url'); + public async hasError(errorName: string): Promise { + const formErrors = await this.getFormErrors(); + return !!formErrors.find((error) => { + return error.errorName === errorName; + }); } } diff --git a/libs/components/forms/testing/src/input-box/fixtures/input-box-harness-test.component.html b/libs/components/forms/testing/src/input-box/fixtures/input-box-harness-test.component.html index 9419510535..e8e5bf744e 100644 --- a/libs/components/forms/testing/src/input-box/fixtures/input-box-harness-test.component.html +++ b/libs/components/forms/testing/src/input-box/fixtures/input-box-harness-test.component.html @@ -40,4 +40,53 @@ +
    + + + + + + + + + + + + + + + + + + + + + This is a customer error + + + Help content from template diff --git a/libs/components/forms/testing/src/input-box/fixtures/input-box-harness-test.component.ts b/libs/components/forms/testing/src/input-box/fixtures/input-box-harness-test.component.ts index baa02a6c51..3f9b9beaa7 100644 --- a/libs/components/forms/testing/src/input-box/fixtures/input-box-harness-test.component.ts +++ b/libs/components/forms/testing/src/input-box/fixtures/input-box-harness-test.component.ts @@ -11,6 +11,7 @@ import { }) export class InputBoxHarnessTestComponent { public myForm: UntypedFormGroup; + public directiveErrorForm: UntypedFormGroup; @ViewChild('helpContentTemplate', { read: TemplateRef, @@ -32,5 +33,10 @@ export class InputBoxHarnessTestComponent { firstName: new UntypedFormControl('John'), lastName: new UntypedFormControl('Doe'), }); + this.directiveErrorForm = formBuilder.group({ + easyModeDatepicker: new UntypedFormControl('123'), + easyModeTimepicker: new UntypedFormControl('abc'), + easyModePhoneField: new UntypedFormControl('abc'), + }); } } diff --git a/libs/components/forms/testing/src/input-box/fixtures/input-box-harness-test.module.ts b/libs/components/forms/testing/src/input-box/fixtures/input-box-harness-test.module.ts index 26b4933f3d..08ca311bf2 100644 --- a/libs/components/forms/testing/src/input-box/fixtures/input-box-harness-test.module.ts +++ b/libs/components/forms/testing/src/input-box/fixtures/input-box-harness-test.module.ts @@ -1,8 +1,10 @@ import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { SkyIdModule } from '@skyux/core'; +import { SkyDatepickerModule, SkyTimepickerModule } from '@skyux/datetime'; import { SkyInputBoxModule } from '@skyux/forms'; import { SkyStatusIndicatorModule } from '@skyux/indicators'; +import { SkyPhoneFieldModule } from '@skyux/phone-field'; import { InputBoxHarnessTestComponent } from './input-box-harness-test.component'; @@ -13,6 +15,9 @@ import { InputBoxHarnessTestComponent } from './input-box-harness-test.component SkyIdModule, SkyInputBoxModule, SkyStatusIndicatorModule, + SkyDatepickerModule, + SkyTimepickerModule, + SkyPhoneFieldModule, ], declarations: [InputBoxHarnessTestComponent], }) diff --git a/libs/components/forms/testing/src/input-box/input-box-harness.spec.ts b/libs/components/forms/testing/src/input-box/input-box-harness.spec.ts index b349563143..8539749f63 100644 --- a/libs/components/forms/testing/src/input-box/input-box-harness.spec.ts +++ b/libs/components/forms/testing/src/input-box/input-box-harness.spec.ts @@ -2,6 +2,7 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Validators } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SkyValidators } from '@skyux/validation'; import { InputBoxHarnessTestComponent } from './fixtures/input-box-harness-test.component'; import { InputBoxHarnessTestModule } from './fixtures/input-box-harness-test.module'; @@ -138,6 +139,127 @@ describe('Input box harness', () => { await expectAsync(customError.getText()).toBeResolvedTo('Test error'); }); + it('should return whether custom form error has fired', async () => { + const { inputBoxHarness } = await setupTest({ + dataSkyId: 'custom-error-easy-mode', + }); + + await expectAsync( + inputBoxHarness.hasCustomFormError('custom'), + ).toBeResolvedTo(true); + }); + + it('should return whether required error has fired', async () => { + const { component, fixture, inputBoxHarness } = await setupTest({ + dataSkyId: 'my-input-box-last-name-easy-mode', + }); + + const control = component.myForm.controls['lastName']; + control.addValidators(Validators.required); + control.setValue(''); + control.markAsDirty(); + + fixture.detectChanges(); + + await expectAsync(inputBoxHarness.hasRequiredError()).toBeResolvedTo(true); + }); + + it('should return whether minimum length error has fired', async () => { + const { component, fixture, inputBoxHarness } = await setupTest({ + dataSkyId: 'my-input-box-last-name-easy-mode', + }); + + const control = component.myForm.controls['lastName']; + control.addValidators(Validators.minLength(2)); + control.setValue('a'); + control.markAsDirty(); + + fixture.detectChanges(); + + await expectAsync(inputBoxHarness.hasMinLengthError()).toBeResolvedTo(true); + }); + + it('should return whether maximum length error has fired', async () => { + const { component, fixture, inputBoxHarness } = await setupTest({ + dataSkyId: 'my-input-box-last-name-easy-mode', + }); + + const control = component.myForm.controls['lastName']; + control.addValidators(Validators.maxLength(1)); + control.setValue('abc'); + control.markAsDirty(); + + fixture.detectChanges(); + + await expectAsync(inputBoxHarness.hasMaxLengthError()).toBeResolvedTo(true); + }); + + it('should return whether email validator error has fired', async () => { + const { component, fixture, inputBoxHarness } = await setupTest({ + dataSkyId: 'my-input-box-last-name-easy-mode', + }); + + const control = component.myForm.controls['lastName']; + control.addValidators(SkyValidators.email); + control.setValue('abc'); + control.markAsDirty(); + + fixture.detectChanges(); + + await expectAsync(inputBoxHarness.hasEmailError()).toBeResolvedTo(true); + }); + + it('should return whether url validator error has fired', async () => { + const { component, fixture, inputBoxHarness } = await setupTest({ + dataSkyId: 'my-input-box-last-name-easy-mode', + }); + + const control = component.myForm.controls['lastName']; + control.addValidators(SkyValidators.url); + control.setValue('abc'); + control.markAsDirty(); + + fixture.detectChanges(); + + await expectAsync(inputBoxHarness.hasUrlError()).toBeResolvedTo(true); + }); + + it('should return whether date picker validator error has fired', async () => { + const { fixture, inputBoxHarness } = await setupTest({ + dataSkyId: 'datepicker-easy-mode', + }); + + fixture.detectChanges(); + + await expectAsync(inputBoxHarness.hasDateError()).toBeResolvedTo(true); + }); + + it('should return whether time picker validator error has fired', async () => { + const { component, fixture, inputBoxHarness } = await setupTest({ + dataSkyId: 'timepicker-easy-mode', + }); + + const control = component.directiveErrorForm.controls['easyModeTimepicker']; + control.markAsDirty(); + fixture.detectChanges(); + + await expectAsync(inputBoxHarness.hasTimeError()).toBeResolvedTo(true); + }); + + it('should return whether phone field validator error has fired', async () => { + const { component, fixture, inputBoxHarness } = await setupTest({ + dataSkyId: 'phone-field-easy-mode', + }); + + const control = component.directiveErrorForm.controls['easyModePhoneField']; + control.markAsDirty(); + fixture.detectChanges(); + + await expectAsync(inputBoxHarness.hasPhoneFieldError()).toBeResolvedTo( + true, + ); + }); + it('should return character counter indicator', async () => { const { component, fixture, inputBoxHarness } = await setupTest({ dataSkyId: DATA_SKY_ID_EASY_MODE, diff --git a/libs/components/forms/testing/src/input-box/input-box-harness.ts b/libs/components/forms/testing/src/input-box/input-box-harness.ts index 01aefb6fcc..dc00ac57c8 100644 --- a/libs/components/forms/testing/src/input-box/input-box-harness.ts +++ b/libs/components/forms/testing/src/input-box/input-box-harness.ts @@ -9,6 +9,7 @@ import { SkyStatusIndicatorHarness } from '@skyux/indicators/testing'; import { SkyPopoverHarness } from '@skyux/popovers/testing'; import { SkyCharacterCounterIndicatorHarness } from '../character-counter/character-counter-indicator-harness'; +import { SkyFormErrorsHarness } from '../public-api'; import { SkyInputBoxHarnessFilters } from './input-box-harness-filters'; @@ -25,6 +26,10 @@ export class SkyInputBoxHarness extends SkyComponentHarness { #getLabel = this.locatorForOptional('.sky-control-label'); #getWrapper = this.locatorFor('.sky-input-box'); + async #getFormError(): Promise { + return this.locatorFor(SkyFormErrorsHarness)(); + } + /** * Gets a `HarnessPredicate` that can be used to search for a * `SkyInputBoxHarness` that meets certain criteria. @@ -79,6 +84,69 @@ export class SkyInputBoxHarness extends SkyComponentHarness { return errors; } + /** + * Whether the custom error is triggered. + */ + public async hasCustomFormError(errorName: string): Promise { + return (await this.#getFormError()).hasError(errorName); + } + + /** + * Whether the required field is empty. + */ + public async hasRequiredError(): Promise { + return (await this.#getFormError()).hasError('required'); + } + + /** + * Whether the field has more characters than allowed. + */ + public async hasMaxLengthError(): Promise { + return (await this.#getFormError()).hasError('maxlength'); + } + + /** + * Whether the field has fewer characters than allowed. + */ + public async hasMinLengthError(): Promise { + return (await this.#getFormError()).hasError('minlength'); + } + + /* + * Whether the field is set to an invalid email address. + */ + public async hasEmailError(): Promise { + return (await this.#getFormError()).hasError('email'); + } + + /* + * Whether the field is set to an invalid URL. + */ + public async hasUrlError(): Promise { + return (await this.#getFormError()).hasError('url'); + } + + /* + * Whether the field is set to an invalid date. + */ + public async hasDateError(): Promise { + return (await this.#getFormError()).hasError('date'); + } + + /* + * Whether the field is set to an invalid phone number. + */ + public async hasPhoneFieldError(): Promise { + return (await this.#getFormError()).hasError('phone'); + } + + /* + * Whether the field is set to an invalid time. + */ + public async hasTimeError(): Promise { + return (await this.#getFormError()).hasError('time'); + } + /** * Indicates whether the input box has disabled styles applied. */