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 16f4fb3f9d..cb8a58bed8 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 @@ -11,6 +11,11 @@ [presetColors]="swatches" [skyColorpickerInput]="colorPicker" /> + 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 2be6ed63c9..1847d4b646 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, + ValidationErrors, } from '@angular/forms'; import { SkyColorpickerModule, SkyColorpickerOutput } from '@skyux/colorpicker'; @@ -11,10 +14,11 @@ import { SkyColorpickerModule, SkyColorpickerOutput } from '@skyux/colorpicker'; standalone: true, selector: 'app-demo', templateUrl: './demo.component.html', - imports: [ReactiveFormsModule, SkyColorpickerModule], + imports: [CommonModule, ReactiveFormsModule, SkyColorpickerModule], }) export class DemoComponent { protected formGroup: FormGroup; + protected favoriteColor: FormControl; protected swatches: string[] = [ '#BD4040', @@ -26,8 +30,18 @@ export class DemoComponent { ]; constructor() { + this.favoriteColor = new FormControl('#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/playground/src/app/components/colorpicker/colorpicker/colorpicker.component.html b/apps/playground/src/app/components/colorpicker/colorpicker/colorpicker.component.html index 53271e1fed..1a77020fd3 100644 --- a/apps/playground/src/app/components/colorpicker/colorpicker/colorpicker.component.html +++ b/apps/playground/src/app/components/colorpicker/colorpicker/colorpicker.component.html @@ -13,8 +13,29 @@ [presetColors]="swatches" [skyColorpickerInput]="colorPicker" /> + + + + + Touched + {{ favoriteColor.touched }} + + + Pristine + {{ favoriteColor.pristine }} + + + Valid + {{ favoriteColor.valid }} + + + Submit 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..c537e4dae8 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,7 @@ import { SkyColorpickerOutput } from '@skyux/colorpicker'; }) export class ColorpickerComponent { public reactiveForm: UntypedFormGroup; + public favoriteColor: UntypedFormControl; public swatches: string[] = [ '#BD4040', @@ -23,8 +26,18 @@ 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.reactiveForm = formBuilder.group({ - favoriteColor: new UntypedFormControl('#f00'), + favoriteColor: this.favoriteColor, }); } 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 a9ba564bbe..901a8c0246 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 @@ -22,7 +22,7 @@ import { } from '@angular/forms'; import { SkyLibResourcesService } from '@skyux/i18n'; -import { Subject, Subscription, takeUntil } from 'rxjs'; +import { Subject, Subscription, distinctUntilChanged, takeUntil } from 'rxjs'; import { SkyColorpickerInputService } from './colorpicker-input.service'; import { SkyColorpickerComponent } from './colorpicker.component'; @@ -219,6 +219,27 @@ export class SkyColorpickerInputDirective } }); + 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.skyColorpickerInput.updatePickerValues(this.initialColor); /* Sanity check */ 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 index 5a6c21baf8..4c2647ee40 100644 --- a/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker-input.service.ts +++ b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker-input.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, OnDestroy } from '@angular/core'; import { ReplaySubject } from 'rxjs'; @@ -6,7 +6,16 @@ import { ReplaySubject } from 'rxjs'; * @internal */ @Injectable() -export class SkyColorpickerInputService { +export class SkyColorpickerInputService implements OnDestroy { public inputId = new ReplaySubject(1); public labelText = new ReplaySubject(1); + public ariaError = new ReplaySubject<{ hasError: boolean; errorId: string }>( + 1, + ); + + public ngOnDestroy(): void { + this.inputId.complete(); + this.labelText.complete(); + this.ariaError.complete(); + } } 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 335b1fe234..280338d3eb 100644 --- a/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.html +++ b/libs/components/colorpicker/src/lib/modules/colorpicker/colorpicker.component.html @@ -323,3 +323,13 @@ + + + + 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 20ca8bd882..b36baab140 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(); @@ -1210,20 +1212,21 @@ describe('Colorpicker Component', () => { verifyColorpicker(nativeElement, 'rgba(40,137,229,1)', '40, 137, 229'); })); - it('should toggle reset button via messageStream.', fakeAsync(() => { + 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) => { @@ -1318,6 +1321,67 @@ 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'); + + 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'); + + 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 03c7d742cf..b2541651f7 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,6 +16,12 @@ import { ViewEncapsulation, inject, } from '@angular/core'; +import { + AbstractControlDirective, + FormControlDirective, + FormControlName, + NgModel, +} from '@angular/forms'; import { SkyAffixAutoFitContext, SkyAffixService, @@ -23,6 +31,7 @@ import { SkyOverlayInstance, SkyOverlayService, } from '@skyux/core'; +import { SKY_FORM_ERRORS_ENABLED } from '@skyux/forms'; import { SkyIconType } from '@skyux/indicators'; import { SkyThemeService } from '@skyux/theme'; @@ -52,10 +61,16 @@ let componentIdIndex = 0; selector: 'sky-colorpicker', templateUrl: './colorpicker.component.html', styleUrls: ['./colorpicker.component.scss'], - providers: [SkyColorpickerInputService, SkyColorpickerService], + providers: [ + SkyColorpickerInputService, + SkyColorpickerService, + { provide: SKY_FORM_ERRORS_ENABLED, 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 @@ -281,6 +296,30 @@ 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 inputId: string | undefined; protected colorpickerId: string; protected isOpen = false; @@ -299,6 +338,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; @@ -322,6 +362,8 @@ export class SkyColorpickerComponent implements OnInit, OnDestroy { readonly #colorpickerInputSvc = inject(SkyColorpickerInputService); readonly #idSvc = inject(SkyIdService); + protected readonly errorId = this.#idSvc.generateId(); + #_backgroundColorForDisplay: string | undefined; #_colorpickerRef: ElementRef | undefined; #_disabled = false; @@ -411,6 +453,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(); @@ -420,6 +471,8 @@ 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..7c87e666b4 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 { + SkyFormErrorModule, + SkyFormErrorsModule, + SkyInputBoxModule, +} from '@skyux/forms'; import { SkyIconModule } from '@skyux/indicators'; import { SkyThemeModule } from '@skyux/theme'; @@ -23,10 +27,16 @@ import { SkyColorpickerComponent } from './colorpicker.component'; CommonModule, SkyAffixModule, SkyColorpickerResourcesModule, + SkyFormErrorModule, + SkyFormErrorsModule, SkyIconModule, SkyInputBoxModule, SkyThemeModule, ], - exports: [SkyColorpickerComponent, SkyColorpickerInputDirective], + exports: [ + SkyColorpickerComponent, + SkyColorpickerInputDirective, + SkyFormErrorModule, + ], }) export class SkyColorpickerModule {} 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 78f54098f5..239eea9202 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 @@ -13,5 +13,28 @@ [outputFormat]="selectedOutputFormat" [alphaChannel]="selectedHexType" /> + + + + + + + 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 428a90a61f..a3421100b0 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'; @@ -40,11 +45,32 @@ export class ColorpickerReactiveTestComponent { public newValues = { colorModel: '#000', + colorModel2: '#000', }; - public colorControl = new UntypedFormControl('#00f'); + 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, }); public sendMessage(type: SkyColorpickerMessageType) {