Merge pull request #6667 from penpot/niwinz-develop-enhacements-2

 Add internal changes to tooltip* ds component
This commit is contained in:
Andrey Antukh 2025-06-16 15:03:42 +02:00 committed by GitHub
commit 2af1feafb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 204 additions and 116 deletions

View file

@ -36,4 +36,4 @@
(on-ref node)))})] (on-ref node)))})]
[:> "button" props [:> "button" props
(when icon [:> icon* {:icon-id icon :size "m"}]) (when icon [:> icon* {:icon-id icon :size "m"}])
[:span {:class (stl/css :label-wrapper)} children]])) [:span {:class (stl/css :label-wrapper)} children]]))

View file

@ -6,11 +6,11 @@
(ns app.main.ui.ds.buttons.icon-button (ns app.main.ui.ds.buttons.icon-button
(:require-macros (:require-macros
[app.common.data.macros :as dm]
[app.main.style :as stl]) [app.main.style :as stl])
(:require (:require
[app.common.data :as d]
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list]] [app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list]]
[app.main.ui.ds.tooltip.tooltip :refer [tooltip*]] [app.main.ui.ds.tooltip :refer [tooltip*]]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(def ^:private schema:icon-button (def ^:private schema:icon-button
@ -24,18 +24,30 @@
[:maybe [:enum "primary" "secondary" "ghost" "destructive" "action"]]]]) [:maybe [:enum "primary" "secondary" "ghost" "destructive" "action"]]]])
(mf/defc icon-button* (mf/defc icon-button*
{::mf/schema schema:icon-button} {::mf/schema schema:icon-button
::mf/memo true}
[{:keys [class icon icon-class variant aria-label children] :rest props}] [{:keys [class icon icon-class variant aria-label children] :rest props}]
(let [variant (or variant "primary") (let [variant
tooltip-id (mf/use-id) (d/nilv variant "primary")
class (dm/str class " " (stl/css-case :icon-button true
:icon-button-primary (= variant "primary") tooltip-id
:icon-button-secondary (= variant "secondary") (mf/use-id)
:icon-button-ghost (= variant "ghost")
:icon-button-action (= variant "action") button-class
:icon-button-destructive (= variant "destructive"))) (stl/css-case :icon-button true
props (mf/spread-props props {:class class :icon-button-primary (identical? variant "primary")
:aria-labelledby tooltip-id})] :icon-button-secondary (identical? variant "secondary")
[:> tooltip* {:tooltip-content aria-label :icon-button-ghost (identical? variant "ghost")
:icon-button-action (identical? variant "action")
:icon-button-destructive (identical? variant "destructive"))
props
(mf/spread-props props
{:class [class button-class]
:aria-labelledby tooltip-id})]
[:> tooltip* {:content aria-label
:id tooltip-id} :id tooltip-id}
[:> "button" props [:> icon* {:icon-id icon :aria-hidden true :class icon-class}] children]])) [:> :button props
[:> icon* {:icon-id icon :aria-hidden true :class icon-class}]
children]]))

View file

@ -0,0 +1,12 @@
;; 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
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.tooltip.tooltip :as impl]))
(dm/export impl/tooltip*)

View file

