Skip to content

Commit 4cd5bfc

Browse files
committed
feat: implement scroll helpers for Cmd+K focus visibility and wheel anchoring to improve ux
1 parent f49e472 commit 4cd5bfc

3 files changed

Lines changed: 351 additions & 66 deletions

File tree

src/main/frontend/components/cmdk/core.cljs

Lines changed: 149 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
[clojure.string :as string]
44
[frontend.components.block :as block]
55
[frontend.components.cmdk.list-item :as list-item]
6+
[frontend.components.cmdk.scroll :as scroll]
67
[frontend.components.cmdk.state :as cmdk-state]
78
[frontend.components.icon :as icon-component]
89
[frontend.config :as config]
@@ -622,25 +623,45 @@
622623
(when-let [action (state->action state)]
623624
(handle-action action state event)))
624625

625-
(defn- scroll-into-view-when-invisible
626+
(defn- ensure-focus-visible!
626627
[state target]
627-
(let [*container-ref (::scroll-container-ref state)
628-
container-rect (.getBoundingClientRect @*container-ref)
629-
t1 (.-top container-rect)
630-
b1 (.-bottom container-rect)
631-
target-rect (.getBoundingClientRect target)
632-
t2 (.-top target-rect)
633-
b2 (.-bottom target-rect)]
634-
(when-not (<= t1 t2 b2 b1) ; not visible
635-
(.scrollIntoView target
636-
#js {:inline "nearest"
637-
:behavior "smooth"}))))
628+
(let [container @(::scroll-container-ref state)
629+
rect (scroll/focus-row-visible-rect container target)]
630+
(when rect
631+
(let [next-scroll-top (scroll/ensure-focus-visible-scroll-top rect)]
632+
(when (not= next-scroll-top (.-scrollTop container))
633+
(set! (.-scrollTop container) next-scroll-top))))))
634+
635+
(defn- apply-anchored-wheel-scroll!
636+
[state e]
637+
(when (and @(::wheel-focus-anchor? state)
638+
(= :keyboard @(::focus-source state)))
639+
(let [container @(::scroll-container-ref state)
640+
target @(::highlighted-row-el state)
641+
rect (scroll/focus-row-visible-rect container target)
642+
delta-y (some-> e .-deltaY)]
643+
(when (and rect (number? delta-y) (not (zero? delta-y)))
644+
(let [next-scroll-top (scroll/anchored-scroll-top (assoc rect :delta-y delta-y))]
645+
(.preventDefault e)
646+
(when (not= next-scroll-top (.-scrollTop container))
647+
(set! (.-scrollTop container) next-scroll-top)))))))
648+
649+
(defn- handle-results-wheel!
650+
[state e]
651+
;; Wheel input means user wants free scrolling, so exit keyboard-anchored mode first.
652+
(when (= :keyboard @(::focus-source state))
653+
(reset! (::focus-source state) :mouse)
654+
(reset! (::highlighted-row-el state) nil))
655+
(apply-anchored-wheel-scroll! state e))
638656

