Skip to content

Commit 70262c3

Browse files
author
Julien Bouquillon
authored
feat: add ToggleSwitch (#43)
feat: add ToggleSwitch
1 parent fb37e54 commit 70262c3

File tree

5 files changed

+326
-1
lines changed

5 files changed

+326
-1
lines changed

COMPONENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
- [ ] Consent banner
2020
- [ ] Favicon (?)
2121
- [x] Stepper
22-
- [ ] Toggle switch
22+
- [x] Toggle switch
2323
- [ ] Follow
2424
- [ ] Link
2525
- [x] SkipLinks

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@
132132
"./mui": "./dist/mui.js",
133133
"./tools/cx": "./dist/tools/cx.js",
134134
"./dsfr/*": "./dsfr/*",
135+
"./ToggleSwitch": "./dist/ToggleSwitch.js",
135136
"./Tile": "./dist/Tile.js",
136137
"./Tabs": "./dist/Tabs.js",
137138
"./Summary": "./dist/Summary.js",

src/ToggleSwitch.tsx

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import React, { memo, forwardRef, ReactNode, useId, useState } from "react";
2+
import { symToStr } from "tsafe/symToStr";
3+
import { assert } from "tsafe/assert";
4+
import type { Equals } from "tsafe";
5+
6+
import { cx } from "./tools/cx";
7+
import { fr } from "./fr";
8+
import { createComponentI18nApi } from "./i18n";
9+
import { useConstCallback } from "./tools/powerhooks/useConstCallback";
10+
11+
export type ToggleSwitchProps = ToggleSwitchProps.Controlled | ToggleSwitchProps.Uncontrolled;
12+
13+
export namespace ToggleSwitchProps {
14+
export type Common = {
15+
className?: string;
16+
label: ReactNode;
17+
text?: ReactNode;
18+
/** Default: "true" */
19+
showCheckedHint?: boolean;
20+
/** Default: "false" */
21+
disabled?: boolean;
22+
/** Default: "left" */
23+
labelPosition?: "left" | "right";
24+
classes?: Partial<Record<"root" | "label" | "input" | "hint", string>>;
25+
};
26+
27+
export type Uncontrolled = Common & {
28+
/** Default: "false" */
29+
defaultChecked?: boolean;
30+
checked?: undefined;
31+
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
32+
};
33+
34+
export type Controlled = Common & {
35+
/** Default: "false" */
36+
defaultChecked?: undefined;
37+
checked: boolean;
38+
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
39+
};
40+
}
41+
42+
export type ToggleSwitchGroupProps = {
43+
className?: string;
44+
/** Needs at least one ToggleSwitch */
45+
togglesProps: [ToggleSwitchProps, ...ToggleSwitchProps[]];
46+
/** Default: "true" */
47+
showCheckedHint?: boolean;
48+
/** Default: "left" */
49+
labelPosition?: "left" | "right";
50+
classes?: Partial<Record<"root" | "li", string>>;
51+
};
52+
53+
/** @see <https://react-dsfr-components.etalab.studio/?path=/docs/components-toggleswitch> */
54+
export const ToggleSwitchGroup = memo<ToggleSwitchGroupProps>(props => {
55+
const {
56+
className,
57+
togglesProps,
58+
showCheckedHint = true,
59+
labelPosition = "right",
60+
classes = {},
61+
...rest
62+
} = props;
63+
64+
assert<Equals<keyof typeof rest, never>>();
65+
66+
return (
67+
<ul className={cx(fr.cx("fr-toggle__list"), classes.root, className)} {...rest}>
68+
{togglesProps &&
69+
togglesProps.map((toggleProps, i) => (
70+
<li key={i} className={classes.li}>
71+
<ToggleSwitch
72+
{...{
73+
...toggleProps,
74+
showCheckedHint,
75+
labelPosition
76+
}}
77+
/>
78+
</li>
79+
))}
80+
</ul>
81+
);
82+
});
83+
84+
/** @see <https://react-dsfr-components.etalab.studio/?path=/docs/components-toggleswitch> */
85+
export const ToggleSwitch = memo(
86+
forwardRef<HTMLDivElement, ToggleSwitchProps>((props, ref) => {
87+
const {
88+
className,
89+
label,
90+
text,
91+
defaultChecked = false,
92+
checked,
93+
showCheckedHint = true,
94+
disabled = false,
95+
labelPosition = "right",
96+
classes = {},
97+
onChange,
98+
...rest
99+
} = props;
100+
101+
const [checkedState, setCheckState] = useState(defaultChecked);
102+
103+
const checkedValue = checked !== undefined ? checked : checkedState;
104+
105+
assert<Equals<keyof typeof rest, never>>();
106+
107+
const inputId = useId();
108+
109+
const { t } = useTranslation();
110+
111+
const onInputChange = useConstCallback((event: React.ChangeEvent<HTMLInputElement>) => {
112+
setCheckState(event.currentTarget.checked);
113+
onChange?.(event);
114+
});
115+
116+
return (
117+
<div
118+
className={cx(
119+
fr.cx("fr-toggle", labelPosition === "left" && "fr-toggle--label-left"),
120+
classes.root,
121+
className
122+
)}
123+
ref={ref}
124+
>
125+
<input
126+
onChange={onInputChange}
127+
type="checkbox"
128+
disabled={disabled || undefined}
129+
className={cx(fr.cx("fr-toggle__input"), classes.input)}
130+
aria-describedby={`${inputId}-hint-text`}
131+
id={inputId}
132+
checked={checkedValue}
133+
/>
134+
<label
135+
className={cx(fr.cx("fr-toggle__label"), classes.label)}
136+
htmlFor={inputId}
137+
{...(showCheckedHint && {
138+
"data-fr-checked-label": t("checked"),
139+
"data-fr-unchecked-label": t("unchecked")
140+
})}
141+
>
142+
{label}
143+
</label>
144+
{text && (
145+
<p
146+
className={cx(fr.cx("fr-hint-text"), classes.hint)}
147+
id={`${inputId}-hint-text`}
148+
>
149+
{text}
150+
</p>
151+
)}
152+
</div>
153+
);
154+
})
155+
);
156+
157+
ToggleSwitch.displayName = symToStr({ ToggleSwitch });
158+
159+
const { useTranslation, addToggleSwitchTranslations } = createComponentI18nApi({
160+
"componentName": symToStr({ ToggleSwitch }),
161+
"frMessages": {
162+
/* spell-checker: disable */
163+
"checked": "Activé",
164+
"unchecked": "Désactivé"
165+
/* spell-checker: enable */
166+
}
167+
});
168+
169+
addToggleSwitchTranslations({
170+
"lang": "en",
171+
"messages": {
172+
"checked": "Active",
173+
"unchecked": "Inactive"
174+
}
175+
});
176+
177+
export { addToggleSwitchTranslations };
178+
179+
export default ToggleSwitch;

stories/ToggleSwitch.stories.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { ToggleSwitch } from "../dist/ToggleSwitch";
2+
import { sectionName } from "./sectionName";
3+
import { getStoryFactory } from "./getStory";
4+
5+
const { meta, getStory } = getStoryFactory({
6+
sectionName,
7+
"wrappedComponent": { ToggleSwitch },
8+
"description": `
9+
- [See DSFR documentation](//www.systeme-de-design.gouv.fr/elements-d-interface/composants/interrupteur)
10+
- [See DSFR demo](https://main--ds-gouv.netlify.app/example/component/toggle/)
11+
- [See source code](//github.com/codegouvfr/react-dsfr/blob/main/src/ToggleSwitch.tsx)`,
12+
"disabledProps": ["lang"]
13+
});
14+
15+
export default meta;
16+
17+
export const Default = getStory({
18+
label: "Label action interrupteur",
19+
text: "Texte d’aide pour clarifier l’action",
20+
disabled: false,
21+
labelPosition: "right",
22+
showCheckedHint: true,
23+
defaultChecked: false
24+
});
25+
26+
export const ToggleSwitchControlled = getStory({
27+
label: "Label action interrupteur",
28+
disabled: false,
29+
labelPosition: "right",
30+
showCheckedHint: false,
31+
checked: true,
32+
onChange: e => alert("checked: " + e.currentTarget.checked)
33+
});
34+
35+
export const ToggleSwitchNoTextNoHint = getStory({
36+
label: "Label action interrupteur",
37+
disabled: false,
38+
labelPosition: "right",
39+
showCheckedHint: false
40+
});
41+
42+
export const ToggleSwitchDisabled = getStory({
43+
label: "Label action interrupteur",
44+
text: "Texte d’aide pour clarifier l’action",
45+
disabled: true,
46+
labelPosition: "right"
47+
});
48+
49+
export const ToggleSwitchLabelLeft = getStory({
50+
label: "Label action interrupteur",
51+
text: "Texte d’aide pour clarifier l’action",
52+
labelPosition: "left"
53+
});
54+
55+
export const ToggleSwitchLabelLeftCheckedWithOnChange = getStory({
56+
label: "Label action interrupteur",
57+
text: "Texte d’aide pour clarifier l’action",
58+
labelPosition: "left",
59+
defaultChecked: true,
60+
onChange: e => {
61+
alert("checked: " + e.currentTarget.checked);
62+
}
63+
});
64+
65+
export const ToggleSwitchLabelLeftCheckedDisabled = getStory({
66+
label: "Label action interrupteur",
67+
text: "Texte d’aide pour clarifier l’action",
68+
labelPosition: "left",
69+
disabled: true
70+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { ToggleSwitchGroup } from "../dist/ToggleSwitch";
2+
import { sectionName } from "./sectionName";
3+
import { getStoryFactory } from "./getStory";
4+
5+
const { meta, getStory } = getStoryFactory({
6+
sectionName,
7+
"wrappedComponent": { ToggleSwitchGroup },
8+
"description": `
9+
- [See DSFR documentation](//www.systeme-de-design.gouv.fr/elements-d-interface/composants/interrupteur)
10+
- [See DSFR demo](https://main--ds-gouv.netlify.app/example/component/toggle/)
11+
- [See source code](//github.com/codegouvfr/react-dsfr/blob/main/src/ToggleSwitchGroup.tsx)`,
12+
"disabledProps": ["lang"]
13+
});
14+
15+
export default meta;
16+
17+
export const Default = getStory({
18+
showCheckedHint: false,
19+
labelPosition: "right",
20+
togglesProps: [
21+
{
22+
label: "Toggle 1",
23+
text: "Text toggle 1",
24+
defaultChecked: true
25+
},
26+
{
27+
label: "Toggle 2",
28+
text: "Text toggle 2",
29+
defaultChecked: true
30+
},
31+
{
32+
label: "Toggle 3",
33+
text: "Text toggle 3",
34+
disabled: true
35+
},
36+
{
37+
label: "Toggle 4",
38+
text: "Text toggle 4"
39+
},
40+
{
41+
label: "Toggle 5",
42+
text: "Text toggle 5"
43+
}
44+
]
45+
});
46+
47+
export const ToggleSwitchGroupLeftLabelWithHint = getStory({
48+
showCheckedHint: true,
49+
labelPosition: "left",
50+
togglesProps: [
51+
{
52+
label: "Toggle 1",
53+
text: "Text toggle 1",
54+
defaultChecked: true
55+
},
56+
{
57+
label: "Toggle 2",
58+
text: "Text toggle 2",
59+
defaultChecked: true
60+
},
61+
{
62+
label: "Toggle 3",
63+
text: "Text toggle 3"
64+
},
65+
{
66+
label: "Toggle 4",
67+
text: "Text toggle 4",
68+
disabled: true
69+
},
70+
{
71+
label: "Toggle 5",
72+
text: "Text toggle 5"
73+
}
74+
]
75+
});

0 commit comments

Comments
 (0)