Skip to content

Commit 722b427

Browse files
committed
Feat: Modal
1 parent 2ab7b86 commit 722b427

File tree

3 files changed

+200
-116
lines changed

3 files changed

+200
-116
lines changed

src/Modal.tsx

Lines changed: 91 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,53 +7,33 @@ import { createComponentI18nApi } from "./i18n";
77
import type { FrIconClassName, RiIconClassName } from "./fr/generatedFromCss/classNames";
88
import { symToStr } from "tsafe/symToStr";
99
import Button, { ButtonProps } from "./Button";
10+
import { capitalize } from "tsafe/capitalize";
11+
import { uncapitalize } from "tsafe/uncapitalize";
1012

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-
}
13+
export type ModalProps = {
14+
className?: string;
15+
/** Default: "medium" */
16+
size?: "small" | "medium" | "large";
17+
title: ReactNode;
18+
children: ReactNode;
19+
/** Default: true */
20+
concealingBackdrop?: boolean;
21+
topAnchor?: boolean;
22+
ref?: ForwardedRef<HTMLDialogElement>;
23+
iconId?: FrIconClassName | RiIconClassName;
24+
buttons?:
25+
| [ModalProps.ActionAreaButtonProps, ...ModalProps.ActionAreaButtonProps[]]
26+
| ModalProps.ActionAreaButtonProps;
27+
};
2228

23-
export type ModalProps = ModalProps.Common &
24-
(ModalProps.WithTitleIcon | ModalProps.WithoutTitleIcon) &
25-
(ModalProps.WithAction | ModalProps.WithoutAction);
2629
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;
30+
export type ActionAreaButtonProps = ButtonProps & {
31+
/** Default: true */
32+
doClosesModal?: boolean;
5233
};
5334
}
5435

