mirror of
https://github.com/logseq/logseq.git
synced 2026-05-29 15:09:41 +00:00
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:
@@ -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 []
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user