@iyulab/data-components 0.11.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,7 +1,106 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.13.0] - 2026-08-05
4
+
5
+ ### Added
6
+
7
+ - **Agent skill pack ships with the package** (`skills/iyulab-data-components`).
8
+ Until now the only way to learn the API was to read the emitted `.d.ts` files, which
9
+ made the internal file layout a de-facto contract and left the *combinations* — server
10
+ paging together with selection — undocumented. The reference for `u-rich-table` opens
11
+ with the two things that actually bite: every row needs a unique `_id` (without it a
12
+ single selection looks like "everything is selected"), and the app owns the query
13
+ (`totalCount` is not `data.length`).
14
+
15
+ ```bash
16
+ npx skills add ./node_modules/@iyulab/data-components/skills/iyulab-data-components
17
+ ```
18
+
19
+ - **The public types `u-rich-table` asks for are exported from the package root** —
20
+ `ColumnDef`, `CellPosition`, `SortState`, `FilterState`, `RichTableEventMap`.
21
+ `columns` requires `ColumnDef[]`, but that name could not be imported from the root, so
22
+ consumers had to deep-import an internal path or hand-copy the shape (which then drifts
23
+ silently when the upstream type changes).
24
+
25
+ ```ts
26
+ import type { ColumnDef, RichTableEventMap } from '@iyulab/data-components';
27
+ ```
28
+
29
+ ### Fixed
30
+
31
+ - **`u-data-view`: the layout toggle buttons did nothing.** Grid / list / table were
32
+ rendered but never wired to a click handler, so `mode` could only be changed by setting
33
+ the property — and the "selected" highlight was therefore stuck on `grid` forever.
34
+ The buttons now switch the layout and expose their state via `aria-pressed`.
35
+
36
+ ### Removed
37
+
38
+ - **`u-data-view`: unreachable selected-row styling.** The `.selected` rules were driven
39
+ by an internal index that was never assigned, so they could not apply. Removed along
40
+ with the dead state.
41
+
42
+ ## [0.12.0] - 2026-08-04
43
+
44
+ ### Fixed
45
+
46
+ - 🔴**`u-rich-table` 의 전체선택이 다른 페이지의 선택을 조용히 파괴했다.** 서버 페이징
47
+ (`totalCount`/`currentPage`/`page-change`)에서 선택 상태는 페이지를 넘어 유지되는데,
48
+ 전체선택 체크박스만 그 사실을 모르고 있었다:
49
+ - 체크 상태를 **«누적 선택 수 == 이 페이지 행 수»** 로 계산했다 ⇒ 1페이지를 전량 선택하고
50
+ 2페이지로 넘어가면 **아무것도 고르지 않았는데 켜져 보였다.**
51
+ - 그 상태에서 체크박스를 끄면 선택 집합을 **통째로 비웠다** ⇒ 사용자에게는 *"이 페이지 해제"*
52
+ 로 보이는 조작이 1페이지의 선택을 말없이 지웠다.
53
+ - 켜는 쪽도 합집합이 아니라 **치환**이었다 ⇒ 2페이지에서 전체선택하면 1페이지 선택분이 사라졌다.
54
+ `Ctrl`/`Cmd` + `A` 도 같은 경로였다.
55
+
56
+ ⇒ 전체선택/해제의 범위를 **현재 페이지**로 확정했다(합집합·차집합). 체크박스의 분자와 분모가
57
+ 모두 현재 페이지 기준이 되어, 다른 페이지의 선택분이 이 페이지의 표시로 새지 않는다.
58
+
59
+ ⚠**단일 페이지 사용(`totalCount = 0`)에서는 동작이 종전과 완전히 같다** — 현재 페이지가
60
+ 곧 전체이므로 합집합·차집합이 치환·전역소거와 일치한다.
61
+
62
+ ### Added
63
+
64
+ - **선택 집합을 읽고 쓸 수 있다** — 종전에는 선택 상태가 `private` 이라 소비앱이 «해제»와
65
+ «다른 페이지에 있음»을 구분할 방법이 없었고, 그래서 추정할 수밖에 없었다.
66
+ - **`selectedRowIds`** *(getter)* — 페이지를 가로질러 누적된 **모든** 선택 식별자.
67
+ `getSelectedRows()` 는 현재 페이지의 행 객체만 주므로(갖고 있지 않은 행을 만들 수 없다)
68
+ *"여러 페이지에 걸쳐 고른 뒤 일괄 처리"* 에는 이쪽이 필요하다. **스냅샷**이라 돌려받은
69
+ 집합을 고쳐도 내부 상태가 흔들리지 않는다.
70
+ - **`setSelection(ids)`** — 선택을 통째로 대체한다. 현재 페이지에 없는 식별자도 받는다.
71
+ ⚠**같은 집합이면 아무 일도 하지 않는다** — `selection-change` 를 받아 앱 상태를 갱신하고
72
+ 다시 이것을 부르는 배선이 자연스러운데, 무조건 발생시키면 그 자리가 무한 루프가 된다.
73
+ - **`clearSelection()`** — 누적분까지 전부 비운다. 전체선택 체크박스가 «이 페이지» 범위가
74
+ 되면서 전역 소거의 자리가 없어졌고, 일괄 처리 후 앱이 상태를 되돌리는 경로가 여기다.
75
+ - **`selection-change` detail 에 `selectedIds: string[]` 추가** — `selectedRows`(현재 페이지)와
76
+ 나란히 누적 식별자를 싣는다. 두 필드의 길이가 다른 것이 서버 페이징에서는 정상이다.
77
+ - **`select-all` 이벤트** — 사용자가 전체선택 체크박스를 조작했다는 **의도**를 따로 알린다.
78
+ `selection-change` 만으로는 *"세 행을 골랐다"* 와 *"전체선택을 눌렀다"* 가 구분되지 않는데,
79
+ 서버 페이징에서 후자는 앱이 *"조건에 맞는 N건 전부"* 를 제안할 자리다.
80
+ ⚠**`scope: 'page' | 'all'` 필드는 두지 않았다** — 이 컴포넌트는 `'all'` 을 낼 수 없다.
81
+ 전역 전체선택은 식별자 목록이 아니라 **조회 조건**이라 컴포넌트가 표현할 수 있는 것이 아니고,
82
+ 값이 하나뿐인 유니온은 라이브러리가 모르는 개념을 아는 척하는 필드가 된다.
83
+
84
+ ### Changed
85
+
86
+ - **선택 건수 라벨이 두 숫자를 구분해 알린다** — 선택이 페이지를 가로지르면
87
+ `4 selected (1 on this page)` 처럼 누적과 이 페이지 몫을 함께 낸다. 한 페이지 안에 머무는
88
+ 동안에는 종전대로 `3 selected` 다. 두 숫자가 다르다는 사실 자체가 사용자에게 보여야 한다.
89
+ - **선택 건수·페이지 크기 문구가 로케일 레지스트리로 이주했다**(`selected` ·
90
+ `selectedAcrossPages` · `rowsPerPage`). `0.11.0` 의 이주가 중첩 템플릿 안의 두 건을
91
+ 남겨 두어, 다른 문구가 전부 영어인 환경에서 이 둘만 한국어로 나왔다.
92
+
3
93
  ## [0.11.0] - 2026-08-04
4
94
 
95
+ ### Fixed
96
+
97
+ - 🔴**`LICENSE` 파일이 없어 게시본에 MIT 원문이 빠져 있었다.** `package.json` 이
98
+ `"license": "MIT"` 를 선언하고 `files` 에 `LICENSE` 를 나열했지만 파일 자체가 없었다
99
+ ⇒ `0.10.0` 까지의 tarball 에 라이선스 원문이 들어 있지 않다. MIT 는 *"위 저작권 고지와
100
+ 이 허가 고지를 모든 사본에 포함시켜야 한다"* 는 조건부 허가이므로, 원문 누락은 그 조건을
101
+ 스스로 이행하지 못한 상태였다. ⚠`npm view … license` 가 `MIT` 를 보여 주는 것은
102
+ `package.json` 필드를 읽은 것이지 **원문이 배포됐다는 뜻이 아니다.**
103
+
5
104
  ### Changed
6
105
 
7
106
  - **`u-rich-table` 의 `_id` 부재 경고가 영어로 이주했다** — 개발자 대면 진단이므로 로케일
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 iyulab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -18,6 +18,23 @@
18
18
  npm install @iyulab/data-components
