@iyulab/data-components 0.1.4 → 0.1.7

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/CHANGELOG.md CHANGED
@@ -1,4 +1,12 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.1.7] - 2026-03-08
4
+ ### Added
5
+ - USimpleSheet: `compute` 콜백을 통한 열 단위 자동 계산 기능
6
+ - 가로(같은 행) 및 세로(행 간) 계산 모두 지원
7
+ - compute 열 자동 readonly 및 시각적 구분 (이탤릭 + 파란 배경)
8
+ - 에러 발생 시 빈 문자열 표시
9
+ - 붙여넣기/Fill 시 compute 열 건너뛰기
10
+
11
+ ## [0.1.6]
4
12
  - Initial library version release
package/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  데이터 표시 및 입력을 위한 웹 컴포넌트 라이브러리.
4
4
 
5
- - **USimpleSheet** — 엑셀 호환 스프레드시트 입력 컴포넌트 (Lit)
5
+ **[Live Demo](https://iyulab.github.io/node-data-components/)**
6
+
7
+ - **USimpleSheet** — 엑셀 호환 스프레드시트 입력 컴포넌트 (Lit, compute 자동 계산 지원)
6
8
  - **UDataView** — Grid / List / Table 뷰 전환 컴포넌트 (Lit)
7
9
  - **UDataGrid** — OData 기반 서버 사이드 데이터 그리드 (React + DevExtreme)
8
10
 
@@ -42,6 +44,25 @@ npm install @iyulab/data-components
42
44
  ></u-simple-sheet>
43
45
  ```
44
46
 
47
+ 자동 계산 열 (compute):
48
+
49
+ ```html
50
+ <u-simple-sheet
51
+ .columns=${[
52
+ { key: 'item', label: '품목', width: 150 },
53
+ { key: 'qty', label: '수량', width: 80 },
54
+ { key: 'price', label: '단가', width: 100 },
55
+ { key: 'total', label: '합계', width: 100,
56
+ compute: (r, data) => {
57
+ const qty = Number(data[r][1]) || 0;
58
+ const price = Number(data[r][2]) || 0;
59
+ return String(qty * price);
60
+ }
61
+ },
62
+ ]}
63
+ ></u-simple-sheet>
64
+ ```
65
+
45
66
  ### UDataView
46
67
 
47
68
  ```html
@@ -9,6 +9,12 @@ export interface SheetColumn {
9
9
  width?: number;
10
10
  /** 읽기 전용 열 */
11
11
  readonly?: boolean;
12
+ /** 선택 가능한 옵션 목록 (정적 배열 또는 동적 콜백) */
13
+ options?: string[] | ((row: number, col: number) => string[]);
14
+ /** true이면 목록에 있는 값만 입력 가능 (기본: false = 자유 입력 허용) */
15
+ strict?: boolean;
16
+ /** 자동 계산 함수. 설정 시 해당 열은 자동 readonly. 열 순서(좌→우), 행 순서(위→아래)로 계산. */
17
+ compute?: (rowIndex: number, data: string[][]) => string;
12
18
  }
13
19
  /**
14
20
  * USimpleSheet - 심플 스프레드시트 컴포넌트
@@ -44,6 +50,9 @@ export declare class USimpleSheet extends UElement {
44
50
  private _editing;
45
51
  private _editVal;
46
52
  private _replaceOnEdit;
53
+ private _dropdownItems;
54
+ private _dropdownIndex;
55
+ private _isDropdownClick;
47
56
  private _data;
48
57
  private _colWidths;
49
58
  private _isMouseSelecting;
@@ -79,6 +88,7 @@ export declare class USimpleSheet extends UElement {
79
88
  private _onInputChange;
80
89
  private _onInputKeyDown;
81
90
  private _onInputBlur;
91
+ private _onDropdownItemMouseDown;
82
92
  private _onTableMouseDown;
83
93
  private _onTableMouseOver;
84
94
  private _onTableDblClick;
@@ -112,6 +122,11 @@ export declare class USimpleSheet extends UElement {
112
122
  private _scrollCellIntoView;
113
123
  private _getCellFromEvent;
114
124
  private _isColReadonly;
125
+ private _isColComputed;
126
+ /** compute 열을 재계산 (열 순서 좌→우, 행 순서 위→아래) */
127
+ private _recompute;
128
+ private _getColOptions;
129
+ private _filterOptions;
115
130
  private _emitChange;
116
131
  /** 현재 데이터를 2D 배열로 반환 */
117
132
  getData(): string[][];
@@ -32,6 +32,9 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
32
32
  this._editing = null;
33
33
  this._editVal = "";
34
34
  this._replaceOnEdit = false;
35
+ this._dropdownItems = [];
36
+ this._dropdownIndex = -1;
37
+ this._isDropdownClick = false;
35
38
  // @state() 없음 — 직접 requestUpdate() 호출
36
39
  this._data = [];
37
40
  this._colWidths = [];
@@ -180,6 +183,7 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
180
183
  return;
181
184
  }
