Initial import of LogDarioSeq baseline
Establishes version control for the personal Logseq DB fork before starting the server-client architecture migration and domain features described in docs/agent-guide and the approved implementation plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
17
deps/shui/.clj-kondo/config.edn
vendored
Normal file
17
deps/shui/.clj-kondo/config.edn
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
{ :config-in-ns
|
||||
;; :used-underscored-binding is turned off for components because of false positive
|
||||
;; for rum/defcs and _state.
|
||||
{all-components {:linters {:used-underscored-binding {:level :off}}}}
|
||||
|
||||
:linters
|
||||
{;; Disable until it doesn't trigger false positives on rum/defcontext
|
||||
:earmuffed-var-not-dynamic {:level :off}}
|
||||
:hooks {:analyze-call {rum.core/defc hooks.rum/defc
|
||||
rum.core/defcs hooks.rum/defcs
|
||||
clojure.string/join hooks.path-invalid-construct/string-join}}
|
||||
:lint-as {rum.core/defcc rum.core/defc
|
||||
rum.core/with-context clojure.core/let
|
||||
rum.core/defcontext clojure.core/def
|
||||
rum.core/defc clojure.core/defn
|
||||
rum.core/defcs clojure.core/defn
|
||||
frontend.react/defc clojure.core/defn}}
|
||||
11
deps/shui/README.md
vendored
Normal file
11
deps/shui/README.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
## Description
|
||||
|
||||
Shui is the component library for logseq. It has 3 main goals:
|
||||
|
||||
1. Provide an abstraction for specific components, separate from the main codebase
|
||||
2. Provide a consistent look and feel for the future of logseq
|
||||
3. Provide ready to use components to plugin authors to allow for a more consistent better user experience of plugin authors and users
|
||||
|
||||
## API
|
||||
|
||||
This library is under the parent namespace `logseq.shui`.
|
||||
10
deps/shui/deps.edn
vendored
Normal file
10
deps/shui/deps.edn
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{:paths ["src"]
|
||||
:deps
|
||||
{org.clojure/clojure {:mvn/version "1.12.4"}
|
||||
org.clojure/clojurescript {:mvn/version "1.12.134"}
|
||||
funcool/promesa {:mvn/version "11.0.678"}
|
||||
io.factorhouse/hsx {:git/url "https://github.com/logseq/hsx"
|
||||
:sha "21615ffa2aa530cc830e3464b793a53ef4fdd18c"}
|
||||
|
||||
medley/medley {:mvn/version "1.4.0"}
|
||||
cljs-bean/cljs-bean {:mvn/version "1.9.0"}}}
|
||||
61
deps/shui/src/logseq/shui/base/core.cljs
vendored
Normal file
61
deps/shui/src/logseq/shui/base/core.cljs
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
(ns logseq.shui.base.core
|
||||
(:require [cljs-bean.core :as bean]
|
||||
["react" :as react]
|
||||
[io.factorhouse.hsx.core :as hsx]
|
||||
[logseq.shui.icon.v2 :as tabler-icon]
|
||||
[logseq.shui.util :as util]))
|
||||
|
||||
(def button-base (util/ui-wrap "Button" {:static? false}))
|
||||
(def link (util/ui-wrap "Link"))
|
||||
|
||||
;; Note: used for the shui popup trigger
|
||||
(defn trigger-as
|
||||
([as & props-or-children]
|
||||
(let [[props children] [(first props-or-children) (rest props-or-children)]
|
||||
props' (cond->
|
||||
{:on-key-down #(case (.-key %)
|
||||
(" " "Enter")
|
||||
(do (some-> (.-target %) (.click))
|
||||
(.preventDefault %)
|
||||
(.stopPropagation %))
|
||||
:dune)}
|
||||
(map? props)
|
||||
(merge props))
|
||||
children (if (map? props) children (cons props children))]
|
||||
(into [as props'] children))))
|
||||
|
||||
;; Note: fix the custom trigger content
|
||||
;; for the {:as-child true} menu trigger
|
||||
(defn trigger-child-wrap
|
||||
[& props-and-children]
|
||||
(let [props (first props-and-children)
|
||||
children (rest props-and-children)
|
||||
children (if (map? props) children (cons props children))
|
||||
children (when (seq children) (hsx/create-element children))
|
||||
props (if (map? props) props {})]
|
||||
(apply react/createElement "div" (bean/->js props) children)))
|
||||
|
||||
;; Note: keep this as a plain function
|
||||
;; to be compatible for the radix as-child option
|
||||
(defn button
|
||||
[& props-and-children]
|
||||
(let [props (first props-and-children)
|
||||
children (rest props-and-children)
|
||||
children (if (map? props) children (cons props children))
|
||||
props' (if (map? props) props {})]
|
||||
(apply button-base props' children)))
|
||||
|
||||
(defn button-icon
|
||||
[variant icon-name {:keys [icon-props button-size] :as props} child]
|
||||
(button
|
||||
(merge (dissoc props :icon-props :button-size)
|
||||
{:variant variant
|
||||
:size :icon
|
||||
:data-button :icon
|
||||
:style (when button-size {:width button-size :height button-size})})
|
||||
(tabler-icon/root (name icon-name) (merge {:size 20} icon-props))
|
||||
child))
|
||||
|
||||
(def button-ghost-icon (partial button-icon :ghost))
|
||||
(def button-outline-icon (partial button-icon :outline))
|
||||
(def button-secondary-icon (partial button-icon :secondary))
|
||||
1365
deps/shui/src/logseq/shui/components.cljs
vendored
Normal file
1365
deps/shui/src/logseq/shui/components.cljs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
629
deps/shui/src/logseq/shui/demo.cljs
vendored
Normal file
629
deps/shui/src/logseq/shui/demo.cljs
vendored
Normal file
@@ -0,0 +1,629 @@
|
||||
(ns logseq.shui.demo
|
||||
(:require [dommy.core :refer-macros [sel1]]
|
||||
[frontend.handler.notification :as notification]
|
||||
[logseq.shui.dialog.core :as dialog-core]
|
||||
[logseq.shui.form.core :refer [yup] :as form-core]
|
||||
[logseq.shui.hooks :as hooks]
|
||||
[logseq.shui.ui :as shui]
|
||||
[promesa.core :as p]
|
||||
[io.factorhouse.hsx.core :as hsx]))
|
||||
|
||||
(hsx/defc section-item
|
||||
[title children]
|
||||
[:section.mb-4
|
||||
[:h2.text-xl.font-semibold.py-2.italic.opacity-50 title]
|
||||
[:div.py-4 children]])
|
||||
|
||||
(hsx/defc sample-dropdown-menu-content
|
||||
[]
|
||||
(let [icon #(shui/tabler-icon (name %1) {:class "scale-90 pr-1 opacity-80"})]
|
||||
(shui/dropdown-menu-content
|
||||
{:class "w-56"
|
||||
:on-click (fn [^js e] (some-> (.-target e) (.-innerText)
|
||||
(#(identity ["You select: " [:b.text-red-700 %1]])) (shui/toast! :info)))}
|
||||
(shui/dropdown-menu-label "My Account")
|
||||
(shui/dropdown-menu-separator)
|
||||
(shui/dropdown-menu-group
|
||||
;; items
|
||||
(shui/dropdown-menu-item (icon :user) "Profile" (shui/dropdown-menu-shortcut "⌘P"))
|
||||
(shui/dropdown-menu-item (icon :brand-mastercard) [:span "Billing"] (shui/dropdown-menu-shortcut "⌘B"))
|
||||
(shui/dropdown-menu-item (icon :adjustments-alt) [:span "Settings"] (shui/dropdown-menu-shortcut "⌘,"))
|
||||
(shui/dropdown-menu-item (icon :keyboard) [:span "Keyboard shortcuts"]))
|
||||
(shui/dropdown-menu-separator)
|
||||
;; group
|
||||
(shui/dropdown-menu-group
|
||||
;; items
|
||||
(shui/dropdown-menu-item (icon :users) "Team")
|
||||
;; sub menu
|
||||
(shui/dropdown-menu-sub
|
||||
(shui/dropdown-menu-sub-trigger
|
||||
(icon :user-plus) [:span "Invite users"])
|
||||
(shui/dropdown-menu-sub-content
|
||||
(shui/dropdown-menu-item (icon :mail) "Email")
|
||||
(shui/dropdown-menu-item (icon :message) "Message")
|
||||
(shui/dropdown-menu-item (icon :dots-circle-horizontal) "More...")))
|
||||
;; menu item
|
||||
(shui/dropdown-menu-item (icon :plus) "New Team" (shui/dropdown-menu-shortcut "⌘+T")))
|
||||
(shui/dropdown-menu-separator)
|
||||
(shui/dropdown-menu-item (icon :brand-github) "GitHub")
|
||||
(shui/dropdown-menu-item {:disabled true} (icon :cloud) "Cloud API")
|
||||
(shui/dropdown-menu-separator)
|
||||
(shui/dropdown-menu-item (icon :logout) "Logout" (shui/dropdown-menu-shortcut "⌘+Q")))))
|
||||
|
||||
(hsx/defc sample-context-menu-content
|
||||
[]
|
||||
(let [icon #(shui/tabler-icon (name %1) {:class "scale-90 pr-1 opacity-80"})]
|
||||
(shui/context-menu
|
||||
;; trigger
|
||||
(shui/context-menu-trigger
|
||||
[:div.border.px-6.py-12.border-dashed.rounded.text-center.select-none
|
||||
{:key "ctx-menu-click"}
|
||||
[:span.opacity-50 "Right click here"]])
|
||||
;; content
|
||||
(shui/context-menu-content
|
||||
{:class "w-60 max-h-[80vh] overflow-auto"}
|
||||
(shui/context-menu-item
|
||||
(icon "arrow-left")
|
||||
"Back"
|
||||
(shui/context-menu-shortcut "⌘["))
|
||||
(shui/context-menu-item {:disabled true}
|
||||
(icon "arrow-right")
|
||||
"Forward"
|
||||
(shui/context-menu-shortcut "⌘]"))
|
||||
(shui/context-menu-item
|
||||
(icon "refresh")
|
||||
"Reload"
|
||||
(shui/context-menu-shortcut "⌘R"))
|
||||
;; Sub menu
|
||||
(shui/context-menu-sub
|
||||
(shui/context-menu-sub-trigger {:inset true} "More tools")
|
||||
(shui/context-menu-sub-content {:class "w-48"}
|
||||
(shui/context-menu-item "Save page As..."
|
||||
(shui/context-menu-shortcut "⇧⌘S"))
|
||||
(shui/context-menu-item "Create Shortcut...")
|
||||
(shui/context-menu-item "Name Window...")
|
||||
(shui/context-menu-separator)
|
||||
(shui/context-menu-item "Developer Tools")))
|
||||
;; more
|
||||
(shui/context-menu-separator)
|
||||
(shui/context-menu-checkbox-item {:checked true}
|
||||
"Show Bookmarks Bar" (shui/context-menu-shortcut "⌘⇧B"))
|
||||
(shui/context-menu-checkbox-item "Show Full URLs")
|
||||
(shui/context-menu-separator)
|
||||
(shui/context-menu-radio-group {:value "pedro"}
|
||||
(shui/context-menu-label {:inset true} "People")
|
||||
(shui/context-menu-separator)
|
||||
(shui/context-menu-radio-item {:value "pedro"} "Pedro Duarte")
|
||||
(shui/context-menu-radio-item {:value "colm"} "Colm Tuite"))))))
|
||||
|
||||
(hsx/defc sample-tabs
|
||||
[]
|
||||
(shui/tabs
|
||||
{:defaultValue "account"
|
||||
:className "w-[400px]"}
|
||||
(shui/tabs-list
|
||||
(shui/tabs-trigger
|
||||
{:value "account"}
|
||||
"Account")
|
||||
(shui/tabs-trigger
|
||||
{:value "password"}
|
||||
"Password"))
|
||||
(shui/tabs-content
|
||||
{:value "account"}
|
||||
"Make changes to your account here.")
|
||||
(shui/tabs-content
|
||||
{:value "password"}
|
||||
"Change your password here.")))
|
||||
|
||||
(hsx/defc sample-form-basic
|
||||
[]
|
||||
[:div.border.p-6.rounded.bg-gray-01
|
||||
(let [form-ctx (form-core/use-form
|
||||
{:defaultValues {:username ""
|
||||
:agreement true
|
||||
:notification "all"
|
||||
:bio ""}
|
||||
:yupSchema (-> (.object yup)
|
||||
(.shape #js {:username (-> (.string yup) (.required))})
|
||||
(.required))})
|
||||
handle-submit (:handleSubmit form-ctx)
|
||||
on-submit-valid (handle-submit
|
||||
(fn [^js e]
|
||||
(js/console.log "[form] submit: " e)
|
||||
(js/alert (js/JSON.stringify e nil 2))))]
|
||||
|
||||
(shui/form-provider form-ctx
|
||||
[:form
|
||||
{:on-submit on-submit-valid}
|
||||
|
||||
;; field item
|
||||
(shui/form-field {:name "username"}
|
||||
(fn [field error]
|
||||
(shui/form-item
|
||||
(shui/form-label "Username")
|
||||
(shui/form-control
|
||||
(shui/input (merge {:placeholder "Username"} field)))
|
||||
(shui/form-description
|
||||
(if error
|
||||
[:b.text-red-800 (:message error)]
|
||||
"This is your public display name.")))))
|
||||
|
||||
(shui/form-field {:name "bio"}
|
||||
(fn [field _error]
|
||||
(shui/form-item
|
||||
{:class "pt-4"}
|
||||
(shui/form-control
|
||||
(shui/textarea (merge {:placeholder "Bio text..."} field))))))
|
||||
|
||||
;; radio
|
||||
(shui/form-field {:name "notification"}
|
||||
;; item render
|
||||
(fn [field]
|
||||
(shui/form-item
|
||||
{:class "space-y-3 my-4"}
|
||||
(shui/form-control
|
||||
(shui/radio-group
|
||||
{:value (:value field)
|
||||
:on-value-change (:onChange field)
|
||||
:class "flex flex-col space-y-3"}
|
||||
(shui/form-item
|
||||
{:class "flex flex-row space-x-2 items-center space-y-0"}
|
||||
(shui/form-control
|
||||
(shui/radio-group-item {:value "all"}))
|
||||
(shui/form-label "All"))
|
||||
|
||||
(shui/form-item
|
||||
{:class "flex flex-row space-x-2 items-center space-y-0"}
|
||||
(shui/form-control
|
||||
(shui/radio-group-item {:value "direct"}))
|
||||
(shui/form-label "Direct messages and mentions")))))))
|
||||
|
||||
[:hr]
|
||||
|
||||
;; checkbox
|
||||
(shui/form-field {:name "agreement"}
|
||||
(fn [field]
|
||||
(shui/form-item
|
||||
{:class "flex justify-start items-center space-x-2 space-y-0 my-3 pr-3"}
|
||||
(shui/form-control
|
||||
(shui/checkbox {:checked (:value field)
|
||||
:on-checked-change (:onChange field)}))
|
||||
(shui/form-label {:class "font-normal cursor-pointer"} "Agreement terms"))))
|
||||
|
||||
;; actions
|
||||
[:div.relative.px-2
|
||||
(shui/button {:type "submit" :class "!absolute right-0 top-[-40px]"} "Submit")]]))])
|
||||
|
||||
(hsx/defc sample-date-picker
|
||||
[]
|
||||
(let [[open? set-open!] (hooks/use-state false)
|
||||
[date set-date!] (hooks/use-state (js/Date.))]
|
||||
(shui/popover
|
||||
{:open open?
|
||||
:on-open-change (fn [o] (set-open! o))}
|
||||
;; trigger
|
||||
(shui/popover-trigger
|
||||
{:as-child true
|
||||
:class "w-2/3"}
|
||||
(shui/input
|
||||
{:type :text
|
||||
:placeholder "pick a date"
|
||||
:default-value (.toDateString date)}))
|
||||
;; content
|
||||
(shui/popover-content
|
||||
{:on-open-auto-focus #(.preventDefault %)
|
||||
:side-offset 8
|
||||
:class "p-0"}
|
||||
(shui/calendar
|
||||
{:selected date
|
||||
:on-day-click
|
||||
(fn [^js d]
|
||||
(set-date! d)
|
||||
(set-open! false))})))))
|
||||
|
||||
(hsx/defc sample-dialog-basic
|
||||
[]
|
||||
(let [[open? set-open!] (hooks/use-state false)]
|
||||
(shui/dialog
|
||||
{:open open?
|
||||
:on-open-change #(set-open! %)}
|
||||
(shui/dialog-trigger
|
||||
{:as-child true}
|
||||
(shui/button {:variant :outline}
|
||||
(shui/tabler-icon "notification") "Open as modal locally"))
|
||||
(shui/dialog-content
|
||||
(shui/dialog-header
|
||||
(shui/dialog-title "Header")
|
||||
(shui/dialog-description
|
||||
"Description"))
|
||||
[:div.max-h-96.overflow-y-auto
|
||||
{:class "-mx-6"}
|
||||
[:section.px-6
|
||||
(repeat 8 [:p "Your custom content"])]]
|
||||
(shui/dialog-footer
|
||||
(shui/button
|
||||
{:on-click #(set-open! false)
|
||||
:size :md} "🍄 * Footer"))))))
|
||||
|
||||
(hsx/defc sample-notifications
|
||||
[]
|
||||
(let [[last-dismissed set-last-dismissed!] (hooks/use-state nil)
|
||||
progress-id :demo/notification-progress
|
||||
callback-id :demo/notification-callback]
|
||||
[:div.flex.flex-col.gap-3
|
||||
[:div.flex.flex-row.flex-wrap.gap-2
|
||||
(shui/button
|
||||
{:variant :outline
|
||||
:size :sm
|
||||
:on-click #(notification/show! "Saved successfully" :success)}
|
||||
"Success")
|
||||
(shui/button
|
||||
{:variant :outline
|
||||
:size :sm
|
||||
:on-click #(notification/show! "Background sync is running" :info)}
|
||||
"Info")
|
||||
(shui/button
|
||||
{:variant :outline
|
||||
:size :sm
|
||||
:on-click #(notification/show! "Storage space is almost full" :warning false)}
|
||||
"Warning")
|
||||
(shui/button
|
||||
{:variant :destructive
|
||||
:size :sm
|
||||
:on-click #(notification/show! "Upload failed. Fix it before retrying." :error false)}
|
||||
"Persistent error")]
|
||||
|
||||
[:div.flex.flex-row.flex-wrap.gap-2
|
||||
(shui/button
|
||||
{:class "primary-green"
|
||||
:size :sm
|
||||
:on-click (fn []
|
||||
(notification/show! "Syncing notifications demo..." :info true progress-id 10000)
|
||||
(js/setTimeout
|
||||
#(notification/show! "Notifications demo synced" :success true progress-id 1500)
|
||||
1200))}
|
||||
"Update same id")
|
||||
(shui/button
|
||||
{:variant :secondary
|
||||
:size :sm
|
||||
:on-click #(notification/clear! progress-id)}
|
||||
"Clear updated one")
|
||||
(shui/button
|
||||
{:variant :secondary
|
||||
:size :sm
|
||||
:on-click #(notification/show! "Dismiss me to trigger callback" :warning true callback-id 4000
|
||||
(fn [uid] (set-last-dismissed! (name uid))))}
|
||||
"Dismiss callback")
|
||||
(shui/button
|
||||
{:variant :ghost
|
||||
:size :sm
|
||||
:on-click notification/clear-all!}
|
||||
"Clear all")]
|
||||
|
||||
[:div.text-sm.opacity-70
|
||||
"Last dismissed notification: "
|
||||
[:code (or last-dismissed "none")]]]))
|
||||
|
||||
(hsx/defc page []
|
||||
(shui/tooltip-provider
|
||||
[:div.sm:p-10
|
||||
[:hr]
|
||||
[:input
|
||||
{:type "checkbox" :on-change #(js/console.log "===>> onChange:" % (.-value (.-target %)))}]
|
||||
(shui/checkbox {:on-click
|
||||
(fn [^js e] (js/console.log "==>> click:"
|
||||
(set! (. (.-target e) -checked) (.-state (.-dataset (.-target e))))
|
||||
(.-checked (.-target e))))
|
||||
:on-checked-change #(js/console.log "==>> on checked change:" %)} "abc")
|
||||
|
||||
[:h1.text-3xl.font-bold "Logseq UI"]
|
||||
[:hr]
|
||||
|
||||
;; Button
|
||||
(section-item "Button"
|
||||
[:div.flex.flex-row.flex-wrap.gap-2
|
||||
(let [[loading? set-loading!] (hooks/use-state false)]
|
||||
(shui/button
|
||||
{:size :sm
|
||||
:on-click (fn []
|
||||
(set-loading! true)
|
||||
(js/setTimeout #(set-loading! false) 5000))
|
||||
:disabled loading?}
|
||||
(when loading?
|
||||
(shui/tabler-icon "loader2" {:class "animate-spin"}))
|
||||
"Logseq Classic Button"
|
||||
(shui/tabler-icon "arrow-right")))
|
||||
|
||||
(shui/button {:variant :outline :size :sm} "Outline")
|
||||
(shui/button {:variant :secondary :size :sm} "Secondary")
|
||||
(shui/button {:disabled true :size :sm} "Disabled")
|
||||
(shui/button {:variant :destructive :size :sm} "Destructive")
|
||||
(shui/button {:class "primary-green" :size :sm} "Custom (.primary-green)")
|
||||
(shui/button {:variant :ghost :size :sm} "Ghost")
|
||||
(shui/button {:variant :link :size :sm} "Link")
|
||||
(shui/button
|
||||
{:variant :icon
|
||||
:size :sm}
|
||||
[:a.flex.items-center.text-blue-rx-10.hover:text-blue-rx-10-alpha
|
||||
{:href "https://x.com/logseq" :target "_blank"}
|
||||
(shui/tabler-icon "brand-twitter" {:size 15})])])
|
||||
|
||||
;; Toast
|
||||
(section-item "Toast"
|
||||
[:div.flex.flex-row.flex-wrap.gap-2
|
||||
(shui/button
|
||||
{:size :md
|
||||
:variant :outline
|
||||
:on-click #(shui/toast!
|
||||
"Check for updates ..."
|
||||
(nth [:success :error :default :info :warning] (rand-int 3))
|
||||
{:title (if (odd? (js/Date.now)) "History of China" "")
|
||||
:duration 3000})}
|
||||
"Open random toast"
|
||||
(shui/tabler-icon "arrow-right"))
|
||||
|
||||
(shui/button
|
||||
{:variant :secondary
|
||||
:size :md
|
||||
:on-click (fn []
|
||||
(shui/toast!
|
||||
(fn [{:keys [id dismiss! update!]}]
|
||||
[:b.text-red-700
|
||||
[:div.flex.items-center.gap-2
|
||||
(shui/tabler-icon "info-circle")
|
||||
(str "#(" id ") ")
|
||||
(.toLocaleString (js/Date.))]
|
||||
[:div.flex.flex-row.gap-2
|
||||
(shui/button
|
||||
{:on-click #(dismiss! id) :size :sm}
|
||||
"x close")
|
||||
|
||||
(shui/button
|
||||
{:on-click #(update! {:title (js/Date.now)
|
||||
:action [:b (shui/button {:on-click (fn [] (shui/toast-dismiss!))} "clear all")]})
|
||||
:size :sm}
|
||||
"x update")]])
|
||||
:default
|
||||
{:duration 3000 :onDismiss #(js/console.log "===>> dismiss?:" %1)}))}
|
||||
(shui/tabler-icon "apps")
|
||||
"Toast callback handle")
|
||||
|
||||
(shui/button
|
||||
{:on-click #(shui/toast! "A message from SoundCloud..."
|
||||
{:class "text-orange-rx-10"
|
||||
:icon [:b.pl-1 (shui/tabler-icon "brand-soundcloud" {:size 20})]
|
||||
:duration 3000})
|
||||
:class "primary-orange"
|
||||
:size :md}
|
||||
"Custom icon")])
|
||||
|
||||
(section-item "Notification"
|
||||
(sample-notifications))
|
||||
|
||||
[:div.flex.flex-row.space-x-16.items-center
|
||||
;; Tips
|
||||
(section-item "Tips"
|
||||
[:div.flex.flex-row.flex-wrap.gap-2
|
||||
(shui/tooltip-provider
|
||||
(shui/tooltip
|
||||
(shui/tooltip-trigger
|
||||
(shui/button
|
||||
{:variant :outline
|
||||
:on-click #(dialog-core/open! [:h1.text-9xl.text-center.scale-110 "🍄"])}
|
||||
"Tip for hint?"))
|
||||
(shui/tooltip-content
|
||||
{:class "w-42 px-8 py-4 text-xl border-green-rx-08 bg-green-rx-07-alpha"}
|
||||
"🍄")))])
|
||||
;; Avatar
|
||||
(section-item "Avatar"
|
||||
[:div.flex.flex-row.space-x-6.items-center
|
||||
(shui/avatar
|
||||
(shui/avatar-image {:src "https://avatars.githubusercontent.com/u/63385289?s=200&v=4"})
|
||||
(shui/avatar-fallback "L"))
|
||||
(shui/avatar
|
||||
(shui/avatar-fallback "CH"))])]
|
||||
|
||||
;; Badge
|
||||
(section-item "Badge"
|
||||
[:div.flex.flex-row.flex-wrap.gap-2
|
||||
(shui/badge "Default")
|
||||
(shui/badge {:variant :outline} "Outline")
|
||||
(shui/badge {:variant :secondary} "Secondary")
|
||||
(shui/badge {:variant :destructive} "Destructive")
|
||||
(shui/badge {:class "primary-yellow"} "Custom (.primary-yellow)")])
|
||||
|
||||
[:div.grid.sm:grid-cols-3.sm:gap-8
|
||||
;; Dropdown
|
||||
(section-item "Dropdown"
|
||||
(shui/dropdown-menu
|
||||
(shui/tooltip
|
||||
(shui/tooltip-trigger
|
||||
(shui/dropdown-menu-trigger
|
||||
{:as-child true}
|
||||
(shui/button {:variant :outline}
|
||||
(shui/tabler-icon "list") "Open dropdown menu")))
|
||||
(shui/tooltip-content "test hide?"))
|
||||
|
||||
(sample-dropdown-menu-content)))
|
||||
|
||||
;; Context menu
|
||||
[:div.col-span-2
|
||||
(section-item "Context Menu"
|
||||
(sample-context-menu-content))]]
|
||||
|
||||
(section-item "Tabs" (sample-tabs))
|
||||
|
||||
;; Dialog
|
||||
(section-item "Dialog"
|
||||
[:div.flex.flex-row.flex-wrap.gap-2
|
||||
(sample-dialog-basic)
|
||||
(let [fc #(dialog-core/open!
|
||||
(fn []
|
||||
[:div.py-2
|
||||
[:p [:strong "a modal dialog from `open!`"]]
|
||||
[:p "You can put any content here, and even use shui components!"]
|
||||
[:p (shui/button {:on-click (fn [] (dialog-core/alert! [:p "nest"] {}))} "open nested dialog")]])
|
||||
{:title "Title"})]
|
||||
(shui/button
|
||||
{:on-click fc}
|
||||
"Imperative API: open!"))
|
||||
|
||||
(shui/button
|
||||
{:class "primary-yellow"
|
||||
:on-click (fn []
|
||||
(-> (dialog-core/alert!
|
||||
"a alert dialog from `alert!`"
|
||||
{:title [:div.flex.flex-row.space-x-2.items-center
|
||||
(shui/tabler-icon "alert-triangle" {:size 18})
|
||||
[:span "Alert"]]})
|
||||
(p/then #(js/console.log "=> alert (promise): " %))))}
|
||||
"Imperative API: alert!")
|
||||
|
||||
(shui/button
|
||||
{:class "primary-green"
|
||||
:on-click (fn []
|
||||
(-> (dialog-core/confirm!
|
||||
"a alert dialog from `confirm!`"
|
||||
{:title [:div.flex.flex-row.space-x-2.items-center
|
||||
(shui/tabler-icon "alert-triangle" {:size 18})
|
||||
[:span "Confirm"]]})
|
||||
(p/then #(js/console.log "=> confirm (promise): " %))
|
||||
(p/catch #(js/console.log "=> confirm (promise): " %))))}
|
||||
"Imperative API: confirm!")])
|
||||
|
||||
;; Alert
|
||||
(section-item "Alert"
|
||||
[:<>
|
||||
(shui/alert
|
||||
{:class "text-orange-rx-09 border-orange-rx-07-alpha mb-4"}
|
||||
(shui/tabler-icon "brand-soundcloud")
|
||||
(shui/alert-title "Title is SoundCloud")
|
||||
(shui/alert-description
|
||||
"content: radix colors for Logseq"))
|
||||
(shui/alert
|
||||
(shui/tabler-icon "brand-github")
|
||||
(shui/alert-title "GitHub")
|
||||
(shui/alert-description
|
||||
"content: radix colors for Logseq"))])
|
||||
|
||||
;; Slider
|
||||
[:div.grid.sm:grid-cols-8.gap-4
|
||||
[:div.col-span-4.mr-6
|
||||
(section-item "Slider" (shui/slider))]
|
||||
[:div.col-span-1
|
||||
(section-item "Switch"
|
||||
(shui/switch {:size :sm :class "relative top-[-8px]"}))]
|
||||
[:div.col-span-3.pl-4.pr-2
|
||||
(section-item "Select"
|
||||
(shui/select
|
||||
{:on-value-change (fn [v] (shui/toast! v :info))}
|
||||
;; trigger
|
||||
(shui/select-trigger
|
||||
(shui/select-value {:placeholder "Select a fruit"}))
|
||||
;; content
|
||||
(shui/select-content
|
||||
(shui/select-group
|
||||
(shui/select-label "Fruits")
|
||||
(shui/select-item {:value "apple"} "Apple")
|
||||
(shui/select-item {:value "pear"} "Pear")
|
||||
(shui/select-item {:value "grapes"} "Grapes")))))]]
|
||||
|
||||
;; Form
|
||||
(section-item "Form"
|
||||
[:<>
|
||||
(sample-form-basic)])
|
||||
|
||||
;; Card
|
||||
[:div.grid.sm:grid-cols-2.sm:gap-8
|
||||
(section-item "Card"
|
||||
(shui/card
|
||||
(shui/card-header
|
||||
(shui/card-title "Title")
|
||||
(shui/card-description "Description"))
|
||||
(shui/card-content "This is content")
|
||||
(shui/card-footer "Footer")))
|
||||
|
||||
(section-item "Skeleton"
|
||||
(shui/card
|
||||
(shui/card-header
|
||||
(shui/card-title
|
||||
(shui/skeleton {:class "h-4 w-1/2"}))
|
||||
(shui/card-description
|
||||
(shui/skeleton {:class "h-2 w-full"})))
|
||||
(shui/card-content
|
||||
(shui/skeleton {:class "h-3 mb-1"})
|
||||
(shui/skeleton {:class "h-3 mb-1"})
|
||||
(shui/skeleton {:class "h-3 w-2/3"}))
|
||||
|
||||
(shui/card-footer
|
||||
(shui/skeleton {:class "h-4 w-full mb-2"}))))]
|
||||
|
||||
;; Calendar
|
||||
[:div.grid.sm:grid-cols-2.sm:gap-8
|
||||
(section-item "Calendar"
|
||||
(shui/card
|
||||
{:class "inline-flex"}
|
||||
(shui/calendar {:on-day-click #(shui/toast! (.toString %) :success)})))
|
||||
(section-item "Date Picker"
|
||||
(sample-date-picker))]
|
||||
|
||||
[:hr.mb-80]]))
|
||||
|
||||
(defn- get-head-container
|
||||
[]
|
||||
(sel1 "#head"))
|
||||
|
||||
(defn- get-main-scroll-container
|
||||
[]
|
||||
(sel1 "#main-content-container"))
|
||||
|
||||
(hsx/defc sticky-table
|
||||
[]
|
||||
|
||||
(let [el-ref (hooks/use-ref nil)]
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(let [^js container (get-main-scroll-container)
|
||||
^js el (hooks/deref el-ref)
|
||||
^js cls (.-classList el)
|
||||
*ticking? (volatile! false)
|
||||
el-top (-> el (.getBoundingClientRect) (.-top))
|
||||
head-top (-> (get-head-container) (js/getComputedStyle) (.-height) (js/parseInt))
|
||||
translate (fn [offset]
|
||||
(set! (. (.-style el) -transform) (str "translate3d(0, " offset "px , 0)"))
|
||||
(if (zero? offset)
|
||||
(.remove cls "translated")
|
||||
(.add cls "translated")))
|
||||
*last-offset (volatile! 0)
|
||||
handle (fn []
|
||||
(let [scroll-top (js/parseInt (.-scrollTop container))
|
||||
offset (if (> (+ scroll-top head-top) el-top)
|
||||
(+ (- scroll-top el-top) head-top 1) 0)
|
||||
offset (js/parseInt offset)
|
||||
last-offset @*last-offset]
|
||||
(if (and (not (zero? last-offset))
|
||||
(not= offset last-offset))
|
||||
(let [dir (if (neg? (- offset last-offset)) -1 1)]
|
||||
(loop [offset' (+ last-offset dir)]
|
||||
(translate offset')
|
||||
(if (and (not= offset offset')
|
||||
(< (abs (- offset offset')) 100))
|
||||
(recur (+ offset' dir))
|
||||
(translate offset))))
|
||||
(translate offset))
|
||||
(vreset! *last-offset offset)))
|
||||
handler (fn [^js _e]
|
||||
(when (not @*ticking?)
|
||||
(js/window.requestAnimationFrame
|
||||
#(do (handle) (vreset! *ticking? false)))
|
||||
(vreset! *ticking? true)))]
|
||||
(.addEventListener container "scroll" handler)
|
||||
#(.removeEventListener container "scroll" handler)))
|
||||
[])
|
||||
|
||||
[:div.charlie-table
|
||||
[:div.charlie-table-header
|
||||
{:ref el-ref}
|
||||
[:strong "header"]]
|
||||
[:div.charlie-table-content
|
||||
[:strong "content"]]]))
|
||||
392
deps/shui/src/logseq/shui/demo2.cljs
vendored
Normal file
392
deps/shui/src/logseq/shui/demo2.cljs
vendored
Normal file
@@ -0,0 +1,392 @@
|
||||
(ns logseq.shui.demo2
|
||||
(:require [cljs-bean.core :as bean]
|
||||
[clojure.string :as string]
|
||||
[frontend.components.icon :refer [emojis-cp emojis icon-search]]
|
||||
[frontend.storage :as storage]
|
||||
[logseq.shui.hooks :as hooks]
|
||||
[logseq.shui.popup.core :refer [update-popup! get-popup]]
|
||||
[logseq.shui.select.multi :refer [x-select-content]]
|
||||
[logseq.shui.ui :as shui]
|
||||
[promesa.core :as p]
|
||||
[io.factorhouse.hsx.core :as hsx]))
|
||||
|
||||
(defn do-fetch!
|
||||
([action] (do-fetch! action nil))
|
||||
([action query-str]
|
||||
(-> (js/window.fetch
|
||||
(str "https://movies-api14.p.rapidapi.com/" (name action) (when query-str (str "?" query-str)))
|
||||
#js {:method "GET"
|
||||
:headers #js {:X-RapidAPI-Key "808ffd08c0mshc67d496f6024b46p164350jsn7b35179966c9",
|
||||
:X-RapidAPI-Host "movies-api14.p.rapidapi.com"}})
|
||||
(p/then #(.json %)))))
|
||||
|
||||
(hsx/defc multi-select-demo
|
||||
[]
|
||||
|
||||
[:div.sm:p-10
|
||||
[:h1.text-3xl.font-bold.border-b.pb-4.mb-8
|
||||
"Multi X Select"]
|
||||
|
||||
(let [[items set-items!] (hooks/use-state [])
|
||||
[q set-q!] (hooks/use-state "")
|
||||
[fetching? set-fetching?] (hooks/use-state nil)
|
||||
|
||||
[selected-items set-selected-items!]
|
||||
(hooks/use-state (storage/get :ls-demo-multi-selected-items))
|
||||
|
||||
rm-item! (fn [item-or-id]
|
||||
(set-selected-items!
|
||||
(remove #(or (= item-or-id %)
|
||||
(= item-or-id (str (:id %))))
|
||||
selected-items)))
|
||||
add-item! (fn [item] (set-selected-items! (conj selected-items item)))
|
||||
|
||||
[open? set-open!] (hooks/use-state false)]
|
||||
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(storage/set :ls-demo-multi-selected-items selected-items))
|
||||
[selected-items])
|
||||
|
||||
(shui/card
|
||||
(shui/card-header
|
||||
(shui/card-title "Search Movies")
|
||||
(shui/card-description "x multiselect for the remote items"))
|
||||
(shui/card-content
|
||||
|
||||
;; Basic
|
||||
(shui/dropdown-menu
|
||||
{:open open?}
|
||||
;; trigger
|
||||
(shui/dropdown-menu-trigger
|
||||
[:div.border.p-2.rounded.w-full.cursor-pointer.flex.items-center.gap-1.flex-wrap
|
||||
{:on-click (fn [^js e]
|
||||
(let [^js target (.-target e)]
|
||||
(if-let [^js c (some-> target (.closest ".close"))]
|
||||
(some-> (.-dataset c) (.-k) (rm-item!))
|
||||
(set-open! true))))}
|
||||
(for [{:keys [id original_title class poster_path]} selected-items]
|
||||
(shui/badge {:variant :secondary :class (str class " group relative")}
|
||||
[:span.flex.items-center.gap-1.flex-nowrap
|
||||
[:img {:src poster_path :class "w-[16px] scale-75"}]
|
||||
[:b original_title]]
|
||||
(shui/button
|
||||
{:variant :destructive
|
||||
:size :icon
|
||||
:data-k id
|
||||
:class "!rounded-full !h-4 !w-4 absolute top-[-7px] right-[-3px] group-hover:visible invisible close"}
|
||||
(shui/tabler-icon "x" {:size 12}))))
|
||||
(shui/button {:variant :link :size :sm} "+")])
|
||||
;; content
|
||||
(x-select-content items selected-items
|
||||
{;; test item render
|
||||
:open? open?
|
||||
:close! #(set-open! false)
|
||||
:search-enabled? true
|
||||
:search-key q
|
||||
:search-fn (fn [items]
|
||||
(when (not fetching?) items))
|
||||
:on-search-key-change (fn [v]
|
||||
(set-q! v)
|
||||
(if (string/blank? v)
|
||||
(set-items! [])
|
||||
(when (not fetching?)
|
||||
(set-fetching? true)
|
||||
(-> (do-fetch! :search (str "query=" v))
|
||||
(p/then #(when-let [ret (bean/->clj %)]
|
||||
(when-let [items (:contents ret)]
|
||||
(set-items! (map (fn [item] (assoc item :id (:_id item))) (take 12 items))))))
|
||||
(p/finally #(set-fetching? false))))))
|
||||
|
||||
:item-render (fn [item {:keys [selected?]}]
|
||||
(if item
|
||||
(shui/dropdown-menu-checkbox-item
|
||||
{:checked selected?
|
||||
:on-click (fn []
|
||||
(if selected?
|
||||
(rm-item! item)
|
||||
(add-item! item))
|
||||
;(set-open! false)
|
||||
)}
|
||||
[:div.flex.items-center.gap-2
|
||||
[:span [:img {:src (:poster_path item)
|
||||
:class "w-[20px]"}]]
|
||||
[:span.flex.flex-col
|
||||
[:b (:original_title item)]
|
||||
[:small.opacity-50
|
||||
{:class "text-[10px]"}
|
||||
(:release_date item)]]])
|
||||
(shui/dropdown-menu-separator)))
|
||||
|
||||
:head-render (fn [] (when (and fetching? (not (string/blank? q)))
|
||||
[:b.flex.items-center.justify-center.py-4
|
||||
(shui/tabler-icon "loader" {:class "animate-spin"})]))
|
||||
;:foot-render (fn [] [:b "footer"])
|
||||
|
||||
:content-props
|
||||
{:align "start"
|
||||
:class "w-80"}})))))
|
||||
|
||||
[:hr]
|
||||
|
||||
(let [items [{:key 1 :value "Apple" :class "bg-gray-800 text-gray-50"}
|
||||
{:key 2 :value "Orange" :class "bg-orange-700 text-gray-50"}
|
||||
{:key 3 :value "Pear"}
|
||||
{:key 4 :value "Banana" :class "bg-yellow-700 text-gray-700"}]
|
||||
|
||||
[selected-items set-selected-items!]
|
||||
(hooks/use-state [(second items)])
|
||||
|
||||
[search? set-search?] (hooks/use-state false)
|
||||
|
||||
rm-item! (fn [item] (set-selected-items! (remove #(= item %) selected-items)))
|
||||
add-item! (fn [item] (set-selected-items! (conj selected-items item)))
|
||||
on-chosen (fn [item {:keys [selected?]}]
|
||||
(if (true? selected?)
|
||||
(rm-item! item) (add-item! item)))
|
||||
[open? set-open!] (hooks/use-state false)]
|
||||
|
||||
(shui/card
|
||||
(shui/card-header
|
||||
(shui/card-title "Basic")
|
||||
(shui/card-description "x multiselect for shui"))
|
||||
(shui/card-content
|
||||
[:label.block.flex.items-center.pb-3.cursor-pointer
|
||||
(shui/checkbox {:checked search?
|
||||
:on-click #(set-search? (not search?))})
|
||||
[:small.pl-2 "Enable basic search input"]]
|
||||
;; Basic
|
||||
(shui/dropdown-menu
|
||||
{:open open?}
|
||||
;; trigger
|
||||
(shui/dropdown-menu-trigger
|
||||
[:p.border.p-2.rounded.w-full.cursor-pointer
|
||||
{:on-click #(set-open! true)}
|
||||
(for [{:keys [key value class]} selected-items]
|
||||
(shui/badge {:variant :secondary :class class} (str "#" key " " value)))
|
||||
(shui/button {:variant :link :size :sm} "+")])
|
||||
;; content
|
||||
(x-select-content items selected-items
|
||||
{:close! #(set-open! false)
|
||||
:search-enabled? search?
|
||||
:search-key-render (fn [q {:keys [items]}]
|
||||
(when (and (not (string/blank? q))
|
||||
(not (seq items)))
|
||||
[:b.flex.items-center.justify-center.py-4.gap-2.font-normal.opacity-80
|
||||
(shui/tabler-icon "lemon") [:small "No fruits!"]]))
|
||||
:on-chosen on-chosen
|
||||
:value-render (fn [v {:keys [selected?]}]
|
||||
(if selected?
|
||||
[:b.text-red-800 v]
|
||||
[:b.text-green-800 v]))
|
||||
:content-props
|
||||
{:class "w-48"}})))))
|
||||
|
||||
[:hr]
|
||||
|
||||
(let [[items _set-items!]
|
||||
(hooks/use-state
|
||||
[{:key 1 :value "Apple" :class "bg-gray-800 text-gray-50"}
|
||||
{:key 2 :value "Orange" :class "bg-orange-700 text-gray-50"}
|
||||
nil
|
||||
{:key 3 :value "Pear"}
|
||||
{:key 4 :value "Banana" :class "bg-yellow-700 text-gray-700"}])
|
||||
|
||||
[selected-items set-selected-items!]
|
||||
(hooks/use-state [(last items) (first items)])
|
||||
|
||||
rm-item! (fn [item] (set-selected-items! (remove #(= item %) selected-items)))
|
||||
add-item! (fn [item] (set-selected-items! (conj selected-items item)))
|
||||
_on-chosen (fn [item {:keys [selected?]}]
|
||||
(if (true? selected?)
|
||||
(rm-item! item) (add-item! item)))
|
||||
[open? set-open!] (hooks/use-state false)]
|
||||
|
||||
(shui/card
|
||||
(shui/card-header
|
||||
(shui/card-title "Search & Custom")
|
||||
(shui/card-description "x multiselect for shui"))
|
||||
(shui/card-content
|
||||
|
||||
;; Basic
|
||||
(shui/dropdown-menu
|
||||
{:open open?}
|
||||
;; trigger
|
||||
(shui/dropdown-menu-trigger
|
||||
[:p.border.p-2.rounded.w-full.cursor-pointer
|
||||
{:on-click #(set-open! true)}
|
||||
(for [{:keys [key value class]} selected-items]
|
||||
(shui/badge {:variant :secondary :class class} (str "#" key " " value)))
|
||||
(shui/button {:variant :link :size :sm} "+")])
|
||||
;; content
|
||||
(x-select-content items selected-items
|
||||
{;; test item render
|
||||
:open? open?
|
||||
:close! #(set-open! false)
|
||||
:search-enabled? true
|
||||
:item-render (fn [item {:keys [selected?]}]
|
||||
(if item
|
||||
(shui/dropdown-menu-checkbox-item
|
||||
{:checked selected?
|
||||
:on-click (fn []
|
||||
(if selected?
|
||||
(rm-item! item)
|
||||
(add-item! item)))}
|
||||
(:value item))
|
||||
(shui/dropdown-menu-separator)))
|
||||
|
||||
:search-key-render
|
||||
(fn [k {:keys [_items x-item exist-fn]}]
|
||||
(when (and
|
||||
(not (string/blank? k))
|
||||
(not (exist-fn)))
|
||||
(x-item
|
||||
{:on-click (fn []
|
||||
(shui/toast! (str "Create: " k) :warning)
|
||||
(set-open! false))}
|
||||
(str "+ create: " k))))
|
||||
|
||||
;:head-render (fn [] [:b "header"])
|
||||
;:foot-render (fn [] [:b "footer"])
|
||||
:content-props
|
||||
{:align "start"
|
||||
:class "w-48"}})))))])
|
||||
|
||||
(hsx/defc icon-picker-demo
|
||||
[]
|
||||
[:div.sm:p-10
|
||||
[:h1.text-3xl.font-bold.border-b.pb-4.mb-8
|
||||
"UI X Emojis & Icons Picker"]
|
||||
|
||||
[:div.border.rounded.bg-gray-01.overflow-hidden
|
||||
{:class "w-fit"}
|
||||
(icon-search {})]])
|
||||
|
||||
(hsx/defc popup-demo
|
||||
[]
|
||||
[:div.sm:p-10
|
||||
[:h1.text-3xl.font-bold.border-b.pb-4 "UI X Popup"]
|
||||
|
||||
(let [[emoji set-emoji!] (hooks/use-state nil)
|
||||
[q set-q!] (hooks/use-state "")
|
||||
*q-ref (hooks/use-ref nil)
|
||||
|
||||
emoji-picker
|
||||
(fn [_nested?]
|
||||
[:p.py-4
|
||||
"Choose a inline "
|
||||
[:a.underline
|
||||
{:on-click
|
||||
#(shui/popup-show! %
|
||||
(fn [_config]
|
||||
[:div.max-h-72.overflow-auto.p-1
|
||||
(emojis-cp (take 80 emojis)
|
||||
{:on-chosen
|
||||
(fn [_ t]
|
||||
(set-emoji! t)
|
||||
(shui/popup-hide-all!))})])
|
||||
{:content-props {:class "w-72 p-0"}
|
||||
:as-dropdown? true})}
|
||||
(if emoji [:strong.px-1.text-6xl [:em-emoji emoji]] "emoji :O")] "."])]
|
||||
[:<>
|
||||
(emoji-picker nil)
|
||||
|
||||
[:p.py-4
|
||||
(shui/button
|
||||
{:variant :secondary
|
||||
:on-click #(shui/popup-show! %
|
||||
(fn []
|
||||
[:p.p-4
|
||||
(emoji-picker true)]))}
|
||||
"Play a nested x popup.")]
|
||||
|
||||
[:p.py-4
|
||||
(let [gen-content
|
||||
(fn [q]
|
||||
[:p.x-input-popup-content.bg-green-rx-06
|
||||
(shui/button {:on-click #(shui/toast! "Just a joke :)")} "play a magic")
|
||||
(emoji-picker true)
|
||||
[:strong.px-1.text-6xl q]])]
|
||||
(shui/input
|
||||
{:placeholder "Select a fruit."
|
||||
:ref *q-ref
|
||||
:value q
|
||||
:on-change (fn [^js e]
|
||||
(let [val (.-value (.-target e))]
|
||||
(set-q! val)
|
||||
(update-popup! :select-a-fruit-input [:content] (gen-content val))))
|
||||
:class "w-1/5"
|
||||
:on-focus (fn [^js e]
|
||||
(let [id :select-a-fruit-input
|
||||
[_ popup] (get-popup id)]
|
||||
(if (not popup)
|
||||
(shui/popup-show! (.-target e)
|
||||
(gen-content q)
|
||||
{:id id
|
||||
:align "start"
|
||||
:content-props
|
||||
{:class "x-input-popup-content"
|
||||
:onPointerDownOutside
|
||||
(fn [^js e]
|
||||
(js/console.log "===>> onPointerDownOutside:" e (hooks/deref *q-ref))
|
||||
(when-let [q-ref (hooks/deref *q-ref)]
|
||||
(let [^js target (or (.-relatedTarget e)
|
||||
(.-target e))]
|
||||
(js/console.log "t:" target)
|
||||
(when (and
|
||||
(not (.contains q-ref target))
|
||||
(not (.closest target ".x-input-popup-content")))
|
||||
(shui/popup-hide! id)))))
|
||||
:onOpenAutoFocus #(.preventDefault %)}})
|
||||
|
||||
;; update content
|
||||
(update-popup! id [:content]
|
||||
(gen-content q)))))
|
||||
;:on-blur (fn [^js e]
|
||||
; (let [^js target (.-relatedTarget e)]
|
||||
; (js/console.log "==>>>" target)
|
||||
; (when-not (.closest target ".x-input-popup-content")
|
||||
; (hide-x-popup! :select-a-fruit-input))))
|
||||
}))]
|
||||
|
||||
[:div.w-full.p-4.border.rounded.dotted.h-48.mt-8.bg-gray-02
|
||||
{:on-click #(shui/popup-show! %
|
||||
(->> (range 8)
|
||||
(map (fn [it]
|
||||
(shui/dropdown-menu-item
|
||||
{:on-select (fn []
|
||||
(shui/toast! it)
|
||||
(shui/popup-hide-all!))}
|
||||
[:strong it]))))
|
||||
{:as-dropdown? true
|
||||
:content-props {:class "w-48"}})
|
||||
:on-context-menu #(shui/popup-show! %
|
||||
[:h1.text-3xl.font-bold "hi x popup for custom context menu!"])}]])])
|
||||
|
||||
(hsx/defc custom-trigger-content
|
||||
[]
|
||||
[:p
|
||||
[:code "more content"] [:br]
|
||||
(shui/input {:auto-focus true}) [:br]
|
||||
(shui/button "select sth")])
|
||||
|
||||
(hsx/defc sample-dropdown-trigger
|
||||
[]
|
||||
|
||||
[:div.py-4
|
||||
[:h1.text-3xl.font-bold.border-b.pb-4 "Sample dropdown/menu trigger"]
|
||||
[:div.py-4
|
||||
(shui/dropdown-menu
|
||||
(shui/dropdown-menu-trigger
|
||||
{:as-child true}
|
||||
(shui/trigger-child-wrap
|
||||
{:class "border p-6 border"}
|
||||
(custom-trigger-content)))
|
||||
(shui/dropdown-menu-content
|
||||
(shui/dropdown-menu-item "A item")
|
||||
(shui/dropdown-menu-item "B item")
|
||||
(shui/dropdown-menu-item "C item")))]])
|
||||
|
||||
(hsx/defc page
|
||||
[]
|
||||
(sample-dropdown-trigger))
|
||||
372
deps/shui/src/logseq/shui/dialog/core.cljs
vendored
Normal file
372
deps/shui/src/logseq/shui/dialog/core.cljs
vendored
Normal file
@@ -0,0 +1,372 @@
|
||||
(ns logseq.shui.dialog.core
|
||||
(:require [io.factorhouse.hsx.core :as hsx]
|
||||
[logseq.shui.base.core :as base]
|
||||
[logseq.shui.form.core :as form]
|
||||
[logseq.shui.hooks :as hooks]
|
||||
[logseq.shui.util :as util]
|
||||
[medley.core :as medley]
|
||||
[promesa.core :as p]))
|
||||
|
||||
;; provider
|
||||
(def dialog (util/ui-wrap "Dialog"))
|
||||
(def dialog-portal (util/ui-wrap "DialogPortal"))
|
||||
(def alert-dialog (util/ui-wrap "AlertDialog"))
|
||||
(def alert-dialog-portal (util/ui-wrap "AlertDialogPortal"))
|
||||
|
||||
;; ui
|
||||
(def dialog-overlay (util/ui-wrap "DialogOverlay"))
|
||||
(def dialog-close (util/ui-wrap "DialogClose"))
|
||||
(def dialog-trigger (util/ui-wrap "DialogTrigger"))
|
||||
(def dialog-content (util/ui-wrap "DialogContent"))
|
||||
(def dialog-header (util/ui-wrap "DialogHeader"))
|
||||
(def dialog-footer (util/ui-wrap "DialogFooter"))
|
||||
(def dialog-title (util/ui-wrap "DialogTitle"))
|
||||
(def dialog-description (util/ui-wrap "DialogDescription"))
|
||||
(def alert-dialog-overlay (util/ui-wrap "AlertDialogOverlay"))
|
||||
(def alert-dialog-trigger (util/ui-wrap "AlertDialogTrigger"))
|
||||
(def alert-dialog-content (util/ui-wrap "AlertDialogContent"))
|
||||
(def alert-dialog-header (util/ui-wrap "AlertDialogHeader"))
|
||||
(def alert-dialog-title (util/ui-wrap "AlertDialogTitle"))
|
||||
(def alert-dialog-description (util/ui-wrap "AlertDialogDescription"))
|
||||
(def alert-dialog-footer (util/ui-wrap "AlertDialogFooter"))
|
||||
(def alert-dialog-action (util/ui-wrap "AlertDialogAction"))
|
||||
(def alert-dialog-cancel (util/ui-wrap "AlertDialogCancel"))
|
||||
|
||||
(defn interpret-vals
|
||||
[config ks & args]
|
||||
(reduce (fn [config k]
|
||||
(let [v (get config k)
|
||||
v (if (fn? v) (apply v args) v)]
|
||||
(if (vector? v) (assoc config k (hsx/create-element v)) config)))
|
||||
config ks))
|
||||
|
||||
;; {:id :title :description :content :footer :open? :on-close ...}
|
||||
(def ^:private *dialogs (atom []))
|
||||
(def ^:private *id (atom 0))
|
||||
(def ^:private gen-id #(reset! *id (inc @*id)))
|
||||
|
||||
(defn get-dialog
|
||||
[id]
|
||||
(when id
|
||||
(some->> (medley/indexed @*dialogs)
|
||||
(filter #(= id (:id (second %)))) (first))))
|
||||
|
||||
(defn- open-dialogs
|
||||
[]
|
||||
(filter :open? @*dialogs))
|
||||
|
||||
(defn update-dialog!
|
||||
[id ks val & {:keys [closing?]}]
|
||||
(when-let [[index config] (get-dialog id)]
|
||||
(let [ks (if (coll? ks) ks [ks])
|
||||
config (if (nil? val)
|
||||
(medley/dissoc-in config ks)
|
||||
(assoc-in config ks val))]
|
||||
(swap! *dialogs assoc index config)
|
||||
(when (and (false? (:open? config))
|
||||
(fn? (:on-close config))
|
||||
(not closing?))
|
||||
((:on-close config) id)))))
|
||||
|
||||
(defn upsert-dialog!
|
||||
[config]
|
||||
(when-let [_id (:id config)]
|
||||
(swap! *dialogs conj config)))
|
||||
|
||||
(defn detach-dialog!
|
||||
[id]
|
||||
(when-let [[index] (get-dialog id)]
|
||||
(swap! *dialogs #(->> % (medley/remove-nth index) (vec)))))
|
||||
|
||||
(defn has-dialog?
|
||||
[]
|
||||
(boolean (some :open? @*dialogs)))
|
||||
|
||||
(defn- close-prevented?
|
||||
[handler-result ^js event-details ^js native-event]
|
||||
(when (or (false? handler-result)
|
||||
(some-> native-event (.-defaultPrevented))
|
||||
(some-> event-details (.-isCanceled)))
|
||||
(some-> event-details (.cancel))
|
||||
true))
|
||||
|
||||
(defn- call-close-handler!
|
||||
[handler ^js event-details]
|
||||
(let [native-event (some-> event-details (.-event))
|
||||
result (when (fn? handler) (handler native-event))]
|
||||
(close-prevented? result event-details native-event)))
|
||||
|
||||
;; apis
|
||||
(declare close!)
|
||||
|
||||
(defn open!
|
||||
[content-or-config & config']
|
||||
(let [config (if (map? content-or-config)
|
||||
content-or-config
|
||||
{:content content-or-config})
|
||||
content (:content config)
|
||||
generated-id (gen-id)
|
||||
config (merge {:id generated-id :open? true} config (first config'))
|
||||
id (:id config)
|
||||
config (cond-> config
|
||||
(nil? (:close config))
|
||||
(assoc :close #(close! id)))
|
||||
config (cond-> config
|
||||
(fn? content)
|
||||
(assoc :content (content config)))]
|
||||
(upsert-dialog! (update config :content-props
|
||||
(fn [content-props]
|
||||
(merge {:onCloseAutoFocus #(.preventDefault %)}
|
||||
content-props))))))
|
||||
|
||||
(defn alert!
|
||||
[content-or-config & config']
|
||||
(let [deferred (p/deferred)]
|
||||
(open! content-or-config
|
||||
(merge {:alert? :default :deferred deferred} (first config')))
|
||||
(p/promise deferred)))
|
||||
|
||||
(defn confirm!
|
||||
[content-or-config & config']
|
||||
(alert! content-or-config (assoc (first config') :alert? :confirm)))
|
||||
|
||||
(defn get-last-dialog-id
|
||||
[]
|
||||
(some-> (last (open-dialogs)) (:id)))
|
||||
|
||||
(defn get-first-dialog-id
|
||||
[]
|
||||
(some-> (first (open-dialogs)) (:id)))
|
||||
|
||||
(defn- top-dialog?
|
||||
[id]
|
||||
(= id (get-last-dialog-id)))
|
||||
|
||||
(defn- prevent-dismiss!
|
||||
[^js event-details]
|
||||
(some-> event-details (.-event) (.preventDefault))
|
||||
(some-> event-details (.cancel))
|
||||
true)
|
||||
|
||||
(defn close!
|
||||
([] (close! (get-last-dialog-id)))
|
||||
([id] (update-dialog! id :open? false {:closing? true})))
|
||||
|
||||
(defn close-all! []
|
||||
(doseq [{:keys [id]} @*dialogs]
|
||||
(close! id)))
|
||||
|
||||
;; components
|
||||
(hsx/defc dialog-inner
|
||||
[config]
|
||||
(let [{:keys [id title description content footer on-open-change align open?
|
||||
auto-width? close-btn? root-props content-props
|
||||
onEscapeKeyDown onPointerDownOutside]} config
|
||||
props (dissoc config
|
||||
:id :title :description :content :footer :auto-width? :close-btn?
|
||||
:close :align :on-open-change :open? :root-props :content-props
|
||||
:onEscapeKeyDown :onPointerDownOutside)
|
||||
props (assoc-in props [:overlay-props :data-align] (name (or align :center)))]
|
||||
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(when (false? open?)
|
||||
(detach-dialog! id)))
|
||||
[open?])
|
||||
|
||||
(dialog
|
||||
(merge root-props
|
||||
{:key (str "dialog-" id)
|
||||
:open open?
|
||||
:on-open-change (fn [v e]
|
||||
(let [set-open! #(update-dialog! id :open? %)
|
||||
reason (some-> e (.-reason))
|
||||
escape-handler (or (:onEscapeKeyDown content-props)
|
||||
onEscapeKeyDown)
|
||||
pointer-handler (or (:onPointerDownOutside content-props)
|
||||
onPointerDownOutside)
|
||||
prevented? (when (false? v)
|
||||
(cond
|
||||
(and (contains? #{"escape-key" "outside-press"} reason)
|
||||
(not (top-dialog? id)))
|
||||
(prevent-dismiss! e)
|
||||
|
||||
(= reason "escape-key")
|
||||
(call-close-handler! escape-handler e)
|
||||
|
||||
(= reason "outside-press")
|
||||
(call-close-handler! pointer-handler e)
|
||||
|
||||
:else false))]
|
||||
(when-not prevented?
|
||||
(if (fn? on-open-change)
|
||||
(on-open-change {:value v :set-open! set-open!})
|
||||
(set-open! v)))))})
|
||||
(let [onPointerDownOutside (:onPointerDownOutside content-props)
|
||||
onEscapeKeyDown (:onEscapeKeyDown content-props)
|
||||
handle-key-escape! (fn [^js e]
|
||||
(if (top-dialog? id)
|
||||
(if (fn? onEscapeKeyDown)
|
||||
(onEscapeKeyDown e)
|
||||
(do
|
||||
(.preventDefault e)
|
||||
(close! id)))
|
||||
(.preventDefault e)))
|
||||
handle-pointer-down-outside! (fn [^js e]
|
||||
(if (top-dialog? id)
|
||||
(when (fn? onPointerDownOutside)
|
||||
(onPointerDownOutside e))
|
||||
(.preventDefault e)))
|
||||
content-props (-> content-props
|
||||
(dissoc :onEscapeKeyDown :onPointerDownOutside)
|
||||
(assoc :onEscapeKeyDown handle-key-escape!
|
||||
:onPointerDownOutside handle-pointer-down-outside!))]
|
||||
(dialog-content
|
||||
(cond-> (merge props content-props)
|
||||
auto-width? (assoc :data-auto-width true)
|
||||
(false? close-btn?) (assoc :data-close-btn false))
|
||||
|
||||
;; Title component is required for accessible dialog content.
|
||||
(dialog-title {:class (when (nil? title) "hidden")} title)
|
||||
(when description (dialog-description description))
|
||||
|
||||
(when content
|
||||
[:div.ui__dialog-main-content content])
|
||||
|
||||
(when footer
|
||||
(dialog-footer footer)))))))
|
||||
|
||||
(hsx/defc alert-inner
|
||||
[config]
|
||||
(let [{:keys [id title description content footer deferred open? ok-label
|
||||
root-props content-props]} config
|
||||
props (dissoc config
|
||||
:id :title :description :content :footer :deferred :open? :alert? :ok-label
|
||||
:close :on-close :root-props :content-props
|
||||
:outside-cancel? :cancel-label :data-reminder :data-reminder-label)
|
||||
ok-label (or ok-label "OK")]
|
||||
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(when (false? open?)
|
||||
(detach-dialog! id)))
|
||||
[open?])
|
||||
|
||||
(alert-dialog
|
||||
(merge root-props
|
||||
{:key (str "alert-" id)
|
||||
:open open?
|
||||
:on-open-change (fn [v e]
|
||||
(let [reason (some-> e (.-reason))]
|
||||
(if (and (false? v)
|
||||
(= reason "escape-key")
|
||||
(not (top-dialog? id)))
|
||||
(prevent-dismiss! e)
|
||||
(update-dialog! id :open? v))))})
|
||||
(alert-dialog-content (merge props content-props)
|
||||
(when (or title description)
|
||||
(alert-dialog-header
|
||||
{:class "ui__alert-dialog-header"}
|
||||
(when title (alert-dialog-title title))
|
||||
(when description (alert-dialog-description description))))
|
||||
|
||||
(when content
|
||||
[:div.ui__alert-dialog-main-content content])
|
||||
|
||||
(alert-dialog-footer
|
||||
{:class "ui__alert-dialog-footer"}
|
||||
(if footer
|
||||
footer
|
||||
[:<>
|
||||
(base/button
|
||||
{:key "ok"
|
||||
:on-click #(do (close!) (p/resolve! deferred true))
|
||||
:size :sm} ok-label)])))
|
||||
)))
|
||||
|
||||
(hsx/defc confirm-inner
|
||||
[config]
|
||||
(let [{:keys [id deferred outside-cancel? data-reminder data-reminder-label
|
||||
cancel-label ok-label]} config
|
||||
reminder? (boolean (and id data-reminder))
|
||||
[ready?, set-ready!] (hooks/use-state (not reminder?))
|
||||
*ok-ref (hooks/use-ref nil)
|
||||
*reminder-ref (hooks/use-ref nil)
|
||||
cancel-label (or cancel-label "Cancel")
|
||||
ok-label (or ok-label "OK")
|
||||
footer [:<>
|
||||
[:span.flex.items-center.pt-1
|
||||
(when (and id data-reminder data-reminder-label)
|
||||
[:label.flex.items-center.gap-1.text-sm
|
||||
(form/checkbox {:ref *reminder-ref})
|
||||
[:span.opacity-50 data-reminder-label]])]
|
||||
[:span.flex.gap-2
|
||||
(base/button
|
||||
{:key "cancel"
|
||||
:on-click #(do (close!) (p/reject! deferred false))
|
||||
:variant :outline
|
||||
:size :sm} cancel-label)
|
||||
(base/button
|
||||
{:key "ok"
|
||||
:ref *ok-ref
|
||||
:on-click (fn []
|
||||
(when-let [^js reminder (and id data-reminder (hooks/deref *reminder-ref))]
|
||||
(when (= "checked" (.-state (.-dataset reminder)))
|
||||
(js/localStorage.setItem (str id) (js/Date.now))))
|
||||
(close!)
|
||||
(p/resolve! deferred true))
|
||||
:size :sm} ok-label)]]]
|
||||
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(when ready?
|
||||
(let [timeout (js/setTimeout
|
||||
#(some-> (hooks/deref *ok-ref) (.focus)) 128)]
|
||||
#(js/clearTimeout timeout))))
|
||||
[ready?])
|
||||
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(try
|
||||
(if-let [reminder-v (and reminder? (js/localStorage.getItem (str id)))]
|
||||
(if (< (- (js/Date.now) reminder-v) (* 1000 60 10))
|
||||
(do (detach-dialog! id) (p/resolve! deferred true))
|
||||
(set-ready! true))
|
||||
(set-ready! true))
|
||||
(catch js/Error _e
|
||||
(set-ready! true))))
|
||||
[])
|
||||
|
||||
(when ready?
|
||||
(alert-inner
|
||||
(assoc config
|
||||
:data-mode :confirm
|
||||
:overlay-props
|
||||
{:on-click #(when outside-cancel? (close!) (p/reject! deferred nil))}
|
||||
:footer footer)))))
|
||||
|
||||
(defn- render-dialog
|
||||
[config]
|
||||
(let [id (:id config)
|
||||
alert? (:alert? config)
|
||||
config (interpret-vals config
|
||||
[:title :description :content :footer]
|
||||
{:id id})]
|
||||
(case alert?
|
||||
:default
|
||||
(alert-inner config)
|
||||
|
||||
:confirm
|
||||
(confirm-inner config)
|
||||
|
||||
(dialog-inner config))))
|
||||
|
||||
(defn- render-dialogs
|
||||
[configs]
|
||||
(when-let [configs (seq (filter map? configs))]
|
||||
(into [:<>] (map render-dialog configs))))
|
||||
|
||||
(hsx/defc install-dialogs
|
||||
[]
|
||||
(let [[dialogs _set-dialogs!] (util/use-atom *dialogs)]
|
||||
(render-dialogs dialogs)))
|
||||
58
deps/shui/src/logseq/shui/form/core.cljs
vendored
Normal file
58
deps/shui/src/logseq/shui/form/core.cljs
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
(ns logseq.shui.form.core
|
||||
(:require [io.factorhouse.hsx.core :as hsx]
|
||||
[logseq.shui.util :as util]
|
||||
[cljs-bean.core :as bean]))
|
||||
|
||||
|
||||
;; State
|
||||
(def form-provider (util/ui-wrap "Form" {:static? false}))
|
||||
(def form-field' (util/ui-wrap "FormField" {:static? false}))
|
||||
|
||||
(hsx/defc form-field
|
||||
[render' & args]
|
||||
(let [[props render']
|
||||
(if (map? render')
|
||||
[render' (first args)]
|
||||
[(first args) render'])
|
||||
_ (assert (contains? props :name) ":name is required for <ui/form-field>")
|
||||
render (fn [^js ctx]
|
||||
;; TODO: convert field-state?
|
||||
(render'
|
||||
(bean/bean (.-field ctx))
|
||||
(some-> (.-fieldState ctx) (.-error) (bean/bean))
|
||||
(bean/bean (.-fieldState ctx))
|
||||
ctx))]
|
||||
(form-field' (assoc props :render render))))
|
||||
|
||||
(def form-control (util/ui-wrap "FormControl" {:static? false}))
|
||||
|
||||
(def ^js yup (util/ui-get "yup"))
|
||||
(def yup-resolver (util/ui-get "yupResolver"))
|
||||
|
||||
;; Hooks
|
||||
;; https://react-hook-form.com/docs/useform#resolver
|
||||
(def use-form' (util/ui-get "useForm"))
|
||||
(def use-form-context (util/ui-get "useFormContext"))
|
||||
|
||||
(defn use-form
|
||||
([] (use-form {}))
|
||||
([opts]
|
||||
(let [yup-schema (:yupSchema opts)
|
||||
^js form-methods (use-form' (bean/->js
|
||||
(cond-> opts
|
||||
(not (nil? yup-schema))
|
||||
(assoc :resolver (yup-resolver yup-schema)))))]
|
||||
;; NOTE: just shallow convert return object!
|
||||
(bean/bean form-methods))))
|
||||
|
||||
;; UI
|
||||
(def form-item (util/ui-wrap "FormItem"))
|
||||
(def form-label (util/ui-wrap "FormLabel"))
|
||||
(def form-description (util/ui-wrap "FormDescription"))
|
||||
(def form-message (util/ui-wrap "FormMessage"))
|
||||
(def input (util/ui-wrap "Input"))
|
||||
(def textarea (util/ui-wrap "Textarea"))
|
||||
(def switch (util/ui-wrap "Switch"))
|
||||
(def checkbox (util/ui-wrap "Checkbox"))
|
||||
(def radio-group (util/ui-wrap "RadioGroup"))
|
||||
(def radio-group-item (util/ui-wrap "RadioGroupItem"))
|
||||
25
deps/shui/src/logseq/shui/form/password.cljs
vendored
Normal file
25
deps/shui/src/logseq/shui/form/password.cljs
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
(ns logseq.shui.form.password
|
||||
(:require [clojure.string :as string]
|
||||
[logseq.shui.base.core :as base-core]
|
||||
[logseq.shui.form.core :as form-core]
|
||||
[logseq.shui.hooks :as hooks]
|
||||
[logseq.shui.icon.v2 :as icon-v2]
|
||||
[io.factorhouse.hsx.core :as hsx]))
|
||||
|
||||
(hsx/defc toggle-password
|
||||
[option]
|
||||
(let [[visible? set-visible!] (hooks/use-state false)]
|
||||
[:div.ls-toggle-password-input.relative
|
||||
(form-core/input
|
||||
(merge
|
||||
option
|
||||
{:type (or (:type option)
|
||||
(if visible? "text" "password"))}))
|
||||
(when-not (string/blank? (:value option))
|
||||
(base-core/button
|
||||
{:variant :ghost
|
||||
:class "absolute right-1"
|
||||
:style {:top 6}
|
||||
:size :sm
|
||||
:on-click #(set-visible! (not visible?))}
|
||||
(icon-v2/root (if visible? "eye-off" "eye"))))]))
|
||||
291
deps/shui/src/logseq/shui/hooks.cljs
vendored
Normal file
291
deps/shui/src/logseq/shui/hooks.cljs
vendored
Normal file
@@ -0,0 +1,291 @@
|
||||
(ns logseq.shui.hooks
|
||||
"React custom hooks."
|
||||
(:refer-clojure :exclude [deref])
|
||||
(:require ["react" :as react]
|
||||
[goog.functions :as gfun]))
|
||||
|
||||
(defn- memo-deps
|
||||
[equal-fn deps]
|
||||
(let [equal-fn (or equal-fn =)
|
||||
^js deps-ref (react/useRef deps)]
|
||||
(when-not (equal-fn (.-current deps-ref) deps)
|
||||
(set! (.-current deps-ref) deps))
|
||||
(.-current deps-ref)))
|
||||
|
||||
(defn- deps-array
|
||||
[deps equal-fn]
|
||||
(if (empty? deps)
|
||||
#js[]
|
||||
#js[(memo-deps equal-fn deps)]))
|
||||
|
||||
(defn- effect-result
|
||||
[result]
|
||||
(if (fn? result)
|
||||
result
|
||||
js/undefined))
|
||||
|
||||
(defn use-memo
|
||||
[f deps & {:keys [equal-fn]}]
|
||||
(react/useMemo f (deps-array deps equal-fn)))
|
||||
|
||||
(defn use-effect!
|
||||
"setup-fn will be invoked every render of component when no deps arg provided"
|
||||
([setup-fn]
|
||||
(assert (fn? setup-fn) "use-effect! setup-fn should be a function")
|
||||
(react/useEffect (fn []
|
||||
(let [result (setup-fn)]
|
||||
(effect-result result)))))
|
||||
([setup-fn deps & {:keys [equal-fn]}]
|
||||
(assert (fn? setup-fn) "use-effect! setup-fn should be a function")
|
||||
(react/useEffect (fn []
|
||||
(let [result (setup-fn)]
|
||||
(effect-result result)))
|
||||
(deps-array deps equal-fn))))
|
||||
|
||||
(defn use-layout-effect!
|
||||
([setup-fn]
|
||||
(assert (fn? setup-fn) "use-layout-effect! setup-fn should be a function")
|
||||
(react/useLayoutEffect (fn []
|
||||
(let [result (setup-fn)]
|
||||
(effect-result result)))))
|
||||
([setup-fn deps & {:keys [equal-fn]}]
|
||||
(assert (fn? setup-fn) "use-layout-effect! setup-fn should be a function")
|
||||
(react/useLayoutEffect (fn []
|
||||
(let [result (setup-fn)]
|
||||
(effect-result result)))
|
||||
(deps-array deps equal-fn))))
|
||||
|
||||
(defn use-callback
|
||||
[callback deps & {:keys [equal-fn]}]
|
||||
(react/useCallback callback (deps-array deps equal-fn)))
|
||||
|
||||
(defn- event-target
|
||||
[target]
|
||||
(cond
|
||||
(fn? target)
|
||||
(target)
|
||||
|
||||
(and target (not (undefined? (.-current target))))
|
||||
(.-current target)
|
||||
|
||||
:else
|
||||
target))
|
||||
|
||||
(defn use-event-listener
|
||||
([target event-name handler deps]
|
||||
(use-event-listener target event-name handler deps nil))
|
||||
([target event-name handler deps options]
|
||||
(use-effect!
|
||||
(fn []
|
||||
(when-let [target' (event-target target)]
|
||||
(.addEventListener target' event-name handler options)
|
||||
#(.removeEventListener target' event-name handler options)))
|
||||
(into [target event-name options] deps))))
|
||||
|
||||
(defn use-window-keydown
|
||||
([handler deps]
|
||||
(use-window-keydown handler deps nil))
|
||||
([handler deps options]
|
||||
(use-event-listener js/window "keydown" handler deps options)))
|
||||
|
||||
(defn use-window-keyup
|
||||
([handler deps]
|
||||
(use-window-keyup handler deps nil))
|
||||
([handler deps options]
|
||||
(use-event-listener js/window "keyup" handler deps options)))
|
||||
|
||||
(defn use-hide-on-esc-or-outside
|
||||
[{:keys [active? root-ref on-hide ignore-class outside-event outside-options]
|
||||
:or {ignore-class "ignore-outside-event"
|
||||
outside-event "mousedown"}}]
|
||||
(let [outside-handler (use-callback
|
||||
(fn [e]
|
||||
(when (and active?
|
||||
(not (some-> (event-target root-ref)
|
||||
(.contains (.-target e))))
|
||||
(not (some-> e .-target .-classList (.contains ignore-class))))
|
||||
(on-hide e)))
|
||||
[active? root-ref on-hide ignore-class])
|
||||
keydown-handler (use-callback
|
||||
(fn [e]
|
||||
(when (and active? (= 27 (.-keyCode e)))
|
||||
(on-hide e)))
|
||||
[active? on-hide])]
|
||||
(use-event-listener js/window outside-event outside-handler [outside-handler] outside-options)
|
||||
(use-window-keydown keydown-handler [keydown-handler])))
|
||||
|
||||
;;; React hooks
|
||||
(def use-ref react/useRef)
|
||||
(def create-ref react/createRef)
|
||||
(defn deref [ref] (.-current ref))
|
||||
(defn set-ref! [ref value] (set! (.-current ref) value))
|
||||
(defn- choose-value [nv cv]
|
||||
(if (= nv cv)
|
||||
cv
|
||||
nv))
|
||||
|
||||
;; borrowed from https://github.com/pitch-io/uix/blob/master/core/src/uix/hooks/alpha.cljs
|
||||
(defn- use-clojure-aware-updater
|
||||
"Replicates React's behaviour when updating state with identical JS value,
|
||||
but using Clojure's value equality here"
|
||||
[updater]
|
||||
(react/useCallback
|
||||
(fn [v & args]
|
||||
(updater
|
||||
(fn [cv]
|
||||
(if (fn? v)
|
||||
(choose-value (apply v cv args) cv)
|
||||
(choose-value v cv)))))
|
||||
#js [updater]))
|
||||
|
||||
(defn use-state [value]
|
||||
(let [[state set-state] (react/useState value)
|
||||
set-state (use-clojure-aware-updater set-state)]
|
||||
#js [state set-state]))
|
||||
|
||||
(defn- clojure-aware-reducer-updater
|
||||
"Same as `use-clojure-primitive-aware-updater` but for `use-reducer`"
|
||||
[f]
|
||||
(fn [state action]
|
||||
(choose-value (f state action) state)))
|
||||
|
||||
(defn use-reducer
|
||||
([f value]
|
||||
(let [updater (clojure-aware-reducer-updater f)]
|
||||
(react/useReducer updater value)))
|
||||
([f value init-state]
|
||||
(let [updater (clojure-aware-reducer-updater f)]
|
||||
(react/useReducer updater value init-state))))
|
||||
|
||||
;;; other custom hooks
|
||||
|
||||
(defn use-debounced-value
|
||||
"Return the debounced value"
|
||||
[value msec]
|
||||
(let [[debounced-value set-value!] (use-state value)
|
||||
cb (use-callback (gfun/debounce set-value! msec) [])]
|
||||
(use-effect! #(cb value) [value])
|
||||
debounced-value))
|
||||
|
||||
(defn- is-touch-event? [e]
|
||||
(exists? (.-touches e)))
|
||||
|
||||
(defn- prevent-default [e]
|
||||
(when (and (is-touch-event? e)
|
||||
(< (.-length (.-touches e)) 2)
|
||||
(.-preventDefault e))
|
||||
(.preventDefault e)))
|
||||
|
||||
(defn use-long-press
|
||||
[{:keys [on-click on-long-press prevent-default? delay]
|
||||
:or {prevent-default? true
|
||||
delay 300}}]
|
||||
(let [[long-press-triggered set-long-press-triggered] (use-state false)
|
||||
timeout-ref (use-ref nil)
|
||||
target-ref (use-ref nil)
|
||||
start (use-callback
|
||||
(fn [e]
|
||||
(when (and prevent-default? (.-target e))
|
||||
(.addEventListener (.-target e) "touchend" prevent-default #js {:passive false})
|
||||
(set! (.-current target-ref) (.-target e)))
|
||||
(set! (.-current timeout-ref)
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(on-long-press e)
|
||||
(set-long-press-triggered true))
|
||||
delay)))
|
||||
[on-long-press delay prevent-default?])
|
||||
|
||||
clear (use-callback
|
||||
(fn [_e should-trigger-click]
|
||||
(when (.-current timeout-ref)
|
||||
(js/clearTimeout (.-current timeout-ref)))
|
||||
(when (and (or (nil? should-trigger-click) should-trigger-click)
|
||||
(not long-press-triggered))
|
||||
(on-click))
|
||||
(set-long-press-triggered false)
|
||||
(when (and prevent-default? (.-current target-ref))
|
||||
(.removeEventListener (.-current target-ref) "touchend" prevent-default)))
|
||||
[prevent-default? on-click long-press-triggered])]
|
||||
|
||||
{:onMouseDown #(start %)
|
||||
:onTouchStart #(start %)
|
||||
:onMouseUp #(clear % true)
|
||||
:onMouseLeave #(clear % false)
|
||||
:onTouchEnd #(clear % true)}))
|
||||
|
||||
(defn- use-atom-fn
|
||||
[a getter-fn setter-fn]
|
||||
(let [[val set-val] (use-state (getter-fn @a))]
|
||||
(use-effect!
|
||||
(fn []
|
||||
(let [current-value (getter-fn @a)]
|
||||
(when-not (= val current-value)
|
||||
(set-val current-value)))
|
||||
(let [id (str (random-uuid))]
|
||||
(add-watch a id (fn [_ _ prev-state next-state]
|
||||
(let [prev-value (getter-fn prev-state)
|
||||
next-value (getter-fn next-state)]
|
||||
(when-not (= prev-value next-value)
|
||||
(set-val next-value)))))
|
||||
#(remove-watch a id)))
|
||||
[a])
|
||||
[val #(swap! a setter-fn %)]))
|
||||
|
||||
(defn use-atom
|
||||
"(use-atom my-atom)"
|
||||
[a]
|
||||
(use-atom-fn a identity (fn [_ v] v)))
|
||||
|
||||
(defn use-atom-value
|
||||
[a]
|
||||
(first (use-atom a)))
|
||||
|
||||
(defn use-atom-in
|
||||
[a ks]
|
||||
(let [ks (if (keyword? ks) [ks] ks)]
|
||||
(use-atom-fn a #(get-in % ks) (fn [a' v] (assoc-in a' ks v)))))
|
||||
|
||||
(defn use-modal-state
|
||||
[initial-open?]
|
||||
(let [open-atom (use-memo #(atom (boolean initial-open?)) [])
|
||||
[open-value] (use-atom open-atom)
|
||||
close-fn #(reset! open-atom false)
|
||||
open-fn #(reset! open-atom true)
|
||||
toggle-fn #(swap! open-atom not)]
|
||||
{:open? open-value
|
||||
:open-atom open-atom
|
||||
:close-fn close-fn
|
||||
:open-fn open-fn
|
||||
:toggle-fn toggle-fn}))
|
||||
|
||||
(defn use-mounted
|
||||
[]
|
||||
(let [mounted-ref (use-ref false)]
|
||||
(use-effect!
|
||||
(fn []
|
||||
(set-ref! mounted-ref true)
|
||||
#(set-ref! mounted-ref false))
|
||||
[])
|
||||
#(deref mounted-ref)))
|
||||
|
||||
(defn use-bounding-client-rect
|
||||
"Return a callback ref and the current bounding client rect for that node."
|
||||
([] (use-bounding-client-rect nil))
|
||||
([tick]
|
||||
(let [[ref set-ref] (use-state nil)
|
||||
[rect set-rect] (use-state nil)]
|
||||
(use-effect!
|
||||
(if ref
|
||||
(fn []
|
||||
(let [update-rect #(set-rect (.getBoundingClientRect ref))
|
||||
update-from-observer! (fn [entries]
|
||||
(when (some-> entries (aget 0) .-contentRect)
|
||||
(update-rect)))
|
||||
observer (js/ResizeObserver. update-from-observer!)]
|
||||
(update-rect)
|
||||
(.observe observer ref)
|
||||
#(.disconnect observer)))
|
||||
#())
|
||||
[ref tick])
|
||||
[set-ref rect])))
|
||||
58
deps/shui/src/logseq/shui/icon/v2.cljs
vendored
Normal file
58
deps/shui/src/logseq/shui/icon/v2.cljs
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
(ns logseq.shui.icon.v2
|
||||
(:require
|
||||
["@tabler/icons-react" :as tabler-icons]
|
||||
[camel-snake-kebab.core :as csk]
|
||||
[clojure.string :as string]
|
||||
[goog.object :as gobj]
|
||||
[goog.string :as gstring]
|
||||
[io.factorhouse.hsx.core :as hsx]
|
||||
[logseq.shui.util :as shui-utils]))
|
||||
|
||||
(def ^:private tabler-extension-icon-names
|
||||
#{"add-link" "app-feature" "block" "block-search" "cloud-exclamation"
|
||||
"connector" "group" "h-auto" "heading-off" "internal-link"
|
||||
"link-to-block" "link-to-page" "link-to-whiteboard" "move-to-sidebar-right"
|
||||
"new-block" "new-page" "new-whiteboard" "new-whiteboard-element"
|
||||
"object-compact" "object-expanded" "open-as-page" "page" "page-search"
|
||||
"references-hide" "references-show" "select-cursor" "text" "ungroup"
|
||||
"whiteboard" "whiteboard-element" "whiteboard-search"})
|
||||
|
||||
(defn- font-icon
|
||||
[name extension? opts]
|
||||
[:span.ui__icon (merge {:class
|
||||
(gstring/format
|
||||
(str "%s-" name
|
||||
(when (:class opts)
|
||||
(str " " (string/trim (:class opts)))))
|
||||
(if extension? "tie tie" "ti ti"))}
|
||||
(dissoc opts :class :extension? :font?))])
|
||||
|
||||
(defn- svg-icon
|
||||
[icons name icon-component-name class opts]
|
||||
(let [f (shui-utils/component-wrap icons icon-component-name)]
|
||||
[:span.ui__icon.ti
|
||||
{:class (str "ls-icon-" name " " class)}
|
||||
(f (merge {:size 18}
|
||||
(shui-utils/map-keys->camel-case
|
||||
(dissoc opts :class :extension? :font?))))]))
|
||||
|
||||
(hsx/defc root
|
||||
([name] (root name nil))
|
||||
([name {:keys [extension? font? class] :as opts}]
|
||||
(when (and (string? name)
|
||||
(not (string/blank? name)))
|
||||
(let [^js js-tabler-icons (gobj/get js/window "tablerIcons")
|
||||
icon-component-name (str "Icon" (csk/->PascalCase name))]
|
||||
(cond
|
||||
font?
|
||||
(font-icon name extension? opts)
|
||||
|
||||
(and js-tabler-icons (gobj/get js-tabler-icons icon-component-name))
|
||||
(svg-icon js-tabler-icons name icon-component-name class opts)
|
||||
|
||||
(and (not extension?) (gobj/get tabler-icons icon-component-name))
|
||||
(svg-icon tabler-icons name icon-component-name class opts)
|
||||
|
||||
:else
|
||||
(font-icon name (or extension?
|
||||
(contains? tabler-extension-icon-names name)) opts))))))
|
||||
418
deps/shui/src/logseq/shui/popup/core.cljs
vendored
Normal file
418
deps/shui/src/logseq/shui/popup/core.cljs
vendored
Normal file
@@ -0,0 +1,418 @@
|
||||
(ns logseq.shui.popup.core
|
||||
(:require [dommy.core :as d]
|
||||
[io.factorhouse.hsx.core :as hsx]
|
||||
[logseq.shui.util :as util :refer [use-atom]]
|
||||
[medley.core :as medley]))
|
||||
|
||||
;; ui
|
||||
(def button (util/ui-wrap "Button"))
|
||||
(def popover (util/ui-wrap "Popover"))
|
||||
(def popover-trigger (util/ui-wrap "PopoverTrigger"))
|
||||
(def popover-content (util/ui-wrap "PopoverContent"))
|
||||
(def popover-arrow (util/ui-wrap "PopoverArrow"))
|
||||
(def popover-close (util/ui-wrap "PopoverClose"))
|
||||
(def popover-remove-scroll (util/ui-wrap "PopoverRemoveScroll"))
|
||||
(def dropdown-menu (util/ui-wrap "DropdownMenu"))
|
||||
(def dropdown-menu-trigger (util/ui-wrap "DropdownMenuTrigger"))
|
||||
(def dropdown-menu-content (util/ui-wrap "DropdownMenuContent"))
|
||||
(def dropdown-menu-arrow (util/ui-wrap "DropdownMenuArrow"))
|
||||
(def dropdown-menu-group (util/ui-wrap "DropdownMenuGroup"))
|
||||
(def dropdown-menu-item (util/ui-wrap "DropdownMenuItem"))
|
||||
(def dropdown-menu-checkbox-item (util/ui-wrap "DropdownMenuCheckboxItem"))
|
||||
(def dropdown-menu-radio-group (util/ui-wrap "DropdownMenuRadioGroup"))
|
||||
(def dropdown-menu-radio-item (util/ui-wrap "DropdownMenuRadioItem"))
|
||||
(def dropdown-menu-label (util/ui-wrap "DropdownMenuLabel"))
|
||||
(def dropdown-menu-separator (util/ui-wrap "DropdownMenuSeparator"))
|
||||
(def dropdown-menu-shortcut (util/ui-wrap "DropdownMenuShortcut"))
|
||||
(def dropdown-menu-sub (util/ui-wrap "DropdownMenuSub"))
|
||||
(def dropdown-menu-sub-content (util/ui-wrap "DropdownMenuSubContent"))
|
||||
(def dropdown-menu-sub-trigger (util/ui-wrap "DropdownMenuSubTrigger"))
|
||||
|
||||
;; {:id :open? false :content nil :position [0 0] :root-props nil :content-props nil}
|
||||
(defonce ^:private *popups (atom []))
|
||||
(defonce ^:private *id (atom 0))
|
||||
(defonce ^:private gen-id #(reset! *id (inc @*id)))
|
||||
(defonce ^:private *last-hidden (atom nil))
|
||||
(def *opened-sub-menus (atom #{}))
|
||||
|
||||
(defn get-popup
|
||||
[id]
|
||||
(when id
|
||||
(some->> (medley/indexed @*popups)
|
||||
(filter #(= id (:id (second %)))) (first))))
|
||||
|
||||
(defn get-popups [] @*popups)
|
||||
(defn get-last-popup [] (last @*popups))
|
||||
|
||||
(defn upsert-popup!
|
||||
[config]
|
||||
(when-let [id (:id config)]
|
||||
(if-let [[index config'] (get-popup id)]
|
||||
(swap! *popups assoc index (merge config' config))
|
||||
(swap! *popups conj config)) id))
|
||||
|
||||
(defn update-popup!
|
||||
[id ks val]
|
||||
(when-let [[index config] (get-popup id)]
|
||||
(let [ks (if (coll? ks) ks [ks])
|
||||
config (if (nil? val)
|
||||
(medley/dissoc-in config ks)
|
||||
(assoc-in config ks val))]
|
||||
(swap! *popups assoc index config))))
|
||||
|
||||
(defn detach-popup!
|
||||
([id] (detach-popup! id false))
|
||||
([id skip-focus?]
|
||||
(let [[index config] (get-popup id)]
|
||||
(when index
|
||||
(swap! *popups #(->> % (medley/remove-nth index) (vec)))
|
||||
(let [{:keys [auto-focus? target trigger-id]} config]
|
||||
(when (and (not skip-focus?) auto-focus? target)
|
||||
(when-let [target (if trigger-id (js/document.getElementById trigger-id) target)]
|
||||
(d/add-class! target "ls-popup-closed")
|
||||
(.focus target))))))))
|
||||
|
||||
(declare hide!)
|
||||
|
||||
(defn- same-popup-target?
|
||||
[^js target ^js target']
|
||||
(and target target'
|
||||
(or (= target target')
|
||||
(and (instance? js/Node target')
|
||||
(.-contains target)
|
||||
(.contains target target')))))
|
||||
|
||||
(defn- recently-hidden-target?
|
||||
[target]
|
||||
(let [{hidden-target :target until :until} @*last-hidden]
|
||||
(and target
|
||||
hidden-target
|
||||
(number? until)
|
||||
(< (js/Date.now) until)
|
||||
(same-popup-target? hidden-target target))))
|
||||
|
||||
(defn- element?
|
||||
[target]
|
||||
(and (exists? js/Element)
|
||||
(instance? js/Element target)))
|
||||
|
||||
(defn- native-event [^js event]
|
||||
(or (some-> event (.-nativeEvent)) event))
|
||||
|
||||
(defn- event-target [^js event]
|
||||
(let [^js native-event (native-event event)]
|
||||
(or (some-> event (.-target))
|
||||
(some-> native-event (.-target))
|
||||
(some-> event (.-currentTarget))
|
||||
(some-> native-event (.-currentTarget)))))
|
||||
|
||||
(defn- event-related-target [^js event]
|
||||
(let [^js native-event (native-event event)]
|
||||
(or (some-> event (.-relatedTarget))
|
||||
(some-> native-event (.-relatedTarget))
|
||||
(some-> event (.-toElement))
|
||||
(some-> native-event (.-toElement)))))
|
||||
|
||||
(defn- event-composed-path [^js event]
|
||||
(let [^js native-event (native-event event)]
|
||||
(cond
|
||||
(fn? (some-> native-event (.-composedPath)))
|
||||
(array-seq (.composedPath native-event))
|
||||
|
||||
(fn? (some-> event (.-composedPath)))
|
||||
(array-seq (.composedPath event)))))
|
||||
|
||||
(defn- close-targets
|
||||
[^js event-details]
|
||||
(let [event (some-> event-details (.-event))]
|
||||
(remove nil?
|
||||
(concat [(some-> event-details (.-trigger))
|
||||
(event-related-target event)
|
||||
(event-target event)]
|
||||
(event-composed-path event)))))
|
||||
|
||||
(def ^:private menu-transition-close-reasons
|
||||
#{"trigger-hover" "trigger-focus" "list-navigation" "sibling-open"})
|
||||
|
||||
(defn- input-target?
|
||||
[target]
|
||||
(and (element? target)
|
||||
(or (instance? js/HTMLInputElement target)
|
||||
(instance? js/HTMLTextAreaElement target)
|
||||
(instance? js/HTMLSelectElement target)
|
||||
(some? (.closest target "[contenteditable='true']")))))
|
||||
|
||||
(defn- prevent-base-ui-handler!
|
||||
[^js event]
|
||||
(when (fn? (.-preventBaseUIHandler event))
|
||||
(.preventBaseUIHandler event)))
|
||||
|
||||
(defn- menu-key-down-handler
|
||||
[content-props disable-menu-handlers?]
|
||||
(fn [^js event]
|
||||
(some-> content-props :on-key-down (apply [event]))
|
||||
(when (or disable-menu-handlers?
|
||||
(input-target? (event-target event)))
|
||||
(prevent-base-ui-handler! event))))
|
||||
|
||||
(defn- stop-toggle-event! [^js event]
|
||||
(let [^js native-event (native-event event)]
|
||||
(doseq [^js e (distinct (remove nil? [event native-event]))]
|
||||
(when (fn? (.-preventDefault e))
|
||||
(.preventDefault e))
|
||||
(when (fn? (.-stopPropagation e))
|
||||
(.stopPropagation e))
|
||||
(when (fn? (.-stopImmediatePropagation e))
|
||||
(.stopImmediatePropagation e)))))
|
||||
|
||||
(defn- clear-selection!
|
||||
[]
|
||||
(some-> js/window
|
||||
(.getSelection)
|
||||
(.removeAllRanges)))
|
||||
|
||||
(defn- consume-toggle-event! [^js event]
|
||||
(stop-toggle-event! event)
|
||||
(clear-selection!))
|
||||
|
||||
(defn- close-canceled?
|
||||
[handler ^js event-details]
|
||||
(let [native-event (some-> event-details (.-event))
|
||||
result (when (fn? handler) (handler native-event))]
|
||||
(when (or (false? result)
|
||||
(some-> native-event (.-defaultPrevented))
|
||||
(some-> event-details (.-isCanceled)))
|
||||
(some-> event-details (.cancel))
|
||||
true)))
|
||||
|
||||
(defn show!
|
||||
[^js event content & {:keys [id as-mask? as-dropdown? as-content?
|
||||
focus-trigger? align root-props content-props
|
||||
on-before-hide on-after-hide trigger-id] :as opts}]
|
||||
(let [provided-id id
|
||||
id (or provided-id (gen-id))
|
||||
;; _ (prn :debug :show :id id)
|
||||
*target (volatile! nil)
|
||||
pointer-event? (or (and (exists? js/MouseEvent)
|
||||
(instance? js/MouseEvent (or (.-nativeEvent event) event)))
|
||||
(instance? js/goog.events.BrowserEvent event))
|
||||
position (cond
|
||||
(vector? event) event
|
||||
|
||||
pointer-event?
|
||||
(let [event' (or (.-nativeEvent event) event)]
|
||||
(vreset! *target (some->> [(.-currentTarget event)
|
||||
(.-currentTarget event')
|
||||
(.-target event')
|
||||
(.-target event)]
|
||||
(filter element?)
|
||||
first))
|
||||
[(.-clientX event') (.-clientY event')])
|
||||
|
||||
(and (exists? js/Element)
|
||||
(instance? js/Element event))
|
||||
(let [^js rect (.getBoundingClientRect event)
|
||||
left (.-left rect)
|
||||
width (.-width rect)
|
||||
height (.-height rect)
|
||||
bottom (.-bottom rect)]
|
||||
(vreset! *target event)
|
||||
[(+ left (case (keyword align)
|
||||
:start 0
|
||||
:end width
|
||||
(/ width 2)))
|
||||
(- (- bottom height)
|
||||
;; minus default offset
|
||||
(if as-mask? 6 0))
|
||||
width (if as-mask? 1 height)])
|
||||
(and (vector event) (= (count event) 2) (every? integer? event))
|
||||
event
|
||||
:else [0 0])
|
||||
target @*target
|
||||
opened (or (some->> (get-popups) (filter #(= id (:id %))) (first))
|
||||
(when (and (nil? provided-id) target)
|
||||
(some #(when (same-popup-target? target (:target %)) %)
|
||||
(get-popups))))]
|
||||
(cond
|
||||
(recently-hidden-target? target)
|
||||
(do
|
||||
(reset! *last-hidden nil)
|
||||
(consume-toggle-event! event)
|
||||
nil)
|
||||
|
||||
opened
|
||||
(do
|
||||
(consume-toggle-event! event)
|
||||
(if (and (number? (:ignore-opening-outside-press-until opened))
|
||||
(< (js/Date.now) (:ignore-opening-outside-press-until opened)))
|
||||
(update-popup! (:id opened) :ignore-opening-outside-press-until nil)
|
||||
(hide! (:id opened) 0 {:event event :skip-focus? true :suppress-next-show? true}))
|
||||
(:id opened))
|
||||
|
||||
:else
|
||||
(do
|
||||
(when (element? target)
|
||||
(d/remove-class! target "ls-popup-closed")
|
||||
(d/set-attr! target "data-popup-active" (if (keyword? id) (name id) (str id))))
|
||||
(upsert-popup!
|
||||
(merge opts
|
||||
{:id id :target target
|
||||
:trigger-id trigger-id
|
||||
:ignore-opening-outside-press-until (+ (js/Date.now) 150)
|
||||
:focus-trigger? focus-trigger?
|
||||
:open? true :content content :position position
|
||||
:as-dropdown? as-dropdown?
|
||||
:as-content? as-content?
|
||||
:root-props root-props
|
||||
:on-before-hide on-before-hide
|
||||
:on-after-hide on-after-hide
|
||||
:content-props (cond-> content-props
|
||||
(not (nil? align))
|
||||
(assoc :align (name align)))}))
|
||||
id))))
|
||||
|
||||
(defn hide!
|
||||
([] (when-let [id (some-> (get-popups) (last) :id)] (hide! id 0)))
|
||||
([id] (hide! id 0 {}))
|
||||
([id delay] (hide! id delay {}))
|
||||
([id delay {:keys [_all? ^js event skip-focus? suppress-next-show?]}]
|
||||
;; (prn :debug :hide id)
|
||||
(when-let [popup (get-popup id)]
|
||||
(let [config (last popup)
|
||||
target (:target config)
|
||||
f (fn []
|
||||
(when suppress-next-show?
|
||||
(reset! *last-hidden {:target target
|
||||
:until (+ (js/Date.now) 150)}))
|
||||
(detach-popup! id skip-focus?)
|
||||
(some-> (:on-after-hide config) (apply [])))]
|
||||
(when (not (false? (some-> (:on-before-hide config) (apply [event]))))
|
||||
(when (and (not skip-focus?)
|
||||
(not (false? (:focus-trigger? config)))
|
||||
(some? target)
|
||||
(= js/document (.-ownerDocument target))
|
||||
(.-isConnected target)
|
||||
(fn? (.-focus target)))
|
||||
(js/setTimeout #(.focus target) 16))
|
||||
(some-> target (d/remove-attr! "data-popup-active"))
|
||||
(if (and (number? delay) (> delay 0))
|
||||
(js/setTimeout f delay)
|
||||
(f)))))))
|
||||
|
||||
(defn hide-all!
|
||||
[]
|
||||
(doseq [{:keys [id]} @*popups]
|
||||
(hide! id 0 {:all? true})))
|
||||
|
||||
(defn- virtual-anchor
|
||||
[target x y _width height]
|
||||
(let [height (if (and (number? height) (> height 0)) height 1)
|
||||
rect #js {:x x :y y :left x :top y :right (inc x) :bottom (+ y height)
|
||||
:width 1 :height height}
|
||||
anchor #js {:getBoundingClientRect #(js/Object.assign #js {} rect)}]
|
||||
(when (element? target)
|
||||
(aset anchor "contextElement" target))
|
||||
anchor))
|
||||
|
||||
(defn- anchor-props
|
||||
[content-props target position]
|
||||
(let [[x y width height] position]
|
||||
{:anchor (virtual-anchor target x y width height)
|
||||
:position-method (or (:position-method content-props)
|
||||
(:positionMethod content-props)
|
||||
"fixed")}))
|
||||
|
||||
(defn- anchor-height
|
||||
[height]
|
||||
(if (and (number? height) (> height 0)) height 1))
|
||||
|
||||
(hsx/defc x-popup
|
||||
[{:keys [id open? content position as-dropdown? as-content? force-popover?
|
||||
auto-side? as-mask? _auto-focus? target root-props content-props
|
||||
ignore-opening-outside-press-until _on-before-hide _on-after-hide]
|
||||
:as _props}]
|
||||
(when-let [[_x y _width height] position]
|
||||
(let [use-menu? (not force-popover?)
|
||||
popup-root (if use-menu? dropdown-menu popover)
|
||||
popup-trigger (if use-menu? dropdown-menu-trigger popover-trigger)
|
||||
popup-content (if use-menu? dropdown-menu-content popover-content)
|
||||
auto-side-fn (fn []
|
||||
(let [vh js/window.innerHeight
|
||||
height (anchor-height height)
|
||||
[th bh] [y (- vh (+ y height))]]
|
||||
(if (> bh 280)
|
||||
"bottom"
|
||||
(if (> (- th bh) 100)
|
||||
"top" "bottom"))))
|
||||
auto-side? (if (boolean? auto-side?) auto-side? true)
|
||||
content-props (cond-> content-props
|
||||
(and (not as-mask?) auto-side?) (assoc :side (auto-side-fn)))
|
||||
handle-open-change! (fn [open? ^js e]
|
||||
(when-not (false? (some-> root-props (:onOpenChange) (apply [open? e])))
|
||||
(when-not open?
|
||||
(let [native-event (some-> e (.-event))
|
||||
last-popup? (= (:id (get-last-popup)) id)
|
||||
reason (some-> e (.-reason))
|
||||
targets (close-targets e)
|
||||
target-toggle? (some #(same-popup-target? target %) targets)
|
||||
opening-outside-press? (and (= reason "outside-press")
|
||||
(number? ignore-opening-outside-press-until)
|
||||
(< (js/Date.now) ignore-opening-outside-press-until))
|
||||
focus-transition? (= reason "focus-out")
|
||||
menu-transition? (and use-menu?
|
||||
(contains? menu-transition-close-reasons reason))
|
||||
handler (case reason
|
||||
"escape-key" (:onEscapeKeyDown content-props)
|
||||
"outside-press" (:onPointerDownOutside content-props)
|
||||
nil)]
|
||||
(if (or (not last-popup?)
|
||||
target-toggle?
|
||||
opening-outside-press?
|
||||
menu-transition?
|
||||
focus-transition?)
|
||||
(some-> e (.cancel))
|
||||
(when-not (close-canceled? handler e)
|
||||
(hide! id 1 {:event native-event
|
||||
:skip-focus? (= reason "outside-press")
|
||||
:suppress-next-show? (= reason "outside-press")})))))))]
|
||||
(let [disable-menu-handlers? (and use-menu? (not as-dropdown?))
|
||||
content-props (cond-> (merge content-props
|
||||
(anchor-props content-props target position))
|
||||
as-mask?
|
||||
(assoc :data-as-mask true)
|
||||
|
||||
use-menu?
|
||||
(assoc :on-key-down
|
||||
(menu-key-down-handler content-props disable-menu-handlers?))
|
||||
|
||||
disable-menu-handlers?
|
||||
(assoc :on-pointer-move prevent-base-ui-handler!))
|
||||
content (if (fn? content)
|
||||
(content (cond-> {:id id}
|
||||
as-content?
|
||||
(assoc :content-props content-props))) content)]
|
||||
(popup-root
|
||||
(merge root-props {:open open?
|
||||
:onOpenChange handle-open-change!})
|
||||
(popup-trigger
|
||||
{:as-child true}
|
||||
(button {:class "fixed p-0 opacity-0 pointer-events-none"
|
||||
:tab-index -1
|
||||
:aria-hidden true
|
||||
:style {:height 1
|
||||
:width 1
|
||||
:top -10000
|
||||
:left -10000}}
|
||||
""))
|
||||
(if as-content?
|
||||
content
|
||||
(popup-content content-props content)))))))
|
||||
|
||||
(hsx/defc install-popups
|
||||
[]
|
||||
(let [[popups _set-popups!] (use-atom *popups)]
|
||||
[:<>
|
||||
(for [config popups
|
||||
:when (and (map? config) (:id config) (not (:all? config)))]
|
||||
^{:key (:id config)}
|
||||
[x-popup config])]))
|
||||
14
deps/shui/src/logseq/shui/select/core.cljs
vendored
Normal file
14
deps/shui/src/logseq/shui/select/core.cljs
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
(ns logseq.shui.select.core
|
||||
(:require [logseq.shui.util :as util]))
|
||||
|
||||
(def select (util/ui-wrap "Select"))
|
||||
(def select-group (util/ui-wrap "SelectGroup"))
|
||||
(def select-value (util/ui-wrap "SelectValue"))
|
||||
(def select-trigger (util/ui-wrap "SelectTrigger"))
|
||||
(def select-icon (util/ui-wrap "SelectIcon"))
|
||||
(def select-content (util/ui-wrap "SelectContent"))
|
||||
(def select-label (util/ui-wrap "SelectLabel"))
|
||||
(def select-item (util/ui-wrap "SelectItem"))
|
||||
(def select-separator (util/ui-wrap "SelectSeparator"))
|
||||
(def select-scroll-up-button (util/ui-wrap "SelectScrollUpButton"))
|
||||
(def select-scroll-down-button (util/ui-wrap "SelectScrollDownButton"))
|
||||
180
deps/shui/src/logseq/shui/select/multi.cljs
vendored
Normal file
180
deps/shui/src/logseq/shui/select/multi.cljs
vendored
Normal file
@@ -0,0 +1,180 @@
|
||||
(ns logseq.shui.select.multi
|
||||
(:require [clojure.string :as string]
|
||||
[io.factorhouse.hsx.core :as hsx]
|
||||
[logseq.shui.form.core :as form]
|
||||
[logseq.shui.hooks :as hooks]
|
||||
[logseq.shui.popup.core :as shui-popup]))
|
||||
|
||||
(defn- get-k [item]
|
||||
(if (map? item)
|
||||
(some->> ((juxt :id :key :label) item)
|
||||
(remove nil?)
|
||||
(first))
|
||||
item))
|
||||
|
||||
(defn- get-v
|
||||
[item]
|
||||
(if (string? item)
|
||||
item (or (:title item) (:value item))))
|
||||
|
||||
(hsx/defc search-input
|
||||
[input-props & {:keys [on-enter valid-search-key?]}]
|
||||
(let [*el (hooks/use-ref nil)
|
||||
[down set-down!] (hooks/use-state 0)]
|
||||
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(when-let [^js item (and (> down 0)
|
||||
(some-> (hooks/deref *el)
|
||||
(.closest ".head")
|
||||
(.-nextSibling)))]
|
||||
(some-> (if valid-search-key? (.-nextSibling item) item)
|
||||
(.focus))))
|
||||
[down])
|
||||
|
||||
[:div.search-input
|
||||
{:ref *el}
|
||||
(form/input
|
||||
(merge {:on-key-up #(case (.-key %)
|
||||
"ArrowDown" (set-down! (inc down))
|
||||
"ArrowUp" nil
|
||||
"Enter" (when (fn? on-enter) (on-enter))
|
||||
:dune)
|
||||
:auto-focus true}
|
||||
input-props))]))
|
||||
|
||||
(defn- simple-search-fn
|
||||
[items q]
|
||||
(let [q (some-> q (string/trim) (string/lower-case))]
|
||||
(if (string/blank? q)
|
||||
items
|
||||
(filter #(some-> (get-v %)
|
||||
(string/lower-case)
|
||||
(string/includes? q)) items))))
|
||||
|
||||
(hsx/defc x-select-content
|
||||
[items selected-items & {:keys [on-chosen item-render value-render
|
||||
head-render foot-render open? close!
|
||||
search-enabled? search-key on-search-key-change
|
||||
search-input-placeholder
|
||||
search-fn search-key-render
|
||||
item-props content-props]}]
|
||||
(let [x-content shui-popup/dropdown-menu-content
|
||||
x-item shui-popup/dropdown-menu-item
|
||||
*head-ref (hooks/use-ref nil)
|
||||
[search-key1 set-search-key!] (hooks/use-state search-key)
|
||||
search-key1' (some-> search-key1 (string/trim) (string/lower-case))
|
||||
valid-search-key? (and search-enabled? (not (string/blank? search-key1')))
|
||||
get-content-el #(some-> % (.closest ".ui__dropdown-menu-content"))
|
||||
get-item-nodes #(some-> % (get-content-el) (.querySelectorAll ".ui__dropdown-menu-item") (js->clj))
|
||||
focus-search-input! (fn [^js target]
|
||||
(when (and search-enabled? (not= "INPUT" (.-nodeName target)))
|
||||
;; focus search input
|
||||
(some-> (get-content-el target)
|
||||
(.querySelector "input")
|
||||
(.focus))))
|
||||
items (if search-enabled?
|
||||
(if (fn? search-fn)
|
||||
(search-fn items search-key1)
|
||||
(simple-search-fn items search-key1))
|
||||
items)
|
||||
close1! #(when (fn? close!) (close!))]
|
||||
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(when (fn? on-search-key-change)
|
||||
(on-search-key-change search-key1')))
|
||||
[search-key1'])
|
||||
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(when-let [t (when (and search-enabled? (false? open?))
|
||||
(js/setTimeout #(set-search-key! "") 500))]
|
||||
#(js/clearTimeout t)))
|
||||
[open?])
|
||||
|
||||
(x-content
|
||||
(merge
|
||||
{:onInteractOutside close1!
|
||||
:onEscapeKeyDown close1!
|
||||
:on-key-down (fn [^js e]
|
||||
(when-let [^js target (.-target e)]
|
||||
(case (.-key e)
|
||||
"ArrowUp"
|
||||
(when (= (some-> (get-item-nodes target) (first))
|
||||
js/document.activeElement)
|
||||
(focus-search-input! target))
|
||||
"l" (when (or (.-metaKey e) (.-ctrlKey e))
|
||||
(focus-search-input! target))
|
||||
:dune)))
|
||||
:class (str (:class content-props)
|
||||
" ui__multi-select-content"
|
||||
(when valid-search-key? " has-search-key"))}
|
||||
(dissoc content-props :class))
|
||||
;; header
|
||||
(when (or search-enabled? (fn? head-render))
|
||||
[:div.head
|
||||
{:ref *head-ref}
|
||||
(when search-enabled?
|
||||
(search-input
|
||||
{:value search-key1
|
||||
:placeholder (or search-input-placeholder "")
|
||||
:on-key-down (fn [^js e]
|
||||
(.stopPropagation e)
|
||||
(case (.-key e)
|
||||
"Escape" (if (string/blank? search-key1)
|
||||
(some-> (.-target e) (.closest ".ui__dropdown-menu-content") (.focus))
|
||||
(set-search-key! ""))
|
||||
:dune))
|
||||
:on-change #(set-search-key! (.-value (.-target %)))}
|
||||
|
||||
{:on-enter (fn []
|
||||
(when-let [head-el (and (not (string/blank? search-key1'))
|
||||
(hooks/deref *head-ref))]
|
||||
(when-let [^js item (.-nextSibling head-el)]
|
||||
(when (contains? #{"menuitemcheckbox" "menuitem"}
|
||||
(.getAttribute item "role"))
|
||||
(.click item)))))
|
||||
:valid-search-key? valid-search-key?}))
|
||||
(when head-render (head-render))])
|
||||
;; items
|
||||
(for [item items
|
||||
:let [selected? (some #(let [k (get-k item)
|
||||
k' (get-k %)]
|
||||
(or (= item %)
|
||||
(and (not (nil? k))
|
||||
(not (nil? k'))
|
||||
(= k k'))))
|
||||
selected-items)]]
|
||||
(if (fn? item-render)
|
||||
(item-render item {:x-item x-item :selected? selected?})
|
||||
(let [k (get-k item)
|
||||
v (get-v item)]
|
||||
(when k
|
||||
(let [opts {:selected? selected?}
|
||||
on-click' (:on-click item-props)
|
||||
on-click (fn [e]
|
||||
;; TODO: return value
|
||||
(when (fn? on-click') (on-click' e))
|
||||
(when (fn? on-chosen)
|
||||
(on-chosen item opts)))]
|
||||
(x-item (merge {:data-k k :on-click on-click} item-props)
|
||||
[:span.flex.items-center.gap-2.w-full
|
||||
(form/checkbox {:checked selected?})
|
||||
(let [v' (if (fn? v) (v item opts) v)]
|
||||
(if (fn? value-render)
|
||||
(value-render v' (assoc opts :item item)) v'))]))))))
|
||||
|
||||
(when (and search-enabled?
|
||||
(fn? search-key-render))
|
||||
(let [exist-fn (fn []
|
||||
(and (not (string/blank? search-key1))
|
||||
(seq items)
|
||||
(contains? (into #{} (map #(some-> (get-v %) (string/lower-case)) items))
|
||||
(string/lower-case search-key1))))]
|
||||
(search-key-render search-key1
|
||||
{:items items :x-item x-item :exist-fn exist-fn})))
|
||||
;; footer
|
||||
(when (fn? foot-render)
|
||||
[:div.foot
|
||||
(foot-render)]))))
|
||||
407
deps/shui/src/logseq/shui/shortcut.cljs
vendored
Normal file
407
deps/shui/src/logseq/shui/shortcut.cljs
vendored
Normal file
@@ -0,0 +1,407 @@
|
||||
(ns logseq.shui.shortcut
|
||||
"Unified keyboard shortcut component with three styles: combo, separate, and compact.
|
||||
|
||||
Expected shortcut formats:
|
||||
- Combo keys (simultaneous): \"shift+cmd\", [\"shift\" \"cmd\"], or [[\"shift\" \"cmd\"]]
|
||||
- Separate keys (sequential): [\"cmd\" \"up\"], or [\"cmd\" \"k\"]
|
||||
- Compact: same formats but with :style :compact
|
||||
|
||||
Platform mapping:
|
||||
- :mod or \"mod\" maps to ⌘ on macOS, Ctrl on Windows/Linux
|
||||
- Other keys are mapped via print-shortcut-key function"
|
||||
(:require [clojure.string :as string]
|
||||
[goog.userAgent]
|
||||
[io.factorhouse.hsx.core :as hsx]))
|
||||
|
||||
(def mac? goog.userAgent/MAC)
|
||||
|
||||
(defn print-shortcut-key
|
||||
"Maps logical keys to display keys, with platform-specific handling.
|
||||
Supports :mod for platform-agnostic modifier key.
|
||||
Automatically uppercases single letter keys (a-z) while preserving
|
||||
multi-character keys like Ctrl, Backspace, Delete, Opt, etc."
|
||||
[key]
|
||||
(let [result (if (coll? key)
|
||||
(string/join "+" key)
|
||||
(case (if (string? key)
|
||||
(string/lower-case key)
|
||||
key)
|
||||
("cmd" "command" "mod" "⌘") (if mac? "⌘" "Ctrl")
|
||||
("meta") (if mac? "⌘" "⊞")
|
||||
("return" "enter" "⏎") "⏎"
|
||||
("shift" "⇧") "⇧"
|
||||
("alt" "option" "opt" "⌥") (if mac? "⌥" "Alt")
|
||||
("ctrl" "control" "⌃") "Ctrl"
|
||||
("space" " ") "Space"
|
||||
("up" "↑") "↑"
|
||||
("down" "↓") "↓"
|
||||
("left" "←") "←"
|
||||
("right" "→") "→"
|
||||
("tab") "Tab"
|
||||
("open-square-bracket") "["
|
||||
("close-square-bracket") "]"
|
||||
("dash") "-"
|
||||
("semicolon") ";"
|
||||
("equals") "="
|
||||
("single-quote") "'"
|
||||
("backslash") "\\"
|
||||
("comma") ","
|
||||
("period") "."
|
||||
("slash") "/"
|
||||
("grave-accent") "`"
|
||||
("page-up") "PgUp"
|
||||
("page-down") "PgDn"
|
||||
("esc" "escape") "Esc"
|
||||
("backspace") "⌫"
|
||||
("delete") "Delete"
|
||||
("caps-lock" "capslock") "⇪"
|
||||
(nil) ""
|
||||
(name key)))]
|
||||
;; If result is a single letter (a-z), uppercase it
|
||||
;; Otherwise, capitalize only if it's a single character (for symbols)
|
||||
(cond
|
||||
(and (= (count result) 1)
|
||||
(re-matches #"[a-z]" result))
|
||||
(string/upper-case result)
|
||||
|
||||
(= (count result) 1)
|
||||
result
|
||||
|
||||
:else
|
||||
(string/capitalize result))))
|
||||
|
||||
(defn- flatten-keys
|
||||
"Recursively flattens nested collections, preserving strings."
|
||||
[coll]
|
||||
(mapcat (fn [x]
|
||||
(if (and (coll? x) (not (string? x)))
|
||||
(flatten-keys x)
|
||||
[x]))
|
||||
coll))
|
||||
|
||||
(defn- normalize-binding
|
||||
"Normalizes a shortcut binding to a string format for data attributes.
|
||||
Examples: 'cmd+k', 'shift+cmd+k', 'cmd up'
|
||||
Handles 'mod' -> 'meta' on Mac, 'ctrl' on Windows/Linux for consistency with shortcut system."
|
||||
[binding]
|
||||
(let [normalize-key (fn [k]
|
||||
(cond
|
||||
(string? k) (let [lowered (string/lower-case k)]
|
||||
;; Convert 'mod' to 'meta' on Mac, 'ctrl' on Windows/Linux
|
||||
;; to match what the shortcut system uses
|
||||
(if (= lowered "mod")
|
||||
(if mac? "meta" "ctrl")
|
||||
lowered))
|
||||
(keyword? k) (name k)
|
||||
(symbol? k) (name k)
|
||||
(number? k) (str k)
|
||||
:else (str k)))]
|
||||
(cond
|
||||
(string? binding)
|
||||
(let [trimmed (string/trim binding)
|
||||
normalized (string/lower-case trimmed)]
|
||||
;; Normalize modifier aliases to canonical form (meta/ctrl)
|
||||
(-> normalized
|
||||
(string/replace #"\bmod\b" (if mac? "meta" "ctrl"))
|
||||
(string/replace #"\b(?:cmd|command)\b" "meta")
|
||||
(string/replace #"(?:\bopt\b|\boption\b|⌥)" "alt")))
|
||||
|
||||
(coll? binding)
|
||||
(if (and (coll? (first binding)) (> (count binding) 1) (every? coll? binding))
|
||||
;; Chord sequence: normalize each group separately, join with space
|
||||
(string/join " " (map (fn [group]
|
||||
(string/join "+" (map normalize-key (flatten-keys group))))
|
||||
binding))
|
||||
;; Single combo group
|
||||
(let [keys (flatten-keys binding)]
|
||||
(string/join "+" (map normalize-key keys))))
|
||||
|
||||
(keyword? binding)
|
||||
(name binding)
|
||||
|
||||
(symbol? binding)
|
||||
(name binding)
|
||||
|
||||
:else
|
||||
(str binding))))
|
||||
|
||||
(defn- detect-style
|
||||
"Automatically detects style from shortcut format.
|
||||
Returns :combo, :separate, or :compact"
|
||||
[shortcut]
|
||||
(cond
|
||||
(string? shortcut)
|
||||
(if (string/includes? shortcut "+")
|
||||
:combo
|
||||
:separate)
|
||||
|
||||
(coll? shortcut)
|
||||
(let [first-item (first shortcut)]
|
||||
(cond
|
||||
(coll? first-item) :combo ; nested collection means combo
|
||||
(string/includes? (str first-item) "+") :combo
|
||||
:else :separate))
|
||||
|
||||
:else :separate))
|
||||
|
||||
(defn- parse-shortcuts
|
||||
"Parses shortcut string into structured format.
|
||||
Handles ' | ' separator for multiple shortcuts."
|
||||
[s]
|
||||
(->> (string/split s #" \| ")
|
||||
(map (fn [x]
|
||||
(->> (string/split x #" ")
|
||||
(map #(if (string/includes? % "+")
|
||||
(string/split % #"\+")
|
||||
%)))))))
|
||||
|
||||
(def ^:private press-animation-ms 160)
|
||||
(def ^:private row-shimmer-ms 750)
|
||||
|
||||
(defn- highlight-row!
|
||||
"Add or remove the row highlight class on the closest shortcut row ancestor.
|
||||
On first trigger, forces a reflow to start the CSS animation.
|
||||
On repeated triggers, the class stays applied (no reflow) and the
|
||||
removal timer is reset so it fires after the last trigger."
|
||||
[^js container add?]
|
||||
(when-let [^js row (or (.closest container ".shui-shortcut-row, .shortcut-row")
|
||||
(.-parentElement container))]
|
||||
(if add?
|
||||
(let [already? (.contains (.-classList row) "shui-shortcut-row--pressed")]
|
||||
;; Cancel any pending removal
|
||||
(when-let [t (.-__sweepTimer row)]
|
||||
(js/clearTimeout t)
|
||||
(set! (.-__sweepTimer row) nil))
|
||||
;; Only force reflow on first trigger to start the animation
|
||||
(when-not already?
|
||||
(.add (.-classList row) "shui-shortcut-row--pressed"))
|
||||
;; Schedule removal after the last trigger
|
||||
(set! (.-__sweepTimer row)
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(.remove (.-classList row) "shui-shortcut-row--pressed")
|
||||
(set! (.-__sweepTimer row) nil))
|
||||
row-shimmer-ms)))
|
||||
(do
|
||||
(when-let [t (.-__sweepTimer row)]
|
||||
(js/clearTimeout t)
|
||||
(set! (.-__sweepTimer row) nil))
|
||||
(.remove (.-classList row) "shui-shortcut-row--pressed")))))
|
||||
|
||||
(defn- animate-element!
|
||||
"Add pressed class, optionally highlight row, then auto-reset after animation.
|
||||
Key badge uses a simple clearTimeout+reset pattern to avoid stale removals."
|
||||
[^js el ^js container highlight-row?]
|
||||
(.add (.-classList el) "shui-shortcut-key-pressed")
|
||||
(when highlight-row? (highlight-row! container true))
|
||||
;; Clear any pending badge removal, then schedule a new one
|
||||
(when-let [t (.-__badgeTimer el)]
|
||||
(js/clearTimeout t))
|
||||
(set! (.-__badgeTimer el)
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(.remove (.-classList el) "shui-shortcut-key-pressed")
|
||||
(set! (.-__badgeTimer el) nil))
|
||||
press-animation-ms)))
|
||||
|
||||
(defn shortcut-press!
|
||||
"Central helper to trigger key press animation.
|
||||
For combo keys, animates the container (the whole keycap depresses).
|
||||
For separate keys, animates individual kbd elements.
|
||||
Optionally highlights parent row.
|
||||
|
||||
Args:
|
||||
- binding: normalized shortcut binding string (e.g., \"cmd+k\")
|
||||
- highlight-row?: if true, also highlights parent row (default: false)"
|
||||
([binding] (shortcut-press! binding false))
|
||||
([binding highlight-row?]
|
||||
(let [normalized (normalize-binding binding)
|
||||
selector (str "[data-shortcut-binding=\"" normalized "\"]")
|
||||
containers (.querySelectorAll js/document selector)]
|
||||
(doseq [^js container (array-seq containers)]
|
||||
(if (.contains (.-classList container) "shui-shortcut-combo")
|
||||
;; Combo keys: animate the container as a unit (one keycap)
|
||||
(animate-element! container container highlight-row?)
|
||||
;; Separate keys: animate each kbd individually
|
||||
(let [keys (.querySelectorAll container "kbd.shui-shortcut-key")]
|
||||
(if (> (.-length keys) 0)
|
||||
(doseq [^js key-el (array-seq keys)]
|
||||
(animate-element! key-el container highlight-row?))
|
||||
(animate-element! container container highlight-row?))))))))
|
||||
|
||||
(hsx/defc combo-keys
|
||||
"Renders combo keys (simultaneous key combinations) with separator."
|
||||
[keys binding {:keys [aria-label aria-hidden? glow?]}]
|
||||
(let [key-elements (map print-shortcut-key keys)
|
||||
normalized-binding (normalize-binding binding)
|
||||
container-class (str "shui-shortcut-combo" (when glow? " shui-shortcut-glow"))
|
||||
container-attrs (cond-> {:class container-class
|
||||
:data-shortcut-binding normalized-binding
|
||||
:style {:white-space "nowrap"}}
|
||||
aria-label (assoc :aria-label aria-label)
|
||||
aria-hidden? (assoc :aria-hidden "true"))]
|
||||
[:div container-attrs
|
||||
(for [[index key-text] (map-indexed vector key-elements)]
|
||||
(list
|
||||
(when (< 0 index)
|
||||
[:span.shui-shortcut-separator {:key (str "sep-" index)}])
|
||||
[:kbd.shui-shortcut-key
|
||||
{:key (str "combo-key-" index)
|
||||
:aria-hidden (if aria-label "true" "false")}
|
||||
key-text]))]))
|
||||
|
||||
(hsx/defc separate-keys
|
||||
"Renders separate keys (sequential key presses) with 4px gap."
|
||||
[keys binding {:keys [aria-label aria-hidden? glow?]}]
|
||||
(let [key-elements (map print-shortcut-key keys)
|
||||
normalized-binding (normalize-binding binding)
|
||||
container-class (str "shui-shortcut-separate" (when glow? " shui-shortcut-glow"))
|
||||
container-attrs (cond-> {:class container-class
|
||||
:data-shortcut-binding normalized-binding
|
||||
:style {:white-space "nowrap"
|
||||
:gap "4px"}}
|
||||
aria-label (assoc :aria-label aria-label)
|
||||
aria-hidden? (assoc :aria-hidden "true"))]
|
||||
[:div container-attrs
|
||||
(for [[index key-text] (map-indexed vector key-elements)]
|
||||
[:kbd.shui-shortcut-key
|
||||
{:key (str "separate-key-" index)
|
||||
:aria-hidden (if aria-label "true" "false")}
|
||||
key-text])]))
|
||||
|
||||
(hsx/defc compact-keys
|
||||
"Renders compact style (text-only, minimal styling)."
|
||||
[keys binding {:keys [aria-label aria-hidden?]}]
|
||||
(let [key-elements (map print-shortcut-key keys)
|
||||
normalized-binding (normalize-binding binding)
|
||||
container-attrs (cond-> {:class "shui-shortcut-compact"
|
||||
:data-shortcut-binding normalized-binding
|
||||
:style {:white-space "nowrap"}}
|
||||
aria-label (assoc :aria-label aria-label)
|
||||
aria-hidden? (assoc :aria-hidden "true"))]
|
||||
[:div container-attrs
|
||||
(for [[index key-text] (map-indexed vector key-elements)]
|
||||
[:span
|
||||
{:key (str "compact-key-" index)
|
||||
:style {:display "inline-block"
|
||||
:margin-right "2px"}}
|
||||
key-text])]))
|
||||
|
||||
(hsx/defc chord-sequence-keys
|
||||
"Renders a chord sequence (multi-step key combinations) with 'then' separators.
|
||||
E.g., [['⌘' 'c'] ['⌘' 'r']] renders as: [⌘ C] then [⌘ R]"
|
||||
[groups binding {:keys [aria-label aria-hidden? glow? chord-separator]
|
||||
:or {chord-separator "then"}}]
|
||||
(let [normalized-binding (normalize-binding binding)
|
||||
container-attrs (cond-> {:class "shui-shortcut-chord"
|
||||
:data-shortcut-binding normalized-binding
|
||||
:style {:white-space "nowrap"
|
||||
:display "inline-flex"
|
||||
:align-items "center"
|
||||
:gap "8px"}}
|
||||
aria-label (assoc :aria-label aria-label)
|
||||
aria-hidden? (assoc :aria-hidden "true"))]
|
||||
[:div container-attrs
|
||||
(for [[gi group] (map-indexed vector groups)]
|
||||
(list
|
||||
(when (> gi 0)
|
||||
[:span.shui-shortcut-chord-sep
|
||||
{:key (str "chord-sep-" gi)
|
||||
:style {:font-size "10px"
|
||||
:opacity 0.45}}
|
||||
chord-separator])
|
||||
(let [key-elements (map print-shortcut-key group)]
|
||||
[:span
|
||||
{:key (str "chord-group-" gi)
|
||||
:class (str "shui-shortcut-combo" (when glow? " shui-shortcut-glow"))
|
||||
:style {:display "inline-flex"
|
||||
:align-items "center"
|
||||
:white-space "nowrap"}}
|
||||
(for [[ki key-text] (map-indexed vector key-elements)]
|
||||
(list
|
||||
(when (< 0 ki)
|
||||
[:span.shui-shortcut-separator {:key (str "gsep-" gi "-" ki)}])
|
||||
[:kbd.shui-shortcut-key
|
||||
{:key (str "chord-key-" gi "-" ki)
|
||||
:aria-hidden (if aria-label "true" "false")}
|
||||
key-text]))])))]))
|
||||
|
||||
(hsx/defc root
|
||||
"Main shortcut component with automatic style detection.
|
||||
|
||||
Props:
|
||||
- :style - :combo, :separate, :compact, or :auto (default: :auto)
|
||||
- :aria-label - accessibility label for container
|
||||
- :aria-hidden? - if true, hides from screen readers (default: false for decorative hints)
|
||||
- :glow? - if true, adds inner glow effect to combo/separate keys (default: true)
|
||||
- :raw-binding - raw binding format for data-shortcut-binding (for animation matching).
|
||||
If not provided, will normalize from shortcut prop."
|
||||
[shortcut & {:keys [style aria-label aria-hidden? glow? raw-binding chord-separator]
|
||||
:or {style :auto
|
||||
aria-hidden? false
|
||||
glow? true}}]
|
||||
(when (and shortcut (seq shortcut))
|
||||
(let [shortcuts (if (coll? shortcut)
|
||||
(if (every? string? shortcut)
|
||||
[shortcut] ; single shortcut as vector
|
||||
(if (string? (first shortcut))
|
||||
[shortcut] ; single shortcut string
|
||||
shortcut)) ; multiple shortcuts
|
||||
(parse-shortcuts shortcut))
|
||||
opts (cond-> {:aria-label aria-label
|
||||
:aria-hidden? aria-hidden?
|
||||
:glow? glow?}
|
||||
chord-separator (assoc :chord-separator chord-separator))]
|
||||
(for [[index binding] (map-indexed vector shortcuts)]
|
||||
(let [;; Chord sequence: multiple nested groups like [["⌘" "c"] ["⌘" "r"]]
|
||||
chord-sequence? (and (coll? binding)
|
||||
(> (count binding) 1)
|
||||
(every? coll? binding))
|
||||
detected-style (if (= style :auto)
|
||||
(detect-style binding)
|
||||
style)
|
||||
keys (cond
|
||||
(string? binding)
|
||||
(if (string/includes? binding "+")
|
||||
(string/split binding #"\+") ; combo: "cmd+k" -> ["cmd" "k"]
|
||||
(string/split binding #" ")) ; separate: "cmd k" -> ["cmd" "k"]
|
||||
|
||||
(and (coll? binding) (coll? (first binding)))
|
||||
(first binding) ; combo: nested collection like [["shift" "cmd"]]
|
||||
|
||||
(coll? binding)
|
||||
(let [flattened (mapcat (fn [item]
|
||||
(cond
|
||||
(coll? item) item
|
||||
(and (string? item) (string/includes? item "+"))
|
||||
(string/split item #"\+") ; split combo strings like "meta+caps-lock"
|
||||
:else [item]))
|
||||
binding)]
|
||||
(if (every? string? flattened)
|
||||
flattened ; separate: flat collection like ["cmd" "k"] or ["⇧" "g"]
|
||||
(map str flattened))) ; convert any non-strings to strings
|
||||
|
||||
:else
|
||||
[(str binding)])
|
||||
;; Use raw-binding if provided, otherwise normalize from binding
|
||||
binding-for-data (cond
|
||||
(nil? raw-binding) binding
|
||||
(and (coll? raw-binding) (= (count raw-binding) 1)) (first raw-binding)
|
||||
:else raw-binding)
|
||||
render-fn (case detected-style
|
||||
:combo combo-keys
|
||||
:separate separate-keys
|
||||
:compact compact-keys
|
||||
separate-keys)] ; fallback
|
||||
[:span
|
||||
{:key (str "shortcut-" index)
|
||||
:style {:display "inline-flex"
|
||||
:align-items "center"
|
||||
:white-space "nowrap"}}
|
||||
(when (< 0 index)
|
||||
[:span.text-gray-11.text-sm {:key (str "sep-" index)
|
||||
:style {:margin "0 4px"}} "|"])
|
||||
(if chord-sequence?
|
||||
(chord-sequence-keys binding binding-for-data opts)
|
||||
(render-fn keys binding-for-data opts))])))))
|
||||
|
||||
276
deps/shui/src/logseq/shui/table/core.cljc
vendored
Normal file
276
deps/shui/src/logseq/shui/table/core.cljc
vendored
Normal file
@@ -0,0 +1,276 @@
|
||||
(ns logseq.shui.table.core
|
||||
"Table"
|
||||
(:require [clojure.set :as set]
|
||||
[dommy.core :refer-macros [sel1] :as dom]
|
||||
[frontend.util :as util]
|
||||
[goog.object :as gobj]
|
||||
[io.factorhouse.hsx.core :as hsx]
|
||||
[logseq.shui.hooks :as hooks]
|
||||
[logseq.shui.table.impl :as impl]))
|
||||
|
||||
(defn- get-head-container
|
||||
[]
|
||||
#_{:clj-kondo/ignore [:unresolved-namespace]}
|
||||
(if (and js/window (gobj/get js/window "isCapacitorNew"))
|
||||
(sel1 "ion-header")
|
||||
(sel1 "#head")))
|
||||
|
||||
(defn- row-selected?
|
||||
[row row-selection]
|
||||
(let [id (:db/id row)]
|
||||
(or
|
||||
(and (:selected-all? row-selection)
|
||||
;; exclude ids
|
||||
(not (contains? (:excluded-ids row-selection) id)))
|
||||
(and (not (:selected-all? row-selection))
|
||||
;; included ids
|
||||
(contains? (:selected-ids row-selection) id)))))
|
||||
|
||||
(defn- select-some?
|
||||
[row-selection rows]
|
||||
(or
|
||||
(and (seq (:selected-ids row-selection))
|
||||
(some (:selected-ids row-selection) rows))
|
||||
(and (seq (:excluded-ids row-selection))
|
||||
(not= (count rows) (count (:excluded-ids row-selection))))))
|
||||
|
||||
(defn- select-all?
|
||||
[row-selection rows]
|
||||
(and (seq (:selected-ids row-selection))
|
||||
(set/subset? (set rows)
|
||||
(:selected-ids row-selection))))
|
||||
|
||||
(defn- toggle-selected-all!
|
||||
[table value set-row-selection!]
|
||||
(let [group-by-property (get-in table [:state :group-by-property])
|
||||
row-selection (get-in table [:state :row-selection])]
|
||||
(cond
|
||||
(and group-by-property value)
|
||||
(let [new-selection (update row-selection :selected-ids
|
||||
(fn [ids]
|
||||
(set/union (set ids) (set (:rows table)))))]
|
||||
(set-row-selection! new-selection))
|
||||
|
||||
value
|
||||
(set-row-selection! {:selected-all? value})
|
||||
|
||||
group-by-property
|
||||
(let [new-selection (update row-selection :selected-ids
|
||||
(fn [ids]
|
||||
(set/difference (set ids) (set (:rows table)))))]
|
||||
(set-row-selection! new-selection))
|
||||
|
||||
:else
|
||||
(set-row-selection! {}))))
|
||||
|
||||
(defn- set-conj
|
||||
[col item]
|
||||
(if (seq col)
|
||||
(conj (if (set? col) col (set col)) item)
|
||||
(conj #{} item)))
|
||||
|
||||
(defn- row-toggle-selected!
|
||||
[row value set-row-selection! row-selection]
|
||||
(let [id (:db/id row)
|
||||
new-selection (if (:selected-all? row-selection)
|
||||
(update row-selection :excluded-ids (if value disj set-conj) id)
|
||||
(update row-selection :selected-ids (if value set-conj disj) id))]
|
||||
(set-row-selection! new-selection)))
|
||||
|
||||
(defn- column-set-sorting!
|
||||
[column set-sorting! sorting asc?]
|
||||
(let [id (:id column)
|
||||
existing-column (some (fn [item] (when (= (:id item) id) item)) sorting)
|
||||
value (->> (if existing-column
|
||||
(if (nil? asc?)
|
||||
(remove (fn [item] (= (:id item) id)) sorting)
|
||||
(map (fn [item] (if (= (:id item) id) (assoc item :asc? asc?) item)) sorting))
|
||||
(when-not (nil? asc?)
|
||||
(into [{:id id :asc? asc?}] sorting)))
|
||||
(remove nil?)
|
||||
vec)]
|
||||
(set-sorting! value)
|
||||
value))
|
||||
|
||||
(defn get-selection-rows
|
||||
[row-selection rows]
|
||||
(if (:selected-all? row-selection)
|
||||
(let [excluded-ids (:excluded-ids row-selection)]
|
||||
(if (seq excluded-ids)
|
||||
(remove #(excluded-ids %) rows)
|
||||
rows))
|
||||
(let [selected-ids (:selected-ids row-selection)]
|
||||
(when (seq selected-ids)
|
||||
(filter #(selected-ids %) rows)))))
|
||||
|
||||
(defn table-option
|
||||
[{:keys [data columns state data-fns]
|
||||
:as option}]
|
||||
(let [{:keys [sorting row-filter row-selection visible-columns]} state
|
||||
{:keys [set-sorting! set-visible-columns! set-row-selection!]} data-fns
|
||||
columns' (impl/visible-columns columns visible-columns)
|
||||
filtered-rows (impl/rows {:rows data
|
||||
:columns columns
|
||||
:sorting sorting
|
||||
:row-filter row-filter})]
|
||||
(assoc option
|
||||
;; visible columns
|
||||
:columns columns'
|
||||
;; filtered rows
|
||||
:rows filtered-rows
|
||||
|
||||
;; fns
|
||||
:column-visible? (fn [column] (impl/column-visible? column visible-columns))
|
||||
:column-toggle-visibility (fn [column v] (set-visible-columns! (assoc visible-columns (impl/column-id column) v)))
|
||||
:selected-all? (or (and (:selected-all? row-selection)
|
||||
(nil? (seq (:excluded-ids row-selection))))
|
||||
(select-all? row-selection filtered-rows))
|
||||
:selected-some? (select-some? row-selection filtered-rows)
|
||||
:row-selected? (fn [row] (row-selected? row row-selection))
|
||||
:row-toggle-selected! (fn [row-selection row value] (row-toggle-selected! row value set-row-selection! row-selection))
|
||||
:toggle-selected-all! (fn [table value]
|
||||
(toggle-selected-all! table value set-row-selection!))
|
||||
:column-set-sorting! (fn [sorting column asc?] (column-set-sorting! column set-sorting! sorting asc?)))))
|
||||
|
||||
(defn- get-prop-and-children
|
||||
[prop-and-children]
|
||||
(let [prop (when (map? (first prop-and-children)) (first prop-and-children))]
|
||||
(if prop
|
||||
[prop (rest prop-and-children)]
|
||||
[{} prop-and-children])))
|
||||
|
||||
(hsx/defc table
|
||||
[& prop-and-children]
|
||||
(let [[prop children] (get-prop-and-children prop-and-children)]
|
||||
(into
|
||||
[:div (merge {:class "ls-table w-full caption-bottom text-sm table-fixed"}
|
||||
prop)]
|
||||
children)))
|
||||
|
||||
(defn- remove-sticky-header
|
||||
[]
|
||||
(let [existing-headings (dom/sel ".ls-fixed")]
|
||||
(doseq [node existing-headings]
|
||||
(dom/remove-class! node "ls-fixed"))))
|
||||
|
||||
(defn- use-sticky-element!
|
||||
[target-ref container enabled?]
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(when enabled?
|
||||
(let [^js/HTMLElement target (hooks/deref target-ref)
|
||||
^js/HTMLElement container (or (.closest target ".sidebar-item-list") container)
|
||||
^js/HTMLElement table-el (.closest target ".ls-table-rows")]
|
||||
(when (and container table-el)
|
||||
(let [^js/DOMTokenList target-cls (.-classList target)
|
||||
^js/HTMLElement table-footer (some-> table-el (.querySelector ".ls-table-footer"))
|
||||
^js/HTMLElement page-el (.closest target ".page-inner")
|
||||
*el-top (volatile! (-> target (.getBoundingClientRect) (.-top)))
|
||||
head-height (-> (get-head-container) (.-offsetHeight))
|
||||
update-target-top! (fn []
|
||||
(when (not (.contains target-cls "ls-fixed"))
|
||||
(vreset! *el-top (+ (-> target (.getBoundingClientRect) (.-top))
|
||||
(.-scrollTop container)))))
|
||||
update-footer! (fn []
|
||||
(let [tw (.-scrollWidth table-el)]
|
||||
(when (and table-footer (number? tw) (> tw 0))
|
||||
(set! (. (.-style table-footer) -width) (str tw "px")))))
|
||||
update-target! (fn []
|
||||
(if (.contains target-cls "ls-fixed")
|
||||
(let [^js/DOMRect rect (-> table-el (.getBoundingClientRect))
|
||||
width (.-clientWidth table-el)
|
||||
left (.-left rect)]
|
||||
(set! (. (.-style target) -width) (str width "px"))
|
||||
(set! (. (.-style target) -left) (str left "px")))
|
||||
(do
|
||||
(set! (. (.-style target) -width) "auto")
|
||||
(set! (. (.-style target) -left) "0px")))
|
||||
;; update scroll
|
||||
(set! (. target -scrollLeft) (.-scrollLeft table-el)))
|
||||
;; target observer
|
||||
target-observe! (fn [_e]
|
||||
(let [first-visible-table (some #(when (util/el-visible-in-viewport? % true) %)
|
||||
(dom/sel container ".ls-table-rows"))]
|
||||
(when (= table-el first-visible-table)
|
||||
(let [table-bottom (.-bottom (.getBoundingClientRect table-el))
|
||||
table-top (.-top (.getBoundingClientRect table-el))]
|
||||
(if (and (< table-top head-height)
|
||||
(> table-bottom 100))
|
||||
(do
|
||||
(remove-sticky-header)
|
||||
(.add target-cls "ls-fixed"))
|
||||
(.remove target-cls "ls-fixed"))
|
||||
(update-target!)))))
|
||||
resize-observer (js/ResizeObserver. update-target!)
|
||||
page-resize-observer (js/ResizeObserver. (fn [] (update-target-top!)))]
|
||||
;; events
|
||||
(.observe resize-observer container)
|
||||
(.observe resize-observer table-el)
|
||||
(some->> page-el (.observe page-resize-observer))
|
||||
(.addEventListener container "scroll" target-observe!)
|
||||
(.addEventListener table-el "scroll" update-target!)
|
||||
(.addEventListener table-el "resize" update-target!)
|
||||
(update-footer!)
|
||||
|
||||
;; teardown
|
||||
#(do (.removeEventListener container "scroll" target-observe!)
|
||||
(.disconnect resize-observer)
|
||||
(.disconnect page-resize-observer)))))))
|
||||
[enabled? container]))
|
||||
|
||||
(defn- mobile?
|
||||
[]
|
||||
(when-let [user-agent js/navigator.userAgent]
|
||||
(re-find #"Mobi" user-agent)))
|
||||
|
||||
(hsx/defc table-header
|
||||
[& prop-and-children]
|
||||
(let [[prop children] (get-prop-and-children prop-and-children)
|
||||
el-ref (hooks/use-ref nil)
|
||||
_ (use-sticky-element! el-ref (:main-container prop) (not (mobile?)))]
|
||||
(into
|
||||
[:div.ls-table-header
|
||||
(merge {:class "border-y transition-colors bg-gray-01"
|
||||
:ref el-ref
|
||||
:style {:z-index 9}}
|
||||
(dissoc prop :main-container))]
|
||||
children)))
|
||||
|
||||
(hsx/defc table-footer
|
||||
[& children]
|
||||
(into [:div.ls-table-footer.fade-in.faster] children))
|
||||
|
||||
(hsx/defc table-row
|
||||
[& prop-and-children]
|
||||
(let [[prop children] (get-prop-and-children prop-and-children)]
|
||||
(into
|
||||
[:div.ls-table-row.ls-block.flex.flex-row.items-center
|
||||
(merge {:class "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted bg-gray-01 items-stretch"}
|
||||
prop)]
|
||||
children)))
|
||||
|
||||
(hsx/defc table-cell
|
||||
[& prop-and-children]
|
||||
(let [[prop children] (get-prop-and-children prop-and-children)]
|
||||
[:div.ls-table-cell.flex.relative.h-full (dissoc prop :select? :add-property?)
|
||||
(into
|
||||
[:div {:class (str "flex align-middle w-full overflow-x-clip items-center"
|
||||
(cond
|
||||
(:select? prop)
|
||||
" px-0"
|
||||
(:add-property? prop)
|
||||
""
|
||||
:else
|
||||
" border-r px-2"))}]
|
||||
children)]))
|
||||
|
||||
(hsx/defc table-actions
|
||||
[& prop-and-children]
|
||||
(let [[prop children] (get-prop-and-children prop-and-children)
|
||||
el-ref (hooks/use-ref nil)]
|
||||
(into
|
||||
[:div.ls-table-actions.flex.flex-row.items-center.gap-1.bg-gray-01
|
||||
(merge {:ref el-ref
|
||||
:style {:z-index 101}}
|
||||
prop)]
|
||||
children)))
|
||||
22
deps/shui/src/logseq/shui/table/impl.cljc
vendored
Normal file
22
deps/shui/src/logseq/shui/table/impl.cljc
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
(ns logseq.shui.table.impl
|
||||
"Table impl")
|
||||
|
||||
(defn column-id
|
||||
[column]
|
||||
(assert (some? (:id column)) "No id specified for this column")
|
||||
(:id column))
|
||||
|
||||
(defn column-visible?
|
||||
[column visible-columns]
|
||||
(let [value (get visible-columns (column-id column))]
|
||||
(not (false? value))))
|
||||
|
||||
(defn visible-columns
|
||||
[columns visible-columns']
|
||||
(filter #(column-visible? % visible-columns') columns))
|
||||
|
||||
(defn rows
|
||||
[{:keys [row-filter]
|
||||
:as opts}]
|
||||
(let [rows' (:rows opts)]
|
||||
(if row-filter (filter row-filter rows') rows')))
|
||||
98
deps/shui/src/logseq/shui/toaster/core.cljs
vendored
Normal file
98
deps/shui/src/logseq/shui/toaster/core.cljs
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
(ns logseq.shui.toaster.core
|
||||
(:require [cljs-bean.core :as bean]
|
||||
[io.factorhouse.hsx.core :as hsx]
|
||||
[logseq.shui.hooks :as hooks]
|
||||
[logseq.shui.util :as util]))
|
||||
|
||||
(defonce ^:private Toaster (util/ui-wrap "Toaster"))
|
||||
(defonce ^:private *toast (atom nil))
|
||||
(defonce ^:private *pending-toasts (atom []))
|
||||
|
||||
(defn gen-id []
|
||||
((util/ui-get "genToastId")))
|
||||
|
||||
(defn use-toast []
|
||||
(when-let [use-toast' (util/ui-get "useToast")]
|
||||
(let [^js js-toast (use-toast')
|
||||
toast-fn! (.-toast js-toast)
|
||||
dismiss! (.-dismiss js-toast)]
|
||||
[(fn [s]
|
||||
(let [^js s (bean/->js s)]
|
||||
(toast-fn! s)))
|
||||
dismiss!])))
|
||||
|
||||
(defn update-html-props
|
||||
[v]
|
||||
(update-keys v
|
||||
#(case %
|
||||
:class :className
|
||||
:for :htmlFor
|
||||
:on-dismiss :onDismiss
|
||||
:on-open-change :onOpenChange
|
||||
%)))
|
||||
|
||||
(defn interpret-vals
|
||||
[config ks & args]
|
||||
(reduce (fn [config k]
|
||||
(let [v (get config k)
|
||||
v (if (fn? v) (apply v args) v)]
|
||||
(if (vector? v) (assoc config k (hsx/create-element v)) config)))
|
||||
config ks))
|
||||
|
||||
(defn toast!
|
||||
([content-or-config] (toast! content-or-config :default nil))
|
||||
([content-or-config status] (toast! content-or-config status nil))
|
||||
([content-or-config status opts]
|
||||
(if-let [{:keys [toast dismiss]} @*toast]
|
||||
(let [config (if (map? content-or-config)
|
||||
content-or-config
|
||||
(-> {:description content-or-config}
|
||||
(merge (if (map? status) status {:variant status}))))
|
||||
config (update-html-props (merge config opts))
|
||||
id (or (:id config) (gen-id))
|
||||
config (assoc config :id id)
|
||||
config (interpret-vals config [:title :description :action :icon]
|
||||
{:id id :dismiss! #(dismiss id) :update! #(toast! (assoc %1 :id id))})]
|
||||
(js->clj (toast (clj->js config))))
|
||||
(let [config (if (map? content-or-config)
|
||||
content-or-config
|
||||
(-> {:description content-or-config}
|
||||
(merge (if (map? status) status {:variant status}))))
|
||||
config (update-html-props (merge config opts))
|
||||
id (or (:id config) (gen-id))
|
||||
config (assoc config :id id)]
|
||||
(swap! *pending-toasts conj config)
|
||||
{"id" id}))))
|
||||
|
||||
(defn dismiss!
|
||||
([] (dismiss! nil))
|
||||
([id]
|
||||
(if id
|
||||
(swap! *pending-toasts
|
||||
(fn [pending-toasts]
|
||||
(vec (remove #(= id (:id %)) pending-toasts))))
|
||||
(reset! *pending-toasts []))
|
||||
(when-let [{:keys [dismiss]} @*toast]
|
||||
;; dismiss all
|
||||
(dismiss))))
|
||||
|
||||
(hsx/defc install-toaster
|
||||
[]
|
||||
(let [^js js-toast ((util/ui-get "useToast"))]
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(let [toast-api {:toast (.-toast js-toast)
|
||||
:dismiss (.-dismiss js-toast)
|
||||
:update (.-update js-toast)}]
|
||||
(reset! *toast toast-api)
|
||||
(doseq [config @*pending-toasts]
|
||||
(let [id (:id config)
|
||||
config (interpret-vals config [:title :description :action :icon]
|
||||
{:id id
|
||||
:dismiss! #((:dismiss toast-api) id)
|
||||
:update! #(toast! (assoc %1 :id id))})]
|
||||
((:toast toast-api) (clj->js config))))
|
||||
(reset! *pending-toasts []))
|
||||
#())
|
||||
[])
|
||||
[:<> (Toaster)]))
|
||||
165
deps/shui/src/logseq/shui/ui.cljs
vendored
Normal file
165
deps/shui/src/logseq/shui/ui.cljs
vendored
Normal file
@@ -0,0 +1,165 @@
|
||||
(ns logseq.shui.ui
|
||||
(:require [logseq.shui.base.core :as base-core]
|
||||
[logseq.shui.dialog.core :as dialog-core]
|
||||
[logseq.shui.form.core :as form-core]
|
||||
[logseq.shui.form.password :as form-password]
|
||||
[logseq.shui.icon.v2 :as icon-v2]
|
||||
[logseq.shui.popup.core :as popup-core]
|
||||
[logseq.shui.select.core :as select-core]
|
||||
[logseq.shui.select.multi :as select-multi]
|
||||
[logseq.shui.shortcut :as shui.shortcut]
|
||||
[logseq.shui.table.core :as table-core]
|
||||
[logseq.shui.toaster.core :as toaster-core]
|
||||
[logseq.shui.util :as util]))
|
||||
|
||||
(def button base-core/button)
|
||||
(def button-icon base-core/button-icon)
|
||||
(def button-ghost-icon base-core/button-ghost-icon)
|
||||
(def button-outline-icon base-core/button-outline-icon)
|
||||
(def button-secondary-icon base-core/button-secondary-icon)
|
||||
(def button-group (util/ui-wrap "ButtonGroup"))
|
||||
(def toolbar (util/ui-wrap "Toolbar"))
|
||||
(def toolbar-group (util/ui-wrap "ToolbarGroup"))
|
||||
(def toolbar-button (util/ui-wrap "ToolbarButton"))
|
||||
(def toolbar-input (util/ui-wrap "ToolbarInput"))
|
||||
(def toolbar-link (util/ui-wrap "ToolbarLink"))
|
||||
(def toolbar-separator (util/ui-wrap "ToolbarSeparator"))
|
||||
(def link base-core/link)
|
||||
(def trigger-as base-core/trigger-as)
|
||||
(def trigger-child-wrap base-core/trigger-child-wrap)
|
||||
(def shortcut shui.shortcut/root)
|
||||
(def shortcut-press! shui.shortcut/shortcut-press!)
|
||||
(def ^:export tabler-icon icon-v2/root)
|
||||
|
||||
(def alert (util/ui-wrap "Alert"))
|
||||
(def alert-title (util/ui-wrap "AlertTitle"))
|
||||
(def alert-description (util/ui-wrap "AlertDescription"))
|
||||
(def slider (util/ui-wrap "Slider"))
|
||||
(def slider-track (util/ui-wrap "SliderTrack"))
|
||||
(def slider-range (util/ui-wrap "SliderRange"))
|
||||
(def slider-thumb (util/ui-wrap "SliderThumb"))
|
||||
(def separator (util/ui-wrap "Separator"))
|
||||
(def badge (util/ui-wrap "Badge"))
|
||||
(def skeleton (util/ui-wrap "Skeleton"))
|
||||
(def calendar (util/ui-wrap "Calendar"))
|
||||
(def avatar (util/ui-wrap "Avatar"))
|
||||
(def avatar-image (util/ui-wrap "AvatarImage"))
|
||||
(def avatar-fallback (util/ui-wrap "AvatarFallback"))
|
||||
(def input form-core/input)
|
||||
(def textarea form-core/textarea)
|
||||
(def switch form-core/switch)
|
||||
(def checkbox form-core/checkbox)
|
||||
(def radio-group form-core/radio-group)
|
||||
(def radio-group-item form-core/radio-group-item)
|
||||
(def popover popup-core/popover)
|
||||
(def popover-trigger popup-core/popover-trigger)
|
||||
(def popover-content popup-core/popover-content)
|
||||
(def popover-arrow popup-core/popover-arrow)
|
||||
|
||||
(def tooltip (util/ui-wrap "Tooltip"))
|
||||
(def tooltip-trigger (util/ui-wrap "TooltipTrigger"))
|
||||
(def tooltip-portal (util/ui-wrap "TooltipPortal"))
|
||||
(def tooltip-content (util/ui-wrap "TooltipContent"))
|
||||
(def tooltip-provider (util/ui-wrap "TooltipProvider"))
|
||||
(def tooltip-arrow (util/ui-wrap "TooltipArrow"))
|
||||
|
||||
(def card (util/ui-wrap "Card"))
|
||||
(def card-header (util/ui-wrap "CardHeader"))
|
||||
(def card-title (util/ui-wrap "CardTitle"))
|
||||
(def card-description (util/ui-wrap "CardDescription"))
|
||||
(def card-content (util/ui-wrap "CardContent"))
|
||||
(def card-footer (util/ui-wrap "CardFooter"))
|
||||
|
||||
(def form-provider form-core/form-provider)
|
||||
(def form-item form-core/form-item)
|
||||
(def form-label form-core/form-label)
|
||||
(def form-description form-core/form-description)
|
||||
(def form-message form-core/form-message)
|
||||
(def form-field form-core/form-field)
|
||||
(def form-control form-core/form-control)
|
||||
|
||||
(def select select-core/select)
|
||||
(def select-group select-core/select-group)
|
||||
(def select-value select-core/select-value)
|
||||
(def select-trigger select-core/select-trigger)
|
||||
(def select-icon select-core/select-icon)
|
||||
(def select-content select-core/select-content)
|
||||
(def select-label select-core/select-label)
|
||||
(def select-item select-core/select-item)
|
||||
(def select-separator select-core/select-separator)
|
||||
(def select-scroll-up-button select-core/select-scroll-up-button)
|
||||
(def select-scroll-down-button select-core/select-scroll-down-button)
|
||||
|
||||
(def dropdown-menu popup-core/dropdown-menu)
|
||||
(def dropdown-menu-trigger popup-core/dropdown-menu-trigger)
|
||||
(def dropdown-menu-content popup-core/dropdown-menu-content)
|
||||
(def dropdown-menu-group popup-core/dropdown-menu-group)
|
||||
(def dropdown-menu-item popup-core/dropdown-menu-item)
|
||||
(def dropdown-menu-checkbox-item popup-core/dropdown-menu-checkbox-item)
|
||||
(def dropdown-menu-radio-group popup-core/dropdown-menu-radio-group)
|
||||
(def dropdown-menu-radio-item popup-core/dropdown-menu-radio-item)
|
||||
(def dropdown-menu-label popup-core/dropdown-menu-label)
|
||||
(def dropdown-menu-separator popup-core/dropdown-menu-separator)
|
||||
(def dropdown-menu-shortcut popup-core/dropdown-menu-shortcut)
|
||||
(def dropdown-menu-sub popup-core/dropdown-menu-sub)
|
||||
(def dropdown-menu-sub-content popup-core/dropdown-menu-sub-content)
|
||||
(def dropdown-menu-sub-trigger popup-core/dropdown-menu-sub-trigger)
|
||||
|
||||
(def context-menu (util/ui-wrap "ContextMenu"))
|
||||
(def context-menu-trigger (util/ui-wrap "ContextMenuTrigger"))
|
||||
(def context-menu-content (util/ui-wrap "ContextMenuContent"))
|
||||
(def context-menu-item (util/ui-wrap "ContextMenuItem"))
|
||||
(def context-menu-checkbox-item (util/ui-wrap "ContextMenuCheckboxItem"))
|
||||
(def context-menu-radio-item (util/ui-wrap "ContextMenuRadioItem"))
|
||||
(def context-menu-label (util/ui-wrap "ContextMenuLabel"))
|
||||
(def context-menu-separator (util/ui-wrap "ContextMenuSeparator"))
|
||||
(def context-menu-shortcut (util/ui-wrap "ContextMenuShortcut"))
|
||||
(def context-menu-group (util/ui-wrap "ContextMenuGroup"))
|
||||
(def context-menu-portal (util/ui-wrap "ContextMenuPortal"))
|
||||
(def context-menu-sub (util/ui-wrap "ContextMenuSub"))
|
||||
(def context-menu-sub-content (util/ui-wrap "ContextMenuSubContent"))
|
||||
(def context-menu-sub-trigger (util/ui-wrap "ContextMenuSubTrigger"))
|
||||
(def context-menu-radio-group (util/ui-wrap "ContextMenuRadioGroup"))
|
||||
|
||||
;; tabs
|
||||
(def tabs (util/ui-wrap "Tabs"))
|
||||
(def tabs-list (util/ui-wrap "TabsList"))
|
||||
(def tabs-trigger (util/ui-wrap "TabsTrigger"))
|
||||
(def tabs-indicator (util/ui-wrap "TabsIndicator"))
|
||||
(def tabs-content (util/ui-wrap "TabsContent"))
|
||||
|
||||
(def dialog dialog-core/dialog)
|
||||
(def dialog-portal dialog-core/dialog-portal)
|
||||
(def dialog-overlay dialog-core/dialog-overlay)
|
||||
(def dialog-close dialog-core/dialog-close)
|
||||
(def dialog-trigger dialog-core/dialog-trigger)
|
||||
(def dialog-content dialog-core/dialog-content)
|
||||
(def dialog-header dialog-core/dialog-header)
|
||||
(def dialog-footer dialog-core/dialog-footer)
|
||||
(def dialog-title dialog-core/dialog-title)
|
||||
(def dialog-description dialog-core/dialog-description)
|
||||
|
||||
(def toast! toaster-core/toast!)
|
||||
(def toast-dismiss! toaster-core/dismiss!)
|
||||
(def dialog-open! dialog-core/open!)
|
||||
(def dialog-alert! dialog-core/alert!)
|
||||
(def dialog-confirm! dialog-core/confirm!)
|
||||
(def dialog-close! dialog-core/close!)
|
||||
(def dialog-close-all! dialog-core/close-all!)
|
||||
(def dialog-get dialog-core/get-dialog)
|
||||
(def popup-show! popup-core/show!)
|
||||
(def popup-hide! popup-core/hide!)
|
||||
(def popup-hide-all! popup-core/hide-all!)
|
||||
|
||||
(def multi-select-content select-multi/x-select-content)
|
||||
|
||||
(def table-option table-core/table-option)
|
||||
(def table table-core/table)
|
||||
(def table-header table-core/table-header)
|
||||
(def table-footer table-core/table-footer)
|
||||
(def table-row table-core/table-row)
|
||||
(def table-cell table-core/table-cell)
|
||||
(def table-actions table-core/table-actions)
|
||||
(def table-get-selection-rows table-core/get-selection-rows)
|
||||
|
||||
(def toggle-password form-password/toggle-password)
|
||||
113
deps/shui/src/logseq/shui/util.cljs
vendored
Normal file
113
deps/shui/src/logseq/shui/util.cljs
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
(ns logseq.shui.util
|
||||
(:require
|
||||
["uniqolor" :as uniqolor]
|
||||
["react" :as react]
|
||||
[cljs-bean.core :as bean]
|
||||
[clojure.set :refer [rename-keys]]
|
||||
[clojure.string :as string]
|
||||
[clojure.walk :as w]
|
||||
[goog.object :as gobj]
|
||||
[io.factorhouse.hsx.core :as hsx]
|
||||
[logseq.shui.components :as components]
|
||||
[logseq.shui.hooks :as hooks]))
|
||||
|
||||
(goog-define NODETEST false)
|
||||
|
||||
(defn kebab-case->camel-case
|
||||
"Converts from kebab case to camel case, eg: on-click to onClick"
|
||||
[input]
|
||||
(let [words (string/split input #"-")
|
||||
capitalize (->> (rest words)
|
||||
(map #(apply str (string/upper-case (first %)) (rest %))))]
|
||||
(apply str (first words) capitalize)))
|
||||
|
||||
(defn map-keys->camel-case
|
||||
"Stringify all the keys of a cljs hashmap and converts them
|
||||
from kebab case to camel case. If :html-props option is specified,
|
||||
then rename the html properties values to their dom equivalent
|
||||
before conversion"
|
||||
[data & {:keys [html-props]}]
|
||||
(let [convert-to-camel (fn [[key value]]
|
||||
(let [k (name key)]
|
||||
[(if-not (or (string/starts-with? k "data-")
|
||||
(string/starts-with? k "aria-"))
|
||||
(kebab-case->camel-case k) k) value]))]
|
||||
(w/postwalk (fn [x]
|
||||
(if (map? x)
|
||||
(let [new-map (if html-props
|
||||
(rename-keys x {:class :className :for :htmlFor})
|
||||
x)]
|
||||
(into {} (map convert-to-camel new-map)))
|
||||
x))
|
||||
data)))
|
||||
|
||||
(def dev? goog.DEBUG)
|
||||
|
||||
(defn uuid-color
|
||||
[uuid-str]
|
||||
(some-> (uniqolor uuid-str
|
||||
#js {:saturation #js [55, 70]
|
||||
:lightness 70
|
||||
:differencePoint 60})
|
||||
(aget "color")))
|
||||
|
||||
(defn get-path
|
||||
"Returns the component path."
|
||||
[component-name]
|
||||
(string/split (name component-name) #"\."))
|
||||
|
||||
(defn adapt-class [react-class & args]
|
||||
(let [[opts children] (if (map? (first args))
|
||||
[(first args) (rest args)]
|
||||
[{} args])
|
||||
children (some->> children (remove nil?))
|
||||
children (map hsx/create-element children)
|
||||
|
||||
;; convert any options key value to a React element, if
|
||||
;; a valid html element tag is used.
|
||||
vector->react-elems (fn [[key val]]
|
||||
(if (sequential? val)
|
||||
[key (hsx/create-element val)]
|
||||
[key val]))
|
||||
new-options (into {} (map vector->react-elems opts))
|
||||
react-class (if dev? (react-class) react-class)]
|
||||
(apply react/createElement react-class
|
||||
;; sablono html-to-dom-attrs does not work for nested hash-maps
|
||||
(bean/->js (map-keys->camel-case new-options :html-props true))
|
||||
children)))
|
||||
|
||||
(def use-atom hooks/use-atom)
|
||||
(def use-mounted hooks/use-mounted)
|
||||
|
||||
(defn- same-args?
|
||||
[^js prev-props ^js next-props]
|
||||
(= (.-args prev-props)
|
||||
(.-args next-props)))
|
||||
|
||||
(defn react->component [c static?]
|
||||
(if static?
|
||||
(let [class (fn [^js props]
|
||||
(apply adapt-class c (.-args props)))
|
||||
memo-class (if-some [memo (.-memo react)]
|
||||
(memo class same-args?)
|
||||
class)]
|
||||
(fn [& args]
|
||||
(react/createElement memo-class #js {:args args})))
|
||||
(partial adapt-class c)))
|
||||
|
||||
(defn component-wrap
|
||||
"Returns the component by the given component name."
|
||||
[^js ns name & {:keys [static?] :or {static? false}}]
|
||||
(let [path (get-path name)
|
||||
;; lazy calculating is for HMR from ts
|
||||
cp #(gobj/getValueByKeys ns (clj->js path))]
|
||||
(react->component (if dev? cp (cp)) static?)))
|
||||
|
||||
(def ui-wrap
|
||||
(partial component-wrap components/registry))
|
||||
|
||||
(defn ui-get
|
||||
[name]
|
||||
(if NODETEST
|
||||
#js {}
|
||||
(some-> components/registry (aget name))))
|
||||
Reference in New Issue
Block a user