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,391 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
import { type VariantProps, cva } from 'class-variance-authority'
|
|
6
|
+
|
|
7
|
+
import { Icon } from '../Icon'
|
|
8
|
+
import { cn } from '../../lib/utils'
|
|
9
|
+
import { useKitT } from '../../i18n'
|
|
10
|
+
import { notify } from '../../utils/notify'
|
|
11
|
+
|
|
12
|
+
// Стили несёт ОБЁРТКА (заливка/рамка, скругление, кольцо фокуса через focus-within), а
|
|
13
|
+
// сам <input> — прозрачный и без рамок. Так слоты слева/справа и служебные кнопки
|
|
14
|
+
// (очистка/копирование) живут внутри одной «коробки» и попадают в общую подсветку
|
|
15
|
+
// фокуса.
|
|
16
|
+
// variant — внешний вид «коробки»:
|
|
17
|
+
// - filled (деф.) — заливка bg-secondary, без рамки;
|
|
18
|
+
// - bordered — рамка border-input на поверхности bg-card (как outline-поле).
|
|
19
|
+
// На обоих фокус — кольцо ring-ring через focus-within. Статусы (error/warning/success)
|
|
20
|
+
// перекрывают bg/рамку отдельно (statusRing) поверх варианта.
|
|
21
|
+
const inputVariants = cva(
|
|
22
|
+
'flex w-full items-center gap-2.5 text-foreground transition-[color,box-shadow] outline-none focus-within:ring-2 focus-within:ring-ring has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50',
|
|
23
|
+
{
|
|
24
|
+
variants: {
|
|
25
|
+
variant: {
|
|
26
|
+
filled: 'bg-secondary',
|
|
27
|
+
bordered: 'border border-disabled',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: {
|
|
31
|
+
variant: 'filled',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// Размер — FLUID (высота/padding/шрифт клампятся 320→1280px), один size = «тир».
|
|
37
|
+
const INPUT_SIZE: Record<'sm' | 'md' | 'lg', string> = {
|
|
38
|
+
sm: 'min-h-fl-32/36 p-fl-14/16 text-button-s rounded-sm',
|
|
39
|
+
md: 'min-h-fl-40/44 p-fl-18/20 text-button-m rounded-md',
|
|
40
|
+
lg: 'min-h-fl-48/52 p-fl-22/24 text-button-l rounded-lg',
|
|
41
|
+
}
|
|
42
|
+
// Размер текста сообщения под полем — в тон размеру поля.
|
|
43
|
+
const INPUT_MSG_SIZE: Record<'sm' | 'md' | 'lg', string> = {
|
|
44
|
+
sm: 'text-button-s',
|
|
45
|
+
md: 'text-button-m',
|
|
46
|
+
lg: 'text-button-l',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Размер служебных иконок (× / copy / check) — в тон размеру поля.
|
|
50
|
+
const INPUT_ICON_SIZE: Record<'sm' | 'md' | 'lg', number> = {
|
|
51
|
+
sm: 18,
|
|
52
|
+
md: 20,
|
|
53
|
+
lg: 22,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface InputProps
|
|
57
|
+
// Omit 'size': у нативного <input> size — это number (видимая ширина в
|
|
58
|
+
// символах). Мы переопределяем его на вариант 'sm' | 'md' | 'lg' из cva.
|
|
59
|
+
extends
|
|
60
|
+
Omit<React.ComponentProps<'input'>, 'size'>,
|
|
61
|
+
Omit<VariantProps<typeof inputVariants>, 'size'> {
|
|
62
|
+
/** Размер: `sm | md | lg`. По умолчанию md. Каждый размер сам fluid-клампится. */
|
|
63
|
+
size?: 'sm' | 'md' | 'lg'
|
|
64
|
+
/** Внешний вид: `filled` (деф., заливка bg-secondary) | `bordered` (рамка на bg-card). */
|
|
65
|
+
variant?: 'filled' | 'bordered'
|
|
66
|
+
/** Класс на внешнюю обёртку (заливка/скругление/высота). `className` идёт на сам <input>. */
|
|
67
|
+
containerClassName?: string
|
|
68
|
+
/** Контент перед инпутом — иконка или картинка (напр. флаг). */
|
|
69
|
+
startContent?: React.ReactNode
|
|
70
|
+
/** Контент после инпута — иконка, шеврон и т.п. */
|
|
71
|
+
endContent?: React.ReactNode
|
|
72
|
+
/** Показать кнопку «очистить» (×) справа, когда в поле есть значение. */
|
|
73
|
+
clearable?: boolean
|
|
74
|
+
/** Показать кнопку «копировать» справа — копирует текущее значение поля. */
|
|
75
|
+
copiable?: boolean
|
|
76
|
+
/**
|
|
77
|
+
* Тост при успешном копировании (кнопка `copiable`). `true`/не задан — типовой
|
|
78
|
+
* «Скопировано в буфер обмена»; строка — свой заголовок (уже локализованный, напр.
|
|
79
|
+
* «Ссылка скопирована»); `false` — не показывать тост.
|
|
80
|
+
*/
|
|
81
|
+
copyToast?: string | boolean
|
|
82
|
+
/**
|
|
83
|
+
* Показать кнопку «вставить» справа, когда поле ПУСТОЕ — вставляет текст из
|
|
84
|
+
* буфера обмена. Взаимоисключима с `clearable` (× появляется при наличии значения).
|
|
85
|
+
*/
|
|
86
|
+
pasteable?: boolean
|
|
87
|
+
/**
|
|
88
|
+
* Состояние поля. У каждого вид `boolean | string`: `true` — только цветная
|
|
89
|
+
* подсветка, строка — подсветка плюс текст под полем. Приоритет, если задано
|
|
90
|
+
* несколько: error > warning > success.
|
|
91
|
+
*
|
|
92
|
+
* `error` означает невалидность: проставляет aria-invalid, сообщение —
|
|
93
|
+
* role="alert". `warning`/`success` — подсказки: role="status", без aria-invalid.
|
|
94
|
+
*/
|
|
95
|
+
error?: boolean | string
|
|
96
|
+
/** Предупреждение (формат и приоритет — см. `error`). */
|
|
97
|
+
warning?: boolean | string
|
|
98
|
+
/** Успех/подтверждение (формат и приоритет — см. `error`). */
|
|
99
|
+
success?: boolean | string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function Input({
|
|
103
|
+
ref,
|
|
104
|
+
className,
|
|
105
|
+
containerClassName,
|
|
106
|
+
size,
|
|
107
|
+
variant,
|
|
108
|
+
type = 'text',
|
|
109
|
+
startContent,
|
|
110
|
+
endContent,
|
|
111
|
+
clearable = false,
|
|
112
|
+
copiable = false,
|
|
113
|
+
copyToast = true,
|
|
114
|
+
pasteable = false,
|
|
115
|
+
error,
|
|
116
|
+
warning,
|
|
117
|
+
success,
|
|
118
|
+
disabled,
|
|
119
|
+
readOnly,
|
|
120
|
+
onChange,
|
|
121
|
+
'aria-describedby': ariaDescribedBy,
|
|
122
|
+
...props
|
|
123
|
+
}: InputProps) {
|
|
124
|
+
const t = useKitT('ui.actions')
|
|
125
|
+
const tToast = useKitT('ui.toast')
|
|
126
|
+
const sizeClasses = INPUT_SIZE[size ?? 'md']
|
|
127
|
+
// Внутренний ref нужен служебным кнопкам, чтобы читать/менять значение прямо в
|
|
128
|
+
// DOM. Это работает и с неконтролируемыми инпутами react-hook-form
|
|
129
|
+
// (`{...register('x')}`), где value в проп не приходит.
|
|
130
|
+
const innerRef = React.useRef<HTMLInputElement>(null)
|
|
131
|
+
|
|
132
|
+
// Сливаем внешний ref (напр. от register) с внутренним: оба должны указывать
|
|
133
|
+
// на один и тот же <input>.
|
|
134
|
+
const setRefs = React.useCallback(
|
|
135
|
+
(node: HTMLInputElement | null) => {
|
|
136
|
+
innerRef.current = node
|
|
137
|
+
if (typeof ref === 'function') ref(node)
|
|
138
|
+
else if (ref) ref.current = node
|
|
139
|
+
},
|
|
140
|
+
[ref],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
// Есть ли в поле значение — нужно, чтобы показывать «×». В контролируемом
|
|
144
|
+
// режиме берём из value-пропа, иначе ведём локально (обновляем в onChange).
|
|
145
|
+
const isControlled = props.value !== undefined
|
|
146
|
+
const [uncontrolledHasValue, setUncontrolledHasValue] = React.useState(
|
|
147
|
+
() => props.defaultValue != null && String(props.defaultValue).length > 0,
|
|
148
|
+
)
|
|
149
|
+
const hasValue = isControlled
|
|
150
|
+
? String(props.value ?? '').length > 0
|
|
151
|
+
: uncontrolledHasValue
|
|
152
|
+
|
|
153
|
+
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
154
|
+
if (!isControlled) setUncontrolledHasValue(event.target.value.length > 0)
|
|
155
|
+
onChange?.(event)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const handleClear = React.useCallback(() => {
|
|
159
|
+
const input = innerRef.current
|
|
160
|
+
if (!input) return
|
|
161
|
+
// Меняем значение «как пользователь»: нативный сеттер + событие input.
|
|
162
|
+
// Простое `input.value = ''` НЕ триггерит React-onChange и не доходит до
|
|
163
|
+
// react-hook-form — этот трюк заставляет React увидеть изменение.
|
|
164
|
+
const setValue = Object.getOwnPropertyDescriptor(
|
|
165
|
+
HTMLInputElement.prototype,
|
|
166
|
+
'value',
|
|
167
|
+
)?.set
|
|
168
|
+
setValue?.call(input, '')
|
|
169
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
170
|
+
input.focus()
|
|
171
|
+
}, [])
|
|
172
|
+
|
|
173
|
+
const [copied, setCopied] = React.useState(false)
|
|
174
|
+
const resetTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
175
|
+
|
|
176
|
+
// Чистим таймер сброса «галочки» при размонтировании.
|
|
177
|
+
React.useEffect(() => () => clearTimeout(resetTimer.current), [])
|
|
178
|
+
|
|
179
|
+
const handleCopy = React.useCallback(async () => {
|
|
180
|
+
const value = innerRef.current?.value
|
|
181
|
+
if (!value) return
|
|
182
|
+
try {
|
|
183
|
+
await navigator.clipboard.writeText(value)
|
|
184
|
+
setCopied(true)
|
|
185
|
+
clearTimeout(resetTimer.current)
|
|
186
|
+
resetTimer.current = setTimeout(() => setCopied(false), 1500)
|
|
187
|
+
// Тост-подтверждение: типовой заголовок, своя строка или выкл (copyToast=false).
|
|
188
|
+
if (copyToast !== false) {
|
|
189
|
+
notify({
|
|
190
|
+
title: typeof copyToast === 'string' ? copyToast : tToast('copied'),
|
|
191
|
+
variant: 'success',
|
|
192
|
+
timeout: 2000,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// Clipboard недоступен (insecure context / отказ доступа) — тихо игнорим.
|
|
197
|
+
}
|
|
198
|
+
}, [copyToast, tToast])
|
|
199
|
+
|
|
200
|
+
// Вставка из буфера: тот же native-setter трюк, что и в handleClear, чтобы
|
|
201
|
+
// React/react-hook-form увидели изменение значения.
|
|
202
|
+
const handlePaste = React.useCallback(async () => {
|
|
203
|
+
const input = innerRef.current
|
|
204
|
+
if (!input) return
|
|
205
|
+
try {
|
|
206
|
+
const text = await navigator.clipboard.readText()
|
|
207
|
+
if (!text) return
|
|
208
|
+
const setValue = Object.getOwnPropertyDescriptor(
|
|
209
|
+
HTMLInputElement.prototype,
|
|
210
|
+
'value',
|
|
211
|
+
)?.set
|
|
212
|
+
setValue?.call(input, text)
|
|
213
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
214
|
+
input.focus()
|
|
215
|
+
} catch {
|
|
216
|
+
// Clipboard недоступен (insecure context / отказ доступа) — тихо игнорим.
|
|
217
|
+
}
|
|
218
|
+
}, [])
|
|
219
|
+
|
|
220
|
+
const showClear = clearable && hasValue && !disabled && !readOnly
|
|
221
|
+
// «Вставить» — только на пустом поле; при наличии значения его место занимает «×».
|
|
222
|
+
const showPaste = pasteable && !hasValue && !disabled && !readOnly
|
|
223
|
+
|
|
224
|
+
// Состояние поля. Приоритет: error > warning > success — активно одно.
|
|
225
|
+
// `error` — невалидность (aria-invalid + role="alert"); warning/success —
|
|
226
|
+
// подсказки (role="status", без aria-invalid).
|
|
227
|
+
const status: 'error' | 'warning' | 'success' | null = error
|
|
228
|
+
? 'error'
|
|
229
|
+
: warning
|
|
230
|
+
? 'warning'
|
|
231
|
+
: success
|
|
232
|
+
? 'success'
|
|
233
|
+
: null
|
|
234
|
+
|
|
235
|
+
const statusValue = error || warning || success
|
|
236
|
+
const statusMessage =
|
|
237
|
+
typeof statusValue === 'string' ? statusValue : undefined
|
|
238
|
+
|
|
239
|
+
const statusRing = status
|
|
240
|
+
? {
|
|
241
|
+
error: 'ring-1 ring-error bg-error-soft focus-within:ring-error',
|
|
242
|
+
warning:
|
|
243
|
+
'ring-1 ring-warning bg-warning-soft focus-within:ring-warning',
|
|
244
|
+
success:
|
|
245
|
+
'ring-1 ring-success bg-success-soft focus-within:ring-success',
|
|
246
|
+
}[status]
|
|
247
|
+
: undefined
|
|
248
|
+
// const statusPlaceHolder = status
|
|
249
|
+
// ? {
|
|
250
|
+
// error: 'placeholder:text-error',
|
|
251
|
+
// warning: 'placeholder:text-warning',
|
|
252
|
+
// success: 'placeholder:text-success',
|
|
253
|
+
// }[status]
|
|
254
|
+
// : undefined
|
|
255
|
+
const statusTextColor = status
|
|
256
|
+
? { error: 'text-error', warning: 'text-warning', success: 'text-success' }[
|
|
257
|
+
status
|
|
258
|
+
]
|
|
259
|
+
: undefined
|
|
260
|
+
|
|
261
|
+
// Размер текста сообщения — в тон размеру поля (text-button-* по size), как и
|
|
262
|
+
// текст внутри инпута. У button-токенов line-height = 1 → сообщение на одну строку.
|
|
263
|
+
const messageTextSize = INPUT_MSG_SIZE[size ?? 'md']
|
|
264
|
+
|
|
265
|
+
// Служебные иконки (×/copy/check) — фикс размер (инлайн-px нельзя адаптировать
|
|
266
|
+
// CSS-брейкпоинтами; разница sm↔lg мелкая, они и показываются редко).
|
|
267
|
+
const iconSize = INPUT_ICON_SIZE.md
|
|
268
|
+
|
|
269
|
+
const reactId = React.useId()
|
|
270
|
+
const messageId = `${reactId}-msg`
|
|
271
|
+
// Связываем сообщение с инпутом, не теряя чужой aria-describedby, если был.
|
|
272
|
+
const describedBy =
|
|
273
|
+
[statusMessage ? messageId : null, ariaDescribedBy]
|
|
274
|
+
.filter(Boolean)
|
|
275
|
+
.join(' ') || undefined
|
|
276
|
+
|
|
277
|
+
const box = (
|
|
278
|
+
<div
|
|
279
|
+
className={cn(
|
|
280
|
+
inputVariants({ variant }),
|
|
281
|
+
sizeClasses,
|
|
282
|
+
statusRing,
|
|
283
|
+
containerClassName,
|
|
284
|
+
)}
|
|
285
|
+
>
|
|
286
|
+
{startContent ? (
|
|
287
|
+
<span
|
|
288
|
+
className={cn(
|
|
289
|
+
'inline-flex shrink-0 items-center text-muted-foreground',
|
|
290
|
+
// statusTextColor,
|
|
291
|
+
)}
|
|
292
|
+
>
|
|
293
|
+
{startContent}
|
|
294
|
+
</span>
|
|
295
|
+
) : null}
|
|
296
|
+
|
|
297
|
+
<input
|
|
298
|
+
ref={setRefs}
|
|
299
|
+
type={type}
|
|
300
|
+
disabled={disabled}
|
|
301
|
+
readOnly={readOnly}
|
|
302
|
+
onChange={handleChange}
|
|
303
|
+
// aria-invalid — только при error (невалидность). Нужно скринридерам
|
|
304
|
+
// и подхватывается react-hook-form. Потребитель может переопределить.
|
|
305
|
+
aria-invalid={status === 'error' || undefined}
|
|
306
|
+
aria-describedby={describedBy}
|
|
307
|
+
// min-w-0 — чтобы инпут ужимался в flex и не выталкивал слоты.
|
|
308
|
+
className={cn(
|
|
309
|
+
'min-w-0 flex-1 border-0 bg-transparent p-0 text-current outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',
|
|
310
|
+
// Автозаполнение WebKit: гасим его жёлто-белый ПРЯМОУГОЛЬНЫЙ фон (у input
|
|
311
|
+
// нет скругления — оно на обёртке) вечной transition на background-color —
|
|
312
|
+
// сквозь него просвечивает bg обёртки (secondary/card) со скруглением; и
|
|
313
|
+
// перекрашиваем принудительный чёрный текст автофилла в --foreground.
|
|
314
|
+
'autofill:[-webkit-text-fill-color:var(--color-foreground)] autofill:[transition:background-color_9999s_ease-in-out_0s]',
|
|
315
|
+
className,
|
|
316
|
+
// statusPlaceHolder,
|
|
317
|
+
)}
|
|
318
|
+
{...props}
|
|
319
|
+
/>
|
|
320
|
+
|
|
321
|
+
{endContent ? (
|
|
322
|
+
<span className='inline-flex shrink-0 items-center text-muted-foreground'>
|
|
323
|
+
{endContent}
|
|
324
|
+
</span>
|
|
325
|
+
) : null}
|
|
326
|
+
|
|
327
|
+
{showClear ? (
|
|
328
|
+
<button
|
|
329
|
+
// type='button' обязателен: иначе внутри <form> клик сабмитит форму.
|
|
330
|
+
type='button'
|
|
331
|
+
onClick={handleClear}
|
|
332
|
+
aria-label={t('clear')}
|
|
333
|
+
className='inline-flex shrink-0 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground'
|
|
334
|
+
>
|
|
335
|
+
<Icon type='close' isCurrentColor size={iconSize} />
|
|
336
|
+
</button>
|
|
337
|
+
) : null}
|
|
338
|
+
|
|
339
|
+
{showPaste ? (
|
|
340
|
+
// Текстовая подпись «Вставить» вместо иконки — понятнее как affordance.
|
|
341
|
+
// Видимый текст сам служит accessible name → aria-label не нужен.
|
|
342
|
+
<button
|
|
343
|
+
type='button'
|
|
344
|
+
onClick={handlePaste}
|
|
345
|
+
className='inline-flex shrink-0 cursor-pointer items-center justify-center whitespace-nowrap text-button-s font-medium text-muted-foreground transition-colors hover:text-primary'
|
|
346
|
+
>
|
|
347
|
+
{t('paste')}
|
|
348
|
+
</button>
|
|
349
|
+
) : null}
|
|
350
|
+
|
|
351
|
+
{copiable ? (
|
|
352
|
+
<button
|
|
353
|
+
type='button'
|
|
354
|
+
onClick={handleCopy}
|
|
355
|
+
disabled={disabled}
|
|
356
|
+
aria-label={copied ? t('copied') : t('copy')}
|
|
357
|
+
className={cn(
|
|
358
|
+
'inline-flex shrink-0 cursor-pointer items-center justify-center transition-colors disabled:pointer-events-none',
|
|
359
|
+
'text-muted-foreground hover:text-foreground',
|
|
360
|
+
)}
|
|
361
|
+
>
|
|
362
|
+
{!copied ? (
|
|
363
|
+
<Icon type='copy' isCurrentColor size={iconSize} />
|
|
364
|
+
) : (
|
|
365
|
+
<Icon type='check' isCurrentColor size={iconSize} />
|
|
366
|
+
)}
|
|
367
|
+
</button>
|
|
368
|
+
) : null}
|
|
369
|
+
</div>
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
// Без текста сообщения возвращаем только «коробку» — лишнюю обёртку не плодим.
|
|
373
|
+
if (!statusMessage) return box
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
// w-full обязателен: без него в родителях с items-center обёртка сообщения
|
|
377
|
+
// сжимается по контенту, и поле «худеет» при появлении ошибки.
|
|
378
|
+
<div className='flex w-full flex-col gap-1.5'>
|
|
379
|
+
{box}
|
|
380
|
+
<p
|
|
381
|
+
id={messageId}
|
|
382
|
+
role={status === 'error' ? 'alert' : 'status'}
|
|
383
|
+
className={cn('px-1', messageTextSize, statusTextColor)}
|
|
384
|
+
>
|
|
385
|
+
{statusMessage}
|
|
386
|
+
</p>
|
|
387
|
+
</div>
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export { inputVariants }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './label'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
import * as LabelPrimitive from '@radix-ui/react-label'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../../lib/utils'
|
|
8
|
+
|
|
9
|
+
// Label — подпись к контролу на нативном <label> (через Radix). Связь с полем — через
|
|
10
|
+
// htmlFor/id: клик по подписи фокусит/переключает контрол, скринридер зачитывает имя
|
|
11
|
+
// поля. peer-disabled:* — если контрол с классом `peer` рядом и disabled, подпись гаснет.
|
|
12
|
+
// Размер = только шрифт (text-button-*), а он уже fluid-клампится.
|
|
13
|
+
const LABEL_BASE =
|
|
14
|
+
'inline-flex items-center gap-2 text-foreground select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50'
|
|
15
|
+
const LABEL_SIZE: Record<'sm' | 'md' | 'lg', string> = {
|
|
16
|
+
sm: 'text-button-s',
|
|
17
|
+
md: 'text-button-m',
|
|
18
|
+
lg: 'text-button-l',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LabelProps
|
|
22
|
+
extends React.ComponentProps<typeof LabelPrimitive.Root> {
|
|
23
|
+
/** Размер: `sm | md | lg`. По умолчанию md. */
|
|
24
|
+
size?: 'sm' | 'md' | 'lg'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Label({ className, size, ...props }: LabelProps) {
|
|
28
|
+
return (
|
|
29
|
+
<LabelPrimitive.Root
|
|
30
|
+
className={cn(LABEL_BASE, LABEL_SIZE[size ?? 'md'], className)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MethodCard } from './method-card'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { Card } from '../card'
|
|
4
|
+
import { Skeleton } from '../skeleton'
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
|
|
7
|
+
// Карточка-строка «иконка + название + описание» с опциональным трейлинг-слотом справа.
|
|
8
|
+
// НЕЙТРАЛЬНАЯ и презентационная: не знает про оплату/выплату — что положить в trailing и
|
|
9
|
+
// что делать по клику, решает потребитель (оплата — переход + шеврон; выплата — выбор,
|
|
10
|
+
// рамка, без трейлинга). Выбираемая: у выбранной синяя рамка. Рамка есть всегда (по
|
|
11
|
+
// умолчанию прозрачная) → выбор не сдвигает раскладку. Focus-ring на кнопке.
|
|
12
|
+
// loading — скелетоним ТОЛЬКО данные (иконка/название/описание). Каркас Card (паддинги,
|
|
13
|
+
// рамка) и трейлинг неизменны; плейсхолдер иконки повторяет её отступы (p-fl-6/8) → текст
|
|
14
|
+
// не сдвигается; высота строк держится текстовыми токенами (плейсхолдер 0.9em <
|
|
15
|
+
// line-height). При подгрузке ничего не прыгает.
|
|
16
|
+
export function MethodCard({
|
|
17
|
+
icon,
|
|
18
|
+
name,
|
|
19
|
+
desc,
|
|
20
|
+
selected = false,
|
|
21
|
+
onClick,
|
|
22
|
+
loading = false,
|
|
23
|
+
trailing,
|
|
24
|
+
className,
|
|
25
|
+
}: {
|
|
26
|
+
icon?: ReactNode
|
|
27
|
+
name?: string
|
|
28
|
+
desc?: string
|
|
29
|
+
selected?: boolean
|
|
30
|
+
onClick?: () => void
|
|
31
|
+
loading?: boolean
|
|
32
|
+
/** Элемент справа: шеврон (переход), галочка/радио (выбор) и т.п. */
|
|
33
|
+
trailing?: ReactNode
|
|
34
|
+
className?: string
|
|
35
|
+
}) {
|
|
36
|
+
const inner = (
|
|
37
|
+
<>
|
|
38
|
+
<span className='flex min-w-0 items-center gap-fl-12/16'>
|
|
39
|
+
<span className='flex shrink-0 items-center justify-center'>
|
|
40
|
+
{loading ? (
|
|
41
|
+
// Footprint точь-в-точь как у PaymentMethodIcon (бокс p-fl-6/8 вокруг
|
|
42
|
+
// картинки size-fl-16/20 → внешний fl-28/36, скругление 8px = rounded-xl).
|
|
43
|
+
// Так при загрузке иконка не прыгает и не «сжимается».
|
|
44
|
+
<Skeleton className='size-fl-28/36 rounded-xl' />
|
|
45
|
+
) : (
|
|
46
|
+
icon
|
|
47
|
+
)}
|
|
48
|
+
</span>
|
|
49
|
+
<span className='flex min-w-0 flex-col gap-0.5'>
|
|
50
|
+
<span className='truncate text-body-l font-semibold text-foreground'>
|
|
51
|
+
{loading ? (
|
|
52
|
+
<Skeleton className='inline-block h-[0.9em] w-32 align-middle' />
|
|
53
|
+
) : (
|
|
54
|
+
name
|
|
55
|
+
)}
|
|
56
|
+
</span>
|
|
57
|
+
<span className='truncate text-body-s text-muted-foreground'>
|
|
58
|
+
{loading ? (
|
|
59
|
+
<Skeleton className='inline-block h-[0.9em] w-40 align-middle' />
|
|
60
|
+
) : (
|
|
61
|
+
desc
|
|
62
|
+
)}
|
|
63
|
+
</span>
|
|
64
|
+
</span>
|
|
65
|
+
</span>
|
|
66
|
+
{trailing != null && (
|
|
67
|
+
<span className='flex shrink-0 items-center'>{trailing}</span>
|
|
68
|
+
)}
|
|
69
|
+
</>
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
// На загрузке — тот же каркас, но без кнопки/выбора (просто плейсхолдер).
|
|
73
|
+
if (loading) {
|
|
74
|
+
return (
|
|
75
|
+
<Card className='flex-row py-fl-11/15 items-center justify-between border-2 border-transparent'>
|
|
76
|
+
{inner}
|
|
77
|
+
</Card>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Card
|
|
83
|
+
asChild
|
|
84
|
+
interactive
|
|
85
|
+
className={cn(
|
|
86
|
+
'flex-row py-fl-11/15 items-center justify-between border-2',
|
|
87
|
+
selected ? 'border-primary' : 'border-transparent',
|
|
88
|
+
className,
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
<button
|
|
92
|
+
type='button'
|
|
93
|
+
onClick={onClick}
|
|
94
|
+
aria-pressed={selected}
|
|
95
|
+
className='cursor-pointer text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background'
|
|
96
|
+
>
|
|
97
|
+
{inner}
|
|
98
|
+
</button>
|
|
99
|
+
</Card>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './modal'
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
6
|
+
|
|
7
|
+
import { Icon } from '../Icon'
|
|
8
|
+
import { cn } from '../../lib/utils'
|
|
9
|
+
|
|
10
|
+
// Modal на Radix Dialog: фокус-трап, Esc, клик по оверлею, портал, aria — из коробки.
|
|
11
|
+
// Composition-API: Modal / ModalTrigger / ModalContent / ModalHeader / ModalTitle / …
|
|
12
|
+
export const Modal = DialogPrimitive.Root
|
|
13
|
+
export const ModalTrigger = DialogPrimitive.Trigger
|
|
14
|
+
export const ModalClose = DialogPrimitive.Close
|
|
15
|
+
|
|
16
|
+
function ModalOverlay({
|
|
17
|
+
className,
|
|
18
|
+
...props
|
|
19
|
+
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
|
20
|
+
return (
|
|
21
|
+
<DialogPrimitive.Overlay
|
|
22
|
+
className={cn(
|
|
23
|
+
'fixed inset-0 z-50 bg-navy/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out',
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ModalContentProps extends React.ComponentProps<
|
|
32
|
+
typeof DialogPrimitive.Content
|
|
33
|
+
> {
|
|
34
|
+
/** Показывать крестик закрытия в углу. По умолчанию true. */
|
|
35
|
+
showClose?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function ModalContent({
|
|
39
|
+
className,
|
|
40
|
+
children,
|
|
41
|
+
showClose = true,
|
|
42
|
+
...props
|
|
43
|
+
}: ModalContentProps) {
|
|
44
|
+
return (
|
|
45
|
+
<DialogPrimitive.Portal>
|
|
46
|
+
<ModalOverlay />
|
|
47
|
+
{/* Центрирующая обёртка: флекс по центру экрана. pointer-events-none — клик мимо
|
|
48
|
+
контента проходит на оверлей (закрытие). Анимируется ВНУТРЕННИЙ Content
|
|
49
|
+
(scale/blur/opacity, без translate) — потому центрирование и анимация не спорят. */}
|
|
50
|
+
<div className='pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4'>
|
|
51
|
+
<DialogPrimitive.Content
|
|
52
|
+
// Не переводим фокус на первый интерактивный элемент при открытии: он
|
|
53
|
+
// оказывался подсвечен до всякого действия пользователя (первый таб
|
|
54
|
+
// в окне оплаты). Фокус-ловушка и закрытие по Esc продолжают работать —
|
|
55
|
+
// Radix ставит фокус на сам контейнер (у него tabIndex=-1).
|
|
56
|
+
onOpenAutoFocus={event => event.preventDefault()}
|
|
57
|
+
className={cn(
|
|
58
|
+
'pointer-events-auto relative flex w-full max-w-xl flex-col gap-fl-12/16 rounded-xl border border-border bg-card p-fl-20/30 text-card-foreground shadow-lg outline-none data-[state=open]:modal-blur-in data-[state=closed]:modal-blur-out',
|
|
59
|
+
className,
|
|
60
|
+
)}
|
|
61
|
+
{...props}
|
|
62
|
+
>
|
|
63
|
+
{children}
|
|
64
|
+
{showClose ? (
|
|
65
|
+
<DialogPrimitive.Close
|
|
66
|
+
aria-label='Закрыть'
|
|
67
|
+
className='absolute top-6 right-6 inline-flex size-8 cursor-pointer items-center justify-center text-muted-foreground outline-none transition-colors hover:text-foreground active:text-foreground focus-visible:ring-2 focus-visible:ring-ring'
|
|
68
|
+
>
|
|
69
|
+
<Icon type='close' isCurrentColor size={18} />
|
|
70
|
+
</DialogPrimitive.Close>
|
|
71
|
+
) : null}
|
|
72
|
+
</DialogPrimitive.Content>
|
|
73
|
+
</div>
|
|
74
|
+
</DialogPrimitive.Portal>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// pr-8 — чтобы заголовок не залезал под крестик.
|
|
79
|
+
export function ModalHeader({
|
|
80
|
+
className,
|
|
81
|
+
...props
|
|
82
|
+
}: React.ComponentProps<'div'>) {
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
className={cn('flex flex-col gap-1.5 pr-8 text-left', className)}
|
|
86
|
+
{...props}
|
|
87
|
+
/>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function ModalFooter({
|
|
92
|
+
className,
|
|
93
|
+
...props
|
|
94
|
+
}: React.ComponentProps<'div'>) {
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
className={cn(
|
|
98
|
+
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
|
99
|
+
className,
|
|
100
|
+
)}
|
|
101
|
+
{...props}
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function ModalTitle({
|
|
107
|
+
className,
|
|
108
|
+
...props
|
|
109
|
+
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
110
|
+
return (
|
|
111
|
+
<DialogPrimitive.Title
|
|
112
|
+
className={cn('text-h3 text-foreground', className)}
|
|
113
|
+
{...props}
|
|
114
|
+
/>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function ModalDescription({
|
|
119
|
+
className,
|
|
120
|
+
...props
|
|
121
|
+
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
|
122
|
+
return (
|
|
123
|
+
<DialogPrimitive.Description
|
|
124
|
+
className={cn('text-body-l text-foreground', className)}
|
|
125
|
+
{...props}
|
|
126
|
+
/>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './otp-input'
|