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

Improve tooltip accessibility #1749

Merged
merged 7 commits into from
Dec 1, 2023
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"monospace",
"mousedown",
"mousemove",
"mouseout",
"mouseup",
"multiselectable",
"nextjs",
Expand Down
134 changes: 134 additions & 0 deletions docs/pages/components/popup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/pages/resources/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
15 changes: 4 additions & 11 deletions src/components/menu-item/menu-item.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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)
);
}

Expand Down
95 changes: 95 additions & 0 deletions src/components/popup/popup.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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({
Expand Down
20 changes: 20 additions & 0 deletions src/components/popup/popup.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
`;
11 changes: 7 additions & 4 deletions src/components/tooltip/tooltip.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ 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);
}
Expand Down Expand Up @@ -143,9 +142,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();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not related to the PR, but any reason we stopPropagation() here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We call this because it's listening on the document, and if you have a tooltip inside of a dialog (or similar) then the event will trigger the ancestor(s) listeners, causing them to close as well.

this.hide();
}
Expand Down Expand Up @@ -181,17 +180,20 @@ export default class SlTooltip extends ShoelaceElement {

// Show
this.emit('sl-show');
document.addEventListener('keydown', this.handleDocumentKeyDown);
claviska marked this conversation as resolved.
Show resolved Hide resolved

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() });
Expand Down Expand Up @@ -263,6 +265,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>
Expand Down
2 changes: 1 addition & 1 deletion src/components/tooltip/tooltip.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default css`
}

.tooltip::part(popup) {
pointer-events: none;
user-select: none;
z-index: var(--sl-z-index-tooltip);
}

Expand Down
Loading