diff --git a/COMPONENTS.md b/COMPONENTS.md index acc737c80..4f6b6fc99 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -8,7 +8,7 @@ - [x] Button - [x] Buttons group - [ ] France Connect button -- [ ] Radio button +- [x] Radio button - [ ] Radio rich - [ ] Checkbox - [x] Cards diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65c21c272..17a11d36e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,10 +38,29 @@ Thank You Very Much ❤️ PS: If you want to contribute to the Doc website. You can edit [the source Markdown](https://github.com/codegouvfr/react-dsfr/tree/v1_docs) or ask me for access to our GitBook. (We'll migrate to Docusaurus once we have the DSFR theme for it ready.) -## Linking a working version of `@gouvfr/dsfr` +## Linking your local copy of `@codegouvfr/react-dsfr` in your project + +This will enable you to see your react-dsfr changes in your main project. + +```bash +cd ~/github +git clone https://github.com/ORG/YOUR-PROJECT-USING-REACT-DSFR +cd YOUR-PROJECT-USING-REACT-DSFR +yarn # or npm install or pnpm install depending of what you are using... + +cd ~/github +git clone https://github.com/codegouvfr/react-dsfr +cd react-dsfr +yarn +yarn build +yarn link-external YOUR-PROJECT-USING-REACT-DSFR +npx tsc -w -p src # Leave this running if you want hot reload. +``` + +## Linking a working version of `@gouvfr/dsfr` (For the SIG) ```bash -cd ~/github/ +cd ~/github git clone http://github.com/gouvernementfr/dsfr cd dsfr # git checkout my-working-branch diff --git a/README.fr.md b/README.fr.md index 60666ed61..5eac90e52 100644 --- a/README.fr.md +++ b/README.fr.md @@ -45,7 +45,7 @@ DSFR en pur JavaScript/CSS. - [x] la plupart des composants peuvent être rendus directement sur le serveur (voir [RSC](https://reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html)). Les autres sont étiquetées `"use client";`. - [x] [Intégration clef en main pour les différents frameworks de développement: vite, Next.js, y compris la version beta de Next 13 (configuration AppDir) et Create React App](https://react-dsfr.etalab.studio/) si votre framework n'est pas supporter, il suffit de demander notre, nous avons pour but de couvrir tous les cas d'usage effectifs. -- [ ] tout [les composants de référence implémenter](https://www.systeme-de-design.gouv.fr/elements-d-interface). À ce jour 18/41, [see details](COMPONENTS.md) +- [ ] tout [les composants de référence implémenter](https://www.systeme-de-design.gouv.fr/elements-d-interface). À ce jour 19/41, [see details](COMPONENTS.md) - [x] seulement le code des composants que vous utilisez effectivement sera inclus dans votre projet final. - [x] Intégration facultative avec [MUI](https://mui.com/). Si vous utilisez des composants MUI ils seront automatiquement adaptés pour ressembler à des composants DSFR. Voir là [documentation](https://react-dsfr.etalab.studio/mui-integration). diff --git a/README.md b/README.md index 0fbc7c87e..3006fa1d3 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ This module is a wrapper/compatibility layer for [@gouvfr/dsfr](https://github.c - [x] No [white flash when reloading in SSR setup](https://github.com/codegouvfr/@codegouvfr/react-dsfr/issues/2#issuecomment-1257263480). - [x] Most components are server component ready. The others are labeled with `"use client";` - [x] [Perfect integration with all major React framework: Next.js (PagesDir and AppDir), Create React App, Vue](https://react-dsfr.etalab.studio/). -- [ ] All [the components](https://www.systeme-de-design.gouv.fr/elements-d-interface) are implemented (18/41, [see details](COMPONENTS.md)) +- [ ] All [the components](https://www.systeme-de-design.gouv.fr/elements-d-interface) are implemented (19/41, [see details](COMPONENTS.md)) - [x] Three shakable distribution, cherry pick the components you import. (It's not all in a big .js bundle) - [x] Optional integration with [MUI](https://mui.com/). If you use MUI components they will be automatically adapted to look like [DSFR components](https://www.systeme-de-design.gouv.fr/elements-d-interface). See [documentation](https://react-dsfr.etalab.studio/mui-integration). diff --git a/package.json b/package.json index c127640d3..c8df403ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codegouvfr/react-dsfr", - "version": "0.30.1", + "version": "0.31.0", "description": "French State Design System React integration library", "repository": { "type": "git", @@ -8,11 +8,12 @@ }, "scripts": { "build": "ts-node -T src/scripts/build", - "yarn-link": "ts-node -T src/scripts/yarn-link.ts", - "start-cra": "yarn build && yarn yarn-link && ((tsc -w -p src) & (cd test/integration/cra && yarn start))", - "start-vite": "yarn build && yarn yarn-link && ((tsc -w -p src) & (cd test/integration/vite && yarn dev))", - "start-next-pagesdir": "yarn build && yarn yarn-link && ((tsc -w -p src) & (cd test/integration/next-pagesdir && yarn dev))", - "start-next-appdir": "yarn build && yarn yarn-link && ((tsc -w -p src) & (cd test/integration/next-appdir && yarn dev))", + "_link": "ts-node -T src/scripts/link-in-integration-apps.ts", + "link-external": "ts-node -T src/scripts/link-in-external-project.ts", + "start-cra": "yarn build && yarn _link && ((tsc -w -p src) & (cd test/integration/cra && yarn start))", + "start-vite": "yarn build && yarn _link && ((tsc -w -p src) & (cd test/integration/vite && yarn dev))", + "start-next-pagesdir": "yarn build && yarn _link && ((tsc -w -p src) & (cd test/integration/next-pagesdir && yarn dev))", + "start-next-appdir": "yarn build && yarn _link && ((tsc -w -p src) & (cd test/integration/next-appdir && yarn dev))", "test": "vitest", "lint:check": "eslint . --ext .ts,.tsx", "lint": "yarn lint:check --fix", @@ -137,6 +138,7 @@ "./SkipLinks": "./dist/SkipLinks.js", "./Select": "./dist/Select.js", "./SearchBar": "./dist/SearchBar.js", + "./RadioButtons": "./dist/RadioButtons.js", "./Quote": "./dist/Quote.js", "./Pagination": "./dist/Pagination.js", "./Notice": "./dist/Notice.js", diff --git a/src/RadioButtons.tsx b/src/RadioButtons.tsx new file mode 100644 index 000000000..29bd6fbbd --- /dev/null +++ b/src/RadioButtons.tsx @@ -0,0 +1,180 @@ +import React, { + useId, + memo, + forwardRef, + type ReactNode, + type CSSProperties, + type InputHTMLAttributes, + type DetailedHTMLProps +} 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"; + +export type RadioButtonsProps = { + className?: string; + classes?: Partial>; + style?: CSSProperties; + legend: ReactNode; + hintText?: ReactNode; + name?: string; + options: { + label: ReactNode; + hintText?: ReactNode; + nativeInputProps: DetailedHTMLProps< + InputHTMLAttributes, + HTMLInputElement + >; + }[]; + /** Default: "vertical" */ + orientation?: "vertical" | "horizontal"; + /** Default: "default" */ + state?: "success" | "error" | "default"; + /** + * The message won't be displayed if state is "default". + * If the state is "error" providing a message is mandatory + **/ + stateRelatedMessage?: ReactNode; + /** Default: false */ + disabled?: boolean; +}; + +/** @see */ +export const RadioButtons = memo( + forwardRef((props, ref) => { + const { + className, + classes = {}, + style, + name: name_props, + legend, + hintText, + options, + orientation = "vertical", + state = "default", + stateRelatedMessage, + disabled = false, + ...rest + } = props; + + assert>(); + + const { getRadioId, legendId, errorDescId, successDescId } = (function useClosure() { + const id = `radio${name_props === undefined ? "" : `-${name_props}`}-${useId()}`; + + const getRadioId = (i: number) => `${id}-${i}`; + + const legendId = `${id}-legend`; + + const errorDescId = `${id}-desc-error`; + const successDescId = `${id}-desc-valid`; + + return { getRadioId, legendId, errorDescId, successDescId }; + })(); + + const name = (function useClosure() { + const id = useId(); + + return name_props ?? `radio-name-${id}`; + })(); + + return ( +
{ + switch (state) { + case "default": + return undefined; + case "error": + return "fr-fieldset--error"; + case "success": + return "fr-fieldset--valid"; + } + })() + ), + classes.root, + className + )} + disabled={disabled} + style={style} + aria-labelledby={cx( + legendId, + (() => { + switch (state) { + case "default": + return undefined; + case "error": + return errorDescId; + case "success": + return successDescId; + } + })() + )} + role={state === "default" ? undefined : "group"} + {...rest} + ref={ref} + > + + {legend} + {hintText !== undefined && ( + {hintText} + )} + +
+ {options.map(({ label, hintText, nativeInputProps }, i) => ( +
+ + +
+ ))} +
+ {state !== "default" && ( +

{ + switch (state) { + case "error": + return errorDescId; + case "success": + return successDescId; + } + })()} + className={fr.cx( + (() => { + switch (state) { + case "error": + return "fr-error-text"; + case "success": + return "fr-valid-text"; + } + })() + )} + > + {stateRelatedMessage ?? ""} +

