diff --git a/components/radio-group/.npmignore b/components/radio-group/.npmignore new file mode 100644 index 00000000..85de9cf9 --- /dev/null +++ b/components/radio-group/.npmignore @@ -0,0 +1 @@ +src diff --git a/components/radio-group/README.md b/components/radio-group/README.md new file mode 100644 index 00000000..42e48c16 --- /dev/null +++ b/components/radio-group/README.md @@ -0,0 +1 @@ +# `@byndyusoft-ui/radio-group` diff --git a/components/radio-group/package.json b/components/radio-group/package.json new file mode 100644 index 00000000..d9621ebf --- /dev/null +++ b/components/radio-group/package.json @@ -0,0 +1,38 @@ +{ + "name": "@byndyusoft-ui/radio", + "version": "0.0.1", + "description": "Byndyusoft UI RadioGroup React Component", + "keywords": [ + "byndyusoft", + "byndyusoft-ui", + "react", + "radio" + ], + "author": "Denis Timashev ", + "homepage": "https://github.com/Byndyusoft/ui/tree/master/components/radio-group#readme", + "license": "ISC", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "rollup --config", + "clean": "rimraf dist", + "lint": "eslint src --config ../../eslint.config.js", + "test": "jest --config ../../jest.config.js --roots components/radio-group/src", + "storybook": "start-storybook -p 6006", + "build-storybook": "build-storybook" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } +} diff --git a/components/radio-group/rollup.config.js b/components/radio-group/rollup.config.js new file mode 100644 index 00000000..b14566e7 --- /dev/null +++ b/components/radio-group/rollup.config.js @@ -0,0 +1,15 @@ +import typescript from '@rollup/plugin-typescript'; +import baseConfig from '../../rollup.base.config'; + +export default { + ...baseConfig, + input: ['src/index.ts'], + plugins: [ + ...baseConfig.plugins, + typescript({ + tsconfig: './tsconfig.json', + module: 'ESNext', + exclude: ['src/*.stories.tsx', 'src/*.tests.tsx', 'node_modules'] + }) + ] +}; diff --git a/components/radio-group/src/RadioGroup.tests.tsx b/components/radio-group/src/RadioGroup.tests.tsx new file mode 100644 index 00000000..745d8ac0 --- /dev/null +++ b/components/radio-group/src/RadioGroup.tests.tsx @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import RadioGroup from './components/RadioGroup'; +import Radio from './components/Radio'; + +const TestComponent = ({ selectedOption = 'apple' }: { selectedOption?: string }) => { + const [value, setValue] = useState(selectedOption); + + return ( + <> + Fruit: {value} + + Apple + Banana + Pineapple + + + ); +}; + +const setup = () => render(); + +describe('RadioGroup', () => { + test('renders radio group with labels', () => { + setup(); + + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + expect(screen.getByText('Pineapple')).toBeInTheDocument(); + }); + + test('selecting option is changing value', async () => { + setup(); + + const bananaRadio = screen.getByRole('radio', { name: 'Banana' }); + await userEvent.click(bananaRadio); + + expect(screen.getByText('Fruit: banana')).toBeInTheDocument(); + }); +}); diff --git a/components/radio-group/src/RadioGroup.types.ts b/components/radio-group/src/RadioGroup.types.ts new file mode 100644 index 00000000..861729a3 --- /dev/null +++ b/components/radio-group/src/RadioGroup.types.ts @@ -0,0 +1,35 @@ +import { ReactNode } from 'react'; + +export type TOnChange = (value: string) => void; + +export interface IRadioProps { + value: string; + isDisabled?: boolean; + children: ReactNode; +} + +export interface IRadioGroupProps { + value: string; + children: ReactNode; + name: string; + onChange: TOnChange; +} + +export interface IUseRadioGroupStateProps { + name: string; + value: string; + onChange: TOnChange; +} + +export interface IUseRadioGroup { + name: string; + value: string; + setValue: (value: string) => void; +} + +export interface IRadioGroupContextProviderProps { + name: string; + value: string; + children: ReactNode; + onChange: TOnChange; +} diff --git a/components/radio-group/src/components/Radio.tsx b/components/radio-group/src/components/Radio.tsx new file mode 100644 index 00000000..6d19f097 --- /dev/null +++ b/components/radio-group/src/components/Radio.tsx @@ -0,0 +1,30 @@ +import React, { ReactElement } from 'react'; +import { IRadioProps } from '../RadioGroup.types'; +import { useRadioGroupContext } from './RadioGroupContext'; + +const Radio = ({ value, isDisabled, children }: IRadioProps): ReactElement => { + const { name, value: groupValue, setValue } = useRadioGroupContext(); + + const radioId = `${name}-${value}`; + + const handleInputChange = (event: React.ChangeEvent): void => { + setValue(event.target.value); + }; + + return ( +
+ + +
+ ); +}; + +export default Radio; diff --git a/components/radio-group/src/components/RadioGroup.tsx b/components/radio-group/src/components/RadioGroup.tsx new file mode 100644 index 00000000..2087fbc3 --- /dev/null +++ b/components/radio-group/src/components/RadioGroup.tsx @@ -0,0 +1,11 @@ +import React, { ReactElement } from 'react'; +import { IRadioGroupProps } from '../RadioGroup.types'; +import { RadioGroupContextProvider } from './RadioGroupContext'; + +const RadioGroup = ({ name, value, children, onChange }: IRadioGroupProps): ReactElement => ( + + {children} + +); + +export default RadioGroup; diff --git a/components/radio-group/src/components/RadioGroupContext.tsx b/components/radio-group/src/components/RadioGroupContext.tsx new file mode 100644 index 00000000..2a2b5336 --- /dev/null +++ b/components/radio-group/src/components/RadioGroupContext.tsx @@ -0,0 +1,22 @@ +import React, { createContext, useContext } from 'react'; +import { IRadioGroupContextProviderProps, IUseRadioGroup } from '../RadioGroup.types'; +import useRadioGroupState from '../useRadioGroupState'; + +const RadioGroupContext = createContext({} as IUseRadioGroup); + +export function useRadioGroupContext(): IUseRadioGroup { + const context = useContext(RadioGroupContext); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (context === undefined) { + throw new Error('useRadioGroupContext must be used within the RadioGroupContextProvider'); + } + + return context; +} + +export const RadioGroupContextProvider = ({ name, value, onChange, children }: IRadioGroupContextProviderProps) => { + const radioGroupState = useRadioGroupState({ name, value, onChange }); + + return {children}; +}; diff --git a/components/radio-group/src/index.ts b/components/radio-group/src/index.ts new file mode 100644 index 00000000..98edb6c1 --- /dev/null +++ b/components/radio-group/src/index.ts @@ -0,0 +1,6 @@ +export { default as RadioGroup } from './components/RadioGroup'; +export { default as Radio } from './components/Radio'; +export { default as useRadioGroupState } from './useRadioGroupState'; +export { RadioGroupContextProvider, useRadioGroupContext } from './components/RadioGroupContext'; + +export { IRadioGroupProps, IRadioProps, IUseRadioGroupStateProps, IUseRadioGroup } from './RadioGroup.types'; diff --git a/components/radio-group/src/stories/CustomRadio.tsx b/components/radio-group/src/stories/CustomRadio.tsx new file mode 100644 index 00000000..bd848aac --- /dev/null +++ b/components/radio-group/src/stories/CustomRadio.tsx @@ -0,0 +1,29 @@ +import React, { ReactNode, useCallback } from 'react'; +import { useRadioGroupContext } from '../components/RadioGroupContext'; + +const CustomRadioComponent = ({ value, children }: { value: string; children: ReactNode }): JSX.Element => { + const { name, value: groupValue, setValue } = useRadioGroupContext(); + + const onChangeHandler = useCallback( + (event: React.ChangeEvent) => { + setValue(event.target.value); + }, + [setValue] + ); + + return ( +
+ + +
+ ); +}; + +export default CustomRadioComponent; diff --git a/components/radio-group/src/stories/Radio.stories.tsx b/components/radio-group/src/stories/Radio.stories.tsx new file mode 100644 index 00000000..a0b19212 --- /dev/null +++ b/components/radio-group/src/stories/Radio.stories.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { Story } from '@storybook/react'; +import RadioGroup from '../components/RadioGroup'; +import Radio from '../components/Radio'; +import CustomRadioComponent from './CustomRadio'; + +const DefaultStory: Story = () => { + const [value, setValue] = useState('apple'); + + return ( +
+ Fruit: {value} + + 🍎 Apple + 🍌 Banana + 🍍 Pineapple + +
+ ); +}; + +export const Default = DefaultStory.bind({}); + +const WithCustomRadioStory: Story = () => { + const [value, setValue] = useState('happy'); + + return ( +
+ Mood: {value} + + 😊 Happy + 😐 Neutral + 😔 Upset + +
+ ); +}; + +export const WithCustomRadio = WithCustomRadioStory.bind({}); + +const ResetStateStory: Story = () => { + const [value, setValue] = useState('happy'); + + const handleResetState = (): void => { + setValue('happy'); + }; + + return ( +
+ Mood: {value} + + 😊 Happy + 😐 Neutral + 😔 Upset + + +
+ ); +}; + +export const ResetState = ResetStateStory.bind({}); + +export default { + title: 'components/RadioGroup', + component: RadioGroup +}; diff --git a/components/radio-group/src/useRadioGroupState.ts b/components/radio-group/src/useRadioGroupState.ts new file mode 100644 index 00000000..857da5a1 --- /dev/null +++ b/components/radio-group/src/useRadioGroupState.ts @@ -0,0 +1,29 @@ +import { useCallback, useEffect, useState } from 'react'; +import { IUseRadioGroupStateProps, IUseRadioGroup } from './RadioGroup.types'; + +const useRadioGroupState = ({ name, value, onChange }: IUseRadioGroupStateProps): IUseRadioGroup => { + const [stateValue, setStateValue] = useState(value); + + useEffect(() => { + if (value !== stateValue) { + setStateValue(value); + onChange?.(value); + } + }, [value, stateValue]); + + const setValueHandler = useCallback( + (targetValue: string) => { + setStateValue(targetValue); + onChange?.(targetValue); + }, + [onChange] + ); + + return { + name, + value: stateValue, + setValue: setValueHandler + }; +}; + +export default useRadioGroupState; diff --git a/components/radio-group/tsconfig.json b/components/radio-group/tsconfig.json new file mode 100644 index 00000000..98f42aee --- /dev/null +++ b/components/radio-group/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs" + }, + "include": ["../../types.d.ts", "src"] +} diff --git a/package-lock.json b/package-lock.json index 6852563a..caff8775 100644 --- a/package-lock.json +++ b/package-lock.json @@ -130,6 +130,15 @@ "react-dom": ">=17" } }, + "components/radio-group": { + "name": "@byndyusoft-ui/radio", + "version": "0.0.1", + "license": "ISC", + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "hooks/use-array": { "name": "@byndyusoft-ui/use-array", "version": "0.0.1", @@ -2319,6 +2328,10 @@ "resolved": "components/portal", "link": true }, + "node_modules/@byndyusoft-ui/radio": { + "resolved": "components/radio-group", + "link": true + }, "node_modules/@byndyusoft-ui/reset-css": { "resolved": "styles/reset-css", "link": true