diff --git a/COMPONENTS.md b/COMPONENTS.md index accd798fd..c48c557b6 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -11,7 +11,7 @@ - [ ] Radio button - [ ] Radio rich - [ ] Checkbox -- [ ] Cards +- [x] Cards - [x] Quote - [ ] Media - [x] Header diff --git a/package.json b/package.json old mode 100755 new mode 100644 index a6ce91628..fd1b0af6b --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "./Header": "./dist/Header/index.js", "./Footer": "./dist/Footer.js", "./Display": "./dist/Display.js", + "./Card": "./dist/Card.js", "./ButtonsGroup": "./dist/ButtonsGroup.js", "./Button": "./dist/Button.js", "./Breadcrumb": "./dist/Breadcrumb.js", diff --git a/src/Card.tsx b/src/Card.tsx new file mode 100644 index 000000000..496059beb --- /dev/null +++ b/src/Card.tsx @@ -0,0 +1,179 @@ +import React, { memo, forwardRef } from "react"; +import type { ReactNode } from "react"; +import { symToStr } from "tsafe/symToStr"; +import { assert } from "tsafe/assert"; +import type { Equals } from "tsafe"; + +import { FrIconClassName, RiIconClassName } from "./lib/generatedFromCss/classNames"; +import { fr, RegisteredLinkProps } from "./lib"; +import { getLink } from "./lib/routing"; +import { cx } from "./lib/tools/cx"; + +import "./dsfr/component/card/card.css"; + +//https://main--ds-gouv.netlify.app/example/component/card/ +export type CardProps = { + className?: string; + title: ReactNode; + linkProps: RegisteredLinkProps; + desc?: ReactNode; + imageUrl?: string; + imageAlt?: string; + start?: ReactNode; + detail?: ReactNode; + end?: ReactNode; + endDetail?: ReactNode; + badges?: ReactNode[]; // todo: restrict to badge component ? these badges are display on the image + /** where actions can be placed */ + footer?: ReactNode; + /** only affect the text */ + size?: "small" | "medium" | "large"; + /** make the whole card clickable */ + enlargeLink?: boolean; + /** only needed when enlargeLink=true */ + iconId?: FrIconClassName | RiIconClassName; + shadow?: boolean; + background?: boolean; + border?: boolean; + grey?: boolean; + classes?: Partial< + Record< + | "root" + | "title" + | "card" + | "link" + | "body" + | "content" + | "desc" + | "header" + | "img" + | "imgTag" + | "start" + | "detail" + | "end" + | "endDetail" + | "badges" + | "footer", + string + > + >; +} & (CardProps.Default | CardProps.Horizontal); + +export namespace CardProps { + export type Default = { + horizontal?: never; + }; + + export type Horizontal = { + horizontal: true; + }; +} + +/** @see */ +export const Card = memo( + forwardRef((props, ref) => { + const { + className, + title, + linkProps, + desc, + imageUrl, + imageAlt, + start, + detail, + end, + endDetail, + badges, + footer, + horizontal = false, + size = "medium", + classes = {}, + enlargeLink = true, + background = true, + border = true, + shadow = false, + grey = false, + iconId, + ...rest + } = props; + + assert>(); + + const { Link } = getLink(); + + return ( +
+
+
+

+ + {title} + +

+ {desc !== undefined && ( +

{desc}

+ )} +
+ {start} + {detail !== undefined && ( +

+ {detail} +

+ )} +
+
+ {end} + {endDetail !== undefined && ( +

+ {endDetail} +

+ )} +
+
+ {footer !== undefined && ( +

{footer}

+ )} +
+ {/* ensure we dont have an empty imageUrl string */} + {imageUrl !== undefined && imageUrl.length && ( +
+
+ {imageAlt} +
+ {badges !== undefined && badges.length && ( +
    + {badges.map((badge, i) => ( +
  • {badge}
  • + ))} +
+ )} +
+ )} +
+ ); + }) +); + +Card.displayName = symToStr({ Card }); + +export default Card; diff --git a/stories/Card.stories.tsx b/stories/Card.stories.tsx new file mode 100644 index 000000000..746f30bc4 --- /dev/null +++ b/stories/Card.stories.tsx @@ -0,0 +1,258 @@ +import React from "react"; +import { Card, CardProps } from "../dist/Card"; +import { Badge } from "../dist/Badge"; +import { sectionName } from "./sectionName"; +import { getStoryFactory } from "./getStory"; +import { assert } from "tsafe/assert"; +import type { Equals } from "tsafe"; + +import "../dsfr/utility/icons/icons-system/icons-system.css"; +import { fr } from "../dist/lib"; + +const { meta, getStory } = getStoryFactory({ + sectionName, + defaultContainerWidth: 360, + "wrappedComponent": { Card }, + "description": ` +- [See DSFR documentation](https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/carte) +- [See DSFR demos](https://main--ds-gouv.netlify.app/example/component/card/) +- [See source code](https://github.com/codegouvfr/react-dsfr/blob/main/src/Card.tsx)`, + "argTypes": { + "title": { "description": `Required.` }, + "desc": { "description": `` }, + linkProps: { + "description": `Required. the Card Link props` + }, + enlargeLink: { + "description": `default: true. Set to false to restrict the link area to the Card title only.`, + "control": { "type": "boolean" } + }, + size: { + "description": "Card title text sizing", + "options": (() => { + const sizes = ["small", "medium", "large"] as const; + + assert>(); + + return sizes; + })(), + "control": { "type": "radio" } + }, + imageUrl: { + "description": "Use any image URL, or none" + } + }, + "disabledProps": ["lang"] +}); + +export default meta; + +const defaultProps = { + enlargeLink: true, + title: "Intitulé de la carte (sur lequel se trouve le lien)", + linkProps: { + href: "#" + }, + desc: "Lorem ipsum dolor sit amet, consectetur adipiscing, incididunt, ut labore et dolore magna aliqua. Vitae sapien pellentesque habitant morbi tristique senectus et", + imageUrl: "https://www.systeme-de-design.gouv.fr/img/placeholder.16x9.png", + imageAlt: "texte alternatif de l’image" +}; + +export const Default = getStory({ ...defaultProps }); + +// todo: wrap with grid +export const CardWithoutEnlargeLink = getStory( + { ...defaultProps, enlargeLink: false }, + { description: "Carte sans lien étendu à la carte" } +); + +export const CardWithIcon = getStory( + { ...defaultProps, iconId: "fr-icon-warning-fill" }, + { description: "Carte avec icône personnalisée" } +); + +export const CardWithoutBorder = getStory( + { ...defaultProps, border: false }, + { description: "Carte sans bordure" } +); + +export const CardWithShadow = getStory( + { ...defaultProps, shadow: true }, + { description: "Carte avec ombre portée" } +); + +export const CardWithoutImage = getStory( + { ...defaultProps, imageUrl: undefined }, + { description: "Carte sans image" } +); + +export const CardWithImageRatio = getStory( + { ...defaultProps, classes: { imgTag: "fr-ratio-3x4" } }, + { description: "Carte verticale avec image au ratio d'aspect 3x4" } +); + +export const CardWithBadgeInTheHeader = getStory( + { + ...defaultProps, + badges: [, ] + }, + { description: "Carte verticale avec badge dans l'image" } +); + +export const CardWithBadgeInTheContent = getStory( + { + ...defaultProps, + detail: ( +
    + + +
