Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/core/folder-tabs/__story__/useFolderTabsContainerDecorator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { FOLDER_TABS_CSS_CONTAINER_NAME } from '../constants'

import type { Decorator } from '@storybook/react'

export function useFolderTabsContainerDecorator(): Decorator {
return (Story) => (
<div style={{ containerName: FOLDER_TABS_CSS_CONTAINER_NAME, containerType: 'inline-size' }}>
<Story />
</div>
)
}
29 changes: 29 additions & 0 deletions src/core/folder-tabs/__tests__/folder-tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { FolderTabs } from '../folder-tabs'
import { render, screen } from '@testing-library/react'
import { FolderTab } from '../tab'
import { FolderTabCountLabel } from '../count-label'

test('renders a navigation element with a child group', () => {
render(<FolderTabs>Tabs</FolderTabs>)
expect(screen.getByRole('navigation')).toBeVisible()
expect(screen.getByRole('group')).toBeVisible()
expect(screen.getByRole('navigation').firstElementChild).toBe(screen.getByRole('group'))
})

test('displays provided content', () => {
render(<FolderTabs>Tabs</FolderTabs>)
expect(screen.getByText('Tabs')).toBeVisible()
})

test('forwards additional props to the navigation element', () => {
render(<FolderTabs data-testid="test-id">Tabs</FolderTabs>)
expect(screen.getByTestId('test-id')).toBe(screen.getByRole('navigation'))
})

test('provides `FolderTabs.Item` subcomponent', () => {
expect(FolderTabs.Item).toBe(FolderTab)
})

test('provides `FolderTabs.CountLabel` subcomponent', () => {
expect(FolderTabs.CountLabel).toBe(FolderTabCountLabel)
})
5 changes: 5 additions & 0 deletions src/core/folder-tabs/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { isWidthAtOrAbove, isWidthBelow } from '#src/utils/index'

export const FOLDER_TABS_CSS_CONTAINER_NAME = '--folder-tabs-container'
export const FOLDER_TABS_LARGE_CONTAINER_QUERY = `@container ${FOLDER_TABS_CSS_CONTAINER_NAME} ${isWidthAtOrAbove('SM')}`
export const FOLDER_TABS_SMALL_CONTAINER_QUERY = `@container ${FOLDER_TABS_CSS_CONTAINER_NAME} ${isWidthBelow('SM')}`
26 changes: 26 additions & 0 deletions src/core/folder-tabs/count-label/__tests__/count-label.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react'
import { FolderTabCountLabel } from '../count-label'

test('renders a span element', () => {
const { container } = render(<FolderTabCountLabel count="00">Label</FolderTabCountLabel>)
expect(container.firstElementChild?.tagName).toBe('SPAN')
})

test('displays count', () => {
render(<FolderTabCountLabel count="10">Label</FolderTabCountLabel>)
expect(screen.getByText('10')).toBeVisible()
})

test('displays children', () => {
render(<FolderTabCountLabel count="10">Label</FolderTabCountLabel>)
expect(screen.getByText('Label')).toBeVisible()
})

test('additional props are forwarded to the root span element', () => {
const { container } = render(
<FolderTabCountLabel data-testid="test-id" count="00">
Label
</FolderTabCountLabel>,
)
expect(screen.getByTestId('test-id')).toBe(container.firstElementChild)
})
94 changes: 94 additions & 0 deletions src/core/folder-tabs/count-label/count-label.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { FOLDER_TABS_CSS_CONTAINER_NAME } from '../constants'
import { FolderTabCountLabel } from './count-label'
import { useFolderTabsContainerDecorator } from '../__story__/useFolderTabsContainerDecorator'

import type { Meta, StoryObj } from '@storybook/react-vite'

const meta = {
title: 'Core/FolderTabs/CountLabel',
component: FolderTabCountLabel,
} satisfies Meta<typeof FolderTabCountLabel>

export default meta

type Story = StoryObj<typeof FolderTabCountLabel>

/**
* By default, the count will expand to fill the available space of it's container.
*/
export const Example: Story = {
args: {
children: 'Label',
count: '00',
},
decorators: [useFolderTabsContainerDecorator()],
}

