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
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {
IconFileDownload,
IconFileUpload,
IconMoveToInbox,
IconSend,
} from '@royalnavy/icon-library'
import { Meta, StoryFn } from '@storybook/react-webpack5'
import React from 'react'

import { NAVIGATION_CARD_COLOR, NavigationCard } from '.'

export default {
component: NavigationCard,
title: 'Components/Navigation Card',
} as Meta<typeof NavigationCard>

export const Default: StoryFn<typeof NavigationCard> = (args) => (
<NavigationCard {...args} />
)

Default.args = {
link: <a href="#">Received files</a>,
description: 'Browse and download all received files.',
icon: <IconFileDownload />,
color: NAVIGATION_CARD_COLOR.ACTION,
}

Default.argTypes = {
color: {
control: 'select',
options: [...Object.values(NAVIGATION_CARD_COLOR)],
},
}

export const WithoutIcon: StoryFn<typeof NavigationCard> = () => (
<NavigationCard
link={<a href="#">Inbound transfers</a>}
description="Monitor files currently being received."
/>
)

export const Colours: StoryFn<typeof NavigationCard> = () => (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
gap: '24px',
}}
>
<NavigationCard
color={NAVIGATION_CARD_COLOR.ACTION}
icon={<IconFileDownload />}
link={<a href="#">Received files</a>}
description="Browse and download all received files."
/>
<NavigationCard
color={NAVIGATION_CARD_COLOR.SUCCESS}
icon={<IconFileUpload />}
link={<a href="#">Upload files</a>}
description="Add new files ready to send to a recipient."
/>
<NavigationCard
color={NAVIGATION_CARD_COLOR.WARNING}
icon={<IconMoveToInbox />}
link={<a href="#">Inbound transfers</a>}
description="Monitor files currently being received."
/>
<NavigationCard
color={NAVIGATION_CARD_COLOR.DANGER}
icon={<IconSend />}
link={<a href="#">Files sent</a>}
description="View a history of dispatched files."
/>
<NavigationCard
color={NAVIGATION_CARD_COLOR.NEUTRAL}
icon={<IconMoveToInbox />}
link={<a href="#">Outbound transfers</a>}
description="Track files currently being sent."
/>
</div>
)

Colours.parameters = {
docs: {
description: { story: 'A NavigationCard in each supported colour.' },
},
}

export const MixedIcons: StoryFn<typeof NavigationCard> = () => (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
gap: '24px',
}}
>
<NavigationCard
icon={<IconFileDownload />}
link={<a href="#">Received files</a>}
description="Browse and download all received files."
/>
<NavigationCard
link={<a href="#">Inbound transfers</a>}
description="Monitor files currently being received."
/>
</div>
)

