mirror of
https://github.com/penpot/penpot.git
synced 2025-05-26 06:26:12 +02:00
✨ Improve combobox component (#6424)
This commit is contained in:
parent
afcff84e38
commit
d277fefc87
4 changed files with 174 additions and 155 deletions
|
@ -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"}])]))
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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]}]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue