diff --git a/.gitignore b/.gitignore index df7228fab..0bd579ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ jspm_packages .node_repl_history .vscode +.idea .DS_Store diff --git a/package.json b/package.json index 53be809db..f85f4a7a8 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codegouvfr/react-dsfr", - "version": "0.0.74", + "version": "0.0.72", "description": "French State Design System React integration library", "repository": { "type": "git", @@ -124,6 +124,7 @@ "./next": "./dist/next.js", "./mui": "./dist/mui.js", "./Tabs": "./dist/Tabs.js", + "./Notice": "./dist/Notice.js", "./Header": "./dist/Header.js", "./DarkModeSwitch": "./dist/DarkModeSwitch.js", "./Alert": "./dist/Alert.js", diff --git a/src/Notice.tsx b/src/Notice.tsx new file mode 100644 index 000000000..53dc3e690 --- /dev/null +++ b/src/Notice.tsx @@ -0,0 +1,168 @@ +import React, { memo, forwardRef, useState, useEffect, useRef, ReactNode } from "react"; +import { symToStr } from "tsafe/symToStr"; +import { fr } from "./lib"; +import { cx } from "./lib/tools/cx"; +import { assert } from "tsafe/assert"; +import type { Equals } from "tsafe"; +import { useConstCallback } from "./lib/tools/powerhooks/useConstCallback"; +import { createComponentI18nApi } from "./lib/i18n"; +// We make users import dsfr.css so we don't need to import the scoped CSS +// but in the future if we have a complete component coverage it +// we could stop requiring users to import the hole CSS and only import on a +// per component basis. +import "./dsfr/component/notice/notice.css"; + +export type NoticeProps = { + className?: string; + classes?: Partial>; + title: NonNullable; +} & (NoticeProps.NonClosable | NoticeProps.Closable); + +export namespace NoticeProps { + export type NonClosable = { + isClosable?: false; + isClosed?: undefined; + onClose?: undefined; + }; + + export type Closable = { + isClosable: true; + } & (Closable.Controlled | Closable.Uncontrolled); + + export namespace Closable { + export type Controlled = { + isClosed: boolean; + onClose: () => void; + }; + + export type Uncontrolled = { + isClosed?: undefined; + onClose?: () => void; + }; + } +} + +/** @see */ +export const Notice = memo( + forwardRef((props, ref) => { + const { + className, + classes = {}, + title, + isClosable = false, + isClosed: props_isClosed, + onClose, + ...rest + } = props; + + assert>(); + + const [isClosed, setIsClosed] = useState(props_isClosed ?? false); + + const [buttonElement, setButtonElement] = useState(null); + + const refShouldButtonGetFocus = useRef(false); + const refShouldSetRole = useRef(false); + + useEffect(() => { + if (props_isClosed === undefined) { + return; + } + setIsClosed((isClosed: boolean) => { + if (isClosed && !props_isClosed) { + refShouldButtonGetFocus.current = true; + refShouldSetRole.current = true; + } + + return props_isClosed; + }); + }, [props_isClosed]); + + useEffect(() => { + if (!refShouldButtonGetFocus.current) { + return; + } + + if (buttonElement === null) { + //NOTE: This should not be reachable + return; + } + + refShouldButtonGetFocus.current = false; + buttonElement.focus(); + }, [buttonElement]); + + const onCloseButtonClick = useConstCallback(() => { + if (props_isClosed === undefined) { + //Uncontrolled + setIsClosed(true); + onClose?.(); + } else { + //Controlled + onClose(); + } + }); + + const { t } = useTranslation(); + + if (isClosed) { + return null; + } + + return ( +
+
+
+

{title}

+ {/* TODO: Use our button once we have one */} + {isClosable && ( + + )} +
+
+
+ ); + }) +); + +Notice.displayName = symToStr({ Notice }); + +export default Notice; + +const { useTranslation, addNoticeTranslations } = createComponentI18nApi({ + "componentName": symToStr({ Notice }), + "frMessages": { + /* spell-checker: disable */ + "hide message": "Masquer le message" + /* spell-checker: enable */ + } +}); + +addNoticeTranslations({ + "lang": "en", + "messages": { + "hide message": "Hide the message" + } +}); + +addNoticeTranslations({ + "lang": "es", + "messages": { + /* spell-checker: disable */ + "hide message": "Occultar el mesage" + /* spell-checker: enable */ + } +}); + +export { addNoticeTranslations }; diff --git a/stories/Notice.stories.tsx b/stories/Notice.stories.tsx new file mode 100644 index 000000000..eb9e167e6 --- /dev/null +++ b/stories/Notice.stories.tsx @@ -0,0 +1,50 @@ +import { Notice } from "../dist/Notice"; +import { sectionName } from "./sectionName"; +import { getStoryFactory, logCallbacks } from "./getStory"; + +const { meta, getStory } = getStoryFactory({ + sectionName, + "wrappedComponent": { Notice }, + "description": ` +- [See DSFR documentation](https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/bandeau-d-information-importante) +- [See source code](https://github.com/codegouvfr/react-dsfr/blob/main/src/Notice.tsx)`, + "argTypes": { + "title": { + "description": + 'Required message to display, it should not relay a "classic" information, but an important and temporary information.' + }, + "isClosable": { + "description": "If the notice should have a close button" + }, + "onClose": { + "description": "Called when the user clicks the close button" + }, + "isClosed": { + "description": `If specified the \`\` is in + [controlled mode](https://reactjs.org/docs/forms.html#controlled-components) + this means that when the close button is clicked + the \`onClose()\` callback will be called but you are responsible + for setting \`isClosed\` to \`false\`, the \`\` wont close itself.`, + "control": { "type": null } + } + }, + "disabledProps": ["lang"] +}); + +export default meta; + +export const Default = getStory({ + "title": "Service maintenance is scheduled today from 12:00 to 14:00", + "isClosable": true, + "isClosed": undefined, + ...logCallbacks(["onClose"]) +}); + +export const NonClosableNotice = getStory({ + "title": "This is the title" +}); + +export const ClosableNotice = getStory({ + "title": "This is the title", + "isClosable": true +});