diff --git a/packages/unity-bootstrap-theme/src/js/tooltips.js b/packages/unity-bootstrap-theme/src/js/tooltips.js new file mode 100644 index 0000000000..672a4ea887 --- /dev/null +++ b/packages/unity-bootstrap-theme/src/js/tooltips.js @@ -0,0 +1,97 @@ +import { EventHandler } from "./bootstrap-helper"; + +function initTooltips() { + /* this value must stay in sync, found in files: */ + /* packages/unity-bootstrap-theme/src/scss/extends/_tooltips.scss */ + /* packages/unity-bootstrap-theme/src/js/tooltips.js */ + const TOOLTIP_MAX_WIDTH = 288; + const CONTAINER_CLASS = "uds-tooltip-container"; + const CONTAINER_SELECTOR = `.${CONTAINER_CLASS}`; + const TRIGGER_ATTR = "aria-describedby"; + const TRIGGER_SELECTOR = `[${TRIGGER_ATTR}]`; + const CONTENT_ATTR = "role=tooltip"; + const CONTENT_SELECTOR = `[${CONTENT_ATTR}]`; + + // This query selector is not just creating a List, + // it's also checking to ensure all 3 elements are present + // (container, trigger, content) and in the correct order + // (trigger immediately followed by content) + const tooltipContentList = document.querySelectorAll( + `${CONTAINER_SELECTOR} > ${TRIGGER_SELECTOR} + ${CONTENT_SELECTOR}` + ); + + function closeActiveTooltips() { + const activeTooltips = document.querySelectorAll( + `${TRIGGER_SELECTOR}[aria-expanded="true"]` + ); + activeTooltips.forEach(activeTooltip => { + activeTooltip.setAttribute("aria-expanded", "false"); + }); + } + + function show(e) { + // container or trigger + let trigger = + e.target.querySelector(`${CONTAINER_SELECTOR} ${TRIGGER_SELECTOR}`) || + e.target; + let content = trigger.nextSibling; + + if (e.type === "keypress") { + if (e.charCode !== 32) { + return; + } + } + + closeActiveTooltips(); + + if ( + trigger.getBoundingClientRect().right + TOOLTIP_MAX_WIDTH > + window.innerWidth + ) { + content.classList.add("bottom-placement"); + } else { + content.classList.remove("bottom-placement"); + } + trigger.setAttribute("aria-expanded", "true"); + } + + function hide(e) { + // container or trigger + let trigger = + e.target.querySelector(`${CONTAINER_SELECTOR} ${TRIGGER_SELECTOR}`) || + e.target; + + if (e.type === "mouseleave") { + if (trigger === document.activeElement) { + return; + } + } + trigger.setAttribute("aria-expanded", "false"); + } + + function keyboardHide(e) { + if (e.key === "Escape") { + hide(e); + } + } + + [...tooltipContentList].map(contentEl => { + const controller = new AbortController(); + const { signal } = controller; + const triggerEl = contentEl.previousElementSibling; + const containerEl = triggerEl.parentElement; + + triggerEl.addEventListener("mouseenter", show, { signal }); + triggerEl.addEventListener("focus", show, { signal }); + triggerEl.addEventListener("keypress", show, { signal }); + triggerEl.addEventListener("blur", hide, { signal }); + triggerEl.addEventListener("keydown", keyboardHide, { signal }); + containerEl.addEventListener("mouseleave", hide, { signal }); + + return controller; + }); +} + +EventHandler.on(window, "load.uds.tooltips", initTooltips); + +export { initTooltips }; diff --git a/packages/unity-bootstrap-theme/src/js/unity-bootstrap.js b/packages/unity-bootstrap-theme/src/js/unity-bootstrap.js index 6b9a13e59f..c45d6aa1b9 100644 --- a/packages/unity-bootstrap-theme/src/js/unity-bootstrap.js +++ b/packages/unity-bootstrap-theme/src/js/unity-bootstrap.js @@ -13,6 +13,7 @@ import { initImageParallax } from "./image-parallax.js"; import { initModals } from "./modals.js"; import { initTabbedPanels } from "./tabbed-panels.js"; import { initFixedTable } from "./tables.js"; +import { initTooltips } from "./tooltips.js"; import { initVideo } from "./video.js"; const unityBootstrap = { @@ -30,6 +31,7 @@ const unityBootstrap = { initModals, initRankingCard, initTabbedPanels, + initTooltips, initVideo, initCardBodies, }; diff --git a/packages/unity-bootstrap-theme/src/scss/extends/_tooltips.scss b/packages/unity-bootstrap-theme/src/scss/extends/_tooltips.scss index 4828119056..1b8dc0d27d 100644 --- a/packages/unity-bootstrap-theme/src/scss/extends/_tooltips.scss +++ b/packages/unity-bootstrap-theme/src/scss/extends/_tooltips.scss @@ -1,32 +1,43 @@ -@mixin focusState { - + div[role='tooltip'].uds-tooltip-description { - visibility: visible; - } - .fa-circle { - color: $uds-color-font-light-info; - } -} - .uds-tooltip-container { + /* this value must stay in sync, found in files: */ + /* packages/unity-bootstrap-theme/src/scss/extends/_tooltips.scss */ + /* packages/unity-bootstrap-theme/src/js/tooltips.js */ + --tooltip-max-width: 288px; + + --tooltip-offset: .5rem; display: inline-block; position: relative; + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: calc(-1 * var(--tooltip-offset)); + bottom: calc(-1 * var(--tooltip-offset)); + } + [aria-describedby] { + & { + position: relative; + } + + [role="tooltip"] { - visibility: hidden; + display: none; + z-index: 2; } } - [aria-describedby]:focus, - [aria-describedby]:hover { + [aria-describedby][aria-expanded='true'] { + [role="tooltip"] { visibility: visible; + display: block; } } } button.uds-tooltip { - background: none; + background-color: transparent; color: inherit; border: none; padding: 0; @@ -50,12 +61,9 @@ button.uds-tooltip { vertical-align: middle; } - &:focus { - @include focusState(); - } - @include media-breakpoint-up(sm) { - &:hover { - @include focusState(); + &[aria-expanded='true'] { + .fa-circle { + color: $uds-color-font-light-info; } } } @@ -95,16 +103,23 @@ div[role='tooltip'].uds-tooltip-description { color: $asu-gray-7; font: normal normal normal $uds-size-spacing-2 Arial; line-height: $uds-size-spacing-3; - margin: 0px 5px; - max-width: 353px; - min-width: 300px; + max-width: var(--tooltip-max-width); + min-width: min(100vw, var(--min-width)); + width: -webkit-max-content; padding: $uds-size-spacing-4; position: absolute; - left: 40px; + left: calc(100% + var(--tooltip-offset)); top: 0; - visibility: hidden; + justify-self: start; + align-self: end; z-index: 1; + &.bottom-placement { + left: unset; + top: calc(100% + var(--tooltip-offset)); + right: 0; + } + & > span.uds-tooltip-heading { color: $asu-gray-7; display: block; diff --git a/packages/unity-react-core/.storybook/decorators.tsx b/packages/unity-react-core/.storybook/decorators.tsx index 73b39dd149..e7c9fb038a 100644 --- a/packages/unity-react-core/.storybook/decorators.tsx +++ b/packages/unity-react-core/.storybook/decorators.tsx @@ -55,6 +55,9 @@ export const withContainer: Decorator = ( // custom events created by eventSpy.js to allow storybook to dispatch load events after the page is loaded document.dispatchEvent(new Event("sb_DOMContentLoaded")); window.dispatchEvent(new Event('sb_load')); + } else { + window.dispatchEvent(new Event("DOMContentLoaded")); + window.dispatchEvent(new Event("load")); } emit("HTML/CodeUpdated", { code: root.current.innerHTML }); diff --git a/packages/unity-react-core/src/components/Tooltip/Tooltip.stories.tsx b/packages/unity-react-core/src/components/Tooltip/Tooltip.stories.tsx index a856dfe88e..49f0b487b7 100644 --- a/packages/unity-react-core/src/components/Tooltip/Tooltip.stories.tsx +++ b/packages/unity-react-core/src/components/Tooltip/Tooltip.stories.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Tooltip } from "./Tooltip"; -import { ButtonIconOnly } from "../ButtonIconOnly/ButtonIconOnly"; +import { Image } from "../Image/Image"; +import { img01 } from "@asu/shared"; /** * TODO * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tooltip_role @@ -10,38 +11,68 @@ import { ButtonIconOnly } from "../ButtonIconOnly/ButtonIconOnly"; * * probably limit the triggers to something with a visual inidicator (like button or link) */ + +const defaultProps = { + title: "Header", + content: "Content goes here, this is a tooltip. It can be long or short.", +}; export default { title: "Components/Tooltip", component: Tooltip, + decorators: [ + story => ( + <> +