redesigned Shortcut component

This commit is contained in:
scheinriese
2025-11-28 03:27:06 +01:00
parent 807cfed6d4
commit c51e58a7fb
7 changed files with 478 additions and 21 deletions

View File

@@ -0,0 +1,312 @@
(ns logseq.shui.shortcut.v2
"Unified keyboard shortcut component with three styles: combo, separate, and compact.
Expected shortcut formats:
- Combo keys (simultaneous): \"shift+cmd\", [\"shift\" \"cmd\"], or [[\"shift\" \"cmd\"]]
- Separate keys (sequential): [\"cmd\" \"up\"], or [\"cmd\" \"k\"]
- Compact: same formats but with :style :compact
Platform mapping:
- :mod or \"mod\" maps to ⌘ on macOS, Ctrl on Windows/Linux
- Other keys are mapped via print-shortcut-key function"
(:require [clojure.string :as string]
[goog.userAgent]
[rum.core :as rum]))
(def mac? goog.userAgent/MAC)
(defn print-shortcut-key
"Maps logical keys to display keys, with platform-specific handling.
Supports :mod for platform-agnostic modifier key.
Automatically uppercases single letter keys (a-z) while preserving
multi-character keys like Ctrl, Backspace, Delete, Opt, etc."
[key]
(let [result (if (coll? key)
(string/join "+" key)
(case (if (string? key)
(string/lower-case key)
key)
("cmd" "command" "mod" "⌘") (if mac? "⌘" "Ctrl")
("meta") (if mac? "⌘" "⊞")
("return" "enter" "⏎") "⏎"
("shift" "⇧") "⇧"
("alt" "option" "opt" "⌥") (if mac? "Opt" "Alt")
("ctrl" "control" "⌃") "Ctrl"
("space" " ") "Space"
("up" "↑") "↑"
("down" "↓") "↓"
("left" "←") "←"
("right" "→") "→"
("tab") "Tab"
("open-square-bracket") "["
("close-square-bracket") "]"
("dash") "-"
("semicolon") ";"
("equals") "="
("single-quote") "'"
("backslash") "\\"
("comma") ","
("period") "."
("slash") "/"
("grave-accent") "`"
("page-up") ""
("page-down") ""
("esc" "escape") "Esc"
("backspace") "Backspace"
("delete") "Delete"
(nil) ""
(name key)))
;; If result is a single letter (a-z), uppercase it
;; Otherwise, capitalize only if it's a single character (for symbols)
final-result (cond
(and (= (count result) 1)
(re-matches #"[a-z]" result))
(string/upper-case result)
(= (count result) 1)
result
:else
(string/capitalize result))]
final-result))
(defn- flatten-keys
"Recursively flattens nested collections, preserving strings."
[coll]
(mapcat (fn [x]
(if (and (coll? x) (not (string? x)))
(flatten-keys x)
[x]))
coll))
(defn- normalize-binding
"Normalizes a shortcut binding to a string format for data attributes.
Examples: 'cmd+k', 'shift+cmd+k', 'cmd up'"
[binding]
(cond
(string? binding)
(string/lower-case (string/trim binding))
(coll? binding)
(let [first-item (first binding)
keys (flatten-keys binding)
normalize-key (fn [k]
(cond
(string? k) (string/lower-case k)
(keyword? k) (name k)
(symbol? k) (name k)
(number? k) (str k)
:else (str k)))]
(string/join "+" (map normalize-key keys)))
(keyword? binding)
(name binding)
(symbol? binding)
(name binding)
:else
(str binding)))
(defn- detect-style
"Automatically detects style from shortcut format.
Returns :combo, :separate, or :compact"
[shortcut]
(cond
(string? shortcut)
(if (string/includes? shortcut "+")
:combo
:separate)
(coll? shortcut)
(let [first-item (first shortcut)]
(cond
(coll? first-item) :combo ; nested collection means combo
(string/includes? (str first-item) "+") :combo
:else :separate))
:else :separate))
(defn- parse-shortcuts
"Parses shortcut string into structured format.
Handles ' | ' separator for multiple shortcuts."
[s]
(->> (string/split s #" \| ")
(map (fn [x]
(->> (string/split x #" ")
(map #(if (string/includes? % "+")
(string/split % #"\+")
%)))))))
(defn shortcut-press!
"Central helper to trigger key press animation.
Finds all nodes with matching data-shortcut-binding and toggles pressed class.
Optionally highlights parent row.
Args:
- binding: normalized shortcut binding string (e.g., \"cmd+k\")
- highlight-row?: if true, also highlights parent row (default: false)"
([binding] (shortcut-press! binding false))
([binding highlight-row?]
(let [normalized (normalize-binding binding)
selector (str "[data-shortcut-binding=\"" normalized "\"]")
elements (.querySelectorAll js/document selector)]
(doseq [^js el (array-seq elements)]
(.add (.-classList el) "shui-shortcut-key-pressed")
(when highlight-row?
(let [^js row (or (.closest el ".shui-shortcut-row")
(.-parentElement el))]
(when row
(.add (.-classList row) "shui-shortcut-row--pressed"))))
;; Auto-reset after animation duration
(js/setTimeout
(fn []
(.remove (.-classList el) "shui-shortcut-key-pressed")
(when highlight-row?
(let [^js row (or (.closest el ".shui-shortcut-row")
(.-parentElement el))]
(when row
(.remove (.-classList row) "shui-shortcut-row--pressed")))))
160)))))
(rum/defc combo-keys
"Renders combo keys (simultaneous key combinations) with separator."
[keys binding {:keys [interactive? aria-label aria-hidden? glow?]}]
(let [key-elements (map print-shortcut-key keys)
normalized-binding (normalize-binding binding)
container-class (str "shui-shortcut-combo" (when glow? " shui-shortcut-glow"))
container-attrs {:class container-class
:data-shortcut-binding normalized-binding
:style {:white-space "nowrap"}}
container-attrs (if aria-label
(assoc container-attrs :aria-label aria-label)
container-attrs)
container-attrs (if aria-hidden?
(assoc container-attrs :aria-hidden "true")
container-attrs)]
[:div container-attrs
(for [[index key-text] (map-indexed vector key-elements)]
(list
(when (< 0 index)
[:span.shui-shortcut-separator {:key (str "sep-" index)}])
[:kbd.shui-shortcut-key
{:key (str "combo-key-" index)
:aria-hidden (if aria-label "true" "false")
:tab-index (if interactive? 0 -1)
:role (when interactive? "button")}
key-text]))]))
(rum/defc separate-keys
"Renders separate keys (sequential key presses) with 4px gap."
[keys binding {:keys [interactive? aria-label aria-hidden? glow?]}]
(let [key-elements (map print-shortcut-key keys)
normalized-binding (normalize-binding binding)
container-class (str "shui-shortcut-separate" (when glow? " shui-shortcut-glow"))
container-attrs {:class container-class
:data-shortcut-binding normalized-binding
:style {:white-space "nowrap"
:gap "4px"}}
container-attrs (if aria-label
(assoc container-attrs :aria-label aria-label)
container-attrs)
container-attrs (if aria-hidden?
(assoc container-attrs :aria-hidden "true")
container-attrs)]
[:div container-attrs
(for [[index key-text] (map-indexed vector key-elements)]
[:kbd.shui-shortcut-key
{:key (str "separate-key-" index)
:aria-hidden (if aria-label "true" "false")
:tab-index (if interactive? 0 -1)
:role (when interactive? "button")
:style {:min-width "fit-content"}}
key-text])]))
(rum/defc compact-keys
"Renders compact style (text-only, minimal styling)."
[keys binding {:keys [aria-label aria-hidden?]}]
(let [key-elements (map print-shortcut-key keys)
normalized-binding (normalize-binding binding)
container-attrs {:class "shui-shortcut-compact"
:data-shortcut-binding normalized-binding
:style {:white-space "nowrap"}}
container-attrs (if aria-label
(assoc container-attrs :aria-label aria-label)
container-attrs)
container-attrs (if aria-hidden?
(assoc container-attrs :aria-hidden "true")
container-attrs)]
[:div container-attrs
(for [[index key-text] (map-indexed vector key-elements)]
[:span
{:key (str "compact-key-" index)
:style {:display "inline-block"
:margin-right "2px"}}
key-text])]))
(rum/defc root
"Main shortcut component with automatic style detection.
Props:
- :style - :combo, :separate, :compact, or :auto (default: :auto)
- :interactive? - if true, keys are focusable (default: false)
- :aria-label - accessibility label for container
- :aria-hidden? - if true, hides from screen readers (default: false for decorative hints)
- :animate-on-press? - if true, enables press animation (default: true)
- :glow? - if true, adds inner glow effect to combo/separate keys (default: true)"
[shortcut & {:keys [style size theme interactive? aria-label aria-hidden? animate-on-press? glow?]
:or {style :auto
size :xs
interactive? false
aria-hidden? false
animate-on-press? true
glow? true}}]
(when (and shortcut (seq shortcut))
(let [shortcuts (if (coll? shortcut)
(if (every? string? shortcut)
[shortcut] ; single shortcut as vector
(if (string? (first shortcut))
[shortcut] ; single shortcut string
shortcut)) ; multiple shortcuts
(parse-shortcuts shortcut))
opts {:interactive? interactive?
:aria-label aria-label
:aria-hidden? aria-hidden?
:glow? glow?}]
(for [[index binding] (map-indexed vector shortcuts)]
(let [detected-style (if (= style :auto)
(detect-style binding)
style)
keys (cond
(string? binding)
(if (string/includes? binding "+")
(string/split binding #"\+") ; combo: "cmd+k" -> ["cmd" "k"]
(string/split binding #" ")) ; separate: "cmd k" -> ["cmd" "k"]
(and (coll? binding) (coll? (first binding)))
(first binding) ; combo: nested collection like [["shift" "cmd"]]
(coll? binding)
(let [flattened (mapcat #(if (coll? %) % [%]) binding)]
(if (every? string? flattened)
flattened ; separate: flat collection like ["cmd" "k"] or ["⇧" "g"]
(map str flattened))) ; convert any non-strings to strings
:else
[(str binding)])
render-fn (case detected-style
:combo combo-keys
:separate separate-keys
:compact compact-keys
separate-keys)] ; fallback
[:span
{:key (str "shortcut-" index)
:style {:display "inline-flex"
:align-items "center"
:min-height "20px"
:white-space "nowrap"}}
(when (< 0 index)
[:span.text-gray-11.text-sm {:key (str "sep-" index)
:style {:margin "0 4px"}} "|"])
(render-fn keys binding opts)])))))

View File

@@ -7,6 +7,7 @@
[logseq.shui.select.core :as select-core]
[logseq.shui.select.multi :as select-multi]
[logseq.shui.shortcut.v1 :as shui.shortcut.v1]
[logseq.shui.shortcut.v2 :as shui.shortcut.v2]
[logseq.shui.table.core :as table-core]
[logseq.shui.toaster.core :as toaster-core]
[logseq.shui.util :as util]))
@@ -20,7 +21,8 @@
(def link base-core/link)
(def trigger-as base-core/trigger-as)
(def trigger-child-wrap base-core/trigger-child-wrap)
(def ^:todo shortcut shui.shortcut.v1/root)
(def ^:todo shortcut shui.shortcut.v2/root)
(def shortcut-press! shui.shortcut.v2/shortcut-press!)
(def ^:export tabler-icon icon-v2/root)
(def alert (util/lsui-wrap "Alert"))

View File

@@ -267,11 +267,148 @@ div[data-radix-popper-content-wrapper] {
}
}
.ui__button-shortcut-key {
@apply text-xs font-normal h-5 w-5 flex items-center justify-center rounded bg-gray-06-alpha;
/* Deprecated: .ui__button-shortcut-key - replaced by shui-shortcut-key */
&:first-of-type {
@apply ml-2;
/* Unified Keyboard Shortcut Component Styles */
/* Combo Keys - simultaneous key combinations with separator */
.shui-shortcut-combo {
@apply flex items-start relative rounded;
background-color: rgba(223, 239, 254, 0.14);
border: 1px solid rgba(0, 0, 0, 0.1);
box-sizing: border-box;
white-space: nowrap;
}
/* Glow effect for combo and separate keys */
/* Combo keys: glow on container (wraps all keys together) */
.shui-shortcut-combo.shui-shortcut-glow {
box-shadow: rgba(255, 255, 255, 0.15) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.15) 0px -1px 0px 0px inset;
border: none;
}
/* Separate keys: glow on individual keys (not container) */
.shui-shortcut-separate.shui-shortcut-glow kbd.shui-shortcut-key {
box-shadow: rgba(255, 255, 255, 0.15) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.15) 0px -1px 0px 0px inset;
border: none;
}
.shui-shortcut-combo kbd.shui-shortcut-key {
@apply flex flex-col items-center justify-center px-1 py-0.5 relative shrink-0;
min-width: fit-content;
background: transparent;
border: none;
}
.shui-shortcut-separator {
background-color: rgba(224, 243, 255, 0.18);
align-self: stretch;
flex-shrink: 0;
width: 1px;
}
/* Separate Keys - sequential key presses with 4px gap */
.shui-shortcut-separate {
@apply flex items-start relative;
gap: 4px;
white-space: nowrap;
}
.shui-shortcut-separate kbd.shui-shortcut-key {
@apply flex flex-col items-center justify-center px-1 py-0.5 relative rounded shrink-0;
background-color: rgba(223, 239, 254, 0.14);
border: 1px solid rgba(0, 0, 0, 0.1);
box-sizing: border-box;
min-width: fit-content;
}
/* Compact Keys - minimal text-only style */
.shui-shortcut-compact {
@apply flex items-start relative;
font-family: 'Inter', sans-serif;
font-weight: normal;
line-height: 16px;
font-style: normal;
color: #ecedee;
font-size: 12px;
text-align: center;
letter-spacing: -0.5px;
white-space: nowrap;
gap: 2px;
}
/* Individual key styling with inner glow */
kbd.shui-shortcut-key,
.shui-shortcut-key {
@apply text-xs font-normal h-5 flex items-center justify-center;
font-family: 'Inter', sans-serif;
color: #ecedee;
font-size: 12px;
text-align: center;
letter-spacing: -0.5px;
padding: 2px 4px;
line-height: 16px;
min-width: fit-content;
transition: transform 140ms ease-out, box-shadow 140ms ease-out;
box-shadow: 0 0 0 rgba(0, 0, 0, 0);
}
/* Keys in separate containers get their own styling */
.shui-shortcut-separate kbd.shui-shortcut-key {
@apply rounded;
background-color: rgba(223, 239, 254, 0.14);
border: 1px solid rgba(0, 0, 0, 0.1);
box-sizing: border-box;
}
/* Key press animation */
kbd.shui-shortcut-key-pressed,
.shui-shortcut-key-pressed {
transform: translateY(1px);
}
/* Key press animation with glow - preserve glow effect */
/* Combo keys: animate the container */
.shui-shortcut-combo.shui-shortcut-glow.shui-shortcut-key-pressed {
box-shadow: rgba(255, 255, 255, 0.15) 0px 2px 0px 0px inset, rgba(0, 0, 0, 0.15) 0px 0px 0px 0px inset, 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Separate keys: animate individual keys */
.shui-shortcut-separate.shui-shortcut-glow kbd.shui-shortcut-key-pressed {
box-shadow: rgba(255, 255, 255, 0.15) 0px 2px 0px 0px inset, rgba(0, 0, 0, 0.15) 0px 0px 0px 0px inset, 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Key press animation without glow */
.shui-shortcut-combo:not(.shui-shortcut-glow) kbd.shui-shortcut-key-pressed,
.shui-shortcut-separate:not(.shui-shortcut-glow) kbd.shui-shortcut-key-pressed {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Row highlight animation */
.shui-shortcut-row--pressed {
background-color: rgba(223, 239, 254, 0.1);
transition: background-color 160ms ease-out;
}
/* Ensure consistent height for shortcut containers */
.shui-shortcut-row {
min-height: 20px;
align-items: center;
flex-wrap: nowrap;
}
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
kbd.shui-shortcut-key,
.shui-shortcut-key,
.shui-shortcut-row--pressed {
transition: none;
transform: none;
box-shadow: 0 0 0 rgba(0, 0, 0, 0);
}
.shui-shortcut-row--pressed {
background-color: transparent;
}
}

View File

@@ -669,10 +669,10 @@
(if (= show :more)
[:div.flex.flex-row.gap-1.items-center
"Show less"
(shui/shortcut "mod up" nil)]
(shui/shortcut "mod up" {:style :compact})]
[:div.flex.flex-row.gap-1.items-center
"Show more"
(shui/shortcut "mod down" nil)])])])
(shui/shortcut "mod down" {:style :compact})])])])
[:div.search-results
(for [item visible-items
@@ -805,6 +805,10 @@
(show-less)
(move-highlight state -1))
(and enter? (not composing?)) (do
(when shift?
(shui/shortcut-press! "shift+return" true))
(when-not shift?
(shui/shortcut-press! "return" true))
(handle-action :default state e)
(util/stop-propagation e))
esc? (let [filter' @(::filter state)]
@@ -817,6 +821,7 @@
(util/stop e)
(handle-input-change state nil ""))))
(and meta? (= keyname "c")) (do
(shui/shortcut-press! (if goog.userAgent/MAC "cmd+c" "ctrl+c") true)
(copy-block-ref state)
(util/stop-propagation e))
(and meta? (= keyname "o"))
@@ -923,16 +928,9 @@
[[:span.opacity-60 text]
;; shortcut
(when (not-empty shortcut)
(for [key shortcut]
[:div.ui__button-shortcut-key
(case key
"cmd" [:div (if goog.userAgent/MAC "⌘" "Ctrl")]
"shift" [:div "⇧"]
"return" [:div "⏎"]
"esc" [:div.tracking-tightest {:style {:transform "scaleX(0.8) scaleY(1.2) "
:font-size "0.5rem"
:font-weight "500"}} "ESC"]
(cond-> key (string? key) .toUpperCase))]))]))
(shui/shortcut shortcut {:style :separate
:interactive? false
:aria-hidden? true}))]))
(rum/defc hints
[state]

View File

@@ -115,6 +115,9 @@
(when value
[:span.text-gray-11 (to-string value)])])
(when shortcut
[:div {:class "flex gap-1"
:style {:opacity (if (or highlighted hover?) 1 0.9)}}
(shui/shortcut shortcut)])]]))
[:div {:class "flex gap-1 shui-shortcut-row items-center"
:style {:opacity (if (or highlighted hover?) 1 0.9)
:min-height "20px"
:flex-wrap "nowrap"}}
(shui/shortcut shortcut {:interactive? false
:aria-hidden? true})])]]))

View File

@@ -477,6 +477,9 @@
(not unset?)
[:code.flex.items-center.bg-transparent
{:style {:min-height "20px"
:flex-wrap "nowrap"
:white-space "nowrap"}}
(shui/shortcut
(string/join " | " (map #(dh/binding-for-display id %) binding))
{:size :md :interactive? true})])]]))))])])]]))

View File

@@ -207,7 +207,9 @@
(string/trim)
(string/lower-case)
(string/split #" "))
sequence)]
sequence)
opts (merge {:interactive? false
:aria-hidden? true} opts)]
[:span.keyboard-shortcut
(shui/shortcut sequence opts)]))