@ -15,21 +15,48 @@
[app.util.timers :as ts] [app.util.timers :as ts]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(defn- calculate-tooltip-rect [tooltip trigger-rect placement offset] (def ^:private ^:const arrow-height 12)
(def ^:private ^:const half-arrow-height (/ arrow-height 2))
(def ^:private ^:const overlay-offset 32)
(defn- clear-schedule
[ref]
(when-let [schedule (mf/ref-val ref)]
(ts/dispose! schedule)
(mf/set-ref-val! ref nil)))
(defn- add-schedule
[ref delay f]
(mf/set-ref-val! ref (ts/schedule delay f)))
(defn- show-popover
[node]
(when (.-isConnected ^js node)
(.showPopover ^js node)))
(defn- hide-popover
[node]
(dom/unset-css-property! node "display")
(.hidePopover ^js node))
(defn- calculate-placement-bounding-rect
"Given a placement, calcultates the bounding rect for it taking in
account provided tooltip bounding rect and the origin bounding
rect."
[placement tooltip-brect origin-brect offset]
(let [{trigger-top :top (let [{trigger-top :top
trigger-left :left trigger-left :left
trigger-right :right trigger-right :right
trigger-bottom :bottom trigger-bottom :bottom
trigger-width :width trigger-width :width
trigger-height :height} trigger-rect trigger-height :height}
origin-brect
{tooltip-width :width {tooltip-width :width
tooltip-height :height} (dom/get-bounding-rect tooltip) tooltip-height :height}
tooltip-brect
offset (d/nilv offset 2) offset (d/nilv offset 2)]
arrow-height 12
half-arrow-height (/ arrow-height 2)
overlay-offset 32]
(case placement (case placement
"bottom" "bottom"
@ -95,7 +122,10 @@
:width tooltip-width :width tooltip-width
:height tooltip-height}))) :height tooltip-height})))
(defn- get-fallback-order [placement] (defn- get-fallback-order
"Get a vector of placement followed with ordered fallback pacements
for the specified placement"
[placement]
(case placement (case placement
"top" ["top" "right" "bottom" "left" "top-right" "bottom-right" "bottom-left" "top-left"] "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"] "bottom" ["bottom" "left" "top" "right" "bottom-right" "bottom-left" "top-left" "top-right"]
@ -106,65 +136,93 @@
"bottom-left" ["bottom-left" "left" "top" "right" "bottom" "top-left" "top-right" "bottom-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"])) "top-left" ["top-left" "top" "right" "bottom" "left" "bottom-left" "top-right" "bottom-right"]))
(defn- find-matching-placement
"Algorithm for find a correct placement and placement-brect for the
provided placement, if the current placement does not matches, it
uses the predefined fallbacks. Returns an array of matched placement
and its bounding rect."
[placement tooltip-brect origin-brect window-size offset]
(loop [placements (seq (get-fallback-order placement))]
(when-let [placement (first placements)]
(let [placement-brect (calculate-placement-bounding-rect placement tooltip-brect origin-brect offset)]
(if (dom/is-bounding-rect-outside? placement-brect window-size)
(recur (rest placements))
#js [placement placement-brect])))))
(defn- update-tooltip-position
"Update the tooltip position having in account the current window
size, placement. It calculates the appropriate placement and updates
the dom with the result."
[tooltip placement origin-brect offset]
(show-popover tooltip)
(let [tooltip-brect (dom/get-bounding-rect tooltip)
window-size (dom/get-window-size)]
(when-let [[placement placement-rect] (find-matching-placement placement tooltip-brect origin-brect window-size offset)]
(let [height (if (or (= placement "right") (= placement "left"))
(- (:height placement-rect) arrow-height)
(:height placement-rect))]
(dom/set-css-property! tooltip "display" "grid")
(dom/set-css-property! tooltip "block-size" (dm/str height "px"))
(dom/set-css-property! tooltip "inset-block-start" (dm/str (:top placement-rect) "px"))
(dom/set-css-property! tooltip "inset-inline-start" (dm/str (:left placement-rect) "px")))
placement)))
(def ^:private schema:tooltip (def ^:private schema:tooltip
[:map [:map
[:class {:optional true} :string] [:class {:optional true} :string]
[:id {:optional true} :string] [:id {:optional true} :string]
[:offset {:optional true} :int] [:offset {:optional true} :int]
[:delay {:optional true} :int] [:delay {:optional true} :int]
[:content [:or fn? :string [:fn mf/element?]]]
[:placement {:optional true} [:placement {:optional true}
[:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]]]) [:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]]])
(mf/defc tooltip* (mf/defc tooltip*
{::mf/schema schema:tooltip} {::mf/schema schema:tooltip}
[{:keys [class id children tooltip-content placement offset delay] :rest props}] [{:keys [class id children content placement offset delay] :rest props}]
(let [id (or id (mf/use-id)) (let [internal-id
placement* (mf/use-state #(d/nilv placement "top")) (mf/use-id)
placement (deref placement*)
delay (d/nilv delay 300)
schedule-ref (mf/use-ref nil) id
(d/nilv id internal-id)
position-tooltip placement*
(fn [^js tooltip trigger-rect] (mf/use-state #(d/nilv placement "top"))
(let [all-placements (get-fallback-order placement)]
(when (.-isConnected tooltip) placement
(.showPopover ^js tooltip)) (deref placement*)
(loop [[current-placement & remaining-placements] all-placements]
(when current-placement delay
(reset! placement* current-placement) (d/nilv delay 300)
(let [tooltip-rect (calculate-tooltip-rect tooltip trigger-rect current-placement offset)]
(if (dom/is-bounding-rect-outside? tooltip-rect) schedule-ref
(recur remaining-placements) (mf/use-ref nil)
(do (dom/set-css-property! tooltip "display" "grid")
(dom/set-css-property! tooltip "inset-block-start" (dm/str (:top tooltip-rect) "px"))
(dom/set-css-property! tooltip "inset-inline-start" (dm/str (:left tooltip-rect) "px")))))))))
on-show on-show
(mf/use-fn (mf/use-fn
(mf/deps id placement) (mf/deps id placement offset)
(fn [event] (fn [event]
(when-let [schedule (mf/ref-val schedule-ref)] (clear-schedule schedule-ref)
(ts/dispose! schedule)
(mf/set-ref-val! schedule-ref nil))
(when-let [tooltip (dom/get-element id)] (when-let [tooltip (dom/get-element id)]
(let [trigger-rect (->> (dom/get-target event) (let [origin-brect
(dom/get-bounding-rect))] (->> (dom/get-target event)
(mf/set-ref-val! (dom/get-bounding-rect))
schedule-ref
(ts/schedule update-position
delay (fn []
#(position-tooltip tooltip trigger-rect))))))) (let [placement (update-tooltip-position tooltip placement origin-brect offset)]
(reset! placement* placement)))]
(add-schedule schedule-ref delay update-position)))))
on-hide on-hide
(mf/use-fn (mf/use-fn
(mf/deps id) (mf/deps id)
(fn [] (when-let [tooltip (dom/get-element id)] (fn []
(when-let [schedule (mf/ref-val schedule-ref)] (when-let [tooltip (dom/get-element id)]
(ts/dispose! schedule) (clear-schedule schedule-ref)
(mf/set-ref-val! schedule-ref nil)) (hide-popover tooltip))))
(dom/unset-css-property! tooltip "display")
(.hidePopover ^js tooltip))))
handle-key-down handle-key-down
(mf/use-fn (mf/use-fn
@ -173,33 +231,38 @@
(when (kbd/esc? event) (when (kbd/esc? event)
(on-hide)))) (on-hide))))
class (d/append-class class (stl/css-case tooltip-class
:tooltip true (stl/css-case
:tooltip-top (= placement "top") :tooltip true
:tooltip-bottom (= placement "bottom") :tooltip-top (identical? placement "top")
:tooltip-left (= placement "left") :tooltip-bottom (identical? placement "bottom")
:tooltip-right (= placement "right") :tooltip-left (identical? placement "left")
:tooltip-top-right (= placement "top-right") :tooltip-right (identical? placement "right")
:tooltip-bottom-right (= placement "bottom-right") :tooltip-top-right (identical? placement "top-right")
:tooltip-bottom-left (= placement "bottom-left") :tooltip-bottom-right (identical? placement "bottom-right")
:tooltip-top-left (= placement "top-left"))) :tooltip-bottom-left (identical? placement "bottom-left")
:tooltip-top-left (identical? 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})
content
(if (fn? content)
(content)
content)]
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 [:> :div props
children children
[:div {:class class [:div {:class [class tooltip-class]
:id id :id id
:popover "auto" :popover "auto"
:role "tooltip"} :role "tooltip"}
[:div {:class (stl/css :tooltip-content)} [:div {:class (stl/css :tooltip-content)} content]
(if (fn? tooltip-content)
(tooltip-content)
tooltip-content)]
[:div {:class (stl/css :tooltip-arrow) [:div {:class (stl/css :tooltip-arrow)
:id "tooltip-arrow"}]]])) :id "tooltip-arrow"}]]]))

View file

@ -32,7 +32,7 @@ If `placement` is not provided, the tooltip will default to "top".
[:> tlp/tooltip* {:id "test-tooltip" [:> tlp/tooltip* {:id "test-tooltip"
:placement "bottom" :placement "bottom"
:tooltip-content "Tooltip content"} :content "Tooltip content"}
[:div "Trigger component"]]) [:div "Trigger component"]])
``` ```
@ -44,8 +44,7 @@ Tooltip content can include HTML elements:
[:> tlp/tooltip* {:id "test-tooltip" [:> tlp/tooltip* {:id "test-tooltip"
:placement "bottom" :placement "bottom"
:tooltip-content (mf/html :content (mf/html [:span "Tooltip content"])}
[:span "Tooltip content"])}
[:div "Trigger component"]]) [:div "Trigger component"]])
``` ```

View file

@ -16,6 +16,7 @@ $arrow-side: 12px;
background-color: transparent; background-color: transparent;
overflow: hidden; overflow: hidden;
inline-size: fit-content; inline-size: fit-content;
block-size: fit-content;
} }
.tooltip-arrow { .tooltip-arrow {
@ -145,6 +146,7 @@ $arrow-side: 12px;
border: $b-1 solid var(--color-accent-primary-muted); border: $b-1 solid var(--color-accent-primary-muted);
padding: var(--sp-s) var(--sp-m); padding: var(--sp-s) var(--sp-m);
grid-area: content; grid-area: content;
block-size: fit-content;
} }
.tooltip-trigger { .tooltip-trigger {

View file

@ -37,10 +37,10 @@ export default {
}, },
args: { args: {
children: ( children: (
<button popovertarget="popover-example">Hover this element</button> <button popoverTarget="popover-example">Hover this element</button>
), ),
id: "popover-example", id: "popover-example",
tooltipContent: "This is the tooltip content", content: "This is the tooltip content",
delay: 300, delay: 300,
}, },
render: ({ children, ...args }) => ( render: ({ children, ...args }) => (
@ -74,18 +74,18 @@ export const Corners = {
> >
<Tooltip <Tooltip
id="popover-example10" id="popover-example10"
tooltipContent="This is the tooltip content, and must be shown in two lines." content="This is the tooltip content, it's very long, and must be shown in three lines to check how it respond to different sizes."
style={{ style={{
placeSelf: "start start", placeSelf: "start start",
width: "fit-content", width: "fit-content",
height: "fit-content", height: "fit-content",
}} }}
> >
<button popovertarget="popover-example10">Hover here</button> <button popoverTarget="popover-example10">Hover here</button>
</Tooltip> </Tooltip>
<Tooltip <Tooltip
id="popover-example2" id="popover-example2"
tooltipContent="This is the tooltip content, and must be shown in two lines." content="This is the tooltip content, it's very long, and must be shown in three lines to check how it respond to different sizes."
style={{ style={{
alignSelf: "start", alignSelf: "start",
justifySelf: "center", justifySelf: "center",
@ -94,7 +94,7 @@ export const Corners = {
}} }}
> >
<button <button
popovertarget="popover-example2" popoverTarget="popover-example2"
style={{ style={{
alignSelf: "start", alignSelf: "start",
justifySelf: "center", justifySelf: "center",
@ -106,7 +106,7 @@ export const Corners = {
</Tooltip> </Tooltip>
<Tooltip <Tooltip
id="popover-example3" id="popover-example3"
tooltipContent="This is the tooltip content, and must be shown in two lines." content="This is the tooltip content, it's very long, and must be shown in three lines to check how it respond to different sizes."
style={{ style={{
alignSelf: "start", alignSelf: "start",
justifySelf: "end", justifySelf: "end",
@ -114,11 +114,11 @@ export const Corners = {
height: "fit-content", height: "fit-content",
}} }}
> >
<button popovertarget="popover-example3">Hover here</button> <button popoverTarget="popover-example3">Hover here</button>
</Tooltip> </Tooltip>
<Tooltip <Tooltip
id="popover-example4" id="popover-example4"
tooltipContent="This is the tooltip content, and must be shown in two lines." content="This is the tooltip content, it's very long, and must be shown in three lines to check how it respond to different sizes."
style={{ style={{
alignSelf: "center", alignSelf: "center",
justifySelf: "start", justifySelf: "start",
@ -126,11 +126,11 @@ export const Corners = {
height: "fit-content", height: "fit-content",
}} }}
> >
<button popovertarget="popover-example4">Hover here</button> <button popoverTarget="popover-example4">Hover here</button>
</Tooltip> </Tooltip>
<Tooltip <Tooltip
id="popover-example5" id="popover-example5"
tooltipContent="This is the tooltip content, and must be shown in two lines." content="This is the tooltip content, it's very long, and must be shown in three lines to check how it respond to different sizes."
style={{ style={{
alignSelf: "center", alignSelf: "center",
justifySelf: "center", justifySelf: "center",
@ -138,11 +138,11 @@ export const Corners = {
height: "fit-content", height: "fit-content",
}} }}
> >
<button popovertarget="popover-example5">Hover here</button> <button popoverTarget="popover-example5">Hover here</button>
</Tooltip> </Tooltip>
<Tooltip <Tooltip
id="popover-example6" id="popover-example6"
tooltipContent="This is the tooltip content, and must be shown in two lines." content="This is the tooltip content, it's very long, and must be shown in three lines to check how it respond to different sizes."
style={{ style={{
alignSelf: "center", alignSelf: "center",
justifySelf: "end", justifySelf: "end",
@ -150,11 +150,11 @@ export const Corners = {
height: "fit-content", height: "fit-content",
}} }}
> >
<button popovertarget="popover-example6">Hover here</button> <button popoverTarget="popover-example6">Hover here</button>
</Tooltip> </Tooltip>
<Tooltip <Tooltip
id="popover-example7" id="popover-example7"
tooltipContent="This is the tooltip content, and must be shown in two lines." content="This is the tooltip content, it's very long, and must be shown in three lines to check how it respond to different sizes."
style={{ style={{
alignSelf: "end", alignSelf: "end",
justifySelf: "start", justifySelf: "start",
@ -162,11 +162,11 @@ export const Corners = {
height: "fit-content", height: "fit-content",
}} }}
> >
<button popovertarget="popover-example7">Hover here</button> <button popoverTarget="popover-example7">Hover here</button>
</Tooltip> </Tooltip>
<Tooltip <Tooltip
id="popover-example8" id="popover-example8"
tooltipContent="This is the tooltip content, and must be shown in two lines." content="This is the tooltip content, it's very long, and must be shown in three lines to check how it respond to different sizes."
style={{ style={{
alignSelf: "end", alignSelf: "end",
justifySelf: "center", justifySelf: "center",
@ -174,11 +174,11 @@ export const Corners = {
height: "fit-content", height: "fit-content",
}} }}
> >
<button popovertarget="popover-example8">Hover here</button> <button popoverTarget="popover-example8">Hover here</button>
</Tooltip> </Tooltip>
<Tooltip <Tooltip
id="popover-example9" id="popover-example9"
tooltipContent="This is the tooltip content, and must be shown in two lines." content="This is the tooltip content, it's very long, and must be shown in three lines to check how it respond to different sizes."
style={{ style={{
alignSelf: "end", alignSelf: "end",
justifySelf: "end", justifySelf: "end",
@ -186,7 +186,7 @@ export const Corners = {
height: "fit-content", height: "fit-content",
}} }}
> >
<button popovertarget="popover-example9">Hover here</button> <button popoverTarget="popover-example9">Hover here</button>
</Tooltip> </Tooltip>
</div> </div>
), ),

View file

@ -421,17 +421,16 @@
:height (.-height ^js rect)})) :height (.-height ^js rect)}))
(defn is-bounding-rect-outside? (defn is-bounding-rect-outside?
[rect] [{:keys [left top right bottom]} {:keys [width height]}]
(let [{:keys [left top right bottom]} rect (or (< left 0)
{:keys [width height]} (get-window-size)] (< top 0)
(or (< left 0) (> right width)
(< top 0) (> bottom height)))
(> right width)
(> bottom height))))
(defn is-element-outside? (defn is-element-outside?
[element] [element]
(is-bounding-rect-outside? (get-bounding-rect element))) (is-bounding-rect-outside? (get-bounding-rect element)
(get-window-size)))
(defn bounding-rect->rect (defn bounding-rect->rect
[rect] [rect]

View file

@ -9,6 +9,7 @@
extensions" extensions"
(:require (:require
[promesa.impl :as pi]) [promesa.impl :as pi])
(:import goog.async.Deferred)) (:import
goog.async.Deferred))
(pi/extend-promise! Deferred) (pi/extend-promise! Deferred)