hallo-kit 0.3.0 → 0.3.1
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 +2 -0
- package/fonts/Manrope/OFL.txt +93 -93
- package/fonts/index.ts +30 -30
- package/package.json +4 -1
- package/src/hooks/useCountdown.ts +44 -44
- package/src/hooks/usePlatform.ts +102 -102
- package/src/ui/Icon/Icon.types.ts +60 -60
- package/src/ui/Icon/data/ui.data.tsx +780 -780
- package/src/ui/breadcrumbs/breadcrumbs.tsx +63 -63
- package/src/ui/data-list/data-list.tsx +331 -331
- package/src/ui/modal/modal.tsx +128 -128
- package/src/ui/select/select.tsx +215 -215
- package/src/ui/skeleton/skeleton.tsx +44 -44
- package/src/ui/tabs/tabs.tsx +190 -190
- package/src/utils/copy-to-clipboard.ts +12 -12
|
@@ -1,331 +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
|
-
}
|
|
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
|
+
}
|