Skip to content
Closed
59 changes: 59 additions & 0 deletions src/js/_enqueues/wp/wp-tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,62 @@
trigger.addEventListener( 'blur', hideTooltip );
});
})();

/**
* Add focus and hover support for the toggle tip's hint in `wp_get_toggletip()`.
*
* A toggle tip has no visible text label, so a hover/focus hint exposes the
* toggle button's accessible name to sighted users. The hint is suppressed while
* the toggle tip dialog itself is open, so the two never overlap.
* This script can be made obsolete when support is available for Interest Invokers.
*/
(() => {

const toggletips = /** @type {NodeListOf<HTMLSpanElement>} */ ( document.querySelectorAll( '.wp-is-toggletip' ) );

/** @type {ReturnType<typeof setTimeout>} */
let openTimeout;

toggletips.forEach( function( toggletip ) {
const trigger = /** @type {HTMLButtonElement|null} */ ( toggletip.querySelector( 'button.wp-tooltip__toggle' ) );
const hint = /** @type {HTMLSpanElement|null} */ ( toggletip.querySelector( '.wp-tooltip__hint' ) );
const dialog = /** @type {HTMLDialogElement|null} */ ( toggletip.querySelector( 'dialog.wp-tooltip__bubble' ) );

if ( ! trigger || ! hint ) {
return;
}

// Show Hint Function (with delay to prevent flickering).
const showHint = () => {
clearTimeout( openTimeout );
openTimeout = setTimeout( () => {
// Don't show the hint over an open toggle tip dialog.
if ( dialog && dialog.matches( ':popover-open' ) ) {
return;
}
// Only show if it's not already open.
if ( ! hint.matches( ':popover-open' ) ) {
// pass the triggering element so implicit position anchors work.
hint.showPopover( { source: trigger } );
}
}, 300 );
};
// Hide Hint Function.
const hideHint = () => {
clearTimeout( openTimeout );
if ( hint.matches( ':popover-open' ) ) {
hint.hidePopover();
}
};

// Bind Hover and Focus Events.
trigger.addEventListener( 'mouseenter', showHint );
trigger.addEventListener( 'focus', showHint );

trigger.addEventListener( 'mouseleave', hideHint );
trigger.addEventListener( 'blur', hideHint );

// Hide the hint as soon as the toggle tip is opened.
trigger.addEventListener( 'click', hideHint );
});
})();
16 changes: 9 additions & 7 deletions src/wp-admin/css/wp-tooltip.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,16 @@
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.08);
}

