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.
- package/README.md +168 -0
- package/bin/cli.mjs +136 -0
- package/fonts/Manrope/Manrope-VariableFont_wght.woff2 +0 -0
- package/fonts/Manrope/OFL.txt +93 -0
- package/fonts/index.ts +30 -0
- package/package.json +77 -0
- package/src/config.ts +11 -0
- package/src/hooks/index.ts +14 -0
- package/src/hooks/useAutoHideOnScroll.ts +26 -0
- package/src/hooks/useCountdown.ts +44 -0
- package/src/hooks/useDevice.ts +63 -0
- package/src/hooks/useIsTelegramMiniApp.ts +17 -0
- package/src/hooks/useIsTouch.ts +23 -0
- package/src/hooks/useKeyboardOpen.ts +35 -0
- package/src/hooks/useMediaQuery.ts +24 -0
- package/src/hooks/usePlatform.ts +102 -0
- package/src/hooks/useQueryParams.tsx +70 -0
- package/src/hooks/useRevealOnce.ts +21 -0
- package/src/hooks/useScrolledPast.ts +19 -0
- package/src/hooks/useSettleOnce.ts +26 -0
- package/src/hooks/useTelegram.tsx +57 -0
- package/src/i18n/index.tsx +92 -0
- package/src/index.ts +35 -0
- package/src/lib/device.ts +28 -0
- package/src/lib/platform.ts +19 -0
- package/src/lib/utils.ts +36 -0
- package/src/logo/LogoFull.tsx +59 -0
- package/src/logo/LogoMark.tsx +31 -0
- package/src/logo/index.ts +2 -0
- package/src/next.ts +13 -0
- package/src/server.ts +4 -0
- package/src/theme-color-sync.tsx +43 -0
- package/src/theme-provider.tsx +31 -0
- package/src/types/css.d.ts +6 -0
- package/src/types/globals.d.ts +47 -0
- package/src/ui/Icon/Icon.module.css +25 -0
- package/src/ui/Icon/Icon.tsx +45 -0
- package/src/ui/Icon/Icon.types.ts +60 -0
- package/src/ui/Icon/data/brand.data.tsx +75 -0
- package/src/ui/Icon/data/index.ts +2 -0
- package/src/ui/Icon/data/ui.data.tsx +780 -0
- package/src/ui/Icon/index.ts +3 -0
- package/src/ui/badge/badge.tsx +185 -0
- package/src/ui/badge/index.ts +1 -0
- package/src/ui/breadcrumbs/breadcrumbs.tsx +63 -0
- package/src/ui/breadcrumbs/index.ts +1 -0
- package/src/ui/burger-icon/burger-icon.tsx +22 -0
- package/src/ui/burger-icon/index.ts +1 -0
- package/src/ui/button/button.tsx +179 -0
- package/src/ui/button/index.ts +1 -0
- package/src/ui/card/card.tsx +100 -0
- package/src/ui/card/index.ts +1 -0
- package/src/ui/confirm/confirm-dialog-host.tsx +89 -0
- package/src/ui/confirm/index.ts +1 -0
- package/src/ui/count-up/count-up.tsx +96 -0
- package/src/ui/count-up/index.ts +1 -0
- package/src/ui/data-list/data-list.tsx +331 -0
- package/src/ui/data-list/index.ts +1 -0
- package/src/ui/drawer/drawer.tsx +166 -0
- package/src/ui/drawer/index.ts +1 -0
- package/src/ui/flag/flag.data.ts +240 -0
- package/src/ui/flag/flag.tsx +57 -0
- package/src/ui/flag/index.ts +1 -0
- package/src/ui/image/image.tsx +67 -0
- package/src/ui/image/index.ts +1 -0
- package/src/ui/input/amount-input.tsx +55 -0
- package/src/ui/input/index.ts +2 -0
- package/src/ui/input/input.tsx +391 -0
- package/src/ui/label/index.ts +1 -0
- package/src/ui/label/label.tsx +34 -0
- package/src/ui/method-card/index.ts +1 -0
- package/src/ui/method-card/method-card.tsx +101 -0
- package/src/ui/modal/index.ts +1 -0
- package/src/ui/modal/modal.tsx +128 -0
- package/src/ui/otp-input/index.ts +1 -0
- package/src/ui/otp-input/otp-input.tsx +78 -0
- package/src/ui/pagination/index.ts +1 -0
- package/src/ui/pagination/pagination.tsx +138 -0
- package/src/ui/radio-group/index.ts +1 -0
- package/src/ui/radio-group/radio-group.tsx +81 -0
- package/src/ui/select/index.ts +1 -0
- package/src/ui/select/select.tsx +215 -0
- package/src/ui/separator/index.ts +1 -0
- package/src/ui/separator/separator.tsx +54 -0
- package/src/ui/skeleton/index.ts +1 -0
- package/src/ui/skeleton/skeleton.tsx +44 -0
- package/src/ui/spinner/index.ts +1 -0
- package/src/ui/spinner/spinner.tsx +33 -0
- package/src/ui/switch/index.ts +1 -0
- package/src/ui/switch/switch.tsx +48 -0
- package/src/ui/tabs/index.ts +1 -0
- package/src/ui/tabs/tabs.tsx +190 -0
- package/src/ui/textarea/index.ts +1 -0
- package/src/ui/textarea/textarea.tsx +151 -0
- package/src/ui/theme-switch/index.ts +1 -0
- package/src/ui/theme-switch/theme-switch.tsx +161 -0
- package/src/ui/toast/index.ts +1 -0
- package/src/ui/toast/toast.tsx +159 -0
- package/src/utils/api/stringFormat.ts +5 -0
- package/src/utils/clearAllCookies.ts +7 -0
- package/src/utils/clsx.ts +18 -0
- package/src/utils/confirm.ts +48 -0
- package/src/utils/copy-to-clipboard.ts +12 -0
- package/src/utils/date/convertTimestamp.ts +44 -0
- package/src/utils/date/formatCountdown.ts +9 -0
- package/src/utils/date/formatDayLabel.ts +50 -0
- package/src/utils/date/formatRelativeDateTime.ts +34 -0
- package/src/utils/date/locales.ts +9 -0
- package/src/utils/form/validationSchema.ts +46 -0
- package/src/utils/getByKeyString.ts +15 -0
- package/src/utils/getCssVar.ts +2 -0
- package/src/utils/getDeepType.ts +3 -0
- package/src/utils/getQueryParams.ts +21 -0
- package/src/utils/inAppBrowser.ts +151 -0
- package/src/utils/index.ts +28 -0
- package/src/utils/isRTL.ts +29 -0
- package/src/utils/notify.ts +46 -0
- package/src/utils/openExternal.ts +27 -0
- package/src/utils/system/getMillisecondsPeriod.ts +31 -0
- package/src/utils/validation/authValidators.ts +23 -0
- package/src/utils/validation/refCode.ts +30 -0
- package/styles/base.css +97 -0
- package/styles/brand.css +36 -0
- package/styles/index.css +19 -0
- package/styles/motion.css +207 -0
- package/styles/semantic.css +151 -0
- package/styles/theme.css +143 -0
- package/styles/utilities.css +289 -0
|
@@ -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'
|