Skip to content

Commit 6e13602

Browse files
committed
feat: finish range and segmented
1 parent bc3689f commit 6e13602

File tree

5 files changed

+528
-4
lines changed

5 files changed

+528
-4
lines changed

src/Range.tsx

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

src/SegmentedControl.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,30 +19,46 @@ export type SegmentedControlProps = {
1919
className?: string;
2020
name?: string;
2121
classes?: Partial<
22-
Record<"root" | "legend" | "elements" | "element-each" | "element-each__label", CxArg>
22+
Record<
23+
"root" | "legend" | "hintText" | "elements" | "element-each" | "element-each__label",
24+
CxArg
25+
>
2326
>;
2427
style?: CSSProperties;
28+
/** default: false */
2529
small?: boolean;
26-
legend?: ReactNode;
2730
/**
2831
* Minimum 1, Maximum 5.
2932
*
3033
* All with icon or all without icon.
3134
*/
3235
segments: SegmentedControlProps.Segments;
33-
} & (SegmentedControlProps.WithInlineLegend | SegmentedControlProps.WithHiddenLegend);
36+
} & (
37+
| SegmentedControlProps.WithLegend
38+
| SegmentedControlProps.WithInlineLegend
39+
| SegmentedControlProps.WithHiddenLegend
40+
);
3441

3542
//https://main--ds-gouv.netlify.app/example/component/segmented/
3643
export namespace SegmentedControlProps {
44+
export type WithLegend = {
45+
inlineLegend?: boolean;
46+
legend: ReactNode;
47+
hintText?: ReactNode;
48+
hideLegend?: boolean;
49+
};
50+
3751
export type WithInlineLegend = {
3852
inlineLegend: true;
3953
legend: ReactNode;
54+
hintText?: ReactNode;
4055
hideLegend?: never;
4156
};
4257

4358
export type WithHiddenLegend = {
4459
inlineLegend?: never;
4560
legend?: ReactNode;
61+
hintText?: never;
4662
hideLegend: true;
4763
};
4864

@@ -83,11 +99,12 @@ export const SegmentedControl = memo(
8399
className,
84100
classes = {},
85101
style,
86-
small,
102+
small = false,
87103
segments,
88104
hideLegend,
89105
inlineLegend,
90106
legend,
107+
hintText,
91108
...rest
92109
} = props;
93110

@@ -133,6 +150,11 @@ export const SegmentedControl = memo(
133150
)}
134151
>
135152
{legend}
153+
{hintText !== undefined && (
154+
<span className={cx(fr.cx("fr-hint-text"), classes.hintText)}>
155+
{hintText}
156+
</span>
157+
)}
136158
</legend>
137159
)}
138160
<div className={cx(fr.cx("fr-segmented__elements"), classes.elements)}>

0 commit comments

Comments
 (0)