.wp-tooltip.wp-is-tooltip .wp-tooltip__bubble {
.wp-tooltip.wp-is-tooltip .wp-tooltip__bubble,
.wp-tooltip .wp-tooltip__hint {
background: #000;
color: #f0f0f0;
text-align: center;
padding: 4px 8px;
line-height: 1.4;
}

.wp-tooltip:not(.wp-is-tooltip) .wp-tooltip__text {
.wp-tooltip:not(.wp-is-tooltip) .wp-tooltip__bubble:not(.wp-tooltip__hint) .wp-tooltip__text {
display: block;
padding-inline-end: 24px;
}
Expand All @@ -94,13 +95,14 @@
position-try-fallbacks: flip-block, flip-inline;
}

.wp-tooltip.wp-is-tooltip .wp-tooltip__bubble {
.wp-tooltip.wp-is-tooltip .wp-tooltip__bubble,
.wp-tooltip .wp-tooltip__hint {
margin: 0;
}

/* Arrow: gray border (::before) under a white fill (::after). */
.wp-tooltip:not(.wp-is-tooltip) .wp-tooltip__bubble::before,
.wp-tooltip:not(.wp-is-tooltip) .wp-tooltip__bubble::after {
.wp-tooltip:not(.wp-is-tooltip) .wp-tooltip__bubble:not(.wp-tooltip__hint)::before,
.wp-tooltip:not(.wp-is-tooltip) .wp-tooltip__bubble:not(.wp-tooltip__hint)::after {
content: "";
position: absolute;
top: 100%;
Expand All @@ -111,12 +113,12 @@
border-bottom: 0;
}

.wp-tooltip:not(.wp-is-tooltip) .wp-tooltip__bubble::before {
.wp-tooltip:not(.wp-is-tooltip) .wp-tooltip__bubble:not(.wp-tooltip__hint)::before {
margin-left: -8px;
border-top-color: #c3c4c7;
}

.wp-tooltip:not(.wp-is-tooltip) .wp-tooltip__bubble::after {
.wp-tooltip:not(.wp-is-tooltip) .wp-tooltip__bubble:not(.wp-tooltip__hint)::after {
margin-left: -7px;
border-width: 7px;
border-bottom: 0;
Expand Down
8 changes: 6 additions & 2 deletions src/wp-includes/general-template.php
Original file line number Diff line number Diff line change
Expand Up @@ -584,8 +584,11 @@ function wp_get_tooltip_helper( $content, $args = array() ) {
*/
$markup = sprintf(
'<span class="%1$s">
%7$s
<span popover="auto" id="%2$s" class="wp-tooltip__bubble" role="dialog" aria-label="%3$s" tabindex="-1" autofocus>' .
%8$s
<span popover="hint" id="%2$s-hint" class="wp-tooltip__bubble wp-tooltip__hint" aria-hidden="true">' .
'<span class="wp-tooltip__text">%7$s</span>' .
'</span>' .
'<span popover="auto" id="%2$s" class="wp-tooltip__bubble" role="dialog" aria-label="%3$s" tabindex="-1" autofocus>' .
'<span id="%2$s-text" class="wp-tooltip__text">%5$s</span>' .
'<button type="button" class="wp-tooltip__close" popovertarget="%2$s" popovertargetaction="hide" aria-label="%6$s">' .
'<span class="dashicons dashicons-no-alt" aria-hidden="true"></span>' .
Expand All @@ -598,6 +601,7 @@ function wp_get_tooltip_helper( $content, $args = array() ) {
esc_attr( $icon ),
esc_html( $content ),
esc_attr( $args['close_label'] ),
esc_html( $args['label'] ),
$button,
);
}
Expand Down
56 changes: 56 additions & 0 deletions tests/phpunit/tests/general/wpGetTooltip.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public function test_wp_get_tooltip_returns_accessible_markup() {
$this->assertStringNotContainsString( 'class="wp-tooltip__close"', $tooltip );
$this->assertStringNotContainsString( 'popovertargetaction="hide"', $tooltip );

// A plain tooltip has no separate hint element.
$this->assertStringNotContainsString( 'wp-tooltip__hint', $tooltip );

$toggletip = wp_get_toggletip(
'Helpful text.',
array(
Expand All @@ -54,6 +57,59 @@ public function test_wp_get_tooltip_returns_accessible_markup() {
$this->assertStringContainsString( 'popovertargetaction="hide"', $toggletip );
}

/**
* Tests that a toggle tip exposes its accessible name through a hint tooltip.
*
* With no visible text label, the toggle button's accessible name is only
* available to assistive technologies via `aria-label`. A hover/focus hint
* tooltip exposes that same name to sighted users, while being hidden from
* assistive technologies to avoid a duplicate announcement.
*
* @ticket 65633
*/
public function test_wp_get_toggletip_includes_accessible_name_hint() {
$toggletip = wp_get_toggletip(
'Helpful text.',
array(
'id' => 'my-tip',
'label' => 'About this field',
)
);

// The hint is a hint popover that duplicates the toggle button's label.
$this->assertStringContainsString(
'<span popover="hint" id="my-tip-hint" class="wp-tooltip__bubble wp-tooltip__hint" aria-hidden="true">',
$toggletip
);
$this->assertStringContainsString(
'<span class="wp-tooltip__text">About this field</span>',
$toggletip
);

// The toggle button still exposes the accessible name to assistive technologies.
$this->assertStringContainsString( 'aria-label="About this field"', $toggletip );
}

/**
* Tests that the toggle tip hint escapes its content.
*
* @ticket 65633
*/
public function test_wp_get_toggletip_hint_escapes_label() {
$toggletip = wp_get_toggletip(
'Helpful text.',
array(
'label' => '<script>alert(1)</script>',
)
);

$this->assertStringContainsString(
'<span class="wp-tooltip__text">&lt;script&gt;',
$toggletip
);
$this->assertStringNotContainsString( '<span class="wp-tooltip__text"><script>', $toggletip );
}

/**
* Tests that content is escaped.
*
Expand Down
Loading