VirtualList
Katta ro'yxatlar uchun: 10 000+ qator qotmasdan, chunki faqat ko'rinadigan qatorlar chiziladi.
'use client';
import { Badge, Button, Input, VirtualList, type VirtualListHandle } from '@akhatmirzo/ui';
import { useMemo, useRef, useState } from 'react';
const LEVELS = [
{ label: 'Info', color: 'info', text: 'Foydalanuvchi tizimga kirdi' },
{ label: 'Tayyor', color: 'success', text: "To'lov qabul qilindi" },
{ label: 'Ogohlantirish', color: 'warning', text: 'Omborda mahsulot kam qoldi' },
{ label: 'Xato', color: 'danger', text: "SMS yuborib bo'lmadi" },
] as const;
export default function VirtualListBasic() {
const logs = useMemo(
() => Array.from({ length: 10_000 }, (_, i) => ({ id: i + 1, ...LEVELS[i % LEVELS.length]! })),
[],
);
const ref = useRef<VirtualListHandle>(null);
const [target, setTarget] = useState('5000');
const go = () => ref.current?.scrollToIndex(Number(target) - 1, { align: 'start' });
return (
<div className="flex w-full flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Input
aria-label="Qator raqami"
inputMode="numeric"
value={target}
onChange={(e) => setTarget(e.target.value.replace(/\D/g, ''))}
onKeyDown={(e) => e.key === 'Enter' && go()}
className="w-28"
/>
<Button variant="soft" onClick={go}>
Qatorga o'tish
</Button>
</div>
<VirtualList
ref={ref}
aria-label="Tizim jurnali"
items={logs}
estimateSize={48}
height={400}
overscan={8}
getItemKey={(log) => log.id}
className="rounded-xl border border-border bg-surface"
renderItem={(log) => (
<div className="flex h-12 items-center gap-3 border-b border-border px-4 text-sm">
<span className="w-14 shrink-0 text-text-faint tabular-nums">#{log.id}</span>
<Badge color={log.color}>{log.label}</Badge>
<span className="min-w-0 flex-1 truncate">{log.text}</span>
</div>
)}
/>
</div>
);
}
Import
import { VirtualList, type VirtualListHandle } from '@akhatmirzo/ui';Yuqoridagi ro'yxatda 10 000 ta yozuv, lekin DOM'da bir vaqtda faqat 20 ga yaqin qator bor:
ko'rinib turganlar va ikki tomonda bir oz zaxira (overscan). Scroll paytida qatorlar qayta
ishlatiladi (TanStack Virtual asosida).
Qachon ishlatish
- Yuzlab va minglab elementli ro'yxatlar — jurnal (log), xabarlar, bildirishnomalar, katalog. 100 tagacha element bo'lsa, oddiy ro'yxat yetarli va soddaroq.
- Cheksiz yuklash (infinite scroll) —
onEndReachedvaloadingMorebilan, sahifalash tugmalarisiz. - Jadval kerak bo'lsa — ustunlar, saralash, filtr bilan — DataTable ning
virtualrejimini ishlating. - Balandlikni aniq bering:
heightmajburiy — virtual ro'yxat o'z scroll konteyneriga ega bo'lishi kerak. - Har bir qatorga barqaror kalit bering (
getItemKey) — ro'yxat o'zgarganda (qo'shish, o'chirish) qatorlar adashmaydi.
Qatorga o'tish
ref — DOM element emas, boshqaruv obyekti (VirtualListHandle): scrollToIndex,
scrollToOffset, measure va scroll konteyneri — element. Yuqoridagi demoda raqam yozib,
Enter bosing.
const ref = useRef<VirtualListHandle>(null);
ref.current?.scrollToIndex(4999, { align: 'start' }); // 5000-qator tepada
ref.current?.scrollToOffset(0); // boshiga
ref.current?.element?.focus(); // DOM elementHar xil balandlik
Qatorlar balandligi har xil bo'lsa (xabarlar, izohlar) — dynamic bering: har bir qator
chizilgach haqiqiy o'lchami olinadi. estimateSize bu holda faqat boshlang'ich taxmin; u haqiqiy
o'rtachaga qanchalik yaqin bo'lsa, scroll paneli shunchalik silliq.
Assalomu alaykum!
Yetkazish manzilini o'zgartirsam bo'ladimi? Hozir Samarqanddaman, kuryer oldindan qo'ng'iroq qilsin.
Rahmat, mahsulot juda yoqdi. Qadoqlash ham a'lo darajada edi, do'stlarimga ham tavsiya qilaman.
Buyurtmam qachon yetib keladi? Kecha to'lov qilgan edim.
Rahmat, mahsulot juda yoqdi. Qadoqlash ham a'lo darajada edi, do'stlarimga ham tavsiya qilaman.
Buyurtmam qachon yetib keladi? Kecha to'lov qilgan edim.
Ha, albatta.
Assalomu alaykum!
Ha, albatta.
Assalomu alaykum!
Yetkazish manzilini o'zgartirsam bo'ladimi? Hozir Samarqanddaman, kuryer oldindan qo'ng'iroq qilsin.
'use client';
import { Avatar, VirtualList } from '@akhatmirzo/ui';
const NAMES = ['Aziza Karimova', 'Bobur Toshmatov', "G'ayrat Usmonov", 'Malika Yusupova'];
const TEXTS = [
'Assalomu alaykum!',
"Buyurtmam qachon yetib keladi? Kecha to'lov qilgan edim.",
"Yetkazish manzilini o'zgartirsam bo'ladimi? Hozir Samarqanddaman, kuryer oldindan qo'ng'iroq qilsin.",
'Ha, albatta.',
"Rahmat, mahsulot juda yoqdi. Qadoqlash ham a'lo darajada edi, do'stlarimga ham tavsiya qilaman.",
];
const messages = Array.from({ length: 2000 }, (_, i) => ({
id: i,
author: NAMES[(i * 3) % NAMES.length]!,
text: TEXTS[(i * 2 + (i >> 2)) % TEXTS.length]!,
}));
export default function VirtualListDynamic() {
return (
<VirtualList
dynamic
aria-label="Mijozlar bilan yozishmalar"
items={messages}
estimateSize={72}
height={400}
getItemKey={(m) => m.id}
className="w-full rounded-xl border border-border bg-surface"
renderItem={(m) => (
<div className="flex gap-3 border-b border-border px-4 py-3">
<Avatar name={m.author} size="sm" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">{m.author}</span>
<p className="m-0 text-sm text-text-muted">{m.text}</p>
</div>
</div>
)}
/>
);
}
Balandlik qatorning o'zida bo'lsin
dynamic siz qatorga estimateSize balandligi beriladi — qator ichidagi element ham shu
balandlikda bo'lsin (h-12 = 48px). gap qatorlar orasidagi masofani beradi; margin ishlatmang
— u o'lchovga kirmaydi.
Cheksiz yuklash
onEndReached — oxiriga endReachedThreshold (standart 5) qator qolganda chaqiriladi, har bir
ro'yxat uzunligi uchun bir marta. Yuklash paytida loadingMore bering: pastda Spinner qatori
chiqadi va onEndReached qayta chaqirilmaydi. Agar yuklash xato bilan tugasa (loadingMore false
bo'ldi, lekin uzunlik o'zgarmadi), keyingi scroll'da yana chaqiriladi — qayta urinish o'zi
bo'ladi.
Yuklangan: 40 / 400
'use client';
import { formatMoney } from '@akhatmirzo/core';
import { VirtualList } from '@akhatmirzo/ui';
import { useCallback, useState } from 'react';
const PAGE = 40;
const TOTAL = 400;
const NAMES = ['Jasur Karimov', 'Malika Rahimova', 'Nodira Saidova', 'Otabek Qodirov'];
const makeOrders = (from: number) =>
Array.from({ length: PAGE }, (_, i) => {
const id = from + i + 1;
return {
id,
customer: NAMES[id % NAMES.length]!,
total: ((id * 7919) % 90) * 15_000 + 150_000,
};
});
export default function VirtualListInfinite() {
const [orders, setOrders] = useState(() => makeOrders(0));
const [loading, setLoading] = useState(false);
const loadMore = useCallback(() => {
if (orders.length >= TOTAL) return;
setLoading(true);
// Haqiqiy loyihada — fetch(`/api/orders?offset=${orders.length}`)
setTimeout(() => {
setOrders((prev) => [...prev, ...makeOrders(prev.length)]);
setLoading(false);
}, 900);
}, [orders.length]);
return (
<div className="flex w-full flex-col gap-2">
<p className="m-0 text-sm text-text-muted">
Yuklangan: {orders.length} / {TOTAL}
</p>
<VirtualList
aria-label="Buyurtmalar"
items={orders}
estimateSize={52}
height={360}
loadingMore={loading}
onEndReached={loadMore}
getItemKey={(o) => o.id}
labels={{ loadingMore: 'Buyurtmalar yuklanmoqda...' }}
className="rounded-xl border border-border bg-surface"
renderItem={(o) => (
<div className="flex h-13 items-center gap-3 border-b border-border px-4 text-sm">
<span className="w-14 shrink-0 text-text-faint tabular-nums">№{o.id}</span>
<span className="min-w-0 flex-1 truncate">{o.customer}</span>
<span className="shrink-0 font-medium tabular-nums">{formatMoney(o.total)}</span>
</div>
)}
/>
</div>
);
}
Bo'sh holat
items bo'sh bo'lsa, emptyState (standart — "Ro'yxat bo'sh") ko'rsatiladi.
'use client';
import { VirtualList } from '@akhatmirzo/ui';
export default function VirtualListEmpty() {
return (
<VirtualList
aria-label="Bildirishnomalar"
items={[]}
estimateSize={48}
height={160}
renderItem={() => null}
emptyState="Hali bildirishnomalar yo'q"
className="w-full max-w-md rounded-xl border border-border bg-surface"
/>
);
}
Props
Qolgan <div> atributlarini qabul qiladi (scroll konteyneriga). aria-label /
aria-labelledby ichki ro'yxat elementiga (role="list") beriladi.
| Prop | Tip | Standart | Tavsif |
|---|---|---|---|
itemsmajburiy | readonly T[] | — | Elementlar |
renderItemmajburiy | (item: T, index: number) => ReactNode | — | Qatorni chizish |
estimateSizemajburiy | number | ((index: number) => number) | — | Taxminiy qator balandligi, px |
heightmajburiy | number | string | — | Konteyner balandligi: px yoki CSS qiymat ("60vh") |
overscan | number | 5 | Ekrandan tashqarida qo'shimcha chiziladigan qatorlar |
dynamic | boolean | false | Qatorlar har xil balandlikda — o'lchab olinadi |
getItemKey | (item: T, index: number) => string | number | — | Qator kaliti (standart — indeks) |
gap | number | 0 | Qatorlar orasidagi masofa, px |
onEndReached | () => void | — | Oxiriga yaqinlashganda (har uzunlik uchun bir marta) |
endReachedThreshold | number | 5 | Necha qator qolganda |
loadingMore | boolean | false | Pastda Spinner; bu paytda onEndReached chaqirilmaydi |
emptyState | ReactNode | — | Bo'sh ro'yxat ko'rinishi |
onScroll | ComponentProps<'div'>['onScroll'] | — | |
labels | { loadingMore?: string; empty?: ReactNode } | { loadingMore: 'Yuklanmoqda...', empty: "Ro'yxat bo'sh" } | |
listClassName | string | — | role="list" elementi klassi |
ref | Ref<VirtualListHandle> | — | Boshqaruv obyekti; DOM — handle.element |
VirtualListHandle
| Prop | Tip | Standart | Tavsif |
|---|---|---|---|
element | HTMLDivElement | null | — | Scroll konteyneri |
scrollToIndex | (index: number, options?: ScrollToOptions) => void | — | options: { align?: 'start' | 'center' | 'end' | 'auto', behavior? } |
scrollToOffset | (offset: number, options?: ScrollToOptions) => void | — | |
measure | () => void | — | O'lchamlar keshini tozalab, qatorlarni qayta o'lchaydi |
Klaviatura
Scroll konteyneri Tab bilan fokuslanadi (fokus halqasi ko'rinadi). Fokusda brauzerning odatiy
scroll tugmalari ishlaydi: ↑ ↓, PageUp / PageDown, Home / End, Space.
Accessibility
- Ro'yxat semantikasi: ichki element —
role="list", har bir qator —role="listitem".aria-setsize(jami) vaaria-posinset(o'rni) bilan — ekran o'quvchi "10 000 dan 5 000-element" deb o'qiydi, garchi DOM'da 20 ta qator bo'lsa ham. - Nom bering:
aria-labelyokiaria-labelledby— ro'yxatga beriladi. - Yuklanish:
loadingMorepaytida ro'yxatdaaria-busy, Spinner'ning yashirin matni (labels.loadingMore) o'qiladi. - Qator ichidagi interaktiv elementlar (tugma, havola) odatdagidek ishlaydi, lekin ekrandan
tashqaridagi qatorlar DOM'da yo'q — sahifa ichidagi qidiruv (
Ctrl+F) ularni topmaydi. Bunday holatda o'z qidiruv maydoningizni qo'shing.