639657
(rum/defcs result-group
640658
< rum/reactive
641659
[state' state title group visible-items first-item sidebar?]
642660
(let [{:keys [show items]} (some-> state ::results deref group)
643-
highlighted-item (or @(::highlighted-item state) first-item)
661+
focus-source @(::focus-source state)
662+
highlighted-item (or @(::highlighted-item state)
663+
(when (= :keyboard focus-source) first-item))
664+
disable-lazy? @(::disable-lazy? state)
644665
*mouse-active? (::mouse-active? state)
645666
filter' @(::filter state)
646667
can-show-less? (< (get-group-limit group) (count visible-items))
@@ -650,8 +671,14 @@
650671
[:div {:class (if (= title "Create")
651672
"border-b border-gray-06 last:border-b-0"
652673
"border-b border-gray-06 pb-1 last:border-b-0")
653-
:on-mouse-move #(reset! *mouse-active? true)
654-
:on-mouse-enter #(reset! *mouse-active? true)}
674+
:on-mouse-move (fn [e]
675+
(let [dx (or (.-movementX e) 0)
676+
dy (or (.-movementY e) 0)
677+
real-pointer-move? (or (not (zero? dx))
678+
(not (zero? dy)))]
679+
(when real-pointer-move?
680+
(when-not @*mouse-active?
681+
(reset! *mouse-active? true)))))}
655682
(when-not (= title "Create")
656683
[:div {:class "text-xs py-1.5 px-3 flex justify-between items-center gap-2 text-gray-11 bg-gray-02 h-8"}
657684
[:div {:class "font-bold text-gray-11 pl-0.5 cursor-pointer select-none"
@@ -664,7 +691,7 @@
664691
[:div {:class "pl-1.5 text-gray-12 rounded-full"
665692
:style {:font-size "0.7rem"}}
666693
(if (<= 100 (count items))
667-
(str "99+")
694+
"99+"
668695
(count items))])
669696

670697
[:div {:class "flex-1"}]
@@ -673,7 +700,12 @@
673700
(empty? filter')
674701
(not sidebar?))
675702
[:a.text-link.select-node.opacity-50.hover:opacity-90
676-
{:on-click (if (= show :more) show-less show-more)}
703+
{:on-click (fn [e]
704+
(util/stop e)
705+
(reset! (::focus-source state) :mouse)
706+
(when-let [input-el @(::input-ref state)]
707+
(.focus input-el))
708+
((if (= show :more) show-less show-more)))}
677709
(if (= show :more)
678710
[:div.flex.flex-row.gap-1.items-center
679711
"Show less"
@@ -709,28 +741,64 @@
709741
(handle-action :default state item)
710742
(when-let [on-click (:on-click item)]
711743
(on-click e)))
712-
:on-mouse-enter
713-
(fn [_e]
714-
(when (not= item @(::highlighted-item state))
715-
(reset! (::highlighted-item state) item)))
716-
:on-highlight (fn [ref]
717-
(reset! (::highlighted-group state) group)
718-
(when (and ref (.-current ref)
719-
(not (:mouse-enter-triggered-highlight @(::highlighted-item state))))
720-
(scroll-into-view-when-invisible state (.-current ref)))))
721-
nil)]
722-
(if (= group :nodes)
744+
:on-mouse-enter
745+
(fn [_e]
746+
(when (and @*mouse-active?
747+
(= :mouse @(::focus-source state)))
748+
(when (not= item @(::highlighted-item state))
749+
(reset! (::highlighted-item state) item))))
750+
:component-opts
751+
{:on-mouse-move
752+
(fn [e]
753+
(let [dx (or (.-movementX e) 0)
754+
dy (or (.-movementY e) 0)
755+
real-pointer-move? (or (not (zero? dx))
756+
(not (zero? dy)))]
757+
(when real-pointer-move?
758+
(when-not @*mouse-active?
759+
(reset! *mouse-active? true))
760+
(when-not (= :mouse @(::focus-source state))
761+
(reset! (::focus-source state) :mouse))
762+
(when (not= item @(::highlighted-item state))
763+
(reset! (::highlighted-item state) item)))))}
764+
:on-highlight (fn [ref]
765+
(reset! (::highlighted-group state) group)
766+
(when (and ref (.-current ref))
767+
(let [row-el (.-current ref)]
768+
(reset! (::highlighted-row-el state) row-el)
769+
(when (= :keyboard @(::focus-source state))
770+
(ensure-focus-visible! state row-el))))))
771+
nil)]
772+
(if (and (= group :nodes) (not disable-lazy?))
723773
(ui/lazy-visible (fn [] item) {:trigger-once? true})
724774
item)))]]))
725775

726776
(defn move-highlight [state n]
727777
(let [items (mapcat last (state->results-ordered state (:search/mode @state/state)))
778+
focus-source @(::focus-source state)
728779
highlighted-item (some-> state ::highlighted-item deref (dissoc :mouse-enter-triggered-highlight))
729-
current-item-index (some->> highlighted-item (.indexOf items))
730-
next-item-index (some-> (or current-item-index 0) (+ n) (mod (count items)))]
731-
(if-let [next-highlighted-item (nth items next-item-index nil)]
732-
(reset! (::highlighted-item state) next-highlighted-item)
733-
(reset! (::highlighted-item state) nil))))
780+
fallback-highlighted? (and (nil? highlighted-item)
781+
(= :keyboard focus-source)
782+
(seq items))
783+
current-item-index (cond
784+
highlighted-item (.indexOf items highlighted-item)
785+
fallback-highlighted? 0
786+
:else nil)
787+
items-count (count items)]
788+
(if (pos? items-count)
789+
(let [base-index (if (some? current-item-index)
790+
current-item-index
791+
(if (pos? n) -1 0))
792+
next-item-index (mod (+ base-index n) items-count)
793+
next-highlighted-item (nth items next-item-index nil)]
794+
(if next-highlighted-item
795+
(reset! (::highlighted-item state) next-highlighted-item)
796+
(do
797+
(reset! (::highlighted-item state) nil)
798+
(reset! (::highlighted-row-el state) nil))))
799+
(do
800+
(reset! (::highlighted-item state) nil)
801+
(reset! (::highlighted-row-el state) nil)))))
734802

