Aller au contenu

Tooltip

composant

A 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

Clair

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>

Sombre

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éTypePar défautDescription
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 -%}