Implement Scott O'Hara's ARIA tooltips

This commit is contained in:
Helen Chong 2024-07-06 18:21:42 +08:00
parent 4a73587a53
commit cf4040da6d
7 changed files with 372 additions and 28 deletions

View File

@ -9,6 +9,9 @@
{# CSS #}
{% include "global/css-bundle.njk" %}
{% if hasTooltips %}
<link rel="stylesheet" href="{{'/assets/css/tooltips.css' | url | safe}}">
{% endif %}
{% if customCSSSheets %}
{%- for sheet in customCSSSheets -%}
<link rel="stylesheet" href="{{'/assets/css/' + sheet + '.css' | url | safe}}">
@ -46,5 +49,16 @@
{{ content | safe }}
</main>
{% block footer %}{% include "global/footer.njk" %}{% endblock %}
{% if hasTooltips %}
<script src="{{'/assets/js/tooltips.js' | url | safe}}"></script>
<script>
var selector = '[data-tooltip]';
var els = document.querySelectorAll(selector);
for ( var i = 0; i < els.length; i++ ) {
var dm = new ARIAtip( els[i] );
}
</script>
{% endif %}
</body>
</html>

View File

@ -128,28 +128,6 @@
p + .adoptables { margin-top: 1em; }
/* Tooltips */
.tooltip { display: none; }
.tipcontainer { position: relative; }
.tipactivator {
display: block;
padding: 0;
border: none;
}
.tipactivator:hover + .tooltip,
.tipactivator:focus + .tooltip,
.tooltip:hover {
display: inline-block;
position: absolute;
background-color: var(--clr-quote-bg);
border: 0.15em solid var(--clr-main-heading);
padding: 0.25em 0.5em;
z-index: 998;
font-size: 1rem;
}
/* Web button lists */
.web-btn-wrapper {
display: flex;

View File

@ -0,0 +1,49 @@
/* ARIA Tooltips by Scott O'Hara: https://github.com/scottaohara/a11y_tooltips */
[data-tooltip] {
display: inline-block;
position: relative;
}
[data-tooltip-block] { display: block; }
.tooltip {
font-size: .825rem;
left: 0;
min-width: 20ch;
max-width: 44ch;
pointer-events: none;
position: absolute;
top: 100%;
z-index: 2;
}
.push-up .tooltip {
bottom: 100%;
top: auto;
}
.push-right .tooltip {
left: auto;
right: 0;
}
.tooltip__content {
background-color: var(--clr-quote-bg);
border: 0.15em solid var(--clr-main-heading);
display: inline-block;
opacity: 0;
padding: .625em;
visibility: hidden;
}
.tooltip__content > * { margin: .5em 0; }
.tooltip__content :last-child { margin-bottom: 0; }
.tooltip__content :first-child { margin-top: 0; }
.tooltip--show .tooltip__content {
opacity: 1;
pointer-events: auto;
transition: opacity .1s ease-in;
visibility: visible;
}

303
src/assets/js/tooltips.js Normal file
View File

@ -0,0 +1,303 @@
/* ARIA Tooltips by Scott O'Hara: https://github.com/scottaohara/a11y_tooltips */
'use strict';
if (typeof Object.assign != 'function') {
// Must be writable: true, enumerable: false, configurable: true
Object.defineProperty(Object, "assign", {
value: function assign(target, varArgs) { // .length of function is 2
'use strict';
if (target == null) { // TypeError if undefined or null
throw new TypeError('Cannot convert undefined or null to object');
}
var to = Object(target);
for (var index = 1; index < arguments.length; index++) {
var nextSource = arguments[index];
if (nextSource != null) { // Skip over if undefined or null
for (var nextKey in nextSource) {
// Avoid bugs when hasOwnProperty is shadowed
if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
to[nextKey] = nextSource[nextKey];
}
}
}
}
return to;
},
writable: true,
configurable: true
});
}
var util = {
keyCodes: {
ESC: 27
},
generateID: function ( base ) {
return base + Math.floor(Math.random() * 999);
}
};
(function ( w, doc, undefined ) {
/**
* ARIA Tooltips
* Widget to reveal a short description, or
* the element's accessible name on hover/focus.
*
* Author: Scott O'Hara
* Version: 1.0.0
* License: MIT
*/
var tipConfig = {
baseID: 'tt_',
ariaHiddenTip: true,
tipWrapperClass: 'tooltip',
tipContentClass: 'tooltip__content',
tipTypeAttr: 'data-tooltip',
tipContentAttr: 'data-tooltip-content',
tipSelector: '[data-tooltip-tip]',
triggerSelector: '[data-tooltip-trigger]'
};
var ARIAtip = function ( inst, options ) {
var el = inst;
var elID;
var elTip;
var elTipID;
var elTrigger;
var tipContent;
var tipType;
var _options = Object.assign(tipConfig, options);
var init = function () {
// if an element has an ID, use that as the
// basis for the tooltip's ID. Or, generate one.
elID = el.id || util.generateID(_options.baseID);
// the element that will trigger the tooltip on hover or focus.
elTrigger = el.querySelector(_options.triggerSelector);
// base the tip's ID off from the element's tip
elTipID = elID + '_tip';
// determine the type of tip
tipType = tipType();
// retrieve the content for the tip (flatted text string)
tipContent = getTipContent();
// create the tip
createTip();
// add/modify the necessary attributes of the trigger.
setupTrigger();
// get the generated tooltip
elTip = el.querySelector('.'+_options.tipContentClass);
// Attach the various events to the triggers
attachEvents();
};
/**
* A tooltip can either provide a description to
* the element that it is associated with, or it
* can provide a means to visually display the element's
* accessible name (making it not actually a tooltip then,
* but rather the accessible name that is revealed on
* hover/focus).
*/
var tipType = function () {
if ( el.getAttribute(_options.tipTypeAttr) === 'label' ) {
return 'label';
}
else {
return 'description';
}
};
/**
* The content of a tooltip can come from different sources
* so as to allow for fallback content in case this script
* cannot run.
*
* A tip could be sourced from an element in the DOM via
* the attribute data-tooltip-tip (a child of the widget),
* or an ID referenced from data-tooltip-source (does not
* have to be a child of the widget),
* the trigger's title or aria-label attribute
*/
var getTipContent = function () {
var returnTextString; // text string to return
var tipAttrContent = el.getAttribute(_options.tipContentAttr);
var tipAriaLabel = elTrigger.getAttribute('aria-label');
var widgetChild = el.querySelector(_options.tipSelector);
if ( tipAttrContent ) {
returnTextString = tipAttrContent;
}
else if ( widgetChild ) {
returnTextString = widgetChild.textContent;
widgetChild.parentNode.removeChild(widgetChild);
}
else if ( tipAriaLabel && tipType === 'label' ) {
returnTextString = tipAriaLabel;
elTrigger.removeAttribute('aria-label');
}
else if ( elTrigger.title ) {
returnTextString = elTrigger.title;
}
// an element cannot have both a custom tooltip
// and a tooltip from a title attribute. So no
// matter what, remove the title attribute.
elTrigger.removeAttribute('title');
return returnTextString;
};
/**
* Create the necessary tooltip components for each
* instance of the widget.
*/
var createTip = function () {
var tipOuter = doc.createElement('span');
var tipInner = doc.createElement('span');
tipOuter.classList.add(_options.tipWrapperClass);
tipInner.classList.add(_options.tipContentClass);
tipInner.textContent = tipContent;
tipInner.id = elTipID;
if ( tipType !== 'label') {
tipInner.setAttribute('role', 'tooltip');
}
// this is a bit silly, as it shows how unnecessary the
// tooltip role is, but it ensures that a screen reader's
// virtual cursor cannot interact with the tooltip by itself,
// and helps reduce Chrome on PC w/JAWS and NVDA announcing the tooltip
// multiple times, when it's shown/hidden.
// If you want to reveal the tips anyway,
// set the ariaHiddenTip config to false.
if ( _options.ariaHiddenTip ) {
tipInner.setAttribute('aria-hidden', 'true');
}
tipOuter.appendChild(tipInner);
el.appendChild(tipOuter);
};
/**
* Ensure the tooltip trigger has the appropriate
* attributes on it, and that they point to the
* correct IDs.
*/
var setupTrigger = function () {
if ( tipType === 'label' ) {
elTrigger.setAttribute('aria-labelledby', elTipID);
}
else {
elTrigger.setAttribute('aria-describedby', elTipID);
}
};
/**
* Check the current viewport to determine if the tooltip
* will be revealed outside of the current viewport's bounds.
* If so, try to reposition to ensure it's within.
*/
var checkPositioning = function () {
var bounding = elTip.getBoundingClientRect();
if ( bounding.bottom > w.innerHeight ) {
el.classList.add('push-up');
}
if ( bounding.right > w.innerWidth ) {
el.classList.add('push-right');
}
};
/**
* Remove the positioning classes, assuming the next time
* the element is interacted with, it will require the
* default positioning.
*/
var resetPositioning = function () {
el.classList.remove('push-up', 'push-right');
};
/**
* Add class to show tooltip.
* Checks positioning to help ensure within viewport.
* Adds global event to escape tip.
*/
var showTip = function () {
el.classList.add(_options.tipWrapperClass + '--show');
checkPositioning();
doc.addEventListener('keydown', globalEscape, false);
doc.addEventListener('touchend', hideTip, false);
};
/**
* Removes classes for show and/or suppressed tip.
* Removes classes for positioning.
* Removes global event to escape tip.
*/
var hideTip = function () {
el.classList.remove(_options.tipWrapperClass + '--show');
resetPositioning();
doc.removeEventListener('keydown', globalEscape);
doc.addEventListener('touchend', hideTip);
};
/**
* Global event to allow the ESC key to close
* an invoked tooltip, regardless of where focus/hover
* is in the DOM.
*
* Calls both hideTip and suppressTip functions to help
* better replicate native tooltip suppression behavior.
*/
var globalEscape = function ( e ) {
var keyCode = e.keyCode || e.which;
switch ( keyCode ) {
case util.keyCodes.ESC:
e.preventDefault();
hideTip();
break;
default:
break;
}
};
var attachEvents = function () {
elTrigger.addEventListener('mouseenter', showTip, false);
elTrigger.addEventListener('focus', showTip, false);
el.addEventListener('mouseleave', hideTip, false);
elTrigger.addEventListener('blur', hideTip, false);
};
init.call(this);
return this;
}; // ARIAtip
w.ARIAtip = ARIAtip;
})( window, document );

View File

@ -4,6 +4,7 @@ tags: pages
metadata:
title: Home
isContentDivided: true
hasTooltips: true
eleventyNavigation:
key: Home
order: 1
@ -74,11 +75,10 @@ eleventyComputed:
<h2>Always Proud</h2>
<div class="flag-progress-intersex-lesbian" role="img" aria-label="Custom pride flag which combines the lesbian pride flag and the progress pride flag triangle" style="height: 20rem; margin-bottom: 1em;"></div>
{%- macro prideButton(file, alt, tooltip=alt, width=88, height=31) -%}
<li class="tipcontainer">
<button class="tipactivator" style="cursor: unset;">
<li data-tooltip>
<button data-tooltip-trigger title="{{ tooltip }}" style="cursor: unset; padding: 0; margin: 0; border: 0;">
<img src="/assets/buttons/pride/{{ file }}.png" alt="{{ alt }}" width="{{ width }}" height="{{ height }}" loading="lazy">
</button>
<p class="tooltip">{{ tooltip }}</p>
</li>
{%- endmacro -%}
<ul class="web-btn-wrapper">

View File

@ -4,11 +4,10 @@ date: 2023-01-28
---
{%- macro siteButton(url, file, alt, tooltip=alt, width=88, height=31) -%}
<li class="tipcontainer">
<a href="{{ url }}" class="tipactivator">
<li data-tooltip>
<a href="{{ url }}" data-tooltip-trigger title="{{ tooltip }}">
<img src="/assets/buttons/{{ file }}" alt="{{ alt }}" width="{{ width }}" height="{{ height }}" loading="lazy">
</a>
<p class="tooltip">{{ tooltip }}</p>
</li>
{%- endmacro -%}

View File

@ -2,6 +2,7 @@
layout: main/links.njk
title: Links
desc: A curated collection of links.
hasTooltips: true
eleventyNavigation:
order: 8
---