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,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'
|