Improve combobox component (#6424)

This commit is contained in:
luisδμ 2025-05-09 11:33:57 +02:00 committed by GitHub
parent afcff84e38
commit d277fefc87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 174 additions and 155 deletions

View file

@ -34,13 +34,6 @@
(reset! focused* id) (reset! focused* id)
(dom/scroll-into-view-if-needed! node))) (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 (def ^:private schema:combobox-option
[:and [:and
[:map {:title "option"} [:map {:title "option"}
@ -71,22 +64,25 @@
(mf/defc combobox* (mf/defc combobox*
{::mf/props :obj {::mf/props :obj
::mf/schema schema:combobox} ::mf/schema schema:combobox}
[{:keys [id options class placeholder disabled has-error default-selected on-change max-length] :rest props}] [{:keys [id options class placeholder disabled has-error default-selected max-length on-change] :rest props}]
(let [open* (mf/use-state false) (let [is-open* (mf/use-state false)
open (deref open*) is-open (deref is-open*)
;;use-memo-equal selected-value* (mf/use-state default-selected)
selected* (mf/use-state default-selected) selected-value (deref selected-value*)
selected (deref selected*)
filter-value* (mf/use-state "") filter-value* (mf/use-state "")
filter-value (deref filter-value*) filter-value (deref filter-value*)
focused* (mf/use-state nil) focused-value* (mf/use-state nil)
focused (deref focused*) focused-value (deref focused-value*)
has-focus* (mf/use-state false) combobox-ref (mf/use-ref nil)
has-focus (deref has-focus*) 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 dropdown-options
(mf/use-memo (mf/use-memo
@ -98,37 +94,7 @@
lower-filter (.toLowerCase filter-value)] lower-filter (.toLowerCase filter-value)]
(.includes lower-option lower-filter))))))) (.includes lower-option lower-filter)))))))
on-click set-option-ref
(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
(mf/use-fn (mf/use-fn
(fn [node id] (fn [node id]
(let [refs (or (mf/ref-val options-nodes-refs) #js {}) (let [refs (or (mf/ref-val options-nodes-refs) #js {})
@ -137,80 +103,124 @@
(obj/unset! refs id))] (obj/unset! refs id))]
(mf/set-ref-val! options-nodes-refs refs)))) (mf/set-ref-val! options-nodes-refs refs))))
on-blur on-option-click
(mf/use-fn (mf/use-fn
(mf/deps on-change)
(fn [event] (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) (let [target (.-relatedTarget event)
outside? (not (.contains (mf/ref-val combobox-ref) target))] outside? (not (.contains (mf/ref-val combobox-ref) target))]
(when outside? (when outside?
(reset! focused* nil) (reset! is-open* false)
(reset! open* false) (reset! focused-value* nil)
(reset! has-focus* false))))) (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/use-fn
(mf/deps open focused disabled dropdown-options) (mf/deps disabled)
(fn [event] (fn [event]
(dom/stop-propagation event)
(when-not disabled (when-not disabled
(let [options dropdown-options (when-not (deref is-open*)
focused (deref focused*) (reset! filter-value* ""))
len (alength options) (reset! is-open* true))))
index (array/find-index #(= (deref focused*) (obj/get % "id")) options)]
(dom/stop-propagation event) 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) (when (< len 0)
(reset! index len)) (reset! index len))
(cond (if is-open
(and (not open) (kbd/down-arrow? event))
(reset! open* true)
open
(cond (cond
(kbd/home? event) (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) (kbd/up-arrow? event)
(let [new-index (if (= index -1) (let [new-index (if (= index -1)
(dec len) (dec len)
(mod (- index 1) 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) (kbd/down-arrow? event)
(let [new-index (if (= index -1) (let [new-index (if (= index -1)
0 0
(mod (+ index 1) 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/enter? event) (kbd/enter? event)
(when (deref open*) (do
(dom/prevent-default event) #_(handle-selection focused-value* selected-value* is-open*)
(handle-selection focused* selected* 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) (when (and (fn? on-change)
(some? focused)) (some? focused-value))
(on-change focused))) (on-change focused-value)))
(kbd/esc? event) (kbd/esc? event)
(do (reset! open* false) (do (reset! is-open* false)
(reset! focused* nil)))))))) (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 on-input-change
(mf/use-fn (mf/use-fn
(fn [event] (fn [event]
(let [value (-> event dom/get-target dom/get-value)] (dom/stop-propagation event)
(reset! selected* value) (let [value (-> event
dom/get-target
dom/get-value)]
(reset! selected-value* value)
(reset! filter-value* value) (reset! filter-value* value)
(reset! focused* nil) (reset! focused-value* nil))))
(when (fn? on-change)
(on-change value)))))
on-focus
(mf/use-fn
(fn [_] (reset! has-focus* true)))
class (dm/str class " " (stl/css :combobox)) selected-option (get-option options selected-value)
selected-option (get-option options selected)
icon (obj/get selected-option "icon")] icon (obj/get selected-option "icon")]
(mf/with-effect [options] (mf/with-effect [options]
@ -219,60 +229,62 @@
(mf/use-effect (mf/use-effect
(mf/deps default-selected) (mf/deps default-selected)
(fn [] (fn []
(reset! selected* default-selected))) (reset! selected-value* default-selected)))
[:div {:ref combobox-ref [:div {:ref combobox-ref
:class (stl/css-case :class (stl/css-case
:combobox-wrapper true :wrapper true
:focused has-focus
:has-error has-error :has-error has-error
:disabled disabled)} :disabled disabled)}
[:div {:class class [:div {:class (dm/str class " " (stl/css :combobox))
:on-click on-click :on-blur on-component-blur
:on-focus on-focus :on-click on-component-click}
:on-blur on-blur}
[:span {:class (stl/css-case :combobox-header true [:span {:class (stl/css-case :header true
:header-icon (some? icon))} :header-icon (some? icon))}
(when icon (when icon
[:> icon* {:icon-id icon [:> icon* {:icon-id icon
:size "s" :size "s"
:aria-hidden true}]) :aria-hidden true}])
[:input {:id id [:input {:id id
:ref input-ref
:type "text" :type "text"
:role "combobox" :role "combobox"
:autoComplete "off"
:aria-autocomplete "both"
:aria-expanded open
:aria-controls listbox-id
:aria-activedescendant focused
:class (stl/css :input) :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" :data-testid "combobox-input"
:maxlength (d/nilv max-length max-input-length) :max-length (d/nilv max-length max-input-length)
:disabled disabled :disabled disabled
:value selected :value selected-value
:on-change on-input-change
:placeholder placeholder :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) (when (d/not-empty? options)
[:> :button {:type "button" [:> :button {:type "button"
:tab-index "-1" :tab-index "-1"
:aria-expanded open :aria-expanded is-open
:aria-controls listbox-id :aria-controls listbox-id
:class (stl/css :button-toggle-list) :class (stl/css :button-toggle-list)
:on-click on-click} :on-click on-component-click}
[:> icon* {:icon-id i/arrow [:> icon* {:icon-id i/arrow
:class (stl/css :arrow) :class (stl/css :arrow)
:size "s" :size "s"
:aria-hidden true :aria-hidden true
:data-testid "combobox-open-button"}]])] :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* {:on-click on-option-click
:options dropdown-options :options dropdown-options
:selected selected :selected selected-value
:focused focused :focused focused-value
:set-ref set-ref :set-ref set-option-ref
:id listbox-id :id listbox-id
:data-testid "combobox-options"}])])) :data-testid "combobox-options"}])]))

View file

@ -8,7 +8,7 @@
@use "../_sizes.scss" as *; @use "../_sizes.scss" as *;
@use "../typography.scss" as *; @use "../typography.scss" as *;
.combobox-wrapper { .wrapper {
--combobox-icon-fg-color: var(--color-foreground-secondary); --combobox-icon-fg-color: var(--color-foreground-secondary);
--combobox-fg-color: var(--color-foreground-primary); --combobox-fg-color: var(--color-foreground-primary);
--combobox-bg-color: var(--color-background-tertiary); --combobox-bg-color: var(--color-background-tertiary);
@ -26,6 +26,11 @@
&:hover:not(.disabled) { &:hover:not(.disabled) {
--combobox-bg-color: var(--color-background-quaternary); --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 { .combobox {
@ -44,22 +49,22 @@
appearance: none; appearance: none;
} }
.focused {
--combobox-outline-color: var(--color-accent-primary);
--combobox-bg-color: var(--color-background-primary);
}
.arrow { .arrow {
color: var(--combobox-icon-fg-color); color: var(--combobox-icon-fg-color);
transform: rotate(90deg); transform: rotate(90deg);
} }
.combobox-header { .header {
display: grid; display: grid;
justify-items: start; justify-items: start;
gap: var(--sp-xs); gap: var(--sp-xs);
} }
.header-icon {
grid-template-columns: auto 1fr;
color: var(--combobox-icon-fg-color);
}
.input { .input {
all: unset; all: unset;
@ -77,11 +82,6 @@
} }
} }
.header-icon {
grid-template-columns: auto 1fr;
color: var(--combobox-icon-fg-color);
}
.button-toggle-list { .button-toggle-list {
all: unset; all: unset;
display: flex; display: flex;

View file

@ -112,9 +112,10 @@ export const TestInteractions = {
return options; 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 userEvent.click(button);
await waitOptionsPresent(); await waitOptionsPresent();
@ -125,7 +126,24 @@ export const TestInteractions = {
expect(combobox).toHaveAttribute("aria-expanded", "false"); 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); await userEvent.click(button);
const ariaControls = combobox.getAttribute("aria-controls"); const ariaControls = combobox.getAttribute("aria-controls");
@ -136,6 +154,9 @@ export const TestInteractions = {
}); });
await step("Navigation keys", async () => { await step("Navigation keys", async () => {
await userEvent.clear(input);
await userEvent.keyboard("{Escape}");
// Arrow down // Arrow down
await userEvent.click(input); await userEvent.click(input);
await waitOptionsPresent(); await waitOptionsPresent();
@ -176,32 +197,19 @@ export const TestInteractions = {
await userEvent.clear(input); 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 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"); await userEvent.type(input, "Ju");
const options = await canvas.findAllByTestId("dropdown-option"); const options = await canvas.findAllByTestId("dropdown-option");
expect(options).toHaveLength(2); expect(options).toHaveLength(2);
await userEvent.keyboard("{ArrowDown}"); await userEvent.keyboard("[ArrowDown]");
await userEvent.keyboard("{ArrowDown}"); await userEvent.keyboard("[ArrowDown]");
await userEvent.keyboard("{Enter}"); await userEvent.keyboard("{Enter}");
@ -209,7 +217,10 @@ export const TestInteractions = {
expect(lastValue).toBe("July"); 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 userEvent.click(button);
await waitOptionsPresent(); await waitOptionsPresent();

View file

@ -257,7 +257,7 @@
:value :value
(map (fn [val] {:label val :id val}))))) (map (fn [val] {:label val :id val})))))
change-property-value update-property-value
(mf/use-fn (mf/use-fn
(mf/deps component-ids) (mf/deps component-ids)
(fn [pos value] (fn [pos value]
@ -283,16 +283,12 @@
:data-position pos :data-position pos
:on-blur update-property-name}]] :on-blur update-property-name}]]
(let [mixed-value? (= (:value prop) false) (let [mixed-value? (= (:value prop) false)]
empty-value? (str/empty? (:value prop))]
[:> combobox* {:id (str "variant-prop-" variant-id "-" pos) [:> combobox* {:id (str "variant-prop-" variant-id "-" pos)
:placeholder (if mixed-value? (tr "settings.multiple") "") :placeholder (if mixed-value? (tr "settings.multiple") "--")
:default-selected (cond :default-selected (if mixed-value? "" (:value prop))
mixed-value? ""
empty-value? "--"
:else (:value prop))
:options (clj->js (get-options (:name 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* (mf/defc component-variant*
[{:keys [component shape data]}] [{:keys [component shape data]}]