55-
/** @see <> add doc url */
56-
export const Modal = memo((props: ModalProps) => {
36+
const Modal = memo((props: ModalProps & { id: string }) => {
5737
const {
5838
className,
5939
id,
@@ -63,13 +43,20 @@ export const Modal = memo((props: ModalProps) => {
6343
topAnchor = false,
6444
ref,
6545
iconId,
66-
actionArea,
46+
buttons: buttons_props,
6747
size = "medium",
6848
...rest
6949
} = props;
7050

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

53+
const buttons =
54+
buttons_props === undefined
55+
? undefined
56+
: buttons_props instanceof Array
57+
? buttons_props
58+
: [buttons_props];
59+
7360
const { t } = useTranslation();
7461

7562
return (
@@ -117,7 +104,7 @@ export const Modal = memo((props: ModalProps) => {
117104
</h1>
118105
{children}
119106
</div>
120-
{actionArea !== undefined && (
107+
{buttons !== undefined && (
121108
<div className="fr-modal__footer">
122109
<ul
123110
className={fr.cx(
@@ -128,16 +115,34 @@ export const Modal = memo((props: ModalProps) => {
128115
"fr-btns-group--icon-left"
129116
)}
130117
>
131-
{actionArea.map((buttonProps, i) => {
132-
const { priority: priorityProps, ...rest } =
133-
buttonProps;
134-
const priority = i === 0 ? "primary" : priorityProps;
135-
return (
118+
{[...buttons]
119+
.reverse()
120+
.map(({ doClosesModal = true, ...buttonProps }, i) => (
136121
<li key={i}>
137-
<Button priority={priority} {...rest} />
122+
<Button
123+
{...buttonProps}
124+
priority={
125+
buttonProps.priority ??
126+
(i === 0 ? "primary" : "secondary")
127+
}
128+
{...(!doClosesModal
129+
? {}
130+
: "linkProps" in buttonProps
131+
? {
132+
"linkProps": {
133+
...buttonProps.linkProps,
134+
"aria-controls": id
135+
} as any
136+
}
137+
: {
138+
"nativeButtonProps": {
139+
...buttonProps.nativeButtonProps,
140+
"aria-controls": id
141+
} as any
142+
})}
143+
/>
138144
</li>
139-
);
140-
})}
145+
))}
141146
</ul>
142147
</div>
143148
)}
@@ -168,8 +173,43 @@ addModalTranslations({
168173
addModalTranslations({
169174
"lang": "es",
170175
"messages": {
176+
/* spell-checker: disable */
171177
"close": "Cerrar"
178+
/* spell-checker: enable */
172179
}
173180
});
174181

175182
export { addModalTranslations };
183+
184+
function createOpenModalButtonProps(id: string) {
185+
return {
186+
"onClick": undefined as any as () => void,
187+
"nativeButtonProps": {
188+
"aria-controls": id,
189+
"data-fr-opened": false
190+
}
191+
};
192+
}
193+
194+
let counter = 0;
195+
196+
/** @see <https://react-dsfr-components.etalab.studio/?path=/docs/components-modal> */
197+
export function createModal<Name extends string>(
198+
name: Name
199+
): Record<`${Uncapitalize<Name>}ModalButtonProps`, ReturnType<typeof createOpenModalButtonProps>> &
200+
Record<`${Capitalize<Name>}Modal`, (props: ModalProps) => JSX.Element> {
201+
const modalId = `${uncapitalize(name)}-modal-${counter++}`;
202+
203+
function InternalModal(props: ModalProps) {
204+
return <Modal {...props} id={modalId} />;
205+
}
206+
207+
InternalModal.displayName = `${capitalize(name)}Modal`;
208+
209+
Object.defineProperty(InternalModal, "name", { "value": InternalModal.displayName });
210+
211+
return {
212+
[InternalModal.displayName]: InternalModal,
213+
[`${uncapitalize(name)}ModalButtonProps`]: createOpenModalButtonProps(modalId)
214+
} as any;
215+
}

stories/Modal.stories.tsx

Lines changed: 84 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
1-
import * as React from "react";
2-
import { Modal, ModalProps } from "../dist/Modal";
1+
import React from "react";
2+
import { createModal } from "../dist/Modal";
3+
import type { ModalProps } from "../dist/Modal";
34
import { sectionName } from "./sectionName";
45
import { getStoryFactory } from "./getStory";
56
import { Button } from "../dist/Button";
6-
import { symToStr } from "tsafe/symToStr";
77
import { assert } from "tsafe/assert";
88
import { Equals } from "tsafe";
99

1010
const { meta, getStory } = getStoryFactory({
1111
sectionName,
12-
"wrappedComponent": { [symToStr({ Modal })]: Template },
13-
"description": `
14-
A button that opens a modale
12+
"wrappedComponent": { "Modal": Template },
13+
"description": `\`import { createModal } from "@codegouvfr/react-dsfr/Modal";\` (Click **show code** for usage details)
14+
1515
- [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)`,
16+
- [See source code](https://github.com/codegouvfr/react-dsfr/blob/main/src/Modal.tsx)
17+
`,
1718
"argTypes": {
18-
"id": {
19-
"description": "Required : This modal id (shared with button aria-controls)"
20-
},
2119
"title": {
2220
"description": `Required : The modal title`
2321
},
@@ -56,77 +54,98 @@ A button that opens a modale
5654
"topAnchor": {
5755
"control": "boolean",
5856
"description": "Default : false, make modal anchor to the top"
57+
},
58+
"buttons": {
59+
"control": { "type": null },
60+
"description": `The buttons at the bottom of the Modal, it's an array of ButtonProps objects.
61+
If not stated otherwise all buttons are "secondary" except the last one that is "primary".
62+
By default all buttons closes the modal, if you want it to be otherwise you can add \`doClosesModal: false\`
63+
`
5964
}
60-
}
65+
},
66+
"doHideImportInstruction": true
6167
});
6268

6369
export default meta;
6470

71+
const { SimpleModal, simpleModalButtonProps } = createModal("simple");
72+
6573
function Template(args: ModalProps) {
6674
return (
6775
<>
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} />
76+
<Button {...simpleModalButtonProps}>Open modal</Button>
77+
<SimpleModal {...args} />
7978
</>
8079
);
8180
}
8281

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",
82+
export const Default = getStory({
83+
"children": `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas varius tortor nibh,
84+
sit amet tempor nibh finibus et. Aenean eu enim justo. Vestibulum aliquam hendrerit molestie. Mauris
85+
malesuada nisi sit amet augue accumsan tincidunt. Maecenas tincidunt, velit ac porttitor pulvinar,
86+
tortor eros facilisis libero, vitae commodo nunc quam et ligula. Ut nec ipsum sapien. Interdum et
87+
malesuada fames ac ante ipsum primis in faucibus. Integer id nisi nec nulla luctus lacinia non eu
88+
turpis. Etiam in ex imperdiet justo tincidunt egestas. Ut porttitor urna ac augue cursus tincidunt sit amet sed orci.`,
89+
"title": "Accept terms",
8890
"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": [
91+
"buttons": [
11992
{
120-
"linkProps": { "href": "#" },
121-
"iconId": "fr-icon-git-commit-fill",
122-
"children": "Button 1 label",
123-
"priority": "secondary"
93+
"linkProps": { "href": "https://example.com", "target": "_blank" },
94+
"doClosesModal": false, //Default true, clicking a button close the modal.
95+
"children": "Learn more"
12496
},
12597
{
126-
"priority": "secondary",
127-
"linkProps": { "href": "#" },
128-
"iconId": "fr-icon-chat-check-fill",
129-
"children": "Button 2 label (longer)"
98+
"iconId": "ri-check-line",
99+
"children": "Ok",
100+
"onClick": () => console.log("terms accepted")
130101
}
131102
]
132103
});
104+
105+
Default.parameters = {
106+
"docs": {
107+
"source": {
108+
"code": `
109+
import { createModal } from "@codegouvfr/react-dsfr/Modal";
110+
import { Button } from "@codegouvfr/react-dsfr/Button";
111+
112+
const { AcceptTermsModal, acceptTermsModalButtonProps } = createModal("acceptTerms");
113+
114+
function MyComponent(){
115+
116+
return (
117+
<>
118+
<Button {...acceptTermsModalButtonProps}>Open modal</Button>
119+
<AcceptTermsModal
120+
title="Accept terms"
121+
iconId="fr-icon-checkbox-circle-line"
122+
buttons={
123+
[
124+
{
125+
linkProps: { href: "https://example.com", target: "_blank" },
126+
doClosesModal: false, //Default true, clicking a button close the modal.
127+
children: "Learn more"
128+
},
129+
{
130+
iconId: "ri-check-line",
131+
onClick: ()=> console.log("terms accepted"),
132+
children: "Ok"
133+
}
134+
]
135+
}
136+
>
137+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas varius tortor nibh,
138+
sit amet tempor nibh finibus et. Aenean eu enim justo. Vestibulum aliquam hendrerit molestie. Mauris
139+
malesuada nisi sit amet augue accumsan tincidunt. Maecenas tincidunt, velit ac porttitor pulvinar,
140+
tortor eros facilisis libero, vitae commodo nunc quam et ligula. Ut nec ipsum sapien. Interdum et
141+
malesuada fames ac ante ipsum primis in faucibus. Integer id nisi nec nulla luctus lacinia non eu
142+
turpis. Etiam in ex imperdiet justo tincidunt egestas. Ut porttitor urna ac augue cursus tincidunt sit amet sed orci.
143+
</AcceptTermsModal>
144+
</>
145+
);
146+
147+
}
148+
`
149+
}
150+
}
151+
};

0 commit comments

Comments
 (0)