teachable-design-system 0.2.1 → 0.3.2

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.
Files changed (50) hide show
  1. package/dist/index.cjs.js +875 -64
  2. package/dist/index.cjs.js.map +1 -1
  3. package/dist/index.d.ts +72 -2
  4. package/dist/index.esm.js +877 -67
  5. package/dist/index.esm.js.map +1 -1
  6. package/dist/types/components/Dropdown/Dropdown.d.ts +1 -1
  7. package/dist/types/components/Dropdown/Dropdown.d.ts.map +1 -1
  8. package/dist/types/components/Dropdown/style.d.ts +1 -0
  9. package/dist/types/components/Dropdown/style.d.ts.map +1 -1
  10. package/dist/types/components/Table/Table.d.ts +3 -0
  11. package/dist/types/components/Table/Table.d.ts.map +1 -0
  12. package/dist/types/components/Table/index.d.ts +3 -0
  13. package/dist/types/components/Table/index.d.ts.map +1 -0
  14. package/dist/types/components/Table/style.d.ts +98 -0
  15. package/dist/types/components/Table/style.d.ts.map +1 -0
  16. package/dist/types/components/Table/table-body.d.ts +3 -0
  17. package/dist/types/components/Table/table-body.d.ts.map +1 -0
  18. package/dist/types/components/Table/table-cell.d.ts +3 -0
  19. package/dist/types/components/Table/table-cell.d.ts.map +1 -0
  20. package/dist/types/components/Table/table-header.d.ts +4 -0
  21. package/dist/types/components/Table/table-header.d.ts.map +1 -0
  22. package/dist/types/components/index.d.ts +1 -0
  23. package/dist/types/components/index.d.ts.map +1 -1
  24. package/dist/types/types/Dropdown.types.d.ts +1 -0
  25. package/dist/types/types/Dropdown.types.d.ts.map +1 -1
  26. package/dist/types/types/index.d.ts +6 -0
  27. package/dist/types/types/index.d.ts.map +1 -1
  28. package/dist/types/types/table.d.ts +141 -0
  29. package/dist/types/types/table.d.ts.map +1 -0
  30. package/package.json +1 -1
  31. package/src/components/Dropdown/Dropdown.stories.tsx +4 -0
  32. package/src/components/Dropdown/Dropdown.tsx +2 -1
  33. package/src/components/Dropdown/style.ts +2 -1
  34. package/src/components/Sidebar/style.ts +4 -4
  35. package/src/components/Table/Table.stories.tsx +179 -0
  36. package/src/components/Table/Table.tsx +510 -0
  37. package/src/components/Table/index.ts +2 -0
  38. package/src/components/Table/style.ts +345 -0
  39. package/src/components/Table/table-body.tsx +112 -0
  40. package/src/components/Table/table-cell.tsx +153 -0
  41. package/src/components/Table/table-header.tsx +52 -0
  42. package/src/components/index.ts +5 -4
  43. package/src/types/Dropdown.types.ts +1 -0
  44. package/src/types/index.ts +6 -0
  45. package/src/types/table.ts +150 -0
  46. package/dist/assets/icons/arrow-down.png +0 -0
  47. package/dist/assets/icons/checked.png +0 -0
  48. package/dist/assets/icons/icon_size.png +0 -0
  49. package/dist/assets/images/.gitkeep +0 -0
  50. package/dist/assets/index.ts +0 -1
