aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/components/atoms/lists/description-list.module.scss42
-rw-r--r--src/components/atoms/lists/description-list.stories.tsx18
-rw-r--r--src/components/atoms/lists/description-list.tsx45
-rw-r--r--src/components/molecules/images/responsive-image.tsx5
-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
8 files changed, 435 insertions, 20 deletions
diff --git a/src/components/atoms/lists/description-list.module.scss b/src/components/atoms/lists/description-list.module.scss
index 4758816..caa2711 100644
--- a/src/components/atoms/lists/description-list.module.scss
+++ b/src/components/atoms/lists/description-list.module.scss
@@ -6,18 +6,6 @@
gap: var(--spacing-2xs);
margin: 0;
- &__item {
- display: flex;
- flex-flow: column wrap;
- gap: var(--spacing-2xs);
-
- @include mix.media("screen") {
- @include mix.dimensions("sm") {
- flex-flow: row wrap;
- }
- }
- }
-
&__term {
flex: 0 0 max-content;
color: var(--color-fg-light);
@@ -27,16 +15,40 @@
&__description {
flex: 0 0 auto;
margin: 0;
+ }
+
+ &__item {
+ display: flex;
+ }
+
+ &--inline &__item {
+ flex-flow: column wrap;
@include mix.media("screen") {
- @include mix.dimensions("sm") {
- &:not(:first-of-type) {
+ @include mix.dimensions("xs") {
+ flex-flow: row wrap;
+ gap: var(--spacing-2xs);
+
+ .list__description:not(:first-of-type) {
&::before {
content: "/";
- margin: 0 var(--spacing-2xs);
+ margin-right: var(--spacing-2xs);
}
}
}
}
}
+
+ &--column#{&}--responsive {
+ @include mix.media("screen") {
+ @include mix.dimensions("xs") {
+ flex-flow: row wrap;
+ gap: var(--spacing-lg);
+ }
+ }
+ }
+
+ &--column &__item {
+ flex-flow: column wrap;
+ }
}
diff --git a/src/components/atoms/lists/description-list.stories.tsx b/src/components/atoms/lists/description-list.stories.tsx
index c65241d..66d94af 100644
--- a/src/components/atoms/lists/description-list.stories.tsx
+++ b/src/components/atoms/lists/description-list.stories.tsx
@@ -6,6 +6,9 @@ import DescriptionListComponent, {
export default {
title: 'Atoms/Lists',
component: DescriptionListComponent,
+ args: {
+ layout: 'column',
+ },
argTypes: {
className: {
control: {
@@ -31,6 +34,21 @@ export default {
value: {},
},
},
+ layout: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list layout.',
+ options: ['column', 'inline'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'column' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
},
} as ComponentMeta<typeof DescriptionListComponent>;
diff --git a/src/components/atoms/lists/description-list.tsx b/src/components/atoms/lists/description-list.tsx
index a5ab1d5..0a92465 100644
--- a/src/components/atoms/lists/description-list.tsx
+++ b/src/components/atoms/lists/description-list.tsx
@@ -22,9 +22,29 @@ export type DescriptionListProps = {
*/
className?: string;
/**
+ * Set additional classnames to the `dd` element.
+ */
+ descriptionClassName?: string;
+ /**
+ * Set additional classnames to the `dt`/`dd` couple wrapper.
+ */
+ groupClassName?: string;
+ /**
* The list items.
*/
items: DescriptionListItem[];
+ /**
+ * The list items layout. Default: column.
+ */
+ layout?: 'inline' | 'column';
+ /**
+ * Define if the layout should automatically create rows/columns.
+ */
+ responsiveLayout?: boolean;
+ /**
+ * Set additional classnames to the `dt` element.
+ */
+ termClassName?: string;
};
/**
@@ -34,8 +54,16 @@ export type DescriptionListProps = {
*/
const DescriptionList: VFC<DescriptionListProps> = ({
className = '',
+ descriptionClassName = '',
+ groupClassName = '',
items,
+ layout = 'column',
+ responsiveLayout = false,
+ termClassName = '',
}) => {
+ const layoutModifier = `list--${layout}`;
+ const responsiveModifier = responsiveLayout ? 'list--responsive' : '';
+
/**
* Retrieve the description list items wrapped in a div element.
*
@@ -45,10 +73,13 @@ const DescriptionList: VFC<DescriptionListProps> = ({
const getItems = (listItems: DescriptionListItem[]): JSX.Element[] => {
return listItems.map(({ id, term, value }) => {
return (
- <div key={id} className={styles.list__item}>
- <dt className={styles.list__term}>{term}</dt>
+ <div key={id} className={`${styles.list__item} ${groupClassName}`}>
+ <dt className={`${styles.list__term} ${termClassName}`}>{term}</dt>
{value.map((currentValue, index) => (
- <dd key={`${id}-${index}`} className={styles.list__description}>
+ <dd
+ key={`${id}-${index}`}
+ className={`${styles.list__description} ${descriptionClassName}`}
+ >
{currentValue}
</dd>
))}
@@ -57,7 +88,13 @@ const DescriptionList: VFC<DescriptionListProps> = ({
});
};
- return <dl className={`${styles.list} ${className}`}>{getItems(items)}</dl>;
+ return (
+ <dl
+ className={`${styles.list} ${styles[layoutModifier]} ${styles[responsiveModifier]} ${className}`}
+ >
+ {getItems(items)}
+ </dl>
+ );
};
export default DescriptionList;
diff --git a/src/components/molecules/images/responsive-image.tsx b/src/components/molecules/images/responsive-image.tsx
index 3d54e95..1d8787e 100644
--- a/src/components/molecules/images/responsive-image.tsx
+++ b/src/components/molecules/images/responsive-image.tsx
@@ -3,7 +3,10 @@ import Image, { ImageProps } from 'next/image';
import { VFC } from 'react';
import styles from './responsive-image.module.scss';
-type ResponsiveImageProps = Omit<ImageProps, 'alt' | 'width' | 'height'> & {
+export type ResponsiveImageProps = Omit<
+ ImageProps,
+ 'alt' | 'width' | 'height'
+> & {
/**
* An alternative text.
*/
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;