/**
* While labels should be concise to avoid overflow, if there is not enough space available, the label
* text will be permitted to wrap to a second line when the tabs container is large enough.
*/
export const Wrapping: Story = {
args: {
...Example.args,
children: "A long tab label that will wrap to a second line but won't truncate",
},
decorators: [
(Story) => (
<div
style={{
boxSizing: 'content-box',
border: '1px solid #FA00FF',
containerName: FOLDER_TABS_CSS_CONTAINER_NAME,
containerType: 'inline-size',
display: 'grid',
gridTemplateColumns: '50% 50%',
width: '768px',
}}
>
<Story />
</div>
),
],
}

/**
* If there is not enough space, even after wrapping is permitted in large containers, the text will
* be truncated.
*/
export const Overflow: Story = {
args: {
...Example.args,
children: 'A very very very long tab label that will need to wrap to additional lines and may even be truncated',
},
decorators: [
(Story) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-4)' }}>
<div
style={{
boxSizing: 'content-box',
border: '1px solid #FA00FF',
containerName: FOLDER_TABS_CSS_CONTAINER_NAME,
containerType: 'inline-size',
display: 'grid',
gridTemplateColumns: '50% 50%',
width: '768px',
}}
>
<Story />
</div>
<div
style={{
boxSizing: 'content-box',
border: '1px solid #FA00FF',
containerName: FOLDER_TABS_CSS_CONTAINER_NAME,
containerType: 'inline-size',
width: '300px',
}}
>
<Story />
</div>
</div>
),
],
}
23 changes: 23 additions & 0 deletions src/core/folder-tabs/count-label/count-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ElFolderTabCountContainer, ElFolderTabCountLabel, ElFolderTabCountText } from './styles'
import type { HTMLAttributes, ReactNode } from 'react'

interface FolderTabCountLabelProps extends HTMLAttributes<HTMLSpanElement> {
/** The label text. */
children: ReactNode
/** The featured numerical count. */
count: ReactNode
}

/**
* Displays a numerical count alongside a label for a folder tab. Typically used via `FolderTabs.Count`.
* This is used to label tabs that need to indicate the number of associated items it contains, such as
* the number of transactions ready for processing.
*/
export function FolderTabCountLabel({ children, count, ...rest }: FolderTabCountLabelProps) {
return (
<ElFolderTabCountContainer {...rest}>
<ElFolderTabCountText>{count}</ElFolderTabCountText>
<ElFolderTabCountLabel>{children}</ElFolderTabCountLabel>
</ElFolderTabCountContainer>
)
}
2 changes: 2 additions & 0 deletions src/core/folder-tabs/count-label/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './count-label'
export * from './styles'
47 changes: 47 additions & 0 deletions src/core/folder-tabs/count-label/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { FOLDER_TABS_LARGE_CONTAINER_QUERY } from '../constants'
import { font } from '#src/core/text'
import { styled } from '@linaria/react'

export const ElFolderTabCountContainer = styled.span`
position: relative;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: center;
gap: var(--spacing-2);

${FOLDER_TABS_LARGE_CONTAINER_QUERY} {
gap: var(--spacing-3);
}
`

export const ElFolderTabCountText = styled.span`
${font('base', 'bold')}

${FOLDER_TABS_LARGE_CONTAINER_QUERY} {
${font('3xl', 'bold')}
}
`

export const ElFolderTabCountLabel = styled.span`
${font('sm', 'medium')}
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;

${FOLDER_TABS_LARGE_CONTAINER_QUERY} {
/* NOTE: See https://developer.mozilla.org/en-US/docs/Web/CSS/line-clamp for more details.
* Specifically, all major browsers suppor the property, but explicitly with the -webkit-*
* prefix. Same deal with the -webkit-box display property and -webkit-box-orient.
*
* We could use -webkit-line-clamp: 1 for the small tab styles above, but it using standard
* inline text overflow is more reliable cross-browser. */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;

${font('base', 'medium')}
white-space: normal;
text-align: start;
}
`
90 changes: 90 additions & 0 deletions src/core/folder-tabs/folder-tabs.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { FolderTabs } from './folder-tabs'

