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..66179066a4 --- /dev/null +++ b/packages/unity-bootstrap-theme/src/js/tooltips.js @@ -0,0 +1,92 @@ +import { EventHandler } from "./bootstrap-helper"; + +function initTooltips() { + /* if this value changes, update tooltips.js file */ + const TOOLTIP_MAX_WIDTH = 288; + + // 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( + '.uds-tooltip-container > .uds-tooltip + [role="tooltip"]' + ); + + function show(e) { + // container or trigger + let trigger = e.target.querySelector(".uds-tooltip") || e.target; + let content = trigger.nextSibling; + + if (e.type === "keypress") { + if (e.charCode !== 32) { + return; + } + } + // content.getBoundingClientRect().width + + // trigger.getBoundingClientRect().right > + // window.innerWidth + + 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(".uds-tooltip") || e.target; + + if (e.type === "mouseleave") { + if (trigger === document.activeElement) { + return; + } + } + trigger.setAttribute("aria-expanded", "false"); + } + + function kbHide(e) { + if (e.key === "Escape") { + hide(e); + } + } + + window.t2 = [...tooltipContentList].map(contentEl => { + const controller = new AbortController(); + const { signal } = controller; + const triggerEl = contentEl.previousElementSibling; + const containerEl = triggerEl.parentElement; + + // const showEvents = [ + // ["mouseenter"], + // ["focus"], + // ["keypress", e => e.charCode === 32 || e.key === "Enter"], + // ]; + // const hideEvents = [ + // ["mouseleave", e => e.target !== document.activeElement], + // ["blur"], + // ["keydown", e => e.key === "Escape"], + // ]; + + // containerEl.attachShadow({ mode: "open" }); + // containerEl.appendChild(document.createElement("slot")); + // shadowRoot.appendChild(contentEl.cloneNode(true)); + + triggerEl.addEventListener("mouseenter", show, { signal }); + triggerEl.addEventListener("focus", show, { signal }); + triggerEl.addEventListener("keypress", show, { signal }); + triggerEl.addEventListener("blur", hide, { signal }); + triggerEl.addEventListener("keydown", kbHide, { 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/tooltips2.js b/packages/unity-bootstrap-theme/src/js/tooltips2.js new file mode 100644 index 0000000000..c8279a0c1e --- /dev/null +++ b/packages/unity-bootstrap-theme/src/js/tooltips2.js @@ -0,0 +1,163 @@ +import { EventHandler } from "./bootstrap-helper"; + +// WeakMap for better garbage collection +const tooltipInstances = new WeakMap(); +const t2 = []; +let isInitialized = false; +window.tooltipInstances = tooltipInstances; // Expose for debugging +window.t2 = t2; // Expose for debugging + +function getOrCreateTooltipInstance(triggerEl, contentEl) { + if (!tooltipInstances.has(triggerEl)) { + const popperInstance = new bootstrap.Popover(triggerEl, contentEl, { + placement: "right", + modifiers: [ + { + name: "flip", + options: { + fallbackPlacements: ["bottom", "left"], + }, + }, + { + name: "offset", + options: { + offset: [0, 8], + }, + }, + ], + }); + console.log("Creating tooltip instance for:", triggerEl, popperInstance); + tooltipInstances.set(triggerEl, popperInstance); + t2.push(popperInstance); + } + return tooltipInstances.get(triggerEl); +} + +function showTooltip(triggerEl) { + const contentEl = triggerEl.nextElementSibling; + if ( + !contentEl || + !contentEl.hasAttribute("role") || + contentEl.getAttribute("role") !== "tooltip" + ) { + return; + } + + const popperInstance = getOrCreateTooltipInstance(triggerEl); + triggerEl.setAttribute("aria-expanded", "true"); + console.log(popperInstance); + popperInstance.setOptions && + popperInstance.setOptions(options => ({ + ...options, + modifiers: [ + ...options.modifiers, + { name: "eventListeners", enabled: true }, + ], + })); + + // Update position after showing + popperInstance.update(); +} + +function hideTooltip(triggerEl) { + triggerEl.setAttribute("aria-expanded", "false"); + + // Disable the event listeners + if (!getOrCreateTooltipInstance(triggerEl).setOptions) return; + getOrCreateTooltipInstance(triggerEl).setOptions(options => ({ + ...options, + modifiers: [ + ...options.modifiers, + { name: "eventListeners", enabled: false }, + ], + })); +} + +function tryGetTriggerElement(element) { + return element.closest ? element.closest(".uds-tooltip") : element; +} + +function isTooltipTrigger(element) { + return ( + element?.classList?.contains("uds-tooltip") && + element.nextElementSibling?.getAttribute("role") === "tooltip" + ); +} + +// Event delegation handlers +function handleShowEvent(event) { + const triggerEl = tryGetTriggerElement(event.target); + if (!isTooltipTrigger(triggerEl)) return; + + // Handle specific event conditions + if (event.type === "keypress") { + if (!(event.charCode === 32 || event.key === "Enter")) return; + } + + showTooltip(triggerEl); +} + +function handleHideEvent(event) { + const triggerEl = tryGetTriggerElement(event.target); + if (!isTooltipTrigger(triggerEl)) return; + + // Handle specific event conditions + if (event.type === "mouseleave") { + if (triggerEl === document.activeElement) return; + } + + if (event.target !== triggerEl) return; + hideTooltip(triggerEl); +} + +function handleEscapeKey(event) { + if (event.key === "Escape") { + const triggerEl = tryGetTriggerElement(event.target); + if (!isTooltipTrigger(triggerEl)) return; + hideTooltip(triggerEl); + } +} + +function initTooltips() { + if (isInitialized) return; + + // Check if tooltips exist before setting up listeners + const hasTooltips = document.querySelector( + '.uds-tooltip-container .uds-tooltip + [role="tooltip"]' + ); + if (!hasTooltips) return; + + // Event delegation - single listeners for all tooltips + document.addEventListener("mouseenter", handleShowEvent, true); + document.addEventListener("focus", handleShowEvent, true); + document.addEventListener("keypress", handleShowEvent, true); + + document.addEventListener("mouseleave", handleHideEvent, true); + document.addEventListener("blur", handleHideEvent, true); + document.addEventListener("keydown", handleEscapeKey, true); + + isInitialized = true; +} + +function destroyTooltips() { + if (!isInitialized) return; + + // Remove event listeners + document.removeEventListener("mouseenter", handleShowEvent, true); + document.removeEventListener("focus", handleShowEvent, true); + document.removeEventListener("keypress", handleShowEvent, true); + document.removeEventListener("mouseleave", handleHideEvent, true); + document.removeEventListener("blur", handleHideEvent, true); + document.removeEventListener("keydown", handleEscapeKey, true); + + // Clear instances - WeakMap will handle garbage collection + if (tooltipInstances.clear) { + tooltipInstances.clear(); + } + + isInitialized = false; +} + +EventHandler.on(window, "load.uds.tooltips", initTooltips); + +export { initTooltips, destroyTooltips }; diff --git a/packages/unity-bootstrap-theme/src/js/unity-bootstrap.js b/packages/unity-bootstrap-theme/src/js/unity-bootstrap.js index 6b9a13e59f..8518454ff7 100644 --- a/packages/unity-bootstrap-theme/src/js/unity-bootstrap.js +++ b/packages/unity-bootstrap-theme/src/js/unity-bootstrap.js @@ -1,5 +1,4 @@ // import Banner from "./banner.js"; -import { initAnchorMenu } from "./anchor-menu.js"; import { initBlockquoteAnimation } from "./blockquote-animated.js"; import { initCalendar } from "./calendar.js"; import { initCardBodies } from "./card-bodies.js"; @@ -13,6 +12,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 +30,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..10d43adbcf 100644 --- a/packages/unity-bootstrap-theme/src/scss/extends/_tooltips.scss +++ b/packages/unity-bootstrap-theme/src/scss/extends/_tooltips.scss @@ -1,32 +1,41 @@ -@mixin focusState { - + div[role='tooltip'].uds-tooltip-description { - visibility: visible; - } - .fa-circle { - color: $uds-color-font-light-info; - } -} - .uds-tooltip-container { + /* if this value changes, update tooltips.js file */ + --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; + // visibility: hidden; + display: none; } } - [aria-describedby]:focus, - [aria-describedby]:hover { + [aria-describedby][aria-expanded='true'] { + [role="tooltip"] { visibility: visible; + display: block; } } } button.uds-tooltip { - background: none; + background: #f007; color: inherit; border: none; padding: 0; @@ -50,12 +59,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; } } } @@ -90,21 +96,47 @@ button.uds-tooltip-dark { } } +// div[role='tooltip'].uds-tooltip-description:before { +// content: ''; +// position: absolute; +// top: 0; +// bottom: 0; +// left: calc(-1 * var(--tooltip-offset)); +// right: 100%; +// } +// div[role='tooltip'].uds-tooltip-description:after { +// content: ''; +// position: absolute; +// top: calc(-1 * var(--tooltip-offset)); +// bottom: 100%; +// left: 0; +// right: 0; +// background-color: #f001; +// } + div[role='tooltip'].uds-tooltip-description { background: $asu-gray-1 0% 0% no-repeat padding-box; 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; + // visibility: hidden; 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/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 => ( + <> +
+ Lorem ipsum dolor sit amet, + consectetur adipiscing elit. + {story()} Sed Sed do eiusmod tempor incididunt + + ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi + ut aliquip ex ea commodo consequat. Duis aute irure dolor in + {story()} Sed Sed do eiusmod tempor incididunt + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + ut aliquip ex ea commodo consequat. Duis aute irure dolor in + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in + culpa qui officia deserunt mollit anim id est laborum. +
+ + ), + ], + render: args => , + args: { + ...defaultProps, + }, }; -const defaultProps = { - title: "Header", - content: "Content", -} +export const NoChildrenDefaultIcon = {}; -const tooltipTemplate = args => ; +export const Link = { + render: args => ( + + Tooltiptrigger + + ), +}; -export const Icon = { - render: tooltipTemplate.bind({}), - args: { - ...defaultProps, - triggerElement: , - } +export const Text = { + render: args => just a plain string, }; -export const link = { - render: args =>
This is a sentence.
, - args: { - ...defaultProps, - triggerElement: Tooltiptrigger, - } +export const JsxSpanContainingText = { + render: args => ( + + html string Tooltiptrigger + + ), }; -export const text = { - render: tooltipTemplate.bind({}), - args: { - ...defaultProps, - triggerElement: Tooltiptrigger, - } +export const ImageOnly = { + render: args => ( + + + {""} + + + ), }; diff --git a/packages/unity-react-core/src/components/Tooltip/Tooltip.tsx b/packages/unity-react-core/src/components/Tooltip/Tooltip.tsx index bdc93fcb32..9368f7fabc 100644 --- a/packages/unity-react-core/src/components/Tooltip/Tooltip.tsx +++ b/packages/unity-react-core/src/components/Tooltip/Tooltip.tsx @@ -1,4 +1,14 @@ -import React, { ReactElement, useRef, useState } from "react"; +import React, { ComponentProps, ReactElement, useId, useRef } from "react"; + +import { ButtonIconOnly } from "../ButtonIconOnly/ButtonIconOnly"; + +type TooltipTrigger = + | ReactElement< + | HTMLAnchorElement + | HTMLButtonElement + | (HTMLElement & { tabIndex?: number }) + > + | string; export interface TooltipProps { /** @@ -6,31 +16,61 @@ export interface TooltipProps { */ title?: string; /** - * Content + * Tooltip content. */ content?: string; /** - * The element where we will position the dialog beside. + * Element that triggers the tooltip. Ignored if `children` is provided. + */ + triggerElement?: TooltipTrigger; + + /** + * Element that triggers the tooltip. If provided, this will override `triggerElement`. + * If a string is provided, it will be wrapped in a span with `tabIndex={0}`. */ - triggerElement: ReactElement; + children?: TooltipTrigger | string; } -let toolTipIdCounter = 0; +/** + * Default tooltip icon button used if no triggerElement or children are provided. + */ +const TooltipIcon: React.FC> = props => ( + +); export const Tooltip: React.FC = ({ title, content, triggerElement, + children, }) => { - const [toolTipId] = useState(`tooltip-${toolTipIdCounter++}`); + const toolTipId = "tooltip-" + useId(); const ref = useRef(null); + let domTrigger: TooltipTrigger = children || triggerElement || ( + + ); + + if (typeof domTrigger === "string") { + domTrigger = ( + + {domTrigger} + + ); + } + return ( - {React.cloneElement(triggerElement, { + {React.cloneElement(domTrigger as ReactElement, { ref, "aria-describedby": toolTipId, - "tabindex": 0, + "tabIndex": 0, })}