182
185
  if (!this.readonly && !this._isColReadonly(anchor.col) && e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
186
+ e.preventDefault();
183
187
  this._startEdit(anchor.row, anchor.col, e.key);
184
188
  }
185
189
  };
@@ -188,14 +192,46 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
188
192
  // ──────────────────────────────────────────
189
193
  this._onInputChange = (e) => {
190
194
  this._editVal = e.target.value;
195
+ if (this._editing) {
196
+ const options = this._getColOptions(this._editing.row, this._editing.col);
197
+ if (options) {
198
+ const filtered = this._filterOptions(options, this._editVal);
199
+ this._dropdownItems = filtered;
200
+ this._dropdownIndex = filtered.length > 0 ? 0 : -1;
201
+ }
202
+ }
191
203
  };
192
204
  this._onInputKeyDown = (e) => {
193
205
  e.stopPropagation();
206
+ const hasDropdown = this._dropdownItems.length > 0;
194
207
  if (e.key === "Escape") {
195
208
  e.preventDefault();
196
209
  this._cancelEdit();
197
210
  return;
198
211
  }
212
+ if (hasDropdown && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
213
+ e.preventDefault();
214
+ const len = this._dropdownItems.length;
215
+ if (e.key === "ArrowDown") {
216
+ this._dropdownIndex = (this._dropdownIndex + 1) % len;
217
+ } else {
218
+ this._dropdownIndex = (this._dropdownIndex - 1 + len) % len;
219
+ }
220
+ this.updateComplete.then(() => {
221
+ const dropdown = this.renderRoot.querySelector(".cell-dropdown");
222
+ const highlighted = this.renderRoot.querySelector(".dropdown-item.highlighted");
223
+ if (dropdown && highlighted) {
224
+ const dRect = dropdown.getBoundingClientRect();
225
+ const hRect = highlighted.getBoundingClientRect();
226
+ if (hRect.bottom > dRect.bottom) dropdown.scrollTop += hRect.bottom - dRect.bottom;
227
+ else if (hRect.top < dRect.top) dropdown.scrollTop -= dRect.top - hRect.top;
228
+ }
229
+ });
230
+ return;
231
+ }
232
+ if (hasDropdown && this._dropdownIndex >= 0 && (e.key === "Enter" || e.key === "Tab")) {
233
+ this._editVal = this._dropdownItems[this._dropdownIndex];
234
+ }
199
235
  if (e.key === "Enter") {
200
236
  e.preventDefault();
201
237
  const pos = this._editing;
@@ -248,10 +284,20 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
248
284
  }
249
285
  };
250
286
  this._onInputBlur = () => {
287
+ if (this._isDropdownClick) {
288
+ this._isDropdownClick = false;
289
+ return;
290
+ }
251
291
  if (this._editing) {
252
292
  this._commitEdit();
253
293
  }
254
294
  };
295
+ this._onDropdownItemMouseDown = (e, value) => {
296
+ e.preventDefault();
297
+ this._isDropdownClick = true;
298
+ this._editVal = value;
299
+ this._commitEdit();
300
+ };
255
301
  // ──────────────────────────────────────────
256
302
  // 마우스 이벤트
257
303
  // ──────────────────────────────────────────
@@ -431,6 +477,7 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
431
477
  newData.push(row);
432
478
  }
433
479
  this._data = newData;
480
+ this._recompute();
434
481
  this._history = [this._data.map((r) => [...r])];
435
482
  this._historyIndex = 0;
436
483
  const prevWidths = this._colWidths;
@@ -464,6 +511,7 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
464
511
  if (this._historyIndex <= 0) return;
465
512
  this._historyIndex--;
466
513
  this._data = this._history[this._historyIndex].map((r) => [...r]);
514
+ this._recompute();
467
515
  this._emitChange();
468
516
  this.requestUpdate();
469
517
  }
@@ -471,6 +519,7 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
471
519
  if (this._historyIndex >= this._history.length - 1) return;
472
520
  this._historyIndex++;
473
521
  this._data = this._history[this._historyIndex].map((r) => [...r]);
522
+ this._recompute();
474
523
  this._emitChange();
475
524
  this.requestUpdate();
476
525
  }