19
19
  ```
20
20
 
21
+ ## 🤖 Skills Usage
22
+
23
+ 이 패키지는 AI 코딩 에이전트가 라이브러리를 이해하고 사용하도록 돕는
24
+ [Agent Skill](https://agentskills.io/)을 포함합니다.
25
+
26
+ **GitHub 경유 (권장):**
27
+
28
+ ```bash
29
+ npx skills add iyulab/node-data-components
30
+ ```
31
+
32
+ **로컬 `node_modules` 경유:**
33
+
34
+ ```bash
35
+ npx skills add ./node_modules/@iyulab/data-components/skills/iyulab-data-components
36
+ ```
37
+
21
38
  ## Quick Start
22
39
 
23
40
  ### USimpleSheet
@@ -34,9 +34,17 @@ export declare class UDataView extends UElement {
34
34
  renderCard?: (item: DataItem, index: number) => TemplateResult;
35
35
  /** 커스텀 셀 렌더 함수 (table용) */
36
36
  renderCell?: (item: DataItem, column: DataColumn, index: number) => TemplateResult | string;
37
- private selectedIndex;
38
37
  render(): TemplateResult<1>;
39
38
  private renderToolbar;
39
+ /**
40
+ * 레이아웃 전환 버튼.
41
+ *
42
+ * ⚠**선택 상태를 `variant`+`color` 로 나타낸다.** 종전에는 `?active` 로 이 시트의
43
+ * `u-button[active]` 규칙(배경 한 줄)을 켰는데, 그 규칙은 자기 주석에 *"주색 위의 글자
44
+ * 대비가 3.45~3.68 로 최선이 아니다"* 라고 적고 있었다. `UButton` 자신의 `variant`·`color`
45
+ * 를 쓰면 그 대비 계약을 컴포넌트가 책임진다.
46
+ * 접근성은 `aria-pressed` 가 나른다 — 색만으로는 토글 상태가 보조기술에 닿지 않는다.
47
+ */
40
48
  private renderViewButton;
41
49
  private renderContent;
42
50
  private renderGrid;
@@ -2,7 +2,7 @@ import { styles } from "./UDataView.styles.js";
2
2
  import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js";
3
3
  import __decorate from "../../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js";
4
4
  import { html } from "lit";
5
- import { customElement, property, state } from "lit/decorators.js";
5
+ import { customElement, property } from "lit/decorators.js";
6
6
  import "@iyulab/components/dist/components/icon/UIcon.js";
7
7
  import "@iyulab/components/dist/components/button/UButton.js";
8
8
  import { UElement } from "@iyulab/components/dist/components/UElement.js";
@@ -14,7 +14,6 @@ var UDataView = class UDataView extends UElement {
14
14
  this.mode = "grid";
15
15
  this.gridMinWidth = "200px";
16
16
  this.gap = "1rem";
17
- this.selectedIndex = null;
18
17
  }
19
18
  static {
20
19
  this.styles = [super.styles, styles];
@@ -41,11 +40,27 @@ var UDataView = class UDataView extends UElement {
41
40
  </div>
42
41
  `;
43
42
  }
