penpot/frontend/src/app/main/ui/components/context_menu_a11y.cljs
Andrey Antukh 0bffca2dc7 🐛 Fix unexpected exception on closing dropdown event
Caused by a incorrect call to the internal dropdown
component
2025-01-08 12:53:27 +01:00

294 lines
11 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
(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 i]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.timers :as tm]
[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]
[: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*
[{:keys [show 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 (boolean? show) "missing `show` 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)
on-local-close
(mf/use-fn
(mf/deps on-close)
(fn []
(swap! state* assoc :levels [{:parent nil
:options options}])
(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 [ids]
(tm/schedule-on-idle
#(dom/focus! (dom/get-element (first ids)))))
(when (and show (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 show
: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-let [parent (:parent level)]
[:*
[: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)} i/arrow]
parent]]
[:li {:class (stl/css :separator)}]])
(for [[index option] (d/enumerate (:options level))]
(let [name (:name option)
id (:id option)
sub-options (:options option)
handler (:handler 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 :context-menu-action)
:on-click #(do (dom/stop-propagation %)
(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)} i/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)} i/arrow]])]))))]])])))