@@ -573,12 +622,20 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
573
622
  const isSelected = this._inSel(r, c);
574
623
  const isAnchor = this._isAnchor(r, c);
575
624
  const value = this._data[r]?.[c] ?? "";
625
+ const isColReadonly = this._isColReadonly(c);
626
+ const isComputed = this._isColComputed(c);
576
627
  const classes = [
577
628
  "cell",
578
629
  isSelected ? "selected" : "",
579
630
  isAnchor ? "anchor" : "",
580
- isEditing ? "editing" : ""
631
+ isEditing ? "editing" : "",
632
+ isColReadonly ? "cell-readonly" : "",
633
+ isComputed ? "cell-computed" : ""
581
634
  ].filter(Boolean).join(" ");
635
+ const hasOptions = isEditing && this._getColOptions(r, c) !== null;
636
+ const showDropdown = isEditing && this._dropdownItems.length > 0;
637
+ const isStrict = this.columns?.[c]?.strict ?? false;
638
+ const noMatch = isEditing && hasOptions && this._dropdownItems.length === 0 && this._editVal !== "";
582
639
  return html`
583
640
  <td
584
641
  class=${classes}
@@ -593,6 +650,20 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
593
650
  @keydown=${this._onInputKeyDown}
594
651
  @blur=${this._onInputBlur}
595
652
  />
653
+ ${showDropdown ? html`
654
+ <div class="cell-dropdown">
655
+ ${this._dropdownItems.map((item, i) => html`
656
+ <div
657
+ class="dropdown-item ${i === this._dropdownIndex ? "highlighted" : ""}"
658
+ @mousedown=${(e) => this._onDropdownItemMouseDown(e, item)}
659
+ >${item}</div>
660
+ `)}
661
+ </div>
662
+ ` : noMatch && isStrict ? html`
663
+ <div class="cell-dropdown">
664
+ <div class="dropdown-empty">일치하는 항목 없음</div>
665
+ </div>
666
+ ` : ""}
596
667
  ` : value}
597
668
  </td>
598
669
  `;
@@ -630,12 +701,13 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
630
701
  for (let ci = 0; ci < pasteRows[ri].length; ci++) {
631
702
  const r = anchor.row + ri;
632
703
  const c = anchor.col + ci;
633
- if (r < newData.length && c < (newData[r]?.length ?? 0)) {
704
+ if (r < newData.length && c < (newData[r]?.length ?? 0) && !this._isColComputed(c)) {
634
705
  newData[r][c] = pasteRows[ri][ci];
635
706
  }
636
707
  }
637
708
  }
638
709
  this._data = newData;
710
+ this._recompute();
639
711
  this._pushHistory();
640
712
  this._emitChange();
641
713
  this.requestUpdate();
@@ -658,6 +730,7 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
658
730
  }
659
731
  }
660
732
  this._data = newData;
733
+ this._recompute();
661
734
  this._pushHistory();
662
735
  this._emitChange();
663
736
  this.requestUpdate();
@@ -677,6 +750,7 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
677
750
  }
678
751
  }
679
752
  this._data = newData;
753
+ this._recompute();
680
754
  this._pushHistory();
681
755
  this._emitChange();
682
756
  this.requestUpdate();
@@ -692,6 +766,15 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
692
766
  this._editVal = typedChar !== void 0 ? typedChar : this._data[row]?.[col] ?? "";
693
767
  this._editing = { row, col };
694
768
  this._select(row, col);
769
+ const options = this._getColOptions(row, col);
770
+ if (options) {
771
+ const filtered = this._filterOptions(options, this._editVal);
772
+ this._dropdownItems = filtered;
773
+ this._dropdownIndex = filtered.length > 0 ? 0 : -1;
774
+ } else {
775
+ this._dropdownItems = [];
776
+ this._dropdownIndex = -1;
777
+ }
695
778
  this.updateComplete.then(() => {
696
779
  const input = this.renderRoot.querySelector(".cell-input");
697
780
  if (input) {
@@ -708,12 +791,28 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
708
791
  _commitEdit() {
709
792
  if (!this._editing) return;
710
793
  const { row, col } = this._editing;
794
+ const colDef = this.columns?.[col];
795
+ if (colDef?.strict && colDef?.options && this._editVal !== "") {
796
+ const allOptions = this._getColOptions(row, col) ?? [];
797
+ if (!allOptions.includes(this._editVal)) {
798
+ this._editing = null;
799
+ this._dropdownItems = [];
800
+ this._dropdownIndex = -1;
801
+ this._isDropdownClick = false;
802
+ this.requestUpdate();
803
+ this._containerEl?.focus();
804
+ return;
805
+ }
806
+ }
711
807
  const prevVal = this._data[row]?.[col] ?? "";
712
808
  const newData = this._data.map((r) => [...r]);
713
809
  newData[row][col] = this._editVal;
714
810
  this._data = newData;
715
811
  this._editing = null;
812
+ this._dropdownItems = [];
813
+ this._dropdownIndex = -1;
716
814
  if (this._editVal !== prevVal) {
815
+ this._recompute();
717
816
  this._pushHistory();
718
817
  this._emitChange();
719
818
  }
@@ -722,6 +821,8 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
722
821
  }
723
822
  _cancelEdit() {
724
823
  this._editing = null;
824
+ this._dropdownItems = [];
825
+ this._dropdownIndex = -1;
725
826
  this.requestUpdate();
726
827
  this._containerEl?.focus();
727
828
  }
@@ -735,6 +836,7 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
735
836
  }
736
837
  }
737
838
  this._data = newData;
839
+ this._recompute();
738
840
  this._pushHistory();
739
841
  this._emitChange();
740
842
  this.requestUpdate();
@@ -764,7 +866,35 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
764
866
  return { row, col };
765
867
  }
766
868
  _isColReadonly(col) {
767
- return this.columns?.[col]?.readonly ?? false;
869
+ return (this.columns?.[col]?.readonly ?? false) || this._isColComputed(col);
870
+ }
871
+ _isColComputed(col) {
872
+ return typeof this.columns?.[col]?.compute === "function";
873
+ }
874
+ /** compute 열을 재계산 (열 순서 좌→우, 행 순서 위→아래) */
875
+ _recompute() {
876
+ if (!this.columns?.some((c) => c.compute)) return;
877
+ for (let c = 0; c < this._colCount; c++) {
878
+ const fn = this.columns?.[c]?.compute;
879
+ if (!fn) continue;
880
+ for (let r = 0; r < this._rowCount; r++) {
881
+ try {
882
+ this._data[r][c] = fn(r, this._data);
883
+ } catch {
884
+ this._data[r][c] = "";
885
+ }
886
+ }
887
+ }
888
+ }
889
+ _getColOptions(row, col) {
890
+ const colDef = this.columns?.[col];
891
+ if (!colDef?.options) return null;
892
+ return typeof colDef.options === "function" ? colDef.options(row, col) : colDef.options;
893
+ }
894
+ _filterOptions(options, query) {
895
+ if (!query) return options;
896
+ const lower = query.toLowerCase();
897
+ return options.filter((o) => o.toLowerCase().includes(lower));
768
898
  }
769
899
  _emitChange() {
770
900
  this.emit("change", { data: this._data });
@@ -804,6 +934,7 @@ const _USimpleSheet = class _USimpleSheet extends UElement {
804
934
  const newData = this._data.map((r) => [...r]);
805
935
  newData[row][col] = value;
806
936
  this._data = newData;
937
+ this._recompute();
807
938
  this._pushHistory();
808
939
  this._emitChange();
809
940
  this.requestUpdate();
@@ -850,6 +981,12 @@ __decorateClass([
850
981
  __decorateClass([
851
982
  state()
852
983
  ], _USimpleSheet.prototype, "_replaceOnEdit");
984
+ __decorateClass([
985
+ state()
986
+ ], _USimpleSheet.prototype, "_dropdownItems");
987
+ __decorateClass([
988
+ state()
989
+ ], _USimpleSheet.prototype, "_dropdownIndex");
853
990
  let USimpleSheet = _USimpleSheet;
854
991
 
855
992
  export { USimpleSheet };
@@ -197,7 +197,81 @@ const styles = css`
197
197
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
198
198
  }
199
199
 
