diff --git a/index.ts b/index.ts index 6ccd627..498a1cd 100644 --- a/index.ts +++ b/index.ts @@ -10,3 +10,4 @@ export { default as Message } from "./src/blocks/Message" export { default as Toast } from "./src/blocks/Toast" export { default as Card } from "./src/blocks/Card" export { default as Grid } from "./src/layout/Grid" +export { default as Tabs } from "./src/navigation/Tabs" diff --git a/package.json b/package.json index 8284aff..5b57f91 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "markdown-to-jsx": "^6.11.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-tabs": "^6.0.0", "typescript": "^4.4.2" }, "devDependencies": { @@ -55,11 +56,11 @@ "@testing-library/user-event": "^13.2.1", "@types/jest": "^27.0.1", "babel-plugin-named-exports-order": "^0.0.2", + "postcss": "8.4.19", "postcss-import": "^14.1.0", "postcss-load-config": "^3.1.4", "postcss-loader": "7.0.1", "postcss-nested": "6.0.0", - "postcss": "8.4.19", "prop-types": "^15.8.1", "react-scripts": "5.0.1", "sass": "1.56.1", diff --git a/src/navigation/Tabs.scss b/src/navigation/Tabs.scss new file mode 100644 index 0000000..c3c5388 --- /dev/null +++ b/src/navigation/Tabs.scss @@ -0,0 +1,140 @@ +.tabs { + --tab-font: var(--bloom-font-alt-sans); + --tab-font-size: var(--bloom-type-body); + --tab-font-size-sm: var(--bloom-type-label); + --tab-font-weight: var(--bloom-font-weight-semibold); + --tab-font-weight-sm: var(--bloom-font-weight-regular); + --tab-background-color: var(--bloom-bg-color-surface); + --tab-label-color: var(--bloom-text-color-light); + --tab-padding-inline: var(--bloom-s6); + --tab-padding-block: var(--bloom-s4); + --tab-padding-inline-sm: var(--bloom-s6); + --tab-padding-block-sm: var(--bloom-s3); + --tab-border-radius: var(--bloom-rounded-lg); + --tab-border-color: var(--bloom-border-color); + --tab-border-width: var(--bloom-border-1); + --tab-hover-background-color: var(--bloom-bg-color-surface-primary); + --tab-active-label-color: var(--bloom-color-on-surface); + --tab-active-indicator-width: var(--bloom-border-2); + --tab-active-indicator-color: var(--bloom-color-primary); + --tab-panel-padding: var(--bloom-spacer-section); + --tab-panel-background-color: var(--bloom-bg-color-surface); +} + +.tabs-tablist { + display: flex; + font-family: var(--tab-font); + font-size: var(--tab-font-size); + font-weight: var(--tab-font-weight); + margin: 0; + margin-block-end: -1px; + padding: 0; + list-style-type: none; + + &[data-size="sm"] { + font-size: var(--tab-font-size-sm); + font-weight: var(--tab-font-weight-sm); + } +} + +.tabs-tab { + background-color: var(--tab-background-color); + color: var(--tab-label-color); + padding-inline: var(--tab-padding-inline); + padding-block: var(--tab-padding-block) calc(var(--tab-padding-block) - var(--tab-active-indicator-width)); + + border: var(--tab-border-width) solid var(--tab-border-color); + border-radius: 0; + + cursor: pointer; + position: relative; + + [data-size="sm"] > & { + padding-inline: var(--tab-padding-inline-sm); + padding-block: var(--tab-padding-block-sm) calc(var(--tab-padding-block-sm) - var(--tab-active-indicator-width)); + } + + &:focus-visible { + outline: 2px solid var(--bloom-color-accent-cool); + outline-offset: 2px; + } + + &:hover { + color: var(--tab-active-indicator-color); + background-color: var(--tab-hover-background-color); + } + + &:not(:last-of-type) { + border-right-width: 0; + } + + &:first-of-type { + border-top-left-radius: var(--tab-border-radius); + } + + &:last-of-type { + border-top-right-radius: var(--tab-border-radius); + } + + &[aria-selected="true"] { + border-bottom-color: var(--tab-active-indicator-color); + border-bottom-width: var(--tab-active-indicator-width); + z-index: 1; + } + + &:not(:hover)[aria-selected="true"] { + color: var(--tab-active-label-color); + } + + &[aria-disabled="true"] { + color: var(--bloom-text-color-disabled); + border-bottom-width: var(--tab-border-width); + cursor: not-allowed; + } +} + +.tabs-panel { + padding: var(--tab-panel-padding); + background-color: var(--tab-panel-background-color); + border: var(--tab-border-width) solid var(--tab-border-color); + + &:not(.is-active) { + display: none; + } +} + +@media (max-width: 640px) { + .tabs-tablist { + flex-direction: column; + } + + .tabs-tab { + border-color: var(--tab-border-color); + border-bottom-width: 0; + + &:first-of-type { + border-top-right-radius: var(--tab-border-radius); + } + + &:last-of-type { + border-top-right-radius: 0; + border-bottom-left-radius: var(--tab-border-radius); + border-bottom-right-radius: var(--tab-border-radius); + border-bottom-width: var(--tab-border-width); + } + + &:not(:last-of-type) { + border-right-width: var(--tab-border-width); + border-bottom-width: 0; + } + + &[aria-selected="true"] { + border-bottom-color: var(--tab-border-color); + box-shadow: inset calc(var(--tab-active-indicator-width) + var(--tab-border-width)) 0px 0px var(--tab-active-indicator-color); + } + } + + .tabs-panel { + margin-block-start: var(--bloom-spacer-label); + } +} diff --git a/src/navigation/Tabs.tsx b/src/navigation/Tabs.tsx new file mode 100644 index 0000000..4d664f3 --- /dev/null +++ b/src/navigation/Tabs.tsx @@ -0,0 +1,92 @@ +import React from "react" + +import "./Tabs.scss" + +import { + Tab as ReactTab, + Tabs as ReactTabs, + TabList as ReactTabList, + TabPanel as ReactTabPanel, +} from "react-tabs" + +export interface TabsProps { + children: React.ReactNode + className?: string + defaultFocus?: boolean + defaultIndex?: number + disabledTabClassName?: string + domRef?: (node?: HTMLElement) => void + focusTabOnClick?: boolean + forceRenderTabPanel?: boolean + onSelect?: (index: number, last: number, event: Event) => boolean | void + selectedIndex?: number + selectedTabClassName?: string + selectedTabPanelClassName?: string +} + +const Tabs = (props: TabsProps) => { + const className = ["tabs"] + if (props.className) className.push(props.className) + const focusTab = typeof props.focusTabOnClick !== "undefined" ? props.focusTabOnClick : false + + return ( + + ) +} + +export interface TabProps { + children: React.ReactNode + className?: string + disabled?: boolean + disabledClassName?: string + selectedClassName?: string + tabIndex?: string +} + +const Tab = (props: TabProps) => { + const className = ["tabs-tab"] + if (props.className) className.push(props.className) + return ( + + ) +} + +Tab.tabsRole = "Tab" + +export interface TabListProps { + children: React.ReactNode + size?: "sm" | "base" + className?: string +} + +const TabList = (props: TabListProps) => { + const className = ["tabs-tablist"] + if (props.className) className.push(props.className) + + return +} + +TabList.tabsRole = "TabList" + +export interface TabPanelProps { + children: React.ReactNode + className?: string + forceRender?: boolean + selectedClassName?: string +} + +const TabPanel = (props: TabPanelProps) => { + const className = ["tabs-panel"] + if (props.className) className.push(props.className) + return ( + + ) +} + +TabPanel.tabsRole = "TabPanel" + +Tabs.Tab = Tab +Tabs.TabList = TabList +Tabs.TabPanel = TabPanel + +export {Tabs as default, Tab, TabList, TabPanel} diff --git a/src/navigation/__stories__/Tabs.docs.mdx b/src/navigation/__stories__/Tabs.docs.mdx new file mode 100644 index 0000000..b375faf --- /dev/null +++ b/src/navigation/__stories__/Tabs.docs.mdx @@ -0,0 +1,45 @@ +import { ArgsTable } from "@storybook/addon-docs" +import Tabs from "../Tabs" +import { Swatch } from "../../../documentation/components/Swatch.tsx" + +# <Tabs /> + +## Properties + + + +## Tab List Properties + + + +## Tab Properties + + + +## Tab Panel Properties + + + +## Theme Variables + +| Name | Description | Default | +| ------------------------------ | ----------------------------------------- | ------------------------------ | +| `--tab-font` | Font family | `--bloom-font-alt-sans` | +| `--tab-font-size` | Font size | `--bloom-type-body` | +| `--tab-font-size-sm` | Font size on mobile screens | `--bloom-type-label` | +| `--tab-font-weight` | Font weight | `--bloom-font-weight-semibold` | +| `--tab-font-weight-sm` | Font weight on mobile screens | `--bloom-font-weight-regular` | +| `--tab-background-color` | | `--bloom-bg-color-surface` | +| `--tab-label-color` | | `--bloom-text-color-light` | +| `--tab-padding-inline` | Horizontal spacing within tab interior | `--bloom-s6` | +| `--tab-padding-block` | Vertical spacing within tab interior | `--bloom-s4` | +| `--tab-padding-inline-sm` | Horizontal spacing on mobile screens | `--bloom-s6` | +| `--tab-padding-block-sm` | Vertical spacing on mobile screens | `--bloom-s3` | +| `--tab-border-radius` | Tab border radius | `--bloom-rounded-lg` | +| `--tab-border-color` | | `--bloom-border-color` | +| `--tab-border-width` | Tab border radius | `--bloom-border-1` | +| `--tab-active-label-color` | | `--bloom-color-on-surface` | +| `--tab-active-indicator-width` | Width of the colorful border | `--bloom-border-2` | +| `--tab-active-indicator-color` | | `--bloom-color-primary` | +| `--tab-panel-padding` | Spacing within the panel interior | `--bloom-spacer-section` | +| `--tab-panel-background-color` | | `--bloom-bg-color-surface` | diff --git a/src/navigation/__stories__/Tabs.stories.tsx b/src/navigation/__stories__/Tabs.stories.tsx new file mode 100644 index 0000000..076361a --- /dev/null +++ b/src/navigation/__stories__/Tabs.stories.tsx @@ -0,0 +1,97 @@ +import React from "react" +import Tabs from "../Tabs" + +import MDXDocs from "./Tabs.docs.mdx" + +export default { + title: "Navigation/Tabs", + decorators: [ + (storyFn: any) => ( +
+ + + {storyFn()} +
+ ), + ], + parameters: { + docs: { + page: MDXDocs, + }, + }, +} + +export const Default = () => { + return ( + + + Title 1 + Title 2 + Long Tab Title 3 + Disabled Tab + Funky Tab + + + +

Any content 1

+

Paragraph text 1.

+
+ +

Any content 2

+

Paragraph text 2.

+
+ +

Any kind of content here

+

Paragraph text 3.

+

Paragraph text 3.

+

Paragraph text 3.

+
+ +

This is disabled

+
+ +

Feeling funky!

+
+
+ ) +} + +export const SmallTabs = () => { + return ( + + + Title 1 + Title 2 + Long Tab Title 3 + + + +

Any content 1

+

Paragraph text 1.

+
+ +

Any content 2

+

Paragraph text 2.

+
+ +

Any kind of content here

+

Paragraph text 3.

+

Paragraph text 3.

+

Paragraph text 3.

+
+
+ ) +} diff --git a/src/navigation/__tests__/Tabs.test.tsx b/src/navigation/__tests__/Tabs.test.tsx new file mode 100644 index 0000000..da8a6d7 --- /dev/null +++ b/src/navigation/__tests__/Tabs.test.tsx @@ -0,0 +1,41 @@ +import { render, fireEvent, cleanup } from "@testing-library/react" +import Tabs from "../Tabs" + +afterEach(cleanup) + +describe("", () => { + it("outputs the right markup for selected tab", () => { + const { container, getByText } = render( + + + Other + Default + + OtherPanel + DefaultPanel + + ) + + expect(getByText("Other")).toBeTruthy() + expect(container.getElementsByClassName("other-tab").length).toBe(1) + expect(container.getElementsByClassName("other-panel").length).toBe(1) + + expect(getByText("Default")).toBeTruthy() + expect(container.getElementsByClassName("default-tab").length).toBe(1) + expect(container.getElementsByClassName("default-panel").length).toBe(1) + + let activeTabs = container.querySelectorAll(".tabs-tab.is-active") + expect(activeTabs.length).toBe(1) + expect(activeTabs[0].className).toStrictEqual("tabs-tab default-tab is-active") + let activePanels = container.querySelectorAll(".tabs-panel.is-active") + expect(activePanels.length).toBe(1) + expect(activePanels[0].className).toStrictEqual("tabs-panel default-panel is-active") + + fireEvent.click(getByText("Other")) + + activeTabs = container.querySelectorAll(".tabs-tab.is-active") + expect(activeTabs[0].className).toStrictEqual("tabs-tab other-tab is-active") + activePanels = container.querySelectorAll(".tabs-panel.is-active") + expect(activePanels[0].className).toStrictEqual("tabs-panel other-panel is-active") + }) +}) diff --git a/yarn.lock b/yarn.lock index 8131ec8..d43e9ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6027,7 +6027,7 @@ clsx@1.1.0: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.0.tgz#62937c6adfea771247c34b54d320fb99624f5702" integrity sha512-3avwM37fSK5oP6M5rQ9CNe99lwxhXDOeSWVPAOYF6OazUTgZCMb0yWlJpmdD74REy1gkEaFiub2ULv4fq9GUhA== -clsx@^1.0.4: +clsx@^1.0.4, clsx@^1.1.0: version "1.2.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== @@ -13693,7 +13693,7 @@ promzard@^0.3.0: dependencies: read "1" -prop-types@^15.0.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.0.0, prop-types@^15.5.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -14093,6 +14093,14 @@ react-scripts@5.0.1: optionalDependencies: fsevents "^2.3.2" +react-tabs@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-6.0.0.tgz#328fd61ca534e0517d24f4927e37e99b29461880" + integrity sha512-8jKLKrlwxmn5/+xsa76yi27ZdB8E/WhlhQZw739O5UlOeUGtVoVeWnpqIewv/KbjTw7gQf/uA51zWUNt4IVygQ== + dependencies: + clsx "^1.1.0" + prop-types "^15.5.0" + react@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"