0
Fork 0
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:
Belén Albeza 2024-08-12 17:46:59 +02:00 committed by GitHub
commit fc333ae098
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 537 additions and 1 deletions

View file

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

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

View 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.

View 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);
}

View 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>,
},
],
},
};