UNPKG

7.87 kBTypeScriptView Raw
1import React, {
2 useId,
3 memo,
4 forwardRef,
5 type ReactNode,
6 type CSSProperties,
7 type ComponentProps
8} from "react";
9import { symToStr } from "tsafe/symToStr";
10import { assert } from "tsafe/assert";
11import type { Equals } from "tsafe";
12import { cx } from "../tools/cx";
13import { fr } from "../fr";
14import { useAnalyticsId } from "../tools/useAnalyticsId";
15
16export type FieldsetProps = FieldsetProps.Radio | FieldsetProps.Checkbox;
17
18export namespace FieldsetProps {
19 export type Common = {
20 className?: string;
21 id?: string;
22 classes?: Partial<Record<"root" | "legend" | "content" | "inputGroup", string>>;
23 style?: CSSProperties;
24 legend?: ReactNode;
25 hintText?: ReactNode;
26 options: {
27 label: ReactNode;
28 hintText?: ReactNode;
29 nativeInputProps: ComponentProps<"input">;
30 }[];
31
32 /** Default: "vertical" */
33 orientation?: "vertical" | "horizontal";
34 /** Default: "default" */
35 state?: "success" | "error" | "default";
36 /**
37 * The message won't be displayed if state is "default".
38 * If the state is "error" providing a message is mandatory
39 **/
40 stateRelatedMessage?: ReactNode;
41 /** Default: false */
42 disabled?: boolean;
43 /** default: false */
44 small?: boolean;
45 };
46
47 export type Radio = Omit<Common, "options"> & {
48 type: "radio";
49 name?: string;
50 options: (Common["options"][number] & {
51 illustration?: ReactNode;
52 })[];
53 };
54
55 export type Checkbox = Common & {
56 type: "checkbox";
57 name?: never;
58 };
59}
60
61/** @see <https://components.react-dsfr.codegouv.studio/?path=/docs/components-radiobutton> */
62export const Fieldset = memo(
63 forwardRef<HTMLFieldSetElement, FieldsetProps>((props, ref) => {
64 const {
65 className,
66 id: id_props,
67 classes = {},
68 style,
69 legend,
70 hintText,
71 options,
72 orientation = "vertical",
73 state = "default",
74 stateRelatedMessage,
75 disabled = false,
76 type,
77 name: name_props,
78 small = false,
79 ...rest
80 } = props;
81
82 const isRichRadio =
83 type === "radio" &&
84 options.find(options => options.illustration !== undefined) !== undefined;
85
86 assert<Equals<keyof typeof rest, never>>();
87
88 const id = useAnalyticsId({
89 "defaultIdPrefix": `fr-fieldset-${type}${
90 name_props === undefined ? "" : `-${name_props}`
91 }`,
92 "explicitlyProvidedId": id_props
93 });
94
95 const getInputId = (i: number) => `${id}-${i}`;
96
97 const legendId = `${id}-legend`;
98
99 const errorDescId = `${id}-desc-error`;
100 const successDescId = `${id}-desc-valid`;
101 const messagesWrapperId = `${id}-messages`;
102
103 const radioName = (function useClosure() {
104 const id = useId();
105
106 return name_props ?? `radio-name-${id}`;
107 })();
108
109 return (
110 <fieldset
111 id={id}
112 className={cx(
113 fr.cx(
114 "fr-fieldset",
115 orientation === "horizontal" && "fr-fieldset--inline",
116 (() => {
117 switch (state) {
118 case "default":
119 return undefined;
120 case "error":
121 return "fr-fieldset--error";
122 case "success":
123 return "fr-fieldset--valid";
124 }
125 })()
126 ),
127 classes.root,
128 className
129 )}
130 disabled={disabled}
131 style={style}
132 aria-labelledby={cx(legend !== undefined && legendId, messagesWrapperId)}
133 role={state === "default" ? undefined : "group"}
134 {...rest}
135 ref={ref}
136 >
137 {legend !== undefined && (
138 <legend
139 id={legendId}
140 className={cx(
141 fr.cx("fr-fieldset__legend", "fr-text--regular"),
142 classes.legend
143 )}
144 >
145 {legend}
146 {hintText !== undefined && (
147 <span className={fr.cx("fr-hint-text")}>{hintText}</span>
148 )}
149 </legend>
150 )}
151 <div className={cx(fr.cx("fr-fieldset__content"), classes.content)}>
152 {options.map(({ label, hintText, nativeInputProps, ...rest }, i) => (
153 <div
154 className={cx(
155 fr.cx(
156 `fr-${type}-group`,
157 isRichRadio && "fr-radio-rich",
158 small && `fr-${type}-group--sm`
159 ),
160 classes.inputGroup
161 )}
162 key={i}
163 >
164 <input
165 type={type}
166 id={getInputId(i)}
167 name={radioName}
168 {...nativeInputProps}
169 />
170 <label className={fr.cx("fr-label")} htmlFor={getInputId(i)}>
171 {label}
172 {hintText !== undefined && (
173 <span className={fr.cx("fr-hint-text")}>{hintText}</span>
174 )}
175 </label>
176 {"illustration" in rest && (
177 <div className={fr.cx("fr-radio-rich__img")}>
178 {rest.illustration}
179 </div>
180 )}
181 </div>
182 ))}
183 </div>
184 <div
185 className={fr.cx("fr-messages-group")}
186 id={messagesWrapperId}
187 aria-live="assertive"
188 >
189 {stateRelatedMessage !== undefined && (
190 <p
191 id={(() => {
192 switch (state) {
193 case "error":
194 return errorDescId;
195 case "success":
196 return successDescId;
197 }
198 })()}
199 className={fr.cx(
200 "fr-message",
201 (() => {
202 switch (state) {
203 case "error":
204 return "fr-message--error";
205 case "success":
206 return "fr-message--valid";
207 }
208 })()
209 )}
210 >
211 {stateRelatedMessage}
212 </p>
213 )}
214 </div>
215 </fieldset>
216 );
217 })
218);
219
220Fieldset.displayName = symToStr({ Fieldset });
221
222export default Fieldset;
223
\No newline at end of file