From bcb0311e841369293edb156a70d2b699135feb88 Mon Sep 17 00:00:00 2001 From: Virginia Czosek Date: Thu, 14 May 2026 16:10:45 -0700 Subject: [PATCH 1/3] First pass at tabs. --- src/components/Buttons/button.tsx | 5 +- src/components/Tabs/tab.scss | 40 ++++++++++++ src/components/Tabs/tab.stories.tsx | 61 ++++++++++++++++++ src/components/Tabs/tab.test.tsx | 20 ++++++ src/components/Tabs/tab.tsx | 99 +++++++++++++++++++++++++++++ src/index.ts | 1 + 6 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 src/components/Tabs/tab.scss create mode 100644 src/components/Tabs/tab.stories.tsx create mode 100644 src/components/Tabs/tab.test.tsx create mode 100644 src/components/Tabs/tab.tsx diff --git a/src/components/Buttons/button.tsx b/src/components/Buttons/button.tsx index 4fd4739b6b..894b85e762 100644 --- a/src/components/Buttons/button.tsx +++ b/src/components/Buttons/button.tsx @@ -2,11 +2,12 @@ import { forwardRef, JSX, type ButtonHTMLAttributes, + type MouseEvent, type ReactNode, } from 'react'; import { Icon } from '../Icon/icon'; -interface ButtonProperties extends ButtonHTMLAttributes { +export interface ButtonProperties extends ButtonHTMLAttributes { /** * Button contents */ @@ -26,7 +27,7 @@ interface ButtonProperties extends ButtonHTMLAttributes { /** * Optional click handler */ - onClick?: () => void; + onClick?: (event: MouseEvent) => void; /** * Button should be styled as a link? */ diff --git a/src/components/Tabs/tab.scss b/src/components/Tabs/tab.scss new file mode 100644 index 0000000000..0d202c5566 --- /dev/null +++ b/src/components/Tabs/tab.scss @@ -0,0 +1,40 @@ +@use 'sass:math'; +@use '@cfpb/cfpb-design-system/src/elements/abstracts' as *; +@use '@cfpb/cfpb-design-system/src/utilities' as *; + +.tablist { + display: flex; + border-bottom: 1px solid var(--gray-40); + // margin-bottom: -1px; + // position: relative; + // z-index: 10; + + button.tab { + @include heading-4($has-margin-bottom: false); + padding: math.div(math.div($grid-gutter-width, 3), $base-font-size-px) + rem + math.div($grid-gutter-width, $base-font-size-px) + rem; + margin-bottom: -1px; + border: 1px solid transparent; + + &:focus:not(:focus-visible) { + outline: none; + } + + &:focus-visible { + outline-offset: -1px; + } + + &--active { + color: var(--black); + background: var(--gray-5); + text-decoration: none; + pointer-events: none; + border-color: var(--gray-40); + border-bottom-color: transparent; + } + } +} + +.tab-panel { + // border-top: 1px solid var(--gray-40); +} diff --git a/src/components/Tabs/tab.stories.tsx b/src/components/Tabs/tab.stories.tsx new file mode 100644 index 0000000000..da3c101b5a --- /dev/null +++ b/src/components/Tabs/tab.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { Heading, Tab, TabList, TabPanel } from '~/src/index'; +import type { JSXElement } from '../../types/jsx-element'; + +const meta: Meta = { + title: 'Components (Draft)/Tabs', + tags: ['autodocs'], + component: Tab, + argTypes: {}, +}; + +export default meta; + +type Story = StoryObj; + +const TestTabs = (): JSXElement => { + const [activeTab, setActiveTab] = useState('one') + + const onClick = (event: React.MouseEvent) => { + setActiveTab(event.currentTarget.value); + } + return ( + <> + + + + + + + Panel {activeTab} + + + ); +}; + +export const Default: Story = { + name: 'Tabs', + render: () => ( + + ), +}; + diff --git a/src/components/Tabs/tab.test.tsx b/src/components/Tabs/tab.test.tsx new file mode 100644 index 0000000000..a663ab9abb --- /dev/null +++ b/src/components/Tabs/tab.test.tsx @@ -0,0 +1,20 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { Tab, TabList } from './tab'; + +describe('', () => { + + it('renders tabs', () => { + render( + + One tab + Second tab + + ); + + const tabs = screen.getByRole('tablist'); + expect(tabs).toBeInTheDocument(); + }); + + +}); diff --git a/src/components/Tabs/tab.tsx b/src/components/Tabs/tab.tsx new file mode 100644 index 0000000000..db43ab544c --- /dev/null +++ b/src/components/Tabs/tab.tsx @@ -0,0 +1,99 @@ +import classnames from 'classnames'; +import type { HTMLAttributes, ReactNode, MouseEvent } from 'react'; +import type { JSXElement } from '../../types/jsx-element'; +import { Button } from '../Buttons/button'; +import type { ButtonProperties } from '../Buttons/button'; +import './tab.scss'; + +export interface TabProperties extends ButtonProperties { + /** + * Id for the tab. Allows it to be associated with its content panel. + */ + id: string; + /** + * Any additional classes for the tab + */ + className?: string; + /** + * Whether this is the active tab + */ + isActive?: boolean; + /** + * Any children to render within the tab. Allows you to wrap any node with tab tag + */ + children?: ReactNode; + /** + * Optional click handler + */ + onClick?: (event: MouseEvent) => void; +} + +export const Tab = ({ + id, + className, + isActive, + onClick = () => null, + children, + ...properties +}: TabProperties): JSXElement => { + const cname = classnames('tab', className, {'tab--active': isActive}); + + return ( + + ); +}; + +export interface TabListProperties extends HTMLAttributes { + className?: string; + children?: ReactNode; +} + +export const TabList = ({ + className, + children, + ...properties +}: TabListProperties): JSXElement => { + const cname = classnames('tablist', className); + + return ( +
+ {children} +
+ ); +}; + +export interface TabPanelProperties extends HTMLAttributes { + id: string; + className?: string; + children?: ReactNode; +} + +export const TabPanel = ({ + id, + className, + children, + ...properties +}: TabPanelProperties): JSXElement => { + const cname = classnames('tab-panel', className); + + return ( +
+ {children} +
+ ); +}; diff --git a/src/index.ts b/src/index.ts index 0931563191..c505308da9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ export { SelectMulti } from './components/Select/select-multi'; export { SelectSingle } from './components/Select/select-single'; export { default as SkipNav } from './components/SkipNav/skip-nav'; export { Summary } from './components/Summaries/summary'; +export { Tab, TabList, TabPanel } from './components/Tabs/tab'; export { Table } from './components/Table/table'; export { TextArea } from './components/TextInput/text-area'; export { TextInput } from './components/TextInput/text-input'; From dd9ac6aa865e4d749ef6b2d2f85c1cae3e75873a Mon Sep 17 00:00:00 2001 From: Virginia Czosek Date: Thu, 14 May 2026 17:28:15 -0700 Subject: [PATCH 2/3] Omit unnecessary button props from tab --- src/components/Tabs/tab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Tabs/tab.tsx b/src/components/Tabs/tab.tsx index db43ab544c..1c0817b6f5 100644 --- a/src/components/Tabs/tab.tsx +++ b/src/components/Tabs/tab.tsx @@ -5,7 +5,7 @@ import { Button } from '../Buttons/button'; import type { ButtonProperties } from '../Buttons/button'; import './tab.scss'; -export interface TabProperties extends ButtonProperties { +export interface TabProperties extends Omit { /** * Id for the tab. Allows it to be associated with its content panel. */ From e091236e1f1e55bf276a446c1f65377f28019ec9 Mon Sep 17 00:00:00 2001 From: Virginia Czosek Date: Thu, 14 May 2026 17:36:49 -0700 Subject: [PATCH 3/3] Update tab story --- src/components/Tabs/tab.stories.tsx | 78 +++++++++++++---------------- src/components/Tabs/tab.tsx | 2 +- 2 files changed, 37 insertions(+), 43 deletions(-) diff --git a/src/components/Tabs/tab.stories.tsx b/src/components/Tabs/tab.stories.tsx index da3c101b5a..0a62665d73 100644 --- a/src/components/Tabs/tab.stories.tsx +++ b/src/components/Tabs/tab.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; import { Heading, Tab, TabList, TabPanel } from '~/src/index'; -import type { JSXElement } from '../../types/jsx-element'; const meta: Meta = { title: 'Components (Draft)/Tabs', @@ -14,48 +13,43 @@ export default meta; type Story = StoryObj; -const TestTabs = (): JSXElement => { - const [activeTab, setActiveTab] = useState('one') - - const onClick = (event: React.MouseEvent) => { - setActiveTab(event.currentTarget.value); - } - return ( - <> - - - - - - - Panel {activeTab} - - - ); -}; - export const Default: Story = { name: 'Tabs', - render: () => ( - - ), + render: () => { + const [activeTab, setActiveTab] = useState('one') + const onClick = (event: React.MouseEvent) => { + setActiveTab(event.currentTarget.value); + } + return ( + <> + + + + + + + Panel {activeTab} + + + ) + } }; diff --git a/src/components/Tabs/tab.tsx b/src/components/Tabs/tab.tsx index 1c0817b6f5..d3fecd3392 100644 --- a/src/components/Tabs/tab.tsx +++ b/src/components/Tabs/tab.tsx @@ -5,7 +5,7 @@ import { Button } from '../Buttons/button'; import type { ButtonProperties } from '../Buttons/button'; import './tab.scss'; -export interface TabProperties extends Omit { +export interface TabProperties extends Omit { /** * Id for the tab. Allows it to be associated with its content panel. */