735803
(defn handle-input-change
736804
([state e] (handle-input-change state e (.. e -target -value)))
@@ -808,14 +876,24 @@
808876
(shui/dialog-close! :ls-dialog-cmdk)
809877
(state/sidebar-add-block! repo input :search))
810878
as-keydown? (if meta?
811-
(show-more)
812879
(do
880+
(reset! (::disable-lazy? state) true)
881+
(show-more))
882+
(do
883+
(reset! (::disable-lazy? state) true)
813884
(reset! (::mouse-active? state) false)
885+
(reset! (::focus-source state) :keyboard)
886+
(reset! (::highlighted-row-el state) nil)
814887
(move-highlight state 1)))
815888
as-keyup? (if meta?
816-
(show-less)
817889
(do
890+
(reset! (::disable-lazy? state) true)
891+
(show-less))
892+
(do
893+
(reset! (::disable-lazy? state) true)
818894
(reset! (::mouse-active? state) false)
895+
(reset! (::focus-source state) :keyboard)
896+
(reset! (::highlighted-row-el state) nil)
819897
(move-highlight state -1)))
820898
(and enter? (not composing?)) (do
821899
(handle-action :default state e)
@@ -888,7 +966,8 @@
888966
;; This was moved to a functional component
889967
(hooks/use-effect! (fn []
890968
(when (and highlighted-item (= -1 (.indexOf all-items (dissoc highlighted-item :mouse-enter-triggered-highlight))))
891-
(reset! (::highlighted-item state) nil)))
969+
(reset! (::highlighted-item state) nil)
970+
(reset! (::highlighted-row-el state) nil)))
892971
[all-items])
893972
(hooks/use-effect!
894973
(fn []
@@ -1072,7 +1151,11 @@
10721151
(rum/local false ::meta?)
10731152
(rum/local nil ::highlighted-group)
10741153
(rum/local nil ::highlighted-item)
1154+
(rum/local nil ::highlighted-row-el)
1155+
(rum/local false ::disable-lazy?)
1156+
(rum/local :keyboard ::focus-source)
10751157
(rum/local false ::mouse-active?)
1158+
(rum/local true ::wheel-focus-anchor?)
10761159
(rum/local default-results ::results)
10771160
(rum/local nil ::scroll-container-ref)
10781161
(rum/local nil ::input-ref)
@@ -1090,36 +1173,36 @@
10901173
:class (cond-> "w-full h-full relative flex flex-col justify-start"
10911174
(not sidebar?) (str " rounded-lg"))}
10921175
(input-row state all-items opts)
1093-
[:div {:class (cond-> "w-full flex-1 overflow-y-auto min-h-[65dvh] max-h-[65dvh]"
1094-
(not sidebar?) (str " pb-14"))
1095-
:ref #(let [*ref (::scroll-container-ref state)]
1096-
(when-not @*ref (reset! *ref %)))
1097-
:on-mouse-enter #(reset! (::mouse-active? state) true)
1098-
:on-mouse-leave #(reset! (::mouse-active? state) false)
1099-
:style {:background "var(--lx-gray-02)"
1100-
:scroll-padding-block 32}}
1101-
1102-
(when group-filter
1103-
[:div.flex.flex-col.px-3.py-1.opacity-70.text-sm
1104-
(search-only state (string/capitalize (name group-filter)))])
1105-
1106-
(let [items (filter
1107-
(fn [[_group-name group-key group-count _group-items]]
1108-
(and (not= 0 group-count)
1109-
(if-not group-filter true
1110-
(or (= group-filter group-key)
1111-
(and (= group-filter :nodes)
1112-
(= group-key :current-page))
1113-
(and (contains? #{:create} group-filter)
1114-
(= group-key :create))))))
1115-
results-ordered)]
1116-
(if (seq items)
1117-
(for [[group-name group-key _group-count group-items] items]
1118-
(let [title (string/capitalize group-name)]
1119-
(result-group state title group-key group-items first-item sidebar?)))
1120-
[:div.flex.flex-col.p-4.opacity-50
1121-
(when-not (string/blank? @*input)
1122-
"No matched results")]))]
1176+
[:div {:class (cond-> "w-full flex-1 overflow-y-auto min-h-[65dvh] max-h-[65dvh]"
1177+
(not sidebar?) (str " pb-14"))
1178+
:ref #(let [*ref (::scroll-container-ref state)]
1179+
(when-not @*ref (reset! *ref %)))
1180+
:on-mouse-leave #(reset! (::mouse-active? state) false)
1181+
:on-wheel #(handle-results-wheel! state %)
1182+
:style {:background "var(--lx-gray-02)"
1183+
:scroll-padding-block 32}}
1184+
1185+
(when group-filter
1186+
[:div.flex.flex-col.px-3.py-1.opacity-70.text-sm
1187+
(search-only state (string/capitalize (name group-filter)))])
1188+
1189+
(let [items (filter
1190+
(fn [[_group-name group-key group-count _group-items]]
1191+
(and (not= 0 group-count)
1192+
(if-not group-filter true
1193+
(or (= group-filter group-key)
1194+
(and (= group-filter :nodes)
1195+
(= group-key :current-page))
1196+
(and (contains? #{:create} group-filter)
1197+
(= group-key :create))))))
1198+
results-ordered)]
1199+
(if (seq items)
1200+
(for [[group-name group-key _group-count group-items] items]
1201+
(let [title (string/capitalize group-name)]
1202+
(result-group state title group-key group-items first-item sidebar?)))
1203+
[:div.flex.flex-col.p-4.opacity-50
1204+
(when-not (string/blank? @*input)
1205+
"No matched results")]))]
11231206
(when-not sidebar? (hints state))]))
11241207

11251208
(rum/defc cmdk-modal [props]

0 commit comments

Comments
 (0)