diff options
Diffstat (limited to 'src/components')
36 files changed, 1019 insertions, 690 deletions
| diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts index 70ac3c9..cb0b7eb 100644 --- a/src/components/molecules/index.ts +++ b/src/components/molecules/index.ts @@ -4,5 +4,6 @@ export * from './collapsible';  export * from './forms';  export * from './images';  export * from './layout'; +export * from './meta-list';  export * from './nav';  export * from './tooltip'; diff --git a/src/components/molecules/layout/card.fixture.ts b/src/components/molecules/layout/card.fixture.ts deleted file mode 100644 index 01fe2e9..0000000 --- a/src/components/molecules/layout/card.fixture.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const cover = { -  alt: 'A picture', -  height: 480, -  src: 'https://picsum.photos/640/480', -  width: 640, -}; - -export const id = 'nam'; - -export const meta = { -  author: 'Possimus', -  thematics: ['Autem', 'Eos'], -}; - -export const tagline = 'Ut rerum incidunt'; - -export const title = 'Alias qui porro'; - -export const url = '/an-existing-url'; diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss index 7a06508..14a5baf 100644 --- a/src/components/molecules/layout/card.module.scss +++ b/src/components/molecules/layout/card.module.scss @@ -1,5 +1,9 @@  @use "../../../styles/abstracts/functions" as fun; +.footer { +  margin-top: auto; +} +  .wrapper {    --scale-up: 1.05;    --scale-down: 0.95; diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx index a9545d1..070c978 100644 --- a/src/components/molecules/layout/card.stories.tsx +++ b/src/components/molecules/layout/card.stories.tsx @@ -1,6 +1,6 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { MetaItemData } from '../meta-list';  import { Card } from './card'; -import { cover, id, meta, tagline, title, url } from './card.fixture';  /**   * Card - Storybook Meta @@ -119,6 +119,33 @@ export default {  const Template: ComponentStory<typeof Card> = (args) => <Card {...args} />; +const cover = { +  alt: 'A picture', +  height: 480, +  src: 'https://picsum.photos/640/480', +  width: 640, +}; + +const id = 'nam'; + +const meta = [ +  { id: 'author', label: 'Author', value: 'Possimus' }, +  { +    id: 'categories', +    label: 'Categories', +    value: [ +      { id: 'autem', value: 'Autem' }, +      { id: 'eos', value: 'Eos' }, +    ], +  }, +] satisfies MetaItemData[]; + +const tagline = 'Ut rerum incidunt'; + +const title = 'Alias qui porro'; + +const url = '/an-existing-url'; +  /**   * Card Stories - Default   */ diff --git a/src/components/molecules/layout/card.test.tsx b/src/components/molecules/layout/card.test.tsx index c6498b8..b690d4c 100644 --- a/src/components/molecules/layout/card.test.tsx +++ b/src/components/molecules/layout/card.test.tsx @@ -1,37 +1,69 @@  import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import type { MetaItemData } from '../meta-list';  import { Card } from './card'; -import { cover, id, meta, tagline, title, url } from './card.fixture'; + +const cover = { +  alt: 'A picture', +  height: 480, +  src: 'https://picsum.photos/640/480', +  width: 640, +}; + +const id = 'nam'; + +const meta = [ +  { id: 'author', label: 'Author', value: 'Possimus' }, +  { +    id: 'categories', +    label: 'Categories', +    value: [ +      { id: 'autem', value: 'Autem' }, +      { id: 'eos', value: 'Eos' }, +    ], +  }, +] satisfies MetaItemData[]; + +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 id={id} title={title} titleLevel={2} url={url} />);      expect( -      screen.getByRole('heading', { level: 2, name: title }) +      rtlScreen.getByRole('heading', { level: 2, name: title })      ).toBeInTheDocument();    });    it('renders a link to another page', () => {      render(<Card id={id} title={title} titleLevel={2} url={url} />); -    expect(screen.getByRole('link')).toHaveAttribute('href', url); +    expect(rtlScreen.getByRole('link')).toHaveAttribute('href', url);    });    it('renders a cover', () => {      render(        <Card id={id} title={title} titleLevel={2} url={url} cover={cover} />      ); -    expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); +    expect(rtlScreen.getByRole('img', { name: cover.alt })).toBeInTheDocument();    });    it('renders a tagline', () => {      render(        <Card id={id} title={title} titleLevel={2} url={url} tagline={tagline} />      ); -    expect(screen.getByText(tagline)).toBeInTheDocument(); +    expect(rtlScreen.getByText(tagline)).toBeInTheDocument();    });    it('renders some meta', () => {      render(<Card id={id} title={title} titleLevel={2} url={url} meta={meta} />); -    expect(screen.getByText(meta.author)).toBeInTheDocument(); + +    const metaLabels = meta.map((item) => item.label); + +    for (const label of metaLabels) { +      expect(rtlScreen.getByText(label)).toBeInTheDocument(); +    }    });  }); diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx index c316100..d90cba2 100644 --- a/src/components/molecules/layout/card.tsx +++ b/src/components/molecules/layout/card.tsx @@ -1,8 +1,8 @@  import NextImage, { type ImageProps as NextImageProps } from 'next/image';  import type { FC } from 'react';  import { ButtonLink, Figure, Heading, type HeadingLevel } from '../../atoms'; +import { MetaList, type MetaItemData } from '../meta-list';  import styles from './card.module.scss'; -import { Meta, type MetaData } from './meta';  export type CardProps = {    /** @@ -20,7 +20,7 @@ export type CardProps = {    /**     * The card meta.     */ -  meta?: MetaData; +  meta?: MetaItemData[];    /**     * The card tagline.     */ @@ -73,7 +73,13 @@ export const Card: FC<CardProps> = ({          {tagline ? <div className={styles.tagline}>{tagline}</div> : null}          {meta ? (            <footer className={styles.footer}> -            <Meta className={styles.list} data={meta} spacing="sm" /> +            <MetaList +              className={styles.list} +              hasBorderedValues={meta.length < 2} +              hasInlinedValues={meta.length < 2} +              isCentered +              items={meta} +            />            </footer>          ) : null}        </article> diff --git a/src/components/molecules/layout/index.ts b/src/components/molecules/layout/index.ts index e43e664..58d5442 100644 --- a/src/components/molecules/layout/index.ts +++ b/src/components/molecules/layout/index.ts @@ -1,6 +1,5 @@  export * from './card';  export * from './code';  export * from './columns'; -export * from './meta';  export * from './page-footer';  export * from './page-header'; diff --git a/src/components/molecules/layout/meta.module.scss b/src/components/molecules/layout/meta.module.scss deleted file mode 100644 index 26faac3..0000000 --- a/src/components/molecules/layout/meta.module.scss +++ /dev/null @@ -1,16 +0,0 @@ -.list { -  .description:not(:first-of-type) { -    &::before { -      display: inline; -      float: left; -      content: "/"; -      margin-right: var(--itemSpacing); -    } -  } - -  &--stack { -    .term { -      flex: 0 0 100%; -    } -  } -} diff --git a/src/components/molecules/layout/meta.stories.tsx b/src/components/molecules/layout/meta.stories.tsx deleted file mode 100644 index 6faa265..0000000 --- a/src/components/molecules/layout/meta.stories.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Meta as MetaComponent, type MetaData } from './meta'; - -/** - * Meta - Storybook Meta - */ -export default { -  title: 'Molecules/Layout', -  component: MetaComponent, -  args: {}, -  argTypes: { -    data: { -      description: 'The page metadata.', -      type: { -        name: 'object', -        required: true, -        value: {}, -      }, -    }, -  }, -} as ComponentMeta<typeof MetaComponent>; - -const Template: ComponentStory<typeof MetaComponent> = (args) => ( -  <MetaComponent {...args} /> -); - -const data: MetaData = { -  publication: { date: '2022-04-09', time: '01:04:00' }, -  thematics: [ -    <a key="category1" href="#a"> -      Category 1 -    </a>, -    <a key="category2" href="#b"> -      Category 2 -    </a>, -  ], -}; - -/** - * Layout Stories - Meta - */ -export const Meta = Template.bind({}); -Meta.args = { -  data, -}; diff --git a/src/components/molecules/layout/meta.test.tsx b/src/components/molecules/layout/meta.test.tsx deleted file mode 100644 index 0635fc3..0000000 --- a/src/components/molecules/layout/meta.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { getFormattedDate } from '../../../utils/helpers'; -import { Meta } from './meta'; - -const data = { -  publication: { date: '2022-04-09' }, -  thematics: [ -    <a key="category1" href="#a"> -      Category 1 -    </a>, -    <a key="category2" href="#b"> -      Category 2 -    </a>, -  ], -}; - -describe('Meta', () => { -  it('format a date string', () => { -    render(<Meta data={data} />); -    expect( -      rtlScreen.getByText(getFormattedDate(data.publication.date)) -    ).toBeInTheDocument(); -  }); -}); diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx deleted file mode 100644 index 63909a4..0000000 --- a/src/components/molecules/layout/meta.tsx +++ /dev/null @@ -1,395 +0,0 @@ -import type { FC, ReactNode } from 'react'; -import { useIntl } from 'react-intl'; -import { getFormattedDate, getFormattedTime } from '../../../utils/helpers'; -import { -  DescriptionList, -  type DescriptionListProps, -  Link, -  Group, -  Term, -  Description, -} from '../../atoms'; -import styles from './meta.module.scss'; - -export type CustomMeta = { -  label: string; -  value: ReactNode; -}; - -export type MetaComments = { -  /** -   * A page title. -   */ -  about: string; -  /** -   * The comments count. -   */ -  count: number; -  /** -   * Wrap the comments count with a link to the given target. -   */ -  target?: string; -}; - -export type MetaDate = { -  /** -   * A date string. Ex: `2022-04-30`. -   */ -  date: string; -  /** -   * A time string. Ex: `10:25:59`. -   */ -  time?: string; -  /** -   * Wrap the date with a link to the given target. -   */ -  target?: string; -}; - -export type MetaData = { -  /** -   * The author name. -   */ -  author?: string; -  /** -   * The comments count. -   */ -  comments?: MetaComments; -  /** -   * The creation date. -   */ -  creation?: MetaDate; -  /** -   * A custom label/value metadata. -   */ -  custom?: CustomMeta; -  /** -   * The license name. -   */ -  license?: string; -  /** -   * The popularity. -   */ -  popularity?: string | JSX.Element; -  /** -   * The publication date. -   */ -  publication?: MetaDate; -  /** -   * The estimated reading time. -   */ -  readingTime?: string | JSX.Element; -  /** -   * An array of repositories. -   */ -  repositories?: string[] | JSX.Element[]; -  /** -   * An array of technologies. -   */ -  technologies?: string[]; -  /** -   * An array of thematics. -   */ -  thematics?: string[] | JSX.Element[]; -  /** -   * An array of thematics. -   */ -  topics?: string[] | JSX.Element[]; -  /** -   * A total number of posts. -   */ -  total?: number; -  /** -   * The update date. -   */ -  update?: MetaDate; -  /** -   * An url. -   */ -  website?: string; -}; - -const isCustomMeta = ( -  key: keyof MetaData, -  _value: unknown -): _value is MetaData['custom'] => key === 'custom'; - -export type MetaProps = Omit<DescriptionListProps, 'children'> & { -  /** -   * The meta data. -   */ -  data: MetaData; -}; - -/** - * Meta component - * - * Renders the given metadata. - */ -export const Meta: FC<MetaProps> = ({ -  className = '', -  data, -  isInline = false, -  ...props -}) => { -  const layoutClass = styles[isInline ? 'list--inline' : 'list--stack']; -  const listClass = `${styles.list} ${layoutClass} ${className}`; -  const intl = useIntl(); - -  /** -   * Retrieve the item label based on its key. -   * -   * @param {keyof MetaData} key - The meta key. -   * @returns {string} The item label. -   */ -  const getLabel = (key: keyof MetaData): string => { -    switch (key) { -      case 'author': -        return intl.formatMessage({ -          defaultMessage: 'Written by:', -          description: 'Meta: author label', -          id: 'OI0N37', -        }); -      case 'comments': -        return intl.formatMessage({ -          defaultMessage: 'Comments:', -          description: 'Meta: comments label', -          id: 'jTVIh8', -        }); -      case 'creation': -        return intl.formatMessage({ -          defaultMessage: 'Created on:', -          description: 'Meta: creation date label', -          id: 'b4fdYE', -        }); -      case 'license': -        return intl.formatMessage({ -          defaultMessage: 'License:', -          description: 'Meta: license label', -          id: 'AuGklx', -        }); -      case 'popularity': -        return intl.formatMessage({ -          defaultMessage: 'Popularity:', -          description: 'Meta: popularity label', -          id: 'pWTj2W', -        }); -      case 'publication': -        return intl.formatMessage({ -          defaultMessage: 'Published on:', -          description: 'Meta: publication date label', -          id: 'QGi5uD', -        }); -      case 'readingTime': -        return intl.formatMessage({ -          defaultMessage: 'Reading time:', -          description: 'Meta: reading time label', -          id: 'EbFvsM', -        }); -      case 'repositories': -        return intl.formatMessage({ -          defaultMessage: 'Repositories:', -          description: 'Meta: repositories label', -          id: 'DssFG1', -        }); -      case 'technologies': -        return intl.formatMessage({ -          defaultMessage: 'Technologies:', -          description: 'Meta: technologies label', -          id: 'ADQmDF', -        }); -      case 'thematics': -        return intl.formatMessage({ -          defaultMessage: 'Thematics:', -          description: 'Meta: thematics label', -          id: 'bz53Us', -        }); -      case 'topics': -        return intl.formatMessage({ -          defaultMessage: 'Topics:', -          description: 'Meta: topics label', -          id: 'gJNaBD', -        }); -      case 'total': -        return intl.formatMessage({ -          defaultMessage: 'Total:', -          description: 'Meta: total label', -          id: '92zgdp', -        }); -      case 'update': -        return intl.formatMessage({ -          defaultMessage: 'Updated on:', -          description: 'Meta: update date label', -          id: 'tLC7bh', -        }); -      case 'website': -        return intl.formatMessage({ -          defaultMessage: 'Official website:', -          description: 'Meta: official website label', -          id: 'GRyyfy', -        }); -      default: -        return ''; -    } -  }; - -  /** -   * Retrieve a formatted date (and time). -   * -   * @param {MetaDate} dateTime - A date object. -   * @returns {JSX.Element} The formatted date wrapped in a time element. -   */ -  const getDate = (dateTime: MetaDate): JSX.Element => { -    const { date, time, target } = dateTime; - -    if (!dateTime.time) { -      const isoDate = new Date(`${date}`).toISOString(); -      return target ? ( -        <Link href={target}> -          <time dateTime={isoDate}>{getFormattedDate(dateTime.date)}</time> -        </Link> -      ) : ( -        <time dateTime={isoDate}>{getFormattedDate(dateTime.date)}</time> -      ); -    } - -    const isoDateTime = new Date(`${date}T${time}`).toISOString(); -    const dateString = intl.formatMessage( -      { -        defaultMessage: '{date} at {time}', -        description: 'Meta: publication date and time', -        id: 'fcHeyC', -      }, -      { -        date: getFormattedDate(dateTime.date), -        time: getFormattedTime(`${dateTime.date}T${dateTime.time}`), -      } -    ); - -    return target ? ( -      <Link href={target}> -        <time dateTime={isoDateTime}>{dateString}</time> -      </Link> -    ) : ( -      <time dateTime={isoDateTime}>{dateString}</time> -    ); -  }; - -  /** -   * Retrieve the formatted comments count. -   * -   * @param comments - The comments object. -   * @returns {string | JSX.Element} - The comments count. -   */ -  const getCommentsCount = (comments: MetaComments): string | JSX.Element => { -    const { about, count, target } = comments; -    const commentsCount = intl.formatMessage( -      { -        defaultMessage: -          '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>', -        description: 'Meta: comments count', -        id: '02rgLO', -      }, -      { -        a11y: (chunks: ReactNode) => ( -          <span className="screen-reader-text">{chunks}</span> -        ), -        commentsCount: count, -        title: about, -      } -    ); - -    return target ? ( -      <Link href={target}>{commentsCount as JSX.Element}</Link> -    ) : ( -      (commentsCount as JSX.Element) -    ); -  }; - -  /** -   * Retrieve the formatted item value. -   * -   * @param {keyof MetaData} key - The meta key. -   * @param {ValueOf<MetaData>} value - The meta value. -   * @returns {string|ReactNode|ReactNode[]} - The formatted value. -   */ -  const getValue = <T extends keyof MetaData>( -    key: T, -    value: MetaData[T] -  ): string | ReactNode | ReactNode[] => { -    switch (key) { -      case 'comments': -        return getCommentsCount(value as MetaComments); -      case 'creation': -      case 'publication': -      case 'update': -        return getDate(value as MetaDate); -      case 'total': -        return intl.formatMessage( -          { -            defaultMessage: -              '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}', -            description: 'BlogPage: posts count meta', -            id: 'OF5cPz', -          }, -          { postsCount: value as number } -        ); -      case 'website': -        return typeof value === 'string' ? ( -          <Link href={value} isExternal> -            {value} -          </Link> -        ) : null; -      default: -        return value as string | ReactNode | ReactNode[]; -    } -  }; - -  /** -   * Transform the metadata to description list item format. -   * -   * @param {MetaData} items - The meta. -   * @returns {DescriptionListItem[]} The formatted description list items. -   */ -  const getItems = (items: MetaData) => { -    const entries = Object.entries(items) as [ -      keyof MetaData, -      MetaData[keyof MetaData], -    ][]; -    const listItems = entries.map(([key, meta]) => { -      if (!meta) return null; - -      return ( -        <Group isInline key={key} spacing="2xs"> -          <Term className={styles.term}> -            {isCustomMeta(key, meta) ? meta.label : getLabel(key)} -          </Term> -          {Array.isArray(meta) ? ( -            meta.map((singleMeta, index) => ( -              /* eslint-disable-next-line react/no-array-index-key -- Unsafe, -               * but also temporary. This component should be removed or -               * refactored. */ -              <Description className={styles.description} key={index}> -                {isCustomMeta(key, singleMeta) -                  ? singleMeta -                  : getValue(key, singleMeta)} -              </Description> -            )) -          ) : ( -            <Description className={styles.description}> -              {isCustomMeta(key, meta) ? meta.value : getValue(key, meta)} -            </Description> -          )} -        </Group> -      ); -    }); - -    return listItems; -  }; - -  return ( -    <DescriptionList {...props} className={listClass} isInline={isInline}> -      {getItems(data)} -    </DescriptionList> -  ); -}; diff --git a/src/components/molecules/layout/page-footer.stories.tsx b/src/components/molecules/layout/page-footer.stories.tsx index 8e991a4..48c8c17 100644 --- a/src/components/molecules/layout/page-footer.stories.tsx +++ b/src/components/molecules/layout/page-footer.stories.tsx @@ -1,5 +1,4 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { MetaData } from './meta'; +import type { ComponentMeta, ComponentStory } from '@storybook/react';  import { PageFooter as PageFooterComponent } from './page-footer';  /** @@ -40,16 +39,17 @@ const Template: ComponentStory<typeof PageFooterComponent> = (args) => (    <PageFooterComponent {...args} />  ); -const meta: MetaData = { -  custom: { +const meta = [ +  { +    id: 'more-about',      label: 'More posts about:', -    value: [ -      <a key="topic-1" href="#"> +    value: ( +      <a key="topic-1" href="#topic1">          Topic name -      </a>, -    ], +      </a> +    ),    }, -}; +];  /**   * Page Footer Stories - With meta diff --git a/src/components/molecules/layout/page-footer.tsx b/src/components/molecules/layout/page-footer.tsx index 375cbc4..a93fced 100644 --- a/src/components/molecules/layout/page-footer.tsx +++ b/src/components/molecules/layout/page-footer.tsx @@ -1,12 +1,12 @@  import type { FC } from 'react';  import { Footer, type FooterProps } from '../../atoms'; -import { Meta, type MetaData } from './meta'; +import { MetaList, type MetaItemData } from '../meta-list';  export type PageFooterProps = Omit<FooterProps, 'children'> & {    /**     * The footer metadata.     */ -  meta?: MetaData; +  meta?: MetaItemData[];  };  /** @@ -15,5 +15,7 @@ export type PageFooterProps = Omit<FooterProps, 'children'> & {   * Render a footer to display page meta.   */  export const PageFooter: FC<PageFooterProps> = ({ meta, ...props }) => ( -  <Footer {...props}>{meta ? <Meta data={meta} /> : null}</Footer> +  <Footer {...props}> +    {meta ? <MetaList hasInlinedValues items={meta} /> : null} +  </Footer>  ); diff --git a/src/components/molecules/layout/page-header.stories.tsx b/src/components/molecules/layout/page-header.stories.tsx index ea943bf..54d5fe8 100644 --- a/src/components/molecules/layout/page-header.stories.tsx +++ b/src/components/molecules/layout/page-header.stories.tsx @@ -1,4 +1,4 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory } from '@storybook/react';  import { PageHeader } from './page-header';  /** @@ -62,17 +62,31 @@ const Template: ComponentStory<typeof PageHeader> = (args) => (    <PageHeader {...args} />  ); -const meta = { -  publication: { date: '2022-04-09' }, -  thematics: [ -    <a key="category1" href="#"> -      Category 1 -    </a>, -    <a key="category2" href="#"> -      Category 2 -    </a>, -  ], -}; +const meta = [ +  { id: 'publication-date', label: 'Published on:', value: '2022-04-09' }, +  { +    id: 'thematics', +    label: 'Thematics:', +    value: [ +      { +        id: 'cat-1', +        value: ( +          <a key="category1" href="#cat1"> +            Category 1 +          </a> +        ), +      }, +      { +        id: 'cat-2', +        value: ( +          <a key="category2" href="#cat2"> +            Category 2 +          </a> +        ), +      }, +    ], +  }, +];  /**   * Page Header Stories - Default diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx index b727cc1..ea0dd2c 100644 --- a/src/components/molecules/layout/page-header.tsx +++ b/src/components/molecules/layout/page-header.tsx @@ -1,6 +1,6 @@  import type { FC, ReactNode } from 'react';  import { Header, Heading } from '../../atoms'; -import { Meta, type MetaData } from './meta'; +import { MetaList, type MetaItemData } from '../meta-list';  import styles from './page-header.module.scss';  export type PageHeaderProps = { @@ -15,7 +15,7 @@ export type PageHeaderProps = {    /**     * The page metadata.     */ -  meta?: MetaData; +  meta?: MetaItemData[];    /**     * The page title.     */ @@ -56,7 +56,7 @@ export const PageHeader: FC<PageHeaderProps> = ({            {title}          </Heading>          {meta ? ( -          <Meta className={styles.meta} data={meta} isInline spacing="xs" /> +          <MetaList className={styles.meta} hasInlinedItems items={meta} />          ) : null}          {intro ? getIntro() : null}        </div> diff --git a/src/components/molecules/meta-list/index.ts b/src/components/molecules/meta-list/index.ts new file mode 100644 index 0000000..93f437d --- /dev/null +++ b/src/components/molecules/meta-list/index.ts @@ -0,0 +1,2 @@ +export * from './meta-item'; +export * from './meta-list'; diff --git a/src/components/molecules/meta-list/meta-item/index.ts b/src/components/molecules/meta-list/meta-item/index.ts new file mode 100644 index 0000000..47795de --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/index.ts @@ -0,0 +1 @@ +export * from './meta-item'; diff --git a/src/components/molecules/meta-list/meta-item/meta-item.module.scss b/src/components/molecules/meta-list/meta-item/meta-item.module.scss new file mode 100644 index 0000000..a1c2d47 --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/meta-item.module.scss @@ -0,0 +1,62 @@ +@use "../../../../styles/abstracts/functions" as fun; + +.item { +  column-gap: var(--spacing-2xs); +  align-content: baseline; + +  &--bordered-values { +    row-gap: var(--spacing-2xs); +  } + +  &--centered { +    margin-inline: auto; +    text-align: center; +    place-items: center; +    justify-content: center; +  } + +  &--inlined { +    align-items: first baseline; +  } + +  &--inlined-values { +    flex-flow: row wrap; +  } + +  &:not(#{&}--bordered-values) { +    row-gap: fun.convert-px(3); +  } +} + +.value { +  width: fit-content; +  height: fit-content; +  color: var(--color-fg); +  font-weight: 400; +} + +:where(.item--bordered-values) { +  .value { +    padding: fun.convert-px(2) var(--spacing-2xs); +    border: fun.convert-px(1) solid var(--color-primary-darker); +  } +} + +:where(.item--inlined-values) { +  .label { +    flex: 1 0 100%; +  } +} + +/* It's an arbitrary choice. When there is only one meta item (like on small + * cards) removing the width can mess up the layout. However, must of the times + * when there are multiples items, we need to remove the width especially if we + * want to use `isCentered` prop. */ +:where(.item--inlined-values:not(:only-of-type)) { +  .label { +    /* We need to remove its width to avoid an extra space and make the +     * container width fit its contents. However the label should be smaller +     * than the values to avoid unexpected behavior with layout. */ +    width: 0; +  } +} diff --git a/src/components/molecules/meta-list/meta-item/meta-item.stories.tsx b/src/components/molecules/meta-list/meta-item/meta-item.stories.tsx new file mode 100644 index 0000000..3ddb8f1 --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/meta-item.stories.tsx @@ -0,0 +1,108 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Link } from '../../../atoms'; +import { MetaItem } from './meta-item'; + +/** + * MetaItem - Storybook Meta + */ +export default { +  title: 'Molecules/MetaList/Item', +  component: MetaItem, +  argTypes: { +    label: { +      control: { +        type: 'text', +      }, +      description: 'The item label.', +      type: { +        name: 'string', +        required: true, +      }, +    }, +  }, +} as ComponentMeta<typeof MetaItem>; + +const Template: ComponentStory<typeof MetaItem> = (args) => ( +  <MetaItem {...args} /> +); + +/** + * MetaItem Stories - SingleValue + */ +export const SingleValue = Template.bind({}); +SingleValue.args = { +  label: 'Comments', +  value: 'No comments', +}; + +/** + * MetaItem Stories - MultipleValues + */ +export const MultipleValues = Template.bind({}); +MultipleValues.args = { +  label: 'Tags', +  value: [ +    { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> }, +    { id: 'tag2', value: <Link href="#tag2">Tag 2</Link> }, +  ], +}; + +/** + * MetaItem Stories - SingleValueBordered + */ +export const SingleValueBordered = Template.bind({}); +SingleValueBordered.args = { +  hasBorderedValues: true, +  label: 'Comments', +  value: 'No comments', +}; + +/** + * MetaItem Stories - MultipleValuesBordered + */ +export const MultipleValuesBordered = Template.bind({}); +MultipleValuesBordered.args = { +  hasBorderedValues: true, +  label: 'Tags', +  value: [ +    { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> }, +    { id: 'tag2', value: <Link href="#tag2">Tag 2</Link> }, +  ], +}; + +/** + * MetaItem Stories - SingleValueInlined + */ +export const SingleValueInlined = Template.bind({}); +SingleValueInlined.args = { +  isInline: true, +  label: 'Comments', +  value: 'No comments', +}; + +/** + * MetaItem Stories - MultipleValuesInlined + */ +export const MultipleValuesInlined = Template.bind({}); +MultipleValuesInlined.args = { +  isInline: true, +  label: 'Tags', +  value: [ +    { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> }, +    { id: 'tag2', value: <Link href="#tag2">Tag 2</Link> }, +  ], +}; + +/** + * MetaItem Stories - InlinedValues + */ +export const InlinedValues = Template.bind({}); +InlinedValues.args = { +  hasInlinedValues: true, +  label: 'Tags', +  value: [ +    { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> }, +    { id: 'tag2', value: <Link href="#tag2">A long tag 2</Link> }, +    { id: 'tag3', value: <Link href="#tag3">Tag 3</Link> }, +  ], +}; diff --git a/src/components/molecules/meta-list/meta-item/meta-item.test.tsx b/src/components/molecules/meta-list/meta-item/meta-item.test.tsx new file mode 100644 index 0000000..629c4b2 --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/meta-item.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { MetaItem } from './meta-item'; + +describe('MetaItem', () => { +  it('renders a label and a value', () => { +    const label = 'iusto'; +    const value = 'autem'; + +    render( +      <dl> +        <MetaItem label={label} value={value} /> +      </dl> +    ); + +    expect(rtlScreen.getByRole('term')).toHaveTextContent(label); +    expect(rtlScreen.getByRole('definition')).toHaveTextContent(value); +  }); + +  it('can render a label with multiple values', () => { +    const label = 'iusto'; +    const values = [ +      { id: 'autem', value: 'autem' }, +      { id: 'quisquam', value: 'aut' }, +      { id: 'molestias', value: 'voluptatem' }, +    ]; + +    render( +      <dl> +        <MetaItem label={label} value={values} /> +      </dl> +    ); + +    expect(rtlScreen.getByRole('term')).toHaveTextContent(label); +    expect(rtlScreen.getAllByRole('definition')).toHaveLength(values.length); +  }); + +  it('can render a centered group of label and values', () => { +    const label = 'iusto'; +    const value = 'autem'; + +    render( +      <dl> +        <MetaItem isCentered label={label} value={value} /> +      </dl> +    ); + +    expect(rtlScreen.getByRole('term').parentElement).toHaveClass( +      'item--centered' +    ); +  }); + +  it('can render an inlined group of label and values', () => { +    const label = 'iusto'; +    const value = 'autem'; + +    render( +      <dl> +        <MetaItem isInline label={label} value={value} /> +      </dl> +    ); + +    expect(rtlScreen.getByRole('term').parentElement).toHaveClass( +      'item--inlined' +    ); +  }); + +  it('can render a group of label and bordered values', () => { +    const label = 'iusto'; +    const value = 'autem'; + +    render( +      <dl> +        <MetaItem hasBorderedValues label={label} value={value} /> +      </dl> +    ); + +    expect(rtlScreen.getByRole('term').parentElement).toHaveClass( +      'item--bordered-values' +    ); +  }); + +  it('can render a group of label and inlined values', () => { +    const label = 'iusto'; +    const value = 'autem'; + +    render( +      <dl> +        <MetaItem hasInlinedValues label={label} value={value} /> +      </dl> +    ); + +    expect(rtlScreen.getByRole('term').parentElement).toHaveClass( +      'item--inlined-values' +    ); +  }); +}); diff --git a/src/components/molecules/meta-list/meta-item/meta-item.tsx b/src/components/molecules/meta-list/meta-item/meta-item.tsx new file mode 100644 index 0000000..c5223c2 --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/meta-item.tsx @@ -0,0 +1,90 @@ +import { +  type ForwardRefRenderFunction, +  type ReactElement, +  type ReactNode, +  forwardRef, +} from 'react'; +import { Description, Group, type GroupProps, Term } from '../../../atoms'; +import styles from './meta-item.module.scss'; + +export type MetaValue = string | ReactElement; + +export type MetaValues = { +  id: string; +  value: MetaValue; +}; + +export type MetaItemProps = Omit<GroupProps, 'children' | 'spacing'> & { +  /** +   * Should the values be bordered? +   * +   * @default false +   */ +  hasBorderedValues?: boolean; +  /** +   * Should the values be inlined? +   * +   * @warning If you use it make sure the value is larger than the label. It +   * could mess up your design since we are removing the label width. +   * +   * @default false +   */ +  hasInlinedValues?: boolean; +  /** +   * Should the label and values be centered? +   * +   * @default false +   */ +  isCentered?: boolean; +  /** +   * The item label. +   */ +  label: ReactNode; +  /** +   * The item value or values. +   */ +  value: MetaValue | MetaValues[]; +}; + +const MetaItemWithRef: ForwardRefRenderFunction< +  HTMLDivElement, +  MetaItemProps +> = ( +  { +    className = '', +    hasBorderedValues = false, +    hasInlinedValues = false, +    isCentered = false, +    isInline = false, +    label, +    value, +    ...props +  }, +  ref +) => { +  const itemClass = [ +    styles.item, +    styles[hasBorderedValues ? 'item--bordered-values' : ''], +    styles[hasInlinedValues ? 'item--inlined-values' : ''], +    styles[isCentered ? 'item--centered' : ''], +    styles[isInline ? 'item--inlined' : 'item--stacked'], +    className, +  ].join(' '); + +  return ( +    <Group {...props} className={itemClass} isInline={isInline} ref={ref}> +      <Term className={styles.label}>{label}</Term> +      {Array.isArray(value) ? ( +        value.map((item) => ( +          <Description className={styles.value} key={item.id}> +            {item.value} +          </Description> +        )) +      ) : ( +        <Description className={styles.value}>{value}</Description> +      )} +    </Group> +  ); +}; + +export const MetaItem = forwardRef(MetaItemWithRef); diff --git a/src/components/molecules/meta-list/meta-list.module.scss b/src/components/molecules/meta-list/meta-list.module.scss new file mode 100644 index 0000000..5570f4c --- /dev/null +++ b/src/components/molecules/meta-list/meta-list.module.scss @@ -0,0 +1,24 @@ +.list { +  display: grid; +  width: fit-content; +  height: fit-content; + +  &--centered { +    margin-inline: auto; +    justify-items: center; +  } + +  &--inlined { +    grid-auto-flow: column; +    grid-template-columns: repeat( +      auto-fit, +      min(calc(100vw - (var(--spacing-md) * 2)), 1fr) +    ); +    column-gap: clamp(var(--spacing-lg), 3vw, var(--spacing-3xl)); +    row-gap: clamp(var(--spacing-sm), 3vw, var(--spacing-md)); +  } + +  &--stacked { +    gap: var(--spacing-2xs); +  } +} diff --git a/src/components/molecules/meta-list/meta-list.stories.tsx b/src/components/molecules/meta-list/meta-list.stories.tsx new file mode 100644 index 0000000..463ec96 --- /dev/null +++ b/src/components/molecules/meta-list/meta-list.stories.tsx @@ -0,0 +1,70 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Link } from '../../atoms'; +import { type MetaItemData, MetaList } from './meta-list'; + +/** + * MetaList - Storybook Meta + */ +export default { +  title: 'Molecules/MetaList', +  component: MetaList, +  argTypes: { +    items: { +      description: 'The meta items.', +      type: { +        name: 'object', +        required: true, +        value: {}, +      }, +    }, +  }, +} as ComponentMeta<typeof MetaList>; + +const Template: ComponentStory<typeof MetaList> = (args) => ( +  <MetaList {...args} /> +); + +const items: MetaItemData[] = [ +  { id: 'comments', label: 'Comments', value: 'No comments.' }, +  { +    id: 'category', +    label: 'Category', +    value: <Link href="#cat1">Cat 1</Link>, +  }, +  { +    id: 'tags', +    label: 'Tags', +    value: [ +      { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> }, +      { id: 'tag2', value: <Link href="#tag2">Tag 2</Link> }, +    ], +  }, +  { +    hasBorderedValues: true, +    hasInlinedValues: true, +    id: 'technologies', +    label: 'Technologies', +    value: [ +      { id: 'techno1', value: 'HTML' }, +      { id: 'techno2', value: 'CSS' }, +      { id: 'techno3', value: 'Javascript' }, +    ], +  }, +]; + +/** + * MetaList Stories - Default + */ +export const Default = Template.bind({}); +Default.args = { +  items, +}; + +/** + * MetaList Stories - Inlined + */ +export const Inlined = Template.bind({}); +Inlined.args = { +  isInline: true, +  items, +}; diff --git a/src/components/molecules/meta-list/meta-list.test.tsx b/src/components/molecules/meta-list/meta-list.test.tsx new file mode 100644 index 0000000..cc4d2fa --- /dev/null +++ b/src/components/molecules/meta-list/meta-list.test.tsx @@ -0,0 +1,79 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { type MetaItemData, MetaList } from './meta-list'; + +describe('MetaList', () => { +  it('renders a list of meta items', () => { +    const items: MetaItemData[] = [ +      { id: 'item1', label: 'Item 1', value: 'Value 1' }, +      { id: 'item2', label: 'Item 2', value: 'Value 2' }, +      { id: 'item3', label: 'Item 3', value: 'Value 3' }, +      { id: 'item4', label: 'Item 4', value: 'Value 4' }, +    ]; + +    render(<MetaList items={items} />); + +    expect(rtlScreen.getAllByRole('term')).toHaveLength(items.length); +    expect(rtlScreen.getAllByRole('definition')).toHaveLength(items.length); +  }); + +  it('can render a centered list of meta items', () => { +    const items: MetaItemData[] = [ +      { id: 'item1', label: 'Item 1', value: 'Value 1' }, +      { id: 'item2', label: 'Item 2', value: 'Value 2' }, +      { id: 'item3', label: 'Item 3', value: 'Value 3' }, +      { id: 'item4', label: 'Item 4', value: 'Value 4' }, +    ]; + +    render(<MetaList isCentered items={items} />); + +    const terms = rtlScreen.getAllByRole('term'); + +    expect(terms[0].parentElement?.parentElement).toHaveClass('list--centered'); +  }); + +  it('can render an inlined list of meta items', () => { +    const items: MetaItemData[] = [ +      { id: 'item1', label: 'Item 1', value: 'Value 1' }, +      { id: 'item2', label: 'Item 2', value: 'Value 2' }, +      { id: 'item3', label: 'Item 3', value: 'Value 3' }, +      { id: 'item4', label: 'Item 4', value: 'Value 4' }, +    ]; + +    render(<MetaList isInline items={items} />); + +    const terms = rtlScreen.getAllByRole('term'); + +    expect(terms[0].parentElement?.parentElement).toHaveClass('list--inlined'); +  }); + +  it('can render a list of meta items with bordered values', () => { +    const items: MetaItemData[] = [ +      { id: 'item1', label: 'Item 1', value: 'Value 1' }, +      { id: 'item2', label: 'Item 2', value: 'Value 2' }, +      { id: 'item3', label: 'Item 3', value: 'Value 3' }, +      { id: 'item4', label: 'Item 4', value: 'Value 4' }, +    ]; + +    render(<MetaList hasBorderedValues items={items} />); + +    const terms = rtlScreen.getAllByRole('term'); + +    expect(terms[0].parentElement).toHaveClass('item--bordered-values'); +  }); + +  it('can render a list of meta items with inlined values', () => { +    const items: MetaItemData[] = [ +      { id: 'item1', label: 'Item 1', value: 'Value 1' }, +      { id: 'item2', label: 'Item 2', value: 'Value 2' }, +      { id: 'item3', label: 'Item 3', value: 'Value 3' }, +      { id: 'item4', label: 'Item 4', value: 'Value 4' }, +    ]; + +    render(<MetaList hasInlinedValues items={items} />); + +    const terms = rtlScreen.getAllByRole('term'); + +    expect(terms[0].parentElement).toHaveClass('item--inlined-values'); +  }); +}); diff --git a/src/components/molecules/meta-list/meta-list.tsx b/src/components/molecules/meta-list/meta-list.tsx new file mode 100644 index 0000000..288fd9a --- /dev/null +++ b/src/components/molecules/meta-list/meta-list.tsx @@ -0,0 +1,78 @@ +import { type ForwardRefRenderFunction, forwardRef } from 'react'; +import { DescriptionList, type DescriptionListProps } from '../../atoms'; +import { MetaItem, type MetaItemProps } from './meta-item'; +import styles from './meta-list.module.scss'; + +export type MetaItemData = Pick< +  MetaItemProps, +  | 'hasBorderedValues' +  | 'hasInlinedValues' +  | 'isCentered' +  | 'isInline' +  | 'label' +  | 'value' +> & { +  id: string; +}; + +export type MetaListProps = Omit<DescriptionListProps, 'children' | 'spacing'> & +  Pick<MetaItemProps, 'hasBorderedValues' | 'hasInlinedValues'> & { +    /** +     * Should the items be inlined? +     * +     * @default false +     */ +    hasInlinedItems?: boolean; +    /** +     * Should the meta be centered? +     * +     * @default false +     */ +    isCentered?: boolean; +    /** +     * The meta items. +     */ +    items: MetaItemData[]; +  }; + +const MetaListWithRef: ForwardRefRenderFunction< +  HTMLDListElement, +  MetaListProps +> = ( +  { +    className = '', +    hasBorderedValues = false, +    hasInlinedItems = false, +    hasInlinedValues = false, +    isCentered = false, +    isInline = false, +    items, +    ...props +  }, +  ref +) => { +  const listClass = [ +    styles.list, +    styles[isCentered ? 'list--centered' : ''], +    styles[isInline ? 'list--inlined' : 'list--stacked'], +    className, +  ].join(' '); + +  return ( +    <DescriptionList {...props} className={listClass} ref={ref}> +      {items.map(({ id, ...item }) => ( +        <MetaItem +          hasBorderedValues={hasBorderedValues} +          hasInlinedValues={hasInlinedValues} +          isCentered={isCentered} +          isInline={hasInlinedItems} +          // Each item should be able to override the global settings. +          {...item} +          key={id} +        /> +      ))} +    </DescriptionList> +  ); +}; + +export const MetaList = forwardRef(MetaListWithRef); diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx index 1b5051f..03feee7 100644 --- a/src/components/organisms/layout/cards-list.stories.tsx +++ b/src/components/organisms/layout/cards-list.stories.tsx @@ -90,11 +90,21 @@ const items: CardsListItem[] = [      id: 'card-1',      cover: {        alt: 'card 1 picture', -      src: 'http://picsum.photos/640/480', +      src: 'https://picsum.photos/640/480',        width: 640,        height: 480,      }, -    meta: { thematics: ['Velit', 'Ex', 'Alias'] }, +    meta: [ +      { +        id: 'categories', +        label: 'Categories', +        value: [ +          { id: 'velit', value: 'Velit' }, +          { id: 'ex', value: 'Ex' }, +          { id: 'alias', value: 'Alias' }, +        ], +      }, +    ],      tagline: 'Molestias ut error',      title: 'Et alias omnis',      url: '#', @@ -103,11 +113,11 @@ const items: CardsListItem[] = [      id: 'card-2',      cover: {        alt: 'card 2 picture', -      src: 'http://picsum.photos/640/480', +      src: 'https://picsum.photos/640/480',        width: 640,        height: 480,      }, -    meta: { thematics: ['Voluptas'] }, +    meta: [{ id: 'categories', label: 'Categories', value: 'Voluptas' }],      tagline: 'Quod vel accusamus',      title: 'Laboriosam doloremque mollitia',      url: '#', @@ -116,13 +126,22 @@ const items: CardsListItem[] = [      id: 'card-3',      cover: {        alt: 'card 3 picture', -      src: 'http://picsum.photos/640/480', +      src: 'https://picsum.photos/640/480',        width: 640,        height: 480,      }, -    meta: { -      thematics: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'], -    }, +    meta: [ +      { +        id: 'categories', +        label: 'Categories', +        value: [ +          { id: 'quisquam', value: 'Quisquam' }, +          { id: 'quia', value: 'Quia' }, +          { id: 'sapiente', value: 'Sapiente' }, +          { id: 'perspiciatis', value: 'Perspiciatis' }, +        ], +      }, +    ],      tagline: 'Quo error eum',      title: 'Magni rem nulla',      url: '#', diff --git a/src/components/organisms/layout/cards-list.test.tsx b/src/components/organisms/layout/cards-list.test.tsx index 751a502..c9d6ae7 100644 --- a/src/components/organisms/layout/cards-list.test.tsx +++ b/src/components/organisms/layout/cards-list.test.tsx @@ -1,5 +1,5 @@  import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; +import { render, screen as rtlScreen } from '../../../../tests/utils';  import { CardsList, type CardsListItem } from './cards-list';  const items: CardsListItem[] = [ @@ -7,11 +7,21 @@ const items: CardsListItem[] = [      id: 'card-1',      cover: {        alt: 'card 1 picture', -      src: 'http://placeimg.com/640/480', +      src: 'https://picsum.photos/640/480',        width: 640,        height: 480,      }, -    meta: { thematics: ['Velit', 'Ex', 'Alias'] }, +    meta: [ +      { +        id: 'categories', +        label: 'Categories', +        value: [ +          { id: 'velit', value: 'Velit' }, +          { id: 'ex', value: 'Ex' }, +          { id: 'alias', value: 'Alias' }, +        ], +      }, +    ],      tagline: 'Molestias ut error',      title: 'Et alias omnis',      url: '#', @@ -20,11 +30,11 @@ const items: CardsListItem[] = [      id: 'card-2',      cover: {        alt: 'card 2 picture', -      src: 'http://placeimg.com/640/480', +      src: 'https://picsum.photos/640/480',        width: 640,        height: 480,      }, -    meta: { thematics: ['Voluptas'] }, +    meta: [{ id: 'categories', label: 'Categories', value: 'Voluptas' }],      tagline: 'Quod vel accusamus',      title: 'Laboriosam doloremque mollitia',      url: '#', @@ -33,13 +43,22 @@ const items: CardsListItem[] = [      id: 'card-3',      cover: {        alt: 'card 3 picture', -      src: 'http://placeimg.com/640/480', +      src: 'https://picsum.photos/640/480',        width: 640,        height: 480,      }, -    meta: { -      thematics: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'], -    }, +    meta: [ +      { +        id: 'categories', +        label: 'Categories', +        value: [ +          { id: 'quisquam', value: 'Quisquam' }, +          { id: 'quia', value: 'Quia' }, +          { id: 'sapiente', value: 'Sapiente' }, +          { id: 'perspiciatis', value: 'Perspiciatis' }, +        ], +      }, +    ],      tagline: 'Quo error eum',      title: 'Magni rem nulla',      url: '#', @@ -49,7 +68,7 @@ const items: CardsListItem[] = [  describe('CardsList', () => {    it('renders a list of cards', () => {      render(<CardsList items={items} titleLevel={2} />); -    expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength( +    expect(rtlScreen.getAllByRole('heading', { level: 2 })).toHaveLength(        items.length      );    }); diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx index ca209f5..e1ea6b5 100644 --- a/src/components/organisms/layout/comment.tsx +++ b/src/components/organisms/layout/comment.tsx @@ -5,9 +5,10 @@ import { type FC, useCallback, useState } from 'react';  import { useIntl } from 'react-intl';  import type { Comment as CommentSchema, WithContext } from 'schema-dts';  import type { SingleComment } from '../../../types'; +import { getFormattedDate, getFormattedTime } from '../../../utils/helpers';  import { useSettings } from '../../../utils/hooks';  import { Button, Link } from '../../atoms'; -import { Meta } from '../../molecules'; +import { MetaList } from '../../molecules';  import { CommentForm, type CommentFormProps } from '../forms';  import styles from './comment.module.scss'; @@ -61,6 +62,20 @@ export const UserComment: FC<UserCommentProps> = ({    const { author, date } = meta;    const [publicationDate, publicationTime] = date.split(' '); +  const isoDateTime = new Date( +    `${publicationDate}T${publicationTime}` +  ).toISOString(); +  const commentDate = intl.formatMessage( +    { +      defaultMessage: '{date} at {time}', +      description: 'Comment: publication date and time', +      id: 'Ld6yMP', +    }, +    { +      date: getFormattedDate(publicationDate), +      time: getFormattedTime(`${publicationDate}T${publicationTime}`), +    } +  );    const buttonLabel = isReplying      ? intl.formatMessage({ @@ -135,16 +150,24 @@ export const UserComment: FC<UserCommentProps> = ({              <span className={styles.author}>{author.name}</span>            )}          </header> -        <Meta +        <MetaList            className={styles.date} -          data={{ -            publication: { -              date: publicationDate, -              time: publicationTime, -              target: `#comment-${id}`, -            }, -          }}            isInline +          items={[ +            { +              id: 'publication-date', +              label: intl.formatMessage({ +                defaultMessage: 'Published on:', +                description: 'Comment: publication date label', +                id: 'soj7do', +              }), +              value: ( +                <Link href={`#comment-${id}`}> +                  <time dateTime={isoDateTime}>{commentDate}</time> +                </Link> +              ), +            }, +          ]}          />          <div            className={styles.body} diff --git a/src/components/organisms/layout/overview.module.scss b/src/components/organisms/layout/overview.module.scss index 59ce167..c1d9463 100644 --- a/src/components/organisms/layout/overview.module.scss +++ b/src/components/organisms/layout/overview.module.scss @@ -11,7 +11,7 @@        auto-fit,        min(calc(100vw - (var(--spacing-md) * 2)), 23ch)      ); -    row-gap: var(--spacing-2xs); +    row-gap: var(--spacing-sm);      @include mix.media("screen") {        @include mix.dimensions("md") { @@ -21,21 +21,6 @@          );        }      } - -    &--has-techno { -      div:last-child { -        gap: var(--spacing-2xs); - -        dd { -          padding: 0 var(--spacing-2xs); -          border: fun.convert-px(1) solid var(--color-border-dark); - -          &::before { -            display: none; -          } -        } -      } -    }    }    .cover { diff --git a/src/components/organisms/layout/overview.stories.tsx b/src/components/organisms/layout/overview.stories.tsx index 8f56d3a..562d7c4 100644 --- a/src/components/organisms/layout/overview.stories.tsx +++ b/src/components/organisms/layout/overview.stories.tsx @@ -1,5 +1,6 @@  import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Overview, type OverviewMeta } from './overview'; +import type { MetaItemData } from '../../molecules'; +import { Overview } from './overview';  /**   * Overview - Storybook Meta @@ -54,10 +55,10 @@ const cover = {    width: 640,  }; -const meta: OverviewMeta = { -  creation: { date: '2022-05-09' }, -  license: 'Dignissimos ratione veritatis', -}; +const meta = [ +  { id: 'creation-date', label: 'Creation date', value: '2022-05-09' }, +  { id: 'license', label: 'License', value: 'Dignissimos ratione veritatis' }, +] satisfies MetaItemData[];  /**   * Overview Stories - Default diff --git a/src/components/organisms/layout/overview.test.tsx b/src/components/organisms/layout/overview.test.tsx index 0f2af7b..b98bd6f 100644 --- a/src/components/organisms/layout/overview.test.tsx +++ b/src/components/organisms/layout/overview.test.tsx @@ -1,27 +1,33 @@  import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { Overview, type OverviewMeta } from './overview'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import type { MetaItemData } from '../../molecules'; +import { Overview } from './overview';  const cover = {    alt: 'Incidunt unde quam',    height: 480, -  src: 'http://placeimg.com/640/480/cats', +  src: 'https://picsum.photos/640/480',    width: 640,  }; -const data: OverviewMeta = { -  creation: { date: '2022-05-09' }, -  license: 'Dignissimos ratione veritatis', -}; +const meta = [ +  { id: 'creation-date', label: 'Creation date', value: '2022-05-09' }, +  { id: 'license', label: 'License', value: 'Dignissimos ratione veritatis' }, +] satisfies MetaItemData[];  describe('Overview', () => { -  it('renders some data', () => { -    render(<Overview meta={data} />); -    expect(screen.getByText(data.license!)).toBeInTheDocument(); +  it('renders some meta', () => { +    render(<Overview meta={meta} />); + +    const metaLabels = meta.map((item) => item.label); + +    for (const label of metaLabels) { +      expect(rtlScreen.getByText(label)).toBeInTheDocument(); +    }    });    it('renders a cover', () => { -    render(<Overview cover={cover} meta={data} />); -    expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); +    render(<Overview cover={cover} meta={meta} />); +    expect(rtlScreen.getByRole('img', { name: cover.alt })).toBeInTheDocument();    });  }); diff --git a/src/components/organisms/layout/overview.tsx b/src/components/organisms/layout/overview.tsx index 8af58ec..ede2627 100644 --- a/src/components/organisms/layout/overview.tsx +++ b/src/components/organisms/layout/overview.tsx @@ -1,19 +1,9 @@  import NextImage, { type ImageProps as NextImageProps } from 'next/image';  import type { FC } from 'react';  import { Figure } from '../../atoms'; -import { Meta, type MetaData } from '../../molecules'; +import { MetaList, type MetaItemData } from '../../molecules';  import styles from './overview.module.scss'; -export type OverviewMeta = Pick< -  MetaData, -  | 'creation' -  | 'license' -  | 'popularity' -  | 'repositories' -  | 'technologies' -  | 'update' ->; -  export type OverviewProps = {    /**     * Set additional classnames to the overview wrapper. @@ -26,7 +16,7 @@ export type OverviewProps = {    /**     * The overview meta.     */ -  meta: OverviewMeta; +  meta: MetaItemData[];  };  /** @@ -39,20 +29,16 @@ export const Overview: FC<OverviewProps> = ({    cover,    meta,  }) => { -  const { technologies, ...remainingMeta } = meta; -  const metaModifier = technologies ? styles['meta--has-techno'] : ''; +  const wrapperClass = `${styles.wrapper} ${className}`;    return ( -    <div className={`${styles.wrapper} ${className}`}> +    <div className={wrapperClass}>        {cover ? (          <Figure>            <NextImage {...cover} className={styles.cover} />          </Figure>        ) : null} -      <Meta -        className={`${styles.meta} ${metaModifier}`} -        data={{ ...remainingMeta, technologies }} -      /> +      <MetaList className={styles.meta} hasInlinedValues items={meta} />      </div>    );  }; diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss index 9dc1a69..ffc30ac 100644 --- a/src/components/organisms/layout/summary.module.scss +++ b/src/components/organisms/layout/summary.module.scss @@ -109,13 +109,9 @@    flex-flow: row wrap;    font-size: var(--font-size-sm); -  &__item { -    flex: 1 0 min(calc(100vw - 2 * var(--spacing-md)), 14ch); -  } -    @include mix.media("screen") {      @include mix.dimensions("sm") { -      display: flex; +      flex-flow: column wrap;        margin-top: 0;      }    } diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx index fa3dfe5..f5c16cd 100644 --- a/src/components/organisms/layout/summary.tsx +++ b/src/components/organisms/layout/summary.tsx @@ -2,6 +2,7 @@ import NextImage, { type ImageProps as NextImageProps } from 'next/image';  import type { FC, ReactNode } from 'react';  import { useIntl } from 'react-intl';  import type { Article, Meta as MetaType } from '../../../types'; +import { getFormattedDate } from '../../../utils/helpers';  import { useReadingTime } from '../../../utils/hooks';  import {    ButtonLink, @@ -11,7 +12,7 @@ import {    Link,    Figure,  } from '../../atoms'; -import { Meta, type MetaData } from '../../molecules'; +import { MetaList, type MetaItemData } from '../../molecules';  import styles from './summary.module.scss';  export type Cover = Pick<NextImageProps, 'alt' | 'src' | 'width' | 'height'>; @@ -69,42 +70,134 @@ export const Summary: FC<SummaryProps> = ({        ),      }    ); -  const { author, commentsCount, cover, dates, thematics, topics, wordsCount } = -    meta; -  const readingTime = useReadingTime(wordsCount, true); +  const readingTime = useReadingTime(meta.wordsCount, true); -  const getMeta = (): MetaData => { -    return { -      author: author?.name, -      publication: { date: dates.publication }, -      update: -        dates.update && dates.publication !== dates.update -          ? { date: dates.update } -          : undefined, -      readingTime, -      thematics: thematics?.map((thematic) => ( -        <Link key={thematic.id} href={thematic.url}> -          {thematic.name} -        </Link> -      )), -      topics: topics?.map((topic) => ( -        <Link key={topic.id} href={topic.url}> -          {topic.name} -        </Link> -      )), -      comments: { -        about: title, -        count: commentsCount ?? 0, -        target: `${url}#comments`, +  /** +   * Retrieve a formatted date (and time). +   * +   * @param {string} date - A date string. +   * @returns {JSX.Element} The formatted date wrapped in a time element. +   */ +  const getDate = (date: string): JSX.Element => { +    const isoDate = new Date(`${date}`).toISOString(); + +    return <time dateTime={isoDate}>{getFormattedDate(date)}</time>; +  }; + +  const getMetaItems = (): MetaItemData[] => { +    const summaryMeta: MetaItemData[] = [ +      { +        id: 'publication-date', +        label: intl.formatMessage({ +          defaultMessage: 'Published on:', +          description: 'Summary: publication date label', +          id: 'TvQ2Ee', +        }), +        value: getDate(meta.dates.publication),        }, -    }; +    ]; + +    if (meta.dates.update && meta.dates.update !== meta.dates.publication) +      summaryMeta.push({ +        id: 'update-date', +        label: intl.formatMessage({ +          defaultMessage: 'Updated on:', +          description: 'Summary: update date label', +          id: 'f0Z/Po', +        }), +        value: getDate(meta.dates.update), +      }); + +    summaryMeta.push({ +      id: 'reading-time', +      label: intl.formatMessage({ +        defaultMessage: 'Reading time:', +        description: 'Summary: reading time label', +        id: 'tyzdql', +      }), +      value: readingTime, +    }); + +    if (meta.author) +      summaryMeta.push({ +        id: 'author', +        label: intl.formatMessage({ +          defaultMessage: 'Written by:', +          description: 'Summary: author label', +          id: 'r/6HOI', +        }), +        value: meta.author.name, +      }); + +    if (meta.thematics) +      summaryMeta.push({ +        id: 'thematics', +        label: intl.formatMessage({ +          defaultMessage: 'Thematics:', +          description: 'Summary: thematics label', +          id: 'bk0WOp', +        }), +        value: meta.thematics.map((thematic) => { +          return { +            id: `thematic-${thematic.id}`, +            value: <Link href={thematic.url}>{thematic.name}</Link>, +          }; +        }), +      }); + +    if (meta.topics) +      summaryMeta.push({ +        id: 'topics', +        label: intl.formatMessage({ +          defaultMessage: 'Topics:', +          description: 'Summary: topics label', +          id: 'yIZ+AC', +        }), +        value: meta.topics.map((topic) => { +          return { +            id: `topic-${topic.id}`, +            value: <Link href={topic.url}>{topic.name}</Link>, +          }; +        }), +      }); + +    if (meta.commentsCount !== undefined) { +      const commentsCount = intl.formatMessage( +        { +          defaultMessage: +            '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>', +          description: 'Summary: comments count', +          id: 'ye/vlA', +        }, +        { +          a11y: (chunks: ReactNode) => ( +            <span className="screen-reader-text">{chunks}</span> +          ), +          commentsCount: meta.commentsCount, +          title, +        } +      ); +      summaryMeta.push({ +        id: 'comments-count', +        label: intl.formatMessage({ +          defaultMessage: 'Comments:', +          description: 'Summary: comments label', +          id: 'bfPp0g', +        }), +        value: ( +          <Link href={`${url}#comments`}>{commentsCount as JSX.Element}</Link> +        ), +      }); +    } + +    return summaryMeta;    };    return (      <article className={styles.wrapper}> -      {cover ? ( +      {meta.cover ? (          <Figure> -          <NextImage {...cover} className={styles.cover} /> +          <NextImage {...meta.cover} className={styles.cover} />          </Figure>        ) : null}        <header className={styles.header}> @@ -121,21 +214,19 @@ export const Summary: FC<SummaryProps> = ({            dangerouslySetInnerHTML={{ __html: intro }}          />          <ButtonLink className={styles['read-more']} to={url}> -          <> -            {readMore} -            <Icon -              aria-hidden={true} -              className={styles.icon} -              // eslint-disable-next-line react/jsx-no-literals -- Direction allowed -              orientation="right" -              // eslint-disable-next-line react/jsx-no-literals -- Shape allowed -              shape="arrow" -            /> -          </> +          {readMore} +          <Icon +            aria-hidden={true} +            className={styles.icon} +            // eslint-disable-next-line react/jsx-no-literals -- Direction allowed +            orientation="right" +            // eslint-disable-next-line react/jsx-no-literals -- Shape allowed +            shape="arrow" +          />          </ButtonLink>        </div>        <footer className={styles.footer}> -        <Meta className={styles.meta} data={getMeta()} spacing="xs" /> +        <MetaList className={styles.meta} items={getMetaItems()} />        </footer>      </article>    ); diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx index 683b6b2..7977382 100644 --- a/src/components/templates/page/page-layout.stories.tsx +++ b/src/components/templates/page/page-layout.stories.tsx @@ -271,23 +271,38 @@ Post.args = {    breadcrumb: postBreadcrumb,    title: pageTitle,    intro: pageIntro, -  headerMeta: { -    publication: { date: '2020-03-14' }, -    thematics: [ -      <Link key="cat1" href="#"> -        Cat 1 -      </Link>, -      <Link key="cat2" href="#"> -        Cat 2 -      </Link>, -    ], -  }, -  footerMeta: { -    custom: { +  headerMeta: [ +    { id: 'publication-date', label: 'Published on:', value: '2020-03-14' }, +    { +      id: 'thematics', +      label: 'Thematics:', +      value: [ +        { +          id: 'cat-1', +          value: ( +            <Link key="cat1" href="#"> +              Cat 1 +            </Link> +          ), +        }, +        { +          id: 'cat-2', +          value: ( +            <Link key="cat2" href="#"> +              Cat 2 +            </Link> +          ), +        }, +      ], +    }, +  ], +  footerMeta: [ +    { +      id: 'read-more',        label: 'Read more about:',        value: <ButtonLink to="#">Topic 1</ButtonLink>,      }, -  }, +  ],    children: (      <>        <Heading level={2}>Impedit commodi rerum</Heading> @@ -357,7 +372,7 @@ export const Blog = Template.bind({});  Blog.args = {    breadcrumb: postsListBreadcrumb,    title: 'Blog', -  headerMeta: { total: posts.length }, +  headerMeta: [{ id: 'total', label: 'Total:', value: `${posts.length}` }],    children: (      <PostsList        posts={posts} diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx index ee3fd3a..dbac43e 100644 --- a/src/components/templates/page/page-layout.tsx +++ b/src/components/templates/page/page-layout.tsx @@ -16,7 +16,6 @@ import { Heading, Notice, type NoticeKind, Sidebar } from '../../atoms';  import {    Breadcrumb,    type BreadcrumbItem, -  type MetaData,    PageFooter,    type PageFooterProps,    PageHeader, @@ -41,13 +40,6 @@ const hasComments = (  ): comments is SingleComment[] =>    Array.isArray(comments) && comments.length > 0; -/** - * Check if meta properties are defined. - * - * @param {MetaData} meta - The metadata. - */ -const hasMeta = (meta: MetaData) => Object.values(meta).every((value) => value); -  type CommentStatus = {    isReply: boolean;    kind: NoticeKind; @@ -256,7 +248,7 @@ export const PageLayout: FC<PageLayoutProps> = ({            {children}          </div>        )} -      {footerMeta && hasMeta(footerMeta) ? ( +      {footerMeta?.length ? (          <PageFooter meta={footerMeta} className={styles.footer} />        ) : null}        <Sidebar | 
