@de-kit/st-ui 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +145 -143
  2. package/dist/components/button/button.cjs +26 -13
  3. package/dist/components/button/button.cjs.map +1 -1
  4. package/dist/components/button/button.d.cts +9 -8
  5. package/dist/components/button/button.d.ts +9 -8
  6. package/dist/components/button/button.js +29 -16
  7. package/dist/components/button/button.js.map +1 -1
  8. package/dist/components/button/button.scss +213 -147
  9. package/dist/components/button/index.d.cts +2 -2
  10. package/dist/components/button/index.d.ts +2 -2
  11. package/dist/components/checkbox/checkbox.cjs.map +1 -1
  12. package/dist/components/checkbox/checkbox.js.map +1 -1
  13. package/dist/components/checkbox/checkbox.scss +137 -137
  14. package/dist/components/field/field-context.cjs.map +1 -1
  15. package/dist/components/field/field-context.js.map +1 -1
  16. package/dist/components/field/field.cjs.map +1 -1
  17. package/dist/components/field/field.js.map +1 -1
  18. package/dist/components/field/field.scss +43 -43
  19. package/dist/components/input/input.cjs.map +1 -1
  20. package/dist/components/input/input.js.map +1 -1
  21. package/dist/components/input/input.scss +127 -127
  22. package/dist/components/radio/radio.cjs.map +1 -1
  23. package/dist/components/radio/radio.js.map +1 -1
  24. package/dist/components/radio/radio.scss +144 -144
  25. package/dist/components/spinner/spinner.cjs.map +1 -1
  26. package/dist/components/spinner/spinner.js.map +1 -1
  27. package/dist/components/spinner/spinner.scss +29 -29
  28. package/dist/components/textarea/textarea.cjs.map +1 -1
  29. package/dist/components/textarea/textarea.js.map +1 -1
  30. package/dist/components/textarea/textarea.scss +85 -85
  31. package/dist/icons/svg.cjs.map +1 -1
  32. package/dist/icons/svg.js.map +1 -1
  33. package/dist/index.d.cts +2 -2
  34. package/dist/index.d.ts +2 -2
  35. package/dist/lib/cn.cjs.map +1 -1
  36. package/dist/lib/cn.js.map +1 -1
  37. package/dist/lib/slot.cjs +100 -0
  38. package/dist/lib/slot.cjs.map +1 -0
  39. package/dist/lib/slot.js +100 -0
  40. package/dist/lib/slot.js.map +1 -0
  41. package/dist/styles/global.scss +201 -201
  42. package/dist/styles/mixins.scss +51 -51
  43. package/dist/styles/vars.scss +87 -87
  44. package/package.json +1 -1
