diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs index c9848fb719..e0f1c944f5 100644 --- a/frontend/src/app/main/ui/ds.cljs +++ b/frontend/src/app/main/ui/ds.cljs @@ -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)) diff --git a/frontend/src/app/main/ui/ds/tab_switcher.cljs b/frontend/src/app/main/ui/ds/tab_switcher.cljs new file mode 100644 index 0000000000..5a5d5218a2 --- /dev/null +++ b/frontend/src/app/main/ui/ds/tab_switcher.cljs @@ -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]])) + diff --git a/frontend/src/app/main/ui/ds/tab_switcher.mdx b/frontend/src/app/main/ui/ds/tab_switcher.mdx new file mode 100644 index 0000000000..30697b851e --- /dev/null +++ b/frontend/src/app/main/ui/ds/tab_switcher.mdx @@ -0,0 +1,121 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import * as TabSwitcher from "./tab_switcher.stories"; + + + + +# 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. + + +**Text**, we will use this variant when there are enough space and icons don't add any useful context. + + + +**Icon**, we will use this variant in small spaces, when an icon is enough hint to understand the tab content. + + +**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. + + + + +## 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 ]} + ]}] +``` + + + +### 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 ]} + ]}] +``` + + + +## 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. \ No newline at end of file diff --git a/frontend/src/app/main/ui/ds/tab_switcher.scss b/frontend/src/app/main/ui/ds/tab_switcher.scss new file mode 100644 index 0000000000..a0572f54f1 --- /dev/null +++ b/frontend/src/app/main/ui/ds/tab_switcher.scss @@ -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); +} diff --git a/frontend/src/app/main/ui/ds/tab_switcher.stories.jsx b/frontend/src/app/main/ui/ds/tab_switcher.stories.jsx new file mode 100644 index 0000000000..1148e53e8c --- /dev/null +++ b/frontend/src/app/main/ui/ds/tab_switcher.stories.jsx @@ -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 }) => ( +
Lorem Ipsum
+Dolor sit amet
+Consectetur adipiscing elit
+Lorem Ipsum
, + }, + { + "aria-label": "Design", + id: "tab-design", + icon: "pentool", + content:Dolor sit amet
, + }, + { + "aria-label": "Menu", + id: "tab-menu", + icon: "mask", + content:Consectetur adipiscing elit
, + }, + ], + }, +}; + +export const WithIconsAndText = { + args: { + tabs: [ + { + label: "Code", + id: "tab-code", + icon: "fill-content", + content:Lorem Ipsum
, + }, + { + label: "Design", + id: "tab-design", + icon: "pentool", + content:Dolor sit amet
, + }, + { + label: "Menu", + id: "tab-menu", + icon: "mask", + content:Consectetur adipiscing elit
, + }, + ], + }, +};