요구사항
- Only View
- 정렬
- 행 선택 (체크박스)
- 셀 색상 (글자색, 배경색) → 메서드로 구현
- 셀 더블클릭 이벤트
- 마우스 hover 시 행 하이라이팅
- 가로/세로 스크롤
구현
1. 구조
<table>
<thead>
<th />
...
<th />
</thead>
<tbody>
<tr>
<td />
</tr>
...
<tr>
<td />
</tr>
</tbody>
</table>
2. 데이터 매핑
<th id=””> 와 <td headers=””> 사용
즉 헤더의 id와 같은 headers 값을 가진 데이터를 매핑해준다.
3. 정렬
컬럼 헤더 클릭 → 클릭한 컬럼 정보 props로 넘겨줌 → 조건 맞춰 rowData 정렬
- 숫자는 크기 순으로
- 문자는 ㄱㄴㄷ, abc 순으로
- 클릭 시 동작 순서 : 오름차순 → 내림차순 → 원래대로
결론적으로 sort + localeCompare 사용
정렬 함수
import type { RowData } from "../types/grid";
// 오름차순
export const AscendingSort = (rowData: RowData[], col: string) => {
return [...rowData].sort((x, y) => {
if (x[col] === y[col]) return 0;
if (x[col] === undefined || x[col] === null) return -1;
if (y[col] === undefined || y[col] === null) return 1;
if (typeof x[col] === "string" && typeof y[col] === "string") {
return x[col].localeCompare(y[col], undefined, { numeric: true });
} else return x[col] - y[col];
});
};
// 내림차순
export const DescendingSort = (rowData: RowData[], col: string) => {
return [...rowData].sort((x, y) => {
if (x[col] === y[col]) return 0;
if (x[col] === undefined || x[col] === null) return 1;
if (y[col] === undefined || y[col] === null) return -1;
if (typeof x[col] === "string" && typeof y[col] === "string") {
return y[col].localeCompare(x[col], undefined, { numeric: true });
} else return y[col] - x[col];
});
};
비교하는 두 값이 동일할 경우 변동없이 원래의 순서대로 위치하도록 했다.
값이 없거나(undefined) null일 때는 가장 작은 값으로 취급해 오름차순일 경우 가장 상위로, 내림차순일 경우 가장 하위로 보내줬다.
문자열 타입의 경우 localeCompare 함수를 이용했고, 그 외의 경우 단순히 사칙연산을 이용해 코드를 크기를 비교했다. (=순서를 정했다.)
정렬 동작 순서
오름차순 → 내림차순 → 원래대로의 순서를 적용하기 위해 isSort라는 변수를 추가했다. (이름과 달리 숫자 타입이다…)
const [isSort, setIsSort] = useState(0); // 0:정렬X, 1:오름차순, 2:내림차순
해당 숫자에 따라 다음에 어떤 동작을 시행할지 switch 문을 사용해서 구분했다.
다른 컬럼 클릭 시
그러나 다른 컬럼을 클릭 시 새롭게 정렬을 시작해야 하므로 어떤 컬럼을 정렬하고 있는지 저장하는 변수를 추가했다.
const [sortCol, setSortCol] = useState("");
// 헤더 셀 클릭 시 정렬 추가
const SortRowData = (col: string) => {
if (col !== sortCol) {
setViewData(AscendingSort(rowData, col));
setIsSort(1);
setSortCol(col);
} else {
switch (isSort) {
case 0: // 정렬 X 상태
setViewData(AscendingSort(rowData, col));
setIsSort(1);
return;
case 1: // 오름차순 상태
setViewData(DescendingSort(rowData, col));
setIsSort(2);
return;
case 2: // 내림차순 상태
setViewData(rowData);
setIsSort(0);
return;
}
}
};
4. 행 선택 (체크박스)
checkbox 속성 활성화 시 행 선택 가능한 checkbox를 그리드 가장 왼쪽 열에 생성
- 전체 선택
- 개별 선택
어떻게 구현할까?
❌ 체크박스에 체킹된 행들을 배열에 보관? → 그럼 스타일링 줄 때 in 연산자를 사용해야 해서 성능이 악화될 위험이 있다.
✅ rowData를 변경? → 정렬할 때도 체크된 상태 지니기 위해 rowData의 복사본인 viewData 사용 → 복사본 배열 내 객체에 새로운 key:value 쌍을 추가 (isChecked: boolean)
useEffect로 처음 마운트됐을 때 viewData와 viewCol에 props로 받은 colDefs + isChecked, rowData + isChecked 배열 할당
useEffect(() => {
if (!checkbox) return;
const newCol = {
name: "isChecked",
field: "isChecked",
sortable: false,
};
setViewData(
rowData.map((data) => ({
...data,
isChecked: "false",
}))
);
setViewCol([newCol, ...colDefs]);
}, []);
체크박스 클릭 시 동작
배열 내 객체가 id 값을 가지고 있지 않으므로 index로 제어
react에서 배열의 변경을 감지하게 하려면 새로운 배열을 setState 함수에 할당하여 state의 주소값을 바꿔줘야 한다.
// 모든 체크박스 제어
const checkAllCell = (e: React.ChangeEvent<HTMLInputElement>) => {
const newData = viewData.map((data) => ({
...data,
isChecked: e.target.checked,
}));
setViewData(newData);
setAllChecked(e.target.checked);
};
// 개별 체크박스 제어
const checkEachCell = (
e: React.ChangeEvent<HTMLInputElement>,
idx: number
) => {
const newData = [...viewData];
newData[idx] = { ...newData[idx], isChecked: e.target.checked };
setViewData(newData);
};
- 배열 복사본 생성
- 모든 객체 또는 해당 idx의 객체의 isChecked 값 변경
- setState 함수에 새로운 배열 복사본 할당
스타일은 <td> 태그에 다음 속성 추가
$checked={item["isChecked"] || false}
체크박스와 정렬 충돌 문제
정렬 시 props로 받은 원본 데이터인 rowData를 이용했는데, 이 경우 isChecked 변수가 없다.
단순히 정렬 시 사용하는 데이터를 viewData로 바꾼다하더라도 여러 문제가 발생한다. (원래대로 돌아오려면 기존 순서를 지닌 배열을 따로 보관한다. → 정렬 시에 새로운 행이 선택되더라도 index 값을 사용하기 때문에 순서 배열은 이 값을 알 수 없다. 등등)
그래서 isChecked를 추가할 때 order라는 순서를 가진 변수를 함께 추가해줬다.
useEffect(() => {
if (!checkbox) {
setViewData(
rowData.map((data, idx) => ({
...data,
order: idx,
}))
);
setViewCol(colDefs);
return;
}
const newCol = {
name: "isChecked",
field: "isChecked",
sortable: false,
};
setViewData(
rowData.map((data, idx) => ({
...data,
isChecked: false,
order: idx,
}))
);
setViewCol([newCol, ...colDefs]);
}, []);
그리고 정렬 시 사용하는 데이터를 rowData에서 viewData로 바꿔줬다.
// 헤더 셀 클릭 시 정렬 추가
const SortRowData = (col: string) => {
if (col !== sortCol) {
setViewData(AscendingSort(viewData, col));
setIsSort(1);
setSortCol(col);
} else {
switch (isSort) {
case 0: // 정렬 X 상태
setViewData(AscendingSort(viewData, col));
setIsSort(1);
return;
case 1: // 오름차순 상태
setViewData(DescendingSort(viewData, col));
setIsSort(2);
return;
case 2: // 내림차순 상태
setViewData(AscendingSort(viewData, "order"));
setIsSort(0);
return;
}
}
};
😎 체크박스 전체 선택/해제 시에는 헤더 체크박스에 변화가 있는 게 자연스러움
- isChecked: true의 개수를 센다. ✅
- 배열을 돌면서 확인한다. → 비효율적
🍀🍀🍀 추가 : 체크박스 하나라도 해제 시에는 헤더 체크박스도 false로 변환 🍀🍀🍀
🍀🍀🍀 추가 : 수동으로 모든 체크박스 선택했을 시 헤더 체크박스도 true로 변환 🍀🍀🍀
→ 체크된 행 수 저장하는 변수 추가
const [checkCnt, setCheckCnt] = useState(0);
체크박스 개별/전체 선택 함수 실행 시 조건에 따라 checkCnt와 allChecked 변수를 변경해줬다.
5. 셀 색상 (글자색, 배경색)
상위에서 셀 스타일을 커스텀하는 함수를 정의한 후 공통 컴포넌트에서 props로 전달 받는 방식을 사용했다. → cellStyleHandler
export interface GridProps {
colDefs: ColDefs[];
rowData: RowData[];
checkbox?: boolean;
cellStyleHandler: (columnName: string, rowContent: unknown) => object;
}
각 셀을 나타내는 td 태그에 style={cellStyleHandler(col.field, item[col.field])} 부여
// 상위 함수 예시
const cellStyleHandler = (columnName: string, rowContent: any) => {
if (columnName === "a") return { backgroundColor: "#ffff00" };
if (columnName === "c" && rowContent < "2023-01-01")
return {
backgroundColor: "#ffa500",
};
};
6. 셀 더블클릭 이벤트
cellStyleHandler와 동일하게 상위 컴포넌트에서 정의해 둔 함수를 CommonTable에 props로 전달하는 방법을 사용했다.
export interface GridProps {
colDefs: ColDefs[];
rowData: RowData[];
checkbox?: boolean;
cellStyleHandler: (columnName: string, rowContent: unknown) => object;
cellDoubleClickHandler: (e: React.MouseEvent) => void;
}
onDoubleClick={cellDoubleClickHandler}
더블클릭 event의 타입은 MouseEvent이다.
// 상위 컴포넌트 예시 코드
const cellDoubleClickHandler = (e: React.MouseEvent) => {
const target = e.target as HTMLTableCellElement;
console.log(target.textContent);
console.log(target.headers);
};
e.target을 HTMLTableCellElement로 타입 단언을 해줘야 TS 컴파일러가 내뱉는 많은 에러를 벗어날 수 있다.
단순히 HTMLElement를 단언하면 td 태그만 지닌 headers 등의 속성은 찾지 못하기 때문에 HTMLTableCellElement를 써줘야 한다.
이때 셀이 가진 데이터 값은 textContent, 컬럼명은 headers로 가져올 수 있다.
7. 스크롤
display: block;
display 속성이 table이면 overflow가 적용되지 않는다. 그렇기 때문에 display 속성을 block으로 바꿔줘야 한다.
처음에는 thead, tbody 각각을 바꿔줬는데, 그 경우 table의 가로 스크롤 생성을 위해서는 상위 요소에 overflow: auto 속성이 적용되어야 했다.
그 컨트롤 부분을 table 내부로 옮기기 위해 table 태그에 display: block 속성을 부여했다.
export const Table = styled.table`
width: 100%;
height: 100%;
overflow: auto;
display: block; // 스크롤 적용 위해 추가
...
`
세로 스크롤
헤더는 고정한 채 데이터 행들만 스크롤 생성
thead에 sticky 속성을 주어 스크롤이 생기더라도 상단에 고정되도록 했다.
thead {
height: 3rem;
position: sticky;
top: 0;
}
스크롤 CSS
기본 스크롤은 너무 못생겼으니까 스크롤 디자인을 변경해줬다.
&::-webkit-scrollbar {
width: 0.7rem;
height: 0.7rem;
}
&::-webkit-scrollbar-thumb {
background-clip: padding-box;
border: 1px solid transparent;
border-radius: 3rem;
background-color: #dddddd;
}
&::-webkit-scrollbar-track {
background-color: ${({ theme }) => theme.colors.grayBg};
}
다음번에는 이 테이블에 편집 기능을 넣어서 올 예정이다. 그정도면 차라리 라이브러리를 쓰는게 낫지 않나.. 라는 생각이 들 수도 있지만 (나도 들지만) 필요한 기능만 딱 넣어서 사용하기엔 나쁘지 않은 것 같기도 해서 일단 츄라이츄라이~
'React' 카테고리의 다른 글
[ React ] React에서 slot 사용하기 (0) | 2024.11.14 |
---|---|
[ React ] CommonTable 편집모드 (데이터 변경, 행 추가, 삭제) (0) | 2024.08.29 |
[ React ] DatePicker, SelectBox의 팝업을 createPortal로 변경 (0) | 2024.07.12 |
[ React ] SelectBox 컴포넌트 키보드 이벤트 추가 | onKeyDown (0) | 2024.07.11 |
[ React ] input 태그의 placeholder로 icon 사용하기 (0) | 2024.07.10 |