Add portal option to select

This commit is contained in:
Eva Marco 2026-07-27 11:45:34 +02:00
parent 54468de5c5
commit 1c21b1b1bd
3 changed files with 123 additions and 51 deletions

View File

@ -26,10 +26,10 @@
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.notifications.context-notification :refer [context-notification]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as deprecated-icon]
[app.main.worker :as mw]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
@ -224,13 +224,13 @@
;; FIXME: rename to format
format (:type entry)
loading? false #_(or (= :analyze status)
loading? (or (= :analyze status)
(= :import-progress status)
(and is-progress (= :import-ready status)))
analyze-error? true #_(= :analyze-error status)
import-success? false #_(= :import-success status)
import-error? false #_(= :import-error status)
import-ready? false #_(= :import-ready status)
analyze-error? (= :analyze-error status)
import-success? (= :import-success status)
import-error? (= :import-error status)
import-ready? (= :import-ready status)
level (cond
import-success? :success
@ -319,19 +319,20 @@
(when ^boolean is-shared?
[:> icon* {:icon-id i/library :class (stl/css :icon)}])]])])
[:div {:class (stl/css :edit-entry-buttons)}
(when ^boolean editable?
[:> icon-button* {:on-click on-edit'
:variant "ghost"
:icon-size "s"
:aria-label (tr "labels.edit")
:icon i/curve}])
(when ^boolean can-be-deleted
[:> icon-button* {:on-click on-delete'
:variant "ghost"
:icon-size "s"
:aria-label (tr "labels.delete")
:icon i/delete}])]]
(when ^boolean (or editable? can-be-deleted)
[:div {:class (stl/css :edit-entry-buttons)}
(when ^boolean editable?
[:> icon-button* {:on-click on-edit'
:variant "ghost"
:icon-size "s"
:aria-label (tr "labels.edit")
:icon i/curve}])
(when ^boolean can-be-deleted
[:> icon-button* {:on-click on-delete'
:variant "ghost"
:icon-size "s"
:aria-label (tr "labels.delete")
:icon i/delete}])])]
(cond
analyze-error?
[:> text* {:class (stl/css :error-message)
@ -401,8 +402,10 @@
(on-select id (:id first-c))))))
[:div {:class (stl/css :library-resolution)}
[:p {:class (stl/css :library-resolution-message)}
(tr "dashboard.import.resolve-libraries")]
[:> text* {:class (stl/css :library-resolution-message)
:as "p"
:typography t/body-large}
"Some libraries couldn't be linked automatically. Select the correct library for each:"]
(for [{:keys [id name candidates]} candidates]
(let [options (mapv (fn [c]
@ -415,7 +418,9 @@
[:div {:class (stl/css :library-resolution-item-name)}
name]
[:> select* {:options options
:class (stl/css :library-resolution-select)
:default-selected (or (some-> selected str) "")
:has-portal true
:on-change (partial on-select id)}]]))]))
(mf/defc library-resolution-summary-file*
@ -735,26 +740,26 @@
[:div {:class (stl/css :modal-content)}
(cond
(and (= :analyze status) errors?)
[:& context-notification
[:> context-notification*
{:level :warning
:class (stl/css :context-notification-error)
:content (tr "dashboard.import.import-warning")}]
:class (stl/css :context-notification-error)}
(tr "dashboard.import.import-warning")]
(= :import-success status)
[:*
[:& context-notification
{:level (if (zero? import-success-total) :warning :success)
:content (tr "dashboard.import.import-message" (i18n/c import-success-total))}]
[:> context-notification*
{:level (if (zero? import-success-total) :warning :success)}
(tr "dashboard.import.import-message" (i18n/c import-success-total))]
(when (pos? auto-linked-count)
[:& context-notification
{:level :success
:content (tr "dashboard.import.auto-linked-libraries" (i18n/c auto-linked-count))}])]
[:> context-notification*
{:level :success}
(tr "dashboard.import.auto-linked-libraries" (i18n/c auto-linked-count))])]
(= :import-error status)
[:& context-notification
[:> context-notification*
{:level :error
:class (stl/css :context-notification-error)
:content (tr "dashboard.import.import-error.disclaimer")}]
:class (stl/css :context-notification-error)}
(tr "dashboard.import.import-error.disclaimer")]
;; :resolution — wizard step (current derived file)
(= :library-resolution status)

View File

@ -426,11 +426,6 @@
}
.library-resolution-message {
// TODO: Typography does not match any existing DS typography token.
font-family: "worksans", "vazirmatn", sans-serif;
font-size: px2rem(12);
font-weight: 400;
line-height: 1.4;
color: var(--color-foreground-secondary);
margin-block-end: var(--sp-s);
}

View File

