mirror of
https://github.com/penpot/penpot.git
synced 2025-02-12 18:18:24 -05:00
Merge pull request #4941 from penpot/eva-tabs-component-ds
✨ Add tabs component to DS
This commit is contained in:
commit
fc333ae098
5 changed files with 537 additions and 1 deletions
|
@ -16,7 +16,8 @@
|
|||
[app.main.ui.ds.foundations.typography.text :refer [text*]]
|
||||
[app.main.ui.ds.notifications.toast :refer [toast*]]
|
||||
[app.main.ui.ds.product.loader :refer [loader*]]
|
||||
[app.main.ui.ds.storybook :as sb]))
|
||||
[app.main.ui.ds.storybook :as sb]
|
||||
[app.main.ui.ds.tab-switcher :refer [tab-switcher*]]))
|
||||
|
||||
(def default
|
||||
"A export used for storybook"
|
||||
|
@ -28,6 +29,7 @@
|
|||
:Loader loader*
|
||||
:RawSvg raw-svg*
|
||||
:Text text*
|
||||
:TabSwitcher tab-switcher*
|
||||
:Toast toast*
|
||||
;; meta / misc
|
||||
:meta #js {:icons (clj->js (sort icon-list))
|
||||
|
|
160
frontend/src/app/main/ui/ds/tab_switcher.cljs
Normal file
160
frontend/src/app/main/ui/ds/tab_switcher.cljs
Normal file
|
@ -0,0 +1,160 @@
|
|||
;; 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.tab-switcher
|
||||
(:require-macros
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list] :as i]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.keyboard :as kbd]
|
||||
[app.util.object :as obj]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc tab*
|
||||
{::mf/props :obj
|
||||
::mf/private true}
|
||||
[{:keys [selected icon label aria-label id tab-ref] :rest props}]
|
||||
|
||||
(let [class (stl/css-case :tab true
|
||||
:selected selected)
|
||||
props (mf/spread-props props {:class class
|
||||
:role "tab"
|
||||
:aria-selected selected
|
||||
:title (or label aria-label)
|
||||
:tab-index (if selected nil -1)
|
||||
:ref tab-ref
|
||||
:data-id id})]
|
||||
|
||||
[:> "li" {}
|
||||
[:> "button" props
|
||||
(when icon
|
||||
[:> icon*
|
||||
{:id icon
|
||||
:aria-hidden (when label true)
|
||||
:aria-label (when (not label) aria-label)}])
|
||||
(when label
|
||||
[:span {:class (stl/css-case :tab-text true
|
||||
:tab-text-and-icon icon)} label])]]))
|
||||
|
||||
(mf/defc tab-nav*
|
||||
{::mf/props :obj
|
||||
::mf/private true}
|
||||
[{:keys [tabs-refs tabs selected on-click button-position action-button] :rest props}]
|
||||
(let [class (stl/css-case :tab-nav true
|
||||
:tab-nav-start (= "start" button-position)
|
||||
:tab-nav-end (= "end" button-position))
|
||||
props (mf/spread-props props {:class (stl/css :tab-list)
|
||||
:role "tablist"
|
||||
:aria-orientation "horizontal"})]
|
||||
[:> "nav" {:class class}
|
||||
(when (= button-position "start")
|
||||
action-button)
|
||||
|
||||
[:> "ul" props
|
||||
(for [[index element] (map-indexed vector tabs)]
|
||||
(let [icon (obj/get element "icon")
|
||||
label (obj/get element "label")
|
||||
aria-label (obj/get element "aria-label")
|
||||
id (obj/get element "id")]
|
||||
|
||||
[:> tab* {:icon icon
|
||||
:key (dm/str "tab-" id)
|
||||
:label label
|
||||
:aria-label aria-label
|
||||
:selected (= index selected)
|
||||
:on-click on-click
|
||||
:id id
|
||||
:tab-ref (nth tabs-refs index)}]))]
|
||||
|
||||
(when (= button-position "end")
|
||||
action-button)]))
|
||||
|
||||
(mf/defc tab-panel*
|
||||
{::mf/props :obj
|
||||
::mf/private true}
|
||||
[{:keys [children name] :rest props}]
|
||||
(let [props (mf/spread-props props {:class (stl/css :tab-panel)
|
||||
:aria-labelledby name
|
||||
:role "tabpanel"})]
|
||||
[:> "section" props
|
||||
children]))
|
||||
|
||||
(defn- valid-tabs?
|
||||
[tabs]
|
||||
(every? (fn [tab]
|
||||
(let [icon (obj/get tab "icon")
|
||||
label (obj/get tab "label")
|
||||
aria-label (obj/get tab "aria-label")]
|
||||
(and (or (not icon) (contains? icon-list icon))
|
||||
(not (and icon (nil? label) (nil? aria-label)))
|
||||
(not (and aria-label (or (nil? icon) label))))))
|
||||
(seq tabs)))
|
||||
|
||||
(def ^:private positions (set '("start" "end")))
|
||||
|
||||
(defn- valid-button-position? [position button]
|
||||
(or (nil? position) (and (contains? positions position) (some? button))))
|
||||
|
||||
(mf/defc tab-switcher*
|
||||
{::mf/props :obj}
|
||||
[{:keys [class tabs on-change-tab default-selected action-button-position action-button] :rest props}]
|
||||
;; TODO: Use a schema to assert the tabs prop -> https://tree.taiga.io/project/penpot/task/8521
|
||||
(assert (valid-tabs? tabs) "unexpected props for tab-switcher")
|
||||
(assert (valid-button-position? action-button-position action-button) "invalid action-button-position")
|
||||
(let [tab-ids (mapv #(obj/get % "id") tabs)
|
||||
|
||||
active-tab-index* (mf/use-state (or (d/index-of tab-ids default-selected) 0))
|
||||
active-tab-index (deref active-tab-index*)
|
||||
|
||||
tabs-refs (mapv (fn [_] (mf/use-ref)) tabs)
|
||||
|
||||
active-tab (nth tabs active-tab-index)
|
||||
panel-content (obj/get active-tab "content")
|
||||
|
||||
handle-click
|
||||
(mf/use-fn
|
||||
(mf/deps on-change-tab tab-ids)
|
||||
(fn [event]
|
||||
(let [id (dom/get-data (dom/get-current-target event) "id")
|
||||
index (d/index-of tab-ids id)]
|
||||
(reset! active-tab-index* index)
|
||||
|
||||
(when (fn? on-change-tab)
|
||||
(on-change-tab id)))))
|
||||
|
||||
on-key-down
|
||||
(mf/use-fn
|
||||
(mf/deps tabs-refs active-tab-index)
|
||||
(fn [event]
|
||||
(let [len (count tabs-refs)
|
||||
index (cond
|
||||
(kbd/home? event) 0
|
||||
(kbd/left-arrow? event) (mod (- active-tab-index 1) len)
|
||||
(kbd/right-arrow? event) (mod (+ active-tab-index 1) len))]
|
||||
(when index
|
||||
(reset! active-tab-index* index)
|
||||
(dom/focus! (mf/ref-val (nth tabs-refs index)))))))
|
||||
|
||||
class (dm/str class " " (stl/css :tabs))
|
||||
|
||||
props (mf/spread-props props {:class class
|
||||
:on-key-down on-key-down})]
|
||||
|
||||
[:> "article" props
|
||||
[:> "div" {:class (stl/css :padding-wrapper)}
|
||||
[:> tab-nav* {:button-position action-button-position
|
||||
:action-button action-button
|
||||
:tabs tabs
|
||||
:selected active-tab-index
|
||||
:on-click handle-click
|
||||
:tabs-refs tabs-refs}]]
|
||||
|
||||
[:> tab-panel* {:tab-index 0}
|
||||
panel-content]]))
|
||||
|
121
frontend/src/app/main/ui/ds/tab_switcher.mdx
Normal file
121
frontend/src/app/main/ui/ds/tab_switcher.mdx
Normal file
|
@ -0,0 +1,121 @@
|
|||
import { Canvas, Meta } from '@storybook/blocks';
|
||||
import * as TabSwitcher from "./tab_switcher.stories";
|
||||
|
||||
|
||||
<Meta title="Tab switcher" />
|
||||
|
||||
# Tab Switcher
|
||||
|
||||
Tabbed interfaces are a way of navigating between multiple panels,
|
||||
reducing clutter and fitting more into a smaller space.
|
||||
|
||||
## Variants
|
||||
|
||||
**Icon + Text**, we will use this variant when there is plenty of space
|
||||
and an icon can help to understand the tab content quickly.
|
||||
Use it only when icons add real value, to avoid too much noise in the UI.
|
||||
<Canvas of={TabSwitcher.WithIconsAndText} />
|
||||
|
||||
**Text**, we will use this variant when there are enough space and icons don't add any useful context.
|
||||
|
||||
<Canvas of={TabSwitcher.Default} />
|
||||
|
||||
**Icon**, we will use this variant in small spaces, when an icon is enough hint to understand the tab content.
|
||||
<Canvas of={TabSwitcher.WithIcons} />
|
||||
|
||||
**With action button**, we can add an action button to the begining or ending of the tab nav.
|
||||
This button must be configured and styled outside of the component.
|
||||
|
||||
<Canvas of={TabSwitcher.WithActionButton} />
|
||||
|
||||
|
||||
## Technical notes
|
||||
|
||||
### Icons
|
||||
|
||||
Each tab of `tab_switcher*` 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
|
||||
[:> tab_switcher*
|
||||
{:tabs [{ :label "Code"
|
||||
:id "tab-code"
|
||||
:icon i/fill-content
|
||||
:content [:p Lorem Ipsum ]}
|
||||
{ :label "Design"
|
||||
:id "tab-design"
|
||||
:icon i/pentool
|
||||
:content [:p Dolor sit amet ]}
|
||||
{ :label "Menu"
|
||||
:id "tab-menu"
|
||||
:icon i/mask
|
||||
:content [:p Consectetur adipiscing elit ]}
|
||||
]}]
|
||||
```
|
||||
|
||||
<Canvas of={TabSwitcher.WithIconsAndText} />
|
||||
|
||||
### Paddings
|
||||
|
||||
We have the option to define `paddings` for tab nav from outside the component to fit all needs. In order to do so
|
||||
we will create, on the parent, this variables with the desired `value`.
|
||||
|
||||
```scss
|
||||
.parent {
|
||||
--tabs-nav-padding-inline-start: value;
|
||||
--tabs-nav-padding-inline-end: value;
|
||||
--tabs-nav-padding-block-start: value;
|
||||
--tabs-nav-padding-block-end: value;
|
||||
}
|
||||
```
|
||||
|
||||
### Accessibility
|
||||
|
||||
A tab with icons only on a `tab_switcher*` require an `aria-label`. This is also shown in a tooltip on hovering the tab.
|
||||
|
||||
```clj
|
||||
[:> tab_switcher*
|
||||
{:tabs [{ :aria-label "Code"
|
||||
:id "tab-code"
|
||||
:icon i/fill-content
|
||||
:content [:p Lorem Ipsum ]}
|
||||
{ :aria-label "Design"
|
||||
:id "tab-design"
|
||||
:icon i/pentool
|
||||
:content [:p Dolor sit amet ]}
|
||||
{ :aria-label "Menu"
|
||||
:id "tab-menu"
|
||||
:icon i/mask
|
||||
:content [:p Consectetur adipiscing elit ]}
|
||||
]}]
|
||||
```
|
||||
|
||||
<Canvas of={TabSwitcher.WithIcons} />
|
||||
|
||||
## Usage guidelines (design)
|
||||
|
||||
### Where to use
|
||||
|
||||
In panels where we want to show elements that are related but are
|
||||
different or have different goals, or that are in the same hierarchy level.
|
||||
|
||||
### When to use
|
||||
|
||||
Used when we need to display in the same space a full complex views of related elements.
|
||||
|
||||
### Interaction / Behavior
|
||||
|
||||
On click, switch the tab content.
|
||||
Tabs with icons only should display a tooltip on hover.
|
||||
|
||||
In the event that the content of the tabs, due to language changes,
|
||||
modifies its length and does not fit within the tab sizes, the tabs
|
||||
will adapt to the content, trying to display it in full and reducing
|
||||
the size of the other tabs when possible.
|
101
frontend/src/app/main/ui/ds/tab_switcher.scss
Normal file
101
frontend/src/app/main/ui/ds/tab_switcher.scss
Normal file
|
@ -0,0 +1,101 @@
|
|||
// 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 "./_sizes.scss" as *;
|
||||
@use "./_borders.scss" as *;
|
||||
@use "./typography.scss" as *;
|
||||
|
||||
.tabs {
|
||||
--tabs-bg-color: var(--color-background-secondary);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.padding-wrapper {
|
||||
padding-inline-start: var(--tabs-nav-padding-inline-start, 0);
|
||||
padding-inline-end: var(--tabs-nav-padding-inline-end, 0);
|
||||
padding-block-start: var(--tabs-nav-padding-block-start, 0);
|
||||
padding-block-end: var(--tabs-nav-padding-block-end, 0);
|
||||
}
|
||||
|
||||
// TAB NAV
|
||||
.tab-nav {
|
||||
display: grid;
|
||||
gap: var(--sp-xxs);
|
||||
width: 100%;
|
||||
border-radius: $br-8;
|
||||
padding: var(--sp-xxs);
|
||||
background-color: var(--tabs-bg-color);
|
||||
}
|
||||
|
||||
.tab-nav-start {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.tab-nav-end {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: var(--sp-xxs);
|
||||
width: 100%;
|
||||
// Removing margin bottom from default ul
|
||||
margin-block-end: 0;
|
||||
border-radius: $br-8;
|
||||
}
|
||||
|
||||
// TAB
|
||||
.tab {
|
||||
--tabs-item-bg-color: var(--color-background-secondary);
|
||||
--tabs-item-fg-color: var(--color-foreground-secondary);
|
||||
--tabs-item-fg-color-hover: var(--color-foreground-primary);
|
||||
--tabs-item-outline-color: none;
|
||||
|
||||
&:hover {
|
||||
--tabs-item-fg-color: var(--tabs-item-fg-color-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
--tabs-item-outline-color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
appearance: none;
|
||||
height: $sz-32;
|
||||
border: none;
|
||||
border-radius: $br-8;
|
||||
padding: 0 var(--sp-s);
|
||||
outline: $b-1 solid var(--tabs-item-outline-color);
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
column-gap: var(--sp-xs);
|
||||
background: var(--tabs-item-bg-color);
|
||||
color: var(--tabs-item-fg-color);
|
||||
padding: 0 var(--sp-m);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selected {
|
||||
--tabs-item-bg-color: var(--color-background-quaternary);
|
||||
--tabs-item-fg-color: var(--color-accent-primary);
|
||||
--tabs-item-fg-color-hover: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
@include use-typography("headline-small");
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-text-and-icon {
|
||||
padding-inline: var(--sp-xxs);
|
||||
}
|
152
frontend/src/app/main/ui/ds/tab_switcher.stories.jsx
Normal file
152
frontend/src/app/main/ui/ds/tab_switcher.stories.jsx
Normal file
|
@ -0,0 +1,152 @@
|
|||
// 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 { TabSwitcher } = Components;
|
||||
|
||||
const Padded = ({ children }) => (
|
||||
<div style={{ padding: "10px" }}>{children}</div>
|
||||
);
|
||||
|
||||
export default {
|
||||
title: "Tab switcher",
|
||||
component: TabSwitcher,
|
||||
args: {
|
||||
tabs: [
|
||||
{
|
||||
label: "Code",
|
||||
id: "tab-code",
|
||||
content: (
|
||||
<Padded>
|
||||
<p>Lorem Ipsum</p>
|
||||
</Padded>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Design",
|
||||
id: "tab-design",
|
||||
content: (
|
||||
<Padded>
|
||||
<p>Dolor sit amet</p>
|
||||
</Padded>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Menu",
|
||||
id: "tab-menu",
|
||||
content: (
|
||||
<Padded>
|
||||
<p>Consectetur adipiscing elit</p>
|
||||
</Padded>
|
||||
),
|
||||
},
|
||||
],
|
||||
defaultSelected: "tab-code",
|
||||
},
|
||||
argTypes: {
|
||||
actionButtonPosition: {
|
||||
control: "radio",
|
||||
options: ["start", "end"],
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
controls: {
|
||||
exclude: [
|
||||
"tabs",
|
||||
"actionButton",
|
||||
"defaultSelected",
|
||||
"actionButtonPosition",
|
||||
],
|
||||
},
|
||||
},
|
||||
render: ({ ...args }) => <TabSwitcher {...args} />,
|
||||
};
|
||||
|
||||
export const Default = {};
|
||||
|
||||
const ActionButton = (
|
||||
<button
|
||||
onClick={() => {
|
||||
alert("You have clicked on the action button");
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: "var(--tabs-bg-color)",
|
||||
height: "32px",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
color: "var(--color-foreground-secondary)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
appearance: "none",
|
||||
}}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
);
|
||||
|
||||
export const WithActionButton = {
|
||||
args: {
|
||||
actionButtonPosition: "start",
|
||||
actionButton: ActionButton,
|
||||
},
|
||||
parameters: {
|
||||
controls: {
|
||||
exclude: ["tabs", "actionButton", "defaultSelected"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcons = {
|
||||
args: {
|
||||
tabs: [
|
||||
{
|
||||
"aria-label": "Code",
|
||||
id: "tab-code",
|
||||
icon: "fill-content",
|
||||
content: <p>Lorem Ipsum</p>,
|
||||
},
|
||||
{
|
||||
"aria-label": "Design",
|
||||
id: "tab-design",
|
||||
icon: "pentool",
|
||||
content: <p>Dolor sit amet</p>,
|
||||
},
|
||||
{
|
||||
"aria-label": "Menu",
|
||||
id: "tab-menu",
|
||||
icon: "mask",
|
||||
content: <p>Consectetur adipiscing elit</p>,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIconsAndText = {
|
||||
args: {
|
||||
tabs: [
|
||||
{
|
||||
label: "Code",
|
||||
id: "tab-code",
|
||||
icon: "fill-content",
|
||||
content: <p>Lorem Ipsum</p>,
|
||||
},
|
||||
{
|
||||
label: "Design",
|
||||
id: "tab-design",
|
||||
icon: "pentool",
|
||||
content: <p>Dolor sit amet</p>,
|
||||
},
|
||||
{
|
||||
label: "Menu",
|
||||
id: "tab-menu",
|
||||
icon: "mask",
|
||||
content: <p>Consectetur adipiscing elit</p>,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
Loading…
Add table
Reference in a new issue