hallo-kit 0.3.0

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 (128) hide show
  1. package/README.md +168 -0
  2. package/bin/cli.mjs +136 -0
  3. package/fonts/Manrope/Manrope-VariableFont_wght.woff2 +0 -0
  4. package/fonts/Manrope/OFL.txt +93 -0
  5. package/fonts/index.ts +30 -0
  6. package/package.json +77 -0
  7. package/src/config.ts +11 -0
  8. package/src/hooks/index.ts +14 -0
  9. package/src/hooks/useAutoHideOnScroll.ts +26 -0
  10. package/src/hooks/useCountdown.ts +44 -0
  11. package/src/hooks/useDevice.ts +63 -0
  12. package/src/hooks/useIsTelegramMiniApp.ts +17 -0
  13. package/src/hooks/useIsTouch.ts +23 -0
  14. package/src/hooks/useKeyboardOpen.ts +35 -0
  15. package/src/hooks/useMediaQuery.ts +24 -0
  16. package/src/hooks/usePlatform.ts +102 -0
  17. package/src/hooks/useQueryParams.tsx +70 -0
  18. package/src/hooks/useRevealOnce.ts +21 -0
  19. package/src/hooks/useScrolledPast.ts +19 -0
  20. package/src/hooks/useSettleOnce.ts +26 -0
  21. package/src/hooks/useTelegram.tsx +57 -0
  22. package/src/i18n/index.tsx +92 -0
  23. package/src/index.ts +35 -0
  24. package/src/lib/device.ts +28 -0
  25. package/src/lib/platform.ts +19 -0
  26. package/src/lib/utils.ts +36 -0
  27. package/src/logo/LogoFull.tsx +59 -0
  28. package/src/logo/LogoMark.tsx +31 -0
  29. package/src/logo/index.ts +2 -0
  30. package/src/next.ts +13 -0
  31. package/src/server.ts +4 -0
  32. package/src/theme-color-sync.tsx +43 -0
  33. package/src/theme-provider.tsx +31 -0
  34. package/src/types/css.d.ts +6 -0
  35. package/src/types/globals.d.ts +47 -0
  36. package/src/ui/Icon/Icon.module.css +25 -0
  37. package/src/ui/Icon/Icon.tsx +45 -0
  38. package/src/ui/Icon/Icon.types.ts +60 -0
  39. package/src/ui/Icon/data/brand.data.tsx +75 -0
  40. package/src/ui/Icon/data/index.ts +2 -0
  41. package/src/ui/Icon/data/ui.data.tsx +780 -0
  42. package/src/ui/Icon/index.ts +3 -0
  43. package/src/ui/badge/badge.tsx +185 -0
  44. package/src/ui/badge/index.ts +1 -0
  45. package/src/ui/breadcrumbs/breadcrumbs.tsx +63 -0
  46. package/src/ui/breadcrumbs/index.ts +1 -0
  47. package/src/ui/burger-icon/burger-icon.tsx +22 -0
  48. package/src/ui/burger-icon/index.ts +1 -0
  49. package/src/ui/button/button.tsx +179 -0
  50. package/src/ui/button/index.ts +1 -0
  51. package/src/ui/card/card.tsx +100 -0
  52. package/src/ui/card/index.ts +1 -0
  53. package/src/ui/confirm/confirm-dialog-host.tsx +89 -0
  54. package/src/ui/confirm/index.ts +1 -0
  55. package/src/ui/count-up/count-up.tsx +96 -0
  56. package/src/ui/count-up/index.ts +1 -0
  57. package/src/ui/data-list/data-list.tsx +331 -0
  58. package/src/ui/data-list/index.ts +1 -0
  59. package/src/ui/drawer/drawer.tsx +166 -0
  60. package/src/ui/drawer/index.ts +1 -0
  61. package/src/ui/flag/flag.data.ts +240 -0
  62. package/src/ui/flag/flag.tsx +57 -0
  63. package/src/ui/flag/index.ts +1 -0
  64. package/src/ui/image/image.tsx +67 -0
  65. package/src/ui/image/index.ts +1 -0
  66. package/src/ui/input/amount-input.tsx +55 -0
  67. package/src/ui/input/index.ts +2 -0
  68. package/src/ui/input/input.tsx +391 -0
  69. package/src/ui/label/index.ts +1 -0
  70. package/src/ui/label/label.tsx +34 -0
  71. package/src/ui/method-card/index.ts +1 -0
  72. package/src/ui/method-card/method-card.tsx +101 -0
  73. package/src/ui/modal/index.ts +1 -0
  74. package/src/ui/modal/modal.tsx +128 -0
  75. package/src/ui/otp-input/index.ts +1 -0
  76. package/src/ui/otp-input/otp-input.tsx +78 -0
  77. package/src/ui/pagination/index.ts +1 -0
  78. package/src/ui/pagination/pagination.tsx +138 -0
  79. package/src/ui/radio-group/index.ts +1 -0
  80. package/src/ui/radio-group/radio-group.tsx +81 -0
  81. package/src/ui/select/index.ts +1 -0
  82. package/src/ui/select/select.tsx +215 -0
  83. package/src/ui/separator/index.ts +1 -0
  84. package/src/ui/separator/separator.tsx +54 -0
  85. package/src/ui/skeleton/index.ts +1 -0
  86. package/src/ui/skeleton/skeleton.tsx +44 -0
  87. package/src/ui/spinner/index.ts +1 -0
  88. package/src/ui/spinner/spinner.tsx +33 -0
  89. package/src/ui/switch/index.ts +1 -0
  90. package/src/ui/switch/switch.tsx +48 -0
  91. package/src/ui/tabs/index.ts +1 -0
  92. package/src/ui/tabs/tabs.tsx +190 -0
  93. package/src/ui/textarea/index.ts +1 -0
  94. package/src/ui/textarea/textarea.tsx +151 -0
  95. package/src/ui/theme-switch/index.ts +1 -0
  96. package/src/ui/theme-switch/theme-switch.tsx +161 -0
  97. package/src/ui/toast/index.ts +1 -0
  98. package/src/ui/toast/toast.tsx +159 -0
  99. package/src/utils/api/stringFormat.ts +5 -0
  100. package/src/utils/clearAllCookies.ts +7 -0
  101. package/src/utils/clsx.ts +18 -0
  102. package/src/utils/confirm.ts +48 -0
  103. package/src/utils/copy-to-clipboard.ts +12 -0
  104. package/src/utils/date/convertTimestamp.ts +44 -0
  105. package/src/utils/date/formatCountdown.ts +9 -0
  106. package/src/utils/date/formatDayLabel.ts +50 -0
  107. package/src/utils/date/formatRelativeDateTime.ts +34 -0
  108. package/src/utils/date/locales.ts +9 -0
  109. package/src/utils/form/validationSchema.ts +46 -0
  110. package/src/utils/getByKeyString.ts +15 -0
  111. package/src/utils/getCssVar.ts +2 -0
  112. package/src/utils/getDeepType.ts +3 -0
  113. package/src/utils/getQueryParams.ts +21 -0
  114. package/src/utils/inAppBrowser.ts +151 -0
  115. package/src/utils/index.ts +28 -0
  116. package/src/utils/isRTL.ts +29 -0
  117. package/src/utils/notify.ts +46 -0
  118. package/src/utils/openExternal.ts +27 -0
  119. package/src/utils/system/getMillisecondsPeriod.ts +31 -0
  120. package/src/utils/validation/authValidators.ts +23 -0
  121. package/src/utils/validation/refCode.ts +30 -0
  122. package/styles/base.css +97 -0
  123. package/styles/brand.css +36 -0
  124. package/styles/index.css +19 -0
  125. package/styles/motion.css +207 -0
  126. package/styles/semantic.css +151 -0
  127. package/styles/theme.css +143 -0
  128. package/styles/utilities.css +289 -0
