0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-01-08 07:50:43 -05:00

Merge pull request #5017 from penpot/eva-add-select-to-ds

 Add select component to the DS
This commit is contained in:
Belén Albeza 2024-09-04 15:51:10 +02:00 committed by GitHub
commit 53f580ad40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 534 additions and 4 deletions

View file

@ -9,7 +9,8 @@
[app.config :as cf]
[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.controls.select :refer [select*]]
[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]]
@ -33,6 +34,7 @@
:Input input*
:Loader loader*
:RawSvg raw-svg*
:Select select*
:Text text*
:TabSwitcher tab-switcher*
:Toast toast*

View file

@ -9,4 +9,6 @@
// TODO: create actual tokens once we have them from design
$sz-16: px2rem(16);
$sz-32: px2rem(32);
$sz-36: px2rem(36);
$sz-224: px2rem(224);
$sz-400: px2rem(400);

View file

@ -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])

View file

@ -1,7 +1,7 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as InputStories from "./input.stories";
<Meta title="Forms/Input" />
<Meta title="Controls/Input" />
# Input

View file

@ -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 *;

View file

@ -11,7 +11,7 @@ const { Input } = Components;
const { icons } = Components.meta;
export default {
title: "Forms/Input",
title: "Controls/Input",
component: Components.Input,
argTypes: {
icon: {

View file

@ -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 set-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]
(set-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 [set-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
:set-ref set-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)
set-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
[:span {: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
:set-ref set-ref}])]))

View file

@ -0,0 +1,63 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as SelectStories from "./select.stories";
<Meta title="Controls/Select" />
# Select
Select lets users choose one option from an options menu.
## Variants
**Text**: We will use this variant when there are enough space and icons don't add any useful context.
<Canvas of={SelectStories.Default} />
**Icon and text**: We will use this variant when there are enough space and icons add any useful context.
<Canvas of={SelectStories.WithIcons} />
## Technical notes
### Icons
Each option of `select*` may accept an `icon`, which must contain an [icon ID](../foundations/assets/icon.mdx).
These are available in the `app.main.ds.foundations.assets.icon` namespace.
```clj
(ns app.main.ui.foo
(:require
[app.main.ui.ds.foundations.assets.icon :as i]))
```
```clj
[:> select*
{:options [{ :label "Code"
:id "option-code"
:icon i/fill-content }
{ :label "Design"
:id "option-design"
:icon i/pentool }
{ :label "Menu"
:id "option-menu" }
]}]
```
<Canvas of={SelectStories.WithIcons} />
## Usage guidelines (design)
### Where to use
Used in a wide range of applications in the app,
to select among available text-based options,
sometimes with icons that offers additional context.
### When to use
Consider using select when you have 5 or more options to choose from.
### Interaction / Behavior
When the user clicks on the clickable area, a list of
options appears. When an option is chosen, the list is closed.

View file

@ -0,0 +1,147 @@
// 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");
position: relative;
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);
position: absolute;
right: 0;
top: $sz-36;
width: 100%;
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);
}

View file

@ -0,0 +1,65 @@
// 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
import * as React from "react";
import Components from "@target/components";
const { Select } = Components;
export default {
title: "Controls/Select",
component: Select,
argTypes: {
disabled: { control: "boolean" },
},
args: {
disabled: false,
options: [
{
label: "Code",
id: "option-code",
},
{
label: "Design",
id: "option-design",
},
{
label: "Menu",
id: "opeion-menu",
},
],
defaultSelected: "option-code",
},
parameters: {
controls: {
exclude: ["options", "defaultSelected"],
},
},
render: ({ ...args }) => <Select {...args} />,
};
export const Default = {};
export const WithIcons = {
args: {
options: [
{
label: "Code",
id: "option-code",
icon: "fill-content",
},
{
label: "Design",
id: "option-design",
icon: "pentool",
},
{
label: "Menu",
id: "option-menu",
},
],
},
};