hallo-kit 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +168 -0
  2. package/bin/cli.mjs +136 -0
  3. package/fonts/Manrope/Manrope-VariableFont_wght.woff2 +0 -0
  4. package/fonts/Manrope/OFL.txt +93 -0
  5. package/fonts/index.ts +30 -0
  6. package/package.json +77 -0
  7. package/src/config.ts +11 -0
  8. package/src/hooks/index.ts +14 -0
  9. package/src/hooks/useAutoHideOnScroll.ts +26 -0
  10. package/src/hooks/useCountdown.ts +44 -0
  11. package/src/hooks/useDevice.ts +63 -0
  12. package/src/hooks/useIsTelegramMiniApp.ts +17 -0
  13. package/src/hooks/useIsTouch.ts +23 -0
  14. package/src/hooks/useKeyboardOpen.ts +35 -0
  15. package/src/hooks/useMediaQuery.ts +24 -0
  16. package/src/hooks/usePlatform.ts +102 -0
  17. package/src/hooks/useQueryParams.tsx +70 -0
  18. package/src/hooks/useRevealOnce.ts +21 -0
  19. package/src/hooks/useScrolledPast.ts +19 -0
  20. package/src/hooks/useSettleOnce.ts +26 -0
  21. package/src/hooks/useTelegram.tsx +57 -0
  22. package/src/i18n/index.tsx +92 -0
  23. package/src/index.ts +35 -0
  24. package/src/lib/device.ts +28 -0
  25. package/src/lib/platform.ts +19 -0
  26. package/src/lib/utils.ts +36 -0
  27. package/src/logo/LogoFull.tsx +59 -0
  28. package/src/logo/LogoMark.tsx +31 -0
  29. package/src/logo/index.ts +2 -0
  30. package/src/next.ts +13 -0
  31. package/src/server.ts +4 -0
  32. package/src/theme-color-sync.tsx +43 -0
  33. package/src/theme-provider.tsx +31 -0
  34. package/src/types/css.d.ts +6 -0
  35. package/src/types/globals.d.ts +47 -0
  36. package/src/ui/Icon/Icon.module.css +25 -0
  37. package/src/ui/Icon/Icon.tsx +45 -0
  38. package/src/ui/Icon/Icon.types.ts +60 -0
  39. package/src/ui/Icon/data/brand.data.tsx +75 -0
  40. package/src/ui/Icon/data/index.ts +2 -0
  41. package/src/ui/Icon/data/ui.data.tsx +780 -0
  42. package/src/ui/Icon/index.ts +3 -0
  43. package/src/ui/badge/badge.tsx +185 -0
  44. package/src/ui/badge/index.ts +1 -0
  45. package/src/ui/breadcrumbs/breadcrumbs.tsx +63 -0
  46. package/src/ui/breadcrumbs/index.ts +1 -0
  47. package/src/ui/burger-icon/burger-icon.tsx +22 -0
  48. package/src/ui/burger-icon/index.ts +1 -0
  49. package/src/ui/button/button.tsx +179 -0
  50. package/src/ui/button/index.ts +1 -0
  51. package/src/ui/card/card.tsx +100 -0
  52. package/src/ui/card/index.ts +1 -0
  53. package/src/ui/confirm/confirm-dialog-host.tsx +89 -0
  54. package/src/ui/confirm/index.ts +1 -0
  55. package/src/ui/count-up/count-up.tsx +96 -0
  56. package/src/ui/count-up/index.ts +1 -0
  57. package/src/ui/data-list/data-list.tsx +331 -0
  58. package/src/ui/data-list/index.ts +1 -0
  59. package/src/ui/drawer/drawer.tsx +166 -0
  60. package/src/ui/drawer/index.ts +1 -0
  61. package/src/ui/flag/flag.data.ts +240 -0
  62. package/src/ui/flag/flag.tsx +57 -0
  63. package/src/ui/flag/index.ts +1 -0
  64. package/src/ui/image/image.tsx +67 -0
  65. package/src/ui/image/index.ts +1 -0
  66. package/src/ui/input/amount-input.tsx +55 -0
  67. package/src/ui/input/index.ts +2 -0
  68. package/src/ui/input/input.tsx +391 -0
  69. package/src/ui/label/index.ts +1 -0
  70. package/src/ui/label/label.tsx +34 -0
  71. package/src/ui/method-card/index.ts +1 -0
  72. package/src/ui/method-card/method-card.tsx +101 -0
  73. package/src/ui/modal/index.ts +1 -0
  74. package/src/ui/modal/modal.tsx +128 -0
  75. package/src/ui/otp-input/index.ts +1 -0
  76. package/src/ui/otp-input/otp-input.tsx +78 -0
  77. package/src/ui/pagination/index.ts +1 -0
  78. package/src/ui/pagination/pagination.tsx +138 -0
  79. package/src/ui/radio-group/index.ts +1 -0
  80. package/src/ui/radio-group/radio-group.tsx +81 -0
  81. package/src/ui/select/index.ts +1 -0
  82. package/src/ui/select/select.tsx +215 -0
  83. package/src/ui/separator/index.ts +1 -0
  84. package/src/ui/separator/separator.tsx +54 -0
  85. package/src/ui/skeleton/index.ts +1 -0
  86. package/src/ui/skeleton/skeleton.tsx +44 -0
  87. package/src/ui/spinner/index.ts +1 -0
  88. package/src/ui/spinner/spinner.tsx +33 -0
  89. package/src/ui/switch/index.ts +1 -0
  90. package/src/ui/switch/switch.tsx +48 -0
  91. package/src/ui/tabs/index.ts +1 -0
  92. package/src/ui/tabs/tabs.tsx +190 -0
  93. package/src/ui/textarea/index.ts +1 -0
  94. package/src/ui/textarea/textarea.tsx +151 -0
  95. package/src/ui/theme-switch/index.ts +1 -0
  96. package/src/ui/theme-switch/theme-switch.tsx +161 -0
  97. package/src/ui/toast/index.ts +1 -0
  98. package/src/ui/toast/toast.tsx +159 -0
  99. package/src/utils/api/stringFormat.ts +5 -0
  100. package/src/utils/clearAllCookies.ts +7 -0
  101. package/src/utils/clsx.ts +18 -0
  102. package/src/utils/confirm.ts +48 -0
  103. package/src/utils/copy-to-clipboard.ts +12 -0
  104. package/src/utils/date/convertTimestamp.ts +44 -0
  105. package/src/utils/date/formatCountdown.ts +9 -0
  106. package/src/utils/date/formatDayLabel.ts +50 -0
  107. package/src/utils/date/formatRelativeDateTime.ts +34 -0
  108. package/src/utils/date/locales.ts +9 -0
  109. package/src/utils/form/validationSchema.ts +46 -0
  110. package/src/utils/getByKeyString.ts +15 -0
  111. package/src/utils/getCssVar.ts +2 -0
  112. package/src/utils/getDeepType.ts +3 -0
  113. package/src/utils/getQueryParams.ts +21 -0
  114. package/src/utils/inAppBrowser.ts +151 -0
  115. package/src/utils/index.ts +28 -0
  116. package/src/utils/isRTL.ts +29 -0
  117. package/src/utils/notify.ts +46 -0
  118. package/src/utils/openExternal.ts +27 -0
  119. package/src/utils/system/getMillisecondsPeriod.ts +31 -0
  120. package/src/utils/validation/authValidators.ts +23 -0
  121. package/src/utils/validation/refCode.ts +30 -0
  122. package/styles/base.css +97 -0
  123. package/styles/brand.css +36 -0
  124. package/styles/index.css +19 -0
  125. package/styles/motion.css +207 -0
  126. package/styles/semantic.css +151 -0
  127. package/styles/theme.css +143 -0
  128. package/styles/utilities.css +289 -0
@@ -0,0 +1,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'