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.
@@ -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
+ }