diff options
Diffstat (limited to 'src/components/molecules/layout')
| -rw-r--r-- | src/components/molecules/layout/card.module.scss | 77 | ||||
| -rw-r--r-- | src/components/molecules/layout/card.stories.tsx | 102 | ||||
| -rw-r--r-- | src/components/molecules/layout/card.test.tsx | 52 | ||||
| -rw-r--r-- | src/components/molecules/layout/card.tsx | 114 | 
4 files changed, 345 insertions, 0 deletions
| diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss new file mode 100644 index 0000000..2b1b7dc --- /dev/null +++ b/src/components/molecules/layout/card.module.scss @@ -0,0 +1,77 @@ +@use "@styles/abstracts/functions" as fun; + +.wrapper { +  --scale-up: 1.05; +  --scale-down: 0.95; + +  display: flex; +  flex-flow: column wrap; +  max-width: var(--card-width, 40ch); +  padding: 0; +  text-align: center; + +  .article { +    flex: 1; +    display: flex; +    flex-flow: column nowrap; +    justify-content: flex-start; +  } + +  .footer { +    margin-top: var(--spacing-md); +  } + +  .cover { +    align-self: flex-start; +    max-height: fun.convert-px(150); +    margin: auto; +    border-bottom: fun.convert-px(1) solid var(--color-border); +  } + +  .title, +  .tagline, +  .footer { +    padding: 0 var(--spacing-md); +  } + +  .title { +    flex: 1; +    margin: var(--spacing-sm) 0; +  } + +  h2.title { +    background: none; +    text-shadow: none; +  } + +  .tagline { +    flex: 1; +    color: var(--color-fg); +    font-weight: 400; +  } + +  .list { +    margin-bottom: var(--spacing-md); +  } + +  .items { +    flex-flow: row wrap; +    place-content: center; +    gap: var(--spacing-2xs); +  } + +  .term { +    flex: 0 0 100%; +  } + +  .description { +    padding: fun.convert-px(2) var(--spacing-xs); +    border: fun.convert-px(1) solid var(--color-primary-darker); +    color: var(--color-fg); +    font-weight: 400; + +    &::before { +      display: none; +    } +  } +} diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx new file mode 100644 index 0000000..a07f8dc --- /dev/null +++ b/src/components/molecules/layout/card.stories.tsx @@ -0,0 +1,102 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import CardComponent from './card'; + +export default { +  title: 'Molecules/Layout', +  component: CardComponent, +  argTypes: { +    cover: { +      description: 'The card cover data (src, dimensions, alternative text).', +      table: { +        category: 'Options', +      }, +      type: { +        name: 'object', +        required: false, +        value: {}, +      }, +    }, +    meta: { +      description: 'The card metadata (a publication date for example).', +      table: { +        category: 'Options', +      }, +      type: { +        name: 'object', +        required: false, +        value: {}, +      }, +    }, +    tagline: { +      control: { +        type: 'text', +      }, +      description: 'A few words about the card.', +      table: { +        category: 'Options', +      }, +      type: { +        name: 'string', +        required: false, +      }, +    }, +    title: { +      control: { +        type: 'text', +      }, +      description: 'The card title.', +      type: { +        name: 'string', +        required: true, +      }, +    }, +    titleLevel: { +      control: { +        type: 'number', +      }, +      description: 'The title level.', +      type: { +        name: 'number', +        required: true, +      }, +    }, +    url: { +      control: { +        type: 'text', +      }, +      description: 'The card target.', +      type: { +        name: 'string', +        required: true, +      }, +    }, +  }, +} as ComponentMeta<typeof CardComponent>; + +const Template: ComponentStory<typeof CardComponent> = (args) => ( +  <CardComponent {...args} /> +); + +const cover = { +  alt: 'A picture', +  height: 480, +  src: 'http://placeimg.com/640/480', +  width: 640, +}; + +const meta = [ +  { +    id: 'an-id', +    term: 'Voluptates', +    value: ['Autem', 'Eos'], +  }, +]; + +export const Card = Template.bind({}); +Card.args = { +  cover, +  meta, +  title: 'Veritatis dicta quod', +  titleLevel: 2, +  url: '#', +}; diff --git a/src/components/molecules/layout/card.test.tsx b/src/components/molecules/layout/card.test.tsx new file mode 100644 index 0000000..404bc7a --- /dev/null +++ b/src/components/molecules/layout/card.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@test-utils'; +import Card from './card'; + +const cover = { +  alt: 'A picture', +  height: 480, +  src: 'http://placeimg.com/640/480', +  width: 640, +}; + +const meta = [ +  { +    id: 'an-id', +    term: 'Voluptates', +    value: ['Autem', 'Eos'], +  }, +]; + +const tagline = 'Ut rerum incidunt'; + +const title = 'Alias qui porro'; + +const url = '/an-existing-url'; + +describe('Card', () => { +  it('renders a title wrapped in h2 element', () => { +    render(<Card title={title} titleLevel={2} url={url} />); +    expect( +      screen.getByRole('heading', { level: 2, name: title }) +    ).toBeInTheDocument(); +  }); + +  it('renders a link to another page', () => { +    render(<Card title={title} titleLevel={2} url={url} />); +    expect(screen.getByRole('link')).toHaveAttribute('href', url); +  }); + +  it('renders a cover', () => { +    render(<Card title={title} titleLevel={2} url={url} cover={cover} />); +    expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); +  }); + +  it('renders a tagline', () => { +    render(<Card title={title} titleLevel={2} url={url} tagline={tagline} />); +    expect(screen.getByText(tagline)).toBeInTheDocument(); +  }); + +  it('renders some meta', () => { +    render(<Card title={title} titleLevel={2} url={url} meta={meta} />); +    expect(screen.getByText(meta[0].term)).toBeInTheDocument(); +  }); +}); diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx new file mode 100644 index 0000000..23a0e54 --- /dev/null +++ b/src/components/molecules/layout/card.tsx @@ -0,0 +1,114 @@ +import ButtonLink from '@components/atoms/buttons/button-link'; +import Heading, { type HeadingLevel } from '@components/atoms/headings/heading'; +import DescriptionList, { +  DescriptionListItem, +} from '@components/atoms/lists/description-list'; +import { VFC } from 'react'; +import ResponsiveImage, { +  ResponsiveImageProps, +} from '../images/responsive-image'; +import styles from './card.module.scss'; + +export type Cover = { +  /** +   * The cover alternative text. +   */ +  alt: string; +  /** +   * The cover height. +   */ +  height: number; +  /** +   * The cover source. +   */ +  src: string; +  /** +   * The cover width. +   */ +  width: number; +}; + +export type CardProps = { +  /** +   * Set additional classnames to the card wrapper. +   */ +  className?: string; +  /** +   * The card cover. +   */ +  cover?: Cover; +  /** +   * The cover fit. Default: cover. +   */ +  coverFit?: ResponsiveImageProps['objectFit']; +  /** +   * The card meta. +   */ +  meta?: DescriptionListItem[]; +  /** +   * The card tagline. +   */ +  tagline?: string; +  /** +   * The card title. +   */ +  title: string; +  /** +   * The title level (hn). +   */ +  titleLevel: HeadingLevel; +  /** +   * The card target. +   */ +  url: string; +}; + +/** + * Card component + * + * Render a link with minimal information about its content. + */ +const Card: VFC<CardProps> = ({ +  className = '', +  cover, +  coverFit = 'cover', +  meta, +  tagline, +  title, +  titleLevel, +  url, +}) => { +  return ( +    <ButtonLink target={url} className={`${styles.wrapper} ${className}`}> +      <article className={styles.article}> +        <header className={styles.header}> +          {cover && ( +            <ResponsiveImage +              {...cover} +              objectFit={coverFit} +              className={styles.cover} +            /> +          )} +          <Heading level={titleLevel} className={styles.title}> +            {title} +          </Heading> +        </header> +        <div className={styles.tagline}>{tagline}</div> +        {meta && ( +          <footer className={styles.footer}> +            <DescriptionList +              items={meta} +              layout="inline" +              className={styles.list} +              groupClassName={styles.items} +              termClassName={styles.term} +              descriptionClassName={styles.description} +            /> +          </footer> +        )} +      </article> +    </ButtonLink> +  ); +}; + +export default Card; | 
