Skip to content

Commit

Permalink
feat(Tab): Add Tab Component
Browse files Browse the repository at this point in the history
  • Loading branch information
itsweliton committed Jun 21, 2021
1 parent 01479f4 commit d7ec2b1
Show file tree
Hide file tree
Showing 5 changed files with 302 additions and 0 deletions.
12 changes: 12 additions & 0 deletions src/components/Tab/Tab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@

```jsx
<Tab initialOpenIndex={1}>
<Tab.Link title="Tab 1" />
<Tab.Link title="Tab 2" />
<Tab.Link title="Tab 3" />
<Tab.Panel index={0}>Stuff Goes here</Tab.Panel>
<Tab.Panel index={1}>Other Goes here</Tab.Panel>
<Tab.Panel index={2}>More Stuff Goes here</Tab.Panel>
</Tab>

```
41 changes: 41 additions & 0 deletions src/components/Tab/Tab.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/* eslint-disable react/jsx-key */
/** @jsxImportSource theme-ui */

import Tab, { TabProps } from "./Tab"
import { Meta, Story } from "@storybook/react/types-6-0"
import Paper from "../Paper/Paper"

export default {
title: "Design System/Tab",
component: Tab,
decorators: [
(Story) => (
<Paper sx={{ p: 0, width: 500 }}>
<Story />
</Paper>
),
],
} as Meta

const Template: Story<TabProps> = (props) => (
<Tab {...props} initialOpenIndex={1} />
)

// Each story then reuses that template
export const Default = Template.bind({})
Default.args = {
children: [
<Tab.Link title="Tab 1" />,
<Tab.Link title="Tab 2" />,
<Tab.Link title="Tab 3" />,
<Tab.Panel index={0}>Stuff Goes here</Tab.Panel>,
<Tab.Panel index={1}>Other Goes here</Tab.Panel>,
<Tab.Panel index={2}>More Stuff Goes here</Tab.Panel>,
],
}

export const WithInitialOpen = Template.bind({})
WithInitialOpen.args = {
...Default.args,
initialOpenIndex: 0,
}
77 changes: 77 additions & 0 deletions src/components/Tab/Tab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { render, screen, waitFor } from "../../tests/utils"
import { axe } from "jest-axe"
import Tab from "./Tab"
import userEvent from "@testing-library/user-event"

describe("Tab", () => {
test("do not renders closed tab", async () => {
// Arrange
render(
<Tab initialOpenIndex={1}>
<Tab.Link title="ITEM_TITLE_0" />
<Tab.Link title="ITEM_TITLE_1" />
<Tab.Link title="ITEM_TITLE_2" />
<Tab.Panel index={0}>ITEM_PANEL_0</Tab.Panel>
<Tab.Panel index={1}>ITEM_PANEL_1</Tab.Panel>
<Tab.Panel index={2}>ITEM_PANEL_2</Tab.Panel>
</Tab>
)
// Assert
expect(screen.queryByText("ITEM_TITLE_0")).toBeInTheDocument()
expect(screen.queryByText("ITEM_TITLE_1")).toBeInTheDocument()
expect(screen.queryByText("ITEM_TITLE_2")).toBeInTheDocument()
expect(screen.queryByText("ITEM_PANEL_0")).not.toBeInTheDocument()
expect(screen.queryByText("ITEM_PANEL_1")).toBeInTheDocument()
expect(screen.queryByText("ITEM_PANEL_2")).not.toBeInTheDocument()
})

test("opens when clicked", async () => {
// Arrange
render(
<Tab initialOpenIndex={1}>
<Tab.Link title="ITEM_TITLE_0" />
<Tab.Link title="ITEM_TITLE_1" />
<Tab.Link title="ITEM_TITLE_2" />
<Tab.Panel index={0}>ITEM_PANEL_0</Tab.Panel>
<Tab.Panel index={1}>ITEM_PANEL_1</Tab.Panel>
<Tab.Panel index={2}>ITEM_PANEL_2</Tab.Panel>
</Tab>
)
// Assert
expect(screen.queryByText("ITEM_TITLE_0")).toBeInTheDocument()
expect(screen.queryByText("ITEM_TITLE_1")).toBeInTheDocument()
expect(screen.queryByText("ITEM_TITLE_2")).toBeInTheDocument()
expect(screen.queryByText("ITEM_PANEL_0")).not.toBeInTheDocument()
expect(screen.queryByText("ITEM_PANEL_1")).toBeInTheDocument()
expect(screen.queryByText("ITEM_PANEL_2")).not.toBeInTheDocument()

userEvent.click(screen.getByText("ITEM_TITLE_2"))
await waitFor(() =>
expect(screen.queryByText("ITEM_PANEL_2")).toBeInTheDocument()
)

userEvent.click(screen.getByText("ITEM_TITLE_0"))
await waitFor(() => {
expect(screen.queryByText("ITEM_PANEL_2")).not.toBeInTheDocument()
expect(screen.queryByText("ITEM_PANEL_0")).toBeInTheDocument()
})
})

test("passes a11y check", async () => {
// Arrange
const { container } = render(
<Tab initialOpenIndex={1}>
<Tab.Link title="ITEM_TITLE_0" />
<Tab.Link title="ITEM_TITLE_1" />
<Tab.Link title="ITEM_TITLE_2" />
<Tab.Panel index={0}>ITEM_PANEL_0</Tab.Panel>
<Tab.Panel index={1}>ITEM_PANEL_1</Tab.Panel>
<Tab.Panel index={2}>ITEM_PANEL_2</Tab.Panel>
</Tab>
)
const results = await axe(container)

// Assert
expect(results).toHaveNoViolations()
})
})
149 changes: 149 additions & 0 deletions src/components/Tab/Tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/** @jsxImportSource theme-ui */
import { get } from "@theme-ui/css"
import React, { useCallback, useState } from "react"
import { useTabContext } from "./context"
import { TabContext } from "./context"

