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"