package/README.md CHANGED
@@ -1,143 +1,145 @@
1
- # @de-kit/st-ui
2
-
3
- React-компоненты splittest. Стили едут вместе с компонентом (глобальный SCSS, не CSS-модули). Токены скопированы из `splittest-frontend/src/app/styles`. Tailwind нет.
4
-
5
- ## Подключение
6
-
7
- В CRA и в Vite отдельный импорт CSS не нужен — `import './button.scss'` сидит внутри компонента:
8
-
9
- ```tsx
10
- import {
11
- Button,
12
- Input,
13
- TextArea,
14
- CheckboxInput,
15
- RadioGroup,
16
- RadioInput,
17
- Spinner,
18
- } from '@de-kit/st-ui';
19
- ```
20
-
21
- Иконки — отдельный вход, в бандл попадают только импортированные:
22
-
23
- ```tsx
24
- import { IconClose, LogoVisa } from '@de-kit/st-ui/icons';
25
-
26
- <IconClose />
27
- <IconClose width={16} height={16} aria-label="Закрыть" />
28
- ```
29
-
30
- `icon_*` и `control_*` — 20×20 и `currentColor` (галерея 20×40). Размер меняется пропом `width`/`height` или CSS, он перекрывает дефолт. `icon_label-*` сохраняют заливку. `logo_*` и `illustration_*` — размер из SVG, у иллюстраций чёрный stroke заменён на `currentColor`. Новый svg кладётся в `src/icons/svg`, дальше `pnpm build:icons` (его же зовут `dev` и `build`).
31
-
32
- `global.scss` — база приложения (body, заголовки, таблицы, `.wrapper`). В чужой странице его не подключать. Шрифт Inter библиотека не везёт, его по-прежнему грузит фронтенд.
33
-
34
- Токены подключаются один раз вместе с пакетом (`import './styles/vars.scss'` в `src/index.ts`). В своих scss это обычные `var()`, `@use` не нужен. `@use '@de-kit/st-ui/vars.scss'` — только если стили собираются без JS.
35
-
36
- Миксины — только брейкпоинты для адаптивной вёрстки:
37
-
38
- ```scss
39
- @use '@de-kit/st-ui/mixins.scss' as mixins;
40
-
41
- .card {
42
- padding: var(--space-2);
43
-
44
- @include mixins.min-width(md) {
45
- padding: var(--space-3);
46
- }
47
-
48
- @include mixins.max-width(sm) {
49
- display: none;
50
- }
51
- }
52
- ```
53
-
54
- `sm` 480, `md` 680, `lg` 1000, `xl` 1200. Это единственный источник: `--breakpoint-*` в `vars.scss` генерируются из того же списка, так что новый брейкпоинт в `$breakpoints` сразу даёт и миксин, и токен. Отдельный список нужен потому, что в `@media` custom properties не работают. `min-width` включает брейкпоинт, `max-width` — нет (граница `679.98px`), поэтому на ровно 680px срабатывает только `min-width`. Вместо имени можно передать размер: `min-width(900px)`. Список доступен как `mixins.$breakpoints`, значение — `mixins.breakpoint(md)`.
55
-
56
- `var(--color-foreground)`, `var(--color-primary)`, `var(--font-sans)`, `var(--radius-sm)`, `var(--breakpoint-md)`. Цвета — роли как у Tailwind v4, не пигмент: `--color-foreground` текст, `--color-background` страница, `--color-primary` / `--color-primary-foreground` залитая кнопка и текст на ней. Outline берёт `--color-foreground` (текст и рамка, фон прозрачный). `--color-accent` синий, `--color-destructive` ошибка, `--color-success` успех, `--color-warning` жёлтый — из него разбавлен фон `--color-recommended`. Тема — переопределение этих переменных, компоненты не перекрашивать. `--color-overlay` и `--color-shadow` из темы не выводятся: вуаль и тень остаются тёмными и в светлой, и в тёмной, нужен другой оттенок — переопределять их отдельно.
57
-
58
- Текст — пара токенов на размер, других размеров в splittest нет: `--text-xs` (11/15), `--text-sm` (14/20, по умолчанию почти везде), `--text-base` (16/20), `--text-lg` (18/20), `--text-2xl` (28/30), `--text-3xl` (45/50); высота строки — `--text-*--line-height`. Заголовки `global.scss` собраны из них: h1 и h2 — `2xl` (h1 с 680px — `3xl`), h3 — `lg`, h4 — `sm`. Размеры `Button` `sm` (12/16) и `lg` (16/24) в шкалу не попадают и остаются литералами. Отступы — сетка 5px: `--space-1` … `--space-6`; паддинги кнопки и инпута сняты с макета вне сетки и остаются литералами. Обводка фокуса — `--focus-ring-width`, `--focus-ring-color` и `--focus-ring-offset`, одна на `box-shadow` инпута и `outline` остальных контролов.
59
-
60
- Компонентный scss собирает sass хоста. Класс в DOM буквальный: `button`, `input`, `checkbox`, `radio`, `textarea`, `spinner`. Корень и модификаторы одинарные (`.button`, `.button--outline`). Внутренние элементы — BEM: `checkbox__input`, `textarea__counter`.
61
-
62
- ## Кнопка
63
-
64
- Пропсы как в `shared/components/button`, без `react-router`.
65
-
66
- ```tsx
67
- <Button onClick={save}>Сохранить</Button>
68
- <Button variant="outline">Отмена</Button>
69
- <Button variant="link" disabled>Скрыть</Button>
70
- <Button href="/prices">Тарифы</Button>
71
- <Button size="sm">Маленькая</Button>
72
- <Button size="lg">Крупная</Button>
73
- <Button fullWidth>Дальше</Button>
74
- <Button>
75
- <IconArrowLeft width={20} height={20} />
76
- Назад
77
- </Button>
78
- <Button>
79
- Дальше
80
- <IconArrowRight width={20} height={20} />
81
- </Button>
82
- <Button loading>Сохранение</Button>
83
- ```
84
-
85
- Иконка — обычный `children`, слева или справа, какой набор нужен. `loading` оставляет подпись в потоке (`opacity: 0`) и кладёт спиннер поверх, поэтому ширина не прыгает. Ставит `aria-busy`. Пока `loading`, `<button>` получает `disabled`, у ссылки клик отменяется.
86
-
87
- | Prop | По умолчанию | |
88
- | ----------- | ------------ | --------------------------------------------------- |
89
- | `variant` | `default` | `default` заливка, `outline` обводка, `link` текст |
90
- | `size` | `md` | `sm` компактная, `md` обычная, `lg` крупная |
91
- | `fullWidth` | `false` | ширина 100%, не зависит от `size` |
92
- | `href` | — | рендер `<a>`, не `<Link>` |
93
- | `loading` | `false` | `aria-busy`, клик блокируется, ширина как у подписи |
94
-
95
- `variant="link"` — это вид кнопки (`font-weight: 400`), не тип элемента. Ссылка — отдельный проп `href`.
96
-
97
- ## Остальные
98
-
99
- ```tsx
100
- <Input recommended />
101
- <Input error />
102
- <Input size="sm" />
103
- <TextArea maxLength={200} />
104
- <TextArea maxLength={200} counter={false} />
105
- <CheckboxInput label="18-24" />
106
- <CheckboxInput label="Согласен" error />
107
- <RadioGroup label="Возраст" name="age" required>
108
- <RadioInput label="18-24" />
109
- </RadioGroup>
110
- <Spinner position="top" />
111
- <Spinner label="Считаем" size={24} />
112
- ```
113
-
114
- `size` у `Input` и `TextArea` — те же `sm`, `md`, `lg`, что у кнопки. У `Input` высота совпадает с кнопкой ровно: 30, 40 и 52px, включая `type="date"`. У `TextArea` размер меняет паддинги и `min-height`: 120 / 160 / 200px, с 680px — 60 / 80 / 100px. Текст во всех размерах остаётся `--text-sm`. У `Input` проп перекрывает нативный `size` (ширину в символах). `counter={false}` заменяет опечатку `noMaxMoty`. Счётчик `TextArea` показывает остаток символов и связан с полем через `aria-describedby`, сам `maxLength` по-прежнему режет ввод. `CheckboxInput` сам ставит `id`, если его не передали. Общий `name` у радио задаёт `RadioGroup`, она же рисует `fieldset` с `role="radiogroup"`. Её `required` делает обязательной всю группу и вне `Field`: `aria-required` уезжает на группу, нативный `required` — на пункты. Красная рамка у `CheckboxInput` и `RadioInput` — это проп `error`; один `required` больше не красит, он только обязывает. `Spinner` тянется по родителю, `position` раскладывает его по высоте, а `label` (по умолчанию «Загрузка») — подпись для скринридера.
115
-
116
- ## Поле
117
-
118
- `Field` оборачивает один контрол: ставит лейбл, подсказку и ошибку и связывает их через `aria-describedby`, `aria-errormessage` и `role="alert"`.
119
-
120
- ```tsx
121
- <Field label="Имя" hint="Нельзя изменить после проверки">
122
- <Input />
123
- </Field>
124
- <Field label="Почта" required error="Проверьте адрес">
125
- <Input type="email" />
126
- </Field>
127
- <Field label="Возраст" required>
128
- <RadioGroup name="age">
129
- <RadioInput label="18-24" value="18" />
130
- </RadioGroup>
131
- </Field>
132
- ```
133
-
134
- Контрол внутри должен быть один: id он берёт у поля, поэтому свой `id` на ребёнке перебивается, а два контрола получат одинаковый. `required` ставит `aria-required` на контрол и показывает ошибку «Поле «Имя» обязательно» (свой текст — проп `requiredMessage`) в двух случаях: когда в поле что-то было, а потом не стало (стёрли текст, сняли галочку) и когда форму не пустила проверка браузера — `Field` ловит `invalid` в фазе захвата. Звёздочки у лейбла нет, обязательность видно по сообщению. У радиогруппы момента «стёрли» не бывает, поэтому её держит нативный `required` на пунктах: форма не отправится, ошибка появится после неудачной отправки и уйдёт, как только выберут вариант. Отсюда и граница: `required` поля работает с нативными контролами — `Input`, `TextArea`, `CheckboxInput`, `RadioGroup`. Произвольный ребёнок не отдаёт ни значения, ни `invalid`, и поле о его пустоте не узнает. `RadioGroup` внутри `Field` легенду не рисует: именем группы становится лейбл поля.
135
-
136
- ## Разработка
137
-
138
- ```bash
139
- pnpm install
140
- pnpm dev # Storybook, :6006
141
- pnpm test
142
- pnpm build # dist: js + d.ts + scss рядом с компонентами
143
- ```
1
+ # @de-kit/st-ui
2
+
3
+ React-компоненты splittest. Стили едут вместе с компонентом (глобальный SCSS, не CSS-модули). Токены скопированы из `splittest-frontend/src/app/styles`. Tailwind нет.
4
+
5
+ ## Подключение
6
+
7
+ В CRA и в Vite отдельный импорт CSS не нужен — `import './button.scss'` сидит внутри компонента:
8
+
9
+ ```tsx
10
+ import {
11
+ Button,
12
+ Input,
13
+ TextArea,
14
+ CheckboxInput,
15
+ RadioGroup,
16
+ RadioInput,
17
+ Spinner,
18
+ } from '@de-kit/st-ui';
19
+ ```
20
+
21
+ Иконки — отдельный вход, в бандл попадают только импортированные:
22
+
23
+ ```tsx
24
+ import { IconClose, LogoVisa } from '@de-kit/st-ui/icons';
25
+
26
+ <IconClose />
27
+ <IconClose width={16} height={16} aria-label="Закрыть" />
28
+ ```
29
+
30
+ `icon_*` и `control_*` — 20×20 и `currentColor` (галерея 20×40). Размер меняется пропом `width`/`height` или CSS, он перекрывает дефолт. `icon_label-*` сохраняют заливку. `logo_*` и `illustration_*` — размер из SVG, у иллюстраций чёрный stroke заменён на `currentColor`. Новый svg кладётся в `src/icons/svg`, дальше `pnpm build:icons` (его же зовут `dev` и `build`).
31
+
32
+ `global.scss` — база приложения (body, заголовки, таблицы, `.wrapper`). В чужой странице его не подключать. Шрифт Inter библиотека не везёт, его по-прежнему грузит фронтенд.
33
+
34
+ Токены подключаются один раз вместе с пакетом (`import './styles/vars.scss'` в `src/index.ts`). В своих scss это обычные `var()`, `@use` не нужен. `@use '@de-kit/st-ui/vars.scss'` — только если стили собираются без JS.
35
+
36
+ Миксины — только брейкпоинты для адаптивной вёрстки:
37
+
38
+ ```scss
39
+ @use '@de-kit/st-ui/mixins.scss' as mixins;
40
+
41
+ .card {
42
+ padding: var(--space-2);
43
+
44
+ @include mixins.min-width(md) {
45
+ padding: var(--space-3);
46
+ }
47
+
48
+ @include mixins.max-width(sm) {
49
+ display: none;
50
+ }
51
+ }
52
+ ```
53
+
54
+ `sm` 480, `md` 680, `lg` 1000, `xl` 1200. Это единственный источник: `--breakpoint-*` в `vars.scss` генерируются из того же списка, так что новый брейкпоинт в `$breakpoints` сразу даёт и миксин, и токен. Отдельный список нужен потому, что в `@media` custom properties не работают. `min-width` включает брейкпоинт, `max-width` — нет (граница `679.98px`), поэтому на ровно 680px срабатывает только `min-width`. Вместо имени можно передать размер: `min-width(900px)`. Список доступен как `mixins.$breakpoints`, значение — `mixins.breakpoint(md)`.
55
+
56
+ `var(--color-foreground)`, `var(--color-primary)`, `var(--font-sans)`, `var(--radius-sm)`, `var(--breakpoint-md)`. Цвета — роли как у Tailwind v4, не пигмент: `--color-foreground` текст, `--color-background` страница, `--color-primary` / `--color-primary-foreground` залитая кнопка и текст на ней. Outline берёт `--color-foreground` (текст и рамка, фон прозрачный). `--color-accent` синий, `--color-destructive` ошибка, `--color-success` успех, `--color-warning` жёлтый — из него разбавлен фон `--color-recommended`. Тема — переопределение этих переменных, компоненты не перекрашивать. `--color-overlay` и `--color-shadow` из темы не выводятся: вуаль и тень остаются тёмными и в светлой, и в тёмной, нужен другой оттенок — переопределять их отдельно.
57
+
58
+ Текст — пара токенов на размер, других размеров в splittest нет: `--text-xs` (11/15), `--text-sm` (14/20, по умолчанию почти везде), `--text-base` (16/20), `--text-lg` (18/20), `--text-2xl` (28/30), `--text-3xl` (45/50); высота строки — `--text-*--line-height`. Заголовки `global.scss` собраны из них: h1 и h2 — `2xl` (h1 с 680px — `3xl`), h3 — `lg`, h4 — `sm`. Размеры `Button` `sm` (12/16) и `lg` (16/24) в шкалу не попадают и остаются литералами. Отступы — сетка 5px: `--space-1` … `--space-6`; паддинги кнопки и инпута сняты с макета вне сетки и остаются литералами. Обводка фокуса — `--focus-ring-width`, `--focus-ring-color` и `--focus-ring-offset`, одна на `box-shadow` инпута и `outline` остальных контролов.
59
+
60
+ Компонентный scss собирает sass хоста. Класс в DOM буквальный: `button`, `input`, `checkbox`, `radio`, `textarea`, `spinner`. Корень и модификаторы одинарные (`.button`, `.button--outline`). Внутренние элементы — BEM: `checkbox__input`, `textarea__counter`.
61
+
62
+ ## Кнопка
63
+
64
+ Пропсы как в `shared/components/button`, без `react-router`.
65
+
66
+ ```tsx
67
+ <Button onClick={save}>Сохранить</Button>
68
+ <Button variant="outline">Отмена</Button>
69
+ <Button variant="link" disabled>Скрыть</Button>
70
+ <Button asChild>
71
+ <a href="/prices">Тарифы</a>
72
+ </Button>
73
+ <Button size="sm">Маленькая</Button>
74
+ <Button size="lg">Крупная</Button>
75
+ <Button fullWidth>Дальше</Button>
76
+ <Button>
77
+ <IconArrowLeft width={20} height={20} />
78
+ Назад
79
+ </Button>
80
+ <Button>
81
+ Дальше
82
+ <IconArrowRight width={20} height={20} />
83
+ </Button>
84
+ <Button loading>Сохранение</Button>
85
+ ```
86
+
87
+ Иконка — обычный `children`, слева или справа, какой набор нужен. `loading` оставляет подпись в потоке (`opacity: 0`) и кладёт спиннер поверх, поэтому ширина не прыгает. Ставит `aria-busy`. Пока `loading`, `<button>` получает `disabled`. У `asChild` на не-кнопке клик отменяется и ставится `aria-disabled`.
88
+
89
+ | Prop | По умолчанию | |
90
+ | ----------- | ------------ | ------------------------------------------------------- |
91
+ | `variant` | `default` | `default` заливка, `outline` обводка, `link` текст |
92
+ | `size` | `md` | `sm` компактная, `md` обычная, `lg` крупная |
93
+ | `fullWidth` | `false` | ширина 100%, не зависит от `size` |
94
+ | `asChild` | `false` | классы и пропы на единственном ребёнке, без своего тега |
95
+ | `loading` | `false` | `aria-busy`, клик блокируется, ширина как у подписи |
96
+
97
+ `variant="link"` — это вид кнопки (`font-weight: 400`), не тип элемента. Ссылка — `asChild` вокруг своего `<a>` или `Link`: классы, `ref` и обработчики сливаются в единственного ребёнка, свой `<button>` не рисуется. Ребёнок должен принять `className` и `ref`.
98
+
99
+ ## Остальные
100
+
101
+ ```tsx
102
+ <Input recommended />
103
+ <Input error />
104
+ <Input size="sm" />
105
+ <TextArea maxLength={200} />
106
+ <TextArea maxLength={200} counter={false} />
107
+ <CheckboxInput label="18-24" />
108
+ <CheckboxInput label="Согласен" error />
109
+ <RadioGroup label="Возраст" name="age" required>
110
+ <RadioInput label="18-24" />
111
+ </RadioGroup>
112
+ <Spinner position="top" />
113
+ <Spinner label="Считаем" size={24} />
114
+ ```
115
+
116
+ `size` у `Input` и `TextArea` — те же `sm`, `md`, `lg`, что у кнопки. У `Input` высота совпадает с кнопкой ровно: 30, 40 и 52px, включая `type="date"`. У `TextArea` размер меняет паддинги и `min-height`: 120 / 160 / 200px, с 680px — 60 / 80 / 100px. Текст во всех размерах остаётся `--text-sm`. У `Input` проп перекрывает нативный `size` (ширину в символах). `counter={false}` заменяет опечатку `noMaxMoty`. Счётчик `TextArea` показывает остаток символов и связан с полем через `aria-describedby`, сам `maxLength` по-прежнему режет ввод. `CheckboxInput` сам ставит `id`, если его не передали. Общий `name` у радио задаёт `RadioGroup`, она же рисует `fieldset` с `role="radiogroup"`. Её `required` делает обязательной всю группу и вне `Field`: `aria-required` уезжает на группу, нативный `required` — на пункты. Красная рамка у `CheckboxInput` и `RadioInput` — это проп `error`; один `required` больше не красит, он только обязывает. `Spinner` тянется по родителю, `position` раскладывает его по высоте, а `label` (по умолчанию «Загрузка») — подпись для скринридера.
117
+
118
+ ## Поле
119
+
120
+ `Field` оборачивает один контрол: ставит лейбл, подсказку и ошибку и связывает их через `aria-describedby`, `aria-errormessage` и `role="alert"`.
121
+
122
+ ```tsx
123
+ <Field label="Имя" hint="Нельзя изменить после проверки">
124
+ <Input />
125
+ </Field>
126
+ <Field label="Почта" required error="Проверьте адрес">
127
+ <Input type="email" />
128
+ </Field>
129
+ <Field label="Возраст" required>
130
+ <RadioGroup name="age">
131
+ <RadioInput label="18-24" value="18" />
132
+ </RadioGroup>
133
+ </Field>
134
+ ```
135
+
136
+ Контрол внутри должен быть один: id он берёт у поля, поэтому свой `id` на ребёнке перебивается, а два контрола получат одинаковый. `required` ставит `aria-required` на контрол и показывает ошибку «Поле «Имя» обязательно» (свой текст — проп `requiredMessage`) в двух случаях: когда в поле что-то было, а потом не стало (стёрли текст, сняли галочку) и когда форму не пустила проверка браузера — `Field` ловит `invalid` в фазе захвата. Звёздочки у лейбла нет, обязательность видно по сообщению. У радиогруппы момента «стёрли» не бывает, поэтому её держит нативный `required` на пунктах: форма не отправится, ошибка появится после неудачной отправки и уйдёт, как только выберут вариант. Отсюда и граница: `required` поля работает с нативными контролами — `Input`, `TextArea`, `CheckboxInput`, `RadioGroup`. Произвольный ребёнок не отдаёт ни значения, ни `invalid`, и поле о его пустоте не узнает. `RadioGroup` внутри `Field` легенду не рисует: именем группы становится лейбл поля.
137
+
138
+ ## Разработка
139
+
140
+ ```bash
141
+ pnpm install
142
+ pnpm dev # Storybook, :6006
143
+ pnpm test
144
+ pnpm build # dist: js + d.ts + scss рядом с компонентами
145
+ ```
@@ -1,5 +1,6 @@
1
1
  const require_icon_spinner = require("../../icons/icon-spinner.cjs");
