1 | import React, {
|
2 | useId,
|
3 | memo,
|
4 | forwardRef,
|
5 | type ReactNode,
|
6 | type CSSProperties,
|
7 | type ComponentProps
|
8 | } from "react";
|
9 | import { symToStr } from "tsafe/symToStr";
|
10 | import { assert } from "tsafe/assert";
|
11 | import type { Equals } from "tsafe";
|
12 | import { cx } from "../tools/cx";
|
13 | import { fr } from "../fr";
|
14 | import { useAnalyticsId } from "../tools/useAnalyticsId";
|
15 |
|
16 | export type FieldsetProps = FieldsetProps.Radio | FieldsetProps.Checkbox;
|
17 |
|
18 | export 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 |
|
33 | orientation?: "vertical" | "horizontal";
|
34 |
|
35 | state?: "success" | "error" | "default";
|
36 | |
37 |
|
38 |
|
39 |
|
40 | stateRelatedMessage?: ReactNode;
|
41 |
|
42 | disabled?: boolean;
|
43 |
|
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 |
|
62 | export 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 |
|
220 | Fieldset.displayName = symToStr({ Fieldset });
|
221 |
|
222 | export default Fieldset;
|
223 |
|
\ | No newline at end of file |