diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-04-12 16:09:21 +0200 | 
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2022-04-12 16:09:21 +0200 | 
| commit | 27ff3104aabed240470d351bda02095d8169501f (patch) | |
| tree | e4b43947c1150201067d40622b52b65bd19f01a2 /src/components | |
| parent | ff3a251e75fafce7d95177010401483127973313 (diff) | |
chore: add a Summary component
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/atoms/buttons/button-link.stories.tsx | 13 | ||||
| -rw-r--r-- | src/components/atoms/buttons/button-link.tsx | 14 | ||||
| -rw-r--r-- | src/components/molecules/images/responsive-image.tsx | 14 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.module.scss | 84 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.stories.tsx | 114 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.test.tsx | 85 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.tsx | 105 | 
7 files changed, 424 insertions, 5 deletions
| diff --git a/src/components/atoms/buttons/button-link.stories.tsx b/src/components/atoms/buttons/button-link.stories.tsx index 6fe786b..92b7521 100644 --- a/src/components/atoms/buttons/button-link.stories.tsx +++ b/src/components/atoms/buttons/button-link.stories.tsx @@ -28,6 +28,19 @@ export default {          required: true,        },      }, +    className: { +      control: { +        type: 'text', +      }, +      description: 'Set additional classnames to the button link.', +      table: { +        category: 'Styles', +      }, +      type: { +        name: 'string', +        required: false, +      }, +    },      kind: {        control: {          type: 'select', diff --git a/src/components/atoms/buttons/button-link.tsx b/src/components/atoms/buttons/button-link.tsx index 81229c8..77a7f7b 100644 --- a/src/components/atoms/buttons/button-link.tsx +++ b/src/components/atoms/buttons/button-link.tsx @@ -8,6 +8,10 @@ export type ButtonLinkProps = {     */    'aria-label'?: string;    /** +   * Set additional classnames to the button link. +   */ +  className?: string; +  /**     * True if it is an external link. Default: false.     */    external?: boolean; @@ -18,7 +22,7 @@ export type ButtonLinkProps = {    /**     * ButtonLink shape. Default: rectangle.     */ -  shape?: 'rectangle' | 'square'; +  shape?: 'circle' | 'rectangle' | 'square';    /**     * Define an URL as target.     */ @@ -32,6 +36,7 @@ export type ButtonLinkProps = {   */  const ButtonLink: FC<ButtonLinkProps> = ({    children, +  className,    target,    kind = 'secondary',    shape = 'rectangle', @@ -44,14 +49,17 @@ const ButtonLink: FC<ButtonLinkProps> = ({    return external ? (      <a        href={target} -      className={`${styles.btn} ${kindClass} ${shapeClass}`} +      className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`}        {...props}      >        {children}      </a>    ) : (      <Link href={target}> -      <a className={`${styles.btn} ${kindClass} ${shapeClass}`} {...props}> +      <a +        className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`} +        {...props} +      >          {children}        </a>      </Link> diff --git a/src/components/molecules/images/responsive-image.tsx b/src/components/molecules/images/responsive-image.tsx index 9f96f18..3d54e95 100644 --- a/src/components/molecules/images/responsive-image.tsx +++ b/src/components/molecules/images/responsive-image.tsx @@ -13,6 +13,10 @@ type ResponsiveImageProps = Omit<ImageProps, 'alt' | 'width' | 'height'> & {     */    caption?: string;    /** +   * Set additional classnames to the figure wrapper. +   */ +  className?: string; +  /**     * The image height.     */    height: number | string; @@ -34,16 +38,22 @@ type ResponsiveImageProps = Omit<ImageProps, 'alt' | 'width' | 'height'> & {  const ResponsiveImage: VFC<ResponsiveImageProps> = ({    alt,    caption, +  className = '',    layout,    objectFit,    target,    ...props  }) => {    return ( -    <figure className={styles.wrapper}> +    <figure className={`${styles.wrapper} ${className}`}>        {target ? (          <Link href={target} className={styles.link}> -          <Image alt={alt} layout={layout || 'intrinsic'} {...props} /> +          <Image +            alt={alt} +            layout={layout || 'intrinsic'} +            objectFit={objectFit || 'contain'} +            {...props} +          />            {caption && (              <figcaption className={styles.caption}>{caption}</figcaption>            )} diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss new file mode 100644 index 0000000..5da0a18 --- /dev/null +++ b/src/components/organisms/layout/summary.module.scss @@ -0,0 +1,84 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/mixins" as mix; + +.wrapper { +  @include mix.media("screen") { +    @include mix.dimensions("xs") { +      padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-md); +      border: fun.convert-px(1) solid var(--color-primary-dark); +      border-radius: fun.convert-px(3); +      box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) 0 +          var(--color-shadow), +        fun.convert-px(3) fun.convert-px(3) fun.convert-px(3) fun.convert-px(-1) +          var(--color-shadow-light), +        fun.convert-px(5) fun.convert-px(5) fun.convert-px(7) fun.convert-px(-1) +          var(--color-shadow-light); +    } + +    @include mix.dimensions("sm") { +      display: grid; +      grid-template-columns: minmax(0, 3fr) minmax(0, 1fr); +      grid-template-rows: repeat(3, max-content); +      column-gap: var(--spacing-md); +    } +  } +} + +.cover { +  width: auto; +  max-height: fun.convert-px(100); +  max-width: 100%; +  border: fun.convert-px(1) solid var(--color-border); + +  @include mix.media("screen") { +    @include mix.dimensions("sm") { +      grid-column: 2; +      grid-row: 1; +    } +  } +} + +.header { +  @include mix.media("screen") { +    @include mix.dimensions("sm") { +      grid-column: 1; +      grid-row: 1; +      align-self: center; +    } +  } +} + +.body { +  @include mix.media("screen") { +    @include mix.dimensions("sm") { +      grid-column: 1; +      grid-row: 2; +    } +  } +} + +.footer { +  @include mix.media("screen") { +    @include mix.dimensions("sm") { +      grid-column: 2; +      grid-row: 2 / 4; +    } +  } +} + +.title { +  background: none; +  text-shadow: none; +} + +.read-more { +  display: flex; +  flex-flow: row nowrap; +  column-gap: var(--spacing-xs); +  width: max-content; +  margin: var(--spacing-sm) 0; +} + +.meta { +  font-size: var(--font-size-sm); +} diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx new file mode 100644 index 0000000..5214d70 --- /dev/null +++ b/src/components/organisms/layout/summary.stories.tsx @@ -0,0 +1,114 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import SummaryComponent from './summary'; + +export default { +  title: 'Organisms/Layout', +  component: SummaryComponent, +  args: { +    titleLevel: 2, +  }, +  argTypes: { +    cover: { +      description: 'The cover data.', +      table: { +        category: 'Options', +      }, +      type: { +        name: 'object', +        required: false, +        value: {}, +      }, +    }, +    excerpt: { +      control: { +        type: 'text', +      }, +      description: 'The page excerpt.', +      type: { +        name: 'string', +        required: true, +      }, +    }, +    meta: { +      description: 'The page metadata.', +      type: { +        name: 'object', +        required: true, +        value: {}, +      }, +    }, +    title: { +      control: { +        type: 'text', +      }, +      description: 'The page title', +      type: { +        name: 'string', +        required: true, +      }, +    }, +    titleLevel: { +      control: { +        type: 'number', +      }, +      description: 'The page title level (hn)', +      table: { +        category: 'Options', +        defaultValue: { summary: 2 }, +      }, +      type: { +        name: 'number', +        required: false, +      }, +    }, +    url: { +      control: { +        type: 'text', +      }, +      description: 'The page url.', +      type: { +        name: 'string', +        required: true, +      }, +    }, +  }, +} as ComponentMeta<typeof SummaryComponent>; + +const Template: ComponentStory<typeof SummaryComponent> = (args) => ( +  <IntlProvider locale="en"> +    <SummaryComponent {...args} /> +  </IntlProvider> +); + +const meta = { +  publication: { name: 'Published on:', value: 'April 11th 2022' }, +  readingTime: { name: 'Reading time:', value: '5 minutes' }, +  categories: { +    name: 'Categories:', +    value: [ +      <a key="cat-1" href="#"> +        Cat 1 +      </a>, +      <a key="cat-2" href="#"> +        Cat 2 +      </a>, +    ], +  }, +  comments: { name: 'Comments:', value: '1 comment' }, +}; + +export const Summary = Template.bind({}); +Summary.args = { +  cover: { +    alt: 'A cover', +    height: 480, +    url: 'http://placeimg.com/640/480', +    width: 640, +  }, +  excerpt: +    'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.', +  meta, +  title: 'Odio odit necessitatibus', +  url: '#', +}; diff --git a/src/components/organisms/layout/summary.test.tsx b/src/components/organisms/layout/summary.test.tsx new file mode 100644 index 0000000..ce87c0c --- /dev/null +++ b/src/components/organisms/layout/summary.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@test-utils'; +import Summary from './summary'; + +const cover = { +  alt: 'A cover', +  height: 480, +  url: 'http://placeimg.com/640/480', +  width: 640, +}; + +const excerpt = +  'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.'; + +const meta = { +  publication: { name: 'Published on:', value: 'April 11th 2022' }, +  readingTime: { name: 'Reading time:', value: '5 minutes' }, +  categories: { +    name: 'Categories:', +    value: [ +      <a key="cat-1" href="#"> +        Cat 1 +      </a>, +      <a key="cat-2" href="#"> +        Cat 2 +      </a>, +    ], +  }, +  comments: { name: 'Comments:', value: '1 comment' }, +}; + +const title = 'Odio odit necessitatibus'; + +const url = '#'; + +describe('Summary', () => { +  it('renders a title wrapped in a h2 element', () => { +    render( +      <Summary +        excerpt={excerpt} +        meta={meta} +        title={title} +        titleLevel={2} +        url={url} +      /> +    ); +    expect( +      screen.getByRole('heading', { level: 2, name: title }) +    ).toBeInTheDocument(); +  }); + +  it('renders an excerpt', () => { +    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />); +    expect(screen.getByText(excerpt)).toBeInTheDocument(); +  }); + +  it('renders a cover', () => { +    render( +      <Summary +        cover={cover} +        excerpt={excerpt} +        meta={meta} +        title={title} +        url={url} +      /> +    ); +    expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); +  }); + +  it('renders a link to the full post', () => { +    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />); +    expect(screen.getByRole('link', { name: title })).toBeInTheDocument(); +  }); + +  it('renders a read more link', () => { +    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />); +    expect( +      screen.getByRole('link', { name: `Read more about ${title}` }) +    ).toBeInTheDocument(); +  }); + +  it('renders some meta', () => { +    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />); +    expect(screen.getByText(meta.publication.name)).toBeInTheDocument(); +  }); +}); diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx new file mode 100644 index 0000000..3624e5d --- /dev/null +++ b/src/components/organisms/layout/summary.tsx @@ -0,0 +1,105 @@ +import ButtonLink from '@components/atoms/buttons/button-link'; +import Heading, { type HeadingLevel } from '@components/atoms/headings/heading'; +import Arrow from '@components/atoms/icons/arrow'; +import Link from '@components/atoms/links/link'; +import ResponsiveImage from '@components/molecules/images/responsive-image'; +import Meta, { type MetaItem } from '@components/molecules/layout/meta'; +import { VFC } from 'react'; +import { useIntl } from 'react-intl'; +import styles from './summary.module.scss'; + +export type Cover = { +  alt: string; +  height: number; +  url: string; +  width: number; +}; + +export type RequiredMetaKey = 'publication'; + +export type RequiredMeta = { +  [key in RequiredMetaKey]: MetaItem; +}; + +export type OptionalMetaKey = +  | 'author' +  | 'categories' +  | 'comments' +  | 'readingTime' +  | 'update'; + +export type OptionalMeta = { +  [key in OptionalMetaKey]?: MetaItem; +}; + +export type Meta = RequiredMeta & OptionalMeta; + +export type SummaryProps = { +  cover?: Cover; +  excerpt: string; +  meta: Meta; +  title: string; +  titleLevel?: HeadingLevel; +  url: string; +}; + +/** + * Summary component + * + * Render a page summary. + */ +const Summary: VFC<SummaryProps> = ({ +  cover, +  excerpt, +  meta, +  title, +  titleLevel = 2, +  url, +}) => { +  const intl = useIntl(); + +  return ( +    <article className={styles.wrapper}> +      {cover && ( +        <ResponsiveImage +          alt={cover.alt} +          src={cover.url} +          width={cover.width} +          height={cover.height} +          className={styles.cover} +        /> +      )} +      <header className={styles.header}> +        <Link href={url}> +          <Heading level={titleLevel} className={styles.title}> +            {title} +          </Heading> +        </Link> +      </header> +      <div className={styles.body}> +        {excerpt} +        <ButtonLink target={url} className={styles['read-more']}> +          {intl.formatMessage( +            { +              defaultMessage: 'Read more<a11y> about {title}</a11y>', +              description: 'Summary: read more link', +              id: 'Zpgv+f', +            }, +            { +              title, +              a11y: (chunks: string) => ( +                <span className="screen-reader-text">{chunks}</span> +              ), +            } +          )} +          <Arrow direction="right" /> +        </ButtonLink> +      </div> +      <footer className={styles.footer}> +        <Meta data={meta} layout="column" className={styles.meta} /> +      </footer> +    </article> +  ); +}; + +export default Summary; | 
