mirror of
https://github.com/penpot/penpot.git
synced 2026-08-29 08:08:46 +00:00
* 🐛 Fix leaked deferred DOM ops on dashboard navigation and template clone The React reconciliation "removeChild" error surfaced during rapid dashboard navigation because several effects scheduled deferred DOM operations (focus, CSS positioning) without returning a cleanup that cancelled them. When the component unmounted before the callback fired, it ran against stale DOM and desynchronized React fiber tree from the actual DOM. - context_menu_a11y.cljs: replace tm/schedule-on-idle (30s idle window) with tm/schedule (setTimeout 0) and return a rx/dispose! cleanup. - dropdown.cljs: capture the tm/schedule handle and dispose it in the effect cleanup. - tooltip.cljs: capture the ts/raf handle and cancel it on cleanup. AI-assisted-by: opencode-go/mimo-v2.5-pro * 🐛 Fix leaked focus timers in dashboard sidebar navigation Six sidebar navigation handlers scheduled setTimeout callbacks to mutate tabindex/focus on React-managed title elements without cancelling prior pending callbacks. During rapid keyboard navigation (Projects→Fonts→Libs→Drafts) the stale callbacks fired against unmounted DOM, desyncing React fiber tree and triggering "removeChild" NotFoundError. - sidebar-project*: cancel prior timer in on-key-down - sidebar-search*: cancel prior timer in on-key-press - sidebar-content*: cancel prior timer in go-projects-with-key, go-fonts-with-key, go-drafts-with-key, go-libs-with-key Each handler now stores the timer handle in a component-level ref and disposes any pending handle before scheduling a new one. AI-assisted-by: opencode-go/mimo-v2.5-pro * ♻️ Refactor sidebar focus timer handling into helpers Extract the repeated dispose-before-schedule focus idiom into schedule-focus-by-id! (sidebar.cljs) and focus-and-untabbable! (app.util.dom). Replaces the six duplicated blocks and adds mf/use-effect unmount cleanup to dispose any pending timer in the three sidebar components, closing the remaining leak noted in the original fix. AI-assisted-by: opencode/hy3-free * ♻️ Extract use-focus-timer-ref hook for sidebar components Replace the duplicated mf/use-ref + mf/use-effect cleanup pairs in sidebar-project*, sidebar-search*, and sidebar-content* with a shared use-focus-timer-ref hook (app.main.ui.hooks). The hook creates the ref and disposes any pending timer on unmount via mf/with-effect, reading the ref with mf/ref-val instead of deref. mf/use-effect is now a body-level hook call rather than a let binding. AI-assisted-by: opencode/hy3-free * 📎 Add pr feedback fix
325 lines
13 KiB
Clojure
325 lines
13 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
|
|
|
(ns app.main.ui.components.context-menu-a11y
|
|
(:require-macros [app.main.style :as stl])
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.data.macros :as dm]
|
|
[app.common.schema :as sm]
|
|
[app.main.refs :as refs]
|
|
[app.main.ui.components.dropdown :refer [dropdown-content*]]
|
|
[app.main.ui.icons :as deprecated-icon]
|
|
[app.util.dom :as dom]
|
|
[app.util.globals :as ug]
|
|
[app.util.i18n :as i18n :refer [tr]]
|
|
[app.util.keyboard :as kbd]
|
|
[app.util.timers :as tm]
|
|
[beicon.v2.core :as rx]
|
|
[rumext.v2 :as mf]))
|
|
|
|
(def ^:private xf:options
|
|
(comp
|
|
(map :id)
|
|
(filter some?)))
|
|
|
|
(defn- generate-ids-group
|
|
[options has-parents?]
|
|
(let [ids (sequence xf:options options)
|
|
ids (if has-parents?
|
|
(cons "go-back-sub-option" ids)
|
|
ids)]
|
|
(vec ids)))
|
|
|
|
(def ^:private schema:option
|
|
[:schema {:registry
|
|
{::option
|
|
[:or
|
|
:nil
|
|
[:map [:name [:= :separator]]]
|
|
[:and
|
|
[:map
|
|
[:name :string]
|
|
[:id :string]
|
|
[:title {:optional true} [:maybe :string]]
|
|
[:disabled {:optional true} [:maybe :boolean]]
|
|
[:handler {:optional true} fn?]
|
|
[:options {:optional true}
|
|
[:sequential [:ref ::option]]]]
|
|
[::sm/contains-any #{:handler :options}]]]}}
|
|
[:ref ::option]])
|
|
|
|
(def ^:private valid-option?
|
|
(sm/lazy-validator schema:option))
|
|
|
|
(mf/defc context-menu-inner*
|
|
[{:keys [on-close options selectable selected
|
|
top left fixed min-width origin width]
|
|
:as props}]
|
|
|
|
(assert (every? valid-option? options) "expected valid options")
|
|
(assert (fn? on-close) "missing `on-close` prop")
|
|
(assert (vector? options) "missing `options` prop")
|
|
|
|
(let [width (d/nilv width "initial")
|
|
min-width (d/nilv min-width false)
|
|
left (d/nilv left 0)
|
|
top (d/nilv top 0)
|
|
|
|
route (mf/deref refs/route)
|
|
in-dashboard? (= :dashboard-projects (:name (:data route)))
|
|
|
|
state* (mf/use-state
|
|
#(-> {:offset-y 0
|
|
:offset-x 0
|
|
:levels nil}))
|
|
|
|
state (deref state*)
|
|
offset-x (get state :offset-x)
|
|
offset-y (get state :offset-y)
|
|
levels (get state :levels)
|
|
internal-id (mf/use-id)
|
|
|
|
on-local-close
|
|
(mf/use-fn
|
|
(mf/deps on-close)
|
|
(fn []
|
|
(swap! state* assoc :levels [{:parent nil :options options}])
|
|
(when (fn? on-close)
|
|
(on-close))))
|
|
|
|
props
|
|
(mf/spread-props props {:on-close on-local-close})
|
|
|
|
ids
|
|
(mf/with-memo [levels]
|
|
(let [last-level (last levels)]
|
|
(generate-ids-group (:options last-level)
|
|
(:parent last-level))))
|
|
|
|
check-menu-offscreen
|
|
(mf/use-fn
|
|
(mf/deps top left offset-x offset-y)
|
|
(fn [node]
|
|
(when (some? node)
|
|
(let [bounding-rect (dom/get-bounding-rect node)
|
|
window-size (dom/get-window-size)
|
|
node-height (dm/get-prop bounding-rect :height)
|
|
node-width (dm/get-prop bounding-rect :width)
|
|
window-height (get window-size :height)
|
|
window-width (get window-size :width)
|
|
|
|
target-offset-y (if (> (+ top node-height) window-height)
|
|
(- node-height)
|
|
0)
|
|
target-offset-x (if (> (+ left node-width) window-width)
|
|
(- node-width)
|
|
0)]
|
|
|
|
(when (or (not= target-offset-y offset-y)
|
|
(not= target-offset-x offset-x))
|
|
(swap! state* assoc
|
|
:offset-y target-offset-y
|
|
:offset-x target-offset-x))))))
|
|
|
|
;; NOTE: this function is used for build navigation callbacks
|
|
;; so we don't really need to use the use-fn here. It is not
|
|
;; an efficient approach but this manages a reasonable small
|
|
;; list of objects, so doing it this way has no real
|
|
;; implications on performance but facilitates a lot the
|
|
;; implementation
|
|
enter-submenu
|
|
(fn [name options]
|
|
(fn [event]
|
|
(dom/stop-propagation event)
|
|
(swap! state* update :levels conj {:parent name
|
|
:options options})))
|
|
on-submenu-exit
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(dom/stop-propagation event)
|
|
(swap! state* update :levels pop)))
|
|
|
|
;; NOTE: this function is used for build navigation callbacks
|
|
;; so we don't really need to use the use-fn here. It is not
|
|
;; an efficient approach but this manages a reasonable small
|
|
;; list of objects, so doing it this way has no real
|
|
;; implications on performance but facilitates a lot the
|
|
;; implementation
|
|
on-key-down
|
|
(fn [options-original parent-original]
|
|
(fn [event]
|
|
(let [ids (generate-ids-group options-original
|
|
parent-original)
|
|
first-id (dom/get-element (first ids))
|
|
first-element (dom/get-element first-id)
|
|
len (count ids)
|
|
|
|
parent (dom/get-target event)
|
|
parent-id (dom/get-attribute parent "id")
|
|
|
|
option (d/seek #(= parent-id (:id %)) options-original)
|
|
sub-options (not-empty (:options option))
|
|
handler (:handler option)
|
|
is-back-option? (= "go-back-sub-option" parent-id)]
|
|
|
|
(when (kbd/home? event)
|
|
(when first-element
|
|
(dom/focus! first-element)))
|
|
|
|
(when (kbd/enter? event)
|
|
(if is-back-option?
|
|
(on-submenu-exit event)
|
|
|
|
(if sub-options
|
|
(do
|
|
(dom/stop-propagation event)
|
|
(swap! state* update :levels conj {:parent (:name option)
|
|
:options sub-options}))
|
|
|
|
(do
|
|
(dom/stop-propagation event)
|
|
(handler event)))))
|
|
|
|
(when (and is-back-option? (kbd/left-arrow? event))
|
|
(on-submenu-exit event))
|
|
|
|
(when (and sub-options (kbd/right-arrow? event))
|
|
(dom/stop-propagation event)
|
|
(swap! state* update :levels conj {:parent (:name option)
|
|
:options sub-options}))
|
|
|
|
(when (kbd/up-arrow? event)
|
|
(let [actual-selected (dom/get-active)
|
|
actual-id (dom/get-attribute actual-selected "id")
|
|
actual-index (d/index-of ids actual-id)
|
|
previous-id (if (= 0 actual-index)
|
|
(last ids)
|
|
(nth ids (- actual-index 1)))]
|
|
(dom/focus! (dom/get-element previous-id))))
|
|
|
|
(when (kbd/down-arrow? event)
|
|
(let [actual-selected (dom/get-active)
|
|
actual-id (dom/get-attribute actual-selected "id")
|
|
actual-index (d/index-of ids actual-id)
|
|
next-id (if (= (- len 1) actual-index)
|
|
(first ids)
|
|
(nth ids (+ 1 actual-index)))]
|
|
(dom/focus! (dom/get-element next-id))))
|
|
|
|
(when (or (kbd/esc? event) (kbd/tab? event))
|
|
(on-close event)
|
|
(dom/focus! (dom/get-element origin))))))]
|
|
|
|
(mf/with-effect [options]
|
|
(swap! state* assoc :levels [{:parent nil
|
|
:options options}]))
|
|
|
|
(mf/with-effect [internal-id]
|
|
(ug/dispatch! (ug/event "penpot:context-menu:open" #js {:id internal-id})))
|
|
|
|
(mf/with-effect [internal-id on-local-close]
|
|
(letfn [(on-event [event]
|
|
(when-let [detail (unchecked-get event "detail")]
|
|
(when (not= internal-id (unchecked-get detail "id"))
|
|
(on-local-close event))))]
|
|
(ug/listen "penpot:context-menu:open" on-event)
|
|
(partial ug/unlisten "penpot:context-menu:open" on-event)))
|
|
|
|
(mf/with-effect [ids]
|
|
(let [handle (tm/schedule
|
|
(fn []
|
|
(some-> (dom/get-element (first ids))
|
|
(dom/focus!))))]
|
|
#(rx/dispose! handle)))
|
|
|
|
(when (some? levels)
|
|
[:> dropdown-content* props
|
|
(let [level (peek levels)
|
|
options (:options level)
|
|
parent (:parent level)]
|
|
|
|
[:div {:class (stl/css-case
|
|
:is-selectable selectable
|
|
:context-menu true
|
|
:is-open true
|
|
:fixed fixed)
|
|
:style {:top (+ top offset-y)
|
|
:left (+ left offset-x)}
|
|
:on-key-down (on-key-down options parent)}
|
|
|
|
[:ul {:class (stl/css-case :min-width min-width
|
|
:context-menu-items true)
|
|
:style {:width width}
|
|
:role "menu"
|
|
:ref check-menu-offscreen}
|
|
|
|
(when parent
|
|
[:*
|
|
[:li {:id "go-back-sub-option"
|
|
:class (stl/css :context-menu-item)
|
|
:role "menuitem"
|
|
:tab-index "0"
|
|
:on-key-down dom/prevent-default}
|
|
[:button {:class (stl/css :context-menu-action :submenu-back)
|
|
:data-no-close true
|
|
:on-click on-submenu-exit}
|
|
[:span {:class (stl/css :submenu-icon-back)} deprecated-icon/arrow]
|
|
parent]]
|
|
|
|
[:li {:class (stl/css :separator)}]])
|
|
|
|
(for [[index option] (d/enumerate options)]
|
|
(let [name (:name option)
|
|
id (:id option)
|
|
sub-options (:options option)
|
|
handler (:handler option)
|
|
title (:title option)
|
|
disabled (:disabled option)]
|
|
(when name
|
|
(if (= name :separator)
|
|
[:li {:key (dm/str "context-item-" index)
|
|
:class (stl/css :separator)}]
|
|
[:li {:id id
|
|
:key id
|
|
:class (stl/css-case
|
|
:is-selected (and selected (= name selected))
|
|
:selected (and selected (= id selected))
|
|
:context-menu-item true)
|
|
:tab-index "0"
|
|
:role "menuitem"
|
|
:on-key-down dom/prevent-default}
|
|
(if-not sub-options
|
|
[:a {:class (stl/css-case :context-menu-action true :context-menu-action-disabled disabled)
|
|
:title title
|
|
:on-click #(do (dom/stop-propagation %)
|
|
(when-not disabled
|
|
(on-close %)
|
|
(handler %)))
|
|
:data-testid id}
|
|
(if (and in-dashboard? (= name "Default"))
|
|
(tr "dashboard.default-team-name")
|
|
name)
|
|
|
|
(when (and selected (= id selected))
|
|
[:span {:class (stl/css :selected-icon)} deprecated-icon/tick])]
|
|
|
|
[:a {:class (stl/css :context-menu-action :submenu)
|
|
:data-no-close true
|
|
:on-click (enter-submenu name sub-options)
|
|
:data-testid id}
|
|
name
|
|
[:span {:class (stl/css :submenu-icon)} deprecated-icon/arrow]])]))))]])])))
|
|
|
|
(mf/defc context-menu*
|
|
{::mf/private true}
|
|
[{:keys [show] :as props}]
|
|
|
|
(assert (boolean? show) "expected `show` prop to be a boolean")
|
|
|
|
(when ^boolean show
|
|
[:> context-menu-inner* props]))
|