MixedIcons.parameters = {
docs: {
description: {
story:
'Cards with and without an icon stay aligned and equal-height: the icon panel space is reserved even when no icon is supplied.',
},
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { color } from '@royalnavy/design-tokens'
import { IconHome } from '@royalnavy/icon-library'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'

import { NAVIGATION_CARD_COLOR } from './constants'
import { NavigationCard } from './index'

describe('NavigationCard', () => {
it('renders the title from the supplied link', () => {
render(<NavigationCard link={<a href="/library">File library</a>} />)

expect(screen.getByRole('link', { name: 'File library' })).toHaveAttribute(
'href',
'/library'
)
})

it('renders the optional description', () => {
render(
<NavigationCard
link={<a href="/library">File library</a>}
description="Browse and download all received files."
/>
)

expect(
screen.getByText('Browse and download all received files.')
).toBeInTheDocument()
})

it('renders the icon panel when an icon is provided', () => {
render(
<NavigationCard
link={<a href="/library">File library</a>}
icon={<IconHome />}
/>
)

const panel = screen.getByTestId('navigation-card-icon')

expect(panel).toBeInTheDocument()
expect(panel.querySelector('svg')).toBeInTheDocument()
})

it('reserves an empty, transparent icon panel when no icon is provided', () => {
render(<NavigationCard link={<a href="/library">File library</a>} />)

const panel = screen.getByTestId('navigation-card-icon')

expect(panel).toBeInTheDocument()
expect(panel.querySelector('svg')).not.toBeInTheDocument()
expect(panel).toHaveStyleRule('background-color', 'transparent')
})

it('renders a chevron affordance', () => {
render(<NavigationCard link={<a href="/library">File library</a>} />)

expect(screen.getByTestId('navigation-card-chevron')).toBeInTheDocument()
})

it('applies the injected custom class to the wrapper', () => {
render(
<NavigationCard
className="example-class"
link={<a href="/library">File library</a>}
/>
)

expect(screen.getByTestId('navigation-card').classList).toContain(
'example-class'
)
})

it('spreads arbitrary props onto the wrapper', () => {
render(
<NavigationCard
data-arbitrary="arbitrary"
link={<a href="/library">File library</a>}
/>
)

expect(screen.getByTestId('navigation-card')).toHaveAttribute(
'data-arbitrary',
'arbitrary'
)
})

describe('when the link is clicked', () => {
let onClickSpy: jest.Mock

beforeEach(async () => {
onClickSpy = jest.fn((event: React.MouseEvent) => event.preventDefault())

render(
<NavigationCard
link={
<a href="/library" onClick={onClickSpy}>
File library
</a>
}
/>
)

await userEvent.click(screen.getByRole('link', { name: 'File library' }))
})

it('invokes the link handler', () => {
expect(onClickSpy).toHaveBeenCalledTimes(1)
})
})

it.each([
[NAVIGATION_CARD_COLOR.ACTION],
[NAVIGATION_CARD_COLOR.SUCCESS],
[NAVIGATION_CARD_COLOR.WARNING],
[NAVIGATION_CARD_COLOR.DANGER],
[NAVIGATION_CARD_COLOR.NEUTRAL],
])('tints the icon panel with the %s colour', (cardColor) => {
render(
<NavigationCard
color={cardColor}
icon={<IconHome />}
link={<a href="/library">File library</a>}
/>
)

expect(screen.getByTestId('navigation-card-icon')).toHaveStyleRule(
'background-color',
color(cardColor, '100')
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { IconChevronRight } from '@royalnavy/icon-library'
import React from 'react'

import { ComponentWithClass } from '../../common/ComponentWithClass'
import { NAVIGATION_CARD_COLOR } from './constants'
import {
StyledChevron,
StyledContent,
StyledDescription,
StyledIconPanel,
StyledNavigationCard,
StyledTitle,
} from './partials'
import { NavigationCardColorType } from './types'

export interface NavigationCardProps
extends Omit<ComponentWithClass, 'children'> {
/**
* The link element (e.g. an anchor or router `Link`) whose text becomes the
* card title. The whole card is made clickable via this single link.
*/
link: React.ReactElement
/**
* Optional icon rendered in the coloured panel to the left of the content.
* The panel space is reserved even when no icon is supplied, so a grid can
* mix cards with and without icons and keep them aligned and equal-height.
*/
icon?: React.ReactNode
/**
* Optional supporting text displayed beneath the title.
*
* Note: the whole card is a single clickable overlay, so this text is not
* selectable. Avoid placing copyable data here (e.g. filenames, counts) —
* use it for descriptive prose only.
*/
description?: React.ReactNode
/**
* Colour applied to the icon panel, chevron and hover/focus states.
*/
color?: NavigationCardColorType
}

export const NavigationCard: React.FC<NavigationCardProps> = ({
link,
icon,
description,
color = NAVIGATION_CARD_COLOR.ACTION,
...rest
}) => (
<StyledNavigationCard data-testid="navigation-card" $color={color} {...rest}>
<StyledIconPanel
data-testid="navigation-card-icon"
$color={color}
$empty={!icon}
aria-hidden
>
{icon}
</StyledIconPanel>
<StyledContent>
<StyledTitle $color={color}>{link}</StyledTitle>
{description && <StyledDescription>{description}</StyledDescription>}
</StyledContent>
<StyledChevron
data-testid="navigation-card-chevron"
$color={color}
aria-hidden
>
<IconChevronRight />
</StyledChevron>
</StyledNavigationCard>
)

NavigationCard.displayName = 'NavigationCard'
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const NAVIGATION_CARD_COLOR = {
ACTION: 'action',
SUCCESS: 'success',
WARNING: 'warning',
DANGER: 'danger',
NEUTRAL: 'neutral',
} as const

export { NAVIGATION_CARD_COLOR }
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './NavigationCard'
export * from './constants'
export * from './types'
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { color, spacing } from '@royalnavy/design-tokens'
import styled from 'styled-components'

import { NavigationCardColorType } from '../types'

interface StyledChevronProps {
$color: NavigationCardColorType
}

export const StyledChevron = styled.span<StyledChevronProps>`
display: inline-flex;
flex-shrink: 0;
margin-left: ${spacing('4')};
color: ${({ $color }) => color($color, '600')};
transition: transform 150ms ease;

svg {
width: 24px;
height: 24px;
}
`
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { spacing } from '@royalnavy/design-tokens'
import styled from 'styled-components'

export const StyledContent = styled.span`
display: flex;
flex: 1;
flex-direction: column;
gap: ${spacing('2')};
min-width: 0;
`
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { color, fontSize } from '@royalnavy/design-tokens'
import styled from 'styled-components'

export const StyledDescription = styled.span`
color: ${color('neutral', '400')};
font-size: ${fontSize('s')};
line-height: 1.4;
`
Loading
Loading