@ -13,9 +13,11 @@
[app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown* schema:option]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.tooltip.tooltip :refer [tooltip*]]
[app.main.ui.hooks :as hooks]
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[app.util.object :as obj]
[app.util.timers :as timers]
[clojure.string :as str]
[rumext.v2 :as mf]
[rumext.v2.util :as mfu]))
@ -58,11 +60,12 @@
[:empty-to-end {:optional true} [:maybe :boolean]]
[:on-change {:optional true} fn?]
[:dropdown-alignment {:optional true} [:maybe [:enum :left :right]]]
[:variant {:optional true} [:maybe [:enum "default" "ghost" "icon-only"]]]])
[:variant {:optional true} [:maybe [:enum "default" "ghost" "icon-only"]]]
[:has-portal {:optional true} :boolean]])
(mf/defc select*
{::mf/schema schema:select}
[{:keys [options class disabled default-selected empty-to-end on-change variant wrapper-class dropdown-alignment] :rest props}]
[{:keys [options class disabled default-selected empty-to-end on-change variant wrapper-class dropdown-alignment has-portal] :rest props}]
(let [;; NOTE: we use mfu/bean here for transparently handle
;; options provide as clojure data structures or javascript
;; plain objects and lists.
@ -88,6 +91,9 @@
options-ref (mf/use-ref nil)
select-ref (mf/use-ref nil)
container (hooks/use-portal-container :popup)
dropdown-wrapper-ref (mf/use-ref nil)
empty-selected-id?
(str/blank? selected-id)
@ -208,10 +214,63 @@
(reset! selected-id*
(get-selected-option-id options default-selected)))
;; Portal mode: click-outside + floating positioning
(mf/with-effect [is-open has-portal]
(when (and is-open has-portal)
(let [handler
(fn [event]
(let [wrapper-node (mf/ref-val select-ref)
dropdown-node (mf/ref-val dropdown-wrapper-ref)
target (dom/get-target event)]
(when (and wrapper-node dropdown-node
(not (dom/child? target wrapper-node))
(not (dom/child? target dropdown-node)))
(reset! is-open* false)
(reset! focused-id* nil))))
calculate
(fn []
(timers/raf
(fn []
(when-let [select-node (mf/ref-val select-ref)]
(when-let [dropdown-node (mf/ref-val dropdown-wrapper-ref)]
(let [select-rect (dom/get-bounding-rect select-node)
dropdown-rect (dom/get-bounding-rect dropdown-node)
window-height (.-innerHeight js/window)
space-below (- window-height (:bottom select-rect))
open-up? (> (:height dropdown-rect) space-below)]
(if open-up?
(let [bottom (+ (- window-height (:top select-rect)) 4)]
(dom/set-css-property! dropdown-node "top" "unset")
(dom/set-css-property! dropdown-node "bottom" (str bottom "px")))
(let [top (+ (:bottom select-rect) 4)]
(dom/set-css-property! dropdown-node "bottom" "unset")
(dom/set-css-property! dropdown-node "top" (str top "px"))))
(dom/set-css-property! dropdown-node "left" (str (:left select-rect) "px"))
(dom/set-css-property! dropdown-node "width" (str (:width select-rect) "px"))
(dom/set-css-property! dropdown-node "position" "fixed")))))))]
(.addEventListener js/document "mousedown" handler)
(let [ro (js/ResizeObserver. (fn [_] (calculate)))]
(when-let [node (mf/ref-val select-ref)]
(.observe ro node))
(.addEventListener js/window "resize" calculate)
(.addEventListener js/window "scroll" calculate true)
(calculate)
(fn []
(.removeEventListener js/document "mousedown" handler)
(.disconnect ro)
(.removeEventListener js/window "resize" calculate)
(.removeEventListener js/window "scroll" calculate true))))))
[:div {:class [wrapper-class (stl/css :select-wrapper)]
:on-click on-click
:ref select-ref
:on-blur on-blur}
:on-blur (when-not has-portal on-blur)}
[:> :button props
[:span {:class (stl/css-case :select-header true
@ -241,11 +300,24 @@
:aria-hidden true}]]
(when ^boolean is-open
[:> options-dropdown* {:on-click on-option-click
:id listbox-id
:options options
:selected selected-id
:focused focused-id
:align dropdown-alignment
:empty-to-end empty-to-end
:ref set-option-ref}])]))
(if has-portal
(mf/portal
(mf/html
[:> options-dropdown* {:on-click on-option-click
:id listbox-id
:options options
:selected selected-id
:focused focused-id
:align dropdown-alignment
:empty-to-end empty-to-end
:ref set-option-ref
:wrapper-ref dropdown-wrapper-ref}])
container)
[:> options-dropdown* {:on-click on-option-click
:id listbox-id
:options options
:selected selected-id
:focused focused-id
:align dropdown-alignment
:empty-to-end empty-to-end
:ref set-option-ref}]))]))