Add HEX, HSB, and HSL support in the third color tab (#9134)

*  Add HEX, HSB, and HSL support in the third color tab

Relabel the existing HSVA tab to HSBA (the math was already HSB) and add
an inline HSB ↔ HSL model toggle inside the tab, matching Figma's color
panel. Sliders, gradients, and labels update dynamically per mode; HSL
values roundtrip through RGB/HSV so no color-storage changes are needed.
Model choice persists across sessions.

* 💄 Fix lint errors

Signed-off-by: juan-flores077 <toptalent399@gmail.com>

* 🐛 Fix Plugin API token application for JS array of strings (#9166)

* 🐛 Fix Plugin API token application for JS array of strings

Plugin code calling `shape.applyToken(token, ["fill"])` or
`token.applyToShapes([rect], ["fill"])` from JavaScript supplies a JS
array of strings. The plugin proxies expected a Clojure set of
keywords, and two coupled defects made the calls silently no-op (or,
with `throwValidationErrors` enabled, throw "check error"):

1. `token-attr-plugin->token-attr` only consulted its alias map when
   the input was already a keyword. String inputs like "fill" fell
   through to the identity branch, so the downstream
   `cto/token-attr?` predicate (which checks against a set of
   keywords) returned false for every string. Coerce strings to
   keywords first.

2. The `applyToken` / `applyToShapes` / `applyToSelected` schemas
   used plain `[:set ...]`, which has no `:decode/json` transformer
   for JS array → Clojure set coercion. Switch to the registered
   `[::sm/set ...]` (in `app.common.schema`) which provides the
   array → set decoder. After the switch, the standard JSON pipeline
   converts `["fill"]` to `#{"fill"}`, then the inner
   `[:and ::sm/keyword [:fn token-attr?]]` decodes each element to a
   keyword and validates it.

Also extends the docstring on `token-attr-plugin->token-attr` to make
the string-friendly contract explicit, and registers a new
`tokens-test` ns under `frontend/test/frontend_tests/plugins/` with
six `deftest` blocks covering:

- known keywords passing through unchanged
- keyword aliases (`:r1` → `:border-radius-top-left`, etc.)
- string inputs coerced to keywords (regression for #9162)
- `token-attr?` accepting both keyword and string inputs
- `token-attr?` rejecting unknown attrs and nil

Closes #9162

* 🐛 Fix wrong direction in plugin-name alias tests

The added tests in tokens_test.cljs and the new docstring in tokens.cljs
described the alias resolution in the wrong direction. The map is
{:r1 :border-radius-top-left, …} then map-invert'd, so
token-attr-plugin->token-attr maps verbose plugin-side names
(:border-radius-top-left) to canonical internal short names (:r1),
not the other way around. Inputs already in canonical form (:r1, :fill,
"fill", …) pass through unchanged. Flipped the alias-resolution test
expectations and the keyword/string-input cases, refreshed the docstring
and the regression-coverage comment to match.

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>

* 💄 Fix sucess typo in subscription dialog i18n keys (#9204)

Rename subscription.settings.sucess.dialog.{title,footer} to
subscription.settings.success.dialog.{title,footer} in en.po and
update the three callsites in subscription.cljs.

Closes #9203

Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>

* 🐛 Fix HSVA → HSBA test rename and Prettier formatting

Signed-off-by: juan-flores077 <toptalent399@gmail.com>

* 🐛 Fix CI failures and address review feedback for HSB color tab

Signed-off-by: juan-flores077 <toptalent399@gmail.com>

* 💄 Resolve Conflicts

Signed-off-by: juan-flores077 <toptalent399@gmail.com>

---------

Signed-off-by: juan-flores077 <toptalent399@gmail.com>
Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: boskodev790 <boskomaljkovic790@outlook.com>
Co-authored-by: Jack Storment <88656337+jack-stormentswe@users.noreply.github.com>
This commit is contained in:
Juan Flores 2026-04-30 06:41:04 -07:00 committed by GitHub
parent 9f94566005
commit 4902037c7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 343 additions and 43 deletions

View File

@ -50,6 +50,7 @@
- Preserve vector content when pasting from external tools such as Inkscape: recognise SVG sent as text/plain (with optional XML declaration and HTML comments), skip the raster preview when an SVG sibling is on the clipboard, and ignore empty SVG blobs that some tools advertise alongside the real payload, so pasted graphics arrive editable without spurious "SVG is invalid" warnings [Github #546](https://github.com/penpot/penpot/issues/546)
- Add Shift+Numpad0/1/2 as aliases to Shift+0/1/2 for zoom shortcuts [Github #2457](https://github.com/penpot/penpot/issues/2457)
- Adds a **Pixel grid color** picker in the viewport settings, next to the existing canvas color control [Github #7750](https://github.com/penpot/penpot/issues/7750)
- Add HEX, HSB and HSL support to the third color tab via an inline model switcher: relabel the existing HSVA tab as HSBA (the math was already HSB-equivalent), add an HSB ↔ HSL pill toggle that updates input labels, slider gradients and round-trip values without changing how colors are stored, and persist the chosen model across sessions [Github #9133](https://github.com/penpot/penpot/issues/9133)
- Show specific invitation-link error messages instead of a single generic "Invite invalid" page: distinguish expired invitations, email-mismatch (signed in with the wrong account) and corrupted/invalid tokens, each with an actionable recovery hint [Github #9220](https://github.com/penpot/penpot/issues/9220)
- Show detailed messages on file import errors to help diagnose why a file could not be imported (by @jsdevninja) [Github #9004](https://github.com/penpot/penpot/issues/9004)
- Add read-only preview mode for saved versions — click a version name to open a dedicated preview view (by @wdeveloper16) [Github #8976](https://github.com/penpot/penpot/issues/8976)

View File

@ -610,6 +610,36 @@
[hsv]
(-> hsv hsv->hex hex->hsl))
;; HSB (Hue, Saturation, Brightness) — same color model as HSV but with
;; the brightness component normalized to a 0-100 range, matching Figma,
;; Sketch, and Adobe XD conventions. Internally we reuse the HSV math and
;; only rescale the brightness axis.
(defn rgb->hsb
[rgb]
(let [[h s v] (rgb->hsv rgb)]
[h s (* (/ v 255.0) 100.0)]))
(defn hsb->rgb
[[h s b]]
(hsv->rgb [h s (int (* (/ b 100.0) 255.0))]))
(defn hex->hsb
[v]
(-> v hex->rgb rgb->hsb))
(defn hsb->hex
[hsb]
(-> hsb hsb->rgb rgb->hex))
(defn hsv->hsb
[[h s v]]
[h s (* (/ v 255.0) 100.0)])
(defn hsb->hsv
[[h s b]]
[h s (int (* (/ b 100.0) 255.0))])
(defn expand-hex
[v]
(cond

View File

@ -164,3 +164,78 @@
{:color "#ffffff" :opacity 1.0 :offset 0.5}]
result (colors/interpolate-gradient stops 1.0)]
(t/is (= "#ffffff" (:color result))))))
(t/deftest rgb-to-hsb
;; Achromatic black: brightness 0
(let [[h s b] (colors/rgb->hsb [0 0 0])]
(t/is (= 0 h))
(t/is (= 0 s))
(t/is (mth/close? b 0.0)))
;; Pure red: hue 0, full saturation, brightness 100
(let [[h s b] (colors/rgb->hsb [255 0 0])]
(t/is (mth/close? h 0.0))
(t/is (mth/close? s 1.0))
(t/is (mth/close? b 100.0)))
;; Pure white: brightness 100
(let [[_ _ b] (colors/rgb->hsb [255 255 255])]
(t/is (mth/close? b 100.0)))
;; Mid gray: brightness ~50.2
(let [[_ _ b] (colors/rgb->hsb [128 128 128])]
(t/is (mth/close? b (* (/ 128.0 255.0) 100.0)))))
(t/deftest hsb-to-rgb
(t/is (= [0 0 0] (colors/hsb->rgb [0 0 0])))
(t/is (= [255 255 255] (colors/hsb->rgb [0 0 100])))
;; Pure red from HSB
(let [[r g b] (colors/hsb->rgb [0 1 100])]
(t/is (= 255 r))
(t/is (= 0 g))
(t/is (= 0 b))))
(t/deftest hex-to-hsb
;; Black
(let [[h s b] (colors/hex->hsb "#000000")]
(t/is (= 0 h))
(t/is (= 0 s))
(t/is (mth/close? b 0.0)))
;; White: brightness 100
(let [[_ _ b] (colors/hex->hsb "#ffffff")]
(t/is (mth/close? b 100.0)))
;; Red
(let [[h s b] (colors/hex->hsb "#ff0000")]
(t/is (mth/close? h 0.0))
(t/is (mth/close? s 1.0))
(t/is (mth/close? b 100.0))))
(t/deftest hsb-to-hex
(t/is (= "#000000" (colors/hsb->hex [0 0 0])))
(t/is (= "#ffffff" (colors/hsb->hex [0 0 100]))))
(t/deftest hsv-hsb-roundtrip
;; HSV brightness is 0-255, HSB brightness is 0-100. Round-trip
;; should reach the same triple within ±1 (integer rounding).
(let [orig [210.0 0.5 128]
hsb (colors/hsv->hsb orig)
result (colors/hsb->hsv hsb)]
(t/is (mth/close? (nth orig 0) (nth result 0)))
(t/is (mth/close? (nth orig 1) (nth result 1)))
(t/is (< (mth/abs (- (nth orig 2) (nth result 2))) 2))))
(t/deftest rgb-hsb-roundtrip
;; RGB → HSB → RGB should land within ±1 per channel
(let [orig [100 150 200]
hsb (colors/rgb->hsb orig)
result (colors/hsb->rgb hsb)]
(t/is (every? true? (map #(< (mth/abs (- %1 %2)) 2) orig result)))))
(t/deftest hex-hsb-roundtrip
;; HEX → HSB → HEX should preserve the color across the model swap
(let [orig "#fabada"
hsb (colors/hex->hsb orig)
result (colors/hsb->hex hsb)]
;; Allow ±1 per channel after the round-trip due to integer rounding
(let [[r1 g1 b1] (colors/hex->rgb orig)
[r2 g2 b2] (colors/hex->rgb result)]
(t/is (< (mth/abs (- r1 r2)) 2))
(t/is (< (mth/abs (- g1 g2)) 2))
(t/is (< (mth/abs (- b1 b2)) 2)))))

View File

@ -196,7 +196,7 @@ test("Gradient stops limit", async ({ page }) => {
});
// Fix for https://tree.taiga.io/project/penpot/issue/9900
test("Bug 9900 - Color picker has no inputs for HSV values", async ({
test("Bug 9900 - Color picker has no inputs for HSB values", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
@ -207,12 +207,12 @@ test("Bug 9900 - Color picker has no inputs for HSV values", async ({
const swatch = workspacePage.page.getByRole("button", { name: "E8E9EA" });
await swatch.click();
const HSVA = await workspacePage.page.getByLabel("HSVA");
await HSVA.click();
const HSBA = await workspacePage.page.getByLabel("HSBA");
await HSBA.click();
await workspacePage.page.getByLabel("H", { exact: true }).isVisible();
await workspacePage.page.getByLabel("S", { exact: true }).isVisible();
await workspacePage.page.getByLabel("V", { exact: true }).isVisible();
await workspacePage.page.getByLabel("B(V)", { exact: true }).isVisible();
});
test("Bug 10089 - Cannot change alpha", async ({ page }) => {

View File

@ -82,6 +82,15 @@
hsl-from (cc/hsv->hsl [h 0.0 v])
hsl-to (cc/hsv->hsl [h 1.0 v])
;; HSL-mode gradients. For S: fix current lightness, sweep
;; saturation 0 → 1. For L: fix current saturation, sweep
;; lightness 0 → 0.5 (pure hue) → 1. All computed at the
;; current hue.
[_ cur-hsl-s cur-hsl-l] (cc/rgb->hsl rgb)
hsl-sat-from [h 0.0 cur-hsl-l]
hsl-sat-to [h 1.0 cur-hsl-l]
lightness-mid [h cur-hsl-s 0.5]
format-hsl (fn [[h s l]]
(str/fmt "hsl(%s, %s, %s)"
h
@ -90,7 +99,10 @@
(dom/set-css-property! node "--color" (str/join ", " rgb))
(dom/set-css-property! node "--hue-rgb" (str/join ", " hue-rgb))
(dom/set-css-property! node "--saturation-grad-from" (format-hsl hsl-from))
(dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to)))))
(dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to))
(dom/set-css-property! node "--hsl-saturation-grad-from" (format-hsl hsl-sat-from))
(dom/set-css-property! node "--hsl-saturation-grad-to" (format-hsl hsl-sat-to))
(dom/set-css-property! node "--lightness-grad-mid" (format-hsl lightness-mid)))))
(mf/defc colorpicker*
[{:keys [data disable-gradient disable-opacity disable-image on-change on-accept origin combined-tokens color-origin on-token-change tab applied-token]}]
@ -128,10 +140,15 @@
active-color-tab* (hooks/use-persisted-state ::color-tab "ramp")
active-color-tab (deref active-color-tab*)
;; Inline HSB/HSL toggle inside the HSBA tab — shared between
;; the slider selector (for labels) and the numeric inputs.
hsb-mode* (hooks/use-persisted-state ::hsb-mode :hsb)
hsb-mode (deref hsb-mode*)
drag?* (mf/use-state false)
drag? (deref drag?*)
type (if (= active-color-tab "hsva") :hsv :rgb)
type (if (= active-color-tab "hsva") :hsb :rgb)
fill-image-ref (mf/use-ref nil)
@ -351,7 +368,7 @@
{:aria-label "Harmony"
:icon i/rgba-complementary
:id "harmony"}
{:aria-label "HSVA"
{:aria-label "HSBA"
:icon i/hsva
:id "hsva"}])
@ -505,6 +522,7 @@
[:> hsva-selector*
{:color current-color
:disable-opacity disable-opacity
:mode hsb-mode
:on-change handle-change-color
:on-start-drag on-start-drag
:on-finish-drag on-finish-drag}]))]]
@ -512,6 +530,8 @@
[:> color-inputs*
{:type type
:disable-opacity disable-opacity
:mode hsb-mode
:on-mode-change #(reset! hsb-mode* %)
:color current-color
:on-change handle-change-color}]

View File

@ -28,11 +28,23 @@
[val]
(* (/ val 255) 100))
(mf/defc color-inputs* [{:keys [type color disable-opacity on-change]}]
(mf/defc color-inputs* [{:keys [type color disable-opacity mode on-mode-change on-change]}]
(let [{red :r green :g blue :b
hue :h saturation :s value :v
hex :hex alpha :alpha} color
;; Sub-model selector for the HSB tab: users can toggle between
;; HSB and HSL input display without leaving the tab. State is
;; lifted to the colorpicker parent so the slider labels stay
;; in sync with the inputs.
hsb-mode (or mode :hsb)
;; Compute HSL from current RGB (derived; not stored on the color map)
[_hsl-h hsl-s hsl-l]
(if (and red green blue)
(cc/rgb->hsl [red green blue])
[0 0 0])
refs {:hex (mf/use-ref nil)
:r (mf/use-ref nil)
:g (mf/use-ref nil)
@ -40,6 +52,8 @@
:h (mf/use-ref nil)
:s (mf/use-ref nil)
:v (mf/use-ref nil)
:hsl-s (mf/use-ref nil)
:hsl-l (mf/use-ref nil)
:alpha (mf/use-ref nil)}
setup-hex-color
@ -73,6 +87,7 @@
(let [val (case property
:s (/ val 100)
:v (value->hsv-value val)
(:hsl-s :hsl-l) (/ val 100)
:alpha (/ val 100)
val)]
(cond
@ -87,6 +102,18 @@
:h h :s s :v v
:r r :g g :b b}))
;; HSL changes: recompute RGB/HSV from the new HSL triple,
;; reusing the current hue when only S or L changes.
(#{:hsl-s :hsl-l} property)
(let [new-s (if (= property :hsl-s) val hsl-s)
new-l (if (= property :hsl-l) val hsl-l)
[r g b] (cc/hsl->rgb [hue new-s new-l])
hex (cc/rgb->hex [r g b])
[h s v] (cc/hex->hsv hex)]
(on-change {:hex hex
:h h :s s :v v
:r r :g g :b b}))
:else
(let [{:keys [h s v]} (merge color (hash-map property val))
hex (cc/hsv->hex [h s v])
@ -126,10 +153,13 @@
;; Updates the inputs values when a property is changed in the parent
(mf/use-effect
(mf/deps color type)
(mf/deps color type hsb-mode)
(fn []
(doseq [ref-key (keys refs)]
(let [property-val (get color ref-key)
(let [property-val (case ref-key
:hsl-s hsl-s
:hsl-l hsl-l
(get color ref-key))
property-ref (get refs ref-key)]
(when (and property-val property-ref)
(when-let [node (mf/ref-val property-ref)]
@ -137,14 +167,32 @@
(case ref-key
(:s :alpha) (mth/precision (* property-val 100) 2)
:v (mth/precision (hsv-value->value property-val) 2)
(:hsl-s :hsl-l) (mth/precision (* property-val 100) 2)
property-val)]
(dom/set-value! node new-val))))))))
[:div {:class (stl/css-case :color-values true
:disable-opacity disable-opacity)}
;; Inline HSB/HSL switcher — only shown on the HSB tab so that
;; designers can pick whichever hue-based model matches their
;; workflow (HSB matches Figma/Sketch/XD, HSL matches CSS).
(when (and (not= type :rgb) on-mode-change)
[:div {:class (stl/css :model-switcher)}
[:button {:type "button"
:class (stl/css-case :model-pill true
:model-pill-active (= hsb-mode :hsb))
:on-click #(on-mode-change :hsb)}
"HSB"]
[:button {:type "button"
:class (stl/css-case :model-pill true
:model-pill-active (= hsb-mode :hsl))
:on-click #(on-mode-change :hsl)}
"HSL"]])
[:div {:class (stl/css :colors-row)}
(if (= type :rgb)
(cond
(= type :rgb)
[:*
[:div {:class (stl/css :input-wrapper)}
[:label {:for "red-value" :class (stl/css :input-label)} "R"]
@ -177,6 +225,42 @@
:on-change (on-change-property :b 255)
:on-key-down (on-key-down-property :b 255)}]]]
(= hsb-mode :hsl)
[:*
[:div {:class (stl/css :input-wrapper)}
[:label {:for "hue-value" :class (stl/css :input-label)} "H"]
[:input {:id "hue-value"
:ref (:h refs)
:type "number"
:min 0
:max 360
:default-value hue
:on-change (on-change-property :h 360)
:on-key-down (on-key-down-property :h 360)}]]
[:div {:class (stl/css :input-wrapper)}
[:label {:for "hsl-saturation-value" :class (stl/css :input-label)} "S"]
[:input {:id "hsl-saturation-value"
:ref (:hsl-s refs)
:type "number"
:min 0
:max 100
:step 1
:default-value (mth/precision (* hsl-s 100) 2)
:on-change (on-change-property :hsl-s 100)
:on-key-down (on-key-down-property :hsl-s 100)}]]
[:div {:class (stl/css :input-wrapper)}
[:label {:for "lightness-value" :class (stl/css :input-label)} "L"]
[:input {:id "lightness-value"
:ref (:hsl-l refs)
:type "number"
:min 0
:max 100
:step 1
:default-value (mth/precision (* hsl-l 100) 2)
:on-change (on-change-property :hsl-l 100)
:on-key-down (on-key-down-property :hsl-l 100)}]]]
:else
[:*
[:div {:class (stl/css :input-wrapper)}
[:label {:for "hue-value" :class (stl/css :input-label)} "H"]
@ -200,8 +284,8 @@
:on-change (on-change-property :s 100)
:on-key-down (on-key-down-property :s 100)}]]
[:div {:class (stl/css :input-wrapper)}
[:label {:for "value-value" :class (stl/css :input-label)} "V"]
[:input {:id "value-value"
[:label {:for "brightness-value" :class (stl/css :input-label)} "B(V)"]
[:input {:id "brightness-value"
:ref (:v refs)
:type "number"
:min 0

View File

@ -11,6 +11,36 @@
margin-top: deprecated.$s-8;
.model-switcher {
display: flex;
gap: deprecated.$s-4;
margin-bottom: deprecated.$s-8;
padding: deprecated.$s-2;
background-color: var(--color-background-tertiary);
border-radius: deprecated.$s-6;
align-self: flex-start;
.model-pill {
@include deprecated.body-small-typography;
padding: deprecated.$s-2 deprecated.$s-8;
border: none;
border-radius: deprecated.$s-4;
background: transparent;
color: var(--color-foreground-secondary);
cursor: pointer;
&:hover {
color: var(--color-foreground-primary);
}
&.model-pill-active {
background-color: var(--color-background-primary);
color: var(--color-accent-primary);
}
}
}
&.disable-opacity {
grid-template-columns: 3.5rem repeat(3, 1fr);
}

View File

@ -11,17 +11,45 @@
[app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector*]]
[rumext.v2 :as mf]))
(mf/defc hsva-selector* [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}]
(let [{hue :h saturation :s value :v alpha :alpha} color
handle-change-slider (fn [key]
(fn [new-value]
(let [change (hash-map key new-value)
{:keys [h s v]} (merge color change)
hex (cc/hsv->hex [h s v])
[r g b] (cc/hex->rgb hex)]
(on-change (merge change
{:hex hex
:r r :g g :b b})))))
(mf/defc hsva-selector* [{:keys [color disable-opacity mode on-change on-start-drag on-finish-drag]}]
(let [{hue :h saturation :s value :v alpha :alpha
r-val :r g-val :g b-val :b} color
hsl-mode? (= mode :hsl)
;; Current HSL derived from RGB — used as the starting point
;; for HSL saturation/lightness slider values and for
;; recomputing the color when either is dragged.
[_ hsl-s hsl-l] (if (and r-val g-val b-val)
(cc/rgb->hsl [r-val g-val b-val])
[0 0 0])
;; HSB math — current default behavior.
handle-change-hsv
(fn [key]
(fn [new-value]
(let [change (hash-map key new-value)
{:keys [h s v]} (merge color change)
hex (cc/hsv->hex [h s v])
[r g b] (cc/hex->rgb hex)]
(on-change (merge change
{:hex hex
:r r :g g :b b})))))
;; HSL math — when the user drags the S or L slider in HSL mode,
;; we recompute RGB from the updated HSL triple and derive HSV
;; for the canonical color representation.
handle-change-hsl
(fn [key]
(fn [new-value]
(let [new-s (if (= key :hsl-s) new-value hsl-s)
new-l (if (= key :hsl-l) new-value hsl-l)
[r g b] (cc/hsl->rgb [hue new-s new-l])
hex (cc/rgb->hex [r g b])
[h s v] (cc/hex->hsv hex)]
(on-change {:hex hex
:h h :s s :v v
:r r :g g :b b}))))
on-change-opacity (fn [new-alpha] (on-change {:alpha new-alpha}))]
[:div {:class (stl/css :hsva-selector)}
[:div {:class (stl/css :hsva-row)}
@ -31,29 +59,47 @@
:type :hue
:max-value 360
:value hue
:on-change (handle-change-slider :h)
:on-change (handle-change-hsv :h)
:on-start-drag on-start-drag
:on-finish-drag on-finish-drag}]]
[:div {:class (stl/css :hsva-row)}
[:span {:class (stl/css :hsva-selector-label)} "S"]
[:> slider-selector*
{:class (stl/css :hsva-bar)
:type :saturation
:max-value 1
:value saturation
:on-change (handle-change-slider :s)
:on-start-drag on-start-drag
:on-finish-drag on-finish-drag}]]
(if hsl-mode?
[:> slider-selector*
{:class (stl/css :hsva-bar)
:type :hsl-saturation
:max-value 1
:value hsl-s
:on-change (handle-change-hsl :hsl-s)
:on-start-drag on-start-drag
:on-finish-drag on-finish-drag}]
[:> slider-selector*
{:class (stl/css :hsva-bar)
:type :saturation
:max-value 1
:value saturation
:on-change (handle-change-hsv :s)
:on-start-drag on-start-drag
:on-finish-drag on-finish-drag}])]
[:div {:class (stl/css :hsva-row)}
[:span {:class (stl/css :hsva-selector-label)} "V"]
[:> slider-selector*
{:class (stl/css :hsva-bar)
:type :value
:max-value 255
:value value
:on-change (handle-change-slider :v)
:on-start-drag on-start-drag
:on-finish-drag on-finish-drag}]]
[:span {:class (stl/css :hsva-selector-label)} (if hsl-mode? "L" "B(V)")]
(if hsl-mode?
[:> slider-selector*
{:class (stl/css :hsva-bar)
:type :lightness
:max-value 1
:value hsl-l
:on-change (handle-change-hsl :hsl-l)
:on-start-drag on-start-drag
:on-finish-drag on-finish-drag}]
[:> slider-selector*
{:class (stl/css :hsva-bar)
:type :value
:max-value 255
:value value
:on-change (handle-change-hsv :v)
:on-start-drag on-start-drag
:on-finish-drag on-finish-drag}])]
(when (not disable-opacity)
[:div {:class (stl/css :hsva-row)}
[:span {:class (stl/css :hsva-selector-label)} "A"]

View File

@ -53,7 +53,9 @@
:slider-selector true
:hue (= type :hue)
:opacity (= type :opacity)
:value (= type :value)))
:value (= type :value)
:hsl-saturation (= type :hsl-saturation)
:lightness (= type :lightness)))
:data-testid (when (= type :opacity) "slider-opacity")
:on-pointer-down handle-start-drag
:on-pointer-up handle-stop-drag

View File

@ -72,6 +72,18 @@
background: linear-gradient(var(--gradient-direction), #000 0%, #fff 100%);
}
&.hsl-saturation {
background: linear-gradient(
var(--gradient-direction),
var(--hsl-saturation-grad-from) 0%,
var(--hsl-saturation-grad-to) 100%
);
}
&.lightness {
background: linear-gradient(var(--gradient-direction), #000 0%, var(--lightness-grad-mid) 50%, #fff 100%);
}
.handler {
position: absolute;
left: 50%;