diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-10-17 19:46:08 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-11 18:14:41 +0100 |
| commit | c153f93dc8691a71dc76aad3dd618298da9d238a (patch) | |
| tree | 9c116c1472bab5585f98bceee19cfeca5041360d | |
| parent | 006b15b467a5cd835a6eab1b49023100bdc8f2e6 (diff) | |
refactor(components): rewrite Card component
* make the component more generic
* merge `<Summary />` and `<Comment />` styles into card component
to avoid repeating the same structure
* remove most of the props to use composition
However the CSS is a bit complex because of the two variants...
Also, the component should be refactored when the CSS pseudo-class
`:has` has enough support: the provider and the `cover` and `meta`
props should be removed.
36 files changed, 1796 insertions, 958 deletions
diff --git a/src/components/atoms/buttons/button-link/button-link.tsx b/src/components/atoms/buttons/button-link/button-link.tsx index f8bbadc..96f5d3e 100644 --- a/src/components/atoms/buttons/button-link/button-link.tsx +++ b/src/components/atoms/buttons/button-link/button-link.tsx @@ -27,7 +27,7 @@ export type ButtonLinkProps = Omit< * * @default 'rectangle' */ - shape?: 'circle' | 'rectangle' | 'square'; + shape?: 'auto' | 'circle' | 'rectangle' | 'square'; /** * Define an URL or anchor as target. */ diff --git a/src/components/molecules/card/card-actions.test.tsx b/src/components/molecules/card/card-actions.test.tsx new file mode 100644 index 0000000..94c2677 --- /dev/null +++ b/src/components/molecules/card/card-actions.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { CardActions } from './card-actions'; + +describe('CardActions', () => { + it('renders its children', () => { + const actions = 'animi et omnis'; + + render(<CardActions>{actions}</CardActions>); + + expect(rtlScreen.getByText(actions)).toBeInTheDocument(); + }); + + it('can render its children with start alignment', () => { + const actions = 'animi et omnis'; + + render(<CardActions alignment="start">{actions}</CardActions>); + + expect(rtlScreen.getByText(actions)).toHaveStyle(`--alignment: flex-start`); + }); + + it('can render its children with centered alignment', () => { + const actions = 'animi et omnis'; + + render(<CardActions alignment="center">{actions}</CardActions>); + + expect(rtlScreen.getByText(actions)).toHaveStyle(`--alignment: center`); + }); + + it('can render its children with end alignment', () => { + const actions = 'animi et omnis'; + + render(<CardActions alignment="end">{actions}</CardActions>); + + expect(rtlScreen.getByText(actions)).toHaveStyle(`--alignment: flex-end`); + }); +}); diff --git a/src/components/molecules/card/card-actions.tsx b/src/components/molecules/card/card-actions.tsx new file mode 100644 index 0000000..9d3e09e --- /dev/null +++ b/src/components/molecules/card/card-actions.tsx @@ -0,0 +1,42 @@ +import { + type ForwardRefRenderFunction, + type HTMLAttributes, + forwardRef, + type ReactNode, +} from 'react'; +import styles from './card.module.scss'; + +export type CardActionsProps = Omit< + HTMLAttributes<HTMLDivElement>, + 'children' +> & { + /** + * The actions alignment. + * + * @default 'end' + */ + alignment?: 'center' | 'end' | 'start'; + /** + * The card actions (ie. buttons, links...). + */ + children: ReactNode; +}; + +const CardActionsWithRef: ForwardRefRenderFunction< + HTMLDivElement, + CardActionsProps +> = ({ alignment = 'end', children, className = '', style, ...props }, ref) => { + const actionsClass = `${styles.actions} ${className}`; + const actionsStyles = { + ...style, + '--alignment': alignment === 'center' ? alignment : `flex-${alignment}`, + }; + + return ( + <div {...props} className={actionsClass} ref={ref} style={actionsStyles}> + {children} + </div> + ); +}; + +export const CardActions = forwardRef(CardActionsWithRef); diff --git a/src/components/molecules/card/card-body.tsx b/src/components/molecules/card/card-body.tsx new file mode 100644 index 0000000..40cf515 --- /dev/null +++ b/src/components/molecules/card/card-body.tsx @@ -0,0 +1,18 @@ +import type { FC, HTMLAttributes } from 'react'; +import styles from './card.module.scss'; + +export type CardBodyProps = HTMLAttributes<HTMLElement>; + +export const CardBody: FC<CardBodyProps> = ({ + children, + className = '', + ...props +}) => { + const bodyClass = `${styles.body} ${className}`; + + return ( + <div {...props} className={bodyClass}> + {children} + </div> + ); +}; diff --git a/src/components/molecules/card/card-cover.test.tsx b/src/components/molecules/card/card-cover.test.tsx new file mode 100644 index 0000000..541da44 --- /dev/null +++ b/src/components/molecules/card/card-cover.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import NextImage from 'next/image'; +import { CardCover } from './card-cover'; + +describe('CardCover', () => { + it('renders a cover', () => { + const altTxt = 'nam cupiditate ex'; + + render( + <CardCover> + <NextImage + alt={altTxt} + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ); + + expect(rtlScreen.getByRole('img', { name: altTxt })).toBeInTheDocument(); + }); +}); diff --git a/src/components/molecules/card/card-cover.tsx b/src/components/molecules/card/card-cover.tsx new file mode 100644 index 0000000..3d6bc7b --- /dev/null +++ b/src/components/molecules/card/card-cover.tsx @@ -0,0 +1,29 @@ +import { + type ForwardRefRenderFunction, + type ReactElement, + forwardRef, +} from 'react'; +import { Figure, type FigureProps } from '../../atoms'; +import styles from './card.module.scss'; + +export type CardCoverProps = Omit<FigureProps, 'caption' | 'children'> & { + /** + * The cover. + */ + children: ReactElement; +}; + +const CardCoverWithRef: ForwardRefRenderFunction< + HTMLElement, + CardCoverProps +> = ({ className = '', children, ...props }, ref) => { + const coverClass = `${styles.cover} ${className}`; + + return ( + <Figure {...props} className={coverClass} ref={ref}> + {children} + </Figure> + ); +}; + +export const CardCover = forwardRef(CardCoverWithRef); diff --git a/src/components/molecules/card/card-footer.tsx b/src/components/molecules/card/card-footer.tsx new file mode 100644 index 0000000..56a6513 --- /dev/null +++ b/src/components/molecules/card/card-footer.tsx @@ -0,0 +1,32 @@ +import { + forwardRef, + type ForwardRefRenderFunction, + type ReactNode, +} from 'react'; +import { Footer, type FooterProps } from '../../atoms'; +import { useCardFooterMeta } from './card-provider'; +import styles from './card.module.scss'; + +export type CardFooterProps = Omit<FooterProps, 'children'> & { + /** + * The card footer contents. + */ + children?: ReactNode; +}; + +const CardFooterWithRef: ForwardRefRenderFunction< + HTMLElement, + CardFooterProps +> = ({ children, className = '', ...props }, ref) => { + const footerClass = `${styles.footer} ${className}`; + const meta = useCardFooterMeta(); + + return ( + <Footer {...props} className={footerClass} ref={ref}> + {children} + {meta} + </Footer> + ); +}; + +export const CardFooter = forwardRef(CardFooterWithRef); diff --git a/src/components/molecules/card/card-header.tsx b/src/components/molecules/card/card-header.tsx new file mode 100644 index 0000000..213d1bc --- /dev/null +++ b/src/components/molecules/card/card-header.tsx @@ -0,0 +1,32 @@ +import { + forwardRef, + type ForwardRefRenderFunction, + type ReactNode, +} from 'react'; +import { Header, type HeaderProps } from '../../atoms'; +import { useCardCover } from './card-provider'; +import styles from './card.module.scss'; + +export type CardHeaderProps = Omit<HeaderProps, 'children'> & { + /** + * The card header contents. + */ + children?: ReactNode; +}; + +const CardHeaderWithRef: ForwardRefRenderFunction< + HTMLElement, + CardHeaderProps +> = ({ children, className = '', ...props }, ref) => { + const cover = useCardCover(); + const headerClass = `${styles.header} ${className}`; + + return ( + <Header {...props} className={headerClass} ref={ref}> + {cover} + {children} + </Header> + ); +}; + +export const CardHeader = forwardRef(CardHeaderWithRef); diff --git a/src/components/molecules/card/card-meta.tsx b/src/components/molecules/card/card-meta.tsx new file mode 100644 index 0000000..403d543 --- /dev/null +++ b/src/components/molecules/card/card-meta.tsx @@ -0,0 +1,16 @@ +import { type ForwardRefRenderFunction, forwardRef } from 'react'; +import { MetaList, type MetaListProps } from '../meta-list'; +import styles from './card.module.scss'; + +export type CardMetaProps = MetaListProps; + +const CardMetaWithRef: ForwardRefRenderFunction< + HTMLDListElement, + CardMetaProps +> = ({ className = '', ...props }, ref) => { + const metaClass = `${styles.meta} ${className}`; + + return <MetaList {...props} className={metaClass} ref={ref} />; +}; + +export const CardMeta = forwardRef(CardMetaWithRef); diff --git a/src/components/molecules/card/card-provider.tsx b/src/components/molecules/card/card-provider.tsx new file mode 100644 index 0000000..5bcdb49 --- /dev/null +++ b/src/components/molecules/card/card-provider.tsx @@ -0,0 +1,43 @@ +import { + createContext, + type FC, + type ReactElement, + type ReactNode, + useContext, +} from 'react'; + +export type CardCoverProviderProps = { + children: ReactNode; + cover?: ReactElement; +}; + +export const CardCoverContext = createContext<ReactElement | null>(null); + +export const useCardCover = () => useContext(CardCoverContext); + +export const CardCoverProvider: FC<CardCoverProviderProps> = ({ + children, + cover, +}) => ( + <CardCoverContext.Provider value={cover ?? null}> + {children} + </CardCoverContext.Provider> +); + +export type CardFooterMetaProviderProps = { + children: ReactNode; + meta?: ReactElement; +}; + +export const CardFooterMetaContext = createContext<ReactElement | null>(null); + +export const useCardFooterMeta = () => useContext(CardFooterMetaContext); + +export const CardFooterMetaProvider: FC<CardFooterMetaProviderProps> = ({ + children, + meta, +}) => ( + <CardFooterMetaContext.Provider value={meta ?? null}> + {children} + </CardFooterMetaContext.Provider> +); diff --git a/src/components/molecules/card/card-title.test.tsx b/src/components/molecules/card/card-title.test.tsx new file mode 100644 index 0000000..9dbf6ac --- /dev/null +++ b/src/components/molecules/card/card-title.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { CardTitle } from './card-title'; + +describe('CardTitle', () => { + it('renders a title of level 2', () => { + const title = 'animi et omnis'; + + render(<CardTitle>{title}</CardTitle>); + + expect(rtlScreen.getByRole('heading', { level: 2 })).toHaveTextContent( + title + ); + }); + + it('can render a title with a custom level', () => { + const level = 4; + const title = 'animi et omnis'; + + render(<CardTitle level={level}>{title}</CardTitle>); + + expect(rtlScreen.getByRole('heading', { level })).toHaveTextContent(title); + }); +}); diff --git a/src/components/molecules/card/card-title.tsx b/src/components/molecules/card/card-title.tsx new file mode 100644 index 0000000..09d61ea --- /dev/null +++ b/src/components/molecules/card/card-title.tsx @@ -0,0 +1,27 @@ +import { type ForwardRefRenderFunction, forwardRef } from 'react'; +import { Heading, type HeadingProps } from '../../atoms'; +import styles from './card.module.scss'; + +export type CardTitleProps = Omit<HeadingProps, 'level'> & { + /** + * The title level (between 1 and 6). + * + * @default 2 + */ + level?: HeadingProps['level']; +}; + +const CardTitleWithRef: ForwardRefRenderFunction< + HTMLHeadingElement, + CardTitleProps +> = ({ children, className = '', level = 2, ...props }, ref) => { + const headingClass = `${styles.title} ${className}`; + + return ( + <Heading {...props} className={headingClass} level={level} ref={ref}> + {children} + </Heading> + ); +}; + +export const CardTitle = forwardRef(CardTitleWithRef); diff --git a/src/components/molecules/card/card.module.scss b/src/components/molecules/card/card.module.scss new file mode 100644 index 0000000..65f92f6 --- /dev/null +++ b/src/components/molecules/card/card.module.scss @@ -0,0 +1,296 @@ +@use "../../../styles/abstracts/functions" as fun; + +$breakpoint: 50ch; + +.cover { + width: var(--cover-width, 100%); + height: var(--cover-height, fun.convert-px(150)); + max-width: none; + position: relative; + + > * { + width: 100%; + height: 100%; + max-width: 100%; + position: absolute; + inset: 0; + object-position: center; + object-fit: cover; + } +} + +.title { + background: none; + padding: 0; +} + +.body { + max-width: 80ch; + color: var(--color-fg); + font-weight: 400; +} + +.actions { + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: var(--alignment); + column-gap: var(--spacing-md); + row-gap: var(--spacing-sm); + padding-block: var(--spacing-sm); +} + +.meta { + font-size: var(--font-size-sm); +} + +:where(.footer) .meta { + flex-flow: row wrap; +} + +.header, +.footer { + display: contents; +} + +.card { + --card-padding: clamp(var(--spacing-sm), 3vw, var(--spacing-md)); + + column-gap: var(--spacing-md); + row-gap: var(--spacing-sm); + width: 100%; + height: 100%; + background: var(--color-bg); + + &--variant-1 { + --cover-height: clamp( + #{fun.convert-px(100)}, + calc(#{fun.convert-px(200)} - 10cqw), + #{fun.convert-px(200)} + ); + } + + &--variant-2 { + --cover-height: #{fun.convert-px(90)}; + --cover-width: var(--cover-height); + } +} + +:where(.card--variant-2) .cover { + border-radius: fun.convert-px(3); + box-shadow: + 0 0 0 fun.convert-px(1) var(--color-shadow-light), + fun.convert-px(2) fun.convert-px(2) 0 fun.convert-px(1) var(--color-shadow); +} + +.wrapper { + container: card / inline-size; + + &--centered { + .header, + .body, + .footer { + text-align: center; + } + + .meta { + margin-inline: auto; + } + } + + &--is-link { + --scale-up: 1.05; + + &:not(:disabled):focus:not(:hover) { + text-decoration: none; + + .title { + text-decoration: underline solid var(--color-primary) 0.3ex; + } + } + } +} + +/* stylelint-disable no-descending-specificity -- Stylelint complains about + * specificity but I think it is clearer and DRY this way. */ + +:where(.wrapper--is-block) .card--variant-2 { + border: fun.convert-px(1) solid var(--color-border); + box-shadow: + fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-shadow-light), + fun.convert-px(4) fun.convert-px(4) fun.convert-px(3) fun.convert-px(-2) + var(--color-shadow); +} + +@container card (width <= #{$breakpoint}) { + .card { + display: flex; + flex-flow: column wrap; + row-gap: var(--card-padding); + + &:where(:not(&--has-cover)) { + padding: var(--card-padding); + } + + &:where(&--has-cover) { + padding-block-end: var(--card-padding); + + :where(.header, .footer) > *:not(.cover), + .body { + padding-inline: var(--card-padding); + } + } + } + + :where(.card--variant-1) { + .cover { + --cover-width: 100%; + } + + :where(.footer) .meta { + margin-block-start: auto; + justify-content: space-between; + } + } + + :where(.card--variant-2) { + :where(.header) { + .cover, + .title { + margin-inline: auto; + } + + .cover { + margin-block-start: var(--card-padding); + } + } + } +} + +@container card (width > #{$breakpoint}) { + .card { + display: grid; + grid-auto-rows: max-content; + padding: var(--card-padding); + } + + .cover, + .title, + .meta, + .body, + .actions { + grid-column: 1; + } + + .card--variant-1 { + grid-auto-columns: minmax(30ch, 80ch) 1fr minmax(min-content, 25cqw); + } + + :where(.wrapper--is-block) .card--variant-1 { + 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); + } + + .card--variant-2 { + grid-auto-columns: minmax(min-content, 15cqw) 1fr minmax(30ch, 80ch); + } + + .cover { + grid-row-start: 1; + } + + :where(.wrapper--is-link:not(:disabled, :hover):focus) .title { + text-decoration: underline solid var(--color-primary) 0.3ex; + } + + :where(.card--has-cover .header:only-child) .cover:only-child { + --cover-width: calc(100% + (2 * var(--card-padding))); + --cover-height: calc(100% + (2 * var(--card-padding))); + + margin-block-start: calc(var(--card-padding) * -1); + margin-inline-start: calc(var(--card-padding) * -1); + } + + :where(.card--variant-1) { + :where(.header:only-child) > *:only-child, + .body:only-child, + :where(.footer:only-child) > *:only-child { + grid-column: 1 / span 2; + } + + :where(.header:not(:only-child)) .cover, + :where(.header:only-child) .cover:not(:only-child), + :where(.footer:not(:only-child)) .meta, + :where(.footer:only-child) .meta:not(:only-child) { + grid-column: 3; + } + + :where(.footer) .meta { + grid-row-start: 1; + flex-flow: column wrap; + } + + :where(.body:first-child + .footer) .meta, + :where(.footer:only-child) .meta { + grid-row-end: 1; + } + + :where(.header) .title, + :where(.header) .cover + .meta { + grid-row: 1; + align-self: center; + } + } + + :where(.card--variant-1.card--has-cover .footer) .meta { + grid-row: 2 / span 3; + } + + :where(.card--variant-1.card--no-footer-meta .header:not(:only-child)) .cover, + :where(.card--variant-1.card--no-footer-meta .header) .cover:not(:only-child), + :where(.card--variant-1.card--no-cover .footer) .meta { + --cover-height: 100%; + + grid-row-end: span 4; + } + + :where(.card--variant-2) { + .header:only-child > *, + .body:only-child, + .footer:only-child > * { + grid-column: 1 / span 2; + } + + .header:only-child > .meta { + justify-self: center; + } + + :where(.header:not(:only-child)) .meta, + .body, + :where(.footer:not(:only-child)) .actions, + :where(.footer:not(:only-child)) .meta { + grid-column: 3; + } + + :where(.header) { + .cover, + .title { + grid-row: 1 / span 2; + justify-self: center; + } + } + } + + :where(.card--variant-2.card--has-cover .header) .title { + margin-top: calc(var(--cover-height) + var(--spacing-sm)); + } +} + +/* stylelint-enable no-descending-specificity */ diff --git a/src/components/molecules/card/card.stories.tsx b/src/components/molecules/card/card.stories.tsx new file mode 100644 index 0000000..74f426c --- /dev/null +++ b/src/components/molecules/card/card.stories.tsx @@ -0,0 +1,545 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import NextImage from 'next/image'; +import { Button, ButtonLink, Link, Time } from '../../atoms'; +import { Card, type CardProps } from './card'; +import { CardActions } from './card-actions'; +import { CardBody } from './card-body'; +import { CardCover } from './card-cover'; +import { CardFooter } from './card-footer'; +import { CardHeader } from './card-header'; +import { CardMeta } from './card-meta'; +import { CardTitle } from './card-title'; + +/** + * Card - Storybook Meta + */ +export default { + title: 'Molecules/Card', + component: Card, + argTypes: {}, +} as ComponentMeta<typeof Card>; + +const Template: ComponentStory<typeof Card> = <T extends string | undefined>( + args: CardProps<T> +) => <Card {...args} />; + +/** + * Card Stories - Default + */ +export const Default = Template.bind({}); +Default.args = { + children: 'The card contents.', +}; + +/** + * Card Stories - AsLink + */ +export const AsLink = Template.bind({}); +AsLink.args = { + 'aria-label': 'Learn more about this card', + children: 'The card contents.', + linkTo: '#card', +}; + +export const HeaderCover = Template.bind({}); +HeaderCover.args = { + children: <CardHeader />, + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), +}; + +export const HeaderTitle = Template.bind({}); +HeaderTitle.args = { + children: ( + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + ), +}; + +export const HeaderMeta = Template.bind({}); +HeaderMeta.args = { + children: ( + <CardHeader> + <CardMeta + isInline + items={[ + { id: 'author', label: 'Written by:', value: 'The author' }, + { + id: 'publication-date', + label: 'Published on:', + value: <Time date={new Date().toISOString()} />, + }, + ]} + /> + </CardHeader> + ), +}; + +export const BodyContents = Template.bind({}); +BodyContents.args = { + children: <CardBody>The card contents</CardBody>, +}; + +export const FooterActions = Template.bind({}); +FooterActions.args = { + children: ( + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + ), +}; + +export const FooterMeta = Template.bind({}); +FooterMeta.args = { + children: <CardFooter />, + meta: ( + <CardMeta + items={[ + { + id: 'categories', + label: 'Categories:', + value: [ + { id: 'cat-1', value: <Link href="#cat1">Category 1</Link> }, + { id: 'cat-2', value: <Link href="#cat2">Category 2</Link> }, + ], + }, + { + id: 'tags', + label: 'Tags:', + value: [ + { id: 'tag-1', value: 'Tag 1' }, + { id: 'tag-2', value: 'Tag 2' }, + { id: 'tag-3', value: 'Tag 3' }, + ], + }, + ]} + /> + ), +}; + +export const CompositionCoverTitle = Template.bind({}); +CompositionCoverTitle.args = { + children: ( + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), +}; + +export const CompositionTitleMeta = Template.bind({}); +CompositionTitleMeta.args = { + children: ( + <CardHeader> + <CardTitle>The card title</CardTitle> + <CardMeta + isInline + items={[ + { id: 'author', label: 'Written by:', value: 'The author' }, + { + id: 'publication-date', + label: 'Published on:', + value: <Time date={new Date().toISOString()} />, + }, + ]} + /> + </CardHeader> + ), +}; + +export const CompositionCoverTitleMeta = Template.bind({}); +CompositionCoverTitleMeta.args = { + children: ( + <CardHeader> + <CardTitle>The card title</CardTitle> + <CardMeta + isInline + items={[ + { id: 'author', label: 'Written by:', value: 'The author' }, + { + id: 'publication-date', + label: 'Published on:', + value: <Time date={new Date().toISOString()} />, + }, + ]} + /> + </CardHeader> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), +}; + +export const CompositionTitleBody = Template.bind({}); +CompositionTitleBody.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + </> + ), +}; + +export const CompositionCoverTitleBody = Template.bind({}); +CompositionCoverTitleBody.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + </> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), +}; + +export const CompositionTitleMetaBody = Template.bind({}); +CompositionTitleMetaBody.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + <CardMeta + isInline + items={[ + { id: 'author', label: 'Written by:', value: 'The author' }, + { + id: 'publication-date', + label: 'Published on:', + value: <Time date={new Date().toISOString()} />, + }, + ]} + /> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + </> + ), +}; + +export const CompositionCoverTitleMetaBody = Template.bind({}); +CompositionCoverTitleMetaBody.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + <CardMeta + isInline + items={[ + { id: 'author', label: 'Written by:', value: 'The author' }, + { + id: 'publication-date', + label: 'Published on:', + value: <Time date={new Date().toISOString()} />, + }, + ]} + /> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + </> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), +}; + +export const CompositionTitleActions = Template.bind({}); +CompositionTitleActions.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + </> + ), +}; + +export const CompositionCoverTitleActions = Template.bind({}); +CompositionCoverTitleActions.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + </> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), +}; + +export const CompositionTitleBodyActions = Template.bind({}); +CompositionTitleBodyActions.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + </> + ), +}; + +export const CompositionTitleBodyActionsMeta = Template.bind({}); +CompositionTitleBodyActionsMeta.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + </> + ), + meta: ( + <CardMeta + items={[ + { + id: 'categories', + label: 'Categories:', + value: [ + { id: 'cat-1', value: <Link href="#cat1">Category 1</Link> }, + { id: 'cat-2', value: <Link href="#cat2">Category 2</Link> }, + ], + }, + { + id: 'tags', + label: 'Tags:', + value: [ + { id: 'tag-1', value: 'Tag 1' }, + { id: 'tag-2', value: 'Tag 2' }, + { id: 'tag-3', value: 'Tag 3' }, + ], + }, + ]} + /> + ), +}; + +export const CompositionCoverTitleBodyActionsMeta = Template.bind({}); +CompositionCoverTitleBodyActionsMeta.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + </> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), + meta: ( + <CardMeta + items={[ + { + id: 'categories', + label: 'Categories:', + value: [ + { id: 'cat-1', value: <Link href="#cat1">Category 1</Link> }, + { id: 'cat-2', value: <Link href="#cat2">Category 2</Link> }, + ], + }, + { + id: 'tags', + label: 'Tags:', + value: [ + { id: 'tag-1', value: 'Tag 1' }, + { id: 'tag-2', value: 'Tag 2' }, + { id: 'tag-3', value: 'Tag 3' }, + ], + }, + ]} + /> + ), +}; + +export const CompositionAllContents = Template.bind({}); +CompositionAllContents.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + <CardMeta + isInline + items={[ + { id: 'author', label: 'Written by:', value: 'The author' }, + { + id: 'publication-date', + label: 'Published on:', + value: <Time date={new Date().toISOString()} />, + }, + ]} + /> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + </> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), + meta: ( + <CardMeta + items={[ + { + id: 'categories', + label: 'Categories:', + value: [ + { id: 'cat-1', value: <Link href="#cat1">Category 1</Link> }, + { id: 'cat-2', value: <Link href="#cat2">Category 2</Link> }, + ], + }, + { + id: 'tags', + label: 'Tags:', + value: [ + { id: 'tag-1', value: 'Tag 1' }, + { id: 'tag-2', value: 'Tag 2' }, + { id: 'tag-3', value: 'Tag 3' }, + ], + }, + ]} + /> + ), +}; diff --git a/src/components/molecules/card/card.test.tsx b/src/components/molecules/card/card.test.tsx new file mode 100644 index 0000000..40a5830 --- /dev/null +++ b/src/components/molecules/card/card.test.tsx @@ -0,0 +1,129 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import NextImage from 'next/image'; +import { Card } from './card'; +import { CardFooter } from './card-footer'; +import { CardHeader } from './card-header'; +import { CardMeta } from './card-meta'; + +describe('Card', () => { + it('renders its children', () => { + const body = 'eveniet error voluptas'; + + render(<Card>{body}</Card>); + + expect(rtlScreen.getByText(body)).toBeInTheDocument(); + }); + + it('can render a cover in the card header', () => { + const altTxt = 'quo expedita eveniet'; + + render( + <Card + cover={ + <NextImage + alt={altTxt} + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + } + > + <CardHeader /> + </Card> + ); + + expect(rtlScreen.getByRole('img', { name: altTxt })).toBeInTheDocument(); + }); + + it('does not render a cover without card header', () => { + const body = 'necessitatibus maiores sed'; + const altTxt = 'quo expedita eveniet'; + + render( + <Card + cover={ + <NextImage + alt={altTxt} + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + } + > + {body} + </Card> + ); + + expect( + rtlScreen.queryByRole('img', { name: altTxt }) + ).not.toBeInTheDocument(); + }); + + it('can render some meta in the card footer', () => { + const term = 'ut'; + const desc = 'repudiandae'; + + render( + <Card + meta={<CardMeta items={[{ id: 'any', label: term, value: desc }]} />} + > + <CardFooter /> + </Card> + ); + + expect(rtlScreen.getByRole('term')).toHaveTextContent(term); + expect(rtlScreen.getByRole('definition')).toHaveTextContent(desc); + }); + + it('does not render the meta without card footer', () => { + const body = 'rerum dolore et'; + const term = 'ut'; + const desc = 'repudiandae'; + + render( + <Card + meta={<CardMeta items={[{ id: 'any', label: term, value: desc }]} />} + > + {body} + </Card> + ); + + expect( + rtlScreen.queryByRole('term', { name: term }) + ).not.toBeInTheDocument(); + expect( + rtlScreen.queryByRole('definition', { name: desc }) + ).not.toBeInTheDocument(); + }); + + it('can render a card as link to another page', () => { + const body = 'Et qui harum voluptas est quos qui.'; + const cta = 'asperiores optio incidunt'; + const target = '#molestiae'; + + render( + <Card aria-label={cta} linkTo={target}> + {body} + </Card> + ); + + expect(rtlScreen.getByRole('link', { name: cta })).toHaveAttribute( + 'href', + target + ); + }); + + it('can render a card with centered text', () => { + const body = 'Et qui harum voluptas est quos qui.'; + const label = 'asperiores optio incidunt'; + + render( + <Card aria-label={label} isCentered> + {body} + </Card> + ); + + expect(rtlScreen.getByLabelText(label)).toHaveClass('wrapper--centered'); + }); +}); diff --git a/src/components/molecules/card/card.tsx b/src/components/molecules/card/card.tsx new file mode 100644 index 0000000..788b040 --- /dev/null +++ b/src/components/molecules/card/card.tsx @@ -0,0 +1,112 @@ +import { + forwardRef, + type HTMLAttributes, + type ForwardedRef, + type ReactNode, + type ReactElement, +} from 'react'; +import { Article, ButtonLink, type ButtonLinkProps } from '../../atoms'; +import { CardCoverProvider, CardFooterMetaProvider } from './card-provider'; +import styles from './card.module.scss'; + +type CardBaseProps<T extends string | undefined> = T extends string + ? Omit<ButtonLinkProps, 'children' | 'isExternal' | 'kind' | 'shape' | 'to'> + : Omit<HTMLAttributes<HTMLDivElement>, 'children'>; + +export type CardProps<T extends string | undefined> = CardBaseProps<T> & { + /** + * The card contents. + */ + children: ReactNode; + /** + * The card cover. You need to add a `<CardHeader />` as children to use it. + */ + cover?: ReactElement; + /** + * Should the contents be centered? + * + * @default false + */ + isCentered?: boolean; + /** + * Link the card to another page. + * + * @default undefined + */ + linkTo?: T; + /** + * The meta to place in the card footer. You need to add a `<CardFooter />` + * as children to use it. + */ + meta?: ReactElement; + /** + * The card variant. + * + * @default 1 + */ + variant?: 1 | 2; +}; + +const CardWrapper = <T extends string | undefined>( + { + children, + className = '', + cover, + isCentered = false, + linkTo, + meta, + variant = 1, + ...props + }: CardProps<T>, + ref: ForwardedRef<T extends string ? HTMLAnchorElement : HTMLDivElement> +) => { + const wrapperClass = [ + styles.wrapper, + styles[isCentered ? 'wrapper--centered' : 'wrapper--not-centered'], + styles[linkTo ? 'wrapper--is-link' : 'wrapper--is-block'], + className, + ].join(' '); + const cardClass = [ + styles.card, + styles[cover ? 'card--has-cover' : 'card--no-cover'], + styles[meta ? 'card--has-footer-meta' : 'card--no-footer-meta'], + styles[`card--variant-${variant}`], + ].join(' '); + + return ( + <CardCoverProvider cover={cover}> + <CardFooterMetaProvider meta={meta}> + {linkTo ? ( + <ButtonLink + {...(props as CardBaseProps<typeof linkTo>)} + className={wrapperClass} + ref={ref} + // eslint-disable-next-line react/jsx-no-literals -- Shape allowed + shape="auto" + to={linkTo} + > + <Article className={cardClass}>{children}</Article> + </ButtonLink> + ) : ( + <div + {...(props as CardBaseProps<undefined>)} + className={wrapperClass} + ref={ref as ForwardedRef<HTMLDivElement>} + > + <Article className={cardClass}>{children}</Article> + </div> + )} + </CardFooterMetaProvider> + </CardCoverProvider> + ); +}; + +/** + * Card component + * + * Render a card with title and some other optional data. + * + * TODO: remove `cover` and `meta` props (and adapt CSS) when support for CSS + * `:has()` pseudo-class will be good enough. + */ +export const Card = forwardRef(CardWrapper); diff --git a/src/components/molecules/card/index.ts b/src/components/molecules/card/index.ts new file mode 100644 index 0000000..2b55191 --- /dev/null +++ b/src/components/molecules/card/index.ts @@ -0,0 +1,8 @@ +export * from './card'; +export * from './card-actions'; +export * from './card-body'; +export * from './card-cover'; +export * from './card-footer'; +export * from './card-header'; +export * from './card-meta'; +export * from './card-title'; diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts index a1e2c7a..b042a96 100644 --- a/src/components/molecules/index.ts +++ b/src/components/molecules/index.ts @@ -1,5 +1,6 @@ export * from './branding'; export * from './buttons'; +export * from './card'; export * from './code'; export * from './collapsible'; export * from './forms'; diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss deleted file mode 100644 index 14a5baf..0000000 --- a/src/components/molecules/layout/card.module.scss +++ /dev/null @@ -1,90 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; - -.footer { - margin-top: auto; -} - -.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; - } - - .cover { - place-content: center; - height: fun.convert-px(150); - object-fit: scale-down; - margin: auto; - border-bottom: fun.convert-px(1) solid var(--color-border); - } - - .title, - .tagline, - .footer { - padding: 0 var(--spacing-md); - } - - .title { - width: fit-content; - margin: var(--spacing-sm) auto; - } - - h2.title { - background: none; - text-shadow: none; - } - - .tagline { - flex: 1; - margin-bottom: var(--spacing-md); - color: var(--color-fg); - font-weight: 400; - } - - .list { - margin-bottom: var(--spacing-md); - } - - .meta { - &__item { - flex-flow: row wrap; - place-content: center; - gap: var(--spacing-2xs); - margin: auto; - } - - &__label { - flex: 0 0 100%; - } - - &__value { - 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; - } - } - } - - &:not(:disabled):focus { - text-decoration: none; - - .title { - text-decoration: underline solid var(--color-primary) 0.3ex; - } - } -} diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx deleted file mode 100644 index 070c978..0000000 --- a/src/components/molecules/layout/card.stories.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import type { MetaItemData } from '../meta-list'; -import { Card } from './card'; - -/** - * Card - Storybook Meta - */ -export default { - title: 'Molecules/Layout/Card', - component: Card, - argTypes: { - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the card wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - cover: { - description: 'The card cover data (src, dimensions, alternative text).', - table: { - category: 'Options', - }, - type: { - name: 'object', - required: false, - value: {}, - }, - }, - coverFit: { - control: { - type: 'select', - }, - description: 'The cover fit.', - options: ['contain', 'cover', 'fill', 'scale-down'], - table: { - category: 'Options', - defaultValue: { summary: 'cover' }, - }, - type: { - name: 'string', - required: false, - }, - }, - id: { - control: { - type: 'text', - }, - description: 'The card id.', - type: { - name: 'string', - required: true, - }, - }, - 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', - min: 1, - max: 6, - }, - 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 Card>; - -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 - */ -export const Default = Template.bind({}); -Default.args = { - id, - title, - titleLevel: 2, - url, -}; - -/** - * Card Stories - With cover - */ -export const WithCover = Template.bind({}); -WithCover.args = { - cover, - id, - title, - titleLevel: 2, - url, -}; - -/** - * Card Stories - With meta - */ -export const WithMeta = Template.bind({}); -WithMeta.args = { - id, - meta, - title, - titleLevel: 2, - url, -}; - -/** - * Card Stories - With tagline - */ -export const WithTagline = Template.bind({}); -WithTagline.args = { - id, - tagline, - title, - titleLevel: 2, - url, -}; - -/** - * Card Stories - With all data - */ -export const WithAll = Template.bind({}); -WithAll.args = { - cover, - id, - meta, - tagline, - title, - titleLevel: 2, - url, -}; diff --git a/src/components/molecules/layout/card.test.tsx b/src/components/molecules/layout/card.test.tsx deleted file mode 100644 index b690d4c..0000000 --- a/src/components/molecules/layout/card.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import type { MetaItemData } from '../meta-list'; -import { Card } from './card'; - -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( - 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(rtlScreen.getByRole('link')).toHaveAttribute('href', url); - }); - - it('renders a cover', () => { - render( - <Card id={id} title={title} titleLevel={2} url={url} cover={cover} /> - ); - 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(rtlScreen.getByText(tagline)).toBeInTheDocument(); - }); - - it('renders some meta', () => { - render(<Card id={id} title={title} titleLevel={2} url={url} meta={meta} />); - - 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 deleted file mode 100644 index d90cba2..0000000 --- a/src/components/molecules/layout/card.tsx +++ /dev/null @@ -1,88 +0,0 @@ -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'; - -export type CardProps = { - /** - * Set additional classnames to the card wrapper. - */ - className?: string; - /** - * The card cover. - */ - cover?: Pick<NextImageProps, 'alt' | 'src' | 'title' | 'width' | 'height'>; - /** - * The card id. - */ - id: string; - /** - * The card meta. - */ - meta?: MetaItemData[]; - /** - * 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. - */ -export const Card: FC<CardProps> = ({ - className = '', - cover, - id, - meta, - tagline, - title, - titleLevel, - url, -}) => { - const cardClass = `${styles.wrapper} ${className}`; - const headingId = `${id}-heading`; - - return ( - <ButtonLink aria-labelledby={headingId} className={cardClass} to={url}> - <article className={styles.article}> - <header className={styles.header}> - {cover ? ( - <Figure> - <NextImage {...cover} className={styles.cover} /> - </Figure> - ) : null} - <Heading className={styles.title} id={headingId} level={titleLevel}> - {title} - </Heading> - </header> - {tagline ? <div className={styles.tagline}>{tagline}</div> : null} - {meta ? ( - <footer className={styles.footer}> - <MetaList - className={styles.list} - hasBorderedValues={meta.length < 2} - hasInlinedValues={meta.length < 2} - isCentered - items={meta} - /> - </footer> - ) : null} - </article> - </ButtonLink> - ); -}; diff --git a/src/components/molecules/layout/index.ts b/src/components/molecules/layout/index.ts index 80db10a..75cbe28 100644 --- a/src/components/molecules/layout/index.ts +++ b/src/components/molecules/layout/index.ts @@ -1,4 +1,3 @@ -export * from './card'; export * from './columns'; export * from './page-footer'; export * from './page-header'; diff --git a/src/components/organisms/layout/cards-list.module.scss b/src/components/organisms/layout/cards-list.module.scss index 8b18c08..1665829 100644 --- a/src/components/organisms/layout/cards-list.module.scss +++ b/src/components/organisms/layout/cards-list.module.scss @@ -19,6 +19,6 @@ } } -.card { +.item > * { height: 100%; } diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx index 03feee7..3f8e72a 100644 --- a/src/components/organisms/layout/cards-list.stories.tsx +++ b/src/components/organisms/layout/cards-list.stories.tsx @@ -1,4 +1,5 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Card, CardBody, CardHeader, CardTitle } from '../../molecules'; import { CardsList as CardsListComponent, type CardsListItem, @@ -10,39 +11,7 @@ import { export default { title: 'Organisms/Layout', component: CardsListComponent, - args: { - coverFit: 'cover', - kind: 'unordered', - }, argTypes: { - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the list wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - coverFit: { - control: { - type: 'select', - }, - description: 'The cover fit.', - options: ['fill', 'contain', 'cover', 'none', 'scale-down'], - table: { - category: 'Options', - defaultValue: { summary: 'cover' }, - }, - type: { - name: 'string', - required: false, - }, - }, items: { description: 'The cards data.', type: { @@ -51,33 +20,20 @@ export default { value: {}, }, }, - kind: { + isOrdered: { control: { - type: 'select', + type: 'boolean', }, - description: 'The list kind.', - options: ['ordered', 'unordered'], + description: 'Should the list be ordered?', table: { category: 'Options', - defaultValue: { summary: 'unordered' }, + defaultValue: { summary: false }, }, type: { - name: 'string', + name: 'boolean', required: false, }, }, - titleLevel: { - control: { - type: 'number', - min: 1, - max: 6, - }, - description: 'The heading level for each card.', - type: { - name: 'number', - required: true, - }, - }, }, } as ComponentMeta<typeof CardsListComponent>; @@ -88,63 +44,49 @@ const Template: ComponentStory<typeof CardsListComponent> = (args) => ( const items: CardsListItem[] = [ { id: 'card-1', - cover: { - alt: 'card 1 picture', - src: 'https://picsum.photos/640/480', - width: 640, - height: 480, - }, - 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: '#', + card: ( + <Card> + <CardHeader> + <CardTitle>Et alias omnis</CardTitle> + </CardHeader> + <CardBody> + Rerum voluptatem sint sint sit dignissimos. Labore totam possimus + tempore atque veniam. Doloremque tenetur quidem beatae veritatis quo. + Quaerat voluptatem deleniti voluptas quia. Qui voluptatem iure iste + expedita et sed beatae. + </CardBody> + </Card> + ), }, { id: 'card-2', - cover: { - alt: 'card 2 picture', - src: 'https://picsum.photos/640/480', - width: 640, - height: 480, - }, - meta: [{ id: 'categories', label: 'Categories', value: 'Voluptas' }], - tagline: 'Quod vel accusamus', - title: 'Laboriosam doloremque mollitia', - url: '#', + card: ( + <Card> + <CardHeader> + <CardTitle>Fugiat magnam nesciunt</CardTitle> + </CardHeader> + <CardBody> + Sit corporis animi ea. Earum asperiores error et. Aliquid quia et + consequatur. Magnam sit ut facere explicabo vel dolorem earum + assumenda. Aspernatur inventore quod libero est. + </CardBody> + </Card> + ), }, { id: 'card-3', - cover: { - alt: 'card 3 picture', - src: 'https://picsum.photos/640/480', - width: 640, - height: 480, - }, - 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: '#', + card: ( + <Card> + <CardHeader> + <CardTitle>Asperiores eum quas</CardTitle> + </CardHeader> + <CardBody> + Doloremque ut cupiditate distinctio aperiam. Neque tempora unde + perferendis asperiores. Doloremque velit vel quam. Temporibus itaque + non non exercitationem. + </CardBody> + </Card> + ), }, ]; @@ -154,5 +96,4 @@ const items: CardsListItem[] = [ export const CardsList = Template.bind({}); CardsList.args = { items, - titleLevel: 2, }; diff --git a/src/components/organisms/layout/cards-list.test.tsx b/src/components/organisms/layout/cards-list.test.tsx index c9d6ae7..b04e109 100644 --- a/src/components/organisms/layout/cards-list.test.tsx +++ b/src/components/organisms/layout/cards-list.test.tsx @@ -1,73 +1,60 @@ import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { Card, CardBody, CardHeader, CardTitle } from '../../molecules'; import { CardsList, type CardsListItem } from './cards-list'; const items: CardsListItem[] = [ { id: 'card-1', - cover: { - alt: 'card 1 picture', - src: 'https://picsum.photos/640/480', - width: 640, - height: 480, - }, - 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: '#', + card: ( + <Card> + <CardHeader> + <CardTitle>Et alias omnis</CardTitle> + </CardHeader> + <CardBody> + Rerum voluptatem sint sint sit dignissimos. Labore totam possimus + tempore atque veniam. Doloremque tenetur quidem beatae veritatis quo. + Quaerat voluptatem deleniti voluptas quia. Qui voluptatem iure iste + expedita et sed beatae. + </CardBody> + </Card> + ), }, { id: 'card-2', - cover: { - alt: 'card 2 picture', - src: 'https://picsum.photos/640/480', - width: 640, - height: 480, - }, - meta: [{ id: 'categories', label: 'Categories', value: 'Voluptas' }], - tagline: 'Quod vel accusamus', - title: 'Laboriosam doloremque mollitia', - url: '#', + card: ( + <Card> + <CardHeader> + <CardTitle>Fugiat magnam nesciunt</CardTitle> + </CardHeader> + <CardBody> + Sit corporis animi ea. Earum asperiores error et. Aliquid quia et + consequatur. Magnam sit ut facere explicabo vel dolorem earum + assumenda. Aspernatur inventore quod libero est. + </CardBody> + </Card> + ), }, { id: 'card-3', - cover: { - alt: 'card 3 picture', - src: 'https://picsum.photos/640/480', - width: 640, - height: 480, - }, - 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: '#', + card: ( + <Card> + <CardHeader> + <CardTitle>Asperiores eum quas</CardTitle> + </CardHeader> + <CardBody> + Doloremque ut cupiditate distinctio aperiam. Neque tempora unde + perferendis asperiores. Doloremque velit vel quam. Temporibus itaque + non non exercitationem. + </CardBody> + </Card> + ), }, ]; describe('CardsList', () => { it('renders a list of cards', () => { - render(<CardsList items={items} titleLevel={2} />); + render(<CardsList items={items} />); expect(rtlScreen.getAllByRole('heading', { level: 2 })).toHaveLength( items.length ); diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx index 29add3b..4f920e8 100644 --- a/src/components/organisms/layout/cards-list.tsx +++ b/src/components/organisms/layout/cards-list.tsx @@ -1,16 +1,20 @@ -import type { FC } from 'react'; +import type { FC, ReactElement } from 'react'; import { List, ListItem } from '../../atoms'; -import { Card, type CardProps } from '../../molecules'; +import type { CardProps } from '../../molecules'; import styles from './cards-list.module.scss'; -export type CardsListItem = Omit<CardProps, 'className' | 'titleLevel'> & { +export type CardsListItem = { + /** + * The card. + */ + card: ReactElement<CardProps<string> | CardProps<undefined>>; /** * The card id. */ id: string; }; -export type CardsListProps = Pick<CardProps, 'titleLevel'> & { +export type CardsListProps = { /** * Set additional classnames to the list wrapper. */ @@ -36,7 +40,6 @@ export const CardsList: FC<CardsListProps> = ({ className = '', isOrdered = false, items, - titleLevel, }) => { const kindModifier = `wrapper--${isOrdered ? 'ordered' : 'unordered'}`; @@ -47,15 +50,9 @@ export const CardsList: FC<CardsListProps> = ({ isInline isOrdered={isOrdered} > - {items.map(({ id, ...item }) => ( - <ListItem key={id}> - <Card - {...item} - className={styles.card} - key={id} - id={id} - titleLevel={titleLevel} - /> + {items.map(({ id, card }) => ( + <ListItem className={styles.item} key={id}> + {card} </ListItem> ))} </List> diff --git a/src/components/organisms/layout/comment.module.scss b/src/components/organisms/layout/comment.module.scss index bf8aada..f26b3fe 100644 --- a/src/components/organisms/layout/comment.module.scss +++ b/src/components/organisms/layout/comment.module.scss @@ -2,132 +2,72 @@ @use "../../../styles/abstracts/mixins" as mix; @use "../../../styles/abstracts/placeholders"; -.wrapper { - padding: var(--spacing-md); - background: var(--color-bg); - border: fun.convert-px(1) solid var(--color-border); - box-shadow: - fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-shadow-light), - fun.convert-px(4) fun.convert-px(4) fun.convert-px(3) fun.convert-px(-2) - var(--color-shadow); - - &--comment { - @include mix.media("screen") { - @include mix.dimensions("sm") { - display: grid; - grid-template-columns: minmax(0, #{fun.convert-px(150)}) minmax(0, 1fr); - column-gap: var(--spacing-lg); - } - } - } - - &--form { - display: flex; - flex-flow: column wrap; - place-content: center; - margin-top: var(--spacing-sm); - } - - .header { - display: flex; - flex-flow: column wrap; - align-items: center; - row-gap: var(--spacing-sm); - - @include mix.media("screen") { - @include mix.dimensions("sm") { - grid-row: 1 / 4; - } - } - } - - .author { - color: var(--color-primary-darker); - font-weight: 600; - text-align: center; - } - - .avatar { - width: fun.convert-px(85); - height: fun.convert-px(85); - position: relative; +.avatar { + img { border-radius: fun.convert-px(3); box-shadow: 0 0 0 fun.convert-px(1) var(--color-shadow-light), fun.convert-px(2) fun.convert-px(2) 0 fun.convert-px(1) var(--color-shadow); - - img { - border-radius: fun.convert-px(3); - } } +} - .date { - margin: var(--spacing-sm) 0; - font-size: var(--font-size-sm); +.author { + color: var(--color-primary-darker); + font-family: var(--font-family-regular); + font-size: var(--font-size-md); + font-weight: 600; + text-align: center; + text-shadow: none; +} - &__item { - justify-content: center; - } +.body { + overflow-wrap: break-word; - @include mix.media("screen") { - @include mix.dimensions("sm") { - margin: 0 0 var(--spacing-sm); + :global { + a { + @extend %link; - &__item { - justify-content: left; - } + &[hreflang], + &.download, + &.external { + @extend %link-with-icon; } - } - } - - .body { - overflow-wrap: break-word; - - :global { - a { - @extend %link; - &[hreflang], - &.download, - &.external { - @extend %link-with-icon; - } - - &[hreflang] { - @extend %link-with-lang; - } + &[hreflang] { + @extend %link-with-lang; + } - &[hreflang]:not(.download, .external) { - --is-icon-hidden: ""; - } + &[hreflang]:not(.download, .external) { + --is-icon-hidden: ""; + } - &.download { - @extend %download-link; - } + &.download { + @extend %download-link; + } - &.external { - @extend %external-link; - } + &.external { + @extend %external-link; + } - &.download, - &.external { - &:not([hreflang]) { - --is-lang-hidden: ""; - } + &.download, + &.external { + &:not([hreflang]) { + --is-lang-hidden: ""; } + } - &.external.download { - @extend %external-download-link; - } + &.external.download { + @extend %external-download-link; } } } +} + +.form { + margin-top: var(--spacing-sm); - .footer { - display: flex; - justify-content: flex-end; - align-items: center; - padding: var(--spacing-md) 0 0; + form > * { + margin-inline: auto; } } diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx index cb2f16f..db7cb3a 100644 --- a/src/components/organisms/layout/comment.tsx +++ b/src/components/organisms/layout/comment.tsx @@ -7,7 +7,16 @@ import type { Comment as CommentSchema, WithContext } from 'schema-dts'; import type { SingleComment } from '../../../types'; import { useSettings } from '../../../utils/hooks'; import { Button, Link, Time } from '../../atoms'; -import { MetaList } from '../../molecules'; +import { + Card, + CardActions, + CardBody, + CardCover, + CardFooter, + CardHeader, + CardMeta, + CardTitle, +} from '../../molecules'; import { CommentForm, type CommentFormProps } from '../forms'; import styles from './comment.module.scss'; @@ -103,9 +112,6 @@ export const UserComment: FC<UserCommentProps> = ({ text: content, }; - const commentWrapperClass = `${styles.wrapper} ${styles['wrapper--comment']}`; - const formWrapperClass = `${styles.wrapper} ${styles['wrapper--form']}`; - return ( <> <Script @@ -113,10 +119,10 @@ export const UserComment: FC<UserCommentProps> = ({ id="schema-comments" type="application/ld+json" /> - <article className={commentWrapperClass} id={`comment-${id}`}> - <header className={styles.header}> - {author.avatar ? ( - <div className={styles.avatar}> + <Card + cover={ + author.avatar ? ( + <CardCover className={styles.avatar}> <NextImage {...props} alt={author.avatar.alt} @@ -124,55 +130,64 @@ export const UserComment: FC<UserCommentProps> = ({ src={author.avatar.src} style={{ objectFit: 'cover' }} /> - </div> - ) : null} - {author.website ? ( - <Link href={author.website} className={styles.author}> - {author.name} - </Link> - ) : ( - <span className={styles.author}>{author.name}</span> - )} - </header> - <MetaList - className={styles.date} - isInline - items={[ - { - id: 'publication-date', - label: intl.formatMessage({ - defaultMessage: 'Published on:', - description: 'Comment: publication date label', - id: 'soj7do', - }), - value: ( - <Link href={`#comment-${id}`}> - <Time date={date} showTime /> - </Link> - ), - }, - ]} - /> - <div + </CardCover> + ) : undefined + } + id={`comment-${id}`} + variant={2} + > + <CardHeader> + <CardTitle className={styles.author} isFake> + {author.website ? ( + <Link href={author.website}>{author.name}</Link> + ) : ( + author.name + )} + </CardTitle> + <CardMeta + hasInlinedItems + items={[ + { + id: 'publication-date', + label: intl.formatMessage({ + defaultMessage: 'Published on:', + description: 'Comment: publication date label', + id: 'soj7do', + }), + value: ( + <Link href={`#comment-${id}`}> + <Time date={date} showTime /> + </Link> + ), + }, + ]} + /> + </CardHeader> + <CardBody className={styles.body} dangerouslySetInnerHTML={{ __html: content }} /> - <footer className={styles.footer}> - {canReply ? ( - <Button kind="tertiary" onClick={handleReply}> - {buttonLabel} - </Button> - ) : null} - </footer> - </article> + {canReply ? ( + <CardFooter> + <CardActions> + <Button kind="tertiary" onClick={handleReply}> + {buttonLabel} + </Button> + </CardActions> + </CardFooter> + ) : null} + </Card> {isReplying ? ( - <CommentForm - className={formWrapperClass} - Notice={Notice} - parentId={id} - saveComment={saveComment} - title={formTitle} - /> + <Card className={styles.form} variant={2}> + <CardBody> + <CommentForm + Notice={Notice} + parentId={id} + saveComment={saveComment} + title={formTitle} + /> + </CardBody> + </Card> ) : null} </> ); diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss index ffc30ac..6e0af6a 100644 --- a/src/components/organisms/layout/summary.module.scss +++ b/src/components/organisms/layout/summary.module.scss @@ -1,34 +1,6 @@ -@use "../../../styles/abstracts/functions" as fun; -@use "../../../styles/abstracts/mixins" as mix; @use "../../../styles/abstracts/placeholders"; .wrapper { - display: grid; - grid-template-columns: minmax(0, 1fr); - column-gap: var(--spacing-md); - row-gap: var(--spacing-sm); - padding: var(--spacing-2xs) 0 var(--spacing-lg); - - @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") { - grid-template-columns: minmax(0, 3fr) minmax(0, 1fr); - grid-template-rows: repeat(3, max-content); - } - } - &:hover { .icon { :global { @@ -38,86 +10,15 @@ } } -.cover { - display: inline-flex; - flex-flow: column nowrap; - justify-content: center; - width: auto; - height: fun.convert-px(100); - max-width: 100%; - border: fun.convert-px(1) solid var(--color-border); - object-fit: scale-down; - - @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; - } - } -} - -.link { - display: block; - width: fit-content; -} - .title { - margin: 0; - background: none; - color: inherit; font-size: var(--font-size-2xl); - text-shadow: none; -} - -.read-more { - display: flex; - flex-flow: row nowrap; - column-gap: var(--spacing-xs); - width: max-content; - margin: var(--spacing-sm) 0 0; } -.meta { - flex-flow: row wrap; - font-size: var(--font-size-sm); - - @include mix.media("screen") { - @include mix.dimensions("sm") { - flex-flow: column wrap; - margin-top: 0; - } +.intro { + > *:last-child { + margin-bottom: 0; } -} -.intro { :global { a { @extend %link; diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx index 4fe7632..0c95f90 100644 --- a/src/components/organisms/layout/summary.tsx +++ b/src/components/organisms/layout/summary.tsx @@ -3,16 +3,18 @@ import type { FC, ReactNode } from 'react'; import { useIntl } from 'react-intl'; import type { Article, Meta as MetaType } from '../../../types'; import { useReadingTime } from '../../../utils/hooks'; +import { ButtonLink, type HeadingLevel, Icon, Link, Time } from '../../atoms'; import { - ButtonLink, - Heading, - type HeadingLevel, - Icon, - Link, - Figure, - Time, -} from '../../atoms'; -import { MetaList, type MetaItemData } from '../../molecules'; + Card, + CardActions, + CardBody, + CardCover, + CardFooter, + CardHeader, + CardMeta, + CardTitle, + type MetaItemData, +} from '../../molecules'; import styles from './summary.module.scss'; export type Cover = Pick<NextImageProps, 'alt' | 'src' | 'width' | 'height'>; @@ -56,6 +58,14 @@ export const Summary: FC<SummaryProps> = ({ url, }) => { const intl = useIntl(); + const figureLabel = intl.formatMessage( + { + defaultMessage: '{title} cover', + description: 'Summary: figure (cover) accessible name', + id: 'RNVe1W', + }, + { title } + ); const readMore = intl.formatMessage( { defaultMessage: 'Read more<a11y> about {title}</a11y>', @@ -182,40 +192,44 @@ export const Summary: FC<SummaryProps> = ({ }; return ( - <article className={styles.wrapper}> - {meta.cover ? ( - <Figure> - <NextImage {...meta.cover} className={styles.cover} /> - </Figure> - ) : null} - <header className={styles.header}> - <Link href={url} className={styles.link}> - <Heading level={titleLevel} className={styles.title}> - {title} - </Heading> - </Link> - </header> - <div className={styles.body}> + <Card + className={styles.wrapper} + cover={ + meta.cover ? ( + <CardCover aria-label={figureLabel} hasBorders> + <NextImage {...meta.cover} /> + </CardCover> + ) : undefined + } + meta={<CardMeta items={getMetaItems()} />} + > + <CardHeader> + <CardTitle className={styles.title} level={titleLevel}> + <Link href={url}>{title}</Link> + </CardTitle> + </CardHeader> + <CardBody> <div className={styles.intro} // eslint-disable-next-line react/no-danger 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" - /> - </ButtonLink> - </div> - <footer className={styles.footer}> - <MetaList className={styles.meta} items={getMetaItems()} /> - </footer> - </article> + </CardBody> + <CardFooter> + <CardActions> + <ButtonLink 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" + /> + </ButtonLink> + </CardActions> + </CardFooter> + </Card> ); }; diff --git a/src/i18n/en.json b/src/i18n/en.json index 9bfe248..8f552e6 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -231,6 +231,10 @@ "defaultMessage": "Updated on:", "description": "Page: update date label" }, + "FdF33B": { + "defaultMessage": "{title} cover", + "description": "ProjectsPage: figure (cover) accessible name" + }, "G+Twgm": { "defaultMessage": "Search", "description": "SearchModal: modal title" @@ -371,6 +375,10 @@ "defaultMessage": "CV", "description": "Layout: main nav - cv link" }, + "RNVe1W": { + "defaultMessage": "{title} cover", + "description": "Summary: figure (cover) accessible name" + }, "RecdwX": { "defaultMessage": "Published on:", "description": "ArticlePage: publication date label" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index a988729..b499a1f 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -231,6 +231,10 @@ "defaultMessage": "Mis à jour le :", "description": "Page: update date label" }, + "FdF33B": { + "defaultMessage": "Illustration de {title}", + "description": "ProjectsPage: figure (cover) accessible name" + }, "G+Twgm": { "defaultMessage": "Recherche", "description": "SearchModal: modal title" @@ -371,6 +375,10 @@ "defaultMessage": "CV", "description": "Layout: main nav - cv link" }, + "RNVe1W": { + "defaultMessage": "Illustration de {title}", + "description": "Summary: figure (cover) accessible name" + }, "RecdwX": { "defaultMessage": "Publié le :", "description": "ArticlePage: publication date label" diff --git a/src/pages/index.tsx b/src/pages/index.tsx index fb6ba9a..e482fb4 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -8,19 +8,25 @@ import type { FC, HTMLAttributes } from 'react'; import { useIntl } from 'react-intl'; import { ButtonLink, + Card, + CardCover, + CardFooter, + CardHeader, + CardMeta, + CardTitle, CardsList, type CardsListItem, Column, Columns, type ColumnsProps, + Figure, getLayout, + Heading, Icon, List, ListItem, Section, type SectionProps, - Heading, - Figure, Time, } from '../components'; import HomePageContent from '../content/pages/homepage.mdx'; @@ -299,22 +305,47 @@ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => { const getRecentPosts = (): JSX.Element => { const posts: CardsListItem[] = recentPosts.map((post) => { return { - cover: post.cover, - id: post.slug, - meta: [ - { - id: 'publication-date', - label: publicationDate, - value: <Time date={post.dates.publication} />, - }, - ], - title: post.title, - url: `${ROUTES.ARTICLE}/${post.slug}`, + card: ( + <Card + cover={ + post.cover ? ( + <CardCover hasBorders> + <NextImage + {...post.cover} + style={{ objectFit: 'scale-down' }} + /> + </CardCover> + ) : undefined + } + meta={ + <CardMeta + hasBorderedValues + hasInlinedValues + isCentered + items={[ + { + id: 'publication-date', + label: publicationDate, + value: <Time date={post.dates.publication} />, + }, + ]} + /> + } + isCentered + linkTo={`${ROUTES.ARTICLE}/${post.slug}`} + > + <CardHeader> + <CardTitle level={3}>{post.title}</CardTitle> + </CardHeader> + <CardFooter /> + </Card> + ), + id: `${post.id}`, }; }); const listClass = `${styles.list} ${styles['list--cards']}`; - return <CardsList className={listClass} items={posts} titleLevel={3} />; + return <CardsList className={listClass} items={posts} />; }; const components: MDXComponents = { diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx index 44354ce..abb6da0 100644 --- a/src/pages/projets/index.tsx +++ b/src/pages/projets/index.tsx @@ -2,14 +2,22 @@ import type { MDXComponents } from 'mdx/types'; import type { GetStaticProps } from 'next'; import Head from 'next/head'; +import NextImage from 'next/image'; import { useRouter } from 'next/router'; import Script from 'next/script'; import { useIntl } from 'react-intl'; import { + Card, + CardCover, + CardBody, + CardFooter, + CardHeader, + CardTitle, CardsList, type CardsListItem, getLayout, Link, + MetaList, PageLayout, } from '../../components'; import PageContent, { meta } from '../../content/pages/projects.mdx'; @@ -56,24 +64,54 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => { const items: CardsListItem[] = projects.map( ({ id, meta: projectMeta, slug, title: projectTitle }) => { const { cover, tagline, technologies } = projectMeta; + const figureLabel = intl.formatMessage( + { + defaultMessage: '{title} cover', + description: 'ProjectsPage: figure (cover) accessible name', + id: 'FdF33B', + }, + { title: projectTitle } + ); return { - cover, - id: id as string, - meta: technologies?.length - ? [ - { - id: 'technologies', - label: metaLabel, - value: technologies.map((techno) => { - return { id: techno, value: techno }; - }), - }, - ] - : [], - tagline, - title: projectTitle, - url: `${ROUTES.PROJECTS}/${slug}`, + card: ( + <Card + cover={ + cover ? ( + <CardCover aria-label={figureLabel} hasBorders> + <NextImage {...cover} /> + </CardCover> + ) : undefined + } + meta={ + technologies ? ( + <MetaList + hasBorderedValues + hasInlinedValues + isCentered + items={[ + { + id: 'technologies', + label: metaLabel, + value: technologies.map((techno) => { + return { id: techno, value: techno }; + }), + }, + ]} + /> + ) : undefined + } + isCentered + linkTo={`${ROUTES.PROJECTS}/${slug}`} + > + <CardHeader> + <CardTitle>{projectTitle}</CardTitle> + </CardHeader> + <CardBody>{tagline}</CardBody> + <CardFooter /> + </Card> + ), + id: `${id}`, }; } ); @@ -127,7 +165,7 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => { breadcrumb={breadcrumbItems} breadcrumbSchema={breadcrumbSchema} > - <CardsList items={items} titleLevel={2} className={styles.list} /> + <CardsList className={styles.list} items={items} /> </PageLayout> </> ); diff --git a/src/styles/abstracts/placeholders/_buttons.scss b/src/styles/abstracts/placeholders/_buttons.scss index 38388a1..896c5a9 100644 --- a/src/styles/abstracts/placeholders/_buttons.scss +++ b/src/styles/abstracts/placeholders/_buttons.scss @@ -1,7 +1,7 @@ @use "../functions" as fun; %button { - display: inline-flex; + display: flex; place-content: center; align-items: center; gap: var(--spacing-2xs); |
