virtual-excel-table 2.2.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/README.md +410 -0
- package/demo.html +624 -0
- package/package.json +39 -0
- package/src/main/webapp/WEB-INF/views/virtual-excel-demo.jsp +233 -0
- package/src/main/webapp/resources/js/virtual-excel-table.js +3391 -0
package/demo.html
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ko">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>엑셀 붙여넣기 테이블 데모</title>
|
|
7
|
+
<link rel="stylesheet" href="https://code.jquery.com/ui/1.14.2/themes/base/jquery-ui.css">
|
|
8
|
+
<style>
|
|
9
|
+
body {
|
|
10
|
+
margin: 0;
|
|
11
|
+
background: #f3f4f6;
|
|
12
|
+
color: #1f2937;
|
|
13
|
+
font-family: Arial, "Malgun Gothic", sans-serif;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.container {
|
|
17
|
+
max-width: 960px;
|
|
18
|
+
margin: 40px auto;
|
|
19
|
+
padding: 24px;
|
|
20
|
+
border: 1px solid #d1d5db;
|
|
21
|
+
border-radius: 10px;
|
|
22
|
+
background: #fff;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
h1 {
|
|
26
|
+
margin: 0 0 10px;
|
|
27
|
+
font-size: 24px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.guide {
|
|
31
|
+
margin: 0 0 18px;
|
|
32
|
+
color: #4b5563;
|
|
33
|
+
line-height: 1.7;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.toolbar {
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-wrap: wrap;
|
|
39
|
+
gap: 8px;
|
|
40
|
+
margin-bottom: 12px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
button {
|
|
44
|
+
padding: 8px 13px;
|
|
45
|
+
border: 1px solid #9ca3af;
|
|
46
|
+
border-radius: 5px;
|
|
47
|
+
background: #fff;
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
button:hover {
|
|
52
|
+
background: #eff6ff;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.table-wrap {
|
|
56
|
+
overflow-x: auto;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#excelTable .vet-field-cell {
|
|
60
|
+
vertical-align: top;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#excelTable .vet-field-control {
|
|
64
|
+
min-height: 24px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#excelTable .vet-field-status {
|
|
68
|
+
min-width: 110px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.vet-field-error {
|
|
72
|
+
display: none;
|
|
73
|
+
box-sizing: border-box;
|
|
74
|
+
min-height: 38px;
|
|
75
|
+
padding: 3px 2px 2px;
|
|
76
|
+
color: #dc2626;
|
|
77
|
+
font-size: 12px;
|
|
78
|
+
line-height: 1.35;
|
|
79
|
+
text-align: left;
|
|
80
|
+
word-break: keep-all;
|
|
81
|
+
overflow-wrap: break-word;
|
|
82
|
+
white-space: normal;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#excelTable tr.has-validation-errors .vet-field-error {
|
|
86
|
+
display: block;
|
|
87
|
+
visibility: hidden;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#excelTable tr.has-validation-errors td.is-invalid > .vet-field-error {
|
|
91
|
+
visibility: visible;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#jsonOutput {
|
|
95
|
+
min-height: 42px;
|
|
96
|
+
margin: 14px 0 0;
|
|
97
|
+
padding: 12px;
|
|
98
|
+
overflow-x: auto;
|
|
99
|
+
border-radius: 5px;
|
|
100
|
+
background: #111827;
|
|
101
|
+
color: #d1fae5;
|
|
102
|
+
font-size: 13px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.shortcut {
|
|
106
|
+
margin: 14px 0 0;
|
|
107
|
+
color: #6b7280;
|
|
108
|
+
font-size: 13px;
|
|
109
|
+
}
|
|
110
|
+
</style>
|
|
111
|
+
</head>
|
|
112
|
+
<body>
|
|
113
|
+
<main class="container">
|
|
114
|
+
<h1>엑셀 붙여넣기 테이블</h1>
|
|
115
|
+
<p class="guide">
|
|
116
|
+
Excel에서 여러 셀을 복사한 뒤 아래 표의 시작 셀을 클릭하고
|
|
117
|
+
<strong>Ctrl+V</strong>를 누르세요. 드래그 또는 Shift+클릭으로 범위를 선택하고,
|
|
118
|
+
<strong>Ctrl+C</strong>로 다시 복사할 수 있습니다. 행을 지울 때는 아무 셀이나 선택한 뒤
|
|
119
|
+
<strong>선택 행 삭제</strong> 버튼을 누르세요. 선택 셀에서 바로 타이핑하면 새 값 입력이 시작되고,
|
|
120
|
+
기존 값의 커서를 편집하려면 <strong>F2</strong> 또는 더블클릭을 사용합니다.
|
|
121
|
+
왼쪽 행 번호와 위쪽 열 제목은 행/열 전체 선택이며, 상태 select는 일반 HTML select처럼 동작합니다.
|
|
122
|
+
한 행 또는 한 열을 복사한 뒤 여러 행·열을 선택해 붙이면 선택 범위 전체에 반복됩니다.
|
|
123
|
+
입고일 input은 jQuery UI Datepicker 예제이며 새 행과 붙여넣기로 자동 추가된 행에도 자동 적용됩니다.
|
|
124
|
+
현재 행에서 <strong>Ctrl+Enter</strong>를 누르면 바로 아래에 새 행이 삽입됩니다.
|
|
125
|
+
<strong>등록(유효성 검사)</strong> 버튼은 각 잘못된 셀 아래에 빨간 오류 문구를 표시합니다.
|
|
126
|
+
</p>
|
|
127
|
+
|
|
128
|
+
<div class="toolbar">
|
|
129
|
+
<button type="button" id="sampleButton">샘플 데이터 넣기</button>
|
|
130
|
+
<button type="button" id="registerButton">등록(유효성 검사)</button>
|
|
131
|
+
<button type="button" id="addRowButton">행 추가</button>
|
|
132
|
+
<button type="button" id="copyButton">선택 복사</button>
|
|
133
|
+
<button type="button" id="undoButton">되돌리기</button>
|
|
134
|
+
<button type="button" id="redoButton">다시 실행</button>
|
|
135
|
+
<button type="button" id="deleteButton">선택 값 삭제</button>
|
|
136
|
+
<button type="button" id="deleteRowsButton">선택 행 삭제</button>
|
|
137
|
+
<button type="button" id="selectAllButton">전체 선택</button>
|
|
138
|
+
<button type="button" id="jsonButton">JSON 추출</button>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div class="table-wrap">
|
|
142
|
+
<table id="excelTable">
|
|
143
|
+
<thead>
|
|
144
|
+
<tr>
|
|
145
|
+
<th data-excel-corner>#</th>
|
|
146
|
+
<th data-excel-column="0" data-excel-key="code">상품 코드</th>
|
|
147
|
+
<th data-excel-column="1" data-excel-key="name">상품명</th>
|
|
148
|
+
<th data-excel-column="2" data-excel-key="quantity">수량</th>
|
|
149
|
+
<th data-excel-column="3" data-excel-key="price">단가</th>
|
|
150
|
+
<th data-excel-column="4" data-excel-key="receivedDate">입고일</th>
|
|
151
|
+
<th data-excel-column="5" data-excel-key="status">상태</th>
|
|
152
|
+
</tr>
|
|
153
|
+
</thead>
|
|
154
|
+
<tbody>
|
|
155
|
+
<tr>
|
|
156
|
+
<th data-excel-row-header>1</th>
|
|
157
|
+
<td><input type="text" data-field="code" data-excel-target name="items[0].code"></td>
|
|
158
|
+
<td><input type="text" data-field="name" data-excel-target name="items[0].name"></td>
|
|
159
|
+
<td><input type="text" data-field="quantity" data-excel-target name="items[0].quantity"></td>
|
|
160
|
+
<td><input type="text" data-field="price" data-excel-target name="items[0].price"></td>
|
|
161
|
+
<td><input type="text" class="js-datepicker" data-field="receivedDate" data-excel-target data-excel-native-editor name="items[0].receivedDate" placeholder="YYYY-MM-DD"></td>
|
|
162
|
+
<td><select data-field="status" data-excel-target name="items[0].status"><option value="">선택</option><option value="A">사용</option><option value="I">미사용</option></select></td>
|
|
163
|
+
</tr>
|
|
164
|
+
<tr>
|
|
165
|
+
<th data-excel-row-header>2</th>
|
|
166
|
+
<td><input type="text" data-field="code" data-excel-target name="items[1].code"></td>
|
|
167
|
+
<td><input type="text" data-field="name" data-excel-target name="items[1].name"></td>
|
|
168
|
+
<td><input type="text" data-field="quantity" data-excel-target name="items[1].quantity"></td>
|
|
169
|
+
<td><input type="text" data-field="price" data-excel-target name="items[1].price"></td>
|
|
170
|
+
<td><input type="text" class="js-datepicker" data-field="receivedDate" data-excel-target data-excel-native-editor name="items[1].receivedDate" placeholder="YYYY-MM-DD"></td>
|
|
171
|
+
<td><select data-field="status" data-excel-target name="items[1].status"><option value="">선택</option><option value="A">사용</option><option value="I">미사용</option></select></td>
|
|
172
|
+
</tr>
|
|
173
|
+
<tr>
|
|
174
|
+
<th data-excel-row-header>3</th>
|
|
175
|
+
<td><input type="text" data-field="code" data-excel-target name="items[2].code"></td>
|
|
176
|
+
<td><input type="text" data-field="name" data-excel-target name="items[2].name"></td>
|
|
177
|
+
<td><input type="text" data-field="quantity" data-excel-target name="items[2].quantity"></td>
|
|
178
|
+
<td><input type="text" data-field="price" data-excel-target name="items[2].price"></td>
|
|
179
|
+
<td><input type="text" class="js-datepicker" data-field="receivedDate" data-excel-target data-excel-native-editor name="items[2].receivedDate" placeholder="YYYY-MM-DD"></td>
|
|
180
|
+
<td><select data-field="status" data-excel-target name="items[2].status"><option value="">선택</option><option value="A">사용</option><option value="I">미사용</option></select></td>
|
|
181
|
+
</tr>
|
|
182
|
+
<tr>
|
|
183
|
+
<th data-excel-row-header>4</th>
|
|
184
|
+
<td><input type="text" data-field="code" data-excel-target name="items[3].code"></td>
|
|
185
|
+
<td><input type="text" data-field="name" data-excel-target name="items[3].name"></td>
|
|
186
|
+
<td><input type="text" data-field="quantity" data-excel-target name="items[3].quantity"></td>
|
|
187
|
+
<td><input type="text" data-field="price" data-excel-target name="items[3].price"></td>
|
|
188
|
+
<td><input type="text" class="js-datepicker" data-field="receivedDate" data-excel-target data-excel-native-editor name="items[3].receivedDate" placeholder="YYYY-MM-DD"></td>
|
|
189
|
+
<td><select data-field="status" data-excel-target name="items[3].status"><option value="">선택</option><option value="A">사용</option><option value="I">미사용</option></select></td>
|
|
190
|
+
</tr>
|
|
191
|
+
<tr>
|
|
192
|
+
<th data-excel-row-header>5</th>
|
|
193
|
+
<td><input type="text" data-field="code" data-excel-target name="items[4].code"></td>
|
|
194
|
+
<td><input type="text" data-field="name" data-excel-target name="items[4].name"></td>
|
|
195
|
+
<td><input type="text" data-field="quantity" data-excel-target name="items[4].quantity"></td>
|
|
196
|
+
<td><input type="text" data-field="price" data-excel-target name="items[4].price"></td>
|
|
197
|
+
<td><input type="text" class="js-datepicker" data-field="receivedDate" data-excel-target data-excel-native-editor name="items[4].receivedDate" placeholder="YYYY-MM-DD"></td>
|
|
198
|
+
<td><select data-field="status" data-excel-target name="items[4].status"><option value="">선택</option><option value="A">사용</option><option value="I">미사용</option></select></td>
|
|
199
|
+
</tr>
|
|
200
|
+
</tbody>
|
|
201
|
+
</table>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<pre id="jsonOutput">JSON 추출 결과가 여기에 표시됩니다.</pre>
|
|
205
|
+
|
|
206
|
+
<p class="shortcut">
|
|
207
|
+
단축키: Ctrl+C 복사 · Ctrl+Enter 현재 행 아래 삽입 · Delete/Backspace 값 삭제 · Ctrl+- 행 삭제 · Ctrl+Z 되돌리기 · Ctrl+Y 다시 실행
|
|
208
|
+
· Tab 좌우 이동 · Enter 상하 이동 · F2 수동 편집 · Escape 편집 취소
|
|
209
|
+
</p>
|
|
210
|
+
</main>
|
|
211
|
+
|
|
212
|
+
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
|
213
|
+
<script src="https://code.jquery.com/ui/1.14.2/jquery-ui.min.js"></script>
|
|
214
|
+
<script src="./src/main/webapp/resources/js/virtual-excel-table.js"></script>
|
|
215
|
+
<script>
|
|
216
|
+
var tableElement = document.getElementById('excelTable');
|
|
217
|
+
var validationFieldNames = [
|
|
218
|
+
'code', 'name', 'quantity', 'price', 'receivedDate', 'status'
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
function trimValue(value) {
|
|
222
|
+
return String(value === null || typeof value === 'undefined' ? '' : value)
|
|
223
|
+
.replace(/^\s+|\s+$/g, '');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function removeClassToken(element, className) {
|
|
227
|
+
var value;
|
|
228
|
+
|
|
229
|
+
if (!element) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
value = (' ' + (element.className || '') + ' ').replace(/\s+/g, ' ');
|
|
233
|
+
while (value.indexOf(' ' + className + ' ') !== -1) {
|
|
234
|
+
value = value.replace(' ' + className + ' ', ' ');
|
|
235
|
+
}
|
|
236
|
+
element.className = value.replace(/^\s+|\s+$/g, '');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function addClassToken(element, className) {
|
|
240
|
+
if (element && (' ' + (element.className || '') + ' ').indexOf(
|
|
241
|
+
' ' + className + ' '
|
|
242
|
+
) === -1) {
|
|
243
|
+
element.className += (element.className ? ' ' : '') + className;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function findDataCell(element) {
|
|
248
|
+
var current = element;
|
|
249
|
+
|
|
250
|
+
while (current && current !== tableElement) {
|
|
251
|
+
if (String(current.tagName || '').toUpperCase() === 'TD') {
|
|
252
|
+
return current;
|
|
253
|
+
}
|
|
254
|
+
current = current.parentNode;
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function initializeValidationFields(root) {
|
|
260
|
+
var controls = root.querySelectorAll('[data-field]');
|
|
261
|
+
var cell;
|
|
262
|
+
var error;
|
|
263
|
+
var wrapper;
|
|
264
|
+
var fieldName;
|
|
265
|
+
var i;
|
|
266
|
+
|
|
267
|
+
for (i = 0; i < controls.length; i += 1) {
|
|
268
|
+
cell = findDataCell(controls[i]);
|
|
269
|
+
if (!cell) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
fieldName = controls[i].getAttribute('data-field');
|
|
273
|
+
addClassToken(cell, 'vet-field-cell');
|
|
274
|
+
addClassToken(cell, 'vet-field-' + fieldName);
|
|
275
|
+
if (controls[i].parentNode === cell) {
|
|
276
|
+
wrapper = document.createElement('div');
|
|
277
|
+
wrapper.className = 'vet-field-control';
|
|
278
|
+
cell.insertBefore(wrapper, controls[i]);
|
|
279
|
+
wrapper.appendChild(controls[i]);
|
|
280
|
+
}
|
|
281
|
+
if (!cell.querySelector('.vet-field-error')) {
|
|
282
|
+
error = document.createElement('div');
|
|
283
|
+
error.className = 'vet-field-error';
|
|
284
|
+
error.setAttribute('role', 'alert');
|
|
285
|
+
error.setAttribute('aria-live', 'polite');
|
|
286
|
+
cell.appendChild(error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function clearFieldError(control) {
|
|
292
|
+
var cell = findDataCell(control);
|
|
293
|
+
var error;
|
|
294
|
+
var row;
|
|
295
|
+
|
|
296
|
+
if (!cell) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
removeClassToken(cell, 'is-invalid');
|
|
300
|
+
error = cell.querySelector('.vet-field-error');
|
|
301
|
+
if (error) {
|
|
302
|
+
error.textContent = '';
|
|
303
|
+
}
|
|
304
|
+
control.removeAttribute('aria-invalid');
|
|
305
|
+
row = cell.parentNode;
|
|
306
|
+
if (row && !row.querySelector('.is-invalid')) {
|
|
307
|
+
removeClassToken(row, 'has-validation-errors');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function showFieldError(control, message) {
|
|
312
|
+
var cell = findDataCell(control);
|
|
313
|
+
var error;
|
|
314
|
+
|
|
315
|
+
if (!cell) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
addClassToken(cell, 'is-invalid');
|
|
319
|
+
addClassToken(cell.parentNode, 'has-validation-errors');
|
|
320
|
+
error = cell.querySelector('.vet-field-error');
|
|
321
|
+
if (error) {
|
|
322
|
+
error.textContent = message;
|
|
323
|
+
}
|
|
324
|
+
control.setAttribute('aria-invalid', 'true');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function resetRowValidation(row) {
|
|
328
|
+
var invalidCells = row.querySelectorAll('.is-invalid');
|
|
329
|
+
var controls = row.querySelectorAll('[data-field]');
|
|
330
|
+
var errors = row.querySelectorAll('.vet-field-error');
|
|
331
|
+
var i;
|
|
332
|
+
|
|
333
|
+
removeClassToken(row, 'is-invalid');
|
|
334
|
+
removeClassToken(row, 'has-validation-errors');
|
|
335
|
+
for (i = 0; i < invalidCells.length; i += 1) {
|
|
336
|
+
removeClassToken(invalidCells[i], 'is-invalid');
|
|
337
|
+
}
|
|
338
|
+
for (i = 0; i < controls.length; i += 1) {
|
|
339
|
+
controls[i].removeAttribute('aria-invalid');
|
|
340
|
+
}
|
|
341
|
+
for (i = 0; i < errors.length; i += 1) {
|
|
342
|
+
errors[i].textContent = '';
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function getFieldControl(row, fieldName) {
|
|
347
|
+
return row.querySelector('[data-field="' + fieldName + '"]');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function getFieldValue(row, fieldName) {
|
|
351
|
+
var control = getFieldControl(row, fieldName);
|
|
352
|
+
return trimValue(control ? control.value : '');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function rowIsEmpty(row) {
|
|
356
|
+
var i;
|
|
357
|
+
|
|
358
|
+
for (i = 0; i < validationFieldNames.length; i += 1) {
|
|
359
|
+
if (getFieldValue(row, validationFieldNames[i]) !== '') {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isValidDate(value) {
|
|
367
|
+
var match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
368
|
+
var year;
|
|
369
|
+
var month;
|
|
370
|
+
var day;
|
|
371
|
+
var leap;
|
|
372
|
+
var days;
|
|
373
|
+
|
|
374
|
+
if (!match) {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
year = Number(match[1]);
|
|
378
|
+
month = Number(match[2]);
|
|
379
|
+
day = Number(match[3]);
|
|
380
|
+
if (month < 1 || month > 12) {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
384
|
+
days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
385
|
+
return day >= 1 && day <= days[month - 1];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function validateField(row, fieldName, valid, message) {
|
|
389
|
+
var control = getFieldControl(row, fieldName);
|
|
390
|
+
|
|
391
|
+
if (!valid && control) {
|
|
392
|
+
showFieldError(control, message);
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function validateRow(row, force) {
|
|
399
|
+
var valid = true;
|
|
400
|
+
var value;
|
|
401
|
+
|
|
402
|
+
if (!force && rowIsEmpty(row)) {
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
value = getFieldValue(row, 'code');
|
|
406
|
+
valid = validateField(row, 'code', value !== '',
|
|
407
|
+
'상품 코드를 입력하세요.') && valid;
|
|
408
|
+
value = getFieldValue(row, 'name');
|
|
409
|
+
valid = validateField(row, 'name', value !== '',
|
|
410
|
+
'상품명을 입력하세요.') && valid;
|
|
411
|
+
value = getFieldValue(row, 'quantity');
|
|
412
|
+
valid = validateField(row, 'quantity', /^\d+$/.test(value) && Number(value) > 0,
|
|
413
|
+
'수량은 1 이상의 정수여야 합니다.') && valid;
|
|
414
|
+
value = getFieldValue(row, 'price');
|
|
415
|
+
valid = validateField(row, 'price',
|
|
416
|
+
/^\d+(?:\.\d+)?$/.test(value) && Number(value) >= 0,
|
|
417
|
+
'단가는 0 이상의 숫자여야 합니다.') && valid;
|
|
418
|
+
value = getFieldValue(row, 'receivedDate');
|
|
419
|
+
valid = validateField(row, 'receivedDate', isValidDate(value),
|
|
420
|
+
'입고일은 유효한 YYYY-MM-DD 형식이어야 합니다.') && valid;
|
|
421
|
+
value = getFieldValue(row, 'status');
|
|
422
|
+
valid = validateField(row, 'status', value !== '',
|
|
423
|
+
'상태를 선택하세요.') && valid;
|
|
424
|
+
return valid;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function validateTable() {
|
|
428
|
+
var rows = tableElement.querySelectorAll('tbody tr');
|
|
429
|
+
var controls;
|
|
430
|
+
var anyData = false;
|
|
431
|
+
var valid = true;
|
|
432
|
+
var firstInvalid;
|
|
433
|
+
var i;
|
|
434
|
+
var j;
|
|
435
|
+
|
|
436
|
+
initializeValidationFields(tableElement);
|
|
437
|
+
for (i = 0; i < rows.length; i += 1) {
|
|
438
|
+
controls = rows[i].querySelectorAll('[data-field]');
|
|
439
|
+
for (j = 0; j < controls.length; j += 1) {
|
|
440
|
+
clearFieldError(controls[j]);
|
|
441
|
+
}
|
|
442
|
+
if (!rowIsEmpty(rows[i])) {
|
|
443
|
+
anyData = true;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!anyData) {
|
|
448
|
+
valid = rows.length ? validateRow(rows[0], true) : false;
|
|
449
|
+
} else {
|
|
450
|
+
for (i = 0; i < rows.length; i += 1) {
|
|
451
|
+
if (!rowIsEmpty(rows[i]) && !validateRow(rows[i], false)) {
|
|
452
|
+
valid = false;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
firstInvalid = tableElement.querySelector('[aria-invalid="true"]');
|
|
458
|
+
if (firstInvalid && firstInvalid.focus) {
|
|
459
|
+
try {
|
|
460
|
+
firstInvalid.focus();
|
|
461
|
+
} catch (ignoreFocus) {
|
|
462
|
+
/* Older embedded browsers can reject programmatic focus. */
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return valid;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function reindexItemRows(table) {
|
|
469
|
+
var rows = table.querySelectorAll('tbody tr');
|
|
470
|
+
var fields;
|
|
471
|
+
var i;
|
|
472
|
+
var j;
|
|
473
|
+
|
|
474
|
+
for (i = 0; i < rows.length; i += 1) {
|
|
475
|
+
rows[i].getElementsByTagName('th')[0].textContent = i + 1;
|
|
476
|
+
fields = rows[i].querySelectorAll('[name]');
|
|
477
|
+
for (j = 0; j < fields.length; j += 1) {
|
|
478
|
+
fields[j].name = fields[j].name.replace(
|
|
479
|
+
/^items\[\d+\]/,
|
|
480
|
+
'items[' + i + ']'
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function prepareDatepickerRow(row) {
|
|
487
|
+
var $ = window.jQuery;
|
|
488
|
+
|
|
489
|
+
if (!$) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
$(row).find('.ui-datepicker-trigger, .ui-datepicker-append').remove();
|
|
493
|
+
$(row).find('input.js-datepicker').each(function () {
|
|
494
|
+
$.removeData(this, 'datepicker');
|
|
495
|
+
$(this).removeClass('hasDatepicker').removeAttr(
|
|
496
|
+
'aria-haspopup aria-expanded aria-controls aria-describedby aria-owns'
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function initializeDatepickers(table) {
|
|
502
|
+
var $ = window.jQuery;
|
|
503
|
+
|
|
504
|
+
if (!$ || !$.fn || !$.fn.datepicker) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
$(table).find('input.js-datepicker').each(function () {
|
|
508
|
+
if (!$.data(this, 'datepicker')) {
|
|
509
|
+
$(this)
|
|
510
|
+
.removeClass('hasDatepicker')
|
|
511
|
+
.siblings('.ui-datepicker-trigger, .ui-datepicker-append').remove();
|
|
512
|
+
$(this).datepicker({
|
|
513
|
+
dateFormat: 'yy-mm-dd',
|
|
514
|
+
onSelect: function () {
|
|
515
|
+
$(this).trigger('input').trigger('change');
|
|
516
|
+
},
|
|
517
|
+
onClose: function () {
|
|
518
|
+
var input = this;
|
|
519
|
+
window.setTimeout(function () {
|
|
520
|
+
if (document.activeElement === input) {
|
|
521
|
+
table.focus();
|
|
522
|
+
}
|
|
523
|
+
}, 0);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
jQuery(document).on(
|
|
531
|
+
'mousedown.virtualExcelDatepicker',
|
|
532
|
+
'#ui-datepicker-div a, #ui-datepicker-div button',
|
|
533
|
+
function (event) {
|
|
534
|
+
event.preventDefault();
|
|
535
|
+
}
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
initializeValidationFields(tableElement);
|
|
539
|
+
|
|
540
|
+
var excelTable = VirtualExcelTable('#excelTable', {
|
|
541
|
+
reindexRows: reindexItemRows,
|
|
542
|
+
beforeRowAdd: function (row) {
|
|
543
|
+
resetRowValidation(row);
|
|
544
|
+
prepareDatepickerRow(row);
|
|
545
|
+
},
|
|
546
|
+
afterRowsChange: function (rows) {
|
|
547
|
+
var i;
|
|
548
|
+
|
|
549
|
+
initializeValidationFields(tableElement);
|
|
550
|
+
for (i = 0; i < rows.length; i += 1) {
|
|
551
|
+
if (rows[i].parentNode && tableElement.contains(rows[i])) {
|
|
552
|
+
resetRowValidation(rows[i]);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
initializeDatepickers(tableElement);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
initializeDatepickers(tableElement);
|
|
559
|
+
|
|
560
|
+
function clearValidationOnEdit(event) {
|
|
561
|
+
var target = event.target || event.srcElement;
|
|
562
|
+
|
|
563
|
+
if (target && target.getAttribute && target.getAttribute('data-field') !== null) {
|
|
564
|
+
clearFieldError(target);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
tableElement.addEventListener('input', clearValidationOnEdit, false);
|
|
569
|
+
tableElement.addEventListener('change', clearValidationOnEdit, false);
|
|
570
|
+
|
|
571
|
+
document.getElementById('sampleButton').onclick = function () {
|
|
572
|
+
excelTable.select(0, 0);
|
|
573
|
+
excelTable.paste(
|
|
574
|
+
'A001\t사과\t10\t1200\t2026-07-10\t사용\r\n' +
|
|
575
|
+
'A002\t배\t5\t2500\t2026-07-11\t미사용\r\n' +
|
|
576
|
+
'A003\t복숭아\t8\t3000\t2026-07-12\t사용'
|
|
577
|
+
);
|
|
578
|
+
};
|
|
579
|
+
document.getElementById('registerButton').onclick = function () {
|
|
580
|
+
if (!validateTable()) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
document.getElementById('jsonOutput').textContent = JSON.stringify(
|
|
584
|
+
excelTable.getJsonData(null, { skipEmptyRows: true }),
|
|
585
|
+
null,
|
|
586
|
+
2
|
|
587
|
+
);
|
|
588
|
+
alert('유효성 검사를 통과했습니다. 실제 프로젝트에서는 여기서 폼을 제출하면 됩니다.');
|
|
589
|
+
};
|
|
590
|
+
document.getElementById('addRowButton').onclick = function () {
|
|
591
|
+
excelTable.addRow();
|
|
592
|
+
};
|
|
593
|
+
document.getElementById('undoButton').onclick = function () {
|
|
594
|
+
excelTable.undo();
|
|
595
|
+
};
|
|
596
|
+
document.getElementById('redoButton').onclick = function () {
|
|
597
|
+
excelTable.redo();
|
|
598
|
+
};
|
|
599
|
+
document.getElementById('deleteButton').onclick = function () {
|
|
600
|
+
excelTable.deleteSelection();
|
|
601
|
+
};
|
|
602
|
+
document.getElementById('copyButton').onclick = function () {
|
|
603
|
+
if (!excelTable.copy()) {
|
|
604
|
+
alert('선택 영역에서 Ctrl+C를 눌러 복사해 주세요.');
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
document.getElementById('deleteRowsButton').onclick = function () {
|
|
608
|
+
if (excelTable.deleteRows() === 0) {
|
|
609
|
+
alert('삭제할 행을 선택해 주세요. 마지막 한 행은 삭제할 수 없습니다.');
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
document.getElementById('selectAllButton').onclick = function () {
|
|
613
|
+
excelTable.selectAll();
|
|
614
|
+
};
|
|
615
|
+
document.getElementById('jsonButton').onclick = function () {
|
|
616
|
+
document.getElementById('jsonOutput').textContent = JSON.stringify(
|
|
617
|
+
excelTable.getJsonData(null, { skipEmptyRows: true }),
|
|
618
|
+
null,
|
|
619
|
+
2
|
|
620
|
+
);
|
|
621
|
+
};
|
|
622
|
+
</script>
|
|
623
|
+
</body>
|
|
624
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "virtual-excel-table",
|
|
3
|
+
"version": "2.2.0",
|
|
4
|
+
"description": "Dependency-free Excel-like editing, selection, clipboard, history, row operations, and JSON extraction for legacy HTML tables.",
|
|
5
|
+
"main": "src/main/webapp/resources/js/virtual-excel-table.js",
|
|
6
|
+
"browser": "src/main/webapp/resources/js/virtual-excel-table.js",
|
|
7
|
+
"unpkg": "src/main/webapp/resources/js/virtual-excel-table.js",
|
|
8
|
+
"jsdelivr": "src/main/webapp/resources/js/virtual-excel-table.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/main/webapp/resources/js/virtual-excel-table.js",
|
|
11
|
+
"./virtual-excel-table.js": "./src/main/webapp/resources/js/virtual-excel-table.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src/main/webapp/resources/js/virtual-excel-table.js",
|
|
15
|
+
"src/main/webapp/WEB-INF/views/virtual-excel-demo.jsp",
|
|
16
|
+
"demo.html",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"check": "node --check src/main/webapp/resources/js/virtual-excel-table.js",
|
|
21
|
+
"test": "node --test tests/virtual-excel-table.test.js",
|
|
22
|
+
"prepublishOnly": "npm run check && npm test"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"excel",
|
|
26
|
+
"table",
|
|
27
|
+
"spreadsheet",
|
|
28
|
+
"clipboard",
|
|
29
|
+
"paste",
|
|
30
|
+
"legacy",
|
|
31
|
+
"jsp",
|
|
32
|
+
"spring-mvc"
|
|
33
|
+
],
|
|
34
|
+
"license": "UNLICENSED",
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"registry": "https://registry.npmjs.org/"
|
|
38
|
+
}
|
|
39
|
+
}
|