mirror of
https://github.com/ershisan99/flashcards-docs.git
synced 2025-12-16 12:33:17 +00:00
277 lines
8.4 KiB
Plaintext
277 lines
8.4 KiB
Plaintext
import { Callout } from 'nextra/components'
|
||
import { FileTree } from 'nextra-theme-docs'
|
||
|
||
# Компоненты, полиморфные компоненты
|
||
|
||
## Button
|
||
|
||
### Подготовка
|
||
|
||
- Создайте папку _src/components/ui_
|
||
|
||
- Создайте папку _button_ в _src/components/ui_ со следующей структурой:
|
||
|
||
<FileTree>
|
||
<FileTree.Folder name={'src'} defaultOpen>
|
||
<FileTree.Folder name={'components'} defaultOpen>
|
||
<FileTree.Folder name={'ui'} defaultOpen>
|
||
<FileTree.Folder name={'button'} defaultOpen>
|
||
<FileTree.File name={'button.tsx'} />
|
||
<FileTree.File name={'button.module.scss'} />
|
||
<FileTree.File name={'button.stories.ts'} />
|
||
<FileTree.File name={'index.ts'} />
|
||
</FileTree.Folder>
|
||
<FileTree.File name={'index.ts'} />
|
||
</FileTree.Folder>
|
||
<FileTree.File name={'index.ts'} />
|
||
</FileTree.Folder>
|
||
|
||
</FileTree.Folder>
|
||
</FileTree>
|
||
|
||
### Дизайн и варианты
|
||
|
||
После рассмотрения дизайна стало понятно, что у нас будет несколько вариантов кнопок, а именно:
|
||
|
||

|
||
|
||
- Стандартная, она же основная, она же `primary`
|
||
- Стандартная, она же основная, она же `primary` с иконкой
|
||
- Второстепенная, она же `secondary`
|
||
- Второстепенная, она же `secondary` с иконкой
|
||
- Кнопка шириной в 100%, она же fullWidth, может быть как `primary`, так и `secondary`
|
||
|
||
### Варианты реализации
|
||
|
||
Какие варианты реализации у нас есть?
|
||
|
||
1. Создать один компонент, который будет принимать все возможные пропсы и в зависимости от них будет
|
||
рендериться тот или иной вариант кнопки
|
||
2. Создать отдельный компонент для каждого варианта кнопки
|
||
|
||
Мы отдадим предпочтение **первому варианту**, так как он более гибкий и позволит нам легко добавлять
|
||
новые варианты кнопок в будущем.
|
||
|
||
### Props
|
||
|
||
Опишем пропсы, которые будет принимать наш компонент:
|
||
|
||
```tsx filename="button.tsx"
|
||
import { ComponentPropsWithoutRef } from 'react'
|
||
|
||
export type ButtonProps = {
|
||
variant?: 'primary' | 'secondary'
|
||
fullWidth?: boolean
|
||
} & ComponentPropsWithoutRef<'button'>
|
||
```
|
||
|
||
`ComponentPropsWithoutRef<'button'>` - это пропсы, которые принимает стандартный html-тег button, мы
|
||
их расширяем своими пропсами.
|
||
|
||
### Реализация
|
||
|
||
Создадим сам компонент:
|
||
|
||
```tsx filename="button.tsx"
|
||
import s from './button.module.scss'
|
||
|
||
export const Button = ({ className, fullWidth, variant = 'primary', ...rest }: ButtonProps) => {
|
||
return (
|
||
<button
|
||
className={`${s.button} ${s[variant]} ${fullWidth ? s.fullWidth : ''} ${className}`}
|
||
{...rest}
|
||
/>
|
||
)
|
||
}
|
||
```
|
||
|
||
Для проверки добавим стили в _button.module.scss_:
|
||
|
||
```scss filename="button.module.scss"
|
||
.button {
|
||
all: unset;
|
||
cursor: pointer;
|
||
box-sizing: border-box;
|
||
color: inherit;
|
||
font-family: inherit;
|
||
|
||
&:focus-visible {
|
||
outline: 2px solid var(--color-info-500);
|
||
}
|
||
}
|
||
|
||
.primary {
|
||
background-color: var(--color-primary-500);
|
||
}
|
||
|
||
.secondary {
|
||
background-color: var(--color-dark-300);
|
||
}
|
||
|
||
.fullWidth {
|
||
width: 100%;
|
||
}
|
||
```
|
||
|
||
<Callout type="info">Стили согласно дизайну напишите сами</Callout>
|
||
|
||
- В файл _index.ts_ добавим экспорт компонента:
|
||
|
||
```ts filename="src/components/ui/button/index.ts"
|
||
export * from './button'
|
||
```
|
||
|
||
- Добавим stories для компонента:
|
||
|
||
```tsx filename="button.stories.ts"
|
||
import type { Meta, StoryObj } from '@storybook/react'
|
||
|
||
import { Button } from './'
|
||
|
||
const meta = {
|
||
title: 'Components/Button',
|
||
component: Button,
|
||
tags: ['autodocs'],
|
||
argTypes: {
|
||
variant: {
|
||
options: ['primary', 'secondary'],
|
||
control: { type: 'radio' },
|
||
},
|
||
},
|
||
} satisfies Meta<typeof Button>
|
||
|
||
export default meta
|
||
type Story = StoryObj<typeof meta>
|
||
|
||
export const Primary: Story = {
|
||
args: {
|
||
variant: 'primary',
|
||
children: 'Primary Button',
|
||
disabled: false,
|
||
},
|
||
}
|
||
|
||
export const Secondary: Story = {
|
||
args: {
|
||
variant: 'secondary',
|
||
children: 'Secondary Button',
|
||
disabled: false,
|
||
},
|
||
}
|
||
|
||
export const FullWidth: Story = {
|
||
args: {
|
||
variant: 'primary',
|
||
children: 'Full Width Primary Button',
|
||
disabled: false,
|
||
fullWidth: true,
|
||
},
|
||
}
|
||
```
|
||
|
||
Вот что должно получиться:
|
||
|
||

