@wipco/sdui 0.2.0 → 0.4.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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +27 -0
  3. package/dist/chunk-4SU2KYPU.js +59 -0
  4. package/dist/chunk-56KKWLNI.js +186 -0
  5. package/dist/chunk-7Y4J7EOK.js +115 -0
  6. package/dist/chunk-APCXOCAO.js +110 -0
  7. package/dist/chunk-AROOQ5W6.js +119 -0
  8. package/dist/chunk-DGPRX4MU.js +251 -0
  9. package/dist/chunk-FGRR3ROJ.js +497 -0
  10. package/dist/chunk-IL2KS4WS.js +25 -0
  11. package/dist/chunk-KADFN76X.js +541 -0
  12. package/dist/chunk-KBHI3IUR.js +40 -0
  13. package/dist/chunk-LZJTKVMO.js +767 -0
  14. package/dist/chunk-MOIU7MUA.js +282 -0
  15. package/dist/chunk-MSAV4J4I.js +77 -0
  16. package/dist/chunk-OOHHW4K4.js +159 -0
  17. package/dist/chunk-Q6SBH63R.js +190 -0
  18. package/dist/chunk-QZQDHSMD.js +78 -0
  19. package/dist/chunk-RH7ESUE6.js +309 -0
  20. package/dist/chunk-SEBPBUZR.js +260 -0
  21. package/dist/chunk-USKRM22W.js +39 -0
  22. package/dist/chunk-WNYWTFGW.js +61 -0
  23. package/dist/chunk-WVRCIVAD.js +72 -0
  24. package/dist/chunk-ZNITJ4KK.js +433 -0
  25. package/dist/chunk-ZQR3ULVH.js +193 -0
  26. package/dist/{chunk-QJHD6HVQ.js → chunk-ZUCI34QY.js} +106 -390
  27. package/dist/index.d.ts +419 -56
  28. package/dist/index.js +412 -803
  29. package/dist/meta.d.ts +188 -2
  30. package/dist/meta.js +1754 -9
  31. package/dist/rich.js +19 -4
  32. package/dist/styles.css +246 -0
  33. package/dist/widgets/AutocompleteField.d.ts +96 -0
  34. package/dist/widgets/AutocompleteField.js +3 -0
  35. package/dist/widgets/ColumnBoard.d.ts +47 -0
  36. package/dist/widgets/ColumnBoard.js +2 -0
  37. package/dist/widgets/DiffView.d.ts +30 -0
  38. package/dist/widgets/DiffView.js +2 -0
  39. package/dist/widgets/Disclosure.d.ts +20 -0
  40. package/dist/widgets/Disclosure.js +2 -0
  41. package/dist/widgets/EventCalendar.d.ts +59 -0
  42. package/dist/widgets/EventCalendar.js +2 -0
  43. package/dist/widgets/ImageLightbox.d.ts +25 -0
  44. package/dist/widgets/ImageLightbox.js +2 -0
  45. package/dist/widgets/InlineEdit.d.ts +23 -0
  46. package/dist/widgets/InlineEdit.js +2 -0
  47. package/dist/widgets/JsonField.d.ts +24 -0
  48. package/dist/widgets/JsonField.js +2 -0
  49. package/dist/widgets/LiveClock.d.ts +21 -0
  50. package/dist/widgets/LiveClock.js +1 -0
  51. package/dist/widgets/OrgChart.d.ts +32 -0
  52. package/dist/widgets/OrgChart.js +2 -0
  53. package/dist/widgets/PeriodFilter.d.ts +48 -0
  54. package/dist/widgets/PeriodFilter.js +2 -0
  55. package/dist/widgets/PrintSheet.d.ts +23 -0
  56. package/dist/widgets/PrintSheet.js +2 -0
  57. package/dist/widgets/SearchableMultiPicker.d.ts +29 -0
  58. package/dist/widgets/SearchableMultiPicker.js +3 -0
  59. package/dist/widgets/SecureScreen.d.ts +25 -0
  60. package/dist/widgets/SecureScreen.js +2 -0
  61. package/dist/widgets/StackedMeter.d.ts +26 -0
  62. package/dist/widgets/StackedMeter.js +1 -0
  63. package/dist/widgets/Strip.d.ts +20 -0
  64. package/dist/widgets/Strip.js +1 -0
  65. package/dist/widgets/SummaryTable.d.ts +31 -0
  66. package/dist/widgets/SummaryTable.js +1 -0
  67. package/dist/widgets/TagMultiSelect.d.ts +25 -0
  68. package/dist/widgets/TagMultiSelect.js +2 -0
  69. package/dist/widgets/Timeline.d.ts +30 -0
  70. package/dist/widgets/Timeline.js +3 -0
  71. package/dist/widgets/TreePicker.d.ts +21 -0
  72. package/dist/widgets/TreePicker.js +3 -0
  73. package/dist/widgets/TreeView.d.ts +26 -0
  74. package/dist/widgets/TreeView.js +2 -0
  75. package/dist/widgets/index.d.ts +76 -0
  76. package/dist/widgets/index.js +69 -0
  77. package/package.json +93 -3
package/dist/index.js CHANGED
@@ -1,74 +1,105 @@
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-QJHD6HVQ.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-QJHD6HVQ.js';
1
+ import { DEV, DateRangePicker, DatePicker, isSafeNavigationTarget, useSduiAdapter, hostFetchOf, HoistedStyle, resolveAdapter, assetResolverOf, SduiAdapterContext } from './chunk-FGRR3ROJ.js';
2
+ export { DatePicker, DateRangePicker, defaultAdapter, useSduiAssetResolver, useSduiSandbox } from './chunk-FGRR3ROJ.js';
3
+ import { searchValuesFromParams, splitMultiValue, includesLooseSearch, cascadingValuePaths, SearchToolbar, searchFieldKeys, Popover } from './chunk-KADFN76X.js';
4
+ export { SearchToolbar, cascadingValuePaths, cleanSearchValues, includesLooseSearch, joinMultiValue, normalizeSearchText, searchFieldKeys, searchValuesFromParams, splitMultiValue } from './chunk-KADFN76X.js';
5
+ import { Toast, Textarea, Tag, Tabs, Table, Switch, StatCard, Spinner, Select, ProgressBar, Pagination, Input, IconButton, Icon, Field, EmptyState, Checkbox, Card, Button, Badge, Alert, SegmentedControl, Dialog } from './chunk-ZUCI34QY.js';
6
+ export { Alert, Avatar, Badge, Breadcrumb, Button, Card, Checkbox, Dialog, EmptyState, Field, Icon, IconButton, Input, Pagination, ProgressBar, Radio, SegmentedControl, Select, SidebarNav, Spinner, StatCard, Switch, Table, Tabs, Tag, Textarea, Toast, Tooltip } from './chunk-ZUCI34QY.js';
3
7
  import React, { createContext, useContext, useMemo, useState, useRef, useEffect, useCallback } from 'react';
