Skip to content

Commit

Permalink
fix: describe longpress button to screen readers
Browse files Browse the repository at this point in the history
  • Loading branch information
Najika Yoo authored and najikahalsema committed Jan 11, 2022
1 parent 8443136 commit acdcaf4
Show file tree
Hide file tree
Showing 3 changed files with 374 additions and 2 deletions.
52 changes: 51 additions & 1 deletion packages/overlay/src/OverlayTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,17 @@ export class OverlayTrigger extends LitElement {
@property({ type: Boolean, reflect: true })
public disabled = false;

@property({ type: Boolean, attribute: false })
public hasLongpressContent = false;

private longpressDescriptor?: HTMLElement;
private clickContent?: HTMLElement;
private longpressContent?: HTMLElement;
private hoverContent?: HTMLElement;
private targetContent?: HTMLElement;

public _longpressId = `longpress-describedby-descriptor`;

private handleClose(event?: CustomEvent<OverlayOpenCloseDetail>): void {
if (
event &&
Expand Down Expand Up @@ -121,12 +127,13 @@ export class OverlayTrigger extends LitElement {
@slotchange=${this.onHoverSlotChange}
name="hover-content"
></slot>
<slot name=${this._longpressId}></slot>
</div>
`;
/* eslint-enable lit-a11y/click-events-have-key-events */
}

protected updated(changes: PropertyValues): void {
protected updated(changes: PropertyValues<this>): void {
super.updated(changes);
if (this.disabled && changes.has('disabled')) {
this.closeAllOverlays();
Expand All @@ -135,6 +142,47 @@ export class OverlayTrigger extends LitElement {
if (changes.has('open')) {
this.manageOpen();
}
if (changes.has('hasLongpressContent')) {
this.manageLongpressDescriptor();
}
}

protected manageLongpressDescriptor(): void {
// get overlay trigger
const trigger = this.querySelector('[slot="trigger"]') as HTMLElement;

// get our current describedby attributes, if any
const ariaDescribedby = trigger.getAttribute('aria-describedby');
let descriptors = ariaDescribedby ? ariaDescribedby.split(/\s+/) : [];

if (this.hasLongpressContent) {
// make an element that acts as `aria-describedby` description if it doesn't exist yet
if (!this.longpressDescriptor) {
this.longpressDescriptor = document.createElement(
'div'
) as HTMLElement;

this.longpressDescriptor.id = this._longpressId;
this.longpressDescriptor.slot = this._longpressId;
this.longpressDescriptor.innerHTML =
'Long press for additional options';
}
this.appendChild(this.longpressDescriptor); // add descriptor to light DOM

descriptors.push(this._longpressId);
} else {
// dispose longpressDescriptor if it exists already
if (this.longpressDescriptor) this.longpressDescriptor.remove();
// remove longpressid from the descriptors
descriptors = descriptors.filter(
(descriptor) => descriptor !== this._longpressId
);
}
if (descriptors.length) {
trigger.setAttribute('aria-describedby', descriptors.join(' '));
} else {
trigger.removeAttribute('aria-describedby');
}
}

private closeAllOverlays(): void {
Expand Down Expand Up @@ -332,6 +380,8 @@ export class OverlayTrigger extends LitElement {
event: Event & { target: HTMLSlotElement }
): void {
this.longpressContent = this.extractSlotContentFromEvent(event);
this.hasLongpressContent =
!!this.longpressContent || !!this.closeLongpressOverlay;
this.manageOpen();
}

Expand Down
122 changes: 122 additions & 0 deletions packages/overlay/test/overlay-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import '@spectrum-web-components/action-button/sp-action-button.js';
import { OverlayTrigger } from '..';
import '@spectrum-web-components/overlay/overlay-trigger.js';
import { a11ySnapshot, findAccessibilityNode } from '@web/test-runner-commands';
import { Tooltip } from '@spectrum-web-components/tooltip';

describe('Overlay Trigger - Lifecycle Methods', () => {
it('calls the overlay lifecycle (willOpen/Close)', async () => {
Expand Down Expand Up @@ -124,4 +125,125 @@ describe('Overlay Trigger - Lifecycle Methods', () => {
return el.childNodes.length === 5;
}, 'children');
});
it('gardens `aria-describedby` in its target', async () => {
const el = await fixture<OverlayTrigger>(html`
<overlay-trigger placement="right-start">
<sp-action-button slot="trigger" aria-describedby="descriptor">
Button with Tooltip
</sp-action-button>
<sp-tooltip slot="hover-content" delayed>
Described by this content on focus/hover. 2
</sp-tooltip>
</overlay-trigger>
<div id="descriptor">I'm a description!</div>
`);

const trigger = el.querySelector('[slot="trigger"]') as HTMLElement;
const tooltip = el.querySelector('sp-tooltip') as Tooltip;

await elementUpdated(el);

expect(trigger.getAttribute('aria-describedby')).to.equal('descriptor');
expect(el.open).to.be.undefined;
expect(el.childNodes.length, 'always').to.equal(5);

const opened = oneEvent(el, 'sp-opened');
trigger.dispatchEvent(
new FocusEvent('focusin', { bubbles: true, composed: true })
);
await opened;

expect(trigger.getAttribute('aria-describedby')).to.equal(
`descriptor ${
(tooltip as unknown as { _tooltipId: string })._tooltipId
}`
);

const closed = oneEvent(el, 'sp-closed');
trigger.dispatchEvent(
new FocusEvent('focusout', { bubbles: true, composed: true })
);
await closed;

expect(trigger.getAttribute('aria-describedby')).to.equal('descriptor');
});
it('adds and removes `aria-describedby` attribute', async () => {
const el = await fixture<OverlayTrigger>(html`
<overlay-trigger placement="right-start">
<sp-action-button slot="trigger">
Button with Tooltip
</sp-action-button>
<sp-tooltip slot="hover-content" delayed>
Described by this content on focus/hover. 2
</sp-tooltip>
</overlay-trigger>
`);

const trigger = el.querySelector('[slot="trigger"]') as HTMLElement;
const tooltip = el.querySelector('sp-tooltip') as Tooltip;

await elementUpdated(el);

expect(trigger.hasAttribute('aria-describedby')).to.be.false;
expect(el.open).to.be.undefined;
expect(el.childNodes.length, 'always').to.equal(5);

const opened = oneEvent(el, 'sp-opened');
trigger.dispatchEvent(
new FocusEvent('focusin', { bubbles: true, composed: true })
);
await opened;

expect(trigger.getAttribute('aria-describedby')).to.equal(
`${(tooltip as unknown as { _tooltipId: string })._tooltipId}`
);

const closed = oneEvent(el, 'sp-closed');
trigger.dispatchEvent(
new FocusEvent('focusout', { bubbles: true, composed: true })
);
await closed;

expect(trigger.hasAttribute('aria-describedby')).to.be.false;
});
it('does not duplicate `aria-describedby` attribute', async () => {
const el = await fixture<OverlayTrigger>(html`
<overlay-trigger placement="right-start">
<sp-action-button slot="trigger">
Button with Tooltip
</sp-action-button>
<sp-tooltip slot="hover-content" delayed>
Described by this content on focus/hover. 2
</sp-tooltip>
</overlay-trigger>
`);

const trigger = el.querySelector('[slot="trigger"]') as HTMLElement;
const tooltip = el.querySelector('sp-tooltip') as Tooltip;
const tooltipId = (tooltip as unknown as { _tooltipId: string })
._tooltipId;
trigger.setAttribute('aria-describedby', tooltipId);

await elementUpdated(el);

expect(trigger.getAttribute('aria-describedby')).to.equal(tooltipId);
expect(el.open).to.be.undefined;
expect(el.childNodes.length, 'always').to.equal(5);

const opened = oneEvent(el, 'sp-opened');
trigger.dispatchEvent(
new FocusEvent('focusin', { bubbles: true, composed: true })
);
await opened;

expect(trigger.getAttribute('aria-describedby')).to.equal(tooltipId);

const closed = oneEvent(el, 'sp-closed');
trigger.dispatchEvent(
new FocusEvent('focusout', { bubbles: true, composed: true })
);
await closed;

expect(trigger.getAttribute('aria-describedby')).to.equal(tooltipId);
});
});
Loading

0 comments on commit acdcaf4

Please sign in to comment.