teachable-design-system 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/Table/Table.stories.tsx +179 -0
- package/src/components/Table/Table.tsx +510 -0
- package/src/components/Table/index.ts +2 -0
- package/src/components/Table/style.ts +345 -0
- package/src/components/Table/table-body.tsx +112 -0
- package/src/components/Table/table-cell.tsx +153 -0
- package/src/components/Table/table-header.tsx +52 -0
- package/src/components/index.ts +5 -4
- package/src/types/table.d.ts +150 -0
package/package.json
CHANGED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import React,{ useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import Table from './Table';
|
|
4
|
+
import type { TableColumn } from '../../types/table';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/Table',
|
|
8
|
+
component: Table,
|
|
9
|
+
parameters: { layout: 'padded' },
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
} satisfies Meta<typeof Table>;
|
|
12
|
+
|
|
13
|
+
export default meta;
|
|
14
|
+
type Story = StoryObj<typeof meta>;
|
|
15
|
+
|
|
16
|
+
export const Default: Story = {
|
|
17
|
+
args: {
|
|
18
|
+
columns: [
|
|
19
|
+
{ key: 'id', header: 'ID', width: '60px' },
|
|
20
|
+
{ key: 'name', header: '이름', width: '120px' },
|
|
21
|
+
],
|
|
22
|
+
data: [
|
|
23
|
+
{ id: 1, name: '홍길동' },
|
|
24
|
+
{ id: 2, name: '김철수' },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
interface FileData extends Record<string, unknown> {
|
|
30
|
+
no: string;
|
|
31
|
+
filename: string;
|
|
32
|
+
modifiedDate: string;
|
|
33
|
+
school: string;
|
|
34
|
+
classCount: string;
|
|
35
|
+
teacherCount: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const FileSelectComponent = () => {
|
|
39
|
+
const [selectedRow, setSelectedRow] = useState<number>(0);
|
|
40
|
+
|
|
41
|
+
const columns: TableColumn<FileData>[] = [
|
|
42
|
+
{ key: 'no', header: '번호', width: '80px' },
|
|
43
|
+
{ key: 'filename', header: '파일명', width: '300px' },
|
|
44
|
+
{ key: 'modifiedDate', header: '변경일자', width: '140px' },
|
|
45
|
+
{ key: 'school', header: '학교명', width: '250px' },
|
|
46
|
+
{ key: 'classCount', header: '학급', width: '80px' },
|
|
47
|
+
{ key: 'teacherCount', header: '교사', width: '80px' },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const data: FileData[] = [
|
|
51
|
+
{ no: '01', filename: '2025학년도 2학기 시간표(실습)', modifiedDate: '2025-11-05', school: '대구소프트웨어마이스터고', classCount: '12', teacherCount: '0' },
|
|
52
|
+
{ no: '02', filename: '2025학년도 2학기 시간표(실습)', modifiedDate: '2025-11-05', school: '대구소프트웨어마이스터고', classCount: '12', teacherCount: '0' },
|
|
53
|
+
{ no: '03', filename: '2025학년도 2학기 시간표(실습)', modifiedDate: '2025-11-05', school: '대구소프트웨어마이스터고', classCount: '12', teacherCount: '0' },
|
|
54
|
+
{ no: '04', filename: '2025학년도 2학기 시간표(실습)', modifiedDate: '2025-11-05', school: '대구소프트웨어마이스터고', classCount: '12', teacherCount: '0' },
|
|
55
|
+
{ no: '05', filename: '2025학년도 2학기 시간표(실습)', modifiedDate: '2025-11-05', school: '대구소프트웨어마이스터고', classCount: '12', teacherCount: '0' },
|
|
56
|
+
{ no: '06', filename: '2025학년도 2학기 시간표(실습)', modifiedDate: '2025-11-05', school: '대구소프트웨어마이스터고', classCount: '12', teacherCount: '0' },
|
|
57
|
+
{ no: '07', filename: '2025학년도 2학기 시간표(실습)', modifiedDate: '2025-11-05', school: '대구소프트웨어마이스터고', classCount: '12', teacherCount: '0' },
|
|
58
|
+
{ no: '08', filename: '2025학년도 2학기 시간표(실습)', modifiedDate: '2025-11-05', school: '대구소프트웨어마이스터고', classCount: '12', teacherCount: '0' },
|
|
59
|
+
{ no: '09', filename: '2025학년도 2학기 시간표(실습)', modifiedDate: '2025-11-05', school: '대구소프트웨어마이스터고', classCount: '12', teacherCount: '0' },
|
|
60
|
+
{ no: '10', filename: '2025학년도 2학기 시간표(실습)', modifiedDate: '2025-11-05', school: '대구소프트웨어마이스터고', classCount: '12', teacherCount: '0' },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div style={{ width: '716px' }}>
|
|
65
|
+
<Table<FileData>
|
|
66
|
+
columns={columns}
|
|
67
|
+
data={data}
|
|
68
|
+
enableRowSelection
|
|
69
|
+
enableKeyboardNavigation
|
|
70
|
+
selectedRowIndex={selectedRow}
|
|
71
|
+
onRowClick={(rowIndex) => setSelectedRow(rowIndex)}
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const FileSelect: Story = {
|
|
78
|
+
name: 'File Select',
|
|
79
|
+
args: { columns: [], data: [] },
|
|
80
|
+
render: () => <FileSelectComponent />,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
interface MemberData extends Record<string, unknown> {
|
|
84
|
+
code: string;
|
|
85
|
+
teacher: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const MemberListComponent = () => {
|
|
89
|
+
const [selectedRow, setSelectedRow] = useState<number>(0);
|
|
90
|
+
|
|
91
|
+
const columns: TableColumn<MemberData>[] = [
|
|
92
|
+
{ key: 'code', header: '코드', width: '80px' },
|
|
93
|
+
{ key: 'teacher', header: '교사', width: '80px' },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const data: MemberData[] = [
|
|
97
|
+
{ code: '01', teacher: '강원석' },
|
|
98
|
+
{ code: '02', teacher: '권헌춘' },
|
|
99
|
+
{ code: '03', teacher: '김돈호' },
|
|
100
|
+
{ code: '04', teacher: '김상숙' },
|
|
101
|
+
{ code: '05', teacher: '김영아' },
|
|
102
|
+
{ code: '06', teacher: '김익현' },
|
|
103
|
+
{ code: '07', teacher: '김인숙' },
|
|
104
|
+
{ code: '08', teacher: '김지영' },
|
|
105
|
+
{ code: '09', teacher: '김지운' },
|
|
106
|
+
{ code: '10', teacher: '류주혜' },
|
|
107
|
+
{ code: '11', teacher: '마아람' },
|
|
108
|
+
{ code: '12', teacher: '김인숙' },
|
|
109
|
+
{ code: '13', teacher: '김지영' },
|
|
110
|
+
{ code: '14', teacher: '김지운' },
|
|
111
|
+
{ code: '15', teacher: '류주혜' },
|
|
112
|
+
{ code: '16', teacher: '마아람' },
|
|
113
|
+
{ code: '17', teacher: '마아람' },
|
|
114
|
+
{ code: '18', teacher: '마아람' },
|
|
115
|
+
{ code: '19', teacher: '마아람' },
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div style={{ width: '180px' }}>
|
|
120
|
+
<Table<MemberData>
|
|
121
|
+
columns={columns}
|
|
122
|
+
data={data}
|
|
123
|
+
enableRowSelection
|
|
124
|
+
enableKeyboardNavigation
|
|
125
|
+
selectedRowIndex={selectedRow}
|
|
126
|
+
onRowClick={(rowIndex) => setSelectedRow(rowIndex)}
|
|
127
|
+
maxHeight="570px"
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const MemberList: Story = {
|
|
134
|
+
name: 'Member List',
|
|
135
|
+
args: { columns: [], data: [] },
|
|
136
|
+
render: () => <MemberListComponent />,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
interface EditRow extends Record<string, unknown> {
|
|
140
|
+
id: number;
|
|
141
|
+
name: string;
|
|
142
|
+
score: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const EditTableComponent = () => {
|
|
146
|
+
const [data, setData] = useState<EditRow[]>([
|
|
147
|
+
{ id: 1, name: '홍길동', score: 90 },
|
|
148
|
+
{ id: 2, name: '김철수', score: 75 },
|
|
149
|
+
{ id: 3, name: '이영희', score: 88 },
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
const columns: TableColumn<EditRow>[] = [
|
|
153
|
+
{ key: 'id', header: 'ID', width: '60px', editable: false, dataType: 'number' },
|
|
154
|
+
{ key: 'name', header: '이름', width: '140px' },
|
|
155
|
+
{ key: 'score', header: '점수', width: '100px', dataType: 'number' },
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div style={{ width: '320px' }}>
|
|
160
|
+
<Table<EditRow>
|
|
161
|
+
columns={columns}
|
|
162
|
+
data={data}
|
|
163
|
+
onCellEdit={(rowIndex, columnKey, value) => {
|
|
164
|
+
setData((prev) =>
|
|
165
|
+
prev.map((row, idx) =>
|
|
166
|
+
idx === rowIndex ? ({ ...row, [columnKey]: value } as EditRow) : row
|
|
167
|
+
)
|
|
168
|
+
);
|
|
169
|
+
}}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const Edit: Story = {
|
|
176
|
+
name: 'Edit',
|
|
177
|
+
args: { columns: [], data: [] },
|
|
178
|
+
render: () => <EditTableComponent />,
|
|
179
|
+
};
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import React, { useRef, useState, useMemo, useCallback, useEffect } from 'react';
|
|
2
|
+
import { ChevronUp, ChevronDown, Copy, ClipboardPaste, Trash2, XCircle } from 'lucide-react';
|
|
3
|
+
import type { TableProps, SortDirection, DataType, CellPosition } from '../../types/table';
|
|
4
|
+
import {
|
|
5
|
+
TableWrapper,
|
|
6
|
+
TableContainer,
|
|
7
|
+
StyledTable,
|
|
8
|
+
TableOuterWrapper,
|
|
9
|
+
ScrollContainer,
|
|
10
|
+
ScrollButton,
|
|
11
|
+
ContextMenu,
|
|
12
|
+
ContextMenuItem,
|
|
13
|
+
ContextMenuDivider,
|
|
14
|
+
ContextMenuOverlay,
|
|
15
|
+
} from './style';
|
|
16
|
+
import TableHeader from './table-header';
|
|
17
|
+
import TableBody from './table-body';
|
|
18
|
+
|
|
19
|
+
interface ContextMenuState {
|
|
20
|
+
visible: boolean;
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const compareValues = (a: unknown, b: unknown, dataType?: DataType): number => {
|
|
26
|
+
if (a == null) return 1;
|
|
27
|
+
if (b == null) return -1;
|
|
28
|
+
|
|
29
|
+
switch (dataType) {
|
|
30
|
+
case 'number':
|
|
31
|
+
return Number(a) - Number(b);
|
|
32
|
+
case 'date':
|
|
33
|
+
return new Date(a as string).getTime() - new Date(b as string).getTime();
|
|
34
|
+
case 'boolean':
|
|
35
|
+
return (a ? 1 : 0) - (b ? 1 : 0);
|
|
36
|
+
default:
|
|
37
|
+
return String(a).localeCompare(String(b), 'ko-KR');
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getNextSortDirection = (current: SortDirection): SortDirection => {
|
|
42
|
+
if (current === 'asc') return 'desc';
|
|
43
|
+
if (current === 'desc') return null;
|
|
44
|
+
return 'asc';
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const formatCellValue = (value: unknown): string => {
|
|
48
|
+
if (value == null) return '';
|
|
49
|
+
if (value instanceof Date) return value.toLocaleDateString('ko-KR');
|
|
50
|
+
if (typeof value === 'boolean') return value ? '예' : '아니오';
|
|
51
|
+
if (typeof value === 'number') return value.toString();
|
|
52
|
+
return String(value);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default function Table<T extends Record<string, unknown> = Record<string, unknown>>({
|
|
56
|
+
columns,
|
|
57
|
+
data,
|
|
58
|
+
onCellEdit,
|
|
59
|
+
onSort,
|
|
60
|
+
onSelectionChange,
|
|
61
|
+
onPaste,
|
|
62
|
+
maxHeight,
|
|
63
|
+
rowHeight,
|
|
64
|
+
className,
|
|
65
|
+
enableRowSelection,
|
|
66
|
+
selectedRowIndex,
|
|
67
|
+
onRowClick,
|
|
68
|
+
enableKeyboardNavigation,
|
|
69
|
+
}: TableProps<T>) {
|
|
70
|
+
const outerRef = useRef<HTMLDivElement>(null);
|
|
71
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
72
|
+
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
|
73
|
+
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
|
|
74
|
+
const [isSelecting, setIsSelecting] = useState(false);
|
|
75
|
+
const [selectionStart, setSelectionStart] = useState<CellPosition | null>(null);
|
|
76
|
+
const [selectionEnd, setSelectionEnd] = useState<CellPosition | null>(null);
|
|
77
|
+
const [contextMenu, setContextMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0 });
|
|
78
|
+
const [hoveredRowIndex, setHoveredRowIndex] = useState<number | null>(null);
|
|
79
|
+
const [editingCell, setEditingCell] = useState<CellPosition | null>(null);
|
|
80
|
+
const [editStartValue, setEditStartValue] = useState<string | null>(null);
|
|
81
|
+
const [editToken, setEditToken] = useState(0);
|
|
82
|
+
|
|
83
|
+
const sortedData = useMemo(() => {
|
|
84
|
+
if (!sortColumn || !sortDirection) return data;
|
|
85
|
+
|
|
86
|
+
const column = columns.find((col) => col.key === sortColumn);
|
|
87
|
+
if (!column) return data;
|
|
88
|
+
|
|
89
|
+
return [...data].sort((a, b) => {
|
|
90
|
+
const aVal = a[sortColumn as keyof T];
|
|
91
|
+
const bVal = b[sortColumn as keyof T];
|
|
92
|
+
|
|
93
|
+
const result = column.sortFn
|
|
94
|
+
? column.sortFn(aVal, bVal)
|
|
95
|
+
: compareValues(aVal, bVal, column.dataType);
|
|
96
|
+
|
|
97
|
+
return sortDirection === 'asc' ? result : -result;
|
|
98
|
+
});
|
|
99
|
+
}, [data, sortColumn, sortDirection, columns]);
|
|
100
|
+
|
|
101
|
+
const handleSort = useCallback((columnKey: string) => {
|
|
102
|
+
const isSameColumn = sortColumn === columnKey;
|
|
103
|
+
const nextDirection = isSameColumn
|
|
104
|
+
? getNextSortDirection(sortDirection)
|
|
105
|
+
: 'asc';
|
|
106
|
+
|
|
107
|
+
setSortColumn(nextDirection ? columnKey : null);
|
|
108
|
+
setSortDirection(nextDirection);
|
|
109
|
+
onSort?.(columnKey, nextDirection);
|
|
110
|
+
}, [sortColumn, sortDirection, onSort]);
|
|
111
|
+
|
|
112
|
+
const scroll = useCallback((delta: number) => {
|
|
113
|
+
containerRef.current?.scrollBy({ top: delta, behavior: 'smooth' });
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
const handleCellMouseDown = useCallback((rowIndex: number, colIndex: number) => {
|
|
117
|
+
setIsSelecting(true);
|
|
118
|
+
setSelectionStart({ row: rowIndex, col: colIndex });
|
|
119
|
+
setSelectionEnd({ row: rowIndex, col: colIndex });
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
const handleCellMouseEnter = useCallback((rowIndex: number, colIndex: number) => {
|
|
123
|
+
if (isSelecting) {
|
|
124
|
+
setSelectionEnd({ row: rowIndex, col: colIndex });
|
|
125
|
+
}
|
|
126
|
+
}, [isSelecting]);
|
|
127
|
+
|
|
128
|
+
const handleCellMouseUp = useCallback(() => {
|
|
129
|
+
if (isSelecting && selectionStart && selectionEnd) {
|
|
130
|
+
const cells: CellPosition[] = [];
|
|
131
|
+
const minRow = Math.min(selectionStart.row, selectionEnd.row);
|
|
132
|
+
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
|
|
133
|
+
const minCol = Math.min(selectionStart.col, selectionEnd.col);
|
|
134
|
+
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
|
|
135
|
+
|
|
136
|
+
for (let r = minRow; r <= maxRow; r++) {
|
|
137
|
+
for (let c = minCol; c <= maxCol; c++) {
|
|
138
|
+
cells.push({ row: r, col: c });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
onSelectionChange?.(cells);
|
|
142
|
+
}
|
|
143
|
+
setIsSelecting(false);
|
|
144
|
+
}, [isSelecting, selectionStart, selectionEnd, onSelectionChange]);
|
|
145
|
+
|
|
146
|
+
const getSelectedData = useCallback((): string => {
|
|
147
|
+
if (!selectionStart || !selectionEnd) return '';
|
|
148
|
+
|
|
149
|
+
const minRow = Math.min(selectionStart.row, selectionEnd.row);
|
|
150
|
+
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
|
|
151
|
+
const minCol = Math.min(selectionStart.col, selectionEnd.col);
|
|
152
|
+
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
|
|
153
|
+
|
|
154
|
+
const rows: string[] = [];
|
|
155
|
+
for (let r = minRow; r <= maxRow; r++) {
|
|
156
|
+
const cols: string[] = [];
|
|
157
|
+
for (let c = minCol; c <= maxCol; c++) {
|
|
158
|
+
const colKey = columns[c]?.key;
|
|
159
|
+
const value = sortedData[r]?.[colKey as keyof T];
|
|
160
|
+
cols.push(formatCellValue(value));
|
|
161
|
+
}
|
|
162
|
+
rows.push(cols.join('\t'));
|
|
163
|
+
}
|
|
164
|
+
return rows.join('\n');
|
|
165
|
+
}, [selectionStart, selectionEnd, columns, sortedData]);
|
|
166
|
+
|
|
167
|
+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
168
|
+
// 셀 편집(input/textarea) 중에는 전역 키 핸들러가 Backspace/Delete 등을 가로채지 않도록 무시
|
|
169
|
+
const target = e.target as HTMLElement | null;
|
|
170
|
+
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 행 선택 모드에서 키보드 네비게이션
|
|
175
|
+
if (enableRowSelection && enableKeyboardNavigation && onRowClick) {
|
|
176
|
+
if (e.key === 'ArrowUp') {
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
const currentIndex = selectedRowIndex ?? 0;
|
|
179
|
+
const newIndex = Math.max(0, currentIndex - 1);
|
|
180
|
+
onRowClick(newIndex, sortedData[newIndex]);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (e.key === 'ArrowDown') {
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
const currentIndex = selectedRowIndex ?? -1;
|
|
186
|
+
const newIndex = Math.min(sortedData.length - 1, currentIndex + 1);
|
|
187
|
+
onRowClick(newIndex, sortedData[newIndex]);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!selectionStart || !selectionEnd) return;
|
|
193
|
+
|
|
194
|
+
// 셀 선택 상태에서 문자 입력 시 즉시 편집 모드로 전환 (스프레드시트 UX)
|
|
195
|
+
// - 단일 셀 선택일 때만 동작
|
|
196
|
+
// - onCellEdit가 있어야 의미 있는 편집이 가능
|
|
197
|
+
if (
|
|
198
|
+
onCellEdit &&
|
|
199
|
+
!e.ctrlKey &&
|
|
200
|
+
!e.metaKey &&
|
|
201
|
+
!e.altKey &&
|
|
202
|
+
e.key.length === 1
|
|
203
|
+
) {
|
|
204
|
+
const isSingleCell = selectionStart.row === selectionEnd.row && selectionStart.col === selectionEnd.col;
|
|
205
|
+
if (!isSingleCell) {
|
|
206
|
+
setSelectionEnd(selectionStart);
|
|
207
|
+
}
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
setEditingCell({ row: selectionStart.row, col: selectionStart.col });
|
|
210
|
+
setEditStartValue(e.key);
|
|
211
|
+
setEditToken((t) => t + 1);
|
|
212
|
+
setContextMenu({ visible: false, x: 0, y: 0 });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
|
217
|
+
e.preventDefault();
|
|
218
|
+
const text = getSelectedData();
|
|
219
|
+
navigator.clipboard.writeText(text).catch(console.error);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
navigator.clipboard.readText().then((text) => {
|
|
225
|
+
if (!text || !selectionStart) return;
|
|
226
|
+
|
|
227
|
+
const rows = text
|
|
228
|
+
.replace(/\r\n/g, '\n')
|
|
229
|
+
.replace(/\r/g, '\n')
|
|
230
|
+
.split('\n')
|
|
231
|
+
.filter((row, idx, arr) => !(idx === arr.length - 1 && row === ''))
|
|
232
|
+
.map(row => row.split('\t'));
|
|
233
|
+
|
|
234
|
+
const startRow = Math.min(selectionStart.row, selectionEnd?.row ?? selectionStart.row);
|
|
235
|
+
const startCol = Math.min(selectionStart.col, selectionEnd?.col ?? selectionStart.col);
|
|
236
|
+
|
|
237
|
+
if (onPaste) {
|
|
238
|
+
onPaste(startRow, startCol, rows);
|
|
239
|
+
} else if (onCellEdit) {
|
|
240
|
+
rows.forEach((row, rIdx) => {
|
|
241
|
+
row.forEach((cellValue, cIdx) => {
|
|
242
|
+
const targetRow = startRow + rIdx;
|
|
243
|
+
const targetCol = startCol + cIdx;
|
|
244
|
+
if (targetRow < data.length && targetCol < columns.length) {
|
|
245
|
+
const col = columns[targetCol];
|
|
246
|
+
if (col.editable !== false) {
|
|
247
|
+
let parsedValue: unknown = cellValue;
|
|
248
|
+
if (col.dataType === 'number') {
|
|
249
|
+
const num = parseFloat(cellValue.replace(/,/g, ''));
|
|
250
|
+
parsedValue = isNaN(num) ? 0 : num;
|
|
251
|
+
}
|
|
252
|
+
onCellEdit(targetRow, col.key, parsedValue);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}).catch(console.error);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
262
|
+
if (!onCellEdit) return;
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
const minRow = Math.min(selectionStart.row, selectionEnd.row);
|
|
265
|
+
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
|
|
266
|
+
const minCol = Math.min(selectionStart.col, selectionEnd.col);
|
|
267
|
+
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
|
|
268
|
+
|
|
269
|
+
for (let r = minRow; r <= maxRow; r++) {
|
|
270
|
+
for (let c = minCol; c <= maxCol; c++) {
|
|
271
|
+
const col = columns[c];
|
|
272
|
+
if (col.editable !== false) {
|
|
273
|
+
onCellEdit(r, col.key, '');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (e.key === 'Escape') {
|
|
280
|
+
setSelectionStart(null);
|
|
281
|
+
setSelectionEnd(null);
|
|
282
|
+
setContextMenu({ visible: false, x: 0, y: 0 });
|
|
283
|
+
}
|
|
284
|
+
}, [enableRowSelection, enableKeyboardNavigation, selectedRowIndex, sortedData, onRowClick, selectionStart, selectionEnd, getSelectedData, onPaste, onCellEdit, data, columns]);
|
|
285
|
+
|
|
286
|
+
// 표 밖을 클릭하면 셀 선택(타겟) 해제
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
const handleMouseDownOutside = (event: MouseEvent) => {
|
|
289
|
+
const root = outerRef.current;
|
|
290
|
+
const target = event.target as Node | null;
|
|
291
|
+
if (!root || !target) return;
|
|
292
|
+
if (root.contains(target)) return;
|
|
293
|
+
|
|
294
|
+
setIsSelecting(false);
|
|
295
|
+
setSelectionStart(null);
|
|
296
|
+
setSelectionEnd(null);
|
|
297
|
+
setEditingCell(null);
|
|
298
|
+
setEditStartValue(null);
|
|
299
|
+
setContextMenu({ visible: false, x: 0, y: 0 });
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
document.addEventListener('mousedown', handleMouseDownOutside, true);
|
|
303
|
+
return () => {
|
|
304
|
+
document.removeEventListener('mousedown', handleMouseDownOutside, true);
|
|
305
|
+
};
|
|
306
|
+
}, []);
|
|
307
|
+
|
|
308
|
+
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
|
309
|
+
e.preventDefault();
|
|
310
|
+
if (selectionStart && selectionEnd) {
|
|
311
|
+
setContextMenu({ visible: true, x: e.clientX, y: e.clientY });
|
|
312
|
+
}
|
|
313
|
+
}, [selectionStart, selectionEnd]);
|
|
314
|
+
|
|
315
|
+
const closeContextMenu = useCallback(() => {
|
|
316
|
+
setContextMenu({ visible: false, x: 0, y: 0 });
|
|
317
|
+
}, []);
|
|
318
|
+
|
|
319
|
+
const handleCopy = useCallback(() => {
|
|
320
|
+
const text = getSelectedData();
|
|
321
|
+
navigator.clipboard.writeText(text).catch(console.error);
|
|
322
|
+
closeContextMenu();
|
|
323
|
+
}, [getSelectedData, closeContextMenu]);
|
|
324
|
+
|
|
325
|
+
const handlePaste = useCallback(() => {
|
|
326
|
+
if (!selectionStart || !selectionEnd) return;
|
|
327
|
+
|
|
328
|
+
navigator.clipboard.readText().then((text) => {
|
|
329
|
+
if (!text) return;
|
|
330
|
+
|
|
331
|
+
const rows = text
|
|
332
|
+
.replace(/\r\n/g, '\n')
|
|
333
|
+
.replace(/\r/g, '\n')
|
|
334
|
+
.split('\n')
|
|
335
|
+
.filter((row, idx, arr) => !(idx === arr.length - 1 && row === ''))
|
|
336
|
+
.map(row => row.split('\t'));
|
|
337
|
+
|
|
338
|
+
const startRow = Math.min(selectionStart.row, selectionEnd.row);
|
|
339
|
+
const startCol = Math.min(selectionStart.col, selectionEnd.col);
|
|
340
|
+
|
|
341
|
+
if (onPaste) {
|
|
342
|
+
onPaste(startRow, startCol, rows);
|
|
343
|
+
} else if (onCellEdit) {
|
|
344
|
+
rows.forEach((row, rIdx) => {
|
|
345
|
+
row.forEach((cellValue, cIdx) => {
|
|
346
|
+
const targetRow = startRow + rIdx;
|
|
347
|
+
const targetCol = startCol + cIdx;
|
|
348
|
+
if (targetRow < data.length && targetCol < columns.length) {
|
|
349
|
+
const col = columns[targetCol];
|
|
350
|
+
if (col.editable !== false) {
|
|
351
|
+
let parsedValue: unknown = cellValue;
|
|
352
|
+
if (col.dataType === 'number') {
|
|
353
|
+
const num = parseFloat(cellValue.replace(/,/g, ''));
|
|
354
|
+
parsedValue = isNaN(num) ? 0 : num;
|
|
355
|
+
}
|
|
356
|
+
onCellEdit(targetRow, col.key, parsedValue);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}).catch(console.error);
|
|
363
|
+
closeContextMenu();
|
|
364
|
+
}, [selectionStart, selectionEnd, onPaste, onCellEdit, data, columns, closeContextMenu]);
|
|
365
|
+
|
|
366
|
+
const handleDelete = useCallback(() => {
|
|
367
|
+
if (!selectionStart || !selectionEnd || !onCellEdit) return;
|
|
368
|
+
|
|
369
|
+
const minRow = Math.min(selectionStart.row, selectionEnd.row);
|
|
370
|
+
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
|
|
371
|
+
const minCol = Math.min(selectionStart.col, selectionEnd.col);
|
|
372
|
+
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
|
|
373
|
+
|
|
374
|
+
for (let r = minRow; r <= maxRow; r++) {
|
|
375
|
+
for (let c = minCol; c <= maxCol; c++) {
|
|
376
|
+
const col = columns[c];
|
|
377
|
+
if (col.editable !== false) {
|
|
378
|
+
onCellEdit(r, col.key, '');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
closeContextMenu();
|
|
383
|
+
}, [selectionStart, selectionEnd, onCellEdit, columns, closeContextMenu]);
|
|
384
|
+
|
|
385
|
+
const handleClearSelection = useCallback(() => {
|
|
386
|
+
setSelectionStart(null);
|
|
387
|
+
setSelectionEnd(null);
|
|
388
|
+
closeContextMenu();
|
|
389
|
+
}, [closeContextMenu]);
|
|
390
|
+
|
|
391
|
+
const hasSelection = selectionStart !== null && selectionEnd !== null;
|
|
392
|
+
const selectionCellCount = useMemo(() => {
|
|
393
|
+
if (!selectionStart || !selectionEnd) return 0;
|
|
394
|
+
const rows = Math.abs(selectionEnd.row - selectionStart.row) + 1;
|
|
395
|
+
const cols = Math.abs(selectionEnd.col - selectionStart.col) + 1;
|
|
396
|
+
return rows * cols;
|
|
397
|
+
}, [selectionStart, selectionEnd]);
|
|
398
|
+
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
const container = containerRef.current;
|
|
401
|
+
if (!container) return;
|
|
402
|
+
|
|
403
|
+
const handleMouseUp = () => {
|
|
404
|
+
if (isSelecting) {
|
|
405
|
+
handleCellMouseUp();
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
410
|
+
document.addEventListener('keydown', handleKeyDown as unknown as EventListener);
|
|
411
|
+
|
|
412
|
+
return () => {
|
|
413
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
414
|
+
document.removeEventListener('keydown', handleKeyDown as unknown as EventListener);
|
|
415
|
+
};
|
|
416
|
+
}, [isSelecting, handleCellMouseUp, handleKeyDown]);
|
|
417
|
+
|
|
418
|
+
return (
|
|
419
|
+
<TableOuterWrapper ref={outerRef} className={className} tabIndex={0} onContextMenu={handleContextMenu}>
|
|
420
|
+
<TableWrapper>
|
|
421
|
+
<TableContainer ref={containerRef} maxHeight={maxHeight}>
|
|
422
|
+
<StyledTable>
|
|
423
|
+
<colgroup>
|
|
424
|
+
{columns.map((col) => (
|
|
425
|
+
<col
|
|
426
|
+
key={String(col.key)}
|
|
427
|
+
style={col.width ? { width: col.width } : undefined}
|
|
428
|
+
/>
|
|
429
|
+
))}
|
|
430
|
+
</colgroup>
|
|
431
|
+
<TableHeader<T>
|
|
432
|
+
columns={columns}
|
|
433
|
+
sortColumn={sortColumn ?? undefined}
|
|
434
|
+
sortDirection={sortDirection}
|
|
435
|
+
onSort={handleSort}
|
|
436
|
+
/>
|
|
437
|
+
<TableBody<T>
|
|
438
|
+
columns={columns}
|
|
439
|
+
data={sortedData}
|
|
440
|
+
rowHeight={rowHeight}
|
|
441
|
+
onCellEdit={onCellEdit}
|
|
442
|
+
selectionStart={enableRowSelection ? null : selectionStart}
|
|
443
|
+
selectionEnd={enableRowSelection ? null : selectionEnd}
|
|
444
|
+
editingCell={enableRowSelection ? null : editingCell}
|
|
445
|
+
editStartValue={editStartValue}
|
|
446
|
+
editToken={editToken}
|
|
447
|
+
onCellMouseDown={enableRowSelection ? undefined : handleCellMouseDown}
|
|
448
|
+
onCellMouseEnter={enableRowSelection ? undefined : handleCellMouseEnter}
|
|
449
|
+
onCellMouseUp={enableRowSelection ? undefined : handleCellMouseUp}
|
|
450
|
+
enableRowSelection={enableRowSelection}
|
|
451
|
+
selectedRowIndex={selectedRowIndex}
|
|
452
|
+
hoveredRowIndex={hoveredRowIndex ?? undefined}
|
|
453
|
+
onRowClick={(rowIndex) => onRowClick?.(rowIndex, sortedData[rowIndex])}
|
|
454
|
+
onRowHover={setHoveredRowIndex}
|
|
455
|
+
/>
|
|
456
|
+
</StyledTable>
|
|
457
|
+
</TableContainer>
|
|
458
|
+
</TableWrapper>
|
|
459
|
+
|
|
460
|
+
{maxHeight && (
|
|
461
|
+
<ScrollContainer>
|
|
462
|
+
<ScrollButton position="top" onClick={() => scroll(-100)}>
|
|
463
|
+
<ChevronUp />
|
|
464
|
+
</ScrollButton>
|
|
465
|
+
<ScrollButton position="bottom" onClick={() => scroll(100)}>
|
|
466
|
+
<ChevronDown />
|
|
467
|
+
</ScrollButton>
|
|
468
|
+
</ScrollContainer>
|
|
469
|
+
)}
|
|
470
|
+
|
|
471
|
+
{contextMenu.visible && (
|
|
472
|
+
<>
|
|
473
|
+
<ContextMenuOverlay onClick={closeContextMenu} />
|
|
474
|
+
<ContextMenu x={contextMenu.x} y={contextMenu.y}>
|
|
475
|
+
<ContextMenuItem onClick={handleCopy} disabled={!hasSelection}>
|
|
476
|
+
<Copy size={14} />
|
|
477
|
+
복사
|
|
478
|
+
<span className="shortcut">⌘C</span>
|
|
479
|
+
</ContextMenuItem>
|
|
480
|
+
<ContextMenuItem onClick={handlePaste} disabled={!hasSelection}>
|
|
481
|
+
<ClipboardPaste size={14} />
|
|
482
|
+
붙여넣기
|
|
483
|
+
<span className="shortcut">⌘V</span>
|
|
484
|
+
</ContextMenuItem>
|
|
485
|
+
<ContextMenuDivider />
|
|
486
|
+
<ContextMenuItem onClick={handleDelete} disabled={!hasSelection || !onCellEdit}>
|
|
487
|
+
<Trash2 size={14} />
|
|
488
|
+
삭제
|
|
489
|
+
<span className="shortcut">Del</span>
|
|
490
|
+
</ContextMenuItem>
|
|
491
|
+
<ContextMenuDivider />
|
|
492
|
+
<ContextMenuItem onClick={handleClearSelection} disabled={!hasSelection}>
|
|
493
|
+
<XCircle size={14} />
|
|
494
|
+
선택 해제
|
|
495
|
+
<span className="shortcut">Esc</span>
|
|
496
|
+
</ContextMenuItem>
|
|
497
|
+
{hasSelection && (
|
|
498
|
+
<>
|
|
499
|
+
<ContextMenuDivider />
|
|
500
|
+
<ContextMenuItem disabled style={{ fontSize: '11px', color: '#9ca3af' }}>
|
|
501
|
+
{selectionCellCount}개 셀 선택됨
|
|
502
|
+
</ContextMenuItem>
|
|
503
|
+
</>
|
|
504
|
+
)}
|
|
505
|
+
</ContextMenu>
|
|
506
|
+
</>
|
|
507
|
+
)}
|
|
508
|
+
</TableOuterWrapper>
|
|
509
|
+
);
|
|
510
|
+
}
|