Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ jspm_packages
.node_repl_history

.vscode
.idea

.DS_Store

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
168 changes: 168 additions & 0 deletions src/Notice.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<"root" | "title" | "close", string>>;
title: NonNullable<ReactNode>;
} & (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 <https://react-dsfr-components.etalab.studio/?path=/docs/components-notice> */
export const Notice = memo(
forwardRef<HTMLDivElement, NoticeProps>((props, ref) => {
const {
className,
classes = {},
title,
isClosable = false,
isClosed: props_isClosed,
onClose,
...rest
} = props;

assert<Equals<keyof typeof rest, never>>();

const [isClosed, setIsClosed] = useState(props_isClosed ?? false);

const [buttonElement, setButtonElement] = useState<HTMLButtonElement | null>(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 (
<div
className={cx(fr.cx("fr-notice", `fr-notice--info`), classes.root, className)}
{...(refShouldSetRole.current && { "role": "notice" })}
ref={ref}
{...rest}
>
<div className="fr-container">
<div className="fr-notice__body">
<p className={classes.title}>{title}</p>
{/* TODO: Use our button once we have one */}
{isClosable && (
<button
ref={setButtonElement}
className={cx(fr.cx("fr-btn--close", "fr-btn"), classes.close)}
onClick={onCloseButtonClick}
>
{t("hide message")}
</button>
)}
</div>
</div>
</div>
);
})
);

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 };
50 changes: 50 additions & 0 deletions stories/Notice.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 \`<Notice />\` 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 \`<Notice />\` 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
});