diff --git a/frontend/.storybook/preview.js b/frontend/.storybook/preview.js index 943e0133d..4e73b9e0c 100644 --- a/frontend/.storybook/preview.js +++ b/frontend/.storybook/preview.js @@ -18,6 +18,7 @@ const preview = { decorators: decorators, parameters: { controls: { + disableSaveFromUI: true, matchers: { color: /(background|color)$/i, date: /Date$/i, diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs index f65c31ce3..b32891fcd 100644 --- a/frontend/src/app/main/ui/ds.cljs +++ b/frontend/src/app/main/ui/ds.cljs @@ -32,6 +32,7 @@ [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.ds.product.user-milestone :refer [user-milestone*]] [app.main.ui.ds.storybook :as sb] + [app.main.ui.ds.tooltip.tooltip :refer [tooltip*]] [app.main.ui.ds.utilities.date :refer [date*]] [app.main.ui.ds.utilities.swatch :refer [swatch*]] [app.util.i18n :as i18n] @@ -57,6 +58,7 @@ :Text text* :TabSwitcher tab-switcher* :Toast toast* + :Tooltip tooltip* :ContextNotification context-notification* :NotificationPill notification-pill* :Actionable actionable* diff --git a/frontend/src/app/main/ui/ds/_sizes.scss b/frontend/src/app/main/ui/ds/_sizes.scss index 02687aa0c..dc3af9260 100644 --- a/frontend/src/app/main/ui/ds/_sizes.scss +++ b/frontend/src/app/main/ui/ds/_sizes.scss @@ -18,6 +18,7 @@ $sz-200: px2rem(200); $sz-224: px2rem(224); $sz-252: px2rem(252); $sz-284: px2rem(284); +$sz-352: px2rem(352); $sz-400: px2rem(400); $sz-480: px2rem(480); $sz-964: px2rem(964); diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs new file mode 100644 index 000000000..ec66ba979 --- /dev/null +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -0,0 +1,202 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.ds.tooltip.tooltip + (:require-macros + [app.common.data.macros :as dm] + [app.main.style :as stl]) + (:require + [app.common.data :as d] + [app.util.dom :as dom] + [app.util.keyboard :as kbd] + [app.util.timers :as ts] + [rumext.v2 :as mf])) + +(defn- calculate-tooltip-rect [tooltip trigger-rect placement offset] + (let [{trigger-top :top + trigger-left :left + trigger-right :right + trigger-bottom :bottom + trigger-width :width + trigger-height :height} trigger-rect + + {tooltip-width :width + tooltip-height :height} (dom/get-bounding-rect tooltip) + + offset (d/nilv offset 2) + overlay-offset 32] + + (case placement + "bottom" + {:top (+ trigger-bottom offset) + :left (- (+ trigger-left (/ trigger-width 2)) (/ tooltip-width 2)) + :right (+ (- (+ trigger-left (/ trigger-width 2)) (/ tooltip-width 2)) tooltip-width) + :bottom (+ (- trigger-bottom offset) tooltip-height) + :width tooltip-width + :height tooltip-height} + + "left" + {:top (- (+ trigger-top (/ trigger-height 2) 8) (/ tooltip-height 2)) + :left (- trigger-left tooltip-width 12) + :right (+ (- trigger-left tooltip-width 12) tooltip-width) + :bottom (+ (- (+ trigger-top (/ trigger-height 2) 12) (/ tooltip-height 2)) tooltip-height) + :width tooltip-width + :height tooltip-height} + + "right" + {:top (- (+ trigger-top (/ trigger-height 2) 4) (/ tooltip-height 2)) + :left (+ trigger-right offset 4) + :right (+ trigger-right offset tooltip-width 4) + :bottom (+ (- (+ trigger-top (/ trigger-height 2) 4) (/ tooltip-height 2)) tooltip-height) + :width tooltip-width + :height tooltip-height} + + "bottom-right" + {:top (+ trigger-bottom offset) + :left (- trigger-right overlay-offset) + :right (+ (- trigger-right overlay-offset) tooltip-width) + :bottom (+ (- trigger-bottom offset) tooltip-height) + :width tooltip-width + :height tooltip-height} + + "bottom-left" + {:top (+ trigger-bottom offset) + :left (+ (- trigger-left tooltip-width) overlay-offset) + :right (+ (- trigger-left tooltip-width) overlay-offset tooltip-width) + :bottom (+ (- trigger-bottom offset) tooltip-height) + :width tooltip-width + :height tooltip-height} + + "top-right" + {:top (- trigger-top offset tooltip-height) + :left (- trigger-right overlay-offset) + :right (+ (- trigger-right overlay-offset) tooltip-width) + :bottom (+ (- trigger-top offset tooltip-height) tooltip-height) + :width tooltip-width + :height tooltip-height} + + "top-left" + {:top (- trigger-top offset tooltip-height) + :left (+ (- trigger-left tooltip-width) overlay-offset) + :right (+ (- trigger-left tooltip-width) overlay-offset tooltip-width) + :bottom (+ (- trigger-top offset tooltip-height) tooltip-height) + :width tooltip-width + :height tooltip-height} + + {:top (- trigger-top offset tooltip-height) + :left (- (+ trigger-left (/ trigger-width 2)) (/ tooltip-width 2)) + :right (+ (- (+ trigger-left (/ trigger-width 2)) (/ tooltip-width 2)) tooltip-width) + :bottom (+ (- trigger-top offset tooltip-height) tooltip-height) + :width tooltip-width + :height tooltip-height}))) + +(defn- get-fallback-order [placement] + (case placement + "top" ["top" "right" "bottom" "left" "top-right" "bottom-right" "bottom-left" "top-left"] + "bottom" ["bottom" "left" "top" "right" "bottom-right" "bottom-left" "top-left" "top-right"] + "left" ["left" "top" "right" "bottom" "top-left" "top-right" "bottom-right" "bottom-left"] + "right" ["right" "bottom" "left" "top" "bottom-left" "top-left" "top-right" "bottom-right"] + "top-right" ["top-right" "right" "bottom" "left" "top" "bottom-right" "bottom-left" "top-left"] + "bottom-right" ["bottom-right" "bottom" "left" "top" "right" "bottom-left" "top-left" "top-right"] + "bottom-left" ["bottom-left" "left" "top" "right" "bottom" "top-left" "top-right" "bottom-right"] + "top-left" ["top-left" "top" "right" "bottom" "left" "bottom-left" "top-right" "bottom-right"])) + +(def ^:private schema:tooltip + [:map + [:class {:optional true} :string] + [:id :string] + [:offset {:optional true} :int] + [:delay {:optional true} :int] + [:placement {:optional true} + [:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]]]) + +(mf/defc tooltip* + {::mf/props :obj + ::mf/schema schema:tooltip} + [{:keys [class id children tooltip-content placement offset delay] :rest props}] + (let [placement* (mf/use-state #(d/nilv placement "top")) + placement (deref placement*) + delay (d/nilv delay 300) + + schedule-ref (mf/use-ref nil) + + position-tooltip + (fn [^js tooltip trigger-rect] + (let [all-placements (get-fallback-order placement)] + (.showPopover ^js tooltip) + (loop [[current-placement & remaining-placements] all-placements] + (when current-placement + (reset! placement* current-placement) + (let [tooltip-rect (calculate-tooltip-rect tooltip trigger-rect current-placement offset)] + (if (dom/is-bounding-rect-outside? tooltip-rect) + (recur remaining-placements) + (do (dom/set-css-property! tooltip "display" "grid") + (dom/set-css-property! tooltip "top" (dm/str (:top tooltip-rect) "px")) + (dom/set-css-property! tooltip "left" (dm/str (:left tooltip-rect) "px"))))))))) + + on-show + (mf/use-fn + (mf/deps id placement) + (fn [event] + (when-let [schedule (mf/ref-val schedule-ref)] + (ts/dispose! schedule) + (mf/set-ref-val! schedule-ref nil)) + (when-let [tooltip (dom/get-element id)] + (let [trigger-rect (->> (dom/get-current-target event) + (dom/get-bounding-rect))] + (mf/set-ref-val! + schedule-ref + (ts/schedule + delay + #(position-tooltip tooltip trigger-rect))))))) + + on-hide + (mf/use-fn + (mf/deps id) + (fn [] (when-let [tooltip (dom/get-element id)] + (when-let [schedule (mf/ref-val schedule-ref)] + (ts/dispose! schedule) + (mf/set-ref-val! schedule-ref nil)) + (dom/set-css-property! tooltip "display" "none") + (.hidePopover ^js tooltip)))) + + handle-key-down + (mf/use-fn + (mf/deps on-hide) + (fn [event] + (when (kbd/esc? event) + (on-hide)))) + + class (d/append-class class (stl/css-case + :tooltip true + :tooltip-top (= placement "top") + :tooltip-bottom (= placement "bottom") + :tooltip-left (= placement "left") + :tooltip-right (= placement "right") + :tooltip-top-right (= placement "top-right") + :tooltip-bottom-right (= placement "bottom-right") + :tooltip-bottom-left (= placement "bottom-left") + :tooltip-top-left (= placement "top-left"))) + + props (mf/spread-props props {:on-mouse-enter on-show + :on-mouse-leave on-hide + :on-focus on-show + :on-blur on-hide + :on-key-down handle-key-down + :class (stl/css :tooltip-trigger) + :aria-describedby id})] + [:> :div props + children + [:div {:class class + :id id + :popover "auto" + :role "tooltip"} + [:div {:class (stl/css :tooltip-content)} + (if (fn? tooltip-content) + (tooltip-content) + tooltip-content)] + [:div {:class (stl/css :tooltip-arrow) + :id "tooltip-arrow"}]]])) \ No newline at end of file diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.mdx b/frontend/src/app/main/ui/ds/tooltip/tooltip.mdx new file mode 100644 index 000000000..f2c4b55bf --- /dev/null +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.mdx @@ -0,0 +1,74 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import * as Tooltip from "./tooltip.stories"; + + + + +# Tooltip + +A tooltip is a floating text area that provides helpful or contextual information on hover and focus. + + + +## Technical notes + +The tooltip component accepts a `placement` prop that defines the preferred position of the tooltip relative to its trigger. Possible values are: + +``` +"top" | "bottom" | "left" | "right" | "top-right" | "bottom-right" | "bottom-left" | "top-left" +``` +The component will attempt to use the specified `placement` but may fall back to others if there isn’t enough space. +If `placement` is not provided, the tooltip will default to "top". + +```clj +(ns app.main.ui.foo + (:require + [app.main.ui.ds.tooltip.tooltip :as tlp]) + + [:> tlp/tooltip* {:id "test-tooltip" + :placement "bottom" + :tooltip-content "Tooltip content"} + [:div "Trigger component"]]) +``` + +Tooltip content can include HTML elements: +```clj +(ns app.main.ui.foo + (:require + [app.main.ui.ds.tooltip.tooltip :as tlp]) + + [:> tlp/tooltip* {:id "test-tooltip" + :placement "bottom" + :tooltip-content (mf/html + [:span "Tooltip content"])} + [:div "Trigger component"]]) +``` + +### Accessibility + +The tooltip requires an `id` prop, which is used to associate the trigger with the tooltip content via the `aria-describedby` attribute. +This ensures screen readers can announce the tooltip content when the trigger is focused. + +Tooltips should not contain interactive elements like buttons, links, or form inputs, since users cannot interact with them via mouse or keyboard. + + +## Usage guidelines (design) +### Where to use + +Use a tooltip as a way for users to see more information before they select an element, go to a new page, or trigger an action on the page. + +### When to use + +- When users need help making a decision +- When you need to provide more information for icons or icon buttons without labels +- When you need to define new or unfamiliar UI elements that are not described directly in the user interface + +### Interaction / Behaviour + +Tooltips are triggered on hover or keyboard focus + +They automatically disappear on mouse leave or blur + +Tooltip positioning is dynamically adjusted to stay within the viewport + +A small delay is applied before showing the tooltip to avoid accidental triggers, the duration of this delay is configurable by default it is 300ms. diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.scss b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss new file mode 100644 index 000000000..e29d8a922 --- /dev/null +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss @@ -0,0 +1,152 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "../_sizes.scss" as *; +@use "../_borders.scss" as *; +@use "../typography.scss" as *; + +// This variable refers to the legs of +// an equilateral triangle with base 16px +// and height 8px to design of the arrow. + +$arrow-size: 11px; + +.tooltip { + position: absolute; + max-width: $sz-352; + background-color: transparent; + overflow: hidden; + width: fit-content; +} + +.tooltip-arrow { + background-color: var(--color-background-primary); + border-radius: var(--sp-xs); + width: $arrow-size; + height: $arrow-size; + grid-area: arrow; +} + +.tooltip-top { + grid-template-areas: + "content" + "arrow"; +} + +.tooltip-top .tooltip-arrow { + justify-self: center; + border-radius: 0 0 var(--sp-xs) 0; + transform: rotate(45deg) translateX(calc(-1 * var(--sp-s))); + border-bottom: $b-1 solid var(--color-accent-primary-muted); + border-right: $b-1 solid var(--color-accent-primary-muted); +} + +.tooltip-bottom { + grid-template-areas: + "arrow" + "content"; +} +.tooltip-bottom .tooltip-arrow { + justify-self: center; + border-radius: var(--sp-xs) 0; + transform: rotate(45deg) translateX(var(--sp-s)); + border-top: $b-1 solid var(--color-accent-primary-muted); + border-left: $b-1 solid var(--color-accent-primary-muted); +} + +.tooltip-left { + grid-template-areas: "content arrow "; +} + +.tooltip-left .tooltip-arrow { + align-self: center; + border-radius: 0 var(--sp-xs); + transform: rotate(45deg) translateX(calc(-1 * var(--sp-xs))) translateY(var(--sp-xs)); + border-top: $b-1 solid var(--color-accent-primary-muted); + border-right: $b-1 solid var(--color-accent-primary-muted); +} + +.tooltip-right { + grid-template-areas: " arrow content"; +} + +.tooltip-right .tooltip-arrow { + align-self: center; + border-radius: 0 var(--sp-xs); + transform: rotate(45deg) translateX(var(--sp-xs)) translateY(calc(-1 * var(--sp-xs))); + border-bottom: $b-1 solid var(--color-accent-primary-muted); + border-left: $b-1 solid var(--color-accent-primary-muted); +} + +.tooltip-top-right { + grid-template-areas: + "content" + "arrow"; +} + +.tooltip-top-right .tooltip-arrow { + margin: 0 var(--sp-l); + border-radius: var(--sp-xs) 0; + transform: rotate(45deg) translateX(calc(-1 * var(--sp-s))); + border-bottom: $b-1 solid var(--color-accent-primary-muted); + border-right: $b-1 solid var(--color-accent-primary-muted); +} + +.tooltip-bottom-right { + grid-template-areas: + "arrow" + "content"; +} + +.tooltip-bottom-right .tooltip-arrow { + margin: 0px var(--sp-s); + transform: rotate(45deg) translateX(var(--sp-s)); + border-radius: var(--sp-xs) 0; + border-top: $b-1 solid var(--color-accent-primary-muted); + border-left: $b-1 solid var(--color-accent-primary-muted); +} + +.tooltip-bottom-left { + grid-template-areas: + "arrow" + "content"; +} +.tooltip-bottom-left .tooltip-arrow { + justify-self: end; + margin: 0 var(--sp-s); + transform: rotate(45deg) translateY(var(--sp-s)); + border-radius: var(--sp-xs) 0; + border-top: $b-1 solid var(--color-accent-primary-muted); + border-left: $b-1 solid var(--color-accent-primary-muted); +} + +.tooltip-top-left { + grid-template-areas: + "content" + "arrow"; +} +.tooltip-top-left .tooltip-arrow { + margin: 0 var(--sp-s); + justify-self: end; + border-radius: var(--sp-xs) 0; + transform: rotate(45deg) translateX(calc(-1 * var(--sp-s))); + border-bottom: $b-1 solid var(--color-accent-primary-muted); + border-right: $b-1 solid var(--color-accent-primary-muted); +} + +.tooltip-content { + background-color: var(--color-background-primary); + color: var(--color-foreground-secondary); + border-radius: var(--sp-xs); + border: $b-1 solid var(--color-accent-primary-muted); + padding: var(--sp-s) var(--sp-m); + grid-area: content; +} + +.tooltip-trigger { + width: fit-content; + height: fit-content; +} diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.stories.jsx b/frontend/src/app/main/ui/ds/tooltip/tooltip.stories.jsx new file mode 100644 index 000000000..985ad1989 --- /dev/null +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.stories.jsx @@ -0,0 +1,193 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import * as React from "react"; +import Components from "@target/components"; + +const { Tooltip } = Components; + +export default { + title: "Tooltip", + component: Tooltip, + argTypes: { + placement: { + description: "Position of the tooltip", + control: { type: "select" }, + options: [ + "top", + "bottom", + "left", + "right", + "top-left", + "top-right", + "bottom-left", + "bottom-right", + ], + }, + delay: { + control: { type: "number" }, + description: "Delay in milliseconds before showing the tooltip", + }, + }, + parameters: { + controls: { exclude: ["children", "id"] }, + }, + args: { + children: ( + + ), + id: "popover-example", + tooltipContent: "This is the tooltip content", + delay: 300, + }, + render: ({ children, ...args }) => ( +