🎉 Add search bar to prototype interaction destination dropdown (#9769)

*  Add search bar to prototype interaction destination dropdown

On pages with many boards the destination dropdown becomes hard to
navigate. Add an optional `searchable?` flag to the shared select
component that renders a case-insensitive filter input at the top of
the dropdown, and opt it in for the interaction destination select.

- Filtering reuses the already-computed option list (no extra queries).
- Arrow-key navigation tracks the filtered list.
- Search clears automatically when the dropdown closes, so reopening
  starts with the full list.
- New `labels.no-matches` i18n key renders when nothing matches.

Closes #8618

Signed-off-by: moorsecopers99 <patellscott18@gmail.com>

*  Use trigger input as search field in destination dropdown

Per review on #9006, the searchable select now uses the visible trigger
input as the filter field itself rather than an extra sticky input
inside the dropdown. The trigger behaves like a filterable select:
typing filters the options without permitting free-text values.

Signed-off-by: moorsecopers99 <vadanamihai409@gmail.com>

* ♻️ Update css on old select component

---------

Signed-off-by: moorsecopers99 <patellscott18@gmail.com>
Signed-off-by: moorsecopers99 <vadanamihai409@gmail.com>
Co-authored-by: moorsecopers99 <patellscott18@gmail.com>
Co-authored-by: moorsecopers99 <vadanamihai409@gmail.com>
Co-authored-by: Alejandro Alonso <alejandro.alonso@kaleidos.net>
This commit is contained in:
Eva Marco 2026-05-21 13:21:16 +02:00 committed by GitHub
parent 5c503591b4
commit 05fa8af479
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 342 additions and 74 deletions

View File

@ -10,6 +10,44 @@
- Show a read-only W × H size badge below the bounding box of the current selection (by @bittoby) [Github #9205](https://github.com/penpot/penpot/issues/9205)
- Expose `variants` retrieval on `LibraryComponent` via `isVariant()` type guard in plugin API [Github #9185](https://github.com/penpot/penpot/issues/9185)
- Show alpha percentage next to library color values to distinguish colors that differ only in opacity (by @rockchris099) [Github #6328](https://github.com/penpot/penpot/issues/6328)
- Add "Clear artboard guides" option to right-click context menu for frames (by @eureka0928) [Github #6987](https://github.com/penpot/penpot/issues/6987)
- Add loader feedback while importing and exporting files [Github #9020](https://github.com/penpot/penpot/issues/9020)
- Allow duplicating color and typography styles (by @MkDev11) [Github #2912](https://github.com/penpot/penpot/issues/2912)
- Add woff2 support on user uploaded fonts (by @Nivl) [Github #8248](https://github.com/penpot/penpot/pull/8248)
- Import Tokens from linked library (by @dfelinto) [Github #8391](https://github.com/penpot/penpot/pull/8391)
- Option to download custom fonts (by @dfelinto) [Github #8320](https://github.com/penpot/penpot/issues/8320)
- Add copy as image to clipboard option to workspace context menu (by @dfelinto) [Github #8313](https://github.com/penpot/penpot/pull/8313)
- Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8474)
- Copy and paste entire rows in existing table (by @bittoby) [Github #8498](https://github.com/penpot/penpot/pull/8498)
- Rename token group [Taiga #13137](https://tree.taiga.io/project/penpot/us/13137)
- Duplicate token group [Taiga #10653](https://tree.taiga.io/project/penpot/us/10653)
- Copy token name from contextual menu [Taiga #13568](https://tree.taiga.io/project/penpot/issue/13568)
- Add natural sorting on token names [Taiga #13713](https://tree.taiga.io/project/penpot/issue/13713)
- Add drag-to-change for numeric inputs in workspace sidebar [Github #2466](https://github.com/penpot/penpot/issues/2466)
- Add CSS linter [Taiga #13790](https://tree.taiga.io/project/penpot/us/13790)
- Save and restore selection state in undo/redo (by @eureka0928) [Github #6007](https://github.com/penpot/penpot/issues/6007)
- Fix warnings for unsupported token $type (by @Dexterity104) [Github #8790](https://github.com/penpot/penpot/issues/8790)
- Add per-group add button for typographies (by @eureka0928) [Github #5275](https://github.com/penpot/penpot/issues/5275)
- Add Find & Replace for text content and layer names (by @statxc) [Github #7108](https://github.com/penpot/penpot/issues/7108)
- Use page name for multi-export ZIP/PDF downloads (by @Dexterity104) [Github #8773](https://github.com/penpot/penpot/issues/8773)
- Make links in comments clickable (by @eureka0928) [Github #1602](https://github.com/penpot/penpot/issues/1602)
- Add visibility toggle for strokes (by @eureka0928) [Github #7438](https://github.com/penpot/penpot/issues/7438)
- Sort asset library subfolders alphabetically at every nesting level (by @eureka0928) [Github #2572](https://github.com/penpot/penpot/issues/2572)
- Add Paste to replace (Cmd+Shift+V) to replace the selected shape with clipboard contents (by @eureka0928) [Github #4240](https://github.com/penpot/penpot/issues/4240)
- Differentiate incoming and outgoing interaction link colors (by @claytonlin1110) [Github #7794](https://github.com/penpot/penpot/issues/7794)
- Add guide locking and fix locked elements not selectable in viewer (by @Dexterity104) [Github #8358](https://github.com/penpot/penpot/issues/8358)
- Apply styles to selection (by @AzazelN28) [Taiga #13647](https://tree.taiga.io/project/penpot/task/13647)
- Reorder prototyping overlay options to show Position before Relative to (by @rockchris099) [Github #2910](https://github.com/penpot/penpot/issues/2910)
- Add customizable colors for ruler guides (by @Dexterity104) [Github #5199](https://github.com/penpot/penpot/issues/5199)
- Persist asset search query and section filter when switching sidebar tabs (by @eureka0928) [Github #2913](https://github.com/penpot/penpot/issues/2913)
- Add delete and duplicate buttons to typography dialog (by @eureka0928) [Github #5270](https://github.com/penpot/penpot/issues/5270)
- Edit ruler guide position by double-clicking the guide pill (by @eureka0928) [Github #2311](https://github.com/penpot/penpot/issues/2311)
- Add a search bar to filter colors in the color palette toolbar (by @eureka0928) [Github #7653](https://github.com/penpot/penpot/issues/7653)
- Allow customising the OIDC login button label (by @wdeveloper16) [Github #7027](https://github.com/penpot/penpot/issues/7027)
- Add page separators in Workspace [Taiga #13611](https://tree.taiga.io/project/penpot/us/13611?milestone=262806)
- Add Shift+Numpad0/1/2 as aliases to Shift+0/1/2 for zoom shortcuts [Github #2457](https://github.com/penpot/penpot/issues/2457)
- Add search bar to prototype interaction destination dropdown (by @moorsecopers99) [Github #8618](https://github.com/penpot/penpot/issues/8618)
### :bug: Bugs fixed

View File

@ -13,7 +13,9 @@
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.keyboard :as kbd]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- as-key-value
@ -47,7 +49,7 @@
(:value (nth options (rotate-index-backward index length))))
(mf/defc select
[{:keys [default-value options class dropdown-class is-open? on-change on-pointer-enter-option on-pointer-leave-option disabled data-direction]}]
[{:keys [default-value options class dropdown-class is-open? on-change on-pointer-enter-option on-pointer-leave-option disabled data-direction searchable? search-placeholder]}]
(let [label-index (mf/with-memo [options]
(into {} (map as-key-value) options))
@ -63,6 +65,25 @@
is-open? (get state :is-open?)
;; nil = the user is not actively searching (input shows the current
;; selection's label); a string = the user has typed (input shows that
;; string and options are filtered).
search* (mf/use-state nil)
search (deref search*)
searching? (and searchable? (some? search))
search-input-ref (mf/use-ref nil)
visible-options
(mf/with-memo [options search searchable?]
(if (and searchable? (some? search) (not (str/blank? search)))
(let [needle (str/lower search)]
(into [] (filter (fn [item]
(and (map? item)
(when-let [label (:label item)]
(str/includes? (str/lower (dm/str label)) needle)))))
options))
options))
node-ref (mf/use-ref nil)
dropdown-direction*
@ -76,10 +97,10 @@
handle-key-up
(mf/use-fn
(mf/deps disabled options current-value)
(mf/deps disabled visible-options current-value searchable?)
(fn [e]
(when-not disabled
(let [options (into [] (remove :disabled) options)
(let [options (into [] (remove :disabled) visible-options)
length (count options)
index (d/index-of-pred options #(= (:value %) current-value))
index (d/nilv index 0)]
@ -87,20 +108,26 @@
(cond
(or (kbd/left-arrow? e)
(kbd/up-arrow? e))
(let [value (rotate-option-backward options index length)]
(swap! state* assoc :current-value value)
(when (fn? on-change)
(on-change value)))
(when (pos? length)
(let [value (rotate-option-backward options index length)]
(swap! state* assoc :current-value value)
(when (fn? on-change)
(on-change value))))
(or (kbd/right-arrow? e)
(kbd/down-arrow? e))
(let [value (rotate-option-forward options index)]
(swap! state* assoc :current-value value)
(when (fn? on-change)
(on-change value)))
(when (pos? length)
(let [value (rotate-option-forward options index)]
(swap! state* assoc :current-value value)
(when (fn? on-change)
(on-change value))))
(or (kbd/enter? e)
(kbd/space? e))
(kbd/enter? e)
(swap! state* assoc :is-open? false)
;; In searchable mode the input owns Space — let the user
;; type spaces in the filter without closing the dropdown.
(and (not searchable?) (kbd/space? e))
(swap! state* assoc :is-open? false)
(kbd/tab? e)
@ -108,13 +135,32 @@
:is-open? true
:current-value (-> options first :value)))))))
open-dropdown
handle-search-change
(mf/use-fn
(fn [e]
(let [v (dom/get-target-val e)]
(reset! search* v)
(swap! state* assoc :is-open? true))))
handle-search-focus
(mf/use-fn
(mf/deps disabled)
(fn []
(fn [_]
(when-not disabled
(swap! state* assoc :is-open? true))))
open-dropdown
(mf/use-fn
(mf/deps disabled searchable?)
(fn []
(when-not disabled
(swap! state* assoc :is-open? true)
;; In searchable mode the input is the focus target — pull focus
;; in case the click landed on the wrapper or the dropdown arrow.
(when searchable?
(when-let [el (mf/ref-val search-input-ref)]
(dom/focus! el))))))
close-dropdown
(mf/use-fn #(swap! state* assoc :is-open? false))
@ -158,6 +204,10 @@
(reset! dropdown-direction* "down")
(mf/set-ref-val! dropdown-direction-change* 0)))
(mf/with-effect [is-open?]
(when (and searchable? (not is-open?))
(reset! search* nil)))
(mf/with-effect [is-open?]
(let [dropdown-element (mf/ref-val node-ref)]
(when (and (= 0 (mf/ref-val dropdown-direction-change*)) dropdown-element)
@ -167,25 +217,44 @@
(let [selected-option (first (filter #(= (:value %) default-value) options))
current-icon (:icon selected-option)
current-icon-ref (deprecated-icon/key->icon current-icon)]
current-icon-ref (deprecated-icon/key->icon current-icon)
input-value (if searching? search (or current-label ""))]
[:div {:id (dm/str current-id)
:on-click open-dropdown
:on-key-up handle-key-up
:tab-index "0"
:tab-index (if searchable? "-1" "0")
:role "combobox"
:class (dm/str (stl/css-case :custom-select true
:searchable-select searchable?
:disabled disabled
:icon (some? current-icon-ref))
" " class)}
(when (and current-icon current-icon-ref)
[:span {:class (stl/css :current-icon)} current-icon-ref])
[:span {:class (stl/css :current-label)} current-label]
(if searchable?
[:input {:ref search-input-ref
:type "text"
:class (stl/css :current-label-input)
:value input-value
:placeholder (or search-placeholder current-label)
:disabled disabled
:role "searchbox"
:aria-autocomplete "list"
:aria-label (or search-placeholder current-label)
:on-focus handle-search-focus
:on-click handle-search-focus
:on-change handle-search-change}]
[:span {:class (stl/css :current-label)} current-label])
[:span {:class (stl/css :dropdown-button)} deprecated-icon/arrow]
[:& dropdown {:show is-open? :on-close close-dropdown}
[:ul {:ref node-ref
:data-direction (d/nilv data-direction dropdown-direction)
:class (dm/str dropdown-class " " (stl/css :custom-select-dropdown))}
(for [[index item] (d/enumerate options)]
(when (and searchable? searching? (not (str/blank? search)) (empty? visible-options))
[:li {:class (stl/css :custom-select-no-matches)
:role "presentation"}
(tr "labels.no-matches")])
(for [[index item] (d/enumerate visible-options)]
(if (= :separator item)
[:li {:id (dm/str current-id "-" index)
:key (dm/str current-id "-" index)

View File

@ -4,28 +4,33 @@
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as *;
@use "ds/colors.scss" as *;
@use "ds/mixins.scss" as *;
@use "ds/z-index.scss" as *;
.custom-select {
--border-color: var(--menu-background-color);
--bg-color: var(--menu-background-color);
--icon-color: var(--icon-foreground);
--text-color: var(--menu-foreground-color);
@include custom-scrollbar;
@include use-typography("body-small");
@extend %new-scrollbar;
@include deprecated.body-small-typography;
--border-color: var(--color-background-tertiary);
--bg-color: var(--color-background-tertiary);
--icon-color: var(--color-foreground-secondary);
--text-color: var(--color-foreground-primary);
position: relative;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
height: deprecated.$s-32;
width: 100%;
block-size: $sz-32;
inline-size: 100%;
margin: 0;
padding: deprecated.$s-8;
border-radius: deprecated.$br-8;
padding: var(--sp-s);
border-radius: $br-8;
background-color: var(--bg-color);
border: deprecated.$s-1 solid var(--border-color);
border: $b-1 solid var(--border-color);
color: var(--text-color);
cursor: pointer;
@ -34,106 +39,256 @@
}
&:hover {
--bg-color: var(--menu-background-color-hover);
--border-color: var(--menu-background-color);
--icon-color: var(--menu-foreground-color-hover);
--bg-color: var(--color-background-quaternary);
--border-color: var(--color-background-tertiary);
--icon-color: var(--color-foreground-primary);
}
&:focus {
--bg-color: var(--menu-background-color-focus);
--border-color: var(--menu-background-focus);
&:has(*:focus-visible) {
--bg-color: var(--color-background-tertiary);
--border-color: var(--color-accent-primary);
}
}
.searchable-select {
--searchable-select-bg-color: var(--color-background-tertiary);
--searchable-select-icon-color: var(--color-foreground-secondary);
--searchable-select-outline-color: none;
background: var(--searchable-select-bg-color);
outline: $b-1 solid var(--searchable-select-outline-color);
&:hover {
--searchable-select-bg-color: var(--color-background-quaternary);
}
&:has(*:focus-visible) {
--searchable-select-bg-color: var(--color-background-primary);
--searchable-select-outline-color: var(--color-accent-primary);
}
&:has(*:disabled) {
--searchable-select-bg-color: var(--color-background-primary);
--searchable-select-outline-color: var(--color-background-quaternary);
}
&[data-option-focused="true"]:has(*:focus-visible) {
--searchable-select-bg-color: var(--color-background-tertiary);
--searchable-select-outline-color: none;
}
}
.disabled {
--bg-color: var(--menu-background-color-disabled);
--border-color: var(--menu-border-color-disabled);
--icon-color: var(--menu-foreground-color-disabled);
--text-color: var(--menu-foreground-color-disabled);
--bg-color: var(--color-background-primary);
--border-color: var(--color-background-quaternary);
--icon-color: var(--color-foreground-secondary);
--text-color: var(--color-foreground-secondary);
pointer-events: none;
cursor: default;
}
.dropdown-button {
@include deprecated.flex-center;
display: flex;
justify-content: center;
align-items: center;
margin-inline-end: var(--sp-xxs);
svg {
@extend %button-icon-small;
display: flex;
align-items: center;
gap: var(--sp-xxs);
color: transparent;
fill: none;
block-size: $sz-12;
inline-size: $sz-12;
stroke-width: 1.33px;
visibility: hidden;
stroke: var(--color-foreground-secondary);
transform: rotate(90deg);
stroke: var(--icon-color);
}
}
.current-icon {
@include deprecated.flex-center;
width: deprecated.$s-24;
padding-right: deprecated.$s-4;
display: flex;
justify-content: center;
align-items: center;
inline-size: $sz-24;
padding-inline-end: var(--sp-xs);
svg {
@extend %button-icon-small;
stroke: var(--icon-foreground);
display: flex;
align-items: center;
gap: var(--sp-xxs);
color: transparent;
fill: none;
block-size: $sz-12;
inline-size: $sz-12;
stroke-width: 1.33px;
visibility: hidden;
stroke: var(--color-foreground-secondary);
}
}
.custom-select-dropdown {
@extend %dropdown-wrapper;
box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark);
position: absolute;
top: $sz-32;
left: 0;
inline-size: 100%;
max-block-size: var(--menu-max-height, #{px2rem(300)});
padding: var(--sp-xxs);
margin: 0;
margin-block-start: px2rem(1);
border-radius: $br-8;
z-index: var(--z-index-dropdown);
overflow: hidden auto;
background-color: var(--color-background-tertiary);
color: var(--color-foreground-primary);
border: $b-2 solid var(--color-background-quaternary);
.separator {
margin: 0;
height: deprecated.$s-12;
border-block-start: deprecated.$s-1 solid var(--dropdown-separator-color);
block-size: $sz-12;
border-block-start: $b-1 solid var(--color-background-primary);
}
}
.current-label-input {
@include text-ellipsis;
inline-size: 100%;
block-size: 100%;
margin: 0;
padding: 0;
border: none;
background-color: transparent;
color: inherit;
font: inherit;
outline: none;
cursor: pointer;
&::placeholder {
color: inherit;
opacity: 1;
}
&:focus {
cursor: text;
}
&:disabled {
cursor: default;
}
}
.custom-select-no-matches {
@include use-typography("body-small");
padding: var(--sp-s) var(--sp-m);
color: var(--color-foreground-secondary);
text-align: center;
pointer-events: none;
}
.custom-select-dropdown[data-direction="up"] {
bottom: deprecated.$s-32;
bottom: $sz-32;
top: auto;
}
.checked-element {
@extend %dropdown-element-base;
@include use-typography("body-small");
.icon {
@include deprecated.flex-center;
display: flex;
align-items: center;
gap: var(--sp-s);
block-size: $sz-32;
padding: 0 var(--sp-s);
border-radius: $br-6;
cursor: pointer;
color: var(--color-foreground-primary);
height: deprecated.$s-24;
width: deprecated.$s-24;
padding-right: deprecated.$s-4;
&:hover {
background-color: var(--color-background-quaternary);
color: var(--color-foreground-primary);
svg {
@extend %button-icon;
stroke: var(--color-foreground-primary);
}
}
stroke: var(--icon-foreground);
span {
@include text-ellipsis;
display: flex;
align-items: center;
svg {
display: flex;
align-items: center;
gap: var(--sp-xxs);
color: transparent;
fill: none;
block-size: $sz-12;
inline-size: $sz-12;
stroke-width: 1.33px;
visibility: hidden;
stroke: var(--color-foreground-secondary);
}
}
.icon {
display: flex;
justify-content: center;
align-items: center;
block-size: $sz-24;
inline-size: $sz-24;
padding-inline-end: var(--sp-xs);
svg {
display: flex;
align-items: center;
gap: var(--sp-xxs);
color: transparent;
fill: none;
block-size: $sz-16;
width: $sz-16;
stroke-width: px2rem(1);
visibility: hidden;
stroke: var(--color-foreground-secondary);
}
}
.label {
flex-grow: 1;
width: 100%;
inline-size: 100%;
}
.check-icon {
@include deprecated.flex-center;
display: flex;
justify-content: center;
align-items: center;
svg {
@extend %button-icon-small;
display: flex;
align-items: center;
gap: var(--sp-xxs);
color: transparent;
fill: none;
block-size: $sz-12;
inline-size: $sz-12;
stroke-width: 1.33px;
visibility: hidden;
stroke: var(--icon-foreground);
stroke: var(--color-foreground-secondary);
}
}
&.is-selected {
color: var(--menu-foreground-color);
color: var(--color-accent-primary);
background-color: var(--color-background-quaternary);
.check-icon svg {
stroke: var(--menu-foreground-color);
stroke: var(--color-foreground-primary);
visibility: visible;
}
}
@ -144,5 +299,5 @@
}
.current-label {
@include deprecated.text-ellipsis;
@include text-ellipsis;
}

View File

@ -450,7 +450,9 @@
[:div {:class (stl/css :interaction-row-select)}
[:& select {:default-value (str (:destination interaction))
:options destination-options
:on-change change-destination}]]])
:on-change change-destination
:searchable? true
:search-placeholder (tr "workspace.options.interaction-destination")}]]])
;; Preserve scroll
(when (ctsi/has-preserve-scroll interaction)

View File

@ -2922,6 +2922,10 @@ msgstr "No pending invitations."
msgid "labels.no-invitations-gather-people"
msgstr "Gather your people and build great things together."
#: src/app/main/ui/components/select.cljs
msgid "labels.no-matches"
msgstr "No matches"
#: src/app/main/ui/static.cljs
#, unused
msgid "labels.not-found.desc-message"