가상 스크롤
수천 행의 목록 · 표에서 보이는 행과 위아래 10행만 그립니다. @tanstack/react-virtual 이 필요하고, 그 API 는 밖으로 내보내지 않습니다.
화면 밖 행이 DOM 에 없어도 위치를 읽게 표에는 aria-rowcount · aria-rowindex, 목록에는 aria-setsize · aria-posinset 을 답니다. 포커스된 행은 스크롤로 화면 밖에 나가도 그대로 남아 포커스가 떨어지지 않습니다. 스크롤하는 동안 위치 알약(1,201–1,250 / 12,480)이 1초 보이고, 읽기 프로그램에는 읽히지 않습니다. 줄인 모션에서는 scrollToIndex 가 부드럽게 넘기지 않고 바로 갑니다.
- import
- import { VirtualList, VirtualTable } from "@uyoolabs/ui/virtual";
- 쓰는 경우
- 결제 내역 · 감사 로그 · 검색 결과처럼 1,000행이 넘는 목록. 행 높이가 고정이어야 합니다(셀 글은 한 줄 말줄임). 표는 열 너비가 흔들리지 않게 Th 에 너비를 줍니다.
- 쓰지 않는 경우
- 1,000행 아래는 페이지 나누기(Pagination)가 낫습니다 — 브라우저 찾기 · 인쇄 · 위치 기억이 됩니다. 높이가 바뀌는 행(메시지)은 받지 않습니다. 브라우저 찾기(⌘F)는 그린 행만 찾으니 표 위 검색칸을 함께 둡니다.
표 — 12,480행
머리는 위에 붙고(층 10), 아직 오지 않은 페이지는 300ms 뒤 뼈대 줄로 자리를 지킵니다.
| # | 결제 | 금액 |
|---|---|---|
| 1 | 구독 갱신 · 워크스페이스 2 | ₩84,000 |
| 2 | 구독 갱신 · 워크스페이스 3 | ₩84,137 |
| 3 | 구독 갱신 · 워크스페이스 4 | ₩84,274 |
| 4 | 구독 갱신 · 워크스페이스 5 | ₩84,411 |
| 5 | 구독 갱신 · 워크스페이스 6 | ₩84,548 |
| 6 | 구독 갱신 · 워크스페이스 7 | ₩84,685 |
| 7 | 구독 갱신 · 워크스페이스 8 | ₩84,822 |
| 8 | 구독 갱신 · 워크스페이스 9 | ₩84,959 |
| 9 | 구독 갱신 · 워크스페이스 10 | ₩85,096 |
| 10 | 구독 갱신 · 워크스페이스 11 | ₩85,233 |
| 11 | 구독 갱신 · 워크스페이스 12 | ₩85,370 |
| 12 | 구독 갱신 · 워크스페이스 13 | ₩85,507 |
| 13 | 구독 갱신 · 워크스페이스 14 | ₩85,644 |
| 14 | 구독 갱신 · 워크스페이스 15 | ₩85,781 |
| 15 | 구독 갱신 · 워크스페이스 16 | ₩85,918 |
| 16 | 구독 갱신 · 워크스페이스 17 | ₩86,055 |
| 17 | 구독 갱신 · 워크스페이스 18 | ₩86,192 |
| 18 | 구독 갱신 · 워크스페이스 19 | ₩86,329 |
| 19 | 구독 갱신 · 워크스페이스 20 | ₩86,466 |
| 20 | 구독 갱신 · 워크스페이스 21 | ₩86,603 |
목록 — 커서 ‘더 보기’
끝 근처에서 onEndReached 를 한 번 부릅니다.
- 감사 로그 1 · 결제 설정을 바꿨습니다
- 감사 로그 2 · 결제 설정을 바꿨습니다
- 감사 로그 3 · 결제 설정을 바꿨습니다
- 감사 로그 4 · 결제 설정을 바꿨습니다
- 감사 로그 5 · 결제 설정을 바꿨습니다
- 감사 로그 6 · 결제 설정을 바꿨습니다
- 감사 로그 7 · 결제 설정을 바꿨습니다
- 감사 로그 8 · 결제 설정을 바꿨습니다
- 감사 로그 9 · 결제 설정을 바꿨습니다
- 감사 로그 10 · 결제 설정을 바꿨습니다
- 감사 로그 11 · 결제 설정을 바꿨습니다
- 감사 로그 12 · 결제 설정을 바꿨습니다
- 감사 로그 13 · 결제 설정을 바꿨습니다
- 감사 로그 14 · 결제 설정을 바꿨습니다
- 감사 로그 15 · 결제 설정을 바꿨습니다
- 감사 로그 16 · 결제 설정을 바꿨습니다
키보드
행 포커스가 있을 때입니다.
| 키 | 동작 |
|---|---|
| Tab | 목록에 들어온다. 로빙 포커스라 Tab 정지점은 행 하나다 |
| ↑·↓ | 이전 · 다음 행. 화면 밖이면 먼저 스크롤해 그린 뒤 포커스 |
| PageUp·PageDown | 한 화면만큼 이동 |
| Home·End·⌘/Ctrl+Home·⌘/Ctrl+End | 처음 · 마지막 행(그리지 않은 끝도 스크롤해서 간다) |
props
소스의 타입과 JSDoc 에서 생성한 표입니다. 컴포넌트를 고치면 pnpm gen 으로 다시 만듭니다.
VirtualTable
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| items | readonly T[] | — | 받은 행. count · getItem 을 쓰면 비워 둔다 |
| count | number | — | 서버가 아는 전체 수(aria-setsize · aria-rowcount). 없으면 items 길이 |
| getItem | (index: number) => T | undefined | — | 아직 오지 않은 행은 undefined 를 돌려준다 — 뼈대가 그 자리를 지킨다 |
| getKey필수 | (item: T, index: number) => string | — | 행의 안정된 키(React key) |
| rowHeight필수 | number | — | 고정 높이(44 · 촘촘 34). 늘었다 줄었다 하는 높이는 받지 않는다 |
| overscan | number | — | 보이는 행 위아래로 더 그려 둘 행 수 |
| renderSkeleton | (index: number) => ReactNode | — | 아직 오지 않은 행(getItem 이 undefined)의 뼈대. skeletonDelay 안에 오면 그리지 않는다 |
| skeletonDelay | number | — | 뼈대를 그리기 전 기다림(ms). 그 안에 온 행은 뼈대 없이 바로 그린다 |
| onRangeChange | (range: { start: number; end: number }) => void | — | 보이는 범위(0부터, 끝 포함)가 바뀔 때 — 서버 페이지를 불러온다 |
| onEndReached | () => void | — | 끝에서 rowHeight × 5 안쪽에 오면 한 번. count 가 늘면 다시 부를 수 있다(커서 ‘더 보기’) |
| loadingMore | boolean | — | 끝에서 더 불러오는 중. 끝에 뼈대 3줄을 두고 ‘더 불러오는 중’ 을 한 번 알린다 |
| scrollToIndex | number | — | 제어 — 이 행을 화면 안으로 옮긴다(고른 항목). 줄인 모션이면 부드럽게 넘기지 않는다 |
| initialScrollIndex | number | — | 처음 열 때 이 행에서 시작한다 |
| showPosition | boolean | "always" | — | 스크롤하는 동안 위치 알약을 1초 띄운다. "always" 는 늘 띄운다(문서 · 스냅샷) |
| height | number | string | — | 스크롤 영역 높이. 숫자는 px |
| empty | ReactNode | — | 행이 하나도 없을 때 그릴 것(Empty) |
| footer | ReactNode | — | 마지막 행 뒤의 줄(오류 줄 + 다시 시도 · 끝 표시) |
| aria-label필수 | string | — | 스크롤 영역(region)의 이름 — 필수 |
| className | string | — | |
| lang | "ko" | "en" | "ja" | — | |
| labels | Partial<VirtualLabels> | — | |
| head필수 | ReactNode | — | D-Table 의 Th 들. 머리는 스크롤해도 위에 붙어 있다(sticky, 층 10) |
| columns | number | — | 열 수. 기본 뼈대 줄 · 더 불러오는 줄이 열 전체를 덮는 데 쓴다 |
| renderRow필수 | (item: T, index: number) => ReactElement | — | 한 행. ./ui 의 <Tr> 를 돌려준다 — 부품이 aria-rowindex · 로빙 포커스를 그 행에 단다 |
VirtualList
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| items | readonly T[] | — | 받은 행. count · getItem 을 쓰면 비워 둔다 |
| count | number | — | 서버가 아는 전체 수(aria-setsize · aria-rowcount). 없으면 items 길이 |
| getItem | (index: number) => T | undefined | — | 아직 오지 않은 행은 undefined 를 돌려준다 — 뼈대가 그 자리를 지킨다 |
| getKey필수 | (item: T, index: number) => string | — | 행의 안정된 키(React key) |
| rowHeight필수 | number | — | 고정 높이(44 · 촘촘 34). 늘었다 줄었다 하는 높이는 받지 않는다 |
| overscan | number | — | 보이는 행 위아래로 더 그려 둘 행 수 |
| renderSkeleton | (index: number) => ReactNode | — | 아직 오지 않은 행(getItem 이 undefined)의 뼈대. skeletonDelay 안에 오면 그리지 않는다 |
| skeletonDelay | number | — | 뼈대를 그리기 전 기다림(ms). 그 안에 온 행은 뼈대 없이 바로 그린다 |
| onRangeChange | (range: { start: number; end: number }) => void | — | 보이는 범위(0부터, 끝 포함)가 바뀔 때 — 서버 페이지를 불러온다 |
| onEndReached | () => void | — | 끝에서 rowHeight × 5 안쪽에 오면 한 번. count 가 늘면 다시 부를 수 있다(커서 ‘더 보기’) |
| loadingMore | boolean | — | 끝에서 더 불러오는 중. 끝에 뼈대 3줄을 두고 ‘더 불러오는 중’ 을 한 번 알린다 |
| scrollToIndex | number | — | 제어 — 이 행을 화면 안으로 옮긴다(고른 항목). 줄인 모션이면 부드럽게 넘기지 않는다 |
| initialScrollIndex | number | — | 처음 열 때 이 행에서 시작한다 |
| showPosition | boolean | "always" | — | 스크롤하는 동안 위치 알약을 1초 띄운다. "always" 는 늘 띄운다(문서 · 스냅샷) |
| height | number | string | — | 스크롤 영역 높이. 숫자는 px |
| empty | ReactNode | — | 행이 하나도 없을 때 그릴 것(Empty) |
| footer | ReactNode | — | 마지막 행 뒤의 줄(오류 줄 + 다시 시도 · 끝 표시) |
| aria-label필수 | string | — | 스크롤 영역(region)의 이름 — 필수 |
| className | string | — | |
| lang | "ko" | "en" | "ja" | — | |
| labels | Partial<VirtualLabels> | — | |
| renderRow필수 | (item: T, index: number) => ReactNode | — | 한 행의 내용. 행 틀(role=listitem · 높이 · 포커스)은 부품이 두른다 |