@@ -0,0 +1,345 @@
1
+ import styled from '@emotion/styled';
2
+ import { colors as themeColors, typography } from '../../style/theme';
3
+ import type { SortDirection } from '../../types/table';
4
+
5
+ // ============================================
6
+ // 디자인 토큰
7
+ // ============================================
8
+ const colors = {
9
+ header: themeColors.surface['secondary-subtler'],
10
+ headerHover: themeColors.action['secondary-pressed'],
11
+ body: themeColors.surface.white,
12
+ bodyHover: themeColors.surface['gray-subtler'],
13
+ border: themeColors.border['gray-light'],
14
+ borderLight: themeColors.border['secondary-light'],
15
+ text: themeColors.text.bolder,
16
+ textSecondary: themeColors.text.subtle,
17
+ scrollThumb: themeColors.surface['gray-subtle'],
18
+ scrollThumbBorder: themeColors.border.gray,
19
+ selected: themeColors.surface['information-subtler'],
20
+ selectedBorder: themeColors.border.primary,
21
+ disabledText: themeColors.text.disabled,
22
+ } as const;
23
+
24
+ const spacing = {
25
+ cellPadding: '4px 16px',
26
+ headerPadding: '4px 16px',
27
+ } as const;
28
+
29
+ // ============================================
30
+ // 레이아웃 컴포넌트
31
+ // ============================================
32
+ export const TableOuterWrapper = styled.div`
33
+ position: relative;
34
+ display: inline-block;
35
+ outline: none;
36
+
37
+ &:focus {
38
+ outline: none;
39
+ }
40
+ `;
41
+
42
+ export const TableWrapper = styled.div`
43
+ display: flex;
44
+ flex-direction: column;
45
+ width: 100%;
46
+ overflow: hidden;
47
+ `;
48
+
49
+ export const TableContainer = styled.div<{ maxHeight?: string }>`
50
+ width: 100%;
51
+ overflow: auto;
52
+ position: relative;
53
+ ${({ maxHeight }) => maxHeight && `max-height: ${maxHeight};`}
54
+
55
+ /* 스크롤바 스타일 */
56
+ &::-webkit-scrollbar {
57
+ width: 20px;
58
+ height: 20px;
59
+ }
60
+
61
+ &::-webkit-scrollbar-track {
62
+ background: ${colors.body};
63
+ border: 1px solid ${colors.border};
64
+ margin-top: 20px; /* 상단 화살표 버튼 높이만큼 여백 */
65
+ }
66
+
67
+ &::-webkit-scrollbar-thumb {
68
+ background: ${colors.scrollThumb};
69
+ border: 1px solid ${colors.scrollThumbBorder};
70
+
71
+ &:hover {
72
+ background: ${colors.headerHover};
73
+ }
74
+ }
75
+
76
+ &::-webkit-scrollbar-button:vertical:start:decrement,
77
+ &::-webkit-scrollbar-button:vertical:end:increment {
78
+ display: block;
79
+ height: 20px;
80
+ background: ${colors.header};
81
+ border: 1px solid ${colors.borderLight};
82
+ }
83
+
84
+ &::-webkit-scrollbar-button:vertical:start:decrement:hover,
85
+ &::-webkit-scrollbar-button:vertical:end:increment:hover {
86
+ background: ${colors.headerHover};
87
+ }
88
+ `;
89
+
90
+ // ============================================
91
+ // 테이블 기본 컴포넌트
92
+ // ============================================
93
+ export const StyledTable = styled.table`
94
+ width: 100%;
95
+ border-collapse: separate;
96
+ border-spacing: 0;
97
+ table-layout: auto;
98
+ font-family: ${typography.fontFamily.primary};
99
+ `;
100
+
101
+ export const TableHead = styled.thead`
102
+ position: sticky;
103
+ top: 0;
104
+ z-index: 10;
105
+ background: ${colors.header};
106
+ `;
107
+
108
+ export const TableBody = styled.tbody``;
109
+
110
+ export const TableRow = styled.tr<{ striped?: boolean }>`
111
+ &:nth-of-type(even) {
112
+ ${({ striped }) => striped && `background-color: ${colors.bodyHover};`}
113
+ }
114
+
115
+ &:hover {
116
+ background-color: ${colors.bodyHover};
117
+ }
118
+ `;
119
+
120
+ // ============================================
121
+ // 셀 컴포넌트
122
+ // ============================================
123
+ const baseCellStyle = `
124
+ box-sizing: border-box;
125
+ vertical-align: middle;
126
+ line-height: 1.5;
127
+ `;
128
+
129
+ export const TableHeaderCell = styled.th<{ width?: string; sortable?: boolean }>`
130
+ ${baseCellStyle}
131
+ min-width: ${({ width }) => (width ? '0' : '80px')};
132
+ background: ${colors.header};
133
+ border: 1px solid ${colors.borderLight};
134
+ border-left: none;
135
+ padding: ${spacing.headerPadding};
136
+ text-align: left;
137
+ font-weight: 700;
138
+ font-size: 15px;
139
+ color: ${colors.text};
140
+ height: 30px;
141
+ white-space: nowrap;
142
+ position: relative;
143
+ ${({ width }) => width && `width: ${width};`}
144
+
145
+ &:first-of-type {
146
+ border-left: 1px solid ${colors.borderLight};
147
+ }
148
+
149
+ ${({ sortable }) =>
150
+ sortable &&
151
+ `
152
+ cursor: pointer;
153
+ user-select: none;
154
+
155
+ &:hover {
156
+ background: ${colors.headerHover};
157
+ }
158
+ `}
159
+ `;
160
+
161
+ export const TableDataCell = styled.td<{
162
+ editable?: boolean;
163
+ width?: string;
164
+ height?: string;
165
+ isHeaderColumn?: boolean;
166
+ isSelected?: boolean;
167
+ $edgeTop?: boolean;
168
+ $edgeBottom?: boolean;
169
+ $edgeLeft?: boolean;
170
+ $edgeRight?: boolean;
171
+ $rowSelected?: boolean;
172
+ }>`
173
+ ${baseCellStyle}
174
+ min-width: ${({ width }) => (width ? '0' : '80px')};
175
+ background: ${({ isHeaderColumn, isSelected, $rowSelected }) =>
176
+ isSelected ? colors.selected : (isHeaderColumn ? colors.header : ($rowSelected ? 'inherit' : colors.body))};
177
+ border-right: 1px solid ${({ isHeaderColumn }) =>
178
+ isHeaderColumn ? colors.borderLight : colors.border};
179
+ border-bottom: 1px solid ${({ isHeaderColumn }) =>
180
+ isHeaderColumn ? colors.borderLight : colors.border};
181
+ border-left: none;
182
+ border-top: none;
183
+ padding: ${({ isHeaderColumn }) => (isHeaderColumn ? spacing.headerPadding : spacing.cellPadding)};
184
+ font-weight: ${({ isHeaderColumn }) => (isHeaderColumn ? 700 : 400)};
185
+ font-size: ${({ isHeaderColumn }) => (isHeaderColumn ? '15px' : '13px')};
186
+ color: ${({ isHeaderColumn }) => (isHeaderColumn ? colors.text : colors.textSecondary)};
187
+ height: ${({ height }) => height ?? '30px'};
188
+ position: relative;
189
+ user-select: none;
190
+
191
+ &:first-of-type {
192
+ border-left: 1px solid ${({ isHeaderColumn }) =>
193
+ isHeaderColumn ? colors.borderLight : colors.border};
194
+ }
195
+
196
+ ${({ isSelected, $edgeTop, $edgeBottom, $edgeLeft, $edgeRight }) =>
197
+ isSelected &&
198
+ `
199
+ z-index: 1;
200
+ box-shadow:
201
+ ${$edgeTop ? `inset 0 2px 0 0 ${colors.selectedBorder}` : `inset 0 0.5px 0 0 ${colors.selectedBorder}50`}${$edgeBottom ? `, inset 0 -2px 0 0 ${colors.selectedBorder}` : `, inset 0 -0.5px 0 0 ${colors.selectedBorder}50`}${$edgeLeft ? `, inset 2px 0 0 0 ${colors.selectedBorder}` : `, inset 0.5px 0 0 0 ${colors.selectedBorder}50`}${$edgeRight ? `, inset -2px 0 0 0 ${colors.selectedBorder}` : `, inset -0.5px 0 0 0 ${colors.selectedBorder}50`};
202
+ `}
203
+
204
+ ${({ editable, isSelected, $rowSelected }) =>
205
+ editable && !isSelected && !$rowSelected &&
206
+ `
207
+ cursor: cell;
208
+ &:hover {
209
+ background-color: ${colors.bodyHover};
210
+ }
211
+ `}
212
+
213
+ ${({ isSelected }) =>
214
+ isSelected &&
215
+ `
216
+ &:hover {
217
+ background-color: ${colors.selected};
218
+ }
219
+ `}
220
+ `;
221
+
222
+ // ============================================
223
+ // 정렬 및 입력 컴포넌트
224
+ // ============================================
225
+ export const SortIcon = styled.span<{ active?: boolean; direction?: SortDirection }>`
226
+ display: inline-flex;
227
+ flex-direction: column;
228
+ margin-left: 4px;
229
+ opacity: ${({ active }) => (active ? 1 : 0.3)};
230
+
231
+ svg {
232
+ width: 12px;
233
+ height: 12px;
234
+ }
235
+ `;
236
+
237
+ export const EditableInput = styled.input`
238
+ width: 100%;
239
+ border: none;
240
+ outline: none;
241
+ background: transparent;
242
+ font: inherit;
243
+ color: inherit;
244
+ margin: -12px -16px;
245
+ padding: ${spacing.cellPadding};
246
+
247
+ &:focus {
248
+ outline: none;
249
+ }
250
+ `;
251
+
252
+ // ============================================
253
+ // 스크롤 컨트롤
254
+ // ============================================
255
+ export const ScrollContainer = styled.div`
256
+ display: flex;
257
+ flex-direction: column;
258
+ position: absolute;
259
+ right: 0;
260
+ top: 0;
261
+ `;
262
+
263
+ export const ScrollButton = styled.button<{ position: 'top' | 'bottom' }>`
264
+ width: 20px;
265
+ height: 20px;
266
+ padding: 0;
267
+ background: ${colors.header};
268
+ border: 1px solid ${colors.borderLight};
269
+ cursor: pointer;
270
+ display: flex;
271
+ align-items: center;
272
+ justify-content: center;
273
+ transition: background-color 0.2s;
274
+
275
+ &:hover {
276
+ background: ${colors.headerHover};
277
+ }
278
+
279
+ &:disabled {
280
+ opacity: 0.5;
281
+ cursor: not-allowed;
282
+ }
283
+
284
+ svg {
285
+ width: 14px;
286
+ height: 14px;
287
+ color: ${colors.text};
288
+ }
289
+ `;
290
+
291
+ // ============================================
292
+ // 컨텍스트 메뉴
293
+ // ============================================
294
+ export const ContextMenuOverlay = styled.div`
295
+ position: fixed;
296
+ top: 0;
297
+ left: 0;
298
+ right: 0;
299
+ bottom: 0;
300
+ z-index: 999;
301
+ `;
302
+
303
+ export const ContextMenu = styled.div<{ x: number; y: number }>`
304
+ position: fixed;
305
+ top: ${({ y }) => y}px;
306
+ left: ${({ x }) => x}px;
307
+ z-index: 1000;
308
+ min-width: 160px;
309
+ background: ${colors.body};
310
+ border: 1px solid ${colors.border};
311
+ border-radius: 6px;
312
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
313
+ padding: 4px 0;
314
+ font-family: ${typography.fontFamily.primary};
315
+ `;
316
+
317
+ export const ContextMenuItem = styled.button<{ disabled?: boolean }>`
318
+ width: 100%;
319
+ padding: 8px 12px;
320
+ border: none;
321
+ background: transparent;
322
+ text-align: left;
323
+ font-size: 13px;
324
+ color: ${({ disabled }) => (disabled ? colors.disabledText : colors.text)};
325
+ cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
326
+ display: flex;
327
+ align-items: center;
328
+ gap: 8px;
329
+
330
+ &:hover {
331
+ background: ${({ disabled }) => (disabled ? 'transparent' : colors.bodyHover)};
332
+ }
333
+
334
+ span.shortcut {
335
+ margin-left: auto;
336
+ font-size: 11px;
337
+ color: ${colors.disabledText};
338
+ }
339
+ `;
340
+
341
+ export const ContextMenuDivider = styled.div`
342
+ height: 1px;
343
+ background: ${colors.borderLight};
344
+ margin: 4px 0;
345
+ `;
@@ -0,0 +1,112 @@
1
+ import React from 'react';
2
+ import type { TableBodyProps, CellPosition } from '../../types/table';
3
+ import { TableBody as StyledTableBody, TableRow } from './style';
4
+ import TableCell from './table-cell';
5
+
6
+ const getSelectionInfo = (
7
+ rowIndex: number,
8
+ colIndex: number,
9
+ start: CellPosition | null | undefined,
10
+ end: CellPosition | null | undefined
11
+ ): { isSelected: boolean; edge: { top: boolean; bottom: boolean; left: boolean; right: boolean } } => {
12
+ if (!start || !end) {
13
+ return { isSelected: false, edge: { top: false, bottom: false, left: false, right: false } };
14
+ }
15
+
16
+ const minRow = Math.min(start.row, end.row);
17
+ const maxRow = Math.max(start.row, end.row);
18
+ const minCol = Math.min(start.col, end.col);
19
+ const maxCol = Math.max(start.col, end.col);
20
+
21
+ const isSelected = rowIndex >= minRow && rowIndex <= maxRow && colIndex >= minCol && colIndex <= maxCol;
22
+
23
+ if (!isSelected) {
24
+ return { isSelected: false, edge: { top: false, bottom: false, left: false, right: false } };
25
+ }
26
+
27
+ return {
28
+ isSelected: true,
29
+ edge: {
30
+ top: rowIndex === minRow,
31
+ bottom: rowIndex === maxRow,
32
+ left: colIndex === minCol,
33
+ right: colIndex === maxCol,
34
+ }
35
+ };
36
+ };
37
+
38
+ export default function TableBody<T extends Record<string, unknown> = Record<string, unknown>>({
39
+ columns,
40
+ data,
41
+ rowHeight,
42
+ onCellEdit,
43
+ selectionStart,
44
+ selectionEnd,
45
+ editingCell,
46
+ editStartValue,
47
+ editToken,
48
+ onCellMouseDown,
49
+ onCellMouseEnter,
50
+ onCellMouseUp,
51
+ enableRowSelection,
52
+ selectedRowIndex,
53
+ hoveredRowIndex,
54
+ onRowClick,
55
+ onRowHover,
56
+ }: TableBodyProps<T>) {
57
+ const getRowBackground = (rowIndex: number): string | undefined => {
58
+ if (!enableRowSelection) return undefined;
59
+ if (selectedRowIndex === rowIndex) return '#e7f4fe';
60
+ if (hoveredRowIndex === rowIndex) return '#f4f5f6';
61
+ return undefined;
62
+ };
63
+
64
+ return (
65
+ <StyledTableBody>
66
+ {data.map((row, rowIndex) => (
67
+ <TableRow
68
+ key={rowIndex}
69
+ onClick={enableRowSelection ? () => onRowClick?.(rowIndex) : undefined}
70
+ onMouseEnter={enableRowSelection ? () => onRowHover?.(rowIndex) : undefined}
71
+ onMouseLeave={enableRowSelection ? () => onRowHover?.(null) : undefined}
72
+ style={{
73
+ cursor: enableRowSelection ? 'pointer' : undefined,
74
+ backgroundColor: getRowBackground(rowIndex),
75
+ transition: enableRowSelection ? 'background-color 0.15s ease' : undefined,
76
+ }}
77
+ >
78
+ {columns.map((col, colIndex) => {
79
+ const { isSelected, edge } = getSelectionInfo(rowIndex, colIndex, selectionStart, selectionEnd);
80
+ const isEditingRequested =
81
+ !!editingCell && editingCell.row === rowIndex && editingCell.col === colIndex;
82
+ return (
83
+ <TableCell
84
+ key={`${rowIndex}-${col.key}`}
85
+ value={row[col.key]}
86
+ editable={col.editable !== false}
87
+ width={col.width}
88
+ height={col.height}
89
+ rowHeight={rowHeight}
90
+ dataType={col.dataType}
91
+ isHeaderColumn={col.isHeaderColumn}
92
+ isSelected={isSelected}
93
+ rowSelected={enableRowSelection && (selectedRowIndex === rowIndex || hoveredRowIndex === rowIndex)}
94
+ isEditingRequested={isEditingRequested}
95
+ startEditingToken={editToken}
96
+ startEditingValue={isEditingRequested ? editStartValue : null}
97
+ selectionEdge={isSelected ? edge : undefined}
98
+ rowSpan={col.rowSpan}
99
+ colSpan={col.colSpan}
100
+ onEdit={(value) => onCellEdit?.(rowIndex, col.key, value)}
101
+ render={col.render ? (value) => col.render!(value, row, rowIndex) : undefined}
102
+ onMouseDown={enableRowSelection ? undefined : () => onCellMouseDown?.(rowIndex, colIndex)}
103
+ onMouseEnter={enableRowSelection ? undefined : () => onCellMouseEnter?.(rowIndex, colIndex)}
104
+ onMouseUp={enableRowSelection ? undefined : onCellMouseUp}
105
+ />
106
+ );
107
+ })}
108
+ </TableRow>
109
+ ))}
110
+ </StyledTableBody>
111
+ );
112
+ }
@@ -0,0 +1,153 @@
1
+ import React, { useEffect, useRef, useState, useCallback, type KeyboardEvent, type MouseEvent } from 'react';
2
+ import type { TableCellProps, DataType } from '../../types/table';
3
+ import { TableDataCell, EditableInput } from './style';
4
+
5
+ const formatValue = (val: unknown, dataType: DataType): string => {
6
+ if (val == null) return '';
7
+
8
+ switch (dataType) {
9
+ case 'number':
10
+ return typeof val === 'number' ? val.toLocaleString() : String(val);
11
+ case 'date':
12
+ return val instanceof Date ? val.toLocaleDateString('ko-KR') : String(val);
13
+ case 'boolean':
14
+ return val ? '예' : '아니오';
15
+ default:
16
+ return String(val);
17
+ }
18
+ };
19
+
20
+ const parseValue = (val: string, dataType: DataType): unknown => {
21
+ switch (dataType) {
22
+ case 'number': {
23
+ const num = parseFloat(val.replace(/,/g, ''));
24
+ return isNaN(num) ? 0 : num;
25
+ }
26
+ case 'date':
27
+ return new Date(val);
28
+ case 'boolean':
29
+ return val === '예' || val === 'true' || val === '1';
30
+ default:
31
+ return val;
32
+ }
33
+ };
34
+
35
+ const getInputType = (dataType: DataType): string => {
36
+ if (dataType === 'number') return 'number';
37
+ if (dataType === 'date') return 'date';
38
+ return 'text';
39
+ };
40
+
41
+ export default function TableCell({
42
+ value,
43
+ editable = false,
44
+ width,
45
+ height,
46
+ rowHeight,
47
+ dataType = 'text',
48
+ isHeaderColumn = false,
49
+ isSelected = false,
50
+ rowSelected = false,
51
+ isEditingRequested,
52
+ startEditingToken,
53
+ startEditingValue,
54
+ selectionEdge,
55
+ rowSpan,
56
+ colSpan,
57
+ onEdit,
58
+ render,
59
+ onMouseDown,
60
+ onMouseEnter,
61
+ onMouseUp,
62
+ }: TableCellProps) {
63
+ const [isEditing, setIsEditing] = useState(false);
64
+ const [editValue, setEditValue] = useState('');
65
+ const lastStartTokenRef = useRef<number | undefined>(undefined);
66
+
67
+ const startEditing = useCallback(() => {
68
+ if (!editable) return;
69
+ setEditValue(formatValue(value, dataType));
70
+ setIsEditing(true);
71
+ }, [editable, value, dataType]);
72
+
73
+ // 부모에서 "편집 시작" 요청이 오면 (예: 선택 셀에서 타이핑) 편집 모드로 진입
74
+ useEffect(() => {
75
+ if (!isEditingRequested) return;
76
+ if (startEditingToken == null) return;
77
+ if (lastStartTokenRef.current === startEditingToken) return;
78
+
79
+ lastStartTokenRef.current = startEditingToken;
80
+ if (!editable) return;
81
+
82
+ const nextValue = startEditingValue ?? formatValue(value, dataType);
83
+ setEditValue(nextValue);
84
+ setIsEditing(true);
85
+ }, [isEditingRequested, startEditingToken, startEditingValue, editable, value, dataType]);
86
+
87
+ const save = useCallback(() => {
88
+ onEdit?.(parseValue(editValue, dataType));
89
+ setIsEditing(false);
90
+ }, [editValue, dataType, onEdit]);
91
+
92
+ const cancel = useCallback(() => {
93
+ setEditValue('');
94
+ setIsEditing(false);
95
+ }, []);
96
+
97
+ const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
98
+ if (e.key === 'Enter') {
99
+ e.preventDefault();
100
+ save();
101
+ } else if (e.key === 'Escape') {
102
+ e.preventDefault();
103
+ cancel();
104
+ }
105
+ }, [save, cancel]);
106
+
107
+ const handleMouseDown = useCallback((e: MouseEvent) => {
108
+ if (isEditing) return;
109
+ e.preventDefault();
110
+ onMouseDown?.();
111
+ }, [isEditing, onMouseDown]);
112
+
113
+ const handleMouseEnter = useCallback(() => {
114
+ if (isEditing) return;
115
+ onMouseEnter?.();
116
+ }, [isEditing, onMouseEnter]);
117
+
118
+ const displayValue = render ? render(value) : formatValue(value, dataType);
119
+
120
+ return (
121
+ <TableDataCell
122
+ editable={editable}
123
+ width={width}
124
+ height={height || rowHeight}
125
+ isHeaderColumn={isHeaderColumn}
126
+ isSelected={isSelected}
127
+ $rowSelected={rowSelected}
128
+ $edgeTop={selectionEdge?.top}
129
+ $edgeBottom={selectionEdge?.bottom}
130
+ $edgeLeft={selectionEdge?.left}
131
+ $edgeRight={selectionEdge?.right}
132
+ rowSpan={rowSpan}
133
+ colSpan={colSpan}
134
+ onDoubleClick={startEditing}
135
+ onMouseDown={handleMouseDown}
136
+ onMouseEnter={handleMouseEnter}
137
+ onMouseUp={onMouseUp}
138
+ >
139
+ {isEditing ? (
140
+ <EditableInput
141
+ type={getInputType(dataType)}
142
+ value={editValue}
143
+ onChange={(e) => setEditValue(e.target.value)}
144
+ onBlur={save}
145
+ onKeyDown={handleKeyDown}
146
+ autoFocus
147
+ />
148
+ ) : (
149
+ displayValue
150
+ )}
151
+ </TableDataCell>
152
+ );
153
+ }
@@ -0,0 +1,52 @@
1
+ import React from 'react';
2
+ import { ChevronUp, ChevronDown } from 'lucide-react';
3
+ import type { TableHeaderProps, SortDirection } from '../../types/table';
4
+ import { TableHead, TableRow, TableHeaderCell, SortIcon } from './style';
5
+
6
+ /** 정렬 아이콘 컴포넌트 */
7
+ function SortIndicator({ isActive, direction }: { isActive: boolean; direction?: SortDirection }) {
8
+ if (!isActive || !direction) {
9
+ return <ChevronUp style={{ opacity: 0.3 }} />;
10
+ }
11
+ return direction === 'asc' ? <ChevronUp /> : <ChevronDown />;
12
+ }
13
+
14
+ /** 테이블 헤더 */
15
+ export default function TableHeader<T = Record<string, unknown>>({
16
+ columns,
17
+ sortColumn,
18
+ sortDirection,
19
+ onSort,
20
+ }: TableHeaderProps<T>) {
21
+ const handleClick = (key: string, sortable?: boolean) => {
22
+ if (sortable) onSort?.(key);
23
+ };
24
+
25
+ return (
26
+ <TableHead>
27
+ <TableRow>
28
+ {columns.map(({ key, header, width, sortable, rowSpan, colSpan }) => {
29
+ const isActive = sortColumn === key;
30
+
31
+ return (
32
+ <TableHeaderCell
33
+ key={key}
34
+ width={width}
35
+ sortable={sortable}
36
+ rowSpan={rowSpan}
37
+ colSpan={colSpan}
38
+ onClick={() => handleClick(key, sortable)}
39
+ >
40
+ {header}
41
+ {sortable && (
42
+ <SortIcon active={isActive} direction={isActive ? sortDirection : null}>
43
+ <SortIndicator isActive={isActive} direction={sortDirection} />
44
+ </SortIcon>
45
+ )}
46
+ </TableHeaderCell>
47
+ );
48
+ })}
49
+ </TableRow>
50
+ </TableHead>
51
+ );
52
+ }
@@ -1,6 +1,7 @@
1
- export * from './Button'
2
- export * from './CheckBox'
1
+ export * from './Button';
2
+ export * from './CheckBox';
3
3
  export * from './Dropdown'
4
- export * from './Input'
4
+ export * from './Input';
5
5
  export * from './Sidebar'
6
- export * from './TabBar'
6
+ export * from './TabBar';
7
+ export * from './Table';
@@ -4,4 +4,5 @@ export interface DropdownProps {
4
4
  options: string[];
5
5
  label?: string;
6
6
  placeholder?: string;
7
+ width?: string;
7
8
  }
@@ -0,0 +1,6 @@
1
+ export * from './button.types';
2
+ export * from './checkBox.types';
3
+ export * from './Dropdown.types';
4
+ export * from './input.types';
5
+ export * from './Sidebar.types';
6
+ export * from './table';