mirror of
https://github.com/penpot/penpot.git
synced 2025-06-12 18:41:38 +02:00
✨ Add tab component to the DS
This commit is contained in:
parent
4b2742efca
commit
b8693c3f85
4 changed files with 416 additions and 1 deletions
|
@ -16,7 +16,8 @@
|
||||||
[app.main.ui.ds.foundations.typography.text :refer [text*]]
|
[app.main.ui.ds.foundations.typography.text :refer [text*]]
|
||||||
[app.main.ui.ds.notifications.toast :refer [toast*]]
|
[app.main.ui.ds.notifications.toast :refer [toast*]]
|
||||||
[app.main.ui.ds.product.loader :refer [loader*]]
|
[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
|
(def default
|
||||||
"A export used for storybook"
|
"A export used for storybook"
|
||||||
|
@ -28,6 +29,7 @@
|
||||||
:Loader loader*
|
:Loader loader*
|
||||||
:RawSvg raw-svg*
|
:RawSvg raw-svg*
|
||||||
:Text text*
|
:Text text*
|
||||||
|
:TabSwitcher tab-switcher*
|
||||||
:Toast toast*
|
:Toast toast*
|
||||||
;; meta / misc
|
;; meta / misc
|
||||||
:meta #js {:icons (clj->js (sort icon-list))
|
: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 0 -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* {}
|
||||||
|
panel-content]]))
|
||||||
|
|
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
Add a link
Reference in a new issue