feat: implement scroll helpers for Cmd+K focus visibility and wheel anchoring to improve ux

This commit is contained in:
Mega Yu
2026-02-11 14:37:25 +08:00
parent f49e472402
commit 4cd5bfc102
3 changed files with 349 additions and 64 deletions

View File

@@ -3,6 +3,7 @@
[clojure.string :as string]
[frontend.components.block :as block]
[frontend.components.cmdk.list-item :as list-item]
[frontend.components.cmdk.scroll :as scroll]
[frontend.components.cmdk.state :as cmdk-state]
[frontend.components.icon :as icon-component]
[frontend.config :as config]
@@ -622,25 +623,45 @@
(when-let [action (state->action state)]
(handle-action action state event)))
(defn- scroll-into-view-when-invisible
(defn- ensure-focus-visible!
[state target]
(let [*container-ref (::scroll-container-ref state)
container-rect (.getBoundingClientRect @*container-ref)
t1 (.-top container-rect)
b1 (.-bottom container-rect)
target-rect (.getBoundingClientRect target)
t2 (.-top target-rect)
b2 (.-bottom target-rect)]
(when-not (<= t1 t2 b2 b1) ; not visible
(.scrollIntoView target
#js {:inline "nearest"
:behavior "smooth"}))))
(let [container @(::scroll-container-ref state)
rect (scroll/focus-row-visible-rect container target)]
(when rect
(let [next-scroll-top (scroll/ensure-focus-visible-scroll-top rect)]
(when (not= next-scroll-top (.-scrollTop container))
(set! (.-scrollTop container) next-scroll-top))))))
(defn- apply-anchored-wheel-scroll!
[state e]
(when (and @(::wheel-focus-anchor? state)
(= :keyboard @(::focus-source state)))
(let [container @(::scroll-container-ref state)
target @(::highlighted-row-el state)
rect (scroll/focus-row-visible-rect container target)
delta-y (some-> e .-deltaY)]
(when (and rect (number? delta-y) (not (zero? delta-y)))
(let [next-scroll-top (scroll/anchored-scroll-top (assoc rect :delta-y delta-y))]
(.preventDefault e)
(when (not= next-scroll-top (.-scrollTop container))
(set! (.-scrollTop container) next-scroll-top)))))))
(defn- handle-results-wheel!
[state e]
;; Wheel input means user wants free scrolling, so exit keyboard-anchored mode first.
(when (= :keyboard @(::focus-source state))
(reset! (::focus-source state) :mouse)
(reset! (::highlighted-row-el state) nil))
(apply-anchored-wheel-scroll! state e))
(rum/defcs result-group
< rum/reactive
[state' state title group visible-items first-item sidebar?]
(let [{:keys [show items]} (some-> state ::results deref group)
highlighted-item (or @(::highlighted-item state) first-item)
focus-source @(::focus-source state)
highlighted-item (or @(::highlighted-item state)
(when (= :keyboard focus-source) first-item))
disable-lazy? @(::disable-lazy? state)
*mouse-active? (::mouse-active? state)
filter' @(::filter state)
can-show-less? (< (get-group-limit group) (count visible-items))
@@ -650,8 +671,14 @@
[:div {:class (if (= title "Create")
"border-b border-gray-06 last:border-b-0"
"border-b border-gray-06 pb-1 last:border-b-0")
:on-mouse-move #(reset! *mouse-active? true)
:on-mouse-enter #(reset! *mouse-active? true)}
:on-mouse-move (fn [e]
(let [dx (or (.-movementX e) 0)
dy (or (.-movementY e) 0)
real-pointer-move? (or (not (zero? dx))
(not (zero? dy)))]
(when real-pointer-move?
(when-not @*mouse-active?
(reset! *mouse-active? true)))))}
(when-not (= title "Create")
[:div {:class "text-xs py-1.5 px-3 flex justify-between items-center gap-2 text-gray-11 bg-gray-02 h-8"}
[:div {:class "font-bold text-gray-11 pl-0.5 cursor-pointer select-none"
@@ -664,7 +691,7 @@
[:div {:class "pl-1.5 text-gray-12 rounded-full"
:style {:font-size "0.7rem"}}
(if (<= 100 (count items))
(str "99+")
"99+"
(count items))])
[:div {:class "flex-1"}]
@@ -673,7 +700,12 @@
(empty? filter')
(not sidebar?))
[:a.text-link.select-node.opacity-50.hover:opacity-90
{:on-click (if (= show :more) show-less show-more)}
{:on-click (fn [e]
(util/stop e)
(reset! (::focus-source state) :mouse)
(when-let [input-el @(::input-ref state)]
(.focus input-el))
((if (= show :more) show-less show-more)))}
(if (= show :more)
[:div.flex.flex-row.gap-1.items-center
"Show less"
@@ -709,28 +741,64 @@
(handle-action :default state item)
(when-let [on-click (:on-click item)]
(on-click e)))
:on-mouse-enter
(fn [_e]
(when (not= item @(::highlighted-item state))
(reset! (::highlighted-item state) item)))
:on-highlight (fn [ref]
(reset! (::highlighted-group state) group)
(when (and ref (.-current ref)
(not (:mouse-enter-triggered-highlight @(::highlighted-item state))))
(scroll-into-view-when-invisible state (.-current ref)))))
nil)]
(if (= group :nodes)
:on-mouse-enter
(fn [_e]
(when (and @*mouse-active?
(= :mouse @(::focus-source state)))
(when (not= item @(::highlighted-item state))
(reset! (::highlighted-item state) item))))
:component-opts
{:on-mouse-move
(fn [e]
(let [dx (or (.-movementX e) 0)
dy (or (.-movementY e) 0)
real-pointer-move? (or (not (zero? dx))
(not (zero? dy)))]
(when real-pointer-move?
(when-not @*mouse-active?
(reset! *mouse-active? true))
(when-not (= :mouse @(::focus-source state))
(reset! (::focus-source state) :mouse))
(when (not= item @(::highlighted-item state))
(reset! (::highlighted-item state) item)))))}
:on-highlight (fn [ref]
(reset! (::highlighted-group state) group)
(when (and ref (.-current ref))
(let [row-el (.-current ref)]
(reset! (::highlighted-row-el state) row-el)
(when (= :keyboard @(::focus-source state))
(ensure-focus-visible! state row-el))))))
nil)]
(if (and (= group :nodes) (not disable-lazy?))
(ui/lazy-visible (fn [] item) {:trigger-once? true})
item)))]]))
(defn move-highlight [state n]
(let [items (mapcat last (state->results-ordered state (:search/mode @state/state)))
focus-source @(::focus-source state)
highlighted-item (some-> state ::highlighted-item deref (dissoc :mouse-enter-triggered-highlight))
current-item-index (some->> highlighted-item (.indexOf items))
next-item-index (some-> (or current-item-index 0) (+ n) (mod (count items)))]
(if-let [next-highlighted-item (nth items next-item-index nil)]
(reset! (::highlighted-item state) next-highlighted-item)
(reset! (::highlighted-item state) nil))))
fallback-highlighted? (and (nil? highlighted-item)
(= :keyboard focus-source)
(seq items))
current-item-index (cond
highlighted-item (.indexOf items highlighted-item)
fallback-highlighted? 0
:else nil)
items-count (count items)]
(if (pos? items-count)
(let [base-index (if (some? current-item-index)
current-item-index
(if (pos? n) -1 0))
next-item-index (mod (+ base-index n) items-count)
next-highlighted-item (nth items next-item-index nil)]
(if next-highlighted-item
(reset! (::highlighted-item state) next-highlighted-item)
(do
(reset! (::highlighted-item state) nil)
(reset! (::highlighted-row-el state) nil))))
(do
(reset! (::highlighted-item state) nil)
(reset! (::highlighted-row-el state) nil)))))
(defn handle-input-change
([state e] (handle-input-change state e (.. e -target -value)))
@@ -808,14 +876,24 @@
(shui/dialog-close! :ls-dialog-cmdk)
(state/sidebar-add-block! repo input :search))
as-keydown? (if meta?
(show-more)
(do
(reset! (::disable-lazy? state) true)
(show-more))
(do
(reset! (::disable-lazy? state) true)
(reset! (::mouse-active? state) false)
(reset! (::focus-source state) :keyboard)
(reset! (::highlighted-row-el state) nil)
(move-highlight state 1)))
as-keyup? (if meta?
(show-less)
(do
(reset! (::disable-lazy? state) true)
(show-less))
(do
(reset! (::disable-lazy? state) true)
(reset! (::mouse-active? state) false)
(reset! (::focus-source state) :keyboard)
(reset! (::highlighted-row-el state) nil)
(move-highlight state -1)))
(and enter? (not composing?)) (do
(handle-action :default state e)
@@ -888,7 +966,8 @@
;; This was moved to a functional component
(hooks/use-effect! (fn []
(when (and highlighted-item (= -1 (.indexOf all-items (dissoc highlighted-item :mouse-enter-triggered-highlight))))
(reset! (::highlighted-item state) nil)))
(reset! (::highlighted-item state) nil)
(reset! (::highlighted-row-el state) nil)))
[all-items])
(hooks/use-effect!
(fn []
@@ -1072,7 +1151,11 @@
(rum/local false ::meta?)
(rum/local nil ::highlighted-group)
(rum/local nil ::highlighted-item)
(rum/local nil ::highlighted-row-el)
(rum/local false ::disable-lazy?)
(rum/local :keyboard ::focus-source)
(rum/local false ::mouse-active?)
(rum/local true ::wheel-focus-anchor?)
(rum/local default-results ::results)
(rum/local nil ::scroll-container-ref)
(rum/local nil ::input-ref)
@@ -1090,36 +1173,36 @@
:class (cond-> "w-full h-full relative flex flex-col justify-start"
(not sidebar?) (str " rounded-lg"))}
(input-row state all-items opts)
[:div {:class (cond-> "w-full flex-1 overflow-y-auto min-h-[65dvh] max-h-[65dvh]"
(not sidebar?) (str " pb-14"))
:ref #(let [*ref (::scroll-container-ref state)]
(when-not @*ref (reset! *ref %)))
:on-mouse-enter #(reset! (::mouse-active? state) true)
:on-mouse-leave #(reset! (::mouse-active? state) false)
:style {:background "var(--lx-gray-02)"
:scroll-padding-block 32}}
[:div {:class (cond-> "w-full flex-1 overflow-y-auto min-h-[65dvh] max-h-[65dvh]"
(not sidebar?) (str " pb-14"))
:ref #(let [*ref (::scroll-container-ref state)]
(when-not @*ref (reset! *ref %)))
:on-mouse-leave #(reset! (::mouse-active? state) false)
:on-wheel #(handle-results-wheel! state %)
:style {:background "var(--lx-gray-02)"
:scroll-padding-block 32}}
(when group-filter
[:div.flex.flex-col.px-3.py-1.opacity-70.text-sm
(search-only state (string/capitalize (name group-filter)))])
(when group-filter
[:div.flex.flex-col.px-3.py-1.opacity-70.text-sm
(search-only state (string/capitalize (name group-filter)))])
(let [items (filter
(fn [[_group-name group-key group-count _group-items]]
(and (not= 0 group-count)
(if-not group-filter true
(or (= group-filter group-key)
(and (= group-filter :nodes)
(= group-key :current-page))
(and (contains? #{:create} group-filter)
(= group-key :create))))))
results-ordered)]
(if (seq items)
(for [[group-name group-key _group-count group-items] items]
(let [title (string/capitalize group-name)]
(result-group state title group-key group-items first-item sidebar?)))
[:div.flex.flex-col.p-4.opacity-50
(when-not (string/blank? @*input)
"No matched results")]))]
(let [items (filter
(fn [[_group-name group-key group-count _group-items]]
(and (not= 0 group-count)
(if-not group-filter true
(or (= group-filter group-key)
(and (= group-filter :nodes)
(= group-key :current-page))
(and (contains? #{:create} group-filter)
(= group-key :create))))))
results-ordered)]
(if (seq items)
(for [[group-name group-key _group-count group-items] items]
(let [title (string/capitalize group-name)]
(result-group state title group-key group-items first-item sidebar?)))
[:div.flex.flex-col.p-4.opacity-50
(when-not (string/blank? @*input)
"No matched results")]))]
(when-not sidebar? (hints state))]))
(rum/defc cmdk-modal [props]

View File

@@ -0,0 +1,101 @@
(ns frontend.components.cmdk.scroll
"Scroll geometry helpers for Cmd+K focus visibility and wheel anchoring.")
(defn focus-row-visible-rect
"Builds normalized focus geometry from `container` and `target` DOM elements.
Input:
- `container`: scroll container element.
- `target`: focused row element.
Output:
- `nil` if either input is missing.
- A map with keys:
`:scroll-top`, `:viewport-height`, `:scroll-height`, `:focus-top`, `:focus-height`."
[container target]
(when (and container target)
(let [container-rect (.getBoundingClientRect container)
target-rect (.getBoundingClientRect target)
scroll-top (.-scrollTop container)
focus-top (+ scroll-top (- (.-top target-rect) (.-top container-rect)))
focus-height (.-height target-rect)]
{:scroll-top scroll-top
:viewport-height (.-clientHeight container)
:scroll-height (.-scrollHeight container)
:focus-top focus-top
:focus-height focus-height})))
(defn- max-scroll-top
"Returns the maximum valid scroll top for geometry map `data`."
[{:keys [scroll-height viewport-height]}]
(max 0 (- (or scroll-height 0) (or viewport-height 0))))
(defn- clamp-scroll-top
"Clamps numeric `scroll-top` into valid range based on geometry map `data`."
[scroll-top data]
(let [max-top (max-scroll-top data)]
(-> scroll-top
(max 0)
(min max-top))))
(defn ensure-focus-visible-scroll-top
"Returns a corrected `scroll-top` that keeps the focus row visible in viewport.
Input map keys:
| key | description |
|--------------------|-----------------------------------------------|
| `:scroll-top` | Current container scroll top |
| `:viewport-height` | Container viewport height |
| `:focus-top` | Focus row top in container scroll coordinates |
| `:focus-height` | Focus row height |
| `:scroll-height` | Full scroll height used for clamping |
Output:
- A clamped numeric `scroll-top`."
[{:keys [scroll-top viewport-height focus-top focus-height] :as data}]
(let [focus-bottom (+ (or focus-top 0) (or focus-height 0))
viewport-bottom (+ (or scroll-top 0) (or viewport-height 0))
target-top (cond
(nil? focus-top) scroll-top
(< focus-top scroll-top) focus-top
(> focus-bottom viewport-bottom) (- focus-bottom viewport-height)
:else scroll-top)]
(clamp-scroll-top target-top data)))
(defn anchored-scroll-top
"Returns anchored `scroll-top` after applying wheel delta while keeping focus in view.
Input map keys:
| key | description |
|--------------------|-----------------------------------------------|
| `:scroll-top` | Current container scroll top |
| `:delta-y` | Requested wheel delta |
| `:viewport-height` | Container viewport height |
| `:scroll-height` | Full scroll height |
| `:focus-top` | Focus row top in container scroll coordinates |
| `:focus-height` | Focus row height |
Behavior:
- If focus geometry is missing, behaves like normal clamped scrolling.
- If focus geometry is present, constrains result so focus row remains visible.
Output:
- A clamped numeric `scroll-top`."
[{:keys [scroll-top delta-y viewport-height focus-top focus-height] :as data}]
(let [base-scroll-top (or scroll-top 0)
desired-scroll-top (clamp-scroll-top (+ base-scroll-top (or delta-y 0)) data)]
(if (or (nil? focus-top) (nil? focus-height) (<= (or viewport-height 0) 0))
desired-scroll-top
(let [focus-bottom (+ focus-top focus-height)
min-visible-top (inc (- focus-top viewport-height))
max-visible-top (dec focus-bottom)
min-top (max 0 min-visible-top)
max-top (min (max-scroll-top data) max-visible-top)
constrained-top (if (> min-top max-top)
desired-scroll-top
(-> desired-scroll-top
(max min-top)
(min max-top)))]
(clamp-scroll-top constrained-top data)))))

View File

@@ -0,0 +1,101 @@
(ns frontend.components.cmdk.scroll-test
(:require [cljs.test :refer [deftest is testing]]
[frontend.components.cmdk.scroll :as scroll]))
(deftest focus-row-visible-rect-normalization
(testing "returns normalized geometry from container and target DOM data"
(let [container #js {:scrollTop 100
:clientHeight 240
:scrollHeight 1200
:getBoundingClientRect (fn [] #js {:top 40 :height 240})}
target #js {:getBoundingClientRect (fn [] #js {:top 90 :height 30})}]
(is (= {:scroll-top 100
:viewport-height 240
:scroll-height 1200
:focus-top 150
:focus-height 30}
(scroll/focus-row-visible-rect container target)))))
(testing "returns nil when container or target is missing"
(is (nil? (scroll/focus-row-visible-rect nil #js {})))
(is (nil? (scroll/focus-row-visible-rect #js {} nil)))))
(deftest keyboard-navigation-still-moves-focus-and-keeps-visible
(testing "keyboard-driven focus visibility correction keeps focused row in viewport"
(is (= 170
(scroll/ensure-focus-visible-scroll-top
{:scroll-top 0
:viewport-height 200
:scroll-height 2000
:focus-top 350
:focus-height 20})))
(is (= 120
(scroll/ensure-focus-visible-scroll-top
{:scroll-top 120
:viewport-height 200
:scroll-height 2000
:focus-top 180
:focus-height 20})))))
(deftest wheel-scroll-cannot-pass-focused-row-downward
(testing "downward wheel scrolling is clamped when it would move past the focused row"
(is (= 59
(scroll/anchored-scroll-top
{:scroll-top 0
:delta-y 300
:viewport-height 200
:scroll-height 2000
:focus-top 40
:focus-height 20})))))
(deftest wheel-scroll-cannot-pass-focused-row-upward
(testing "upward wheel scrolling is clamped when it would move past the focused row"
(is (= 351
(scroll/anchored-scroll-top
{:scroll-top 400
:delta-y -300
:viewport-height 200
:scroll-height 2000
:focus-top 550
:focus-height 20})))))
(deftest no-focus-falls-back-to-normal-wheel
(testing "without focused row anchoring falls back to regular scrolling bounds"
(is (= 340
(scroll/anchored-scroll-top
{:scroll-top 100
:delta-y 240
:viewport-height 200
:scroll-height 2000})))
(is (= 0
(scroll/anchored-scroll-top
{:scroll-top 10
:delta-y -200
:viewport-height 200
:scroll-height 2000})))))
(deftest anchored-scroll-top-boundary-branches
(testing "viewport-height <= 0 uses regular clamped scroll result"
(is (= 100
(scroll/anchored-scroll-top
{:scroll-top 10
:delta-y 500
:viewport-height 0
:scroll-height 100
:focus-top 10
:focus-height 10}))))
(testing "min-top > max-top falls back to desired clamped scroll result"
(is (= 5
(scroll/anchored-scroll-top
{:scroll-top 0
:delta-y 5
:viewport-height 200
:scroll-height 220
:focus-top 500
:focus-height 20}))))
(testing "without focus geometry positive overscroll is clamped to max"
(is (= 100
(scroll/anchored-scroll-top
{:scroll-top 50
:delta-y 1000
:viewport-height 200
:scroll-height 300})))))