Skip to content

Commit

Permalink
fix(contentauth#145): ManifestSummary/Tooltip/Popover accessibility
Browse files Browse the repository at this point in the history
Implement keyboard accessibility for Popover trigger and make it more generic, so that a user only needs to worry about an aria-label for the cai-indicator, rather than the expand/collapse behavior as well.
  • Loading branch information
majornista committed Jul 12, 2024
1 parent 5e9a61b commit d6f2741
Show file tree
Hide file tree
Showing 8 changed files with 141 additions and 71 deletions.
7 changes: 5 additions & 2 deletions examples/minimal-ts-vite/examples/web-components/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
interactive
style="float: right; position: relative; top: 40px; left: -10px"
>
<cai-indicator slot="trigger"></cai-indicator>
<cai-indicator
slot="trigger"
aria-label="Content Credentials for test image"
></cai-indicator>
<cai-manifest-summary
locale="en-US"
slot="content"
></cai-manifest-summary>
</cai-popover>
<img width="600" src="./test.jpg" />
<img width="600" src="./test.jpg" alt="test image" />
</div>
</body>
</html>
7 changes: 5 additions & 2 deletions examples/react-ts-vite/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,14 @@ function WebComponents({
return (
<div className="web-components">
<div className="wrapper">
<img src={imageUrl} />
<img src={imageUrl} alt="test image" />
{manifestStore ? (
<div>
<cai-popover interactive class="theme-spectrum">
<cai-indicator slot="trigger"></cai-indicator>
<cai-indicator
slot="trigger"
aria-label="Content Credentials for test image"
></cai-indicator>
<cai-manifest-summary
ref={summaryRef}
slot="content"
Expand Down
15 changes: 12 additions & 3 deletions packages/c2pa-wc/src/components/Icon/Icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,24 @@ export class Icon extends LitElement {
},
{
pattern: /solana/i,
icon: html`<cai-icon-solana></cai-icon-solana`,
icon: html`<cai-icon-solana
role="img"
aria-label="Solana"
></cai-icon-solana>`,
},
{
pattern: /ethereum/i,
icon: html`<cai-icon-ethereum></cai-icon-ethereum>`,
icon: html`<cai-icon-ethereum
role="img"
aria-label="Ethereum"
></cai-icon-ethereum>`,
},
{
pattern: /linkedin/i,
icon: html`<cai-icon-linkedin></cai-icon-linkedin>`,
icon: html`<cai-icon-linkedin
role="img"
aria-label="LinkedIn"
></cai-icon-linkedin>`,
},
];

Expand Down
6 changes: 6 additions & 0 deletions packages/c2pa-wc/src/components/Indicator/Indicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,17 @@ export class Indicator extends LitElement {
display: inline-block;
width: var(--cai-indicator-size, 24px);
height: var(--cai-indicator-size, 24px);
border-radius: 50% 50% 0 50%;
line-height: 0;
}
.icon {
--cai-icon-width: var(--cai-indicator-size, 24px);
--cai-icon-height: var(--cai-indicator-size, 24px);
}
:host:focus-visible {
outline-color: var(--cai-focus-ring-color, #1473e6);
outline-offset: 1px;
}
`,
];
}
Expand Down
161 changes: 106 additions & 55 deletions packages/c2pa-wc/src/components/Popover/Popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export class Popover extends LitElement {
interactive = false;

@property({ type: String })
trigger: string = 'mouseenter:mouseleave focus:blur';
trigger: string = 'mouseenter:mouseleave click';

@property({ type: Number })
zIndex = 10;
Expand All @@ -101,6 +101,18 @@ export class Popover extends LitElement {
@query('#trigger')
triggerElement: HTMLElement | undefined;

private _triggerElementSlot: HTMLSlotElement | undefined;

private _triggerSlotAssignedNodes: Node[] = [];

private _triggerElementButton: HTMLElement | undefined;

private _contentElementSlot: HTMLSlotElement | undefined;

private _contentSlotAssignedNodes: Node[] = [];

private _hasTooltipRole = false;

// @TODO: respect updated properties
protected updated(
_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>,
Expand Down Expand Up @@ -196,41 +208,58 @@ export class Popover extends LitElement {
private _showTooltip() {
this._isShown = true;
this._updatePosition();
this.hostElement!.ownerDocument!.addEventListener(
'keydown',
this._onKeyDownEsc.bind(this),
);
if (!this._hasTooltipRole) {
this._triggerElementButton?.setAttribute('aria-expanded', 'true');
}
}

private _hideTooltip() {
this._isShown = false;
this.hostElement!.ownerDocument!.removeEventListener(
'keydown',
this._onKeyDownEsc.bind(this),
);
if (!this._hasTooltipRole) {
this._triggerElementButton?.setAttribute('aria-expanded', 'false');
}
}

private _onKeyDown(e: KeyboardEvent) {
private _toogleTooltip() {
if (!this._isShown) {
this._showTooltip();
} else {
this._hideTooltip();
}
}

private _onKeyDownEsc(e: KeyboardEvent) {
switch (e.key) {
case 'Escape':
if (this._isShown) {
e.stopPropagation();
e.preventDefault();
const restoreFocus = this.contains(document.activeElement);
this._hideTooltip();
this.triggerElement!.focus();
}
break;
case 'Enter':
case ' ':
if (
e.target !== this.hostElement &&
e.composedPath().includes(this.triggerElement as EventTarget)
) {
e.stopPropagation();
e.preventDefault();
this._onClick();
if (restoreFocus) {
this._triggerElementButton!.focus();
}
}
break;
}
}

private _onClick() {
if (!this._isShown) {
this._showTooltip();
} else {
this._hideTooltip();
private _onKeyDownTrigger(e: KeyboardEvent) {
switch (e.key) {
case 'Enter':
case ' ':
e.stopPropagation();
e.preventDefault();
(e.target as HTMLElement).click();
break;
}
}

Expand All @@ -239,60 +268,59 @@ export class Popover extends LitElement {
const cleanup = this._eventCleanupFns.shift();
cleanup?.();
}

this.triggerElement!.removeEventListener('click', this._onClick);

this.hostElement!.removeEventListener('keydown', this._onKeyDown, true);
}

private _setTriggers() {
this._cleanupTriggers();
const triggers = this.trigger.split(/\s+/);
const toggleTooltipFn = this._toogleTooltip.bind(this);
const showTooltipFn = this._showTooltip.bind(this);
const hideTooltipFn = this._hideTooltip.bind(this);
const keydownTriggerFn = this._onKeyDownTrigger.bind(this);

this._eventCleanupFns = triggers.map((trigger) => {
const [show, hide] = trigger.split(':');
this.triggerElement!.addEventListener(
show,
this._showTooltip.bind(this),
show === 'focus',
);
if (this.interactive && hide === 'mouseleave') {
this.hostElement!.addEventListener(hide, this._hideTooltip.bind(this));
if (show === 'click') {
this.triggerElement!.addEventListener(show, toggleTooltipFn);
this.triggerElement!.addEventListener('keydown', keydownTriggerFn);
} else {
this.triggerElement!.addEventListener(
hide,
this._hideTooltip.bind(this),
hide === 'blur',
);
}
return () => {
this.triggerElement!.removeEventListener(
show,
this._showTooltip,
showTooltipFn,
show === 'focus',
);
if (this.interactive && hide === 'mouseleave') {
this.contentElement!.addEventListener(
this.hostElement!.addEventListener(hide, hideTooltipFn);
} else {
this.triggerElement!.addEventListener(
hide,
this._hideTooltip.bind(this),
hideTooltipFn,
hide === 'blur',
);
}
}
return () => {
if (show === 'click') {
this.triggerElement!.removeEventListener(show, toggleTooltipFn);
this.triggerElement!.removeEventListener('keydown', keydownTriggerFn);
} else {
this.triggerElement!.removeEventListener(
hide,
this._hideTooltip,
hide === 'blur',
show,
showTooltipFn,
show === 'focus',
);
if (this.interactive && hide === 'mouseleave') {
this.contentElement!.addEventListener(hide, hideTooltipFn);
} else {
this.triggerElement!.removeEventListener(
hide,
hideTooltipFn,
hide === 'blur',
);
}
}
};
});

this.triggerElement!.addEventListener('click', this._onClick.bind(this));

this.hostElement!.addEventListener(
'keydown',
this._onKeyDown.bind(this),
true,
);
}

private async _updatePosition() {
Expand Down Expand Up @@ -372,6 +400,29 @@ export class Popover extends LitElement {
);

this.contentElement?.classList.add('hidden');

this._contentElementSlot = this.contentElement?.querySelector(
'slot[name="content"]',
) as HTMLSlotElement;
this._contentSlotAssignedNodes =
this._contentElementSlot?.assignedElements({ flatten: true }) ?? [];
this._hasTooltipRole = this._contentSlotAssignedNodes.some(
(node) =>
node instanceof HTMLElement && node.getAttribute('role') === 'tooltip',
);

this._triggerElementSlot = this.triggerElement?.querySelector(
'slot[name="trigger"]',
) as HTMLSlotElement;
this._triggerSlotAssignedNodes =
this._triggerElementSlot?.assignedElements({ flatten: true }) ?? [];
this._triggerElementButton = this
._triggerSlotAssignedNodes[0] as HTMLElement;
this._triggerElementButton.setAttribute('role', 'button');
this._triggerElementButton.setAttribute('tabindex', '0');
if (!this._hasTooltipRole) {
this._triggerElementButton.setAttribute('aria-expanded', 'false');
}
}

disconnectedCallback(): void {
Expand All @@ -390,6 +441,10 @@ export class Popover extends LitElement {
};

return html`<div id="element">
<div id="trigger">
<div class="hidden-layer"></div>
<slot name="trigger"></slot>
</div>
<div
id="content"
class=${classMap(contentClassMap)}
Expand All @@ -413,10 +468,6 @@ export class Popover extends LitElement {
<slot name="content"></slot>
${this.arrow ? html`<div id="arrow"></div>` : null}
</div>
<div id="trigger">
<div class="hidden-layer"></div>
<slot name="trigger"></slot>
</div>
</div>`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default {
component: 'cai-thumbnail',
argTypes: {
src: {
defaultValue: 'https://place-puppy.com/450x300',
defaultValue: 'https://place.dog/450/300',
control: {
type: 'text',
},
Expand Down
13 changes: 5 additions & 8 deletions packages/c2pa-wc/src/components/Tooltip/Tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { autoPlacement } from '@floating-ui/dom';
import { css, html, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import '../../../assets/svg/monochrome/help.svg';
import { defaultStyles } from '../../styles';
import { Configurable } from '../../mixins/configurable';
Expand Down Expand Up @@ -109,22 +108,20 @@ export class Tooltip extends Configurable(LitElement, defaultConfig) {
?arrow=${this.arrow}
.autoPlacement=${this.autoPlacement}
?interactive=${false}
trigger="mouseenter:mouseleave focus:blur click"
>
<div
id="trigger"
slot="trigger"
role="button"
tabindex="0"
aria-label=${ifDefined(this.label ?? undefined)}
aria-labelledby=${`${this.label ? 'trigger' : ''} icon`}
aria-label=${`${this.label ?? ''} ${
this._config.stringMap['tooltip.information']
}`.trim()}
aria-describedby="tooltip"
>
<slot name="trigger">
<cai-icon-help
id="icon"
role="img"
aria-label=${this._config.stringMap['tooltip.information']}
></cai-icon-help>
<cai-icon-help></cai-icon-help>
</slot>
</div>
<div
Expand Down
1 change: 1 addition & 0 deletions packages/c2pa-wc/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const defaultStyles = css`
--cai-popover-color: var(--cai-primary-color);
--cai-focus-ring-color: #1473e6;
--cai-social-media-item-color: #1473e6;
--cai-icon-border-radius: var(--cai-popover-icon-border-radius, 50%);
font-family: var(--cai-font-family);
font-size: var(--cai-font-size-base);
Expand Down

0 comments on commit d6f2741

Please sign in to comment.