🎉 Create tooltip DS component (#6340)

*  Add new tooltip DS component

* 🎉 Add delay

* 🎉 Update docs and stories

* 🎉 Add configurable delay

* ♻️ Fix comments

* ♻️ Fix comments
This commit is contained in:
Eva Marco 2025-05-06 17:15:22 +02:00 committed by GitHub
parent c45187eedd
commit e2918f4148
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 628 additions and 2 deletions

View file

@ -18,6 +18,7 @@ const preview = {
decorators: decorators,
parameters: {
controls: {
disableSaveFromUI: true,
matchers: {
color: /(background|color)$/i,
date: /Date$/i,

View file

@ -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*

View file

@ -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);

View file

@ -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"}]]]))

View file

@ -0,0 +1,74 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as Tooltip from "./tooltip.stories";
<Meta title= "Tooltip" />
# Tooltip
A tooltip is a floating text area that provides helpful or contextual information on hover and focus.
<Canvas of={Tooltip.Default} />
## 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 isnt 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.

View file

@ -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;
}

View file

@ -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: (
<button popovertarget="popover-example">Hover this element</button>
),
id: "popover-example",
tooltipContent: "This is the tooltip content",
delay: 300,
},
render: ({ children, ...args }) => (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100%",
height: "100%",
}}
>
<Tooltip {...args}>{children}</Tooltip>
</div>
),
};
export const Default = {};
export const Corners = {
render: ({}) => (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gridTemplateRows: "repeat(3, 1fr)",
gap: "1rem",
width: "100%",
height: "100%",
}}
>
<Tooltip
id="popover-example10"
tooltipContent="This is the tooltip content, and must be shown in two lines."
style={{
placeSelf: "start start",
width: "fit-content",
height: "fit-content",
}}
>
<button popovertarget="popover-example10">Hover here</button>
</Tooltip>
<Tooltip
id="popover-example2"
tooltipContent="This is the tooltip content, and must be shown in two lines."
style={{
alignSelf: "start",
justifySelf: "center",
width: "fit-content",
height: "fit-content",
}}
>
<button
popovertarget="popover-example2"
style={{
alignSelf: "start",
justifySelf: "center",
width: "fit-content",
}}
>
Hover here
</button>
</Tooltip>
<Tooltip
id="popover-example3"
tooltipContent="This is the tooltip content, and must be shown in two lines."
style={{
alignSelf: "start",
justifySelf: "end",
width: "fit-content",
height: "fit-content",
}}
>
<button popovertarget="popover-example3">Hover here</button>
</Tooltip>
<Tooltip
id="popover-example4"
tooltipContent="This is the tooltip content, and must be shown in two lines."
style={{
alignSelf: "center",
justifySelf: "start",
width: "fit-content",
height: "fit-content",
}}
>
<button popovertarget="popover-example4">Hover here</button>
</Tooltip>
<Tooltip
id="popover-example5"
tooltipContent="This is the tooltip content, and must be shown in two lines."
style={{
alignSelf: "center",
justifySelf: "center",
width: "fit-content",
height: "fit-content",
}}
>
<button popovertarget="popover-example5">Hover here</button>
</Tooltip>
<Tooltip
id="popover-example6"
tooltipContent="This is the tooltip content, and must be shown in two lines."
style={{
alignSelf: "center",
justifySelf: "end",
width: "fit-content",
height: "fit-content",
}}
>
<button popovertarget="popover-example6">Hover here</button>
</Tooltip>
<Tooltip
id="popover-example7"
tooltipContent="This is the tooltip content, and must be shown in two lines."
style={{
alignSelf: "end",
justifySelf: "start",
width: "fit-content",
height: "fit-content",
}}
>
<button popovertarget="popover-example7">Hover here</button>
</Tooltip>
<Tooltip
id="popover-example8"
tooltipContent="This is the tooltip content, and must be shown in two lines."
style={{
alignSelf: "end",
justifySelf: "center",
width: "fit-content",
height: "fit-content",
}}
>
<button popovertarget="popover-example8">Hover here</button>
</Tooltip>
<Tooltip
id="popover-example9"
tooltipContent="This is the tooltip content, and must be shown in two lines."
style={{
alignSelf: "end",
justifySelf: "end",
width: "fit-content",
height: "fit-content",
}}
>
<button popovertarget="popover-example9">Hover here</button>
</Tooltip>
</div>
),
};

View file

@ -240,8 +240,6 @@
(dom/stop-propagation event)
(when (and can-edit? (not (seq errors)) on-click)
(on-click event))))
;; FIXME: missing deps
on-hover
(mf/use-fn
(mf/deps selected-shapes is-viewer? active-theme-tokens token half-applied? no-valid-value ref-not-in-active-set)
@ -266,8 +264,11 @@
:token-pill-invalid-applied-viewer (and is-viewer?
(and full-applied? errors?)))
:type "button"
:on-focus on-hover
:on-click on-click
:on-mouse-enter on-hover
:on-context-menu on-context-menu}
(cond
errors?