import type { Meta, StoryObj } from '@storybook/react-vite'

const href = globalThis.top?.location.href!

const meta = {
title: 'Core/FolderTabs',
component: FolderTabs,
argTypes: {
children: {
control: 'radio',
options: ['Two tabs', 'Three tabs', 'Many tabs', 'With counts'],
mapping: {
'Two tabs': buildTabs('Two tabs'),
'Three tabs': buildTabs('Three tabs'),
'Many tabs': buildTabs('Many tabs'),
'With counts': buildTabs('With counts'),
},
},
},
globals: {
backgrounds: {
value: 'light',
},
},
} satisfies Meta<typeof FolderTabs>

export default meta

type Story = StoryObj<typeof meta>

export const Example: Story = {
args: {
children: 'Many tabs',
},
}

/**
* When the tabs are constrained by their container, they will display in a compact vertical layout
* that is appropriate for smaller screens. This occurs when the container's inline width is less than
* the equivalent `SM` breakpoint minimum width.
*/
export const Breakpoints: Story = {
args: {
children: 'With counts',
},
decorators: [
(Story) => {
return (
<div style={{ border: '1px solid #FA00FF', width: '397px' }}>
<Story />
</div>
)
},
],
}

function buildTabs(type: 'Two tabs' | 'Three tabs' | 'Many tabs' | 'With counts') {
const renderLabel = (label: string) => {
return type === 'With counts' ? <FolderTabs.CountLabel count="00">{label}</FolderTabs.CountLabel> : label
}
return (
<>
<FolderTabs.Item key="apples" href={href} aria-current={false}>
{renderLabel('Apples')}
</FolderTabs.Item>
<FolderTabs.Item key="bananas" aria-current="page" href={href}>
{renderLabel('Bananas')}
</FolderTabs.Item>
{type !== 'Two tabs' && (
<>
<FolderTabs.Item key="peaches" aria-current={false} href={href}>
{renderLabel('Peaches')}
</FolderTabs.Item>
{type !== 'Three tabs' && (
<>
<FolderTabs.Item key="strawberries" aria-current={false} href={href}>
{renderLabel('Strawberries')}
</FolderTabs.Item>
<FolderTabs.Item key="watermelon" aria-current={false} href={href}>
{renderLabel('Watermelon')}
</FolderTabs.Item>
</>
)}
</>
)}
</>
)
}
25 changes: 25 additions & 0 deletions src/core/folder-tabs/folder-tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ElFolderTabs, ElFolderTabsGroup } from './styles'
import { FolderTab } from './tab'
import { FolderTabCountLabel } from './count-label'

import type { HTMLAttributes, ReactNode } from 'react'

interface FolderTabsProps extends HTMLAttributes<HTMLElement> {
/** The tab items for primary navigation. Typically a collection of `FolderTabs.Item` components. */
children: ReactNode
}

/**
* A navigation container for primary tabs. Typically used with `FolderTabs.Item`
* and `FolderTabs.CountLabel`.
*/
export function FolderTabs({ children, ...rest }: FolderTabsProps) {
return (
<ElFolderTabs {...rest}>
<ElFolderTabsGroup role="group">{children}</ElFolderTabsGroup>
</ElFolderTabs>
)
}

FolderTabs.Item = FolderTab
FolderTabs.CountLabel = FolderTabCountLabel
4 changes: 4 additions & 0 deletions src/core/folder-tabs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './count-label'
export * from './folder-tabs'
export * from './styles'
export * from './tab'
22 changes: 22 additions & 0 deletions src/core/folder-tabs/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { FOLDER_TABS_CSS_CONTAINER_NAME, FOLDER_TABS_LARGE_CONTAINER_QUERY } from './constants'
import { styled } from '@linaria/react'

export const ElFolderTabs = styled.nav`
container-name: ${FOLDER_TABS_CSS_CONTAINER_NAME};
container-type: inline-size;
`

export const ElFolderTabsGroup = styled.div`
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: start;

width: 100%;

${FOLDER_TABS_LARGE_CONTAINER_QUERY} {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
}
`
Loading