diff --git a/src/Follow.tsx b/src/Follow.tsx new file mode 100644 index 000000000..b76ebd0c3 --- /dev/null +++ b/src/Follow.tsx @@ -0,0 +1,474 @@ +import React, { CSSProperties, forwardRef, memo, PropsWithChildren, ReactNode } from "react"; +import { assert, Equals } from "tsafe"; +import { symToStr } from "tsafe/symToStr"; +import { fr } from "./fr"; +import { createComponentI18nApi } from "./i18n"; +import { RegisteredLinkProps } from "./link"; +import { cx } from "./tools/cx"; +import { useAnalyticsId } from "./tools/useAnalyticsId"; +import Button, { ButtonProps } from "./Button"; +import { InputProps, Input } from "./Input"; +import Alert from "./Alert"; +import ButtonsGroup from "./ButtonsGroup"; +import { CxArg } from "tss-react"; + +export type FollowProps = { + id?: string; + className?: string; + classes?: Partial< + Record< + | "root" + | "container" + | "row" + | "newsletter-col" + | "newsletter" + | "newsletter-title" + | "newsletter-desc" + | "newsletter-form-wrapper" + | "newsletter-form-hint" + | "social-col" + | "social" + | "social-title" + | "social-buttons" + | "social-buttons-each", + CxArg + > + >; + style?: CSSProperties; + newsletter?: FollowProps.Newsletter; + social?: FollowProps.Social; +} & (FollowProps.EitherNewsletter | FollowProps.EitherSocial | FollowProps.EitherBoth); + +//https://main--ds-gouv.netlify.app/example/component/follow/ +export namespace FollowProps { + export type EitherNewsletter = { + newsletter: Newsletter; + social?: Social; + }; + + export type EitherSocial = { + newsletter?: Newsletter; + social: Social; + }; + + export type EitherBoth = { + newsletter: Newsletter; + social: Social; + }; + + export type TitleAs = { + title?: ReactNode; + /** + * Display only. The tag will stay `h2`. + * + * @default "h5" + */ + titleAs?: `h${2 | 3 | 4 | 5 | 6}`; + }; + + export type NewsletterForm = { + /** Bound props to display success alert */ + success: boolean; + successMessage?: NonNullable; + /** + * @example + * ```tsx + *
{children}
, + * }, + * }} + * /> + * ``` + */ + formComponent: ({ children }: TProps) => React.ReactNode; + consentHint?: ReactNode; + inputProps?: Partial>; + }; + + export type NewsletterWithForm = { + /** "Subscribe" button */ + buttonProps: ButtonProps.Common & + ButtonProps.AsButton & + // optional children + Partial; + /** When using a form */ + form: NewsletterForm; + }; + + export type NewsletterWithoutForm = { + /** "Subscribe" button */ + buttonProps: ButtonProps.Common & + (ButtonProps.AsButton | ButtonProps.AsAnchor) & + // optional children + Partial; + /** When using a form */ + form?: never; + }; + + export type Newsletter = TitleAs & { + desc?: ReactNode; + } & (NewsletterWithForm | NewsletterWithoutForm); + + /** + * From DSFR `$follow-icons` + `copy` and `mail` + */ + export type SocialType = + | "copy" + | "dailymotion" + | "facebook" + | "github" + | "instagram" + | "linkedin" + | "mail" + | "mastodon" + | "snapchat" + | "telegram" + | "threads" + | "tiktok" + | "twitch" + | "twitter" + | "twitter-x" + | "vimeo" + | "youtube"; + + export type SocialButton = { + type: SocialType; + linkProps: RegisteredLinkProps; + }; + + export type Social = TitleAs & { + buttons: [SocialButton, ...SocialButton[]]; + }; +} + +const FollowNewsletter = ( + props: FollowProps.Newsletter & { hasSocial: boolean; classes: FollowProps["classes"] } +) => { + const { t } = useTranslation(); + + const { + title = t("subscribe to our newsletter"), + desc, + buttonProps, + form, + hasSocial, + titleAs = "h5", + classes = {}, + ...rest + } = props; + assert>(); + + return ( +
+
+
+

+ {title} +

+ {desc !== undefined && ( +

+ {desc} +

+ )} +
+
+ {form !== undefined + ? (() => { + const { + success, + consentHint = t("consent hint"), + formComponent, + inputProps = {}, + successMessage = t("your registration has been processed"), + ...restForm + } = form; + assert>(); + + if (success) + return ( + + ); + + // prepare inputProps with default values + const { + label: inputLabel = t("your email address"), + hintText: inputHintText = consentHint, + nativeInputProps: { + title: inputTitle = t("your email address"), + placeholder: inputPlaceholder = t("your email address"), + autoComplete: inputAutoComplete = "email", + type: inputType = "email", + ...nativeInputProps + } = {}, + ...restInputProps + } = inputProps; + + // prepare buttonProps with default values + const { + children: buttonContent = t("subscribe"), + title: buttonTitle = t("subscribe to our newsletter (2)"), + type: buttonType = "button", + ...restButtonProps + } = buttonProps; + + // use wrapper to add form + return formComponent({ + children: ( + <> + + {buttonContent} + + } + /> + {inputHintText !== undefined && ( +

+ {inputHintText} +

+ )} + + ) + }); + })() + : (() => { + const { + children: buttonContent = t("subscribe"), + title: buttonTitle = t("subscribe to our newsletter (2)"), + ...restButtonProps + } = buttonProps; + + return ( + + ); + })()} +
+
+
+ ); +}; + +const FollowSocial = ( + props: FollowProps.Social & { hasNewsletter: boolean; classes: FollowProps["classes"] } +) => { + const { t } = useTranslation(); + + const { + buttons, + title = t("follow us on social medias"), + titleAs = "h5", + hasNewsletter, + classes = {}, + ...rest + } = props; + assert>(); + + return ( +
+
+

{title}

+ { + const { + target = "_blank", + rel = "noopener external", + title = `${t(button.type)} - ${t("new window")}`, + ...restLinkProps + } = button.linkProps; + + return { + className: cx( + fr.cx(`fr-btn--${button.type}`), + classes["social-buttons-each"] + ), + children: t(button.type), + linkProps: { + ...restLinkProps, + target, + rel, + title + } + }; + }) as [ButtonProps, ...ButtonProps[]] + } + /> +
+
+ ); +}; + +/** @see */ +export const Follow = memo( + forwardRef((props, ref) => { + const { id: props_id, className, classes = {}, social, style, newsletter, ...rest } = props; + + assert>(); + + const id = useAnalyticsId({ + "defaultIdPrefix": "fr-follow", + "explicitlyProvidedId": props_id + }); + + const hasSocial = social !== undefined; + const hasNewsletter = newsletter !== undefined; + + return ( +
+
+
+ {hasNewsletter && ( + + )} + {hasSocial && ( + + )} +
+
+
+ ); + }) +); + +Follow.displayName = symToStr({ Follow }); + +export default Follow; + +const { useTranslation, addFollowTranslations } = createComponentI18nApi({ + componentName: symToStr({ Follow }), + frMessages: { + /* spell-checker: disable */ + "follow us on social medias": ( + <> + Suivez-nous +
sur les réseaux sociaux + + ), + "subscribe to our newsletter": "Abonnez-vous à notre lettre d'information", + "subscribe to our newsletter (2)": "S'abonner à notre lettre d'information", + "subscribe": "S'abonner", + "your registration has been processed": "Votre inscription a bien été prise en compte", + "your email address": "Votre adresse électronique (ex. : nom@domaine.fr)", + "consent hint": + "En renseignant votre adresse électronique, vous acceptez de recevoir nos actualités par courriel. Vous pouvez vous désinscrire à tout moment à l’aide des liens de désinscription ou en nous contactant.", + "new window": "nouvelle fenêtre", + "copy": "copier", + "dailymotion": "Dailymotion", + "facebook": "Facebook", + "github": "Github", + "instagram": "Instagram", + "linkedin": "LinkedIn", + "mail": "Email", + "mastodon": "Mastodon", + "snapchat": "Snapchat", + "telegram": "Telegram", + "threads": "Threads (Instagram)", + "tiktok": "TikTok", + "twitch": "Twitch", + "twitter": "Twitter", + "twitter-x": "X (anciennement Twitter)", + "vimeo": "Vimeo", + "youtube": "Youtube" + /* spell-checker: enable */ + } +}); + +addFollowTranslations({ + lang: "en", + messages: { + /* spell-checker: disable */ + "follow us on social medias": ( + <> + Follow us +
on social medias + + ), + "subscribe to our newsletter": "Subscribe to our newsletter", + "subscribe to our newsletter (2)": "Subscribe to our newsletter", + "subscribe": "Subscribe", + "your registration has been processed": "Your registration has been processed", + "your email address": "Your email address (e.g. name@domain.fr)", + "consent hint": + "By entering your email address, you agree to receive our news by email. You can unsubscribe at any time using the unsubscribe links or by contacting us.", + "new window": "new window", + "copy": "copy", + "dailymotion": "Dailymotion", + "facebook": "Facebook", + "github": "Github", + "instagram": "Instagram", + "linkedin": "LinkedIn", + "mail": "Email", + "mastodon": "Mastodon", + "snapchat": "Snapchat", + "telegram": "Telegram", + "threads": "Threads (Instagram)", + "tiktok": "TikTok", + "twitch": "Twitch", + "twitter": "Twitter", + "twitter-x": "X (formerly Twitter)", + "vimeo": "Vimeo", + "youtube": "Youtube" + /* spell-checker: enable */ + } +}); diff --git a/src/Input.tsx b/src/Input.tsx index c2e09c5a5..86ef84437 100644 --- a/src/Input.tsx +++ b/src/Input.tsx @@ -35,6 +35,7 @@ export namespace InputProps { state?: "success" | "error" | "default"; /** The message won't be displayed if state is "default" */ stateRelatedMessage?: ReactNode; + addon?: ReactNode; }; export type RegularInput = Common & { @@ -82,6 +83,7 @@ export const Input = memo( textArea = false, nativeTextAreaProps, nativeInputProps, + addon, ...rest } = props; @@ -115,7 +117,6 @@ export const Input = memo( case "default": return undefined; } - assert>(false); })() ), classes.root, @@ -149,7 +150,6 @@ export const Input = memo( case "default": return undefined; } - assert>(false); })() ), classes.nativeInputOrTextArea @@ -161,12 +161,21 @@ export const Input = memo( /> ); - return iconId === undefined ? ( - nativeInputOrTextArea - ) : ( -
+ const hasIcon = iconId !== undefined; + const hasAddon = addon !== undefined; + return hasIcon || hasAddon ? ( +
{nativeInputOrTextArea} + {hasAddon && addon}
+ ) : ( + nativeInputOrTextArea ); })()} {state !== "default" && ( @@ -181,7 +190,6 @@ export const Input = memo( case "success": return "fr-valid-text"; } - assert>(false); })() ), classes.message diff --git a/src/blocks/PasswordInput.tsx b/src/blocks/PasswordInput.tsx index e0814f323..6287f5d9c 100644 --- a/src/blocks/PasswordInput.tsx +++ b/src/blocks/PasswordInput.tsx @@ -17,7 +17,7 @@ import { useAnalyticsId } from "../tools/useAnalyticsId"; export type PasswordInputProps = Omit< InputProps.Common, - "state" | "stateRelatedMessage" | "iconId" | "classes" + "state" | "stateRelatedMessage" | "iconId" | "classes" | "addon" > & { classes?: Partial>; /** Default "Your password must contain:", if empty string the hint wont be displayed */ diff --git a/stories/ButtonsGroup.stories.tsx b/stories/ButtonsGroup.stories.tsx index d4ccf623b..43303fe7d 100644 --- a/stories/ButtonsGroup.stories.tsx +++ b/stories/ButtonsGroup.stories.tsx @@ -88,7 +88,7 @@ const { meta, getStory } = getStoryFactory({ "control": { "type": "select" } }, "buttons": { - "description": `An array of ButtonProps (at least 2, RGAA)`, + "description": `An array of ButtonProps (at least 1)`, "control": { "type": null } } }, diff --git a/stories/Follow.stories.tsx b/stories/Follow.stories.tsx new file mode 100644 index 000000000..2467f8aa5 --- /dev/null +++ b/stories/Follow.stories.tsx @@ -0,0 +1,186 @@ +import { Follow, type FollowProps } from "../dist/Follow"; +import { sectionName } from "./sectionName"; +import { getStoryFactory } from "./getStory"; +import { action } from "@storybook/addon-actions"; +import React from "react"; + +const { meta, getStory } = getStoryFactory({ + sectionName, + wrappedComponent: { Follow }, + description: ` +- [See DSFR documentation](https://www.systeme-de-design.gouv.fr/elements-d-interface/lettre-d-information-et-reseaux-sociaux) +- [See DSFR demos](https://main--ds-gouv.netlify.app/example/component/follow/) +- [See source code](https://github.com/codegouvfr/react-dsfr/blob/main/src/Follow.tsx)`, + argTypes: { + classes: { + control: { type: null }, + description: + 'Add custom classes for various inner elements. Possible keys are "root", "container", "row", "newsletter-col", "newsletter", "newsletter-title", "newsletter-desc", "newsletter-form-wrapper", "newsletter-form-hint", "social-col", "social", "social-title", "social-buttons", "social-buttons-each"' + } + }, + disabledProps: ["lang"] +}); + +export default meta; + +const defaultSocialButtons: [FollowProps.SocialButton, ...FollowProps.SocialButton[]] = [ + { + type: "facebook", + linkProps: { + href: "#facebook" + } + }, + { + type: "twitter-x", + linkProps: { + href: "#twitter" + } + }, + { + type: "linkedin", + linkProps: { + href: "#linkedin" + } + }, + { + type: "instagram", + linkProps: { + href: "#instagram" + } + }, + { + type: "youtube", + linkProps: { + href: "#youtube" + } + } +]; + +export const Default = getStory({ + newsletter: { + buttonProps: { + onClick: action("Default onClick") + }, + form: { + formComponent: ({ children }) =>
{children}
, + inputProps: { + label: undefined + }, + success: false + } + }, + social: { + buttons: defaultSocialButtons + } +}); + +export const SocialOnly = getStory({ + social: { + buttons: defaultSocialButtons + } +}); + +export const NewsletterOnly = getStory({ + newsletter: { + buttonProps: { + onClick: action("NewsletterOnly onClick") + } + } +}); + +export const NewsletterOnlyButtonAsLink = getStory({ + newsletter: { + buttonProps: { + linkProps: { + href: "#" + } + } + } +}); + +export const NewsletterOnlyWithDescription = getStory({ + newsletter: { + buttonProps: { + onClick: action("NewsletterOnlyWithDescription onClick") + }, + desc: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas varius tortor nibh, sit amet tempor nibh finibus et." + } +}); + +export const NewsletterOnlyWithForm = getStory({ + newsletter: { + buttonProps: { + onClick: action("NewsletterOnlyWithForm onClick") + }, + desc: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas varius tortor nibh, sit amet tempor nibh finibus et.", + form: { + formComponent: ({ children }) =>
{children}
, + success: false + } + } +}); + +export const SocialAndNewsletter = getStory({ + newsletter: { + buttonProps: { + onClick: action("SocialAndNewsletter onClick") + }, + desc: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas varius tortor nibh, sit amet tempor nibh finibus et." + }, + social: { + buttons: defaultSocialButtons + } +}); + +export const SocialAndNewsletterWithForm = getStory({ + newsletter: { + buttonProps: { + onClick: action("SocialAndNewsletterWithForm onClick") + }, + desc: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas varius tortor nibh, sit amet tempor nibh finibus et.", + form: { + formComponent: ({ children }) =>
{children}
, + success: false + } + }, + social: { + buttons: defaultSocialButtons + } +}); + +export const SocialAndNewsletterWithFormSuccess = getStory({ + newsletter: { + buttonProps: { + onClick: action("SocialAndNewsletterWithFormSuccess onClick") + }, + desc: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas varius tortor nibh, sit amet tempor nibh finibus et.", + form: { + formComponent: ({ children }) =>
{children}
, + success: true + } + }, + social: { + buttons: defaultSocialButtons + } +}); + +export const SocialAndNewsletterWithFormError = getStory({ + newsletter: { + buttonProps: { + onClick: action("SocialAndNewsletterWithFormError onClick") + }, + desc: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas varius tortor nibh, sit amet tempor nibh finibus et.", + form: { + formComponent: ({ children }) =>
{children}
, + success: false, + inputProps: { + state: "error", + stateRelatedMessage: + "Le format de l’adresse electronique saisie n’est pas valide. Le format attendu est : nom@exemple.org" + } + } + }, + social: { + buttons: defaultSocialButtons + } +}); diff --git a/stories/Input.stories.tsx b/stories/Input.stories.tsx index c0de041ca..810f7df4f 100644 --- a/stories/Input.stories.tsx +++ b/stories/Input.stories.tsx @@ -3,6 +3,8 @@ import { sectionName } from "./sectionName"; import { getStoryFactory } from "./getStory"; import { assert } from "tsafe/assert"; import type { Equals } from "tsafe"; +import Button from "../dist/Button"; +import React from "react"; const { meta, getStory } = getStoryFactory({ sectionName, @@ -131,3 +133,8 @@ export const WithPlaceholder = getStory({ "placeholder": "https://" } }); + +export const WithButtonAddon = getStory({ + "label": "Label champs de saisie", + "addon": +}); diff --git a/test/integration/next-appdir/app/Follow.tsx b/test/integration/next-appdir/app/Follow.tsx new file mode 100644 index 000000000..ab8f00000 --- /dev/null +++ b/test/integration/next-appdir/app/Follow.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Follow as BaseFollow } from "@codegouvfr/react-dsfr/Follow"; +import { useState } from 'react' + +export const Follow = () => { + const [success, setSuccess] = useState(false) + return setSuccess(true) + }, + form: { + formComponent: ({ children }) =>
{children}
, + success, + } + }} + social= {{ + buttons: [ + { + type: "facebook", + linkProps: { + href: "#facebook" + } + }, + { + type: "twitter-x", + linkProps: { + href: "#twitter" + } + }, + { + type: "linkedin", + linkProps: { + href: "#linkedin" + } + }, + { + type: "instagram", + linkProps: { + href: "#instagram" + } + }, + { + type: "youtube", + linkProps: { + href: "#youtube" + } + } + ] + }} + /> +} \ No newline at end of file diff --git a/test/integration/next-appdir/app/layout.tsx b/test/integration/next-appdir/app/layout.tsx index 4e3318458..7ef9abdf2 100644 --- a/test/integration/next-appdir/app/layout.tsx +++ b/test/integration/next-appdir/app/layout.tsx @@ -18,6 +18,7 @@ import { headers } from "next/headers"; import { getScriptNonceFromHeader } from "next/dist/server/app-render/get-script-nonce-from-header"; // or use your own implementation import style from "./main.module.css"; import { cx } from '@codegouvfr/react-dsfr/tools/cx'; +import { Follow } from './Follow'; export default function RootLayout({ children }: { children: JSX.Element; }) { @@ -81,6 +82,7 @@ export default function RootLayout({ children }: { children: JSX.Element; }) {
{children}
+