diff --git a/frontend/src/app/main/ui/ds/controls/combobox.cljs b/frontend/src/app/main/ui/ds/controls/combobox.cljs index fdfedad0a..c418df3cc 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.cljs +++ b/frontend/src/app/main/ui/ds/controls/combobox.cljs @@ -34,13 +34,6 @@ (reset! focused* id) (dom/scroll-into-view-if-needed! node))) -(defn- handle-selection - [focused* selected* open*] - (when-let [focused (deref focused*)] - (reset! selected* focused)) - (reset! open* false) - (reset! focused* nil)) - (def ^:private schema:combobox-option [:and [:map {:title "option"} @@ -71,22 +64,25 @@ (mf/defc combobox* {::mf/props :obj ::mf/schema schema:combobox} - [{:keys [id options class placeholder disabled has-error default-selected on-change max-length] :rest props}] - (let [open* (mf/use-state false) - open (deref open*) + [{:keys [id options class placeholder disabled has-error default-selected max-length on-change] :rest props}] + (let [is-open* (mf/use-state false) + is-open (deref is-open*) - ;;use-memo-equal - selected* (mf/use-state default-selected) - selected (deref selected*) + selected-value* (mf/use-state default-selected) + selected-value (deref selected-value*) - filter-value* (mf/use-state "") - filter-value (deref filter-value*) + filter-value* (mf/use-state "") + filter-value (deref filter-value*) - focused* (mf/use-state nil) - focused (deref focused*) + focused-value* (mf/use-state nil) + focused-value (deref focused-value*) - has-focus* (mf/use-state false) - has-focus (deref has-focus*) + combobox-ref (mf/use-ref nil) + input-ref (mf/use-ref nil) + options-nodes-refs (mf/use-ref nil) + options-ref (mf/use-ref nil) + listbox-id-ref (mf/use-ref (dm/str "listbox-" (swap! listbox-id-index inc))) + listbox-id (mf/ref-val listbox-id-ref) dropdown-options (mf/use-memo @@ -98,37 +94,7 @@ lower-filter (.toLowerCase filter-value)] (.includes lower-option lower-filter))))))) - on-click - (mf/use-fn - (mf/deps disabled) - (fn [event] - (dom/stop-propagation event) - (when-not disabled - (reset! has-focus* true) - (when-not (deref open*) (reset! filter-value* "")) - (if (= "INPUT" (.-tagName (.-target event))) - (reset! open* true) - (swap! open* not))))) - - on-option-click - (mf/use-fn - (mf/deps on-change) - (fn [event] - (let [node (dom/get-current-target event) - id (dom/get-data node "id")] - (reset! selected* id) - (reset! focused* nil) - (reset! open* false) - (when (fn? on-change) - (on-change id))))) - - options-nodes-refs (mf/use-ref nil) - options-ref (mf/use-ref nil) - listbox-id-ref (mf/use-ref (dm/str "listbox-" (swap! listbox-id-index inc))) - listbox-id (mf/ref-val listbox-id-ref) - combobox-ref (mf/use-ref nil) - - set-ref + set-option-ref (mf/use-fn (fn [node id] (let [refs (or (mf/ref-val options-nodes-refs) #js {}) @@ -137,80 +103,124 @@ (obj/unset! refs id))] (mf/set-ref-val! options-nodes-refs refs)))) - on-blur + on-option-click (mf/use-fn + (mf/deps on-change) (fn [event] + (dom/stop-propagation event) + (let [node (dom/get-current-target event) + id (dom/get-data node "id")] + (reset! selected-value* id) + (reset! is-open* false) + (reset! focused-value* nil) + (when (fn? on-change) + (on-change id))))) + + on-component-click + (mf/use-fn + (mf/deps disabled) + (fn [event] + (dom/stop-propagation event) + (when-not disabled + (when-not (deref is-open*) + (reset! filter-value* "")) + (swap! is-open* not)))) + + on-component-blur + (mf/use-fn + (mf/deps on-change) + (fn [event] + (dom/stop-propagation event) (let [target (.-relatedTarget event) outside? (not (.contains (mf/ref-val combobox-ref) target))] (when outside? - (reset! focused* nil) - (reset! open* false) - (reset! has-focus* false))))) + (reset! is-open* false) + (reset! focused-value* nil) + (when (fn? on-change) + (on-change (dom/get-input-value (mf/ref-val input-ref)))))))) - on-key-down + on-input-click (mf/use-fn - (mf/deps open focused disabled dropdown-options) + (mf/deps disabled) (fn [event] + (dom/stop-propagation event) (when-not disabled - (let [options dropdown-options - focused (deref focused*) - len (alength options) - index (array/find-index #(= (deref focused*) (obj/get % "id")) options)] - (dom/stop-propagation event) + (when-not (deref is-open*) + (reset! filter-value* "")) + (reset! is-open* true)))) + + on-input-focus + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (when-not disabled + (dom/select-text! (.-target event))))) + + on-input-key-down + (mf/use-fn + (mf/deps is-open focused-value disabled dropdown-options) + (fn [event] + (dom/stop-propagation event) + (when-not disabled + (let [len (alength dropdown-options) + index (array/find-index #(= (deref focused-value*) (obj/get % "id")) dropdown-options)] (when (< len 0) (reset! index len)) - (cond - (and (not open) (kbd/down-arrow? event)) - (reset! open* true) - - open + (if is-open (cond (kbd/home? event) - (handle-focus-change options focused* 0 options-nodes-refs) + (handle-focus-change dropdown-options focused-value* 0 options-nodes-refs) (kbd/up-arrow? event) (let [new-index (if (= index -1) (dec len) (mod (- index 1) len))] - (handle-focus-change options focused* new-index options-nodes-refs)) + (handle-focus-change dropdown-options focused-value* new-index options-nodes-refs)) (kbd/down-arrow? event) (let [new-index (if (= index -1) 0 (mod (+ index 1) len))] - (handle-focus-change options focused* new-index options-nodes-refs)) + (handle-focus-change dropdown-options focused-value* new-index options-nodes-refs)) (kbd/enter? event) - (when (deref open*) - (dom/prevent-default event) - (handle-selection focused* selected* open*) + (do + #_(handle-selection focused-value* selected-value* is-open*) + (reset! selected-value* focused-value) + (reset! is-open* false) + (reset! focused-value* nil) + (dom/blur! (mf/ref-val input-ref)) (when (and (fn? on-change) - (some? focused)) - (on-change focused))) + (some? focused-value)) + (on-change focused-value))) (kbd/esc? event) - (do (reset! open* false) - (reset! focused* nil)))))))) + (do (reset! is-open* false) + (reset! focused-value* nil) + (dom/blur! (mf/ref-val input-ref)))) + + (cond + (kbd/down-arrow? event) + (reset! is-open* true) + + (or (kbd/esc? event) (kbd/enter? event)) + (dom/blur! (mf/ref-val input-ref)))))))) on-input-change (mf/use-fn (fn [event] - (let [value (-> event dom/get-target dom/get-value)] - (reset! selected* value) + (dom/stop-propagation event) + (let [value (-> event + dom/get-target + dom/get-value)] + (reset! selected-value* value) (reset! filter-value* value) - (reset! focused* nil) - (when (fn? on-change) - (on-change value))))) - on-focus - (mf/use-fn - (fn [_] (reset! has-focus* true))) + (reset! focused-value* nil)))) - class (dm/str class " " (stl/css :combobox)) - - selected-option (get-option options selected) + selected-option (get-option options selected-value) icon (obj/get selected-option "icon")] (mf/with-effect [options] @@ -219,60 +229,62 @@ (mf/use-effect (mf/deps default-selected) (fn [] - (reset! selected* default-selected))) + (reset! selected-value* default-selected))) [:div {:ref combobox-ref :class (stl/css-case - :combobox-wrapper true - :focused has-focus + :wrapper true :has-error has-error :disabled disabled)} - [:div {:class class - :on-click on-click - :on-focus on-focus - :on-blur on-blur} - [:span {:class (stl/css-case :combobox-header true + [:div {:class (dm/str class " " (stl/css :combobox)) + :on-blur on-component-blur + :on-click on-component-click} + + [:span {:class (stl/css-case :header true :header-icon (some? icon))} (when icon [:> icon* {:icon-id icon :size "s" :aria-hidden true}]) [:input {:id id + :ref input-ref :type "text" :role "combobox" - :autoComplete "off" - :aria-autocomplete "both" - :aria-expanded open - :aria-controls listbox-id - :aria-activedescendant focused :class (stl/css :input) + :auto-complete "off" + :aria-autocomplete "both" + :aria-expanded is-open + :aria-controls listbox-id + :aria-activedescendant focused-value :data-testid "combobox-input" - :maxlength (d/nilv max-length max-input-length) + :max-length (d/nilv max-length max-input-length) :disabled disabled - :value selected - :on-change on-input-change + :value selected-value :placeholder placeholder - :on-key-down on-key-down}]] + :on-change on-input-change + :on-click on-input-click + :on-focus on-input-focus + :on-key-down on-input-key-down}]] (when (d/not-empty? options) [:> :button {:type "button" :tab-index "-1" - :aria-expanded open + :aria-expanded is-open :aria-controls listbox-id :class (stl/css :button-toggle-list) - :on-click on-click} + :on-click on-component-click} [:> icon* {:icon-id i/arrow :class (stl/css :arrow) :size "s" :aria-hidden true :data-testid "combobox-open-button"}]])] - (when (and open (seq dropdown-options)) + (when (and is-open (seq dropdown-options)) [:> options-dropdown* {:on-click on-option-click :options dropdown-options - :selected selected - :focused focused - :set-ref set-ref + :selected selected-value + :focused focused-value + :set-ref set-option-ref :id listbox-id :data-testid "combobox-options"}])])) diff --git a/frontend/src/app/main/ui/ds/controls/combobox.scss b/frontend/src/app/main/ui/ds/controls/combobox.scss index 1fc660cb3..384dbf96d 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.scss +++ b/frontend/src/app/main/ui/ds/controls/combobox.scss @@ -8,7 +8,7 @@ @use "../_sizes.scss" as *; @use "../typography.scss" as *; -.combobox-wrapper { +.wrapper { --combobox-icon-fg-color: var(--color-foreground-secondary); --combobox-fg-color: var(--color-foreground-primary); --combobox-bg-color: var(--color-background-tertiary); @@ -26,6 +26,11 @@ &:hover:not(.disabled) { --combobox-bg-color: var(--color-background-quaternary); } + + &:focus-within:not(.disabled) { + --combobox-outline-color: var(--color-accent-primary); + --combobox-bg-color: var(--color-background-primary); + } } .combobox { @@ -44,22 +49,22 @@ appearance: none; } -.focused { - --combobox-outline-color: var(--color-accent-primary); - --combobox-bg-color: var(--color-background-primary); -} - .arrow { color: var(--combobox-icon-fg-color); transform: rotate(90deg); } -.combobox-header { +.header { display: grid; justify-items: start; gap: var(--sp-xs); } +.header-icon { + grid-template-columns: auto 1fr; + color: var(--combobox-icon-fg-color); +} + .input { all: unset; @@ -77,11 +82,6 @@ } } -.header-icon { - grid-template-columns: auto 1fr; - color: var(--combobox-icon-fg-color); -} - .button-toggle-list { all: unset; display: flex; diff --git a/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx b/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx index 92c901305..0c57dcd3f 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx @@ -112,9 +112,10 @@ export const TestInteractions = { return options; }; - await userEvent.clear(input); + await step("Toggle dropdown when clicking on arrow", async () => { + await userEvent.clear(input); + await userEvent.keyboard("{Escape}"); - await step("Toggle dropdown on click arrow button", async () => { await userEvent.click(button); await waitOptionsPresent(); @@ -125,7 +126,24 @@ export const TestInteractions = { expect(combobox).toHaveAttribute("aria-expanded", "false"); }); - await step("Aria controls is set correctly", async () => { + await step("Open dropdown when clicking on input", async () => { + await userEvent.clear(input); + await userEvent.keyboard("{Escape}"); + + await userEvent.click(input); + + await waitOptionsPresent(); + expect(combobox).toHaveAttribute("aria-expanded", "true"); + + await userEvent.keyboard("{Escape}"); + await waitOptionNotPresent(); + expect(combobox).toHaveAttribute("aria-expanded", "false"); + }); + + await step("Aria controls set", async () => { + await userEvent.clear(input); + await userEvent.keyboard("{Escape}"); + await userEvent.click(button); const ariaControls = combobox.getAttribute("aria-controls"); @@ -136,6 +154,9 @@ export const TestInteractions = { }); await step("Navigation keys", async () => { + await userEvent.clear(input); + await userEvent.keyboard("{Escape}"); + // Arrow down await userEvent.click(input); await waitOptionsPresent(); @@ -176,32 +197,19 @@ export const TestInteractions = { await userEvent.clear(input); }); - await step("Toggle dropdown with arrow down and ESC", async () => { - userEvent.click(input); - - await waitOptionsPresent(); - - await userEvent.keyboard("{Escape}"); - expect(combobox).toHaveAttribute("aria-expanded", "false"); - await waitOptionNotPresent(); - - await userEvent.keyboard("{ArrowDown}"); - await waitOptionsPresent(); - expect(combobox).toHaveAttribute("aria-expanded", "true"); - - await userEvent.keyboard("{Escape}"); - await waitOptionNotPresent(); - expect(combobox).toHaveAttribute("aria-expanded", "false"); - }); - await step("Filter with 'Ju' and select July", async () => { + await userEvent.clear(input); + await userEvent.keyboard("{Escape}"); + + await userEvent.click(input); + await userEvent.type(input, "Ju"); const options = await canvas.findAllByTestId("dropdown-option"); expect(options).toHaveLength(2); - await userEvent.keyboard("{ArrowDown}"); - await userEvent.keyboard("{ArrowDown}"); + await userEvent.keyboard("[ArrowDown]"); + await userEvent.keyboard("[ArrowDown]"); await userEvent.keyboard("{Enter}"); @@ -209,7 +217,10 @@ export const TestInteractions = { expect(lastValue).toBe("July"); }); - await step("Close dropdown when focus out", async () => { + await step("Close dropdown when focusing out", async () => { + await userEvent.clear(input); + await userEvent.keyboard("{Escape}"); + await userEvent.click(button); await waitOptionsPresent(); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 3c04cfd73..8512a8df9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -257,7 +257,7 @@ :value (map (fn [val] {:label val :id val}))))) - change-property-value + update-property-value (mf/use-fn (mf/deps component-ids) (fn [pos value] @@ -283,16 +283,12 @@ :data-position pos :on-blur update-property-name}]] - (let [mixed-value? (= (:value prop) false) - empty-value? (str/empty? (:value prop))] + (let [mixed-value? (= (:value prop) false)] [:> combobox* {:id (str "variant-prop-" variant-id "-" pos) - :placeholder (if mixed-value? (tr "settings.multiple") "") - :default-selected (cond - mixed-value? "" - empty-value? "--" - :else (:value prop)) + :placeholder (if mixed-value? (tr "settings.multiple") "--") + :default-selected (if mixed-value? "" (:value prop)) :options (clj->js (get-options (:name prop))) - :on-change (partial change-property-value pos)}])]])])) + :on-change (partial update-property-value pos)}])]])])) (mf/defc component-variant* [{:keys [component shape data]}]