feat: color-picker popover keyboard nav, muting, and tooltips

The color swatches popover now behaves like a proper WAI-APG radio
group: the popover lands focus on the currently-selected swatch on
open (deferred a tick past Radix's onOpenAutoFocus so we override it),
ArrowLeft/Right/Up/Down + Home/End rove with wrap-around, and the
trigger keeps roving tabindex so Tab in/out walks one stop instead of
nine.

While the user is engaging with the palette (mouse-hovering anywhere
in the group OR any swatch focused), non-active siblings drop to 0.55
opacity. Three stay bright: hovered, focused, and currently-selected.
At rest the whole row reads at full saturation — engagement-only
muting matches macOS/Figma/Linear and avoids hurting first-impression
discoverability.

Focus halo gets a styling pass: the system blue outline is replaced by
a colored ring with the same anatomy as the selected ring (inset edge
+ bg gap + outer halo) but thinner (2.5px vs 3.5px) and in the global
accent so focus is never the swatch's own hue — keeps focused vs.
selected legibly distinct on, e.g., the orange swatch where the ring
would otherwise blend.

Each swatch carries a tooltip (hover + keyboard focus, 300ms delay,
matching the asset-picker's web-image-item) with its color name. The
"no color" swatch gets a two-tier tooltip — "Default" + "Inherits the
surrounding text color" — to communicate why someone might want it
(theme-aware behavior in custom themes).

Note on the tooltip wiring: rum/with-key on a shui lsui-wrap React
element strips children to nil, so keys are passed in the props map
where React.createElement extracts them natively.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
scheinriese
2026-04-25 15:55:08 +02:00
parent 77dbc2cc66
commit f731037692
2 changed files with 160 additions and 41 deletions

View File

@@ -3832,6 +3832,112 @@
[:span.absolute.hidden {:ref *el-ref}]))
(rum/defc color-swatches-popover
"Popover content for the color-picker. Auto-focuses the currently-
selected swatch on open and supports ArrowLeft/Right/Up/Down + Home/End
nav across the row, following the WAI-APG radio-group pattern (role=
radiogroup, role=radio, roving tab-index)."
[{:keys [colors color set-color! set-hover! on-select! on-hover! on-hover-end!]}]
(let [*parent (rum/use-ref nil)]
;; On mount, land focus on the currently-selected swatch (or the first
;; swatch if no selection matches the preset palette). Deferred a tick
;; so it runs after Radix's own onOpenAutoFocus (which targets the first
;; focusable) — otherwise Radix would clobber our placement.
(hooks/use-effect!
(fn []
(js/setTimeout
(fn []
(when-let [^js parent (rum/deref *parent)]
(when-let [^js btn (or (.querySelector parent ".color-swatch.is-selected")
(.querySelector parent ".color-swatch"))]
(.focus btn))))
0))
[])
[:div.color-picker-presets
{:role "radiogroup"
:aria-label "Icon color"
:ref *parent
:on-mouse-leave (fn []
(set-hover! nil)
(some-> on-hover-end! (apply [])))
:on-key-down
(fn [^js e]
(when-let [^js parent (rum/deref *parent)]
(let [code (.-keyCode e)
stops (vec (array-seq (.querySelectorAll parent ".color-swatch")))
n (count stops)
active js/document.activeElement
idx (.indexOf stops active)
go! (fn [^js el] (some-> el .focus))]
(cond
;; Right / Down: next (wrap)
(or (= code 39) (= code 40))
(do (util/stop e)
(go! (nth stops (mod (if (>= idx 0) (inc idx) 0) n))))
;; Left / Up: previous (wrap)
(or (= code 37) (= code 38))
(do (util/stop e)
(go! (nth stops (mod (if (>= idx 0) (dec idx) (dec n)) n))))
;; Home: first
(= code 36)
(do (util/stop e) (go! (first stops)))
;; End: last
(= code 35)
(do (util/stop e) (go! (last stops)))))))}
(for [{value :value label :label hint :hint} colors
:let [active? (= value color)
swatch-key (or value "none")]]
;; Per-swatch tooltip-provider mirrors the asset-picker's
;; web-image-item pattern (no hoisted top-level provider in this
;; codebase). Radix shows on both hover and keyboard focus, so
;; arrow-rove + Tab both surface the tooltip.
;;
;; Key passed in props (not via rum/with-key): rum's with-key
;; doesn't survive shui's lsui-wrap React element shape — it
;; sets the key but strips children to nil. React.createElement
;; accepts `key` from the props map directly.
(shui/tooltip-provider
{:key swatch-key :delay-duration 300}
(shui/tooltip
(shui/tooltip-trigger
{:as-child true}
[:button.color-swatch
{:role "radio"
:aria-checked (str active?)
;; Roving tabindex: only the active swatch is in the tab order,
;; so Tab into the popover lands here, and Tab out exits to the
;; next document tab stop (rather than walking nine swatches).
:tab-index (if active? "0" "-1")
:class (when active? "is-selected")
:style (when value {"--swatch-color" value})
:on-mouse-enter (fn []
(set-hover! {:color value})
(some-> on-hover! (apply [value])))
:on-focus (fn []
(set-hover! {:color value})
(some-> on-hover! (apply [value])))
:on-click (fn []
(set-color! value)
(set-hover! nil)
(some-> on-hover-end! (apply []))
(some-> on-select! (apply [value]))
(shui/popup-hide!))}
(if value
[:span.swatch-fill {:style {:background-color value}}]
[:span.swatch-empty
(shui/tabler-icon "slash" {:size 14})])])
(shui/tooltip-content
{:side "top" :align "center" :show-arrow true}
[:div.text-center
[:div.font-medium label]
(when hint
[:div.text-xs.mt-0.5
{:style {:color "var(--lx-gray-11)"}}
hint])]))))]))
(rum/defc color-picker
[*color on-select! & {:keys [on-hover! on-hover-end! button-attrs]}]
(let [;; Defensive: never let the CSS sentinel "inherit" leak into React state.
@@ -3842,39 +3948,23 @@
effective-color (if hover (:color hover) color)
*el (rum/use-ref nil)
content-fn (fn []
(let [colors [nil
(colors/variable :gray :10)
(colors/variable :indigo :10)
(colors/variable :cyan :10)
(colors/variable :green :10)
(colors/variable :orange :10)
(colors/variable :tomato :10)
(colors/variable :pink :10)
(colors/variable :red :10)]]
[:div.color-picker-presets
{:on-mouse-leave (fn []
(set-hover! nil)
(some-> on-hover-end! (apply [])))}
(for [c colors]
[:button.color-swatch
{:key (or c "none")
:class (when (= c color) "is-selected")
:style (when c {"--swatch-color" c})
:on-mouse-enter (fn []
(set-hover! {:color c})
(some-> on-hover! (apply [c])))
:on-focus (fn []
(set-hover! {:color c})
(some-> on-hover! (apply [c])))
:on-click (fn [] (set-color! c)
(set-hover! nil)
(some-> on-hover-end! (apply []))
(some-> on-select! (apply [c]))
(shui/popup-hide!))}
(if c
[:span.swatch-fill {:style {:background-color c}}]
[:span.swatch-empty
(shui/tabler-icon "slash" {:size 14})])])]))]
(color-swatches-popover
{:colors [{:value nil :label "Default"
:hint "Inherits the surrounding text color"}
{:value (colors/variable :gray :10) :label "Gray"}
{:value (colors/variable :indigo :10) :label "Indigo"}
{:value (colors/variable :cyan :10) :label "Cyan"}
{:value (colors/variable :green :10) :label "Green"}
{:value (colors/variable :orange :10) :label "Orange"}
{:value (colors/variable :tomato :10) :label "Tomato"}
{:value (colors/variable :pink :10) :label "Pink"}
{:value (colors/variable :red :10) :label "Red"}]
:color color
:set-color! set-color!
:set-hover! set-hover!
:on-select! on-select!
:on-hover! on-hover!
:on-hover-end! on-hover-end!}))]
;; Display effect — fires for hover and committed color. Updates CSS var only.
(hooks/use-effect!
(fn []

View File

@@ -321,20 +321,17 @@
.color-swatch {
@apply w-7 h-7 rounded-full p-0 flex items-center justify-center
cursor-pointer border-0 bg-transparent;
transition: transform 120ms;
transition: transform 120ms, opacity 120ms;
&:hover { transform: scale(1.1); }
&:active { transform: scale(0.95); }
/* Suppress hover scale when selected so ring doesn't overflow into neighbors */
&.is-selected:hover { transform: none; }
/* Keyboard focus parity with hover: keyboard users get the same preview cue */
&:focus { outline: none; }
&:focus-visible {
outline: 2px solid var(--lx-accent-09);
outline-offset: 2px;
border-radius: 9999px;
}
/* Drop the system focus outline — focus state is rendered as a colored
halo on the inner .swatch-fill / .swatch-empty (rules below) so it
matches the selected ring's vocabulary instead of looking grafted on. */
&:focus, &:focus-visible { outline: none; }
.swatch-fill {
@apply w-6 h-6 rounded-full block;
@@ -349,6 +346,19 @@
0 0 0 3.5px color-mix(in srgb, var(--swatch-color, var(--lx-accent-09)) 65%, black);
}
/* Focus halo: same anatomy as the selected ring (inset edge + bg gap +
outer halo) but thinner and in the global accent color. Using the
swatch's own color blurs into "ring around a colored thing" — accent
blue is *never* the swatch's hue, so focus reads as a separate signal
no matter which swatch is hit. Skipped on .is-selected so the
selected ring isn't fighting a focus ring on top of itself. */
&:focus-visible:not(.is-selected) .swatch-fill {
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.08),
0 0 0 1px var(--lx-gray-02),
0 0 0 2.5px var(--lx-accent-09);
}
.swatch-empty {
@apply w-6 h-6 rounded-full relative overflow-hidden;
background-color: var(--rx-gray-05);
@@ -380,6 +390,25 @@
0 0 0 1px var(--lx-gray-02),
0 0 0 3.5px var(--lx-accent-09);
}
/* Empty swatch has no --swatch-color, so fall back to the global accent
so the focus halo is still visible on the slash tile. */
&:focus-visible:not(.is-selected) .swatch-empty {
box-shadow:
inset 0 0 0 1px var(--rx-gray-07),
0 0 0 1px var(--lx-gray-02),
0 0 0 2.5px var(--lx-accent-09);
}
}
/* Mute non-active siblings while the user is engaging with the palette.
Engagement = mouse-hovering anywhere in the group, OR any swatch has
keyboard/programmatic focus. Three swatches stay at full opacity:
the hovered one, the focused one, and the currently-selected one.
At rest (no hover, no focus inside), every swatch is at full opacity. */
&:is(:hover, :focus-within)
.color-swatch:not(:hover):not(:focus):not(.is-selected) {
opacity: 0.55;
}
}