4
8
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
5
9
 
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);
10
+ // src/asset-url.ts
11
+ var FETCHING_URL_PROPS = /* @__PURE__ */ new Set([
12
+ "src",
13
+ "srcSet",
14
+ "imageSrcSet",
15
+ "poster",
16
+ "data",
17
+ "background",
18
+ // SVG `<use href>`/`<image xlink:href>`는 실제로 문서를 받아 온다. 어휘에 `<use>`가
19
+ // 없어 오늘은 도달 불가지만, 성질상 이쪽이다.
20
+ "xlinkHref"
21
+ ]);
22
+ var NAVIGATION_URL_PROPS = /* @__PURE__ */ new Set(["href", "action", "formAction"]);
23
+ var URL_PROPS = /* @__PURE__ */ new Set([
24
+ ...FETCHING_URL_PROPS,
25
+ ...NAVIGATION_URL_PROPS
26
+ ]);
27
+ function isDangerousUrlValue(value) {
28
+ const normalized = value.replace(/[\u0000-\u0020]/g, "").toLowerCase();
29
+ const scheme = /^([a-z][a-z0-9+.-]*):/.exec(normalized);
30
+ if (!scheme) return false;
31
+ if (scheme[1] === "data") {
32
+ return !/^data:image\/(?!svg\+xml)[a-z0-9.+-]+[;,]/.test(normalized);
44
33
  }
45
- return json;
34
+ return !["http", "https", "mailto", "tel", "blob"].includes(scheme[1]);
46
35
  }
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");
36
+ function isStyleProp(name) {
37
+ return name === "style" || name.length > 5 && name.endsWith("Style");
38
+ }
39
+ var CSS_LOADING_VALUE = /(?:url|image-set)\s*\(/i;
40
+ var CSS_URL = /url\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^)'"]*))\s*\)/gi;
41
+ var CSS_BARE_IMAGE_SET = /image-set\(\s*["']/i;
42
+ function cssUrlLiteral(url) {
43
+ return `url("${url.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}")`;
44
+ }
45
+ function resolveStyleAssets(style, resolve, ctx) {
46
+ let out = null;
47
+ for (const [property, value] of Object.entries(style)) {
48
+ if (typeof value !== "string" || !CSS_LOADING_VALUE.test(value)) continue;
49
+ let dropped = false;
50
+ const rewritten = value.replace(CSS_URL, (whole, dq, sq, bare) => {
51
+ const url = (dq ?? sq ?? bare ?? "").trim();
52
+ if (url === "") return whole;
53
+ const next = resolve(url, { ...ctx, kind: "css", cssProperty: property });
54
+ if (next === null) {
55
+ dropped = true;
56
+ return whole;
57
+ }
58
+ return next === url ? whole : cssUrlLiteral(next);
59
+ });
60
+ const unsafeRemains = CSS_BARE_IMAGE_SET.test(rewritten);
61
+ if (!dropped && !unsafeRemains && rewritten === value) continue;
62
+ out ??= { ...style };
63
+ if (dropped || unsafeRemains) delete out[property];
64
+ else out[property] = rewritten;
62
65
  }
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;
66
+ return out ?? style;
67
+ }
68
+ var MAX_DEPTH = 8;
69
+ function resolveAssetsIn(propName, value, resolve, component) {
70
+ return walk(propName, value, 0);
71
+ function walk(key, node, depth) {
72
+ if (depth > MAX_DEPTH) return node;
73
+ if (typeof node === "string") {
74
+ if (!FETCHING_URL_PROPS.has(key) || node === "") return node;
75
+ const next = resolve(node, { component, prop: key, kind: "attribute" });
76
+ return next === null ? void 0 : next;
77
+ }
78
+ if (Array.isArray(node)) {
79
+ let changed = false;
80
+ const next = node.map((entry) => {
81
+ const resolved = walk(key, entry, depth + 1);
82
+ if (resolved !== entry) changed = true;
83
+ return resolved;
84
+ });
85
+ return changed ? next : node;
86
+ }
87
+ if (typeof node !== "object" || node === null) return node;
88
+ if (Object.getPrototypeOf(node) !== Object.prototype && Object.getPrototypeOf(node) !== null) {
89
+ return node;
69
90
  }
91
+ const record = node;
92
+ if (isStyleProp(key)) return resolveStyleAssets(record, resolve, { component, prop: key });
93
+ let out = null;
94
+ for (const [childKey, childValue] of Object.entries(record)) {
95
+ const resolved = walk(childKey, childValue, depth + 1);
96
+ if (resolved === childValue) continue;
97
+ out ??= { ...record };
98
+ if (resolved === void 0) delete out[childKey];
99
+ else out[childKey] = resolved;
100
+ }
101
+ return out ?? record;
70
102
  }
71
- return merged;
72
103
  }
73
104
  var SduiDispatchContext = createContext(null);
74
105
  function useSduiDispatch() {
@@ -229,10 +260,120 @@ function Show({ when, children }) {
229
260
  function Divider({ style }) {
230
261
  return /* @__PURE__ */ jsx("hr", { className: "wp-divider", style });
231
262
  }
232
- var defaultFetcher = async (endpoint, params, signal) => {
263
+ function toNumber(value) {
264
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
265
+ if (typeof value === "string") {
266
+ const plain = value.replace(/,/g, "").trim();
267
+ if (plain !== "" && /^-?\d+(\.\d+)?$/.test(plain)) return Number(plain);
268
+ }
269
+ return null;
270
+ }
271
+ function fillTemplate(template, row) {
272
+ return template.replace(
273
+ /\{([\w.-]+)\}/g,
274
+ (_match, key) => encodeURIComponent(String(row[key] ?? ""))
275
+ );
276
+ }
277
+ function formatDate(raw, format) {
278
+ const date = new Date(raw);
279
+ if (Number.isNaN(date.getTime())) return raw;
280
+ if (format === "datetime") return date.toLocaleString("ko-KR");
281
+ if (format === "time") return date.toLocaleTimeString("ko-KR");
282
+ return date.toLocaleDateString("ko-KR");
283
+ }
284
+ function cellRenderer(cell, key, dispatch) {
285
+ if (!cell || typeof cell !== "object" || typeof cell.type !== "string") return void 0;
286
+ switch (cell.type) {
287
+ case "badge":
288
+ return (row) => {
289
+ const value = String(row[key] ?? "");
290
+ if (!value) return null;
291
+ return /* @__PURE__ */ jsx(Badge, { tone: cell.toneMap?.[value] ?? "neutral", children: value });
292
+ };
293
+ case "tag":
294
+ return (row) => {
295
+ const value = String(row[key] ?? "");
296
+ if (!value) return null;
297
+ return /* @__PURE__ */ jsx(Tag, { color: cell.colorMap?.[value], filled: cell.filled, children: value });
298
+ };
299
+ case "link":
300
+ return (row) => {
301
+ const label = String(row[cell.labelKey ?? key] ?? "");
302
+ if (!label) return null;
303
+ const to = fillTemplate(cell.to ?? "", row);
304
+ return (
305
+ // Button이 아니라 <a>인 이유: 링크는 마우스 오른쪽 클릭·새 탭이 되어야 하는데
306
+ // ButtonProps에 href가 없다. 클래스는 DS의 링크 버튼(.wp-btn--link)을 그대로 쓴다.
307
+ /* @__PURE__ */ jsxs(
308
+ "a",
309
+ {
310
+ className: "wp-btn wp-btn--link",
311
+ href: isSafeNavigationTarget(to) ? to : void 0,
312
+ onClick: (e) => {
313
+ e.preventDefault();
314
+ e.stopPropagation();
315
+ dispatch({ action: "navigate", to });
316
+ },
317
+ children: [
318
+ cell.icon ? /* @__PURE__ */ jsx(Icon, { name: cell.icon, size: 14 }) : null,
319
+ label
320
+ ]
321
+ }
322
+ )
323
+ );
324
+ };
325
+ case "progress":
326
+ return (row) => {
327
+ const value = toNumber(row[key]);
328
+ if (value === null) return String(row[key] ?? "");
329
+ const max = (cell.maxKey ? toNumber(row[cell.maxKey]) : null) ?? cell.max ?? 100;
330
+ return /* @__PURE__ */ jsx(ProgressBar, { value, max: max || 100, tone: cell.tone, showValue: cell.showValue, size: "sm" });
331
+ };
332
+ case "icon":
333
+ return (row) => {
334
+ const value = String(row[key] ?? "");
335
+ const name = cell.iconMap?.[value] ?? cell.icon;
336
+ if (!name) return value;
337
+ return (
338
+ // .wp-row 기본 간격(space-2)은 아이콘+글자 한 덩어리로 보이기엔 넓어 한 단계 좁힌다
339
+ /* @__PURE__ */ jsxs("span", { className: "wp-row", style: { gap: "var(--wp-space-1)" }, children: [
340
+ /* @__PURE__ */ jsx(Icon, { name, size: 14, title: cell.withLabel ? void 0 : value }),
341
+ cell.withLabel ? value : null
342
+ ] })
343
+ );
344
+ };
345
+ case "number":
346
+ return (row) => {
347
+ const value = toNumber(row[key]);
348
+ if (value === null) return String(row[key] ?? "");
349
+ const digits = Number.isFinite(cell.digits) ? Math.max(0, Math.min(6, Number(cell.digits))) : 0;
350
+ const text = new Intl.NumberFormat("ko-KR", {
351
+ minimumFractionDigits: digits,
352
+ maximumFractionDigits: digits
353
+ }).format(value);
354
+ return `${cell.prefix ?? ""}${text}${cell.suffix ?? ""}`;
355
+ };
356
+ case "date":
357
+ return (row) => {
358
+ const raw = String(row[key] ?? "");
359
+ return raw ? formatDate(raw, cell.format) : "";
360
+ };
361
+ case "text":
362
+ return void 0;
363
+ default:
364
+ return void 0;
365
+ }
366
+ }
367
+ function searchUrl(endpoint, params) {
233
368
  const query = params.toString();
234
- const url = `${endpoint}${query ? endpoint.includes("?") ? "&" : "?" : ""}${query}`;
235
- const response = await fetch(url, {
369
+ return `${endpoint}${query ? endpoint.includes("?") ? "&" : "?" : ""}${query}`;
370
+ }
371
+ function toPage(body) {
372
+ const rows = Array.isArray(body?.data) ? body.data : [];
373
+ return { data: rows, total: typeof body?.total === "number" ? body.total : rows.length };
374
+ }
375
+ var defaultFetcher = async (endpoint, params, signal) => {
376
+ const response = await fetch(searchUrl(endpoint, params), {
236
377
  headers: { accept: "application/json" },
237
378
  credentials: "include",
238
379
  signal
@@ -241,9 +382,14 @@ var defaultFetcher = async (endpoint, params, signal) => {
241
382
  if (!response.ok) {
242
383
  throw new Error(body?.message ?? `\uBAA9\uB85D\uC744 \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4 (${response.status})`);
243
384
  }
244
- const rows = Array.isArray(body?.data) ? body.data : [];
245
- return { data: rows, total: typeof body?.total === "number" ? body.total : rows.length };
385
+ return toPage(body);
246
386
  };
387
+ function adapterFetcher(hostFetch) {
388
+ return async (endpoint, params, signal) => {
389
+ const body = await hostFetch(searchUrl(endpoint, params), { method: "GET", signal });
390
+ return toPage(body);
391
+ };
392
+ }
247
393
  var SearchDataContext = createContext(null);
248
394
  function SduiSearchDataProvider({
249
395
  fetcher,
@@ -252,7 +398,13 @@ function SduiSearchDataProvider({
252
398
  return /* @__PURE__ */ jsx(SearchDataContext.Provider, { value: fetcher, children });
253
399
  }
254
400
  function useSearchFetcher() {
255
- return useContext(SearchDataContext) ?? defaultFetcher;
401
+ const injected = useContext(SearchDataContext);
402
+ const adapter = useSduiAdapter();
403
+ return useMemo(() => {
404
+ if (injected) return injected;
405
+ const hostFetch = hostFetchOf(adapter);
406
+ return hostFetch ? adapterFetcher(hostFetch) : defaultFetcher;
407
+ }, [injected, adapter]);
256
408
  }
257
409
  var EMPTY_PAGE = { data: [], total: 0 };
258
410
  function useRemoteSearchPage(endpoint, params) {
@@ -289,78 +441,8 @@ function useRemoteSearchPage(endpoint, params) {
289
441
  const reload = useCallback(() => setReloadKey((value) => value + 1), []);
290
442
  return { page, isLoading, error, reload };
291
443
  }
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
444
  var OVERSCAN = 10;
445
+ var CLIP_CSS = `.wp-table--clip tbody td{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}`;
364
446
  function ResultTable({
365
447
  columns,
366
448
  rows,
@@ -395,47 +477,25 @@ function ResultTable({
395
477
  const table = /* @__PURE__ */ jsxs(
396
478
  "table",
397
479
  {
398
- className: "wp-table wp-table--hover",
480
+ className: `wp-table wp-table--hover${virtual ? " wp-table--sticky wp-table--clip" : ""}`,
399
481
  style: virtual ? { tableLayout: "fixed" } : void 0,
400
482
  children: [
401
483
  /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { children: columns.map((column) => /* @__PURE__ */ jsx(
402
484
  "th",
403
485
  {
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
- }
486
+ style: { width: column.width, textAlign: column.align === "right" ? "right" : void 0 },
487
+ children: column.sortable && onSort ? (
488
+ // 인라인 style로는 :hover를 표현할 수 없다 — 색 피드백은 .wp-table__sort:hover가 낸다
489
+ /* @__PURE__ */ jsxs("button", { type: "button", className: "wp-table__sort", onClick: () => onSort(column.key), children: [
490
+ column.header,
491
+ /* @__PURE__ */ jsx(
492
+ Icon,
493
+ {
494
+ name: sort && sort.key === column.key ? sort.dir === "asc" ? "chevron-up" : "chevron-down" : "chevrons-up-down",
495
+ size: 12
496
+ }
497
+ )
498
+ ] })
439
499
  ) : column.header
440
500
  },
441
501
  column.key
@@ -451,24 +511,13 @@ function ResultTable({
451
511
  ref: offset === 0 ? firstRowRef : void 0,
452
512
  "aria-selected": selected || void 0,
453
513
  onClick: onRowClick ? () => onRowClick(row) : void 0,
454
- style: {
455
- ...onRowClick ? { cursor: "pointer" } : null,
456
- ...selected ? { background: "var(--wp-surface-selected)" } : null
457
- },
514
+ style: onRowClick ? { cursor: "pointer" } : void 0,
458
515
  children: columns.map((column) => {
459
516
  const cls = [];
460
517
  if (column.align === "right") cls.push("wp-table__num");
461
518
  if (column.mono) cls.push("wp-table__mono");
462
519
  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
- );
520
+ return /* @__PURE__ */ jsx("td", { className: cls.join(" ") || void 0, children: column.render ? column.render(row, index) : row[column.key] }, column.key);
472
521
  })
473
522
  },
474
523
  row.id ?? index
@@ -480,494 +529,40 @@ function ResultTable({
480
529
  }
481
530
  );
482
531
  if (!virtual) return table;
483
- return /* @__PURE__ */ jsx("div", { ref: scrollRef, onScroll: handleScroll, style: { height, overflowY: "auto" }, children: table });
532
+ return /* @__PURE__ */ jsxs("div", { ref: scrollRef, onScroll: handleScroll, style: { height, overflowY: "auto" }, children: [
533
+ /* @__PURE__ */ jsx(HoistedStyle, { id: "wp-table-clip", css: CLIP_CSS }),
534
+ table
535
+ ] });
484
536
  }
485
537
  function ResultEmptyState({ title }) {
486
538
  return /* @__PURE__ */ jsx(EmptyState, { icon: "scan-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." });
487
539
  }
488
- function splitMultiValue(value) {
489
- return value.split(",").map((item) => item.trim()).filter(Boolean);
490
- }
491
- function joinMultiValue(values) {
492
- return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))).sort().join(",");
493
- }
494
- function normalizeSearchText(value) {
495
- return value.toLowerCase().replace(/\s+/g, "");
496
- }
497
- function includesLooseSearch(text, keyword) {
498
- const normalizedKeyword = normalizeSearchText(keyword);
499
- return normalizedKeyword.length === 0 || normalizeSearchText(text).includes(normalizedKeyword);
500
- }
501
- function searchFieldKeys(fields) {
502
- return fields.flatMap((field) => {
503
- if (field.type === "dateRange") {
504
- return [field.fromKey ?? `${field.key}From`, field.toKey ?? `${field.key}To`];
505
- }
506
- if (field.type === "numberRange") {
507
- return [field.fromKey ?? `${field.key}Min`, field.toKey ?? `${field.key}Max`];
508
- }
509
- return [field.key];
510
- });
511
- }
512
- function searchValuesFromParams(fields, params) {
513
- const values = {};
514
- for (const key of searchFieldKeys(fields)) {
515
- const value = params.get(key);
516
- if (value) values[key] = value;
517
- }
518
- return values;
519
- }
520
- function cleanSearchValues(fields, values) {
521
- const allowed = new Set(searchFieldKeys(fields));
522
- const fieldsByKey = new Map(fields.map((field) => [field.key, field]));
523
- return Object.fromEntries(
524
- Object.entries(values).map(([key, value]) => {
525
- const field = fieldsByKey.get(key);
526
- const cleaned = field?.type === "select" || field?.type === "cascading" ? joinMultiValue(splitMultiValue(value)) : value.trim();
527
- return [key, cleaned];
528
- }).filter(([key, value]) => allowed.has(key) && value)
529
- );
530
- }
531
- function cascadingPathMap(options) {
532
- const paths = /* @__PURE__ */ new Map();
533
- const visit = (option, parents) => {
534
- const path = [...parents, option];
535
- paths.set(option.value, path);
536
- option.children?.forEach((child) => visit(child, path));
537
- };
538
- options.forEach((option) => visit(option, []));
539
- return paths;
540
- }
541
- function cascadingValuePaths(options) {
542
- const map = /* @__PURE__ */ new Map();
543
- for (const [value, path] of cascadingPathMap(options)) {
544
- map.set(
545
- value,
546
- path.map((option) => option.value)
547
- );
548
- }
549
- return map;
550
- }
551
- function cascadingPathLabel(pathMap, value) {
552
- const path = pathMap.get(value);
553
- return path?.length ? path.map((option) => option.label).join(" > ") : value;
554
- }
555
- function activeSearchCriteria(fields, values) {
556
- const criteria = [];
557
- for (const field of fields) {
558
- if (field.type === "select") {
559
- for (const value2 of splitMultiValue(values[field.key] ?? "")) {
560
- const label = field.options.find((o) => o.value === value2)?.label ?? value2;
561
- criteria.push({
562
- key: `${field.key}:${value2}`,
563
- label: `${field.label}: ${label}`,
564
- clearKeys: [field.key],
565
- valueKey: field.key,
566
- value: value2
567
- });
568
- }
569
- continue;
570
- }
571
- if (field.type === "cascading") {
572
- const pathMap = cascadingPathMap(field.options);
573
- for (const value2 of splitMultiValue(values[field.key] ?? "")) {
574
- criteria.push({
575
- key: `${field.key}:${value2}`,
576
- label: `${field.label}: ${cascadingPathLabel(pathMap, value2)}`,
577
- clearKeys: [field.key],
578
- valueKey: field.key,
579
- value: value2
580
- });
581
- }
582
- continue;
583
- }
584
- if (field.type === "dateRange" || field.type === "numberRange") {
585
- const fromKey = field.fromKey ?? `${field.key}${field.type === "dateRange" ? "From" : "Min"}`;
586
- const toKey = field.toKey ?? `${field.key}${field.type === "dateRange" ? "To" : "Max"}`;
587
- const from = values[fromKey];
588
- const to = values[toKey];
589
- if (from || to) {
590
- criteria.push({
591
- key: field.key,
592
- label: `${field.label}: ${from ?? ""}~${to ?? ""}`,
593
- clearKeys: [fromKey, toKey]
594
- });
595
- }
596
- continue;
597
- }
598
- const value = values[field.key];
599
- if (value) {
600
- criteria.push({
601
- key: field.key,
602
- label: `${field.label}: ${value}`,
603
- clearKeys: [field.key]
604
- });
605
- }
606
- }
607
- return criteria;
608
- }
540
+ var EMPTY_FILTERS = [];
609
541
  var EMPTY_FIELDS = [];
610
- var EMPTY_VALUES = {};
611
- function SearchToolbar({
612
- search,
613
- onSearchChange,
614
- onSearch,
615
- advancedFields = EMPTY_FIELDS,
616
- advancedValues = EMPTY_VALUES,
617
- placeholder = "\uAC80\uC0C9",
618
- className,
619
- hideSearchInput = false
620
- }) {
621
- const [open, setOpen] = useState(false);
622
- const [draft, setDraft] = useState(advancedValues);
623
- const hasAdvanced = advancedFields.length > 0;
624
- useEffect(() => {
625
- if (!open) setDraft(advancedValues);
626
- }, [advancedValues, open]);
627
- const activeCriteria = useMemo(
628
- () => activeSearchCriteria(advancedFields, advancedValues),
629
- [advancedFields, advancedValues]
630
- );
631
- const submit = (event) => {
632
- event.preventDefault();
633
- onSearch(search.trim(), cleanSearchValues(advancedFields, advancedValues));
634
- setOpen(false);
635
- };
636
- const applyAdvancedSearch = () => {
637
- onSearch(search.trim(), cleanSearchValues(advancedFields, draft));
638
- setOpen(false);
639
- };
640
- const resetAdvancedSearch = () => {
641
- setDraft({});
642
- onSearch(search.trim(), {});
643
- setOpen(false);
644
- };
645
- const removeCriterion = (criterion) => {
646
- const nextValues = { ...advancedValues };
647
- if (criterion.valueKey && criterion.value !== void 0) {
648
- const remaining = splitMultiValue(nextValues[criterion.valueKey] ?? "").filter(
649
- (value) => value !== criterion.value
650
- );
651
- if (remaining.length > 0) nextValues[criterion.valueKey] = joinMultiValue(remaining);
652
- else delete nextValues[criterion.valueKey];
653
- } else {
654
- criterion.clearKeys.forEach((key) => delete nextValues[key]);
655
- }
656
- setDraft(nextValues);
657
- onSearch(search.trim(), cleanSearchValues(advancedFields, nextValues));
658
- };
659
- return /* @__PURE__ */ jsxs("form", { className: ["wp-row", className].filter(Boolean).join(" "), onSubmit: submit, children: [
660
- 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,
661
- !hideSearchInput ? /* @__PURE__ */ jsx(
662
- Input,
663
- {
664
- icon: "search",
665
- value: search,
666
- onChange: (event) => onSearchChange(event.target.value),
667
- placeholder
668
- }
669
- ) : null,
670
- hasAdvanced ? /* @__PURE__ */ jsx(
671
- Popover,
672
- {
673
- open,
674
- onClose: () => setOpen(false),
675
- alignLeft: false,
676
- width: "min(560px, 80vw)",
677
- trigger: /* @__PURE__ */ jsx(
678
- IconButton,
679
- {
680
- type: "button",
681
- icon: "sliders-horizontal",
682
- label: "\uC0C1\uC138\uAC80\uC0C9",
683
- "aria-expanded": open,
684
- style: open ? { background: "var(--wp-surface-active)" } : void 0,
685
- onClick: () => setOpen((value) => !value)
686
- }
687
- ),
688
- footer: /* @__PURE__ */ jsxs(Fragment, { children: [
689
- /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", icon: "refresh-cw", onClick: resetAdvancedSearch, children: "\uCD08\uAE30\uD654" }),
690
- /* @__PURE__ */ jsx(Button, { type: "button", variant: "primary", size: "sm", onClick: applyAdvancedSearch, children: "\uC801\uC6A9" })
691
- ] }),
692
- children: /* @__PURE__ */ jsx("div", { className: "wp-grid wp-grid--2", children: advancedFields.map((field) => /* @__PURE__ */ jsx(
693
- SearchFieldInput,
694
- {
695
- field,
696
- values: draft,
697
- onChange: (patch) => setDraft((current) => ({ ...current, ...patch }))
698
- },
699
- field.key
700
- )) })
701
- }
702
- ) : null
703
- ] });
704
- }
705
- function SearchFieldInput({
706
- field,
707
- values,
708
- onChange
709
- }) {
710
- if (field.type === "select") {
711
- return /* @__PURE__ */ jsx(Field, { label: field.label, children: /* @__PURE__ */ jsx(MultiSelectInput, { field, values, onChange }) });
712
- }
713
- if (field.type === "cascading") {
714
- return /* @__PURE__ */ jsx(Field, { label: field.label, children: /* @__PURE__ */ jsx(CascadingInput, { field, values, onChange }) });
715
- }
716
- if (field.type === "dateRange") {
717
- const fromKey = field.fromKey ?? `${field.key}From`;
718
- const toKey = field.toKey ?? `${field.key}To`;
719
- return /* @__PURE__ */ jsx(Field, { label: field.label, children: /* @__PURE__ */ jsxs("div", { className: "wp-row", children: [
720
- /* @__PURE__ */ jsx(
721
- Input,
722
- {
723
- type: "date",
724
- mono: true,
725
- value: values[fromKey] ?? "",
726
- onChange: (e) => onChange({ [fromKey]: e.target.value })
727
- }
728
- ),
729
- /* @__PURE__ */ jsx("span", { className: "wp-caption", children: "~" }),
730
- /* @__PURE__ */ jsx(
731
- Input,
732
- {
733
- type: "date",
734
- mono: true,
735
- value: values[toKey] ?? "",
736
- onChange: (e) => onChange({ [toKey]: e.target.value })
737
- }
738
- )
739
- ] }) });
740
- }
741
- if (field.type === "numberRange") {
742
- const fromKey = field.fromKey ?? `${field.key}Min`;
743
- const toKey = field.toKey ?? `${field.key}Max`;
744
- return /* @__PURE__ */ jsx(Field, { label: field.label, children: /* @__PURE__ */ jsxs("div", { className: "wp-row", children: [
745
- /* @__PURE__ */ jsx(
746
- Input,
747
- {
748
- type: "number",
749
- mono: true,
750
- placeholder: field.minPlaceholder ?? "\uCD5C\uC18C",
751
- value: values[fromKey] ?? "",
752
- onChange: (e) => onChange({ [fromKey]: e.target.value })
753
- }
754
- ),
755
- /* @__PURE__ */ jsx("span", { className: "wp-caption", children: "~" }),
756
- /* @__PURE__ */ jsx(
757
- Input,
758
- {
759
- type: "number",
760
- mono: true,
761
- placeholder: field.maxPlaceholder ?? "\uCD5C\uB300",
762
- value: values[toKey] ?? "",
763
- onChange: (e) => onChange({ [toKey]: e.target.value })
764
- }
765
- )
766
- ] }) });
767
- }
768
- return /* @__PURE__ */ jsx(Field, { label: field.label, children: /* @__PURE__ */ jsx(
769
- Input,
770
- {
771
- placeholder: field.placeholder,
772
- value: values[field.key] ?? "",
773
- onChange: (e) => onChange({ [field.key]: e.target.value })
774
- }
775
- ) });
776
- }
777
- function multiSelectLabel(field, values) {
778
- if (values.length === 0) return field.allLabel ?? "\uC804\uCCB4";
779
- const labels = values.map((value) => field.options.find((item) => item.value === value)?.label ?? value);
780
- if (labels.length <= 2) return labels.join(", ");
781
- return `${labels[0]} \uC678 ${labels.length - 1}`;
782
- }
783
- function MultiSelectInput({
784
- field,
785
- values,
786
- onChange
787
- }) {
788
- const [open, setOpen] = useState(false);
789
- const selected = splitMultiValue(values[field.key] ?? "");
790
- const toggle = (value, checked) => {
791
- const next = checked ? [...selected, value] : selected.filter((item) => item !== value);
792
- onChange({ [field.key]: joinMultiValue(next) });
793
- };
794
- return /* @__PURE__ */ jsx(
795
- Popover,
796
- {
797
- open,
798
- onClose: () => setOpen(false),
799
- trigger: /* @__PURE__ */ jsx(
800
- Button,
801
- {
802
- type: "button",
803
- variant: "secondary",
804
- block: true,
805
- iconEnd: "chevron-down",
806
- onClick: () => setOpen((value) => !value),
807
- children: multiSelectLabel(field, selected)
808
- }
809
- ),
810
- children: /* @__PURE__ */ jsx("div", { className: "wp-col", children: field.options.map((option) => /* @__PURE__ */ jsx(
811
- Checkbox,
812
- {
813
- label: option.label,
814
- checked: selected.includes(option.value),
815
- onChange: (e) => toggle(option.value, e.target.checked)
816
- },
817
- option.value
818
- )) })
819
- }
542
+ var PAGE_SIZE_CHOICES = [10, 20, 50, 100];
543
+ var DEFAULT_VIEWS = [
544
+ { value: "list", icon: "list", label: "\uBAA9\uB85D" },
545
+ { value: "grid", icon: "layout-grid", label: "\uCE74\uB4DC" }
546
+ ];
547
+ function normalizePageSizes(sizes) {
548
+ if (!Array.isArray(sizes)) return void 0;
549
+ const clean = [...new Set(sizes.map(Number).filter((n) => Number.isFinite(n) && n > 0).map(Math.floor))].sort(
550
+ (a, b) => a - b
820
551
  );
552
+ return clean.length > 0 ? clean : void 0;
821
553
  }
822
- function uniqueCascadingOptions(options) {
554
+ function resolveViewItems(views, disableGrid, disableList) {
555
+ const source = Array.isArray(views) && views.length > 0 ? views : DEFAULT_VIEWS.filter((v) => !(v.value === "grid" && disableGrid) && !(v.value === "list" && disableList));
823
556
  const seen = /* @__PURE__ */ new Set();
824
- return options.filter((option) => {
825
- if (seen.has(option.value)) return false;
826
- seen.add(option.value);
827
- return true;
828
- });
829
- }
830
- function cascadingLevels(options, selectedPaths) {
831
- const levels = [];
832
- let currentOptions = options;
833
- let depth = 0;
834
- while (currentOptions.length > 0) {
835
- levels.push(currentOptions);
836
- const selectedAtDepth = uniqueCascadingOptions(
837
- selectedPaths.map((path) => path[depth]).filter(
838
- (option) => Boolean(option && currentOptions.some((current) => current.value === option.value))
839
- )
840
- );
841
- const nextOptions = uniqueCascadingOptions(selectedAtDepth.flatMap((option) => option.children ?? []));
842
- if (nextOptions.length === 0) break;
843
- currentOptions = nextOptions;
844
- depth += 1;
557
+ const items = [];
558
+ for (const view of source) {
559
+ const base = DEFAULT_VIEWS.find((d) => d.value === view?.value);
560
+ if (!base || seen.has(base.value)) continue;
561
+ seen.add(base.value);
562
+ items.push({ value: base.value, icon: view.icon ?? base.icon, label: view.label ?? base.label });
845
563
  }
846
- return levels;
847
- }
848
- function selectedValuesForCascadingLevel(selectedPaths, depth, options) {
849
- const optionValues = new Set(options.map((option) => option.value));
850
- return Array.from(
851
- new Set(
852
- selectedPaths.map((path) => path[depth]?.value).filter((value) => Boolean(value) && optionValues.has(value))
853
- )
854
- );
855
- }
856
- function cascadingPathIncludes(pathMap, value, maybeAncestor) {
857
- return pathMap.get(value)?.some((option) => option.value === maybeAncestor) ?? false;
858
- }
859
- function cascadingValuesOverlap(leftValue, rightValue, pathMap) {
860
- return cascadingPathIncludes(pathMap, leftValue, rightValue) || cascadingPathIncludes(pathMap, rightValue, leftValue);
861
- }
862
- function cascadingLevelLabel(values, allLabel, pathMap, showPathLabels) {
863
- if (values.length === 0) return allLabel;
864
- const labels = values.map((value) => {
865
- if (showPathLabels) return cascadingPathLabel(pathMap, value);
866
- return pathMap.get(value)?.at(-1)?.label ?? value;
867
- });
868
- if (labels.length <= 2) return labels.join(", ");
869
- return `${labels[0]} \uC678 ${labels.length - 1}`;
870
- }
871
- function cascadingOptionLabel(option, pathMap, showPathLabels) {
872
- return showPathLabels ? cascadingPathLabel(pathMap, option.value) : option.label;
873
- }
874
- function CascadingInput({
875
- field,
876
- values,
877
- onChange
878
- }) {
879
- const pathMap = useMemo(() => cascadingPathMap(field.options), [field.options]);
880
- const selectedValues = useMemo(
881
- () => splitMultiValue(values[field.key] ?? "").filter((value) => pathMap.has(value)),
882
- [field.key, pathMap, values]
883
- );
884
- const selectedPaths = useMemo(
885
- () => selectedValues.map((value) => pathMap.get(value)).filter((path) => Boolean(path)),
886
- [pathMap, selectedValues]
887
- );
888
- const levels = useMemo(() => cascadingLevels(field.options, selectedPaths), [field.options, selectedPaths]);
889
- const setSelectedValues = (nextValues) => {
890
- onChange({ [field.key]: joinMultiValue(nextValues) });
891
- };
892
- const toggleOption = (option, nextChecked) => {
893
- const withoutOverlap = selectedValues.filter(
894
- (value) => !cascadingValuesOverlap(option.value, value, pathMap)
895
- );
896
- setSelectedValues(nextChecked ? [...withoutOverlap, option.value] : withoutOverlap);
897
- };
898
- const clearLevel = (depth) => {
899
- if (depth === 0) {
900
- setSelectedValues([]);
901
- return;
902
- }
903
- const parents = selectedValuesForCascadingLevel(selectedPaths, depth - 1, levels[depth - 1] ?? []);
904
- const rest = selectedValues.filter((value) => {
905
- const parentValue = pathMap.get(value)?.[depth - 1]?.value;
906
- return !parentValue || !parents.includes(parentValue);
907
- });
908
- setSelectedValues([...rest, ...parents]);
909
- };
910
- return /* @__PURE__ */ jsx("div", { className: "wp-col", children: levels.map((options, depth) => {
911
- const levelValues = selectedValuesForCascadingLevel(selectedPaths, depth, options);
912
- const showPathLabels = depth > 0 && selectedValuesForCascadingLevel(selectedPaths, depth - 1, levels[depth - 1] ?? []).length > 1;
913
- return /* @__PURE__ */ jsx(
914
- CascadingLevelSelect,
915
- {
916
- allLabel: depth === 0 ? field.allLabel ?? "\uC804\uCCB4" : "\uC804\uCCB4",
917
- options,
918
- pathMap,
919
- selectedValues: levelValues,
920
- showPathLabels,
921
- onClear: () => clearLevel(depth),
922
- onToggle: toggleOption
923
- },
924
- depth
925
- );
926
- }) });
564
+ return items.length > 0 ? items : [DEFAULT_VIEWS[0]];
927
565
  }
928
- function CascadingLevelSelect({
929
- options,
930
- pathMap,
931
- selectedValues,
932
- allLabel,
933
- showPathLabels,
934
- onClear,
935
- onToggle
936
- }) {
937
- const [open, setOpen] = useState(false);
938
- const selectedSet = new Set(selectedValues);
939
- return /* @__PURE__ */ jsx(
940
- Popover,
941
- {
942
- open,
943
- onClose: () => setOpen(false),
944
- trigger: /* @__PURE__ */ jsx(
945
- Button,
946
- {
947
- type: "button",
948
- variant: "secondary",
949
- block: true,
950
- iconEnd: "chevron-down",
951
- onClick: () => setOpen((value) => !value),
952
- children: cascadingLevelLabel(selectedValues, allLabel, pathMap, showPathLabels)
953
- }
954
- ),
955
- footer: /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "xs", icon: "refresh-cw", onClick: onClear, children: allLabel }),
956
- children: /* @__PURE__ */ jsx("div", { className: "wp-col", style: { minWidth: 200 }, children: options.map((option) => /* @__PURE__ */ jsx(
957
- Checkbox,
958
- {
959
- label: cascadingOptionLabel(option, pathMap, showPathLabels),
960
- checked: selectedSet.has(option.value),
961
- onChange: (e) => onToggle(option, e.target.checked)
962
- },
963
- option.value
964
- )) })
965
- }
966
- );
967
- }
968
- var EMPTY_FILTERS = [];
969
- var EMPTY_FIELDS2 = [];
970
- var PAGE_SIZE_CHOICES = [10, 20, 50, 100];
971
566
  function SearchResults({
972
567
  item,
973
568
  columns,
@@ -975,6 +570,8 @@ function SearchResults({
975
570
  leftFilters = EMPTY_FILTERS,
976
571
  rightFilters = EMPTY_FILTERS,
977
572
  initialView = "list",
573
+ views,
574
+ pageSizes,
978
575
  className,
979
576
  searchParams,
980
577
  onParamsChange,
@@ -986,26 +583,28 @@ function SearchResults({
986
583
  headerActions,
987
584
  isLoading = false,
988
585
  hideHeader = false,
989
- advancedSearchFields = EMPTY_FIELDS2,
586
+ advancedSearchFields = EMPTY_FIELDS,
990
587
  searchPlaceholder,
991
588
  virtual
992
589
  }) {
993
590
  const hasActions = actions.length > 0;
591
+ const sizeChoices = normalizePageSizes(pageSizes) ?? PAGE_SIZE_CHOICES;
592
+ const defaultPageSize = sizeChoices[0];
994
593
  const page = Number(searchParams.get("page")) || 1;
995
- const pageSize = Number(searchParams.get("limit")) || 10;
996
- const pageSizeChoices = PAGE_SIZE_CHOICES.includes(pageSize) ? PAGE_SIZE_CHOICES : [...PAGE_SIZE_CHOICES, pageSize].sort((a, b) => a - b);
997
- const applyViewConstraints = (candidate) => {
998
- if (disableGrid && disableList) return "list";
999
- if (candidate === "grid" && disableGrid) return "list";
1000
- if (candidate === "list" && disableList) return "grid";
1001
- return candidate;
1002
- };
594
+ const pageSize = Number(searchParams.get("limit")) || defaultPageSize;
595
+ const pageSizeChoices = sizeChoices.includes(pageSize) ? sizeChoices : [...sizeChoices, pageSize].sort((a, b) => a - b);
596
+ const viewItems = useMemo(
597
+ () => resolveViewItems(views, disableGrid, disableList),
598
+ [views, disableGrid, disableList]
599
+ );
600
+ const applyViewConstraints = (candidate) => viewItems.some((v) => v.value === candidate) ? candidate : viewItems[0].value;
1003
601
  const appliedSearch = searchParams.get("search") ?? "";
1004
602
  const [view, setView] = useState(() => applyViewConstraints(initialView));
1005
603
  const [search, setSearch] = useState(appliedSearch);
1006
604
  const [sort, setSort] = useState(null);
1007
605
  const [selected, setSelected] = useState(/* @__PURE__ */ new Set());
1008
606
  const selectedRowsRef = useRef(/* @__PURE__ */ new Map());
607
+ const activeView = applyViewConstraints(view);
1009
608
  useEffect(() => {
1010
609
  setSearch(appliedSearch);
1011
610
  }, [appliedSearch]);
@@ -1094,7 +693,7 @@ function SearchResults({
1094
693
  const safePage = Math.min(Math.max(1, page), pageCount);
1095
694
  const virtualThreshold = typeof virtual === "number" ? virtual : 150;
1096
695
  const virtualEnabled = virtual === true || typeof virtual === "number";
1097
- const virtualListActive = virtualEnabled && view === "list" && processed.length > virtualThreshold;
696
+ const virtualListActive = virtualEnabled && activeView === "list" && processed.length > virtualThreshold;
1098
697
  const visibleItems = clientPaginated && !virtualListActive ? processed.slice((safePage - 1) * pageSize, safePage * pageSize) : processed;
1099
698
  const visibleIds = visibleItems.map((row) => row.id);
1100
699
  const allSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
@@ -1125,12 +724,12 @@ function SearchResults({
1125
724
  {
1126
725
  key: "__select",
1127
726
  header: /* @__PURE__ */ jsx(
1128
- SelectCheckbox,
727
+ Checkbox,
1129
728
  {
1130
729
  checked: allSelected,
1131
730
  indeterminate: !allSelected && someSelected,
1132
- label: `${title} \uC804\uCCB4 \uC120\uD0DD`,
1133
- onChange: (checked) => toggleSelectAll(checked)
731
+ "aria-label": `${title} \uC804\uCCB4 \uC120\uD0DD`,
732
+ onChange: (e) => toggleSelectAll(e.target.checked)
1134
733
  }
1135
734
  ),
1136
735
  width: "36px",
@@ -1186,7 +785,14 @@ function SearchResults({
1186
785
  placeholder: searchPlaceholder
1187
786
  }
1188
787
  ),
1189
- !disableGrid && !disableList ? /* @__PURE__ */ jsx(ViewToggle, { value: view, onChange: (next) => setView(applyViewConstraints(next)) }) : null,
788
+ viewItems.length > 1 ? /* @__PURE__ */ jsx(
789
+ SegmentedControl,
790
+ {
791
+ items: viewItems,
792
+ value: activeView,
793
+ onChange: (next) => setView(applyViewConstraints(next))
794
+ }
795
+ ) : null,
1190
796
  headerActions
1191
797
  ] })
1192
798
  ] }) : null,
@@ -1211,21 +817,16 @@ function SearchResults({
1211
817
  )) })
1212
818
  ] }) : null,
1213
819
  hasActions && selectedItems.length > 0 ? (
1214
- // 플로팅 벌크 액션 바 — 스크롤 위치와 무관하게 화면 하단 중앙에 뜬다
1215
- // (.wp-toolbar 클래스가 라이브러리 스타일시트에 없어 같은 값을 토큰으로 조립했다)
820
+ // 플로팅 벌크 액션 바 — 스크롤 위치와 무관하게 화면 하단 중앙에 뜬다.
821
+ // 생김새는 DS의 .wp-toolbar가 맡고, 여기서는 "화면 하단 중앙 고정"만 얹는다
822
+ // (--floating은 absolute라 스크롤을 따라오지 않는다).
1216
823
  /* @__PURE__ */ jsxs(
1217
824
  "div",
1218
825
  {
1219
826
  role: "toolbar",
827
+ className: "wp-toolbar",
1220
828
  "aria-label": `${title} \uC120\uD0DD \uD56D\uBAA9 \uC77C\uAD04 \uC791\uC5C5`,
1221
829
  style: {
1222
- display: "flex",
1223
- alignItems: "center",
1224
- gap: 6,
1225
- padding: 5,
1226
- background: "var(--wp-surface-card)",
1227
- border: "1px solid var(--wp-border-subtle)",
1228
- borderRadius: "var(--wp-radius-md)",
1229
830
  position: "fixed",
1230
831
  bottom: "var(--wp-space-6)",
1231
832
  left: "50%",
@@ -1241,7 +842,7 @@ function SearchResults({
1241
842
  selectedItems.length,
1242
843
  "\uAC74 \uC120\uD0DD\uB428"
1243
844
  ] }),
1244
- /* @__PURE__ */ jsx(ToolbarSeparator, {}),
845
+ /* @__PURE__ */ jsx("span", { className: "wp-toolbar__sep" }),
1245
846
  actions.map((action) => /* @__PURE__ */ jsx(
1246
847
  Button,
1247
848
  {
@@ -1253,13 +854,13 @@ function SearchResults({
1253
854
  },
1254
855
  action.label
1255
856
  )),
1256
- /* @__PURE__ */ jsx(ToolbarSeparator, {}),
857
+ /* @__PURE__ */ jsx("span", { className: "wp-toolbar__sep" }),
1257
858
  /* @__PURE__ */ jsx(Button, { size: "sm", variant: "ghost", icon: "x", onClick: () => setSelected(/* @__PURE__ */ new Set()), children: "\uC120\uD0DD \uD574\uC81C" })
1258
859
  ]
1259
860
  }
1260
861
  )
1261
862
  ) : null,
1262
- 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(
863
+ 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` }) }) : activeView === "list" ? /* @__PURE__ */ jsx(
1263
864
  ResultTable,
1264
865
  {
1265
866
  columns: tableColumns,
@@ -1283,8 +884,11 @@ function SearchResults({
1283
884
  options: pageSizeChoices.map((size) => ({ value: String(size), label: `${size}\uAC74` })),
1284
885
  value: String(pageSize),
1285
886
  onChange: (e) => (
1286
- // 기본값 10은 파라미터를 지워 상태를 깔끔하게 유지하고, 페이지는 1로 되돌린다
1287
- updateParams({ limit: e.target.value === "10" ? null : e.target.value, page: null })
887
+ // 기본값(첫 후보)은 파라미터를 지워 상태를 깔끔하게 유지하고, 페이지는 1로 되돌린다
888
+ updateParams({
889
+ limit: e.target.value === String(defaultPageSize) ? null : e.target.value,
890
+ page: null
891
+ })
1288
892
  )
1289
893
  }
1290
894
  )
@@ -1316,65 +920,6 @@ function formatCell(value) {
1316
920
  if (value instanceof Date) return value.toLocaleString("ko-KR");
1317
921
  return value;
1318
922
  }
1319
- function ToolbarSeparator() {
1320
- return /* @__PURE__ */ jsx("span", { style: { width: 1, height: 20, background: "var(--wp-border-subtle)", flex: "none" } });
1321
- }
1322
- function SelectCheckbox({
1323
- checked,
1324
- indeterminate,
1325
- label,
1326
- onChange
1327
- }) {
1328
- const ref = useRef(null);
1329
- useEffect(() => {
1330
- if (ref.current) ref.current.indeterminate = indeterminate;
1331
- }, [indeterminate]);
1332
- return /* @__PURE__ */ jsxs("label", { className: "wp-check", children: [
1333
- /* @__PURE__ */ jsx(
1334
- "input",
1335
- {
1336
- ref,
1337
- type: "checkbox",
1338
- checked,
1339
- "aria-label": label,
1340
- onChange: (e) => onChange(e.target.checked)
1341
- }
1342
- ),
1343
- /* @__PURE__ */ jsx(
1344
- "span",
1345
- {
1346
- className: "wp-check__box",
1347
- style: indeterminate && !checked ? {
1348
- background: "var(--wp-action-primary-bg)",
1349
- borderColor: "var(--wp-action-primary-bg)",
1350
- color: "var(--wp-white)"
1351
- } : void 0,
1352
- children: /* @__PURE__ */ jsx(Icon, { name: indeterminate && !checked ? "minus" : "check", size: 12, strokeWidth: 2.5 })
1353
- }
1354
- )
1355
- ] });
1356
- }
1357
- function ViewToggle({ value, onChange }) {
1358
- const items = [
1359
- { value: "list", icon: "list", label: "\uBAA9\uB85D" },
1360
- { value: "grid", icon: "layout-grid", label: "\uCE74\uB4DC" }
1361
- ];
1362
- return /* @__PURE__ */ jsx("div", { className: "wp-seg", role: "tablist", children: items.map((tab) => /* @__PURE__ */ jsxs(
1363
- "button",
1364
- {
1365
- type: "button",
1366
- role: "tab",
1367
- className: "wp-seg__item",
1368
- "aria-selected": tab.value === value,
1369
- onClick: () => onChange(tab.value),
1370
- children: [
1371
- /* @__PURE__ */ jsx(Icon, { name: tab.icon, size: 14 }),
1372
- tab.label
1373
- ]
1374
- },
1375
- tab.value
1376
- )) });
1377
- }
1378
923
  function resolveRowPart(part, item) {
1379
924
  return typeof part === "function" ? part(item) : part;
1380
925
  }
@@ -1434,6 +979,7 @@ function FilterMultiSelect({
1434
979
  type: "button",
1435
980
  size: "sm",
1436
981
  variant: values.length > 0 ? "primary" : "secondary",
982
+ icon: "funnel",
1437
983
  iconEnd: "chevron-down",
1438
984
  onClick: () => setOpen((value) => !value),
1439
985
  children: label
@@ -1457,7 +1003,7 @@ function FilterMultiSelect({
1457
1003
  function toList(action) {
1458
1004
  return Array.isArray(action) ? action : [action];
1459
1005
  }
1460
- function mapColumns(columns) {
1006
+ function mapColumns(columns, dispatch) {
1461
1007
  return (columns ?? []).map((column) => ({
1462
1008
  key: column.key,
1463
1009
  title: column.title,
@@ -1466,14 +1012,29 @@ function mapColumns(columns) {
1466
1012
  ratio: column.ratio,
1467
1013
  mono: column.mono,
1468
1014
  strong: column.strong,
1469
- render: column.cell?.type === "badge" ? (row) => {
1470
- const value = String(row[column.key] ?? "");
1471
- if (!value) return null;
1472
- return /* @__PURE__ */ jsx(Badge, { tone: column.cell?.toneMap?.[value] ?? "neutral", children: value });
1473
- } : void 0
1015
+ render: cellRenderer(column.cell, column.key, dispatch)
1474
1016
  }));
1475
1017
  }
1476
- function mapGridCard(gridCard) {
1018
+ function matchesCondition(condition, row) {
1019
+ if (!condition || typeof condition.key !== "string") return true;
1020
+ const value = row[condition.key];
1021
+ if (condition.equals !== void 0) return String(value ?? "") === String(condition.equals);
1022
+ if (Array.isArray(condition.in)) return condition.in.some((v) => String(v) === String(value ?? ""));
1023
+ if (condition.notEmpty === false) return true;
1024
+ return value !== void 0 && value !== null && String(value) !== "";
1025
+ }
1026
+ function mapCardRows(rows, dispatch) {
1027
+ return rows?.map((row) => {
1028
+ const render = cellRenderer(row.cell, row.valueKey, dispatch);
1029
+ return {
1030
+ icon: row.icon ? /* @__PURE__ */ jsx(Icon, { name: row.icon, size: 14 }) : void 0,
1031
+ label: row.label,
1032
+ value: render ?? ((item) => String(item[row.valueKey] ?? "")),
1033
+ show: row.showWhen ? (item) => matchesCondition(row.showWhen, item) : void 0
1034
+ };
1035
+ });
1036
+ }
1037
+ function mapGridCard(gridCard, dispatch) {
1477
1038
  if (!gridCard) return void 0;
1478
1039
  return {
1479
1040
  title: (row) => String(row[gridCard.titleKey] ?? ""),
@@ -1483,12 +1044,34 @@ function mapGridCard(gridCard) {
1483
1044
  if (!value) return null;
1484
1045
  return { text: value, variant: gridCard.badge.toneMap?.[value] ?? "neutral" };
1485
1046
  } : void 0,
1486
- rows: gridCard.rows?.map((row) => ({
1487
- label: row.label,
1488
- value: (item) => String(item[row.valueKey] ?? "")
1489
- }))
1047
+ rows: mapCardRows(gridCard.rows, dispatch),
1048
+ leftRows: mapCardRows(gridCard.leftRows, dispatch),
1049
+ rightRows: mapCardRows(gridCard.rightRows, dispatch),
1050
+ note: gridCard.noteKey ? (row) => String(row[gridCard.noteKey] ?? "") || null : void 0,
1051
+ actions: gridCard.actions?.length ? (row) => (
1052
+ // 카드 전체가 클릭 대상(rowClick)일 수 있으므로 버튼 클릭은 여기서 전파를 끊는다
1053
+ /* @__PURE__ */ jsx("span", { className: "wp-row", onClick: (e) => e.stopPropagation(), children: gridCard.actions.map((action) => /* @__PURE__ */ jsx(
1054
+ Button,
1055
+ {
1056
+ size: "xs",
1057
+ icon: action.icon,
1058
+ variant: action.variant === "destructive" ? "danger" : action.variant === "outline" ? "secondary" : "ghost",
1059
+ onClick: () => toList(action.run).forEach((a) => dispatch(a, row)),
1060
+ children: action.label
1061
+ },
1062
+ action.label
1063
+ )) })
1064
+ ) : void 0
1490
1065
  };
1491
1066
  }
1067
+ function mapViews(views, gridCard, disableGrid, disableList) {
1068
+ if (!Array.isArray(views) || views.length === 0) return void 0;
1069
+ const gridOff = disableGrid ?? !gridCard;
1070
+ const kept = views.filter(
1071
+ (view) => !(view?.value === "grid" && gridOff) && !(view?.value === "list" && disableList === true)
1072
+ );
1073
+ return kept.length > 0 ? kept : void 0;
1074
+ }
1492
1075
  function mapActions(actions, dispatch) {
1493
1076
  return (actions ?? []).map((action) => ({
1494
1077
  label: action.label,
@@ -1510,6 +1093,8 @@ function SduiSearchResults({
1510
1093
  gridCard,
1511
1094
  actions,
1512
1095
  initialView,
1096
+ views,
1097
+ pageSizes,
1513
1098
  disableGrid,
1514
1099
  disableList,
1515
1100
  searchPlaceholder,
@@ -1525,12 +1110,14 @@ function SduiSearchResults({
1525
1110
  {
1526
1111
  title,
1527
1112
  item: { data: rows, total: rows.length },
1528
- columns: mapColumns(columns),
1529
- gridConfig: mapGridCard(gridCard),
1113
+ columns: mapColumns(columns, dispatch),
1114
+ gridConfig: mapGridCard(gridCard, dispatch),
1530
1115
  leftFilters,
1531
1116
  rightFilters,
1532
1117
  actions: mapActions(actions, dispatch),
1533
1118
  initialView,
1119
+ views: mapViews(views, gridCard, disableGrid, disableList),
1120
+ pageSizes,
1534
1121
  disableGrid: disableGrid ?? !gridCard,
1535
1122
  disableList,
1536
1123
  advancedSearchFields: fields,
@@ -1553,6 +1140,8 @@ function RemoteSearchResults({
1553
1140
  gridCard,
1554
1141
  actions,
1555
1142
  initialView,
1143
+ views,
1144
+ pageSizes,
1556
1145
  disableGrid,
1557
1146
  disableList,
1558
1147
  searchPlaceholder,
@@ -1567,12 +1156,13 @@ function RemoteSearchResults({
1567
1156
  initial.set("limit", String(pageSize));
1568
1157
  return initial;
1569
1158
  });
1159
+ const defaultPageSize = normalizePageSizes(pageSizes)?.[0] ?? 10;
1570
1160
  const query = useMemo(() => {
1571
1161
  const next = new URLSearchParams(params);
1572
1162
  if (!next.has("page")) next.set("page", "1");
1573
- if (!next.has("limit")) next.set("limit", "10");
1163
+ if (!next.has("limit")) next.set("limit", String(defaultPageSize));
1574
1164
  return next;
1575
- }, [params]);
1165
+ }, [params, defaultPageSize]);
1576
1166
  const { page, isLoading, error, reload } = useRemoteSearchPage(endpoint, query);
1577
1167
  if (error && page.data.length === 0) {
1578
1168
  return /* @__PURE__ */ jsx(
@@ -1590,12 +1180,14 @@ function RemoteSearchResults({
1590
1180
  {
1591
1181
  title,
1592
1182
  item: page,
1593
- columns: mapColumns(columns),
1594
- gridConfig: mapGridCard(gridCard),
1183
+ columns: mapColumns(columns, dispatch),
1184
+ gridConfig: mapGridCard(gridCard, dispatch),
1595
1185
  leftFilters,
1596
1186
  rightFilters,
1597
1187
  actions: mapActions(actions, dispatch),
1598
1188
  initialView,
1189
+ views: mapViews(views, gridCard, disableGrid, disableList),
1190
+ pageSizes,
1599
1191
  disableGrid: disableGrid ?? !gridCard,
1600
1192
  disableList,
1601
1193
  advancedSearchFields: fields,
@@ -1768,6 +1360,13 @@ var defaultRegistry = {
1768
1360
  // 권한 게이트 — 판정은 SduiRenderer의 can 프롭(permission.tsx)에 위임한다.
1769
1361
  // 주입 전까지는 통과시키되 console.error를 한 번 낸다(근거는 permission.tsx의 MISSING 주석).
1770
1362
  Can,
1363
+ // 0.3.0에서 이관한 것들은 여기 **없다.**
1364
+ // - DS 6종(Avatar·Breadcrumb·Radio·SegmentedControl·SidebarNav·Tooltip)은
1365
+ // components.tsx에 있고 export만 한다. 기본 어휘를 넓히는 것은 별개 결정이라,
1366
+ // 이름을 올리려면 `@wipco/sdui/meta`의 메타데이터를 같은 커밋에서 채워야 한다
1367
+ // (SduiComponentName이 그 Record의 키라 빠지면 컴파일이 막힌다).
1368
+ // - 표시 위젯 20종은 `widgetComponents` 묶음으로 나가 있다. 정적 import로 core에
1369
+ // 붙지 않게 하려는 것이고 이유는 위 Chart·MapPreview와 같다(widgets/index.ts 참고).
1771
1370
  // 검색+목록 화면. 스펙 이름은 ERP 어휘를 그대로 쓰고, 구현은 JSON 전용 어댑터를 가리킨다
1772
1371
  // (SearchResults 자체는 함수 프롭을 받는 타입드 컴포넌트라 스펙에서 직접 못 쓴다).
1773
1372
  SearchResults: SduiSearchResults,
@@ -1833,6 +1432,11 @@ var DENIED_PROPS = /* @__PURE__ */ new Set([
1833
1432
  function isPlainObject(v) {
1834
1433
  return typeof v === "object" && v !== null && !Array.isArray(v);
1835
1434
  }
1435
+ function checkUrlValue(key, value, path, issues) {
1436
+ if (typeof value !== "string" || !URL_PROPS.has(key)) return;
1437
+ if (!isDangerousUrlValue(value)) return;
1438
+ issues.push({ path, message: `${key}\uC5D0 \uD5C8\uC6A9\uB418\uC9C0 \uC54A\uB294 URL \uC2A4\uD0B4\uC785\uB2C8\uB2E4: ${value}` });
1439
+ }
1836
1440
  function validateSpec(page, knownComponents) {
1837
1441
  const issues = [];
1838
1442
  if (!isPlainObject(page)) return [{ path: "$", message: "\uC2A4\uD399\uC774 \uAC1D\uCCB4\uAC00 \uC544\uB2D9\uB2C8\uB2E4" }];
@@ -1871,6 +1475,7 @@ function walkChild(child, path, issues, known) {
1871
1475
  });
1872
1476
  continue;
1873
1477
  }
1478
+ checkUrlValue(name, value, `${path}.props.${name}`, issues);
1874
1479
  walkValue(value, `${path}.props.${name}`, issues, name.startsWith("on") || name === "rowClick");
1875
1480
  }
1876
1481
  if (node.component === "Form" && isPlainObject(node.props) && node.props.rules !== void 0) {
@@ -1958,6 +1563,7 @@ function walkValue(value, path, issues, actionEligible) {
1958
1563
  }
1959
1564
  for (const [k, v] of Object.entries(value)) {
1960
1565
  if (k === "$calc" || k === "action") continue;
1566
+ checkUrlValue(k, v, `${path}.${k}`, issues);
1961
1567
  walkValue(v, `${path}.${k}`, issues, k === "run" || k === "rowClick" || k.startsWith("on"));
1962
1568
  }
1963
1569
  }
@@ -1995,7 +1601,7 @@ function eventValue(arg) {
1995
1601
  }
1996
1602
  return arg;
1997
1603
  }
1998
- function toNumber(v) {
1604
+ function toNumber2(v) {
1999
1605
  if (typeof v === "number") return v;
2000
1606
  if (typeof v === "string") {
2001
1607
  const n = Number(v.replace(/,/g, ""));
@@ -2040,9 +1646,9 @@ function ruleError(rule, value, values, label, customValidators) {
2040
1646
  case "required":
2041
1647
  return empty || value === false ? rule.message ?? `${label}\uC744(\uB97C) \uC785\uB825\uD558\uC138\uC694` : null;
2042
1648
  case "min":
2043
- return !empty && toNumber(value) < rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value} \uC774\uC0C1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` : null;
1649
+ return !empty && toNumber2(value) < rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value} \uC774\uC0C1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` : null;
2044
1650
  case "max":
2045
- return !empty && toNumber(value) > rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value} \uC774\uD558\uC5EC\uC57C \uD569\uB2C8\uB2E4` : null;
1651
+ return !empty && toNumber2(value) > rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value} \uC774\uD558\uC5EC\uC57C \uD569\uB2C8\uB2E4` : null;
2046
1652
  case "minLength":
2047
1653
  return !empty && String(value).length < rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value}\uC790 \uC774\uC0C1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` : null;
2048
1654
  case "maxLength":
@@ -2093,13 +1699,6 @@ var DENIED_PROPS2 = /* @__PURE__ */ new Set([
2093
1699
  "constructor",
2094
1700
  "prototype"
2095
1701
  ]);
2096
- var URL_PROPS = /* @__PURE__ */ new Set(["href", "src", "action", "formAction", "xlinkHref", "poster"]);
2097
- function isDangerousUrlValue(value) {
2098
- const normalized = value.replace(/[\u0000-\u0020]/g, "").toLowerCase();
2099
- const scheme = /^([a-z][a-z0-9+.-]*):/.exec(normalized);
2100
- if (!scheme) return false;
2101
- return !["http", "https", "mailto", "tel", "blob"].includes(scheme[1]);
2102
- }
2103
1702
  function childrenOf(node) {
2104
1703
  if (node.children === void 0) return void 0;
2105
1704
  if (Array.isArray(node.children)) return node.children;
@@ -2122,7 +1721,7 @@ function SduiNodeRenderer({
2122
1721
  scope,
2123
1722
  engine
2124
1723
  }) {
2125
- const { registry, resolveValue, dispatch } = engine;
1724
+ const { registry, resolveValue, dispatch, resolveAsset } = engine;
2126
1725
  const slots = isPlainObject2(node.slots) ? node.slots : void 0;
2127
1726
  const childList = childrenOf(node);
2128
1727
  if (node.component === "ForEach") {
@@ -2173,6 +1772,11 @@ function SduiNodeRenderer({
2173
1772
  warnOnce2(`url:${name}:${resolved}`, `[sdui] \uD5C8\uC6A9\uB418\uC9C0 \uC54A\uB294 ${name} \uAC12: ${resolved} \u2014 \uBB34\uC2DC\uD569\uB2C8\uB2E4`);
2174
1773
  continue;
2175
1774
  }
1775
+ if (resolveAsset) {
1776
+ const gated = resolveAssetsIn(name, resolved, resolveAsset, String(node.component));
1777
+ if (gated === void 0) continue;
1778
+ resolved = gated;
1779
+ }
2176
1780
  props[name] = resolved;
2177
1781
  }
2178
1782
  }
@@ -2194,6 +1798,7 @@ function SduiRenderer({
2194
1798
  registry = defaultRegistry,
2195
1799
  adapter,
2196
1800
  syncUrlState = true,
1801
+ can,
2197
1802
  onCustomAction,
2198
1803
  customValidators
2199
1804
  }) {
@@ -2338,8 +1943,8 @@ function SduiRenderer({
2338
1943
  const resolved = args.map((arg) => resolveValue(arg, scope));
2339
1944
  const nums = (v) => {
2340
1945
  if (Array.isArray(v))
2341
- return v.map((row) => field && isPlainObject2(row) ? toNumber(row[field]) : toNumber(row));
2342
- return [toNumber(v)];
1946
+ return v.map((row) => field && isPlainObject2(row) ? toNumber2(row[field]) : toNumber2(row));
1947
+ return [toNumber2(v)];
2343
1948
  };
2344
1949
  switch (op) {
2345
1950
  case "sum":
@@ -2347,17 +1952,17 @@ function SduiRenderer({
2347
1952
  case "count":
2348
1953
  return resolved.reduce((a, v) => a + (Array.isArray(v) ? v.length : v == null ? 0 : 1), 0);
2349
1954
  case "sub": {
2350
- const list = resolved.map((v) => toNumber(v));
1955
+ const list = resolved.map((v) => toNumber2(v));
2351
1956
  return list.length === 0 ? 0 : list.reduce((a, b) => a - b);
2352
1957
  }
2353
1958
  case "mul":
2354
- return resolved.map((v) => toNumber(v)).reduce((a, b) => a * b, 1);
1959
+ return resolved.map((v) => toNumber2(v)).reduce((a, b) => a * b, 1);
2355
1960
  case "div": {
2356
- const [a, b] = resolved.map((v) => toNumber(v));
1961
+ const [a, b] = resolved.map((v) => toNumber2(v));
2357
1962
  return b === 0 ? 0 : a / b;
2358
1963
  }
2359
1964
  case "percent": {
2360
- const [a, b] = resolved.map((v) => toNumber(v));
1965
+ const [a, b] = resolved.map((v) => toNumber2(v));
2361
1966
  return b === 0 ? 0 : Math.round(a / b * 1e3) / 10;
2362
1967
  }
2363
1968
  case "concat":
@@ -2370,13 +1975,13 @@ function SduiRenderer({
2370
1975
  }
2371
1976
  // 수량 비교 — 정원 초과 차단(Button.disabled), 임계값 경고(Show.when) 등
2372
1977
  case "gt":
2373
- return toNumber(resolved[0]) > toNumber(resolved[1]);
1978
+ return toNumber2(resolved[0]) > toNumber2(resolved[1]);
2374
1979
  case "gte":
2375
- return toNumber(resolved[0]) >= toNumber(resolved[1]);
1980
+ return toNumber2(resolved[0]) >= toNumber2(resolved[1]);
2376
1981
  case "lt":
2377
- return toNumber(resolved[0]) < toNumber(resolved[1]);
1982
+ return toNumber2(resolved[0]) < toNumber2(resolved[1]);
2378
1983
  case "lte":
2379
- return toNumber(resolved[0]) <= toNumber(resolved[1]);
1984
+ return toNumber2(resolved[0]) <= toNumber2(resolved[1]);
2380
1985
  /** 부정 — 조건을 뒤집어 "없을 때만" 렌더링하는 데 쓴다 */
2381
1986
  case "not":
2382
1987
  return !resolved[0];
@@ -2554,8 +2159,8 @@ function SduiRenderer({
2554
2159
  );
2555
2160
  dispatchRef.current = dispatch;
2556
2161
  const engine = useMemo(
2557
- () => ({ registry, resolveValue, dispatch }),
2558
- [registry, resolveValue, dispatch]
2162
+ () => ({ registry, resolveValue, dispatch, resolveAsset: assetResolverOf(host) }),
2163
+ [registry, resolveValue, dispatch, host]
2559
2164
  );
2560
2165
  const runtime = useMemo(
2561
2166
  () => ({
@@ -2570,14 +2175,18 @@ function SduiRenderer({
2570
2175
  }),
2571
2176
  [state, setEntries]
2572
2177
  );
2573
- return /* @__PURE__ */ jsx(SduiDispatchContext.Provider, { value: dispatch, children: /* @__PURE__ */ jsx(SduiRuntimeContext.Provider, { value: runtime, children: /* @__PURE__ */ jsxs("div", { className: "sdui-root", children: [
2574
- page.title || page.subtitle ? /* @__PURE__ */ jsx("div", { className: "wp-page-head", children: /* @__PURE__ */ jsxs("div", { className: "wp-page-head__text", children: [
2575
- page.title ? /* @__PURE__ */ jsx("h1", { className: "wp-page-head__title", children: page.title }) : null,
2576
- page.subtitle ? /* @__PURE__ */ jsx("div", { className: "wp-page-head__sub", children: page.subtitle }) : null
2577
- ] }) }) : null,
2578
- /* @__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" }) }),
2579
- 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
2580
- ] }) }) });
2178
+ return (
2179
+ // 해석된 어댑터를 트리에 흘려보낸다 — dispatch를 거치지 않고 스스로 요청을 내는
2180
+ // 컴포넌트(목록 조회 등)도 호스트가 건 잠금 안에서 움직이게 하기 위해서다(adapter.ts 주석).
2181
+ /* @__PURE__ */ jsx(SduiAdapterContext.Provider, { value: host, children: /* @__PURE__ */ jsx(SduiDispatchContext.Provider, { value: dispatch, children: /* @__PURE__ */ jsx(SduiRuntimeContext.Provider, { value: runtime, children: /* @__PURE__ */ jsx(SduiPermissionProvider, { can, children: /* @__PURE__ */ jsxs("div", { className: "sdui-root", children: [
2182
+ page.title || page.subtitle ? /* @__PURE__ */ jsx("div", { className: "wp-page-head", children: /* @__PURE__ */ jsxs("div", { className: "wp-page-head__text", children: [
2183
+ page.title ? /* @__PURE__ */ jsx("h1", { className: "wp-page-head__title", children: page.title }) : null,
2184
+ page.subtitle ? /* @__PURE__ */ jsx("div", { className: "wp-page-head__sub", children: page.subtitle }) : null
2185
+ ] }) }) : null,
2186
+ /* @__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" }) }),
2187
+ 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
2188
+ ] }) }) }) }) })
2189
+ );
2581
2190
  }
2582
2191
 
2583
- 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 };
2192
+ export { Actions, Can, Col, Divider, Grid, KeyValueList, NodeErrorBoundary, PageHead, RemoteSearchResults, ResultTable, Row, SduiDialog, SduiForm, SduiPermissionProvider, SduiRenderer, SduiSearchDataProvider, SduiSearchResults, SearchResults, Section, Show, Spacer, Text, cellRenderer, createRegistry, defaultRegistry, normalizePageSizes, useRemoteSearchPage, useSduiCan, useSduiDispatch, useSduiRuntime, useSearchFetcher, validateSpec };