Skip to content

Commit bc3689f

Browse files
committed
feat: SegmentedControl component
1 parent 1666338 commit bc3689f

File tree

1 file changed

+142
-25
lines changed

1 file changed

+142
-25
lines changed

src/SegmentedControl.tsx

Lines changed: 142 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1-
import { CSSProperties, forwardRef, memo } from "react";
1+
import {
2+
CSSProperties,
3+
DetailedHTMLProps,
4+
forwardRef,
5+
InputHTMLAttributes,
6+
memo,
7+
ReactNode,
8+
useId
9+
} from "react";
210
import { assert, Equals } from "tsafe";
3-
import { fr } from "./fr";
11+
import { fr, FrIconClassName, RiIconClassName } from "./fr";
412
import React from "react";
513
import { CxArg } from "tss-react";
614
import { cx } from "./tools/cx";
@@ -9,51 +17,160 @@ import { useAnalyticsId } from "./tools/useAnalyticsId";
917
export type SegmentedControlProps = {
1018
id?: string;
1119
className?: string;
20+
name?: string;
1221
classes?: Partial<
13-
Record<
14-
| "root"
15-
| "container"
16-
| "row"
17-
| "newsletter-col"
18-
| "newsletter"
19-
| "newsletter-title"
20-
| "newsletter-desc"
21-
| "newsletter-form-wrapper"
22-
| "newsletter-form-hint"
23-
| "social-col"
24-
| "social"
25-
| "social-title"
26-
| "social-buttons"
27-
| "social-buttons-each",
28-
CxArg
29-
>
22+
Record<"root" | "legend" | "elements" | "element-each" | "element-each__label", CxArg>
3023
>;
3124
style?: CSSProperties;
32-
};
25+
small?: boolean;
26+
legend?: ReactNode;
27+
/**
28+
* Minimum 1, Maximum 5.
29+
*
30+
* All with icon or all without icon.
31+
*/
32+
segments: SegmentedControlProps.Segments;
33+
} & (SegmentedControlProps.WithInlineLegend | SegmentedControlProps.WithHiddenLegend);
3334

3435
//https://main--ds-gouv.netlify.app/example/component/segmented/
35-
export namespace SegmentedControlProps {}
36+
export namespace SegmentedControlProps {
37+
export type WithInlineLegend = {
38+
inlineLegend: true;
39+
legend: ReactNode;
40+
hideLegend?: never;
41+
};
42+
43+
export type WithHiddenLegend = {
44+
inlineLegend?: never;
45+
legend?: ReactNode;
46+
hideLegend: true;
47+
};
48+
49+
export type Segment = {
50+
label: ReactNode;
51+
nativeInputProps?: DetailedHTMLProps<
52+
InputHTMLAttributes<HTMLInputElement>,
53+
HTMLInputElement
54+
>;
55+
iconId?: FrIconClassName | RiIconClassName;
56+
};
57+
58+
export type SegmentWithIcon = Segment & {
59+
iconId: FrIconClassName | RiIconClassName;
60+
};
61+
62+
export type SegmentWithoutIcon = Segment & {
63+
iconId?: never;
64+
};
65+
66+
export type Segments =
67+
| [SegmentWithIcon, SegmentWithIcon?, SegmentWithIcon?, SegmentWithIcon?, SegmentWithIcon?]
68+
| [
69+
SegmentWithoutIcon,
70+
SegmentWithoutIcon?,
71+
SegmentWithoutIcon?,
72+
SegmentWithoutIcon?,
73+
SegmentWithoutIcon?
74+
];
75+
}
3676

3777
/** @see <https://components.react-dsfr.codegouv.studio/?path=/docs/components-segmented-control> */
3878
export const SegmentedControl = memo(
3979
forwardRef<HTMLFieldSetElement, SegmentedControlProps>((props, ref) => {
40-
const { id: props_id, className, classes = {}, style, ...rest } = props;
80+
const {
81+
id: props_id,
82+
name: props_name,
83+
className,
84+
classes = {},
85+
style,
86+
small,
87+
segments,
88+
hideLegend,
89+
inlineLegend,
90+
legend,
91+
...rest
92+
} = props;
4193

4294
assert<Equals<keyof typeof rest, never>>();
4395

4496
const id = useAnalyticsId({
45-
"defaultIdPrefix": "fr-follow",
97+
"defaultIdPrefix": `fr-segmented${props_name === undefined ? "" : `-${props_name}`}`,
4698
"explicitlyProvidedId": props_id
4799
});
48100

101+
const getInputId = (i: number) => `${id}-${i}`;
102+
103+
const segmentedName = (function useClosure() {
104+
const id = useId();
105+
106+
return props_name ?? `segmented-name-${id}`;
107+
})();
108+
49109
return (
50110
<fieldset
51111
id={id}
52-
className={cx(fr.cx("fr-segmented"), classes.root, className)}
112+
className={cx(
113+
fr.cx(
114+
"fr-segmented",
115+
small && "fr-segmented--sm",
116+
hideLegend && "fr-segmented--no-legend"
117+
),
118+
classes.root,
119+
className
120+
)}
53121
ref={ref}
54122
style={style}
55123
{...rest}
56-
></fieldset>
124+
>
125+
{legend !== undefined && (
126+
<legend
127+
className={cx(
128+
fr.cx(
129+
"fr-segmented__legend",
130+
inlineLegend && "fr-segmented__legend--inline"
131+
),
132+
classes.legend
133+
)}
134+
>
135+
{legend}
136+
</legend>
137+
)}
138+
<div className={cx(fr.cx("fr-segmented__elements"), classes.elements)}>
139+
{segments.map((segment, index) => {
140+
if (!segment) return null;
141+
142+
const segmentId = getInputId(index);
143+
return (
144+
<div
145+
className={cx(
146+
fr.cx("fr-segmented__element"),
147+
classes["element-each"]
148+
)}
149+
key={index}
150+
>
151+
<input
152+
{...segment.nativeInputProps}
153+
id={segmentId}
154+
name={segmentedName}
155+
type="radio"
156+
/>
157+
<label
158+
className={cx(
159+
fr.cx(
160+
segment.iconId !== undefined && segment.iconId,
161+
"fr-label"
162+
),
163+
classes["element-each__label"]
164+
)}
165+
htmlFor={segmentId}
166+
>
167+
{segment.label}
168+
</label>
169+
</div>
170+
);
171+
})}
172+
</div>
173+
</fieldset>
57174
);
58175
})
59176
);

0 commit comments

Comments
 (0)