diff --git a/COMPONENTS.md b/COMPONENTS.md index 43e1ef292..71cc79064 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -19,7 +19,7 @@ - [ ] Consent banner - [ ] Favicon (?) - [x] Stepper -- [ ] Toggle switch +- [x] Toggle switch - [ ] Follow - [ ] Link - [x] SkipLinks diff --git a/package.json b/package.json index ba6001d73..fbe78d7ac 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "./mui": "./dist/mui.js", "./tools/cx": "./dist/tools/cx.js", "./dsfr/*": "./dsfr/*", + "./ToggleSwitch": "./dist/ToggleSwitch.js", "./Tile": "./dist/Tile.js", "./Tabs": "./dist/Tabs.js", "./Summary": "./dist/Summary.js", diff --git a/src/ToggleSwitch.tsx b/src/ToggleSwitch.tsx new file mode 100644 index 000000000..21cf6960c --- /dev/null +++ b/src/ToggleSwitch.tsx @@ -0,0 +1,179 @@ +import React, { memo, forwardRef, ReactNode, useId, useState } from "react"; +import { symToStr } from "tsafe/symToStr"; +import { assert } from "tsafe/assert"; +import type { Equals } from "tsafe"; + +import { cx } from "./tools/cx"; +import { fr } from "./fr"; +import { createComponentI18nApi } from "./i18n"; +import { useConstCallback } from "./tools/powerhooks/useConstCallback"; + +export type ToggleSwitchProps = ToggleSwitchProps.Controlled | ToggleSwitchProps.Uncontrolled; + +export namespace ToggleSwitchProps { + export type Common = { + className?: string; + label: ReactNode; + text?: ReactNode; + /** Default: "true" */ + showCheckedHint?: boolean; + /** Default: "false" */ + disabled?: boolean; + /** Default: "left" */ + labelPosition?: "left" | "right"; + classes?: Partial>; + }; + + export type Uncontrolled = Common & { + /** Default: "false" */ + defaultChecked?: boolean; + checked?: undefined; + onChange?: (event: React.ChangeEvent) => void; + }; + + export type Controlled = Common & { + /** Default: "false" */ + defaultChecked?: undefined; + checked: boolean; + onChange: (event: React.ChangeEvent) => void; + }; +} + +export type ToggleSwitchGroupProps = { + className?: string; + /** Needs at least one ToggleSwitch */ + togglesProps: [ToggleSwitchProps, ...ToggleSwitchProps[]]; + /** Default: "true" */ + showCheckedHint?: boolean; + /** Default: "left" */ + labelPosition?: "left" | "right"; + classes?: Partial>; +}; + +/** @see */ +export const ToggleSwitchGroup = memo(props => { + const { + className, + togglesProps, + showCheckedHint = true, + labelPosition = "right", + classes = {}, + ...rest + } = props; + + assert>(); + + return ( +
    + {togglesProps && + togglesProps.map((toggleProps, i) => ( +
  • + +
  • + ))} +
+ ); +}); + +/** @see */ +export const ToggleSwitch = memo( + forwardRef((props, ref) => { + const { + className, + label, + text, + defaultChecked = false, + checked, + showCheckedHint = true, + disabled = false, + labelPosition = "right", + classes = {}, + onChange, + ...rest + } = props; + + const [checkedState, setCheckState] = useState(defaultChecked); + + const checkedValue = checked !== undefined ? checked : checkedState; + + assert>(); + + const inputId = useId(); + + const { t } = useTranslation(); + + const onInputChange = useConstCallback((event: React.ChangeEvent) => { + setCheckState(event.currentTarget.checked); + onChange?.(event); + }); + + return ( +
+ + + {text && ( +

+ {text} +

+ )} +
+ ); + }) +); + +ToggleSwitch.displayName = symToStr({ ToggleSwitch }); + +const { useTranslation, addToggleSwitchTranslations } = createComponentI18nApi({ + "componentName": symToStr({ ToggleSwitch }), + "frMessages": { + /* spell-checker: disable */ + "checked": "Activé", + "unchecked": "Désactivé" + /* spell-checker: enable */ + } +}); + +addToggleSwitchTranslations({ + "lang": "en", + "messages": { + "checked": "Active", + "unchecked": "Inactive" + } +}); + +export { addToggleSwitchTranslations }; + +export default ToggleSwitch; diff --git a/stories/ToggleSwitch.stories.tsx b/stories/ToggleSwitch.stories.tsx new file mode 100644 index 000000000..335318515 --- /dev/null +++ b/stories/ToggleSwitch.stories.tsx @@ -0,0 +1,70 @@ +import { ToggleSwitch } from "../dist/ToggleSwitch"; +import { sectionName } from "./sectionName"; +import { getStoryFactory } from "./getStory"; + +const { meta, getStory } = getStoryFactory({ + sectionName, + "wrappedComponent": { ToggleSwitch }, + "description": ` +- [See DSFR documentation](//www.systeme-de-design.gouv.fr/elements-d-interface/composants/interrupteur) +- [See DSFR demo](https://main--ds-gouv.netlify.app/example/component/toggle/) +- [See source code](//github.com/codegouvfr/react-dsfr/blob/main/src/ToggleSwitch.tsx)`, + "disabledProps": ["lang"] +}); + +export default meta; + +export const Default = getStory({ + label: "Label action interrupteur", + text: "Texte d’aide pour clarifier l’action", + disabled: false, + labelPosition: "right", + showCheckedHint: true, + defaultChecked: false +}); + +export const ToggleSwitchControlled = getStory({ + label: "Label action interrupteur", + disabled: false, + labelPosition: "right", + showCheckedHint: false, + checked: true, + onChange: e => alert("checked: " + e.currentTarget.checked) +}); + +export const ToggleSwitchNoTextNoHint = getStory({ + label: "Label action interrupteur", + disabled: false, + labelPosition: "right", + showCheckedHint: false +}); + +export const ToggleSwitchDisabled = getStory({ + label: "Label action interrupteur", + text: "Texte d’aide pour clarifier l’action", + disabled: true, + labelPosition: "right" +}); + +export const ToggleSwitchLabelLeft = getStory({ + label: "Label action interrupteur", + text: "Texte d’aide pour clarifier l’action", + labelPosition: "left" +}); + +export const ToggleSwitchLabelLeftCheckedWithOnChange = getStory({ + label: "Label action interrupteur", + text: "Texte d’aide pour clarifier l’action", + labelPosition: "left", + defaultChecked: true, + onChange: e => { + alert("checked: " + e.currentTarget.checked); + } +}); + +export const ToggleSwitchLabelLeftCheckedDisabled = getStory({ + label: "Label action interrupteur", + text: "Texte d’aide pour clarifier l’action", + labelPosition: "left", + disabled: true +}); diff --git a/stories/ToggleSwitchGroup.stories.tsx b/stories/ToggleSwitchGroup.stories.tsx new file mode 100644 index 000000000..8b74b207a --- /dev/null +++ b/stories/ToggleSwitchGroup.stories.tsx @@ -0,0 +1,75 @@ +import { ToggleSwitchGroup } from "../dist/ToggleSwitch"; +import { sectionName } from "./sectionName"; +import { getStoryFactory } from "./getStory"; + +const { meta, getStory } = getStoryFactory({ + sectionName, + "wrappedComponent": { ToggleSwitchGroup }, + "description": ` +- [See DSFR documentation](//www.systeme-de-design.gouv.fr/elements-d-interface/composants/interrupteur) +- [See DSFR demo](https://main--ds-gouv.netlify.app/example/component/toggle/) +- [See source code](//github.com/codegouvfr/react-dsfr/blob/main/src/ToggleSwitchGroup.tsx)`, + "disabledProps": ["lang"] +}); + +export default meta; + +export const Default = getStory({ + showCheckedHint: false, + labelPosition: "right", + togglesProps: [ + { + label: "Toggle 1", + text: "Text toggle 1", + defaultChecked: true + }, + { + label: "Toggle 2", + text: "Text toggle 2", + defaultChecked: true + }, + { + label: "Toggle 3", + text: "Text toggle 3", + disabled: true + }, + { + label: "Toggle 4", + text: "Text toggle 4" + }, + { + label: "Toggle 5", + text: "Text toggle 5" + } + ] +}); + +export const ToggleSwitchGroupLeftLabelWithHint = getStory({ + showCheckedHint: true, + labelPosition: "left", + togglesProps: [ + { + label: "Toggle 1", + text: "Text toggle 1", + defaultChecked: true + }, + { + label: "Toggle 2", + text: "Text toggle 2", + defaultChecked: true + }, + { + label: "Toggle 3", + text: "Text toggle 3" + }, + { + label: "Toggle 4", + text: "Text toggle 4", + disabled: true + }, + { + label: "Toggle 5", + text: "Text toggle 5" + } + ] +});