penpot/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs
2025-06-16 16:34:53 +02:00

269 lines
9.7 KiB
Clojure

;; 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]))
(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 "block-size")
(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
trigger-left :left
trigger-right :right
trigger-bottom :bottom
trigger-width :width
trigger-height :height}
origin-brect
{tooltip-width :width
tooltip-height :height}
tooltip-brect
offset (d/nilv offset 2)]
(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))
:bottom (+ (- trigger-bottom offset) tooltip-height)
:width tooltip-width
:height tooltip-height}
"left"
{:top (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2))
:left (- trigger-left tooltip-width arrow-height)
:right (+ (- trigger-left tooltip-width) tooltip-width)
:bottom (+ (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2)) tooltip-height)
:width tooltip-width
:height tooltip-height}
"right"
{:top (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2))
:left (+ trigger-right offset)
:right (+ trigger-right offset tooltip-width)
:bottom (+ (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ 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 overlay-offset)
: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)
:width tooltip-width
:height tooltip-height}
"top-left"
{:top (- trigger-top offset tooltip-height)
:left (+ (- trigger-left tooltip-width) overlay-offset)
:right (+ trigger-left overlay-offset)
:bottom (- trigger-top offset)
: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)
:width tooltip-width
:height tooltip-height})))
(defn- get-fallback-order
"Get a vector of placement followed with ordered fallback pacements
for the specified placement"
[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"]))
(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
[:map
[:class {:optional true} :string]
[:id {:optional true} :string]
[:offset {:optional true} :int]
[:delay {:optional true} :int]
[:content [:or fn? :string [:fn mf/element?]]]
[:placement {:optional true}
[:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]]])
(mf/defc tooltip*
{::mf/schema schema:tooltip}
[{:keys [class id children content placement offset delay] :rest props}]
(let [internal-id
(mf/use-id)
id
(d/nilv id internal-id)
placement*
(mf/use-state #(d/nilv placement "top"))
placement
(deref placement*)
delay
(d/nilv delay 300)
schedule-ref
(mf/use-ref nil)
on-show
(mf/use-fn
(mf/deps id placement offset)
(fn [event]
(clear-schedule schedule-ref)
(when-let [tooltip (dom/get-element id)]
(let [origin-brect
(->> (dom/get-target event)
(dom/get-bounding-rect))
update-position
(fn []
(let [placement (update-tooltip-position tooltip placement origin-brect offset)]
(reset! placement* placement)))]
(add-schedule schedule-ref delay update-position)))))
on-hide
(mf/use-fn
(mf/deps id)
(fn []
(when-let [tooltip (dom/get-element id)]
(clear-schedule schedule-ref)
(hide-popover tooltip))))
handle-key-down
(mf/use-fn
(mf/deps on-hide)
(fn [event]
(when (kbd/esc? event)
(on-hide))))
tooltip-class
(stl/css-case
:tooltip true
:tooltip-top (identical? placement "top")
:tooltip-bottom (identical? placement "bottom")
:tooltip-left (identical? placement "left")
:tooltip-right (identical? placement "right")
:tooltip-top-right (identical? placement "top-right")
:tooltip-bottom-right (identical? placement "bottom-right")
: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)]
[:> :div props
children
[:div {:class [class tooltip-class]
:id id
:popover "auto"
:role "tooltip"}
[:div {:class (stl/css :tooltip-content)} content]
[:div {:class (stl/css :tooltip-arrow)
:id "tooltip-arrow"}]]]))