kopia lustrzana https://github.com/shoelace-style/shoelace
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 casepull/1752/head^2
rodzic
bfa7c4cda9
commit
e2b7327d98
|
@ -100,6 +100,7 @@
|
|||
"monospace",
|
||||
"mousedown",
|
||||
"mousemove",
|
||||
"mouseout",
|
||||
"mouseup",
|
||||
"multiselectable",
|
||||
"nextjs",
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -18,7 +18,7 @@ export default css`
|
|||
}
|
||||
|
||||
.tooltip::part(popup) {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
z-index: var(--sl-z-index-tooltip);
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
Ładowanie…
Reference in New Issue