🎉 Add drag-to-change for numeric inputs (#8536)

Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com>
This commit is contained in:
Renzo 2026-03-23 19:01:32 +01:00 committed by GitHub
parent 7adac6df40
commit 852f9ce07f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 201 additions and 19 deletions

View File

@ -35,6 +35,7 @@
- Access Tokens look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
- Enhance readability of applied tokens in plugins API [Taiga #13714](https://tree.taiga.io/project/penpot/issue/13714)
- Add drag-to-change for numeric inputs in workspace sidebar [Github #2466](https://github.com/penpot/penpot/issues/2466)
### :bug: Bugs fixed

View File

@ -27,6 +27,13 @@ body {
width: 100vw;
height: 100vh;
overflow: hidden;
&.cursor-drag-scrub {
cursor: ew-resize !important;
* {
cursor: ew-resize !important;
}
}
}
#app {

View File

@ -338,6 +338,12 @@
background-color: var(--input-background-color);
border: $s-1 solid var(--input-border-color);
color: var(--input-foreground-color);
&:not(:focus-within) {
cursor: ew-resize;
input {
cursor: ew-resize;
}
}
span,
label {
@extend .input-label;

View File

@ -7,6 +7,7 @@
(ns app.main.ui.components.numeric-input
(:require
[app.common.data :as d]
[app.common.math :as mth]
[app.common.schema :as sm]
[app.main.ui.formats :as fmt]
[app.main.ui.hooks :as h]
@ -61,6 +62,11 @@
;; Last value input by the user we need to store to save on unmount
last-value* (mf/use-var value)
;; Drag scrubbing state
drag-state* (mf/use-ref :idle)
drag-start-x* (mf/use-ref 0)
drag-start-val* (mf/use-ref 0)
parse-value
(mf/use-fn
(mf/deps min-value max-value value nillable? default)
@ -213,16 +219,80 @@
(mf/use-callback
(mf/deps on-focus select-on-focus?)
(fn [event]
(reset! last-value* (parse-value))
(let [target (dom/get-target event)]
(when on-focus
(mf/set-ref-val! dirty-ref true)
(on-focus event))
(when-not (= :dragging (mf/ref-val drag-state*))
(reset! last-value* (parse-value))
(let [target (dom/get-target event)]
(when on-focus
(mf/set-ref-val! dirty-ref true)
(on-focus event))
(when select-on-focus?
(dom/select-text! target)
;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect
(.addEventListener target "mouseup" dom/prevent-default #js {:once true})))))
(when select-on-focus?
(dom/select-text! target)
;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect
(.addEventListener target "mouseup" dom/prevent-default #js {:once true}))))))
on-scrub-pointer-down
(mf/use-fn
(mf/deps value value-str min-value max-value default)
(fn [event]
(let [disabled? (unchecked-get props "disabled")
node (mf/ref-val ref)
is-focused (and (some? node) (dom/active? node))]
(when-not (or disabled? is-focused (= :multiple value-str))
(let [client-x (.-clientX event)
start-val (or value default 0)]
(mf/set-ref-val! drag-state* :maybe-dragging)
(mf/set-ref-val! drag-start-x* client-x)
(mf/set-ref-val! drag-start-val* start-val)
(dom/capture-pointer event))))))
on-scrub-pointer-move
(mf/use-fn
(mf/deps apply-value update-input step-value min-value max-value)
(fn [event]
(let [state (mf/ref-val drag-state*)]
(when (or (= state :maybe-dragging) (= state :dragging))
(let [client-x (.-clientX event)
start-x (mf/ref-val drag-start-x*)
delta-x (- client-x start-x)]
(when (and (= state :maybe-dragging)
(>= (js/Math.abs delta-x) 3))
(mf/set-ref-val! drag-state* :dragging)
(dom/add-class! (dom/get-body) "cursor-drag-scrub"))
(when (= (mf/ref-val drag-state*) :dragging)
(let [effective-step (cond
(.-shiftKey event) (* step-value 10)
(.-ctrlKey event) (* step-value 0.1)
:else step-value)
steps (js/Math.round (/ delta-x 1))
new-val (+ (mf/ref-val drag-start-val*)
(* steps effective-step))
new-val (cond-> new-val
(d/num? min-value) (mth/max min-value)
(d/num? max-value) (mth/min max-value))]
(update-input new-val)
(apply-value event new-val))))))))
on-scrub-pointer-up
(mf/use-fn
(mf/deps ref)
(fn [event]
(let [state (mf/ref-val drag-state*)]
(when (= state :maybe-dragging)
(mf/set-ref-val! drag-state* :idle)
(dom/release-pointer event)
(when-let [node (mf/ref-val ref)]
(dom/focus! node)))
(when (= state :dragging)
(mf/set-ref-val! drag-state* :idle)
(dom/remove-class! (dom/get-body) "cursor-drag-scrub")
(dom/release-pointer event)))))
on-scrub-lost-pointer-capture
(mf/use-fn
(fn [_event]
(mf/set-ref-val! drag-state* :idle)
(dom/remove-class! (dom/get-body) "cursor-drag-scrub")))
props (-> (obj/clone props)
(obj/unset! "selectOnFocus")
@ -236,7 +306,11 @@
(obj/set! "title" title)
(obj/set! "onKeyDown" handle-key-down)
(obj/set! "onBlur" handle-blur)
(obj/set! "onFocus" handle-focus))]
(obj/set! "onFocus" handle-focus)
(obj/set! "onPointerDown" on-scrub-pointer-down)
(obj/set! "onPointerMove" on-scrub-pointer-move)
(obj/set! "onPointerUp" on-scrub-pointer-up)
(obj/set! "onLostPointerCapture" on-scrub-lost-pointer-capture))]
(mf/with-effect [value]
(when-let [input-node (mf/ref-val ref)]

View File

@ -136,6 +136,8 @@
[:applied-token {:optional true} [:maybe [:or :string [:= :multiple]]]]
[:empty-to-end {:optional true} :boolean]
[:on-change {:optional true} fn?]
[:on-change-start {:optional true} fn?]
[:on-change-end {:optional true} fn?]
[:on-blur {:optional true} fn?]
[:on-focus {:optional true} fn?]
[:on-detach {:optional true} fn?]
@ -151,7 +153,8 @@
min max max-length step
is-selected-on-focus nillable
tokens applied-token empty-to-end
on-change on-blur on-focus on-detach
on-change on-change-start on-change-end
on-blur on-focus on-detach
property align ref name
tooltip-placement text-icon]
:rest props}]
@ -222,6 +225,11 @@
open-dropdown-ref (mf/use-ref nil)
token-detach-btn-ref (mf/use-ref nil)
;; Drag scrubbing state
drag-state* (mf/use-ref :idle)
drag-start-x* (mf/use-ref 0)
drag-start-val* (mf/use-ref 0)
dropdown-options
(mf/with-memo [tokens filter-id]
(csu/get-token-dropdown-options tokens filter-id))
@ -442,13 +450,14 @@
(mf/use-fn
(mf/deps on-focus select-on-focus)
(fn [event]
(when (fn? on-focus)
(on-focus event))
(let [target (dom/get-target event)]
(when select-on-focus
(dom/select-text! target)
;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect
(.addEventListener target "mouseup" dom/prevent-default #js {:once true})))))
(when-not (= :dragging (mf/ref-val drag-state*))
(when (fn? on-focus)
(on-focus event))
(let [target (dom/get-target event)]
(when select-on-focus
(dom/select-text! target)
;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect
(.addEventListener target "mouseup" dom/prevent-default #js {:once true}))))))
on-mouse-wheel
(mf/use-fn
@ -468,6 +477,77 @@
(dom/stop-propagation event)
(apply-value (dm/str new-val)))))))
on-scrub-pointer-down
(mf/use-fn
(mf/deps disabled is-open is-multiple? ref min max nillable default)
(fn [event]
(when-not (or disabled is-open is-multiple?)
(let [node (mf/ref-val ref)
is-focused (and (some? node) (dom/active? node))
has-token (some? (deref token-applied*))]
(when-not (or is-focused has-token)
(let [client-x (.-clientX event)
parsed (parse-value (mf/ref-val raw-value*) (mf/ref-val last-value*) min max nillable)
start-val (or parsed default 0)]
(mf/set-ref-val! drag-state* :maybe-dragging)
(mf/set-ref-val! drag-start-x* client-x)
(mf/set-ref-val! drag-start-val* start-val)
(dom/capture-pointer event)))))))
on-scrub-pointer-move
(mf/use-fn
(mf/deps apply-value update-input step min max on-change-start)
(fn [event]
(let [state (mf/ref-val drag-state*)]
(when (or (= state :maybe-dragging) (= state :dragging))
(let [client-x (.-clientX event)
start-x (mf/ref-val drag-start-x*)
delta-x (- client-x start-x)]
(when (and (= state :maybe-dragging)
(>= (js/Math.abs delta-x) 3))
(mf/set-ref-val! drag-state* :dragging)
(dom/add-class! (dom/get-body) "cursor-drag-scrub")
(when (fn? on-change-start)
(on-change-start)))
(when (= (mf/ref-val drag-state*) :dragging)
(let [effective-step (cond
(.-shiftKey event) (* step 10)
(.-ctrlKey event) (* step 0.1)
:else step)
steps (js/Math.round (/ delta-x 1))
new-val (mth/clamp (+ (mf/ref-val drag-start-val*)
(* steps effective-step))
min max)]
(update-input (fmt/format-number new-val))
(apply-value (dm/str new-val)))))))))
on-scrub-pointer-up
(mf/use-fn
(mf/deps ref on-change-end)
(fn [event]
(let [state (mf/ref-val drag-state*)]
(when (= state :maybe-dragging)
(mf/set-ref-val! drag-state* :idle)
(dom/release-pointer event)
(when-let [node (mf/ref-val ref)]
(dom/focus! node)))
(when (= state :dragging)
(mf/set-ref-val! drag-state* :idle)
(dom/remove-class! (dom/get-body) "cursor-drag-scrub")
(dom/release-pointer event)
(when (fn? on-change-end)
(on-change-end))))))
on-scrub-lost-pointer-capture
(mf/use-fn
(mf/deps on-change-end)
(fn [_event]
(let [was-dragging (= :dragging (mf/ref-val drag-state*))]
(mf/set-ref-val! drag-state* :idle)
(dom/remove-class! (dom/get-body) "cursor-drag-scrub")
(when (and was-dragging (fn? on-change-end))
(on-change-end)))))
open-dropdown
(mf/use-fn
(mf/deps disabled ref)
@ -658,7 +738,11 @@
(mf/set-ref-val! options-ref dropdown-options))
[:div {:class [class (stl/css :input-wrapper)]
:ref wrapper-ref}
:ref wrapper-ref
:on-pointer-down on-scrub-pointer-down
:on-pointer-move on-scrub-pointer-move
:on-pointer-up on-scrub-pointer-up
:on-lost-pointer-capture on-scrub-lost-pointer-capture}
(if (and (some? token-applied)
(not= :multiple token-applied))

View File

@ -20,6 +20,9 @@
inline-size: 100%;
position: relative;
&:not(:focus-within) {
cursor: ew-resize;
}
&:hover {
--opacity-button: 1;
}

View File

@ -184,6 +184,9 @@
@include t.use-typography("body-small");
display: flex;
align-items: center;
&:not(:focus-within) {
cursor: ew-resize;
}
block-size: $sz-32;
inline-size: px2rem(60);
padding-inline-start: var(--sp-xs);
@ -240,6 +243,10 @@
margin: var(--sp-xxs) 0;
padding: 0 0 0 px2rem(6);
color: var(--color-foreground-primary);
cursor: ew-resize;
&:focus {
cursor: text;
}
&[disabled] {
opacity: 0.5;
pointer-events: none;