From cf4040da6dc4cb818c0a83b7f1813e93e47c9b68 Mon Sep 17 00:00:00 2001 From: Helen Chong <119173961+helenclx@users.noreply.github.com> Date: Sat, 6 Jul 2024 18:21:42 +0800 Subject: [PATCH] Implement Scott O'Hara's ARIA tooltips --- src/_includes/global/baselayout.njk | 14 ++ src/assets/css/components.css | 22 -- src/assets/css/tooltips.css | 49 +++++ src/assets/js/tooltips.js | 303 ++++++++++++++++++++++++++++ src/index.njk | 6 +- src/links/websites.njk | 5 +- src/pages/links.njk | 1 + 7 files changed, 372 insertions(+), 28 deletions(-) create mode 100644 src/assets/css/tooltips.css create mode 100644 src/assets/js/tooltips.js diff --git a/src/_includes/global/baselayout.njk b/src/_includes/global/baselayout.njk index 8b94d08c..8382aa27 100644 --- a/src/_includes/global/baselayout.njk +++ b/src/_includes/global/baselayout.njk @@ -9,6 +9,9 @@ {# CSS #} {% include "global/css-bundle.njk" %} + {% if hasTooltips %} + + {% endif %} {% if customCSSSheets %} {%- for sheet in customCSSSheets -%} @@ -46,5 +49,16 @@ {{ content | safe }} {% block footer %}{% include "global/footer.njk" %}{% endblock %} + {% if hasTooltips %} + + + {% endif %} \ No newline at end of file diff --git a/src/assets/css/components.css b/src/assets/css/components.css index 8dfb0770..1a005bbf 100644 --- a/src/assets/css/components.css +++ b/src/assets/css/components.css @@ -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; diff --git a/src/assets/css/tooltips.css b/src/assets/css/tooltips.css new file mode 100644 index 00000000..334310f2 --- /dev/null +++ b/src/assets/css/tooltips.css @@ -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; +} diff --git a/src/assets/js/tooltips.js b/src/assets/js/tooltips.js new file mode 100644 index 00000000..e597045a --- /dev/null +++ b/src/assets/js/tooltips.js @@ -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 ); \ No newline at end of file diff --git a/src/index.njk b/src/index.njk index 8638cafc..7d0a6451 100644 --- a/src/index.njk +++ b/src/index.njk @@ -4,6 +4,7 @@ tags: pages metadata: title: Home isContentDivided: true +hasTooltips: true eleventyNavigation: key: Home order: 1 @@ -74,11 +75,10 @@ eleventyComputed:

Always Proud

{%- macro prideButton(file, alt, tooltip=alt, width=88, height=31) -%} -
  • - -

    {{ tooltip }}

  • {%- endmacro -%}