Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(components/popovers): screen readers can navigate to popover contents #2672

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,37 @@
<button
Blackbaud-SteveBrush marked this conversation as resolved.
Show resolved Hide resolved
class="sky-btn sky-btn-link"
type="button"
[skyPopover]="myPopover0"
[skyPopover]="myPopover1"
[skyPopoverTrigger]="'mouseenter'"
>
Popover demo on hover
</button>

<sky-popover popoverTitle="Playground popover" #myPopover0>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque ut velit
a urna fermentum fermentum. Quisque sed lectus sit amet nibh tempus
fermentum ac eget lorem. Mauris lorem nisl, finibus ut turpis vitae,
venenatis faucibus nibh. Phasellus laoreet elit ac sagittis tincidunt. Sed
finibus, sem nec convallis condimentum, nulla odio mattis sem, venenatis
rhoncus neque nisi quis quam. Ut quis aliquet eros. Fusce quis mauris
tellus. Ut pharetra mi sed nisi pharetra, sit amet bibendum leo cursus.
Maecenas bibendum risus vestibulum nisl sagittis, vitae fermentum nibh
facilisis. Nunc luctus vehicula ex ac aliquam. Suspendisse sodales iaculis
nibh id condimentum.
</p>

<p>
<button type="button">Some button</button>
</p>

<sky-popover #myPopover0 popoverTitle="Playground popover">
The content of a popover can be text, HTML, or Angular components.
<button type="button">Subscribe</button>
</sky-popover>

<sky-popover #myPopover1 popoverTitle="Playground popover">
The content of a popover can be text, HTML, or Angular components.
<button type="button">Subscribe</button>
</sky-popover>
</sky-page-content>
</sky-page>
7 changes: 6 additions & 1 deletion karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ module.exports = () => {
customLaunchers: {
ChromeHeadlessNoSandbox: {
base: 'ChromeHeadless',
flags: ['--headless', '--disable-gpu', '--disable-dev-shm-usage'],
flags: [
'--headless',
'--disable-gpu',
'--disable-dev-shm-usage',
'--window-size=1920,1080',
],
},
},
restartOnFileChange: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -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: `
<button data-sky-id="triggerEl" type="button" [skyPopover]="popover1">
What's this?
</button>
<sky-popover #popover1> Some help message. </sky-popover>
`,
})
export class PopoverA11yTestComponent {}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@angular/core';
import {
SKY_STACKING_CONTEXT,
SkyIdService,
SkyOverlayInstance,
SkyOverlayService,
SkyStackingContext,
Expand All @@ -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',
Expand Down Expand Up @@ -123,7 +122,7 @@ export class SkyPopoverComponent implements OnDestroy {

public isMouseEnter = false;

public popoverId = `sky-popover-${nextId++}`;
public popoverId: string;

@ViewChild('templateRef', {
read: TemplateRef,
Expand Down Expand Up @@ -161,6 +160,8 @@ export class SkyPopoverComponent implements OnDestroy {
) {
this.#overlayService = overlayService;
this.#zIndex = stackingContext?.zIndex;

this.popoverId = inject(SkyIdService).generateId();
}

public ngOnDestroy(): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -1082,3 +1084,70 @@ describe('Popover directive', () => {
}));
});
});

describe('Popover directive accessibility', () => {
function getPopoverEl(): HTMLElement | null {
return document.querySelector('sky-popover-content') as HTMLElement | null;
}

/**
* Asserts the trigger button is accessible.
*/
async function expectAccessible(
buttonEl: HTMLButtonElement | null,
attrs: { ariaExpanded: string },
): Promise<void> {
const popoverEl = getPopoverEl();
const ariaControls = buttonEl?.getAttribute('aria-controls');

expect(buttonEl?.getAttribute('aria-expanded')).toEqual(attrs.ariaExpanded);

if (attrs.ariaExpanded === 'true') {
expect(popoverEl).toExist();
expect(ariaControls).toBeDefined();
expect(ariaControls).toEqual(popoverEl?.id ?? null);
} else {
expect(popoverEl).toBeNull();
expect(ariaControls).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.querySelector(
'button[data-sky-id="triggerEl"]',
) as HTMLButtonElement;

// 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',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
Input,
OnDestroy,
OnInit,
Renderer2,
inject,
} from '@angular/core';

import { Subject, Subscription, fromEvent as observableFromEvent } from 'rxjs';
Expand All @@ -29,11 +31,9 @@ export class SkyPopoverDirective implements OnInit, OnDestroy {
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.
* The ID of the opened popover element.
* @internal
*/
// TODO: replace this with relevant ARIA attributes
Blackbaud-SteveBrush marked this conversation as resolved.
Show resolved Hide resolved
@HostBinding('attr.data-popover-id')
protected popoverId: string | undefined;

/**
Expand Down Expand Up @@ -100,6 +100,7 @@ export class SkyPopoverDirective implements OnInit, OnDestroy {
#_trigger: SkyPopoverTrigger = 'click';

#elementRef: ElementRef;
readonly #renderer = inject(Renderer2);

constructor(elementRef: ElementRef) {
this.#elementRef = elementRef;
Expand Down Expand Up @@ -134,6 +135,7 @@ export class SkyPopoverDirective implements OnInit, OnDestroy {

#closePopover(): void {
this.skyPopover?.close();
this.#updateAriaAttributes({ isExpanded: false });
}

#closePopoverOrMarkForClose(): void {
Expand Down Expand Up @@ -253,6 +255,7 @@ export class SkyPopoverDirective implements OnInit, OnDestroy {
switch (message.type) {
case SkyPopoverMessageType.Open:
this.#positionPopover();
this.#updateAriaAttributes({ isExpanded: true });
break;

case SkyPopoverMessageType.Close:
Expand Down Expand Up @@ -295,4 +298,24 @@ export class SkyPopoverDirective implements OnInit, OnDestroy {
this.#messageStreamSub = undefined;
}
}

#updateAriaAttributes(options: {
/**
* Whether the popover button should be marked as "expanded".
*/
isExpanded: boolean;
}): void {
const hostEl = this.#elementRef.nativeElement;

if (options.isExpanded === true) {
this.#renderer.setAttribute(hostEl, 'aria-expanded', 'true');

if (this.popoverId) {
this.#renderer.setAttribute(hostEl, 'aria-controls', this.popoverId);
}
} else {
this.#renderer.setAttribute(hostEl, 'aria-expanded', 'false');
this.#renderer.removeAttribute(hostEl, 'aria-controls');
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,16 @@ export class SkyPopoverHarness extends SkyComponentHarness {
async #getContent(): Promise<SkyPopoverContentHarness | null> {
const popoverId = await this.#getPopoverId();

if (!popoverId) {
return null;
}

return this.#documentRootLocator.locatorForOptional(
SkyPopoverContentHarness.with({ selector: `#${popoverId}` }),
)();
}

async #getPopoverId(): Promise<string> {
return (
(await (await this.host()).getAttribute('data-popover-id')) ||
/* istanbul ignore next */
''
);
async #getPopoverId(): Promise<string | null> {
return (await this.host()).getAttribute('aria-controls');
}
}
Loading