diff --git a/packages/react-component-library/src/components/NavigationCard/NavigationCard.stories.tsx b/packages/react-component-library/src/components/NavigationCard/NavigationCard.stories.tsx new file mode 100644 index 000000000..92b318997 --- /dev/null +++ b/packages/react-component-library/src/components/NavigationCard/NavigationCard.stories.tsx @@ -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 + +export const Default: StoryFn = (args) => ( + +) + +Default.args = { + link: Received files, + description: 'Browse and download all received files.', + icon: , + color: NAVIGATION_CARD_COLOR.ACTION, +} + +Default.argTypes = { + color: { + control: 'select', + options: [...Object.values(NAVIGATION_CARD_COLOR)], + }, +} + +export const WithoutIcon: StoryFn = () => ( + Inbound transfers} + description="Monitor files currently being received." + /> +) + +export const Colours: StoryFn = () => ( +
+ } + link={Received files} + description="Browse and download all received files." + /> + } + link={Upload files} + description="Add new files ready to send to a recipient." + /> + } + link={Inbound transfers} + description="Monitor files currently being received." + /> + } + link={Files sent} + description="View a history of dispatched files." + /> + } + link={Outbound transfers} + description="Track files currently being sent." + /> +
+) + +Colours.parameters = { + docs: { + description: { story: 'A NavigationCard in each supported colour.' }, + }, +} + +export const MixedIcons: StoryFn = () => ( +
+ } + link={Received files} + description="Browse and download all received files." + /> + Inbound transfers} + description="Monitor files currently being received." + /> +
+) + +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.', + }, + }, +} diff --git a/packages/react-component-library/src/components/NavigationCard/NavigationCard.test.tsx b/packages/react-component-library/src/components/NavigationCard/NavigationCard.test.tsx new file mode 100644 index 000000000..4338def4e --- /dev/null +++ b/packages/react-component-library/src/components/NavigationCard/NavigationCard.test.tsx @@ -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(File library} />) + + expect(screen.getByRole('link', { name: 'File library' })).toHaveAttribute( + 'href', + '/library' + ) + }) + + it('renders the optional description', () => { + render( + File library} + 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( + File library} + icon={} + /> + ) + + 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(File library} />) + + 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(File library} />) + + expect(screen.getByTestId('navigation-card-chevron')).toBeInTheDocument() + }) + + it('applies the injected custom class to the wrapper', () => { + render( + File library} + /> + ) + + expect(screen.getByTestId('navigation-card').classList).toContain( + 'example-class' + ) + }) + + it('spreads arbitrary props onto the wrapper', () => { + render( + File library} + /> + ) + + 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( + + File library + + } + /> + ) + + 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( + } + link={File library} + /> + ) + + expect(screen.getByTestId('navigation-card-icon')).toHaveStyleRule( + 'background-color', + color(cardColor, '100') + ) + }) +}) diff --git a/packages/react-component-library/src/components/NavigationCard/NavigationCard.tsx b/packages/react-component-library/src/components/NavigationCard/NavigationCard.tsx new file mode 100644 index 000000000..6d2275aca --- /dev/null +++ b/packages/react-component-library/src/components/NavigationCard/NavigationCard.tsx @@ -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 { + /** + * 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 = ({ + link, + icon, + description, + color = NAVIGATION_CARD_COLOR.ACTION, + ...rest +}) => ( + + + {icon} + + + {link} + {description && {description}} + + + + + +) + +NavigationCard.displayName = 'NavigationCard' diff --git a/packages/react-component-library/src/components/NavigationCard/constants.ts b/packages/react-component-library/src/components/NavigationCard/constants.ts new file mode 100644 index 000000000..67ecd6901 --- /dev/null +++ b/packages/react-component-library/src/components/NavigationCard/constants.ts @@ -0,0 +1,9 @@ +const NAVIGATION_CARD_COLOR = { + ACTION: 'action', + SUCCESS: 'success', + WARNING: 'warning', + DANGER: 'danger', + NEUTRAL: 'neutral', +} as const + +export { NAVIGATION_CARD_COLOR } diff --git a/packages/react-component-library/src/components/NavigationCard/index.ts b/packages/react-component-library/src/components/NavigationCard/index.ts new file mode 100644 index 000000000..4739578a4 --- /dev/null +++ b/packages/react-component-library/src/components/NavigationCard/index.ts @@ -0,0 +1,3 @@ +export * from './NavigationCard' +export * from './constants' +export * from './types' diff --git a/packages/react-component-library/src/components/NavigationCard/partials/StyledChevron.tsx b/packages/react-component-library/src/components/NavigationCard/partials/StyledChevron.tsx new file mode 100644 index 000000000..cc0f575e3 --- /dev/null +++ b/packages/react-component-library/src/components/NavigationCard/partials/StyledChevron.tsx @@ -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` + display: inline-flex; + flex-shrink: 0; + margin-left: ${spacing('4')}; + color: ${({ $color }) => color($color, '600')}; + transition: transform 150ms ease; + + svg { + width: 24px; + height: 24px; + } +` diff --git a/packages/react-component-library/src/components/NavigationCard/partials/StyledContent.tsx b/packages/react-component-library/src/components/NavigationCard/partials/StyledContent.tsx new file mode 100644 index 000000000..c6352c543 --- /dev/null +++ b/packages/react-component-library/src/components/NavigationCard/partials/StyledContent.tsx @@ -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; +` diff --git a/packages/react-component-library/src/components/NavigationCard/partials/StyledDescription.tsx b/packages/react-component-library/src/components/NavigationCard/partials/StyledDescription.tsx new file mode 100644 index 000000000..b1943686e --- /dev/null +++ b/packages/react-component-library/src/components/NavigationCard/partials/StyledDescription.tsx @@ -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; +` diff --git a/packages/react-component-library/src/components/NavigationCard/partials/StyledIconPanel.tsx b/packages/react-component-library/src/components/NavigationCard/partials/StyledIconPanel.tsx new file mode 100644 index 000000000..fbbc47762 --- /dev/null +++ b/packages/react-component-library/src/components/NavigationCard/partials/StyledIconPanel.tsx @@ -0,0 +1,32 @@ +import { color } from '@royalnavy/design-tokens' +import styled from 'styled-components' + +import { NavigationCardColorType } from '../types' + +interface StyledIconPanelProps { + $color: NavigationCardColorType + $empty: boolean +} + +/** + * The icon panel always occupies the same space, even when `$empty`, so cards + * with and without an icon stay left-aligned and equal-height within a grid. + * When empty it is transparent (no tint) rather than removed. + */ +export const StyledIconPanel = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 96px; + height: 96px; + border-radius: 8px; + color: ${({ $color }) => color($color, '600')}; + background-color: ${({ $empty, $color }) => + $empty ? 'transparent' : color($color, '100')}; + + svg { + width: 44px; + height: 44px; + } +` diff --git a/packages/react-component-library/src/components/NavigationCard/partials/StyledNavigationCard.tsx b/packages/react-component-library/src/components/NavigationCard/partials/StyledNavigationCard.tsx new file mode 100644 index 000000000..4d4d8d8eb --- /dev/null +++ b/packages/react-component-library/src/components/NavigationCard/partials/StyledNavigationCard.tsx @@ -0,0 +1,32 @@ +import { color, shadow, spacing } from '@royalnavy/design-tokens' +import styled from 'styled-components' + +import { CardFrame } from '../../CardFrame' +import { NavigationCardColorType } from '../types' +import { StyledChevron } from './StyledChevron' + +interface StyledNavigationCardProps { + $color: NavigationCardColorType +} + +export const StyledNavigationCard = styled( + CardFrame +)` + position: relative; + display: flex; + align-items: center; + gap: ${spacing('5')}; + height: 100%; + padding: ${spacing('6')}; + transition: box-shadow 150ms ease, border-color 150ms ease; + + &:hover, + &:focus-within { + border-color: ${({ $color }) => color($color, '600')}; + box-shadow: ${shadow('2')}; + } + + &:hover ${StyledChevron}, &:focus-within ${StyledChevron} { + transform: translateX(4px); + } +` diff --git a/packages/react-component-library/src/components/NavigationCard/partials/StyledTitle.tsx b/packages/react-component-library/src/components/NavigationCard/partials/StyledTitle.tsx new file mode 100644 index 000000000..6585cc1e4 --- /dev/null +++ b/packages/react-component-library/src/components/NavigationCard/partials/StyledTitle.tsx @@ -0,0 +1,41 @@ +import { color, fontSize } from '@royalnavy/design-tokens' +import styled from 'styled-components' + +import { NavigationCardColorType } from '../types' + +interface StyledTitleProps { + $color: NavigationCardColorType +} + +/** + * Wraps the consumer-supplied link. The descendant anchor is styled as the + * card title and given a stretched `::after` overlay so the entire card is + * clickable while remaining a single link in the accessibility tree. + */ +export const StyledTitle = styled.span` + font-size: ${fontSize('l')}; + font-weight: 700; + line-height: 1.3; + + a { + color: ${color('neutral', '700')}; + text-decoration: none; + outline: 0; + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 3px; + } + + &:hover, + &:focus-visible { + text-decoration: none; + } + + &:focus-visible::after { + box-shadow: 0 0 0 3px ${({ $color }) => color($color, '200')}; + } + } +` diff --git a/packages/react-component-library/src/components/NavigationCard/partials/index.ts b/packages/react-component-library/src/components/NavigationCard/partials/index.ts new file mode 100644 index 000000000..e723e660d --- /dev/null +++ b/packages/react-component-library/src/components/NavigationCard/partials/index.ts @@ -0,0 +1,6 @@ +export { StyledChevron } from './StyledChevron' +export { StyledContent } from './StyledContent' +export { StyledDescription } from './StyledDescription' +export { StyledIconPanel } from './StyledIconPanel' +export { StyledNavigationCard } from './StyledNavigationCard' +export { StyledTitle } from './StyledTitle' diff --git a/packages/react-component-library/src/components/NavigationCard/types.ts b/packages/react-component-library/src/components/NavigationCard/types.ts new file mode 100644 index 000000000..99e1de752 --- /dev/null +++ b/packages/react-component-library/src/components/NavigationCard/types.ts @@ -0,0 +1,4 @@ +import { ValueOf } from '../../helpers' +import { NAVIGATION_CARD_COLOR } from './constants' + +export type NavigationCardColorType = ValueOf diff --git a/packages/react-component-library/src/index.ts b/packages/react-component-library/src/index.ts index 68b8bf3ce..38aa08493 100755 --- a/packages/react-component-library/src/index.ts +++ b/packages/react-component-library/src/index.ts @@ -30,6 +30,7 @@ export * from './components/Forms' export * from './components/Link' export * from './components/List' export * from './components/Modal' +export * from './components/NavigationCard' export * from './components/NumberInput' export * from './components/Pagination' export * from './components/Panel'