diff --git a/components/checkbox/.npmignore b/components/checkbox/.npmignore new file mode 100644 index 00000000..85de9cf9 --- /dev/null +++ b/components/checkbox/.npmignore @@ -0,0 +1 @@ +src diff --git a/components/checkbox/README.md b/components/checkbox/README.md new file mode 100644 index 00000000..5dc7283d --- /dev/null +++ b/components/checkbox/README.md @@ -0,0 +1,21 @@ +# `@byndyusoft-ui/checkbox` + +A React CheckBox component. + +### Installation + +```sh +npm i @byndyusoft-ui/checkbox +# or +yarn add @byndyusoft-ui/checkbox +``` + +### Usage + +```ts +// Usage examples coming soon +``` + +### License + +Apache-2.0 diff --git a/components/checkbox/package.json b/components/checkbox/package.json new file mode 100644 index 00000000..93fad912 --- /dev/null +++ b/components/checkbox/package.json @@ -0,0 +1,45 @@ +{ + "name": "@byndyusoft-ui/checkbox", + "version": "0.0.1", + "description": "Byndyusoft UI CheckBox React Component", + "keywords": [ + "byndyusoft", + "byndyusoft-ui", + "react", + "checkbox" + ], + "author": "Tolmachev Serega ", + "homepage": "https://github.com/Byndyusoft/ui/tree/master/components/checkbox#readme", + "license": "Apache-2.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "clean": "rimraf dist", + "lint:check": "npm run eslint:check && npm run prettier:check && npm run stylelint:check", + "lint:fix": "npm run eslint:fix && npm run prettier:fix && npm run stylelint:fix", + "eslint:check": "eslint src --config ../../eslint.config.js", + "eslint:fix": "eslint src --config ../../eslint.config.js --fix", + "prettier:check": "prettier --check '**/*.{ts,tsx,css,scss,json}' '!**/dist/**'", + "prettier:fix": "prettier --write '**/*.{ts,tsx,css,scss,json}' '!**/dist/**'", + "stylelint:check": "stylelint '**/*.{css,scss}' --allow-empty-input", + "stylelint:fix": "stylelint '**/*.{css,scss}' --fix --allow-empty-input" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + }, + "devDependencies": { + "react-hook-form": "^7.43.9" + } +} diff --git a/components/checkbox/rollup.config.js b/components/checkbox/rollup.config.js new file mode 100644 index 00000000..b14566e7 --- /dev/null +++ b/components/checkbox/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/checkbox/src/CheckBox.module.css b/components/checkbox/src/CheckBox.module.css new file mode 100644 index 00000000..34404209 --- /dev/null +++ b/components/checkbox/src/CheckBox.module.css @@ -0,0 +1,77 @@ +/** + * Container + */ +.container { + position: relative; + + display: flex; + align-items: flex-start; + gap: 0.5rem; + width: fit-content; + + user-select: none; + cursor: pointer; +} + +.containerDisabled { + cursor: default; +} + +/** + * Label + */ +.label { + flex-grow: 1; + flex-shrink: 1; +} + +.labelDisabled { + color: #7a7f82; +} + +/** + * Indicator + */ +.indicator { + box-sizing: border-box; + display: flex; + align-items: center; + flex-grow: 0; + flex-shrink: 0; + justify-content: center; + + width: 1rem; + height: 1rem; + + border: 1px solid #21272b; + border-radius: 0.125rem; + color: #fff; +} + +.indicator:not(.indicatorDisabled):hover { + background-color: #e0e0e0; +} + +.indicatorDisabled { + border-color: #adb0b2; +} + +.indicatorChecked, +.indicatorIndeterminate { + border: none; + background-color: #2b83ba; +} + +.indicatorChecked:not(.indicatorDisabled):hover, +.indicatorIndeterminate:not(.indicatorDisabled):hover { + background-color: #3b99d4; +} + +.indicatorChecked.indicatorDisabled, +.indicatorIndeterminate.indicatorDisabled { + background-color: #adb0b2; +} + +.input:focus-visible + .indicator { + box-shadow: 0 0 0 2px #ffd86a; +} diff --git a/components/checkbox/src/CheckBox.stories.mdx b/components/checkbox/src/CheckBox.stories.mdx new file mode 100644 index 00000000..3ade97d4 --- /dev/null +++ b/components/checkbox/src/CheckBox.stories.mdx @@ -0,0 +1,8 @@ +import { Meta } from '@storybook/addon-docs'; +import { Markdown } from '@storybook/blocks'; + +import Readme from '../README.md'; + + + +{Readme} diff --git a/components/checkbox/src/CheckBox.tests.tsx b/components/checkbox/src/CheckBox.tests.tsx new file mode 100644 index 00000000..4e0e60d6 --- /dev/null +++ b/components/checkbox/src/CheckBox.tests.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { useForm } from 'react-hook-form'; +// eslint-disable-next-line import/no-named-as-default +import userEvent from '@testing-library/user-event'; + +import CheckBox from './CheckBox'; +import CheckBoxLabel from './partials/CheckBoxLabel'; + +interface IFormValues { + isChecked: boolean; +} + +interface IFormProps { + defaultValues: IFormValues; + onSubmit: (values: IFormValues) => void; +} + +const Form = ({ defaultValues, onSubmit }: IFormProps): JSX.Element => { + const { register, handleSubmit } = useForm({ defaultValues }); + + return ( + // eslint-disable-next-line @typescript-eslint/no-misused-promises +
+ {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-expect-error */} + Label + +
+ ); +}; + +describe('components/CheckBox', () => { + test('should render children', () => { + const onChange = vi.fn(); + + render( + + Check box label + + ); + + expect(screen.getByText('Check box label')).toBeInTheDocument(); + }); + + test('should render checked checkbox', () => { + const onChange = vi.fn(); + + render( + + Check box label + + ); + + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + test('should render unchecked checkbox', () => { + const onChange = vi.fn(); + + render( + + Check box label + + ); + + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + test('should render disabled checkbox', () => { + const onChange = vi.fn(); + + render( + + Disabled check box label + + ); + + expect(screen.getByRole('checkbox')).toBeDisabled(); + }); + + test('should render indeterminate checkbox', () => { + const onChange = vi.fn(); + + render( + + Indeterminate check box label + + ); + + expect(screen.getByRole('checkbox')).toBePartiallyChecked(); + }); + + test('should works with react-hook-form correctly', async () => { + const onSubmit = vi.fn(); + const defaultValues = { + isChecked: true + }; + + render(
); + + expect(screen.getByRole('checkbox')).toBeChecked(); + + await userEvent.click(screen.getByRole('checkbox')); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + + await userEvent.click(screen.getByText('Submit')); + expect(onSubmit).toHaveBeenCalledWith({ isChecked: false }, expect.any(Object)); + }); + + test('should throw error without using context', () => { + expect(() => render(some label text)).toThrow(); + }); +}); diff --git a/components/checkbox/src/CheckBox.tsx b/components/checkbox/src/CheckBox.tsx new file mode 100644 index 00000000..acb84e9c --- /dev/null +++ b/components/checkbox/src/CheckBox.tsx @@ -0,0 +1,31 @@ +import React, { JSX, forwardRef } from 'react'; + +import { ICheckBoxProps } from './CheckBox.types'; +import getDefaultCheckBoxClassNames from './utilities/getDefaultCheckBoxClassNames'; + +import CheckBoxContainer from './partials/CheckBoxContainer'; +import CheckBoxIndicator from './partials/CheckBoxIndicator'; +import CheckBoxLabel from './partials/CheckBoxLabel'; + +const CheckBox = forwardRef( + ( + { children, classNames = getDefaultCheckBoxClassNames(), labelPosition = 'right', renderIndicator, ...props }, + ref + ): JSX.Element => ( + + {labelPosition === 'left' && {children}} + + {renderIndicator ? ( + renderIndicator(classNames.indicator) + ) : ( + + )} + + {labelPosition === 'right' && {children}} + + ) +); + +CheckBox.displayName = 'CheckBox'; + +export default CheckBox; diff --git a/components/checkbox/src/CheckBox.types.ts b/components/checkbox/src/CheckBox.types.ts new file mode 100644 index 00000000..8ad8de67 --- /dev/null +++ b/components/checkbox/src/CheckBox.types.ts @@ -0,0 +1,40 @@ +import { InputHTMLAttributes } from 'react'; + +export interface ICheckBoxClassNames { + container?: ICheckBoxContainerClassNames; + indicator?: ICheckBoxIndicatorClassNames; + label?: ICheckBoxLabelClassNames; +} + +export interface ICheckBoxContainerClassNames { + main?: string; + disabled?: string; + input?: string; +} + +export interface ICheckBoxIndicatorClassNames { + main?: string; + checked?: string; + disabled?: string; + indeterminate?: string; +} + +export interface ICheckBoxLabelClassNames { + main?: string; + disabled?: string; +} + +export interface ICheckBoxInputProps + extends Omit, 'checked' | 'disabled' | 'id' | 'type'> { + id?: string; + isChecked: boolean; + isDisabled?: boolean; + isIndeterminate?: boolean; +} + +export interface ICheckBoxProps extends ICheckBoxInputProps { + className?: string; + classNames?: ICheckBoxClassNames; + labelPosition?: 'left' | 'right'; + renderIndicator?: (classNames?: ICheckBoxIndicatorClassNames) => JSX.Element; +} diff --git a/components/checkbox/src/Checkbox.stories.tsx b/components/checkbox/src/Checkbox.stories.tsx new file mode 100644 index 00000000..4dc2810a --- /dev/null +++ b/components/checkbox/src/Checkbox.stories.tsx @@ -0,0 +1,111 @@ +import React, { ComponentType, JSX, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { ICheckBoxProps } from './CheckBox.types'; +import CheckBox from './CheckBox'; + +const meta: Meta = { + component: CheckBox, + title: 'Components/CheckBox', + args: { + children: 'some text', + isChecked: false, + isDisabled: false, + isIndeterminate: false, + labelPosition: 'right', + onChange() {} + } +}; + +type TStory = StoryObj; + +export const Default: TStory = {}; + +export const Checked: TStory = { + args: { + isChecked: true + } +}; + +export const Indeterminate: TStory = { + args: { + isIndeterminate: true + } +}; + +export const Disabled: TStory = { + args: { + isDisabled: true + } +}; + +export const WithoutText: TStory = { + args: { + children: null + } +}; + +export const MultilineText: TStory = { + decorators: [ + (CurrentStory: ComponentType): JSX.Element => ( + // eslint-disable-next-line react/forbid-dom-props +
+ +
+ ) + ], + args: { + children: + 'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Aperiam explicabo maiores mollitia nesciunt, nisi non quam voluptatibus. Debitis dolorem earum eius, esse eum facere iste quo temporibus vero voluptatem voluptatum.' + } +}; + +export const TextLeftPosition: TStory = { + args: { + labelPosition: 'left' + } +}; + +const CheckBoxPlayground = (): JSX.Element => { + const [state1, setState1] = useState({ isChecked: false }); + const [state2, setState2] = useState({ isChecked: true }); + const [state3, setState3] = useState({ isChecked: false, isIndeterminate: true }); + + const prepareState = (isChecked: boolean): ICheckBoxProps => + isChecked ? { isChecked, isIndeterminate: false } : { isChecked }; + + return ( +
+ setState1(prevState => ({ ...prevState, ...prepareState(event.target.checked) }))} + > + Чекбокс 1 + +
+ setState2(prevState => ({ ...prevState, ...prepareState(event.target.checked) }))} + > + Чекбокс 2 + +
+ setState3(prevState => ({ ...prevState, ...prepareState(event.target.checked) }))} + > + Чекбокс 3 + +
+ ); +}; + +export const Playground: TStory = { + args: {}, + render() { + return ; + } +}; + +export default meta; diff --git a/components/checkbox/src/assets/check.svg b/components/checkbox/src/assets/check.svg new file mode 100644 index 00000000..574e7d18 --- /dev/null +++ b/components/checkbox/src/assets/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/components/checkbox/src/assets/indeterminate.svg b/components/checkbox/src/assets/indeterminate.svg new file mode 100644 index 00000000..1e639217 --- /dev/null +++ b/components/checkbox/src/assets/indeterminate.svg @@ -0,0 +1,3 @@ + + + diff --git a/components/checkbox/src/index.ts b/components/checkbox/src/index.ts new file mode 100644 index 00000000..4e68b34c --- /dev/null +++ b/components/checkbox/src/index.ts @@ -0,0 +1,8 @@ +export { default as CheckBoxContainer } from './partials/CheckBoxContainer'; +export { default as CheckBoxContext, useCheckBox } from './partials/CheckBoxContext'; +export { default as CheckBoxIndicator } from './partials/CheckBoxIndicator'; +export { default as CheckBoxLabel } from './partials/CheckBoxLabel'; +export { default } from './CheckBox'; + +export * from './CheckBox.types'; +export { default as getDefaultCheckBoxClassNames } from './utilities/getDefaultCheckBoxClassNames'; diff --git a/components/checkbox/src/partials/CheckBoxContainer.module.css b/components/checkbox/src/partials/CheckBoxContainer.module.css new file mode 100644 index 00000000..cacb50b5 --- /dev/null +++ b/components/checkbox/src/partials/CheckBoxContainer.module.css @@ -0,0 +1,4 @@ +.input { + appearance: none; + position: absolute; +} diff --git a/components/checkbox/src/partials/CheckBoxContainer.tsx b/components/checkbox/src/partials/CheckBoxContainer.tsx new file mode 100644 index 00000000..29186b80 --- /dev/null +++ b/components/checkbox/src/partials/CheckBoxContainer.tsx @@ -0,0 +1,65 @@ +import React, { JSX, ReactNode, forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'; +import { v4 as guid } from 'uuid'; +import cn from 'classnames'; + +import { ICheckBoxContainerClassNames, ICheckBoxInputProps } from '../CheckBox.types'; + +import CheckBoxContext from './CheckBoxContext'; +import styles from './CheckBoxContainer.module.css'; + +export interface ICheckBoxContainerProps extends ICheckBoxInputProps { + children: ReactNode; + className?: string; + classNames?: ICheckBoxContainerClassNames; +} + +const CheckBoxContainer = forwardRef( + ( + { children, className, classNames, id, isChecked, isDisabled = false, isIndeterminate = false, ...otherProps }, + ref + ): JSX.Element => { + const contextValue = useMemo( + () => ({ + id: id ?? guid(), + isChecked, + isDisabled, + isIndeterminate + }), + [id, isChecked, isDisabled, isIndeterminate] + ); + + const inputRef = useRef(null); + + useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); + + useEffect(() => { + if (inputRef.current !== null) { + inputRef.current.indeterminate = contextValue.isIndeterminate; + } + }, [contextValue.isIndeterminate]); + + return ( + + + + ); + } +); + +CheckBoxContainer.displayName = 'CheckBoxContainer'; + +export default CheckBoxContainer; diff --git a/components/checkbox/src/partials/CheckBoxContext.tsx b/components/checkbox/src/partials/CheckBoxContext.tsx new file mode 100644 index 00000000..908c7094 --- /dev/null +++ b/components/checkbox/src/partials/CheckBoxContext.tsx @@ -0,0 +1,22 @@ +import { createContext, useContext } from 'react'; + +interface ICheckBoxContext { + id: string; + isChecked: boolean; + isDisabled: boolean; + isIndeterminate: boolean; +} + +const CheckBoxContext = createContext(null); + +export function useCheckBox(): ICheckBoxContext | null { + const context = useContext(CheckBoxContext); + + if (!context) { + throw new Error('useCheckBox must be used within the CheckBoxContext.Provider'); + } + + return context; +} + +export default CheckBoxContext; diff --git a/components/checkbox/src/partials/CheckBoxIndicator.tsx b/components/checkbox/src/partials/CheckBoxIndicator.tsx new file mode 100644 index 00000000..4badbad2 --- /dev/null +++ b/components/checkbox/src/partials/CheckBoxIndicator.tsx @@ -0,0 +1,38 @@ +import React, { JSX } from 'react'; +import cn from 'classnames'; + +import { ICheckBoxIndicatorClassNames } from '../CheckBox.types'; +import { useCheckBox } from './CheckBoxContext'; + +import CheckIcon from '../assets/check.svg?react'; +import IndeterminateIcon from '../assets/indeterminate.svg?react'; + +export interface ICheckBoxIndicatorProps { + className?: string; + classNames?: ICheckBoxIndicatorClassNames; +} + +const CheckBoxIndicator = ({ className, classNames }: ICheckBoxIndicatorProps): JSX.Element => { + const context = useCheckBox(); + + const isChecked = Boolean(context?.isChecked); + const isDisabled = Boolean(context?.isDisabled); + const isIndeterminate = !isChecked && Boolean(context?.isIndeterminate); + + const containerClassNames = cn( + classNames?.main, + isDisabled && classNames?.disabled, + isChecked && classNames?.checked, + isIndeterminate && classNames?.indeterminate, + className + ); + + return ( +
+ {isChecked && } + {isIndeterminate && } +
+ ); +}; + +export default CheckBoxIndicator; diff --git a/components/checkbox/src/partials/CheckBoxLabel.tsx b/components/checkbox/src/partials/CheckBoxLabel.tsx new file mode 100644 index 00000000..0258b904 --- /dev/null +++ b/components/checkbox/src/partials/CheckBoxLabel.tsx @@ -0,0 +1,21 @@ +import React, { JSX, ReactNode } from 'react'; +import cn from 'classnames'; + +import { ICheckBoxLabelClassNames } from '../CheckBox.types'; +import { useCheckBox } from './CheckBoxContext'; + +export interface ICheckBoxLabelProps { + children: ReactNode; + className?: string; + classNames?: ICheckBoxLabelClassNames; +} + +const CheckBoxLabel = ({ children, className, classNames }: ICheckBoxLabelProps): JSX.Element => { + const context = useCheckBox(); + + const classes = cn(classNames?.main, context?.isDisabled && classNames?.disabled, className); + + return
{children}
; +}; + +export default CheckBoxLabel; diff --git a/components/checkbox/src/utilities/getDefaultCheckBoxClassNames.ts b/components/checkbox/src/utilities/getDefaultCheckBoxClassNames.ts new file mode 100644 index 00000000..a7ff9f6c --- /dev/null +++ b/components/checkbox/src/utilities/getDefaultCheckBoxClassNames.ts @@ -0,0 +1,22 @@ +import { ICheckBoxClassNames } from '../CheckBox.types'; +import styles from '../CheckBox.module.css'; + +const getDefaultCheckBoxClassNames = (): ICheckBoxClassNames => ({ + container: { + main: styles.container, + disabled: styles.containerDisabled, + input: styles.input + }, + indicator: { + main: styles.indicator, + checked: styles.indicatorChecked, + disabled: styles.indicatorDisabled, + indeterminate: styles.indicatorIndeterminate + }, + label: { + main: styles.label, + disabled: styles.labelDisabled + } +}); + +export default getDefaultCheckBoxClassNames; diff --git a/components/checkbox/tsconfig.build.json b/components/checkbox/tsconfig.build.json new file mode 100644 index 00000000..2c767fd6 --- /dev/null +++ b/components/checkbox/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/*.tests.ts", "src/*.stories.tsx"] +} diff --git a/components/checkbox/tsconfig.json b/components/checkbox/tsconfig.json new file mode 100644 index 00000000..98f42aee --- /dev/null +++ b/components/checkbox/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 42fe857c..3b3249f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,18 @@ "react-dom": "^17.0.2" } }, + "components/checkbox": { + "name": "@byndyusoft-ui/checkbox", + "version": "0.0.1", + "license": "Apache-2.0", + "devDependencies": { + "react-hook-form": "^7.43.9" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "components/flex": { "name": "@byndyusoft-ui/flex", "version": "0.0.1", @@ -650,6 +662,10 @@ "node": ">=6.9.0" } }, + "node_modules/@byndyusoft-ui/checkbox": { + "resolved": "components/checkbox", + "link": true + }, "node_modules/@byndyusoft-ui/css-utilities": { "resolved": "styles/utilities", "link": true @@ -8577,6 +8593,23 @@ "react-dom": ">= 16.3" } }, + "node_modules/react-hook-form": { + "version": "7.56.4", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.4.tgz", + "integrity": "sha512-Rob7Ftz2vyZ/ZGsQZPaRdIefkgOSrQSPXfqBdvOPwJfoGnjwRJUs7EM7Kc1mcoDv3NOtqBzPGbcMB8CGn9CKgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "17.0.2", "dev": true,