Skip to content

Commit 0095f91

Browse files
sylvainlggarronej
andauthored
WIP feat: ✨ Upload component (#136)
* feat: ✨ Upload component * fixup * fixup * Update src/Upload.tsx Signed-off-by: Joseph Garrone <joseph.garrone.gj@gmail.com> --------- Signed-off-by: Joseph Garrone <joseph.garrone.gj@gmail.com> Co-authored-by: Joseph Garrone <joseph.garrone.gj@gmail.com>
1 parent b63f585 commit 0095f91

File tree

2 files changed

+204
-0
lines changed

2 files changed

+204
-0
lines changed

src/Upload.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import React, {
2+
DetailedHTMLProps,
3+
InputHTMLAttributes,
4+
ReactNode,
5+
forwardRef,
6+
memo,
7+
useId
8+
} from "react";
9+
import { createComponentI18nApi } from "./i18n";
10+
import { symToStr } from "tsafe/symToStr";
11+
import { cx } from "./tools/cx";
12+
import { fr } from "./fr";
13+
import { Equals, assert } from "tsafe";
14+
15+
export type UploadProps = {
16+
className?: string;
17+
/** @default false */
18+
disabled?: boolean;
19+
hint?: string;
20+
/** @default false */
21+
multiple?: boolean;
22+
/** @default "default" */
23+
state?: "success" | "error" | "default";
24+
/** The message won't be displayed if state is "default" */
25+
stateRelatedMessage?: ReactNode;
26+
/** Props forwarded to the underlying <input /> element */
27+
nativeInputProps?: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>;
28+
};
29+
30+
export const Upload = memo(
31+
forwardRef<HTMLDivElement, UploadProps>((props, ref) => {
32+
const { t } = useTranslation();
33+
const {
34+
className,
35+
disabled = false,
36+
hint = t("hint"),
37+
multiple = false,
38+
state = "default",
39+
stateRelatedMessage,
40+
nativeInputProps = {}
41+
} = props;
42+
43+
const inputId = (function useClosure() {
44+
const id = useId();
45+
46+
return nativeInputProps.id ?? `input-${id}`;
47+
})();
48+
49+
const messageId = `${inputId}-desc-error`;
50+
return (
51+
<div
52+
className={cx(
53+
fr.cx(
54+
"fr-upload-group",
55+
disabled && "fr-input-group--disabled",
56+
(() => {
57+
switch (state) {
58+
case "error":
59+
return "fr-input-group--error";
60+
case "success":
61+
return "fr-input-group--valid";
62+
case "default":
63+
return undefined;
64+
}
65+
assert<Equals<typeof state, never>>(false);
66+
})()
67+
),
68+
className
69+
)}
70+
ref={ref}
71+
>
72+
<label className={fr.cx("fr-label")} aria-disabled={disabled} htmlFor={inputId}>
73+
{t("add_files")}
74+
<span className={fr.cx("fr-hint-text")}>{hint}</span>
75+
</label>
76+
<input
77+
aria-describedby={messageId}
78+
aria-disabled={disabled}
79+
className={cx(fr.cx("fr-upload"))}
80+
disabled={disabled}
81+
id={inputId}
82+
multiple={multiple}
83+
name={inputId}
84+
type="file"
85+
/>
86+
{state !== "default" && (
87+
<p
88+
id={messageId}
89+
className={cx(
90+
fr.cx(
91+
(() => {
92+
switch (state) {
93+
case "error":
94+
return "fr-error-text";
95+
case "success":
96+
return "fr-valid-text";
97+
}
98+
assert<Equals<typeof state, never>>(false);
99+
})()
100+
)
101+
)}
102+
>
103+
{stateRelatedMessage}
104+
</p>
105+
)}
106+
</div>
107+
);
108+
})
109+
);
110+
111+
Upload.displayName = symToStr({ Upload });
112+
113+
const { useTranslation, addUploadTranslations } = createComponentI18nApi({
114+
"componentName": symToStr({ Upload }),
115+
"frMessages": {
116+
/* spell-checker: disable */
117+
"add_files": "Ajouter des fichiers",
118+
"hint": "Taille maximale : 500 Mo. Formats supportés : jpg, png, pdf. Plusieurs fichiers possibles."
119+
/* spell-checker: enable */
120+
}
121+
});
122+
123+
addUploadTranslations({
124+
lang: "en",
125+
messages: {
126+
"add_files": "Add files",
127+
"hint": "Maximum size : 500 Mo. Supported formats : jpg, png, pdf. Many files possible."
128+
}
129+
});

stories/Upload.stories.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Upload, type UploadProps } from "../dist/Upload";
2+
import { sectionName } from "./sectionName";
3+
import { getStoryFactory } from "./getStory";
4+
import { assert } from "tsafe/assert";
5+
import type { Equals } from "tsafe";
6+
7+
const { meta, getStory } = getStoryFactory({
8+
sectionName,
9+
"wrappedComponent": { Upload },
10+
"description": `
11+
- [See DSFR documentation](https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/ajout-de-fichier)
12+
- [See source code](https://github.com/codegouvfr/react-dsfr/blob/main/src/Upload.tsx)`,
13+
"argTypes": {
14+
"disabled": {
15+
"control": { "type": "boolean" }
16+
},
17+
"state": {
18+
"options": (() => {
19+
const options = ["default", "success", "error"] as const;
20+
21+
assert<Equals<typeof options[number] | undefined, UploadProps["state"]>>();
22+
23+
return options;
24+
})(),
25+
"control": { "type": "radio" }
26+
},
27+
"stateRelatedMessage": {
28+
"description": `This message is only displayed when \`state\` is \`success\` or \`error\`.
29+
If state is \`success\` or \`error\` this message is mandatory.`
30+
},
31+
"nativeInputProps": {
32+
"description": `An object that is forwarded as props to te underlying native \`<input />\` element.
33+
This is where you pass the \`name\` prop or \`onChange\` for example.`,
34+
"control": { "type": null }
35+
},
36+
"multiple": {
37+
"description": "Multiple files",
38+
"control": { "type": "boolean" }
39+
}
40+
},
41+
"disabledProps": ["lang"]
42+
});
43+
44+
export default meta;
45+
46+
export const Default = getStory({
47+
"hint": "Texte de description",
48+
"state": "default",
49+
"stateRelatedMessage": "Text de validation / d'explication de l'erreur",
50+
"multiple": false
51+
});
52+
53+
export const Basic = getStory({});
54+
55+
export const WithErrorMessage = getStory({
56+
"state": "error",
57+
"stateRelatedMessage": "Texte d’erreur obligatoire"
58+
});
59+
60+
export const WithSuccessMessage = getStory({
61+
"state": "success",
62+
"stateRelatedMessage": "Texte de validation"
63+
});
64+
65+
export const Disabled = getStory({
66+
"disabled": true
67+
});
68+
69+
export const WithHint = getStory({
70+
"hint": "Texte de description additionnel"
71+
});
72+
73+
export const Multiple = getStory({
74+
"multiple": true
75+
});

0 commit comments

Comments
 (0)