@wipco/sdui 0.1.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/dist/index.js ADDED
@@ -0,0 +1,2620 @@
1
+ import { Toast, Textarea, Tag, Tabs, Table, Switch, StatCard, Spinner, Select, ProgressBar, Pagination, Input, IconButton, Icon, Field, EmptyState, DateRangePicker, DatePicker, Checkbox, Card, Button, Badge, Alert, Dialog } from './chunk-AR2JYE75.js';
2
+ export { Alert, Badge, Button, Card, Checkbox, DatePicker, DateRangePicker, Dialog, EmptyState, Field, Icon, IconButton, Input, Pagination, ProgressBar, Select, Spinner, StatCard, Switch, Table, Tabs, Tag, Textarea, Toast } from './chunk-AR2JYE75.js';
3
+ import React, { createContext, useContext, useMemo, useState, useRef, useEffect, useCallback } from 'react';
4
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
5
+
6
+ // src/env.ts
7
+ var DEV = (() => {
8
+ try {
9
+ const meta = import.meta;
10
+ return Boolean(meta.env?.DEV);
11
+ } catch {
12
+ return false;
13
+ }
14
+ })();
15
+
16
+ // src/adapter.ts
17
+ function isSafeNavigationTarget(to) {
18
+ if (typeof to !== "string") return false;
19
+ const value = to.replace(/[\u0000-\u0020]/g, "");
20
+ if (value === "") return false;
21
+ if (value.startsWith("//")) return false;
22
+ if (value.startsWith("/") || value.startsWith("?") || value.startsWith("#")) return true;
23
+ return /^https?:\/\//i.test(value);
24
+ }
25
+ async function defaultFetch(path, init) {
26
+ const response = await fetch(path, {
27
+ method: init.method,
28
+ headers: { "content-type": "application/json", accept: "application/json" },
29
+ credentials: "include",
30
+ body: init.body === void 0 ? void 0 : JSON.stringify(init.body)
31
+ });
32
+ const text = await response.text();
33
+ let json = null;
34
+ if (text) {
35
+ try {
36
+ json = JSON.parse(text);
37
+ } catch {
38
+ json = null;
39
+ }
40
+ }
41
+ if (!response.ok) {
42
+ const message = json && typeof json === "object" && typeof json.message === "string" ? json.message : `\uC694\uCCAD\uC774 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4 (${response.status})`;
43
+ throw new Error(message);
44
+ }
45
+ return json;
46
+ }
47
+ var defaultAdapter = {
48
+ navigate: (to) => {
49
+ if (!isSafeNavigationTarget(to)) {
50
+ console.error(`[sdui] \uD5C8\uC6A9\uB418\uC9C0 \uC54A\uB294 navigate \uB300\uC0C1: ${String(to)}`);
51
+ return;
52
+ }
53
+ window.location.assign(to);
54
+ },
55
+ fetch: defaultFetch,
56
+ rawFetch: (path) => fetch(path, { credentials: "include" }),
57
+ invalidate: (prefix) => {
58
+ if (DEV) console.warn(`[sdui] invalidate \uC561\uC158\uC774 \uC5B4\uB311\uD130 \uC5C6\uC774 \uD638\uCD9C\uB428 (prefix: ${prefix})`);
59
+ },
60
+ refresh: () => {
61
+ if (DEV) console.warn("[sdui] refresh \uC561\uC158\uC774 \uC5B4\uB311\uD130 \uC5C6\uC774 \uD638\uCD9C\uB428");
62
+ }
63
+ };
64
+ function resolveAdapter(adapter) {
65
+ const merged = { ...defaultAdapter };
66
+ if (adapter) {
67
+ for (const [key, value] of Object.entries(adapter)) {
68
+ if (value !== void 0) merged[key] = value;
69
+ }
70
+ }
71
+ return merged;
72
+ }
73
+ var SduiDispatchContext = createContext(null);
74
+ function useSduiDispatch() {
75
+ const dispatch = useContext(SduiDispatchContext);
76
+ if (!dispatch) {
77
+ throw new Error("useSduiDispatch\uB294 SduiRenderer \uB0B4\uBD80\uC5D0\uC11C\uB9CC \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.");
78
+ }
79
+ return dispatch;
80
+ }
81
+ var NodeErrorBoundary = class extends React.Component {
82
+ state = { error: null };
83
+ static getDerivedStateFromError(error) {
84
+ return { error };
85
+ }
86
+ componentDidCatch(error) {
87
+ if (DEV) {
88
+ console.error(`[sdui] <${this.props.name}> \uB80C\uB354 \uC624\uB958:`, error);
89
+ }
90
+ }
91
+ componentDidUpdate(prevProps) {
92
+ if (this.state.error && prevProps.children !== this.props.children) {
93
+ this.setState({ error: null });
94
+ }
95
+ }
96
+ render() {
97
+ if (this.state.error) {
98
+ return /* @__PURE__ */ jsxs(
99
+ "div",
100
+ {
101
+ role: "alert",
102
+ className: "wp-caption",
103
+ style: {
104
+ color: "var(--wp-text-danger)",
105
+ border: "1px dashed var(--wp-border-danger)",
106
+ borderRadius: "var(--wp-radius-md)",
107
+ padding: "var(--wp-space-3)"
108
+ },
109
+ children: [
110
+ "[",
111
+ this.props.name,
112
+ "] \uB80C\uB354 \uC624\uB958: ",
113
+ this.state.error.message
114
+ ]
115
+ }
116
+ );
117
+ }
118
+ return this.props.children;
119
+ }
120
+ };
121
+
122
+ // src/warn-once.ts
123
+ var warned = /* @__PURE__ */ new Set();
124
+ function warnOnce(key, message) {
125
+ if (warned.has(key)) return;
126
+ warned.add(key);
127
+ console.error(message);
128
+ }
129
+ var ALLOW_ALL = () => true;
130
+ var DENY_ALL = () => false;
131
+ var MISSING = () => {
132
+ warnOnce(
133
+ "permission:missing",
134
+ '[sdui] \uAD8C\uD55C \uD310\uC815(can)\uC774 \uC8FC\uC785\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4 \u2014 <Can>\uC774 \uC804\uBD80 \uD1B5\uACFC\uD569\uB2C8\uB2E4. SduiRenderer\uC5D0 can \uCF5C\uBC31\uC744 \uB118\uAE30\uAC70\uB098, \uC138\uC158\uC774 \uC5C6\uB294 \uBBF8\uB9AC\uBCF4\uAE30\uB77C\uBA74 can="allow-all"\uC744 \uBA85\uC2DC\uD558\uC138\uC694.'
135
+ );
136
+ return true;
137
+ };
138
+ var SduiPermissionContext = createContext(null);
139
+ function SduiPermissionProvider({ can, children }) {
140
+ const value = useMemo(() => {
141
+ if (can === "allow-all") return ALLOW_ALL;
142
+ if (can === "deny-all") return DENY_ALL;
143
+ return typeof can === "function" ? can : null;
144
+ }, [can]);
145
+ return /* @__PURE__ */ jsx(SduiPermissionContext.Provider, { value, children });
146
+ }
147
+ function useSduiCan() {
148
+ return useContext(SduiPermissionContext) ?? MISSING;
149
+ }
150
+ function Can({ entity, action = "read", scope, fallback = null, children }) {
151
+ const can = useSduiCan();
152
+ if (typeof entity !== "string" || entity === "") {
153
+ warnOnce("can:entity", "[sdui] <Can>\uC5D0 entity\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4 \u2014 \uAC8C\uC774\uD2B8\uB97C \uC5F4\uC5B4 \uB454 \uCC44 \uB80C\uB354\uD569\uB2C8\uB2E4");
154
+ return /* @__PURE__ */ jsx(Fragment, { children });
155
+ }
156
+ return can({ entity, action: typeof action === "string" ? action : "read", scope }) ? /* @__PURE__ */ jsx(Fragment, { children }) : /* @__PURE__ */ jsx(Fragment, { children: fallback });
157
+ }
158
+ function cx(...parts) {
159
+ return parts.filter(Boolean).join(" ");
160
+ }
161
+ function Grid({ columns = 3, template, className, style, children }) {
162
+ return /* @__PURE__ */ jsx(
163
+ "div",
164
+ {
165
+ className: cx("wp-grid", !template && `wp-grid--${columns}`, className),
166
+ style: template ? { gridTemplateColumns: template, ...style } : style,
167
+ children
168
+ }
169
+ );
170
+ }
171
+ var JUSTIFY_CSS = {
172
+ start: "flex-start",
173
+ center: "center",
174
+ end: "flex-end",
175
+ between: "space-between"
176
+ };
177
+ function Row({ justify, className, style, children }) {
178
+ return /* @__PURE__ */ jsx(
179
+ "div",
180
+ {
181
+ className: cx("wp-row", justify === "between" && "wp-row--between", className),
182
+ style: justify && justify !== "between" ? { justifyContent: JUSTIFY_CSS[justify], ...style } : style,
183
+ children
184
+ }
185
+ );
186
+ }
187
+ function Actions({ justify = "end", className, style, children }) {
188
+ return /* @__PURE__ */ jsx("div", { className: cx("wp-row", className), style: { flex: 1, justifyContent: JUSTIFY_CSS[justify], ...style }, children });
189
+ }
190
+ function Col({ className, style, children }) {
191
+ return /* @__PURE__ */ jsx("div", { className: cx("wp-col", className), style, children });
192
+ }
193
+ var SPACER_STEPS = /* @__PURE__ */ new Set([1, 2, 3, 4, 5, 6, 8]);
194
+ function Spacer({ size }) {
195
+ const step = size !== void 0 && SPACER_STEPS.has(Number(size)) ? Number(size) : void 0;
196
+ return /* @__PURE__ */ jsx("div", { className: cx("wp-spacer", step !== void 0 && `wp-spacer--${step}`) });
197
+ }
198
+ function Section({ title, className, style, children }) {
199
+ return /* @__PURE__ */ jsxs("section", { className: cx("wp-col", className), style, children: [
200
+ title ? /* @__PURE__ */ jsx("h2", { className: "wp-h4", children: title }) : null,
201
+ children
202
+ ] });
203
+ }
204
+ function Text({ preset = "body", as: Tag2 = "p", className, style, value, children }) {
205
+ return /* @__PURE__ */ jsx(Tag2, { className: cx(`wp-${preset}`, className), style, children: value ?? children });
206
+ }
207
+ function KeyValueList({ source, items, className, style }) {
208
+ return /* @__PURE__ */ jsx("div", { className: cx("wp-kv", className), style, children: (items ?? []).map((item, index) => {
209
+ const raw = item.key ? source?.[item.key] : item.value;
210
+ const content = raw === null || raw === void 0 || raw === "" ? "\u2014" : raw;
211
+ return /* @__PURE__ */ jsxs(React.Fragment, { children: [
212
+ /* @__PURE__ */ jsx("span", { className: "wp-kv__k", children: item.label }),
213
+ /* @__PURE__ */ jsx("span", { className: cx("wp-kv__v", item.mono && "wp-mono"), children: content })
214
+ ] }, index);
215
+ }) });
216
+ }
217
+ function PageHead({ title, subtitle, actions }) {
218
+ return /* @__PURE__ */ jsxs("div", { className: "wp-page-head", children: [
219
+ /* @__PURE__ */ jsxs("div", { className: "wp-page-head__text", children: [
220
+ title ? /* @__PURE__ */ jsx("h1", { className: "wp-page-head__title", children: title }) : null,
221
+ subtitle ? /* @__PURE__ */ jsx("div", { className: "wp-page-head__sub", children: subtitle }) : null
222
+ ] }),
223
+ actions ? /* @__PURE__ */ jsx("div", { className: "wp-page-head__actions", children: actions }) : null
224
+ ] });
225
+ }
226
+ function Show({ when, children }) {
227
+ return when ? /* @__PURE__ */ jsx(Fragment, { children }) : null;
228
+ }
229
+ function Divider({ style }) {
230
+ return /* @__PURE__ */ jsx("hr", { className: "wp-divider", style });
231
+ }
232
+ var defaultFetcher = async (endpoint, params, signal) => {
233
+ const query = params.toString();
234
+ const url = `${endpoint}${query ? endpoint.includes("?") ? "&" : "?" : ""}${query}`;
235
+ const response = await fetch(url, {
236
+ headers: { accept: "application/json" },
237
+ credentials: "include",
238
+ signal
239
+ });
240
+ const body = await response.json().catch(() => null);
241
+ if (!response.ok) {
242
+ throw new Error(body?.message ?? `\uBAA9\uB85D\uC744 \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4 (${response.status})`);
243
+ }
244
+ const rows = Array.isArray(body?.data) ? body.data : [];
245
+ return { data: rows, total: typeof body?.total === "number" ? body.total : rows.length };
246
+ };
247
+ var SearchDataContext = createContext(null);
248
+ function SduiSearchDataProvider({
249
+ fetcher,
250
+ children
251
+ }) {
252
+ return /* @__PURE__ */ jsx(SearchDataContext.Provider, { value: fetcher, children });
253
+ }
254
+ function useSearchFetcher() {
255
+ return useContext(SearchDataContext) ?? defaultFetcher;
256
+ }
257
+ var EMPTY_PAGE = { data: [], total: 0 };
258
+ function useRemoteSearchPage(endpoint, params) {
259
+ const fetcher = useSearchFetcher();
260
+ const [page, setPage] = useState(EMPTY_PAGE);
261
+ const [isLoading, setIsLoading] = useState(true);
262
+ const [error, setError] = useState(null);
263
+ const [reloadKey, setReloadKey] = useState(0);
264
+ const query = params.toString();
265
+ const fetcherRef = useRef(fetcher);
266
+ fetcherRef.current = fetcher;
267
+ useEffect(() => {
268
+ const controller = new AbortController();
269
+ let alive = true;
270
+ setIsLoading(true);
271
+ fetcherRef.current(endpoint, new URLSearchParams(query), controller.signal).then(
272
+ (next) => {
273
+ if (!alive) return;
274
+ setPage(next);
275
+ setError(null);
276
+ setIsLoading(false);
277
+ },
278
+ (cause) => {
279
+ if (!alive || controller.signal.aborted) return;
280
+ setError(cause instanceof Error ? cause : new Error("\uBAA9\uB85D\uC744 \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4"));
281
+ setIsLoading(false);
282
+ }
283
+ );
284
+ return () => {
285
+ alive = false;
286
+ controller.abort();
287
+ };
288
+ }, [endpoint, query, reloadKey]);
289
+ const reload = useCallback(() => setReloadKey((value) => value + 1), []);
290
+ return { page, isLoading, error, reload };
291
+ }
292
+ var HOST_STYLE = { position: "relative", display: "inline-flex" };
293
+ var PANEL_STYLE = {
294
+ position: "absolute",
295
+ top: "calc(100% + var(--wp-space-2))",
296
+ minWidth: "100%",
297
+ background: "var(--wp-surface-card)",
298
+ border: "1px solid var(--wp-border-subtle)",
299
+ borderRadius: "var(--wp-radius-lg)",
300
+ boxShadow: "var(--wp-shadow-lg)",
301
+ // 다이얼로그(--wp-z-dialog: 100)보다는 아래 — 모달 위로 삐져나오면 안 된다
302
+ zIndex: 90
303
+ };
304
+ var BODY_STYLE = {
305
+ maxHeight: "60vh",
306
+ overflow: "auto",
307
+ padding: "var(--wp-space-4)"
308
+ };
309
+ var FOOT_STYLE = {
310
+ display: "flex",
311
+ alignItems: "center",
312
+ justifyContent: "space-between",
313
+ gap: "var(--wp-space-2)",
314
+ padding: "var(--wp-space-3)",
315
+ borderTop: "1px solid var(--wp-divider)"
316
+ };
317
+ function Popover({
318
+ open,
319
+ onClose,
320
+ trigger,
321
+ footer,
322
+ alignLeft = true,
323
+ width,
324
+ children
325
+ }) {
326
+ const hostRef = useRef(null);
327
+ const closeRef = useRef(onClose);
328
+ closeRef.current = onClose;
329
+ useEffect(() => {
330
+ if (!open) return;
331
+ const closeOnOutside = (event) => {
332
+ if (event.target instanceof Node && hostRef.current?.contains(event.target)) return;
333
+ closeRef.current();
334
+ };
335
+ const closeOnEscape = (event) => {
336
+ if (event.key === "Escape") closeRef.current();
337
+ };
338
+ document.addEventListener("pointerdown", closeOnOutside);
339
+ document.addEventListener("keydown", closeOnEscape);
340
+ return () => {
341
+ document.removeEventListener("pointerdown", closeOnOutside);
342
+ document.removeEventListener("keydown", closeOnEscape);
343
+ };
344
+ }, [open]);
345
+ return /* @__PURE__ */ jsxs("div", { ref: hostRef, style: HOST_STYLE, children: [
346
+ trigger,
347
+ open ? /* @__PURE__ */ jsxs(
348
+ "div",
349
+ {
350
+ style: {
351
+ ...PANEL_STYLE,
352
+ ...alignLeft ? { left: 0 } : { right: 0 },
353
+ ...width ? { width } : null
354
+ },
355
+ children: [
356
+ /* @__PURE__ */ jsx("div", { style: BODY_STYLE, children }),
357
+ footer ? /* @__PURE__ */ jsx("div", { style: FOOT_STYLE, children: footer }) : null
358
+ ]
359
+ }
360
+ ) : null
361
+ ] });
362
+ }
363
+ var OVERSCAN = 10;
364
+ function ResultTable({
365
+ columns,
366
+ rows,
367
+ sort,
368
+ onSort,
369
+ onRowClick,
370
+ selectedIds,
371
+ empty,
372
+ virtual = false,
373
+ height = 560,
374
+ rowHeight = 41
375
+ }) {
376
+ const scrollRef = useRef(null);
377
+ const firstRowRef = useRef(null);
378
+ const [scrollTop, setScrollTop] = useState(0);
379
+ const [measured, setMeasured] = useState(null);
380
+ const handleScroll = useCallback(() => {
381
+ setScrollTop(scrollRef.current?.scrollTop ?? 0);
382
+ }, []);
383
+ useEffect(() => {
384
+ if (!virtual) return;
385
+ const actual = firstRowRef.current?.getBoundingClientRect().height;
386
+ if (actual && Math.abs(actual - (measured ?? rowHeight)) > 0.5) setMeasured(actual);
387
+ });
388
+ if (!rows.length && empty) return /* @__PURE__ */ jsx(Fragment, { children: empty });
389
+ const selectedSet = selectedIds && selectedIds.length > 0 ? new Set(selectedIds) : null;
390
+ const unit = measured ?? rowHeight;
391
+ const start = virtual ? Math.max(0, Math.floor(scrollTop / unit) - OVERSCAN) : 0;
392
+ const end = virtual ? Math.min(rows.length, Math.ceil((scrollTop + height) / unit) + OVERSCAN) : rows.length;
393
+ const padTop = start * unit;
394
+ const padBottom = (rows.length - end) * unit;
395
+ const table = /* @__PURE__ */ jsxs(
396
+ "table",
397
+ {
398
+ className: "wp-table wp-table--hover",
399
+ style: virtual ? { tableLayout: "fixed" } : void 0,
400
+ children: [
401
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { children: columns.map((column) => /* @__PURE__ */ jsx(
402
+ "th",
403
+ {
404
+ style: {
405
+ width: column.width,
406
+ textAlign: column.align === "right" ? "right" : void 0,
407
+ // wp-table--sticky 클래스가 없으므로 가상 모드에서만 직접 고정한다
408
+ ...virtual ? { position: "sticky", top: 0, zIndex: 1, background: "var(--wp-surface-card)" } : null
409
+ },
410
+ children: column.sortable && onSort ? /* @__PURE__ */ jsxs(
411
+ "button",
412
+ {
413
+ type: "button",
414
+ onClick: () => onSort(column.key),
415
+ style: {
416
+ display: "inline-flex",
417
+ alignItems: "center",
418
+ gap: 3,
419
+ background: "none",
420
+ border: 0,
421
+ padding: 0,
422
+ cursor: "pointer",
423
+ color: "inherit",
424
+ font: "inherit",
425
+ letterSpacing: "inherit",
426
+ textTransform: "inherit"
427
+ },
428
+ children: [
429
+ column.header,
430
+ /* @__PURE__ */ jsx(
431
+ Icon,
432
+ {
433
+ name: sort && sort.key === column.key ? sort.dir === "asc" ? "chevron-up" : "chevron-down" : "chevrons-up-down",
434
+ size: 12
435
+ }
436
+ )
437
+ ]
438
+ }
439
+ ) : column.header
440
+ },
441
+ column.key
442
+ )) }) }),
443
+ /* @__PURE__ */ jsxs("tbody", { children: [
444
+ padTop > 0 ? /* @__PURE__ */ jsx("tr", { "aria-hidden": "true", children: /* @__PURE__ */ jsx("td", { colSpan: columns.length, style: { height: padTop, padding: 0, border: 0 } }) }) : null,
445
+ rows.slice(start, end).map((row, offset) => {
446
+ const index = start + offset;
447
+ const selected = selectedSet ? selectedSet.has(row.id) : false;
448
+ return /* @__PURE__ */ jsx(
449
+ "tr",
450
+ {
451
+ ref: offset === 0 ? firstRowRef : void 0,
452
+ "aria-selected": selected || void 0,
453
+ onClick: onRowClick ? () => onRowClick(row) : void 0,
454
+ style: {
455
+ ...onRowClick ? { cursor: "pointer" } : null,
456
+ ...selected ? { background: "var(--wp-surface-selected)" } : null
457
+ },
458
+ children: columns.map((column) => {
459
+ const cls = [];
460
+ if (column.align === "right") cls.push("wp-table__num");
461
+ if (column.mono) cls.push("wp-table__mono");
462
+ if (column.strong) cls.push("wp-table__strong");
463
+ return /* @__PURE__ */ jsx(
464
+ "td",
465
+ {
466
+ className: cls.join(" ") || void 0,
467
+ style: virtual ? { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } : void 0,
468
+ children: column.render ? column.render(row, index) : row[column.key]
469
+ },
470
+ column.key
471
+ );
472
+ })
473
+ },
474
+ row.id ?? index
475
+ );
476
+ }),
477
+ padBottom > 0 ? /* @__PURE__ */ jsx("tr", { "aria-hidden": "true", children: /* @__PURE__ */ jsx("td", { colSpan: columns.length, style: { height: padBottom, padding: 0, border: 0 } }) }) : null
478
+ ] })
479
+ ]
480
+ }
481
+ );
482
+ if (!virtual) return table;
483
+ return /* @__PURE__ */ jsx("div", { ref: scrollRef, onScroll: handleScroll, style: { height, overflowY: "auto" }, children: table });
484
+ }
485
+ function ResultEmptyState({ title }) {
486
+ return (
487
+ // 아이콘 세트에 scan-search가 없어 같은 뜻의 search로 대체
488
+ /* @__PURE__ */ jsx(EmptyState, { icon: "search", title: `${title}\uC774(\uAC00) \uC5C6\uC2B5\uB2C8\uB2E4`, tight: true, children: "\uAC80\uC0C9 \uC870\uAC74\uC744 \uBC14\uAFB8\uAC70\uB098 \uCD08\uAE30\uD654\uD574 \uBCF4\uC138\uC694." })
489
+ );
490
+ }
491
+ function splitMultiValue(value) {
492
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
493
+ }
494
+ function joinMultiValue(values) {
495
+ return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))).sort().join(",");
496
+ }
497
+ function normalizeSearchText(value) {
498
+ return value.toLowerCase().replace(/\s+/g, "");
499
+ }
500
+ function includesLooseSearch(text, keyword) {
501
+ const normalizedKeyword = normalizeSearchText(keyword);
502
+ return normalizedKeyword.length === 0 || normalizeSearchText(text).includes(normalizedKeyword);
503
+ }
504
+ function searchFieldKeys(fields) {
505
+ return fields.flatMap((field) => {
506
+ if (field.type === "dateRange") {
507
+ return [field.fromKey ?? `${field.key}From`, field.toKey ?? `${field.key}To`];
508
+ }
509
+ if (field.type === "numberRange") {
510
+ return [field.fromKey ?? `${field.key}Min`, field.toKey ?? `${field.key}Max`];
511
+ }
512
+ return [field.key];
513
+ });
514
+ }
515
+ function searchValuesFromParams(fields, params) {
516
+ const values = {};
517
+ for (const key of searchFieldKeys(fields)) {
518
+ const value = params.get(key);
519
+ if (value) values[key] = value;
520
+ }
521
+ return values;
522
+ }
523
+ function cleanSearchValues(fields, values) {
524
+ const allowed = new Set(searchFieldKeys(fields));
525
+ const fieldsByKey = new Map(fields.map((field) => [field.key, field]));
526
+ return Object.fromEntries(
527
+ Object.entries(values).map(([key, value]) => {
528
+ const field = fieldsByKey.get(key);
529
+ const cleaned = field?.type === "select" || field?.type === "cascading" ? joinMultiValue(splitMultiValue(value)) : value.trim();
530
+ return [key, cleaned];
531
+ }).filter(([key, value]) => allowed.has(key) && value)
532
+ );
533
+ }
534
+ function cascadingPathMap(options) {
535
+ const paths = /* @__PURE__ */ new Map();
536
+ const visit = (option, parents) => {
537
+ const path = [...parents, option];
538
+ paths.set(option.value, path);
539
+ option.children?.forEach((child) => visit(child, path));
540
+ };
541
+ options.forEach((option) => visit(option, []));
542
+ return paths;
543
+ }
544
+ function cascadingValuePaths(options) {
545
+ const map = /* @__PURE__ */ new Map();
546
+ for (const [value, path] of cascadingPathMap(options)) {
547
+ map.set(
548
+ value,
549
+ path.map((option) => option.value)
550
+ );
551
+ }
552
+ return map;
553
+ }
554
+ function cascadingPathLabel(pathMap, value) {
555
+ const path = pathMap.get(value);
556
+ return path?.length ? path.map((option) => option.label).join(" > ") : value;
557
+ }
558
+ function activeSearchCriteria(fields, values) {
559
+ const criteria = [];
560
+ for (const field of fields) {
561
+ if (field.type === "select") {
562
+ for (const value2 of splitMultiValue(values[field.key] ?? "")) {
563
+ const label = field.options.find((o) => o.value === value2)?.label ?? value2;
564
+ criteria.push({
565
+ key: `${field.key}:${value2}`,
566
+ label: `${field.label}: ${label}`,
567
+ clearKeys: [field.key],
568
+ valueKey: field.key,
569
+ value: value2
570
+ });
571
+ }
572
+ continue;
573
+ }
574
+ if (field.type === "cascading") {
575
+ const pathMap = cascadingPathMap(field.options);
576
+ for (const value2 of splitMultiValue(values[field.key] ?? "")) {
577
+ criteria.push({
578
+ key: `${field.key}:${value2}`,
579
+ label: `${field.label}: ${cascadingPathLabel(pathMap, value2)}`,
580
+ clearKeys: [field.key],
581
+ valueKey: field.key,
582
+ value: value2
583
+ });
584
+ }
585
+ continue;
586
+ }
587
+ if (field.type === "dateRange" || field.type === "numberRange") {
588
+ const fromKey = field.fromKey ?? `${field.key}${field.type === "dateRange" ? "From" : "Min"}`;
589
+ const toKey = field.toKey ?? `${field.key}${field.type === "dateRange" ? "To" : "Max"}`;
590
+ const from = values[fromKey];
591
+ const to = values[toKey];
592
+ if (from || to) {
593
+ criteria.push({
594
+ key: field.key,
595
+ label: `${field.label}: ${from ?? ""}~${to ?? ""}`,
596
+ clearKeys: [fromKey, toKey]
597
+ });
598
+ }
599
+ continue;
600
+ }
601
+ const value = values[field.key];
602
+ if (value) {
603
+ criteria.push({
604
+ key: field.key,
605
+ label: `${field.label}: ${value}`,
606
+ clearKeys: [field.key]
607
+ });
608
+ }
609
+ }
610
+ return criteria;
611
+ }
612
+ var EMPTY_FIELDS = [];
613
+ var EMPTY_VALUES = {};
614
+ function SearchToolbar({
615
+ search,
616
+ onSearchChange,
617
+ onSearch,
618
+ advancedFields = EMPTY_FIELDS,
619
+ advancedValues = EMPTY_VALUES,
620
+ placeholder = "\uAC80\uC0C9",
621
+ className,
622
+ hideSearchInput = false
623
+ }) {
624
+ const [open, setOpen] = useState(false);
625
+ const [draft, setDraft] = useState(advancedValues);
626
+ const hasAdvanced = advancedFields.length > 0;
627
+ useEffect(() => {
628
+ if (!open) setDraft(advancedValues);
629
+ }, [advancedValues, open]);
630
+ const activeCriteria = useMemo(
631
+ () => activeSearchCriteria(advancedFields, advancedValues),
632
+ [advancedFields, advancedValues]
633
+ );
634
+ const submit = (event) => {
635
+ event.preventDefault();
636
+ onSearch(search.trim(), cleanSearchValues(advancedFields, advancedValues));
637
+ setOpen(false);
638
+ };
639
+ const applyAdvancedSearch = () => {
640
+ onSearch(search.trim(), cleanSearchValues(advancedFields, draft));
641
+ setOpen(false);
642
+ };
643
+ const resetAdvancedSearch = () => {
644
+ setDraft({});
645
+ onSearch(search.trim(), {});
646
+ setOpen(false);
647
+ };
648
+ const removeCriterion = (criterion) => {
649
+ const nextValues = { ...advancedValues };
650
+ if (criterion.valueKey && criterion.value !== void 0) {
651
+ const remaining = splitMultiValue(nextValues[criterion.valueKey] ?? "").filter(
652
+ (value) => value !== criterion.value
653
+ );
654
+ if (remaining.length > 0) nextValues[criterion.valueKey] = joinMultiValue(remaining);
655
+ else delete nextValues[criterion.valueKey];
656
+ } else {
657
+ criterion.clearKeys.forEach((key) => delete nextValues[key]);
658
+ }
659
+ setDraft(nextValues);
660
+ onSearch(search.trim(), cleanSearchValues(advancedFields, nextValues));
661
+ };
662
+ return /* @__PURE__ */ jsxs("form", { className: ["wp-row", className].filter(Boolean).join(" "), onSubmit: submit, children: [
663
+ activeCriteria.length > 0 ? /* @__PURE__ */ jsx("div", { className: "wp-row", style: { flexWrap: "wrap" }, children: activeCriteria.map((criterion) => /* @__PURE__ */ jsx(Tag, { onRemove: () => removeCriterion(criterion), children: criterion.label }, criterion.key)) }) : null,
664
+ !hideSearchInput ? /* @__PURE__ */ jsx(
665
+ Input,
666
+ {
667
+ icon: "search",
668
+ value: search,
669
+ onChange: (event) => onSearchChange(event.target.value),
670
+ placeholder
671
+ }
672
+ ) : null,
673
+ hasAdvanced ? /* @__PURE__ */ jsx(
674
+ Popover,
675
+ {
676
+ open,
677
+ onClose: () => setOpen(false),
678
+ alignLeft: false,
679
+ width: "min(560px, 80vw)",
680
+ trigger: /* @__PURE__ */ jsx(
681
+ IconButton,
682
+ {
683
+ type: "button",
684
+ icon: "settings",
685
+ label: "\uC0C1\uC138\uAC80\uC0C9",
686
+ "aria-expanded": open,
687
+ style: open ? { background: "var(--wp-surface-active)" } : void 0,
688
+ onClick: () => setOpen((value) => !value)
689
+ }
690
+ ),
691
+ footer: /* @__PURE__ */ jsxs(Fragment, { children: [
692
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", icon: "refresh-cw", onClick: resetAdvancedSearch, children: "\uCD08\uAE30\uD654" }),
693
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "primary", size: "sm", onClick: applyAdvancedSearch, children: "\uC801\uC6A9" })
694
+ ] }),
695
+ children: /* @__PURE__ */ jsx("div", { className: "wp-grid wp-grid--2", children: advancedFields.map((field) => /* @__PURE__ */ jsx(
696
+ SearchFieldInput,
697
+ {
698
+ field,
699
+ values: draft,
700
+ onChange: (patch) => setDraft((current) => ({ ...current, ...patch }))
701
+ },
702
+ field.key
703
+ )) })
704
+ }
705
+ ) : null
706
+ ] });
707
+ }
708
+ function SearchFieldInput({
709
+ field,
710
+ values,
711
+ onChange
712
+ }) {
713
+ if (field.type === "select") {
714
+ return /* @__PURE__ */ jsx(Field, { label: field.label, children: /* @__PURE__ */ jsx(MultiSelectInput, { field, values, onChange }) });
715
+ }
716
+ if (field.type === "cascading") {
717
+ return /* @__PURE__ */ jsx(Field, { label: field.label, children: /* @__PURE__ */ jsx(CascadingInput, { field, values, onChange }) });
718
+ }
719
+ if (field.type === "dateRange") {
720
+ const fromKey = field.fromKey ?? `${field.key}From`;
721
+ const toKey = field.toKey ?? `${field.key}To`;
722
+ return /* @__PURE__ */ jsx(Field, { label: field.label, children: /* @__PURE__ */ jsxs("div", { className: "wp-row", children: [
723
+ /* @__PURE__ */ jsx(
724
+ Input,
725
+ {
726
+ type: "date",
727
+ mono: true,
728
+ value: values[fromKey] ?? "",
729
+ onChange: (e) => onChange({ [fromKey]: e.target.value })
730
+ }
731
+ ),
732
+ /* @__PURE__ */ jsx("span", { className: "wp-caption", children: "~" }),
733
+ /* @__PURE__ */ jsx(
734
+ Input,
735
+ {
736
+ type: "date",
737
+ mono: true,
738
+ value: values[toKey] ?? "",
739
+ onChange: (e) => onChange({ [toKey]: e.target.value })
740
+ }
741
+ )
742
+ ] }) });
743
+ }
744
+ if (field.type === "numberRange") {
745
+ const fromKey = field.fromKey ?? `${field.key}Min`;
746
+ const toKey = field.toKey ?? `${field.key}Max`;
747
+ return /* @__PURE__ */ jsx(Field, { label: field.label, children: /* @__PURE__ */ jsxs("div", { className: "wp-row", children: [
748
+ /* @__PURE__ */ jsx(
749
+ Input,
750
+ {
751
+ type: "number",
752
+ mono: true,
753
+ placeholder: field.minPlaceholder ?? "\uCD5C\uC18C",
754
+ value: values[fromKey] ?? "",
755
+ onChange: (e) => onChange({ [fromKey]: e.target.value })
756
+ }
757
+ ),
758
+ /* @__PURE__ */ jsx("span", { className: "wp-caption", children: "~" }),
759
+ /* @__PURE__ */ jsx(
760
+ Input,
761
+ {
762
+ type: "number",
763
+ mono: true,
764
+ placeholder: field.maxPlaceholder ?? "\uCD5C\uB300",
765
+ value: values[toKey] ?? "",
766
+ onChange: (e) => onChange({ [toKey]: e.target.value })
767
+ }
768
+ )
769
+ ] }) });
770
+ }
771
+ return /* @__PURE__ */ jsx(Field, { label: field.label, children: /* @__PURE__ */ jsx(
772
+ Input,
773
+ {
774
+ placeholder: field.placeholder,
775
+ value: values[field.key] ?? "",
776
+ onChange: (e) => onChange({ [field.key]: e.target.value })
777
+ }
778
+ ) });
779
+ }
780
+ function multiSelectLabel(field, values) {
781
+ if (values.length === 0) return field.allLabel ?? "\uC804\uCCB4";
782
+ const labels = values.map((value) => field.options.find((item) => item.value === value)?.label ?? value);
783
+ if (labels.length <= 2) return labels.join(", ");
784
+ return `${labels[0]} \uC678 ${labels.length - 1}`;
785
+ }
786
+ function MultiSelectInput({
787
+ field,
788
+ values,
789
+ onChange
790
+ }) {
791
+ const [open, setOpen] = useState(false);
792
+ const selected = splitMultiValue(values[field.key] ?? "");
793
+ const toggle = (value, checked) => {
794
+ const next = checked ? [...selected, value] : selected.filter((item) => item !== value);
795
+ onChange({ [field.key]: joinMultiValue(next) });
796
+ };
797
+ return /* @__PURE__ */ jsx(
798
+ Popover,
799
+ {
800
+ open,
801
+ onClose: () => setOpen(false),
802
+ trigger: /* @__PURE__ */ jsx(
803
+ Button,
804
+ {
805
+ type: "button",
806
+ variant: "secondary",
807
+ block: true,
808
+ iconEnd: "chevron-down",
809
+ onClick: () => setOpen((value) => !value),
810
+ children: multiSelectLabel(field, selected)
811
+ }
812
+ ),
813
+ children: /* @__PURE__ */ jsx("div", { className: "wp-col", children: field.options.map((option) => /* @__PURE__ */ jsx(
814
+ Checkbox,
815
+ {
816
+ label: option.label,
817
+ checked: selected.includes(option.value),
818
+ onChange: (e) => toggle(option.value, e.target.checked)
819
+ },
820
+ option.value
821
+ )) })
822
+ }
823
+ );
824
+ }
825
+ function uniqueCascadingOptions(options) {
826
+ const seen = /* @__PURE__ */ new Set();
827
+ return options.filter((option) => {
828
+ if (seen.has(option.value)) return false;
829
+ seen.add(option.value);
830
+ return true;
831
+ });
832
+ }
833
+ function cascadingLevels(options, selectedPaths) {
834
+ const levels = [];
835
+ let currentOptions = options;
836
+ let depth = 0;
837
+ while (currentOptions.length > 0) {
838
+ levels.push(currentOptions);
839
+ const selectedAtDepth = uniqueCascadingOptions(
840
+ selectedPaths.map((path) => path[depth]).filter(
841
+ (option) => Boolean(option && currentOptions.some((current) => current.value === option.value))
842
+ )
843
+ );
844
+ const nextOptions = uniqueCascadingOptions(selectedAtDepth.flatMap((option) => option.children ?? []));
845
+ if (nextOptions.length === 0) break;
846
+ currentOptions = nextOptions;
847
+ depth += 1;
848
+ }
849
+ return levels;
850
+ }
851
+ function selectedValuesForCascadingLevel(selectedPaths, depth, options) {
852
+ const optionValues = new Set(options.map((option) => option.value));
853
+ return Array.from(
854
+ new Set(
855
+ selectedPaths.map((path) => path[depth]?.value).filter((value) => Boolean(value) && optionValues.has(value))
856
+ )
857
+ );
858
+ }
859
+ function cascadingPathIncludes(pathMap, value, maybeAncestor) {
860
+ return pathMap.get(value)?.some((option) => option.value === maybeAncestor) ?? false;
861
+ }
862
+ function cascadingValuesOverlap(leftValue, rightValue, pathMap) {
863
+ return cascadingPathIncludes(pathMap, leftValue, rightValue) || cascadingPathIncludes(pathMap, rightValue, leftValue);
864
+ }
865
+ function cascadingLevelLabel(values, allLabel, pathMap, showPathLabels) {
866
+ if (values.length === 0) return allLabel;
867
+ const labels = values.map((value) => {
868
+ if (showPathLabels) return cascadingPathLabel(pathMap, value);
869
+ return pathMap.get(value)?.at(-1)?.label ?? value;
870
+ });
871
+ if (labels.length <= 2) return labels.join(", ");
872
+ return `${labels[0]} \uC678 ${labels.length - 1}`;
873
+ }
874
+ function cascadingOptionLabel(option, pathMap, showPathLabels) {
875
+ return showPathLabels ? cascadingPathLabel(pathMap, option.value) : option.label;
876
+ }
877
+ function CascadingInput({
878
+ field,
879
+ values,
880
+ onChange
881
+ }) {
882
+ const pathMap = useMemo(() => cascadingPathMap(field.options), [field.options]);
883
+ const selectedValues = useMemo(
884
+ () => splitMultiValue(values[field.key] ?? "").filter((value) => pathMap.has(value)),
885
+ [field.key, pathMap, values]
886
+ );
887
+ const selectedPaths = useMemo(
888
+ () => selectedValues.map((value) => pathMap.get(value)).filter((path) => Boolean(path)),
889
+ [pathMap, selectedValues]
890
+ );
891
+ const levels = useMemo(() => cascadingLevels(field.options, selectedPaths), [field.options, selectedPaths]);
892
+ const setSelectedValues = (nextValues) => {
893
+ onChange({ [field.key]: joinMultiValue(nextValues) });
894
+ };
895
+ const toggleOption = (option, nextChecked) => {
896
+ const withoutOverlap = selectedValues.filter(
897
+ (value) => !cascadingValuesOverlap(option.value, value, pathMap)
898
+ );
899
+ setSelectedValues(nextChecked ? [...withoutOverlap, option.value] : withoutOverlap);
900
+ };
901
+ const clearLevel = (depth) => {
902
+ if (depth === 0) {
903
+ setSelectedValues([]);
904
+ return;
905
+ }
906
+ const parents = selectedValuesForCascadingLevel(selectedPaths, depth - 1, levels[depth - 1] ?? []);
907
+ const rest = selectedValues.filter((value) => {
908
+ const parentValue = pathMap.get(value)?.[depth - 1]?.value;
909
+ return !parentValue || !parents.includes(parentValue);
910
+ });
911
+ setSelectedValues([...rest, ...parents]);
912
+ };
913
+ return /* @__PURE__ */ jsx("div", { className: "wp-col", children: levels.map((options, depth) => {
914
+ const levelValues = selectedValuesForCascadingLevel(selectedPaths, depth, options);
915
+ const showPathLabels = depth > 0 && selectedValuesForCascadingLevel(selectedPaths, depth - 1, levels[depth - 1] ?? []).length > 1;
916
+ return /* @__PURE__ */ jsx(
917
+ CascadingLevelSelect,
918
+ {
919
+ allLabel: depth === 0 ? field.allLabel ?? "\uC804\uCCB4" : "\uC804\uCCB4",
920
+ options,
921
+ pathMap,
922
+ selectedValues: levelValues,
923
+ showPathLabels,
924
+ onClear: () => clearLevel(depth),
925
+ onToggle: toggleOption
926
+ },
927
+ depth
928
+ );
929
+ }) });
930
+ }
931
+ function CascadingLevelSelect({
932
+ options,
933
+ pathMap,
934
+ selectedValues,
935
+ allLabel,
936
+ showPathLabels,
937
+ onClear,
938
+ onToggle
939
+ }) {
940
+ const [open, setOpen] = useState(false);
941
+ const selectedSet = new Set(selectedValues);
942
+ return /* @__PURE__ */ jsx(
943
+ Popover,
944
+ {
945
+ open,
946
+ onClose: () => setOpen(false),
947
+ trigger: /* @__PURE__ */ jsx(
948
+ Button,
949
+ {
950
+ type: "button",
951
+ variant: "secondary",
952
+ block: true,
953
+ iconEnd: "chevron-down",
954
+ onClick: () => setOpen((value) => !value),
955
+ children: cascadingLevelLabel(selectedValues, allLabel, pathMap, showPathLabels)
956
+ }
957
+ ),
958
+ footer: /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "xs", icon: "refresh-cw", onClick: onClear, children: allLabel }),
959
+ children: /* @__PURE__ */ jsx("div", { className: "wp-col", style: { minWidth: 200 }, children: options.map((option) => /* @__PURE__ */ jsx(
960
+ Checkbox,
961
+ {
962
+ label: cascadingOptionLabel(option, pathMap, showPathLabels),
963
+ checked: selectedSet.has(option.value),
964
+ onChange: (e) => onToggle(option, e.target.checked)
965
+ },
966
+ option.value
967
+ )) })
968
+ }
969
+ );
970
+ }
971
+ var EMPTY_FILTERS = [];
972
+ var EMPTY_FIELDS2 = [];
973
+ var PAGE_SIZE_CHOICES = [10, 20, 50, 100];
974
+ function SearchResults({
975
+ item,
976
+ columns,
977
+ gridConfig,
978
+ leftFilters = EMPTY_FILTERS,
979
+ rightFilters = EMPTY_FILTERS,
980
+ initialView = "list",
981
+ className,
982
+ searchParams,
983
+ onParamsChange,
984
+ actions = [],
985
+ disableGrid = false,
986
+ disableList = false,
987
+ onRowClick,
988
+ title,
989
+ headerActions,
990
+ isLoading = false,
991
+ hideHeader = false,
992
+ advancedSearchFields = EMPTY_FIELDS2,
993
+ searchPlaceholder,
994
+ virtual
995
+ }) {
996
+ const hasActions = actions.length > 0;
997
+ const page = Number(searchParams.get("page")) || 1;
998
+ const pageSize = Number(searchParams.get("limit")) || 10;
999
+ const pageSizeChoices = PAGE_SIZE_CHOICES.includes(pageSize) ? PAGE_SIZE_CHOICES : [...PAGE_SIZE_CHOICES, pageSize].sort((a, b) => a - b);
1000
+ const applyViewConstraints = (candidate) => {
1001
+ if (disableGrid && disableList) return "list";
1002
+ if (candidate === "grid" && disableGrid) return "list";
1003
+ if (candidate === "list" && disableList) return "grid";
1004
+ return candidate;
1005
+ };
1006
+ const appliedSearch = searchParams.get("search") ?? "";
1007
+ const [view, setView] = useState(() => applyViewConstraints(initialView));
1008
+ const [search, setSearch] = useState(appliedSearch);
1009
+ const [sort, setSort] = useState(null);
1010
+ const [selected, setSelected] = useState(/* @__PURE__ */ new Set());
1011
+ const selectedRowsRef = useRef(/* @__PURE__ */ new Map());
1012
+ useEffect(() => {
1013
+ setSearch(appliedSearch);
1014
+ }, [appliedSearch]);
1015
+ const advancedValues = useMemo(
1016
+ () => searchValuesFromParams(advancedSearchFields, searchParams),
1017
+ [advancedSearchFields, searchParams]
1018
+ );
1019
+ const updateParams = (patch) => {
1020
+ const next = new URLSearchParams(searchParams);
1021
+ for (const [key, value] of Object.entries(patch)) {
1022
+ if (value === null || value === "") next.delete(key);
1023
+ else next.set(key, value);
1024
+ }
1025
+ onParamsChange(next);
1026
+ };
1027
+ const activeFilters = useMemo(() => {
1028
+ const entries = [];
1029
+ for (const filter of [...leftFilters, ...rightFilters]) {
1030
+ const values = splitMultiValue(searchParams.get(filter.key) ?? "");
1031
+ if (values.length > 0) entries.push({ key: filter.key, values });
1032
+ }
1033
+ return entries;
1034
+ }, [leftFilters, rightFilters, searchParams]);
1035
+ const processed = useMemo(() => {
1036
+ const cell = (row, key) => row[key];
1037
+ let rows = item.data;
1038
+ if (appliedSearch) {
1039
+ rows = rows.filter(
1040
+ (row) => includesLooseSearch(
1041
+ Object.values(row).filter((v) => typeof v === "string" || typeof v === "number").join(" "),
1042
+ appliedSearch
1043
+ )
1044
+ );
1045
+ }
1046
+ for (const { key, values } of activeFilters) {
1047
+ rows = rows.filter((row) => values.includes(String(cell(row, key))));
1048
+ }
1049
+ for (const field of advancedSearchFields) {
1050
+ if (field.type === "select") {
1051
+ const wanted = splitMultiValue(advancedValues[field.key] ?? "");
1052
+ if (wanted.length > 0) rows = rows.filter((row) => wanted.includes(String(cell(row, field.key))));
1053
+ } else if (field.type === "cascading") {
1054
+ const wanted = splitMultiValue(advancedValues[field.key] ?? "");
1055
+ if (wanted.length > 0) {
1056
+ const lineages = cascadingValuePaths(field.options);
1057
+ rows = rows.filter((row) => {
1058
+ const own = String(cell(row, field.key));
1059
+ const lineage = lineages.get(own) ?? [own];
1060
+ return lineage.some((value) => wanted.includes(value));
1061
+ });
1062
+ }
1063
+ } else if (field.type === "dateRange") {
1064
+ const from = advancedValues[field.fromKey ?? `${field.key}From`];
1065
+ const to = advancedValues[field.toKey ?? `${field.key}To`];
1066
+ if (from) rows = rows.filter((row) => String(cell(row, field.key) ?? "") >= from);
1067
+ if (to) rows = rows.filter((row) => String(cell(row, field.key) ?? "") <= to);
1068
+ } else if (field.type === "numberRange") {
1069
+ const min = numericValue(advancedValues[field.fromKey ?? `${field.key}Min`]);
1070
+ const max = numericValue(advancedValues[field.toKey ?? `${field.key}Max`]);
1071
+ if (min !== null) rows = rows.filter((row) => (numericValue(cell(row, field.key)) ?? -Infinity) >= min);
1072
+ if (max !== null) rows = rows.filter((row) => (numericValue(cell(row, field.key)) ?? Infinity) <= max);
1073
+ } else {
1074
+ const keyword = advancedValues[field.key];
1075
+ if (keyword) rows = rows.filter((row) => includesLooseSearch(String(cell(row, field.key) ?? ""), keyword));
1076
+ }
1077
+ }
1078
+ if (sort) {
1079
+ const column = columns.find((c) => c.key === sort.key);
1080
+ const dirFactor = sort.dir === "asc" ? 1 : -1;
1081
+ rows = [...rows].sort((a, b) => {
1082
+ const av = column?.accessor ? column.accessor(a) : cell(a, sort.key);
1083
+ const bv = column?.accessor ? column.accessor(b) : cell(b, sort.key);
1084
+ if (av == null) return 1;
1085
+ if (bv == null) return -1;
1086
+ const an = numericValue(av);
1087
+ const bn = numericValue(bv);
1088
+ if (an !== null && bn !== null) return (an - bn) * dirFactor;
1089
+ return String(av).localeCompare(String(bv), "ko", { numeric: true }) * dirFactor;
1090
+ });
1091
+ }
1092
+ return rows;
1093
+ }, [item.data, appliedSearch, activeFilters, advancedSearchFields, advancedValues, sort, columns]);
1094
+ const clientPaginated = item.data.length >= item.total;
1095
+ const totalCount = clientPaginated ? processed.length : item.total;
1096
+ const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));
1097
+ const safePage = Math.min(Math.max(1, page), pageCount);
1098
+ const virtualThreshold = typeof virtual === "number" ? virtual : 150;
1099
+ const virtualEnabled = virtual === true || typeof virtual === "number";
1100
+ const virtualListActive = virtualEnabled && view === "list" && processed.length > virtualThreshold;
1101
+ const visibleItems = clientPaginated && !virtualListActive ? processed.slice((safePage - 1) * pageSize, safePage * pageSize) : processed;
1102
+ const visibleIds = visibleItems.map((row) => row.id);
1103
+ const allSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
1104
+ const someSelected = visibleIds.some((id) => selected.has(id));
1105
+ const toggleSelectAll = (checked) => {
1106
+ if (checked) visibleItems.forEach((row) => selectedRowsRef.current.set(row.id, row));
1107
+ else visibleIds.forEach((id) => selectedRowsRef.current.delete(id));
1108
+ setSelected((prev) => {
1109
+ const next = new Set(prev);
1110
+ visibleIds.forEach((id) => checked ? next.add(id) : next.delete(id));
1111
+ return next;
1112
+ });
1113
+ };
1114
+ const toggleSelect = (row, checked) => {
1115
+ if (checked) selectedRowsRef.current.set(row.id, row);
1116
+ else selectedRowsRef.current.delete(row.id);
1117
+ setSelected((prev) => {
1118
+ const next = new Set(prev);
1119
+ if (checked) next.add(row.id);
1120
+ else next.delete(row.id);
1121
+ return next;
1122
+ });
1123
+ };
1124
+ const selectedItems = Array.from(selected).map((id) => selectedRowsRef.current.get(id)).filter((row) => row !== void 0);
1125
+ const ratioSum = columns.reduce((sum, c) => sum + (c.ratio ?? 0), 0);
1126
+ const tableColumns = [
1127
+ ...hasActions ? [
1128
+ {
1129
+ key: "__select",
1130
+ header: /* @__PURE__ */ jsx(
1131
+ SelectCheckbox,
1132
+ {
1133
+ checked: allSelected,
1134
+ indeterminate: !allSelected && someSelected,
1135
+ label: `${title} \uC804\uCCB4 \uC120\uD0DD`,
1136
+ onChange: (checked) => toggleSelectAll(checked)
1137
+ }
1138
+ ),
1139
+ width: "36px",
1140
+ render: (row) => (
1141
+ // 셀 어디를 눌러도 행 클릭(onRowClick)으로 번지지 않게 래퍼에서 전파를 끊는다
1142
+ /* @__PURE__ */ jsx("span", { onClick: (e) => e.stopPropagation(), children: /* @__PURE__ */ jsx(
1143
+ Checkbox,
1144
+ {
1145
+ checked: selected.has(row.id),
1146
+ onChange: (e) => toggleSelect(row, e.target.checked),
1147
+ "aria-label": `${row.id} \uC120\uD0DD`
1148
+ }
1149
+ ) })
1150
+ )
1151
+ }
1152
+ ] : [],
1153
+ ...columns.map((column) => ({
1154
+ key: column.key,
1155
+ header: column.title,
1156
+ sortable: column.sortable,
1157
+ align: column.textAlign === "right" ? "right" : "left",
1158
+ mono: column.mono,
1159
+ strong: column.strong,
1160
+ width: column.ratio && ratioSum > 0 ? `${Math.round(column.ratio / ratioSum * 100)}%` : void 0,
1161
+ render: column.render ? (row) => column.render(row) : column.accessor ? (row) => formatCell(column.accessor(row)) : void 0
1162
+ }))
1163
+ ];
1164
+ const handleSort = (key) => {
1165
+ setSort((prev) => prev?.key === key ? prev.dir === "asc" ? { key, dir: "desc" } : null : { key, dir: "asc" });
1166
+ };
1167
+ const empty = /* @__PURE__ */ jsx(ResultEmptyState, { title });
1168
+ return /* @__PURE__ */ jsxs("div", { className: ["wp-col", className].filter(Boolean).join(" "), "aria-label": title, children: [
1169
+ !hideHeader ? /* @__PURE__ */ jsxs("div", { className: "wp-row wp-row--between", style: { flexWrap: "wrap" }, children: [
1170
+ /* @__PURE__ */ jsxs("span", { className: "wp-caption", children: [
1171
+ "\uCD1D ",
1172
+ totalCount.toLocaleString("ko-KR"),
1173
+ "\uAC74"
1174
+ ] }),
1175
+ /* @__PURE__ */ jsxs("div", { className: "wp-row", style: { flexWrap: "wrap" }, children: [
1176
+ /* @__PURE__ */ jsx(
1177
+ SearchToolbar,
1178
+ {
1179
+ search,
1180
+ onSearchChange: setSearch,
1181
+ onSearch: (nextSearch, nextValues) => {
1182
+ const patch = { search: nextSearch || null, page: null };
1183
+ for (const key of searchFieldKeys(advancedSearchFields)) patch[key] = null;
1184
+ for (const [key, value] of Object.entries(nextValues)) patch[key] = value;
1185
+ updateParams(patch);
1186
+ },
1187
+ advancedFields: advancedSearchFields,
1188
+ advancedValues,
1189
+ placeholder: searchPlaceholder
1190
+ }
1191
+ ),
1192
+ !disableGrid && !disableList ? /* @__PURE__ */ jsx(ViewToggle, { value: view, onChange: (next) => setView(applyViewConstraints(next)) }) : null,
1193
+ headerActions
1194
+ ] })
1195
+ ] }) : null,
1196
+ leftFilters.length > 0 || rightFilters.length > 0 ? /* @__PURE__ */ jsxs("div", { className: "wp-row wp-row--between", style: { flexWrap: "wrap" }, children: [
1197
+ /* @__PURE__ */ jsx("div", { className: "wp-row", style: { flexWrap: "wrap" }, children: leftFilters.map((filter) => /* @__PURE__ */ jsx(
1198
+ FilterMultiSelect,
1199
+ {
1200
+ filter,
1201
+ values: splitMultiValue(searchParams.get(filter.key) ?? ""),
1202
+ onChange: (values) => updateParams({ [filter.key]: values.join(",") || null, page: null })
1203
+ },
1204
+ filter.key
1205
+ )) }),
1206
+ /* @__PURE__ */ jsx("div", { className: "wp-row", style: { flexWrap: "wrap" }, children: rightFilters.map((filter) => /* @__PURE__ */ jsx(
1207
+ FilterMultiSelect,
1208
+ {
1209
+ filter,
1210
+ values: splitMultiValue(searchParams.get(filter.key) ?? ""),
1211
+ onChange: (values) => updateParams({ [filter.key]: values.join(",") || null, page: null })
1212
+ },
1213
+ filter.key
1214
+ )) })
1215
+ ] }) : null,
1216
+ hasActions && selectedItems.length > 0 ? (
1217
+ // 플로팅 벌크 액션 바 — 스크롤 위치와 무관하게 화면 하단 중앙에 뜬다
1218
+ // (.wp-toolbar 클래스가 라이브러리 스타일시트에 없어 같은 값을 토큰으로 조립했다)
1219
+ /* @__PURE__ */ jsxs(
1220
+ "div",
1221
+ {
1222
+ role: "toolbar",
1223
+ "aria-label": `${title} \uC120\uD0DD \uD56D\uBAA9 \uC77C\uAD04 \uC791\uC5C5`,
1224
+ style: {
1225
+ display: "flex",
1226
+ alignItems: "center",
1227
+ gap: 6,
1228
+ padding: 5,
1229
+ background: "var(--wp-surface-card)",
1230
+ border: "1px solid var(--wp-border-subtle)",
1231
+ borderRadius: "var(--wp-radius-md)",
1232
+ position: "fixed",
1233
+ bottom: "var(--wp-space-6)",
1234
+ left: "50%",
1235
+ transform: "translateX(-50%)",
1236
+ maxWidth: "min(680px, calc(100vw - var(--wp-space-6) * 2))",
1237
+ flexWrap: "wrap",
1238
+ boxShadow: "var(--wp-shadow-lg)",
1239
+ // 다이얼로그(100)·토스트(110)보다 아래
1240
+ zIndex: 90
1241
+ },
1242
+ children: [
1243
+ /* @__PURE__ */ jsxs("span", { className: "wp-label", style: { padding: "0 var(--wp-space-2)", whiteSpace: "nowrap" }, children: [
1244
+ selectedItems.length,
1245
+ "\uAC74 \uC120\uD0DD\uB428"
1246
+ ] }),
1247
+ /* @__PURE__ */ jsx(ToolbarSeparator, {}),
1248
+ actions.map((action) => /* @__PURE__ */ jsx(
1249
+ Button,
1250
+ {
1251
+ size: "sm",
1252
+ icon: action.icon,
1253
+ variant: action.variant === "destructive" ? "danger" : action.variant === "outline" ? "secondary" : "primary",
1254
+ onClick: () => void action.onClick(selectedItems),
1255
+ children: action.label
1256
+ },
1257
+ action.label
1258
+ )),
1259
+ /* @__PURE__ */ jsx(ToolbarSeparator, {}),
1260
+ /* @__PURE__ */ jsx(Button, { size: "sm", variant: "ghost", icon: "x", onClick: () => setSelected(/* @__PURE__ */ new Set()), children: "\uC120\uD0DD \uD574\uC81C" })
1261
+ ]
1262
+ }
1263
+ )
1264
+ ) : null,
1265
+ isLoading ? /* @__PURE__ */ jsx("div", { className: "wp-row", style: { justifyContent: "center", padding: "var(--wp-space-8) 0" }, children: /* @__PURE__ */ jsx(Spinner, { size: "lg", label: `${title} \uBD88\uB7EC\uC624\uB294 \uC911` }) }) : view === "list" ? /* @__PURE__ */ jsx(
1266
+ ResultTable,
1267
+ {
1268
+ columns: tableColumns,
1269
+ rows: visibleItems,
1270
+ sort: sort ?? void 0,
1271
+ onSort: handleSort,
1272
+ onRowClick,
1273
+ selectedIds: hasActions ? Array.from(selected) : void 0,
1274
+ empty,
1275
+ virtual: virtualListActive
1276
+ }
1277
+ ) : visibleItems.length === 0 ? empty : /* @__PURE__ */ jsx("div", { className: "wp-grid wp-grid--3", children: visibleItems.map((row) => /* @__PURE__ */ jsx(GridCard, { item: row, config: gridConfig, onClick: onRowClick }, row.id)) }),
1278
+ virtualListActive && clientPaginated ? null : /* @__PURE__ */ jsxs("div", { className: "wp-row wp-row--between", children: [
1279
+ /* @__PURE__ */ jsxs("div", { className: "wp-row", style: { flex: "none" }, children: [
1280
+ /* @__PURE__ */ jsx("span", { className: "wp-caption", style: { whiteSpace: "nowrap" }, children: "\uD398\uC774\uC9C0\uB2F9" }),
1281
+ /* @__PURE__ */ jsx(
1282
+ Select,
1283
+ {
1284
+ size: "sm",
1285
+ "aria-label": "\uD398\uC774\uC9C0\uB2F9 \uD45C\uC2DC \uAC74\uC218",
1286
+ options: pageSizeChoices.map((size) => ({ value: String(size), label: `${size}\uAC74` })),
1287
+ value: String(pageSize),
1288
+ onChange: (e) => (
1289
+ // 기본값 10은 파라미터를 지워 상태를 깔끔하게 유지하고, 페이지는 1로 되돌린다
1290
+ updateParams({ limit: e.target.value === "10" ? null : e.target.value, page: null })
1291
+ )
1292
+ }
1293
+ )
1294
+ ] }),
1295
+ /* @__PURE__ */ jsx(
1296
+ Pagination,
1297
+ {
1298
+ page: safePage,
1299
+ pageCount,
1300
+ total: totalCount,
1301
+ pageSize,
1302
+ onChange: (nextPage) => updateParams({ page: String(nextPage) })
1303
+ }
1304
+ )
1305
+ ] })
1306
+ ] });
1307
+ }
1308
+ function numericValue(value) {
1309
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
1310
+ if (value instanceof Date) return value.getTime();
1311
+ if (typeof value === "string") {
1312
+ const plain = value.replace(/,/g, "").trim();
1313
+ if (plain !== "" && /^-?\d+(\.\d+)?$/.test(plain)) return Number(plain);
1314
+ }
1315
+ return null;
1316
+ }
1317
+ function formatCell(value) {
1318
+ if (value == null) return "";
1319
+ if (value instanceof Date) return value.toLocaleString("ko-KR");
1320
+ return value;
1321
+ }
1322
+ function ToolbarSeparator() {
1323
+ return /* @__PURE__ */ jsx("span", { style: { width: 1, height: 20, background: "var(--wp-border-subtle)", flex: "none" } });
1324
+ }
1325
+ function SelectCheckbox({
1326
+ checked,
1327
+ indeterminate,
1328
+ label,
1329
+ onChange
1330
+ }) {
1331
+ const ref = useRef(null);
1332
+ useEffect(() => {
1333
+ if (ref.current) ref.current.indeterminate = indeterminate;
1334
+ }, [indeterminate]);
1335
+ return /* @__PURE__ */ jsxs("label", { className: "wp-check", children: [
1336
+ /* @__PURE__ */ jsx(
1337
+ "input",
1338
+ {
1339
+ ref,
1340
+ type: "checkbox",
1341
+ checked,
1342
+ "aria-label": label,
1343
+ onChange: (e) => onChange(e.target.checked)
1344
+ }
1345
+ ),
1346
+ /* @__PURE__ */ jsx(
1347
+ "span",
1348
+ {
1349
+ className: "wp-check__box",
1350
+ style: indeterminate && !checked ? {
1351
+ background: "var(--wp-action-primary-bg)",
1352
+ borderColor: "var(--wp-action-primary-bg)",
1353
+ color: "var(--wp-white)"
1354
+ } : void 0,
1355
+ children: /* @__PURE__ */ jsx(Icon, { name: indeterminate && !checked ? "minus" : "check", size: 12, strokeWidth: 2.5 })
1356
+ }
1357
+ )
1358
+ ] });
1359
+ }
1360
+ function ViewToggle({ value, onChange }) {
1361
+ const items = [
1362
+ { value: "list", icon: "list", label: "\uBAA9\uB85D" },
1363
+ // 아이콘 세트에 layout-grid가 없어 같은 뜻의 layout-dashboard로 대체
1364
+ { value: "grid", icon: "layout-dashboard", label: "\uCE74\uB4DC" }
1365
+ ];
1366
+ return /* @__PURE__ */ jsx(
1367
+ "div",
1368
+ {
1369
+ role: "tablist",
1370
+ style: {
1371
+ display: "inline-flex",
1372
+ padding: 2,
1373
+ gap: 2,
1374
+ background: "var(--wp-surface-sunken)",
1375
+ border: "1px solid var(--wp-border-subtle)",
1376
+ borderRadius: "var(--wp-radius-md)"
1377
+ },
1378
+ children: items.map((tab) => {
1379
+ const active = tab.value === value;
1380
+ return /* @__PURE__ */ jsxs(
1381
+ "button",
1382
+ {
1383
+ type: "button",
1384
+ role: "tab",
1385
+ "aria-selected": active,
1386
+ onClick: () => onChange(tab.value),
1387
+ style: {
1388
+ display: "inline-flex",
1389
+ alignItems: "center",
1390
+ justifyContent: "center",
1391
+ gap: 5,
1392
+ height: 24,
1393
+ padding: "0 10px",
1394
+ border: 0,
1395
+ borderRadius: "var(--wp-radius-sm)",
1396
+ fontSize: 12,
1397
+ fontWeight: 500,
1398
+ whiteSpace: "nowrap",
1399
+ cursor: "pointer",
1400
+ background: active ? "var(--wp-surface-card)" : "transparent",
1401
+ color: active ? "var(--wp-text-strong)" : "var(--wp-text-muted)",
1402
+ boxShadow: active ? "var(--wp-shadow-sm)" : void 0
1403
+ },
1404
+ children: [
1405
+ /* @__PURE__ */ jsx(Icon, { name: tab.icon, size: 14 }),
1406
+ tab.label
1407
+ ]
1408
+ },
1409
+ tab.value
1410
+ );
1411
+ })
1412
+ }
1413
+ );
1414
+ }
1415
+ function resolveRowPart(part, item) {
1416
+ return typeof part === "function" ? part(item) : part;
1417
+ }
1418
+ function GridCard({
1419
+ item,
1420
+ config,
1421
+ onClick
1422
+ }) {
1423
+ if (!config) return null;
1424
+ const badge = config.badge?.(item) ?? null;
1425
+ const renderRows = (rows) => /* @__PURE__ */ jsx("div", { className: "wp-col", children: rows.map((row, idx) => {
1426
+ if (row.show && !row.show(item)) return null;
1427
+ return /* @__PURE__ */ jsxs("div", { className: "wp-row", children: [
1428
+ resolveRowPart(row.icon, item),
1429
+ row.label != null ? /* @__PURE__ */ jsx("span", { className: "wp-caption", children: resolveRowPart(row.label, item) }) : null,
1430
+ /* @__PURE__ */ jsx("span", { className: "wp-body-sm", children: resolveRowPart(row.value, item) })
1431
+ ] }, idx);
1432
+ }) });
1433
+ const note = config.note?.(item);
1434
+ return /* @__PURE__ */ jsxs(
1435
+ Card,
1436
+ {
1437
+ interactive: Boolean(onClick),
1438
+ title: config.title(item),
1439
+ subtitle: config.subtitle?.(item),
1440
+ actions: badge || config.actions ? /* @__PURE__ */ jsxs("span", { className: "wp-row", children: [
1441
+ badge ? /* @__PURE__ */ jsx(Badge, { tone: badge.variant, children: badge.text }) : null,
1442
+ config.actions?.(item)
1443
+ ] }) : void 0,
1444
+ ...onClick ? { onClick: () => onClick(item) } : {},
1445
+ children: [
1446
+ config.leftRows?.length || config.rightRows?.length ? /* @__PURE__ */ jsxs("div", { className: "wp-grid wp-grid--2", children: [
1447
+ renderRows(config.leftRows ?? []),
1448
+ renderRows(config.rightRows ?? [])
1449
+ ] }) : null,
1450
+ config.rows?.length ? renderRows(config.rows) : null,
1451
+ note ? /* @__PURE__ */ jsx("p", { className: "wp-caption", children: note }) : null
1452
+ ]
1453
+ }
1454
+ );
1455
+ }
1456
+ function FilterMultiSelect({
1457
+ filter,
1458
+ values,
1459
+ onChange
1460
+ }) {
1461
+ const [open, setOpen] = useState(false);
1462
+ const label = values.length === 0 ? filter.label : `${filter.label} \xB7 ${values.length}`;
1463
+ return /* @__PURE__ */ jsx(
1464
+ Popover,
1465
+ {
1466
+ open,
1467
+ onClose: () => setOpen(false),
1468
+ trigger: /* @__PURE__ */ jsx(
1469
+ Button,
1470
+ {
1471
+ type: "button",
1472
+ size: "sm",
1473
+ variant: values.length > 0 ? "primary" : "secondary",
1474
+ iconEnd: "chevron-down",
1475
+ onClick: () => setOpen((value) => !value),
1476
+ children: label
1477
+ }
1478
+ ),
1479
+ footer: /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "xs", icon: "refresh-cw", onClick: () => onChange([]), children: "\uCD08\uAE30\uD654" }),
1480
+ children: /* @__PURE__ */ jsx("div", { className: "wp-col", children: filter.options.map((option) => /* @__PURE__ */ jsx(
1481
+ Checkbox,
1482
+ {
1483
+ label: option.label,
1484
+ checked: values.includes(option.value),
1485
+ onChange: (e) => onChange(
1486
+ e.target.checked ? [...values, option.value] : values.filter((value) => value !== option.value)
1487
+ )
1488
+ },
1489
+ option.key
1490
+ )) })
1491
+ }
1492
+ );
1493
+ }
1494
+ function toList(action) {
1495
+ return Array.isArray(action) ? action : [action];
1496
+ }
1497
+ function mapColumns(columns) {
1498
+ return (columns ?? []).map((column) => ({
1499
+ key: column.key,
1500
+ title: column.title,
1501
+ sortable: column.sortable,
1502
+ textAlign: column.textAlign,
1503
+ ratio: column.ratio,
1504
+ mono: column.mono,
1505
+ strong: column.strong,
1506
+ render: column.cell?.type === "badge" ? (row) => {
1507
+ const value = String(row[column.key] ?? "");
1508
+ if (!value) return null;
1509
+ return /* @__PURE__ */ jsx(Badge, { tone: column.cell?.toneMap?.[value] ?? "neutral", children: value });
1510
+ } : void 0
1511
+ }));
1512
+ }
1513
+ function mapGridCard(gridCard) {
1514
+ if (!gridCard) return void 0;
1515
+ return {
1516
+ title: (row) => String(row[gridCard.titleKey] ?? ""),
1517
+ subtitle: gridCard.subtitleKey ? (row) => String(row[gridCard.subtitleKey] ?? "") : void 0,
1518
+ badge: gridCard.badge ? (row) => {
1519
+ const value = String(row[gridCard.badge.key] ?? "");
1520
+ if (!value) return null;
1521
+ return { text: value, variant: gridCard.badge.toneMap?.[value] ?? "neutral" };
1522
+ } : void 0,
1523
+ rows: gridCard.rows?.map((row) => ({
1524
+ label: row.label,
1525
+ value: (item) => String(item[row.valueKey] ?? "")
1526
+ }))
1527
+ };
1528
+ }
1529
+ function mapActions(actions, dispatch) {
1530
+ return (actions ?? []).map((action) => ({
1531
+ label: action.label,
1532
+ icon: action.icon,
1533
+ variant: action.variant,
1534
+ onClick: (selectedItems) => {
1535
+ const ids = selectedItems.map((row) => row.id);
1536
+ toList(action.run).forEach((a) => dispatch(a, ids));
1537
+ }
1538
+ }));
1539
+ }
1540
+ function SduiSearchResults({
1541
+ title,
1542
+ data,
1543
+ columns,
1544
+ fields,
1545
+ leftFilters,
1546
+ rightFilters,
1547
+ gridCard,
1548
+ actions,
1549
+ initialView,
1550
+ disableGrid,
1551
+ disableList,
1552
+ searchPlaceholder,
1553
+ rowClick,
1554
+ headerActions,
1555
+ virtual
1556
+ }) {
1557
+ const dispatch = useSduiDispatch();
1558
+ const [params, setParams] = useState(() => new URLSearchParams());
1559
+ const rows = Array.isArray(data) ? data : [];
1560
+ return /* @__PURE__ */ jsx(
1561
+ SearchResults,
1562
+ {
1563
+ title,
1564
+ item: { data: rows, total: rows.length },
1565
+ columns: mapColumns(columns),
1566
+ gridConfig: mapGridCard(gridCard),
1567
+ leftFilters,
1568
+ rightFilters,
1569
+ actions: mapActions(actions, dispatch),
1570
+ initialView,
1571
+ disableGrid: disableGrid ?? !gridCard,
1572
+ disableList,
1573
+ advancedSearchFields: fields,
1574
+ searchPlaceholder,
1575
+ headerActions,
1576
+ virtual,
1577
+ onRowClick: rowClick ? (row) => toList(rowClick).forEach((a) => dispatch(a, row)) : void 0,
1578
+ searchParams: params,
1579
+ onParamsChange: setParams
1580
+ }
1581
+ );
1582
+ }
1583
+ function RemoteSearchResults({
1584
+ title,
1585
+ endpoint,
1586
+ columns,
1587
+ fields,
1588
+ leftFilters,
1589
+ rightFilters,
1590
+ gridCard,
1591
+ actions,
1592
+ initialView,
1593
+ disableGrid,
1594
+ disableList,
1595
+ searchPlaceholder,
1596
+ rowClick,
1597
+ headerActions,
1598
+ virtual,
1599
+ pageSize = 10
1600
+ }) {
1601
+ const dispatch = useSduiDispatch();
1602
+ const [params, setParams] = useState(() => {
1603
+ const initial = new URLSearchParams();
1604
+ initial.set("limit", String(pageSize));
1605
+ return initial;
1606
+ });
1607
+ const query = useMemo(() => {
1608
+ const next = new URLSearchParams(params);
1609
+ if (!next.has("page")) next.set("page", "1");
1610
+ if (!next.has("limit")) next.set("limit", "10");
1611
+ return next;
1612
+ }, [params]);
1613
+ const { page, isLoading, error, reload } = useRemoteSearchPage(endpoint, query);
1614
+ if (error && page.data.length === 0) {
1615
+ return /* @__PURE__ */ jsx(
1616
+ EmptyState,
1617
+ {
1618
+ icon: "triangle-alert",
1619
+ title: `${title} \uBAA9\uB85D\uC744 \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4`,
1620
+ actions: /* @__PURE__ */ jsx(Button, { size: "sm", variant: "secondary", icon: "refresh-cw", onClick: reload, children: "\uB2E4\uC2DC \uC2DC\uB3C4" }),
1621
+ children: error.message || "\uC7A0\uC2DC \uD6C4 \uB2E4\uC2DC \uC2DC\uB3C4\uD574 \uC8FC\uC138\uC694."
1622
+ }
1623
+ );
1624
+ }
1625
+ return /* @__PURE__ */ jsx(
1626
+ SearchResults,
1627
+ {
1628
+ title,
1629
+ item: page,
1630
+ columns: mapColumns(columns),
1631
+ gridConfig: mapGridCard(gridCard),
1632
+ leftFilters,
1633
+ rightFilters,
1634
+ actions: mapActions(actions, dispatch),
1635
+ initialView,
1636
+ disableGrid: disableGrid ?? !gridCard,
1637
+ disableList,
1638
+ advancedSearchFields: fields,
1639
+ searchPlaceholder,
1640
+ headerActions,
1641
+ virtual,
1642
+ isLoading,
1643
+ onRowClick: rowClick ? (row) => toList(rowClick).forEach((a) => dispatch(a, row)) : void 0,
1644
+ searchParams: params,
1645
+ onParamsChange: setParams
1646
+ }
1647
+ );
1648
+ }
1649
+ function SduiDialog(props) {
1650
+ const { onClose } = props;
1651
+ useEffect(() => {
1652
+ if (!onClose) return;
1653
+ const onKeyDown = (event) => {
1654
+ if (event.key === "Escape") onClose();
1655
+ };
1656
+ document.addEventListener("keydown", onKeyDown);
1657
+ return () => document.removeEventListener("keydown", onKeyDown);
1658
+ }, [onClose]);
1659
+ return /* @__PURE__ */ jsx(
1660
+ "div",
1661
+ {
1662
+ style: { position: "fixed", inset: 0, zIndex: "var(--wp-z-dialog)" },
1663
+ onClick: (event) => {
1664
+ if (onClose && event.target instanceof HTMLElement && event.target.classList.contains("wp-overlay")) {
1665
+ onClose();
1666
+ }
1667
+ },
1668
+ children: /* @__PURE__ */ jsx(Dialog, { ...props })
1669
+ }
1670
+ );
1671
+ }
1672
+ var SduiRuntimeContext = createContext(null);
1673
+ function useSduiRuntime() {
1674
+ const runtime = useContext(SduiRuntimeContext);
1675
+ if (!runtime) throw new Error("useSduiRuntime\uC740 SduiRenderer \uB0B4\uBD80\uC5D0\uC11C\uB9CC \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.");
1676
+ return runtime;
1677
+ }
1678
+ function SduiForm({
1679
+ id,
1680
+ initial,
1681
+ initialFromUrl,
1682
+ rules,
1683
+ labels,
1684
+ guard = false,
1685
+ onSubmit,
1686
+ className,
1687
+ style,
1688
+ children
1689
+ }) {
1690
+ const runtime = useSduiRuntime();
1691
+ const initialSnapshot = useRef(null);
1692
+ const rulesJson = JSON.stringify(rules ?? {});
1693
+ const labelsJson = JSON.stringify(labels ?? {});
1694
+ useEffect(() => {
1695
+ runtime.registerForm(id, { rules: JSON.parse(rulesJson), labels: JSON.parse(labelsJson) });
1696
+ return () => runtime.unregisterForm(id);
1697
+ }, [id, rulesJson, labelsJson]);
1698
+ const sliceOf = (state) => {
1699
+ const prefix = `${id}.`;
1700
+ const keys = Object.keys(state).filter((key) => key.startsWith(prefix) && !key.slice(prefix.length).startsWith("$")).sort();
1701
+ return JSON.stringify(keys.map((key) => [key, state[key]]));
1702
+ };
1703
+ useEffect(() => {
1704
+ const entries = {};
1705
+ for (const [field, value] of Object.entries(initial ?? {})) {
1706
+ if (runtime.state[`${id}.${field}`] === void 0) entries[`${id}.${field}`] = value;
1707
+ }
1708
+ if (initialFromUrl && typeof window !== "undefined") {
1709
+ const params = new URLSearchParams(window.location.search);
1710
+ for (const [field, param] of Object.entries(initialFromUrl)) {
1711
+ const raw = params.get(param);
1712
+ if (raw !== null && runtime.state[`${id}.${field}`] === void 0) {
1713
+ entries[`${id}.${field}`] = raw;
1714
+ }
1715
+ }
1716
+ }
1717
+ if (Object.keys(entries).length > 0) runtime.setEntries(entries);
1718
+ initialSnapshot.current = sliceOf({ ...runtime.state, ...entries });
1719
+ }, [id]);
1720
+ useEffect(() => {
1721
+ return () => {
1722
+ runtime.setEntries({ [`${id}.$errors`]: {}, [`${id}.$dirty`]: false });
1723
+ };
1724
+ }, [id]);
1725
+ const slice = useMemo(() => sliceOf(runtime.state), [runtime.state, id]);
1726
+ useEffect(() => {
1727
+ if (initialSnapshot.current === null) return;
1728
+ if (runtime.state[`${id}.$resetDirty`] === true) {
1729
+ initialSnapshot.current = slice;
1730
+ runtime.setEntries({ [`${id}.$resetDirty`]: false, [`${id}.$dirty`]: false });
1731
+ return;
1732
+ }
1733
+ const dirty2 = slice !== initialSnapshot.current;
1734
+ if (Boolean(runtime.state[`${id}.$dirty`]) !== dirty2) {
1735
+ runtime.setEntries({ [`${id}.$dirty`]: dirty2 });
1736
+ }
1737
+ }, [slice, id, runtime]);
1738
+ const dirty = Boolean(runtime.state[`${id}.$dirty`]);
1739
+ useEffect(() => {
1740
+ if (!guard || !dirty) return;
1741
+ const onBeforeUnload = (event) => {
1742
+ event.preventDefault();
1743
+ event.returnValue = "";
1744
+ };
1745
+ window.addEventListener("beforeunload", onBeforeUnload);
1746
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
1747
+ }, [guard, dirty]);
1748
+ return /* @__PURE__ */ jsx(
1749
+ "form",
1750
+ {
1751
+ className: ["wp-col", className].filter(Boolean).join(" "),
1752
+ style,
1753
+ onSubmit: (event) => {
1754
+ event.preventDefault();
1755
+ onSubmit?.(event);
1756
+ },
1757
+ noValidate: true,
1758
+ children
1759
+ }
1760
+ );
1761
+ }
1762
+
1763
+ // src/registry.tsx
1764
+ var defaultRegistry = {
1765
+ // 기본 컴포넌트
1766
+ Alert,
1767
+ Badge,
1768
+ Button,
1769
+ Card,
1770
+ Checkbox,
1771
+ DatePicker,
1772
+ DateRangePicker,
1773
+ // Dialog는 표준 닫기 UX(Escape·배경 클릭)가 붙은 래퍼로 등록한다
1774
+ Dialog: SduiDialog,
1775
+ EmptyState,
1776
+ Field,
1777
+ Icon,
1778
+ IconButton,
1779
+ Input,
1780
+ Pagination,
1781
+ ProgressBar,
1782
+ Select,
1783
+ Spinner,
1784
+ StatCard,
1785
+ Switch,
1786
+ Table,
1787
+ Tabs,
1788
+ Tag,
1789
+ Textarea,
1790
+ Toast,
1791
+ // 레이아웃/유틸 프리미티브
1792
+ Actions,
1793
+ Col,
1794
+ Divider,
1795
+ Grid,
1796
+ KeyValueList,
1797
+ PageHead,
1798
+ Row,
1799
+ Section,
1800
+ Show,
1801
+ Spacer,
1802
+ Text,
1803
+ // 폼 컨테이너 — 검증 규칙 등록·dirty 추적·submitForm 대상
1804
+ Form: SduiForm,
1805
+ // 권한 게이트 — 판정은 SduiRenderer의 can 프롭(permission.tsx)에 위임한다.
1806
+ // 주입 전까지는 통과시키되 console.error를 한 번 낸다(근거는 permission.tsx의 MISSING 주석).
1807
+ Can,
1808
+ // 검색+목록 화면. 스펙 이름은 ERP 어휘를 그대로 쓰고, 구현은 JSON 전용 어댑터를 가리킨다
1809
+ // (SearchResults 자체는 함수 프롭을 받는 타입드 컴포넌트라 스펙에서 직접 못 쓴다).
1810
+ SearchResults: SduiSearchResults,
1811
+ // 서버 페이징 모드 — endpoint + pageSize로 조회한다
1812
+ RemoteSearchResults
1813
+ };
1814
+ function createRegistry(extensions) {
1815
+ return { ...defaultRegistry, ...extensions };
1816
+ }
1817
+
1818
+ // src/validate.ts
1819
+ var ACTION_NAMES = /* @__PURE__ */ new Set([
1820
+ "navigate",
1821
+ "setState",
1822
+ "open",
1823
+ "close",
1824
+ "toast",
1825
+ "submitForm",
1826
+ "invalidate",
1827
+ "refresh",
1828
+ "download",
1829
+ "print",
1830
+ "arrayAppend",
1831
+ "arrayRemove",
1832
+ "arrayMove",
1833
+ "arrayUpdate",
1834
+ "custom"
1835
+ ]);
1836
+ var CALC_OPS = /* @__PURE__ */ new Set([
1837
+ "sum",
1838
+ "sub",
1839
+ "mul",
1840
+ "div",
1841
+ "percent",
1842
+ "count",
1843
+ "concat",
1844
+ "encode",
1845
+ "eq",
1846
+ "gt",
1847
+ "gte",
1848
+ "lt",
1849
+ "lte",
1850
+ "not"
1851
+ ]);
1852
+ var RULE_NAMES = /* @__PURE__ */ new Set([
1853
+ "required",
1854
+ "min",
1855
+ "max",
1856
+ "minLength",
1857
+ "maxLength",
1858
+ "pattern",
1859
+ "notBeforeField",
1860
+ "custom"
1861
+ ]);
1862
+ var DENIED_PROPS = /* @__PURE__ */ new Set([
1863
+ "dangerouslySetInnerHTML",
1864
+ "ref",
1865
+ "key",
1866
+ "__proto__",
1867
+ "constructor",
1868
+ "prototype"
1869
+ ]);
1870
+ function isPlainObject(v) {
1871
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1872
+ }
1873
+ function validateSpec(page, knownComponents) {
1874
+ const issues = [];
1875
+ if (!isPlainObject(page)) return [{ path: "$", message: "\uC2A4\uD399\uC774 \uAC1D\uCCB4\uAC00 \uC544\uB2D9\uB2C8\uB2E4" }];
1876
+ if (page.version !== 1) issues.push({ path: "$.version", message: "version\uC740 1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4" });
1877
+ if (!Array.isArray(page.body)) {
1878
+ issues.push({ path: "$.body", message: "body\uB294 \uBC30\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4" });
1879
+ return issues;
1880
+ }
1881
+ page.body.forEach(
1882
+ (child, i) => walkChild(child, `$.body[${i}]`, issues, knownComponents)
1883
+ );
1884
+ return issues;
1885
+ }
1886
+ function walkChild(child, path, issues, known) {
1887
+ if (typeof child === "string") return;
1888
+ if (!isPlainObject(child)) {
1889
+ issues.push({ path, message: "children \uD56D\uBAA9\uC740 \uBB38\uC790\uC5F4 \uB610\uB294 \uB178\uB4DC \uAC1D\uCCB4\uC5EC\uC57C \uD569\uB2C8\uB2E4" });
1890
+ return;
1891
+ }
1892
+ const node = child;
1893
+ if (typeof node.component !== "string" || node.component.length === 0) {
1894
+ issues.push({ path, message: "component\uB294 \uBE44\uC5B4 \uC788\uC9C0 \uC54A\uC740 \uBB38\uC790\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4" });
1895
+ return;
1896
+ }
1897
+ if (known && node.component !== "ForEach" && !known.has(node.component)) {
1898
+ issues.push({ path, message: `\uB4F1\uB85D\uB418\uC9C0 \uC54A\uC740 \uCEF4\uD3EC\uB10C\uD2B8: ${node.component}` });
1899
+ }
1900
+ if (node.props !== void 0 && !isPlainObject(node.props)) {
1901
+ issues.push({ path: `${path}.props`, message: "props\uB294 \uAC1D\uCCB4\uC5EC\uC57C \uD569\uB2C8\uB2E4" });
1902
+ }
1903
+ for (const [name, value] of Object.entries(node.props ?? {})) {
1904
+ if (DENIED_PROPS.has(name)) {
1905
+ issues.push({
1906
+ path: `${path}.props.${name}`,
1907
+ message: name === "dangerouslySetInnerHTML" ? "dangerouslySetInnerHTML\uC740 \uC2A4\uD399\uC5D0\uC11C \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4 (\uC784\uC758 HTML \uC8FC\uC785 \uD1B5\uB85C)" : `${name}\uC740(\uB294) React \uC608\uC57D \uD504\uB86D\uC774\uB77C \uC2A4\uD399\uC5D0\uC11C \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4`
1908
+ });
1909
+ continue;
1910
+ }
1911
+ walkValue(value, `${path}.props.${name}`, issues, name.startsWith("on") || name === "rowClick");
1912
+ }
1913
+ if (node.component === "Form" && isPlainObject(node.props) && node.props.rules !== void 0) {
1914
+ const rules = node.props.rules;
1915
+ if (!isPlainObject(rules)) {
1916
+ issues.push({ path: `${path}.props.rules`, message: "rules\uB294 { \uD544\uB4DC: \uADDC\uCE59\uBC30\uC5F4 } \uAC1D\uCCB4\uC5EC\uC57C \uD569\uB2C8\uB2E4" });
1917
+ } else {
1918
+ for (const [field, list] of Object.entries(rules)) {
1919
+ if (!Array.isArray(list)) {
1920
+ issues.push({ path: `${path}.props.rules.${field}`, message: "\uADDC\uCE59 \uBAA9\uB85D\uC740 \uBC30\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4" });
1921
+ }
1922
+ }
1923
+ }
1924
+ }
1925
+ if (node.slots !== void 0) {
1926
+ if (!isPlainObject(node.slots)) {
1927
+ issues.push({ path: `${path}.slots`, message: "slots\uB294 \uAC1D\uCCB4\uC5EC\uC57C \uD569\uB2C8\uB2E4" });
1928
+ } else {
1929
+ for (const [slot, children] of Object.entries(node.slots)) {
1930
+ if (!Array.isArray(children)) {
1931
+ issues.push({ path: `${path}.slots.${slot}`, message: "\uC2AC\uB86F \uAC12\uC740 \uBC30\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4" });
1932
+ continue;
1933
+ }
1934
+ children.forEach((c, i) => walkChild(c, `${path}.slots.${slot}[${i}]`, issues, known));
1935
+ }
1936
+ }
1937
+ }
1938
+ if (node.children !== void 0) {
1939
+ if (!Array.isArray(node.children)) {
1940
+ issues.push({ path: `${path}.children`, message: "children\uC740 \uBC30\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4" });
1941
+ } else {
1942
+ node.children.forEach((c, i) => walkChild(c, `${path}.children[${i}]`, issues, known));
1943
+ }
1944
+ }
1945
+ }
1946
+ function walkValue(value, path, issues, actionEligible) {
1947
+ if (Array.isArray(value)) {
1948
+ value.forEach((v, i) => walkValue(v, `${path}[${i}]`, issues, actionEligible));
1949
+ return;
1950
+ }
1951
+ if (!isPlainObject(value)) return;
1952
+ if (typeof value.action === "string" && (actionEligible || ACTION_NAMES.has(value.action))) {
1953
+ if (actionEligible && !ACTION_NAMES.has(value.action)) {
1954
+ issues.push({ path, message: `\uC54C \uC218 \uC5C6\uB294 \uC561\uC158: ${value.action}` });
1955
+ } else if (!actionEligible) {
1956
+ issues.push({ path, message: `\uC561\uC158\uC740 on*/rowClick/run \uC790\uB9AC\uC5D0\uC11C\uB9CC \uD574\uC11D\uB429\uB2C8\uB2E4 (${value.action})` });
1957
+ }
1958
+ for (const key of ["onSuccess", "onError"]) {
1959
+ const chain = value[key];
1960
+ if (chain !== void 0) {
1961
+ if (!Array.isArray(chain)) {
1962
+ issues.push({ path: `${path}.${key}`, message: `${key}\uB294 \uC561\uC158 \uBC30\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` });
1963
+ } else {
1964
+ chain.forEach((a, i) => walkValue(a, `${path}.${key}[${i}]`, issues, true));
1965
+ }
1966
+ }
1967
+ }
1968
+ return;
1969
+ }
1970
+ if ("$state" in value && typeof value.$state !== "string") {
1971
+ issues.push({ path, message: "$state\uB294 \uBB38\uC790\uC5F4 \uD0A4\uC5EC\uC57C \uD569\uB2C8\uB2E4" });
1972
+ }
1973
+ if ("$calc" in value) {
1974
+ const calc = value.$calc;
1975
+ if (!isPlainObject(calc) || typeof calc.op !== "string" || !CALC_OPS.has(calc.op)) {
1976
+ issues.push({ path, message: "$calc.op\uC774 \uC720\uD6A8\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4" });
1977
+ } else if (!Array.isArray(calc.args)) {
1978
+ issues.push({ path: `${path}.$calc.args`, message: "args\uB294 \uBC30\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4" });
1979
+ }
1980
+ }
1981
+ if ("rule" in value && typeof value.rule === "string") {
1982
+ if (!RULE_NAMES.has(value.rule)) {
1983
+ issues.push({ path, message: `\uC54C \uC218 \uC5C6\uB294 \uAC80\uC99D \uADDC\uCE59: ${value.rule}` });
1984
+ } else if (value.rule === "pattern") {
1985
+ if (typeof value.value !== "string") {
1986
+ issues.push({ path: `${path}.value`, message: "pattern \uADDC\uCE59\uC758 value\uB294 \uC815\uADDC\uC2DD \uBB38\uC790\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4" });
1987
+ } else {
1988
+ try {
1989
+ new RegExp(value.value);
1990
+ } catch {
1991
+ issues.push({ path: `${path}.value`, message: `\uC815\uADDC\uC2DD\uC73C\uB85C \uCEF4\uD30C\uC77C\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: ${value.value}` });
1992
+ }
1993
+ }
1994
+ }
1995
+ }
1996
+ for (const [k, v] of Object.entries(value)) {
1997
+ if (k === "$calc" || k === "action") continue;
1998
+ walkValue(v, `${path}.${k}`, issues, k === "run" || k === "rowClick" || k.startsWith("on"));
1999
+ }
2000
+ }
2001
+ function isPlainObject2(v) {
2002
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2003
+ }
2004
+ function isStateRef(v) {
2005
+ return isPlainObject2(v) && typeof v.$state === "string";
2006
+ }
2007
+ function isItemRef(v) {
2008
+ return isPlainObject2(v) && "$item" in v;
2009
+ }
2010
+ function isIndexRef(v) {
2011
+ return isPlainObject2(v) && "$index" in v;
2012
+ }
2013
+ function isCalcRef(v) {
2014
+ return isPlainObject2(v) && isPlainObject2(v.$calc);
2015
+ }
2016
+ function isAction(v) {
2017
+ return isPlainObject2(v) && typeof v.action === "string";
2018
+ }
2019
+ function isActionList(v) {
2020
+ return Array.isArray(v) && v.length > 0 && v.every(isAction);
2021
+ }
2022
+ function eventValue(arg) {
2023
+ if (isPlainObject2(arg) && "target" in arg && typeof arg.preventDefault === "function") {
2024
+ const target = arg.target;
2025
+ if (target.type === "checkbox") return target.checked;
2026
+ if (target.type === "number" || target.type === "range") {
2027
+ if (target.value === "") return null;
2028
+ const n = target.valueAsNumber;
2029
+ return Number.isNaN(n) ? null : n;
2030
+ }
2031
+ return target.value;
2032
+ }
2033
+ return arg;
2034
+ }
2035
+ function toNumber(v) {
2036
+ if (typeof v === "number") return v;
2037
+ if (typeof v === "string") {
2038
+ const n = Number(v.replace(/,/g, ""));
2039
+ return Number.isFinite(n) ? n : 0;
2040
+ }
2041
+ return 0;
2042
+ }
2043
+ function toIndex(v) {
2044
+ if (typeof v === "number") return Number.isInteger(v) ? v : null;
2045
+ if (typeof v === "string" && /^-?\d+$/.test(v.trim())) return Number(v.trim());
2046
+ return null;
2047
+ }
2048
+ function inRange(index, length) {
2049
+ return index !== null && index >= 0 && index < length;
2050
+ }
2051
+ var warned2 = /* @__PURE__ */ new Set();
2052
+ function warnOnce2(key, message) {
2053
+ if (warned2.has(key)) return;
2054
+ warned2.add(key);
2055
+ console.error(message);
2056
+ }
2057
+ function validateForm(values, config, customValidators) {
2058
+ const errors = {};
2059
+ if (!config) return errors;
2060
+ for (const [field, rules] of Object.entries(config.rules)) {
2061
+ const value = values[field];
2062
+ const label = config.labels[field] ?? field;
2063
+ for (const rule of rules) {
2064
+ const message = ruleError(rule, value, values, label, customValidators);
2065
+ if (message) {
2066
+ errors[field] = message;
2067
+ break;
2068
+ }
2069
+ }
2070
+ }
2071
+ return errors;
2072
+ }
2073
+ function ruleError(rule, value, values, label, customValidators) {
2074
+ const empty = value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0 || // DateRangePicker의 {} 같은 "값 없는 객체"도 미입력으로 본다
2075
+ typeof value === "object" && !Array.isArray(value) && Object.values(value).every((v) => v === void 0 || v === null || v === "");
2076
+ switch (rule.rule) {
2077
+ case "required":
2078
+ return empty || value === false ? rule.message ?? `${label}\uC744(\uB97C) \uC785\uB825\uD558\uC138\uC694` : null;
2079
+ case "min":
2080
+ return !empty && toNumber(value) < rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value} \uC774\uC0C1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` : null;
2081
+ case "max":
2082
+ return !empty && toNumber(value) > rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value} \uC774\uD558\uC5EC\uC57C \uD569\uB2C8\uB2E4` : null;
2083
+ case "minLength":
2084
+ return !empty && String(value).length < rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value}\uC790 \uC774\uC0C1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` : null;
2085
+ case "maxLength":
2086
+ return !empty && String(value).length > rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value}\uC790 \uC774\uD558\uC5EC\uC57C \uD569\uB2C8\uB2E4` : null;
2087
+ case "pattern":
2088
+ return !empty && !new RegExp(rule.value).test(String(value)) ? rule.message ?? `${label} \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4` : null;
2089
+ case "notBeforeField": {
2090
+ const other = values[rule.field];
2091
+ if (empty || other === void 0 || other === null || other === "") return null;
2092
+ const a = Number(String(value).replace(/,/g, ""));
2093
+ const b = Number(String(other).replace(/,/g, ""));
2094
+ const before = Number.isFinite(a) && Number.isFinite(b) && String(value).trim() !== "" ? a < b : String(value) < String(other);
2095
+ return before ? rule.message ?? `${label}\uC774(\uAC00) \uC21C\uC11C\uC5D0 \uB9DE\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4` : null;
2096
+ }
2097
+ case "custom": {
2098
+ const validator = customValidators?.[rule.name];
2099
+ if (!validator) {
2100
+ if (DEV) console.warn(`[sdui] \uB4F1\uB85D\uB418\uC9C0 \uC54A\uC740 custom \uAC80\uC99D: ${rule.name}`);
2101
+ return null;
2102
+ }
2103
+ const result = validator(value, values);
2104
+ return result ? rule.message ?? result : null;
2105
+ }
2106
+ }
2107
+ }
2108
+ var EMPTY_SCOPE = [];
2109
+ function SpecErrorCard({ children }) {
2110
+ return /* @__PURE__ */ jsx(
2111
+ "div",
2112
+ {
2113
+ role: "alert",
2114
+ className: "wp-caption",
2115
+ style: {
2116
+ color: "var(--wp-text-danger)",
2117
+ border: "1px dashed var(--wp-border-danger)",
2118
+ borderRadius: "var(--wp-radius-md)",
2119
+ padding: "var(--wp-space-3)"
2120
+ },
2121
+ children
2122
+ }
2123
+ );
2124
+ }
2125
+ var DENIED_PROPS2 = /* @__PURE__ */ new Set([
2126
+ "dangerouslySetInnerHTML",
2127
+ "ref",
2128
+ "key",
2129
+ "__proto__",
2130
+ "constructor",
2131
+ "prototype"
2132
+ ]);
2133
+ var URL_PROPS = /* @__PURE__ */ new Set(["href", "src", "action", "formAction", "xlinkHref", "poster"]);
2134
+ function isDangerousUrlValue(value) {
2135
+ const normalized = value.replace(/[\u0000-\u0020]/g, "").toLowerCase();
2136
+ const scheme = /^([a-z][a-z0-9+.-]*):/.exec(normalized);
2137
+ if (!scheme) return false;
2138
+ return !["http", "https", "mailto", "tel", "blob"].includes(scheme[1]);
2139
+ }
2140
+ function childrenOf(node) {
2141
+ if (node.children === void 0) return void 0;
2142
+ if (Array.isArray(node.children)) return node.children;
2143
+ warnOnce2(`children:${node.component}`, `[sdui] <${node.component}>.children\uC740 \uBC30\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4`);
2144
+ return typeof node.children === "string" ? [node.children] : void 0;
2145
+ }
2146
+ function renderChild(child, index, scope, engine) {
2147
+ if (typeof child === "string") return child;
2148
+ if (!isPlainObject2(child)) {
2149
+ warnOnce2(`child:${String(child)}`, `[sdui] \uC798\uBABB\uB41C \uB178\uB4DC(\uBB38\uC790\uC5F4 \uB610\uB294 \uAC1D\uCCB4\uC5EC\uC57C \uD569\uB2C8\uB2E4): ${String(child)}`);
2150
+ return /* @__PURE__ */ jsx(SpecErrorCard, { children: "[sdui] \uC798\uBABB\uB41C \uB178\uB4DC \u2014 \uBB38\uC790\uC5F4 \uB610\uB294 \uB178\uB4DC \uAC1D\uCCB4\uC5EC\uC57C \uD569\uB2C8\uB2E4" }, index);
2151
+ }
2152
+ const node = child;
2153
+ const key = typeof node.key === "string" || typeof node.key === "number" ? node.key : index;
2154
+ const name = typeof node.component === "string" ? node.component : "(\uC774\uB984 \uC5C6\uC74C)";
2155
+ return /* @__PURE__ */ jsx(NodeErrorBoundary, { name, children: /* @__PURE__ */ jsx(SduiNodeRenderer, { node, scope, engine }) }, key);
2156
+ }
2157
+ function SduiNodeRenderer({
2158
+ node,
2159
+ scope,
2160
+ engine
2161
+ }) {
2162
+ const { registry, resolveValue, dispatch } = engine;
2163
+ const slots = isPlainObject2(node.slots) ? node.slots : void 0;
2164
+ const childList = childrenOf(node);
2165
+ if (node.component === "ForEach") {
2166
+ const source = resolveValue(
2167
+ isPlainObject2(node.props) ? node.props.source ?? null : null,
2168
+ scope
2169
+ );
2170
+ const rows = Array.isArray(source) ? source : [];
2171
+ const empty = slots && Array.isArray(slots.empty) ? slots.empty : void 0;
2172
+ if (rows.length === 0 && empty) {
2173
+ return /* @__PURE__ */ jsx(Fragment, { children: empty.map((child, i) => renderChild(child, i, scope, engine)) });
2174
+ }
2175
+ return /* @__PURE__ */ jsx(Fragment, { children: rows.map((item, rowIndex) => {
2176
+ const rowScope = [...scope, { item, index: rowIndex }];
2177
+ const rowKey = isPlainObject2(item) && typeof item.id === "string" ? item.id : rowIndex;
2178
+ return /* @__PURE__ */ jsx(React.Fragment, { children: childList?.map((child, i) => renderChild(child, i, rowScope, engine)) }, rowKey);
2179
+ }) });
2180
+ }
2181
+ const Component = typeof node.component === "string" && Object.hasOwn(registry, node.component) ? registry[node.component] : void 0;
2182
+ if (!Component) {
2183
+ warnOnce2(
2184
+ `component:${String(node.component)}`,
2185
+ `[sdui] \uB4F1\uB85D\uB418\uC9C0 \uC54A\uC740 \uCEF4\uD3EC\uB10C\uD2B8: ${String(node.component)} \u2014 \uC774 \uB178\uB4DC\uC640 \uC790\uC2DD\uC740 \uB80C\uB354\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4`
2186
+ );
2187
+ return /* @__PURE__ */ jsxs(SpecErrorCard, { children: [
2188
+ "[sdui] \uB4F1\uB85D\uB418\uC9C0 \uC54A\uC740 \uCEF4\uD3EC\uB10C\uD2B8: ",
2189
+ String(node.component)
2190
+ ] });
2191
+ }
2192
+ const props = {};
2193
+ for (const [name, raw] of Object.entries(isPlainObject2(node.props) ? node.props : {})) {
2194
+ if (DENIED_PROPS2.has(name)) {
2195
+ warnOnce2(`prop:${name}`, `[sdui] \uC2A4\uD399\uC5D0\uC11C \uD5C8\uC6A9\uB418\uC9C0 \uC54A\uB294 \uD504\uB86D: ${name} \u2014 \uBB34\uC2DC\uD569\uB2C8\uB2E4`);
2196
+ continue;
2197
+ }
2198
+ if (name.startsWith("on") && (isAction(raw) || isActionList(raw))) {
2199
+ const actions = isActionList(raw) ? raw : [raw];
2200
+ props[name] = (arg) => actions.forEach(
2201
+ (a) => dispatch(resolveValue(a, scope), arg)
2202
+ );
2203
+ } else {
2204
+ let resolved = resolveValue(raw, scope);
2205
+ if (resolved === null) {
2206
+ if (name === "value") resolved = "";
2207
+ else if (name === "checked") resolved = false;
2208
+ }
2209
+ if (URL_PROPS.has(name) && typeof resolved === "string" && isDangerousUrlValue(resolved)) {
2210
+ warnOnce2(`url:${name}:${resolved}`, `[sdui] \uD5C8\uC6A9\uB418\uC9C0 \uC54A\uB294 ${name} \uAC12: ${resolved} \u2014 \uBB34\uC2DC\uD569\uB2C8\uB2E4`);
2211
+ continue;
2212
+ }
2213
+ props[name] = resolved;
2214
+ }
2215
+ }
2216
+ for (const [name, slotChildren] of Object.entries(slots ?? {})) {
2217
+ if (!Array.isArray(slotChildren)) {
2218
+ warnOnce2(
2219
+ `slot:${node.component}.${name}`,
2220
+ `[sdui] <${node.component}>.slots.${name}\uC740 \uBC30\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4 \u2014 \uBB34\uC2DC\uD569\uB2C8\uB2E4`
2221
+ );
2222
+ continue;
2223
+ }
2224
+ props[name] = slotChildren.map((child, i) => renderChild(child, i, scope, engine));
2225
+ }
2226
+ const children = childList?.map((child, i) => renderChild(child, i, scope, engine));
2227
+ return /* @__PURE__ */ jsx(Component, { ...props, children });
2228
+ }
2229
+ function SduiRenderer({
2230
+ page,
2231
+ registry = defaultRegistry,
2232
+ adapter,
2233
+ syncUrlState = true,
2234
+ onCustomAction,
2235
+ customValidators
2236
+ }) {
2237
+ const host = useMemo(() => resolveAdapter(adapter), [adapter]);
2238
+ const hostRef = useRef(host);
2239
+ hostRef.current = host;
2240
+ const [state, setState] = useState({});
2241
+ const urlSeeded = useRef(false);
2242
+ const [toasts, setToasts] = useState([]);
2243
+ const toastSeq = useRef(0);
2244
+ const toastTimers = useRef([]);
2245
+ const stateRef = useRef(state);
2246
+ stateRef.current = state;
2247
+ const formsRef = useRef(/* @__PURE__ */ new Map());
2248
+ useEffect(() => {
2249
+ const timers = toastTimers.current;
2250
+ return () => timers.forEach(clearTimeout);
2251
+ }, []);
2252
+ useEffect(() => {
2253
+ if (!DEV) return;
2254
+ const issues = validateSpec(page, new Set(Object.keys(registry)));
2255
+ issues.forEach((issue) => console.warn(`[sdui:spec] ${issue.path} \u2014 ${issue.message}`));
2256
+ }, [page, registry]);
2257
+ const setEntries = useCallback((entries) => {
2258
+ stateRef.current = { ...stateRef.current, ...entries };
2259
+ setState((prev) => ({ ...prev, ...entries }));
2260
+ }, []);
2261
+ useEffect(() => {
2262
+ if (!syncUrlState || urlSeeded.current || !page.urlState?.length || typeof window === "undefined") return;
2263
+ urlSeeded.current = true;
2264
+ const params = new URLSearchParams(window.location.search);
2265
+ const seeded = {};
2266
+ for (const key of page.urlState) {
2267
+ const raw = params.get(key);
2268
+ if (raw !== null) {
2269
+ try {
2270
+ seeded[key] = JSON.parse(raw);
2271
+ } catch {
2272
+ seeded[key] = raw;
2273
+ }
2274
+ }
2275
+ }
2276
+ if (Object.keys(seeded).length > 0) setEntries(seeded);
2277
+ }, [page.urlState, setEntries, syncUrlState]);
2278
+ useEffect(() => {
2279
+ if (!syncUrlState || !page.urlState?.length || !urlSeeded.current || typeof window === "undefined") return;
2280
+ const params = new URLSearchParams(window.location.search);
2281
+ let changed = false;
2282
+ for (const key of page.urlState) {
2283
+ const value = state[key];
2284
+ const serialized = value === void 0 || value === null || value === "" ? null : JSON.stringify(value);
2285
+ if (serialized === null) {
2286
+ if (params.has(key)) {
2287
+ params.delete(key);
2288
+ changed = true;
2289
+ }
2290
+ } else if (params.get(key) !== serialized) {
2291
+ params.set(key, serialized);
2292
+ changed = true;
2293
+ }
2294
+ }
2295
+ if (changed) {
2296
+ if (hostRef.current.replaceQuery) {
2297
+ hostRef.current.replaceQuery(params);
2298
+ } else {
2299
+ const query = params.toString();
2300
+ window.history.replaceState(
2301
+ window.history.state,
2302
+ "",
2303
+ `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`
2304
+ );
2305
+ }
2306
+ }
2307
+ }, [state, page.urlState, syncUrlState]);
2308
+ const subscribeChannels = page.subscribe?.join(",") ?? "";
2309
+ useEffect(() => {
2310
+ if (!subscribeChannels || !host.subscribe) return;
2311
+ let timer = null;
2312
+ const unsubscribe = host.subscribe(subscribeChannels.split(","), () => {
2313
+ if (timer) clearTimeout(timer);
2314
+ timer = setTimeout(() => {
2315
+ host.invalidate?.("", false);
2316
+ host.refresh?.();
2317
+ }, 300);
2318
+ });
2319
+ return () => {
2320
+ if (timer) clearTimeout(timer);
2321
+ unsubscribe();
2322
+ };
2323
+ }, [subscribeChannels, host]);
2324
+ const closeToast = useCallback((id) => {
2325
+ setToasts((prev) => prev.filter((t) => t.id !== id));
2326
+ }, []);
2327
+ const pushToast = useCallback(
2328
+ (toast) => {
2329
+ const id = ++toastSeq.current;
2330
+ setToasts((prev) => [...prev, { ...toast, id }]);
2331
+ toastTimers.current.push(setTimeout(() => closeToast(id), 4e3));
2332
+ },
2333
+ [closeToast]
2334
+ );
2335
+ const lookupState = useCallback((key) => {
2336
+ const current = stateRef.current;
2337
+ if (key in current) return current[key];
2338
+ const segments = key.split(".");
2339
+ for (let cut = segments.length - 1; cut >= 1; cut--) {
2340
+ const head = segments.slice(0, cut).join(".");
2341
+ if (head in current) {
2342
+ let value = current[head];
2343
+ for (const segment of segments.slice(cut)) {
2344
+ if (!isPlainObject2(value)) return void 0;
2345
+ value = value[segment];
2346
+ }
2347
+ return value;
2348
+ }
2349
+ }
2350
+ return void 0;
2351
+ }, []);
2352
+ const resolveValue = useCallback(
2353
+ (value, scope) => {
2354
+ if (isStateRef(value)) {
2355
+ const current = lookupState(value.$state);
2356
+ return current === void 0 ? value.default ?? null : current;
2357
+ }
2358
+ if (isItemRef(value)) {
2359
+ const frame = scope[scope.length - 1];
2360
+ if (!frame) return null;
2361
+ if (value.$item === true) return frame.item;
2362
+ return isPlainObject2(frame.item) ? frame.item[value.$item] ?? null : null;
2363
+ }
2364
+ if (isIndexRef(value)) {
2365
+ return scope[scope.length - 1]?.index ?? null;
2366
+ }
2367
+ if (isCalcRef(value)) {
2368
+ const { op: rawOp, args, field: rawField } = value.$calc;
2369
+ const op = typeof rawOp === "string" ? rawOp : "";
2370
+ if (!Array.isArray(args)) {
2371
+ warnOnce2(`calc:${op}`, `[sdui] $calc(${op || "?"}): args\uB294 \uBC30\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4 \u2014 \uAC12 \uC5C6\uC74C\uC73C\uB85C \uCC98\uB9AC\uD569\uB2C8\uB2E4`);
2372
+ return null;
2373
+ }
2374
+ const field = typeof rawField === "string" ? rawField : void 0;
2375
+ const resolved = args.map((arg) => resolveValue(arg, scope));
2376
+ const nums = (v) => {
2377
+ if (Array.isArray(v))
2378
+ return v.map((row) => field && isPlainObject2(row) ? toNumber(row[field]) : toNumber(row));
2379
+ return [toNumber(v)];
2380
+ };
2381
+ switch (op) {
2382
+ case "sum":
2383
+ return resolved.flatMap(nums).reduce((a, b) => a + b, 0);
2384
+ case "count":
2385
+ return resolved.reduce((a, v) => a + (Array.isArray(v) ? v.length : v == null ? 0 : 1), 0);
2386
+ case "sub": {
2387
+ const list = resolved.map((v) => toNumber(v));
2388
+ return list.length === 0 ? 0 : list.reduce((a, b) => a - b);
2389
+ }
2390
+ case "mul":
2391
+ return resolved.map((v) => toNumber(v)).reduce((a, b) => a * b, 1);
2392
+ case "div": {
2393
+ const [a, b] = resolved.map((v) => toNumber(v));
2394
+ return b === 0 ? 0 : a / b;
2395
+ }
2396
+ case "percent": {
2397
+ const [a, b] = resolved.map((v) => toNumber(v));
2398
+ return b === 0 ? 0 : Math.round(a / b * 1e3) / 10;
2399
+ }
2400
+ case "concat":
2401
+ return resolved.map((v) => v == null ? "" : String(v)).join("");
2402
+ case "encode":
2403
+ return encodeURIComponent(resolved.map((v) => v == null ? "" : String(v)).join(""));
2404
+ case "eq": {
2405
+ const [a, b] = resolved;
2406
+ return String(a ?? "") === String(b ?? "");
2407
+ }
2408
+ // 수량 비교 — 정원 초과 차단(Button.disabled), 임계값 경고(Show.when) 등
2409
+ case "gt":
2410
+ return toNumber(resolved[0]) > toNumber(resolved[1]);
2411
+ case "gte":
2412
+ return toNumber(resolved[0]) >= toNumber(resolved[1]);
2413
+ case "lt":
2414
+ return toNumber(resolved[0]) < toNumber(resolved[1]);
2415
+ case "lte":
2416
+ return toNumber(resolved[0]) <= toNumber(resolved[1]);
2417
+ /** 부정 — 조건을 뒤집어 "없을 때만" 렌더링하는 데 쓴다 */
2418
+ case "not":
2419
+ return !resolved[0];
2420
+ default:
2421
+ return null;
2422
+ }
2423
+ }
2424
+ if (Array.isArray(value)) return value.map((v) => resolveValue(v, scope));
2425
+ if (isPlainObject2(value)) {
2426
+ const out = {};
2427
+ for (const [k, v] of Object.entries(value)) out[k] = resolveValue(v, scope);
2428
+ return out;
2429
+ }
2430
+ return value;
2431
+ },
2432
+ [lookupState]
2433
+ );
2434
+ const dispatchRef = useRef(() => {
2435
+ });
2436
+ const dispatch = useCallback(
2437
+ (action, arg) => {
2438
+ switch (action.action) {
2439
+ case "navigate":
2440
+ if (!isSafeNavigationTarget(action.to)) {
2441
+ warnOnce2(`navigate:${String(action.to)}`, `[sdui] \uD5C8\uC6A9\uB418\uC9C0 \uC54A\uB294 navigate \uB300\uC0C1: ${String(action.to)}`);
2442
+ break;
2443
+ }
2444
+ hostRef.current.navigate(action.to);
2445
+ break;
2446
+ case "setState":
2447
+ setEntries({ [action.key]: action.fromEvent ? eventValue(arg) : action.value });
2448
+ break;
2449
+ case "open":
2450
+ setEntries({ [action.key]: true });
2451
+ break;
2452
+ case "close":
2453
+ setEntries({ [action.key]: false });
2454
+ break;
2455
+ case "toast":
2456
+ pushToast({ tone: action.tone ?? "info", title: action.title, message: action.message });
2457
+ break;
2458
+ case "arrayAppend": {
2459
+ const current = stateRef.current[action.key];
2460
+ const arr = Array.isArray(current) ? current : [];
2461
+ setEntries({ [action.key]: [...arr, action.value ?? {}] });
2462
+ break;
2463
+ }
2464
+ case "arrayRemove": {
2465
+ const current = stateRef.current[action.key];
2466
+ if (!Array.isArray(current)) break;
2467
+ const index = toIndex(action.index);
2468
+ if (!inRange(index, current.length)) {
2469
+ warnOnce2(`arrayRemove:${action.key}`, `[sdui] arrayRemove(${action.key}): \uC798\uBABB\uB41C index \u2014 \uBB34\uC2DC\uD569\uB2C8\uB2E4`);
2470
+ break;
2471
+ }
2472
+ setEntries({ [action.key]: current.filter((_, i) => i !== index) });
2473
+ break;
2474
+ }
2475
+ case "arrayMove": {
2476
+ const current = stateRef.current[action.key];
2477
+ if (!Array.isArray(current)) break;
2478
+ const from = toIndex(action.from);
2479
+ const rawTo = toIndex(action.to);
2480
+ if (!inRange(from, current.length) || rawTo === null) {
2481
+ warnOnce2(`arrayMove:${action.key}`, `[sdui] arrayMove(${action.key}): \uC798\uBABB\uB41C from/to \u2014 \uBB34\uC2DC\uD569\uB2C8\uB2E4`);
2482
+ break;
2483
+ }
2484
+ const to = Math.max(0, Math.min(current.length - 1, rawTo));
2485
+ if (from === to) break;
2486
+ const next = [...current];
2487
+ const [moved] = next.splice(from, 1);
2488
+ next.splice(to, 0, moved);
2489
+ setEntries({ [action.key]: next });
2490
+ break;
2491
+ }
2492
+ case "arrayUpdate": {
2493
+ const current = stateRef.current[action.key];
2494
+ if (!Array.isArray(current)) break;
2495
+ const index = toIndex(action.index);
2496
+ if (!inRange(index, current.length) || typeof action.field !== "string") {
2497
+ warnOnce2(`arrayUpdate:${action.key}`, `[sdui] arrayUpdate(${action.key}): \uC798\uBABB\uB41C index/field \u2014 \uBB34\uC2DC\uD569\uB2C8\uB2E4`);
2498
+ break;
2499
+ }
2500
+ const row = current[index];
2501
+ if (!isPlainObject2(row)) break;
2502
+ const nextValue = (action.fromEvent ? eventValue(arg) : action.value) ?? null;
2503
+ const next = [...current];
2504
+ next[index] = { ...row, [action.field]: nextValue };
2505
+ setEntries({ [action.key]: next });
2506
+ break;
2507
+ }
2508
+ case "submitForm": {
2509
+ const formId = action.form;
2510
+ if (stateRef.current[`${formId}.$submitting`] === true) break;
2511
+ void (async () => {
2512
+ setEntries({ [`${formId}.$submitting`]: true });
2513
+ let phase = "validate";
2514
+ try {
2515
+ const prefix = `${formId}.`;
2516
+ const values = {};
2517
+ for (const [key, value] of Object.entries(stateRef.current)) {
2518
+ if (key.startsWith(prefix) && !key.slice(prefix.length).startsWith("$") && value !== void 0) {
2519
+ values[key.slice(prefix.length)] = value;
2520
+ }
2521
+ }
2522
+ const errors = validateForm(values, formsRef.current.get(formId), customValidators);
2523
+ if (Object.keys(errors).length > 0) {
2524
+ setEntries({ [`${formId}.$errors`]: errors, [`${formId}.$submitting`]: false });
2525
+ return;
2526
+ }
2527
+ setEntries({ [`${formId}.$errors`]: {}, [`${formId}.$submitting`]: true });
2528
+ phase = "submit";
2529
+ const response = await hostRef.current.fetch(action.endpoint, {
2530
+ method: action.method ?? "POST",
2531
+ body: values
2532
+ });
2533
+ setEntries({ [`${formId}.$submitting`]: false, [`${formId}.$resetDirty`]: true });
2534
+ (action.onSuccess ?? []).forEach((a) => dispatchRef.current(a, response));
2535
+ } catch (error) {
2536
+ setEntries({ [`${formId}.$submitting`]: false });
2537
+ const message = error instanceof Error ? error.message : "\uC694\uCCAD\uC774 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4";
2538
+ if (phase === "validate") {
2539
+ warnOnce2(`validate:${formId}`, `[sdui] ${formId} \uAC80\uC99D \uADDC\uCE59 \uC624\uB958: ${message}`);
2540
+ pushToast({ tone: "danger", title: "\uAC80\uC99D \uADDC\uCE59 \uC624\uB958", message });
2541
+ } else if (action.onError?.length) {
2542
+ action.onError.forEach((a) => dispatchRef.current(a, message));
2543
+ } else {
2544
+ pushToast({ tone: "danger", title: "\uC800\uC7A5 \uC2E4\uD328", message });
2545
+ }
2546
+ }
2547
+ })();
2548
+ break;
2549
+ }
2550
+ case "invalidate":
2551
+ hostRef.current.invalidate?.(action.prefix, action.exact ?? false);
2552
+ break;
2553
+ case "refresh":
2554
+ hostRef.current.refresh?.();
2555
+ break;
2556
+ case "download": {
2557
+ void (async () => {
2558
+ try {
2559
+ const res = await hostRef.current.rawFetch(action.endpoint);
2560
+ if (!res.ok) {
2561
+ const body = await res.json().catch(() => null);
2562
+ throw new Error(body?.message ?? `\uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328 (${res.status})`);
2563
+ }
2564
+ const blob = await res.blob();
2565
+ const url = URL.createObjectURL(blob);
2566
+ const anchor = document.createElement("a");
2567
+ anchor.href = url;
2568
+ anchor.download = action.filename ?? action.endpoint.split("/").pop() ?? "download";
2569
+ anchor.click();
2570
+ URL.revokeObjectURL(url);
2571
+ } catch (error) {
2572
+ pushToast({
2573
+ tone: "danger",
2574
+ title: "\uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328",
2575
+ message: error instanceof Error ? error.message : void 0
2576
+ });
2577
+ }
2578
+ })();
2579
+ break;
2580
+ }
2581
+ case "print":
2582
+ window.print();
2583
+ break;
2584
+ case "custom":
2585
+ if (onCustomAction) onCustomAction(action.name, action.payload, arg);
2586
+ else if (DEV) console.warn(`[sdui] \uCC98\uB9AC\uB418\uC9C0 \uC54A\uC740 custom \uC561\uC158: ${action.name}`);
2587
+ break;
2588
+ }
2589
+ },
2590
+ [pushToast, onCustomAction, customValidators, setEntries]
2591
+ );
2592
+ dispatchRef.current = dispatch;
2593
+ const engine = useMemo(
2594
+ () => ({ registry, resolveValue, dispatch }),
2595
+ [registry, resolveValue, dispatch]
2596
+ );
2597
+ const runtime = useMemo(
2598
+ () => ({
2599
+ state,
2600
+ setEntries,
2601
+ registerForm: (id, config) => {
2602
+ formsRef.current.set(id, config);
2603
+ },
2604
+ unregisterForm: (id) => {
2605
+ formsRef.current.delete(id);
2606
+ }
2607
+ }),
2608
+ [state, setEntries]
2609
+ );
2610
+ return /* @__PURE__ */ jsx(SduiDispatchContext.Provider, { value: dispatch, children: /* @__PURE__ */ jsx(SduiRuntimeContext.Provider, { value: runtime, children: /* @__PURE__ */ jsxs("div", { className: "sdui-root", children: [
2611
+ page.title || page.subtitle ? /* @__PURE__ */ jsx("div", { className: "wp-page-head", children: /* @__PURE__ */ jsxs("div", { className: "wp-page-head__text", children: [
2612
+ page.title ? /* @__PURE__ */ jsx("h1", { className: "wp-page-head__title", children: page.title }) : null,
2613
+ page.subtitle ? /* @__PURE__ */ jsx("div", { className: "wp-page-head__sub", children: page.subtitle }) : null
2614
+ ] }) }) : null,
2615
+ /* @__PURE__ */ jsx("div", { className: "wp-col", children: Array.isArray(page.body) ? page.body.map((child, i) => renderChild(child, i, EMPTY_SCOPE, engine)) : /* @__PURE__ */ jsx(SpecErrorCard, { children: "[sdui] page.body\uB294 \uBC30\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4" }) }),
2616
+ toasts.length > 0 ? /* @__PURE__ */ jsx("div", { className: "wp-toast-region", children: toasts.map((t) => /* @__PURE__ */ jsx(Toast, { tone: t.tone, title: t.title, onClose: () => closeToast(t.id), children: t.message }, t.id)) }) : null
2617
+ ] }) }) });
2618
+ }
2619
+
2620
+ export { Actions, Can, Col, Divider, Grid, KeyValueList, NodeErrorBoundary, PageHead, RemoteSearchResults, ResultTable, Row, SduiDialog, SduiForm, SduiPermissionProvider, SduiRenderer, SduiSearchDataProvider, SduiSearchResults, SearchResults, SearchToolbar, Section, Show, Spacer, Text, cascadingValuePaths, cleanSearchValues, createRegistry, defaultAdapter, defaultRegistry, includesLooseSearch, joinMultiValue, normalizeSearchText, searchFieldKeys, searchValuesFromParams, splitMultiValue, useRemoteSearchPage, useSduiCan, useSduiDispatch, useSduiRuntime, useSearchFetcher, validateSpec };