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