@@ -0,0 +1,3 @@
1
+ export { Icon } from './Icon'
2
+ export { UIIconNames } from './Icon.types'
3
+ export type { IconProps, IconNameType } from './Icon.types'
@@ -0,0 +1,185 @@
1
+ import * as React from 'react'
2
+
3
+ import { Slot, Slottable } from '@radix-ui/react-slot'
4
+ import { type VariantProps, cva } from 'class-variance-authority'
5
+
6
+ import { Icon } from '../Icon'
7
+ import type { IconNameType } from '../Icon/Icon.types'
8
+ import { cn } from '../../lib/utils'
9
+
10
+ // Две независимые оси: tone (цвет/статус) × appearance (заливка). Конкретные
11
+ // цвета задаются их пересечением в compoundVariants. size — размер текста/паддингов.
12
+ const badgeVariants = cva(
13
+ 'inline-flex w-fit shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded transition-colors [&_svg]:pointer-events-none [&_svg]:shrink-0',
14
+ {
15
+ variants: {
16
+ tone: {
17
+ primary: '',
18
+ success: '',
19
+ warning: '',
20
+ error: '',
21
+ neutral: '',
22
+ },
23
+ appearance: {
24
+ solid: '',
25
+ soft: '',
26
+ outline: 'border',
27
+ },
28
+ },
29
+ compoundVariants: [
30
+ // solid — насыщенная заливка
31
+ {
32
+ appearance: 'solid',
33
+ tone: 'primary',
34
+ class: 'bg-primary text-primary-foreground',
35
+ },
36
+ {
37
+ appearance: 'solid',
38
+ tone: 'success',
39
+ class: 'bg-success text-success-foreground',
40
+ },
41
+ {
42
+ appearance: 'solid',
43
+ tone: 'warning',
44
+ class: 'bg-warning text-warning-foreground',
45
+ },
46
+ {
47
+ appearance: 'solid',
48
+ tone: 'error',
49
+ class: 'bg-error text-error-foreground',
50
+ },
51
+ {
52
+ appearance: 'solid',
53
+ tone: 'neutral',
54
+ class: 'bg-secondary text-secondary-foreground',
55
+ },
56
+ // soft — тонированная подложка + цветной текст
57
+ { appearance: 'soft', tone: 'primary', class: 'bg-accent text-primary' },
58
+ {
59
+ appearance: 'soft',
60
+ tone: 'success',
61
+ class: 'bg-success-soft text-success',
62
+ },
63
+ {
64
+ appearance: 'soft',
65
+ tone: 'warning',
66
+ class: 'bg-warning-soft text-warning',
67
+ },
68
+ {
69
+ appearance: 'soft',
70
+ tone: 'error',
71
+ class: 'bg-error-soft text-error',
72
+ },
73
+ {
74
+ appearance: 'soft',
75
+ tone: 'neutral',
76
+ class: 'bg-secondary text-muted-foreground',
77
+ },
78
+ // outline — рамка цвета тона
79
+ {
80
+ appearance: 'outline',
81
+ tone: 'primary',
82
+ class: 'border-primary text-primary',
83
+ },
84
+ {
85
+ appearance: 'outline',
86
+ tone: 'success',
87
+ class: 'border-success text-success',
88
+ },
89
+ {
90
+ appearance: 'outline',
91
+ tone: 'warning',
92
+ class: 'border-warning text-warning',
93
+ },
94
+ {
95
+ appearance: 'outline',
96
+ tone: 'error',
97
+ class: 'border-error text-error',
98
+ },
99
+ {
100
+ appearance: 'outline',
101
+ tone: 'neutral',
102
+ class: 'border-border text-foreground',
103
+ },
104
+ ],
105
+ defaultVariants: { tone: 'primary', appearance: 'soft' },
106
+ },
107
+ )
108
+
109
+ // Размер — FLUID (padding/шрифт клампятся 320→1280px), а не cva.
110
+ const BADGE_SIZE: Record<'sm' | 'md' | 'lg', string> = {
111
+ sm: 'px-fl-6/8 py-fl-2/4 text-caption',
112
+ md: 'px-fl-8/10 py-fl-6/8 text-button-s',
113
+ lg: 'px-fl-10/12 py-fl-8/10 text-button-s',
114
+ }
115
+
116
+ // Дефолтная иконка-знак под каждый тон (для withIcon). Пара «тон → знак» живёт здесь,
117
+ // чтобы статусные баджи не собирали <Icon> руками. Можно перебить через startContent.
118
+ const TONE_ICON: Record<
119
+ NonNullable<VariantProps<typeof badgeVariants>['tone']>,
120
+ IconNameType
121
+ > = {
122
+ primary: 'info',
123
+ success: 'check_circle',
124
+ warning: 'info',
125
+ error: 'warning',
126
+ neutral: 'info',
127
+ }
128
+ // Размер иконки под размер баджа.
129
+ const ICON_SIZE: Record<'sm' | 'md' | 'lg', number> = { sm: 12, md: 14, lg: 16 }
130
+
131
+ export interface BadgeProps
132
+ extends
133
+ React.ComponentProps<'span'>,
134
+ Omit<VariantProps<typeof badgeVariants>, 'size'> {
135
+ /** Размер: `sm | md | lg`. По умолчанию md. */
136
+ size?: 'sm' | 'md' | 'lg'
137
+ /** Рендерить как дочерний элемент (напр. ссылку <a>) вместо <span>. */
138
+ asChild?: boolean
139
+ /** Показать слева дефолтную иконку-знак тона (TONE_ICON). Перебивается startContent. */
140
+ withIcon?: boolean
141
+ /** Контент перед текстом (обычно иконка статуса). Наследует цвет тона. */
142
+ startContent?: React.ReactNode
143
+ }
144
+
145
+ export function Badge({
146
+ className,
147
+ tone,
148
+ appearance,
149
+ size,
150
+ asChild = false,
151
+ withIcon = false,
152
+ startContent,
153
+ children,
154
+ ...props
155
+ }: BadgeProps) {
156
+ const Comp = asChild ? Slot : 'span'
157
+ // Ведущий контент: явный startContent приоритетнее; иначе по withIcon — дефолтная
158
+ // иконка тона. Наследует цвет тона (isCurrentColor).
159
+ const leading =
160
+ startContent ??
161
+ (withIcon ? (
162
+ <Icon
163
+ type={TONE_ICON[tone ?? 'primary']}
164
+ size={ICON_SIZE[size ?? 'md']}
165
+ isCurrentColor
166
+ />
167
+ ) : null)
168
+ return (
169
+ <Comp
170
+ className={cn(
171
+ badgeVariants({ tone, appearance }),
172
+ BADGE_SIZE[size ?? 'md'],
173
+ className,
174
+ )}
175
+ {...props}
176
+ >
177
+ {leading}
178
+ {/* Slottable указывает Slot, какой ребёнок «настоящий» (для asChild),
179
+ чтобы startContent оставался слева и не ломал Slot. */}
180
+ <Slottable>{children}</Slottable>
181
+ </Comp>
182
+ )
183
+ }
184
+
185
+ export { badgeVariants }
@@ -0,0 +1 @@
1
+ export * from './badge'
@@ -0,0 +1,63 @@
1
+ import { Fragment } from 'react'
2
+
3
+ import Link from 'next/link'
4
+
5
+ import { cn } from '../../lib/utils'
6
+
7
+ // Один сегмент пути. С href → ссылка (предок), без href → просто текст.
8
+ export interface BreadcrumbItem {
9
+ /** Подпись сегмента. Приходит уже локализованной (как title/subtitle в PageHeader). */
10
+ label: string
11
+ /** Куда ведёт сегмент. У текущей страницы (последний сегмент) href обычно не задают. */
12
+ href?: string
13
+ }
14
+
15
+ // Хлебные крошки над заголовком страницы (см. PageHeader). Сегменты передают пропсом
16
+ // уже переведёнными. Последний сегмент — текущая страница: не ссылка, помечен
17
+ // aria-current. Разделитель — «/». Цвет приглушённый (muted), ссылки темнеют на hover.
18
+ export function Breadcrumbs({
19
+ items,
20
+ className,
21
+ // aria-label для landmark <nav>. SR-only; при необходимости локализуй через проп.
22
+ ariaLabel = 'breadcrumb',
23
+ }: {
24
+ items: BreadcrumbItem[]
25
+ className?: string
26
+ ariaLabel?: string
27
+ }) {
28
+ if (items.length === 0) return null
29
+
30
+ return (
31
+ <nav aria-label={ariaLabel} className={cn('min-w-0', className)}>
32
+ <ol className='flex flex-wrap items-center gap-x-2 gap-y-1 text-body-s text-muted-foreground'>
33
+ {items.map((item, index) => {
34
+ const isLast = index === items.length - 1
35
+ return (
36
+ <Fragment key={`${item.label}-${index}`}>
37
+ <li>
38
+ {item.href && !isLast ? (
39
+ <Link
40
+ href={item.href}
41
+ className='rounded-sm underline-offset-2 outline-none transition-colors hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring'
42
+ >
43
+ {item.label}
44
+ </Link>
45
+ ) : (
46
+ <span aria-current={isLast ? 'page' : undefined}>
47
+ {item.label}
48
+ </span>
49
+ )}
50
+ </li>
51
+ {/* Разделитель — декоративный, скринридеру не нужен. */}
52
+ {!isLast && (
53
+ <li aria-hidden className='select-none'>
54
+ /
55
+ </li>
56
+ )}
57
+ </Fragment>
58
+ )
59
+ })}
60
+ </ol>
61
+ </nav>
62
+ )
63
+ }
@@ -0,0 +1 @@
1
+ export * from './breadcrumbs'
@@ -0,0 +1,22 @@
1
+ import { cn } from '../../lib/utils'
2
+
3
+ // Анимированный бургер: две пилюли (пропорции из Union.svg — 18 шир., толщина ~2, gap ~4),
4
+ // при open складываются в крестик. Обе позиционируем по `top` (не bottom) → переход
5
+ // top→центр + rotate анимируется плавно. Цвет наследуется (bg-current).
6
+ export function BurgerIcon({ open }: { open: boolean }) {
7
+ const bar =
8
+ 'absolute left-0 h-[2px] w-full rounded-full bg-current transition-all duration-300 ease-out'
9
+ return (
10
+ <span aria-hidden className='relative block h-4 w-[18px]'>
11
+ <span
12
+ className={cn(bar, open ? 'top-1/2 -translate-y-1/2 rotate-45' : 'top-1')}
13
+ />
14
+ <span
15
+ className={cn(
16
+ bar,
17
+ open ? 'top-1/2 -translate-y-1/2 -rotate-45' : 'top-[10px]',
18
+ )}
19
+ />
20
+ </span>
21
+ )
22
+ }
@@ -0,0 +1 @@
1
+ export * from './burger-icon'
@@ -0,0 +1,179 @@
1
+ import * as React from 'react'
2
+
3
+ import { Slot, Slottable } from '@radix-ui/react-slot'
4
+ import { type VariantProps, cva } from 'class-variance-authority'
5
+
6
+ import { cn } from '../../lib/utils'
7
+
8
+ import { Spinner } from '../spinner'
9
+
10
+ const buttonVariants = cva(
11
+ // Текст ПЕРЕНОСИТСЯ (whitespace-normal) и балансируется по строкам (text-balance) — длинная
12
+ // локализованная подпись не выталкивает границы, а уходит на 2 строки. Truncate тут НЕ нужен:
13
+ // подпись кнопки — действие, её нельзя обрезать (в отличие от динамики в nav-item/крошках).
14
+ 'inline-flex items-center justify-center gap-2 whitespace-normal text-center text-balance transition-[color,background-color,border-color,transform] outline-none cursor-pointer select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 transition-all active:scale-99',
15
+ {
16
+ variants: {
17
+ variant: {
18
+ default:
19
+ 'bg-primary text-primary-foreground hover:bg-primary/80 active:bg-blue-pressed',
20
+ secondary:
21
+ 'border border-ring bg-card text-ring hover:bg-secondary hover:border-secondary active:bg-blue-pressed active:border-ice active:text-primary-foreground',
22
+ ghost: 'text-ring hover:bg-secondary active:bg-transparent',
23
+ // outline = только структура (рамка + прозрачный фон); сам цвет (текст/
24
+ // граница/hover) задаёт ось `color` ниже через compoundVariants.
25
+ outline: 'border bg-transparent',
26
+ // link = текст-кнопка без коробки: ни фона, ни рамки, ни паддингов/высоты
27
+ // (их даёт BUTTON_SIZE, а link их НЕ получает — см. LINK_SIZE ниже). Цвет —
28
+ // от оси `color` через compoundVariants, как у outline. rounded-sm только под
29
+ // кольцо фокуса. Размер задаёт лишь текстовый токен (text-button-*).
30
+ link: 'rounded-sm bg-transparent',
31
+ },
32
+ // Цветовая ось — ортогональна `variant`. Сейчас влияет только на `outline`,
33
+ // но ось общая: цветной solid/ghost можно добавить тем же приёмом (ещё
34
+ // записи в compoundVariants), не плодя имена вроде outlineRed/outlineGold.
35
+ color: {
36
+ primary: '',
37
+ danger: '',
38
+ warning: '',
39
+ },
40
+ },
41
+ // variant × color: оттенок применяется только к нужной форме.
42
+ compoundVariants: [
43
+ {
44
+ variant: 'outline',
45
+ color: 'primary',
46
+ className: 'text-blue border-blue hover:bg-accent',
47
+ },
48
+ {
49
+ variant: 'outline',
50
+ color: 'danger',
51
+ className: 'text-red border-red hover:bg-error-soft',
52
+ },
53
+ {
54
+ variant: 'outline',
55
+ color: 'warning',
56
+ className: 'text-gold border-gold hover:bg-warning-soft',
57
+ },
58
+ // link × color — тот же приём, что у outline: оттенок текста + затемнение на hover.
59
+ {
60
+ variant: 'link',
61
+ color: 'primary',
62
+ className:
63
+ 'text-muted-foreground hover:text-blue active:text-blue-pressed',
64
+ },
65
+ {
66
+ variant: 'link',
67
+ color: 'danger',
68
+ className: 'text-red hover:text-red/80',
69
+ },
70
+ {
71
+ variant: 'link',
72
+ color: 'warning',
73
+ className: 'text-gold hover:text-gold/80',
74
+ },
75
+ ],
76
+ defaultVariants: {
77
+ variant: 'default',
78
+ color: 'primary',
79
+ },
80
+ },
81
+ )
82
+
83
+ type ButtonSize = 'sm' | 'md' | 'lg'
84
+
85
+ // Размер — FLUID: высота-«пол», padding и шрифт клампятся 320→1280px → контрол сам
86
+ // плавно растёт по ширине экрана. Один size (sm/md/lg) — это «тир», а не брейкпоинт.
87
+ const BUTTON_SIZE: Record<ButtonSize, string> = {
88
+ sm: 'min-h-fl-32/36 p-fl-14/16 text-button-s rounded-sm',
89
+ md: 'min-h-fl-40/44 p-fl-18/20 text-button-m rounded-md',
90
+ lg: 'min-h-fl-48/52 p-fl-22/24 text-button-l rounded-lg',
91
+ }
92
+ // icon-only: квадрат (сторона клампится так же, как высота текстовой кнопки), без padding.
93
+ const BUTTON_ICON_SIZE: Record<ButtonSize, string> = {
94
+ sm: 'min-h-fl-32/36 p-fl-14/16 text-button-s rounded-sm',
95
+ md: 'min-h-fl-40/44 p-fl-18/20 text-button-m rounded-md',
96
+ lg: 'min-h-fl-48/52 p-fl-22/24 text-button-l rounded-lg',
97
+ }
98
+ // link: НЕ коробка — только текстовый токен (без min-h/padding/rounded), чтобы кнопка
99
+ // вела себя как инлайн-текст и вставала в строку наравне с обычным текстом.
100
+ const LINK_SIZE: Record<ButtonSize, string> = {
101
+ sm: 'text-button-s',
102
+ md: 'text-button-m',
103
+ lg: 'text-button-l',
104
+ }
105
+
106
+ export interface ButtonProps
107
+ // Omit нативного `color` (string у <button>) — цвет задаёт ось `color` из cva.
108
+ extends
109
+ Omit<React.ComponentProps<'button'>, 'color'>,
110
+ VariantProps<typeof buttonVariants> {
111
+ /** Размер: `sm | md | lg`. По умолчанию md. Каждый размер сам fluid-клампится. */
112
+ size?: ButtonSize
113
+ /** Рендерить как дочерний элемент (напр. ссылку <a>) вместо <button>. */
114
+ asChild?: boolean
115
+ /**
116
+ * Квадратная кнопка под одну иконку (сторона = высоте текстовой кнопки того же
117
+ * `size`). Иконку передавай в `children`. Обязательно задай `aria-label`.
118
+ */
119
+ isIconOnly?: boolean
120
+ /** Контент перед текстом (обычно иконка). */
121
+ startContent?: React.ReactNode
122
+ /** Контент после текста (обычно иконка). */
123
+ endContent?: React.ReactNode
124
+ /** Состояние загрузки: показывает спиннер вместо startContent и блокирует кнопку. */
125
+ isLoading?: boolean
126
+ }
127
+
128
+ export function Button({
129
+ className,
130
+ variant,
131
+ color,
132
+ size = 'md',
133
+ isIconOnly = false,
134
+ asChild = false,
135
+ startContent,
136
+ endContent,
137
+ isLoading = false,
138
+ disabled,
139
+ children,
140
+ ...props
141
+ }: ButtonProps) {
142
+ const Comp = asChild ? Slot : 'button'
143
+ // link — инлайн-текст: берёт только текстовый токен (LINK_SIZE), без коробки.
144
+ const isLink = variant === 'link'
145
+ const sizeClasses = isLink
146
+ ? LINK_SIZE[size]
147
+ : (isIconOnly ? BUTTON_ICON_SIZE : BUTTON_SIZE)[size]
148
+ return (
149
+ <Comp
150
+ className={cn(
151
+ buttonVariants({ variant, color }),
152
+ sizeClasses,
153
+ // Крупный gap — только у текстовой кнопки-коробки; у icon-only и link его нет
154
+ // (link ведёт себя как текст: иконка рядом с базовым gap-2).
155
+ !isIconOnly && !isLink && 'gap-3.75',
156
+ className,
157
+ )}
158
+ disabled={disabled || isLoading}
159
+ aria-busy={isLoading || undefined}
160
+ {...props}
161
+ >
162
+ {/* start/endContent — только для текстовой кнопки; у icon-only единственный слот — children. */}
163
+ {!isIconOnly && startContent}
164
+ {/* Slottable указывает Slot, какой ребёнок — «настоящий» (для asChild),
165
+ чтобы start/endContent оставались по краям и не ломали Slot.
166
+ У icon-only во время загрузки спиннер ЗАМЕНЯЕТ иконку, а не добавляется рядом. */}
167
+ {isIconOnly && isLoading ? (
168
+ <Spinner className='size-[1.25em]' />
169
+ ) : (
170
+ <Slottable>{children}</Slottable>
171
+ )}
172
+ {/* Лоадер всегда справа — заменяет endContent на время загрузки. */}
173
+ {!isIconOnly &&
174
+ (isLoading ? <Spinner className='size-[1.25em]' /> : endContent)}
175
+ </Comp>
176
+ )
177
+ }
178
+
179
+ export { buttonVariants }
@@ -0,0 +1 @@
1
+ export * from './button'
@@ -0,0 +1,100 @@
1
+ import * as React from 'react'
2
+
3
+ import { Slot } from '@radix-ui/react-slot'
4
+ import { type VariantProps, cva } from 'class-variance-authority'
5
+
6
+ import { cn } from '../../lib/utils'
7
+
8
+ // Card — контентный контейнер («коробка»): поверхность, скругление, рамка,
9
+ // внутренние отступы. Поведения нет → без Radix, это просто стилизованные div-ы.
10
+ // - tone — цветовой акцент под статусные карточки дизайна (success/brand/warning);
11
+ // - interactive — hover/курсор/фокус для кликабельных строк-карточек.
12
+ const cardVariants = cva(
13
+ 'flex flex-col gap-5 rounded-lg p-5 text-card-foreground transition-colors',
14
+ {
15
+ variants: {
16
+ tone: {
17
+ default: 'bg-card',
18
+ success: 'border border-success bg-success-soft',
19
+ warning: 'border border-warning bg-warning-soft',
20
+ error: 'border border-error bg-error-soft',
21
+ brand: 'border-transparent bg-brand-gradient text-primary-foreground',
22
+ },
23
+ // Кликабельная карточка: плавный transition (цвет рамки + подъём + scale),
24
+ // подсветка рамки и лёгкий «подъём» с мягкой тенью на hover, кольцо фокуса,
25
+ // лёгкое нажатие. transition тут (а не только базовый transition-colors) —
26
+ // чтобы анимировались и hover:-translate-y, и active:scale-99.
27
+ interactive: {
28
+ true: 'cursor-pointer outline-none transition duration-200 hover:border-ring hover:-translate-y-0.5 focus-visible:ring-2 active:translate-y-0 active:scale-[0.99] focus-visible:ring-ring',
29
+ false: '',
30
+ },
31
+ },
32
+ defaultVariants: {
33
+ tone: 'default',
34
+ interactive: false,
35
+ },
36
+ },
37
+ )
38
+
39
+ export interface CardProps
40
+ extends React.ComponentProps<'div'>, VariantProps<typeof cardVariants> {
41
+ /** Рендерить как дочерний элемент (напр. <a>/<Link>) — для кликабельных карточек. */
42
+ asChild?: boolean
43
+ }
44
+
45
+ export function Card({
46
+ className,
47
+ tone,
48
+ interactive,
49
+ asChild = false,
50
+ ...props
51
+ }: CardProps) {
52
+ const Comp = asChild ? Slot : 'div'
53
+ return (
54
+ <Comp
55
+ className={cn(cardVariants({ tone, interactive }), className)}
56
+ {...props}
57
+ />
58
+ )
59
+ }
60
+
61
+ // Шапка: заголовок + описание. gap-1.5 — как в хедерах Modal/Drawer.
62
+ export function CardHeader({
63
+ className,
64
+ ...props
65
+ }: React.ComponentProps<'div'>) {
66
+ return <div className={cn('flex flex-col gap-1.5', className)} {...props} />
67
+ }
68
+
69
+ export function CardTitle({ className, ...props }: React.ComponentProps<'h4'>) {
70
+ return <h4 className={cn('text-h4 text-foreground', className)} {...props} />
71
+ }
72
+
73
+ export function CardDescription({
74
+ className,
75
+ ...props
76
+ }: React.ComponentProps<'p'>) {
77
+ return (
78
+ <p
79
+ className={cn('text-body-m text-muted-foreground', className)}
80
+ {...props}
81
+ />
82
+ )
83
+ }
84
+
85
+ // Основное содержимое — flex-1, чтобы при фиксированной высоте раздвигать
86
+ // header/footer к краям. На карточках без высоты ведёт себя как обычный блок.
87
+ export function CardContent({
88
+ className,
89
+ ...props
90
+ }: React.ComponentProps<'div'>) {
91
+ return <div className={cn('flex flex-1 flex-col', className)} {...props} />
92
+ }
93
+
94
+ // Подвал — зона действий (кнопки).
95
+ export function CardFooter({
96
+ className,
97
+ ...props
98
+ }: React.ComponentProps<'div'>) {
99
+ return <div className={cn('flex flex-col gap-2', className)} {...props} />
100
+ }
@@ -0,0 +1 @@
1
+ export * from './card'
@@ -0,0 +1,89 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+
5
+ import { Button } from '../button'
6
+ import {
7
+ Modal,
8
+ ModalContent,
9
+ ModalDescription,
10
+ ModalFooter,
11
+ ModalHeader,
12
+ ModalTitle,
13
+ } from '../modal'
14
+ import { useKitT } from '../../i18n'
15
+ import { type ConfirmOptions, registerConfirmHandler } from '../../utils/confirm'
16
+ import { Icon } from '../Icon'
17
+
18
+ interface PendingConfirm {
19
+ options: ConfirmOptions
20
+ resolve: (ok: boolean) => void
21
+ }
22
+
23
+ // Хост глобальных подтверждений: рендерится один раз в Providers и показывает
24
+ // кит-модалку на каждый вызов confirmDialog(...) (см. utils/confirm.ts). «Да» →
25
+ // резолв true; отмена/Esc/клик по оверлею → false. Дефолтные подписи кнопок —
26
+ // из словаря ui.confirm; danger — красная кнопка подтверждения.
27
+ export function ConfirmDialogHost() {
28
+ const t = useKitT('ui.confirm')
29
+ const [pending, setPending] = React.useState<PendingConfirm | null>(null)
30
+
31
+ React.useEffect(
32
+ () =>
33
+ registerConfirmHandler(
34
+ options =>
35
+ new Promise<boolean>(resolve => {
36
+ setPending({ options, resolve })
37
+ }),
38
+ ),
39
+ [],
40
+ )
41
+
42
+ const settle = (ok: boolean) => {
43
+ pending?.resolve(ok)
44
+ setPending(null)
45
+ }
46
+
47
+ const options = pending?.options
48
+
49
+ // Иконка кнопки подтверждения: явный null — без иконки; не задано — дефолтная стрелка.
50
+ const confirmIcon =
51
+ options?.confirmIcon === null
52
+ ? null
53
+ : (options?.confirmIcon ?? 'arrow_right')
54
+
55
+ return (
56
+ <Modal open={!!pending} onOpenChange={next => !next && settle(false)}>
57
+ <ModalContent className='gap-fl-30/50'>
58
+ <ModalHeader className='gap-fl-14/20'>
59
+ <ModalTitle>{options?.title}</ModalTitle>
60
+ {options?.description ? (
61
+ <ModalDescription>{options.description}</ModalDescription>
62
+ ) : null}
63
+ </ModalHeader>
64
+ <ModalFooter>
65
+ {options?.cancelLabel && (
66
+ <Button
67
+ variant='outline'
68
+ className='w-full'
69
+ onClick={() => settle(false)}
70
+ >
71
+ {options?.cancelLabel ?? t('cancel')}
72
+ </Button>
73
+ )}
74
+ <Button
75
+ variant={options?.danger ? 'outline' : 'default'}
76
+ color={options?.danger ? 'danger' : 'primary'}
77
+ onClick={() => settle(true)}
78
+ className='w-full'
79
+ endContent={
80
+ confirmIcon ? <Icon type={confirmIcon} size={18} /> : undefined
81
+ }
82
+ >
83
+ {options?.confirmLabel ?? t('confirm')}
84
+ </Button>
85
+ </ModalFooter>
86
+ </ModalContent>
87
+ </Modal>
88
+ )
89
+ }
@@ -0,0 +1 @@
1
+ export * from './confirm-dialog-host'