feat: header component

feat: dropdown component
feat: avatar component
This commit is contained in:
2023-12-30 13:01:04 +01:00
parent 7416196221
commit 0ccc447e40
31 changed files with 726 additions and 58 deletions

View File

@@ -1,7 +1,7 @@
import type { Preview } from '@storybook/react' import type { Preview } from '@storybook/react'
import '@fontsource/roboto/400.css' import '@fontsource/roboto/400.css'
import '../src/styles/index.scss' import '../src/styles/index.scss'
import { withRouter } from 'storybook-addon-react-router-v6' import { reactRouterParameters, withRouter } from 'storybook-addon-react-router-v6'
import { themes } from '@storybook/theming' import { themes } from '@storybook/theming'
import { ToastContainer } from 'react-toastify' import { ToastContainer } from 'react-toastify'
import 'react-toastify/dist/ReactToastify.min.css' import 'react-toastify/dist/ReactToastify.min.css'
@@ -9,6 +9,12 @@ import 'react-toastify/dist/ReactToastify.min.css'
const preview: Preview = { const preview: Preview = {
parameters: { parameters: {
actions: { argTypesRegex: '^on[A-Z].*' }, actions: { argTypesRegex: '^on[A-Z].*' },
reactRouter: reactRouterParameters({
routing: {
handle: 'Nav',
path: '*',
},
}),
controls: { controls: {
matchers: { matchers: {
color: /(background|color)$/i, color: /(background|color)$/i,

View File

@@ -20,6 +20,7 @@
"@hookform/resolvers": "^3.3.3", "@hookform/resolvers": "^3.3.3",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slider": "^1.1.2",

98
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ dependencies:
'@radix-ui/react-dialog': '@radix-ui/react-dialog':
specifier: ^1.0.5 specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0) version: 1.0.5(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-dropdown-menu':
specifier: ^2.0.6
version: 2.0.6(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-label': '@radix-ui/react-label':
specifier: ^2.0.2 specifier: ^2.0.2
version: 2.0.2(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0) version: 2.0.2(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
@@ -2540,6 +2543,33 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false dev: false
/@radix-ui/react-dropdown-menu@2.0.6(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.6
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-id': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-menu': 2.0.6(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@types/react': 18.2.46
'@types/react-dom': 18.2.18
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-focus-guards@1.0.1(@types/react@18.2.46)(react@18.2.0): /@radix-ui/react-focus-guards@1.0.1(@types/react@18.2.46)(react@18.2.0):
resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==} resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==}
peerDependencies: peerDependencies:
@@ -2633,6 +2663,44 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false dev: false
/@radix-ui/react-menu@2.0.6(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.6
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-id': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.0.2(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@types/react': 18.2.46
'@types/react-dom': 18.2.18
aria-hidden: 1.2.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-remove-scroll: 2.5.5(@types/react@18.2.46)(react@18.2.0)
dev: false
/@radix-ui/react-popper@1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0): /@radix-ui/react-popper@1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-1CnGGfFi/bbqtJZZ0P/NQY20xdG3E0LALJaLUEoKwPLwl6PPPfbeiCqMVQnhoFRAxjJj4RpBRJzDmUgsex2tSg==} resolution: {integrity: sha512-1CnGGfFi/bbqtJZZ0P/NQY20xdG3E0LALJaLUEoKwPLwl6PPPfbeiCqMVQnhoFRAxjJj4RpBRJzDmUgsex2tSg==}
peerDependencies: peerDependencies:
@@ -2662,6 +2730,36 @@ packages:
react: 18.2.0 react: 18.2.0
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
/@radix-ui/react-popper@1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.6
'@floating-ui/react-dom': 2.0.4(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-use-rect': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-use-size': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/rect': 1.0.1
'@types/react': 18.2.46
'@types/react-dom': 18.2.18
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-portal@1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0): /@radix-ui/react-portal@1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-xLYZeHrWoPmA5mEKEfZZevoVRK/Q43GfzRXkWV6qawIWWK8t6ifIiLQdd7rmQ4Vk1bmI21XhqF9BN3jWf+phpA==} resolution: {integrity: sha512-xLYZeHrWoPmA5mEKEfZZevoVRK/Q43GfzRXkWV6qawIWWK8t6ifIiLQdd7rmQ4Vk1bmI21XhqF9BN3jWf+phpA==}
peerDependencies: peerDependencies:

View File

@@ -2,19 +2,31 @@ import { Ref, SVGProps, forwardRef, memo } from 'react'
const SvgComponent = (props: SVGProps<SVGSVGElement>, ref: Ref<SVGSVGElement>) => ( const SvgComponent = (props: SVGProps<SVGSVGElement>, ref: Ref<SVGSVGElement>) => (
<svg <svg
fill={'none'} fill={'none'}
height={24} height={16}
ref={ref} ref={ref}
width={24} width={16}
xmlns={'http://www.w3.org/2000/svg'} xmlns={'http://www.w3.org/2000/svg'}
{...props} {...props}
> >
<g clipPath={'url(#a)'} fill={'#000'}> <g clipPath={'url(#clip0_5928_3635)'}>
<path <path
d={ d={
'M7 6a1 1 0 0 0 0-2H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h2a1 1 0 0 0 0-2H6V6h1Zm13.82 5.42-2.82-4a1 1 0 1 0-1.63 1.16L18.09 11H10a1 1 0 0 0 0 2h8l-1.8 2.4a1 1 0 0 0 1.6 1.2l3-4a1 1 0 0 0 .02-1.18Z' 'M4.66669 4.00081C4.8435 4.00081 5.01307 3.93058 5.13809 3.80555C5.26312 3.68053 5.33335 3.51096 5.33335 3.33415C5.33335 3.15734 5.26312 2.98777 5.13809 2.86274C5.01307 2.73772 4.8435 2.66748 4.66669 2.66748H3.33335C3.15654 2.66748 2.98697 2.73772 2.86195 2.86274C2.73693 2.98777 2.66669 3.15734 2.66669 3.33415V12.6675C2.66669 12.8443 2.73693 13.0139 2.86195 13.1389C2.98697 13.2639 3.15654 13.3341 3.33335 13.3341H4.66669C4.8435 13.3341 5.01307 13.2639 5.13809 13.1389C5.26312 13.0139 5.33335 12.8443 5.33335 12.6675C5.33335 12.4907 5.26312 12.3211 5.13809 12.1961C5.01307 12.0711 4.8435 12.0008 4.66669 12.0008H4.00002V4.00081H4.66669Z'
} }
fill={'white'}
/>
<path
d={
'M13.88 7.61411L12 4.94745C11.898 4.80372 11.7433 4.70621 11.5697 4.67623C11.396 4.64625 11.2176 4.68623 11.0733 4.78745C11.0012 4.83797 10.9399 4.90228 10.8927 4.97665C10.8456 5.05103 10.8137 5.134 10.7988 5.22077C10.784 5.30755 10.7864 5.39641 10.8061 5.48223C10.8257 5.56805 10.8622 5.64913 10.9133 5.72078L12.06 7.33411H6.66667C6.48986 7.33411 6.32029 7.40435 6.19526 7.52938C6.07024 7.6544 6 7.82397 6 8.00078C6 8.17759 6.07024 8.34716 6.19526 8.47219C6.32029 8.59721 6.48986 8.66745 6.66667 8.66745H12L10.8 10.2674C10.7475 10.3375 10.7093 10.4172 10.6875 10.502C10.6658 10.5868 10.661 10.6751 10.6734 10.7617C10.6857 10.8484 10.7151 10.9318 10.7597 11.0071C10.8043 11.0824 10.8633 11.1483 10.9333 11.2008C11.0487 11.2873 11.1891 11.3341 11.3333 11.3341C11.4368 11.3341 11.5389 11.31 11.6315 11.2637C11.724 11.2174 11.8046 11.1502 11.8667 11.0674L13.8667 8.40078C13.952 8.28803 13.9994 8.15104 14.0018 8.00962C14.0041 7.86821 13.9615 7.7297 13.88 7.61411Z'
}
fill={'white'}
/> />
</g> </g>
<defs>
<clipPath id={'clip0_5928_3635'}>
<rect fill={'white'} height={'16'} transform={'translate(0 0.000976562)'} width={'16'} />
</clipPath>
</defs>
</svg> </svg>
) )
const ForwardRef = forwardRef(SvgComponent) const ForwardRef = forwardRef(SvgComponent)

View File

@@ -0,0 +1,18 @@
.root {
width: 100%;
height: var(--header-height);
padding: 12px var(--horizontal-padding);
background: var(--color-dark-700);
border-bottom: 1px solid var(--color-dark-500);
}
.content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: var(--max-width);
margin: 0 auto;
}

View File

@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Header } from './'
const meta = {
argTypes: {
onLogout: { action: 'logout' },
},
component: Header,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
title: 'Components/Header',
} satisfies Meta<typeof Header>
export default meta
type Story = StoryObj<typeof meta>
export const LoggedIn: Story = {
// @ts-expect-error onLogout is required but it is provided through argTypes
args: {
avatar: 'https://avatars.githubusercontent.com/u/1196870?v=4',
email: 'johndoe@gmail.com',
isLoggedIn: true,
userName: 'John Doe',
},
}
export const LoggedOut: Story = {
args: {
isLoggedIn: false,
},
}

View File

@@ -0,0 +1,37 @@
import { memo } from 'react'
import { Link } from 'react-router-dom'
import { Logo } from '@/assets'
import { UserDropdown, UserDropdownProps } from '@/components/layout/header/user-dropdown'
import s from './header.module.scss'
import { Button } from '../../ui'
export type HeaderProps =
| (Partial<UserDropdownProps> & {
isLoggedIn: false
})
| (UserDropdownProps & {
isLoggedIn: true
})
export const Header = memo(({ avatar, email, isLoggedIn, onLogout, userName }: HeaderProps) => {
return (
<header className={s.root}>
<div className={s.content}>
<Link to={'/'}>
<Logo />
</Link>
{isLoggedIn && (
<UserDropdown avatar={avatar} email={email} onLogout={onLogout} userName={userName} />
)}
{!isLoggedIn && (
<Button as={Link} to={'/sign-in'}>
Sign In
</Button>
)}
</div>
</header>
)
})

View File

@@ -0,0 +1 @@
export * from './user-dropdown'

View File

@@ -0,0 +1,3 @@
.email {
color: var(--color-dark-100);
}

View File

@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/react'
import { UserDropdown } from './'
const meta = {
argTypes: {
onLogout: { action: 'logout' },
},
component: UserDropdown,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
title: 'Components/UserDropdown',
} satisfies Meta<typeof UserDropdown>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
// @ts-expect-error onLogout is required but it is provided through argTypes
args: {
avatar: 'https://avatars.githubusercontent.com/u/1196870?v=4',
email: 'johndoe@gmail.com',
userName: 'John Doe',
},
}

View File

@@ -0,0 +1,59 @@
import { ComponentPropsWithoutRef } from 'react'
import { Link } from 'react-router-dom'
import { Logout, PersonOutline } from '@/assets'
import {
Avatar,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Typography,
} from '@/components'
import s from './user-dropdown.module.scss'
export type UserDropdownProps = {
avatar: string
email: string
onLogout: ComponentPropsWithoutRef<typeof DropdownMenuItem>['onSelect']
userName: string
}
export const UserDropdown = ({ avatar, email, onLogout, userName }: UserDropdownProps) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button rounded variant={'icon'}>
<Avatar src={avatar} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>
<Avatar src={avatar} />
<div>
<Typography variant={'subtitle2'}>{userName}</Typography>
<Typography className={s.email} variant={'caption'}>
{email}
</Typography>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={'/profile'}>
<PersonOutline />
My profile
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onLogout}>
<Logout />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,4 @@
.avatar {
object-fit: cover;
border-radius: 9999px;
}

View File

@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Avatar } from './'
const meta = {
component: Avatar,
tags: ['autodocs'],
title: 'Components/Avatar',
} satisfies Meta<typeof Avatar>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
src: 'https://avatars.githubusercontent.com/u/1196870?v=4',
},
}

View File

@@ -0,0 +1,23 @@
import { CSSProperties, ComponentPropsWithoutRef } from 'react'
import clsx from 'clsx'
import s from './avatar.module.scss'
export type AvatarProps = ComponentPropsWithoutRef<'img'> & {
size?: CSSProperties['width']
}
export const Avatar = ({ className, size = '36px', style, ...rest }: AvatarProps) => {
return (
<img
className={clsx(className, s.avatar)}
style={{
...style,
height: size,
width: size,
}}
{...rest}
/>
)
}

View File

@@ -0,0 +1 @@
export * from './avatar'

View File

@@ -1,4 +1,4 @@
@mixin button { .button {
all: unset; all: unset;
cursor: pointer; cursor: pointer;
@@ -28,7 +28,8 @@
var(--transition-duration-basic) color; var(--transition-duration-basic) color;
&:focus-visible { &:focus-visible {
outline: 2px solid var(--color-info-700); outline: var(--outline-focus);
outline-offset: 2px;
} }
&:disabled { &:disabled {
@@ -43,8 +44,6 @@
} }
.primary { .primary {
@include button;
color: var(--color-light-100); color: var(--color-light-100);
background-color: var(--color-accent-500); background-color: var(--color-accent-500);
box-shadow: 0 4px 18px rgb(140 97 255 / 35%); box-shadow: 0 4px 18px rgb(140 97 255 / 35%);
@@ -59,8 +58,6 @@
} }
.secondary { .secondary {
@include button;
color: var(--color-light-100); color: var(--color-light-100);
background-color: var(--color-dark-300); background-color: var(--color-dark-300);
box-shadow: 0 2px 10px 0 #6d6d6d40; box-shadow: 0 2px 10px 0 #6d6d6d40;
@@ -75,8 +72,6 @@
} }
.tertiary { .tertiary {
@include button;
color: var(--color-accent-500); color: var(--color-accent-500);
background-color: var(--color-dark-900); background-color: var(--color-dark-900);
border: 1px solid var(--color-accent-700); border: 1px solid var(--color-accent-700);
@@ -91,8 +86,6 @@
} }
.link { .link {
@include button;
padding: 0.375rem 0; padding: 0.375rem 0;
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
@@ -110,4 +103,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&:focus-visible {
outline: var(--outline-focus);
outline-offset: 2px;
}
&.rounded {
border-radius: 9999px;
}
} }

View File

@@ -1,4 +1,13 @@
import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react' import {
ComponentPropsWithoutRef,
ElementRef,
ElementType,
ForwardedRef,
ReactNode,
forwardRef,
} from 'react'
import { clsx } from 'clsx'
import s from './button.module.scss' import s from './button.module.scss'
@@ -7,13 +16,38 @@ export type ButtonProps<T extends ElementType = 'button'> = {
children: ReactNode children: ReactNode
className?: string className?: string
fullWidth?: boolean fullWidth?: boolean
rounded?: boolean
variant?: 'icon' | 'link' | 'primary' | 'secondary' | 'tertiary' variant?: 'icon' | 'link' | 'primary' | 'secondary' | 'tertiary'
} & ComponentPropsWithoutRef<T> } & ComponentPropsWithoutRef<T>
export const Button = <T extends ElementType = 'button'>(props: ButtonProps<T>) => { const ButtonPolymorph = <T extends ElementType = 'button'>(props: ButtonProps<T>, ref: any) => {
const { as: Component = 'button', className, fullWidth, variant = 'primary', ...rest } = props const {
as: Component = 'button',
className,
fullWidth,
rounded,
variant = 'primary',
...rest
} = props
return ( return (
<Component className={`${s[variant]} ${fullWidth ? s.fullWidth : ''} ${className}`} {...rest} /> <Component
className={clsx(
s.button,
s[variant],
fullWidth && s.fullWidth,
className,
rounded && s.rounded
)}
{...rest}
ref={ref}
/>
) )
} }
export const Button = forwardRef(ButtonPolymorph) as <T extends ElementType = 'button'>(
props: ButtonProps<T> &
Omit<ComponentPropsWithoutRef<T>, keyof ButtonProps<T>> & {
ref?: ForwardedRef<ElementRef<T>>
}
) => ReturnType<typeof ButtonPolymorph>

View File

@@ -0,0 +1,117 @@
.item {
all: unset;
cursor: pointer;
user-select: none;
position: relative;
display: flex;
gap: 6px;
align-items: center;
padding: 6px 8px;
line-height: 16px;
border-radius: 4px;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
transition-property:
color,
background-color,
border-color,
fill,
stroke,
-webkit-text-decoration-color;
&:focus-visible {
background-color: var(--color-dark-100);
outline: none;
}
&[data-disabled] {
pointer-events: none;
opacity: 0.5;
}
}
.content {
position: relative;
z-index: 101;
padding: 0 4px;
background-color: var(--color-dark-700);
border: 1px solid var(--color-dark-500);
border-radius: 4px;
&[data-state='open'] {
animation: fade-in 300ms linear;
}
&[data-state='closed'] {
animation: fade-out 300ms linear;
}
.arrow {
position: absolute;
top: -3.75px;
right: calc(50% - 3px);
transform: rotate(45deg);
width: 9px;
height: 9px;
background-color: var(--color-dark-700);
border: 1px solid var(--color-dark-500);
border-top: none;
border-left: none;
stroke-width: 2px;
}
}
.itemsBox {
> :first-child {
margin-top: 6px;
}
> :last-child {
margin-bottom: 6px;
}
}
.separator {
height: 1px;
margin: 6px 0.5rem;
background: var(--color-dark-300);
}
.label {
display: flex;
gap: 8px;
align-items: center;
padding: 6px 8px;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@@ -0,0 +1,80 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Edit2Outline, Logout, PersonOutline, PlayCircleOutline, TrashOutline } from '@/assets'
import {
Avatar,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components'
const meta = {
component: DropdownMenu,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
title: 'Components/Dropdown',
} satisfies Meta<typeof DropdownMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Learn: Story = {
args: {},
render: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>Open</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<PlayCircleOutline />
Learn
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Edit2Outline />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<TrashOutline />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
}
export const HeaderDropdown: Story = {
args: {},
render: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button rounded variant={'icon'}>
<Avatar src={'https://avatars.githubusercontent.com/u/1196870?v=4'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<PlayCircleOutline />
Learn
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PersonOutline />
My profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Logout />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
}

View File

@@ -0,0 +1,78 @@
import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { clsx } from 'clsx'
import s from './dropdown.module.scss'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuContent = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Content>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ children, className, sideOffset = 8, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
className={clsx(s.content, className)}
ref={ref}
sideOffset={sideOffset}
{...props}
>
<div>
<DropdownMenuPrimitive.Arrow asChild className={s.arrowBox}>
<div className={s.arrow} />
</DropdownMenuPrimitive.Arrow>
<div className={s.itemsBox}>{children}</div>
</div>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Item>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item className={clsx(s.item, className)} ref={ref} {...props} />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuSeparator = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Separator>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator className={clsx(s.separator, className)} ref={ref} {...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuLabel = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Label>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label className={clsx(s.label, className)} ref={ref} {...props} />
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
}

View File

@@ -0,0 +1 @@
export * from './dropdown'

View File

@@ -1,17 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Header } from './'
const meta = {
component: Header,
parameters: { layout: 'fullscreen' },
tags: ['autodocs'],
title: 'Components/Header',
} satisfies Meta<typeof Header>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}

View File

@@ -1,11 +0,0 @@
import s from './header.module.scss'
export type HeaderProps = {
avatar?: string
name?: string
onLogout?: () => void
}
export const Header = ({}: HeaderProps) => {
return <div>Header</div>
}

View File

@@ -1,4 +1,6 @@
export * from './header' export * from './avatar'
export * from './dropdown'
export * from '../layout/header'
export * from './button' export * from './button'
export * from './card' export * from './card'
export * from './typography' export * from './typography'

View File

@@ -5,20 +5,13 @@ import {
DecksResponse, DecksResponse,
GetDecksArgs, GetDecksArgs,
UpdateDeckArgs, UpdateDeckArgs,
} from './decks.types' baseApi,
import { baseApi } from '@/services' } from '@/services'
const decksService = baseApi.injectEndpoints({ const decksService = baseApi.injectEndpoints({
endpoints: builder => ({ endpoints: builder => ({
createDeck: builder.mutation<DeckResponse, CreateDeckArgs>({ createDeck: builder.mutation<DeckResponse, CreateDeckArgs>({
invalidatesTags: ['Decks'], invalidatesTags: ['Decks'],
onQueryStarted: async (_, { dispatch, getCacheEntry, getState, queryFulfilled }) => {
const data = getCacheEntry()
const state = getState()
decksService.util.re
await queryFulfilled
},
query: body => ({ query: body => ({
body, body,
method: 'POST', method: 'POST',

View File

@@ -25,8 +25,15 @@ option {
color: inherit; color: inherit;
} }
a,
a:visited { a:visited {
color: inherit; display: flex;
color: currentcolor;
&:focus-visible {
outline: var(--outline-focus);
outline-offset: 2px;
}
} }
body { body {

29
src/styles/_mixins.scss Normal file
View File

@@ -0,0 +1,29 @@
@mixin sm-up {
@media screen and (width > 600px) {
@content;
}
}
@mixin md-up {
@media screen and (width > 768px) {
@content;
}
}
@mixin lg-up {
@media screen and (width > 1024px) {
@content;
}
}
@mixin xl-up {
@media screen and (width > 1280px) {
@content;
}
}
@mixin xxl-up {
@media screen and (width > 1536px) {
@content;
}
}

View File

@@ -1,3 +1,5 @@
@use 'mixins' as *;
:root { :root {
/* outlines */ /* outlines */
--color-outline-focus: var(--color-info-900); --color-outline-focus: var(--color-info-900);
@@ -11,4 +13,12 @@
/* header */ /* header */
--header-height: 60px; --header-height: 60px;
/* max-width */
--horizontal-padding: 16px;
--max-width: calc(1005px + var(--horizontal-padding) * 2);
@include md-up {
--horizontal-padding: 24px;
}
} }

View File

@@ -1,3 +1,4 @@
@forward 'mixins';
@forward 'colors'; @forward 'colors';
@forward 'typography'; @forward 'typography';
@forward 'boilerplate'; @forward 'boilerplate';