@iyulab/data-components 0.17.0 → 0.19.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,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.19.0] - 2026-08-31
4
+
5
+ ### Fixed
6
+
7
+ - 🔴**`u-simple-sheet`: `Ctrl`/`Cmd` + `C` never copied anything.** Cell selection is internal
8
+ state, not a real browser text selection (`Range`), so the native `copy` DOM event this
9
+ component relied on never fired — every browser requires an actual selection for that event to
10
+ exist. The copy logic itself (TSV serialization, `clipboardData.setData`) was fully implemented
11
+ and correctly bound; it was simply unreachable from the keyboard. `Ctrl`/`Cmd` + `C`/`V` now
12
+ detect the key combination explicitly and call the Clipboard API directly, matching the sibling
13
+ `u-rich-table`'s existing pattern. The native `copy`/`paste` listeners stay as a fallback for an
14
+ actual text-selection scenario.
15
+
16
+ ### Added
17
+
18
+ - **`u-simple-sheet` and `u-rich-table` gained a `clipboard-error` event**
19
+ (`{ action: 'copy' | 'paste', error }`) — a denied clipboard permission or an insecure context
20
+ previously failed the operation silently (an unhandled promise rejection, no signal to the
21
+ consumer). Both components now fire this event instead, matching `flex-table`'s existing
22
+ pattern for the same case.
23
+
24
+ ## [0.18.0] - 2026-08-19
25
+
26
+ ### Added
27
+
28
+ - **`u-record-picker`** — a form control for picking one record from a remote-searched list.
29
+ Typing filters an inline dropdown; Enter (with nothing highlighted) or a trailing find button
30
+ opens a modal lookup dialog (search bar + `u-rich-table`). A single click on a dialog row only
31
+ previews it — Confirm or a row double-click commits the pick, so a stray click in a long
32
+ result list can't accidentally close the dialog.
33
+
3
34
  ## [0.17.0] - 2026-08-17
4
35
 
5
36
  ### Added
package/README.md CHANGED
@@ -9,9 +9,14 @@
9
9
  - **URichTable** — 정렬/필터/편집/페이지네이션 지원 테이블 (Lit)
10
10
 
11
11
  > **UDataGrid (DevExtreme)는 v0.4.0에서 제거되었습니다.**
12
- > OData 서버 사이드 그리드가 필요하면 `@iyulab/flex-table`을 사용하세요.
13
12
  > → [마이그레이션 가이드](./docs/migrating-from-datagrid.md)
14
13
 
14
+ **`URichTable`과 `@iyulab/flex-table` 중 무엇을 쓸지**는 OData 연동 여부가 아니라 데이터
15
+ 규모와 상호작용 축으로 가릅니다 — 둘 다 OData 등 외부 데이터 소스를 소비할 수 있습니다.
16
+
17
+ - **`URichTable`** — 중소 규모 데이터, 행 단위 CRUD, selection/필터 UX 중심의 목록·상세 화면
18
+ - **`@iyulab/flex-table`** — 대량 데이터, 셀 단위 편집, 엑셀급 상호작용이 필요한 화면
19
+
15
20
  ## Installation
16
21
 
17
22
  ```bash
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.144.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.147.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.144.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.147.0/helpers/esm/decorateMetadata.js
2
2
  function __decorateMetadata(k, v) {
3
3
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
4
  }
@@ -1,6 +1,6 @@
1
1
  import { styles } from "./UDataView.styles.js";
2
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.144.0/helpers/esm/decorateMetadata.js";
3
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.144.0/helpers/esm/decorate.js";
2
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js";
3
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js";
4
4
  import { html } from "lit";
5
5
  import { customElement, property } from "lit/decorators.js";
6
6
  import "@iyulab/components/dist/components/icon/UIcon.js";
@@ -112,6 +112,11 @@ export declare class USimpleSheet extends UElement {
112
112
  private _onColHeaderMouseDown;
113
113
  /** 행 헤더 클릭 → 전체 행 선택 */
114
114
  private _onRowHeaderMouseDown;
115
+ /** Ctrl+C 경로 전용 — 권한 거부·비보안 컨텍스트 등으로 Clipboard API가 실패해도
116
+ * 조용히 죽지 않고 `clipboard-error`를 낸다(flex-table의 동일 패턴 참조). */
117
+ private _copySelection;
118
+ /** Ctrl+V 경로 전용 — 위와 동일한 이유로 실패를 흡수한다. */
119
+ private _pasteFromClipboard;
115
120
  private _onCopy;
116
121
  private _onPaste;
117
122
  private _selectionToTSV;
@@ -1,5 +1,5 @@
1
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.144.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.144.0/helpers/esm/decorate.js";
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js";
3
3
  import { messages } from "../../utilities/messages.js";
4
4
  import { styles } from "./USimpleSheet.styles.js";
5
5
  import { html } from "lit";
@@ -92,6 +92,20 @@ var USimpleSheet = class USimpleSheet extends UElement {
92
92
  if (!this.readonly && this._sel) this._fillRight();
93
93
  return;
94
94
  }
95
+ if (e.key === "c" || e.key === "C") {
96
+ if (this._sel) {
97
+ e.preventDefault();
98
+ this._copySelection();
99
+ }
100
+ return;
101
+ }
102
+ if (e.key === "v" || e.key === "V") {
103
+ if (!this.readonly && this._sel) {
104
+ e.preventDefault();
105
+ this._pasteFromClipboard();
106
+ }
107
+ return;
108
+ }
95
109
  }
96
110
  if (e.key === "Delete" || e.key === "Backspace") {
97
111
  e.preventDefault();
@@ -625,6 +639,30 @@ var USimpleSheet = class USimpleSheet extends UElement {
625
639
  </td>
626
640
  `;
627
641
  }
