diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs
index e0f1c944f..a2ecabeae 100644
--- a/frontend/src/app/main/ui/ds.cljs
+++ b/frontend/src/app/main/ui/ds.cljs
@@ -8,7 +8,7 @@
(:require
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
- [app.main.ui.ds.forms.input :refer [input*]]
+ [app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list]]
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg* raw-svg-list]]
[app.main.ui.ds.foundations.typography :refer [typography-list]]
diff --git a/frontend/src/app/main/ui/ds/_sizes.scss b/frontend/src/app/main/ui/ds/_sizes.scss
index 5752bc10c..e8d134cb0 100644
--- a/frontend/src/app/main/ui/ds/_sizes.scss
+++ b/frontend/src/app/main/ui/ds/_sizes.scss
@@ -10,3 +10,4 @@
$sz-16: px2rem(16);
$sz-32: px2rem(32);
$sz-224: px2rem(224);
+$sz-400: px2rem(400);
diff --git a/frontend/src/app/main/ui/ds/forms/input.cljs b/frontend/src/app/main/ui/ds/controls/input.cljs
similarity index 97%
rename from frontend/src/app/main/ui/ds/forms/input.cljs
rename to frontend/src/app/main/ui/ds/controls/input.cljs
index 6b97e5449..1a4422b8d 100644
--- a/frontend/src/app/main/ui/ds/forms/input.cljs
+++ b/frontend/src/app/main/ui/ds/controls/input.cljs
@@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC
-(ns app.main.ui.ds.forms.input
+(ns app.main.ui.ds.controls.input
(:require-macros
[app.common.data.macros :as dm]
[app.main.style :as stl])
diff --git a/frontend/src/app/main/ui/ds/forms/input.mdx b/frontend/src/app/main/ui/ds/controls/input.mdx
similarity index 97%
rename from frontend/src/app/main/ui/ds/forms/input.mdx
rename to frontend/src/app/main/ui/ds/controls/input.mdx
index 2d6d9946a..1ecb0e937 100644
--- a/frontend/src/app/main/ui/ds/forms/input.mdx
+++ b/frontend/src/app/main/ui/ds/controls/input.mdx
@@ -1,7 +1,7 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as InputStories from "./input.stories";
-
+
# Input
diff --git a/frontend/src/app/main/ui/ds/forms/input.scss b/frontend/src/app/main/ui/ds/controls/input.scss
similarity index 85%
rename from frontend/src/app/main/ui/ds/forms/input.scss
rename to frontend/src/app/main/ui/ds/controls/input.scss
index 027e79878..1c9d49034 100644
--- a/frontend/src/app/main/ui/ds/forms/input.scss
+++ b/frontend/src/app/main/ui/ds/controls/input.scss
@@ -1,3 +1,9 @@
+// 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
+
@use "../_borders.scss" as *;
@use "../_sizes.scss" as *;
@use "../typography.scss" as *;
diff --git a/frontend/src/app/main/ui/ds/forms/input.stories.jsx b/frontend/src/app/main/ui/ds/controls/input.stories.jsx
similarity index 100%
rename from frontend/src/app/main/ui/ds/forms/input.stories.jsx
rename to frontend/src/app/main/ui/ds/controls/input.stories.jsx
diff --git a/frontend/src/app/main/ui/ds/controls/select.cljs b/frontend/src/app/main/ui/ds/controls/select.cljs
new file mode 100644
index 000000000..59b6b35a3
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/select.cljs
@@ -0,0 +1,245 @@
+;; 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.ds.controls.select
+ (:require-macros
+ [app.common.data.macros :as dm]
+ [app.main.style :as stl])
+ (:require
+ [app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list] :as i]
+ [app.util.array :as array]
+ [app.util.dom :as dom]
+ [app.util.keyboard :as kbd]
+ [app.util.object :as obj]
+ [rumext.v2 :as mf]))
+
+(mf/defc option*
+ {::mf/props :obj
+ ::mf/private true}
+ [{:keys [id label icon aria-label on-click selected on-ref focused] :rest props}]
+ [:> :li {:value id
+ :class (stl/css-case :option true
+ :option-with-icon (some? icon)
+ :option-current focused)
+ :aria-selected selected
+
+ :ref (fn [node]
+ (on-ref node id))
+ :role "option"
+ :id id
+ :on-click on-click
+ :data-id id}
+
+ (when (some? icon)
+ [:> icon*
+ {:id icon
+ :size "s"
+ :class (stl/css :option-icon)
+ :aria-hidden (when label true)
+ :aria-label (when (not label) aria-label)}])
+
+ [:span {:class (stl/css :option-text)} label]
+ (when selected
+ [:> icon*
+ {:id i/tick
+ :size "s"
+ :class (stl/css :option-check)
+ :aria-hidden (when label true)}])])
+
+(mf/defc options-dropdown*
+ {::mf/props :obj
+ ::mf/private true}
+ [{:keys [on-ref on-click options selected focused] :rest props}]
+ (let [props (mf/spread-props props
+ {:class (stl/css :option-list)
+ :tab-index "-1"
+ :role "listbox"})]
+ [:> "ul" props
+ (for [option ^js options]
+ (let [id (obj/get option "id")
+ label (obj/get option "label")
+ aria-label (obj/get option "aria-label")
+ icon (obj/get option "icon")]
+ [:> option* {:selected (= id selected)
+ :key id
+ :id id
+ :label label
+ :icon icon
+ :aria-label aria-label
+ :on-ref on-ref
+ :focused (= id focused)
+ :on-click on-click}]))]))
+
+(def ^:private schema:select-option
+ [:and
+ [:map {:title "option"}
+ [:id :string]
+ [:icon {:optional true}
+ [:and :string [:fn #(contains? icon-list %)]]]
+ [:label {:optional true} :string]
+ [:aria-label {:optional true} :string]]
+ [:fn {:error/message "invalid data: missing required props"}
+ (fn [option]
+ (or (and (contains? option :icon)
+ (or (contains? option :label)
+ (contains? option :aria-label)))
+ (contains? option :label)))]])
+
+(def ^:private schema:select
+ [:map
+ [:disabled {:optional true} :boolean]
+ [:class {:optional true} :string]
+ [:icon {:optional true}
+ [:and :string [:fn #(contains? icon-list %)]]]
+ [:default-selected {:optional true} :string]
+ [:options [:vector {:min 1} schema:select-option]]])
+
+(defn- get-option
+ [options id]
+ (or (array/find #(= id (obj/get % "id")) options)
+ (aget options 0)))
+
+(defn- get-selected-option-id
+ [options default]
+ (let [option (get-option options default)]
+ (obj/get option "id")))
+
+(defn- handle-focus-change
+ [options focused* new-index options-nodes-refs]
+ (let [option (aget options new-index)
+ id (obj/get option "id")
+ nodes (mf/ref-val options-nodes-refs)
+ node (obj/get nodes id)]
+ (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))
+
+(mf/defc select*
+ {::mf/props :obj
+ ::mf/schema schema:select}
+ [{:keys [disabled default-selected on-change options class] :rest props}]
+ (let [open* (mf/use-state false)
+ open (deref open*)
+ on-click
+ (mf/use-fn
+ (mf/deps disabled)
+ (fn [event]
+ (dom/stop-propagation event)
+ (when-not disabled
+ (swap! open* not))))
+
+ selected* (mf/use-state #(get-selected-option-id options default-selected))
+ selected (deref selected*)
+
+ focused* (mf/use-state nil)
+ focused (deref focused*)
+
+ 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)
+
+ on-ref
+ (mf/use-fn
+ (fn [node id]
+ (let [refs (or (mf/ref-val options-nodes-refs) #js {})
+ refs (if node
+ (obj/set! refs id node)
+ (obj/unset! refs id))]
+ (mf/set-ref-val! options-nodes-refs refs))))
+
+ on-blur
+ (mf/use-fn
+ (fn [event]
+ (let [click-outside (nil? (.-relatedTarget event))]
+ (when click-outside
+ (reset! focused* nil)
+ (reset! open* false)))))
+
+ on-key-down
+ (mf/use-fn
+ (mf/deps focused disabled)
+ (fn [event]
+ (when-not disabled
+ (let [options (mf/ref-val options-ref)
+ len (alength options)
+ index (array/find-index #(= (deref focused*) (obj/get % "id")) options)]
+ (dom/stop-propagation event)
+ (cond
+ (kbd/home? event)
+ (handle-focus-change options focused* 0 options-nodes-refs)
+
+ (kbd/up-arrow? event)
+ (handle-focus-change options focused* (mod (- index 1) len) options-nodes-refs)
+
+ (kbd/down-arrow? event)
+ (handle-focus-change options focused* (mod (+ index 1) len) options-nodes-refs)
+
+ (or (kbd/space? event) (kbd/enter? event))
+ (when (deref open*)
+ (dom/prevent-default event)
+ (handle-selection focused* selected* open*))
+
+ (kbd/esc? event)
+ (do (reset! open* false)
+ (reset! focused* nil)))))))
+
+ class (dm/str class " " (stl/css :select))
+
+ props (mf/spread-props props {:class class
+ :role "combobox"
+ :aria-controls "listbox"
+ :aria-haspopup "listbox"
+ :aria-activedescendant focused
+ :aria-expanded open
+ :on-key-down on-key-down
+ :disabled disabled
+ :on-click on-click
+ :on-blur on-blur})
+
+ selected-option (get-option options selected)
+ label (obj/get selected-option "label")
+ icon (obj/get selected-option "icon")]
+
+ (mf/with-effect [options]
+ (mf/set-ref-val! options-ref options))
+
+ [:div {:class (stl/css :select-wrapper)}
+ [:> :button props
+ [:div {:class (stl/css-case :select-header true
+ :header-icon (some? icon))}
+ (when icon
+ [:> icon* {:id icon
+ :size "s"
+ :aria-hidden true}])
+ [:span {:class (stl/css :header-label)}
+ label]]
+ [:> icon* {:id i/arrow
+ :class (stl/css :arrow)
+ :size "s"
+ :aria-hidden true}]]
+ (when open
+ [:> options-dropdown* {:on-click on-option-click
+ :options options
+ :selected selected
+ :focused focused
+ :on-ref on-ref}])]))
diff --git a/frontend/src/app/main/ui/ds/controls/select.scss b/frontend/src/app/main/ui/ds/controls/select.scss
new file mode 100644
index 000000000..f7e0bf242
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/select.scss
@@ -0,0 +1,142 @@
+// 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
+
+@use "../_borders.scss" as *;
+@use "../_sizes.scss" as *;
+@use "../typography.scss" as *;
+
+.select-wrapper {
+ --select-icon-fg-color: var(--color-foreground-secondary);
+ --select-fg-color: var(--color-foreground-primary);
+ --select-bg-color: var(--color-background-tertiary);
+ --select-outline-color: none;
+ --select-border-color: none;
+ --select-dropdown-border-color: var(--color-background-quaternary);
+
+ &:hover {
+ --select-bg-color: var(--color-background-quaternary);
+ }
+
+ @include use-typography("body-small");
+ display: grid;
+ grid-template-rows: auto;
+ gap: var(--sp-xxs);
+ width: 100%;
+}
+
+.select {
+ &:focus-visible {
+ --select-outline-color: var(--color-accent-primary);
+ }
+
+ &:disabled {
+ --select-bg-color: var(--color-background-primary);
+ --select-border-color: var(--color-background-quaternary);
+ --select-fg-color: var(--color-foreground-secondary);
+ }
+
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: var(--sp-xs);
+ height: $sz-32;
+ width: 100%;
+ padding: var(--sp-s);
+ border: none;
+ border-radius: $br-8;
+ outline: $b-1 solid var(--select-outline-color);
+ border: $b-1 solid var(--select-border-color);
+ background: var(--select-bg-color);
+ color: var(--select-fg-color);
+ appearance: none;
+}
+
+.arrow {
+ color: var(--select-icon-fg-color);
+ transform: rotate(90deg);
+}
+
+.select-header {
+ display: grid;
+ justify-items: start;
+ gap: var(--sp-xs);
+}
+
+.header-label {
+ @include use-typography("body-small");
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+ min-width: 0;
+ padding-inline-start: var(--sp-xxs);
+ text-align: left;
+ color: var(--select-fg-color);
+}
+
+.header-icon {
+ grid-template-columns: auto 1fr;
+ color: var(--select-icon-fg-color);
+}
+
+.option-list {
+ --options-dropdown-bg-color: var(--color-background-tertiary);
+ background-color: var(--options-dropdown-bg-color);
+ border-radius: $br-8;
+ border: $b-1 solid var(--select-dropdown-border-color);
+ padding-block: var(--sp-xs);
+ margin-block-end: 0;
+ max-height: $sz-400;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.option {
+ --select-option-fg-color: var(--color-foreground-primary);
+ --select-option-bg-color: unset;
+
+ &:hover {
+ --select-option-bg-color: var(--color-background-quaternary);
+ }
+
+ &[aria-selected="true"] {
+ --select-option-bg-color: var(--color-background-quaternary);
+ }
+
+ display: grid;
+ align-items: center;
+ justify-items: start;
+ grid-template-columns: 1fr auto;
+ gap: var(--sp-xs);
+ width: 100%;
+ height: $sz-32;
+ padding: var(--sp-s);
+ border-radius: $br-8;
+ outline: $b-1 solid var(--select-outline-color);
+ outline-offset: -1px;
+ background-color: var(--select-option-bg-color);
+}
+
+.option-with-icon {
+ grid-template-columns: auto 1fr auto;
+}
+
+.option-text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+ min-width: 0;
+ padding-inline-start: var(--sp-xxs);
+}
+
+.option-icon {
+ color: var(--select-icon-fg-color);
+}
+
+.option-current {
+ --select-option-outline-color: var(--color-accent-primary);
+ outline: $b-1 solid var(--select-option-outline-color);
+}
diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs
index e113096cf..ba11982ea 100644
--- a/frontend/src/app/main/ui/workspace.cljs
+++ b/frontend/src/app/main/ui/workspace.cljs
@@ -81,6 +81,8 @@
(reset! palete-size size)))
node-ref (use-resize-observer on-resize)]
+
+
[:*
(when (not hide-ui?)
[:& palette {:layout layout