Tooltip
composantA short description of a control, shown on hover and focus: it can be hovered, stays until left, and Escape dismisses it (WCAG 1.4.13).
Aperçu
Saves the draft
Code
<twig:Tooltip id="save">
<twig:Tooltip:Trigger>
<twig:Button {{ ...tooltip_trigger_attrs }}>Save</twig:Button>
</twig:Tooltip:Trigger>
<twig:Tooltip:Content>Saves the draft</twig:Tooltip:Content>
</twig:Tooltip>
Saves the draft
Code
<twig:Tooltip id="save">
<twig:Tooltip:Trigger>
<twig:Button {{ ...tooltip_trigger_attrs }}>Save</twig:Button>
</twig:Tooltip:Trigger>
<twig:Tooltip:Content>Saves the draft</twig:Tooltip:Content>
</twig:Tooltip>
Installation
php bin/console ux:install tooltip --kit=github.com/parler-haut-interagir-librement/ux-kit
Propriétés
| Propriété | Type | Par défaut | Description |
|---|---|---|---|
delayDuration |
number |
0 |
Delay in milliseconds before showing the tooltip. |
Accessibility
The trigger is described by the content through aria-describedby, also when the tooltip is hidden. Nothing that matters goes in a tooltip alone: a touch screen has no hover.
Fichiers à copier
assets/controllers/tooltip_controller.js
import { Controller } from '@hotwired/stimulus';
/**
* Where a box goes against its trigger: on the side asked for, or on the other one when it does not fit there and the
* other has more room; aligned along the cross axis, `start` and `end` following the reading direction, and pushed
* back inside the viewport. Every value is in viewport pixels, as `getBoundingClientRect()` gives them.
*/
export function placement(trigger, box, viewport, { side = 'bottom', align = 'center', gap = 6, rtl = false } = {}) {
const vertical = side === 'top' || side === 'bottom';
const size = vertical ? box.height : box.width;
const roomBefore = (vertical ? trigger.top : trigger.left) - gap;
const roomAfter = (vertical ? viewport.height - trigger.bottom : viewport.width - trigger.right) - gap;
const wantsBefore = side === 'top' || side === 'left';
const flipped = wantsBefore
? size > roomBefore && roomAfter > roomBefore
: size > roomAfter && roomBefore > roomAfter;
const before = wantsBefore !== flipped;
const main = before
? (vertical ? trigger.top : trigger.left) - gap - size
: (vertical ? trigger.bottom : trigger.right) + gap;
const crossStart = vertical ? trigger.left : trigger.top;
const crossEnd = vertical ? trigger.right : trigger.bottom;
const crossSize = vertical ? box.width : box.height;
const edge = vertical && rtl ? ({ start: 'end', end: 'start' }[align] ?? align) : align;
const cross =
edge === 'start' ? crossStart : edge === 'end' ? crossEnd - crossSize : (crossStart + crossEnd - crossSize) / 2;
const limit = (vertical ? viewport.width : viewport.height) - crossSize;
const clamped = Math.min(Math.max(0, cross), Math.max(0, limit));
const names = vertical ? ['top', 'bottom'] : ['left', 'right'];
return {
top: Math.round(vertical ? main : clamped),
left: Math.round(vertical ? clamped : main),
side: names[before ? 0 : 1],
};
}
/** The class of each side, the left and the right being the start and the end of the reading direction. */
export function sideClasses(side, rtl) {
const start = rtl ? 'right' : 'left';
const end = rtl ? 'left' : 'right';
return { isTop: side === 'top', isBottom: side === 'bottom', isStart: side === start, isEnd: side === end };
}
/** How long the tooltip stays once the pointer left, so that it can reach the tooltip (WCAG 1.4.13). */
export const GRACE = 100;
/** The room between the tooltip and its trigger, for the arrow. */
const ARROW = 8;
/*
* A tooltip on a manual popover, in the top layer so that no overflow cuts it. It shows on hover and focus, after the
* delay asked for, stays while the pointer is on it, and Escape dismisses it from anywhere in the page.
*/
export default class extends Controller {
static values = {
delayDuration: { type: Number, default: 0 },
wrapperSelector: String,
contentSelector: String,
arrowSelector: String,
};
static targets = ['trigger', 'wrapper', 'content', 'arrow'];
#showTimer = null;
#hideTimer = null;
#escape = (event) => {
if (event.key === 'Escape') {
this.#conceal();
}
};
disconnect() {
this.#conceal();
}
show() {
clearTimeout(this.#hideTimer);
clearTimeout(this.#showTimer);
if (this.delayDurationValue > 0) {
this.#showTimer = setTimeout(() => this.#reveal(), this.delayDurationValue);
} else {
this.#reveal();
}
}
hide() {
clearTimeout(this.#showTimer);
clearTimeout(this.#hideTimer);
this.#hideTimer = setTimeout(() => this.#conceal(), GRACE);
}
get #wrapper() {
return this.hasWrapperTarget ? this.wrapperTarget : document.querySelector(this.wrapperSelectorValue);
}
get #content() {
return this.hasContentTarget ? this.contentTarget : document.querySelector(this.contentSelectorValue);
}
get #arrow() {
return this.hasArrowTarget ? this.arrowTarget : document.querySelector(this.arrowSelectorValue);
}
#reveal() {
const wrapper = this.#wrapper;
if (!wrapper || wrapper.matches(':popover-open')) {
return;
}
wrapper.showPopover();
this.#position(wrapper);
document.addEventListener('keydown', this.#escape);
}
#conceal() {
clearTimeout(this.#showTimer);
clearTimeout(this.#hideTimer);
document.removeEventListener('keydown', this.#escape);
const wrapper = this.#wrapper;
if (wrapper?.matches(':popover-open')) {
wrapper.hidePopover();
}
}
#position(wrapper) {
const trigger = this.triggerTarget.getBoundingClientRect();
const { top, left, side } = placement(
trigger,
{ width: wrapper.offsetWidth, height: wrapper.offsetHeight },
{ width: document.documentElement.clientWidth, height: document.documentElement.clientHeight },
{ side: wrapper.dataset.side, align: 'center', gap: ARROW + Number(wrapper.dataset.sideOffset ?? 0) },
);
wrapper.style.inset = `${top}px auto auto ${left}px`;
wrapper.dataset.placed = side;
for (const element of [this.#content, this.#arrow]) {
if (element) {
element.dataset.side = side;
}
}
this.#mark(side, trigger, top, left, wrapper);
}
/** Writes on the bubble the side it went to, logical, and keeps the arrow on the trigger when the bubble was pushed. */
#mark(side, trigger, top, left, wrapper) {
const content = this.#content;
const arrow = this.#arrow;
if (!content) {
return;
}
const rtl = getComputedStyle(wrapper).direction === 'rtl';
for (const [name, on] of Object.entries(sideClasses(side, rtl))) {
content.classList.toggle(name, on);
}
if (arrow) {
const pushedX = (trigger.left + trigger.right - wrapper.offsetWidth) / 2 - left;
const pushedY = (trigger.top + trigger.bottom - wrapper.offsetHeight) / 2 - top;
const vertical = side === 'top' || side === 'bottom';
arrow.style.setProperty('--phil-tooltip-arrow-x', `${vertical ? Math.round(pushedX) : 0}px`);
arrow.style.setProperty('--phil-tooltip-arrow-y', `${vertical ? 0 : Math.round(pushedY)}px`);
}
}
}
templates/components/Tooltip.html.twig
{%- props
## string Unique identifier for the tooltip.
id,
## number Delay in milliseconds before showing the tooltip.
delayDuration = 0
-%}
{%- do provide('tooltip.triggerId', id ~ '_trigger') -%}
{%- do provide('tooltip.wrapperId', id ~ '_wrapper') -%}
{%- do provide('tooltip.contentId', id ~ '_content') -%}
{%- do provide('tooltip.arrowId', id ~ '_arrow') -%}
<div id="{{ id }}" data-slot="tooltip" data-tooltip-delay-duration-value="{{ delayDuration }}" data-tooltip-wrapper-selector-value="#{{ id }}_wrapper" data-tooltip-content-selector-value="#{{ id }}_content" data-tooltip-arrow-selector-value="#{{ id }}_arrow"{{ attributes.defaults({class: 'hasTooltip', 'data-controller': 'tooltip'}) }}>
{##- The tooltip structure, typically includes `Tooltip:Trigger` and `Tooltip:Content`. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Tooltip/Content.html.twig
{%- props
## 'top'|'right'|'bottom'|'left' The preferred side to display the tooltip.
side = 'top',
## number The distance in pixels from the trigger.
sideOffset = 0
-%}
<div id="{{ inject('tooltip.wrapperId') }}" class="isTooltipWrapper" popover="manual" role="presentation" data-slot="tooltip-wrapper" data-side="{{ side }}" data-side-offset="{{ sideOffset }}" data-tooltip-target="wrapper" data-action="mouseenter->tooltip#show mouseleave->tooltip#hide"><div id="{{ inject('tooltip.contentId') }}" role="tooltip" data-slot="tooltip-content" data-side="{{ side }}" data-tooltip-target="content"{{ attributes.defaults({class: 'isTooltip'}) }}>
{##- The tooltip text or content. -#}
{%- block content %}{% endblock -%}
<span id="{{ inject('tooltip.arrowId') }}" class="isTooltipArrow" data-side="{{ side }}" data-tooltip-target="arrow" aria-hidden="true"></span></div></div>
templates/components/Tooltip/Trigger.html.twig
{%- set tooltip_trigger_attrs = {
id: inject('tooltip.triggerId'),
'aria-describedby': inject('tooltip.contentId'),
'data-slot': 'tooltip-trigger',
'data-tooltip-target': 'trigger',
'data-action': 'mouseenter->tooltip#show mouseleave->tooltip#hide focus->tooltip#show blur->tooltip#hide',
} -%}
{##- The element that triggers the tooltip on hover/focus. -#}
{%- block content %}{% endblock -%}