Skip to content

Commit 1b48270

Browse files
authored
Merge pull request #224 from codegouvfr/feat/range-and-segmented
feat: SegmentedControl and Range components
2 parents 5cdd854 + 98a2757 commit 1b48270

File tree

5 files changed

+692
-0
lines changed

5 files changed

+692
-0
lines changed

src/Range.tsx

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
"use client";
2+
3+
import React, {
4+
type CSSProperties,
5+
forwardRef,
6+
memo,
7+
type ReactNode,
8+
type ComponentProps
9+
} from "react";
10+
import { assert, type Equals } from "tsafe";
11+
import { symToStr } from "tsafe/symToStr";
12+
import { type CxArg } from "tss-react";
13+
import { fr } from "./fr";
14+
import { cx } from "./tools/cx";
15+
import { useAnalyticsId } from "./tools/useAnalyticsId";
16+
17+
export type RangeProps = {
18+
id?: string;
19+
className?: string;
20+
classes?: Partial<
21+
Record<
22+
| "root"
23+
| "label"
24+
| "messagesGroup"
25+
| "message"
26+
| "hintText"
27+
| "rangeWrapper"
28+
| "output"
29+
| "input"
30+
| "input2"
31+
| "min"
32+
| "max",
33+
CxArg
34+
>
35+
>;
36+
style?: CSSProperties;
37+
small?: boolean;
38+
label: ReactNode;
39+
hintText?: ReactNode;
40+
/** default: false */
41+
min: number;
42+
max: number;
43+
/** default: false */
44+
hideMinMax?: boolean;
45+
step?: number;
46+
prefix?: string;
47+
suffix?: string;
48+
/** default: false */
49+
disabled?: boolean;
50+
/** Default: "default" */
51+
state?: "success" | "error" | "default";
52+
/** The message won't be displayed if state is "default" */
53+
stateRelatedMessage?: ReactNode;
54+
} & (RangeProps.AsSingle | RangeProps.AsDouble);
55+
56+
//https://main--ds-gouv.netlify.app/example/component/range/
57+
export namespace RangeProps {
58+
type NativeInputProps = ComponentProps<"input">;
59+
60+
export type AsSingle = {
61+
double?: never;
62+
nativeInputProps?: NativeInputProps;
63+
};
64+
65+
export type AsDouble = {
66+
double: true;
67+
nativeInputProps?: [NativeInputProps?, NativeInputProps?];
68+
};
69+
}
70+
71+
// const DoubleRange = (props: Pick<RangeProps, "min" | "max" | "nativeInputProps" | "step">) => {};
72+
73+
/** @see <https://components.react-dsfr.codegouv.studio/?path=/docs/components-segmented-control> */
74+
export const Range = memo(
75+
forwardRef<HTMLDivElement, RangeProps>((props, ref) => {
76+
const {
77+
id: props_id,
78+
className,
79+
classes = {},
80+
disabled = false,
81+
double,
82+
hideMinMax = false,
83+
hintText,
84+
label,
85+
max,
86+
min,
87+
nativeInputProps,
88+
prefix,
89+
small = false,
90+
state = "default",
91+
stateRelatedMessage,
92+
step,
93+
style,
94+
suffix,
95+
...rest
96+
} = props;
97+
98+
assert<Equals<keyof typeof rest, never>>();
99+
100+
if (min > max) {
101+
throw new Error(`min must be lower than max`);
102+
}
103+
104+
const id = useAnalyticsId({
105+
"defaultIdPrefix": `fr-range`,
106+
"explicitlyProvidedId": props_id
107+
});
108+
109+
const labelId = `${id}-label`;
110+
111+
const errorMessageId = `${id}-message-error`;
112+
const successMessageId = `${id}-message-valid`;
113+
const messagesWrapperId = `${id}-messages`;
114+
115+
return (
116+
<div
117+
className={cx(
118+
fr.cx(
119+
"fr-range-group",
120+
disabled && "fr-range-group--disabled",
121+
state === "error" && "fr-range-group--error",
122+
state === "success" && "fr-range-group--valid"
123+
),
124+
classes.root,
125+
className
126+
)}
127+
style={style}
128+
ref={ref}
129+
id={`${id}-group`}
130+
{...rest}
131+
>
132+
<label className={cx(fr.cx("fr-label"), classes.label)} id={labelId}>
133+
{label}
134+
{hintText !== undefined && (
135+
<span className={cx(fr.cx("fr-hint-text"), classes.hintText)}>
136+
{hintText}
137+
</span>
138+
)}
139+
</label>
140+
<div
141+
className={cx(
142+
fr.cx(
143+
"fr-range",
144+
small && "fr-range--sm",
145+
double && "fr-range--double",
146+
step !== undefined && "fr-range--step"
147+
),
148+
classes.rangeWrapper
149+
)}
150+
data-fr-prefix={prefix}
151+
data-fr-suffix={suffix}
152+
>
153+
<span className={cx(fr.cx("fr-range__output"), classes.output)}></span>
154+
{(() => {
155+
const partialInputProps = {
156+
type: "range",
157+
id,
158+
name: id,
159+
min,
160+
max,
161+
step,
162+
disabled,
163+
"aria-labelledby": labelId,
164+
"aria-describedby": messagesWrapperId,
165+
"aria-invalid": state === "error"
166+
};
167+
168+
if (double) {
169+
const inputProps1 = nativeInputProps?.[0] ?? {};
170+
const inputProps2 = nativeInputProps?.[1] ?? {};
171+
172+
return (
173+
<>
174+
<input {...inputProps1} {...partialInputProps} />
175+
<input
176+
{...inputProps2}
177+
{...partialInputProps}
178+
id={`${id}-2`}
179+
name={`${id}-2`}
180+
/>
181+
</>
182+
);
183+
}
184+
185+
const inputProps = nativeInputProps ?? {};
186+
return <input {...inputProps} {...partialInputProps} />;
187+
})()}
188+
{!hideMinMax && (
189+
<>
190+
<span className={cx(fr.cx("fr-range__min"), classes.min)} aria-hidden>
191+
{min}
192+
</span>
193+
<span className={cx(fr.cx("fr-range__max"), classes.max)} aria-hidden>
194+
{max}
195+
</span>
196+
</>
197+
)}
198+
</div>
199+
<div
200+
className={cx(fr.cx("fr-messages-group"), classes.messagesGroup)}
201+
id={messagesWrapperId}
202+
aria-live="polite"
203+
>
204+
<p
205+
id={cx({
206+
[errorMessageId]: state === "error",
207+
[successMessageId]: state === "success"
208+
})}
209+
className={cx(
210+
fr.cx("fr-message", {
211+
"fr-message--error": state === "error",
212+
"fr-message--valid": state === "success"
213+
}),
214+
classes.message
215+
)}
216+
>
217+
{stateRelatedMessage}
218+
</p>
219+
</div>
220+
</div>
221+
);
222+
})
223+
);
224+
225+
Range.displayName = symToStr({ Range });
226+
227+
export default Range;

0 commit comments

Comments
 (0)