Skip to content

Commit 2ab7b86

Browse files
committed
WIP : Modal
1 parent a5930d7 commit 2ab7b86

File tree

3 files changed

+308
-0
lines changed

3 files changed

+308
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
"./Quote": "./dist/Quote.js",
141141
"./Pagination": "./dist/Pagination.js",
142142
"./Notice": "./dist/Notice.js",
143+
"./Modal": "./dist/Modal.js",
143144
"./MainNavigation": "./dist/MainNavigation/index.js",
144145
"./Input": "./dist/Input.js",
145146
"./Highlight": "./dist/Highlight.js",

src/Modal.tsx

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import React, { memo, ForwardedRef } from "react";
2+
import { fr } from "./fr";
3+
import { cx } from "./tools/cx";
4+
import type { ReactNode } from "react";
5+
import { Equals, assert } from "tsafe";
6+
import { createComponentI18nApi } from "./i18n";
7+
import type { FrIconClassName, RiIconClassName } from "./fr/generatedFromCss/classNames";
8+
import { symToStr } from "tsafe/symToStr";
9+
import Button, { ButtonProps } from "./Button";
10+
11+
export function createOpenModalButtonProps(id: string): ButtonProps {
12+
return {
13+
"onClick": () => {
14+
/* nothing */
15+
},
16+
"nativeButtonProps": {
17+
"aria-controls": id,
18+
"data-fr-opened": "false"
19+
}
20+
};
21+
}
22+
23+
export type ModalProps = ModalProps.Common &
24+
(ModalProps.WithTitleIcon | ModalProps.WithoutTitleIcon) &
25+
(ModalProps.WithAction | ModalProps.WithoutAction);
26+
export namespace ModalProps {
27+
export type Common = {
28+
className?: string;
29+
size?: "small" | "medium" | "large";
30+
id: string;
31+
title: ReactNode;
32+
children: ReactNode;
33+
concealingBackdrop?: boolean;
34+
topAnchor?: boolean;
35+
ref?: ForwardedRef<HTMLDialogElement>;
36+
};
37+
38+
export type WithTitleIcon = {
39+
iconId: FrIconClassName | RiIconClassName;
40+
};
41+
42+
export type WithoutTitleIcon = {
43+
iconId?: never;
44+
};
45+
46+
export type WithAction = {
47+
actionArea: [ButtonProps, ...ButtonProps[]];
48+
};
49+
50+
export type WithoutAction = {
51+
actionArea?: never;
52+
};
53+
}
54+
55+
/** @see <> add doc url */
56+
export const Modal = memo((props: ModalProps) => {
57+
const {
58+
className,
59+
id,
60+
title,
61+
children,
62+
concealingBackdrop = true,
63+
topAnchor = false,
64+
ref,
65+
iconId,
66+
actionArea,
67+
size = "medium",
68+
...rest
69+
} = props;
70+
71+
assert<Equals<keyof typeof rest, never>>();
72+
73+
const { t } = useTranslation();
74+
75+
return (
76+
<dialog
77+
aria-labelledby="fr-modal-title-modal-1"
78+
role="dialog"
79+
id={id}
80+
className={cx(fr.cx("fr-modal", topAnchor && "fr-modal--top"), className)}
81+
ref={ref}
82+
data-fr-concealing-backdrop={concealingBackdrop}
83+
>
84+
<div className={fr.cx("fr-container", "fr-container--fluid", "fr-container-md")}>
85+
<div className={fr.cx("fr-grid-row", "fr-grid-row--center")}>
86+
<div
87+
className={(() => {
88+
switch (size) {
89+
case "large":
90+
return fr.cx("fr-col-12", "fr-col-md-10", "fr-col-lg-8");
91+
case "small":
92+
return fr.cx("fr-col-12", "fr-col-md-6", "fr-col-lg-4");
93+
case "medium":
94+
return fr.cx("fr-col-12", "fr-col-md-8", "fr-col-lg-6");
95+
}
96+
})()}
97+
>
98+
<div className={fr.cx("fr-modal__body")}>
99+
<div className={fr.cx("fr-modal__header")}>
100+
<button
101+
className={fr.cx("fr-link--close", "fr-link")}
102+
title={t("close")}
103+
aria-controls={id}
104+
>
105+
{t("close")}
106+
</button>
107+
</div>
108+
<div className={fr.cx("fr-modal__content")}>
109+
<h1
110+
id="fr-modal-title-modal-1"
111+
className={fr.cx("fr-modal__title")}
112+
>
113+
{iconId !== undefined && (
114+
<span className={fr.cx(iconId, "fr-fi--lg")} />
115+
)}
116+
{title}
117+
</h1>
118+
{children}
119+
</div>
120+
{actionArea !== undefined && (
121+
<div className="fr-modal__footer">
122+
<ul
123+
className={fr.cx(
124+
"fr-btns-group",
125+
"fr-btns-group--right",
126+
"fr-btns-group--inline-reverse",
127+
"fr-btns-group--inline-lg",
128+
"fr-btns-group--icon-left"
129+
)}
130+
>
131+
{actionArea.map((buttonProps, i) => {
132+
const { priority: priorityProps, ...rest } =
133+
buttonProps;
134+
const priority = i === 0 ? "primary" : priorityProps;
135+
return (
136+
<li key={i}>
137+
<Button priority={priority} {...rest} />
138+
</li>
139+
);
140+
})}
141+
</ul>
142+
</div>
143+
)}
144+
</div>
145+
</div>
146+
</div>
147+
</div>
148+
</dialog>
149+
);
150+
});
151+
152+
Modal.displayName = symToStr({ Modal });
153+
154+
const { useTranslation, addModalTranslations } = createComponentI18nApi({
155+
"componentName": symToStr({ Modal }),
156+
"frMessages": {
157+
"close": "Fermer"
158+
}
159+
});
160+
161+
addModalTranslations({
162+
"lang": "en",
163+
"messages": {
164+
"close": "Close"
165+
}
166+
});
167+
168+
addModalTranslations({
169+
"lang": "es",
170+
"messages": {
171+
"close": "Cerrar"
172+
}
173+
});
174+
175+
export { addModalTranslations };