43
+ /**
44
+ * 레이아웃 전환 버튼.
45
+ *
46
+ * ⚠**선택 상태를 `variant`+`color` 로 나타낸다.** 종전에는 `?active` 로 이 시트의
47
+ * `u-button[active]` 규칙(배경 한 줄)을 켰는데, 그 규칙은 자기 주석에 *"주색 위의 글자
48
+ * 대비가 3.45~3.68 로 최선이 아니다"* 라고 적고 있었다. `UButton` 자신의 `variant`·`color`
49
+ * 를 쓰면 그 대비 계약을 컴포넌트가 책임진다.
50
+ * 접근성은 `aria-pressed` 가 나른다 — 색만으로는 토글 상태가 보조기술에 닿지 않는다.
51
+ */
44
52
  renderViewButton(mode, icon, label) {
53
+ const selected = this.mode === mode;
45
54
  return html`
46
55
  <u-button
47
- ?active=${this.mode === mode}
56
+ variant=${selected ? "solid" : "ghost"}
57
+ color=${selected ? "primary" : "neutral"}
48
58
  title=${label}
59
+ aria-label=${label}
60
+ aria-pressed=${selected ? "true" : "false"}
61
+ @click=${() => {
62
+ this.mode = mode;
63
+ }}
49
64
  >
50
65
  <u-icon lib="bootstrap" name=${icon}></u-icon>
51
66
  </u-button>
@@ -89,9 +104,7 @@ var UDataView = class UDataView extends UElement {
89
104
  </thead>
90
105
  <tbody>
91
106
  ${this.items.map((item, index) => html`
92
- <tr
93
- class=${this.selectedIndex === index ? "selected" : ""}
94
- >
107
+ <tr>
95
108
  ${cols.map((col) => html`
96
109
  <td>${this.getCellContent(item, col, index)}</td>
97
110
  `)}
@@ -105,9 +118,7 @@ var UDataView = class UDataView extends UElement {
105
118
  renderGridItem(item, index) {
106
119
  const content = this.renderCard ? this.renderCard(item, index) : this.renderDefaultCard(item);
107
120
  return html`
108
- <div
109
- class="card ${this.selectedIndex === index ? "selected" : ""}"
110
- >
121
+ <div class="card">
111
122
  ${content}
112
123
  </div>
113
124
  `;
@@ -115,9 +126,7 @@ var UDataView = class UDataView extends UElement {
115
126
  renderListItem(item, index) {
116
127
  const content = this.renderCard ? this.renderCard(item, index) : this.renderDefaultCard(item);
117
128
  return html`
118
- <div
119
- class="card list-card ${this.selectedIndex === index ? "selected" : ""}"
120
- >
129
+ <div class="card list-card">
121
130
  ${content}
122
131
  </div>
123
132
  `;
@@ -168,7 +177,6 @@ __decorate([property({ type: String }), __decorateMetadata("design:type", Object
168
177
  __decorate([property({ type: String }), __decorateMetadata("design:type", Object)], UDataView.prototype, "gap", void 0);
169
178
  __decorate([property({ attribute: false }), __decorateMetadata("design:type", Function)], UDataView.prototype, "renderCard", void 0);
170
179
  __decorate([property({ attribute: false }), __decorateMetadata("design:type", Function)], UDataView.prototype, "renderCell", void 0);
171
- __decorate([state(), __decorateMetadata("design:type", Object)], UDataView.prototype, "selectedIndex", void 0);
172
180
  UDataView = __decorate([customElement("u-data-view")], UDataView);
173
181
  //#endregion
174
182
  export { UDataView };
@@ -44,15 +44,10 @@ var styles = [css`
44
44
  gap: 0.25rem;
45
45
  }
46
46
 
47
- .view-toggles u-button[active] {
48
- background: var(--u-primary-color, #1976D2);
49
- /* 예외 1"주색 위의 글자" 역할 토큰이 없다.
50
- 역할 층의 유채색은 전경 5단(--u-primary-color-*)뿐이고,위에 얹는 글자색을
51
- 가리키는 토큰은 없다. 실측: 라이트 #FFFFFF/#1E88E5 = 3.68, 다크 #000000/#2A659D
52
- = 3.45 — 다크는 흰 글자였다면 6.09 였다. 즉 현재 값이 최선이 아니지만, 팔레트를
53
- 직접 바꾸면 게시된 시각이 움직이므로 토큰 추가 결정까지 현행을 유지한다. */
54
- color: var(--u-neutral-0, #FFFFFF);
55
- }
47
+ /* 선택된 레이아웃 버튼의 표기는 UButton 의 variant+color 가 맡는다(cycle-244).
48
+ 종전의 u-button[active] 규칙은 배경만 바꾸면서 그 위 글자 대비가 3.45~3.68 이라고
49
+ 스스로 적고 있었다컴포넌트의 계약을 우회하지 않고 그쪽에 위임한다.
50
+ (주석 백틱 금지: css 태그드 템플릿을 자리에서 끝낸다) */
56
51
 
57
52
  .info {
58
53
  font-size: 0.875rem;
@@ -90,12 +85,6 @@ var styles = [css`
90
85
  transform: translateY(-2px);
91
86
  }
92
87
 
93
- .card.selected {
94
- border-color: var(--u-primary-color, #1976D2);
95
- background: var(--u-bg-color-active, #EEEEEE);
96
- box-shadow: 0 0 0 3px var(--u-primary-color-weakest, #90CAF9);
97
- }
98
-
99
88
  .card-content {
100
89
  display: flex;
101
90
  flex-direction: column;
@@ -177,10 +166,6 @@ var styles = [css`
177
166
  background: var(--u-bg-color-hover, #F5F5F5);
178
167
  }
179
168
 
180
- tbody tr.selected {
181
- background: var(--u-bg-color-active, #EEEEEE);
182
- }
183
-
184
169
  tbody tr:not(:last-child) {
185
170
  border-bottom: 1px solid var(--u-border-color-weak, #EEEEEE);
186
171
  }
@@ -40,6 +40,35 @@ export declare class URichTable extends LitElement {
40
40
  revertRow(_rowId: string): void;
41
41
  setRowError(rowId: string, message: string): void;
42
42
  clearRowError(rowId: string): void;
43
+ /**
44
+ * 선택된 행의 **식별자 집합** — 페이지를 가로질러 누적된 전부.
45
+ *
46
+ * `getSelectedRows()` 는 현재 페이지의 행 객체만 돌려주므로(갖고 있지 않은 행을 만들 수 없다)
47
+ * *"여러 페이지에 걸쳐 고른 뒤 일괄 처리"* 에는 이쪽이 필요하다.
48
+ *
49
+ * ⚠**스냅샷이다.** 내부 집합을 그대로 넘기면 `ReadonlySet` 이 타입 수준 약속일 뿐이라
50
+ * JS 소비자가 `add` 로 내부 상태를 **갱신 신호 없이** 망가뜨릴 수 있다. 복제 비용은
51
+ * 선택 크기에 비례하고 무시할 수준이다.
52
+ */
53
+ get selectedRowIds(): ReadonlySet<string>;
54
+ /**
55
+ * 선택을 **통째로 대체**한다 — 페이지를 가로지르는 누적분까지.
56
+ *
57
+ * 앱이 자기 상태를 정본으로 삼는 경우(외부 저장·복원, 필터 변경 시 정리)의 진입점이다.
58
+ * 현재 페이지에 없는 식별자를 넣어도 된다 — 그 페이지로 이동하면 선택된 것으로 렌더된다.
59
+ *
60
+ * ⚠**같은 집합이면 아무 일도 하지 않는다.** 앱이 `selection-change` 를 받아 자기 상태를
61
+ * 갱신하고 다시 이것을 부르는 것이 자연스러운 배선인데, 무조건 발생시키면 그 자리가
62
+ * **무한 루프**가 된다.
63
+ */
64
+ setSelection(ids: Iterable<string>): void;
65
+ /**
66
+ * 선택을 **전부** 비운다 — 페이지를 가로지르는 누적분까지.
67
+ *
68
+ * 전체선택 체크박스는 «이 페이지»만 다루므로 전역 소거의 자리가 없다. 일괄 처리를 끝낸 뒤
69
+ * 앱이 상태를 되돌리는 경로가 여기다. `selection-change` 를 발생시킨다.
70
+ */
71
+ clearSelection(): void;
43
72
  /**
44
73
  * 행의 식별자. 선택·확장·행 오류 상태가 전부 이 값으로 추적된다.
45
74
  *
@@ -52,8 +81,24 @@ export declare class URichTable extends LitElement {
52
81
  private _rowId;
53
82
  private _warnedMissingRowId;
54
83
  private _warnMissingRowId;
84
+ /**
85
+ * **현재 페이지의** 선택된 행. 서버 페이징에서 다른 페이지의 선택분은 여기 없다 —
86
+ * 이 컴포넌트가 갖고 있지 않은 행을 돌려줄 수는 없기 때문이다.
87
+ * 페이지를 가로지르는 선택 집합이 필요하면 {@link selectedRowIds} 를 쓴다.
88
+ */
55
89
  getSelectedRows(): Record<string, unknown>[];
90
+ /** 이 페이지 행들의 식별자. 전체선택/해제가 «이 페이지» 범위임을 정의하는 값이다. */
91
+ private _pageRowIds;
92
+ /** 현재 페이지에서 선택된 행 수 — 전체선택 체크박스의 «분자». */
93
+ private get _selectedOnPage();
56
94
  render(): TemplateResult;
95
+ /**
96
+ * ⚠**두 숫자를 섞지 않는다.** 체크박스는 «이 페이지»를 켜고 끄므로 그 상태도 페이지 기준이고,
97
+ * 라벨의 건수는 페이지를 가로지르는 **누적**이다. 종전에는 분자만 누적이고 분모가 페이지라
98
+ * 다른 페이지의 선택분이 이 페이지의 체크 상태로 새어 나왔다 — 페이지 1을 전량 선택하고
99
+ * 넘어가면 페이지 2에서 아무것도 고르지 않았는데 체크박스가 켜져 보였고, 그것을 끄면
100
+ * 페이지 1의 선택이 조용히 사라졌다.
101
+ */
57
102
  private _renderToolbar;
58
103
  private _renderHeader;
59
104
  private _renderFilterRow;
@@ -62,6 +107,23 @@ export declare class URichTable extends LitElement {
62
107
  private _renderCellContent;
63
108
  private _renderNewRow;
64
109
  private _renderPagination;
110
+ /**
111
+ * 전체선택 체크박스 — 범위는 **현재 페이지**다(합집합/차집합, 치환이 아니다).
112
+ * 종전에는 켤 때 `new Set(현재 페이지)` 로 **치환**하고 끌 때 `new Set()` 으로 **전역 소거**해서,
113
+ * 어느 쪽이든 다른 페이지의 선택분이 함께 날아갔다. 전역 소거가 필요하면 {@link clearSelection}.
114
+ *
115
+ * ## 왜 `select-all` 을 따로 내보내는가
116
+ *
117
+ * `selection-change` 만으로는 *"사용자가 세 행을 골랐다"* 와 *"사용자가 전체선택을 눌렀다"* 가
118
+ * 구분되지 않는다. 서버 페이징에서 그 구분은 앱에 필요하다 — 전체선택을 누른 순간이
119
+ * *"이 조건에 맞는 N건 전부"* 를 제안할 자리이기 때문이다.
120
+ *
121
+ * ⚠**`scope: 'page' | 'all'` 필드는 두지 않았다.** 이 컴포넌트는 `'all'` 을 **낼 수 없다** —
122
+ * 다른 페이지의 행을 갖고 있지 않고, 서버 페이징에서 «전역 전체선택»은 id 목록이 아니라
123
+ * **조회 조건**이라 컴포넌트가 표현할 수 있는 것이 아니다. 값이 하나뿐인 유니온은
124
+ * *"라이브러리가 모르는 개념을 아는 척하는"* 필드가 된다. ⇒ 컴포넌트는 **의도만** 알리고
125
+ * 전역 해석은 앱이 한다.
126
+ */
65
127
  private _onSelectAll;
66
128
  private _onRowSelect;
67
129
  private _lastSelectedIndex;
@@ -83,6 +145,12 @@ export declare class URichTable extends LitElement {
83
145
  private _getOptionLabel;
84
146
  private _getPageNumbers;
85
147
  private _moveToNextEditableCell;
148
+ /**
149
+ * ⚠**두 필드가 서로 다른 것을 센다.** `selectedRows` 는 **현재 페이지의 행 객체**이고
150
+ * `selectedIds` 는 페이지를 가로지르는 **누적 식별자**다. 서버 페이징에서 둘의 길이가
151
+ * 다른 것이 정상이며, 그 차이를 감출 방법은 없다 — 컴포넌트가 다른 페이지의 행을
152
+ * 갖고 있지 않기 때문이다.
153
+ */
86
154
  private _fireSelectionChange;
87
155
  connectedCallback(): void;
88
156
  disconnectedCallback(): void;
@@ -90,6 +158,7 @@ export declare class URichTable extends LitElement {
90
158
  private _handleCopy;
91
159
  private _handlePaste;
92
160
  private _moveFocus;
161
+ /** `Ctrl`/`Cmd` + `A` — 전체선택 체크박스와 같은 범위(현재 페이지 합집합)여야 한다. */
93
162
  private _selectAll;
94
163
  static define(tagName?: string): void;
95
164
  }
@@ -102,6 +102,44 @@ var URichTable = class URichTable extends LitElement {
102
102
  this.rowErrors = next;
103
103
  }
104
104
  /**
105
+ * 선택된 행의 **식별자 집합** — 페이지를 가로질러 누적된 전부.
106
+ *
107
+ * `getSelectedRows()` 는 현재 페이지의 행 객체만 돌려주므로(갖고 있지 않은 행을 만들 수 없다)
108
+ * *"여러 페이지에 걸쳐 고른 뒤 일괄 처리"* 에는 이쪽이 필요하다.
109
+ *
110
+ * ⚠**스냅샷이다.** 내부 집합을 그대로 넘기면 `ReadonlySet` 이 타입 수준 약속일 뿐이라
111
+ * JS 소비자가 `add` 로 내부 상태를 **갱신 신호 없이** 망가뜨릴 수 있다. 복제 비용은
112
+ * 선택 크기에 비례하고 무시할 수준이다.
113
+ */
114
+ get selectedRowIds() {
115
+ return new Set(this.selectedIds);
116
+ }
117
+ /**
118
+ * 선택을 **통째로 대체**한다 — 페이지를 가로지르는 누적분까지.
119
+ *
120
+ * 앱이 자기 상태를 정본으로 삼는 경우(외부 저장·복원, 필터 변경 시 정리)의 진입점이다.
121
+ * 현재 페이지에 없는 식별자를 넣어도 된다 — 그 페이지로 이동하면 선택된 것으로 렌더된다.
122
+ *
123
+ * ⚠**같은 집합이면 아무 일도 하지 않는다.** 앱이 `selection-change` 를 받아 자기 상태를
124
+ * 갱신하고 다시 이것을 부르는 것이 자연스러운 배선인데, 무조건 발생시키면 그 자리가
125
+ * **무한 루프**가 된다.
126
+ */
127
+ setSelection(ids) {
128
+ const next = new Set(ids);
129
+ if (next.size === this.selectedIds.size && [...next].every((id) => this.selectedIds.has(id))) return;
130
+ this.selectedIds = next;
131
+ this._fireSelectionChange();
132
+ }
133
+ /**
134
+ * 선택을 **전부** 비운다 — 페이지를 가로지르는 누적분까지.
135
+ *
136
+ * 전체선택 체크박스는 «이 페이지»만 다루므로 전역 소거의 자리가 없다. 일괄 처리를 끝낸 뒤
137
+ * 앱이 상태를 되돌리는 경로가 여기다. `selection-change` 를 발생시킨다.
138
+ */
139
+ clearSelection() {
140
+ this.setSelection([]);
141
+ }
142
+ /**
105
143
  * 행의 식별자. 선택·확장·행 오류 상태가 전부 이 값으로 추적된다.
106
144
  *
107
145
  * ⚠`_id` 는 이 컴포넌트가 **부여하지 않는다.** 소비자가 넣어 주지 않으면 모든 행의
@@ -121,9 +159,22 @@ var URichTable = class URichTable extends LitElement {
121
159
  this._warnedMissingRowId = true;
122
160
  console.warn("[@iyulab/data-components] u-rich-table: rows have no `_id`, so selection is tracked by **position**. Re-sorting or re-paging the data moves the selection to a different row. Give every row a unique `_id`.");
123
161
  }
162
+ /**
163
+ * **현재 페이지의** 선택된 행. 서버 페이징에서 다른 페이지의 선택분은 여기 없다 —
164
+ * 이 컴포넌트가 갖고 있지 않은 행을 돌려줄 수는 없기 때문이다.
165
+ * 페이지를 가로지르는 선택 집합이 필요하면 {@link selectedRowIds} 를 쓴다.
166
+ */
124
167
  getSelectedRows() {
125
168
  return this.data.filter((row, i) => this.selectedIds.has(this._rowId(row, i)));
126
169
  }
170
+ /** 이 페이지 행들의 식별자. 전체선택/해제가 «이 페이지» 범위임을 정의하는 값이다. */
171
+ _pageRowIds() {
172
+ return this.data.map((row, i) => this._rowId(row, i));
173
+ }
174
+ /** 현재 페이지에서 선택된 행 수 — 전체선택 체크박스의 «분자». */
175
+ get _selectedOnPage() {
176
+ return this._pageRowIds().reduce((n, id) => n + (this.selectedIds.has(id) ? 1 : 0), 0);
177
+ }
127
178
  render() {
128
179
  return html`
129
180
  ${this._renderToolbar()}
@@ -138,19 +189,31 @@ var URichTable = class URichTable extends LitElement {
138
189
  ${this._renderPagination()}
139
190
  `;
140
191
  }
192
+ /**
193
+ * ⚠**두 숫자를 섞지 않는다.** 체크박스는 «이 페이지»를 켜고 끄므로 그 상태도 페이지 기준이고,
194
+ * 라벨의 건수는 페이지를 가로지르는 **누적**이다. 종전에는 분자만 누적이고 분모가 페이지라
195
+ * 다른 페이지의 선택분이 이 페이지의 체크 상태로 새어 나왔다 — 페이지 1을 전량 선택하고
196
+ * 넘어가면 페이지 2에서 아무것도 고르지 않았는데 체크박스가 켜져 보였고, 그것을 끄면
197
+ * 페이지 1의 선택이 조용히 사라졌다.
198
+ */
141
199
  _renderToolbar() {
142
- const selectedCount = this.selectedIds.size;
200
+ const total = this.selectedIds.size;
201
+ const onPage = this._selectedOnPage;
202
+ const crossesPages = total > onPage;
143
203
  return html`
144
204
  <div class="toolbar">
145
205
  ${this.selectable ? html`
146
206
  <div class="selection-info">
147
207
  <input type="checkbox"
148
- .checked=${selectedCount > 0 && selectedCount === this.data.length}
149
- .indeterminate=${selectedCount > 0 && selectedCount < this.data.length}
208
+ .checked=${this.data.length > 0 && onPage === this.data.length}
209
+ .indeterminate=${onPage > 0 && onPage < this.data.length}
150
210
  @change=${this._onSelectAll} />
151
- ${selectedCount > 0 ? html`<span>${selectedCount}건 선택됨</span>` : ""}
211
+ ${total > 0 ? html`<span>${crossesPages ? messages.text("selectedAcrossPages", {
212
+ count: total,
213
+ onPage
214
+ }) : messages.text("selected", { count: total })}</span>` : ""}
152
215
  </div>
153
- ${selectedCount > 0 ? html`<slot name="bulk-actions"></slot>` : ""}
216
+ ${total > 0 ? html`<slot name="bulk-actions"></slot>` : ""}
154
217
  ` : ""}
155
218
  <div style="flex:1"></div>
156
219
  <slot name="toolbar-end"></slot>
@@ -332,16 +395,45 @@ var URichTable = class URichTable extends LitElement {
332
395
  25,
333
396
  50,
334
397
  100
335
- ].map((s) => html`<option value=${s} ?selected=${s === this.pageSize}>${s}행</option>`)}
398
+ ].map((s) => html`<option value=${s} ?selected=${s === this.pageSize}>${messages.text("rowsPerPage", { size: s })}</option>`)}
336
399
  </select>
337
400
  </div>
338
401
  </div>
339
402
  `;
340
403
  }
404
+ /**
405
+ * 전체선택 체크박스 — 범위는 **현재 페이지**다(합집합/차집합, 치환이 아니다).
406
+ * 종전에는 켤 때 `new Set(현재 페이지)` 로 **치환**하고 끌 때 `new Set()` 으로 **전역 소거**해서,
407
+ * 어느 쪽이든 다른 페이지의 선택분이 함께 날아갔다. 전역 소거가 필요하면 {@link clearSelection}.
408
+ *
409
+ * ## 왜 `select-all` 을 따로 내보내는가
410
+ *
411
+ * `selection-change` 만으로는 *"사용자가 세 행을 골랐다"* 와 *"사용자가 전체선택을 눌렀다"* 가
412
+ * 구분되지 않는다. 서버 페이징에서 그 구분은 앱에 필요하다 — 전체선택을 누른 순간이
413
+ * *"이 조건에 맞는 N건 전부"* 를 제안할 자리이기 때문이다.
414
+ *
415
+ * ⚠**`scope: 'page' | 'all'` 필드는 두지 않았다.** 이 컴포넌트는 `'all'` 을 **낼 수 없다** —
416
+ * 다른 페이지의 행을 갖고 있지 않고, 서버 페이징에서 «전역 전체선택»은 id 목록이 아니라
417
+ * **조회 조건**이라 컴포넌트가 표현할 수 있는 것이 아니다. 값이 하나뿐인 유니온은
418
+ * *"라이브러리가 모르는 개념을 아는 척하는"* 필드가 된다. ⇒ 컴포넌트는 **의도만** 알리고
419
+ * 전역 해석은 앱이 한다.
420
+ */
341
421
  _onSelectAll(e) {
342
- if (e.target.checked) this.selectedIds = new Set(this.data.map((r, i) => this._rowId(r, i)));
343
- else this.selectedIds = /* @__PURE__ */ new Set();
422
+ const checked = e.target.checked;
423
+ const pageIds = this._pageRowIds();
424
+ const next = new Set(this.selectedIds);
425
+ for (const id of pageIds) if (checked) next.add(id);
426
+ else next.delete(id);
427
+ this.selectedIds = next;
344
428
  this._fireSelectionChange();
429
+ this.dispatchEvent(new CustomEvent("select-all", {
430
+ detail: {
431
+ checked,
432
+ pageRowIds: pageIds
433
+ },
434
+ bubbles: true,
435
+ composed: true
436
+ }));
345
437
  }
346
438
  _onRowSelect(rowId) {
347
439
  const next = new Set(this.selectedIds);
@@ -578,9 +670,18 @@ var URichTable = class URichTable extends LitElement {
578
670
  const value = this.data[rowIndex]?.[this.columns[colIndex]?.key];
579
671
  this._onCellDblClick(rowIndex, colIndex, value);
580
672
  }
673
+ /**
674
+ * ⚠**두 필드가 서로 다른 것을 센다.** `selectedRows` 는 **현재 페이지의 행 객체**이고
675
+ * `selectedIds` 는 페이지를 가로지르는 **누적 식별자**다. 서버 페이징에서 둘의 길이가
676
+ * 다른 것이 정상이며, 그 차이를 감출 방법은 없다 — 컴포넌트가 다른 페이지의 행을
677
+ * 갖고 있지 않기 때문이다.
678
+ */
581
679
  _fireSelectionChange() {
582
680
  this.dispatchEvent(new CustomEvent("selection-change", {
583
- detail: { selectedRows: this.getSelectedRows() },
681
+ detail: {
682
+ selectedRows: this.getSelectedRows(),
683
+ selectedIds: [...this.selectedIds]
684
+ },
584
685
  bubbles: true,
585
686
  composed: true
586
687
  }));
@@ -620,8 +721,11 @@ var URichTable = class URichTable extends LitElement {
620
721
  colIndex: newCol
621
722
  };
622
723
  }
724
+ /** `Ctrl`/`Cmd` + `A` — 전체선택 체크박스와 같은 범위(현재 페이지 합집합)여야 한다. */
623
725
  _selectAll() {
624
- this.selectedIds = new Set(this.data.map((r, i) => this._rowId(r, i)));
726
+ const next = new Set(this.selectedIds);
727
+ for (const id of this._pageRowIds()) next.add(id);
728
+ this.selectedIds = next;
625
729
  this._fireSelectionChange();
626
730
  }
627
731
  static define(tagName = "u-rich-table") {
@@ -31,8 +31,24 @@ export interface FilterState {
31
31
  [field: string]: string;
32
32
  }
33
33
  export interface RichTableEventMap {
34
+ /**
35
+ * ⚠두 필드가 서로 다른 것을 센다 — `selectedRows` 는 **현재 페이지의 행 객체**,
36
+ * `selectedIds` 는 페이지를 가로지르는 **누적 식별자**다. 서버 페이징에서 길이가 다른 것이 정상이다.
37
+ */
34
38
  'selection-change': CustomEvent<{
35
39
  selectedRows: Record<string, unknown>[];
40
+ selectedIds: string[];
41
+ }>;
42
+ /**
43
+ * 사용자가 **전체선택 체크박스**를 조작했다. `selection-change` 와 함께 발생한다.
44
+ *
45
+ * 이 이벤트가 따로 있는 이유는 *"세 행을 골랐다"* 와 *"전체선택을 눌렀다"* 를 가르기 위해서다 —
46
+ * 서버 페이징에서 후자는 앱이 *"조건에 맞는 전체 N건"* 을 제안할 자리다.
47
+ * `pageRowIds` 는 이번 조작이 더하거나 뺀 **현재 페이지의** 식별자다.
48
+ */
49
+ 'select-all': CustomEvent<{
50
+ checked: boolean;
51
+ pageRowIds: string[];
36
52
  }>;
37
53
  'row-create': CustomEvent<{
38
54
  row: Record<string, unknown>;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,15 @@
1
1
  export * from './components/data-view/UDataView';
2
2
  export * from './components/simple-sheet/USimpleSheet';
3
3
  export * from './components/u-rich-table/URichTable';
4
+ /**
5
+ * `URichTable` 의 공개 API 가 요구하는 타입.
6
+ *
7
+ * ⚠**이 줄이 없으면 «타입이 있는데 쓸 수 없는 컴포넌트»가 된다** — `columns` 는
8
+ * `ColumnDef[]` 를 요구하는데 소비자는 그 이름을 루트에서 import 할 수 없어, 내부 경로를
9
+ * 딥임포트하거나(내부 배치가 곧 공개 계약이 된다) 같은 모양을 손으로 복제해야 했다
10
+ * (업스트림이 필드를 바꿔도 모른 채 조용히 갈라진다).
11
+ *
12
+ * ★다른 두 컴포넌트는 타입이 **같은 파일**에 있어 위 `export *` 로 이미 나간다 —
13
+ * `URichTable` 만 타입을 `types.ts` 로 분리해 배럴이 지나치고 있었다.
14
+ */
15
+ export type { ColumnDef, CellPosition, SortState, FilterState, RichTableEventMap, } from './components/u-rich-table/types';
package/dist/react.d.ts CHANGED
@@ -2,7 +2,6 @@ import { EventName } from '@lit/react';
2
2
  import { USimpleSheet } from './components/simple-sheet/USimpleSheet';
3
3
  import { UDataView } from './components/data-view/UDataView';
4
4
  import { URichTable } from './components/u-rich-table/URichTable';
5
- import { RichTableEventMap } from './components/u-rich-table/types';
6
5
  /** USimpleSheet React 래퍼 — `change` 이벤트는 `onChange`로 노출 */
7
6
  export declare const USimpleSheetReact: import('@lit/react').ReactWebComponent<USimpleSheet, {
8
7
  onChange: EventName<CustomEvent<{
@@ -13,15 +12,44 @@ export declare const USimpleSheetReact: import('@lit/react').ReactWebComponent<U
13
12
  export declare const UDataViewReact: import('@lit/react').ReactWebComponent<UDataView, {}>;
14
13
  /** URichTable React 래퍼 — RichTableEventMap의 모든 이벤트를 onXxx로 노출 */
15
14
  export declare const URichTableReact: import('@lit/react').ReactWebComponent<URichTable, {
16
- onSelectionChange: EventName<RichTableEventMap["selection-change"]>;
17
- onRowCreate: EventName<RichTableEventMap["row-create"]>;
18
- onRowUpdate: EventName<RichTableEventMap["row-update"]>;
19
- onRowDelete: EventName<RichTableEventMap["row-delete"]>;
20
- onRowExpand: EventName<RichTableEventMap["row-expand"]>;
21
- onSortChange: EventName<RichTableEventMap["sort-change"]>;
22
- onFilterChange: EventName<RichTableEventMap["filter-change"]>;
23
- onPageChange: EventName<RichTableEventMap["page-change"]>;
24
- onPaste: EventName<RichTableEventMap["paste"]>;
15
+ readonly onSelectionChange: EventName<CustomEvent<{
16
+ selectedRows: Record<string, unknown>[];
17
+ selectedIds: string[];
18
+ }>>;
19
+ readonly onSelectAll: EventName<CustomEvent<{
20
+ checked: boolean;
21
+ pageRowIds: string[];
22
+ }>>;
23
+ readonly onRowCreate: EventName<CustomEvent<{
24
+ row: Record<string, unknown>;
25
+ }>>;
26
+ readonly onRowUpdate: EventName<CustomEvent<{
27
+ row: Record<string, unknown>;
28
+ field: string;
29
+ value: unknown;
30
+ oldValue: unknown;
31
+ }>>;
32
+ readonly onRowDelete: EventName<CustomEvent<{
33
+ row: Record<string, unknown>;
34
+ }>>;
35
+ readonly onRowExpand: EventName<CustomEvent<{
36
+ row: Record<string, unknown>;
37
+ expanded: boolean;
38
+ }>>;
39
+ readonly onSortChange: EventName<CustomEvent<{
40
+ field: string;
41
+ direction: "asc" | "desc" | null;
42
+ }>>;
43
+ readonly onFilterChange: EventName<CustomEvent<{
44
+ filters: import('.').FilterState;
45
+ }>>;
46
+ readonly onPageChange: EventName<CustomEvent<{
47
+ page: number;
48
+ pageSize: number;
49
+ }>>;
50
+ readonly onPaste: EventName<CustomEvent<{
51
+ rows: Record<string, unknown>[];
52
+ }>>;
25
53
  }>;
26
54
  export { USimpleSheet, UDataView, URichTable };
27
55
  export type { SheetColumn } from './components/simple-sheet/USimpleSheet';
package/dist/react.js CHANGED
@@ -34,6 +34,7 @@ var URichTableReact = createComponent({
34
34
  react: React,
35
35
  events: {
36
36
  onSelectionChange: "selection-change",
37
+ onSelectAll: "select-all",
37
38
  onRowCreate: "row-create",
38
39
  onRowUpdate: "row-update",
39
40
  onRowDelete: "row-delete",
@@ -21,5 +21,5 @@
21
21
  * 딸려 온다**(`Locale.getValue('valueMissing')`). 같은 문장을 이 표에 복제하면 두 곳이
22
22
  * 갈라진다.
23
23
  */
24
- export type DataMessageKey = 'empty' | 'loading' | 'filterPlaceholder' | 'filterAll' | 'addRow' | 'pageInfo' | 'noMatch';
24
+ export type DataMessageKey = 'empty' | 'loading' | 'filterPlaceholder' | 'filterAll' | 'addRow' | 'pageInfo' | 'noMatch' | 'selected' | 'selectedAcrossPages' | 'rowsPerPage';
25
25
  export declare const messages: import('@iyulab/components/dist/utilities/Locale.js').LocaleNamespace<DataMessageKey>;
@@ -8,7 +8,10 @@ messages.register("en", {
8
8
  filterAll: "All",
9
9
  addRow: "+ New row",
10
10
  pageInfo: "Showing {start}-{end} of {total}",
11
- noMatch: "No matching item"
11
+ noMatch: "No matching item",
12
+ selected: "{count} selected",
13
+ selectedAcrossPages: "{count} selected ({onPage} on this page)",
14
+ rowsPerPage: "{size} rows"
12
15
  });
13
16
  messages.register("ko", {
14
17
  empty: "데이터가 없습니다",
@@ -17,7 +20,10 @@ messages.register("ko", {
17
20
  filterAll: "전체",
18
21
  addRow: "+ 새 행",
19
22
  pageInfo: "전체 {total}건 중 {start}-{end} 표시",
20
- noMatch: "일치하는 항목 없음"
23
+ noMatch: "일치하는 항목 없음",
24
+ selected: "{count}건 선택됨",
25
+ selectedAcrossPages: "{count}건 선택됨 (이 페이지 {onPage}건)",
26
+ rowsPerPage: "{size}행"
21
27
  });
22
28
  //#endregion
23
29
  export { messages };
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.11.0",
4
+ "version": "0.13.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-components",
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "files": [
17
17
  "dist",
18
+ "skills",
18
19
  "LICENSE",
19
20
  "README.md",
20
21
  "CHANGELOG.md",
@@ -22,6 +23,9 @@
22
23
  ],
23
24
  "type": "module",
24
25
  "types": "./dist/index.d.ts",
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
25
29
  "exports": {
26
30
  ".": {
27
31
  "types": "./dist/index.d.ts",
@@ -51,11 +55,12 @@
51
55
  ],
52
56
  "scripts": {
53
57
  "start": "vite",
54
- "build": "eslint && vite build",
58
+ "build": "npm run typecheck && eslint && vite build",
55
59
  "test": "vitest run",
56
60
  "test:watch": "vitest",
57
61
  "lint": "eslint src/",
58
- "lint:fix": "eslint src/ --fix"
62
+ "lint:fix": "eslint src/ --fix",
63
+ "typecheck": "tsc --noEmit"
59
64
  },
60
65
  "dependencies": {
61
66
  "@iyulab/components": "^1.23.0",
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: iyulab-data-components
3
+ description: Data-oriented web components built on Lit — a server-paged rich table with inline editing and cross-page selection, a spreadsheet-like grid, and a multi-layout data viewer. Use when working with @iyulab/data-components package.
4
+ license: MIT
5
+ metadata:
6
+ author: iyulab
7
+ version: "0.13.0"
8
+ ---
9
+
10
+ # @iyulab/data-components
11
+
12
+ Three custom elements for displaying and editing tabular data. Built on
13
+ [Lit](https://lit.dev/); usable from any framework or plain HTML.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @iyulab/data-components
19
+ ```
20
+
21
+ ## Which component do I use?
22
+
23
+ | Tag | Use it when | Reference |
24
+ |---|---|---|
25
+ | `u-rich-table` | The rows come from a **server** — paging, sorting and filtering are the app's job, and you need selection, inline editing or expandable detail rows | [rich-table.md](references/components/rich-table.md) |
26
+ | `u-simple-sheet` | The data is a **free-form grid** of cells the user types into, spreadsheet-style (fill handle, ranges, clipboard) | [simple-sheet.md](references/components/simple-sheet.md) |
27
+ | `u-data-view` | You are **displaying** records and want to switch between grid / list / table layouts without rebuilding markup | [data-view.md](references/components/data-view.md) |
28
+
29
+ `u-rich-table` and `u-simple-sheet` overlap only superficially. The table is
30
+ **record-oriented** (a row is an object with named columns, and the app owns the
31
+ query); the sheet is **cell-oriented** (a row is an array of strings, and the
32
+ component owns the grid).
33
+
34
+ ## Importing
35
+
36
+ Import the barrel to register every element:
37
+
38
+ ```ts
39
+ import '@iyulab/data-components';
40
+ ```
41
+
42
+ …or import one element to keep the bundle small:
43
+
44
+ ```ts
45
+ import '@iyulab/data-components/dist/components/u-rich-table/URichTable.js';
46
+ ```
47
+
48
+ Registration is a **module side effect** (`@customElement`), so the import must
49
+ not be elided — do not mark this package as side-effect-free in a bundler
50
+ config, or the elements silently never register.
51
+
52
+ ## Types
53
+
54
+ Every type the public API asks for is exported from the package root:
55
+
56
+ ```ts
57
+ import type {
58
+ ColumnDef, CellPosition, SortState, FilterState, RichTableEventMap,
59
+ } from '@iyulab/data-components';
60
+ ```
61
+
62
+ Do **not** deep-import from `dist/components/...` for types. The subpath exists,
63
+ but it turns the internal layout into a contract you depend on.
64
+
65
+ ## Theming
66
+
67
+ Colors come from the `@iyulab/components` design tokens (`--u-*`) and follow the
68
+ active theme automatically. Per-component knobs are listed in each reference
69
+ document under *CSS Custom Properties*.
@@ -0,0 +1,63 @@
1
+ # u-data-view
2
+
3
+ ```ts
4
+ import '@iyulab/data-components/dist/components/data-view/UDataView.js';
5
+ ```
6
+
7
+ **Tag:** `u-data-view`
8
+
9
+ Read-only viewer that renders the same records as a card **grid**, a **list**, or
10
+ a **table**, without the app rebuilding markup per layout.
11
+
12
+ ```html
13
+ <u-data-view mode="list"></u-data-view>
14
+ ```
15
+
16
+ ```ts
17
+ const view = document.querySelector('u-data-view')!;
18
+ view.items = records;
19
+ view.columns = [ // used by mode="table"; inferred from the first item when omitted
20
+ { key: 'name', label: 'Name' },
21
+ { key: 'owner', label: 'Owner', width: '160px' },
22
+ ];
23
+ ```
24
+
25
+ A toolbar above the content shows the item count and one button per layout;
26
+ clicking a button sets `mode`. `mode` is also a plain property, so the app can
27
+ set it directly and persist the user's choice.
28
+
29
+ Switching layout is a **local** interaction — the component does not emit an
30
+ event for it. Read `view.mode` when you need the current layout.
31
+
32
+ Cell and card rendering can be replaced without giving up the layout logic:
33
+
34
+ ```ts
35
+ view.renderCard = (item, index) => html`<strong>${item.name}</strong>`;
36
+ view.renderCell = (item, column) => column.key === 'size' ? formatBytes(item.size) : item[column.key];
37
+ ```
38
+
39
+ ## Display only
40
+
41
+ `u-data-view` renders records; it does not select, sort, page or emit events.
42
+ When the user needs to act on rows — selection, inline edit, server paging — use
43
+ [`u-rich-table`](./rich-table.md).
44
+
45
+ ## Properties
46
+
47
+ | Property | Type | Default | Reflect | Description |
48
+ |----------|------|---------|---------|-------------|
49
+ | `items` | `DataItem[]` | `[]` | | Records to display. `DataItem` is `Record<string, any>` — the component deliberately does not constrain the app's domain type |
50
+ | `mode` | `'grid'\|'list'\|'table'` | `'grid'` | | Current layout |
51
+ | `columns` | `DataColumn[]` | — | | Columns for `mode="table"`; inferred from the first item when omitted |
52
+ | `gridMinWidth` | `string` | `'200px'` | | Minimum card width in `mode="grid"` (CSS length) |
53
+ | `gap` | `string` | `'1rem'` | | Gap between cards or rows (CSS length) |
54
+ | `renderCard` | `(item, index) => TemplateResult` | — | | Replaces card content in `grid` / `list` |
55
+ | `renderCell` | `(item, column, index) => TemplateResult \| string` | — | | Replaces cell content in `table` |
56
+
57
+ ## DataColumn
58
+
59
+ | Field | Type | Description |
60
+ |---|---|---|
61
+ | `key` | `string` | Property read from the item |
62
+ | `label` | `string` | Header text (defaults to `key`) |
63
+ | `width` | `string` | CSS width |
@@ -0,0 +1,209 @@
1
+ # u-rich-table
2
+
3
+ ```ts
4
+ import '@iyulab/data-components/dist/components/u-rich-table/URichTable.js';
5
+ ```
6
+
7
+ **Tag:** `u-rich-table`
8
+
9
+ Record-oriented table for **server-paged** data: selection that survives paging,
10
+ inline editing with validation, column filters, expandable detail rows and
11
+ TSV clipboard paste.
12
+
13
+ ```html
14
+ <u-rich-table selectable editable filterable></u-rich-table>
15
+ ```
16
+
17
+ ```ts
18
+ const table = document.querySelector('u-rich-table')!;
19
+ table.columns = [
20
+ { key: 'name', label: 'Name', sortable: true, editable: true, required: true },
21
+ { key: 'state', label: 'State', type: 'badge', badgeColors: { open: 'green' } },
22
+ ];
23
+ table.data = rows; // the CURRENT page only
24
+ table.totalCount = 1240; // the total the query matches
25
+ table.currentPage = 1;
26
+ table.pageSize = 25;
27
+ ```
28
+
29
+ ---
30
+
31
+ ## Read this first: give every row an `_id`
32
+
33
+ The component does **not** assign row identity. Selection, expansion and row
34
+ errors are all tracked by `row._id`.
35
+
36
+ ```ts
37
+ table.data = rows.map(r => ({ ...r, _id: r.documentNo }));
38
+ ```
39
+
40
+ If `_id` is missing, every row's identity is `undefined`, a `Set` holds exactly
41
+ one of those — so **selecting one row appears to select all of them**. The
42
+ component falls back to the row's *position* and warns once on the console, but
43
+ position-based identity moves the selection to a different row as soon as the
44
+ data is re-sorted or re-paged.
45
+
46
+ ## Read this second: the app owns the query
47
+
48
+ `u-rich-table` never fetches, sorts, filters or slices. It renders the page you
49
+ give it and tells you what the user asked for:
50
+
51
+ ```ts
52
+ table.addEventListener('page-change', e => load({ page: e.detail.page, size: e.detail.pageSize }));
53
+ table.addEventListener('sort-change', e => load({ sort: e.detail.field, dir: e.detail.direction }));
54
+ table.addEventListener('filter-change', e => load({ filters: e.detail.filters }));
55
+ ```
56
+
57
+ `totalCount` is what the pager counts — not `data.length`. Setting `data` alone
58
+ produces a table that believes it holds every matching record.
59
+
60
+ ## Selection across pages
61
+
62
+ Two facts are deliberately separate, because in server paging they differ:
63
+
64
+ | What you want | Where to read it |
65
+ |---|---|
66
+ | The selected **row objects on this page** | `getSelectedRows()` / `event.detail.selectedRows` |
67
+ | Every selected **identifier**, across all pages visited | `selectedRowIds` / `event.detail.selectedIds` |
68
+
69
+ The component cannot return row objects it was never given, so a bulk action
70
+ spanning pages must work from the identifiers:
71
+
72
+ ```ts
73
+ table.addEventListener('selection-change', e => {
74
+ bulkBar.count = e.detail.selectedIds.length; // accumulated
75
+ preview.rows = e.detail.selectedRows; // this page
76
+ });
77
+
78
+ await deleteAll([...table.selectedRowIds]);
79
+ table.clearSelection();
80
+ ```
81
+
82
+ The header checkbox is scoped to the **current page** — that is what makes it
83
+ truthful when the other pages are not loaded. `select-all` fires alongside
84
+ `selection-change` so the app can distinguish *"the user ticked three rows"*
85
+ from *"the user asked for everything"* and offer a "select all N matching"
86
+ affordance of its own:
87
+
88
+ ```ts
89
+ table.addEventListener('select-all', e => {
90
+ offerSelectEntireQuery.hidden = !e.detail.checked; // e.detail.pageRowIds = this page's ids
91
+ });
92
+ ```
93
+
94
+ `setSelection(ids)` replaces the accumulated set (identifiers not on the current
95
+ page are allowed). It is a no-op when the set is unchanged — without that, the
96
+ natural wiring of *listen to `selection-change` → store → write back* would loop
97
+ forever.
98
+
99
+ ---
100
+
101
+ ## Properties
102
+
103
+ | Property | Type | Default | Reflect | Description |
104
+ |----------|------|---------|---------|-------------|
105
+ | `columns` | `ColumnDef[]` | `[]` | | Column definitions — see *ColumnDef* below |
106
+ | `data` | `Record<string, unknown>[]` | `[]` | | Rows of the **current page**. Give each a unique `_id` |
107
+ | `totalCount` | `number` | `0` | | Total rows the query matches, across all pages |
108
+ | `pageSize` | `number` | `25` | | Rows per page |
109
+ | `currentPage` | `number` | `1` | | 1-based page number |
110
+ | `loading` | `boolean` | `false` | | Shows the loading message instead of rows |
111
+ | `emptyMessage` | `string` | `''` | | Text shown when there are no rows (falls back to the locale string) |
112
+ | `loadingMessage` | `string` | `''` | | Text shown while `loading` |
113
+ | `filterPlaceholder` | `string` | `''` | | Placeholder of the column filter inputs |
114
+ | `filterAllLabel` | `string` | `''` | | Label of the "all" option in `select` filters |
115
+ | `addRowLabel` | `string` | `''` | | Label of the add-row button |
116
+ | `pageInfoFormatter` | `(total, start, end) => string` | locale string | | Builds the pager caption. A function rather than a template, because word order differs per language |
117
+ | `selectable` | `boolean` | `false` | | Renders the selection column |
118
+ | `editable` | `boolean` | `false` | | Enables inline cell editing on columns marked `editable` |
119
+ | `addable` | `boolean` | `false` | | Renders the add-row control |
120
+ | `filterable` | `boolean` | `false` | | Renders the filter row for columns marked `filterable` |
121
+ | `expandable` | `boolean` | `false` | | Renders the expander column; pair with `detailRenderer` |
122
+ | `detailRenderer` | `(row) => TemplateResult` | — | | Renders the expanded detail row |
123
+
124
+ ## Methods
125
+
126
+ | Method | Description |
127
+ |---|---|
128
+ | `getSelectedRows(): Record<string, unknown>[]` | Selected rows **on the current page** |
129
+ | `setSelection(ids: Iterable<string>): void` | Replace the accumulated selection; no-op if unchanged |
130
+ | `clearSelection(): void` | Clear it entirely, across pages |
131
+ | `setRowError(rowId, message): void` | Mark a row as failed (e.g. the server rejected a save) |
132
+ | `clearRowError(rowId): void` | Remove that mark |
133
+
134
+ ## Getters
135
+
136
+ | Getter | Description |
137
+ |---|---|
138
+ | `selectedRowIds: ReadonlySet<string>` | Snapshot of every selected identifier, across pages |
139
+
140
+ ## Events
141
+
142
+ All events bubble and cross shadow boundaries. `RichTableEventMap` types every
143
+ `detail`:
144
+
145
+ ```ts
146
+ import type { RichTableEventMap } from '@iyulab/data-components';
147
+ type SelectionChange = RichTableEventMap['selection-change'];
148
+ ```
149
+
150
+ | Event | `detail` | Fired when |
151
+ |---|---|---|
152
+ | `selection-change` | `{ selectedRows, selectedIds }` | Selection changed by any route |
153
+ | `select-all` | `{ checked, pageRowIds }` | The header checkbox was toggled |
154
+ | `sort-change` | `{ field, direction }` | A sortable header was clicked (`direction` is `null` when cleared) |
155
+ | `filter-change` | `{ filters }` | A column filter changed |
156
+ | `page-change` | `{ page, pageSize }` | The pager or page-size selector moved |
157
+ | `row-update` | `{ row, field, value, oldValue }` | An inline edit was committed |
158
+ | `row-create` | `{ row }` | The add-row control produced a row |
159
+ | `row-delete` | `{ row }` | A row was deleted |
160
+ | `row-expand` | `{ row, expanded }` | A detail row was opened or closed |
161
+ | `paste` | `{ rows }` | TSV was pasted into the grid |
162
+
163
+ ## ColumnDef
164
+
165
+ | Field | Type | Description |
166
+ |---|---|---|
167
+ | `key` | `string` | Property read from the row object |
168
+ | `label` | `string` | Header text |
169
+ | `width` | `string` | CSS width |
170
+ | `type` | `'text'\|'number'\|'date'\|'select'\|'badge'` | Cell renderer and editor |
171
+ | `options` | `{ value, label }[]` | Choices for `type: 'select'` |
172
+ | `badgeColors` | `Record<string, string>` | Value → color for `type: 'badge'` |
173
+ | `align` | `'left'\|'center'\|'right'` | Cell alignment |
174
+ | `sortable` | `boolean` | Header emits `sort-change` |
175
+ | `editable` | `boolean` | Cell is editable when the table is `editable` |
176
+ | `required` | `boolean` | Empty value fails validation |
177
+ | `filterable` | `boolean` | Column appears in the filter row |
178
+ | `filterType` | `'text'\|'select'` | Filter control |
179
+ | `validator` | `(value, row) => string \| null` | Returns an error message, or `null` when valid |
180
+ | `render` | `(value, row) => string \| HTMLElement` | Custom cell rendering |
181
+ | `clipboardParse` | `(text) => unknown` | Parses a pasted cell |
182
+ | `clipboardFormat` | `(value) => string` | Formats a copied cell |
183
+
184
+ ## Slots
185
+
186
+ | Name | Description |
187
+ |------|-------------|
188
+ | `bulk-actions` | Toolbar area shown while rows are selected |
189
+ | `toolbar-end` | Trailing toolbar area, always shown |
190
+
191
+ ## Keyboard
192
+
193
+ | Keys | Action |
194
+ |---|---|
195
+ | Arrow keys | Move the focused cell |
196
+ | `Enter` | Start editing the focused cell / commit and move down |
197
+ | `Escape` | Cancel editing |
198
+ | `Tab` | Commit and move to the next cell |
199
+ | `Space` | Toggle selection of the focused row (when `selectable`) |
200
+ | `Delete` | Emit `row-delete` for every selected row |
201
+ | `Ctrl`/`Cmd` + `A` | Select every row **on this page** — same scope as the header checkbox |
202
+ | `Ctrl`/`Cmd` + `C` | Copy the selection as TSV |
203
+ | `Ctrl`/`Cmd` + `V` | Paste TSV (emits `paste`) |
204
+
205
+ ## Localization
206
+
207
+ `emptyMessage`, `loadingMessage`, `filterPlaceholder`, `filterAllLabel` and
208
+ `addRowLabel` default to `''` and fall back to the package's locale strings —
209
+ set them only to override the translation for a specific table.
@@ -0,0 +1,107 @@
1
+ # u-simple-sheet
2
+
3
+ ```ts
4
+ import '@iyulab/data-components/dist/components/simple-sheet/USimpleSheet.js';
5
+ ```
6
+
7
+ **Tag:** `u-simple-sheet`
8
+
9
+ Spreadsheet-like grid of text cells: range selection, fill handle, clipboard
10
+ copy/paste, per-column dropdowns, computed columns, display formatting,
11
+ resizable columns and undo/redo history.
12
+
13
+ ```html
14
+ <u-simple-sheet rows="30" cols="6"></u-simple-sheet>
15
+ ```
16
+
17
+ ```ts
18
+ const sheet = document.querySelector('u-simple-sheet')!;
19
+ sheet.columns = [
20
+ { key: 'item', label: 'Item', width: 180 },
21
+ { key: 'qty', label: 'Qty', format: { maximumFractionDigits: 0 } },
22
+ { key: 'price', label: 'Price', format: { style: 'currency', currency: 'USD' } },
23
+ { key: 'total', label: 'Total', compute: (row, data) => String(+data[row][1] * +data[row][2]) },
24
+ { key: 'state', label: 'State', options: ['draft', 'sent'], strict: true },
25
+ ];
26
+ sheet.addEventListener('change', e => save(sheet.getDataAsObjects()));
27
+ ```
28
+
29
+ The data is a **2-D array of strings** — the sheet stores what the user typed and
30
+ never coerces it. `format` changes only what is displayed; `getData()` returns
31
+ the raw values.
32
+
33
+ ## Cell-oriented, not record-oriented
34
+
35
+ Use `u-simple-sheet` when the grid itself is the input surface (an estimate, a
36
+ bulk-entry form, a paste target). When rows are records fetched page by page
37
+ from a server, use [`u-rich-table`](./rich-table.md) instead.
38
+
39
+ ## Properties
40
+
41
+ | Property | Type | Default | Reflect | Description |
42
+ |----------|------|---------|---------|-------------|
43
+ | `data` | `string[][]` | `[]` | | Initial cell values, row-major |
44
+ | `columns` | `SheetColumn[]` | — | | Column definitions; omit for generic `A`, `B`, `C`… columns |
45
+ | `rows` | `number` | `20` | | Row count when `data` is shorter |
46
+ | `cols` | `number` | `10` | | Column count when neither `data` nor `columns` says otherwise |
47
+ | `readonly` | `boolean` | `false` | | Blocks editing, paste and fill |
48
+ | `noMatchMessage` | `string` | `''` | | Text shown when a `strict` dropdown has no match (falls back to the locale string) |
49
+ | `theme` | `'light'\|'dark'` | — | ✓ | Forces a theme. Unset follows the ancestor `theme`/`data-theme` context (`:host-context`, Chromium only) |
50
+
51
+ ## Methods
52
+
53
+ | Method | Description |
54
+ |---|---|
55
+ | `getData(): string[][]` | Current values as a copied 2-D array |
56
+ | `getDataAsObjects(): Record<string, string>[]` | Rows keyed by `SheetColumn.key` (or `A`, `B`, `C`… when `columns` is unset) |
57
+ | `setData(data: string[][]): void` | Replace all values and re-render |
58
+ | `setCell(row, col, value): void` | Set one cell — recomputes, pushes history and emits `change` |
59
+ | `getSelection(): { minRow, maxRow, minCol, maxCol } \| null` | Current range, normalized |
60
+ | `setSelection(range): void` | Select a range programmatically (clamped to the grid) |
61
+ | `selectAll(): void` | Select every cell |
62
+
63
+ ## Getters
64
+
65
+ | Getter | Description |
66
+ |---|---|
67
+ | `canUndo: boolean` | History has an earlier state |
68
+ | `canRedo: boolean` | History has a later state |
69
+
70
+ ## Events
71
+
72
+ | Event | `detail` | Fired when |
73
+ |---|---|---|
74
+ | `change` | `{ data: string[][] }` | Any edit, paste, fill or `setCell()` committed a value |
75
+
76
+ ## SheetColumn
77
+
78
+ | Field | Type | Description |
79
+ |---|---|---|
80
+ | `key` | `string` | Object key used by `getDataAsObjects()` |
81
+ | `label` | `string` | Header text |
82
+ | `width` | `number` | Column width in px |
83
+ | `readonly` | `boolean` | Column cannot be edited |
84
+ | `options` | `string[] \| ((row, col) => string[])` | Dropdown choices, static or per cell |
85
+ | `strict` | `boolean` | Only listed options may be entered (default: free text) |
86
+ | `compute` | `(rowIndex, data) => string` | Derived value. Makes the column read-only; evaluated left→right, top→bottom |
87
+ | `format` | `Intl.NumberFormatOptions \| ((value, rowIndex) => string)` | Display formatting only — the stored value is untouched |
88
+
89
+ ## Keyboard
90
+
91
+ | Keys | Action |
92
+ |---|---|
93
+ | Arrow keys | Move the active cell |
94
+ | `Shift` + arrows | Extend the selection |
95
+ | `Home` / `End` | Jump to the first / last column |
96
+ | `PageUp` / `PageDown` | Jump a screenful of rows |
97
+ | `Enter` / `F2` | Start editing; `Enter` again commits and moves down |
98
+ | `Escape` | Cancel editing |
99
+ | `Tab` | Commit and move right (`Shift` + `Tab` moves left) |
100
+ | `Delete` / `Backspace` | Clear the selected cells |
101
+ | `Ctrl`/`Cmd` + `C` / `V` | Copy / paste the selection as TSV |
102
+ | `Ctrl`/`Cmd` + `Z` | Undo (`Shift` + `Ctrl`/`Cmd` + `Z` redoes) |
103
+ | `Ctrl`/`Cmd` + `Y` | Redo |
104
+ | `Ctrl`/`Cmd` + `D` / `R` | Fill down / fill right from the selection |
105
+ | `Ctrl`/`Cmd` + `A` | Select every cell |
106
+
107
+ Cut (`Ctrl`/`Cmd` + `X`) is not handled — copy, then `Delete`.