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,96 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+
5
+ // easeOutQuad — быстрый старт с коротким торможением (у cubic хвост слишком долго «ползёт»).
6
+ const easeOut = (t: number) => 1 - (1 - t) * (1 - t)
7
+
8
+ const prefersReducedMotion = () =>
9
+ typeof window !== 'undefined' &&
10
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
11
+
12
+ // Ключи, уже отыгравшие за сессию (пока жив JS). Повторный заход на страницу с тем же
13
+ // replayKey показывает значение сразу — прокрутка появления одноразовая, а не на каждый вход.
14
+ const playedKeys = new Set<string>()
15
+
16
+ export interface CountUpProps {
17
+ /** Целевое значение. */
18
+ value: number
19
+ /** Форматирование итоговой строки (валюта/единицы/локаль). Приоритетнее decimals. */
20
+ format?: (n: number) => string
21
+ /** Знаков после запятой, если format не задан. По умолчанию 0 (целое). */
22
+ decimals?: number
23
+ /** Длительность прокрутки, мс. Короткий «тик» без долгого хвоста. */
24
+ duration?: number
25
+ className?: string
26
+ /**
27
+ * Стабильный ключ — тогда прокрутка играет ОДИН раз за сессию (первый показ). При
28
+ * повторном монтировании (навигация назад на страницу) значение появляется сразу.
29
+ */
30
+ replayKey?: string
31
+ }
32
+
33
+ // Число «накручивается» до value голым requestAnimationFrame, без сторонних либ.
34
+ // prefers-reduced-motion — значение проставляется сразу. Формат тот же, что был бы у
35
+ // статичного числа, поэтому это прямая замена вывода `{value}` в карточках.
36
+ export function CountUp({
37
+ value,
38
+ format,
39
+ decimals = 0,
40
+ duration = 600,
41
+ className,
42
+ replayKey,
43
+ }: CountUpProps) {
44
+ // Ключ уже отыгран → стартуем сразу с финального значения (без прокрутки).
45
+ const alreadyPlayed = replayKey != null && playedKeys.has(replayKey)
46
+ const [display, setDisplay] = useState(alreadyPlayed ? value : 0)
47
+ const displayRef = useRef(alreadyPlayed ? value : 0)
48
+ const rafRef = useRef<number | undefined>(undefined)
49
+
50
+ useEffect(() => {
51
+ const from = displayRef.current
52
+ const to = value
53
+ if (from === to) return
54
+
55
+ // Одноразовость: ключ уже отыгран (первый показ был) — доводим сразу, без прокрутки.
56
+ const skipAnimation =
57
+ (replayKey != null && playedKeys.has(replayKey)) || prefersReducedMotion()
58
+
59
+ if (skipAnimation) {
60
+ const id = requestAnimationFrame(() => {
61
+ displayRef.current = to
62
+ setDisplay(to)
63
+ if (replayKey != null) playedKeys.add(replayKey)
64
+ })
65
+ return () => cancelAnimationFrame(id)
66
+ }
67
+
68
+ let startTs: number | null = null
69
+ const tick = (ts: number) => {
70
+ if (startTs === null) startTs = ts
71
+ const progress = Math.min(1, (ts - startTs) / duration)
72
+ const current = from + (to - from) * easeOut(progress)
73
+ displayRef.current = current
74
+ setDisplay(current)
75
+ if (progress < 1) {
76
+ rafRef.current = requestAnimationFrame(tick)
77
+ } else {
78
+ displayRef.current = to
79
+ setDisplay(to)
80
+ if (replayKey != null) playedKeys.add(replayKey)
81
+ }
82
+ }
83
+ rafRef.current = requestAnimationFrame(tick)
84
+
85
+ return () => {
86
+ if (rafRef.current) cancelAnimationFrame(rafRef.current)
87
+ }
88
+ }, [value, duration, replayKey])
89
+
90
+ // tabular-nums — цифры одной ширины, чтобы строка не «дёргалась» на прокрутке.
91
+ return (
92
+ <span className={className} style={{ fontVariantNumeric: 'tabular-nums' }}>
93
+ {format ? format(display) : display.toFixed(decimals)}
94
+ </span>
95
+ )
96
+ }
@@ -0,0 +1 @@
1
+ export { CountUp, type CountUpProps } from './count-up'
@@ -0,0 +1,331 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+
5
+ import { useWindowVirtualizer } from '@tanstack/react-virtual'
6
+
7
+ import { Card } from '../card'
8
+ import { Separator } from '../separator'
9
+ import { useRevealOnce } from '../../hooks/useRevealOnce'
10
+ import { cn } from '../../lib/utils'
11
+
12
+ // data-list — примитив «разделённый список строк» (то, что в макете выглядит как
13
+ // «таблица», но без шапки и колонок). Владеет общим каркасом: Card-оболочка,
14
+ // вертикальные паддинги строк, разделители, скелетон-режим. Над списком — window-
15
+ // виртуализация (@tanstack/react-virtual): на длинных списках в DOM живут только
16
+ // видимые строки. Высота строк РАЗНАЯ (2–3 текст-линии) → меряем по факту
17
+ // (measureElement). Стили — свои (у Radix примитива списка/таблицы нет, как и для
18
+ // Card/Badge). Контент строки и его скелетон задаёт фича через renderItem/renderSkeleton.
19
+
20
+ // useLayoutEffect на сервере шумит предупреждением → на SSR падаем в useEffect.
21
+ const useIsoLayoutEffect =
22
+ typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect
23
+
24
+ // Вариант каркаса списка:
25
+ // - 'divided' (деф.) — один Card-конверт, строки разделены ui/Separator (то, что в макете
26
+ // выглядит как «таблица»);
27
+ // - 'separated' — каждая строка отдельной Card с вертикальным зазором между ними (список
28
+ // карточек). Внешнего Card-конверта нет.
29
+ export type DataListVariant = 'divided' | 'separated'
30
+
31
+ // Каркас одной строки. Поведение зависит от variant.
32
+ //
33
+ // divided: Card-конверт без своего паддинга (p-0!) — все отступы задаёт строка:
34
+ // - ui/Separator между строками (центрирован: mx-auto + 95% ширины);
35
+ // - по бокам у всех px-fl-16/20; вертикаль между строками 11/15.
36
+ // Разделитель внутри строки → меряется вместе с ней (один код-путь для потока и
37
+ // виртуализации). hoverable → подсветка фона на наведение (full-bleed, Card без p).
38
+ //
39
+ // separated: строка = отдельная Card (свой паддинг/скругление). Зазор — pt у строк, кроме
40
+ // первой; паддинг ВХОДИТ в box → measureElement учитывает его и при виртуализации.
41
+ // hoverable → Card.interactive (курсор + подсветка рамки + лёгкое нажатие).
42
+ //
43
+ // ref как проп (React 19) — для measureElement виртуализатора.
44
+ function RowFrame({
45
+ index,
46
+ // count в API строки оставлен (симметрия с index, нужен кастомным вариантам),
47
+ // но текущие варианты его не используют — вынимаем из ...props, чтобы не утёк в DOM.
48
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
49
+ count: _count,
50
+ variant = 'divided',
51
+ hoverable = false,
52
+ className,
53
+ children,
54
+ ref,
55
+ ...props
56
+ }: React.ComponentProps<'div'> & {
57
+ index: number
58
+ count: number
59
+ variant?: DataListVariant
60
+ hoverable?: boolean
61
+ }) {
62
+ if (variant === 'separated') {
63
+ return (
64
+ <div
65
+ ref={ref}
66
+ className={cn(index > 0 && 'pt-fl-8/10', className)}
67
+ {...props}
68
+ >
69
+ <Card interactive={hoverable}>{children}</Card>
70
+ </div>
71
+ )
72
+ }
73
+
74
+ return (
75
+ <div ref={ref} className={className} {...props}>
76
+ {index > 0 && <Separator className='mx-auto w-[96%]!' />}
77
+ <div
78
+ className={cn(
79
+ 'px-fl-16/20',
80
+ 'py-fl-11/15',
81
+
82
+ hoverable && 'rounded-md transition-colors hover:bg-accent',
83
+ )}
84
+ >
85
+ {children}
86
+ </div>
87
+ </div>
88
+ )
89
+ }
90
+
91
+ export interface DataListProps<T> {
92
+ /** Данные. `undefined` = идёт загрузка → рисуем скелетон-строки. */
93
+ items: readonly T[] | undefined
94
+ /** Стабильный ключ строки. */
95
+ getKey: (item: T, index: number) => React.Key
96
+ /** Содержимое строки (обычно фичевый *-row компонент). */
97
+ renderItem: (item: T, index: number) => React.ReactNode
98
+ /** Содержимое скелетон-строки (тот же *-row без данных). */
99
+ renderSkeleton?: (index: number) => React.ReactNode
100
+ /** Сколько скелетон-строк показать при загрузке. */
101
+ skeletonCount?: number
102
+ /** Стартовая оценка высоты строки, px (дальше меряется по факту). */
103
+ estimateSize?: number
104
+ /** С какого размера списка включать виртуализацию. */
105
+ virtualizeThreshold?: number
106
+ /** Запас строк сверху/снизу видимой области. */
107
+ overscan?: number
108
+ /** Что показать, когда данные пришли, но список пуст. */
109
+ empty?: React.ReactNode
110
+ /** Подсветка строки на наведение (по умолчанию включена). */
111
+ hoverable?: boolean
112
+ /**
113
+ * Вид списка: `divided` (деф.) — один Card с разделителями между строками;
114
+ * `separated` — каждая строка отдельной Card с зазором между ними.
115
+ */
116
+ variant?: DataListVariant
117
+ /**
118
+ * Ключ одноразового каскада появления строк (reveal-stagger): первый показ за сессию —
119
+ * строки всплывают лесенкой, повторный заход — список сразу. Без ключа каскада нет.
120
+ * На виртуализированном списке каскад не играет (transform строк занят позиционированием).
121
+ */
122
+ revealKey?: string
123
+ className?: string
124
+ }
125
+
126
+ export function DataList<T>({
127
+ items,
128
+ getKey,
129
+ renderItem,
130
+ renderSkeleton,
131
+ skeletonCount = 5,
132
+ estimateSize = 64,
133
+ virtualizeThreshold = 40,
134
+ overscan = 6,
135
+ empty,
136
+ hoverable = true,
137
+ variant = 'divided',
138
+ revealKey,
139
+ className,
140
+ }: DataListProps<T>) {
141
+ const loading = items === undefined
142
+ const count = items?.length ?? 0
143
+ const shouldVirtualize = count > virtualizeThreshold
144
+ // Одноразовый каскад появления строк. Значение фиксируется на маунте: если компонент
145
+ // смонтировался в загрузке (скелетоны), каскад сыграет на приходе данных.
146
+ const revealPlay = useRevealOnce(revealKey)
147
+
148
+ // Внешний конверт списка: divided — единый Card (строки несут отступы сами, p-0!);
149
+ // separated — обычный блок, отступы и фон у каждой строки-Card. Это ФУНКЦИЯ-хелпер,
150
+ // а не компонент: вызываем как frame(...), иначе новый тип на каждый рендер ремаунтил
151
+ // бы всё поддерево (ломая виртуализацию/фокус).
152
+ const frame = (children: React.ReactNode) =>
153
+ variant === 'separated' ? (
154
+ <div className={cn('flex flex-col', className)}>{children}</div>
155
+ ) : (
156
+ <Card className={cn('gap-0! p-0!', className)}>{children}</Card>
157
+ )
158
+
159
+ // Смещение списка от верха документа — scrollMargin для window-виртуализатора.
160
+ // Меряем после монтирования (и на resize); до этого виртуализация не активна.
161
+ const listRef = React.useRef<HTMLDivElement>(null)
162
+ const [scrollMargin, setScrollMargin] = React.useState(0)
163
+ useIsoLayoutEffect(() => {
164
+ if (!shouldVirtualize) return
165
+ const measure = () => {
166
+ if (listRef.current) {
167
+ setScrollMargin(
168
+ listRef.current.getBoundingClientRect().top + window.scrollY,
169
+ )
170
+ }
171
+ }
172
+ measure()
173
+ window.addEventListener('resize', measure)
174
+ return () => window.removeEventListener('resize', measure)
175
+ }, [shouldVirtualize])
176
+
177
+ // Хук вызываем всегда (правила хуков); при count=0 он по сути выключен.
178
+ const virtualizer = useWindowVirtualizer({
179
+ count: shouldVirtualize ? count : 0,
180
+ estimateSize: () => estimateSize,
181
+ overscan,
182
+ scrollMargin,
183
+ })
184
+
185
+ // — Загрузка: skeletonCount строк-заглушек (всегда в потоке, их мало).
186
+ if (loading) {
187
+ return frame(
188
+ Array.from({ length: skeletonCount }).map((_, i) => (
189
+ <RowFrame key={i} index={i} count={skeletonCount} variant={variant}>
190
+ {renderSkeleton?.(i)}
191
+ </RowFrame>
192
+ )),
193
+ )
194
+ }
195
+
196
+ // — Пусто.
197
+ if (count === 0) {
198
+ return empty ? frame(empty) : null
199
+ }
200
+
201
+ // — Короткий список: обычный поток, без оверхеда виртуализации. Каскад появления —
202
+ // только здесь (см. revealKey): reveal всплывает строку, задержка растёт до 10-й,
203
+ // дальше плато, чтобы длинный список не тянулся.
204
+ if (!shouldVirtualize) {
205
+ return frame(
206
+ items!.map((item, i) => (
207
+ <RowFrame
208
+ key={getKey(item, i)}
209
+ index={i}
210
+ count={count}
211
+ variant={variant}
212
+ hoverable={hoverable}
213
+ className={revealPlay ? 'reveal' : undefined}
214
+ style={
215
+ revealPlay
216
+ ? { animationDelay: `${Math.min(i, 10) * 35}ms` }
217
+ : undefined
218
+ }
219
+ >
220
+ {renderItem(item, i)}
221
+ </RowFrame>
222
+ )),
223
+ )
224
+ }
225
+
226
+ // — Длинный список: window-виртуализация. В DOM только видимые строки, спейсер
227
+ // держит общую высоту, строки позиционируются translateY от измеренных позиций.
228
+ const virtualItems = virtualizer.getVirtualItems()
229
+ return frame(
230
+ <div
231
+ ref={listRef}
232
+ className='relative w-full'
233
+ style={{ height: virtualizer.getTotalSize() }}
234
+ >
235
+ {virtualItems.map(vi => {
236
+ const item = items![vi.index]
237
+ return (
238
+ <RowFrame
239
+ key={getKey(item, vi.index)}
240
+ index={vi.index}
241
+ count={count}
242
+ variant={variant}
243
+ hoverable={hoverable}
244
+ data-index={vi.index}
245
+ ref={virtualizer.measureElement}
246
+ className='absolute left-0 top-0 w-full'
247
+ style={{
248
+ transform: `translateY(${vi.start - virtualizer.options.scrollMargin}px)`,
249
+ }}
250
+ >
251
+ {renderItem(item, vi.index)}
252
+ </RowFrame>
253
+ )
254
+ })}
255
+ </div>,
256
+ )
257
+ }
258
+
259
+ // DataRow — каркас содержимого строки: слева медиа (пилюля/иконка) + колонка текста,
260
+ // справа — трейлинг-слот (дата/бейдж). Сюда уезжает повторявшийся flex/justify/min-w-0.
261
+ export function DataRow({
262
+ media,
263
+ end,
264
+ className,
265
+ children,
266
+ }: {
267
+ /** Лидирующий элемент: пилюля суммы, иконка метода и т.п. */
268
+ media?: React.ReactNode
269
+ /** Трейлинг справа: дата, бейдж статуса. */
270
+ end?: React.ReactNode
271
+ className?: string
272
+ /** Текстовые строки (DataRowTitle/Subtitle/Caption). */
273
+ children?: React.ReactNode
274
+ }) {
275
+ return (
276
+ <div
277
+ className={cn(
278
+ 'flex items-center justify-between gap-fl-12/16',
279
+ className,
280
+ )}
281
+ >
282
+ <div className='flex min-w-0 items-center gap-fl-12/16'>
283
+ {media}
284
+ {children != null && (
285
+ <div className='flex min-w-0 flex-col gap-0.5'>{children}</div>
286
+ )}
287
+ </div>
288
+ {end}
289
+ </div>
290
+ )
291
+ }
292
+
293
+ // Текстовые линии строки — единая типографика, truncate против переполнения.
294
+ export function DataRowTitle({
295
+ className,
296
+ ...props
297
+ }: React.ComponentProps<'span'>) {
298
+ return (
299
+ <span
300
+ className={cn(
301
+ 'truncate text-body-m font-semibold text-foreground',
302
+ className,
303
+ )}
304
+ {...props}
305
+ />
306
+ )
307
+ }
308
+
309
+ export function DataRowSubtitle({
310
+ className,
311
+ ...props
312
+ }: React.ComponentProps<'span'>) {
313
+ return (
314
+ <span
315
+ className={cn('truncate text-body-s text-muted-foreground', className)}
316
+ {...props}
317
+ />
318
+ )
319
+ }
320
+
321
+ export function DataRowCaption({
322
+ className,
323
+ ...props
324
+ }: React.ComponentProps<'span'>) {
325
+ return (
326
+ <span
327
+ className={cn('truncate text-caption text-muted-foreground', className)}
328
+ {...props}
329
+ />
330
+ )
331
+ }
@@ -0,0 +1 @@
1
+ export * from './data-list'
@@ -0,0 +1,166 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+
5
+ import { Drawer as DrawerPrimitive } from 'vaul'
6
+
7
+ import { Icon } from '../Icon'
8
+ import { cn } from '../../lib/utils'
9
+
10
+ type DrawerDirection = 'top' | 'bottom' | 'left' | 'right'
11
+
12
+ // vaul сам анимирует выезд и тянется пальцем (drag-to-dismiss). Направление нужно
13
+ // и для жеста, и для позиционирования панели — раздаём его в DrawerContent через
14
+ // контекст, чтобы тот подобрал нужный край и скругление.
15
+ const DrawerDirectionContext = React.createContext<DrawerDirection>('bottom')
16
+
17
+ export function Drawer({
18
+ direction = 'bottom',
19
+ ...props
20
+ }: React.ComponentProps<typeof DrawerPrimitive.Root>) {
21
+ return (
22
+ <DrawerDirectionContext.Provider value={direction as DrawerDirection}>
23
+ <DrawerPrimitive.Root direction={direction} {...props} />
24
+ </DrawerDirectionContext.Provider>
25
+ )
26
+ }
27
+
28
+ export const DrawerTrigger = DrawerPrimitive.Trigger
29
+ export const DrawerClose = DrawerPrimitive.Close
30
+
31
+ function DrawerOverlay({
32
+ className,
33
+ ...props
34
+ }: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
35
+ return (
36
+ <DrawerPrimitive.Overlay
37
+ className={cn(
38
+ 'fixed inset-0 z-50 bg-navy/40 backdrop-blur-sm',
39
+ className,
40
+ )}
41
+ {...props}
42
+ />
43
+ )
44
+ }
45
+
46
+ // Край + скругление + размер по направлению выезда.
47
+ const DRAWER_SIDE: Record<DrawerDirection, string> = {
48
+ bottom: 'inset-x-0 bottom-0 max-h-[90vh] rounded-t-xl border-t',
49
+ top: 'inset-x-0 top-0 max-h-[90vh] rounded-b-xl border-b',
50
+ right: 'inset-y-0 right-0 w-[min(90vw,24rem)] rounded-l-xl border-l',
51
+ left: 'inset-y-0 left-0 w-[min(90vw,24rem)] rounded-r-xl border-r',
52
+ }
53
+
54
+ // Как закрывать панель:
55
+ // - close — крестик в углу (клик закрывает);
56
+ // - bar — drag-ручка-«тире» у края (визуальная подсказка, что можно смахнуть);
57
+ // - none — без индикатора (закрытие по оверлею/Esc/жесту).
58
+ export type DrawerDismiss = 'close' | 'bar' | 'none'
59
+
60
+ // Геометрия drag-ручки: она ложится на край, ПРОТИВОПОЛОЖНЫЙ направлению выезда
61
+ // (для bottom — сверху, для right — слева), и ориентируется поперёк смахивания
62
+ // (горизонтальная палочка для вертикальных направлений, вертикальная — для боковых).
63
+ const DRAWER_HANDLE: Record<DrawerDirection, string> = {
64
+ bottom: 'top-2 left-1/2 h-1.5 w-12 -translate-x-1/2',
65
+ top: 'bottom-2 left-1/2 h-1.5 w-12 -translate-x-1/2',
66
+ right: 'top-1/2 left-2 h-12 w-1.5 -translate-y-1/2',
67
+ left: 'top-1/2 right-2 h-12 w-1.5 -translate-y-1/2',
68
+ }
69
+
70
+ export interface DrawerContentProps
71
+ extends React.ComponentProps<typeof DrawerPrimitive.Content> {
72
+ /** Индикатор/способ закрытия панели. По умолчанию `close` (крестик). */
73
+ dismiss?: DrawerDismiss
74
+ }
75
+
76
+ export function DrawerContent({
77
+ className,
78
+ children,
79
+ dismiss = 'close',
80
+ ...props
81
+ }: DrawerContentProps) {
82
+ const direction = React.useContext(DrawerDirectionContext)
83
+
84
+ return (
85
+ <DrawerPrimitive.Portal>
86
+ <DrawerOverlay />
87
+ <DrawerPrimitive.Content
88
+ className={cn(
89
+ 'fixed z-50 flex flex-col gap-fl-12/16 border-border bg-card p-fl-16/20 text-card-foreground shadow-lg outline-none',
90
+ DRAWER_SIDE[direction],
91
+ className,
92
+ )}
93
+ {...props}
94
+ >
95
+ {/* «тире» — некликабельная подсказка для жеста; absolute, поэтому не двигает контент */}
96
+ {dismiss === 'bar' ? (
97
+ <div
98
+ aria-hidden
99
+ className={cn(
100
+ 'absolute shrink-0 rounded-full bg-disabled/60',
101
+ DRAWER_HANDLE[direction],
102
+ )}
103
+ />
104
+ ) : null}
105
+
106
+ {children}
107
+
108
+ {/* крестик — кликабельный Close из vaul, иконка через свой Icon */}
109
+ {dismiss === 'close' ? (
110
+ <DrawerPrimitive.Close
111
+ aria-label='Закрыть'
112
+ className='absolute top-4 right-4 inline-flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-secondary hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring'
113
+ >
114
+ <Icon type='close' isCurrentColor size={18} />
115
+ </DrawerPrimitive.Close>
116
+ ) : null}
117
+ </DrawerPrimitive.Content>
118
+ </DrawerPrimitive.Portal>
119
+ )
120
+ }
121
+
122
+ // pr-8 — чтобы заголовок не залезал под крестик (dismiss='close' в углу).
123
+ export function DrawerHeader({
124
+ className,
125
+ ...props
126
+ }: React.ComponentProps<'div'>) {
127
+ return (
128
+ <div
129
+ className={cn('flex flex-col gap-1.5 pr-8 text-left', className)}
130
+ {...props}
131
+ />
132
+ )
133
+ }
134
+
135
+ export function DrawerFooter({
136
+ className,
137
+ ...props
138
+ }: React.ComponentProps<'div'>) {
139
+ return (
140
+ <div className={cn('mt-auto flex flex-col gap-2', className)} {...props} />
141
+ )
142
+ }
143
+
144
+ export function DrawerTitle({
145
+ className,
146
+ ...props
147
+ }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
148
+ return (
149
+ <DrawerPrimitive.Title
150
+ className={cn('text-h5 text-foreground', className)}
151
+ {...props}
152
+ />
153
+ )
154
+ }
155
+
156
+ export function DrawerDescription({
157
+ className,
158
+ ...props
159
+ }: React.ComponentProps<typeof DrawerPrimitive.Description>) {
160
+ return (
161
+ <DrawerPrimitive.Description
162
+ className={cn('text-body-m text-muted-foreground', className)}
163
+ {...props}
164
+ />
165
+ )
166
+ }
@@ -0,0 +1 @@
1
+ export * from './drawer'