stories/Modal.stories.tsx

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import * as React from "react";
2+
import { Modal, ModalProps } from "../dist/Modal";
3+
import { sectionName } from "./sectionName";
4+
import { getStoryFactory } from "./getStory";
5+
import { Button } from "../dist/Button";
6+
import { symToStr } from "tsafe/symToStr";
7+
import { assert } from "tsafe/assert";
8+
import { Equals } from "tsafe";
9+
10+
const { meta, getStory } = getStoryFactory({
11+
sectionName,
12+
"wrappedComponent": { [symToStr({ Modal })]: Template },
13+
"description": `
14+
A button that opens a modale
15+
- [See DSFR documentation](https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/modale)
16+
- [See source code](https://github.com/codegouvfr/react-dsfr/blob/main/src/Modal.tsx)`,
17+
"argTypes": {
18+
"id": {
19+
"description": "Required : This modal id (shared with button aria-controls)"
20+
},
21+
"title": {
22+
"description": `Required : The modal title`
23+
},
24+
"children": {
25+
"description": "Required : The modal content"
26+
},
27+
"iconId": {
28+
"description": "Optional : icon Id",
29+
"options": (() => {
30+
const options = ["fr-icon-checkbox-circle-line", "ri-ancient-gate-fill"] as const;
31+
32+
assert<typeof options[number] extends ModalProps["iconId"] ? true : false>();
33+
34+
return options;
35+
})(),
36+
"control": { "type": "radio" }
37+
},
38+
"size": {
39+
"options": (() => {
40+
const options = ["small", "medium", "large"] as const;
41+
42+
assert<Equals<typeof options[number] | undefined, ModalProps["size"]>>();
43+
44+
return options;
45+
})(),
46+
"description": `
47+
Default: "medium"
48+
`,
49+
"control": { "type": "select" }
50+
},
51+
"concealingBackdrop": {
52+
"control": "boolean",
53+
"description":
54+
"Default : true, make modal not closable by clicking on the bottom if false"
55+
},
56+
"topAnchor": {
57+
"control": "boolean",
58+
"description": "Default : false, make modal anchor to the top"
59+
}
60+
}
61+
});
62+
63+
export default meta;
64+
65+
function Template(args: ModalProps) {
66+
return (
67+
<>
68+
<Button
69+
onClick={() => console.log("onClick")}
70+
nativeButtonProps={{
71+
"aria-controls": args.id,
72+
"data-fr-opened": "false"
73+
}}
74+
wesh-morray={false}
75+
>
76+
Open Modal
77+
</Button>
78+
<Modal {...args} />
79+
</>
80+
);
81+
}
82+
83+
export const ModalSimple = getStory({
84+
"id": "fr-modal-1",
85+
"children":
86+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas varius tortor nibh, sit amet tempor nibh finibus et. Aenean eu enim justo. Vestibulum aliquam hendrerit molestie. Mauris malesuada nisi sit amet augue accumsan tincidunt. Maecenas tincidunt, velit ac porttitor pulvinar, tortor eros facilisis libero, vitae commodo nunc quam et ligula. Ut nec ipsum sapien. Interdum et malesuada fames ac ante ipsum primis in faucibus. Integer id nisi nec nulla luctus lacinia non eu turpis. Etiam in ex imperdiet justo tincidunt egestas. Ut porttitor urna ac augue cursus tincidunt sit amet sed orci.",
87+
"title": "Titre de la modale",
88+
"iconId": "fr-icon-checkbox-circle-line",
89+
"size": "medium",
90+
"concealingBackdrop": true,
91+
"topAnchor": false
92+
});
93+
94+
ModalSimple.parameters = {
95+
docs: {
96+
source: {
97+
code: `
98+
<>
99+
<Button onClick={() => console.log("onClick")} nativeButtonProps={{ "aria-controls": args.id, data-fr-opened="false"}} >
100+
Open Modal
101+
</Button>
102+
<Modal id="fr-modal-1" children="Lorem ipsum dolor sit amet" title="Titre de la modale" iconId="fr-icon-checkbox-circle-line" />
103+
</>
104+
`
105+
}
106+
}
107+
};
108+
109+
export const ModalAction = getStory({
110+
"id": "fr-modal-2",
111+
"children":
112+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas varius tortor nibh, sit amet tempor nibh finibus et. Aenean eu enim justo. Vestibulum aliquam hendrerit molestie. Mauris malesuada nisi sit amet augue accumsan tincidunt. Maecenas tincidunt, velit ac porttitor pulvinar, tortor eros facilisis libero, vitae commodo nunc quam et ligula. Ut nec ipsum sapien. Interdum et malesuada fames ac ante ipsum primis in faucibus. Integer id nisi nec nulla luctus lacinia non eu turpis. Etiam in ex imperdiet justo tincidunt egestas. Ut porttitor urna ac augue cursus tincidunt sit amet sed orci.",
113+
"title": "Titre de la modale",
114+
"iconId": "fr-icon-checkbox-circle-line",
115+
"size": "medium",
116+
"concealingBackdrop": true,
117+
"topAnchor": false,
118+
"actionArea": [
119+
{
120+
"linkProps": { "href": "#" },
121+
"iconId": "fr-icon-git-commit-fill",
122+
"children": "Button 1 label",
123+
"priority": "secondary"
124+
},
125+
{
126+
"priority": "secondary",
127+
"linkProps": { "href": "#" },
128+
"iconId": "fr-icon-chat-check-fill",
129+
"children": "Button 2 label (longer)"
130+
}
131+
]
132+
});

0 commit comments

Comments
 (0)