Add tab component to the DS

This commit is contained in:
Eva Marco 2024-07-30 08:18:12 +02:00 committed by Belén Albeza
parent 4b2742efca
commit b8693c3f85
4 changed files with 416 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 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]]))

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