표 운영
표(Table) 위에 얹는 거르기 · 보기 · 일괄 작업 · 열 다루기입니다. 위에서부터 저장한 보기 → 툴바 → 필터 칩 → 표 순서로 둡니다.
표 엔진을 들이지 않습니다. 거르기 · 정렬 · 열 · 너비 · 고르기 상태는 앱이 가지고, 이 묶음은 그 상태를 보이고 바꾸는 부품입니다. 행을 고르면 툴바가 같은 자리 · 같은 높이(44)에서 일괄 바로 바뀌어 표가 흔들리지 않습니다. 좁은 화면(40rem 미만)에서는 툴바가 검색 + 필터 하나로 줄고, 칩은 가로로 스크롤되며, 일괄 바는 화면 바닥에 붙습니다. radix-ui 가 필요합니다.
- import
- import { TableToolbar, BulkBar, FilterChips, SavedViews, ColumnMenu, SortHeader, ColumnResizer, EditableCell } from "@uyoolabs/ui/table";
- 쓰는 곳
- 작업 표 · 결제 내역 · 멤버 관리 · 감사 로그처럼 거르고 정렬하고 여러 행을 한 번에 다루는 목록. 1,000행을 넘으면 VirtualTable(./virtual)과 함께 씁니다.
- 쓰지 않는 경우
- 행이 열 개 남짓이고 거를 일이 없는 표(설정 · 요금제 비교)에는 두지 않습니다. 저장한 보기를 탭(role=tablist)으로 만들지 않습니다 — 보기는 주소라 링크입니다. 되돌릴 수 없는 일괄 작업(지우기)을 일괄 바에서 바로 실행하지 않고 AlertDialog 를 거칩니다. 빨강은 셀 오류와 되돌릴 수 없는 일에만 씁니다.
- 상태 표시
- 바뀐 보기는 점 + ‘바뀜’, 셀 오류는 경계 + 아이콘 + 문장, 고른 행은 체크박스가 말하고 틴트가 거듭니다. 색만으로 말하지 않습니다.
- 알려진 한계
- 칩 편집기는 지금 기본 목록 · 입력칸입니다(Combobox · DatePicker · Slider 가 들어오면 바꿉니다). 툴바 끝 단추가 넘칠 때 ⋯ 메뉴로 접는 일은 아직 하지 않습니다 — 좁은 화면에서는 끝 단추를 숨기므로 필터 시트 안에 둡니다. 셀 이동이 켜진 표는 APG Data Grid 지만 Table 이 role=table 을 고정해 셀 포커스 · 화살표 이동만 제공합니다.
한 벌
저장한 보기 · 툴바 · 필터 칩 · 정렬 머리 · 열 메뉴 · 너비 · 셀 편집 · 일괄 바
| 환불 정책 문구 검토 | PAY-139 | 검토 대기 | 이도현 | 2026-10-23 | |
| PG 웹훅 서명 검증 | PAY-142 | 진행 중 | 한서윤 | 2026-10-24 | |
| 결제 실패 재시도 큐 | PAY-137 | 진행 중 | 박지민 | 2026-10-25 |
| 키 | 동작 |
|---|---|
| Enter | 머리: 그 열 하나로 정렬(오름 → 내림 → 풂) |
| Shift+Enter | 머리: 정렬을 더한다(순위 숫자) |
| Alt+↓ | 머리: 열 메뉴를 연다 |
| ←·→ | 너비 손잡이: 16px. Shift 와 함께면 64px. 최소 64 |
| Enter·Enter | 너비 손잡이: 두 번 누르면 내용에 맞춤(두 번 누름도 같다) |
| Space | 열 메뉴: 열 보이기 · 숨기기(메뉴는 열어 둔다) |
| Alt+↑·Alt+↓ | 열 메뉴: 순서 바꾸기(끌기의 대안). 고정 열 앞으로는 가지 않는다 |
| Enter·Space | 거르기 칩: 값 편집 팝오버를 연다 |
| Backspace·Delete | 거르기 칩: 그 거르기를 푼다. 포커스는 다음 칩으로 |
| ←·→·↑·↓ | 편집 셀: 셀 사이를 옮긴다(Home · End 는 행의 처음 · 끝) |
| Enter·F2 | 편집 셀: 편집 시작(읽기 전용이면 아무 일 없음) |
| Enter | 셀 편집 중: 저장. 조합 중(한 · 일 IME) Enter 는 확정만 한다 |
| Esc | 셀 편집 중: 취소하고 셀로. 조합 중 Esc 는 조합만 취소한다 |
| Tab·Shift+Tab | 셀 편집 중: 저장하고 오른쪽 · 왼쪽 편집 셀로 |
늦게 온 응답 버리기
거르기를 빠르게 바꾸면 앞 조건의 응답이 뒤에 올 수 있습니다. useLatestRequest 가 앞 요청을 끊고 늦은 응답을 버립니다.
화면 없는 훅입니다. 요청마다 begin() 을 부르고, 응답을 쓰기 전에 isLatest() 를 봅니다.
props
소스의 타입과 JSDoc 에서 생성한 표입니다. 컴포넌트를 고치면 pnpm gen 으로 다시 만듭니다.
TableToolbar
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| search | ReactNode | — | 보통 <Input size="sm" type="search"> 240px |
| filters | ReactNode | — | ‘필터 3’ secondary sm(개수 칩) |
| resultCount | number | — | ‘결과 9개’. polite 라이브 영역이 입력이 멈추고 500ms 뒤 한 번 읽는다 |
| actions | ReactNode | — | 끝 자리 ghost sm(정렬 · 열 · 밀도 · 내보내기). 좁은 화면(40rem 미만)에서는 숨는다 — 필터 시트 안에 둔다 |
| selection | BulkBarProps | — | count > 0 이면 툴바 대신 일괄 바를 같은 자리에 그린다 |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<TableOpsLabels> | — |
이 밖에 <div> props(children 제외)를 그대로 받습니다.
BulkBar
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| count필수 | number | — | 고른 수 |
| pageCount | number | — | 이 페이지의 행 수. count 와 같으면 ‘전체 모두 고르기’ 안내가 나온다 |
| total | number | — | 전체 수(‘작업 240개 모두 고르기’). pageCount 보다 클 때만 안내한다 |
| noun | string | — | 무엇을 골랐는지(‘작업’). 일괄 바 이름 · 모두 고르기 문구에 들어간다 |
| allSelected | boolean | false | 다른 페이지 행까지 모두 고른 상태. 앱이 onSelectAll 뒤에 켠다 |
| onSelectAll | () => void | — | ‘전체 t개 모두 고르기’. 없으면 안내 글만 보인다 |
| onClear필수 | () => void | — | ‘고르기 풀기’ |
| actions필수 | ReactNode | — | secondary sm 단추들. 되돌릴 수 없는 일(지우기)은 AlertDialog 를 거친다 |
| returnFocusRef | RefObject<HTMLElement | null> | — | 고르기를 푼 뒤 포커스를 돌려줄 곳(마지막으로 고른 행). 없으면 포커스가 body 로 떨어진다 |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<TableOpsLabels> | — | |
| className | string | — |
FilterChips
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| fields필수 | FilterField[] | — | |
| value필수 | FilterValue | — | 제어. 값이 없는 키(undefined · 빈 글자 · 빈 배열)는 칩을 그리지 않는다 |
| onChange필수 | (next: FilterValue) => void | — | |
| locked | string[] | [] | 저장한 보기에 묶인 키 → 읽기 전용 칩(× 없음, ‘모두 지우기’ 에도 남는다) |
| invalid | string[] | [] | 값이 사라진 키(탈퇴한 멤버) → 오류 색 칩 + sr 꼬리 |
| maxValues | number | 1 | 값 이름을 몇 개까지 잇는가. 기본 1 — ‘한서윤 외 2개’ |
| openKey | string | null | — | 열어 둘 칩(제어). 표 머리의 ‘이 열로 거르기’ 가 그 칩의 편집기를 연다 |
| onOpenKeyChange | (key: string | null) => void | — | |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<TableOpsLabels> | — | |
| className | string | — |
SavedViews
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| views필수 | SavedView[] | — | |
| current필수 | string | — | 지금 보기의 id |
| dirty | boolean | false | 지금 거르기 · 정렬 · 열이 보기와 다르다 → ‘바뀜’ 점 + 되돌리기 · 저장 |
| onSave | () => void | — | 지금 상태로 이 보기를 덮어쓴다 |
| onSaveAs | (name: string) => void | — | 지금 상태를 새 보기로 저장한다. 이름은 팝오버에서 받는다 |
| onRevert | () => void | — | 보기의 저장된 상태로 되돌린다 |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<TableOpsLabels> | — | |
| className | string | — |
ColumnMenu
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| columns필수 | ColumnDef[] | — | |
| visible필수 | string[] | — | 보이는 열 id |
| order필수 | string[] | — | 열 순서(모든 열 id) |
| onChange필수 | (next: { visible: string[]; order: string[] }) => void | — | |
| onReset | () => void | — | ‘기본 열로 되돌리기’ |
| open | boolean | — | 메뉴 열림(제어). 보통은 비제어로 둔다 |
| defaultOpen | boolean | — | |
| onOpenChange | (open: boolean) => void | — | |
| modal | boolean | — | 기본 true — 열린 동안 메뉴 밖을 읽기 프로그램에서 가린다. 연 채로 고정해 보이는 자리(문서 · 픽스처)는 false |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<TableOpsLabels> | — |
SortHeader
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| align | "right" | "center" | — | 글 정렬. 숫자 열은 num 을 쓴다 |
| num | boolean | — | 숫자 · 금액 · 날짜: 오른쪽 정렬 · 고정폭 숫자 |
| column필수 | ColumnDef | — | |
| sort | ColumnSort | — | 이 열의 정렬. 없으면 정렬하지 않은 열 |
| sortCount | number | sort ? 1 : 0 | 정렬된 열 수. 둘 이상이면 순위 숫자를 보이고 이름에 ‘2순위’ 를 더한다 |
| onSort | (dir: SortDirection | null, additive: boolean) => void | — | 없으면 정렬할 수 없는 머리(글자만) |
| menu | | Omit< ColumnHeaderMenuProps, "column" | "sort" | "sortCount" | "onSort" | "lang" | "labels" | "open" | "onOpenChange" > | false | — | ⋯ 메뉴의 항목. 하나라도 주면(또는 onSort 가 있으면) 메뉴가 생긴다 |
| width | number | — | 열 너비(px). onResize 와 함께 주면 오른쪽 경계에 너비 손잡이가 생긴다 |
| minWidth | number | — | |
| maxWidth | number | — | |
| onResize | (width: number) => void | — | |
| onAutoFit | () => void | — | |
| resizing | boolean | — | 너비 손잡이를 끄는 중으로 그린다(스펙 · 픽스처에서 상태를 고정해 보일 때) |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<TableOpsLabels> | — | |
| extra | ReactNode | — | 머리 글자 뒤에 붙일 것(도움말 아이콘 등) |
이 밖에 <th> props(children · onResize 제외)를 그대로 받습니다.
ColumnHeaderMenu
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| column필수 | ColumnDef | — | |
| sort | ColumnSort | — | |
| sortCount | number | sort ? 1 : 0 | 정렬된 열 수. 둘 이상이면 메뉴에 ‘지금 n순위’ 를 적는다 |
| onSort필수 | (dir: SortDirection | null, additive: boolean) => void | — | dir 가 null 이면 이 열의 정렬을 뺀다. additive 는 다른 열의 정렬을 남긴다 |
| onFilter | () => void | — | |
| onPin | () => void | — | |
| onHide | () => void | — | |
| open | boolean | — | |
| onOpenChange | (open: boolean) => void | — | |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<TableOpsLabels> | — |
ColumnResizer
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| label필수 | string | — | 열 이름. 손잡이 이름이 ‘작업 열 너비’ 가 된다 |
| width필수 | number | — | 지금 너비(px) |
| min | number | 64 | 기본 64 |
| max | number | — | |
| onResize필수 | (width: number) => void | — | 끄는 동안 · 키를 누를 때마다 부른다. 앱이 너비를 가진다 |
| onAutoFit | () => void | — | 두 번 누름(또는 Enter 두 번) — 내용에 맞춤 |
| resizing | boolean | — | 끄는 중으로 그린다(스펙 · 픽스처에서 상태를 고정해 보일 때) |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<TableOpsLabels> | — | |
| className | string | — |
EditableCell
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| align | "right" | "center" | — | 글 정렬. 숫자 열은 num 을 쓴다 |
| num | boolean | — | 숫자 · 금액 · 날짜: 오른쪽 정렬 · 고정폭 숫자 |
| mono | boolean | — | ID · 해시 같은 값. 고정폭 12.5 · 보조 색 |
| value필수 | T | — | |
| editor | "text" | "select" | "date" | ((props: CellEditorProps<T>) => ReactNode) | "text" | text · date 는 셀 자리의 sm 입력칸, select 는 기본 목록이다. 고르는 값(담당 · 상태)을 Combobox 로 열려면 함수로 편집기를 넘긴다 |
| options | TableOption[] | [] | select 의 값들 |
| validate | (value: T) => string | undefined | — | 오류 문장을 돌려주면 저장하지 않고 편집을 유지한다 |
| onSave필수 | (next: T) => void | Promise<void> | — | 한 번만 부른다(Enter 직후의 blur 로 두 번 저장하지 않는다). 거절하면 편집을 유지하고 오류를 보인다 |
| readOnly | boolean | false | 권한 없음 — 셀로 옮겨 다닐 수는 있고 편집만 안 된다 |
| label필수 | string | — | 편집칸의 이름(‘PAY-142 작업 이름’) |
| editing | boolean | — | 편집 중(제어) |
| defaultEditing | boolean | false | 처음부터 편집 중(비제어) |
| onEditingChange | (editing: boolean) => void | — | |
| error | string | — | 앱이 아는 오류(서버 검증). 주면 편집칸 아래 뜬 판에 보인다 |
| saving | boolean | — | 앱이 저장을 들고 있을 때(제어). 셀 끝 스피너 + aria-busy |
| lang | "ko" | "en" | "ja" | "ko" | |
| labels | Partial<TableOpsLabels> | — | |
| children필수 | ReactNode | — | 보기 |
이 밖에 <td> props(children · onChange 제외)를 그대로 받습니다.