Browse Source

Enhance combobox styling and functionality

- Unified styles for combobox components across various contexts, including popovers and dropdowns.
- Improved CSS rules for consistent padding and margin adjustments in combobox and select components.
- Updated component references in editor and select files to utilize the new combobox implementation.
scheinriese 6 days ago
parent
commit
4a13dc9c98

+ 5 - 0
resources/css/shui.css

@@ -56,6 +56,11 @@ html {
 
 
     div[data-radix-popper-content-wrapper] div[role=menu],
+    /* Also apply to popover content when it contains a combobox (unified styling) */
+    div[data-radix-popper-content-wrapper] .ui__popover-content:has(#ui__ac.ui__combobox),
+    div[data-radix-popper-content-wrapper] .ui__popover-content:has(#ui__ac.cp__commands-slash),
+    div[data-radix-popper-content-wrapper] .ui__popover-content:has(#ui__ac.black),
+    div[data-radix-popper-content-wrapper] .ui__popover-content:has(#ui__ac.ac-block-search),
     .menu-links-wrapper,
     .menu-links-outer,
     .absolute-modal[data-modal-name] {

+ 77 - 0
src/main/frontend/components/combobox.cljs

@@ -0,0 +1,77 @@
+(ns frontend.components.combobox
+  "Unified combobox component with variants for searchable dropdowns.
+   Built on top of ui/auto-complete, supports both with and without visible search input.
+   
+   When show-search-input? is false: Simple wrapper around auto-complete (like command dropdown)
+   When show-search-input? is true: Includes search input UI + separator (like select component)"
+  (:require [frontend.ui :as ui]
+            [logseq.shui.ui :as shui]
+            [rum.core :as rum]))
+
+(rum/defc combobox
+  "Unified combobox component with two variants:
+   - With visible search input + separator (show-search-input? true)
+   - Without visible search input/separator (show-search-input? false)
+   
+   Built on top of ui/auto-complete for consistent behavior.
+   Uses ui__combobox as base class, and cp__select-main for backward compatibility
+   when show-search-input? is true.
+   
+   When show-search-input? is false: Simple wrapper around auto-complete (like command dropdown)
+   When show-search-input? is true: Adds separator and wraps results in item-results-wrap container
+   
+   Note: The search input itself should be provided by the parent component (like select does)."
+  [items
+   {:keys [show-search-input?
+           show-separator?
+           on-chosen
+           on-shift-chosen
+           get-group-name
+           empty-placeholder
+           item-render
+           class
+           header
+           grouped?]
+    ;; All other auto-complete options pass through via `opts`
+    :as opts}]
+  (let [base-class "ui__combobox"
+        combined-class (cond-> base-class
+                         show-search-input? (str " cp__select-main")
+                         class (str " " class))
+        ;; Remove keys we've destructured from opts to avoid conflicts
+        opts' (dissoc opts :show-search-input? :show-separator? :on-chosen :on-shift-chosen
+                      :get-group-name :empty-placeholder :item-render :class :header :grouped?)]
+    (if show-search-input?
+      ;; With search input: wrap results in item-results-wrap container and optionally add separator
+      [:div {:class combined-class}
+       (when (not= show-separator? false)
+         (shui/select-separator))
+       [:div.item-results-wrap
+        (ui/auto-complete
+         items
+         (merge {:on-chosen on-chosen
+                 :on-shift-chosen on-shift-chosen
+                 :get-group-name get-group-name
+                 :empty-placeholder empty-placeholder
+                 :item-render item-render
+                 :header header
+                 :grouped? grouped?
+                 :class "cp__select-results"}
+                opts'))]]
+      ;; Without search input: simple wrapper around auto-complete
+      ;; Ensure ui__combobox class is always applied, then add custom class if provided
+      (let [final-class (if class
+                          (str base-class " " class)
+                          base-class)]
+        (ui/auto-complete
+         items
+         (merge {:on-chosen on-chosen
+                 :on-shift-chosen on-shift-chosen
+                 :get-group-name get-group-name
+                 :empty-placeholder empty-placeholder
+                 :item-render item-render
+                 :header header
+                 :grouped? grouped?
+                 :class final-class}
+                opts'))))))
+

+ 10 - 6
src/main/frontend/components/editor.cljs

@@ -2,6 +2,7 @@
   (:require [clojure.string :as string]
             [dommy.core :as dom]
             [frontend.commands :as commands :refer [*matched-commands]]
+            [frontend.components.combobox :as combobox]
             [frontend.components.file-based.datetime :as datetime-comp]
             [frontend.components.search :as search]
             [frontend.components.svg :as svg]
@@ -61,10 +62,11 @@
         page? (db/page? (db/entity (:db/id (state/get-edit-block))))
         matched (or (filter-commands page? @*matched) no-matched-commands)
         filtered? (not= matched @commands/*initial-commands)]
-    (ui/auto-complete
+    (combobox/combobox
      matched
      (cond->
-      {:item-render
+      {:show-search-input? false
+       :item-render
        (fn [item]
          (let [command-name (first item)
                command-doc (get item 2)
@@ -193,9 +195,10 @@
                                    (matched-pages-with-new-page (rest matched-pages) db-tag? q))
                              (matched-pages-with-new-page matched-pages db-tag? q)))]
       [:<>
-       (ui/auto-complete
+       (combobox/combobox
         matched-pages'
-        {:on-chosen   (page-on-chosen-handler embed? input id q pos format)
+        {:show-search-input? false
+         :on-chosen   (page-on-chosen-handler embed? input id q pos format)
          :on-enter    (fn []
                         (page-handler/page-not-exists-handler input id q current-pos))
          :item-render (fn [block _chosen?]
@@ -329,9 +332,10 @@
         embed? (and db? (= @commands/*current-command "Block embed"))
         chosen-handler (block-on-chosen-handler embed? input id q format selected-text)
         non-exist-block-handler (editor-handler/block-non-exist-handler input)]
-    (ui/auto-complete
+    (combobox/combobox
      result
-     {:on-chosen   chosen-handler
+     {:show-search-input? false
+      :on-chosen   chosen-handler
       :on-enter    non-exist-block-handler
       :empty-placeholder   [:div.text-gray-500.text-sm.px-4.py-2 (t :editor/block-search)]
       :item-render (fn [{:block/keys [page uuid]}]  ;; content returned from search engine is normalized

+ 33 - 17
src/main/frontend/components/property/value.css

@@ -45,13 +45,15 @@
   min-width: 3em;
 }
 
-/* Fix separator width in combobox context - remove padding from ui__dropdown-menu-content when it contains cp__select-main */
-.ui__dropdown-menu-content:has(.cp__select-main) {
+/* Fix separator width in combobox context - remove padding from ui__dropdown-menu-content when it contains combobox */
+.ui__dropdown-menu-content:has(.cp__select-main),
+.ui__dropdown-menu-content:has(.ui__combobox) {
   padding: 0 !important;
 }
 
 /* Add padding back to input and results to maintain visual spacing */
-.ui__dropdown-menu-content .cp__select-main .input-wrap {
+.ui__dropdown-menu-content .cp__select-main .input-wrap,
+.ui__dropdown-menu-content .ui__combobox .input-wrap {
   padding-left: 4px;
   padding-right: 4px;
   padding-top: 4px;
@@ -59,7 +61,8 @@
   overflow-x: hidden !important;
 }
 
-.ui__dropdown-menu-content .cp__select-main .item-results-wrap {
+.ui__dropdown-menu-content .cp__select-main .item-results-wrap,
+.ui__dropdown-menu-content .ui__combobox .item-results-wrap {
   padding: 0 4px !important;
   overflow-x: hidden !important;
   overflow-y: auto !important;
@@ -68,66 +71,79 @@
 
 /* Remove vertical padding from scroll viewport for clean cutoff */
 .ui__dropdown-menu-content .cp__select-main [class*="SelectViewport"],
-.ui__dropdown-menu-content .cp__select-main .ui__select-viewport {
+.ui__dropdown-menu-content .cp__select-main .ui__select-viewport,
+.ui__dropdown-menu-content .ui__combobox [class*="SelectViewport"],
+.ui__dropdown-menu-content .ui__combobox .ui__select-viewport {
   padding-top: 0 !important;
   padding-bottom: 0 !important;
 }
 
 /* Remove py-1 padding from results wrapper */
 .ui__dropdown-menu-content .cp__select-main > div[class*="py-1"],
-.ui__dropdown-menu-content .cp__select-main > div[class*="py-"] {
+.ui__dropdown-menu-content .cp__select-main > div[class*="py-"],
+.ui__dropdown-menu-content .ui__combobox > div[class*="py-1"],
+.ui__dropdown-menu-content .ui__combobox > div[class*="py-"] {
   padding-top: 0 !important;
   padding-bottom: 0 !important;
 }
 
 /* Add breathing room to first and last items (scrolls away naturally) */
-.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link:first-child {
+.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link:first-child,
+.ui__dropdown-menu-content .ui__combobox .item-results-wrap .menu-link:first-child {
   margin-top: 4px;
 }
 
-.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link:last-child {
+.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link:last-child,
+.ui__dropdown-menu-content .ui__combobox .item-results-wrap .menu-link:last-child {
   margin-bottom: 4px;
 }
 
-/* Dropdown combobox menu-link styles for cp__select-main */
-/* These rules account for the fact that cp__select-main doesn't use .menu-link-wrap wrapper */
+/* Dropdown combobox menu-link styles for cp__select-main and ui__combobox */
+/* These rules account for the fact that these don't use .menu-link-wrap wrapper */
 
 /* Default state - unselected items: gray-12 text + opacity-80 + grayscale */
-.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link:not(.chosen) {
+.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link:not(.chosen),
+.ui__dropdown-menu-content .ui__combobox .item-results-wrap .menu-link:not(.chosen) {
   @apply opacity-80 grayscale transition-opacity transition-all duration-75 ease-in;
   color: var(--lx-gray-12, var(--rx-gray-12)) !important;
 }
 
 /* Selected state - chosen item: gray-12 text + opacity-100 + no grayscale + background */
-.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link.chosen {
+.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link.chosen,
+.ui__dropdown-menu-content .ui__combobox .item-results-wrap .menu-link.chosen {
   @apply !opacity-100 grayscale-0;
   color: var(--lx-gray-12, var(--rx-gray-12)) !important;
   background-color: var(--lx-gray-03-alpha, var(--rx-gray-03-alpha)) !important;
 }
 
 /* Disable separate hover state - only .chosen state should be visually distinct */
-.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link:hover:not(.chosen) {
+.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link:hover:not(.chosen),
+.ui__dropdown-menu-content .ui__combobox .item-results-wrap .menu-link:hover:not(.chosen) {
   background: none !important;
   background-color: transparent !important;
 }
 
 /* Force gray-12 on all nested elements in menu items */
-.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link * {
+.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link *,
+.ui__dropdown-menu-content .ui__combobox .item-results-wrap .menu-link * {
   color: var(--lx-gray-12, var(--rx-gray-12)) !important;
 }
 
 /* Force gray-12 on all nested elements in selected items */
-.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link.chosen * {
+.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link.chosen *,
+.ui__dropdown-menu-content .ui__combobox .item-results-wrap .menu-link.chosen * {
   color: var(--lx-gray-12, var(--rx-gray-12)) !important;
 }
 
 /* Exception: Preserve gray-11 for quoted text in "New option" */
-.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link .text-gray-11 {
+.ui__dropdown-menu-content .cp__select-main .item-results-wrap .menu-link .text-gray-11,
+.ui__dropdown-menu-content .ui__combobox .item-results-wrap .menu-link .text-gray-11 {
   color: var(--lx-gray-11, var(--rx-gray-11)) !important;
 }
 
 /* Separator now spans full width since parent has no padding */
-.ui__dropdown-menu-content .cp__select-main .ui__select-separator {
+.ui__dropdown-menu-content .cp__select-main .ui__select-separator,
+.ui__dropdown-menu-content .ui__combobox .ui__select-separator {
   margin: 0 !important;
   width: 100% !important;
 }

+ 25 - 24
src/main/frontend/components/select.cljs

@@ -4,6 +4,7 @@
   new select-type, create an event that calls `select/dialog-select!` with the
   select-type. See the :graph/open command for a full example."
   (:require [clojure.string :as string]
+            [frontend.components.combobox :as combobox]
             [frontend.config :as config]
             [frontend.context.i18n :refer [t]]
             [frontend.handler.common.developer :as dev-common-handler]
@@ -182,31 +183,31 @@
                                  (ui/loading "Loading ...")]
                                 [:div
                                  {:class (when (seq search-result) "py-1")}
-                                 [:div.item-results-wrap
-                                  (ui/auto-complete
-                                   search-result
-                                   {:grouped? grouped?
-                                    :item-render       (or item-cp (fn [result chosen?]
+                                 (combobox/combobox
+                                  search-result
+                                  {:show-search-input? true
+                                   :show-separator? false
+                                   :grouped? grouped?
+                                   :item-render       (or item-cp (fn [result chosen?]
                                                                      (render-item result chosen? multiple-choices? *selected-choices)))
-                                    :class             "cp__select-results"
-                                    :on-chosen         (fn [raw-chosen e]
-                                                         (when clear-input-on-chosen?
-                                                           (reset! *input ""))
-                                                         (let [chosen (extract-chosen-fn raw-chosen)]
-                                                           (if multiple-choices?
-                                                             (if (selected-choices chosen)
-                                                               (do
-                                                                 (swap! *selected-choices disj chosen)
-                                                                 (when on-chosen (on-chosen chosen false @*selected-choices e)))
-                                                               (do
-                                                                 (swap! *selected-choices conj chosen)
-                                                                 (when on-chosen (on-chosen chosen true @*selected-choices e))))
-                                                             (do
-                                                               (when (and close-modal? (not multiple-choices?))
-                                                                 (state/close-modal!))
-                                                               (when on-chosen
-                                                                 (on-chosen chosen true @*selected-choices e))))))
-                                    :empty-placeholder (empty-placeholder t)})]
+                                   :on-chosen         (fn [raw-chosen e]
+                                                        (when clear-input-on-chosen?
+                                                          (reset! *input ""))
+                                                        (let [chosen (extract-chosen-fn raw-chosen)]
+                                                          (if multiple-choices?
+                                                            (if (selected-choices chosen)
+                                                              (do
+                                                                (swap! *selected-choices disj chosen)
+                                                                (when on-chosen (on-chosen chosen false @*selected-choices e)))
+                                                              (do
+                                                                (swap! *selected-choices conj chosen)
+                                                                (when on-chosen (on-chosen chosen true @*selected-choices e))))
+                                                            (do
+                                                              (when (and close-modal? (not multiple-choices?))
+                                                                (state/close-modal!))
+                                                              (when on-chosen
+                                                                (on-chosen chosen true @*selected-choices e))))))
+                                   :empty-placeholder (empty-placeholder t)})
 
                                  (when (and multiple-choices? (fn? on-apply))
                                    [:div.p-4 (ui/button "Apply"

+ 141 - 0
src/main/frontend/ui.css

@@ -93,6 +93,147 @@
   max-height: none;
 }
 
+/* Unified combobox styles - applies to both variants (with and without search input) */
+/* When used without search input, the class is on #ui__ac directly */
+#ui__ac.ui__combobox {
+  #ui__ac-inner {
+    padding: 0 4px !important;
+  }
+
+  /* Unified menu-link styles for combobox (without search input variant) */
+  /* Match the beautiful "Add property" dropdown styling */
+  .menu-link-wrap .menu-link:not(.chosen) {
+    @apply opacity-80 grayscale transition-opacity transition-all duration-75 ease-in;
+    color: var(--lx-gray-12, var(--rx-gray-12)) !important;
+  }
+
+  .menu-link-wrap .menu-link.chosen {
+    @apply !opacity-100 grayscale-0;
+    color: var(--lx-gray-12, var(--rx-gray-12)) !important;
+    background-color: var(--lx-gray-03-alpha, var(--rx-gray-03-alpha)) !important;
+  }
+
+  /* Disable separate hover state - only .chosen state should be visually distinct */
+  .menu-link-wrap .menu-link:hover:not(.chosen) {
+    background: none !important;
+    background-color: transparent !important;
+  }
+
+  /* Force gray-12 on all nested elements in menu items */
+  .menu-link-wrap .menu-link * {
+    color: var(--lx-gray-12, var(--rx-gray-12)) !important;
+  }
+
+  /* Force gray-12 on all nested elements in selected items */
+  .menu-link-wrap .menu-link.chosen * {
+    color: var(--lx-gray-12, var(--rx-gray-12)) !important;
+  }
+
+  /* Exception: Preserve gray-11 for quoted text in "New option" */
+  .menu-link-wrap .menu-link .text-gray-11 {
+    color: var(--lx-gray-11, var(--rx-gray-11)) !important;
+  }
+}
+
+/* When used with search input, the class is on a wrapper div */
+.ui__combobox {
+  /* Group name styling - applies to both variants */
+  .ui__ac-group-name {
+    @apply p-2 text-xs text-popover-foreground/20 font-medium;
+  }
+}
+
+/* Also apply unified styles when combobox is used in popover context (like / command) */
+/* This ensures the same beautiful styling as the "Add property" dropdown */
+
+/* Remove padding from popover content when it contains combobox (like dropdown-menu-content) */
+.ui__popover-content:has(#ui__ac.ui__combobox) {
+  padding: 0 !important;
+}
+
+.ui__popover-content #ui__ac.ui__combobox {
+  #ui__ac-inner {
+    padding: 0 4px !important;
+  }
+
+  /* Match the beautiful "Add property" dropdown styling exactly */
+  .menu-link-wrap .menu-link:not(.chosen) {
+    @apply opacity-80 grayscale transition-opacity transition-all duration-75 ease-in;
+    color: var(--lx-gray-12, var(--rx-gray-12)) !important;
+  }
+
+  .menu-link-wrap .menu-link.chosen {
+    @apply !opacity-100 grayscale-0;
+    color: var(--lx-gray-12, var(--rx-gray-12)) !important;
+    background-color: var(--lx-gray-03-alpha, var(--rx-gray-03-alpha)) !important;
+  }
+
+  /* Disable separate hover state - only .chosen state should be visually distinct */
+  .menu-link-wrap .menu-link:hover:not(.chosen) {
+    background: none !important;
+    background-color: transparent !important;
+  }
+
+  /* Force gray-12 on all nested elements in menu items */
+  .menu-link-wrap .menu-link * {
+    color: var(--lx-gray-12, var(--rx-gray-12)) !important;
+  }
+
+  /* Force gray-12 on all nested elements in selected items */
+  .menu-link-wrap .menu-link.chosen * {
+    color: var(--lx-gray-12, var(--rx-gray-12)) !important;
+  }
+
+  /* Exception: Preserve gray-11 for quoted text in "New option" */
+  .menu-link-wrap .menu-link .text-gray-11 {
+    color: var(--lx-gray-11, var(--rx-gray-11)) !important;
+  }
+}
+
+/* Temporary: Apply unified styles to editor popover dropdowns even without ui__combobox class */
+/* This ensures styling works during migration/if class isn't applied yet */
+.ui__popover-content[data-editor-popup-ref="commands"] #ui__ac.cp__commands-slash,
+.ui__popover-content[data-editor-popup-ref="page-search"] #ui__ac.black,
+.ui__popover-content[data-editor-popup-ref="page-search-hashtag"] #ui__ac.black,
+.ui__popover-content[data-editor-popup-ref="block-search"] #ui__ac.ac-block-search {
+  #ui__ac-inner {
+    padding: 0 4px !important;
+  }
+
+  /* Match the beautiful "Add property" dropdown styling exactly */
+  .menu-link-wrap .menu-link:not(.chosen) {
+    @apply opacity-80 grayscale transition-opacity transition-all duration-75 ease-in;
+    color: var(--lx-gray-12, var(--rx-gray-12)) !important;
+  }
+
+  .menu-link-wrap .menu-link.chosen {
+    @apply !opacity-100 grayscale-0;
+    color: var(--lx-gray-12, var(--rx-gray-12)) !important;
+    background-color: var(--lx-gray-03-alpha, var(--rx-gray-03-alpha)) !important;
+  }
+
+  /* Disable separate hover state - only .chosen state should be visually distinct */
+  .menu-link-wrap .menu-link:hover:not(.chosen) {
+    background: none !important;
+    background-color: transparent !important;
+  }
+
+  /* Force gray-12 on all nested elements in menu items */
+  .menu-link-wrap .menu-link * {
+    color: var(--lx-gray-12, var(--rx-gray-12)) !important;
+  }
+
+  /* Force gray-12 on all nested elements in selected items */
+  .menu-link-wrap .menu-link.chosen * {
+    color: var(--lx-gray-12, var(--rx-gray-12)) !important;
+  }
+
+  /* Exception: Preserve gray-11 for quoted text in "New option" */
+  .menu-link-wrap .menu-link .text-gray-11 {
+    color: var(--lx-gray-11, var(--rx-gray-11)) !important;
+  }
+}
+
 .ui__notifications {
   @apply fixed top-12 pointer-events-none w-full;