642
+ /** Ctrl+C 경로 전용 — 권한 거부·비보안 컨텍스트 등으로 Clipboard API가 실패해도
643
+ * 조용히 죽지 않고 `clipboard-error`를 낸다(flex-table의 동일 패턴 참조). */
644
+ async _copySelection() {
645
+ try {
646
+ await navigator.clipboard.writeText(this._selectionToTSV());
647
+ } catch (err) {
648
+ this.fire("clipboard-error", { detail: {
649
+ action: "copy",
650
+ error: err
651
+ } });
652
+ }
653
+ }
654
+ /** Ctrl+V 경로 전용 — 위와 동일한 이유로 실패를 흡수한다. */
655
+ async _pasteFromClipboard() {
656
+ try {
657
+ const text = await navigator.clipboard.readText();
658
+ this._pasteFromText(text);
659
+ } catch (err) {
660
+ this.fire("clipboard-error", { detail: {
661
+ action: "paste",
662
+ error: err
663
+ } });
664
+ }
665
+ }
628
666
  _selectionToTSV() {
629
667
  if (!this._sel) return "";
630
668
  const { minRow, maxRow, minCol, maxCol } = normalizeRange(this._sel);
@@ -0,0 +1,80 @@
1
+ import { PropertyValues } from 'lit';
2
+ import { UFormControlElement } from '@iyulab/components/dist/components/UFormControlElement.js';
3
+ import { UPopover } from '@iyulab/components/dist/components/popover/UPopover.js';
4
+ import { UDialog } from '@iyulab/components/dist/components/dialog/UDialog.js';
5
+ import { URichTable } from '../u-rich-table/URichTable.js';
6
+ import { ColumnDef } from '../u-rich-table/types.js';
7
+ import { PickerItem } from './types.js';
8
+ /**
9
+ * Form control that picks one record from a remote-searched list. Typing filters an inline
10
+ * dropdown; Enter (with nothing highlighted) or the trailing find button opens a modal lookup
11
+ * dialog backed by `u-rich-table`.
12
+ *
13
+ * @slot header - Extra content above the dialog's search bar.
14
+ * @slot footer - Replaces the dialog's default Cancel/Confirm buttons.
15
+ *
16
+ * @event change - Fired when the selected record changes (bubbles, composed, non-cancelable —
17
+ * same contract as `USelect`/`UInput`). Read `.value` (id) and `.selectedItem` (full record)
18
+ * from the target.
19
+ */
20
+ export declare class URecordPicker extends UFormControlElement<string> {
21
+ static styles: import('lit').CSSResultGroup[];
22
+ /** Async lookup — shared by the inline dropdown and the dialog's search bar. */
23
+ search: (query: string) => Promise<PickerItem[]>;
24
+ /** Column definitions for the lookup dialog's `u-rich-table` (same shape as `u-rich-table`). */
25
+ columns: ColumnDef[];
26
+ /** Lookup dialog header title. Defaults to a localized "Select a record". */
27
+ dialogTitle?: string;
28
+ /** Placeholder text for the main input. */
29
+ placeholder?: string;
30
+ /** Show a clear ("x") button when a value is selected. */
31
+ clearable: boolean;
32
+ /** Inline-dropdown search debounce, in ms. */
33
+ debounce: number;
34
+ containerEl?: HTMLElement;
35
+ inputEl?: HTMLInputElement;
36
+ popoverEl?: UPopover;
37
+ dialogEl?: UDialog;
38
+ tableEl?: URichTable;
39
+ /** Typed text — distinct from `.value` (the committed id). */
40
+ private query;
41
+ private items;
42
+ private loading;
43
+ private activeIndex;
44
+ private error;
45
+ private inlineDebounceTimer?;
46
+ private inlineSearchSeq;
47
+ private dialogDebounceTimer?;
48
+ private dialogSearchSeq;
49
+ private dialogQuery;
50
+ private dialogItems;
51
+ private dialogLoading;
52
+ private pendingId;
53
+ private dialogError;
54
+ private _selectedItem;
55
+ /** The full record behind `.value`, or `null` before any selection. */
56
+ get selectedItem(): PickerItem | null;
57
+ render(): import('lit-html').TemplateResult<1>;
58
+ private handleInlineInput;
59
+ private runInlineSearch;
60
+ private commitInline;
61
+ private handleInlineKeydown;
62
+ private openDialog;
63
+ private handleDialogSearchInput;
64
+ private runDialogSearch;
65
+ /** Single click / focused-row Enter on a non-editable cell — preview only, never closes. */
66
+ private handleRowActivate;
67
+ private handleRowDblClick;
68
+ private confirmDialogSelection;
69
+ private commitFromDialog;
70
+ private handleDialogHide;
71
+ private emitChange;
72
+ protected updated(changedProperties: PropertyValues): void;
73
+ protected setValidity(): void;
74
+ reset(): void;
75
+ }
76
+ declare global {
77
+ interface HTMLElementTagNameMap {
78
+ 'u-record-picker': URecordPicker;
79
+ }
80
+ }
@@ -0,0 +1,307 @@
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js";
3
+ import { messages } from "../../utilities/messages.js";
4
+ import "../u-rich-table/URichTable.js";
5
+ import { styles } from "./styles.js";
6
+ import { html } from "lit";
7
+ import { customElement, property, query, state } from "lit/decorators.js";
8
+ import "@iyulab/components/dist/components/icon/UIcon.js";
9
+ import "@iyulab/components/dist/components/button/UButton.js";
10
+ import { Locale } from "@iyulab/components/dist/utilities/Locale.js";
11
+ import { ifDefined } from "lit/directives/if-defined.js";
12
+ import { live } from "lit/directives/live.js";
13
+ import "@iyulab/components/dist/components/field/UField.js";
14
+ import "@iyulab/components/dist/components/spinner/USpinner.js";
15
+ import "@iyulab/components/dist/components/popover/UPopover.js";
16
+ import "@iyulab/components/dist/components/option/UOption.js";
17
+ import "@iyulab/components/dist/components/dialog/UDialog.js";
18
+ import { UFormControlElement } from "@iyulab/components/dist/components/UFormControlElement.js";
19
+ //#region src/components/u-record-picker/URecordPicker.ts
20
+ var URecordPicker = class URecordPicker extends UFormControlElement {
21
+ constructor(..._args) {
22
+ super(..._args);
23
+ this.columns = [];
24
+ this.clearable = false;
25
+ this.debounce = 250;
26
+ this.query = "";
27
+ this.items = [];
28
+ this.loading = false;
29
+ this.activeIndex = -1;
30
+ this.error = false;
31
+ this.inlineSearchSeq = 0;
32
+ this.dialogSearchSeq = 0;
33
+ this.dialogQuery = "";
34
+ this.dialogItems = [];
35
+ this.dialogLoading = false;
36
+ this.pendingId = null;
37
+ this.dialogError = false;
38
+ this._selectedItem = null;
39
+ this.handleInlineInput = (e) => {
40
+ this.query = e.target.value;
41
+ this.activeIndex = -1;
42
+ window.clearTimeout(this.inlineDebounceTimer);
43
+ if (!this.query) {
44
+ this.inlineSearchSeq++;
45
+ this.items = [];
46
+ this.popoverEl?.hide();
47
+ return;
48
+ }
49
+ this.inlineDebounceTimer = window.setTimeout(() => {
50
+ this.runInlineSearch(this.query);
51
+ }, this.debounce);
52
+ };
53
+ this.handleInlineKeydown = (e) => {
54
+ if (e.key === "Enter" && this.items.length === 0) {
55
+ e.preventDefault();
56
+ this.openDialog();
57
+ return;
58
+ }
59
+ if (this.items.length === 0) return;
60
+ switch (e.key) {
61
+ case "ArrowDown":
62
+ e.preventDefault();
63
+ this.activeIndex = (this.activeIndex + 1) % this.items.length;
64
+ break;
65
+ case "ArrowUp":
66
+ e.preventDefault();
67
+ this.activeIndex = (this.activeIndex - 1 + this.items.length) % this.items.length;
68
+ break;
69
+ case "Enter":
70
+ e.preventDefault();
71
+ if (this.activeIndex >= 0) this.commitInline(this.items[this.activeIndex]);
72
+ else this.openDialog();
73
+ break;
74
+ case "Escape":
75
+ e.preventDefault();
76
+ this.items = [];
77
+ this.popoverEl?.hide();
78
+ }
79
+ };
80
+ this.openDialog = async () => {
81
+ this.dialogQuery = this.query;
82
+ this.pendingId = null;
83
+ this.popoverEl?.hide();
84
+ await this.dialogEl?.show();
85
+ this.runDialogSearch(this.dialogQuery);
86
+ };
87
+ this.handleDialogSearchInput = (e) => {
88
+ this.dialogQuery = e.target.value;
89
+ window.clearTimeout(this.dialogDebounceTimer);
90
+ this.dialogDebounceTimer = window.setTimeout(() => {
91
+ this.runDialogSearch(this.dialogQuery);
92
+ }, this.debounce);
93
+ };
94
+ this.handleRowActivate = (e) => {
95
+ this.pendingId = e.detail.id;
96
+ };
97
+ this.handleRowDblClick = (e) => {
98
+ const tr = e.composedPath().find((node) => node instanceof HTMLElement && node.tagName === "TR");
99
+ if (!tr) return;
100
+ const index = Array.from(this.tableEl.shadowRoot.querySelectorAll("tbody tr")).indexOf(tr);
101
+ const item = this.dialogItems[index];
102
+ if (item) this.commitFromDialog(item);
103
+ };
104
+ this.confirmDialogSelection = () => {
105
+ const item = this.dialogItems.find((i) => i.id === this.pendingId);
106
+ if (item) this.commitFromDialog(item);
107
+ };
108
+ this.handleDialogHide = () => {
109
+ this.pendingId = null;
110
+ };
111
+ }
112
+ static {
113
+ this.styles = [super.styles, styles];
114
+ }
115
+ /** The full record behind `.value`, or `null` before any selection. */
116
+ get selectedItem() {
117
+ return this._selectedItem;
118
+ }
119
+ render() {
120
+ return html`
121
+ <u-field part="field"
122
+ ?required=${this.required}
123
+ ?disabled=${this.disabled}
124
+ ?invalid=${this.invalid}
125
+ .label=${this.label}
126
+ .description=${this.description}
127
+ .validationMessage=${this.validationMessage}
128
+ >
129
+ <div class="container" part="container">
130
+ <input class="main-input" type="text"
131
+ aria-label=${ifDefined(this.label)}
132
+ ?disabled=${this.disabled}
133
+ ?readonly=${this.readonly}
134
+ placeholder=${ifDefined(this.placeholder)}
135
+ .value=${live(this.query)}
136
+ @input=${this.handleInlineInput}
137
+ @keydown=${this.handleInlineKeydown}
138
+ />
139
+ <u-icon class="suffix-item"
140
+ ?hidden=${!this.clearable || !this.value || this.disabled || this.readonly}
141
+ lib="internal" name="x"
142
+ ></u-icon>
143
+ <u-button class="suffix-item find-btn" variant="ghost"
144
+ ?disabled=${this.disabled}
145
+ aria-label=${messages.text("pickerFind")}
146
+ @click=${this.openDialog}
147
+ >
148
+ <u-icon lib="internal" name="search"></u-icon>
149
+ </u-button>
150
+ </div>
151
+ </u-field>
152
+
153
+ <u-popover part="popover" role="listbox" for=".container" trigger="manual"
154
+ strategy="fixed" placement="bottom-start" offset="1"
155
+ >
156
+ ${this.loading ? html`<div class="popover-loading"><u-spinner></u-spinner></div>` : this.error ? html`<div class="no-results">${messages.text("pickerSearchError")}</div>` : this.items.length === 0 ? html`<div class="no-results">${messages.text("noMatch")}</div>` : this.items.map((item, i) => html`
157
+ <u-option .value=${item.id} ?selected=${i === this.activeIndex}
158
+ @click=${() => this.commitInline(item)}
159
+ >${item.label}</u-option>
160
+ `)}
161
+ </u-popover>
162
+
163
+ <u-dialog placement="center" @hide=${this.handleDialogHide}>
164
+ <span slot="header">${this.dialogTitle ?? messages.text("pickerDialogTitle")}</span>
165
+ <slot name="header"></slot>
166
+ <div class="dialog-search">
167
+ <input type="text" .value=${live(this.dialogQuery)}
168
+ @input=${this.handleDialogSearchInput}
169
+ />
170
+ </div>
171
+ ${this.dialogError ? html`<div class="dialog-error">${messages.text("pickerSearchError")}</div>` : ""}
172
+ <div class="dialog-table-wrap">
173
+ <u-rich-table
174
+ .columns=${this.columns}
175
+ .data=${this.dialogItems.map((item) => ({
176
+ ...item,
177
+ _id: item.id
178
+ }))}
179
+ .loading=${this.dialogLoading}
180
+ @row-activate=${this.handleRowActivate}
181
+ @dblclick=${this.handleRowDblClick}
182
+ ></u-rich-table>
183
+ </div>
184
+ <slot name="footer">
185
+ <div class="dialog-footer">
186
+ <u-button variant="ghost" @click=${() => this.dialogEl?.hide()}
187
+ >${messages.text("pickerCancel")}</u-button>
188
+ <u-button color="primary" ?disabled=${!this.pendingId}
189
+ @click=${this.confirmDialogSelection}
190
+ >${messages.text("pickerConfirm")}</u-button>
191
+ </div>
192
+ </slot>
193
+ </u-dialog>
194
+ `;
195
+ }
196
+ async runInlineSearch(query) {
197
+ const seq = ++this.inlineSearchSeq;
198
+ this.loading = true;
199
+ this.error = false;
200
+ this.popoverEl?.show(this.containerEl);
201
+ try {
202
+ const results = await this.search(query);
203
+ if (seq !== this.inlineSearchSeq) return;
204
+ this.items = results;
205
+ await this.updateComplete;
206
+ if (results.length > 0) this.popoverEl?.show(this.containerEl);
207
+ else this.popoverEl?.hide();
208
+ } catch {
209
+ if (seq !== this.inlineSearchSeq) return;
210
+ this.items = [];
211
+ this.error = true;
212
+ await this.updateComplete;
213
+ this.popoverEl?.show(this.containerEl);
214
+ } finally {
215
+ if (seq === this.inlineSearchSeq) this.loading = false;
216
+ }
217
+ }
218
+ commitInline(item) {
219
+ this._selectedItem = item;
220
+ this.query = item.label;
221
+ const changed = this.value !== item.id;
222
+ this.value = item.id;
223
+ this.items = [];
224
+ this.popoverEl?.hide();
225
+ if (changed) this.emitChange();
226
+ }
227
+ async runDialogSearch(query) {
228
+ const seq = ++this.dialogSearchSeq;
229
+ this.dialogLoading = true;
230
+ this.dialogError = false;
231
+ try {
232
+ const results = await this.search(query);
233
+ if (seq !== this.dialogSearchSeq) return;
234
+ this.dialogItems = results;
235
+ } catch {
236
+ if (seq !== this.dialogSearchSeq) return;
237
+ this.dialogItems = [];
238
+ this.dialogError = true;
239
+ } finally {
240
+ if (seq === this.dialogSearchSeq) this.dialogLoading = false;
241
+ }
242
+ }
243
+ commitFromDialog(item) {
244
+ this._selectedItem = item;
245
+ this.query = item.label;
246
+ const changed = this.value !== item.id;
247
+ this.value = item.id;
248
+ this.dialogEl?.hide();
249
+ if (changed) this.emitChange();
250
+ }
251
+ emitChange() {
252
+ if (!this.novalidate) this.validate();
253
+ this.dispatchEvent(new Event("change", {
254
+ bubbles: true,
255
+ composed: true
256
+ }));
257
+ }
258
+ updated(changedProperties) {
259
+ super.updated(changedProperties);
260
+ if (changedProperties.has("value")) this.internals?.setFormValue(this.value ?? "");
261
+ }
262
+ setValidity() {
263
+ let flags = {};
264
+ let message = "";
265
+ if (this.required && !this.value) {
266
+ flags = { valueMissing: true };
267
+ message = Locale.getValue("valueMissing");
268
+ }
269
+ this.commit(flags, message, this.containerEl ?? void 0);
270
+ }
271
+ reset() {
272
+ this.value = void 0;
273
+ this.query = "";
274
+ this._selectedItem = null;
275
+ this.invalid = false;
276
+ }
277
+ };
278
+ __decorate([property({ attribute: false }), __decorateMetadata("design:type", Function)], URecordPicker.prototype, "search", void 0);
279
+ __decorate([property({ type: Array }), __decorateMetadata("design:type", Array)], URecordPicker.prototype, "columns", void 0);
280
+ __decorate([property({
281
+ type: String,
282
+ attribute: "dialog-title"
283
+ }), __decorateMetadata("design:type", String)], URecordPicker.prototype, "dialogTitle", void 0);
284
+ __decorate([property({ type: String }), __decorateMetadata("design:type", String)], URecordPicker.prototype, "placeholder", void 0);
285
+ __decorate([property({
286
+ type: Boolean,
287
+ reflect: true
288
+ }), __decorateMetadata("design:type", Object)], URecordPicker.prototype, "clearable", void 0);
289
+ __decorate([property({ type: Number }), __decorateMetadata("design:type", Object)], URecordPicker.prototype, "debounce", void 0);
290
+ __decorate([query(".container", true), __decorateMetadata("design:type", typeof HTMLElement === "undefined" ? Object : HTMLElement)], URecordPicker.prototype, "containerEl", void 0);
291
+ __decorate([query("input.main-input", true), __decorateMetadata("design:type", typeof HTMLInputElement === "undefined" ? Object : HTMLInputElement)], URecordPicker.prototype, "inputEl", void 0);
292
+ __decorate([query("u-popover", true), __decorateMetadata("design:type", Object)], URecordPicker.prototype, "popoverEl", void 0);
293
+ __decorate([query("u-dialog", true), __decorateMetadata("design:type", Object)], URecordPicker.prototype, "dialogEl", void 0);
294
+ __decorate([query("u-rich-table", true), __decorateMetadata("design:type", Object)], URecordPicker.prototype, "tableEl", void 0);
295
+ __decorate([state(), __decorateMetadata("design:type", Object)], URecordPicker.prototype, "query", void 0);
296
+ __decorate([state(), __decorateMetadata("design:type", Array)], URecordPicker.prototype, "items", void 0);
297
+ __decorate([state(), __decorateMetadata("design:type", Object)], URecordPicker.prototype, "loading", void 0);
298
+ __decorate([state(), __decorateMetadata("design:type", Object)], URecordPicker.prototype, "activeIndex", void 0);
299
+ __decorate([state(), __decorateMetadata("design:type", Object)], URecordPicker.prototype, "error", void 0);
300
+ __decorate([state(), __decorateMetadata("design:type", Object)], URecordPicker.prototype, "dialogQuery", void 0);
301
+ __decorate([state(), __decorateMetadata("design:type", Array)], URecordPicker.prototype, "dialogItems", void 0);
302
+ __decorate([state(), __decorateMetadata("design:type", Object)], URecordPicker.prototype, "dialogLoading", void 0);
303
+ __decorate([state(), __decorateMetadata("design:type", Object)], URecordPicker.prototype, "pendingId", void 0);
304
+ __decorate([state(), __decorateMetadata("design:type", Object)], URecordPicker.prototype, "dialogError", void 0);
305
+ URecordPicker = __decorate([customElement("u-record-picker")], URecordPicker);
306
+ //#endregion
307
+ export { URecordPicker };
@@ -0,0 +1 @@
1
+ export declare const styles: import('lit').CSSResult;
@@ -0,0 +1,83 @@
1
+ import { css } from "lit";
2
+ //#region src/components/u-record-picker/styles.ts
3
+ var styles = css`
4
+ :host {
5
+ display: inline-block;
6
+ }
7
+
8
+ .container {
9
+ display: flex;
10
+ align-items: center;
11
+ gap: 0.25rem;
12
+ }
13
+
14
+ .container input {
15
+ flex: 1;
16
+ min-width: 0;
17
+ border: none;
18
+ outline: none;
19
+ background: transparent;
20
+ font: inherit;
21
+ color: inherit;
22
+ }
23
+
24
+ .suffix-item {
25
+ cursor: pointer;
26
+ flex: none;
27
+ }
28
+
29
+ u-popover {
30
+ display: block;
31
+ max-height: var(--record-picker-popover-max-height, 50vh);
32
+ overflow: auto;
33
+ }
34
+
35
+ .no-results {
36
+ padding: 0.5rem 0.75rem;
37
+ opacity: 0.6;
38
+ font-size: 0.875rem;
39
+ }
40
+
41
+ .popover-loading {
42
+ padding: 0.5rem 0.75rem;
43
+ display: flex;
44
+ justify-content: center;
45
+ }
46
+
47
+ .dialog-error {
48
+ padding: 0.5rem 0.75rem;
49
+ margin-bottom: 0.5rem;
50
+ color: var(--u-color-danger, #b91c1c);
51
+ font-size: 0.875rem;
52
+ }
53
+
54
+ .dialog-search {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 0.5rem;
58
+ padding-bottom: 0.75rem;
59
+ }
60
+
61
+ .dialog-search input {
62
+ flex: 1;
63
+ min-width: 0;
64
+ padding: 0.5rem 0.75rem;
65
+ border: 1px solid currentColor;
66
+ border-radius: 0.25rem;
67
+ font: inherit;
68
+ }
69
+
70
+ .dialog-table-wrap {
71
+ min-height: 16rem;
72
+ max-height: 60vh;
73
+ overflow: auto;
74
+ }
75
+
76
+ .dialog-footer {
77
+ display: flex;
78
+ justify-content: flex-end;
79
+ gap: 0.5rem;
80
+ }
81
+ `;
82
+ //#endregion
83
+ export { styles };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * One searchable/selectable record. `id` is the form value; `label` is what the inline
3
+ * dropdown displays. Extra fields are addressed by `URecordPickerProps.columns[].key` for the
4
+ * lookup dialog's table — the shape those fields take is entirely up to `search()`'s caller.
5
+ */
6
+ export interface PickerItem {
7
+ id: string;
8
+ label: string;
9
+ [key: string]: unknown;
10
+ }
@@ -1,5 +1,5 @@
1
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.144.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.144.0/helpers/esm/decorate.js";
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js";
3
3
  import { messages } from "../../utilities/messages.js";
4
4
  import { richTableStyles } from "./styles.js";
5
5
  import { parseTSV, toTSV } from "./utils/clipboard.js";
@@ -720,11 +720,35 @@ var URichTable = class URichTable extends LitElement {
720
720
  const rows = this.getSelectedRows();
721
721
  if (rows.length === 0) return;
722
722
  const tsv = toTSV(rows, this.columns);
723
- await navigator.clipboard.writeText(tsv);
723
+ try {
724
+ await navigator.clipboard.writeText(tsv);
725
+ } catch (err) {
726
+ this.dispatchEvent(new CustomEvent("clipboard-error", {
727
+ detail: {
728
+ action: "copy",
729
+ error: err
730
+ },
731
+ bubbles: true,
732
+ composed: true
733
+ }));
734
+ }
724
735
  }
725
736
  async _handlePaste() {
726
737
  if (this.editingCell) return;
727
- const text = await navigator.clipboard.readText();
738
+ let text;
739
+ try {
740
+ text = await navigator.clipboard.readText();
741
+ } catch (err) {
742
+ this.dispatchEvent(new CustomEvent("clipboard-error", {
743
+ detail: {
744
+ action: "paste",
745
+ error: err
746
+ },
747
+ bubbles: true,
748
+ composed: true
749
+ }));
750
+ return;
751
+ }
728
752
  if (!text.trim()) return;
729
753
  const parsedRows = parseTSV(text, this.columns);
730
754
  if (parsedRows.length === 0) return;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
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
+ export * from './components/u-record-picker/URecordPicker';
4
5
  /**
5
6
  * `URichTable` 의 공개 API 가 요구하는 타입.
6
7
  *
@@ -13,3 +14,4 @@ export * from './components/u-rich-table/URichTable';
13
14
  * `URichTable` 만 타입을 `types.ts` 로 분리해 배럴이 지나치고 있었다.
14
15
  */
15
16
  export type { ColumnDef, CellPosition, SortState, FilterState, RichTableEventMap, } from './components/u-rich-table/types';
17
+ export type { PickerItem } from './components/u-record-picker/types';
package/dist/index.js CHANGED
@@ -2,4 +2,5 @@ import "./utilities/shadowDomProtection.js";
2
2
  import { UDataView } from "./components/data-view/UDataView.js";
3
3
  import { USimpleSheet } from "./components/simple-sheet/USimpleSheet.js";
4
4
  import { URichTable } from "./components/u-rich-table/URichTable.js";
5
- export { UDataView, URichTable, USimpleSheet };
5
+ import { URecordPicker } from "./components/u-record-picker/URecordPicker.js";
6
+ export { UDataView, URecordPicker, URichTable, USimpleSheet };
@@ -21,5 +21,5 @@
21
21
  * 딸려 온다**(`Locale.getValue('valueMissing')`). 같은 문장을 이 표에 복제하면 두 곳이
22
22
  * 갈라진다.
23
23
  */
24
- export type DataMessageKey = 'empty' | 'loading' | 'filterPlaceholder' | 'filterAll' | 'addRow' | 'pageInfo' | 'noMatch' | 'selected' | 'selectedAcrossPages' | 'rowsPerPage';
24
+ export type DataMessageKey = 'empty' | 'loading' | 'filterPlaceholder' | 'filterAll' | 'addRow' | 'pageInfo' | 'noMatch' | 'selected' | 'selectedAcrossPages' | 'rowsPerPage' | 'pickerDialogTitle' | 'pickerFind' | 'pickerCancel' | 'pickerConfirm' | 'pickerSearchError';
25
25
  export declare const messages: import('@iyulab/components/dist/utilities/Locale.js').LocaleNamespace<DataMessageKey>;
@@ -11,7 +11,12 @@ messages.register("en", {
11
11
  noMatch: "No matching item",
12
12
  selected: "{count} selected",
13
13
  selectedAcrossPages: "{count} selected ({onPage} on this page)",
14
- rowsPerPage: "{size} rows"
14
+ rowsPerPage: "{size} rows",
15
+ pickerDialogTitle: "Select a record",
16
+ pickerFind: "Find",
17
+ pickerCancel: "Cancel",
18
+ pickerConfirm: "Confirm",
19
+ pickerSearchError: "Search failed. Try again."
15
20
  });
16
21
  messages.register("ko", {
17
22
  empty: "데이터가 없습니다",
@@ -23,7 +28,12 @@ messages.register("ko", {
23
28
  noMatch: "일치하는 항목 없음",
24
29
  selected: "{count}건 선택됨",
25
30
  selectedAcrossPages: "{count}건 선택됨 (이 페이지 {onPage}건)",
26
- rowsPerPage: "{size}행"
31
+ rowsPerPage: "{size}행",
32
+ pickerDialogTitle: "레코드 선택",
33
+ pickerFind: "찾기",
34
+ pickerCancel: "취소",
35
+ pickerConfirm: "확인",
36
+ pickerSearchError: "검색에 실패했습니다. 다시 시도해 주세요."
27
37
  });
28
38
  //#endregion
29
39
  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.17.0",
4
+ "version": "0.19.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-components",
@@ -39,7 +39,8 @@
39
39
  },
40
40
  "./simple-sheet": "./dist/components/simple-sheet/USimpleSheet.js",
41
41
  "./data-view": "./dist/components/data-view/UDataView.js",
42
- "./u-rich-table": "./dist/components/u-rich-table/URichTable.js"
42
+ "./u-rich-table": "./dist/components/u-rich-table/URichTable.js",
43
+ "./u-record-picker": "./dist/components/u-record-picker/URecordPicker.js"
43
44
  },
44
45
  "sideEffects": [
45
46
  "./dist/components/data-view/UDataView.js",
@@ -48,6 +49,8 @@
48
49
  "./src/components/simple-sheet/USimpleSheet.ts",
49
50
  "./dist/components/u-rich-table/URichTable.js",
50
51
  "./src/components/u-rich-table/URichTable.ts",
52
+ "./dist/components/u-record-picker/URecordPicker.js",
53
+ "./src/components/u-record-picker/URecordPicker.ts",
51
54
  "./dist/index.js",
52
55
  "./src/index.ts",
53
56
  "./dist/init.js",
@@ -1,15 +1,15 @@
1
1
  ---
2
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.
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, a multi-layout data viewer, and a remote-search record picker. Use when working with @iyulab/data-components package.
4
4
  license: MIT
5
5
  metadata:
6
6
  author: iyulab
7
- version: "0.13.0"
7
+ version: "0.18.0"
8
8
  ---
9
9
 
10
10
  # @iyulab/data-components
11
11
 
12
- Three custom elements for displaying and editing tabular data. Built on
12
+ Four custom elements for displaying and editing tabular data. Built on
13
13
  [Lit](https://lit.dev/); usable from any framework or plain HTML.
14
14
 
15
15
  ## Install
@@ -25,6 +25,7 @@ npm install @iyulab/data-components
25
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
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
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
+ | `u-record-picker` | The user needs to **pick one record** from a remote-searched list — an inline typeahead dropdown or a modal lookup dialog, sharing one `search` callback | [record-picker.md](references/components/record-picker.md) |
28
29
 
29
30
  `u-rich-table` and `u-simple-sheet` overlap only superficially. The table is
30
31
  **record-oriented** (a row is an object with named columns, and the app owns the
@@ -54,6 +54,18 @@ When the user needs to act on rows — selection, inline edit, server paging —
54
54
  | `renderCard` | `(item, index) => TemplateResult` | — | | Replaces card content in `grid` / `list` |
55
55
  | `renderCell` | `(item, column, index) => TemplateResult \| string` | — | | Replaces cell content in `table` |
56
56
 
57
+ ## CSS Custom Properties
58
+
59
+ | Property | Description |
60
+ |----------|-------------|
61
+ | `--dc-muted-color` | Secondary text/labels (default `--u-txt-color-weak`) |
62
+ | `--dc-header-color` | Column/row headers in `mode="table"` (default `--u-txt-color-weak`) |
63
+ | `--dc-empty-color` | Empty-state message (default `--u-txt-color-weak`) |
64
+
65
+ ⚠ Grid gap and card min-width are set via the `gap`/`gridMinWidth` properties (applied as
66
+ inline styles), not custom properties — a stylesheet override of `--gap`/`--min-width` would
67
+ lose to them.
68
+
57
69
  ## DataColumn
58
70
 
59
71
  | Field | Type | Description |
@@ -0,0 +1,90 @@
1
+ # u-record-picker
2
+
3
+ ```ts
4
+ import '@iyulab/data-components/dist/components/u-record-picker/URecordPicker.js';
5
+ ```
6
+
7
+ **Tag:** `u-record-picker`
8
+
9
+ Form control that picks one record from a remote-searched list. Typing filters an inline
10
+ dropdown; Enter (with nothing highlighted) or the trailing find button opens a modal lookup
11
+ dialog backed by [`u-rich-table`](./rich-table.md). Form-associated.
12
+
13
+ ```html
14
+ <u-record-picker label="Owner" placeholder="Search people…" clearable></u-record-picker>
15
+ ```
16
+
17
+ ```ts
18
+ const picker = document.querySelector('u-record-picker')!;
19
+ picker.search = async (query) => {
20
+ const res = await fetch(`/api/people?q=${encodeURIComponent(query)}`);
21
+ const rows = await res.json();
22
+ return rows.map((r) => ({ id: r.id, label: r.name, ...r }));
23
+ };
24
+ picker.columns = [ // used by the lookup dialog's u-rich-table
25
+ { key: 'name', label: 'Name' },
26
+ { key: 'email', label: 'Email' },
27
+ ];
28
+ ```
29
+
30
+ The inline dropdown and the lookup dialog share the single `search` callback — one async
31
+ lookup, two entry points. Both keyboard (`ArrowDown`/`ArrowUp`/`Enter`/`Escape` inline) and
32
+ mouse work; a single click on a dialog row previews the selection without closing the dialog,
33
+ and only the Confirm button or a row double-click commits it.
34
+
35
+ ## Display only vs. actionable
36
+
37
+ The dialog's table only previews and commits a row — it does not expose `u-rich-table`'s own
38
+ edit affordances. If a `columns` entry marks a column `editable`, a double-click on that cell
39
+ enters cell-edit mode **and** commits the dialog selection at the same time (the two gestures
40
+ are not distinguished) — avoid `editable` columns in `u-record-picker`'s `columns` until this
41
+ is resolved.
42
+
43
+ ## Properties
44
+
45
+ | Property | Type | Default | Reflect | Description |
46
+ |----------|------|---------|---------|-------------|
47
+ | `search` | `(query: string) => Promise<PickerItem[]>` | — | — | Async lookup, shared by the inline dropdown and the dialog's search bar. Required |
48
+ | `columns` | `ColumnDef[]` | `[]` | — | Column definitions for the lookup dialog's `u-rich-table` (same shape as `u-rich-table`) |
49
+ | `dialogTitle` | `string` | — | — | Lookup dialog header title (attribute: `dialog-title`). Defaults to a localized "Select a record" |
50
+ | `placeholder` | `string` | — | — | Placeholder text for the main input |
51
+ | `clearable` | `boolean` | `false` | ✓ | Show a clear ("x") button when a value is selected |
52
+ | `debounce` | `number` | `250` | — | Inline-dropdown search debounce, in ms |
53
+ | `value` | `string` | — | — | Selected record's id |
54
+ | `disabled` | `boolean` | `false` | ✓ | Disable |
55
+ | `readonly` | `boolean` | `false` | ✓ | Read-only |
56
+ | `required` | `boolean` | `false` | ✓ | Required |
57
+ | `invalid` | `boolean` | `false` | ✓ | Validation failed |
58
+ | `name` | `string` | — | — | Form field name |
59
+ | `label` | `string` | — | — | Field label |
60
+ | `description` | `string` | — | — | Helper text |
61
+
62
+ `selectedItem` (read-only getter) returns the full `PickerItem` behind `.value`, or `null`
63
+ before any selection.
64
+
65
+ ## PickerItem
66
+
67
+ | Field | Type | Description |
68
+ |---|---|---|
69
+ | `id` | `string` | Value committed to `.value` and the form |
70
+ | `label` | `string` | Text shown in the input and dropdown once selected |
71
+ | `...rest` | `any` | Any other fields the app's `search` result carries — read them from `.selectedItem` or a `u-rich-table` column |
72
+
73
+ ## Events
74
+
75
+ | Event | Description |
76
+ |-------|-------------|
77
+ | `change` | Fires when the selected record changes (bubbles, composed, non-cancelable — same contract as `u-select`/`u-input`). Read `.value` (id) and `.selectedItem` (full record) from the target |
78
+
79
+ ## Slots
80
+
81
+ | Slot | Description |
82
+ |------|-------------|
83
+ | `header` | Extra content above the dialog's search bar |
84
+ | `footer` | Replaces the dialog's default Cancel/Confirm buttons |
85
+
86
+ ## CSS Custom Properties
87
+
88
+ | Property | Description |
89
+ |----------|-------------|
90
+ | `--record-picker-popover-max-height` | Max height of the inline dropdown (default `50vh`) |
@@ -178,6 +178,7 @@ type SelectionChange = RichTableEventMap['selection-change'];
178
178
  | `row-expand` | `{ row, expanded }` | A detail row was opened or closed |
179
179
  | `row-activate` | `{ row, id, via }` | A row was clicked, or `Enter` was pressed on a focused non-editable cell (`via` is `'click'` or `'keyboard'`). Independent of `selectable` — selection is "what to act on", activation is "what to view" |
180
180
  | `paste` | `{ rows }` | TSV was pasted into the grid |
181
+ | `clipboard-error` | `{ action: 'copy' \| 'paste', error }` | `Ctrl`/`Cmd` + `C`/`V` called the Clipboard API and it rejected (denied permission, insecure context) |
181
182
 
182
183
  ## ColumnDef
183
184
 
@@ -207,6 +208,15 @@ type SelectionChange = RichTableEventMap['selection-change'];
207
208
  | `bulk-actions` | Toolbar area shown while rows are selected |
208
209
  | `toolbar-end` | Trailing toolbar area, always shown |
209
210
 
211
+ ## CSS Custom Properties
212
+
213
+ | Property | Description |
214
+ |----------|-------------|
215
+ | `--dc-font-size` | Body cell / edit input font size, shared with the edit control so the size doesn't jump on entering edit mode (default `--u-density`, `13px`) |
216
+ | `--dc-muted-color` | Secondary text — pager caption, disabled affordances (default `--u-txt-color-weak`) |
217
+ | `--dc-icon-color` | Sort indicator, expander, row-menu icon color (default `--u-txt-color-weak`) |
218
+ | `--dc-empty-color` | Empty-state message color (default `--u-txt-color-weak`) |
219
+
210
220
  ## Keyboard
211
221
 
212
222
  | Keys | Action |
@@ -48,6 +48,20 @@ from a server, use [`u-rich-table`](./rich-table.md) instead.
48
48
  | `noMatchMessage` | `string` | `''` | | Text shown when a `strict` dropdown has no match (falls back to the locale string) |
49
49
  | `theme` | `'light'\|'dark'` | — | ✓ | Forces a theme. Unset follows the ancestor `theme`/`data-theme` context (`:host-context`, Chromium only) |
50
50
 
51
+ ## CSS Custom Properties
52
+
53
+ | Property | Description |
54
+ |----------|-------------|
55
+ | `--dc-sheet-height` | Host height (default `400px`) |
56
+ | `--dc-row-height` | Row height, also used as cell `line-height` (default `24px`) |
57
+ | `--dc-cell-padding-block` / `--dc-cell-padding-inline` | Cell and edit-input padding (default `0px` / `6px`) |
58
+ | `--dc-font-size` | Body cell / edit input / dropdown item font size (default `--u-density`, `13px`) |
59
+ | `--dc-header-font-size` | Column header font size (default `12px` — independent of `--dc-font-size`/`--u-density` by design) |
60
+ | `--dc-header-font-weight` | Column header font weight (default `600`) |
61
+ | `--dc-header-color` | Column/row header text color (default `--u-txt-color-weak`) |
62
+ | `--dc-empty-color` | Empty-state message color (default `--u-txt-color-weak`) |
63
+ | `--dc-readonly-color` | Read-only cell text color (default `--u-txt-color-weak`) |
64
+
51
65
  ## Methods
52
66
 
53
67
  | Method | Description |
@@ -72,6 +86,7 @@ from a server, use [`u-rich-table`](./rich-table.md) instead.
72
86
  | Event | `detail` | Fired when |
73
87
  |---|---|---|
74
88
  | `change` | `{ data: string[][] }` | Any edit, paste, fill or `setCell()` committed a value |
89
+ | `clipboard-error` | `{ action: 'copy' \| 'paste', error: unknown }` | `Ctrl`/`Cmd` + `C`/`V` called the Clipboard API and it rejected (denied permission, insecure context) |
75
90
 
76
91
  ## SheetColumn
77
92