aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules/layout
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/molecules/layout')
-rw-r--r--src/components/molecules/layout/card.module.scss77
-rw-r--r--src/components/molecules/layout/card.stories.tsx102
-rw-r--r--src/components/molecules/layout/card.test.tsx52
-rw-r--r--src/components/molecules/layout/card.tsx114
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;