|
||
|
||
## Полиморфные компоненты
|
||
|
||
### Теория и реализация
|
||
|
||
Дизайнеры могут внезапно решить, что кнопка должна выглядеть как ссылка, а ссылка как кнопка, но при
|
||
этом мы должны рендерить правильные тэги в DOM, для удобства пользователей. Как быть в таком случае?
|
||
Для этого нам нужно будет передать нужный тег через пропс:
|
||
|
||
```tsx filename="button.tsx" showLineNumbers {6,15,19} /Component/4
|
||
import { ComponentPropsWithoutRef } from 'react'
|
||
|
||
import s from './button.module.scss'
|
||
|
||
export type ButtonProps = {
|
||
as: any
|
||
variant?: 'primary' | 'secondary'
|
||
fullWidth?: boolean
|
||
} & ComponentPropsWithoutRef<'button'>
|
||
|
||
export const Button = ({
|
||
variant = 'primary',
|
||
fullWidth,
|
||
className,
|
||
as: Component = 'button',
|
||
...rest
|
||
}: ButtonProps) => {
|
||
return (
|
||
<Component
|
||
className={`${s.button} ${s[variant]} ${fullWidth ? s.fullWidth : ''} ${className}`}
|
||
{...rest}
|
||
/>
|
||
)
|
||
}
|
||
```
|
||
|
||
Добавим в stories пример использования:
|
||
|
||
```tsx filename="button.stories.ts"
|
||
export const AsLink: Story = {
|
||
args: {
|
||
variant: 'primary',
|
||
children: 'Link that looks like a button',
|
||
as: 'a',
|
||
},
|
||
}
|
||
```
|
||
|
||
И проверим что всё работает как надо:
|
||
|
||

|
||
|
||
Как видите, в DOM мы получили тег `a` с нужными пропсами.
|
||
|
||
### Типизация
|
||
|
||
В примере выше мы использовали `as: any`, но это не очень хорошо, так как мы теряем типизацию.
|
||
Давайте попробуем это исправить.
|
||
|
||
Добавим generic параметр в наш компонент и в пропсы:
|
||
|
||
```tsx filename="button.tsx" showLineNumbers {1,5,6,7,11,13,14}
|
||
import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react'
|
||
|
||
import s from './button.module.scss'
|
||
|
||
export type ButtonProps<T extends ElementType = 'button'> = {
|
||
as?: T
|
||
children: ReactNode
|
||
variant?: 'primary' | 'secondary'
|
||
fullWidth?: boolean
|
||
className?: string
|
||
} & ComponentPropsWithoutRef<T>
|
||
|
||
export const Button = <T extends ElementType = 'button'>(props: ButtonProps<T>) => {
|
||
const { variant = 'primary', fullWidth, className, as: Component = 'button', ...rest } = props
|
||
|
||
return (
|
||
<Component className={`${s[variant]} ${fullWidth ? s.fullWidth : ''} ${className}`} {...rest} />
|
||
)
|
||
}
|
||
```
|
||
|
||
Попробуем отрисовать две кнопки в App.tsx, где одна кнопка будет с тегом `button`, а другая с тегом
|
||
`a`:
|
||
|
||

|
||
|
||
Типизация работает правильно, потому что в первом случае мы отрисовываем тэг `a`, у которого есть
|
||
проп `href`, а во втором случае мы отрисовываем тэг `button`, у которого его нет, поэтому мы и видим
|
||
ошибку.
|
||
|
||
<Callout type={'default'}>
|
||
Обратите внимание, что типизацию мы тестировали в App.tsx, а не в stories, потому что storybook
|
||
все еще иногда ошибается с TypeScript.
|
||
</Callout>
|
||
|
||
## Коммитим изменения
|
||
|
||
```bash filename="Terminal"
|
||
git add . && git commit -m "add polymorphic button"
|
||
```
|