200
- /* Readonly mode */
200
+ /* Dropdown overlay */
201
+ .cell-dropdown {
202
+ position: absolute;
203
+ top: 100%;
204
+ left: -1px;
205
+ width: calc(100% + 2px);
206
+ min-width: 120px;
207
+ max-height: 200px;
208
+ overflow-y: auto;
209
+ background: var(--u-bg-color, #fff);
210
+ border: 1px solid var(--u-border-color, #e2e8f0);
211
+ border-top: none;
212
+ border-radius: 0 0 4px 4px;
213
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
214
+ z-index: 20;
215
+ }
216
+
217
+ .dropdown-item {
218
+ padding: 4px 8px;
219
+ font-size: 13px;
220
+ color: var(--u-txt-color, #0f172a);
221
+ cursor: pointer;
222
+ white-space: nowrap;
223
+ overflow: hidden;
224
+ text-overflow: ellipsis;
225
+ }
226
+
227
+ .dropdown-item:hover {
228
+ background: var(--u-neutral-100, #f1f5f9);
229
+ }
230
+
231
+ .dropdown-item.highlighted {
232
+ background: var(--u-blue-50, #eff6ff);
233
+ color: var(--u-blue-800, #1e40af);
234
+ }
235
+
236
+ .dropdown-empty {
237
+ padding: 6px 8px;
238
+ font-size: 12px;
239
+ color: var(--u-txt-color-weak, #64748b);
240
+ font-style: italic;
241
+ }
242
+
243
+ /* Readonly column/cell */
244
+ .cell.cell-readonly {
245
+ background: var(--u-neutral-50, #f8fafc);
246
+ color: var(--u-txt-color-weak, #64748b);
247
+ cursor: default;
248
+ }
249
+
250
+ .cell.cell-readonly.selected {
251
+ background: var(--u-neutral-100, #f1f5f9);
252
+ }
253
+
254
+ .cell.cell-readonly.anchor {
255
+ background: var(--u-neutral-50, #f8fafc);
256
+ }
257
+
258
+ /* Computed column/cell */
259
+ .cell.cell-computed {
260
+ background: var(--u-blue-50, #eff6ff);
261
+ color: var(--u-txt-color, #0f172a);
262
+ font-style: italic;
263
+ cursor: default;
264
+ }
265
+
266
+ .cell.cell-computed.selected {
267
+ background: var(--u-blue-100, #dbeafe);
268
+ }
269
+
270
+ .cell.cell-computed.anchor {
271
+ background: var(--u-blue-50, #eff6ff);
272
+ }
273
+
274
+ /* Readonly mode (whole sheet) */
201
275
  :host([readonly]) .cell {
202
276
  cursor: default;
203
277
  }
@@ -254,6 +328,32 @@ const styles = css`
254
328
  border-bottom-color: var(--u-border-color-weak, #2A2A2A);
255
329
  }
256
330
 
331
+ :host-context([theme="dark"]) .cell.cell-readonly {
332
+ background: var(--u-neutral-100, #1E1E1E);
333
+ color: var(--u-txt-color-weak, #8A8A8A);
334
+ }
335
+
336
+ :host-context([theme="dark"]) .cell.cell-readonly.selected {
337
+ background: var(--u-neutral-200, #2A2A2A);
338
+ }
339
+
340
+ :host-context([theme="dark"]) .cell.cell-readonly.anchor {
341
+ background: var(--u-neutral-100, #1E1E1E);
342
+ }
343
+
344
+ :host-context([theme="dark"]) .cell.cell-computed {
345
+ background: var(--u-blue-100, #1e3a5f);
346
+ color: var(--u-txt-color, #D4D4D4);
347
+ }
348
+
349
+ :host-context([theme="dark"]) .cell.cell-computed.selected {
350
+ background: var(--u-blue-200, #2a4a6f);
351
+ }
352
+
353
+ :host-context([theme="dark"]) .cell.cell-computed.anchor {
354
+ background: var(--u-blue-100, #1e3a5f);
355
+ }
356
+
257
357
  :host-context([theme="dark"]) .cell.selected {
258
358
  background: var(--u-blue-0, #0a1e3d);
259
359
  }
@@ -273,6 +373,29 @@ const styles = css`
273
373
  outline-color: var(--u-blue-500, #6ba3e3);
274
374
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
275
375
  }
376
+
377
+ :host-context([theme="dark"]) .cell-dropdown {
378
+ background: var(--u-bg-color, #121212);
379
+ border-color: var(--u-border-color, #3D3D3D);
380
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
381
+ }
382
+
383
+ :host-context([theme="dark"]) .dropdown-item {
384
+ color: var(--u-txt-color, #D4D4D4);
385
+ }
386
+
387
+ :host-context([theme="dark"]) .dropdown-item:hover {
388
+ background: var(--u-neutral-100, #1E1E1E);
389
+ }
390
+
391
+ :host-context([theme="dark"]) .dropdown-item.highlighted {
392
+ background: var(--u-blue-100, #1e3a5f);
393
+ color: var(--u-blue-800, #c2deff);
394
+ }
395
+
396
+ :host-context([theme="dark"]) .dropdown-empty {
397
+ color: var(--u-txt-color-weak, #8A8A8A);
398
+ }
276
399
  `;
277
400
 
278
401
  export { styles };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@iyulab/data-components",
3
3
  "description": "iyulab data visualization components",
4
- "version": "0.1.4",
4
+ "version": "0.1.7",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-components",