fix(properties): improve bottom row keyboard navigation and focus behavior

This commit is contained in:
Tienson Qin
2026-04-24 06:31:19 +08:00
parent b392e1d514
commit fa5f247969
6 changed files with 317 additions and 70 deletions

View File

@@ -2579,9 +2579,164 @@
:tag? true
:disable-preview? true
:disable-click? true) tag)])
[:div.text-sm.opacity-50.ml-1
[:div.text-sm.opacity-50.ml-1
(str "+" (- tags-count 2))]])))))
(defn- bottom-row-focus-elements
[^js row]
(->> (array-seq (.querySelectorAll row "[data-bottom-row-nav='true']"))
vec))
(defn- focus-bottom-row-item!
[^js row index]
(when-let [el (nth (bottom-row-focus-elements row) index nil)]
(.focus el)
true))
(defn- move-bottom-row-focus!
[^js el direction]
(when-let [^js row (.closest el ".bottom-properties-row")]
(let [items (bottom-row-focus-elements row)
current-index (or (first (keep-indexed (fn [idx item]
(when (identical? item el) idx))
items))
0)
last-index (max 0 (dec (count items)))
next-index (case direction
:prev (max 0 (dec current-index))
:next (min last-index (inc current-index))
current-index)]
(focus-bottom-row-item! row next-index))))
(defn- trigger-bottom-pill-edit!
[^js pill]
(when-let [trigger (some-> pill
(.querySelector ".bottom-property-content .jtrigger"))]
(.click trigger)
(some-> trigger .focus)
true))
(defn- current-bottom-pill
[^js el]
(some-> el (.closest ".bottom-property-pill-focusable")))
(defn- input-cursor-at-boundary?
[^js input key]
(let [start (.-selectionStart input)
end (.-selectionEnd input)
value (or (.-value input) "")]
(and (number? start)
(number? end)
(= start end)
(case key
"ArrowUp" (zero? start)
"ArrowDown" (= start (count value))
false))))
(defn- focus-block-editor-from-bottom-row!
[^js row]
(when-let [^js current-block (.closest row ".ls-block")]
(.blur row)
(when-let [block-id (some-> (dom/attr current-block "blockid") uuid)]
(let [container-id (some-> (dom/attr current-block "containerid") js/parseInt)]
(editor-handler/edit-block! {:block/uuid block-id}
:max
{:container-id container-id})))))
(defn- handle-bottom-properties-row-key-down!
[e]
(let [key (util/ekey e)
^js row (.-currentTarget e)
^js active-el (.-activeElement js/document)]
(cond
(= "ArrowUp" key)
(do
(util/stop e)
(focus-block-editor-from-bottom-row! row))
(= "ArrowDown" key)
(do
(util/stop e)
(editor-handler/move-cross-boundary-up-down :down {:exclude-property? true}))
(contains? #{"ArrowLeft" "ArrowRight"} key)
(do
(util/stop e)
(if (and active-el
(= "true" (.getAttribute active-el "data-bottom-row-nav")))
(move-bottom-row-focus! active-el (if (= key "ArrowLeft") :prev :next))
(let [items (bottom-row-focus-elements row)]
(when (seq items)
(.focus (first items))))))
:else
nil)))
(defn- handle-bottom-pill-key-down!
[e]
(let [key (util/ekey e)
^js pill (.-currentTarget e)]
(cond
(contains? #{"ArrowUp" "ArrowDown"} key)
(do
(util/stop e)
(some-> (.closest pill ".bottom-properties-row") (.focus)))
(contains? #{" " "Enter"} key)
(do
(util/stop e)
(trigger-bottom-pill-edit! pill))
:else
nil)))
(defn- bottom-property-pill-cp
[block property opts]
[:div.bottom-property-pill.bottom-property-pill-focusable
{:key (str (:db/id block) "-" (:db/id property))
:data-bottom-pill-focusable true
:data-bottom-row-nav true
:tab-index -1
:on-key-down handle-bottom-pill-key-down!}
[:div.flex.flex-row.items-center
(property-component/property-key-cp block property opts)
[:div.select-none ":"]]
[:div {:class "bottom-property-content ls-block property-value-container"
:style {:min-height 20}}
(pv/property-value block property opts)
(when (contains? #{:date :datetime} (:logseq.property/type property))
[:button.bottom-property-edit-icon.select-none
{:type "button"
:on-click (fn [e]
(util/stop e)
(when-let [trigger
(some-> (.-currentTarget e)
(.closest ".bottom-property-content")
(.querySelector ".jtrigger"))]
(.click trigger)
(some-> trigger .focus)))}
(ui/icon "edit" {:size 15})])]])
(defn- block-below-positioned-properties-cp
[block properties opts show-hidden-properties-toggle? show-add-property-button?]
[:div.positioned-properties.block-below.flex.flex-col.gap-1.text-sm.overflow-x-hidden
[:div.bottom-properties-row.flex.flex-row.gap-2.items-center.flex-wrap.overflow-x-hidden
{:data-bottom-properties-row (:block/uuid block)
:tab-index -1
:on-key-down handle-bottom-properties-row-key-down!}
(for [property properties]
(bottom-property-pill-cp block property opts))
(when show-hidden-properties-toggle?
(property-component/hidden-properties-toggle-button block {:icon-only? true
:bottom-row-nav? true
:tab-index 0}))
(when show-add-property-button?
(property-component/new-property block (assoc opts
:property-position :block-below
:bottom-row-nav? true
:icon-only? true
:tab-index 0)))]])
(rum/defc block-positioned-properties
[config block position]
(let [properties (outliner-property/get-block-positioned-properties (db/get-db) (:db/id block) position)
@@ -2603,38 +2758,48 @@
(not config/publishing?))
show-add-property-button? (and has-viewable-properties?
show-page-add-property?)]
(when (= position :block-below)
(hooks/use-effect!
(fn []
(let [block-uuid (:block/uuid block)
listener (fn [^js e]
(let [key (util/ekey e)
^js active-el (.-activeElement js/document)
edit-input-id (str "edit-block-" block-uuid)
row (when block-uuid
(.querySelector js/document
(str "[data-bottom-properties-row=\"" block-uuid "\"]")))]
(when (and row
has-viewable-properties?
block-uuid
(contains? #{"ArrowUp" "ArrowDown"} key)
(not (or (.-metaKey e)
(.-ctrlKey e)
(.-altKey e)))
active-el
(= (.-id active-el) edit-input-id)
(input-cursor-at-boundary? active-el key))
(util/stop e)
(.focus row))
(when (and row
(= key "Escape")
active-el
(.contains row active-el))
(when-let [pill (current-bottom-pill active-el)]
(js/setTimeout (fn [] (.focus pill)) 0)))))]
(.addEventListener js/document "keydown" listener)
(fn []
(.removeEventListener js/document "keydown" listener))))
[(:block/uuid block) has-viewable-properties?]))
(case position
:block-below
(when has-viewable-properties?
[:div.positioned-properties.block-below.flex.flex-col.gap-1.text-sm.overflow-x-hidden
[:div.bottom-properties-row.flex.flex-row.gap-2.items-center.flex-wrap.overflow-x-hidden
(for [property properties]
[:div.bottom-property-pill
{:key (str (:db/id block) "-" (:db/id property))}
[:div.flex.flex-row.items-center
(property-component/property-key-cp block property opts)
[:div.select-none ":"]]
[:div.bottom-property-content.ls-block.property-value-container
{:style {:min-height 20}}
(pv/property-value block property opts)
(when (contains? #{:number :date :datetime} (:logseq.property/type property))
[:button.bottom-property-edit-icon.select-none
{:type "button"
:on-click (fn [e]
(util/stop e)
(when-let [trigger
(some-> (.-currentTarget e)
(.closest ".bottom-property-content")
(.querySelector ".jtrigger"))]
(.click trigger)
(some-> trigger .focus)))}
(ui/icon "edit" {:size 14})])]])
(when show-hidden-properties-toggle?
(property-component/hidden-properties-toggle-button block {:icon-only? true}))
(when show-add-property-button?
(property-component/new-property block (assoc opts
:property-position :block-below
:icon-only? true)))]])
(block-below-positioned-properties-cp block
properties
opts
show-hidden-properties-toggle?
show-add-property-button?))
(when (seq properties)
[:div.positioned-properties.flex.flex-row.gap-1.select-none.h-6.self-start

View File

@@ -1063,14 +1063,34 @@ html.is-mac {
@apply gap-1.5 py-1;
}
.bottom-properties-row:focus {
outline: none;
box-shadow: inset 0 0 0 1px var(--ls-link-text-color);
border-radius: 9999px;
}
.bottom-property-pill {
@apply inline-flex items-center gap-1 rounded-full px-2 py-0.5 h-6 max-w-full;
background-color: var(--ls-secondary-background-color);
box-shadow: inset 0 0 0 1px var(--ls-border-color);
position: relative;
}
.bottom-property-pill-focusable:focus {
outline: none;
box-shadow: inset 0 0 0 1px var(--ls-link-text-color);
}
.bottom-property-control-btn:focus,
.bottom-property-control-btn:focus-visible,
.bottom-property-add-btn:focus,
.bottom-property-add-btn:focus-visible {
outline: none;
box-shadow: inset 0 0 0 1px var(--ls-link-text-color);
}
.bottom-property-content {
@apply flex items-center gap-1 min-w-0;
@apply flex items-center gap-1 min-w-0 relative;
}
.bottom-property-pill .property-key-inner {
@@ -1098,8 +1118,29 @@ html.is-mac {
box-shadow: inset 0 0 0 1px var(--ls-border-color);
}
.bottom-property-add-btn {
@apply justify-center w-6 h-6 p-0 rounded-full;
}
.ls-new-property .jtrigger.bottom-property-add-btn:focus,
.ls-new-property .jtrigger.bottom-property-add-btn:focus-visible {
outline: none;
box-shadow: inset 0 0 0 1px var(--ls-link-text-color);
}
.bottom-property-edit-icon {
@apply inline-flex items-center justify-center p-0 bg-transparent border-0 text-muted-foreground cursor-pointer;
@apply inline-flex items-center justify-end pr-0 py-2 px-2 border-0 cursor-pointer opacity-0 pointer-events-none;
position: absolute;
right: -10px;
top: 0;
height: 100%;
color: var(--ls-primary-text-color);
background: linear-gradient(to left, var(--ls-primary-background-color) 65%, transparent);
}
.bottom-property-pill:hover .bottom-property-edit-icon,
.bottom-property-edit-icon:focus-visible {
@apply opacity-100 pointer-events-auto;
}
.ls-block.property-value-container {

View File

@@ -447,6 +447,9 @@
[state block opts]
(when-not config/publishing?
(let [icon-only? (:icon-only? opts)
bottom-property-add-button? (= :block-below (:property-position opts))
tab-index (:tab-index opts)
bottom-row-nav? (:bottom-row-nav? opts)
add-new-property! (fn [e]
(state/pub-event! [:editor/new-property (merge opts {:block block
:target (.-target e)})]))]
@@ -454,8 +457,11 @@
(shui/button
{:variant :outline
:size :small
:class "jtrigger flex !px-2 !py-1"
:tab-index 0
:class (util/classnames
["jtrigger flex !px-2 !py-1"
{:bottom-property-add-btn bottom-property-add-button?}])
:tab-index (or tab-index 0)
:data-bottom-row-nav (when bottom-row-nav? true)
:title (t :property/add-new)
:on-click add-new-property!
:on-key-press (fn [e]
@@ -526,7 +532,7 @@
(if (:class-schema? opts)
(pv/property-value property (db/entity :logseq.property/description) opts)
(pv/property-value block' property (assoc opts :suppress-inline-edit-icon? true)))]
(when (contains? #{:number :date :datetime} type)
(when (contains? #{:date :datetime} type)
[:button.property-panel-edit-btn.select-none
{:type "button"
:on-click (fn [e]
@@ -537,7 +543,7 @@
(.querySelector ".jtrigger"))]
(.click trigger)
(some-> trigger .focus)))}
(ui/icon "edit" {:size 14})])])]))))
(ui/icon "edit" {:size 15})])])]))))
(defn- entity-ref-value?
[value]
@@ -765,13 +771,17 @@
(boolean (seq hidden-properties))))
(rum/defc hidden-properties-toggle-button
[block {:keys [icon-only?] :as _opts}]
[block {:keys [icon-only? tab-index bottom-row-nav?] :as _opts}]
(let [block-uuid (:block/uuid block)]
(when block-uuid
(shui/button
{:variant :ghost
:size :sm
:class "bottom-property-control-btn px-1 text-muted-foreground h-6 text-xs"
{:variant :outline
:size :small
:class (util/classnames
["bottom-property-control-btn"
{:bottom-property-add-btn icon-only?}])
:tab-index (or tab-index 0)
:data-bottom-row-nav (when bottom-row-nav? true)
:title (t :property/hidden-properties)
:on-click (fn [e]
(util/stop e)
@@ -803,14 +813,13 @@
(assoc state
::id (str (random-uuid))
::block block)))}
[state _target-block {:keys [sidebar-properties? tag-dialog?] :as opts}]
[state _target-block {:keys [sidebar-properties?] :as opts}]
(let [*bidirectional-properties (::bidirectional-properties state)
bidirectional-properties @*bidirectional-properties
id (::id state)
current-db (db/get-db)
db-id (:db/id (::block state))
block (db/sub-block db-id)
show-properties? (or sidebar-properties? tag-dialog?)
show-hidden-properties? (let [shown-block-ids (rum/react *show-hidden-properties-block-ids)]
(contains? shown-block-ids (:block/uuid block)))
show-empty-and-hidden-properties? (let [{:keys [mode show? ids]} (state/sub :ui/show-empty-and-hidden-properties?)]

View File

@@ -122,7 +122,23 @@
}
.property-panel-edit-btn {
@apply inline-flex items-center justify-center p-0 bg-transparent border-0 text-muted-foreground cursor-pointer;
@apply inline-flex items-center justify-center p-0 border-0 cursor-pointer opacity-0 pointer-events-none;
position: absolute;
right: 2px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
border-radius: 9999px;
color: var(--ls-primary-text-color);
background-color: var(--ls-primary-background-color);
box-shadow: inset 0 0 0 1px var(--ls-border-color);
}
.property-value-panel:has(.property-value-panel-inner:hover) .property-panel-edit-btn,
.property-value-panel:has(.property-value-panel-inner:focus-within) .property-panel-edit-btn,
.property-panel-edit-btn:focus-visible {
@apply opacity-100 pointer-events-auto;
}
.property-pair {
@@ -199,6 +215,10 @@
color: var(--ls-primary-text-color);
}
.property-value-panel {
position: relative;
}
.property-value.property-value-panel-inner > .property-value-inner {
@apply w-full min-w-0;
}

View File

@@ -2146,22 +2146,25 @@
(util/scroll-to-block sibling-block)
(state/exit-editing-and-set-selected-blocks! [sibling-block]))))
(defn- active-jtrigger?
[]
(some-> js/document.activeElement (dom/has-class? "jtrigger")))
(defn- property-value-node?
[node]
(some-> node (dom/has-class? "property-value-container")))
(defn- bottom-properties-row-node
[node]
(some-> node (.closest ".bottom-properties-row")))
(defn- focus-trigger
[_current-block sibling-block]
(when-let [trigger (first (dom/by-class sibling-block "jtrigger"))]
(state/clear-edit!)
(if (or (dom/has-class? trigger "ls-number")
(dom/has-class? trigger "ls-empty-text-property"))
(.click trigger)
(.focus trigger))))
(if-let [trigger (first (dom/by-class sibling-block "jtrigger"))]
(do
(state/clear-edit!)
(if (or (dom/has-class? trigger "ls-number")
(dom/has-class? trigger "ls-empty-text-property"))
(.click trigger)
(.focus trigger)))
(when-let [row (bottom-properties-row-node sibling-block)]
(.focus row))))
(defn move-cross-boundary-up-down
[direction move-opts]
@@ -2174,11 +2177,14 @@
:up util/get-prev-block-non-collapsed
:down util/get-next-block-non-collapsed)
current-block (util/rec-get-node input-or-active-element "ls-block")
sibling-block (f current-block {:up-down? true})
sibling-block (f current-block (cond-> {:up-down? true}
(:exclude-property? move-opts)
(assoc :exclude-property? true)))
{:block/keys [uuid title]} (state/get-edit-block)
sibling-block (or (when (property-value-node? sibling-block)
(first (dom/by-class sibling-block "ls-block")))
sibling-block)
bottom-properties-row (bottom-properties-row-node sibling-block)
property-value-container? (property-value-node? sibling-block)]
(if sibling-block
(let [sibling-block-id (dom/attr sibling-block "blockid")
@@ -2196,6 +2202,11 @@
(util/rec-get-node current-block "ls-page-title"))
(.click sibling-block)
bottom-properties-row
(do
(state/clear-edit!)
(.focus bottom-properties-row))
property-value-container?
(focus-trigger current-block sibling-block)
@@ -2209,8 +2220,13 @@
{:container-id container-id
:direction direction})))))
(case direction
:up (cursor/move-cursor-to input 0)
:down (cursor/move-cursor-to-end input)))))))
:up (if input
(cursor/move-cursor-to input 0)
(when current-block
(util/scroll-to-block current-block)
(state/exit-editing-and-set-selected-blocks! [current-block])))
:down (when input
(cursor/move-cursor-to-end input))))))))
(defn keydown-up-down-handler
[direction {:keys [_pos] :as move-opts}]
@@ -2220,9 +2236,6 @@
up? (= direction :up)
down? (= direction :down)]
(cond
(active-jtrigger?)
(move-cross-boundary-up-down direction move-opts)
(not= selected-start selected-end)
(if up?
(cursor/move-cursor-to input selected-start)
@@ -2297,14 +2310,6 @@
(and property? left? (not (cursor/start? input)))
(cursor/move-cursor-to-start input)
(and property? right? (cursor/end? input)
(or (not= (:logseq.property/type block) :default)
(seq (:property/closed-values block))))
(let [pair (util/rec-get-node input "property-pair")
jtrigger (when pair (dom/sel1 pair ".property-value-container .jtrigger"))]
(when jtrigger
(.focus jtrigger)))
(not= selected-start selected-end)
(cond
left?
@@ -2908,7 +2913,7 @@
(not (state/get-timestamp-block)))
(util/stop e)
(cond
(or (state/editing?) (active-jtrigger?))
(state/editing?)
(keydown-up-down-handler direction {})
(state/selection?)

View File

@@ -1060,11 +1060,18 @@ Similar to re-frame subscriptions"
selected-ids (set (get-selected-block-ids selected-blocks))
_ (set-state! :selection/blocks blocks)
new-ids (set (get-selection-block-ids))
removed (set/difference selected-ids new-ids)]
removed (set/difference selected-ids new-ids)
next-blocks (set (remove nil? blocks))
removed-nodes-without-blockid (->> selected-blocks
(remove nil?)
(remove #(contains? next-blocks %))
(remove #(dom/attr % "blockid")))]
(mark-dom-blocks-as-selected blocks)
(doseq [id removed]
(doseq [node (dom/sel (util/format "[blockid='%s']" id))]
(unselect-node node)))))
(unselect-node node)))
(doseq [node removed-nodes-without-blockid]
(unselect-node node))))
(defn set-selection-blocks!
([blocks]