mirror of
https://github.com/penpot/penpot.git
synced 2025-06-07 13:41:38 +02:00
♻️ Redesign form input tokens (#6294)
* ♻️ Redesign form input tokens * ♻️ Redesign form input tokens --------- Co-authored-by: Xavier Julian <xaviju@proton.me>
This commit is contained in:
parent
f8602810eb
commit
486f036a11
16 changed files with 470 additions and 190 deletions
|
@ -17,6 +17,7 @@
|
||||||
- Duplicate token sets [Taiga #10694](https://tree.taiga.io/project/penpot/issue/10694)
|
- Duplicate token sets [Taiga #10694](https://tree.taiga.io/project/penpot/issue/10694)
|
||||||
- Add set selection in create Token themes flow [Taiga #10746](https://tree.taiga.io/project/penpot/issue/10746)
|
- Add set selection in create Token themes flow [Taiga #10746](https://tree.taiga.io/project/penpot/issue/10746)
|
||||||
- Display indicator on not active sets [Taiga #10668](https://tree.taiga.io/project/penpot/issue/10668)
|
- Display indicator on not active sets [Taiga #10668](https://tree.taiga.io/project/penpot/issue/10668)
|
||||||
|
- Create `input*` wrapper component, and `label*`, `input-field*` and `hint-message*` components [Taiga #10713](https://tree.taiga.io/project/penpot/us/10713)
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,8 @@
|
||||||
[app.main.ui.ds.foundations.typography :refer [typography-list]]
|
[app.main.ui.ds.foundations.typography :refer [typography-list]]
|
||||||
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
|
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
|
||||||
[app.main.ui.ds.foundations.typography.text :refer [text*]]
|
[app.main.ui.ds.foundations.typography.text :refer [text*]]
|
||||||
[app.main.ui.ds.foundations.utilities.token.token-status :refer [token-status-icon* token-status-list]]
|
[app.main.ui.ds.foundations.utilities.token.token-status :refer [token-status-icon*
|
||||||
|
token-status-list]]
|
||||||
[app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]]
|
[app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]]
|
||||||
[app.main.ui.ds.notifications.actionable :refer [actionable*]]
|
[app.main.ui.ds.notifications.actionable :refer [actionable*]]
|
||||||
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
|
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
|
||||||
|
|
|
@ -5,50 +5,62 @@
|
||||||
;; Copyright (c) KALEIDOS INC
|
;; Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
(ns app.main.ui.ds.controls.input
|
(ns app.main.ui.ds.controls.input
|
||||||
(:require-macros
|
(:require-macros [app.main.style :as stl])
|
||||||
[app.common.data.macros :as dm]
|
|
||||||
[app.main.style :as stl])
|
|
||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
[app.main.constants :refer [max-input-length]]
|
[app.main.constants :refer [max-input-length]]
|
||||||
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list]]
|
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
|
||||||
[app.util.dom :as dom]
|
[app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
|
||||||
|
[app.main.ui.ds.controls.utilities.label :refer [label*]]
|
||||||
|
[app.main.ui.ds.foundations.assets.icon :refer [icon-list]]
|
||||||
|
[cuerdas.core :as str]
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
(def ^:private schema:input
|
(def ^:private schema:input
|
||||||
[:map
|
[:map
|
||||||
|
[:id {:optional true} :string]
|
||||||
|
[:label {:optional true} :string]
|
||||||
[:class {:optional true} :string]
|
[:class {:optional true} :string]
|
||||||
|
[:is-optional {:optional true} :boolean]
|
||||||
|
[:placeholder {:optional true} :string]
|
||||||
[:icon {:optional true}
|
[:icon {:optional true}
|
||||||
[:and :string [:fn #(contains? icon-list %)]]]
|
[:and :string [:fn #(contains? icon-list %)]]]
|
||||||
[:type {:optional true} :string]
|
[:type {:optional true} :string]
|
||||||
[:max-length {:optional true} :int]
|
[:max-length {:optional true} :int]
|
||||||
[:variant {:optional true} :string]])
|
[:variant {:optional true} [:maybe [:enum "seamless" "dense" "comfortable"]]]
|
||||||
|
[:hint-message {:optional true} [:maybe :string]]
|
||||||
|
[:hint-type {:optional true} [:maybe [:enum "hint" "error" "warning"]]]])
|
||||||
|
|
||||||
(mf/defc input*
|
(mf/defc input*
|
||||||
{::mf/props :obj
|
{::mf/props :obj
|
||||||
::mf/forward-ref true
|
::mf/forward-ref true
|
||||||
::mf/schema schema:input}
|
::mf/schema schema:input}
|
||||||
[{:keys [icon class type max-length variant] :rest props} ref]
|
[{:keys [id class label is-optional type max-length variant hint-message hint-type children] :rest props} ref]
|
||||||
(let [ref (or ref (mf/use-ref))
|
(let [id (or id (mf/use-id))
|
||||||
type (d/nilv type "text")
|
variant (d/nilv variant "dense")
|
||||||
props (mf/spread-props props
|
is-optional (d/nilv is-optional false)
|
||||||
{:class (stl/css-case
|
type (d/nilv type "text")
|
||||||
:input true
|
max-length (d/nilv max-length max-input-length)
|
||||||
:input-with-icon (some? icon))
|
has-hint (and (some? hint-message) (not (str/blank? hint-message)))
|
||||||
:ref ref
|
has-label (not (str/blank? label))
|
||||||
:maxlength (d/nilv max-length (str max-input-length))
|
ref (or ref (mf/use-ref))
|
||||||
:type type})
|
props (mf/spread-props props {:ref ref
|
||||||
|
:type type
|
||||||
|
:id id
|
||||||
|
:hint-type hint-type
|
||||||
|
:max-length max-length
|
||||||
|
:has-hint has-hint
|
||||||
|
:variant variant})]
|
||||||
|
[:div {:class (dm/str class " " (stl/css-case :input-wrapper true
|
||||||
|
:variant-dense (= variant "dense")
|
||||||
|
:variant-comfortable (= variant "comfortable")
|
||||||
|
:has-hint has-hint))}
|
||||||
|
(when has-label
|
||||||
|
[:> label* {:for id :is-optional is-optional} label])
|
||||||
|
[:> input-field* props children]
|
||||||
|
(when has-hint
|
||||||
|
[:> hint-message* {:id id
|
||||||
|
:message hint-message
|
||||||
|
:type hint-type}])]))
|
||||||
|
|
||||||
on-icon-click
|
|
||||||
(mf/use-fn
|
|
||||||
(mf/deps ref)
|
|
||||||
(fn [_event]
|
|
||||||
(let [input-node (mf/ref-val ref)]
|
|
||||||
(dom/select-node input-node)
|
|
||||||
(dom/focus! input-node))))]
|
|
||||||
|
|
||||||
[:> :span {:class (dm/str class " " (stl/css-case :container true
|
|
||||||
:input-seamless (= variant "seamless")))}
|
|
||||||
(when (some? icon)
|
|
||||||
[:> icon* {:icon-id icon :class (stl/css :icon) :on-click on-icon-click}])
|
|
||||||
[:> :input props]]))
|
|
||||||
|
|
|
@ -5,13 +5,10 @@ import * as InputStories from "./input.stories";
|
||||||
|
|
||||||
# Input
|
# Input
|
||||||
|
|
||||||
The `input*` component is a wrapper to the HTML `<input>` element with custom styling
|
The `input*` component is a wrapper composed of `label*`, `input-field*`, and `hint-message*` components, functioning as a form element that adapts its UI based on configuration, making it suitable for different areas of the interface.
|
||||||
and additional elements that adds context and, in some cases, adds extra
|
|
||||||
functionality.
|
|
||||||
|
|
||||||
<Canvas of={InputStories.Default} />
|
<Canvas of={InputStories.Default} />
|
||||||
|
|
||||||
|
|
||||||
## Technical notes
|
## Technical notes
|
||||||
|
|
||||||
### Icons
|
### Icons
|
||||||
|
@ -29,8 +26,6 @@ These are available in the `app.main.ds.foundations.assets.icon` namespace.
|
||||||
[:> input* {:icon i/effects}]
|
[:> input* {:icon i/effects}]
|
||||||
```
|
```
|
||||||
|
|
||||||
<Canvas of={InputStories.WithIcon} />
|
|
||||||
|
|
||||||
## Usage guidelines (design)
|
## Usage guidelines (design)
|
||||||
|
|
||||||
### Where to use
|
### Where to use
|
||||||
|
|
|
@ -4,98 +4,11 @@
|
||||||
//
|
//
|
||||||
// Copyright (c) KALEIDOS INC
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
@use "../_borders.scss" as *;
|
@use "../spacing.scss" as *;
|
||||||
@use "../_sizes.scss" as *;
|
|
||||||
@use "../typography.scss" as *;
|
|
||||||
|
|
||||||
.container {
|
.input-wrapper {
|
||||||
--input-bg-color: var(--color-background-tertiary);
|
display: flex;
|
||||||
--input-fg-color: var(--color-foreground-primary);
|
flex-direction: column;
|
||||||
--input-icon-color: var(--color-foreground-secondary);
|
gap: var(--sp-xs);
|
||||||
--input-outline-color: none;
|
|
||||||
|
|
||||||
display: inline-flex;
|
|
||||||
column-gap: var(--sp-xs);
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
|
|
||||||
background: var(--input-bg-color);
|
|
||||||
border-radius: $br-8;
|
|
||||||
padding: 0 var(--sp-s);
|
|
||||||
outline-offset: #{$b-1};
|
|
||||||
outline: $b-1 solid var(--input-outline-color);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
--input-bg-color: var(--color-background-quaternary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:has(*:focus-visible) {
|
|
||||||
--input-bg-color: var(--color-background-primary);
|
|
||||||
--input-outline-color: var(--color-accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:has(*:disabled) {
|
|
||||||
--input-bg-color: var(--color-background-primary);
|
|
||||||
--input-outline-color: var(--color-background-quaternary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-seamless {
|
|
||||||
--input-bg-color: none;
|
|
||||||
--input-outline-color: none;
|
|
||||||
--input-height: auto;
|
|
||||||
--input-margin: 0;
|
|
||||||
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
--input-bg-color: none;
|
|
||||||
--input-outline-color: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:has(*:focus-visible) {
|
|
||||||
--input-bg-color: none;
|
|
||||||
--input-outline-color: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
margin: var(--input-margin, unset); // remove settings from global css
|
|
||||||
padding: 0;
|
|
||||||
appearance: none;
|
|
||||||
height: var(--input-height, #{$sz-32});
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
inline-size: 100%;
|
|
||||||
|
|
||||||
@include use-typography("body-small");
|
|
||||||
color: var(--input-fg-color);
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::selection {
|
|
||||||
background: var(--color-accent-select);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
--input-fg-color: var(--color-foreground-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:is(:autofill, :autofill:hover, :autofill:focus, :autofill:active) {
|
|
||||||
-webkit-text-fill-color: var(--input-fg-color);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
caret-color: var(--input-bg-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-with-icon {
|
|
||||||
margin-inline-start: var(--sp-xxs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
color: var(--color-foreground-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,34 +14,74 @@ export default {
|
||||||
title: "Controls/Input",
|
title: "Controls/Input",
|
||||||
component: Components.Input,
|
component: Components.Input,
|
||||||
argTypes: {
|
argTypes: {
|
||||||
|
defaultValue: {
|
||||||
|
control: { type: "text" },
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
control: { type: "text" },
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
control: { type: "text" },
|
||||||
|
},
|
||||||
|
isOptional: { control: { type: "boolean" } },
|
||||||
icon: {
|
icon: {
|
||||||
options: icons,
|
options: icons,
|
||||||
control: { type: "select" },
|
control: { type: "select" },
|
||||||
},
|
},
|
||||||
value: {
|
type: {
|
||||||
|
options: ["text", "email", "password"],
|
||||||
|
control: { type: "select" },
|
||||||
|
},
|
||||||
|
hintMessage: {
|
||||||
control: { type: "text" },
|
control: { type: "text" },
|
||||||
},
|
},
|
||||||
disabled: { control: "boolean" },
|
hintType: {
|
||||||
|
options: ["hint", "error", "warning"],
|
||||||
|
control: { type: "select" },
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
options: ["dense", "comfortable"],
|
||||||
|
control: { type: "select" },
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
control: { type: "boolean" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
|
id: "input",
|
||||||
|
label: "Label",
|
||||||
|
isOptional: false,
|
||||||
|
defaultValue: "Value",
|
||||||
|
placeholder: "Placeholder",
|
||||||
|
type: "text",
|
||||||
|
icon: "search",
|
||||||
|
hintMessage: "This is a hint text to help user.",
|
||||||
|
hintType: "hint",
|
||||||
|
variant: "dense",
|
||||||
disabled: false,
|
disabled: false,
|
||||||
value: "Lorem ipsum",
|
},
|
||||||
|
parameters: {
|
||||||
|
controls: { exclude: ["id"] },
|
||||||
},
|
},
|
||||||
render: ({ ...args }) => <Input {...args} />,
|
render: ({ ...args }) => <Input {...args} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Default = {};
|
export const Default = {};
|
||||||
|
|
||||||
export const WithIcon = {
|
export const Dense = {
|
||||||
args: {
|
args: {
|
||||||
icon: "effects",
|
variant: "dense",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithPlaceholder = {
|
export const Comfortable = {
|
||||||
args: {
|
args: {
|
||||||
icon: "effects",
|
variant: "comfortable",
|
||||||
value: undefined,
|
},
|
||||||
placeholder: "Mixed",
|
};
|
||||||
|
|
||||||
|
export const Seamless = {
|
||||||
|
args: {
|
||||||
|
variant: "seamless",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
;; 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.utilities.hint-message
|
||||||
|
(:require-macros [app.main.style :as stl])
|
||||||
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
|
(def ^:private schema::hint-message
|
||||||
|
[:map
|
||||||
|
[:message :string]
|
||||||
|
[:id :string]
|
||||||
|
[:type {:optional true} [:enum "hint" "error" "warning"]]
|
||||||
|
[:class {:optional true} :string]])
|
||||||
|
|
||||||
|
(mf/defc hint-message*
|
||||||
|
{::mf/props :obj
|
||||||
|
::mf/schema schema::hint-message}
|
||||||
|
[{:keys [id class message type] :rest props}]
|
||||||
|
(let [type (d/nilv type :hint)]
|
||||||
|
[:> "div" {:class (dm/str class " " (stl/css-case
|
||||||
|
:hint-message true
|
||||||
|
:type-hint (= type "hint")
|
||||||
|
:type-warning (= type "warning")
|
||||||
|
:type-error (= type "error")))
|
||||||
|
:aria-live (when (or (= type "warning") (= type "error")) "polite")}
|
||||||
|
(when (some? message)
|
||||||
|
[:span {:class (stl/css :hint-message-text)
|
||||||
|
:id (str id "-hint")}
|
||||||
|
message])]))
|
|
@ -0,0 +1,27 @@
|
||||||
|
// 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 "../../typography.scss" as *;
|
||||||
|
@use "../../colors.scss" as *;
|
||||||
|
|
||||||
|
.hint-message {
|
||||||
|
--hint-color: var(--color-foreground-secondary);
|
||||||
|
|
||||||
|
@include use-typography("body-small");
|
||||||
|
color: var(--hint-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-hint {
|
||||||
|
--hint-color: var(--color-foreground-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-warning {
|
||||||
|
--hint-color: var(--color-accent-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-error {
|
||||||
|
--hint-color: var(--color-foreground-error);
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
;; 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.utilities.input-field
|
||||||
|
(:require-macros
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
|
[app.main.style :as stl])
|
||||||
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
|
[app.main.constants :refer [max-input-length]]
|
||||||
|
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list]]
|
||||||
|
[app.util.dom :as dom]
|
||||||
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
|
(def ^:private schema:input-field
|
||||||
|
[:map
|
||||||
|
[:class {:optional true} :string]
|
||||||
|
[:id :string]
|
||||||
|
[:icon {:optional true}
|
||||||
|
[:and :string [:fn #(contains? icon-list %)]]]
|
||||||
|
[:has-hint {:optional true} :boolean]
|
||||||
|
[:hint-type {:optional true} [:maybe [:enum "hint" "error" "warning"]]]
|
||||||
|
[:type {:optional true} :string]
|
||||||
|
[:max-length {:optional true} :int]
|
||||||
|
[:variant {:optional true} [:enum "seamless" "dense" "comfortable"]]])
|
||||||
|
|
||||||
|
(mf/defc input-field*
|
||||||
|
{::mf/props :obj
|
||||||
|
::mf/forward-ref true
|
||||||
|
::mf/schema schema:input-field}
|
||||||
|
[{:keys [id icon has-hint hint-type class type max-length variant children] :rest props} ref]
|
||||||
|
(let [input-ref (mf/use-ref)
|
||||||
|
type (d/nilv type "text")
|
||||||
|
variant (d/nilv variant "dense")
|
||||||
|
props (mf/spread-props props
|
||||||
|
{:class (stl/css-case
|
||||||
|
:input true
|
||||||
|
:input-with-icon (some? icon))
|
||||||
|
:ref (or ref input-ref)
|
||||||
|
:aria-invalid (when (and has-hint
|
||||||
|
(= hint-type "error"))
|
||||||
|
"true")
|
||||||
|
:aria-describedby (when has-hint
|
||||||
|
(str id "-hint"))
|
||||||
|
:type (d/nilv type "text")
|
||||||
|
:id id
|
||||||
|
:max-length (d/nilv max-length max-input-length)})
|
||||||
|
|
||||||
|
on-icon-click
|
||||||
|
(mf/use-fn
|
||||||
|
(mf/deps ref)
|
||||||
|
(fn [_event]
|
||||||
|
(let [input-node (mf/ref-val ref)]
|
||||||
|
(dom/select-node input-node)
|
||||||
|
(dom/focus! input-node))))]
|
||||||
|
|
||||||
|
[:div {:class (dm/str class " " (stl/css-case :input-wrapper true
|
||||||
|
:has-hint has-hint
|
||||||
|
:hint-type-hint (= hint-type "hint")
|
||||||
|
:hint-type-warning (= hint-type "warning")
|
||||||
|
:hint-type-error (= hint-type "error")
|
||||||
|
:variant-seamless (= variant "seamless")
|
||||||
|
:variant-dense (= variant "dense")
|
||||||
|
:variant-comfortable (= variant "comfortable")))}
|
||||||
|
(when (some? icon)
|
||||||
|
[:> icon* {:icon-id icon :class (stl/css :icon) :on-click on-icon-click}])
|
||||||
|
[:> "input" props]
|
||||||
|
(when (some? children)
|
||||||
|
[:div {:class (stl/css :input-actions)} children])]))
|
121
frontend/src/app/main/ui/ds/controls/utilities/input_field.scss
Normal file
121
frontend/src/app/main/ui/ds/controls/utilities/input_field.scss
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
// 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 *;
|
||||||
|
@use "../../colors.scss" as *;
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
--input-bg-color: var(--color-background-tertiary);
|
||||||
|
--input-fg-color: var(--color-foreground-primary);
|
||||||
|
--input-icon-color: var(--color-foreground-secondary);
|
||||||
|
--input-outline-color: none;
|
||||||
|
--input-height: #{$sz-32};
|
||||||
|
--input-margin: unset;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
column-gap: var(--sp-xs);
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
inline-size: 100%;
|
||||||
|
|
||||||
|
background: var(--input-bg-color);
|
||||||
|
border-radius: $br-8;
|
||||||
|
padding: 0 var(--sp-s);
|
||||||
|
outline-offset: #{$b-1};
|
||||||
|
outline: $b-1 solid var(--input-outline-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--input-bg-color: var(--color-background-quaternary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(*:focus-visible) {
|
||||||
|
--input-bg-color: var(--color-background-primary);
|
||||||
|
--input-outline-color: var(--color-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(*:disabled) {
|
||||||
|
--input-bg-color: var(--color-background-primary);
|
||||||
|
--input-outline-color: var(--color-background-quaternary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-dense {
|
||||||
|
@include use-typography("body-small");
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-comfortable {
|
||||||
|
@include use-typography("body-medium");
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-seamless {
|
||||||
|
--input-bg-color: none;
|
||||||
|
--input-outline-color: none;
|
||||||
|
--input-height: auto;
|
||||||
|
--input-margin: 0;
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--input-bg-color: none;
|
||||||
|
--input-outline-color: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(*:focus-visible) {
|
||||||
|
--input-bg-color: none;
|
||||||
|
--input-outline-color: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
margin: var(--input-margin); // remove settings from global css
|
||||||
|
padding: 0;
|
||||||
|
appearance: none;
|
||||||
|
height: var(--input-height);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
inline-size: 100%;
|
||||||
|
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
|
||||||
|
color: var(--input-fg-color);
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::selection {
|
||||||
|
background: var(--color-accent-select);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
--input-fg-color: var(--color-foreground-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:is(:autofill, :autofill:hover, :autofill:focus, :autofill:active) {
|
||||||
|
-webkit-text-fill-color: var(--input-fg-color);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
caret-color: var(--input-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-icon {
|
||||||
|
margin-inline-start: var(--sp-xxs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-type-error:has(.has-hint) {
|
||||||
|
--input-outline-color: var(--color-foreground-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--color-foreground-secondary);
|
||||||
|
}
|
31
frontend/src/app/main/ui/ds/controls/utilities/label.cljs
Normal file
31
frontend/src/app/main/ui/ds/controls/utilities/label.cljs
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
;; 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.utilities.label
|
||||||
|
(:require-macros [app.main.style :as stl])
|
||||||
|
(:require
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
|
(def ^:private schema::label
|
||||||
|
[:map
|
||||||
|
[:for :string]
|
||||||
|
[:is-optional {:optional true} :boolean]
|
||||||
|
[:class {:optional true} :string]])
|
||||||
|
|
||||||
|
(mf/defc label*
|
||||||
|
{::mf/props :obj
|
||||||
|
::mf/schema schema::label}
|
||||||
|
[{:keys [class for is-optional children] :rest props}]
|
||||||
|
(let [is-optional (or is-optional false)
|
||||||
|
props (mf/spread-props props {:class (dm/str class " " (stl/css :label))
|
||||||
|
:for for})]
|
||||||
|
[:> "label" props
|
||||||
|
[:*
|
||||||
|
(when (some? children)
|
||||||
|
[:span {:class (stl/css :label-text)} children])
|
||||||
|
(when is-optional
|
||||||
|
[:span {:class (stl/css :label-optional)} "(Optional)"])]]))
|
28
frontend/src/app/main/ui/ds/controls/utilities/label.scss
Normal file
28
frontend/src/app/main/ui/ds/controls/utilities/label.scss
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
// 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 "../../typography.scss" as *;
|
||||||
|
@use "../../colors.scss" as *;
|
||||||
|
@use "../../spacing.scss" as *;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
--label-color: var(--color-foreground-primary);
|
||||||
|
--label-optional-color: var(--color-foreground-secondary);
|
||||||
|
|
||||||
|
@include use-typography("body-small");
|
||||||
|
color: var(--label-color);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-xs);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-text {
|
||||||
|
color: var(--label-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-optional {
|
||||||
|
color: var(--label-optional-color);
|
||||||
|
}
|
|
@ -4,7 +4,7 @@
|
||||||
;;
|
;;
|
||||||
;; Copyright (c) KALEIDOS INC
|
;; Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
(ns app.main.ui.workspace.tokens.components.controls.input-tokens
|
(ns app.main.ui.workspace.tokens.components.controls.input-tokens-value
|
||||||
(:require-macros [app.main.style :as stl])
|
(:require-macros [app.main.style :as stl])
|
||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
|
@ -13,9 +13,8 @@
|
||||||
[app.main.ui.ds.controls.input :refer [input*]]
|
[app.main.ui.ds.controls.input :refer [input*]]
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
(def ^:private schema::input-tokens
|
(def ^:private schema::input-tokens-value
|
||||||
[:map
|
[:map
|
||||||
[:id :string]
|
|
||||||
[:label :string]
|
[:label :string]
|
||||||
[:placeholder {:optional true} :string]
|
[:placeholder {:optional true} :string]
|
||||||
[:default-value {:optional true} [:maybe :string]]
|
[:default-value {:optional true} [:maybe :string]]
|
||||||
|
@ -24,19 +23,20 @@
|
||||||
[:error {:optional true} :boolean]
|
[:error {:optional true} :boolean]
|
||||||
[:value {:optional true} :string]])
|
[:value {:optional true} :string]])
|
||||||
|
|
||||||
(mf/defc input-tokens*
|
(mf/defc input-tokens-value*
|
||||||
{::mf/props :obj
|
{::mf/props :obj
|
||||||
::mf/forward-ref true
|
::mf/forward-ref true
|
||||||
::mf/schema schema::input-tokens}
|
::mf/schema schema::input-tokens-value}
|
||||||
[{:keys [class label id max-length error value children] :rest props} ref]
|
[{:keys [class label max-length error value children] :rest props} ref]
|
||||||
(let [ref (or ref (mf/use-ref))
|
(let [id (mf/use-id)
|
||||||
|
input-ref (mf/use-ref)
|
||||||
props (mf/spread-props props {:id id
|
props (mf/spread-props props {:id id
|
||||||
:type "text"
|
:type "text"
|
||||||
:class (stl/css :input)
|
:class (stl/css :input)
|
||||||
:aria-invalid error
|
:aria-invalid error
|
||||||
:max-length (d/nilv max-length max-input-length)
|
:max-length (d/nilv max-length max-input-length)
|
||||||
:value value
|
:value value
|
||||||
:ref ref})]
|
:ref (or ref input-ref)})]
|
||||||
[:div {:class (dm/str class " " (stl/css-case :wrapper true
|
[:div {:class (dm/str class " " (stl/css-case :wrapper true
|
||||||
:input-error error))}
|
:input-error error))}
|
||||||
[:label {:for id :class (stl/css :label)} label]
|
[:label {:for id :class (stl/css :label)} label]
|
|
@ -34,6 +34,7 @@
|
||||||
|
|
||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
&:has(.input-swatch) {
|
&:has(.input-swatch) {
|
||||||
|
@ -51,3 +52,7 @@
|
||||||
inset-inline-start: var(--sp-s);
|
inset-inline-start: var(--sp-s);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
|
@ -12,6 +12,7 @@
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.files.tokens :as cft]
|
[app.common.files.tokens :as cft]
|
||||||
[app.common.types.tokens-lib :as ctob]
|
[app.common.types.tokens-lib :as ctob]
|
||||||
|
[app.main.constants :refer [max-input-length]]
|
||||||
[app.main.data.modal :as modal]
|
[app.main.data.modal :as modal]
|
||||||
[app.main.data.style-dictionary :as sd]
|
[app.main.data.style-dictionary :as sd]
|
||||||
[app.main.data.tinycolor :as tinycolor]
|
[app.main.data.tinycolor :as tinycolor]
|
||||||
|
@ -23,6 +24,8 @@
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||||
|
[app.main.ui.ds.controls.input :refer [input*]]
|
||||||
|
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
|
||||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||||
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
|
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
|
||||||
[app.main.ui.ds.foundations.typography.text :refer [text*]]
|
[app.main.ui.ds.foundations.typography.text :refer [text*]]
|
||||||
|
@ -30,7 +33,7 @@
|
||||||
[app.main.ui.workspace.colorpicker :as colorpicker]
|
[app.main.ui.workspace.colorpicker :as colorpicker]
|
||||||
[app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector*]]
|
[app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector*]]
|
||||||
[app.main.ui.workspace.tokens.components.controls.input-token-color-bullet :refer [input-token-color-bullet*]]
|
[app.main.ui.workspace.tokens.components.controls.input-token-color-bullet :refer [input-token-color-bullet*]]
|
||||||
[app.main.ui.workspace.tokens.components.controls.input-tokens :refer [input-tokens*]]
|
[app.main.ui.workspace.tokens.components.controls.input-tokens-value :refer [input-tokens-value*]]
|
||||||
[app.util.dom :as dom]
|
[app.util.dom :as dom]
|
||||||
[app.util.functions :as uf]
|
[app.util.functions :as uf]
|
||||||
[app.util.i18n :refer [tr]]
|
[app.util.i18n :refer [tr]]
|
||||||
|
@ -277,6 +280,7 @@
|
||||||
token-name-ref (mf/use-var (:name token))
|
token-name-ref (mf/use-var (:name token))
|
||||||
name-ref (mf/use-ref nil)
|
name-ref (mf/use-ref nil)
|
||||||
name-errors (mf/use-state nil)
|
name-errors (mf/use-state nil)
|
||||||
|
|
||||||
validate-name
|
validate-name
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps selected-set-tokens-tree)
|
(mf/deps selected-set-tokens-tree)
|
||||||
|
@ -405,20 +409,22 @@
|
||||||
|
|
||||||
;; Description
|
;; Description
|
||||||
description-ref (mf/use-var (:description token))
|
description-ref (mf/use-var (:description token))
|
||||||
description-errors (mf/use-state nil)
|
description-errors* (mf/use-state nil)
|
||||||
|
description-errors (deref description-errors*)
|
||||||
|
|
||||||
validate-descripion (mf/use-fn #(m/explain token-description-schema %))
|
validate-descripion (mf/use-fn #(m/explain token-description-schema %))
|
||||||
on-update-description-debounced (mf/use-fn
|
on-update-description-debounced (mf/use-fn
|
||||||
(uf/debounce (fn [e]
|
(uf/debounce (fn [e]
|
||||||
(let [value (dom/get-target-val e)
|
(let [value (dom/get-target-val e)
|
||||||
errors (validate-descripion value)]
|
errors (validate-descripion value)]
|
||||||
(reset! description-errors errors)))))
|
(reset! description-errors* errors)))))
|
||||||
on-update-description
|
on-update-description
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps on-update-description-debounced)
|
(mf/deps on-update-description-debounced)
|
||||||
(fn [e]
|
(fn [e]
|
||||||
(reset! description-ref (dom/get-target-val e))
|
(reset! description-ref (dom/get-target-val e))
|
||||||
(on-update-description-debounced e)))
|
(on-update-description-debounced e)))
|
||||||
valid-description-field? (not @description-errors)
|
valid-description-field? (not description-errors)
|
||||||
|
|
||||||
;; Form
|
;; Form
|
||||||
disabled? (or (not valid-name-field?)
|
disabled? (or (not valid-name-field?)
|
||||||
|
@ -523,26 +529,26 @@
|
||||||
|
|
||||||
[:div {:class (stl/css :input-row)}
|
[:div {:class (stl/css :input-row)}
|
||||||
(let [token-title (str/lower (:title token-properties))]
|
(let [token-title (str/lower (:title token-properties))]
|
||||||
[:> input-tokens*
|
[:> input* {:id "token-name"
|
||||||
{:id "token-name"
|
:label (tr "workspace.token.token-name")
|
||||||
:placeholder (tr "workspace.token.enter-token-name", token-title)
|
:placeholder (tr "workspace.token.enter-token-name", token-title)
|
||||||
:error (boolean @name-errors)
|
:max-length max-input-length
|
||||||
:auto-focus true
|
:variant "comfortable"
|
||||||
:label (tr "workspace.token.token-name")
|
:auto-focus true
|
||||||
:default-value @token-name-ref
|
:default-value @token-name-ref
|
||||||
:ref name-ref
|
:ref name-ref
|
||||||
:max-length 256
|
:on-blur on-blur-name
|
||||||
:on-blur on-blur-name
|
:on-change on-update-name}])
|
||||||
:on-change on-update-name}])
|
|
||||||
|
|
||||||
(for [error (->> (:errors @name-errors)
|
(for [error (->> (:errors @name-errors)
|
||||||
(map #(-> (assoc @name-errors :errors [%])
|
(map #(-> (assoc @name-errors :errors [%])
|
||||||
(me/humanize))))]
|
(me/humanize)))
|
||||||
[:> text* {:as "p"
|
(map first))]
|
||||||
:key error
|
|
||||||
:typography "body-small"
|
[:> hint-message* {:key error
|
||||||
:class (stl/css :error)}
|
:message error
|
||||||
error])
|
:type "error"
|
||||||
|
:id "token-name-hint"}])
|
||||||
|
|
||||||
(when (and warning-name-change? (= action "edit"))
|
(when (and warning-name-change? (= action "edit"))
|
||||||
[:div {:class (stl/css :warning-name-change-notification-wrapper)}
|
[:div {:class (stl/css :warning-name-change-notification-wrapper)}
|
||||||
|
@ -550,7 +556,7 @@
|
||||||
{:level :warning :appearance :ghost} (tr "workspace.token.warning-name-change")]])]
|
{:level :warning :appearance :ghost} (tr "workspace.token.warning-name-change")]])]
|
||||||
|
|
||||||
[:div {:class (stl/css :input-row)}
|
[:div {:class (stl/css :input-row)}
|
||||||
[:> input-tokens*
|
[:> input-tokens-value*
|
||||||
{:id "token-value"
|
{:id "token-value"
|
||||||
:placeholder (tr "workspace.token.token-value-enter")
|
:placeholder (tr "workspace.token.token-value-enter")
|
||||||
:label (tr "workspace.token.token-value")
|
:label (tr "workspace.token.token-value")
|
||||||
|
@ -567,21 +573,14 @@
|
||||||
[:> ramp* {:color (some-> color (tinycolor/valid-color))
|
[:> ramp* {:color (some-> color (tinycolor/valid-color))
|
||||||
:on-change on-update-color}])
|
:on-change on-update-color}])
|
||||||
[:& token-value-or-errors {:result-or-errors token-resolve-result}]]
|
[:& token-value-or-errors {:result-or-errors token-resolve-result}]]
|
||||||
|
|
||||||
[:div {:class (stl/css :input-row)}
|
[:div {:class (stl/css :input-row)}
|
||||||
[:> input-tokens*
|
[:> input* {:label (tr "workspace.token.token-description")
|
||||||
{:id "token-description"
|
:placeholder (tr "workspace.token.enter-token-description")
|
||||||
:placeholder (tr "workspace.token.enter-token-description")
|
:max-length max-input-length
|
||||||
:label (tr "workspace.token.token-description")
|
:variant "comfortable"
|
||||||
:max-length 256
|
:default-value @description-ref
|
||||||
:default-value @description-ref
|
:on-blur on-update-description
|
||||||
:on-blur on-update-description
|
:on-change on-update-description}]]
|
||||||
:on-change on-update-description}]
|
|
||||||
(when @description-errors
|
|
||||||
[:> text* {:as "p"
|
|
||||||
:typography "body-small"
|
|
||||||
:class (stl/css :error)}
|
|
||||||
(me/humanize @description-errors)])]
|
|
||||||
|
|
||||||
[:div {:class (stl/css-case :button-row true
|
[:div {:class (stl/css-case :button-row true
|
||||||
:with-delete (= action "edit"))}
|
:with-delete (= action "edit"))}
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.logic.tokens :as clt]
|
[app.common.logic.tokens :as clt]
|
||||||
[app.common.types.tokens-lib :as ctob]
|
[app.common.types.tokens-lib :as ctob]
|
||||||
|
[app.main.constants :refer [max-input-length]]
|
||||||
[app.main.data.event :as ev]
|
[app.main.data.event :as ev]
|
||||||
[app.main.data.modal :as modal]
|
[app.main.data.modal :as modal]
|
||||||
[app.main.data.workspace.tokens.library-edit :as dwtl]
|
[app.main.data.workspace.tokens.library-edit :as dwtl]
|
||||||
|
@ -19,11 +20,11 @@
|
||||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||||
[app.main.ui.ds.controls.combobox :refer [combobox*]]
|
[app.main.ui.ds.controls.combobox :refer [combobox*]]
|
||||||
|
[app.main.ui.ds.controls.input :refer [input*]]
|
||||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as ic]
|
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as ic]
|
||||||
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
|
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
|
||||||
[app.main.ui.ds.foundations.typography.text :refer [text*]]
|
[app.main.ui.ds.foundations.typography.text :refer [text*]]
|
||||||
[app.main.ui.icons :as i]
|
[app.main.ui.icons :as i]
|
||||||
[app.main.ui.workspace.tokens.components.controls.input-tokens :refer [input-tokens*]]
|
|
||||||
[app.main.ui.workspace.tokens.sets :as wts]
|
[app.main.ui.workspace.tokens.sets :as wts]
|
||||||
[app.util.dom :as dom]
|
[app.util.dom :as dom]
|
||||||
[app.util.i18n :refer [tr]]
|
[app.util.i18n :refer [tr]]
|
||||||
|
@ -202,14 +203,13 @@
|
||||||
:on-change on-update-group}]]
|
:on-change on-update-group}]]
|
||||||
|
|
||||||
[:div {:class (stl/css :group-input-wrapper)}
|
[:div {:class (stl/css :group-input-wrapper)}
|
||||||
[:> input-tokens* {:id "theme-input"
|
[:> input* {:label (tr "workspace.token.label.theme")
|
||||||
:label (tr "workspace.token.label.theme")
|
:placeholder (tr "workspace.token.label.theme-placeholder")
|
||||||
:type "text"
|
:max-length max-input-length
|
||||||
:max-length 256
|
:variant "comfortable"
|
||||||
:placeholder (tr "workspace.token.label.theme-placeholder")
|
:default-value (mf/ref-val theme-name-ref)
|
||||||
:on-change on-update-name
|
:auto-focus true
|
||||||
:value (mf/ref-val theme-name-ref)
|
:on-change on-update-name}]]]))
|
||||||
:auto-focus true}]]]))
|
|
||||||
|
|
||||||
(mf/defc theme-modal-buttons*
|
(mf/defc theme-modal-buttons*
|
||||||
[{:keys [close-modal on-save-form disabled?] :as props}]
|
[{:keys [close-modal on-save-form disabled?] :as props}]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue