Skip to content

Commit 868f2db

Browse files
committed
Add Alert and Header component ans i18n system
1 parent f311183 commit 868f2db

File tree

13 files changed

+898
-156
lines changed

13 files changed

+898
-156
lines changed

.eslintrc.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ module.exports = {
1313
"no-extra-boolean-cast": "off",
1414
"@typescript-eslint/explicit-module-boundary-types": "off",
1515
"@typescript-eslint/no-explicit-any": "off",
16-
"@typescript-eslint/no-namespace": "off"
16+
"@typescript-eslint/no-namespace": "off",
17+
"@typescript-eslint/ban-types": "off"
1718
}
1819
};

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ This module is a wrapper/compatibility layer for [@gouvfr/dsfr](https://github.c
3636
be automatically adapted to look like [DSFR components](https://www.systeme-de-design.gouv.fr/elements-d-interface).
3737
- [x] Enable CSS in JS by providing a `useTheme()` hooks that exposes the correct colors options and decision
3838
for the currently enabled color scheme.
39+
- [x] Opt-in i18n.
3940

4041
This module is a product of [Etalab's Free and open source software pole](https://communs.numerique.gouv.fr/a-propos/).
4142
[I](https://github.com/garronej)'m working full time on this project. You can expect rapid development. 🚀

package.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,9 @@
7171
"tsafe": "^1.1.1"
7272
},
7373
"devDependencies": {
74-
"@gouvfr/dsfr": "1.8.2",
75-
"css": "^3.0.0",
76-
"remixicon": "^2.5.0",
7774
"@emotion/react": "^11.10.4",
7875
"@emotion/styled": "^11.10.4",
76+
"@gouvfr/dsfr": "1.8.2",
7977
"@mui/material": "^5.10.7",
8078
"@types/css": "^0.0.33",
8179
"@types/memoizee": "^0.4.8",
@@ -84,6 +82,7 @@
8482
"@types/react-dom": "18.0.6",
8583
"@typescript-eslint/eslint-plugin": "^4.24.0",
8684
"@typescript-eslint/parser": "^4.24.0",
85+
"css": "^3.0.0",
8786
"eslint": "^7.26.0",
8887
"eslint-config-prettier": "^8.3.0",
8988
"evt": "^2.4.2",
@@ -93,9 +92,11 @@
9392
"next": "12.3.1",
9493
"prettier": "^2.3.0",
9594
"react": "18.2.0",
95+
"remixicon": "^2.5.0",
9696
"rimraf": "^3.0.2",
9797
"ts-node": "^10.2.1",
98-
"vitest": "^0.24.3",
99-
"typescript": "^4.9.1-beta"
98+
"tsd": "^0.24.1",
99+
"typescript": "^4.9.1-beta",
100+
"vitest": "^0.24.3"
100101
}
101102
}

src/Alert.tsx

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import React, { memo, forwardRef, useState, useEffect } from "react";
2+
import type { ReactNode } from "react";
3+
import type { FrClassName } from "./lib/generatedFromCss/classNames";
4+
import { symToStr } from "tsafe/symToStr";
5+
import { fr } from "./lib";
6+
import { cx } from "./lib/tools/cx";
7+
import { assert } from "tsafe/assert";
8+
import type { Equals } from "tsafe";
9+
import { useConstCallback } from "./lib/tools/powerhooks/useConstCallback";
10+
import { createComponentI18nApi } from "./lib/i18n";
11+
12+
export type AlertProps = {
13+
className?: string;
14+
severity: AlertProps.Severity;
15+
/** Default h3 */
16+
as?: `h${2 | 3 | 4 | 5 | 6}`;
17+
classes?: Partial<Record<"root" | "title" | "description" | "close", string>>;
18+
} & (AlertProps.Md | AlertProps.Sm) &
19+
(AlertProps.NonClosable | AlertProps.Closable);
20+
21+
export namespace AlertProps {
22+
export type Md = {
23+
isSmall?: false;
24+
title: NonNullable<ReactNode>;
25+
description?: NonNullable<ReactNode>;
26+
};
27+
28+
export type Sm = {
29+
isSmall: true;
30+
title?: NonNullable<ReactNode>;
31+
description: NonNullable<ReactNode>;
32+
};
33+
34+
export type NonClosable = {
35+
isClosable: false;
36+
isClosed?: undefined;
37+
onClose?: undefined;
38+
};
39+
40+
export type Closable = {
41+
isClosable: true;
42+
} & (Closable.Controlled | Closable.Uncontrolled);
43+
44+
export namespace Closable {
45+
export type Controlled = {
46+
isClosed: boolean;
47+
onClose: () => void;
48+
};
49+
50+
export type Uncontrolled = {
51+
isClosed?: undefined;
52+
onClose?: () => void;
53+
};
54+
}
55+
56+
type ExtractSeverity<FrClassName> = FrClassName extends `fr-alert--${infer Severity}`
57+
? Exclude<Severity, "sm">
58+
: never;
59+
60+
export type Severity = ExtractSeverity<FrClassName>;
61+
}
62+
63+
/** @see <https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/alerte> */
64+
export const Alert = memo(
65+
forwardRef<HTMLDivElement, AlertProps>((props, ref) => {
66+
const {
67+
className,
68+
severity,
69+
as: HtmlTitleTag = "h3",
70+
classes = {},
71+
isSmall,
72+
title,
73+
description,
74+
isClosable,
75+
isClosed: props_isClosed,
76+
onClose,
77+
...rest
78+
} = props;
79+
80+
assert<Equals<keyof typeof rest, never>>();
81+
82+
const [isClosed, setIsClosed] = useState(props_isClosed ?? false);
83+
84+
useEffect(() => {
85+
if (props_isClosed === undefined) {
86+
return;
87+
}
88+
89+
setIsClosed(props_isClosed);
90+
}, [props_isClosed]);
91+
92+
const onCloseButtonClick = useConstCallback(() => {
93+
if (props_isClosed === undefined) {
94+
//Uncontrolled
95+
setIsClosed(true);
96+
onClose?.();
97+
} else {
98+
//Controlled
99+
onClose();
100+
}
101+
});
102+
103+
const { t } = useTranslation();
104+
105+
if (isClosed) {
106+
return null;
107+
}
108+
109+
return (
110+
<div
111+
className={cx(
112+
fr.cx("fr-alert", `fr-alert--${severity}`, { "fr-alert--sm": isSmall }),
113+
classes.root,
114+
className
115+
)}
116+
ref={ref}
117+
{...rest}
118+
>
119+
<HtmlTitleTag className={cx(fr.cx("fr-alert__title"), classes.title)}>
120+
{title}
121+
</HtmlTitleTag>
122+
<p className={classes.description}>{description}</p>
123+
{/* TODO: Use our button once we have one */}
124+
{isClosable && (
125+
<button
126+
className={cx(fr.cx("fr-link--close", "fr-link"), classes.close)}
127+
onClick={onCloseButtonClick}
128+
>
129+
{t("hide message")}
130+
</button>
131+
)}
132+
</div>
133+
);
134+
})
135+
);
136+
137+
Alert.displayName = symToStr({ Alert });
138+
139+
export default Alert;
140+
141+
const { useTranslation, addAlertTranslations } = createComponentI18nApi({
142+
"componentName": symToStr({ Alert }),
143+
"frMessages": {
144+
/* spell-checker: disable */
145+
"hide message": "Masquer le message"
146+
/* spell-checker: enable */
147+
}
148+
});
149+
150+
addAlertTranslations({
151+
"lang": "en",
152+
"messages": {
153+
"hide message": "Hide the message"
154+
}
155+
});
156+
157+
addAlertTranslations({
158+
"lang": "es",
159+
"messages": {
160+
/* spell-checker: disable */
161+
"hide message": "Occultar el mesage"
162+
/* spell-checker: enable */
163+
}
164+
});
165+
166+
export { addAlertTranslations };

src/Header.tsx

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import React, { useId } from "react";
2+
import type { ReactNode } from "react";
3+
import { fr } from "./lib";
4+
import { createComponentI18nApi } from "./lib/i18n";
5+
import { symToStr } from "tsafe/symToStr";
6+
import { cx } from "./lib/tools/cx";
7+
import { FrIconClassName, RiIconClassName } from "./lib/generatedFromCss/classNames";
8+
9+
//NOTE: This is a work in progress, this component is not yet usable.
10+
11+
export type HeaderProps = {
12+
className?: string;
13+
intituléOfficiel: ReactNode;
14+
nomDuSiteSlashService: ReactNode;
15+
baselinePrécisionsSurLorganisation: ReactNode;
16+
links: {
17+
text: ReactNode;
18+
href: string;
19+
iconId: FrIconClassName | RiIconClassName;
20+
}[];
21+
};
22+
23+
/** @see <https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/en-tete> */
24+
export function Header(props: HeaderProps) {
25+
const {
26+
className,
27+
intituléOfficiel,
28+
nomDuSiteSlashService,
29+
baselinePrécisionsSurLorganisation,
30+
links
31+
} = props;
32+
33+
const menuButtonId = useId();
34+
const modalId = useId();
35+
36+
const { t } = useTranslation();
37+
38+
return (
39+
<header role="banner" className={cx(fr.cx("fr-header"), className)}>
40+
<div /*className={fr.cx("fr-header__body" as any)}*/>
41+
<div className={fr.cx("fr-container")}>
42+
<div className={fr.cx("fr-header__body-row")}>
43+
<div className={fr.cx("fr-header__brand", "fr-enlarge-link")}>
44+
<div className={fr.cx("fr-header__brand-top")}>
45+
<div className={fr.cx("fr-header__logo")}>
46+
<p className={fr.cx("fr-logo")}>{intituléOfficiel}</p>
47+
</div>
48+
<div className={fr.cx("fr-header__navbar")}>
49+
<button
50+
className={fr.cx("fr-btn--menu", "fr-btn")}
51+
data-fr-opened={false}
52+
aria-controls={modalId}
53+
aria-haspopup="menu"
54+
id={menuButtonId}
55+
title={t("menu")}
56+
>
57+
{t("menu")}
58+
</button>
59+
</div>
60+
</div>
61+
<div className={fr.cx("fr-header__service")}>
62+
<a
63+
href="/"
64+
title="Accueil - [À MODIFIER - Nom du site / service] - Nom de l’entité (ministère, secrétariat d‘état, gouvernement)"
65+
>
66+
<p className={fr.cx("fr-header__service-title")}>
67+
{nomDuSiteSlashService}
68+
</p>
69+
</a>
70+
<p /*className={fr.cx("fr-header__service-tagline" as any)}*/>
71+
{baselinePrécisionsSurLorganisation}
72+
</p>
73+
</div>
74+
</div>
75+
<div className={fr.cx("fr-header__tools")}>
76+
<div className={fr.cx("fr-header__tools-links")}>
77+
<ul className={fr.cx("fr-btns-group")}>
78+
{links.map(({ href, iconId, text }) => (
79+
<li key={href + iconId}>
80+
<a className={fr.cx("fr-btn", iconId)} href={href}>
81+
{text}
82+
</a>
83+
</li>
84+
))}
85+
</ul>
86+
</div>
87+
</div>
88+
</div>
89+
</div>
90+
</div>
91+
<div
92+
className={fr.cx("fr-header__menu", "fr-modal")}
93+
id={modalId}
94+
aria-labelledby={menuButtonId}
95+
>
96+
<div className={fr.cx("fr-container")}>
97+
<button
98+
className={fr.cx("fr-btn--close", "fr-btn")}
99+
aria-controls={modalId}
100+
title={t("close")}
101+
>
102+
{t("close")}
103+
</button>
104+
<div className={fr.cx("fr-header__menu-links")}></div>
105+
</div>
106+
</div>
107+
</header>
108+
);
109+
}
110+
111+
const { useTranslation, addHeaderTranslations } = createComponentI18nApi({
112+
"componentName": symToStr({ Header }),
113+
"frMessages": {
114+
/* spell-checker: disable */
115+
"menu": "Menu",
116+
"close": "Fermer"
117+
/* spell-checker: enable */
118+
}
119+
});
120+
121+
addHeaderTranslations({
122+
"lang": "en",
123+
"messages": {
124+
"close": "Close"
125+
}
126+
});
127+
128+
export { addHeaderTranslations };

src/lib/cx.ts

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { assert } from "tsafe/assert";
2-
import { typeGuard } from "tsafe/typeGuard";
31
import type { FrClassName } from "./generatedFromCss/classNames";
2+
import { cx as genericCx } from "./tools/cx";
43

54
export type FrCxArg =
65
| undefined
@@ -13,42 +12,4 @@ export type FrCxArg =
1312
/** Copy pasted from
1413
* https://github.com/emotion-js/emotion/blob/23f43ab9f24d44219b0b007a00f4ac681fe8712e/packages/react/src/class-names.js#L17-L63
1514
**/
16-
export const cx = (...args: FrCxArg[]): string => {
17-
const len = args.length;
18-
let i = 0;
19-
let cls = "";
20-
for (; i < len; i++) {
21-
const arg = args[i];
22-
if (arg == null) continue;
23-
24-
let toAdd;
25-
switch (typeof arg) {
26-
case "boolean":
27-
break;
28-
case "object": {
29-
if (Array.isArray(arg)) {
30-
toAdd = cx(arg);
31-
} else {
32-
assert(!typeGuard<{ length: number }>(arg, false));
33-
34-
toAdd = "";
35-
for (const k in arg) {
36-
if (arg[k as FrClassName] && k) {
37-
toAdd && (toAdd += " ");
38-
toAdd += k;
39-
}
40-
}
41-
}
42-
break;
43-
}
44-
default: {
45-
toAdd = arg;
46-
}
47-
}
48-
if (toAdd) {
49-
cls && (cls += " ");
50-
cls += toAdd;
51-
}
52-
}
53-
return cls;
54-
};
15+
export const cx: (...args: FrCxArg[]) => string = genericCx;

0 commit comments

Comments
 (0)