Skip to content

Commit 9e88927

Browse files
committed
First draft of Input
1 parent 05b94f7 commit 9e88927

File tree

2 files changed

+243
-0
lines changed

2 files changed

+243
-0
lines changed

src/Input.tsx

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import React, { memo, forwardRef, ReactNode, useId } from "react";
2+
import type { InputHTMLAttributes, TextareaHTMLAttributes } from "react";
3+
import { symToStr } from "tsafe/symToStr";
4+
import { assert } from "tsafe/assert";
5+
import type { Equals } from "tsafe";
6+
import { fr } from "./fr";
7+
import { cx } from "./tools/cx";
8+
import type { FrIconClassName, RiIconClassName } from "./fr/generatedFromCss/classNames";
9+
10+
export type InputProps = InputProps.Input | InputProps.TextArea;
11+
12+
export namespace InputProps {
13+
export type Common = {
14+
className?: string;
15+
label: ReactNode;
16+
hintText?: ReactNode;
17+
/** default: false */
18+
disabled?: boolean;
19+
message?: {
20+
type: "success" | "error";
21+
text: ReactNode;
22+
};
23+
iconId?: FrIconClassName | RiIconClassName;
24+
classes?: Partial<
25+
Record<"root" | "label" | "description" | "nativeInputOrTextArea" | "message", string>
26+
>;
27+
};
28+
29+
export type Input = Common & {
30+
/** Default: false */
31+
isTextArea?: false;
32+
/** Props forwarded to the underlying <input /> element */
33+
nativeInputProps?: InputHTMLAttributes<HTMLInputElement>;
34+
nativeTextAreaProps?: never;
35+
};
36+
37+
export type TextArea = Common & {
38+
/** Default: false */
39+
isTextArea: true;
40+
nativeInputProps?: never;
41+
/** Props forwarded to the underlying <textarea /> element */
42+
nativeTextAreaProps?: TextareaHTMLAttributes<HTMLTextAreaElement>;
43+
};
44+
}
45+
46+
/**
47+
* @see <https://react-dsfr-components.etalab.studio/?path=/docs/components-highlight>
48+
* */
49+
export const Input = memo(
50+
forwardRef<HTMLDivElement, InputProps>((props, ref) => {
51+
const {
52+
className,
53+
label,
54+
hintText,
55+
disabled = false,
56+
message,
57+
iconId: iconId_props,
58+
isTextArea = false,
59+
nativeInputProps = {},
60+
nativeTextAreaProps = {},
61+
classes = {},
62+
...rest
63+
} = props;
64+
65+
const nativeInputOrTextAreaProps = isTextArea ? nativeTextAreaProps : nativeInputProps;
66+
67+
const NativeInputOrTexArea = isTextArea ? "textarea" : "input";
68+
69+
assert<Equals<keyof typeof rest, never>>();
70+
71+
const inputId = (function useClosure() {
72+
const id = useId();
73+
74+
return nativeInputOrTextAreaProps.id ?? `input-${id}`;
75+
})();
76+
77+
const messageId = `${inputId}-desc-error`;
78+
79+
return (
80+
<div
81+
className={cx(
82+
fr.cx(
83+
"fr-input-group",
84+
disabled && "fr-input-group--disabled",
85+
message !== undefined &&
86+
(() => {
87+
switch (message.type) {
88+
case "error":
89+
return "fr-input-group--error";
90+
case "success":
91+
return "fr-input-group--valid";
92+
}
93+
assert<Equals<typeof message.type, never>>(false);
94+
})()
95+
),
96+
classes.root,
97+
className
98+
)}
99+
ref={ref}
100+
{...rest}
101+
>
102+
<label className={cx(fr.cx("fr-label"), classes.label)} htmlFor={inputId}>
103+
{label}
104+
{hintText !== undefined && <span className="fr-hint-text">{hintText}</span>}
105+
</label>
106+
{(() => {
107+
const nativeInputOrTextArea = (
108+
<NativeInputOrTexArea
109+
{...(nativeInputOrTextAreaProps as {})}
110+
className={cx(
111+
fr.cx(
112+
"fr-input",
113+
message !== undefined &&
114+
(() => {
115+
switch (message.type) {
116+
case "error":
117+
return "fr-input--error";
118+
case "success":
119+
return "fr-input--valid";
120+
}
121+
assert<Equals<typeof message.type, never>>(false);
122+
})()
123+
),
124+
classes.nativeInputOrTextArea
125+
)}
126+
disabled={disabled || undefined}
127+
aria-describedby={messageId}
128+
type={isTextArea ? undefined : nativeInputProps.type ?? "text"}
129+
id={inputId}
130+
/>
131+
);
132+
133+
const iconId =
134+
iconId_props ??
135+
(!isTextArea && nativeInputProps.type === "date"
136+
? "ri-calendar-line"
137+
: undefined);
138+
139+
return iconId === undefined ? (
140+
nativeInputOrTextArea
141+
) : (
142+
<div className={fr.cx("fr-input-wrap", iconId)}>
143+
{nativeInputOrTextArea}
144+
</div>
145+
);
146+
})()}
147+
{message !== undefined && (
148+
<p id={messageId} className={cx(fr.cx("fr-error-text"), classes.message)}>
149+
{message.text}
150+
</p>
151+
)}
152+
</div>
153+
);
154+
})
155+
);
156+
157+
Input.displayName = symToStr({ Input });
158+
159+
export default Input;

