diff --git a/.eslintrc-overrides.json b/.eslintrc-overrides.json index 897244ae8d..760848e394 100644 --- a/.eslintrc-overrides.json +++ b/.eslintrc-overrides.json @@ -47,9 +47,11 @@ "@angular-eslint/template/interactive-supports-focus": ["warn"], "@angular-eslint/template/label-has-associated-control": ["warn"], "@angular-eslint/template/no-any": ["error"], - "@angular-eslint/template/no-call-expression": ["warn"], "@angular-eslint/template/no-distracting-elements": ["warn"], - "@angular-eslint/template/no-inline-styles": ["warn"], + "@angular-eslint/template/no-inline-styles": [ + "warn", + { "allowBindToStyle": true, "allowNgStyle": true } + ], "@angular-eslint/template/no-interpolation-in-attributes": ["warn"], "@angular-eslint/template/no-positive-tabindex": ["warn"], "@angular-eslint/template/prefer-control-flow": ["warn"], diff --git a/apps/code-examples/src/app/code-examples/popovers/popover/basic/demo.component.html b/apps/code-examples/src/app/code-examples/popovers/popover/basic/demo.component.html index 9d0678350f..f05093a97e 100644 --- a/apps/code-examples/src/app/code-examples/popovers/popover/basic/demo.component.html +++ b/apps/code-examples/src/app/code-examples/popovers/popover/basic/demo.component.html @@ -1,27 +1,14 @@ - Open popover on click + Open popover - - Open popover on hover - - - + {{ popoverBody }} diff --git a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-validator/ag-grid-cell-validator-tooltip.component.html b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-validator/ag-grid-cell-validator-tooltip.component.html index e5f03341aa..d56ac0e672 100644 --- a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-validator/ag-grid-cell-validator-tooltip.component.html +++ b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-validator/ag-grid-cell-validator-tooltip.component.html @@ -1,4 +1,5 @@ { }); const helpSvc = TestBed.inject(SkyHelpService); const helpSpy = spyOn(helpSvc, 'openHelp'); + fixture.componentInstance.helpKey = 'helpKey.html'; fixture.componentInstance.helpPopoverContent = undefined; fixture.detectChanges(); 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 4e73af1d6d..7beee2d7ce 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 @@ -29,7 +29,7 @@ [labelHidden]="hidePhoneLabel" data-sky-id="my-phone-checkbox" formControlName="phone" - helpKey="helpKey.html" + [helpKey]="helpKey" helpPopoverContent="(xxx)xxx-xxxx" helpPopoverTitle="Format" labelText="Phone" diff --git a/libs/components/forms/testing/src/field-group/field-group-harness.spec.ts b/libs/components/forms/testing/src/field-group/field-group-harness.spec.ts index 609bf08fd8..9d85e45bed 100644 --- a/libs/components/forms/testing/src/field-group/field-group-harness.spec.ts +++ b/libs/components/forms/testing/src/field-group/field-group-harness.spec.ts @@ -155,6 +155,8 @@ describe('Field group harness', () => { const helpSvc = TestBed.inject(SkyHelpService); const helpSpy = spyOn(helpSvc, 'openHelp'); + fixture.componentInstance.helpKey = 'helpKey.html'; + await fieldGroupHarness.clickHelpInline(); fixture.detectChanges(); await fixture.whenStable(); diff --git a/libs/components/forms/testing/src/field-group/fixtures/field-group.component.fixture.ts b/libs/components/forms/testing/src/field-group/fixtures/field-group.component.fixture.ts index db6afcefac..1e7b5a63a1 100644 --- a/libs/components/forms/testing/src/field-group/fixtures/field-group.component.fixture.ts +++ b/libs/components/forms/testing/src/field-group/fixtures/field-group.component.fixture.ts @@ -32,7 +32,7 @@ export class FieldGroupComponent { public headingHidden = false; public headingLevel: SkyFieldGroupHeadingLevel = 3; public headingStyle: SkyFieldGroupHeadingStyle = 3; - public helpKey: string | undefined = 'helpKey.html'; + public helpKey: string | undefined; public helpPopoverContent: string | undefined = 'Popover content'; public helpPopoverTitle = 'Popover title'; 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 2ccb14552c..1b2aefaafb 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 @@ -21,7 +21,7 @@ export class InputBoxHarnessTestComponent { public easyModeDisabled = false; public easyModeHelpContent: string | TemplateRef | undefined = 'Help content'; - public easyModeHelpKey: string | undefined = 'helpKey.html'; + public easyModeHelpKey: string | undefined; public easyModeHelpTitle = 'Help title'; public easyModeLabel: string | undefined = 'Last name (easy mode)'; public easyModeStacked = false; 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 89c76e06c4..474b518d23 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 @@ -144,11 +144,13 @@ describe('Input box harness', () => { ); }); - it('should open help popover and widget when clicked', async () => { + it('should open widget when clicked', async () => { const { fixture, inputBoxHarness } = await setupTest({ dataSkyId: DATA_SKY_ID_EASY_MODE, }); + fixture.componentInstance.easyModeHelpKey = 'helpKey.html'; + const helpSvc = TestBed.inject(SkyHelpService); const helpSpy = spyOn(helpSvc, 'openHelp'); @@ -156,7 +158,6 @@ describe('Input box harness', () => { fixture.detectChanges(); await fixture.whenStable(); - await expectAsync(inputBoxHarness.getHelpPopoverContent()).toBeResolved(); expect(helpSpy).toHaveBeenCalledWith({ helpKey: 'helpKey.html' }); }); diff --git a/libs/components/help-inline/src/lib/modules/help-inline/aria-label.pipe.ts b/libs/components/help-inline/src/lib/modules/help-inline/aria-label.pipe.ts new file mode 100644 index 0000000000..5a8d27b66c --- /dev/null +++ b/libs/components/help-inline/src/lib/modules/help-inline/aria-label.pipe.ts @@ -0,0 +1,32 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +/** + * Sets the value of `aria-label` for inline help buttons. + * @internal + */ +@Pipe({ + name: 'skyHelpInlineAriaLabel', + standalone: true, +}) +export class SkyHelpInlineAriaLabelPipe implements PipeTransform { + public transform( + ariaLabel: string | undefined, + labelText: string | undefined, + labelledBy: string | undefined, + defaultAriaLabel: string | undefined, + ): string | undefined { + if (labelledBy) { + return; + } + + if (labelText) { + return labelText; + } + + if (ariaLabel) { + return ariaLabel; + } + + return defaultAriaLabel; + } +} diff --git a/libs/components/help-inline/src/lib/modules/help-inline/button-help-key.component.ts b/libs/components/help-inline/src/lib/modules/help-inline/button-help-key.component.ts new file mode 100644 index 0000000000..cf0a32676c --- /dev/null +++ b/libs/components/help-inline/src/lib/modules/help-inline/button-help-key.component.ts @@ -0,0 +1,63 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + inject, + input, + output, +} from '@angular/core'; +import { SKY_HELP_GLOBAL_OPTIONS, SkyHelpService } from '@skyux/core'; + +/** + * @internal + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], + selector: 'sky-help-inline-help-key-button', + standalone: true, + styleUrls: [ + './help-inline.default.component.scss', + './help-inline.modern.component.scss', + ], + template: ` + + + + `, +}) +export class SkyHelpInlineHelpKeyButtonComponent { + public actionClick = output(); + public ariaLabel = input(); + public ariaLabelledby = input(); + public helpKey = input.required(); + + protected readonly globalOptions = inject(SKY_HELP_GLOBAL_OPTIONS, { + optional: true, + }); + + protected readonly helpSvc = inject(SkyHelpService, { optional: true }); + + protected openHelpKey(): void { + this.actionClick.emit(); + + this.helpSvc?.openHelp({ + helpKey: this.helpKey(), + }); + } +} diff --git a/libs/components/help-inline/src/lib/modules/help-inline/button-popover.component.ts b/libs/components/help-inline/src/lib/modules/help-inline/button-popover.component.ts new file mode 100644 index 0000000000..7f69048438 --- /dev/null +++ b/libs/components/help-inline/src/lib/modules/help-inline/button-popover.component.ts @@ -0,0 +1,61 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + TemplateRef, + computed, + input, + output, +} from '@angular/core'; +import { SkyPopoverModule } from '@skyux/popovers'; + +/** + * @internal + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, SkyPopoverModule], + selector: 'sky-help-inline-popover-button', + standalone: true, + styleUrls: [ + './help-inline.default.component.scss', + './help-inline.modern.component.scss', + ], + template: ` + + + + + @if (popoverTemplate(); as template) { + + } @else { + {{ popoverContent() }} + } + + `, +}) +export class SkyHelpInlinePopoverButtonComponent { + public actionClick = output(); + public ariaControls = input(); + public ariaLabel = input(); + public ariaLabelledby = input(); + public popoverContent = input.required>(); + public popoverTitle = input(); + + protected popoverTemplate = computed(() => { + const value = this.popoverContent(); + + if (value instanceof TemplateRef) { + return value; + } + + return undefined; + }); +} diff --git a/libs/components/help-inline/src/lib/modules/help-inline/help-inline-aria-controls.pipe.ts b/libs/components/help-inline/src/lib/modules/help-inline/help-inline-aria-controls.pipe.ts deleted file mode 100644 index e191019af6..0000000000 --- a/libs/components/help-inline/src/lib/modules/help-inline/help-inline-aria-controls.pipe.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Pipe, PipeTransform, inject } from '@angular/core'; -import { SKY_HELP_GLOBAL_OPTIONS } from '@skyux/core'; - -/** - * @internal - */ -@Pipe({ - name: 'skyHelpInlineAriaControls', - standalone: true, -}) -export class SkyHelpInlineAriaControlsPipe implements PipeTransform { - readonly #helpGlobalOptions = inject(SKY_HELP_GLOBAL_OPTIONS, { - optional: true, - }); - - public transform( - ariaControls: string | undefined, - popoverId: string | undefined, - helpKey: string | undefined, - widgetReadyState: boolean | null, - ): string | undefined { - if (helpKey) { - if (!widgetReadyState) { - return; - } - - if (this.#helpGlobalOptions) { - return this.#helpGlobalOptions.ariaControls; - } - } - - return popoverId || ariaControls; - } -} diff --git a/libs/components/help-inline/src/lib/modules/help-inline/help-inline-aria-expanded.pipe.ts b/libs/components/help-inline/src/lib/modules/help-inline/help-inline-aria-expanded.pipe.ts deleted file mode 100644 index 8925370316..0000000000 --- a/libs/components/help-inline/src/lib/modules/help-inline/help-inline-aria-expanded.pipe.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; - -/** - * @internal - */ -@Pipe({ - name: 'skyHelpInlineAriaExpanded', - standalone: true, -}) -export class SkyHelpInlineAriaExpandedPipe implements PipeTransform { - public transform( - ariaExpanded: boolean | undefined, - ariaControls: string | undefined, - isPopoverOpened: boolean | undefined, - ): boolean | null { - return isPopoverOpened ?? (ariaControls ? !!ariaExpanded : null); - } -} diff --git a/libs/components/help-inline/src/lib/modules/help-inline/help-inline-aria-haspopup.pipe.ts b/libs/components/help-inline/src/lib/modules/help-inline/help-inline-aria-haspopup.pipe.ts deleted file mode 100644 index b9fc46465c..0000000000 --- a/libs/components/help-inline/src/lib/modules/help-inline/help-inline-aria-haspopup.pipe.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Pipe, PipeTransform, inject } from '@angular/core'; -import { SKY_HELP_GLOBAL_OPTIONS } from '@skyux/core'; - -/** - * @internal - */ -@Pipe({ - name: 'skyHelpInlineAriaHaspopup', - standalone: true, -}) -export class SkyHelpInlineAriaHaspopupPipe implements PipeTransform { - readonly #helpGlobalOptions = inject(SKY_HELP_GLOBAL_OPTIONS, { - optional: true, - }); - - public transform(helpKey: string | undefined): string | undefined { - if (helpKey) { - return this.#helpGlobalOptions?.ariaHaspopup; - } - - return undefined; - } -} diff --git a/libs/components/help-inline/src/lib/modules/help-inline/help-inline.component.html b/libs/components/help-inline/src/lib/modules/help-inline/help-inline.component.html index d5809a3ad3..539810c9e5 100644 --- a/libs/components/help-inline/src/lib/modules/help-inline/help-inline.component.html +++ b/libs/components/help-inline/src/lib/modules/help-inline/help-inline.component.html @@ -1,35 +1,61 @@ - +@if (helpKey) { + + + +} @else if (popoverContent) { + + + +} @else { + + + +} + + - - - - @if (popoverTemplate) { - - } @else { - {{ popoverContent }} - } - + -{{ +{{ 'skyux_help_inline_aria_label' | skyLibResources: '' }} diff --git a/libs/components/help-inline/src/lib/modules/help-inline/help-inline.component.spec.ts b/libs/components/help-inline/src/lib/modules/help-inline/help-inline.component.spec.ts index 960e053a59..c09f154ff7 100644 --- a/libs/components/help-inline/src/lib/modules/help-inline/help-inline.component.spec.ts +++ b/libs/components/help-inline/src/lib/modules/help-inline/help-inline.component.spec.ts @@ -1,5 +1,5 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { DebugElement, Provider } from '@angular/core'; +import { Provider } from '@angular/core'; import { ComponentFixture, TestBed, @@ -86,7 +86,6 @@ describe('Help inline component', () => { let fixture: ComponentFixture; let component: HelpInlineTestComponent; - let debugEl: DebugElement; let mockThemeSvc: { settingsChange: BehaviorSubject }; let uniqueId = 0; let mockHelpSvc: jasmine.SpyObj; @@ -141,7 +140,6 @@ describe('Help inline component', () => { fixture = TestBed.createComponent(HelpInlineTestComponent); component = fixture.componentInstance as HelpInlineTestComponent; - debugEl = fixture.debugElement; // Mock the ID service. const idSvc = TestBed.inject(SkyIdService); @@ -286,6 +284,8 @@ describe('Help inline component', () => { it('should set aria label with labelText', async () => { component.labelText = 'test component'; + fixture.detectChanges(); + // Trigger change detection for resources string observables. fixture.detectChanges(); await checkAriaPropertiesAndAccessibility({ @@ -299,6 +299,8 @@ describe('Help inline component', () => { component.labelText = 'test component'; component.ariaLabel = 'deprecated'; + fixture.detectChanges(); + // Trigger change detection for resources string observables. fixture.detectChanges(); await checkAriaPropertiesAndAccessibility({ @@ -372,13 +374,11 @@ describe('Help inline component', () => { tick(); fixture.detectChanges(); - const popoverElementId = - debugEl.nativeElement.querySelector('sky-popover').id; const helpInlineElement = fixture.nativeElement.querySelector('.sky-help-inline'); expect(helpInlineElement?.getAttribute('aria-controls')).toBe( - popoverElementId, + helpInlineElement.nextElementSibling.id, ); })); @@ -510,28 +510,6 @@ describe('Help inline component', () => { expect(mockHelpSvc.openHelp).not.toHaveBeenCalled(); }); - - it('should fall back to popover when popoverContent is also specified', async () => { - setupTest(false); - - component.helpKey = 'test.html'; - component.popoverContent = 'Hello'; - - fixture.detectChanges(); - - const helpButton = getHelpButton(fixture); - expect(helpButton).not.toHaveCssClass('sky-help-inline-hidden'); - - helpButton.click(); - - const { popoverHarness } = await getPopoverTestHarness(); - - expect( - await (await popoverHarness.getPopoverContent()).getBodyText(), - ).toBe('Hello'); - - expect(mockHelpSvc.openHelp).not.toHaveBeenCalled(); - }); }); }); }); diff --git a/libs/components/help-inline/src/lib/modules/help-inline/help-inline.component.ts b/libs/components/help-inline/src/lib/modules/help-inline/help-inline.component.ts index 16b0c1e662..056981b0ae 100644 --- a/libs/components/help-inline/src/lib/modules/help-inline/help-inline.component.ts +++ b/libs/components/help-inline/src/lib/modules/help-inline/help-inline.component.ts @@ -6,17 +6,21 @@ import { Output, TemplateRef, inject, + signal, } from '@angular/core'; -import { SkyHelpService, SkyIdModule, SkyIdService } from '@skyux/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { SkyIdModule } from '@skyux/core'; +import { SkyLibResourcesService } from '@skyux/i18n'; import { SkyIconModule } from '@skyux/icon'; -import { SkyPopoverModule } from '@skyux/popovers'; import { SkyThemeModule } from '@skyux/theme'; +import { of, switchMap } from 'rxjs'; + import { SkyHelpInlineResourcesModule } from '../shared/sky-help-inline-resources.module'; -import { SkyHelpInlineAriaControlsPipe } from './help-inline-aria-controls.pipe'; -import { SkyHelpInlineAriaExpandedPipe } from './help-inline-aria-expanded.pipe'; -import { SkyHelpInlineAriaHaspopupPipe } from './help-inline-aria-haspopup.pipe'; +import { SkyHelpInlineAriaLabelPipe } from './aria-label.pipe'; +import { SkyHelpInlineHelpKeyButtonComponent } from './button-help-key.component'; +import { SkyHelpInlinePopoverButtonComponent } from './button-popover.component'; /** * Inserts a help button beside an element, such as a field, to display contextual information about the element. @@ -31,17 +35,19 @@ import { SkyHelpInlineAriaHaspopupPipe } from './help-inline-aria-haspopup.pipe' ], imports: [ CommonModule, - SkyHelpInlineAriaControlsPipe, - SkyHelpInlineAriaExpandedPipe, - SkyHelpInlineAriaHaspopupPipe, + SkyHelpInlineAriaLabelPipe, + SkyHelpInlineHelpKeyButtonComponent, + SkyHelpInlinePopoverButtonComponent, SkyHelpInlineResourcesModule, SkyIconModule, SkyIdModule, - SkyPopoverModule, SkyThemeModule, ], }) export class SkyHelpInlineComponent { + readonly #labelText = signal(undefined); + readonly #resourcesSvc = inject(SkyLibResourcesService); + /** * The ID of the element that the help inline button controls. * This property [supports accessibility rules for disclosures](https://www.w3.org/TR/wai-aria-practices-1.1/#disclosure). @@ -83,26 +89,15 @@ export class SkyHelpInlineComponent { * The label of the component the help inline button is attached to. */ @Input() - public labelText: string | undefined; + public set labelText(value: string | undefined) { + this.#labelText.set(value); + } /** * The content of the help popover. When specified, clicking the help inline button opens a popover with this content and optional title. */ @Input() - public set popoverContent(value: string | TemplateRef | undefined) { - this.#_popoverContent = value; - - if (value) { - this.popoverId = this.#idSvc.generateId(); - this.isPopoverOpened = false; - } - - this.popoverTemplate = value instanceof TemplateRef ? value : undefined; - } - - public get popoverContent(): string | TemplateRef | undefined { - return this.#_popoverContent; - } + public popoverContent: string | TemplateRef | undefined; /** * The title of the help popover. This property only applies when `popoverContent` is @@ -117,27 +112,26 @@ export class SkyHelpInlineComponent { @Output() public actionClick = new EventEmitter(); - protected isPopoverOpened: boolean | undefined; - protected popoverId: string | undefined; - protected popoverTemplate: TemplateRef | undefined; - - protected readonly helpSvc = inject(SkyHelpService, { optional: true }); - - #_popoverContent: string | TemplateRef | undefined; - - readonly #idSvc = inject(SkyIdService); + protected readonly defaultAriaLabel = toSignal( + this.#resourcesSvc.getString('skyux_help_inline_button_title'), + ); + + protected readonly labelTextResolved = toSignal( + toObservable(this.#labelText).pipe( + switchMap((labelText) => { + if (labelText) { + return this.#resourcesSvc.getString( + 'skyux_help_inline_aria_label', + labelText, + ); + } + + return of(undefined); + }), + ), + ); protected onClick(): void { this.actionClick.emit(); - - if (this.helpKey) { - this.helpSvc?.openHelp({ - helpKey: this.helpKey, - }); - } - } - - protected popoverOpened(flag: boolean): void { - this.isPopoverOpened = flag; } } diff --git a/libs/components/help-inline/testing/src/help-inline/help-inline-harness.spec.ts b/libs/components/help-inline/testing/src/help-inline/help-inline-harness.spec.ts index 3bc190d36e..90d6c109a1 100644 --- a/libs/components/help-inline/testing/src/help-inline/help-inline-harness.spec.ts +++ b/libs/components/help-inline/testing/src/help-inline/help-inline-harness.spec.ts @@ -174,6 +174,9 @@ describe('Inline help harness', () => { fixture.detectChanges(); await expectAsync(helpInlineHarness.getLabelText()).toBeResolvedTo('label'); + await expectAsync(helpInlineHarness.getAriaLabel()).toBeResolvedTo( + 'Show help content for label', + ); }); it('should throw an error trying to get popover content if popover is closed', async () => { diff --git a/libs/components/popovers/src/lib/modules/popover/fixtures/popover-a11y.component.fixture.ts b/libs/components/popovers/src/lib/modules/popover/fixtures/popover-a11y.component.fixture.ts new file mode 100644 index 0000000000..eab77ec252 --- /dev/null +++ b/libs/components/popovers/src/lib/modules/popover/fixtures/popover-a11y.component.fixture.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; + +import { SkyPopoverModule } from '../popover.module'; + +/** + * Fixture used to test accessibility features. + */ +@Component({ + imports: [SkyPopoverModule], + selector: 'sky-popover-test', + standalone: true, + template: ` + + What's this? + + Some help message. + `, +}) +export class PopoverA11yTestComponent {} diff --git a/libs/components/popovers/src/lib/modules/popover/fixtures/popover.component.fixture.html b/libs/components/popovers/src/lib/modules/popover/fixtures/popover.component.fixture.html index 67e3c1d2c4..fd6ac862f8 100644 --- a/libs/components/popovers/src/lib/modules/popover/fixtures/popover.component.fixture.html +++ b/libs/components/popovers/src/lib/modules/popover/fixtures/popover.component.fixture.html @@ -1,5 +1,6 @@ Caller Caller Popover content. diff --git a/libs/components/popovers/src/lib/modules/popover/popover-content.component.html b/libs/components/popovers/src/lib/modules/popover/popover-content.component.html index 01ae813da3..eb7a3438b4 100644 --- a/libs/components/popovers/src/lib/modules/popover/popover-content.component.html +++ b/libs/components/popovers/src/lib/modules/popover/popover-content.component.html @@ -1,4 +1,5 @@ - - - {{ popoverTitle }} - - + + {{ popoverTitle }} + + + } - + diff --git a/libs/components/popovers/src/lib/modules/popover/popover-sr-pointer.service.ts b/libs/components/popovers/src/lib/modules/popover/popover-sr-pointer.service.ts new file mode 100644 index 0000000000..4b2afe8b6f --- /dev/null +++ b/libs/components/popovers/src/lib/modules/popover/popover-sr-pointer.service.ts @@ -0,0 +1,75 @@ +import { + ElementRef, + Injectable, + OnDestroy, + Renderer2, + inject, +} from '@angular/core'; +import { SkyIdService } from '@skyux/core'; + +/** + * Inserts an empty "pointer" element after the host element to direct screen + * readers to the opened popover content. Popover content is appended to the + * document body, but it needs to be associated with the host element to meet + * accessibility requirements. This is done by setting `aria-owns` on the + * pointer element, which instructs the screen reader to consider the popover + * contents as a child of the pointer element. We cannot set `aria-owns` on + * the host element because it is nearly always a button, and buttons cannot + * have block-level children. + * @see https://github.com/w3c/html-aria/issues/124 + * @internal + */ +@Injectable() +export class SkyPopoverSRPointerService implements OnDestroy { + #srPointerEl: HTMLSpanElement | undefined; + + readonly #renderer = inject(Renderer2); + readonly #srPointerId = inject(SkyIdService).generateId(); + readonly #hostEl = inject(ElementRef); + + public ngOnDestroy(): void { + this.destroySRPointerEl(); + } + + public createSRPointerEl(): void { + const pointerEl = this.#renderer.createElement('span'); + this.#renderer.setAttribute(pointerEl, 'id', this.#srPointerId); + this.#srPointerEl = pointerEl; + + const hostEl = this.#hostEl?.nativeElement; + this.#renderer.setAttribute(hostEl, 'aria-controls', this.#srPointerId); + hostEl?.parentNode?.insertBefore(pointerEl, hostEl.nextSibling); + } + + public destroySRPointerEl(): void { + this.#srPointerEl?.remove(); + this.#srPointerEl = undefined; + } + + public updateAriaAttributes(options: { + ariaExpanded: boolean; + ariaOwns?: string; + }): void { + const hostEl = this.#hostEl?.nativeElement; + const pointerEl = this.#srPointerEl; + + // Only set attributes if the pointer element exists. + if (pointerEl) { + this.#renderer.setAttribute( + hostEl, + 'aria-expanded', + options.ariaExpanded ? 'true' : 'false', + ); + + if (options.ariaExpanded === true) { + if (options.ariaOwns) { + this.#renderer.setAttribute(pointerEl, 'aria-owns', options.ariaOwns); + } + } else { + this.#renderer.removeAttribute(pointerEl, 'aria-owns'); + } + } else { + this.#renderer.removeAttribute(hostEl, 'aria-expanded'); + } + } +} diff --git a/libs/components/popovers/src/lib/modules/popover/popover.component.ts b/libs/components/popovers/src/lib/modules/popover/popover.component.ts index a5bad10f78..73fe115133 100644 --- a/libs/components/popovers/src/lib/modules/popover/popover.component.ts +++ b/libs/components/popovers/src/lib/modules/popover/popover.component.ts @@ -14,6 +14,7 @@ import { } from '@angular/core'; import { SKY_STACKING_CONTEXT, + SkyIdService, SkyOverlayInstance, SkyOverlayService, SkyStackingContext, @@ -28,8 +29,6 @@ import { SkyPopoverAlignment } from './types/popover-alignment'; import { SkyPopoverPlacement } from './types/popover-placement'; import { SkyPopoverType } from './types/popover-type'; -let nextId = 0; - @Component({ selector: 'sky-popover', templateUrl: './popover.component.html', @@ -123,7 +122,7 @@ export class SkyPopoverComponent implements OnDestroy { public isMouseEnter = false; - public popoverId = `sky-popover-${nextId++}`; + public popoverId: string; @ViewChild('templateRef', { read: TemplateRef, @@ -161,6 +160,8 @@ export class SkyPopoverComponent implements OnDestroy { ) { this.#overlayService = overlayService; this.#zIndex = stackingContext?.zIndex; + + this.popoverId = inject(SkyIdService).generateId(); } public ngOnDestroy(): void { diff --git a/libs/components/popovers/src/lib/modules/popover/popover.directive.spec.ts b/libs/components/popovers/src/lib/modules/popover/popover.directive.spec.ts index e6c1ea58b6..a17fce2622 100644 --- a/libs/components/popovers/src/lib/modules/popover/popover.directive.spec.ts +++ b/libs/components/popovers/src/lib/modules/popover/popover.directive.spec.ts @@ -5,7 +5,8 @@ import { inject, tick, } from '@angular/core/testing'; -import { SkyAppTestUtility, expect } from '@skyux-sdk/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SkyAppTestUtility, expect, expectAsync } from '@skyux-sdk/testing'; import { SKY_STACKING_CONTEXT, SkyAffixAutoFitContext, @@ -23,6 +24,7 @@ import { import { BehaviorSubject, Subject } from 'rxjs'; +import { PopoverA11yTestComponent } from './fixtures/popover-a11y.component.fixture'; import { PopoverFixtureComponent } from './fixtures/popover.component.fixture'; import { PopoverFixturesModule } from './fixtures/popover.module.fixture'; import { SkyPopoverAdapterService } from './popover-adapter.service'; @@ -1082,3 +1084,76 @@ describe('Popover directive', () => { })); }); }); + +describe('Popover directive accessibility', () => { + function getPopoverEl(): HTMLElement | null { + return document.querySelector('sky-popover-content'); + } + + /** + * Asserts the popover and trigger button are accessible. + */ + async function expectAccessible( + buttonEl: HTMLButtonElement | null, + attrs: { ariaExpanded: string }, + ): Promise { + const pointerEl = buttonEl?.nextElementSibling; + const popoverEl = getPopoverEl(); + + const popoverId = popoverEl?.id ?? null; + const pointerId = pointerEl?.id ?? null; + const ariaOwns = pointerEl?.getAttribute('aria-owns'); + + expect(buttonEl?.getAttribute('aria-expanded')).toEqual(attrs.ariaExpanded); + expect(pointerEl).toExist(); + expect(pointerId).toBeDefined(); + expect(buttonEl?.getAttribute('aria-controls')).toEqual(pointerId); + + if (attrs.ariaExpanded === 'true') { + expect(popoverEl).toExist(); + expect(ariaOwns).toEqual(popoverId); + } else { + expect(popoverEl).toBeNull(); + expect(ariaOwns).toBeNull(); + } + + await expectAsync(document.body).toBeAccessible({ + rules: { + region: { + enabled: false, + }, + }, + }); + } + + it('should be accessible', async () => { + TestBed.configureTestingModule({ + imports: [PopoverA11yTestComponent, NoopAnimationsModule], + }); + + const fixture = TestBed.createComponent(PopoverA11yTestComponent); + + fixture.detectChanges(); + + const btn = ( + fixture.nativeElement as HTMLElement + ).querySelector('button[data-sky-id="triggerEl"]'); + + // Open the popover. + btn?.click(); + fixture.detectChanges(); + + await expectAccessible(btn, { + ariaExpanded: 'true', + }); + + // Close the popover. + btn?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + await expectAccessible(btn, { + ariaExpanded: 'false', + }); + }); +}); diff --git a/libs/components/popovers/src/lib/modules/popover/popover.directive.ts b/libs/components/popovers/src/lib/modules/popover/popover.directive.ts index e5680f827e..fc6002f2fa 100644 --- a/libs/components/popovers/src/lib/modules/popover/popover.directive.ts +++ b/libs/components/popovers/src/lib/modules/popover/popover.directive.ts @@ -5,11 +5,13 @@ import { Input, OnDestroy, OnInit, + inject, } from '@angular/core'; import { Subject, Subscription, fromEvent as observableFromEvent } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +import { SkyPopoverSRPointerService } from './popover-sr-pointer.service'; import { SkyPopoverComponent } from './popover.component'; import { SkyPopoverAlignment } from './types/popover-alignment'; import { SkyPopoverMessage } from './types/popover-message'; @@ -18,6 +20,7 @@ import { SkyPopoverPlacement } from './types/popover-placement'; import { SkyPopoverTrigger } from './types/popover-trigger'; @Directive({ + providers: [SkyPopoverSRPointerService], selector: '[skyPopover]', }) export class SkyPopoverDirective implements OnInit, OnDestroy { @@ -28,12 +31,6 @@ export class SkyPopoverDirective implements OnInit, OnDestroy { @HostBinding('class') protected directiveClass = 'sky-popover-trigger'; - /** - * Appends the `data-popover-id` attribute to the trigger element set to a unique ID generated by the popover component. - * @internal - */ - // TODO: replace this with relevant ARIA attributes - @HostBinding('attr.data-popover-id') protected popoverId: string | undefined; /** @@ -44,6 +41,7 @@ export class SkyPopoverDirective implements OnInit, OnDestroy { public set skyPopover(value: SkyPopoverComponent | undefined) { this.popoverId = value?.popoverId; this.#_popover = value; + this.#updateSRPointer(value); } public get skyPopover(): SkyPopoverComponent | undefined { @@ -83,10 +81,13 @@ export class SkyPopoverDirective implements OnInit, OnDestroy { /** * The user action that displays the popover. + * @deprecated To ensure usability on touch devices, trigger user-invoked + * popovers on `click` actions rather than `mouseenter` actions. */ @Input() public set skyPopoverTrigger(value: SkyPopoverTrigger | undefined) { this.#_trigger = value ?? 'click'; + this.#updateAriaAttributes(); } public get skyPopoverTrigger(): SkyPopoverTrigger { @@ -100,6 +101,10 @@ export class SkyPopoverDirective implements OnInit, OnDestroy { #_trigger: SkyPopoverTrigger = 'click'; #elementRef: ElementRef; + #expanded = false; + #popoverClosedSubscription: Subscription | undefined; + + readonly #srPointerSvc = inject(SkyPopoverSRPointerService); constructor(elementRef: ElementRef) { this.#elementRef = elementRef; @@ -113,6 +118,7 @@ export class SkyPopoverDirective implements OnInit, OnDestroy { public ngOnDestroy(): void { this.#removeEventListeners(); this.#unsubscribeMessageStream(); + this.#popoverClosedSubscription?.unsubscribe(); } public togglePopover(): void { @@ -253,6 +259,8 @@ export class SkyPopoverDirective implements OnInit, OnDestroy { switch (message.type) { case SkyPopoverMessageType.Open: this.#positionPopover(); + this.#expanded = true; + this.#updateAriaAttributes(); break; case SkyPopoverMessageType.Close: @@ -295,4 +303,31 @@ export class SkyPopoverDirective implements OnInit, OnDestroy { this.#messageStreamSub = undefined; } } + + #updateAriaAttributes(): void { + this.#srPointerSvc.updateAriaAttributes({ + ariaExpanded: this.#expanded, + ariaOwns: this.popoverId, + }); + } + + #updateSRPointer(popover: SkyPopoverComponent | undefined): void { + this.#popoverClosedSubscription?.unsubscribe(); + this.#popoverClosedSubscription = popover + ? popover.popoverClosed.subscribe(() => { + this.#expanded = false; + this.#updateAriaAttributes(); + }) + : undefined; + + if (popover) { + if (this.skyPopoverTrigger === 'click') { + this.#srPointerSvc.createSRPointerEl(); + } + } else { + this.#srPointerSvc.destroySRPointerEl(); + } + + this.#updateAriaAttributes(); + } } diff --git a/libs/components/popovers/src/lib/modules/popover/types/popover-trigger.ts b/libs/components/popovers/src/lib/modules/popover/types/popover-trigger.ts index 6949a7bf84..88d8aeee8b 100644 --- a/libs/components/popovers/src/lib/modules/popover/types/popover-trigger.ts +++ b/libs/components/popovers/src/lib/modules/popover/types/popover-trigger.ts @@ -1,4 +1,6 @@ /** * The user action that displays the popover. + * @deprecated To ensure usability on touch devices, trigger user-invoked + * popovers on `click` actions rather than `mouseenter` actions. */ export type SkyPopoverTrigger = 'click' | 'mouseenter'; diff --git a/libs/components/popovers/testing/src/popover/harness/popover-harness.ts b/libs/components/popovers/testing/src/popover/harness/popover-harness.ts index d94ee65be3..65b4abcf0b 100644 --- a/libs/components/popovers/testing/src/popover/harness/popover-harness.ts +++ b/libs/components/popovers/testing/src/popover/harness/popover-harness.ts @@ -55,16 +55,21 @@ export class SkyPopoverHarness extends SkyComponentHarness { async #getContent(): Promise { const popoverId = await this.#getPopoverId(); - return this.#documentRootLocator.locatorForOptional( - SkyPopoverContentHarness.with({ selector: `#${popoverId}` }), - )(); + if (popoverId) { + return this.#documentRootLocator.locatorForOptional( + SkyPopoverContentHarness.with({ selector: `#${popoverId}` }), + )(); + } + + return null; } - async #getPopoverId(): Promise { - return ( - (await (await this.host()).getAttribute('data-popover-id')) || - /* istanbul ignore next */ - '' - ); + async #getPopoverId(): Promise { + const pointerId = await (await this.host()).getAttribute('aria-controls'); + const pointerEl = await this.#documentRootLocator.locatorForOptional( + `#${pointerId}`, + )(); + + return pointerEl?.getAttribute('aria-owns'); } }
{{ popoverContent() }}
{{ popoverContent }}