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,78 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
import * as OneTimePasswordField from '@radix-ui/react-one-time-password-field'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../../lib/utils'
|
|
8
|
+
|
|
9
|
+
// Поле кода подтверждения из N ячеек (OTP) — headless Radix OneTimePasswordField +
|
|
10
|
+
// свои стили. Примитив сам ведёт roving-фокус, Backspace/стрелки, вставку из буфера
|
|
11
|
+
// и фильтрацию ввода (validationType='numeric' и autoComplete='one-time-code' —
|
|
12
|
+
// дефолты Radix). Значение — сплошная строка цифр.
|
|
13
|
+
// error — как у Input: `true` — только красная подсветка ячеек, строка — подсветка
|
|
14
|
+
// плюс текст под полем (role="alert").
|
|
15
|
+
export function OtpInput({
|
|
16
|
+
length = 6,
|
|
17
|
+
value,
|
|
18
|
+
onChange,
|
|
19
|
+
disabled,
|
|
20
|
+
error,
|
|
21
|
+
autoFocus,
|
|
22
|
+
ariaLabel,
|
|
23
|
+
}: {
|
|
24
|
+
length?: number
|
|
25
|
+
value: string
|
|
26
|
+
onChange: (code: string) => void
|
|
27
|
+
disabled?: boolean
|
|
28
|
+
error?: boolean | string
|
|
29
|
+
autoFocus?: boolean
|
|
30
|
+
ariaLabel?: string
|
|
31
|
+
}) {
|
|
32
|
+
const hasError = !!error
|
|
33
|
+
const errorMessage = typeof error === 'string' ? error : undefined
|
|
34
|
+
|
|
35
|
+
const reactId = React.useId()
|
|
36
|
+
const messageId = `${reactId}-msg`
|
|
37
|
+
|
|
38
|
+
const field = (
|
|
39
|
+
<OneTimePasswordField.Root
|
|
40
|
+
value={value}
|
|
41
|
+
onValueChange={onChange}
|
|
42
|
+
disabled={disabled}
|
|
43
|
+
autoFocus={autoFocus}
|
|
44
|
+
aria-label={ariaLabel}
|
|
45
|
+
aria-describedby={errorMessage ? messageId : undefined}
|
|
46
|
+
className='flex items-center justify-center gap-fl-8/12'
|
|
47
|
+
>
|
|
48
|
+
{/* Число ячеек = число Input-детей — так Radix узнаёт length. */}
|
|
49
|
+
{Array.from({ length }, (_, index) => (
|
|
50
|
+
<OneTimePasswordField.Input
|
|
51
|
+
key={index}
|
|
52
|
+
aria-invalid={hasError || undefined}
|
|
53
|
+
className={cn(
|
|
54
|
+
'size-fl-44/52 rounded-md bg-secondary text-center text-button-l font-medium text-foreground outline-none transition-[color,box-shadow]',
|
|
55
|
+
'focus:ring-2 focus:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
|
56
|
+
hasError && 'ring-1 ring-error bg-error-soft focus:ring-error',
|
|
57
|
+
)}
|
|
58
|
+
/>
|
|
59
|
+
))}
|
|
60
|
+
</OneTimePasswordField.Root>
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
// Без текста сообщения возвращаем только поле — лишнюю обёртку не плодим (как Input).
|
|
64
|
+
if (!errorMessage) return field
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className='flex w-full flex-col gap-1.5'>
|
|
68
|
+
{field}
|
|
69
|
+
<p
|
|
70
|
+
id={messageId}
|
|
71
|
+
role='alert'
|
|
72
|
+
className='text-center text-button-s text-error'
|
|
73
|
+
>
|
|
74
|
+
{errorMessage}
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './pagination'
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
|
|
7
|
+
import { Icon } from '../Icon'
|
|
8
|
+
import { Button } from '../button'
|
|
9
|
+
|
|
10
|
+
export interface PaginationProps {
|
|
11
|
+
/** Текущая страница (с 1). */
|
|
12
|
+
page: number
|
|
13
|
+
/** Всего элементов. */
|
|
14
|
+
total: number
|
|
15
|
+
/** Размер страницы. */
|
|
16
|
+
pageSize: number
|
|
17
|
+
onPageChange: (page: number) => void
|
|
18
|
+
/** Подпись для aria-label навигации. */
|
|
19
|
+
label?: string
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ELLIPSIS = '…' as const
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Ряд номеров страниц: 1 … (p-1) p (p+1) … N. Всегда видны первая/последняя,
|
|
27
|
+
* вокруг текущей — по соседу; разрывы схлопываются в многоточие.
|
|
28
|
+
*/
|
|
29
|
+
const buildRange = (
|
|
30
|
+
page: number,
|
|
31
|
+
pageCount: number,
|
|
32
|
+
): Array<number | typeof ELLIPSIS> => {
|
|
33
|
+
// До 7 страниц выводим все — многоточие не нужно.
|
|
34
|
+
if (pageCount <= 7) return Array.from({ length: pageCount }, (_, i) => i + 1)
|
|
35
|
+
|
|
36
|
+
const wanted = new Set([
|
|
37
|
+
1,
|
|
38
|
+
2,
|
|
39
|
+
page - 1,
|
|
40
|
+
page,
|
|
41
|
+
page + 1,
|
|
42
|
+
pageCount - 1,
|
|
43
|
+
pageCount,
|
|
44
|
+
])
|
|
45
|
+
const result: Array<number | typeof ELLIPSIS> = []
|
|
46
|
+
let prev = 0
|
|
47
|
+
for (let i = 1; i <= pageCount; i++) {
|
|
48
|
+
if (!wanted.has(i)) continue
|
|
49
|
+
// Разрыв в один номер бессмысленно прятать за «…» — показываем сам номер.
|
|
50
|
+
if (i - prev === 2) result.push(i - 1)
|
|
51
|
+
else if (i - prev > 2) result.push(ELLIPSIS)
|
|
52
|
+
result.push(i)
|
|
53
|
+
prev = i
|
|
54
|
+
}
|
|
55
|
+
return result
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Классическая номерная пагинация (1 2 3 … N) c prev/next. Управляемая: страница и
|
|
60
|
+
* обработчик приходят сверху (хуки списков и так постраничные — page/pageSize).
|
|
61
|
+
*/
|
|
62
|
+
export function Pagination({
|
|
63
|
+
page,
|
|
64
|
+
total,
|
|
65
|
+
pageSize,
|
|
66
|
+
onPageChange,
|
|
67
|
+
label,
|
|
68
|
+
className,
|
|
69
|
+
}: PaginationProps) {
|
|
70
|
+
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)))
|
|
71
|
+
const range = useMemo(() => buildRange(page, pageCount), [page, pageCount])
|
|
72
|
+
|
|
73
|
+
if (pageCount <= 1) return null
|
|
74
|
+
|
|
75
|
+
const goTo = (next: number) => {
|
|
76
|
+
const clamped = Math.min(Math.max(1, next), pageCount)
|
|
77
|
+
if (clamped !== page) onPageChange(clamped)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<nav
|
|
82
|
+
aria-label={label}
|
|
83
|
+
className={cn('flex items-center justify-center gap-1', className)}
|
|
84
|
+
>
|
|
85
|
+
<Button
|
|
86
|
+
variant='ghost'
|
|
87
|
+
size='sm'
|
|
88
|
+
isIconOnly
|
|
89
|
+
aria-label={`${label ?? ''} ←`.trim()}
|
|
90
|
+
disabled={page <= 1}
|
|
91
|
+
onClick={() => goTo(page - 1)}
|
|
92
|
+
>
|
|
93
|
+
<Icon
|
|
94
|
+
type='chevron_right'
|
|
95
|
+
size='1.25em'
|
|
96
|
+
isCurrentColor
|
|
97
|
+
className='rotate-180'
|
|
98
|
+
/>
|
|
99
|
+
</Button>
|
|
100
|
+
|
|
101
|
+
{range.map((item, idx) =>
|
|
102
|
+
item === ELLIPSIS ? (
|
|
103
|
+
<span
|
|
104
|
+
key={`gap-${idx}`}
|
|
105
|
+
aria-hidden
|
|
106
|
+
className='min-w-fl-32/36 text-center text-button-s text-muted-foreground select-none'
|
|
107
|
+
>
|
|
108
|
+
{ELLIPSIS}
|
|
109
|
+
</span>
|
|
110
|
+
) : (
|
|
111
|
+
<Button
|
|
112
|
+
key={item}
|
|
113
|
+
variant={item === page ? 'default' : 'ghost'}
|
|
114
|
+
size='sm'
|
|
115
|
+
isIconOnly
|
|
116
|
+
className='size-fl-36/42! p-0!'
|
|
117
|
+
aria-label={String(item)}
|
|
118
|
+
aria-current={item === page ? 'page' : undefined}
|
|
119
|
+
onClick={() => goTo(item)}
|
|
120
|
+
>
|
|
121
|
+
{item}
|
|
122
|
+
</Button>
|
|
123
|
+
),
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
<Button
|
|
127
|
+
variant='ghost'
|
|
128
|
+
size='sm'
|
|
129
|
+
isIconOnly
|
|
130
|
+
aria-label={`${label ?? ''} →`.trim()}
|
|
131
|
+
disabled={page >= pageCount}
|
|
132
|
+
onClick={() => goTo(page + 1)}
|
|
133
|
+
>
|
|
134
|
+
<Icon type='chevron_right' size='1.25em' isCurrentColor />
|
|
135
|
+
</Button>
|
|
136
|
+
</nav>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './radio-group'
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
|
|
6
|
+
import { type VariantProps, cva } from 'class-variance-authority'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../../lib/utils'
|
|
9
|
+
|
|
10
|
+
// Радио-группа — headless Radix + свои стили. Root ведёт одиночный выбор, клавиатуру
|
|
11
|
+
// (стрелки) и ARIA; раскладку задаёт потребитель через className (по умолчанию —
|
|
12
|
+
// вертикальный список карточек с гэпом, как в макетах тарифов/способов оплаты).
|
|
13
|
+
export function RadioGroup({
|
|
14
|
+
className,
|
|
15
|
+
...props
|
|
16
|
+
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
|
17
|
+
return (
|
|
18
|
+
<RadioGroupPrimitive.Root
|
|
19
|
+
className={cn('flex flex-col gap-2.5', className)}
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Внешний вид карточки-варианта (по макетам два вида):
|
|
26
|
+
// - bordered (деф.) — белая карточка, у выбранной только синяя рамка (способы оплаты);
|
|
27
|
+
// - soft — лёгкая рамка всегда, у выбранной синяя рамка + голубая заливка (тарифы).
|
|
28
|
+
const radioCardVariants = cva(
|
|
29
|
+
'group flex w-full cursor-pointer items-center gap-fl-12/16 rounded-lg border-2 p-5 text-left transition outline-none hover:border-ring focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-99 disabled:pointer-events-none disabled:opacity-50',
|
|
30
|
+
{
|
|
31
|
+
variants: {
|
|
32
|
+
appearance: {
|
|
33
|
+
bordered:
|
|
34
|
+
'border-transparent bg-card data-[state=checked]:border-primary',
|
|
35
|
+
soft: 'border-border bg-card data-[state=checked]:border-primary data-[state=checked]:bg-accent',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
defaultVariants: { appearance: 'bordered' },
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// Кружок-индикатор радио: пустой с серой рамкой; у выбранного (data-state=checked
|
|
43
|
+
// на Item-родителе с классом group) — синяя рамка и точка внутри. Размер — как в
|
|
44
|
+
// макете (24px). Отдельным экспортом не нужен — используется внутри RadioGroupCard.
|
|
45
|
+
function RadioDot() {
|
|
46
|
+
return (
|
|
47
|
+
<span
|
|
48
|
+
aria-hidden
|
|
49
|
+
className={cn(
|
|
50
|
+
'relative flex size-6 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
|
|
51
|
+
'border-disabled group-data-[state=checked]:border-primary',
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
<RadioGroupPrimitive.Indicator asChild forceMount>
|
|
55
|
+
<span className='size-3 scale-0 rounded-full bg-primary transition-transform group-data-[state=checked]:scale-100' />
|
|
56
|
+
</RadioGroupPrimitive.Indicator>
|
|
57
|
+
</span>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Карточка-вариант радио-группы: вся карточка — кнопка выбора, слева кружок-радио,
|
|
62
|
+
// дальше произвольный контент (children). Рамка есть всегда → выбор не сдвигает
|
|
63
|
+
// раскладку. Тарифы кладут сюда «название+период | цена» (appearance='soft'),
|
|
64
|
+
// способы оплаты — «иконка + название/описание» (bordered).
|
|
65
|
+
export function RadioGroupCard({
|
|
66
|
+
className,
|
|
67
|
+
appearance,
|
|
68
|
+
children,
|
|
69
|
+
...props
|
|
70
|
+
}: React.ComponentProps<typeof RadioGroupPrimitive.Item> &
|
|
71
|
+
VariantProps<typeof radioCardVariants>) {
|
|
72
|
+
return (
|
|
73
|
+
<RadioGroupPrimitive.Item
|
|
74
|
+
className={cn(radioCardVariants({ appearance }), className)}
|
|
75
|
+
{...props}
|
|
76
|
+
>
|
|
77
|
+
<RadioDot />
|
|
78
|
+
{children}
|
|
79
|
+
</RadioGroupPrimitive.Item>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './select'
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
import * as SelectPrimitive from '@radix-ui/react-select'
|
|
6
|
+
|
|
7
|
+
import { Icon } from '../Icon'
|
|
8
|
+
import { cn } from '../../lib/utils'
|
|
9
|
+
|
|
10
|
+
export interface SelectOption {
|
|
11
|
+
value: string
|
|
12
|
+
label: string
|
|
13
|
+
/** Контент в начале пункта (флаг/иконка/что угодно). Показывается и в списке, и в значении триггера. */
|
|
14
|
+
startContent?: React.ReactNode
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// group — чтобы шеврон в триггере доворачивался по data-state. Размер — через
|
|
19
|
+
// responsiveSize (CSS-адаптив без флэша), а не cva. Иконки — в em (масштаб за font-size).
|
|
20
|
+
const SELECT_TRIGGER_BASE =
|
|
21
|
+
'group inline-flex w-full cursor-pointer items-center bg-card text-foreground shadow-sm transition-[color,box-shadow] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-disabled:pointer-events-none data-disabled:opacity-50 data-placeholder:text-muted-foreground rounded-full'
|
|
22
|
+
const SELECT_TRIGGER_SIZE: Record<'sm' | 'md' | 'lg', string> = {
|
|
23
|
+
sm: 'gap-fl-10/12 min-h-fl-28/32 px-fl-10/12 py-fl-2/4 text-button-s',
|
|
24
|
+
md: 'gap-fl-14/16 min-h-fl-36/40 px-fl-14/16 py-fl-6/8 text-button-m',
|
|
25
|
+
lg: 'gap-fl-18/20 min-h-fl-44/48 px-fl-18/20 py-fl-10/12 text-button-l',
|
|
26
|
+
}
|
|
27
|
+
// Пункт = носитель подсветки; текст инсетит px ВНУТРИ пункта (выравнивание с триггером).
|
|
28
|
+
const SELECT_ITEM_SIZE: Record<'sm' | 'md' | 'lg', string> = {
|
|
29
|
+
sm: 'rounded-sm px-fl-6/8 py-fl-6/8 text-button-s',
|
|
30
|
+
md: 'rounded-md px-fl-10/12 py-fl-8/10 text-button-m',
|
|
31
|
+
lg: 'rounded-lg px-fl-14/16 py-fl-10/12 text-button-l',
|
|
32
|
+
}
|
|
33
|
+
// Боковой инсет разделителя = px пункта → линия выровнена с текстом и уже подсветки.
|
|
34
|
+
const SELECT_DIVIDER_INSET: Record<'sm' | 'md' | 'lg', string> = {
|
|
35
|
+
sm: 'mx-2',
|
|
36
|
+
md: 'mx-3',
|
|
37
|
+
lg: 'mx-4',
|
|
38
|
+
}
|
|
39
|
+
const SELECT_ROUNDED_CONTENT: Record<'sm' | 'md' | 'lg', string> = {
|
|
40
|
+
sm: 'rounded-sm',
|
|
41
|
+
md: 'rounded-md',
|
|
42
|
+
lg: 'rounded-lg',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SelectProps {
|
|
46
|
+
options: SelectOption[]
|
|
47
|
+
/** Размер: `sm | md | lg`. По умолчанию md. Каждый размер сам fluid-клампится. */
|
|
48
|
+
size?: 'sm' | 'md' | 'lg'
|
|
49
|
+
value?: string
|
|
50
|
+
defaultValue?: string
|
|
51
|
+
onValueChange?: (value: string) => void
|
|
52
|
+
placeholder?: string
|
|
53
|
+
disabled?: boolean
|
|
54
|
+
/** Фиксированный контент в начале триггера (помимо иконки выбранного пункта): лейбл, иконка и т.п. */
|
|
55
|
+
startContent?: React.ReactNode
|
|
56
|
+
/** Класс на триггер. */
|
|
57
|
+
className?: string
|
|
58
|
+
/** Класс на выпадающую панель. */
|
|
59
|
+
contentClassName?: string
|
|
60
|
+
/**
|
|
61
|
+
* В триггере показывать только `startContent` выбранного пункта (напр. флаг),
|
|
62
|
+
* без текста; в списке пункты остаются с текстом. Для компактных переключателей
|
|
63
|
+
* (язык). По умолчанию false.
|
|
64
|
+
*/
|
|
65
|
+
compactValue?: boolean
|
|
66
|
+
/**
|
|
67
|
+
* Доступное имя контрола. Заведено явно: у компонента нет rest-спреда, а TypeScript
|
|
68
|
+
* не проверяет лишние JSX-атрибуты с дефисом — `aria-label` на <Select> молча
|
|
69
|
+
* компилировался и никуда не попадал, оставляя контрол без доступного имени.
|
|
70
|
+
*/
|
|
71
|
+
'aria-label'?: string
|
|
72
|
+
'aria-labelledby'?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Флаг/иконка + подпись. Кладём ВНУТРЬ ItemText, чтобы триггер (Select.Value)
|
|
76
|
+
// отрисовал ровно то же — иконку слева и текст, как в макете.
|
|
77
|
+
function OptionContent({ option }: { option: SelectOption }) {
|
|
78
|
+
return (
|
|
79
|
+
// leading-normal: токены text-button-* имеют line-height:1, из-за чего нижние
|
|
80
|
+
// выносные элементы букв (g/y/р) обрезаются строчным боксом. Локально возвращаем запас.
|
|
81
|
+
<span className='flex items-center gap-2.5 leading-normal'>
|
|
82
|
+
{option.startContent ? (
|
|
83
|
+
<span className='inline-flex shrink-0 items-center'>
|
|
84
|
+
{option.startContent}
|
|
85
|
+
</span>
|
|
86
|
+
) : null}
|
|
87
|
+
{option.label}
|
|
88
|
+
</span>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function Select({
|
|
93
|
+
options,
|
|
94
|
+
value,
|
|
95
|
+
defaultValue,
|
|
96
|
+
onValueChange,
|
|
97
|
+
placeholder,
|
|
98
|
+
disabled,
|
|
99
|
+
startContent,
|
|
100
|
+
size,
|
|
101
|
+
className,
|
|
102
|
+
contentClassName,
|
|
103
|
+
compactValue = false,
|
|
104
|
+
'aria-label': ariaLabel,
|
|
105
|
+
'aria-labelledby': ariaLabelledBy,
|
|
106
|
+
}: SelectProps) {
|
|
107
|
+
// Только для контролируемого значения: в неконтролируемом режиме выбранный пункт
|
|
108
|
+
// знает лишь Radix, и подменять его нечем.
|
|
109
|
+
const selectedOption = value != null ? options.find((o) => o.value === value) : undefined
|
|
110
|
+
|
|
111
|
+
const triggerSize = SELECT_TRIGGER_SIZE[size ?? 'md']
|
|
112
|
+
const itemSize = SELECT_ITEM_SIZE[size ?? 'md']
|
|
113
|
+
const dividerInset = SELECT_DIVIDER_INSET[size ?? 'md']
|
|
114
|
+
const roundedContent = SELECT_ROUNDED_CONTENT[size ?? 'md']
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<SelectPrimitive.Root
|
|
118
|
+
value={value}
|
|
119
|
+
defaultValue={defaultValue}
|
|
120
|
+
onValueChange={onValueChange}
|
|
121
|
+
disabled={disabled}
|
|
122
|
+
>
|
|
123
|
+
<SelectPrimitive.Trigger
|
|
124
|
+
aria-label={ariaLabel}
|
|
125
|
+
aria-labelledby={ariaLabelledBy}
|
|
126
|
+
className={cn(SELECT_TRIGGER_BASE, triggerSize, className)}
|
|
127
|
+
>
|
|
128
|
+
{startContent ? (
|
|
129
|
+
<span className='flex shrink-0 items-center text-muted-foreground'>
|
|
130
|
+
{startContent}
|
|
131
|
+
</span>
|
|
132
|
+
) : null}
|
|
133
|
+
{/* Значение рендерим САМИ, а не отдаём на откуп SelectPrimitive.Value.
|
|
134
|
+
Radix берёт текст из смонтированных Item'ов, а они живут в портале и
|
|
135
|
+
на сервере не рендерятся — из-за чего триггер приезжает ПУСТЫМ и
|
|
136
|
+
остаётся таким до гидрации. На медленной сети это сотни миллисекунд
|
|
137
|
+
пустого поля там, где уже должно стоять выбранное значение.
|
|
138
|
+
Когда значение контролируемое, лейбл известен сразу — печатаем его. */}
|
|
139
|
+
<SelectPrimitive.Value placeholder={placeholder}>
|
|
140
|
+
{selectedOption ? (
|
|
141
|
+
compactValue ? (
|
|
142
|
+
selectedOption.startContent
|
|
143
|
+
) : (
|
|
144
|
+
<OptionContent option={selectedOption} />
|
|
145
|
+
)
|
|
146
|
+
) : undefined}
|
|
147
|
+
</SelectPrimitive.Value>
|
|
148
|
+
<SelectPrimitive.Icon asChild>
|
|
149
|
+
<span className='ml-auto flex shrink-0 items-center text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180'>
|
|
150
|
+
<Icon type='chevron_down' isCurrentColor size='0.72em' />
|
|
151
|
+
</span>
|
|
152
|
+
</SelectPrimitive.Icon>
|
|
153
|
+
</SelectPrimitive.Trigger>
|
|
154
|
+
|
|
155
|
+
<SelectPrimitive.Portal>
|
|
156
|
+
<SelectPrimitive.Content
|
|
157
|
+
position='popper'
|
|
158
|
+
sideOffset={8}
|
|
159
|
+
className={cn(
|
|
160
|
+
'z-50 max-h-[var(--radix-select-content-available-height)] min-w-[var(--radix-select-trigger-width)] overflow-hidden border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:zoom-out-95',
|
|
161
|
+
contentClassName,
|
|
162
|
+
roundedContent,
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
<SelectPrimitive.Viewport className='flex flex-col'>
|
|
166
|
+
{options.map((option, index) => (
|
|
167
|
+
<React.Fragment key={option.value}>
|
|
168
|
+
<SelectPrimitive.Item
|
|
169
|
+
value={option.value}
|
|
170
|
+
disabled={option.disabled}
|
|
171
|
+
textValue={option.label}
|
|
172
|
+
className={cn(
|
|
173
|
+
'flex w-full cursor-pointer items-center gap-2.5 text-foreground outline-none select-none data-highlighted:bg-secondary data-disabled:pointer-events-none data-disabled:opacity-50',
|
|
174
|
+
itemSize,
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
<SelectPrimitive.ItemText>
|
|
178
|
+
{compactValue && option.startContent ? (
|
|
179
|
+
<span className='inline-flex shrink-0 items-center'>
|
|
180
|
+
{option.startContent}
|
|
181
|
+
</span>
|
|
182
|
+
) : (
|
|
183
|
+
<OptionContent option={option} />
|
|
184
|
+
)}
|
|
185
|
+
</SelectPrimitive.ItemText>
|
|
186
|
+
{/* compactValue: название живёт ВНЕ ItemText → видно в списке, но НЕ в триггере.
|
|
187
|
+
leading-normal — иначе truncate (overflow-hidden) при line-height:1 режет
|
|
188
|
+
нижние выносы букв («g» в English). */}
|
|
189
|
+
{compactValue ? (
|
|
190
|
+
<span className='truncate leading-normal'>
|
|
191
|
+
{option.label}
|
|
192
|
+
</span>
|
|
193
|
+
) : null}
|
|
194
|
+
{/* галочка прижата вправо; рендерится только у выбранного */}
|
|
195
|
+
<SelectPrimitive.ItemIndicator className='ml-auto flex items-center text-primary'>
|
|
196
|
+
<Icon type='check' isCurrentColor size='0.85em' />
|
|
197
|
+
</SelectPrimitive.ItemIndicator>
|
|
198
|
+
</SelectPrimitive.Item>
|
|
199
|
+
{/* разделитель между пунктами — тонкая линия */}
|
|
200
|
+
{index < options.length - 1 ? (
|
|
201
|
+
<SelectPrimitive.Separator
|
|
202
|
+
className={cn(
|
|
203
|
+
'my-1 h-px bg-border rounded-full',
|
|
204
|
+
dividerInset,
|
|
205
|
+
)}
|
|
206
|
+
/>
|
|
207
|
+
) : null}
|
|
208
|
+
</React.Fragment>
|
|
209
|
+
))}
|
|
210
|
+
</SelectPrimitive.Viewport>
|
|
211
|
+
</SelectPrimitive.Content>
|
|
212
|
+
</SelectPrimitive.Portal>
|
|
213
|
+
</SelectPrimitive.Root>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './separator'
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../../lib/utils'
|
|
8
|
+
|
|
9
|
+
// Separator — тонкая линия-разделитель на Radix (role=separator + aria-orientation;
|
|
10
|
+
// при decorative — чисто визуальный, скринридеру не объявляется). Стиль — как у
|
|
11
|
+
// разделителя пунктов в Select: 1px, цвет sky, скруглённые концы.
|
|
12
|
+
// horizontal → тянется по ширине (h-px w-full), vertical → по высоте (w-px h-full;
|
|
13
|
+
// родитель должен задавать высоту, напр. flex-строка с h-*).
|
|
14
|
+
// С children (только horizontal) → разделитель с подписью по центру: «──── ИЛИ ────».
|
|
15
|
+
export function Separator({
|
|
16
|
+
className,
|
|
17
|
+
orientation = 'horizontal',
|
|
18
|
+
decorative = true,
|
|
19
|
+
children,
|
|
20
|
+
...props
|
|
21
|
+
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
|
22
|
+
// Подпись по центру: две линии по бокам + текст между ними. Radix Root тут не
|
|
23
|
+
// подходит (это одна линия), поэтому собираем role=separator вручную; сами линии
|
|
24
|
+
// декоративны (aria-hidden), а текст-метка остаётся доступной скринридеру.
|
|
25
|
+
if (children != null && orientation === 'horizontal') {
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
role='separator'
|
|
29
|
+
aria-orientation='horizontal'
|
|
30
|
+
className={cn('flex w-full items-center gap-fl-12/16', className)}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
33
|
+
<span aria-hidden className='h-px flex-1 rounded-full bg-border' />
|
|
34
|
+
<span className='text-body-s shrink-0 text-muted-foreground'>
|
|
35
|
+
{children}
|
|
36
|
+
</span>
|
|
37
|
+
<span aria-hidden className='h-px flex-1 rounded-full bg-border' />
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<SeparatorPrimitive.Root
|
|
44
|
+
orientation={orientation}
|
|
45
|
+
decorative={decorative}
|
|
46
|
+
className={cn(
|
|
47
|
+
'shrink-0 rounded-full bg-border',
|
|
48
|
+
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
|
49
|
+
className,
|
|
50
|
+
)}
|
|
51
|
+
{...props}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './skeleton'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/utils'
|
|
4
|
+
|
|
5
|
+
export interface SkeletonProps extends React.ComponentProps<'div'> {
|
|
6
|
+
/**
|
|
7
|
+
* Идёт ли загрузка. По умолчанию `true`. Если `false` и переданы `children` —
|
|
8
|
+
* рендерим реальный контент без заглушки (wrapper-режим).
|
|
9
|
+
*/
|
|
10
|
+
loading?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Skeleton — заглушка-плейсхолдер на время загрузки. Два режима:
|
|
14
|
+
// - без children: пустой блок, форму задаёшь className (w-*/h-*/size-*/rounded-*);
|
|
15
|
+
// - с children (wrapper): children держат размер (invisible), а заглушка ложится
|
|
16
|
+
// поверх — удобно, когда контент уже есть, но «не готов».
|
|
17
|
+
// Анимация — shimmer-блик пробегает слева-направо (keyframes shimmer в globals.css).
|
|
18
|
+
export function Skeleton({
|
|
19
|
+
className,
|
|
20
|
+
loading = true,
|
|
21
|
+
children,
|
|
22
|
+
...props
|
|
23
|
+
}: SkeletonProps) {
|
|
24
|
+
// Загрузка завершена и есть что показать — отдаём реальный контент как есть.
|
|
25
|
+
if (!loading && children !== undefined) return <>{children}</>
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
aria-hidden
|
|
30
|
+
className={cn(
|
|
31
|
+
'relative overflow-hidden rounded-md bg-accent',
|
|
32
|
+
className,
|
|
33
|
+
)}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
{/* блик-полоса: едет translateX(-100% → 100%), overflow-hidden её обрезает */}
|
|
37
|
+
<span className='pointer-events-none absolute inset-0 animate-shimmer bg-linear-to-r from-transparent via-skeleton-sheen to-transparent' />
|
|
38
|
+
{/* children задают размер обёртки, но скрыты на время загрузки */}
|
|
39
|
+
{children !== undefined ? (
|
|
40
|
+
<div className='invisible'>{children}</div>
|
|
41
|
+
) : null}
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './spinner'
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/utils'
|
|
4
|
+
|
|
5
|
+
// Крутилка-индикатор загрузки. Цвет — currentColor (наследует цвет текста),
|
|
6
|
+
// размер — через className (по умолчанию size-5). Трек полупрозрачный, дуга крутится.
|
|
7
|
+
export function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
|
|
8
|
+
return (
|
|
9
|
+
<svg
|
|
10
|
+
role='status'
|
|
11
|
+
aria-label='Загрузка'
|
|
12
|
+
viewBox='0 0 24 24'
|
|
13
|
+
fill='none'
|
|
14
|
+
className={cn('size-5 shrink-0 animate-spin text-current', className)}
|
|
15
|
+
{...props}
|
|
16
|
+
>
|
|
17
|
+
<circle
|
|
18
|
+
cx='12'
|
|
19
|
+
cy='12'
|
|
20
|
+
r='9.5'
|
|
21
|
+
stroke='currentColor'
|
|
22
|
+
strokeWidth='2.5'
|
|
23
|
+
className='opacity-20'
|
|
24
|
+
/>
|
|
25
|
+
<path
|
|
26
|
+
d='M21.5 12A9.5 9.5 0 0 0 12 2.5'
|
|
27
|
+
stroke='currentColor'
|
|
28
|
+
strokeWidth='2.5'
|
|
29
|
+
strokeLinecap='round'
|
|
30
|
+
/>
|
|
31
|
+
</svg>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './switch'
|