Improve tooltip accessibility (#1749)

* always close on escape, even when not focused; #1734

* use fallbacks instead of defaults

* add words

* add safe trapezoids / hover bridge; fixes #1734

* oh, webkit

* remove unused import

* cleanup just in case
pull/1752/head^2
Cory LaViska 2023-12-01 10:02:46 -05:00 zatwierdzone przez GitHub
rodzic bfa7c4cda9
commit e2b7327d98
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
9 zmienionych plików z 274 dodań i 22 usunięć

Wyświetl plik

@ -100,6 +100,7 @@
"monospace",
"mousedown",
"mousemove",
"mouseout",
"mouseup",
"multiselectable",
"nextjs",

Wyświetl plik

@ -1530,6 +1530,140 @@ const App = () => {
};
```
### Hover Bridge
When a gap exists between the anchor and the popup element, this option will add a "hover bridge" that fills the gap using an invisible element. This makes listening for events such as `mouseover` and `mouseout` more sane because the pointer never technically leaves the element. The hover bridge will only be drawn when the popover is active. For demonstration purposes, the bridge in this example is shown in orange.
```html:preview
<div class="popup-hover-bridge">
<sl-popup placement="top" hover-bridge distance="10" skidding="0" active>
<span slot="anchor"></span>
<div class="box"></div>
</sl-popup>
<br>
<sl-switch checked>Hover Bridge</sl-switch><br>
<sl-range min="0" max="50" step="1" value="10" label="Distance"></sl-range>
<sl-range min="-50" max="50" step="1" value="0" label="Skidding"></sl-range>
</div>
<style>
.popup-hover-bridge span[slot='anchor'] {
display: inline-block;
width: 150px;
height: 150px;
border: dashed 2px var(--sl-color-neutral-600);
margin: 50px;
}
.popup-hover-bridge .box {
width: 100px;
height: 50px;
background: var(--sl-color-primary-600);
border-radius: var(--sl-border-radius-medium);
}
.popup-hover-bridge sl-range {
max-width: 260px;
margin-top: .5rem;
}
.popup-hover-bridge sl-popup::part(hover-bridge) {
background: tomato;
opacity: .5;
}
</style>
<script>
const container = document.querySelector('.popup-hover-bridge');
const popup = container.querySelector('sl-popup');
const hoverBridge = container.querySelector('sl-switch');
const distance = container.querySelector('sl-range[label="Distance"]');
const skidding = container.querySelector('sl-range[label="Skidding"]');
distance.addEventListener('sl-input', () => (popup.distance = distance.value));
skidding.addEventListener('sl-input', () => (popup.skidding = skidding.value));
hoverBridge.addEventListener('sl-change', () => (popup.hoverBridge = hoverBridge.checked));
</script>
```
```jsx:react
import { useState } from 'react';
import SlPopup from '@shoelace-style/shoelace/dist/react/popup';
import SlRange from '@shoelace-style/shoelace/dist/react/range';
import SlSwitch from '@shoelace-style/shoelace/dist/react/switch';
const css = `
.popup-hover-bridge span[slot='anchor'] {
display: inline-block;
width: 150px;
height: 150px;
border: dashed 2px var(--sl-color-neutral-600);
margin: 50px;
}
.popup-hover-bridge .box {
width: 100px;
height: 50px;
background: var(--sl-color-primary-600);
border-radius: var(--sl-border-radius-medium);
}
.popup-hover-bridge sl-range {
max-width: 260px;
margin-top: .5rem;
}
.popup-hover-bridge sl-popup::part(hover-bridge) {
background: tomato;
opacity: .5;
}
`;
const App = () => {
const [hoverBridge, setHoverBridge] = useState(true);
const [distance, setDistance] = useState(10);
const [skidding, setSkidding] = useState(0);
return (
<>
<div class="popup-hover-bridge">
<SlPopup placement="top" hover-bridge={hoverBridge} distance={distance} skidding={skidding} active>
<span slot="anchor" />
<div class="box" />
</SlPopup>
<br />
<SlSwitch
checked={hoverBridge}
onSlChange={event => setHoverBridge(event.target.checked)}
>
Hover Bridge
</SlSwitch><br />
<SlRange
min="0"
max="50"
step="1"
value={distance}
label="Distance"
onSlInput={event => setDistance(event.target.value)}
/>
<SlRange
min="-50"
max="50"
step="1"
value={skidding}
label="Skidding"
onSlInput={event => setSkidding(event.target.value)}
/>
</div>
<style>{css}</style>
</>
);
};
```
### Virtual Elements
In most cases, popups are anchored to an actual element. Sometimes, it can be useful to anchor them to a non-element. To do this, you can pass a `VirtualElement` to the anchor property. A virtual element must contain a function called `getBoundingClientRect()` that returns a [`DOMRect`](https://developer.mozilla.org/en-US/docs/Web/API/DOMRect) object as shown below.

Wyświetl plik

@ -12,6 +12,11 @@ Components with the <sl-badge variant="warning" pill>Experimental</sl-badge> bad
New versions of Shoelace are released as-needed and generally occur when a critical mass of changes have accumulated. At any time, you can see what's coming in the next release by visiting [next.shoelace.style](https://next.shoelace.style).
## Next
- Added the `hover-bridge` feature to `<sl-popup>` to support better tooltip accessibility [#1734]
- Improved the accessibility of `<sl-tooltip>` so they persist when hovering over the tooltip and dismiss when pressing [[Esc]] [#1734]
## 2.12.0
- Added the Italian translation [#1727]

Wyświetl plik

@ -7,14 +7,6 @@ export default css`
:host {
--submenu-offset: -2px;
/* Private */
--safe-triangle-cursor-x: 0;
--safe-triangle-cursor-y: 0;
--safe-triangle-submenu-start-x: 0;
--safe-triangle-submenu-start-y: 0;
--safe-triangle-submenu-end-x: 0;
--safe-triangle-submenu-end-y: 0;
display: block;
}
@ -82,10 +74,11 @@ export default css`
right: 0;
bottom: 0;
left: 0;
background: tomato;
clip-path: polygon(
var(--safe-triangle-cursor-x) var(--safe-triangle-cursor-y),
var(--safe-triangle-submenu-start-x) var(--safe-triangle-submenu-start-y),
var(--safe-triangle-submenu-end-x) var(--safe-triangle-submenu-end-y)
var(--safe-triangle-cursor-x, 0) var(--safe-triangle-cursor-y, 0),
var(--safe-triangle-submenu-start-x, 0) var(--safe-triangle-submenu-start-y, 0),
var(--safe-triangle-submenu-end-x, 0) var(--safe-triangle-submenu-end-y, 0)
);
}

Wyświetl plik

@ -32,6 +32,7 @@ function isVirtualElement(e: unknown): e is VirtualElement {
* assigned dynamically as the popup moves. This is most useful for applying a background color to match the popup, and
* maybe a border or box shadow.
* @csspart popup - The popup's container. Useful for setting a background color, box shadow, etc.
* @csspart hover-bridge - The hover bridge element. Only available when the `hover-bridge` option is enabled.
*
* @cssproperty [--arrow-size=6px] - The size of the arrow. Note that an arrow won't be shown unless the `arrow`
* attribute is used.
@ -189,6 +190,14 @@ export default class SlPopup extends ShoelaceElement {
/** The amount of padding, in pixels, to exceed before the auto-size behavior will occur. */
@property({ attribute: 'auto-size-padding', type: Number }) autoSizePadding = 0;
/**
* When a gap exists between the anchor and the popup element, this option will add a "hover bridge" that fills the
* gap using an invisible element. This makes listening for events such as `mouseenter` and `mouseleave` more sane
* because the pointer never technically leaves the element. The hover bridge will only be drawn when the popover is
* active.
*/
@property({ attribute: 'hover-bridge', type: Boolean }) hoverBridge = false;
async connectedCallback() {
super.connectedCallback();
@ -447,13 +456,99 @@ export default class SlPopup extends ShoelaceElement {
}
});
// Wait until the new position is drawn before updating the hover bridge, otherwise it can get out of sync
requestAnimationFrame(() => this.updateHoverBridge());
this.emit('sl-reposition');
}
private updateHoverBridge = () => {
if (this.hoverBridge && this.anchorEl) {
const anchorRect = this.anchorEl.getBoundingClientRect();
const popupRect = this.popup.getBoundingClientRect();
const isVertical = this.placement.includes('top') || this.placement.includes('bottom');
let topLeftX = 0;
let topLeftY = 0;
let topRightX = 0;
let topRightY = 0;
let bottomLeftX = 0;
let bottomLeftY = 0;
let bottomRightX = 0;
let bottomRightY = 0;
if (isVertical) {
if (anchorRect.top < popupRect.top) {
// Anchor is above
topLeftX = anchorRect.left;
topLeftY = anchorRect.bottom;
topRightX = anchorRect.right;
topRightY = anchorRect.bottom;
bottomLeftX = popupRect.left;
bottomLeftY = popupRect.top;
bottomRightX = popupRect.right;
bottomRightY = popupRect.top;
} else {
// Anchor is below
topLeftX = popupRect.left;
topLeftY = popupRect.bottom;
topRightX = popupRect.right;
topRightY = popupRect.bottom;
bottomLeftX = anchorRect.left;
bottomLeftY = anchorRect.top;
bottomRightX = anchorRect.right;
bottomRightY = anchorRect.top;
}
} else {
if (anchorRect.left < popupRect.left) {
// Anchor is on the left
topLeftX = anchorRect.right;
topLeftY = anchorRect.top;
topRightX = popupRect.left;
topRightY = popupRect.top;
bottomLeftX = anchorRect.right;
bottomLeftY = anchorRect.bottom;
bottomRightX = popupRect.left;
bottomRightY = popupRect.bottom;
} else {
// Anchor is on the right
topLeftX = popupRect.right;
topLeftY = popupRect.top;
topRightX = anchorRect.left;
topRightY = anchorRect.top;
bottomLeftX = popupRect.right;
bottomLeftY = popupRect.bottom;
bottomRightX = anchorRect.left;
bottomRightY = anchorRect.bottom;
}
}
this.style.setProperty('--hover-bridge-top-left-x', `${topLeftX}px`);
this.style.setProperty('--hover-bridge-top-left-y', `${topLeftY}px`);
this.style.setProperty('--hover-bridge-top-right-x', `${topRightX}px`);
this.style.setProperty('--hover-bridge-top-right-y', `${topRightY}px`);
this.style.setProperty('--hover-bridge-bottom-left-x', `${bottomLeftX}px`);
this.style.setProperty('--hover-bridge-bottom-left-y', `${bottomLeftY}px`);
this.style.setProperty('--hover-bridge-bottom-right-x', `${bottomRightX}px`);
this.style.setProperty('--hover-bridge-bottom-right-y', `${bottomRightY}px`);
}
};
render() {
return html`
<slot name="anchor" @slotchange=${this.handleAnchorChange}></slot>
<span
part="hover-bridge"
class=${classMap({
'popup-hover-bridge': true,
'popup-hover-bridge--visible': this.hoverBridge && this.active
})}
></span>
<div
part="popup"
class=${classMap({

Wyświetl plik

@ -41,4 +41,24 @@ export default css`
background: var(--arrow-color);
z-index: -1;
}
/* Hover bridge */
.popup-hover-bridge:not(.popup-hover-bridge--visible) {
display: none;
}
.popup-hover-bridge {
position: fixed;
z-index: calc(var(--sl-z-index-dropdown) - 1);
top: 0;
right: 0;
bottom: 0;
left: 0;
clip-path: polygon(
var(--hover-bridge-top-left-x, 0) var(--hover-bridge-top-left-y, 0),
var(--hover-bridge-top-right-x, 0) var(--hover-bridge-top-right-y, 0),
var(--hover-bridge-bottom-right-x, 0) var(--hover-bridge-bottom-right-y, 0),
var(--hover-bridge-bottom-left-x, 0) var(--hover-bridge-bottom-left-y, 0)
);
}
`;

Wyświetl plik

@ -102,13 +102,13 @@ export default class SlTooltip extends ShoelaceElement {
this.addEventListener('blur', this.handleBlur, true);
this.addEventListener('focus', this.handleFocus, true);
this.addEventListener('click', this.handleClick);
this.addEventListener('keydown', this.handleKeyDown);
this.addEventListener('mouseover', this.handleMouseOver);
this.addEventListener('mouseout', this.handleMouseOut);
}
connectedCallback() {
super.connectedCallback();
disconnectedCallback() {
// Cleanup this event in case the tooltip is removed while open
document.removeEventListener('keydown', this.handleDocumentKeyDown);
}
firstUpdated() {
@ -143,9 +143,9 @@ export default class SlTooltip extends ShoelaceElement {
}
};
private handleKeyDown = (event: KeyboardEvent) => {
// Pressing escape when the target element has focus should dismiss the tooltip
if (this.open && !this.disabled && event.key === 'Escape') {
private handleDocumentKeyDown = (event: KeyboardEvent) => {
// Pressing escape when a tooltip is open should dismiss it
if (event.key === 'Escape') {
event.stopPropagation();
this.hide();
}
@ -181,17 +181,20 @@ export default class SlTooltip extends ShoelaceElement {
// Show
this.emit('sl-show');
document.addEventListener('keydown', this.handleDocumentKeyDown);
await stopAnimations(this.body);
this.body.hidden = false;
this.popup.active = true;
const { keyframes, options } = getAnimation(this, 'tooltip.show', { dir: this.localize.dir() });
await animateTo(this.popup.popup, keyframes, options);
this.popup.reposition();
this.emit('sl-after-show');
} else {
// Hide
this.emit('sl-hide');
document.removeEventListener('keydown', this.handleDocumentKeyDown);
await stopAnimations(this.body);
const { keyframes, options } = getAnimation(this, 'tooltip.hide', { dir: this.localize.dir() });
@ -263,6 +266,7 @@ export default class SlTooltip extends ShoelaceElement {
flip
shift
arrow
hover-bridge
>
${'' /* eslint-disable-next-line lit-a11y/no-aria-slot */}
<slot slot="anchor" aria-describedby="tooltip"></slot>

Wyświetl plik

@ -18,7 +18,7 @@ export default css`
}
.tooltip::part(popup) {
pointer-events: none;
user-select: none;
z-index: var(--sl-z-index-tooltip);
}

Wyświetl plik

@ -1,7 +1,6 @@
import '../../../dist/shoelace.js';
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import type SlPopup from '../popup/popup';
import type SlTooltip from './tooltip';
describe('<sl-tooltip>', () => {
@ -149,14 +148,15 @@ describe('<sl-tooltip>', () => {
expect(body.hidden).to.be.false;
});
it('should not accept pointer events on the tooltip', async () => {
it('should not accept user selection on the tooltip', async () => {
const el = await fixture<SlTooltip>(html`
<sl-tooltip content="This is a tooltip" open>
<sl-button>Hover Me</sl-button>
</sl-tooltip>
`);
const popup = el.shadowRoot!.querySelector<SlPopup>('sl-popup')!;
const tooltipBody = el.shadowRoot!.querySelector('.tooltip__body')!;
const userSelect = getComputedStyle(tooltipBody).userSelect || getComputedStyle(tooltipBody).webkitUserSelect;
expect(getComputedStyle(popup.popup).pointerEvents).to.equal('none');
expect(userSelect).to.equal('none');
});
});