데이터 테이블 UI 9종 — 열을 끌어 넓히고 셀을 골라 복사하는 표
데이터 테이블 ui는 줄과 칸으로 늘어놓은 값을 사람이 직접 만져서 다시 배열하고 좁히고 꺼내 가는 화면입니다. 아홉 개 전부 시늉이 아니라 실제로 값을 다시 정렬하고 문자열을 비교하며, 합계는 볼 때마다 그 자리에서 다시 더합니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 두 열을 한꺼번에 잡는 우선순위 정렬
- 02 헤더 경계를 끌어 넓히는 열 너비
- 03 머리를 들어 옮기는 열 순서 바꾸기
- 04 체크를 떼면 열이 사라지는 목록 메뉴
- 05 셀을 끌어 네모로 묶고 복사하기
- 06 열마다 따로 거르는 필터 알약
- 07 숫자 크기대로 칠해지는 셀 색과 막대
- 08 바닥에 붙어 다시 계산되는 합계 줄
- 09 화살표로 칸을 옮겨 다니는 포커스 테두리
순서는 표를 만드는 사람이 아니라 표를 읽는 사람이 막히는 순서입니다. 먼저 줄이 너무 많아 원하는 값이 어디 있는지 모르니 순서를 바꾸고(01), 글자가 잘려 보이니 칸을 넓히고(02), 자주 보는 열을 왼쪽으로 끌어 옵니다(03). 그래도 가로로 넘치면 안 보는 열을 아예 접습니다(04). 여기까지가 표의 모양을 손보는 일입니다. 다음 둘은 값을 꺼내는 일입니다. 필요한 칸만 네모로 묶어 가져가고(05), 조건을 걸어 줄 수를 줄입니다(06). 마지막 셋은 표가 사람에게 말을 거는 쪽입니다. 숫자가 큰지 작은지를 색으로 먼저 알리고(07), 아무리 스크롤해도 합계는 바닥에 남고(08), 마우스가 없어도 칸 사이를 걸어 다닐 수 있어야 합니다(09).
01두 열을 한꺼번에 잡는 우선순위 정렬
점수가 같은 줄이 두 개씩 나오면 한 열만으로는 순서가 안 정해집니다. 이 부품은 Shift 를 누른 채 다른 헤더를 누르면 그 열을 보조 기준으로 더하고, 헤더마다 1 과 2 라는 순번 배지를 붙여 어느 쪽이 먼저 읽히는지 보여 줍니다. 비교 함수는 키 배열을 앞에서부터 훑다가 값이 갈리는 첫 키에서 바로 결과를 돌려줍니다.
function msSort(key, add) {
var was = {};
[].forEach.call(msBody.children, function (el) { was[el.dataset.id] = el.getBoundingClientRect().top; });
var hit = null;
msKeys.forEach(function (k) { if (k.k === key) hit = k; });
if (hit) hit.dir = -hit.dir;
else msKeys = add ? msKeys.concat([{ k: key, dir: 1 }]).slice(-2) : [{ k: key, dir: -1 }];
MS.sort(function (a, b) {
for (var i = 0; i < msKeys.length; i++) {
var k = msKeys[i];
if (a[k.k] < b[k.k]) return -k.dir;
if (a[k.k] > b[k.k]) return k.dir;
}
return 0;
});
msRender(); msMark();
}
02헤더 경계를 끌어 넓히는 열 너비
열 폭을 정하는 값은 한 곳에만 있어야 합니다. 이 부품은 --w1 이라는 길이형 커스텀 속성 하나만 두고 모든 행이 그 값을 받아 쓰며, 끌고 가는 동안 표를 세로로 가로지르는 안내선도 같은 값으로 함께 움직입니다. 폭을 두 군데에 적어 두면 선과 칸이 반드시 어긋납니다.
@property --w1 {
syntax: "<length>";
inherits: true;
initial-value: 132px;
}
.cr__row {
display: grid;
grid-template-columns: var(--w1) 12px 1fr;
}
@keyframes cr-w {
0% { --w1: 132px; }
8% { --w1: 132px; }
34% { --w1: 180px; }
46% { --w1: 180px; }
72% { --w1: 100px; }
86% { --w1: 132px; }
100% { --w1: 132px; }
}
03머리를 들어 옮기는 열 순서 바꾸기
표를 행으로 짜면 열 하나를 통째로 옮기기가 어렵습니다. 그래서 이 부품은 열마다 세로 묶음을 하나씩 만들어 나란히 세워 두고, 잡힌 묶음만 16px 들어 올려 바탕을 한 단계 밝힙니다. 사이에 낀 열들은 방향에 따라 열 폭만큼 비켜서고, 놓일 자리에는 점선 홈이 남습니다.
function rdMove(e) {
var dx = e.clientX - rdAt;
rdEl.style.transform = 'translateX(' + dx + 'px)';
rdTo = Math.max(0, Math.min(rdList().length - 1, rdFrom + Math.round(dx / rdW)));
rdList().forEach(function (el, i) {
if (el === rdEl) return;
var shift = 0;
if (rdTo > rdFrom && i > rdFrom && i <= rdTo) shift = -rdW;
if (rdTo < rdFrom && i >= rdTo && i < rdFrom) shift = rdW;
el.style.transform = 'translateX(' + shift + 'px)';
});
}
04체크를 떼면 열이 사라지는 목록 메뉴
숨긴 열을 display: none 으로 지우면 남은 칸이 제멋대로 벌어집니다. 이 부품은 대신 행의 grid-template-columns 를 1fr 1fr 0fr 로 바꿔 그 칸만 0 으로 접고, 셀에는 min-width: 0 과 넘침 자르기를 걸어 글자가 새지 않게 합니다. 마지막 한 열은 끄지 못하게 막아 두었습니다.
function cvPaint() {
var live = 0, track = [];
['1', '2', '3'].forEach(function (k) { if (cvOn[k]) live++; track.push(cvOn[k] ? '1fr' : '0fr'); });
[].forEach.call(cvGrid.querySelectorAll('[data-col]'), function (el) {
el.classList.toggle('is-off', !cvOn[el.dataset.col]);
});
cv.style.setProperty('--t', track.join(' '));
cvBtn.querySelector('.cv__n3').textContent = String(live);
}
05셀을 끌어 네모로 묶고 복사하기
누른 칸이 기준점이고 지금 가리키는 칸이 반대 모서리입니다. 두 점의 행·열 최솟값과 최댓값이 곧 사각형이고, 선택 테두리는 칸 하나 크기의 요소를 scale(가로칸수, 세로칸수) 로 늘려 만듭니다. 손을 놓으면 행은 줄바꿈, 열은 탭으로 이어 붙여 클립보드로 넘깁니다.
function rsPaint(to) {
var r0 = Math.min(rsAnchor.r, to.r), r1 = Math.max(rsAnchor.r, to.r);
var c0 = Math.min(rsAnchor.c, to.c), c1 = Math.max(rsAnchor.c, to.c);
var sum = 0, n = 0;
rsCells().forEach(function (el) {
var r = +el.dataset.r, c = +el.dataset.c;
var inside = r >= r0 && r <= r1 && c >= c0 && c <= c1;
el.classList.toggle('is-sel', inside);
if (inside) { sum += +el.textContent; n++; }
});
rsBox.style.opacity = '1';
rsBox.style.transform = 'translate(' + (c0 * RS_W) + 'px, ' + ((r0 + 1) * RS_H) + 'px) scale(' + (c1 - c0 + 1) + ', ' + (r1 - r0 + 1) + ')';
rsFoot.querySelector('.rs__s0').textContent = n + '칸 · 합계 ' + sum;
}
06열마다 따로 거르는 필터 알약
조건이 두 개 이상 걸리면 사람은 곧 왜 줄이 줄었는지 잊습니다. 그래서 지금 걸린 조건을 표 위에 알약으로 쌓아 두고, 걸러진 줄은 지우지 않고 높이만 0 으로 접습니다. 행 사이 선을 border 대신 안쪽 그림자로 그린 것도 그래서입니다.
function mfApply() {
var q = [].slice.call(mf.querySelectorAll('.mf__in')).map(function (i) { return i.value.trim().toLowerCase(); });
[].forEach.call(mfBody.children, function (row) {
var keep = q.every(function (v, c) {
return !v || row.children[c].textContent.toLowerCase().indexOf(v) >= 0;
});
row.classList.toggle('is-out', !keep);
});
mfChips.innerHTML = q.map(function (v, c) {
return v ? '<span class="mf__chip is-live">' + MF_H[c] + ' ' + v + '</span>' : '';
}).join('');
}
07숫자 크기대로 칠해지는 셀 색과 막대
배경색과 막대 길이와 낱말 배지를 각각 따로 판단하면 셋이 반드시 어긋납니다. 여기서는 구간표를 배열로 한 번만 적고, 값을 넣으면 등급을 돌려주는 함수 하나가 세 가지를 모두 정합니다. 등급은 data-level 속성에 적히고 색은 그 속성 선택자만 봅니다.
var CF_BAND = [{ min: 80, level: 3, word: '최고' }, { min: 60, level: 2, word: '좋음' }, { min: 0, level: 1, word: '주의' }];
function cfGrade(v) {
for (var i = 0; i < CF_BAND.length; i++) { if (v >= CF_BAND[i].min) return CF_BAND[i]; }
return CF_BAND[CF_BAND.length - 1];
}
function cfPut(row, v) {
var cell = row.querySelector('.cf__num');
var badge = row.querySelector('.cf__badge');
var g = cfGrade(v);
cell.querySelector('i').textContent = String(v);
cell.querySelector('.cf__bar').style.setProperty('--v', String(v / 100));
cell.setAttribute('data-level', String(g.level));
badge.setAttribute('data-level', String(g.level));
badge.textContent = g.word;
}
08바닥에 붙어 다시 계산되는 합계 줄
합계 줄은 스크롤 상자 안쪽에 두고 position: sticky 로 바닥에 붙입니다. 상자 밖에 두면 그냥 아래에 놓인 줄일 뿐이라 스크롤과 아무 관계가 없습니다. 남은 스크롤 길이를 재서 2px보다 크면 그 줄 위에 위로 향하는 그림자를 켜고, 끝에 닿으면 끕니다.
function smSum() {
var picked = smRows().filter(function (r) { return r.classList.contains('is-pick'); });
var list = picked.length ? picked : smRows();
smAll.textContent = String(list.reduce(function (n, r) { return n + (+r.dataset.v); }, 0));
document.getElementById('sm-sum').classList.toggle('is-part', picked.length > 0);
}
function smShade() {
var left = smView.scrollHeight - smView.scrollTop - smView.clientHeight;
sm.classList.toggle('is-more', left > 2);
}
smView.addEventListener('scroll', smShade);
09화살표로 칸을 옮겨 다니는 포커스 테두리
아홉 칸을 전부 탭으로 훑게 만들면 표 하나가 키보드 사용자의 발목을 아홉 번 잡습니다. 그래서 지금 있는 칸만 탭 정지점이 되고 나머지는 전부 빠지며, 칸 사이 이동은 화살표 키가 맡습니다. 보이는 테두리는 칸마다 켜고 끄지 않고 요소 하나를 옮겨 미끄러지게 했습니다.
function kgGo(r, c) {
kgAt = { r: Math.max(0, Math.min(2, r)), c: Math.max(0, Math.min(2, c)) };
var i = kgAt.r * 3 + kgAt.c;
kgCells().forEach(function (el, n) { el.tabIndex = n === i ? 0 : -1; });
kgRing.style.transform = 'translate(' + (kgAt.c * KG_W) + 'px, ' + ((kgAt.r + 1) * KG_H) + 'px)';
kgRing.style.opacity = '1';
kgCells()[i].focus({ preventScroll: true });
}
kgGrid.addEventListener('keydown', function (e) {
var K = { ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0] }[e.key];
if (K) { e.preventDefault(); kgGo(kgAt.r + K[0], kgAt.c + K[1]); return; }
if (e.key === 'Home') { e.preventDefault(); kgGo(kgAt.r, 0); }
if (e.key === 'End') { e.preventDefault(); kgGo(kgAt.r, 2); }
});
어디서 깨지는가 — 함정
표에서 가장 자주 깨지는 곳은 자리를 바꾸는 두 줄이 서로를 지나가는 순간입니다. 01 처럼 동점 두 쌍이 동시에 자리를 맞바꾸면 그 중간에 네 줄이 두 줄로 보이는 프레임이 생깁니다. 두 줄의 배경이 둘 다 불투명하니 위에 그려진 쪽이 아래를 완전히 덮기 때문입니다. 24장으로 추리는 미리보기에서는 하필 그 장이 가장 크게 변한 장이라 대표 그림으로 뽑혔습니다. 그래서 두 쌍의 이동 시각을 20% 떼어 놓고, 위로 가는 줄에만 그림자와 쌓임 순서를 줘서 어느 쪽이 지나가는지 남겼습니다.
두 번째는 반대쪽입니다. 켜는 것과 끄는 것을 똑같은 세기로 만들면 끄는 쪽이 더 크게 변합니다. 04 에서 목록을 열 때는 그 목록만 나타나지만 닫을 때는 목록이 사라지면서 가려져 있던 표까지 한꺼번에 다시 드러나기 때문입니다. 05 도 같았습니다. 아홉 칸을 한 번에 끄니 그 장이 제일 크게 변해서 아무것도 안 골라진 빈 표가 대표 그림이 됐습니다. 고친 방법은 같습니다. 켜는 건 그대로 두고 끄는 쪽만 흩어 놓는 것입니다. 04 는 목록 안쪽을 먼저 흐리게 줄인 다음 바깥 통을 껐고, 05 는 네모가 3×3 에서 2×2, 1×1 로 줄어드는 동안 아래 띠 문구도 같은 순간에 한 단계씩 내려가게 했습니다. 문구만 늦게 끄면 2×2 네모 옆에 아홉 칸의 합계가 적힌 거짓 장면이 남습니다.
세 번째 함정은 열 폭을 두 군데에 적어 두는 것입니다. 02 를 처음 만들 때는 칸 폭과 안내선 위치를 각각의 값으로 두었는데, 빠르게 끌 때마다 선이 경계보다 몇 px 앞서거나 뒤처졌습니다. 둘 다 같은 프레임에 그려지는데도 그랬습니다. 값을 --w1 하나로 합치고 안내선은 그 값을 그대로 translateX 로 받게 하자 어긋남이 사라졌습니다. 덧붙여 이 커스텀 속성은 @property 로 길이형이라고 등록해 두어야 합니다. 등록하지 않으면 브라우저가 그 값을 길이가 아니라 그냥 글자로 보기 때문에 중간값 없이 한 번에 튑니다.
마지막은 좁은 화면입니다. 이 부품들은 글 안에 480×300 크기로 들어가고 폰에서는 320×200 으로 줄어듭니다. 무대 여백을 빼면 세로로 쓸 수 있는 건 174px 뿐인데, 320px 폭에서 아홉 부품의 높이를 재어 보니 108px 에서 164px 사이였습니다. 헤더 아래 필터 줄과 알약 줄이 함께 들어간 06 이 164px 로 가장 컸고 남은 여유는 10px 이었습니다. 그래서 좁은 화면에서는 간격을 올리지 않고 내립니다. 열 폭을 줄이고 셀 좌우 여백을 8px 에서 4px 로 내린 것이 전부이고, 세로 간격은 건드리지 않았습니다. 아홉 벌의 규칙을 화면마다 따로 적어 두면 관리가 안 되므로 한 폴더에 모아 두었고, 그 압축 파일의 비밀번호는 g445uude 이며 안에는 지금 보고 있는 바닐라 판과 React 판이 같은 값으로 들어 있습니다.
접근성
prefers-reduced-motion: reduce 를 켠 화면에서는 아홉 벌 전부 반복 루프가 멈추고 결과 상태만 남습니다. 정렬 방향 표시는 회전 없이 그 방향으로 서 있고, 막대는 자라는 과정 없이 제 길이로 있으며, 옮겨 다니는 테두리는 미끄러지지 않되 어느 칸에 있는지는 그대로 보입니다. 움직임이 사라져도 "이 열로 정렬돼 있다" 는 사실은 사라지지 않아야 하기 때문입니다.
표는 뼈대를 제대로 짜 두는 것이 곧 접근성입니다. 아홉 개 전부 바깥이 role="grid" 이고, 줄과 칸에는 role="row" 와 role="gridcell" 을, 머리글에는 role="columnheader" 를 붙였습니다. 헤더가 버튼인 01 과 열 단위로 세운 03 만 구조에 맞게 역할을 달리 답니다. 색만으로 알리지 않는 것도 같은 이유입니다. 07 은 배경색 옆에 등급 낱말을 따로 붙였고, 08 은 그림자 대신 합계 숫자 자체가 바뀌어 상태를 알립니다.
| 항목 | 눈으로 | 낭독기로 | 조작 |
|---|---|---|---|
| 01 우선순위 정렬 | 삼각형 방향 + 순번 배지 | aria-sort 를 ascending·descending 으로 토글 |
클릭 · Shift 클릭 |
| 02 열 너비 | 안내선과 칸 폭 | role="separator" 손잡이 이름 |
드래그 · 좌우 화살표 8px |
| 03 열 순서 | 들린 열 + 점선 홈 | 열 머리글 이름 순서가 그대로 바뀜 | 포인터 드래그 |
| 04 열 숨김 | 표에서 빠지는 칸 | aria-expanded · role="checkbox" |
클릭 |
| 05 범위 선택 | 파란 네모 + 칸 색 | 아래 띠가 칸 수와 합계를 문장으로 | 드래그 후 놓기 |
| 06 열 필터 | 접히는 줄 + 알약 | 입력칸마다 aria-label |
한 글자마다 |
| 07 조건부 서식 | 배경 · 막대 · 배지 | 등급 낱말이 셀 옆에 글자로 | 셀 클릭 |
| 08 합계 줄 | 바닥 고정 + 그림자 | 합계 숫자가 부분합으로 바뀜 | 스크롤 · 행 클릭 |
| 09 키보드 이동 | 두꺼운 테두리 | 옮긴 칸에 실제 초점이 간다 | 화살표 · Home · End |
격자 형태의 표에 어떤 역할과 상태를 붙여야 하는지는 MDN 의 grid 역할 문서에 정리되어 있습니다. 같은 계열의 목록·표 부품은 대시보드 카테고리에, 자바스크립트가 실제로 도는 부품은 JS 카테고리에 모여 있습니다.
FAQ
정렬을 CSS 만으로 할 수는 없나요?
행의 순서를 바꾸는 것까지는 됩니다. 각 행에 order 값을 주고 부모를 flex 로 두면 그리는 순서가 바뀝니다. 다만 그 값을 누가 정할지가 문제입니다. 값을 비교해서 1등부터 4등까지 번호를 매기는 일은 CSS 가 못 하므로 결국 그 계산은 자바스크립트가 합니다. 게다가 order 로 옮긴 순서는 화면에서만 바뀌고 낭독기가 읽는 순서는 원래 문서 순서 그대로라, 눈으로 본 1등과 귀로 들은 1등이 달라집니다. 01 이 실제 배열을 정렬하고 다시 그리는 이유가 그것입니다.
열 폭을 픽셀로 저장하면 화면 크기가 바뀔 때 깨지지 않나요?
깨집니다. 그래서 02 는 폭에 88px 하한과 200px 상한을 두고 그 사이로만 잘라 넣습니다. 저장까지 할 생각이라면 픽셀 대신 표 전체 폭에 대한 비율로 바꿔 두는 쪽이 안전합니다. 화면이 좁아져도 비율은 그대로 쓸 수 있기 때문입니다. 다만 비율로 두면 아주 좁은 화면에서 한 칸이 글자 한 자보다 작아질 수 있으니, 최소 폭만은 픽셀로 따로 잡아 두는 편이 낫습니다.
행이 1,000 줄이 넘어도 이대로 써도 되나요?
06 의 필터처럼 모든 행을 훑는 방식은 줄이 늘어난 만큼 그대로 느려집니다. 수백 줄까지는 문제가 없지만 그 위로 가면 화면에 보이는 줄만 그리는 방식으로 바꿔야 합니다. 08 이 스크롤 상자를 따로 둔 구조가 그 출발점입니다. 상자 높이와 행 높이를 알면 지금 보이는 첫 줄 번호를 계산할 수 있고, 그 범위만 그리면 됩니다. 이때 합계는 그린 줄이 아니라 원본 배열 전체로 더해야 한다는 점만 조심하면 됩니다.