stories/Input.stories.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { Input } from "../dist/Input";
2+
import { sectionName } from "./sectionName";
3+
import { getStoryFactory } from "./getStory";
4+
5+
const { meta, getStory } = getStoryFactory({
6+
sectionName,
7+
"wrappedComponent": { Input },
8+
"description": `
9+
- [See DSFR documentation](https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/champ-de-saisie)
10+
- [See source code](https://github.com/codegouvfr/react-dsfr/blob/main/src/Input.tsx)`,
11+
"disabledProps": ["lang"]
12+
});
13+
14+
export default meta;
15+
16+
export const Default = getStory({
17+
"label": "Label champ de saisie"
18+
});
19+
20+
export const WithErrorMessage = getStory({
21+
"label": "Label champs de saisie",
22+
"message": {
23+
"type": "error",
24+
"text": "Texte d’erreur obligatoire"
25+
}
26+
});
27+
28+
export const WithSuccessMessage = getStory({
29+
"label": "Label champs de saisie",
30+
"message": {
31+
"type": "success",
32+
"text": "Texte de validation"
33+
}
34+
});
35+
36+
export const Disabled = getStory({
37+
"label": "Label champs de saisie",
38+
"disabled": true
39+
});
40+
41+
export const WithHint = getStory({
42+
"label": "Label champs de saisie",
43+
"hintText": "Texte de description additionnel"
44+
});
45+
46+
export const TextArea = getStory({
47+
"label": "Label champs de saisie",
48+
"isTextArea": true
49+
});
50+
51+
export const WithIcon = getStory({
52+
"label": "Label champs de saisie",
53+
"iconId": "fr-icon-alert-line"
54+
});
55+
56+
export const Date = getStory(
57+
{
58+
"label": "Label champs de saisie",
59+
"nativeInputProps": {
60+
"type": "date"
61+
}
62+
},
63+
{
64+
"description":
65+
"The correct icon is applied automatically if nativeInputProps type is `date`"
66+
}
67+
);
68+
69+
export const InputTypeNumber = getStory({
70+
"label": "Label champs de saisie",
71+
"nativeInputProps": {
72+
"pattern": "[0-9]*",
73+
"inputMode": "numeric",
74+
"type": "number"
75+
}
76+
});
77+
78+
export const WithPlaceholder = getStory({
79+
"label": "Url du site :",
80+
"hintText": "Saisissez une url valide, commençant par https://",
81+
"nativeInputProps": {
82+
"placeholder": "https://"
83+
}
84+
});

0 commit comments

Comments
 (0)