+ ) + }, + { description: "Carte verticale avec badges dans le contenu" } +); + +export const CardWithDetail = getStory( + { + ...defaultProps, + detail: "détail(optionnel)" + }, + { description: "Carte verticale avec détail" } +); + +export const CardWithEndDetail = getStory( + { + ...defaultProps, + endDetail: "détail(optionnel)" + }, + { description: "Carte verticale avec détail en bas" } +); + +export const CardWithActionLinks = getStory( + { + ...defaultProps, + enlargeLink: false, + footer: ( + + ) + }, + { description: "Carte verticale avec liens d'action" } +); + +export const CardWithActionButtons = getStory( + { + ...defaultProps, + enlargeLink: false, + footer: ( +
    +
  • + +
  • +
  • + +
  • +
+ ) + }, + { description: "Carte verticale avec buttons d'action" } +); + +export const CardHorizontale = getStory( + { + ...defaultProps, + horizontal: true + }, + { description: "Carte horizontale", defaultContainerWidth: 700 } +); + +export const CardHorizontaleSM = getStory( + { + ...defaultProps, + horizontal: true, + size: "small" + }, + { description: "Carte horizontale", defaultContainerWidth: 500 } +); + +export const CardHorizontaleLG = getStory( + { + ...defaultProps, + horizontal: true, + size: "large" + }, + { description: "Carte horizontale", defaultContainerWidth: 900 } +); + +export const CardHorizontaleWithoutImage = getStory( + { + ...defaultProps, + horizontal: true, + size: "large", + imageUrl: undefined, + detail: ( +
    + + +
+ ) + }, + { description: "Carte horizontale sans image", defaultContainerWidth: 900 } +); + +export const CardHorizontaleWithoutImageAndEnlargeLink = getStory( + { + ...defaultProps, + horizontal: true, + enlargeLink: false, + size: "large", + imageUrl: undefined + }, + { description: "Carte horizontale sans image", defaultContainerWidth: 900 } +); + +export const CardHorizontaleWithActions = getStory( + { + ...defaultProps, + enlargeLink: false, + horizontal: true, + size: "large", + badges: [], + detail: ( +
    + + +
+ ), + footer: ( +
    +
  • + +
  • +
  • + +
  • +
+ ) + }, + { description: "Carte horizontale", defaultContainerWidth: 900 } +); + +export const CardGrey = getStory( + { + ...defaultProps, + horizontal: true, + grey: false + }, + { description: "Carte horizontale grey", defaultContainerWidth: 900 } +); diff --git a/stories/getStory.tsx b/stories/getStory.tsx index 748ba3d61..8be444f68 100644 --- a/stories/getStory.tsx +++ b/stories/getStory.tsx @@ -92,14 +92,18 @@ export function getStoryFactory>(params: { let isFirstStory = true; - function getStory(props: Props, params?: { description?: string }): typeof Template { - const { description } = params ?? {}; + function getStory( + props: Props, + params?: { defaultContainerWidth?: number; description?: string } + ): typeof Template { + const { defaultContainerWidth: defaultContainerWidthStoryLevel, description } = + params ?? {}; const out = Template.bind({}); out.args = { "darkMode": window.matchMedia("(prefers-color-scheme: dark)").matches, - "containerWidth": defaultContainerWidth ?? 0, + "containerWidth": defaultContainerWidthStoryLevel ?? defaultContainerWidth ?? 0, "lang": "fr", isFirstStory, ...props