diff --git a/.storybook/manager.js b/.storybook/manager.js new file mode 100644 index 0000000..8da4685 --- /dev/null +++ b/.storybook/manager.js @@ -0,0 +1,38 @@ +import { addons } from '@storybook/manager-api'; +import { create } from '@storybook/theming/create'; + +addons.setConfig({ + theme: create({ + base: 'light', + // Typography + fontBase: `'Barlow', + 'Open Sans', + sans-serif, + -apple-system, + BlinkMacSystemFont, + Segoe UI, + Helvetica, + Arial, + sans-serif, + Apple Color Emoji, + Segoe UI Emoji`, + + brandTitle: 'bmates-ui', + brandUrl: 'https://github.com/Bandmators/bmates-ui', + brandImage: 'https://avatars.githubusercontent.com/u/157222787?s=50', + + colorPrimary: '#212121', + colorSecondary: '#585C6D', + + // UI + appBg: '#ffffff', + appContentBg: '#ffffff', + appPreviewBg: '#ffffff', + appBorderColor: '#61616142', + appBorderRadius: 4, + + // Text colors + textColor: '#212121', + textInverseColor: '#ffffff', + }), +}); diff --git a/.storybook/preview.ts b/.storybook/preview.ts index d451eb8..67e8b39 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -7,6 +7,9 @@ import theme from '../src/styles/theme'; const preview: Preview = { parameters: { + backgrounds: { + default: 'light', + }, actions: { argTypesRegex: '^on[A-Z].*' }, controls: { matchers: { diff --git a/package.json b/package.json index 11b4757..2492251 100644 --- a/package.json +++ b/package.json @@ -63,9 +63,11 @@ "@storybook/addon-themes": "^7.6.5", "@storybook/blocks": "7.6.5", "@storybook/builder-vite": "^7.6.5", + "@storybook/manager-api": "^7.6.10", "@storybook/react": "7.6.5", "@storybook/react-vite": "7.6.5", "@storybook/test": "7.6.5", + "@storybook/theming": "^7.6.10", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.1.2", "@trivago/prettier-plugin-sort-imports": "^4.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 114e741..4621d34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,9 @@ devDependencies: '@storybook/builder-vite': specifier: ^7.6.5 version: 7.6.5(typescript@5.3.3)(vite@5.0.10) + '@storybook/manager-api': + specifier: ^7.6.10 + version: 7.6.10(react-dom@18.2.0)(react@18.2.0) '@storybook/react': specifier: 7.6.5 version: 7.6.5(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3) @@ -67,6 +70,9 @@ devDependencies: '@storybook/test': specifier: 7.6.5 version: 7.6.5(@types/jest@29.5.11)(vitest@1.2.0) + '@storybook/theming': + specifier: ^7.6.10 + version: 7.6.10(react-dom@18.2.0)(react@18.2.0) '@testing-library/jest-dom': specifier: ^6.2.0 version: 6.2.0(@types/jest@29.5.11)(vitest@1.2.0) @@ -3382,10 +3388,10 @@ packages: '@storybook/components': 7.6.5(@types/react-dom@18.2.18)(@types/react@18.2.45)(react-dom@18.2.0)(react@18.2.0) '@storybook/core-common': 7.6.5 '@storybook/core-events': 7.6.5 - '@storybook/manager-api': 7.6.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/manager-api': 7.6.10(react-dom@18.2.0)(react@18.2.0) '@storybook/node-logger': 7.6.5 '@storybook/preview-api': 7.6.5 - '@storybook/theming': 7.6.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/theming': 7.6.10(react-dom@18.2.0)(react@18.2.0) '@storybook/types': 7.6.5 css-loader: 6.8.1(webpack@5.89.0) less: 4.2.0 @@ -3537,6 +3543,17 @@ packages: - supports-color dev: true + /@storybook/channels@7.6.10: + resolution: {integrity: sha512-ITCLhFuDBKgxetuKnWwYqMUWlU7zsfH3gEKZltTb+9/2OAWR7ez0iqU7H6bXP1ridm0DCKkt2UMWj2mmr9iQqg==} + dependencies: + '@storybook/client-logger': 7.6.10 + '@storybook/core-events': 7.6.10 + '@storybook/global': 5.0.0 + qs: 6.11.2 + telejson: 7.2.0 + tiny-invariant: 1.3.1 + dev: true + /@storybook/channels@7.6.5: resolution: {integrity: sha512-FIlNkyfQy9uHoJfAFL2/wO3ASGJELFvBzURBE2rcEF/TS7GcUiqWnBfiDxAbwSEjSOm2F0eEq3UXhaZEjpJHDw==} dependencies: @@ -3600,6 +3617,12 @@ packages: - utf-8-validate dev: true + /@storybook/client-logger@7.6.10: + resolution: {integrity: sha512-U7bbpu21ntgePMz/mKM18qvCSWCUGCUlYru8mgVlXLCKqFqfTeP887+CsPEQf29aoE3cLgDrxqbRJ1wxX9kL9A==} + dependencies: + '@storybook/global': 5.0.0 + dev: true + /@storybook/client-logger@7.6.5: resolution: {integrity: sha512-S5aROWgssqg7tcs9lgW5wmCAz4SxMAtioiyVj5oFecmPCbQtFVIAREYzeoxE4GfJL+plrfRkum4BzziANn8EhQ==} dependencies: @@ -3688,6 +3711,12 @@ packages: - supports-color dev: true + /@storybook/core-events@7.6.10: + resolution: {integrity: sha512-yccDH67KoROrdZbRKwxgTswFMAco5nlCyxszCDASCLygGSV2Q2e+YuywrhchQl3U6joiWi3Ps1qWu56NeNafag==} + dependencies: + ts-dedent: 2.2.0 + dev: true + /@storybook/core-events@7.6.5: resolution: {integrity: sha512-zk2q/qicYXAzHA4oV3GDbIql+Kd4TOHUgDE8e4jPCOPp856z2ScqEKUAbiJizs6eEJOH4nW9Db1kuzgrBVEykQ==} dependencies: @@ -3817,6 +3846,28 @@ packages: util: 0.12.5 dev: true + /@storybook/manager-api@7.6.10(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-8eGVpRlpunuFScDtc7nxpPJf/4kJBAAZlNdlhmX09j8M3voX6GpcxabBamSEX5pXZqhwxQCshD4IbqBmjvadlw==} + dependencies: + '@storybook/channels': 7.6.10 + '@storybook/client-logger': 7.6.10 + '@storybook/core-events': 7.6.10 + '@storybook/csf': 0.1.2 + '@storybook/global': 5.0.0 + '@storybook/router': 7.6.10 + '@storybook/theming': 7.6.10(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.6.10 + dequal: 2.0.3 + lodash: 4.17.21 + memoizerific: 1.11.3 + store2: 2.14.2 + telejson: 7.2.0 + ts-dedent: 2.2.0 + transitivePeerDependencies: + - react + - react-dom + dev: true + /@storybook/manager-api@7.6.5(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-tE3OShOcs6A3XtI3NJd6hYQOZLaP++Fn0dCtowBwYh/vS1EN/AyroVmL97tsxn1DZTyoRt0GidwbB6dvLMBOwA==} dependencies: @@ -3956,6 +4007,14 @@ packages: - supports-color dev: true + /@storybook/router@7.6.10: + resolution: {integrity: sha512-G/H4Jn2+y8PDe8Zbq4DVxF/TPn0/goSItdILts39JENucHiuGBCjKjSWGBe1rkwKi1tUbB3yhxJVrLagxFEPpQ==} + dependencies: + '@storybook/client-logger': 7.6.10 + memoizerific: 1.11.3 + qs: 6.11.2 + dev: true + /@storybook/router@7.6.5: resolution: {integrity: sha512-QiTC86gRuoepzzmS6HNJZTwfz/n27NcqtaVEIxJi1Yvsx2/kLa9NkRhylNkfTuZ1gEry9stAlKWanMsB2aKyjQ==} dependencies: @@ -4002,6 +4061,20 @@ packages: - vitest dev: true + /@storybook/theming@7.6.10(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-f5tuy7yV3TOP3fIboSqpgLHy0wKayAw/M8HxX0jVET4Z4fWlFK0BiHJabQ+XEdAfQM97XhPFHB2IPbwsqhCEcQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) + '@storybook/client-logger': 7.6.10 + '@storybook/global': 5.0.0 + memoizerific: 1.11.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: true + /@storybook/theming@7.6.5(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-RpcWT0YEgiobO41McVPDfQQHHFnjyr1sJnNTPJIvOUgSfURdgSj17mQVxtD5xcXcPWUdle5UhIOrCixHbL/NNw==} peerDependencies: @@ -4016,6 +4089,15 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: true + /@storybook/types@7.6.10: + resolution: {integrity: sha512-hcS2HloJblaMpCAj2axgGV+53kgSRYPT0a1PG1IHsZaYQILfHSMmBqM8XzXXYTsgf9250kz3dqFX1l0n3EqMlQ==} + dependencies: + '@storybook/channels': 7.6.10 + '@types/babel__core': 7.20.5 + '@types/express': 4.17.21 + file-system-cache: 2.3.0 + dev: true + /@storybook/types@7.6.5: resolution: {integrity: sha512-Q757v+fYZZSaEpks/zDL5YgXRozxkgKakXFc+BoQHK5q5sVhJ+0jvpLJiAQAniIIaMIkqY/G24Kd6Uo6UdKBCg==} dependencies: diff --git a/src/__tests__/ui.test.tsx b/src/__tests__/ui.test.tsx index cb3e9c5..e00fe13 100644 --- a/src/__tests__/ui.test.tsx +++ b/src/__tests__/ui.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { Route } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; -import { Button, Form, Input, InputDesc, InputGroup, Label, Textarea } from '../'; +import { Button, Checkbox, Form, Input, InputDesc, InputGroup, Label, Textarea } from '../'; import { MockRouter, fireEvent, render, screen, waitFor } from '../libs/test'; describe('UI test', () => { @@ -46,8 +46,49 @@ describe('UI test', () => { fireEvent.click(submitBtn); await waitFor(() => { - // expect(screen.getByText('End')).toBeInTheDocument(); expect(handleSubmitFn).toHaveBeenCalledTimes(1); }); }); + + it('Checkbox icon must be customizable and work when clicked.', () => { + render( +
+ + + + } + /> +
+ +

+ You agree to our Terms of Service and Privacy Policy. +

+
+
, + ); + + const checkbox = screen.getByRole('checkbox-test'); + expect(checkbox).toBeInTheDocument(); + + const checkIcon = document.querySelector('svg[role="checkbox-icon"]'); + expect(checkIcon).toBeInTheDocument(); + + const styledEl = checkIcon?.parentElement; + expect(styledEl).toBeInTheDocument(); + + if (styledEl) { + const beforeBgStyle = styledEl.style.background; + fireEvent.click(checkbox); + + expect(styledEl).not.toHaveStyle(`background: ${beforeBgStyle}`); + } else { + console.error('styledEl is null.'); + } + }); }); diff --git a/src/components/common/Checkbox/index.tsx b/src/components/common/Checkbox/index.tsx new file mode 100644 index 0000000..77be4ff --- /dev/null +++ b/src/components/common/Checkbox/index.tsx @@ -0,0 +1,121 @@ +import styled from '@emotion/styled'; +import * as React from 'react'; + +import { composeEventHandlers } from '@/libs/event'; + +interface CheckboxProps extends React.ComponentPropsWithoutRef<'input'> { + /* + Checkbox checked value + */ + checked?: boolean; + /* + Checkbox Label (Like Description) + */ + label?: string; + /* + Checkbox id value + */ + id?: string; + /* + Checkbox Icon Element + */ + iconEl?: React.ReactNode; + /* + Checkbox align style (css 'algin-items') + */ + align?: React.CSSProperties['alignItems']; +} + +const Checkbox = React.forwardRef( + ( + { className, checked = false, label, onChange, id, children, iconEl, align = 'center', disabled = false, ...props }, + ref, + ) => { + const [chk, setChk] = React.useState(checked); + + React.useEffect(() => { + setChk(checked); + }, [checked]); + + const onChangeHandler = (e: React.ChangeEvent) => { + setChk(e.target.checked); + }; + + return ( + + + + {iconEl ? ( + iconEl + ) : ( + + + + )} + + {label && {label}} + {children &&
{children}
} +
+ ); + }, +); +Checkbox.displayName = 'Checkbox'; + +const CheckboxLabel = styled.span` + font-weight: 500; + margin-left: 1rem; +`; + +const BMatesCheckbox = Checkbox as typeof Checkbox & { + Label: typeof CheckboxLabel; +}; +BMatesCheckbox.Label = CheckboxLabel; +export { BMatesCheckbox as Checkbox }; + +const CheckboxContainer = styled.label<{ align: React.CSSProperties['alignItems']; disabled: boolean }>` + display: inline-flex; + align-items: ${props => props.align}; + line-height: 1; + ${props => props.disabled && 'opacity: 0.5;'} +`; + +const StyledCheckbox = styled.div<{ checked: boolean; disabled: boolean }>` + display: inline-flex; + width: 1rem; + height: 1rem; + background: ${props => (props.checked ? props.theme.colors.primary : 'transparent')}; + border-radius: 4px; + transition: all 150ms; + border: 1px solid ${({ theme }) => theme.colors.grey}; + cursor: pointer; + ${props => props.disabled && 'cursor: not-allowed;'} + svg { + visibility: ${props => (props.checked ? 'visible' : 'hidden')}; + } +`; + +const Icon = styled.svg` + fill: none; + stroke: white; + stroke-width: 2px; +`; + +const HiddenCheckbox = styled.input` + clip: rect(0 0 0 0); + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0px; + border: none; + position: absolute; + white-space: nowrap; +`; diff --git a/src/components/common/Label/index.tsx b/src/components/common/Label/index.tsx index 57e1197..a037708 100644 --- a/src/components/common/Label/index.tsx +++ b/src/components/common/Label/index.tsx @@ -5,7 +5,6 @@ export interface LabelProps extends React.ComponentPropsWithoutRef<'label'> {} const StyledLabel = styled.label` font-weight: 500; - font-size: 0.875rem; line-height: 1; `; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index c96b74b..ba7644b 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -6,3 +6,4 @@ export * from './Input'; export * from './Label'; export * from './Textarea'; export * from './Tooltip'; +export * from './Checkbox'; diff --git a/src/libs/event.ts b/src/libs/event.ts new file mode 100644 index 0000000..cdbdc24 --- /dev/null +++ b/src/libs/event.ts @@ -0,0 +1,12 @@ +export const composeEventHandlers = ( + elementEventHandler?: (event: E) => void, + bmatesEventHandler?: (event: E) => void, +) => { + return (event: E) => { + elementEventHandler?.(event); + + if (!(event as unknown as Event).defaultPrevented) { + return bmatesEventHandler?.(event); + } + }; +}; diff --git a/src/stories/common/Checkbox.stories.tsx b/src/stories/common/Checkbox.stories.tsx new file mode 100644 index 0000000..2a42b46 --- /dev/null +++ b/src/stories/common/Checkbox.stories.tsx @@ -0,0 +1,150 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { Card, Checkbox, Label } from '../..'; + +const meta = { + title: 'common/Checkbox', + component: Checkbox, + tags: ['autodocs'], + args: {}, + argTypes: { + iconEl: { + description: 'Customize Check Icon', + table: { + category: 'Customize', + subcategory: 'Icon', + defaultValue: { + summary: 'svg', + detail: '', + }, + type: { summary: 'React.ReactNode', detail: 'SVG Element' }, + }, + control: { + type: 'select', + options: [5, 10, 15, 20, 30, 40, 60, 80, 100], + }, + }, + }, + parameters: { + componentSubtitle: 'Base Checkbox', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; +export const Default: Story = { + args: {}, +}; + +export const Multiple: Story = { + args: {}, + decorators: [ + Story => { + return ( +
+ + + +
+ ); + }, + ], +}; + +export const Disabled: Story = { + args: { + checked: true, + disabled: true, + label: 'Disabled', + }, +}; + +export const WithLabelProps: Story = { + args: { + label: 'Label', + id: 'with-label', + }, +}; + +export const WithLabelChild: Story = { + parameters: { + docs: { + description: { + story: 'It is same that use label props.\nUnless there are special cases, use label props.', + }, + }, + }, + args: { + children: Use Checkbox.Label, + }, +}; + +export const WithText: Story = { + args: { + id: 'checkbox-with-text', + }, + decorators: [ + Story => { + return ( +
+ +
+ +

+ You agree to our Terms of Service and Privacy Policy. +

+
+
+ ); + }, + ], +}; + +export const WithChild: Story = { + parameters: { + docs: { + description: { + story: 'If you use children, then it has checkbox event', + }, + }, + }, + args: { + children: Click me, + }, +}; + +export const NoneChild: Story = { + parameters: { + docs: { + description: { + story: 'If you dont want to have a checkbox event, dont use children.', + }, + }, + }, + args: {}, + decorators: [ + Story => { + return ( +
+ + Click me (Nothing changed) +
+ ); + }, + ], +}; + +export const ChangeIcon: Story = { + args: { + iconEl: ( + + + + ), + label: `Use 'iconEl' like this.`, + }, +};