mirror of
https://github.com/ershisan99/flashcards-example-project.git
synced 2025-12-16 20:59:27 +00:00
Merge master into storybook-deploy
This commit is contained in:
@@ -1,22 +1,20 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
import type { StorybookConfig } from '@storybook/react-vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
"stories": [
|
||||
"../src/**/*.mdx",
|
||||
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-onboarding',
|
||||
'@storybook/addon-interactions',
|
||||
'storybook-addon-react-router-v6',
|
||||
],
|
||||
"addons": [
|
||||
"@storybook/addon-links",
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/addon-onboarding",
|
||||
"@storybook/addon-interactions"
|
||||
],
|
||||
"framework": {
|
||||
"name": "@storybook/react-vite",
|
||||
"options": {}
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
"docs": {
|
||||
"autodocs": "tag"
|
||||
}
|
||||
};
|
||||
export default config;
|
||||
docs: {
|
||||
autodocs: 'tag',
|
||||
},
|
||||
}
|
||||
export default config
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import '@fontsource/roboto/400.css'
|
||||
import '@fontsource/roboto/700.css'
|
||||
import '../src/styles/index.scss'
|
||||
|
||||
import type { Preview } from '@storybook/react'
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default preview
|
||||
53
.storybook/preview.tsx
Normal file
53
.storybook/preview.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Preview } from '@storybook/react'
|
||||
import '@fontsource/roboto/400.css'
|
||||
import '../src/styles/index.scss'
|
||||
import { withRouter } from 'storybook-addon-react-router-v6'
|
||||
import { themes } from '@storybook/theming'
|
||||
import { ToastContainer } from 'react-toastify'
|
||||
import 'react-toastify/dist/ReactToastify.min.css'
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
docs: {
|
||||
theme: themes.dark,
|
||||
},
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{
|
||||
name: 'dark',
|
||||
value: '#000',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
export const decorators = [withRouter, withToasts]
|
||||
export default preview
|
||||
|
||||
function withToasts(Story: any) {
|
||||
return (
|
||||
<>
|
||||
<Story />
|
||||
<ToastContainer
|
||||
position="top-center"
|
||||
autoClose={5000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="dark"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -17,10 +17,16 @@
|
||||
"@hookform/resolvers": "^3.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@storybook/theming": "^7.2.1",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.45.2",
|
||||
"react-router-dom": "^6.14.2",
|
||||
"react-toastify": "^9.1.3",
|
||||
"remeda": "^1.24.0",
|
||||
"storybook-addon-react-router-v6": "^2.0.0",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
552
pnpm-lock.yaml
generated
552
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,62 @@
|
||||
import { SVGProps, Ref, forwardRef, memo } from 'react'
|
||||
const SvgComponent = (props: SVGProps<SVGSVGElement>, ref: Ref<SVGSVGElement>) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} fill="none" ref={ref} {...props}>
|
||||
<circle cx={16} cy={16} r={15.5} fill="gray" stroke="#fff" />
|
||||
<path
|
||||
fill="#fff"
|
||||
fillRule="evenodd"
|
||||
d="M16 18.84a2.49 2.49 0 1 0 0-4.97 2.49 2.49 0 0 0 0 4.97Zm0-.62a1.87 1.87 0 1 0 0-3.73 1.87 1.87 0 0 0 0 3.73Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="#fff"
|
||||
fillRule="evenodd"
|
||||
d="M19 11.38a3.86 3.86 0 0 0-3.02-1.25c-1.59 0-2.28.56-3.02 1.25H10.4c-1.03 0-1.87.83-1.87 1.86v6.23c0 1.03.84 1.86 1.87 1.86h11.2c1.03 0 1.87-.83 1.87-1.86v-6.23c0-1.03-.84-1.86-1.87-1.86H19Zm-.25.62-.06-.06-.18-.17a5.4 5.4 0 0 0-.67-.53 3.15 3.15 0 0 0-1.86-.48c-.92 0-1.44.2-1.87.48a5.4 5.4 0 0 0-.66.53l-.18.17-.07.06h-2.8c-.69 0-1.24.56-1.24 1.24v6.23c0 .68.55 1.24 1.24 1.24h11.2c.69 0 1.24-.56 1.24-1.24v-6.23c0-.68-.55-1.24-1.24-1.24h-2.85Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<svg
|
||||
width="44"
|
||||
height="44"
|
||||
viewBox="0 0 44 44"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<g filter="url(#filter0_d_5918_2450)">
|
||||
<rect x="10" y="8" width="24" height="24" rx="4" fill="#4C4C4C" />
|
||||
<g clipPath="url(#clip0_5918_2450)">
|
||||
<path
|
||||
d="M26.6666 25.3334H17.3333C17.1565 25.3334 16.9869 25.4036 16.8619 25.5286C16.7369 25.6537 16.6666 25.8232 16.6666 26C16.6666 26.1769 16.7369 26.3464 16.8619 26.4714C16.9869 26.5965 17.1565 26.6667 17.3333 26.6667H26.6666C26.8434 26.6667 27.013 26.5965 27.138 26.4714C27.2631 26.3464 27.3333 26.1769 27.3333 26C27.3333 25.8232 27.2631 25.6537 27.138 25.5286C27.013 25.4036 26.8434 25.3334 26.6666 25.3334Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M17.3333 24H17.3933L20.1733 23.7467C20.4778 23.7163 20.7626 23.5821 20.98 23.3667L26.98 17.3667C27.2128 17.1206 27.3387 16.7923 27.3299 16.4537C27.3212 16.115 27.1786 15.7937 26.9333 15.56L25.1066 13.7333C24.8682 13.5094 24.5558 13.3809 24.2288 13.3723C23.9019 13.3637 23.5831 13.4756 23.3333 13.6867L17.3333 19.6867C17.1178 19.904 16.9836 20.1888 16.9533 20.4933L16.6666 23.2733C16.6576 23.371 16.6703 23.4694 16.7037 23.5616C16.7371 23.6538 16.7905 23.7374 16.86 23.8067C16.9222 23.8684 16.9961 23.9173 17.0773 23.9505C17.1586 23.9837 17.2455 24.0005 17.3333 24ZM24.18 14.6667L26 16.4867L24.6666 17.7867L22.88 16L24.18 14.6667ZM18.2466 20.6067L22 16.88L23.8 18.68L20.0666 22.4133L18.0666 22.6L18.2466 20.6067Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_d_5918_2450"
|
||||
x="0"
|
||||
y="0"
|
||||
width="44"
|
||||
height="44"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="2" />
|
||||
<feGaussianBlur stdDeviation="5" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0.429167 0 0 0 0 0.429167 0 0 0 0 0.429167 0 0 0 0.25 0"
|
||||
/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_5918_2450" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_5918_2450"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<clipPath id="clip0_5918_2450">
|
||||
<rect width="16" height="16" fill="white" transform="translate(14 12)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
const ForwardRef = forwardRef(SvgComponent)
|
||||
|
||||
28
src/components/auth/check-email/check-email.module.scss
Normal file
28
src/components/auth/check-email/check-email.module.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
.card {
|
||||
max-width: 413px;
|
||||
padding: 35px 33px 48px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 27px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-bottom: 19px;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-bottom: 65px;
|
||||
color: var(--color-light-900);
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.signInLink {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
18
src/components/auth/check-email/check-email.stories.tsx
Normal file
18
src/components/auth/check-email/check-email.stories.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { CheckEmail } from './'
|
||||
|
||||
const meta = {
|
||||
title: 'Auth/Check email',
|
||||
component: CheckEmail,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof CheckEmail>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
email: 'your_email@domain.com',
|
||||
},
|
||||
}
|
||||
31
src/components/auth/check-email/check-email.tsx
Normal file
31
src/components/auth/check-email/check-email.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { Email } from '../../../assets/icons'
|
||||
import { Button, Card, Typography } from '../../ui'
|
||||
|
||||
import s from './check-email.module.scss'
|
||||
|
||||
type Props = {
|
||||
email: string
|
||||
}
|
||||
|
||||
export const CheckEmail = ({ email }: Props) => {
|
||||
const message = `We've sent an e-mail with instructions to ${email}`
|
||||
|
||||
return (
|
||||
<Card className={s.card}>
|
||||
<Typography variant="large" className={s.title}>
|
||||
Check your email
|
||||
</Typography>
|
||||
<div className={s.iconContainer}>
|
||||
<Email />
|
||||
</div>
|
||||
<Typography variant="body2" className={s.instructions}>
|
||||
{message}
|
||||
</Typography>
|
||||
<Button fullWidth as={Link} to={'/sing-in'}>
|
||||
Back to Sign in
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
1
src/components/auth/check-email/index.ts
Normal file
1
src/components/auth/check-email/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './check-email'
|
||||
@@ -1 +1,5 @@
|
||||
export * from './login-form'
|
||||
export * from './check-email'
|
||||
export * from './new-password'
|
||||
export * from './recover-password'
|
||||
export * from './sign-up'
|
||||
export * from './sign-in'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './login-form'
|
||||
@@ -1,9 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(3),
|
||||
rememberMe: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export type LoginFormData = z.infer<typeof loginSchema>
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { LoginForm } from './login-form'
|
||||
import { LoginFormData } from './login-form.schema'
|
||||
|
||||
const meta = {
|
||||
title: 'Auth/LoginForm',
|
||||
component: LoginForm,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof LoginForm>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
onSubmit: (data: LoginFormData) => console.log(data),
|
||||
},
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { DevTool } from '@hookform/devtools'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
import { LoginFormData, loginSchema } from './login-form.schema'
|
||||
|
||||
import { Button, TextField, ControlledCheckbox } from '@/components'
|
||||
|
||||
type Props = {
|
||||
onSubmit: (data: LoginFormData) => void
|
||||
}
|
||||
|
||||
export const LoginForm = ({ onSubmit }: Props) => {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DevTool control={control} />
|
||||
<TextField {...register('email')} label={'email'} errorMessage={errors.email?.message} />
|
||||
<TextField
|
||||
{...register('password')}
|
||||
label={'password'}
|
||||
errorMessage={errors.password?.message}
|
||||
/>
|
||||
<ControlledCheckbox label={'remember me'} control={control} name={'rememberMe'} />
|
||||
<Button type="submit">Submit</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
1
src/components/auth/new-password/index.ts
Normal file
1
src/components/auth/new-password/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './new-password'
|
||||
38
src/components/auth/new-password/new-password.module.scss
Normal file
38
src/components/auth/new-password/new-password.module.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
.card {
|
||||
max-width: 413px;
|
||||
padding: 33px 36px 60px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
width: 100%;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 51px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.input {
|
||||
margin-bottom: 19px;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-bottom: 41px;
|
||||
color: var(--color-light-900);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.caption {
|
||||
margin-bottom: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signInLink {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
18
src/components/auth/new-password/new-password.stories.tsx
Normal file
18
src/components/auth/new-password/new-password.stories.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { NewPassword } from './'
|
||||
|
||||
const meta = {
|
||||
title: 'Auth/New password',
|
||||
component: NewPassword,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof NewPassword>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onSubmit: data => console.info(data),
|
||||
},
|
||||
}
|
||||
55
src/components/auth/new-password/new-password.tsx
Normal file
55
src/components/auth/new-password/new-password.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { DevTool } from '@hookform/devtools'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Button, Card, ControlledTextField, Typography } from '../../ui'
|
||||
|
||||
import s from './new-password.module.scss'
|
||||
|
||||
const schema = z.object({
|
||||
password: z.string().nonempty('Enter password'),
|
||||
})
|
||||
|
||||
type FormType = z.infer<typeof schema>
|
||||
|
||||
type Props = {
|
||||
onSubmit: (data: FormType) => void
|
||||
}
|
||||
|
||||
export const NewPassword = (props: Props) => {
|
||||
const { control, handleSubmit } = useForm<FormType>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
const handleFormSubmitted = handleSubmit(props.onSubmit)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DevTool control={control} />
|
||||
<Card className={s.card}>
|
||||
<Typography variant="large" className={s.title}>
|
||||
Create new password
|
||||
</Typography>
|
||||
<form onSubmit={handleFormSubmitted}>
|
||||
<ControlledTextField
|
||||
placeholder={'Password'}
|
||||
name={'password'}
|
||||
control={control}
|
||||
type={'password'}
|
||||
containerProps={{ className: s.input }}
|
||||
/>
|
||||
<Typography variant="caption" className={s.instructions}>
|
||||
Create new password and we will send you further instructions to email
|
||||
</Typography>
|
||||
<Button fullWidth type={'submit'}>
|
||||
Create new password
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
1
src/components/auth/recover-password/index.ts
Normal file
1
src/components/auth/recover-password/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './recover-password'
|
||||
@@ -0,0 +1,39 @@
|
||||
.card {
|
||||
max-width: 413px;
|
||||
padding: 33px 36px 29px;
|
||||
}
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
margin-bottom: 19px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-bottom: 31px;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-bottom: 65px;
|
||||
color: var(--color-light-900);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.caption {
|
||||
margin-bottom: 11px;
|
||||
color: var(--color-light-900);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loginLink {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
font-size: var(--font-size-m);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-accent-500);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { RecoverPassword } from './'
|
||||
|
||||
const meta = {
|
||||
title: 'Auth/Recover password',
|
||||
component: RecoverPassword,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof RecoverPassword>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onSubmit: data => console.info(data),
|
||||
},
|
||||
}
|
||||
59
src/components/auth/recover-password/recover-password.tsx
Normal file
59
src/components/auth/recover-password/recover-password.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { DevTool } from '@hookform/devtools'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Button, Card, ControlledTextField, Typography } from '../../ui'
|
||||
|
||||
import s from './recover-password.module.scss'
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Invalid email address').nonempty('Enter email'),
|
||||
})
|
||||
|
||||
type FormType = z.infer<typeof schema>
|
||||
|
||||
type Props = {
|
||||
onSubmit: (data: FormType) => void
|
||||
}
|
||||
|
||||
export const RecoverPassword = (props: Props) => {
|
||||
const { control, handleSubmit } = useForm<FormType>({
|
||||
mode: 'onSubmit',
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
})
|
||||
|
||||
const handleFormSubmitted = handleSubmit(props.onSubmit)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DevTool control={control} />
|
||||
<Card className={s.card}>
|
||||
<Typography variant="large" className={s.title}>
|
||||
Forgot your password?
|
||||
</Typography>
|
||||
<form onSubmit={handleFormSubmitted}>
|
||||
<div className={s.form}>
|
||||
<ControlledTextField placeholder={'Email'} name={'email'} control={control} />
|
||||
</div>
|
||||
<Typography variant="body2" className={s.instructions}>
|
||||
Enter your email address and we will send you further instructions
|
||||
</Typography>
|
||||
<Button className={s.button} fullWidth type={'submit'}>
|
||||
Send Instructions
|
||||
</Button>
|
||||
</form>
|
||||
<Typography variant="body2" className={s.caption}>
|
||||
Did you remember your password?
|
||||
</Typography>
|
||||
<Typography variant="link1" as={Link} to="/sign-in" className={s.loginLink}>
|
||||
Try logging in
|
||||
</Typography>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
1
src/components/auth/sign-in/index.ts
Normal file
1
src/components/auth/sign-in/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './sign-in'
|
||||
55
src/components/auth/sign-in/sign-in.module.scss
Normal file
55
src/components/auth/sign-in/sign-in.module.scss
Normal file
@@ -0,0 +1,55 @@
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 413px;
|
||||
padding: 33px 36px 29px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 27px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recoverPasswordLink,
|
||||
.recoverPasswordLink:visited {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
width: 100%;
|
||||
margin-bottom: 66px;
|
||||
|
||||
color: inherit;
|
||||
text-align: right;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.caption {
|
||||
margin-bottom: 11px;
|
||||
color: var(--color-light-900);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signUpLink {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
font-size: var(--font-size-m);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-accent-500);
|
||||
}
|
||||
18
src/components/auth/sign-in/sign-in.stories.tsx
Normal file
18
src/components/auth/sign-in/sign-in.stories.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { SignIn } from './'
|
||||
|
||||
const meta = {
|
||||
title: 'Auth/Sign in',
|
||||
component: SignIn,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof SignIn>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onSubmit: data => console.info(data),
|
||||
},
|
||||
}
|
||||
87
src/components/auth/sign-in/sign-in.tsx
Normal file
87
src/components/auth/sign-in/sign-in.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { DevTool } from '@hookform/devtools'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Button, Card, ControlledCheckbox, ControlledTextField, Typography } from '../../ui'
|
||||
|
||||
import s from './sign-in.module.scss'
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Invalid email address').nonempty('Enter email'),
|
||||
password: z.string().nonempty('Enter password'),
|
||||
rememberMe: z.boolean().optional(),
|
||||
})
|
||||
|
||||
type FormType = z.infer<typeof schema>
|
||||
|
||||
type Props = {
|
||||
onSubmit: (data: FormType) => void
|
||||
}
|
||||
|
||||
export const SignIn = (props: Props) => {
|
||||
const { control, handleSubmit } = useForm<FormType>({
|
||||
mode: 'onSubmit',
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
},
|
||||
})
|
||||
|
||||
const handleFormSubmitted = handleSubmit(props.onSubmit)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DevTool control={control} />
|
||||
<Card className={s.card}>
|
||||
<Typography variant="large" className={s.title}>
|
||||
Sign In
|
||||
</Typography>
|
||||
<form onSubmit={handleFormSubmitted}>
|
||||
<div className={s.form}>
|
||||
<ControlledTextField
|
||||
placeholder={'Email'}
|
||||
label={'Email'}
|
||||
name={'email'}
|
||||
control={control}
|
||||
/>
|
||||
<ControlledTextField
|
||||
placeholder={'Password'}
|
||||
label={'Password'}
|
||||
type={'password'}
|
||||
name={'password'}
|
||||
control={control}
|
||||
/>
|
||||
</div>
|
||||
<ControlledCheckbox
|
||||
className={s.checkbox}
|
||||
label={'Remember me'}
|
||||
control={control}
|
||||
name={'rememberMe'}
|
||||
position={'left'}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
as={Link}
|
||||
to="/recover-password"
|
||||
className={s.recoverPasswordLink}
|
||||
>
|
||||
Forgot Password?
|
||||
</Typography>
|
||||
<Button className={s.button} fullWidth type={'submit'}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
<Typography className={s.caption} variant="body2">
|
||||
{`Don't have an account?`}
|
||||
</Typography>
|
||||
<Typography variant="link1" as={Link} to="/sign-up" className={s.signUpLink}>
|
||||
Sign Up
|
||||
</Typography>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
1
src/components/auth/sign-up/index.ts
Normal file
1
src/components/auth/sign-up/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './sign-up'
|
||||
38
src/components/auth/sign-up/sign-up.module.scss
Normal file
38
src/components/auth/sign-up/sign-up.module.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 413px;
|
||||
padding: 33px 36px 29px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
width: 100%;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 27px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.caption {
|
||||
margin-bottom: 11px;
|
||||
color: var(--color-light-900);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signInLink {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
font-size: var(--font-size-m);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-accent-500);
|
||||
}
|
||||
18
src/components/auth/sign-up/sign-up.stories.tsx
Normal file
18
src/components/auth/sign-up/sign-up.stories.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { SignUp } from './'
|
||||
|
||||
const meta = {
|
||||
title: 'Auth/Sign up',
|
||||
component: SignUp,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof SignUp>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onSubmit: data => console.info(data),
|
||||
},
|
||||
}
|
||||
95
src/components/auth/sign-up/sign-up.tsx
Normal file
95
src/components/auth/sign-up/sign-up.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { DevTool } from '@hookform/devtools'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { omit } from 'remeda'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Button, Card, ControlledTextField, Typography } from '../../ui'
|
||||
|
||||
import s from './sign-up.module.scss'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
email: z.string().email('Invalid email address').nonempty('Enter email'),
|
||||
password: z.string().nonempty('Enter password'),
|
||||
passwordConfirmation: z.string().nonempty('Confirm your password'),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.password !== data.passwordConfirmation) {
|
||||
ctx.addIssue({
|
||||
message: 'Passwords do not match',
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['passwordConfirmation'],
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
})
|
||||
|
||||
type FormType = z.infer<typeof schema>
|
||||
|
||||
type Props = {
|
||||
onSubmit: (data: Omit<FormType, 'passwordConfirmation'>) => void
|
||||
}
|
||||
|
||||
export const SignUp = (props: Props) => {
|
||||
const { control, handleSubmit } = useForm<FormType>({
|
||||
mode: 'onSubmit',
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
},
|
||||
})
|
||||
|
||||
const handleFormSubmitted = handleSubmit(data =>
|
||||
props.onSubmit(omit(data, ['passwordConfirmation']))
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DevTool control={control} />
|
||||
<Card className={s.card}>
|
||||
<Typography variant="large" className={s.title}>
|
||||
Sign Up
|
||||
</Typography>
|
||||
<form onSubmit={handleFormSubmitted}>
|
||||
<div className={s.form}>
|
||||
<ControlledTextField
|
||||
label={'Email'}
|
||||
placeholder={'Email'}
|
||||
name={'email'}
|
||||
control={control}
|
||||
/>
|
||||
<ControlledTextField
|
||||
placeholder={'Password'}
|
||||
label={'Password'}
|
||||
type={'password'}
|
||||
name={'password'}
|
||||
control={control}
|
||||
/>
|
||||
<ControlledTextField
|
||||
placeholder={'Confirm password'}
|
||||
label={'Confirm password'}
|
||||
type={'password'}
|
||||
name={'passwordConfirmation'}
|
||||
control={control}
|
||||
/>
|
||||
</div>
|
||||
<Button className={s.button} fullWidth type={'submit'}>
|
||||
Sign Up
|
||||
</Button>
|
||||
</form>
|
||||
{/* eslint-disable-next-line react/no-unescaped-entities */}
|
||||
<Typography variant="body2" className={s.caption}>
|
||||
Already have an account?
|
||||
</Typography>
|
||||
<Typography variant="link1" as={Link} to="/sign-in" className={s.signInLink}>
|
||||
Sign In
|
||||
</Typography>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './ui'
|
||||
export * from './auth'
|
||||
export * from './profile'
|
||||
|
||||
1
src/components/profile/index.ts
Normal file
1
src/components/profile/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './personal-information'
|
||||
1
src/components/profile/personal-information/index.ts
Normal file
1
src/components/profile/personal-information/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './personal-information'
|
||||
@@ -0,0 +1,95 @@
|
||||
.card {
|
||||
max-width: 413px;
|
||||
padding: 33px 33px 41px;
|
||||
}
|
||||
|
||||
.photoContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-bottom: 6px;
|
||||
|
||||
> div {
|
||||
position: relative;
|
||||
|
||||
> img {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
> .editAvatarButton {
|
||||
all: unset;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--outline-focus);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 27px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nameWithEditButton {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editNameButton {
|
||||
all: unset;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--outline-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.email {
|
||||
margin-bottom: 13px;
|
||||
color: var(--color-dark-100);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.caption {
|
||||
margin-bottom: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signInLink {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { PersonalInformation } from './'
|
||||
|
||||
const meta = {
|
||||
title: 'Profile/Personal information',
|
||||
component: PersonalInformation,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PersonalInformation>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
email: 'your_email@domain.com',
|
||||
avatar: 'https://picsum.photos/200',
|
||||
name: 'John Doe',
|
||||
onAvatarChange: () => {
|
||||
console.info('avatar changed')
|
||||
},
|
||||
onNameChange: () => {
|
||||
console.info('name changed')
|
||||
},
|
||||
onLogout: () => {
|
||||
console.info('logout')
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Camera, Edit, Logout } from '../../../assets/icons'
|
||||
import { Button, Card, Typography } from '../../ui'
|
||||
|
||||
import s from './personal-information.module.scss'
|
||||
|
||||
type Props = {
|
||||
email: string
|
||||
avatar: string
|
||||
name: string
|
||||
onLogout: () => void
|
||||
onAvatarChange: (newAvatar: string) => void
|
||||
onNameChange: (newName: string) => void
|
||||
}
|
||||
export const PersonalInformation = ({
|
||||
avatar,
|
||||
email,
|
||||
name,
|
||||
onAvatarChange,
|
||||
onNameChange,
|
||||
onLogout,
|
||||
}: Props) => {
|
||||
const handleAvatarChanged = () => {
|
||||
onAvatarChange('new Avatar')
|
||||
}
|
||||
const handleNameChanged = () => {
|
||||
onNameChange('New name')
|
||||
}
|
||||
const handleLogout = () => {
|
||||
onLogout()
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={s.card}>
|
||||
<Typography variant="large" className={s.title}>
|
||||
Personal Information
|
||||
</Typography>
|
||||
<div className={s.photoContainer}>
|
||||
<div>
|
||||
<img src={avatar} alt={'avatar'} />
|
||||
<button className={s.editAvatarButton} onClick={handleAvatarChanged}>
|
||||
<Camera />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.nameWithEditButton}>
|
||||
<Typography variant="h1" className={s.name}>
|
||||
{name}
|
||||
</Typography>
|
||||
<button className={s.editNameButton} onClick={handleNameChanged}>
|
||||
<Edit />
|
||||
</button>
|
||||
</div>
|
||||
<Typography variant="body2" className={s.email}>
|
||||
{/* eslint-disable-next-line react/no-unescaped-entities */}
|
||||
{email}
|
||||
</Typography>
|
||||
<div className={s.buttonContainer}>
|
||||
<Button variant={'secondary'} onClick={handleLogout}>
|
||||
<Logout />
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Control, FieldPath, FieldValues, useController } from 'react-hook-form'
|
||||
|
||||
import { RadioGroup, RadioGroupProps } from '@/components/ui'
|
||||
|
||||
export type ControlledRadioGroupProps<TFieldValues extends FieldValues> = {
|
||||
name: FieldPath<TFieldValues>
|
||||
control: Control<TFieldValues>
|
||||
} & Omit<RadioGroupProps, 'onChange' | 'value' | 'id'>
|
||||
|
||||
export const ControlledRadioGroup = <TFieldValues extends FieldValues>(
|
||||
props: ControlledRadioGroupProps<TFieldValues>
|
||||
) => {
|
||||
const {
|
||||
field: { onChange, ...field },
|
||||
fieldState: { error },
|
||||
} = useController({
|
||||
name: props.name,
|
||||
control: props.control,
|
||||
})
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
{...props}
|
||||
{...field}
|
||||
onValueChange={onChange}
|
||||
errorMessage={error?.message}
|
||||
id={props.name}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './controlled-radio-group'
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Control, FieldPath, FieldValues, useController } from 'react-hook-form'
|
||||
|
||||
import { TextField, TextFieldProps } from '@/components'
|
||||
|
||||
export type ControlledTextFieldProps<TFieldValues extends FieldValues> = {
|
||||
name: FieldPath<TFieldValues>
|
||||
control: Control<TFieldValues>
|
||||
} & Omit<TextFieldProps, 'onChange' | 'value' | 'id'>
|
||||
|
||||
export const ControlledTextField = <TFieldValues extends FieldValues>(
|
||||
props: ControlledTextFieldProps<TFieldValues>
|
||||
) => {
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController({
|
||||
name: props.name,
|
||||
control: props.control,
|
||||
})
|
||||
|
||||
return <TextField {...props} {...field} errorMessage={error?.message} id={props.name} />
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './controlled-text-field'
|
||||
@@ -1 +1,3 @@
|
||||
export * from './controlled-checkbox'
|
||||
export * from './controlled-text-field'
|
||||
export * from './controlled-radio-group'
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './typography'
|
||||
export * from './checkbox'
|
||||
export * from './text-field'
|
||||
export * from './controlled'
|
||||
export * from './radio-group'
|
||||
|
||||
9
src/components/ui/label/label.module.scss
Normal file
9
src/components/ui/label/label.module.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
.label {
|
||||
display: inline-block;
|
||||
|
||||
margin-bottom: 1px;
|
||||
|
||||
font-size: var(--font-size-s);
|
||||
line-height: var(--line-height-m);
|
||||
color: var(var(--color-light-100));
|
||||
}
|
||||
23
src/components/ui/label/label.tsx
Normal file
23
src/components/ui/label/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ComponentPropsWithoutRef, FC, ReactNode } from 'react'
|
||||
|
||||
import * as LabelRadixUI from '@radix-ui/react-label'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import s from './label.module.scss'
|
||||
|
||||
export type LabelProps = {
|
||||
label?: ReactNode
|
||||
} & ComponentPropsWithoutRef<'label'>
|
||||
|
||||
export const Label: FC<LabelProps> = ({ label, children, className, ...rest }) => {
|
||||
const classNames = {
|
||||
label: clsx(s.label, className),
|
||||
}
|
||||
|
||||
return (
|
||||
<LabelRadixUI.Root {...rest}>
|
||||
{label && <div className={classNames.label}>{label}</div>}
|
||||
{children}
|
||||
</LabelRadixUI.Root>
|
||||
)
|
||||
}
|
||||
1
src/components/ui/radio-group/index.ts
Normal file
1
src/components/ui/radio-group/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './radio-group'
|
||||
83
src/components/ui/radio-group/radio-group.module.scss
Normal file
83
src/components/ui/radio-group/radio-group.module.scss
Normal file
@@ -0,0 +1,83 @@
|
||||
.root {
|
||||
cursor: pointer;
|
||||
|
||||
&[data-disabled] {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.option {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
|
||||
> label {
|
||||
cursor: pointer;
|
||||
|
||||
[data-disabled] & {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
box-sizing: border-box;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
transition: var(--transtition-duration-basic) background-color;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
|
||||
display: block;
|
||||
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
border: 2px solid var(--color-accent-500);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
transition: 100ms background-color;
|
||||
|
||||
[data-state='checked'] & {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--color-accent-500);
|
||||
}
|
||||
}
|
||||
|
||||
.option:focus-visible & {
|
||||
background-color: var(--color-dark-700);
|
||||
}
|
||||
|
||||
.option:hover & {
|
||||
background-color: var(--color-dark-500);
|
||||
}
|
||||
|
||||
.option:active & {
|
||||
background-color: var(--color-accent-900);
|
||||
}
|
||||
|
||||
.root[data-disabled] & {
|
||||
background-color: var(--color-dark-900);
|
||||
}
|
||||
}
|
||||
31
src/components/ui/radio-group/radio-group.stories.tsx
Normal file
31
src/components/ui/radio-group/radio-group.stories.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { RadioGroup } from './'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Radio Group',
|
||||
component: RadioGroup,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof RadioGroup>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ label: 'Option One', value: 'option-one' },
|
||||
{ label: 'Option Two', value: 'option-two' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ label: 'Option One', value: 'option-one' },
|
||||
{ label: 'Option Two', value: 'option-two' },
|
||||
],
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
63
src/components/ui/radio-group/radio-group.tsx
Normal file
63
src/components/ui/radio-group/radio-group.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import s from './radio-group.module.scss'
|
||||
|
||||
import { Typography } from '@/components'
|
||||
|
||||
const RadioGroupRoot = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return <RadioGroupPrimitive.Root className={clsx(s.root, className)} {...props} ref={ref} />
|
||||
})
|
||||
|
||||
RadioGroupRoot.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item ref={ref} className={clsx(s.option, className)} {...props}>
|
||||
<div className={s.icon}></div>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
type Option = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
export type RadioGroupProps = Omit<
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>,
|
||||
'children'
|
||||
> & {
|
||||
options: Option[]
|
||||
errorMessage?: string
|
||||
}
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
RadioGroupProps
|
||||
>((props, ref) => {
|
||||
const { options, errorMessage, ...restProps } = props
|
||||
|
||||
return (
|
||||
<RadioGroupRoot {...restProps} ref={ref}>
|
||||
{options.map(option => (
|
||||
<div className={s.label} key={option.value}>
|
||||
<RadioGroupItem value={option.value} id={option.value} />
|
||||
<Typography variant={'body2'} as={'label'} htmlFor={option.value}>
|
||||
{option.label}
|
||||
</Typography>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroupRoot>
|
||||
)
|
||||
})
|
||||
|
||||
export { RadioGroupRoot, RadioGroupItem, RadioGroup }
|
||||
1
src/components/ui/table/index.tsx
Normal file
1
src/components/ui/table/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './table'
|
||||
50
src/components/ui/table/table.module.scss
Normal file
50
src/components/ui/table/table.module.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
.table {
|
||||
border-collapse: collapse;
|
||||
color: var(--color-light-100);
|
||||
border: 1px solid var(--color-dark-500);
|
||||
}
|
||||
|
||||
.headCell {
|
||||
padding: 6px 24px;
|
||||
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: var(--line-height-m);
|
||||
|
||||
background-color: var(--color-dark-500);
|
||||
|
||||
> span {
|
||||
user-select: none;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.sortable > span {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.tableCell {
|
||||
padding: 6px 24px;
|
||||
|
||||
font-size: var(--font-size-s);
|
||||
line-height: var(--line-height-m);
|
||||
|
||||
background-color: var(--color-bg-table);
|
||||
border-bottom: 1px solid var(--color-dark-500);
|
||||
}
|
||||
|
||||
.chevronDown {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
128
src/components/ui/table/table.stories.tsx
Normal file
128
src/components/ui/table/table.stories.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Meta } from '@storybook/react'
|
||||
|
||||
import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeadCell, TableRow } from './'
|
||||
|
||||
import { Typography } from '@/components'
|
||||
|
||||
export default {
|
||||
title: 'Components/Table',
|
||||
component: Table,
|
||||
} as Meta<typeof Table>
|
||||
|
||||
export const Default = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeadCell>Название</TableHeadCell>
|
||||
<TableHeadCell align="center">Описание</TableHeadCell>
|
||||
<TableHeadCell>Ссылка</TableHeadCell>
|
||||
<TableHeadCell>Тип</TableHeadCell>
|
||||
<TableHeadCell>Вид</TableHeadCell>
|
||||
<TableHeadCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Web Basic</TableCell>
|
||||
<TableCell>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
|
||||
incididunt ut sed do eiusmod tempoei usmodr sit amet, consectetur adipiscing elit, sed
|
||||
do...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
as={'a'}
|
||||
variant={'link1'}
|
||||
href="https://it-incubator.io/"
|
||||
target="_blank"
|
||||
>
|
||||
Какая-то ссылка кудато на какой-то источник с информациейо ссылка кудато на какой-то
|
||||
источник
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>Основной</TableCell>
|
||||
<TableCell>Читать</TableCell>
|
||||
<TableCell>🦎</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Web Basic</TableCell>
|
||||
<TableCell>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
|
||||
incididunt ut sed do eiusmod tempoei usmodr sit amet, consectetur adipiscing elit, sed
|
||||
do...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
Какая-то ссылка кудато на какой-то источник с информациейо ссылка кудато на какой-то
|
||||
источник
|
||||
</TableCell>
|
||||
<TableCell>Основной</TableCell>
|
||||
<TableCell>Читать</TableCell>
|
||||
<TableCell>✨</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const data = [
|
||||
{
|
||||
id: '01',
|
||||
title: 'Web Basic',
|
||||
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor',
|
||||
link: 'Какая-то ссылка кудато на какой-то источник с информациейо ссылка кудато на какой-то',
|
||||
category: 'Основной',
|
||||
type: 'Читать',
|
||||
},
|
||||
{
|
||||
id: '02',
|
||||
title: 'Web Basic',
|
||||
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor',
|
||||
link: 'Какая-то ссылка куда-то',
|
||||
category: 'Основной',
|
||||
type: 'Читать',
|
||||
},
|
||||
{
|
||||
id: '03',
|
||||
title: 'Web Basic',
|
||||
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor',
|
||||
link: 'Какая-то ссылка кудато на какой-то источник с информациейо ссылка кудато на какой-то. Какая-то ссылка кудато на какой-то источник с информациейо ссылка куда-то на какой-то',
|
||||
category: 'Основной',
|
||||
type: 'Читать',
|
||||
},
|
||||
]
|
||||
|
||||
export const WithMapMethod = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeadCell>Название</TableHeadCell>
|
||||
<TableHeadCell align="center">Описание</TableHeadCell>
|
||||
<TableHeadCell>Ссылка</TableHeadCell>
|
||||
<TableHeadCell>Тип</TableHeadCell>
|
||||
<TableHeadCell>Вид</TableHeadCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.map(item => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.title}</TableCell>
|
||||
<TableCell>{item.description}</TableCell>
|
||||
<TableCell>{item.link}</TableCell>
|
||||
<TableCell>{item.category}</TableCell>
|
||||
<TableCell>{item.type}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const Empty = {
|
||||
render: () => <TableEmpty />,
|
||||
}
|
||||
77
src/components/ui/table/table.tsx
Normal file
77
src/components/ui/table/table.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { ComponentProps, ComponentPropsWithoutRef, ElementRef, FC, forwardRef } from 'react'
|
||||
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import s from './table.module.scss'
|
||||
|
||||
import { Typography } from '@/components'
|
||||
|
||||
export const Table = forwardRef<HTMLTableElement, ComponentPropsWithoutRef<'table'>>(
|
||||
({ className, ...rest }, ref) => {
|
||||
const classNames = {
|
||||
table: clsx(className, s.table),
|
||||
}
|
||||
|
||||
return <table className={classNames.table} {...rest} ref={ref} />
|
||||
}
|
||||
)
|
||||
export const TableHead = forwardRef<ElementRef<'thead'>, ComponentPropsWithoutRef<'thead'>>(
|
||||
({ ...rest }, ref) => {
|
||||
return <thead {...rest} ref={ref} />
|
||||
}
|
||||
)
|
||||
|
||||
export const TableBody = forwardRef<ElementRef<'tbody'>, ComponentPropsWithoutRef<'tbody'>>(
|
||||
({ ...rest }, ref) => {
|
||||
return <tbody {...rest} ref={ref} />
|
||||
}
|
||||
)
|
||||
|
||||
export const TableRow = forwardRef<ElementRef<'tr'>, ComponentPropsWithoutRef<'tr'>>(
|
||||
({ ...rest }, ref) => {
|
||||
return <tr {...rest} ref={ref} />
|
||||
}
|
||||
)
|
||||
|
||||
export const TableHeadCell = forwardRef<ElementRef<'th'>, ComponentPropsWithoutRef<'th'>>(
|
||||
({ className, children, ...rest }, ref) => {
|
||||
const classNames = {
|
||||
headCell: clsx(className, s.headCell),
|
||||
}
|
||||
|
||||
return (
|
||||
<th className={classNames.headCell} {...rest} ref={ref}>
|
||||
<span>{children}</span>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
)
|
||||
export const TableCell = forwardRef<ElementRef<'td'>, ComponentPropsWithoutRef<'td'>>(
|
||||
({ className, ...rest }, ref) => {
|
||||
const classNames = {
|
||||
cell: clsx(className, s.tableCell),
|
||||
}
|
||||
|
||||
return <td className={classNames.cell} {...rest} ref={ref} />
|
||||
}
|
||||
)
|
||||
|
||||
export const TableEmpty: FC<ComponentProps<'div'> & { mt?: string; mb?: string }> = ({
|
||||
className,
|
||||
mt = '89px',
|
||||
mb,
|
||||
}) => {
|
||||
const classNames = {
|
||||
empty: clsx(className, s.empty),
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography
|
||||
variant={'h2'}
|
||||
className={classNames.empty}
|
||||
style={{ marginTop: mt, marginBottom: mb }}
|
||||
>
|
||||
Пока тут еще нет данных! :(
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user