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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teachable-design-system",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.cjs.js",
6
6
  "module": "./dist/index.esm.js",
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export { default } from './Table';
2
+ export { default as Table } from './Table';