+ )} +
+ ); + }) +); + +RadioButtons.displayName = symToStr({ RadioButtons }); + +export default RadioButtons; diff --git a/src/scripts/link-in-external-project.ts b/src/scripts/link-in-external-project.ts new file mode 100644 index 000000000..9252f3f71 --- /dev/null +++ b/src/scripts/link-in-external-project.ts @@ -0,0 +1,119 @@ +import { execSync } from "child_process"; +import { join as pathJoin, relative as pathRelative } from "path"; +import { exclude } from "tsafe/exclude"; +import * as fs from "fs"; + +const rootDirPath = pathJoin(__dirname, "..", ".."); + +const commonThirdPartyDeps = (() => { + const namespaceModuleNames = ["@emotion"]; + const standaloneModuleNames = ["react", "@types/react"]; + + return [ + ...namespaceModuleNames + .map(namespaceModuleName => + fs + .readdirSync(pathJoin(rootDirPath, "node_modules", namespaceModuleName)) + .map(submoduleName => `${namespaceModuleName}/${submoduleName}`) + ) + .reduce((prev, curr) => [...prev, ...curr], []), + ...standaloneModuleNames + ]; +})(); + +const yarnHomeDirPath = pathJoin(rootDirPath, ".yarn_home"); + +fs.rmSync(yarnHomeDirPath, { "recursive": true, "force": true }); +fs.mkdirSync(yarnHomeDirPath); + +const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => { + const { targetModuleName, cwd } = params; + + const cmd = [ + "yarn", + "link", + ...(targetModuleName !== undefined ? [targetModuleName] : []) + ].join(" "); + + console.log(`$ cd ${pathRelative(rootDirPath, cwd) || "."} && ${cmd}`); + + execSync(cmd, { + cwd, + "env": { + ...process.env, + "HOME": yarnHomeDirPath + } + }); +}; + +const testAppPaths = (() => { + const [, , ...testAppNames] = process.argv; + + return testAppNames + .map(testAppName => { + const testAppPath = pathJoin(rootDirPath, "..", testAppName); + + if (fs.existsSync(testAppPath)) { + return testAppPath; + } + + console.warn(`Skipping ${testAppName} since it cant be found here: ${testAppPath}`); + + return undefined; + }) + .filter(exclude(undefined)); +})(); + +if (testAppPaths.length === 0) { + console.error("No test app to link into!"); + process.exit(-1); +} + +testAppPaths.forEach(testAppPath => execSync("yarn install", { "cwd": testAppPath })); + +console.log("=== Linking common dependencies ==="); + +const total = commonThirdPartyDeps.length; +let current = 0; + +commonThirdPartyDeps.forEach(commonThirdPartyDep => { + current++; + + console.log(`${current}/${total} ${commonThirdPartyDep}`); + + const localInstallPath = pathJoin( + ...[ + rootDirPath, + "node_modules", + ...(commonThirdPartyDep.startsWith("@") + ? commonThirdPartyDep.split("/") + : [commonThirdPartyDep]) + ] + ); + + execYarnLink({ "cwd": localInstallPath }); +}); + +commonThirdPartyDeps.forEach(commonThirdPartyDep => + testAppPaths.forEach(testAppPath => + execYarnLink({ + "cwd": testAppPath, + "targetModuleName": commonThirdPartyDep + }) + ) +); + +console.log("=== Linking in house dependencies ==="); + +execYarnLink({ "cwd": pathJoin(rootDirPath, "dist") }); + +testAppPaths.forEach(testAppPath => + execYarnLink({ + "cwd": testAppPath, + "targetModuleName": JSON.parse( + fs.readFileSync(pathJoin(rootDirPath, "package.json")).toString("utf8") + )["name"] + }) +); + +export {}; diff --git a/src/scripts/yarn-link.ts b/src/scripts/link-in-integration-apps.ts similarity index 100% rename from src/scripts/yarn-link.ts rename to src/scripts/link-in-integration-apps.ts diff --git a/stories/RadioButtons.stories.tsx b/stories/RadioButtons.stories.tsx new file mode 100644 index 000000000..622b50967 --- /dev/null +++ b/stories/RadioButtons.stories.tsx @@ -0,0 +1,273 @@ +import { RadioButtons, type RadioButtonsProps } from "../dist/RadioButtons"; +import { sectionName } from "./sectionName"; +import { getStoryFactory } from "./getStory"; +import { assert } from "tsafe/assert"; +import type { Equals } from "tsafe"; + +const { meta, getStory } = getStoryFactory({ + sectionName, + "wrappedComponent": { RadioButtons }, + "description": ` +- [See DSFR documentation](https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/boutons-radio) +- [See source code](https://github.com/codegouvfr/react-dsfr/blob/main/src/RadioButtons.tsx)`, + "argTypes": { + "name": { + "description": + "The name that will be applied to all the underlying ``", + "control": { "type": "text" } + }, + "options": { + "description": `An array describing the radio options. + \`nativeInputProps\` is an object that you would pass as prop to \`\`, + this is where you define the value for each option`, + "control": { "type": "null" } + }, + "orientation": { + "description": "Default: 'vertical'", + "options": (() => { + const options = ["horizontal", "vertical"] as const; + + assert< + Equals + >(); + + return options; + })(), + "control": { "type": "radio" } + }, + "state": { + "description": "Default: 'default'", + "options": (() => { + const options = ["success", "error", "default"] as const; + + assert>(); + + return options; + })(), + "control": { "type": "radio" } + }, + "stateRelatedMessage": { + "description": `The message won't be displayed if state is "default". + If the state is "error" providing a message is mandatory`, + "control": { "type": "text" } + }, + "disabled": { + "control": { "type": "boolean" } + } + }, + "disabledProps": ["lang"] +}); + +export default meta; + +export const Default = getStory({ + "legend": "Légende pour l’ensemble de champs", + "name": "radio", + "options": [ + { + "label": "Label radio", + "nativeInputProps": { + "value": "value1" + } + }, + { + "label": "Label radio 2", + "nativeInputProps": { + "value": "value2" + } + }, + { + "label": "Label radio 3", + "nativeInputProps": { + "value": "value3" + } + } + ], + "state": "default", + "stateRelatedMessage": "State description" +}); + +export const Horizontal = getStory({ + "legend": "Légende pour l’ensemble de champs", + "name": "radio", + "orientation": "horizontal", + "options": [ + { + "label": "Label radio", + "nativeInputProps": { + "value": "value1" + } + }, + { + "label": "Label radio 2", + "nativeInputProps": { + "value": "value2" + } + }, + { + "label": "Label radio 3", + "nativeInputProps": { + "value": "value3" + } + } + ] +}); + +export const WithHintText = getStory({ + "legend": "Légende pour l’ensemble de champs", + "name": "radio", + "hintText": "Texte de description additionnel", + "options": [ + { + "label": "Label radio", + "nativeInputProps": { + "value": "value1" + } + }, + { + "label": "Label radio 2", + "nativeInputProps": { + "value": "value2" + } + }, + { + "label": "Label radio 3", + "nativeInputProps": { + "value": "value3" + } + } + ] +}); + +export const WithIndividualHints = getStory({ + "legend": "Légende pour l’ensemble de champs", + "name": "radio", + "options": [ + { + "label": "Label radio", + "hintText": "Texte de description additionnel", + "nativeInputProps": { + "value": "value1" + } + }, + { + "label": "Label radio 2", + "hintText": "Texte de description additionnel", + "nativeInputProps": { + "value": "value2" + } + }, + { + "label": "Label radio 3", + "hintText": "Texte de description additionnel", + "nativeInputProps": { + "value": "value3" + } + } + ] +}); + +export const ErrorState = getStory({ + "legend": "Légende pour l’ensemble de champs", + "state": "error", + "stateRelatedMessage": "Texte d’erreur obligatoire", + "options": [ + { + "label": "Label radio", + "nativeInputProps": { + "value": "value1" + } + }, + { + "label": "Label radio 2", + "nativeInputProps": { + "value": "value2" + } + }, + { + "label": "Label radio 3", + "nativeInputProps": { + "value": "value3" + } + } + ] +}); + +export const HorizontalErrorState = getStory({ + "legend": "Légende pour l’ensemble de champs", + "state": "error", + "orientation": "horizontal", + "stateRelatedMessage": "Texte d’erreur obligatoire", + "options": [ + { + "label": "Label radio", + "nativeInputProps": { + "value": "value1" + } + }, + { + "label": "Label radio 2", + "nativeInputProps": { + "value": "value2" + } + }, + { + "label": "Label radio 3", + "nativeInputProps": { + "value": "value3" + } + } + ] +}); + +export const SuccessState = getStory({ + "legend": "Légende pour l’ensemble de champs", + "state": "success", + "orientation": "horizontal", + "stateRelatedMessage": "Texte de validation", + "options": [ + { + "label": "Label radio", + "nativeInputProps": { + "value": "value1" + } + }, + { + "label": "Label radio 2", + "nativeInputProps": { + "value": "value2" + } + }, + { + "label": "Label radio 3", + "nativeInputProps": { + "value": "value3" + } + } + ] +}); + +export const Disabled = getStory({ + "legend": "Légende pour l’ensemble de champs", + "disabled": true, + "options": [ + { + "label": "Label radio", + "nativeInputProps": { + "value": "value1" + } + }, + { + "label": "Label radio 2", + "nativeInputProps": { + "value": "value2" + } + }, + { + "label": "Label radio 3", + "nativeInputProps": { + "value": "value3" + } + } + ] +});