2
2
  const require_cn = require("../../lib/cn.cjs");
3
+ const require_slot = require("../../lib/slot.cjs");
3
4
  let react = require("react");
4
5
  let react_jsx_runtime = require("react/jsx-runtime");
5
6
  require("./button.scss");
@@ -9,6 +10,7 @@ const styleKeys = [
9
10
  "size",
10
11
  "fullWidth",
11
12
  "loading",
13
+ "asChild",
12
14
  "className"
13
15
  ];
14
16
  const getClassName = ({ variant = "default", size = "md", fullWidth = false, loading = false, className }) => require_cn.cn("button", className, require_cn.when(variant !== "default", `button--${variant}`), require_cn.when(size !== "md", `button--${size}`), require_cn.when(fullWidth, "button--full-width"), require_cn.when(loading, "button--loading"));
@@ -31,25 +33,36 @@ const renderContent = (children, loading) => {
31
33
  const Button = (0, react.forwardRef)(function Button(props, ref) {
32
34
  const { loading } = props;
33
35
  const className = getClassName(props);
34
- const { children, href, ref: propsRef, ...domProps } = omitStyleProps(props);
36
+ const { children, ref: propsRef, ...domProps } = omitStyleProps(props);
35
37
  const nodeRef = ref ?? propsRef;
36
- const content = renderContent(children, loading);
37
- if (typeof href === "string") {
38
- const anchorProps = domProps;
38
+ if (props.asChild) {
39
+ if (!(0, react.isValidElement)(children) || children.type === react.Fragment) throw new Error("Button с asChild ждёт ровно один React-элемент.");
40
+ const childProps = children.props;
41
+ const { disabled, onClick, type, ...childDomProps } = domProps;
42
+ const hostButton = children.type === "button";
43
+ const isBlocked = Boolean(disabled || loading);
39
44
  const handleClick = (event) => {
40
- event.preventDefault();
45
+ if (isBlocked) {
46
+ event.preventDefault();
47
+ return;
48
+ }
49
+ childProps.onClick?.(event);
50
+ if (!event.defaultPrevented) onClick?.(event);
41
51
  };
42
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
43
- ...anchorProps,
44
- ref: nodeRef,
52
+ const element = (0, react.cloneElement)(children, {}, renderContent(childProps.children, loading));
53
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_slot.Slot, {
54
+ ...childDomProps,
45
55
  className,
46
- "aria-busy": loading || void 0,
47
- "aria-disabled": loading || void 0,
48
- onClick: loading ? handleClick : anchorProps.onClick,
49
- href,
50
- children: content
56
+ ref: nodeRef,
57
+ onClick: handleClick,
58
+ ...type !== void 0 || hostButton ? { type: type ?? "button" } : {},
59
+ ...hostButton && isBlocked ? { disabled: true } : {},
60
+ ...loading ? { "aria-busy": true } : {},
61
+ ...!hostButton && isBlocked ? { "aria-disabled": true } : {},
62
+ children: element
51
63
  });
52
64
  }
65
+ const content = renderContent(children, loading);
53
66
  const { type = "button", disabled, onClick, ...buttonProps } = domProps;
54
67
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
55
68
  ...buttonProps,
@@ -1 +1 @@
1
- {"version":3,"file":"button.cjs","names":["cn","when","IconSpinner","forwardRef"],"sources":["../../../src/components/button/button.tsx"],"sourcesContent":["import {\r\n forwardRef,\r\n type AnchorHTMLAttributes,\r\n type ButtonHTMLAttributes,\r\n type ForwardedRef,\r\n type MouseEventHandler,\r\n type ReactElement,\r\n type ReactNode,\r\n type Ref,\r\n} from 'react';\r\n\r\nimport { IconSpinner } from '@/icons/icon-spinner';\r\nimport { cn, when } from '@/lib/cn';\r\nimport './button.scss';\r\n\r\nexport type ButtonVariant = 'default' | 'outline' | 'link';\r\nexport type ButtonSize = 'sm' | 'md' | 'lg';\r\n\r\ninterface ButtonStyleProps {\r\n variant?: ButtonVariant;\r\n size?: ButtonSize;\r\n fullWidth?: boolean;\r\n loading?: boolean;\r\n className?: string;\r\n children?: ReactNode;\r\n}\r\n\r\nexport interface ButtonProps\r\n extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonStyleProps {\r\n href?: undefined;\r\n ref?: Ref<HTMLButtonElement> | ((element: HTMLElement | null) => void);\r\n}\r\n\r\nexport interface ButtonLinkProps\r\n extends\r\n Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>,\r\n ButtonStyleProps {\r\n href: string;\r\n ref?: Ref<HTMLAnchorElement> | ((element: HTMLElement | null) => void);\r\n}\r\n\r\nexport type ButtonComponentProps = ButtonProps | ButtonLinkProps;\r\n\r\ninterface ButtonComponent {\r\n (props: ButtonLinkProps): ReactElement | null;\r\n (props: ButtonProps): ReactElement | null;\r\n}\r\n\r\nconst styleKeys = [\r\n 'variant',\r\n 'size',\r\n 'fullWidth',\r\n 'loading',\r\n 'className',\r\n] as const;\r\n\r\nconst getClassName = ({\r\n variant = 'default',\r\n size = 'md',\r\n fullWidth = false,\r\n loading = false,\r\n className,\r\n}: ButtonStyleProps) =>\r\n cn(\r\n 'button',\r\n className,\r\n when(variant !== 'default', `button--${variant}`),\r\n when(size !== 'md', `button--${size}`),\r\n when(fullWidth, 'button--full-width'),\r\n when(loading, 'button--loading'),\r\n );\r\n\r\nconst omitStyleProps = <T extends ButtonStyleProps>(\r\n props: T,\r\n): Omit<T, (typeof styleKeys)[number]> => {\r\n const next: Record<string, unknown> = {};\r\n const skipped = new Set<string>(styleKeys);\r\n\r\n for (const [key, value] of Object.entries(props)) {\r\n if (!skipped.has(key)) next[key] = value;\r\n }\r\n\r\n return next as Omit<T, (typeof styleKeys)[number]>;\r\n};\r\n\r\nconst renderContent = (children: ReactNode, loading?: boolean) => {\r\n if (!loading) return children;\r\n\r\n return (\r\n <>\r\n <span className=\"button__label\">{children}</span>\r\n <span className=\"button__spinner\">\r\n <IconSpinner className=\"button__icon--spin\" />\r\n </span>\r\n </>\r\n );\r\n};\r\n\r\n// forwardRef остаётся: в React 18 ref ещё не обычный проп, peer — ^18.3 || ^19.\r\nexport const Button = forwardRef(function Button(\r\n props: ButtonComponentProps,\r\n ref: ForwardedRef<HTMLButtonElement | HTMLAnchorElement>,\r\n) {\r\n const { loading } = props;\r\n const className = getClassName(props);\r\n const { children, href, ref: propsRef, ...domProps } = omitStyleProps(props);\r\n const nodeRef = ref ?? propsRef;\r\n const content = renderContent(children, loading);\r\n\r\n if (typeof href === 'string') {\r\n const anchorProps = domProps as AnchorHTMLAttributes<HTMLAnchorElement>;\r\n const handleClick: MouseEventHandler<HTMLAnchorElement> = event => {\r\n event.preventDefault();\r\n };\r\n\r\n return (\r\n <a\r\n {...anchorProps}\r\n ref={nodeRef as Ref<HTMLAnchorElement>}\r\n className={className}\r\n aria-busy={loading || undefined}\r\n // Ссылку нельзя выключить нативно: клик гасим, состояние объявляем.\r\n aria-disabled={loading || undefined}\r\n onClick={loading ? handleClick : anchorProps.onClick}\r\n href={href}\r\n >\r\n {content}\r\n </a>\r\n );\r\n }\r\n\r\n const {\r\n type = 'button',\r\n disabled,\r\n onClick,\r\n ...buttonProps\r\n } = domProps as ButtonHTMLAttributes<HTMLButtonElement>;\r\n\r\n return (\r\n <button\r\n {...buttonProps}\r\n ref={nodeRef as Ref<HTMLButtonElement>}\r\n className={className}\r\n type={type}\r\n disabled={disabled || loading}\r\n onClick={onClick}\r\n aria-busy={loading || undefined}\r\n >\r\n {content}\r\n </button>\r\n );\r\n}) as ButtonComponent;\r\n"],"mappings":";;;;;;AAgDA,MAAM,YAAY;CAChB;CACA;CACA;CACA;CACA;AACF;AAEA,MAAM,gBAAgB,EACpB,UAAU,WACV,OAAO,MACP,YAAY,OACZ,UAAU,OACV,gBAEAA,WAAAA,GACE,UACA,WACAC,WAAAA,KAAK,YAAY,WAAW,WAAW,SAAS,GAChDA,WAAAA,KAAK,SAAS,MAAM,WAAW,MAAM,GACrCA,WAAAA,KAAK,WAAW,oBAAoB,GACpCA,WAAAA,KAAK,SAAS,iBAAiB,CACjC;AAEF,MAAM,kBACJ,UACwC;CACxC,MAAM,OAAgC,CAAC;CACvC,MAAM,UAAU,IAAI,IAAY,SAAS;CAEzC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,GAC7C,IAAI,CAAC,QAAQ,IAAI,GAAG,GAAG,KAAK,OAAO;CAGrC,OAAO;AACT;AAEA,MAAM,iBAAiB,UAAqB,YAAsB;CAChE,IAAI,CAAC,SAAS,OAAO;CAErB,OACE,iBAAA,GAAA,kBAAA,KAAA,CAAA,kBAAA,UAAA,EAAA,UAAA,CACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD;EAAM,WAAU;EAAiB;CAAe,CAAA,GAChD,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD;EAAM,WAAU;EACd,UAAA,iBAAA,GAAA,kBAAA,IAAA,CAACC,qBAAAA,aAAD,EAAa,WAAU,qBAAsB,CAAA;CACzC,CAAA,CACN,EAAA,CAAA;AAEN;AAGA,MAAa,UAAA,GAASC,MAAAA,WAAAA,CAAW,SAAS,OACxC,OACA,KACA;CACA,MAAM,EAAE,YAAY;CACpB,MAAM,YAAY,aAAa,KAAK;CACpC,MAAM,EAAE,UAAU,MAAM,KAAK,UAAU,GAAG,aAAa,eAAe,KAAK;CAC3E,MAAM,UAAU,OAAO;CACvB,MAAM,UAAU,cAAc,UAAU,OAAO;CAE/C,IAAI,OAAO,SAAS,UAAU;EAC5B,MAAM,cAAc;EACpB,MAAM,eAAoD,UAAS;GACjE,MAAM,eAAe;EACvB;EAEA,OACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,KAAD;GACE,GAAI;GACJ,KAAK;GACM;GACX,aAAW,WAAW,KAAA;GAEtB,iBAAe,WAAW,KAAA;GAC1B,SAAS,UAAU,cAAc,YAAY;GACvC;GAEL,UAAA;EACA,CAAA;CAEP;CAEA,MAAM,EACJ,OAAO,UACP,UACA,SACA,GAAG,gBACD;CAEJ,OACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,UAAD;EACE,GAAI;EACJ,KAAK;EACM;EACL;EACN,UAAU,YAAY;EACb;EACT,aAAW,WAAW,KAAA;EAErB,UAAA;CACK,CAAA;AAEZ,CAAC"}
1
+ {"version":3,"file":"button.cjs","names":["cn","when","IconSpinner","forwardRef","isValidElement","Fragment","cloneElement","Slot"],"sources":["../../../src/components/button/button.tsx"],"sourcesContent":["import {\n cloneElement,\n Fragment,\n forwardRef,\n isValidElement,\n type ButtonHTMLAttributes,\n type ForwardedRef,\n type MouseEventHandler,\n type ReactElement,\n type ReactNode,\n type Ref,\n} from 'react';\n\nimport { IconSpinner } from '@/icons/icon-spinner';\nimport { cn, when } from '@/lib/cn';\nimport { Slot } from '@/lib/slot';\nimport './button.scss';\n\nexport type ButtonVariant = 'default' | 'outline' | 'link' | 'image' | 'icon';\nexport type ButtonSize = 'sm' | 'md' | 'lg';\n\ninterface ButtonStyleProps {\n variant?: ButtonVariant;\n size?: ButtonSize;\n fullWidth?: boolean;\n loading?: boolean;\n className?: string;\n children?: ReactNode;\n}\n\nexport interface ButtonProps\n extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonStyleProps {\n asChild?: false;\n ref?: Ref<HTMLButtonElement> | ((element: HTMLElement | null) => void);\n}\n\nexport interface ButtonAsChildProps\n extends\n Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'>,\n ButtonStyleProps {\n asChild: true;\n children: ReactElement;\n ref?: Ref<HTMLElement> | ((element: HTMLElement | null) => void);\n}\n\nexport type ButtonComponentProps = ButtonProps | ButtonAsChildProps;\n\ninterface ButtonComponent {\n (props: ButtonAsChildProps): ReactElement | null;\n (props: ButtonProps): ReactElement | null;\n}\n\ninterface ChildElementProps {\n children?: ReactNode;\n className?: string;\n onClick?: MouseEventHandler<HTMLElement>;\n}\n\nconst styleKeys = [\n 'variant',\n 'size',\n 'fullWidth',\n 'loading',\n 'asChild',\n 'className',\n] as const;\n\nconst getClassName = ({\n variant = 'default',\n size = 'md',\n fullWidth = false,\n loading = false,\n className,\n}: ButtonStyleProps) =>\n cn(\n 'button',\n className,\n when(variant !== 'default', `button--${variant}`),\n when(size !== 'md', `button--${size}`),\n when(fullWidth, 'button--full-width'),\n when(loading, 'button--loading'),\n );\n\nconst omitStyleProps = <T extends ButtonStyleProps>(\n props: T,\n): Omit<T, (typeof styleKeys)[number]> => {\n const next: Record<string, unknown> = {};\n const skipped = new Set<string>(styleKeys);\n\n for (const [key, value] of Object.entries(props)) {\n if (!skipped.has(key)) next[key] = value;\n }\n\n return next as Omit<T, (typeof styleKeys)[number]>;\n};\n\nconst renderContent = (children: ReactNode, loading?: boolean) => {\n if (!loading) return children;\n\n return (\n <>\n <span className=\"button__label\">{children}</span>\n <span className=\"button__spinner\">\n <IconSpinner className=\"button__icon--spin\" />\n </span>\n </>\n );\n};\n\n// forwardRef остаётся: в React 18 ref ещё не обычный проп, peer — ^18.3 || ^19.\nexport const Button = forwardRef(function Button(\n props: ButtonComponentProps,\n ref: ForwardedRef<HTMLElement>,\n) {\n const { loading } = props;\n const className = getClassName(props);\n const { children, ref: propsRef, ...domProps } = omitStyleProps(props);\n const nodeRef = ref ?? propsRef;\n\n if (props.asChild) {\n if (!isValidElement(children) || children.type === Fragment) {\n throw new Error('Button с asChild ждёт ровно один React-элемент.');\n }\n\n const childProps = children.props as ChildElementProps;\n const { disabled, onClick, type, ...childDomProps } =\n domProps as ButtonHTMLAttributes<HTMLButtonElement>;\n const hostButton = children.type === 'button';\n const isBlocked = Boolean(disabled || loading);\n const handleClick: MouseEventHandler<HTMLElement> = event => {\n if (isBlocked) {\n event.preventDefault();\n return;\n }\n\n childProps.onClick?.(event);\n if (!event.defaultPrevented) {\n (onClick as MouseEventHandler<HTMLElement> | undefined)?.(event);\n }\n };\n\n const element = cloneElement(\n children,\n {},\n renderContent(childProps.children, loading),\n );\n\n return (\n <Slot\n {...childDomProps}\n className={className}\n ref={nodeRef}\n onClick={handleClick}\n {...(type !== undefined || hostButton\n ? { type: type ?? 'button' }\n : {})}\n {...(hostButton && isBlocked ? { disabled: true } : {})}\n {...(loading ? { 'aria-busy': true } : {})}\n {...(!hostButton && isBlocked ? { 'aria-disabled': true } : {})}\n >\n {element}\n </Slot>\n );\n }\n\n const content = renderContent(children, loading);\n\n const {\n type = 'button',\n disabled,\n onClick,\n ...buttonProps\n } = domProps as ButtonHTMLAttributes<HTMLButtonElement>;\n\n return (\n <button\n {...buttonProps}\n ref={nodeRef as Ref<HTMLButtonElement>}\n className={className}\n type={type}\n disabled={disabled || loading}\n onClick={onClick}\n aria-busy={loading || undefined}\n >\n {content}\n </button>\n );\n}) as ButtonComponent;\n"],"mappings":";;;;;;;AA0DA,MAAM,YAAY;CAChB;CACA;CACA;CACA;CACA;CACA;AACF;AAEA,MAAM,gBAAgB,EACpB,UAAU,WACV,OAAO,MACP,YAAY,OACZ,UAAU,OACV,gBAEAA,WAAAA,GACE,UACA,WACAC,WAAAA,KAAK,YAAY,WAAW,WAAW,SAAS,GAChDA,WAAAA,KAAK,SAAS,MAAM,WAAW,MAAM,GACrCA,WAAAA,KAAK,WAAW,oBAAoB,GACpCA,WAAAA,KAAK,SAAS,iBAAiB,CACjC;AAEF,MAAM,kBACJ,UACwC;CACxC,MAAM,OAAgC,CAAC;CACvC,MAAM,UAAU,IAAI,IAAY,SAAS;CAEzC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,GAC7C,IAAI,CAAC,QAAQ,IAAI,GAAG,GAAG,KAAK,OAAO;CAGrC,OAAO;AACT;AAEA,MAAM,iBAAiB,UAAqB,YAAsB;CAChE,IAAI,CAAC,SAAS,OAAO;CAErB,OACE,iBAAA,GAAA,kBAAA,KAAA,CAAA,kBAAA,UAAA,EAAA,UAAA,CACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD;EAAM,WAAU;EAAiB;CAAe,CAAA,GAChD,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD;EAAM,WAAU;EACd,UAAA,iBAAA,GAAA,kBAAA,IAAA,CAACC,qBAAAA,aAAD,EAAa,WAAU,qBAAsB,CAAA;CACzC,CAAA,CACN,EAAA,CAAA;AAEN;AAGA,MAAa,UAAA,GAASC,MAAAA,WAAAA,CAAW,SAAS,OACxC,OACA,KACA;CACA,MAAM,EAAE,YAAY;CACpB,MAAM,YAAY,aAAa,KAAK;CACpC,MAAM,EAAE,UAAU,KAAK,UAAU,GAAG,aAAa,eAAe,KAAK;CACrE,MAAM,UAAU,OAAO;CAEvB,IAAI,MAAM,SAAS;EACjB,IAAI,EAAA,GAACC,MAAAA,eAAAA,CAAe,QAAQ,KAAK,SAAS,SAASC,MAAAA,UACjD,MAAM,IAAI,MAAM,iDAAiD;EAGnE,MAAM,aAAa,SAAS;EAC5B,MAAM,EAAE,UAAU,SAAS,MAAM,GAAG,kBAClC;EACF,MAAM,aAAa,SAAS,SAAS;EACrC,MAAM,YAAY,QAAQ,YAAY,OAAO;EAC7C,MAAM,eAA8C,UAAS;GAC3D,IAAI,WAAW;IACb,MAAM,eAAe;IACrB;GACF;GAEA,WAAW,UAAU,KAAK;GAC1B,IAAI,CAAC,MAAM,kBACT,UAA0D,KAAK;EAEnE;EAEA,MAAM,WAAA,GAAUC,MAAAA,aAAAA,CACd,UACA,CAAC,GACD,cAAc,WAAW,UAAU,OAAO,CAC5C;EAEA,OACE,iBAAA,GAAA,kBAAA,IAAA,CAACC,aAAAA,MAAD;GACE,GAAI;GACO;GACX,KAAK;GACL,SAAS;GACT,GAAK,SAAS,KAAA,KAAa,aACvB,EAAE,MAAM,QAAQ,SAAS,IACzB,CAAC;GACL,GAAK,cAAc,YAAY,EAAE,UAAU,KAAK,IAAI,CAAC;GACrD,GAAK,UAAU,EAAE,aAAa,KAAK,IAAI,CAAC;GACxC,GAAK,CAAC,cAAc,YAAY,EAAE,iBAAiB,KAAK,IAAI,CAAC;GAE5D,UAAA;EACG,CAAA;CAEV;CAEA,MAAM,UAAU,cAAc,UAAU,OAAO;CAE/C,MAAM,EACJ,OAAO,UACP,UACA,SACA,GAAG,gBACD;CAEJ,OACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,UAAD;EACE,GAAI;EACJ,KAAK;EACM;EACL;EACN,UAAU,YAAY;EACb;EACT,aAAW,WAAW,KAAA;EAErB,UAAA;CACK,CAAA;AAEZ,CAAC"}
@@ -1,5 +1,5 @@
1
- import { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactElement, ReactNode, Ref } from "react";
2
- export type ButtonVariant = 'default' | 'outline' | 'link';
1
+ import { ButtonHTMLAttributes, ReactElement, ReactNode, Ref } from "react";
2
+ export type ButtonVariant = 'default' | 'outline' | 'link' | 'image' | 'icon';
3
3
  export type ButtonSize = 'sm' | 'md' | 'lg';
4
4
  interface ButtonStyleProps {
5
5
  variant?: ButtonVariant;
@@ -10,16 +10,17 @@ interface ButtonStyleProps {
10
10
  children?: ReactNode;
11
11
  }
12
12
  export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonStyleProps {
13
- href?: undefined;
13
+ asChild?: false;
14
14
  ref?: Ref<HTMLButtonElement> | ((element: HTMLElement | null) => void);
15
15
  }
16
- export interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>, ButtonStyleProps {
17
- href: string;
18
- ref?: Ref<HTMLAnchorElement> | ((element: HTMLElement | null) => void);
16
+ export interface ButtonAsChildProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'>, ButtonStyleProps {
17
+ asChild: true;
18
+ children: ReactElement;
19
+ ref?: Ref<HTMLElement> | ((element: HTMLElement | null) => void);
19
20
  }
20
- export type ButtonComponentProps = ButtonProps | ButtonLinkProps;
21
+ export type ButtonComponentProps = ButtonProps | ButtonAsChildProps;
21
22
  interface ButtonComponent {
22
- (props: ButtonLinkProps): ReactElement | null;
23
+ (props: ButtonAsChildProps): ReactElement | null;
23
24
  (props: ButtonProps): ReactElement | null;
24
25
  }
25
26
  export declare const Button: ButtonComponent;
@@ -1,5 +1,5 @@
1
- import { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactElement, ReactNode, Ref } from "react";
2
- export type ButtonVariant = 'default' | 'outline' | 'link';
1
+ import { ButtonHTMLAttributes, ReactElement, ReactNode, Ref } from "react";
2
+ export type ButtonVariant = 'default' | 'outline' | 'link' | 'image' | 'icon';
3
3
  export type ButtonSize = 'sm' | 'md' | 'lg';
4
4
  interface ButtonStyleProps {
5
5
  variant?: ButtonVariant;
@@ -10,16 +10,17 @@ interface ButtonStyleProps {
10
10
  children?: ReactNode;
11
11
  }
12
12
  export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonStyleProps {
13
- href?: undefined;
13
+ asChild?: false;
14
14
  ref?: Ref<HTMLButtonElement> | ((element: HTMLElement | null) => void);
15
15
  }
16
- export interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>, ButtonStyleProps {
17
- href: string;
18
- ref?: Ref<HTMLAnchorElement> | ((element: HTMLElement | null) => void);
16
+ export interface ButtonAsChildProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'>, ButtonStyleProps {
17
+ asChild: true;
18
+ children: ReactElement;
19
+ ref?: Ref<HTMLElement> | ((element: HTMLElement | null) => void);
19
20
  }
20
- export type ButtonComponentProps = ButtonProps | ButtonLinkProps;
21
+ export type ButtonComponentProps = ButtonProps | ButtonAsChildProps;
21
22
  interface ButtonComponent {
22
- (props: ButtonLinkProps): ReactElement | null;
23
+ (props: ButtonAsChildProps): ReactElement | null;
23
24
  (props: ButtonProps): ReactElement | null;
24
25
  }
25
26
  export declare const Button: ButtonComponent;
@@ -1,7 +1,8 @@
1
1
  import { IconSpinner } from "../../icons/icon-spinner.js";
2
2
  import { cn, when } from "../../lib/cn.js";
3
- import { forwardRef } from "react";
4
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { Slot } from "../../lib/slot.js";
4
+ import { Fragment, cloneElement, forwardRef, isValidElement } from "react";
5
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
6
  import "./button.scss";
6
7
  // src/components/button/button.tsx
7
8
  const styleKeys = [
@@ -9,6 +10,7 @@ const styleKeys = [
9
10
  "size",
10
11
  "fullWidth",
11
12
  "loading",
13
+ "asChild",
12
14
  "className"
13
15
  ];
14
16
  const getClassName = ({ variant = "default", size = "md", fullWidth = false, loading = false, className }) => cn("button", className, when(variant !== "default", `button--${variant}`), when(size !== "md", `button--${size}`), when(fullWidth, "button--full-width"), when(loading, "button--loading"));
@@ -20,7 +22,7 @@ const omitStyleProps = (props) => {
20
22
  };
21
23
  const renderContent = (children, loading) => {
22
24
  if (!loading) return children;
23
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
25
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
24
26
  className: "button__label",
25
27
  children
26
28
  }), /* @__PURE__ */ jsx("span", {
@@ -31,25 +33,36 @@ const renderContent = (children, loading) => {
31
33
  const Button = forwardRef(function Button(props, ref) {
32
34
  const { loading } = props;
33
35
  const className = getClassName(props);
34
- const { children, href, ref: propsRef, ...domProps } = omitStyleProps(props);
36
+ const { children, ref: propsRef, ...domProps } = omitStyleProps(props);
35
37
  const nodeRef = ref ?? propsRef;
36
- const content = renderContent(children, loading);
37
- if (typeof href === "string") {
38
- const anchorProps = domProps;
38
+ if (props.asChild) {
39
+ if (!isValidElement(children) || children.type === Fragment) throw new Error("Button с asChild ждёт ровно один React-элемент.");
40
+ const childProps = children.props;
41
+ const { disabled, onClick, type, ...childDomProps } = domProps;
42
+ const hostButton = children.type === "button";
43
+ const isBlocked = Boolean(disabled || loading);
39
44
  const handleClick = (event) => {
40
- event.preventDefault();
45
+ if (isBlocked) {
46
+ event.preventDefault();
47
+ return;
48
+ }
49
+ childProps.onClick?.(event);
50
+ if (!event.defaultPrevented) onClick?.(event);
41
51
  };
42
- return /* @__PURE__ */ jsx("a", {
43
- ...anchorProps,
44
- ref: nodeRef,
52
+ const element = cloneElement(children, {}, renderContent(childProps.children, loading));
53
+ return /* @__PURE__ */ jsx(Slot, {
54
+ ...childDomProps,
45
55
  className,
46
- "aria-busy": loading || void 0,
47
- "aria-disabled": loading || void 0,
48
- onClick: loading ? handleClick : anchorProps.onClick,
49
- href,
50
- children: content
56
+ ref: nodeRef,
57
+ onClick: handleClick,
58
+ ...type !== void 0 || hostButton ? { type: type ?? "button" } : {},
59
+ ...hostButton && isBlocked ? { disabled: true } : {},
60
+ ...loading ? { "aria-busy": true } : {},
61
+ ...!hostButton && isBlocked ? { "aria-disabled": true } : {},
62
+ children: element
51
63
  });
52
64
  }
65
+ const content = renderContent(children, loading);
53
66
  const { type = "button", disabled, onClick, ...buttonProps } = domProps;
54
67
  return /* @__PURE__ */ jsx("button", {
55
68
  ...buttonProps,
@@ -1 +1 @@
1
- {"version":3,"file":"button.js","names":[],"sources":["../../../src/components/button/button.tsx"],"sourcesContent":["import {\r\n forwardRef,\r\n type AnchorHTMLAttributes,\r\n type ButtonHTMLAttributes,\r\n type ForwardedRef,\r\n type MouseEventHandler,\r\n type ReactElement,\r\n type ReactNode,\r\n type Ref,\r\n} from 'react';\r\n\r\nimport { IconSpinner } from '@/icons/icon-spinner';\r\nimport { cn, when } from '@/lib/cn';\r\nimport './button.scss';\r\n\r\nexport type ButtonVariant = 'default' | 'outline' | 'link';\r\nexport type ButtonSize = 'sm' | 'md' | 'lg';\r\n\r\ninterface ButtonStyleProps {\r\n variant?: ButtonVariant;\r\n size?: ButtonSize;\r\n fullWidth?: boolean;\r\n loading?: boolean;\r\n className?: string;\r\n children?: ReactNode;\r\n}\r\n\r\nexport interface ButtonProps\r\n extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonStyleProps {\r\n href?: undefined;\r\n ref?: Ref<HTMLButtonElement> | ((element: HTMLElement | null) => void);\r\n}\r\n\r\nexport interface ButtonLinkProps\r\n extends\r\n Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>,\r\n ButtonStyleProps {\r\n href: string;\r\n ref?: Ref<HTMLAnchorElement> | ((element: HTMLElement | null) => void);\r\n}\r\n\r\nexport type ButtonComponentProps = ButtonProps | ButtonLinkProps;\r\n\r\ninterface ButtonComponent {\r\n (props: ButtonLinkProps): ReactElement | null;\r\n (props: ButtonProps): ReactElement | null;\r\n}\r\n\r\nconst styleKeys = [\r\n 'variant',\r\n 'size',\r\n 'fullWidth',\r\n 'loading',\r\n 'className',\r\n] as const;\r\n\r\nconst getClassName = ({\r\n variant = 'default',\r\n size = 'md',\r\n fullWidth = false,\r\n loading = false,\r\n className,\r\n}: ButtonStyleProps) =>\r\n cn(\r\n 'button',\r\n className,\r\n when(variant !== 'default', `button--${variant}`),\r\n when(size !== 'md', `button--${size}`),\r\n when(fullWidth, 'button--full-width'),\r\n when(loading, 'button--loading'),\r\n );\r\n\r\nconst omitStyleProps = <T extends ButtonStyleProps>(\r\n props: T,\r\n): Omit<T, (typeof styleKeys)[number]> => {\r\n const next: Record<string, unknown> = {};\r\n const skipped = new Set<string>(styleKeys);\r\n\r\n for (const [key, value] of Object.entries(props)) {\r\n if (!skipped.has(key)) next[key] = value;\r\n }\r\n\r\n return next as Omit<T, (typeof styleKeys)[number]>;\r\n};\r\n\r\nconst renderContent = (children: ReactNode, loading?: boolean) => {\r\n if (!loading) return children;\r\n\r\n return (\r\n <>\r\n <span className=\"button__label\">{children}</span>\r\n <span className=\"button__spinner\">\r\n <IconSpinner className=\"button__icon--spin\" />\r\n </span>\r\n </>\r\n );\r\n};\r\n\r\n// forwardRef остаётся: в React 18 ref ещё не обычный проп, peer — ^18.3 || ^19.\r\nexport const Button = forwardRef(function Button(\r\n props: ButtonComponentProps,\r\n ref: ForwardedRef<HTMLButtonElement | HTMLAnchorElement>,\r\n) {\r\n const { loading } = props;\r\n const className = getClassName(props);\r\n const { children, href, ref: propsRef, ...domProps } = omitStyleProps(props);\r\n const nodeRef = ref ?? propsRef;\r\n const content = renderContent(children, loading);\r\n\r\n if (typeof href === 'string') {\r\n const anchorProps = domProps as AnchorHTMLAttributes<HTMLAnchorElement>;\r\n const handleClick: MouseEventHandler<HTMLAnchorElement> = event => {\r\n event.preventDefault();\r\n };\r\n\r\n return (\r\n <a\r\n {...anchorProps}\r\n ref={nodeRef as Ref<HTMLAnchorElement>}\r\n className={className}\r\n aria-busy={loading || undefined}\r\n // Ссылку нельзя выключить нативно: клик гасим, состояние объявляем.\r\n aria-disabled={loading || undefined}\r\n onClick={loading ? handleClick : anchorProps.onClick}\r\n href={href}\r\n >\r\n {content}\r\n </a>\r\n );\r\n }\r\n\r\n const {\r\n type = 'button',\r\n disabled,\r\n onClick,\r\n ...buttonProps\r\n } = domProps as ButtonHTMLAttributes<HTMLButtonElement>;\r\n\r\n return (\r\n <button\r\n {...buttonProps}\r\n ref={nodeRef as Ref<HTMLButtonElement>}\r\n className={className}\r\n type={type}\r\n disabled={disabled || loading}\r\n onClick={onClick}\r\n aria-busy={loading || undefined}\r\n >\r\n {content}\r\n </button>\r\n );\r\n}) as ButtonComponent;\r\n"],"mappings":";;;;;;AAgDA,MAAM,YAAY;CAChB;CACA;CACA;CACA;CACA;AACF;AAEA,MAAM,gBAAgB,EACpB,UAAU,WACV,OAAO,MACP,YAAY,OACZ,UAAU,OACV,gBAEA,GACE,UACA,WACA,KAAK,YAAY,WAAW,WAAW,SAAS,GAChD,KAAK,SAAS,MAAM,WAAW,MAAM,GACrC,KAAK,WAAW,oBAAoB,GACpC,KAAK,SAAS,iBAAiB,CACjC;AAEF,MAAM,kBACJ,UACwC;CACxC,MAAM,OAAgC,CAAC;CACvC,MAAM,UAAU,IAAI,IAAY,SAAS;CAEzC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,GAC7C,IAAI,CAAC,QAAQ,IAAI,GAAG,GAAG,KAAK,OAAO;CAGrC,OAAO;AACT;AAEA,MAAM,iBAAiB,UAAqB,YAAsB;CAChE,IAAI,CAAC,SAAS,OAAO;CAErB,OACE,qBAAA,UAAA,EAAA,UAAA,CACE,oBAAC,QAAD;EAAM,WAAU;EAAiB;CAAe,CAAA,GAChD,oBAAC,QAAD;EAAM,WAAU;EACd,UAAA,oBAAC,aAAD,EAAa,WAAU,qBAAsB,CAAA;CACzC,CAAA,CACN,EAAA,CAAA;AAEN;AAGA,MAAa,SAAS,WAAW,SAAS,OACxC,OACA,KACA;CACA,MAAM,EAAE,YAAY;CACpB,MAAM,YAAY,aAAa,KAAK;CACpC,MAAM,EAAE,UAAU,MAAM,KAAK,UAAU,GAAG,aAAa,eAAe,KAAK;CAC3E,MAAM,UAAU,OAAO;CACvB,MAAM,UAAU,cAAc,UAAU,OAAO;CAE/C,IAAI,OAAO,SAAS,UAAU;EAC5B,MAAM,cAAc;EACpB,MAAM,eAAoD,UAAS;GACjE,MAAM,eAAe;EACvB;EAEA,OACE,oBAAC,KAAD;GACE,GAAI;GACJ,KAAK;GACM;GACX,aAAW,WAAW,KAAA;GAEtB,iBAAe,WAAW,KAAA;GAC1B,SAAS,UAAU,cAAc,YAAY;GACvC;GAEL,UAAA;EACA,CAAA;CAEP;CAEA,MAAM,EACJ,OAAO,UACP,UACA,SACA,GAAG,gBACD;CAEJ,OACE,oBAAC,UAAD;EACE,GAAI;EACJ,KAAK;EACM;EACL;EACN,UAAU,YAAY;EACb;EACT,aAAW,WAAW,KAAA;EAErB,UAAA;CACK,CAAA;AAEZ,CAAC"}
1
+ {"version":3,"file":"button.js","names":[],"sources":["../../../src/components/button/button.tsx"],"sourcesContent":["import {\n cloneElement,\n Fragment,\n forwardRef,\n isValidElement,\n type ButtonHTMLAttributes,\n type ForwardedRef,\n type MouseEventHandler,\n type ReactElement,\n type ReactNode,\n type Ref,\n} from 'react';\n\nimport { IconSpinner } from '@/icons/icon-spinner';\nimport { cn, when } from '@/lib/cn';\nimport { Slot } from '@/lib/slot';\nimport './button.scss';\n\nexport type ButtonVariant = 'default' | 'outline' | 'link' | 'image' | 'icon';\nexport type ButtonSize = 'sm' | 'md' | 'lg';\n\ninterface ButtonStyleProps {\n variant?: ButtonVariant;\n size?: ButtonSize;\n fullWidth?: boolean;\n loading?: boolean;\n className?: string;\n children?: ReactNode;\n}\n\nexport interface ButtonProps\n extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonStyleProps {\n asChild?: false;\n ref?: Ref<HTMLButtonElement> | ((element: HTMLElement | null) => void);\n}\n\nexport interface ButtonAsChildProps\n extends\n Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'>,\n ButtonStyleProps {\n asChild: true;\n children: ReactElement;\n ref?: Ref<HTMLElement> | ((element: HTMLElement | null) => void);\n}\n\nexport type ButtonComponentProps = ButtonProps | ButtonAsChildProps;\n\ninterface ButtonComponent {\n (props: ButtonAsChildProps): ReactElement | null;\n (props: ButtonProps): ReactElement | null;\n}\n\ninterface ChildElementProps {\n children?: ReactNode;\n className?: string;\n onClick?: MouseEventHandler<HTMLElement>;\n}\n\nconst styleKeys = [\n 'variant',\n 'size',\n 'fullWidth',\n 'loading',\n 'asChild',\n 'className',\n] as const;\n\nconst getClassName = ({\n variant = 'default',\n size = 'md',\n fullWidth = false,\n loading = false,\n className,\n}: ButtonStyleProps) =>\n cn(\n 'button',\n className,\n when(variant !== 'default', `button--${variant}`),\n when(size !== 'md', `button--${size}`),\n when(fullWidth, 'button--full-width'),\n when(loading, 'button--loading'),\n );\n\nconst omitStyleProps = <T extends ButtonStyleProps>(\n props: T,\n): Omit<T, (typeof styleKeys)[number]> => {\n const next: Record<string, unknown> = {};\n const skipped = new Set<string>(styleKeys);\n\n for (const [key, value] of Object.entries(props)) {\n if (!skipped.has(key)) next[key] = value;\n }\n\n return next as Omit<T, (typeof styleKeys)[number]>;\n};\n\nconst renderContent = (children: ReactNode, loading?: boolean) => {\n if (!loading) return children;\n\n return (\n <>\n <span className=\"button__label\">{children}</span>\n <span className=\"button__spinner\">\n <IconSpinner className=\"button__icon--spin\" />\n </span>\n </>\n );\n};\n\n// forwardRef остаётся: в React 18 ref ещё не обычный проп, peer — ^18.3 || ^19.\nexport const Button = forwardRef(function Button(\n props: ButtonComponentProps,\n ref: ForwardedRef<HTMLElement>,\n) {\n const { loading } = props;\n const className = getClassName(props);\n const { children, ref: propsRef, ...domProps } = omitStyleProps(props);\n const nodeRef = ref ?? propsRef;\n\n if (props.asChild) {\n if (!isValidElement(children) || children.type === Fragment) {\n throw new Error('Button с asChild ждёт ровно один React-элемент.');\n }\n\n const childProps = children.props as ChildElementProps;\n const { disabled, onClick, type, ...childDomProps } =\n domProps as ButtonHTMLAttributes<HTMLButtonElement>;\n const hostButton = children.type === 'button';\n const isBlocked = Boolean(disabled || loading);\n const handleClick: MouseEventHandler<HTMLElement> = event => {\n if (isBlocked) {\n event.preventDefault();\n return;\n }\n\n childProps.onClick?.(event);\n if (!event.defaultPrevented) {\n (onClick as MouseEventHandler<HTMLElement> | undefined)?.(event);\n }\n };\n\n const element = cloneElement(\n children,\n {},\n renderContent(childProps.children, loading),\n );\n\n return (\n <Slot\n {...childDomProps}\n className={className}\n ref={nodeRef}\n onClick={handleClick}\n {...(type !== undefined || hostButton\n ? { type: type ?? 'button' }\n : {})}\n {...(hostButton && isBlocked ? { disabled: true } : {})}\n {...(loading ? { 'aria-busy': true } : {})}\n {...(!hostButton && isBlocked ? { 'aria-disabled': true } : {})}\n >\n {element}\n </Slot>\n );\n }\n\n const content = renderContent(children, loading);\n\n const {\n type = 'button',\n disabled,\n onClick,\n ...buttonProps\n } = domProps as ButtonHTMLAttributes<HTMLButtonElement>;\n\n return (\n <button\n {...buttonProps}\n ref={nodeRef as Ref<HTMLButtonElement>}\n className={className}\n type={type}\n disabled={disabled || loading}\n onClick={onClick}\n aria-busy={loading || undefined}\n >\n {content}\n </button>\n );\n}) as ButtonComponent;\n"],"mappings":";;;;;;;AA0DA,MAAM,YAAY;CAChB;CACA;CACA;CACA;CACA;CACA;AACF;AAEA,MAAM,gBAAgB,EACpB,UAAU,WACV,OAAO,MACP,YAAY,OACZ,UAAU,OACV,gBAEA,GACE,UACA,WACA,KAAK,YAAY,WAAW,WAAW,SAAS,GAChD,KAAK,SAAS,MAAM,WAAW,MAAM,GACrC,KAAK,WAAW,oBAAoB,GACpC,KAAK,SAAS,iBAAiB,CACjC;AAEF,MAAM,kBACJ,UACwC;CACxC,MAAM,OAAgC,CAAC;CACvC,MAAM,UAAU,IAAI,IAAY,SAAS;CAEzC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,GAC7C,IAAI,CAAC,QAAQ,IAAI,GAAG,GAAG,KAAK,OAAO;CAGrC,OAAO;AACT;AAEA,MAAM,iBAAiB,UAAqB,YAAsB;CAChE,IAAI,CAAC,SAAS,OAAO;CAErB,OACE,qBAAA,YAAA,EAAA,UAAA,CACE,oBAAC,QAAD;EAAM,WAAU;EAAiB;CAAe,CAAA,GAChD,oBAAC,QAAD;EAAM,WAAU;EACd,UAAA,oBAAC,aAAD,EAAa,WAAU,qBAAsB,CAAA;CACzC,CAAA,CACN,EAAA,CAAA;AAEN;AAGA,MAAa,SAAS,WAAW,SAAS,OACxC,OACA,KACA;CACA,MAAM,EAAE,YAAY;CACpB,MAAM,YAAY,aAAa,KAAK;CACpC,MAAM,EAAE,UAAU,KAAK,UAAU,GAAG,aAAa,eAAe,KAAK;CACrE,MAAM,UAAU,OAAO;CAEvB,IAAI,MAAM,SAAS;EACjB,IAAI,CAAC,eAAe,QAAQ,KAAK,SAAS,SAAS,UACjD,MAAM,IAAI,MAAM,iDAAiD;EAGnE,MAAM,aAAa,SAAS;EAC5B,MAAM,EAAE,UAAU,SAAS,MAAM,GAAG,kBAClC;EACF,MAAM,aAAa,SAAS,SAAS;EACrC,MAAM,YAAY,QAAQ,YAAY,OAAO;EAC7C,MAAM,eAA8C,UAAS;GAC3D,IAAI,WAAW;IACb,MAAM,eAAe;IACrB;GACF;GAEA,WAAW,UAAU,KAAK;GAC1B,IAAI,CAAC,MAAM,kBACT,UAA0D,KAAK;EAEnE;EAEA,MAAM,UAAU,aACd,UACA,CAAC,GACD,cAAc,WAAW,UAAU,OAAO,CAC5C;EAEA,OACE,oBAAC,MAAD;GACE,GAAI;GACO;GACX,KAAK;GACL,SAAS;GACT,GAAK,SAAS,KAAA,KAAa,aACvB,EAAE,MAAM,QAAQ,SAAS,IACzB,CAAC;GACL,GAAK,cAAc,YAAY,EAAE,UAAU,KAAK,IAAI,CAAC;GACrD,GAAK,UAAU,EAAE,aAAa,KAAK,IAAI,CAAC;GACxC,GAAK,CAAC,cAAc,YAAY,EAAE,iBAAiB,KAAK,IAAI,CAAC;GAE5D,UAAA;EACG,CAAA;CAEV;CAEA,MAAM,UAAU,cAAc,UAAU,OAAO;CAE/C,MAAM,EACJ,OAAO,UACP,UACA,SACA,GAAG,gBACD;CAEJ,OACE,oBAAC,UAAD;EACE,GAAI;EACJ,KAAK;EACM;EACL;EACN,UAAU,YAAY;EACb;EACT,aAAW,WAAW,KAAA;EAErB,UAAA;CACK,CAAA;AAEZ,CAAC"}