Browse Source

redesigned Shortcut component

scheinriese 1 week ago
parent
commit
c51e58a7fb

+ 312 - 0
deps/shui/src/logseq/shui/shortcut/v2.cljs

@@ -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)])))))
+

+ 3 - 1
deps/shui/src/logseq/shui/ui.cljs

@@ -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"))

+ 141 - 4
resources/css/shui.css

@@ -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;
   }
 }
 

+ 10 - 12
src/main/frontend/components/cmdk/core.cljs

@@ -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]

+ 6 - 3
src/main/frontend/components/cmdk/list_item.cljs

@@ -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})])]]))

+ 3 - 0
src/main/frontend/components/shortcut.cljs

@@ -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})])]]))))])])]]))

+ 3 - 1
src/main/frontend/ui.cljs

@@ -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)]))