Accessibility
role grid/table/treegrid 선택, 절대 좌표 노출, 정직한 상태 ARIA, 로케일 중립 정책, 키보드 내비게이션 레퍼런스.
설계 원칙 — 정직과 로케일 중립
이 그리드의 접근성은 두 가지 규율 위에 섭니다.
- 상태를 사실대로 노출한다.
aria-sort/aria-selected/aria-readonly는 내부 상태가 그러할 때 그러하게만 붙습니다. 프로그램적(외부 컨트롤) 정렬도 AT에 정직하게 전달되고, "정렬 가능하지만 아직 안 됨"(none)은 실제로 정렬을 배선한 헤더에만 붙습니다. - 로케일 문자열은 소유하지 않는다. 라이브러리는
aria-label·라이브 영역(announcement) 같은 사람이 읽는 문자열을 소유하지 않습니다. 그건 소비자의 언어·도메인이라 소비자 책임입니다.
이 문서는 그리드가 무엇을 노출하고, 소비자가 무엇을 채워야 하는지를 정리합니다.
role — grid · table · treegrid
Root는 role을 받아 그리드의 ARIA 역할을 정합니다. 명시하지 않으면 인터랙티브 여부로 기본값을 고릅니다.
// 명시 안 하면: selection 또는 editing이 주입돼 있으면 'grid', 아니면 'table'.
<DataGrid.Root table={table} selection={selection}>…</DataGrid.Root>
// 트리(계층) 데이터는 명시.
<DataGrid.Root table={table} selection={selection} role="treegrid">…</DataGrid.Root>기본값 규칙은 다음과 같습니다.
// Root 내부 (요지)
const keyboardActive = !!(selection?.enabled || editing?.enabled);
const role = roleProp ?? (keyboardActive ? 'grid' : 'table');grid— 셀 단위 키보드 내비게이션이 있는 인터랙티브 표. selection/editing이 주입되면 자동 선택됩니다.role="grid"는 포커스·화살표 내비 계약을 동반하므로, 그리드는 이때만tabIndex=0을 부여해 "포커스 불가능한role=grid"(역할과 동작 불일치, WCAG 2.1.1)를 피합니다.table— 정적 표. 셀 선택·편집이 없는 읽기 전용 그리드는 정적 표이므로table로 낮춥니다. 키보드 내비 계약이 없으니tabIndex도 붙지 않습니다. 셀role도 컨테이너를 따라 내려가,table이면cell·grid/treegrid이면gridcell이 됩니다.treegrid— 계층(부모/자식 행) 표. 트리 데이터는 소비자가 명시해야 합니다 — 자동으로 올라가지 않습니다. 계층 ARIA(아래)는 이 역할일 때만 부착됩니다.
읽기 전용 그리드에 굳이
role="grid"를 강제하면 포커스·화살표 계약을 충족할 책임이 생깁니다. 기본값이table로 떨어지는 건 그 거짓 계약을 피하기 위한 의도입니다.
Prop
Type
좌표 노출 — 절대 표시 위치 기준
가상화·페이지네이션이 걸리면 DOM에는 화면에 보이는 행만 존재합니다. 그래서 AT가 "전체 N행 중 X행"을 정확히 안내하려면, 각 행/셀이 현재 표시되는 절대 위치를 직접 선언해야 합니다.
aria-rowcount— 전체 행 수에 헤더 행 수를 더한 값. 헤더도aria-rowindex공간에 참여하기 때문입니다. 서버 페이지네이션(manualPagination)이면options.rowCount를, 클라이언트면 전체 행 수를 씁니다.aria-colcount— 보이는 leaf 컬럼 수.aria-rowindex(행) — 1-based, 헤더 포함 전체 기준. 헤더가1..H를 점유하므로 데이터 행은H만큼 offset됩니다. 진실의 출처는 TanStack의row.index가 아니라 현재 보이는 행 목록(getRowModel().rows)에서의 위치입니다.Body가 자동 렌더 시rowIndex로 넘기고(가상화=virtualItem.index, 일반=map 인덱스),Row가 헤더 offset과 페이지 offset을 더해 절대 좌표를 만듭니다.aria-colindex(셀/헤더) — 1-based, 보이는 leaf 컬럼 기준.
// Row 내부 — 절대 행 좌표 = 헤더 행수 + 페이지 시작 + 페이지 내부 위치 + 1
const pageOffset = isPaginated && pagination ? pagination.pageIndex * pagination.pageSize : 0;
const ariaRowIndex = rowIndex !== undefined ? headerRowCount + pageOffset + rowIndex + 1 : undefined;왜 row.index를 안 쓰나. TanStack의 row.index는 형제/원본 기준이라 정렬(역순)·필터(구멍·rowcount 초과)·트리(중복)에서 표시 위치와 어긋납니다. 페이지 offset을 더하지 않으면 2페이지부터 aria-rowindex가 H+1..로 재시작해 aria-rowcount(전체)와 모순됩니다. 그래서 그리드는 표시 위치를 직접 계산합니다.
render-prop으로
Row를 직접 그릴 때:rowIndex를 생략하면aria-rowindex도 생략됩니다. 비가상화면 브라우저가 DOM 순서로 산출하므로 괜찮습니다. 가상화 + render-prop이면 화면 밖 행이 DOM에 없으니 소비자가rowIndex를 넘겨야 위치 안내가 유지됩니다.
// 가상화 render-prop에서는 rowIndex를 넘긴다.
<DataGrid.Body>
{(row, rowIndex) => <DataGrid.Row row={row} rowIndex={rowIndex}>…</DataGrid.Row>}
</DataGrid.Body>정직한 상태 ARIA — sort · selected · readonly
aria-sort — 상태는 정직, 안내는 인터랙티브만
HeaderCell은 정렬 상태를 두 규율로 노출합니다.
- 실제 정렬됨(
asc/desc)은 배선 여부와 무관하게 노출.column.getIsSorted()가 참이면 외부 컨트롤로 프로그램적 정렬을 했더라도aria-sort="ascending"/"descending"이 붙습니다. 정렬이 일어난 사실은 어떻게 일으켰든 AT에 알려야 하기 때문입니다. aria-sort="none"("정렬 가능하나 미정렬" 안내)은 인터랙티브 헤더에만. 정렬 capability(getCanSort)와 실제 배선(소비자가 붙인onClick)이 둘 다 있을 때만 붙습니다.
// 정렬을 AT에 안내하려면 onClick을 명시 배선한다(자동 부착 없음).
<DataGrid.HeaderCell header={header} onClick={header.column.getToggleSortingHandler()}>
채널 <DataGrid.SortArrow direction={…} />
</DataGrid.HeaderCell>getToggleSortingHandler()는 canSort=false여도 no-op 함수를 반환합니다. onClick 존재만으로 인터랙티브 여부를 알 수 없으므로, getCanSort 게이트가 그 거짓 affordance를 차단합니다 — 미배선 헤더에 aria-sort="none"·tabIndex·키보드 활성이 붙지 않습니다. placeholder 헤더(그룹 없는 컬럼의 상단 자리)는 폭 유지용 chrome일 뿐이라 role="presentation" + aria-hidden으로 시맨틱을 통째로 억제해 AT 이중 안내를 막습니다.
aria-selected — 선택 경계
선택이 활성이면 모든 데이터 셀이 aria-selected boolean을 노출합니다 — 범위 안은 true, 밖은 false. 미선택 셀에도 false를 달아 AT가 선택 경계를 일관되게 안내합니다(APG grid 권고). 선택이 비활성(read-only)이면 selection 시맨틱 자체가 없으므로 생략합니다. 선택된 행에는 Row가 aria-selected를 붙입니다.
aria-readonly — 잠긴 셀
편집 가능한 그리드에서 이 데이터 셀이 편집 불가일 때(컬럼 meta.editable: false 또는 isCellEditable veto로 잠금) aria-readonly가 붙습니다. control/skeleton 셀은 제외입니다. 같은 판정이 CSS의 [data-cell-readonly](muted 스타일)로도 노출됩니다.
treegrid — 계층 ARIA는 이 역할에서만
계층 ARIA는 role="treegrid"일 때만 Row에 additive로 부착됩니다. grid/table이면 전부 undefined라 기존 동작과 동일합니다.
| 속성 | 의미 | 부착 조건 |
|---|---|---|
aria-level | 행의 깊이(1-based, row.depth + 1) | treegrid의 모든 행 |
aria-expanded | 확장 상태 | 확장 가능한 행만(row.getCanExpand()) — leaf 행은 미부착 |
aria-posinset | 형제 집합 내 1-based 위치 | 중첩 행만(부모가 있는 행) |
aria-setsize | 형제 집합의 크기 | 중첩 행만(부모 subRows.length) |
// Row 내부 (요지)
const isTreegrid = role === 'treegrid';
const canExpand = isTreegrid && row.getCanExpand();
const treeParent = isTreegrid ? row.getParentRow() : undefined;
// 'aria-level': isTreegrid ? row.depth + 1 : undefined
// 'aria-expanded': canExpand ? isExpanded : undefined // 확장 가능 행만
// 'aria-posinset': treeParent ? row.index + 1 : undefined // 중첩 행만
// 'aria-setsize': treeParent ? treeParent.subRows.length : undefined왜 역할로 게이팅하나. aria-level/aria-expanded/aria-posinset/aria-setsize는 treegrid 역할의 ARIA 계약입니다. role="grid"나 "table"에 이 속성을 얹으면 AT는 표를 트리로 오해합니다. 그래서 계층 ARIA는 소비자가 role="treegrid"를 명시한 경우에만 켜집니다 — 평면 그리드는 무영향입니다.
루트(최상위) 행에는
aria-posinset/aria-setsize를 부착하지 않습니다. 루트 형제 수는 행마다O(n)계산이 필요한데,aria-rowindex+aria-level=1로 위치가 충분히 안내되기 때문입니다.
로케일 중립 정책 — 문자열은 소비자 소유
라이브러리는 사람이 읽는 AT 문자열을 소유하지 않습니다.
aria-label은 소비자 책임. 컬럼 헤더·액션 버튼·아이콘의 레이블은 소비자의 언어·도메인입니다. 영어 기본값이 있는 곳(예: 일부 컴포넌트의 기본 레이블)은 소비자가 override합니다. 그리드는 레이블을 강제하지 않습니다.- 중앙 라이브 영역(announcement) 없음. 그리드는 "3개 행 선택됨", "로딩 중", "결과 없음" 같은 상태 안내를 발화하지 않습니다. 그 문장은 로케일 특정이라, 소비자가
Empty/Loading/Error슬롯 콘텐츠에 자신의aria-live영역을 붙여 자기 로케일로 안내합니다.Root는aria-busy(로딩 시) 같은 불리언 상태만 노출합니다 — 문장이 아니라 신호입니다.
// 상태 안내 문구는 소비자가 슬롯 안에서 자기 로케일로.
<DataGrid.Empty>
<div role="status" aria-live="polite">검색 결과가 없습니다</div>
</DataGrid.Empty>자세한 뷰 상태 슬롯은 View state를 참고하세요.
키보드 내비게이션 레퍼런스
selection 또는 editing이 주입되면 Root가 tabIndex=0을 받아 키 이벤트를 처리합니다. 모델은 roving tabindex가 아니라 aria-activedescendant 입니다 — 포커스는 Root에 유지되고, 활성 셀의 DOM id를 aria-activedescendant로 가리킵니다(가상화로 활성 셀이 언마운트되면 유령 IDREF를 막기 위해 자동 폴백).
활성 셀이 아직 없거나(첫 포커스) 가상화로 언마운트돼 aria-activedescendant가 비면, 활성 셀 outline 대신 컨테이너 자체에 focus ring을 그려 키보드 포커스 위치를 드러냅니다(WCAG 2.4.7). active 셀이 보이면 다시 셀 outline이 맡고 컨테이너 ring은 숨습니다.
내비게이션은 셀 선택(useDataGridSelection)이 있어야 동작하고, 편집 진입(F2/Enter/typing)·클립보드·undo/redo는 편집(useDataGridEditing)·채우기(useDataGridFillHandle)가 주입돼야 동작합니다. 미주입 기능의 단축키는 그리드가 소비하지 않고 브라우저/소비자에 양보합니다.
| 키 | 동작 | 필요 기능 |
|---|---|---|
↑ ↓ ← → | 활성 셀을 한 칸 이동 | selection |
Shift + 화살표 | 선택 범위를 확장 | selection |
Cmd/Ctrl + 화살표 | 데이터 블록 경계로 점프(Excel) | selection |
Shift + Cmd/Ctrl + 화살표 | 블록 경계까지 확장 | selection |
Tab / Shift+Tab | 다음/이전 셀, 행 끝에서 wrap(확장 안 함) | selection |
Home / End | 행의 첫/마지막 셀 | selection |
Cmd/Ctrl + Home / End | 그리드의 좌상단/우하단 셀 | selection |
PageUp / PageDown | 한 화면(viewport)만큼 세로 점프 | selection |
Cmd/Ctrl + A | 전체 선택 | selection |
Esc | 복사 점선이 있으면 점선 취소, 없으면 선택 해제 | selection |
F2 / Enter | 활성 셀 편집 진입 | editing |
| 출력 가능 문자(IME 포함) | 편집 진입 + 첫 글자 prefill | editing |
Esc (편집 중) | 편집 취소 | editing |
Cmd/Ctrl + C | 선택 범위 복사(TSV) | selection |
Cmd/Ctrl + V | 붙여넣기(타일링) | editing |
Cmd/Ctrl + X | 잘라내기(시트식: 붙여넣을 때 비움) | selection + editing |
Backspace / Delete | 선택된 모든 범위 비움 | selection + editing |
Cmd/Ctrl + Z | 실행 취소 | editing |
Cmd/Ctrl + Shift + Z · Cmd/Ctrl + Y | 다시 실행 | editing |
Cmd/Ctrl + D / R | 선택 범위를 아래/오른쪽으로 채움 | fillHandle |
- 화살표·
Tab은 데이터 컬럼 공간에서만 이동합니다 — control(체크박스 등) 컬럼은 건너뜁니다. 병합 셀은 "하나의 셀"로 취급해 통째로 건너뛰거나 anchor로 snap합니다. Esc는 편집 중이면 편집 취소가 우선이고, 편집이 아니면서 복사/잘라내기 점선이 떠 있으면 점선부터 취소합니다(Sheets 동작) — 선택은 건드리지 않습니다.Cmd/Ctrl+D/R·Z/Y는 실제로 처리할 게 있을 때만preventDefault합니다 — 채울 게 없거나 history가 비면 브라우저 단축키(북마크·새로고침·undo)에 양보합니다.- 편집 중에는
Esc를 제외한 모든 키가 소비자 에디터의onKeyDown으로 위임됩니다. 그리드는 진입(F2/Enter/typing)과 취소(Esc)만 소유하고, 입력 자체는 에디터가 처리합니다.
포털(Popover·Menu·Modal)로 띄운 입력에서 친 키는 React 트리를 따라
Root까지 bubble되지만, 그리드는 포털 자손 이벤트를 가로채지 않고 입력의 native 처리에 맡깁니다(편집 중Esc만 예외 — 편집 종료는 시스템 책임). 자세한 seam은 Extending을 참고하세요.
WCAG 매핑
| 기준 | 그리드가 충족하는 방식 |
|---|---|
| 1.3.1 Info and Relationships | role grid/table/treegrid + aria-rowcount/aria-colcount/aria-rowindex/aria-colindex로 표 구조와 좌표를 노출. treegrid는 aria-level/aria-posinset/aria-setsize로 계층 전달. |
| 2.1.1 Keyboard | selection/editing 주입 시 모든 셀 내비·편집·클립보드를 키보드로 수행(위 표). 정렬 인터랙티브 헤더는 Enter/Space로 활성. |
| 2.4.3 Focus Order | aria-activedescendant 모델 — 포커스는 Root에 유지되고 활성 셀을 IDREF로 가리켜 논리적 순서 유지. |
| 2.4.7 Focus Visible | 포커스 가능한 요소에 outline 기반 focus-visible. 셀 선택 모드는 활성 셀 outline으로, 활성 셀이 없으면(첫 포커스·가상화 언마운트) 컨테이너 ring으로 포커스 위치를 보장. |
| 4.1.2 Name, Role, Value | 정직한 role + aria-sort/aria-selected/aria-readonly 상태 노출. 가상화로 활성 셀이 사라지면 aria-activedescendant를 폴백해 유령 IDREF 방지. |
1.1.1(Non-text Content), 3.3.x(입력 레이블/에러 안내) 등 사람이 읽는 문자열을 요구하는 기준은 로케일 중립 정책상 소비자 책임입니다. 그리드는 구조·역할·상태를 제공하고, 소비자가
aria-label·라이브 영역으로 의미를 채웁니다.
다음 단계
- View state — Empty/Loading/Error 슬롯과 소비자 라이브 영역.
- Extending —
onCellKeyDown/onCellPointerDownveto로 키/포인터 동작 재정의. - Selection model — 좌표 모델과 내비게이션 정책 seam.