feat: extend icon-picker topbar arrow-nav and unify focus styling

The icon-picker topbar now ropes the color swatch and trash button into
the same arrow-key rove as the tabs (data-topbar-stop + the controller's
:topbar region). Tabs auto-activate when arrow-roved over (e.detail = 0
discriminates programmatic clicks from real ones, so the on-change
handler keeps focus inside the topbar instead of bouncing to search).

Topbar focus styling is unified across both pickers so back / tabs /
segments / color / trash all paint the same blue ring (the trio of
browser-default outline, custom --lx-accent-09 outline, and shui's
--ring box-shadow no longer disagrees). Trash and inactive tabs need
opacity:1 on :focus-visible so the base opacity-60 doesn't dim the
ring into a muted glow.

Tile-to-tile transition no longer animates outline-color: animating it
from `currentColor` (e.g. red on a tinted icon) briefly tinted the
focus ring red before the accent color landed. Width and offset still
glide so the ring grows/shrinks smoothly; color snaps instantly.

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

View File

@@ -3833,7 +3833,7 @@
[:span.absolute.hidden {:ref *el-ref}]))
(rum/defc color-picker
[*color on-select! & {:keys [on-hover! on-hover-end!]}]
[*color on-select! & {:keys [on-hover! on-hover-end! button-attrs]}]
(let [;; Defensive: never let the CSS sentinel "inherit" leak into React state.
initial-color (let [v @*color] (when (and v (not= v "inherit")) v))
[color, set-color!] (rum/use-state initial-color)
@@ -3900,8 +3900,9 @@
[])
[:button.color-picker-trigger
{:ref *el
:on-click (fn [^js e] (shui/popup-show! (.-target e) content-fn {:content-props {:side "bottom" :side-offset 6}}))}
(merge button-attrs
{:ref *el
:on-click (fn [^js e] (shui/popup-show! (.-target e) content-fn {:content-props {:side "bottom" :side-offset 6}}))})
(if color
[:span.color-picker-fill {:style {:background-color color}}]
[:span.color-picker-empty
@@ -4308,14 +4309,22 @@
:*input-ref *input-ref
:flat-items flat-items
:sections sections
:*virtuoso-ref *virtuoso-ref})
:*virtuoso-ref *virtuoso-ref
:topbar-selector ".cp__emoji-icon-picker .tabs-section [data-topbar-stop]"})
(ui/tab-items
{:tabs [[:all "All"] [:emoji "Emojis"] [:icon "Icons"] [:custom "Custom"]]
:active @*tab
:on-change (fn [id _e]
:on-change (fn [id ^js e]
(reset! *tab id)
(reset! *focus-region :search)
(reset! *highlighted-index nil))})
(reset! *highlighted-index nil)
;; Only return focus to search for genuine mouse
;; clicks. Programmatic .click() from keyboard
;; arrow-rove (handle-topbar-keys auto-activate)
;; has e.detail = 0; real clicks are >= 1. Keeps
;; arrow nav inside the topbar region.
(when (and e (pos? (.-detail e)))
(reset! *focus-region :search)))
:button-attrs {:data-topbar-stop "tab"}})
[:div.tab-actions
;; color picker (always visible)
(color-picker *color (fn [c]
@@ -4338,10 +4347,12 @@
:color c})))
:on-hover-end! (when preview-target-db-id
(fn []
(state/set-state! :ui/icon-hover-preview nil))))
(state/set-state! :ui/icon-hover-preview nil)))
:button-attrs {:data-topbar-stop "color"})
;; delete button
(when del-btn?
(shui/button {:variant :outline :size :sm :data-action "del"
:data-topbar-stop "trash"
:on-click #(on-chosen nil)}
(shui/tabler-icon "trash" {:size 17})))]]
@@ -4368,11 +4379,11 @@
(shui/popup-hide!)
(reset-q!)))
;; Up Arrow / Shift+Tab: move to tab bar
;; Up Arrow / Shift+Tab: move to topbar at the active tab
(or (= code 38)
(and (= code 9) (.-shiftKey e)))
(do (util/stop e)
(reset! *focus-region :tabs)
(reset! *focus-region :topbar)
(reset! *highlighted-index nil)
(when-let [^js cnt (some-> (rum/deref *input-ref) (.closest ".cp__emoji-icon-picker"))]
(when-let [active-tab (.querySelector cnt "[data-active='true'].tab-item")]

View File

@@ -124,7 +124,15 @@
> button {
@apply flex items-center justify-center rounded-lg active:opacity-70;
transition: background-color 150ms, outline-color 150ms;
/* Animate width + offset so the ring grows/shrinks smoothly when
the highlight state changes. outline-color is intentionally NOT
transitioned — animating it from `currentColor` (the icon's
tint, e.g. red) would briefly tint the ring red before the
accent color landed. With color set instantly by the highlight
rules, the ring just grows in the right color from t=0. */
transition: background-color 150ms,
outline-width 150ms,
outline-offset 150ms;
&:hover {
background-color: var(--rx-gray-05);
@@ -162,6 +170,13 @@
&[data-action=del] {
@apply !w-6 !h-6 overflow-hidden rounded-md opacity-60
hover:text-red-rx-09 hover:opacity-90;
/* Force opacity 1 on keyboard focus so the focus ring paints
crisply; the base opacity-60 otherwise dims the outline into
a muted glow. */
&:focus-visible {
opacity: 1;
}
}
}
}
@@ -244,8 +259,11 @@
background: transparent !important;
}
/* Keyboard focus state */
/* Keyboard focus state. Force opacity 1 so the focus ring on an
inactive tab paints fully (the base rule sets opacity-60, which
would otherwise dim the outline). */
&:focus-visible {
opacity: 1;
outline: 2px solid var(--lx-accent-09);
outline-offset: -2px;
border-radius: 4px;
@@ -708,9 +726,47 @@
.ui__button[data-action=del] {
@apply !w-6 !h-6 overflow-hidden rounded-md opacity-60;
@apply hover:text-red-rx-09 hover:opacity-90;
/* Keyboard focus state. Force opacity 1 so the focus ring paints at
full alpha; otherwise the button's base opacity-60 dims the outline
and reads as a "muted glow" instead of a clear focus indicator. */
&:focus-visible {
opacity: 1;
}
}
}
/* Unified focus ring across the asset-picker topbar. Without this, three
different mechanisms render three different blues: back button uses the
browser-default outline (system blue), segments use our custom outline
(--lx-accent-09), and the trash button uses shui's box-shadow ring
(Tailwind --ring token). This rule overrides all three to match. */
.asset-picker-topbar :is(
.back-button,
.segmented-control .segment,
.ui__button[data-action=del]
):focus-visible {
outline: 2px solid var(--lx-accent-09);
outline-offset: 2px;
box-shadow: none;
border-radius: 4px;
}
/* Same unified treatment for the icon-picker topbar. The color-picker
trigger and the trash button each had their own focus stylings (browser
default + shui's --ring respectively); pulling them onto --lx-accent-09
so all topbar stops match. The .tab-item rule (above) already uses the
same token; keep it as-is. */
.cp__emoji-icon-picker .tabs-section :is(
.color-picker-trigger,
.ui__button[data-action=del]
):focus-visible {
outline: 2px solid var(--lx-accent-09);
outline-offset: 2px;
box-shadow: none;
border-radius: 4px;
}
/* Search input matching icon picker */
.asset-picker-search {
@apply py-1;