export interface TabProps {
initialOpenIndex?: number
children: React.ReactNode
activeBackgroundColor?:
| "primary"
| "secondary"
| "text"
| "success"
| "warning"
}

const Tab = ({
children,
initialOpenIndex,
activeBackgroundColor = "secondary",
}: TabProps) => {
const [openIndex, setOpenIndex] = useState<number | undefined>(
initialOpenIndex
)

const toggleOpen = useCallback(
(index: number) => {
if (openIndex === index) setOpenIndex(undefined)
else setOpenIndex(index)
},
[openIndex]
)

const allItems = React.Children.toArray(children)
.filter((child) => {
return React.isValidElement(child)
})
.map((child, index) => (
<TabContext.Provider
value={{ index, toggleOpen, openIndex, activeBackgroundColor }}
key={index}
>
{child}
</TabContext.Provider>
))

const tabLink = allItems.filter((item) =>
item.props.children.props.hasOwnProperty("title")
)
const tabPanel = allItems.filter((item) =>
item.props.children.props.hasOwnProperty("index")
)
return (
<>
<div sx={{ display: "flex", flexDirection: "row" }}>
{tabLink.map((item) => item)}
</div>
<div>{tabPanel.map((item) => item)}</div>
</>
)
}

export interface TabLinkProps {
title: string
// children: React.ReactNode
// className?: HTMLAttributes<HTMLDivElement>["className"]
}

const TabLink = ({ title }: TabLinkProps) => {
const { index, toggleOpen, openIndex, activeBackgroundColor } =
useTabContext()
const isOpen = openIndex === index
return (
<div
role="button"
aria-disabled="false"
aria-expanded={isOpen}
sx={{
px: 6,
py: 2,
variant: "text.body1",
color: "text.40",
cursor: "pointer",
position: "relative",
overflow: "hidden",
borderRadius: 2,
height: "36px",
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
border: isOpen ? "1px solid #E8E8E9" : "none",
borderBottom: "none",
borderTop: "none",
boxSizing: "border-box",
backgroundColor: (t) =>
isOpen ? get(t, `colors.${activeBackgroundColor}.97`) : "transparent",
transition: "background-color 150ms linear",
}}
onClick={() => toggleOpen(index)}
>
<span
sx={{
position: "absolute",
top: 0,
left: 0,
height: "2px",
width: "100%",
backgroundColor: isOpen ? "primary" : "transparent",
}}
/>
<span sx={{ variant: "text.heading4" }}>{title}</span>
</div>
)
}

export interface TabPanelProps {
children: React.ReactNode
index: number
}

const TabPanel = ({ children, index }: TabPanelProps) => {
const { openIndex } = useTabContext()
const isOpen = openIndex === index
return (
<>
{isOpen && (
<div
id={index.toString()}
sx={{
display: "block",
position: "relative",
py: 5,
px: 6,
borderRadius: "12px",
borderTopLeftRadius: openIndex == 0 && isOpen ? "0" : "12px",
border: "1px solid #E8E8E9",
backgroundColor: "#F3F3F3",
}}
>
{children}
</div>
)}
</>
)
}

Tab.Link = TabLink
Tab.Panel = TabPanel
export default Tab
23 changes: 23 additions & 0 deletions src/components/Tab/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createContext, useContext } from "react"

export interface TabContext {
index: number
openIndex?: number
toggleOpen: (index: number) => void
activeBackgroundColor:
| "primary"
| "secondary"
| "text"
| "success"
| "warning"
}

export const TabContext = createContext<TabContext | null>(null)

export function useTabContext() {
const context = useContext(TabContext)
if (!context) {
throw new Error("Tab.Link needs to be wrapped in an Tab component")
}
return context
}

0 comments on commit d7ec2b1

Please sign in to comment.