Skip to content

Commit a018708

Browse files
committed
Enable to open and close modal programatically (while remaining server component ready)
1 parent 2a911f1 commit a018708

File tree

2 files changed

+70
-27
lines changed

2 files changed

+70
-27
lines changed

src/Modal.tsx

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type { FrIconClassName, RiIconClassName } from "./fr/generatedFromCss/cla
99
import Button, { ButtonProps } from "./Button";
1010
import { capitalize } from "tsafe/capitalize";
1111
import { uncapitalize } from "tsafe/uncapitalize";
12+
import { typeGuard } from "tsafe/typeGuard";
13+
import { overwriteReadonlyProp } from "tsafe/lab/overwriteReadonlyProp";
1214

1315
export type ModalProps = {
1416
className?: string;
@@ -32,7 +34,12 @@ export namespace ModalProps {
3234
doClosesModal?: boolean;
3335
};
3436

35-
export type ModalButtonProps = Pick<ButtonProps, "onClick" | "nativeButtonProps">;
37+
export type ModalButtonProps = {
38+
"nativeButtonProps": {
39+
"aria-controls": string;
40+
"data-fr-opened": boolean;
41+
};
42+
};
3643
}
3744

3845
const Modal = memo(
@@ -190,38 +197,28 @@ addModalTranslations({
190197

191198
export { addModalTranslations };
192199

193-
function createOpenModalButtonProps(params: {
194-
modalId: string;
195-
isOpenedByDefault: boolean;
196-
}): ModalProps.ModalButtonProps {
197-
const { modalId, isOpenedByDefault } = params;
198-
199-
return {
200-
//For RSC we don't want to pass an empty function.
201-
"onClick": undefined as any as () => void,
202-
"nativeButtonProps": {
203-
"aria-controls": modalId,
204-
"data-fr-opened": isOpenedByDefault
205-
}
206-
};
207-
}
208-
209200
let counter = 0;
210201

211202
/** @see <https://react-dsfr-components.etalab.studio/?path=/docs/components-modal> */
212203
export function createModal<Name extends string>(params: {
213204
name: Name;
214205
isOpenedByDefault: boolean;
215206
}): Record<`${Uncapitalize<Name>}ModalButtonProps`, ModalProps.ModalButtonProps> &
216-
Record<`${Capitalize<Name>}Modal`, (props: ModalProps) => JSX.Element> {
207+
Record<`${Capitalize<Name>}Modal`, (props: ModalProps) => JSX.Element> &
208+
Record<`close${Capitalize<Name>}Modal`, () => void> &
209+
Record<`open${Capitalize<Name>}Modal`, () => void> {
217210
const { name, isOpenedByDefault } = params;
218211

219212
const modalId = `${uncapitalize(name)}-modal-${counter++}`;
220213

221-
const openModalButtonProps = createOpenModalButtonProps({
222-
modalId,
223-
isOpenedByDefault
224-
});
214+
const openModalButtonProps: ModalProps.ModalButtonProps = {
215+
"nativeButtonProps": {
216+
"aria-controls": modalId,
217+
"data-fr-opened": isOpenedByDefault
218+
}
219+
};
220+
221+
const hiddenControlButtonId = `${modalId}-hidden-control-button`;
225222

226223
function InternalModal(props: ModalProps) {
227224
return (
@@ -238,10 +235,41 @@ export function createModal<Name extends string>(params: {
238235

239236
InternalModal.displayName = `${capitalize(name)}Modal`;
240237

241-
Object.defineProperty(InternalModal, "name", { "value": InternalModal.displayName });
238+
overwriteReadonlyProp(InternalModal as any, "name", InternalModal.displayName);
239+
240+
function openModal() {
241+
const hiddenControlButton = document.getElementById(hiddenControlButtonId);
242+
243+
assert(hiddenControlButton !== null, "Modal isn't mounted");
244+
245+
hiddenControlButton.click();
246+
}
247+
248+
overwriteReadonlyProp(openModal as any, "name", `open${capitalize(name)}Modal`);
249+
250+
function closeModal() {
251+
const modalElement = document.getElementById(modalId);
252+
253+
assert(modalElement !== null, "Modal isn't mounted");
254+
255+
const closeButtonElement = modalElement.querySelector(`.${fr.cx("fr-btn--close")}`);
256+
257+
assert(closeButtonElement !== null);
258+
259+
assert(
260+
typeGuard<HTMLButtonElement>(closeButtonElement, "click" in closeButtonElement),
261+
"Close button isn't a button"
262+
);
263+
264+
closeButtonElement.click();
265+
}
266+
267+
overwriteReadonlyProp(closeModal as any, "name", `close${capitalize(name)}Modal`);
242268

243269
return {
244270
[InternalModal.displayName]: InternalModal,
245-
[`${uncapitalize(name)}ModalButtonProps`]: openModalButtonProps
271+
[`${uncapitalize(name)}ModalButtonProps`]: openModalButtonProps,
272+
[openModal.name]: openModal,
273+
[closeModal.name]: closeModal
246274
} as any;
247275
}

stories/Modal.stories.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,26 @@ of \`name\` key of the \`options\` param object :
3232
- \`\${camelCasePrefix}ModalButtonProps\`: The props object for \`Button\` DSFR component
3333
3434
**Eg.:**
35-
\`\`\`ts
36-
const { MyComponentModal, myComponentModalButtonProps } = createModal({
37-
name: "myComponent", // The name of Modal component and modalButtonProps is compute from this string
35+
\`\`\`tsx
36+
import { createModal } from "@codegouvfr/react-dsfr/Modal";
37+
import { Button } from "@codegouvfr/react-dsfr/Button";
38+
39+
const { FooModal, fooModalButtonProps, openFooModal, closeFooModal } = createModal({
40+
name: "foo", // The name of Modal component and modalButtonProps is compute from this string
3841
isOpenedByDefault: false
3942
});
43+
44+
45+
const node = (
46+
<>
47+
{/* ... */}
48+
<FooModal title="foo modal title"/>
49+
<Button {...fooModalButtonProps}>Open foo modal</Button>
50+
<Button onClick={openFooModal}>Open foo modal</Button>
51+
<Button onClick={closeFooModal}>Close foo modal</Button>
52+
</>
53+
);
54+
4055
\`\`\`
4156
4257
## The Modal component

0 commit comments

Comments
 (0)