@wipco/sdui 0.10.0 → 0.12.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.
@@ -1,4 +1,4 @@
1
- import { includesLooseSearch } from './chunk-NIRR6KSQ.js';
1
+ import { includesLooseSearch } from './chunk-CDYIUQAD.js';
2
2
  import { Button, Dialog, Input, Tag, Checkbox } from './chunk-B5JTXYDB.js';
3
3
  import { boxed } from './chunk-SAN4IX6N.js';
4
4
  import React, { useMemo, useState, useEffect } from 'react';
@@ -1,4 +1,4 @@
1
- import { Tag, Input, Button, IconButton, Field, Checkbox } from './chunk-B5JTXYDB.js';
1
+ import { Input, Button, IconButton, Tag, Field, Checkbox } from './chunk-B5JTXYDB.js';
2
2
  import { useRef, useEffect, useState, useMemo } from 'react';
3
3
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
4
4
 
@@ -52,6 +52,57 @@ function Popover({
52
52
  ] })
53
53
  );
54
54
  }
55
+ function normalizeSearchOptions(options, valueKey, labelKey) {
56
+ if (!Array.isArray(options)) return [];
57
+ return options.flatMap((raw) => {
58
+ if (raw === null || typeof raw !== "object") return [];
59
+ const row = raw;
60
+ const value = valueKey ? row[valueKey] : row.value;
61
+ if (value === void 0 || value === null) return [];
62
+ const label = (labelKey ? row[labelKey] : row.label) ?? value;
63
+ return [{ value: String(value), label: String(label) }];
64
+ });
65
+ }
66
+ function normalizeSearchFields(fields) {
67
+ return fields.map((field) => {
68
+ if (field.type === "select") {
69
+ return { ...field, options: normalizeSearchOptions(field.options, field.valueKey, field.labelKey) };
70
+ }
71
+ if (field.type === "cascading" && field.tree) {
72
+ return { ...field, options: buildCascadingTree(field.options, field.tree) };
73
+ }
74
+ return field;
75
+ });
76
+ }
77
+ function buildCascadingTree(rows, tree) {
78
+ if (!Array.isArray(rows)) return [];
79
+ const nodes = /* @__PURE__ */ new Map();
80
+ const parents = /* @__PURE__ */ new Map();
81
+ for (const raw of rows) {
82
+ if (raw === null || typeof raw !== "object") continue;
83
+ const row = raw;
84
+ const id = row[tree.idKey];
85
+ if (id === void 0 || id === null) continue;
86
+ const parent = row[tree.parentIdKey];
87
+ nodes.set(String(id), {
88
+ value: String(id),
89
+ label: String(row[tree.labelKey] ?? id),
90
+ children: []
91
+ });
92
+ parents.set(String(id), parent === void 0 || parent === null ? null : String(parent));
93
+ }
94
+ const roots = [];
95
+ for (const [id, node] of nodes) {
96
+ const parent = parents.get(id);
97
+ const parentNode = parent === null || parent === void 0 ? void 0 : nodes.get(parent);
98
+ if (parentNode) parentNode.children.push(node);
99
+ else roots.push(node);
100
+ }
101
+ for (const node of nodes.values()) {
102
+ if (node.children.length === 0) delete node.children;
103
+ }
104
+ return roots;
105
+ }
55
106
  function splitMultiValue(value) {
56
107
  return value.split(",").map((item) => item.trim()).filter(Boolean);
57
108
  }
@@ -179,7 +230,7 @@ function SearchToolbar({
179
230
  search,
180
231
  onSearchChange,
181
232
  onSearch,
182
- advancedFields = EMPTY_FIELDS,
233
+ advancedFields: rawAdvancedFields = EMPTY_FIELDS,
183
234
  advancedValues = EMPTY_VALUES,
184
235
  placeholder = "\uAC80\uC0C9",
185
236
  className,
@@ -187,6 +238,7 @@ function SearchToolbar({
187
238
  }) {
188
239
  const [open, setOpen] = useState(false);
189
240
  const [draft, setDraft] = useState(advancedValues);
241
+ const advancedFields = useMemo(() => normalizeSearchFields(rawAdvancedFields), [rawAdvancedFields]);
190
242
  const hasAdvanced = advancedFields.length > 0;
191
243
  useEffect(() => {
192
244
  if (!open) setDraft(advancedValues);
@@ -223,56 +275,76 @@ function SearchToolbar({
223
275
  setDraft(nextValues);
224
276
  onSearch(search.trim(), cleanSearchValues(advancedFields, nextValues));
225
277
  };
226
- return /* @__PURE__ */ jsxs("form", { className: ["wp-row", className].filter(Boolean).join(" "), onSubmit: submit, children: [
227
- 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,
228
- !hideSearchInput ? /* @__PURE__ */ jsx(
229
- Input,
230
- {
231
- icon: "search",
232
- value: search,
233
- onChange: (event) => onSearchChange(event.target.value),
234
- placeholder
235
- }
236
- ) : null,
237
- hasAdvanced ? /* @__PURE__ */ jsx(
238
- Popover,
278
+ return (
279
+ /*
280
+ * 세로 축(컨트롤 행 + 칩 행) — 원래 칩이 컨트롤과 같은 행 **앞**에 끼어들어,
281
+ * 조건 몇 개만 적용해도 검색창·보기 전환·등록 버튼이 밀려 헤더가 통째로
282
+ * 무너졌다(실사용 지적, 장비 목록 스크린샷). 칩은 자기 행에서 개행한다.
283
+ */
284
+ /* @__PURE__ */ jsxs(
285
+ "form",
239
286
  {
240
- open,
241
- onClose: () => setOpen(false),
242
- alignLeft: false,
243
- width: "min(560px, 80vw)",
244
- trigger: (
245
- // 검색 입력창 옆에 나란히 서는 자리라 테두리가 있어야 한 덩어리로 읽힌다.
246
- // 열린 상태는 인라인 배경이 아니라 pressed(aria-pressed 틴트)로 알린다.
247
- /* @__PURE__ */ jsx(
248
- IconButton,
249
- {
250
- type: "button",
251
- icon: "sliders-horizontal",
252
- label: "\uC0C1\uC138\uAC80\uC0C9",
253
- variant: "outlined",
254
- pressed: open,
255
- "aria-expanded": open,
256
- onClick: () => setOpen((value) => !value)
257
- }
258
- )
259
- ),
260
- footer: /* @__PURE__ */ jsxs(Fragment, { children: [
261
- /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", icon: "refresh-cw", onClick: resetAdvancedSearch, children: "\uCD08\uAE30\uD654" }),
262
- /* @__PURE__ */ jsx(Button, { type: "button", variant: "primary", size: "sm", onClick: applyAdvancedSearch, children: "\uC801\uC6A9" })
263
- ] }),
264
- children: /* @__PURE__ */ jsx("div", { className: "wp-grid wp-grid--2", children: advancedFields.map((field) => /* @__PURE__ */ jsx(
265
- SearchFieldInput,
266
- {
267
- field,
268
- values: draft,
269
- onChange: (patch) => setDraft((current) => ({ ...current, ...patch }))
270
- },
271
- field.key
272
- )) })
287
+ className: ["wp-col", className].filter(Boolean).join(" "),
288
+ style: { gap: "var(--wp-space-2)" },
289
+ onSubmit: submit,
290
+ children: [
291
+ /* @__PURE__ */ jsxs("div", { className: "wp-row", children: [
292
+ !hideSearchInput ? /* @__PURE__ */ jsxs(Fragment, { children: [
293
+ /* @__PURE__ */ jsx(
294
+ Input,
295
+ {
296
+ icon: "search",
297
+ value: search,
298
+ onChange: (event) => onSearchChange(event.target.value),
299
+ placeholder
300
+ }
301
+ ),
302
+ /* @__PURE__ */ jsx(Button, { type: "submit", variant: "secondary", icon: "search", children: "\uAC80\uC0C9" })
303
+ ] }) : null,
304
+ hasAdvanced ? /* @__PURE__ */ jsx(
305
+ Popover,
306
+ {
307
+ open,
308
+ onClose: () => setOpen(false),
309
+ alignLeft: false,
310
+ width: "min(560px, 80vw)",
311
+ trigger: (
312
+ // 검색 입력창 옆에 나란히 서는 자리라 테두리가 있어야 한 덩어리로 읽힌다.
313
+ // 열린 상태는 인라인 배경이 아니라 pressed(aria-pressed 틴트)로 알린다.
314
+ /* @__PURE__ */ jsx(
315
+ IconButton,
316
+ {
317
+ type: "button",
318
+ icon: "sliders-horizontal",
319
+ label: "\uC0C1\uC138\uAC80\uC0C9",
320
+ variant: "outlined",
321
+ pressed: open,
322
+ "aria-expanded": open,
323
+ onClick: () => setOpen((value) => !value)
324
+ }
325
+ )
326
+ ),
327
+ footer: /* @__PURE__ */ jsxs(Fragment, { children: [
328
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", icon: "refresh-cw", onClick: resetAdvancedSearch, children: "\uCD08\uAE30\uD654" }),
329
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "primary", size: "sm", onClick: applyAdvancedSearch, children: "\uC801\uC6A9" })
330
+ ] }),
331
+ children: /* @__PURE__ */ jsx("div", { className: "wp-grid wp-grid--2", children: advancedFields.map((field) => /* @__PURE__ */ jsx(
332
+ SearchFieldInput,
333
+ {
334
+ field,
335
+ values: draft,
336
+ onChange: (patch) => setDraft((current) => ({ ...current, ...patch }))
337
+ },
338
+ field.key
339
+ )) })
340
+ }
341
+ ) : null
342
+ ] }),
343
+ 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
344
+ ]
273
345
  }
274
- ) : null
275
- ] });
346
+ )
347
+ );
276
348
  }
277
349
  function SearchFieldInput({
278
350
  field,
@@ -379,7 +451,7 @@ function MultiSelectInput({
379
451
  children: multiSelectLabel(field, selected)
380
452
  }
381
453
  ),
382
- children: /* @__PURE__ */ jsx("div", { className: "wp-col", children: field.options.map((option) => /* @__PURE__ */ jsx(
454
+ children: /* @__PURE__ */ jsx("div", { className: "wp-col", style: { maxHeight: "min(320px, 40vh)", overflowY: "auto" }, children: field.options.map((option) => /* @__PURE__ */ jsx(
383
455
  Checkbox,
384
456
  {
385
457
  label: option.label,
@@ -525,17 +597,24 @@ function CascadingLevelSelect({
525
597
  }
526
598
  ),
527
599
  footer: /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "xs", icon: "refresh-cw", onClick: onClear, children: allLabel }),
528
- children: /* @__PURE__ */ jsx("div", { className: "wp-col", style: { minWidth: 200 }, children: options.map((option) => /* @__PURE__ */ jsx(
529
- Checkbox,
600
+ children: /* @__PURE__ */ jsx(
601
+ "div",
530
602
  {
531
- label: cascadingOptionLabel(option, pathMap, showPathLabels),
532
- checked: selectedSet.has(option.value),
533
- onChange: (e) => onToggle(option, e.target.checked)
534
- },
535
- option.value
536
- )) })
603
+ className: "wp-col",
604
+ style: { minWidth: 200, maxHeight: "min(320px, 40vh)", overflowY: "auto" },
605
+ children: options.map((option) => /* @__PURE__ */ jsx(
606
+ Checkbox,
607
+ {
608
+ label: cascadingOptionLabel(option, pathMap, showPathLabels),
609
+ checked: selectedSet.has(option.value),
610
+ onChange: (e) => onToggle(option, e.target.checked)
611
+ },
612
+ option.value
613
+ ))
614
+ }
615
+ )
537
616
  }
538
617
  );
539
618
  }
540
619
 
541
- export { Popover, SearchToolbar, cascadingValuePaths, cleanSearchValues, includesLooseSearch, joinMultiValue, normalizeSearchText, searchFieldKeys, searchValuesFromParams, splitMultiValue };
620
+ export { Popover, SearchToolbar, cascadingValuePaths, cleanSearchValues, includesLooseSearch, joinMultiValue, normalizeSearchFields, normalizeSearchText, searchFieldKeys, searchValuesFromParams, splitMultiValue };
@@ -1,4 +1,4 @@
1
- import { includesLooseSearch } from './chunk-NIRR6KSQ.js';
1
+ import { includesLooseSearch } from './chunk-CDYIUQAD.js';
2
2
  import { Icon, Button, Input, Spinner } from './chunk-B5JTXYDB.js';
3
3
  import { boxed } from './chunk-SAN4IX6N.js';
4
4
  import { useId, useRef, useState, useEffect, useMemo } from 'react';
package/dist/index.d.ts CHANGED
@@ -202,7 +202,8 @@ declare function useRemoteSearchPage(endpoint: string, params: URLSearchParams):
202
202
  * 그대로 옮긴 것. 스펙 호환이 목적이므로 값 규칙(쉼표 다중값, 느슨한 검색, 범위 키 이름)은
203
203
  * 한 글자도 바꾸지 않았다.
204
204
  *
205
- * - 검색 버튼은 두지 않는다(Enter로 검색) — 시안 규칙 유지
205
+ * - 검색 버튼을 입력창과 상세검색 토글 사이에 둔다(Enter 도 그대로 동작) —
206
+ * 원래 시안은 Enter 전용이었는데 실사용 지적으로 명시 버튼을 되돌렸다
206
207
  * - 상세검색은 아이콘 버튼 → 드롭다운 패널(초기화/적용)
207
208
  * - 적용된 조건은 칩(Tag)으로 나열되고, 다중 선택 칩은 값 하나씩 제거된다
208
209
  */
@@ -220,11 +221,21 @@ type SearchField = {
220
221
  label: string;
221
222
  placeholder?: string;
222
223
  } | {
223
- /** 다중 선택 — 값은 쉼표로 합쳐 저장한다 */
224
+ /**
225
+ * 다중 선택 — 값은 쉼표로 합쳐 저장한다.
226
+ *
227
+ * `valueKey`/`labelKey` 를 주면 options 를 도메인 객체 배열로 받는다
228
+ * (`Select` 0.7.0 과 같은 계약). 스펙에서는 options 에
229
+ * `{"$state":"rt.departments","default":[]}` 를 바인딩하면 page.data 가 채운
230
+ * 실데이터가 그대로 선택지가 된다 — 정적 {label,value}만 받던 것이
231
+ * 예시값 필터가 화면마다 남던 근본 원인이었다(실사용 지적).
232
+ */
224
233
  type: 'select';
225
234
  key: string;
226
235
  label: string;
227
- options: SearchOption[];
236
+ options: Array<SearchOption | Record<string, unknown>>;
237
+ valueKey?: string;
238
+ labelKey?: string;
228
239
  allLabel?: string;
229
240
  } | {
230
241
  type: 'dateRange';
@@ -244,11 +255,20 @@ type SearchField = {
244
255
  /**
245
256
  * 계층 다중 선택 — 단계별 체크박스 목록 체인. 선택된 값들을 쉼표로 합쳐 저장하며,
246
257
  * 부모를 선택하면 그 자손 선택은 제거되고 자손을 선택하면 그 조상 선택이 제거된다.
258
+ *
259
+ * `tree` 를 주면 options 를 **평면 행 배열**로 받아 여기서 계층을 세운다 —
260
+ * `/categories` 처럼 {id, parent_id, name} 평면 목록을 주는 API 를 스펙이
261
+ * 트리로 재조립할 방법이 없어서다($calc 에 그런 연산이 없다).
247
262
  */
248
263
  type: 'cascading';
249
264
  key: string;
250
265
  label: string;
251
- options: CascadingSearchOption[];
266
+ options: Array<CascadingSearchOption | Record<string, unknown>>;
267
+ tree?: {
268
+ idKey: string;
269
+ parentIdKey: string;
270
+ labelKey: string;
271
+ };
252
272
  allLabel?: string;
253
273
  };
254
274
  type SearchValues = Record<string, string>;
@@ -276,7 +296,7 @@ interface SearchToolbarProps {
276
296
  /** 필터 전용 모드: 검색 입력창 없이 상세검색 버튼+칩만 보여준다 */
277
297
  hideSearchInput?: boolean;
278
298
  }
279
- declare function SearchToolbar({ search, onSearchChange, onSearch, advancedFields, advancedValues, placeholder, className, hideSearchInput, }: SearchToolbarProps): React__default.JSX.Element;
299
+ declare function SearchToolbar({ search, onSearchChange, onSearch, advancedFields: rawAdvancedFields, advancedValues, placeholder, className, hideSearchInput, }: SearchToolbarProps): React__default.JSX.Element;
280
300
 
281
301
  /**
282
302
  * 검색 결과 목록 — ERP `app/components/search/SearchResults.tsx`의 계약을 그대로 가져온 포팅.
@@ -402,7 +422,7 @@ interface SearchResultsProps<T> {
402
422
  declare function normalizePageSizes(sizes: number[] | undefined): number[] | undefined;
403
423
  declare function SearchResults<T extends {
404
424
  id: string;
405
- }>({ item, columns, gridConfig, leftFilters, rightFilters, initialView, views, pageSizes, className, style, searchParams, onParamsChange, actions, disableGrid, disableList, onRowClick, title, headerActions, isLoading, hideHeader, advancedSearchFields, searchPlaceholder, virtual, }: SearchResultsProps<T>): React__default.JSX.Element;
425
+ }>({ item, columns, gridConfig, leftFilters, rightFilters, initialView, views, pageSizes, className, style, searchParams, onParamsChange, actions, disableGrid, disableList, onRowClick, title, headerActions, isLoading, hideHeader, advancedSearchFields: rawAdvancedSearchFields, searchPlaceholder, virtual, }: SearchResultsProps<T>): React__default.JSX.Element;
406
426
 
407
427
  /**
408
428
  * 셀 표현 어휘 — 목록 컬럼의 셀과 카드 행의 값이 **같은 규칙**을 쓴다.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { DEV, HoistedStyle, isSafeNavigationTarget, useSduiAdapter, hostFetchOf, DateRangePicker, DatePicker, useSduiSandbox, resolveAdapter, assetResolverOf, SduiAdapterContext } from './chunk-5A7BWIGN.js';
2
2
  export { DatePicker, DateRangePicker, defaultAdapter, useSduiAssetResolver, useSduiSandbox } from './chunk-5A7BWIGN.js';
3
- import { searchValuesFromParams, splitMultiValue, includesLooseSearch, cascadingValuePaths, SearchToolbar, searchFieldKeys, Popover } from './chunk-NIRR6KSQ.js';
4
- export { SearchToolbar, cascadingValuePaths, cleanSearchValues, includesLooseSearch, joinMultiValue, normalizeSearchText, searchFieldKeys, searchValuesFromParams, splitMultiValue } from './chunk-NIRR6KSQ.js';
3
+ import { normalizeSearchFields, searchValuesFromParams, splitMultiValue, includesLooseSearch, cascadingValuePaths, SearchToolbar, searchFieldKeys, Popover } from './chunk-CDYIUQAD.js';
4
+ export { SearchToolbar, cascadingValuePaths, cleanSearchValues, includesLooseSearch, joinMultiValue, normalizeSearchText, searchFieldKeys, searchValuesFromParams, splitMultiValue } from './chunk-CDYIUQAD.js';
5
5
  import { EmptyState, Button, Checkbox, SegmentedControl, Spinner, Select, Pagination, Icon, Card, Badge, ProgressBar, Tag, Toast, Textarea, Tabs, Table, Switch, StatCard, Input, IconButton, Field, Alert, warnOnce, Dialog } from './chunk-B5JTXYDB.js';
6
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-B5JTXYDB.js';
7
7
  import { boxed, mergeStyle, containerLayoutStyle, joinClass, columnsClass, typographyStyle, LAYOUT_CSS, childLayoutFrom, CHILD_LAYOUT_PROPS } from './chunk-SAN4IX6N.js';
@@ -643,11 +643,15 @@ function SearchResults({
643
643
  headerActions,
644
644
  isLoading = false,
645
645
  hideHeader = false,
646
- advancedSearchFields = EMPTY_FIELDS,
646
+ advancedSearchFields: rawAdvancedSearchFields = EMPTY_FIELDS,
647
647
  searchPlaceholder,
648
648
  virtual
649
649
  }) {
650
650
  const hasActions = actions.length > 0;
651
+ const advancedSearchFields = useMemo(
652
+ () => normalizeSearchFields(rawAdvancedSearchFields),
653
+ [rawAdvancedSearchFields]
654
+ );
651
655
  const sizeChoices = normalizePageSizes(pageSizes) ?? PAGE_SIZE_CHOICES;
652
656
  const defaultPageSize = sizeChoices[0];
653
657
  const page = Number(searchParams.get("page")) || 1;
@@ -1,4 +1,4 @@
1
- export { AutocompleteField, toAutocompleteOptions } from '../chunk-TBAIREB3.js';
2
- import '../chunk-NIRR6KSQ.js';
1
+ export { AutocompleteField, toAutocompleteOptions } from '../chunk-PEDU6LQK.js';
2
+ import '../chunk-CDYIUQAD.js';
3
3
  import '../chunk-B5JTXYDB.js';
4
4
  import '../chunk-SAN4IX6N.js';
@@ -1,4 +1,4 @@
1
- export { SearchableMultiPicker } from '../chunk-OINKP2BE.js';
2
- import '../chunk-NIRR6KSQ.js';
1
+ export { SearchableMultiPicker } from '../chunk-4TS2N5KU.js';
2
+ import '../chunk-CDYIUQAD.js';
3
3
  import '../chunk-B5JTXYDB.js';
4
4
  import '../chunk-SAN4IX6N.js';
@@ -1,5 +1,5 @@
1
- import { SearchableMultiPicker } from '../chunk-OINKP2BE.js';
2
- export { SearchableMultiPicker } from '../chunk-OINKP2BE.js';
1
+ import { SearchableMultiPicker } from '../chunk-4TS2N5KU.js';
2
+ export { SearchableMultiPicker } from '../chunk-4TS2N5KU.js';
3
3
  import { SecureScreen } from '../chunk-4CZJF5PR.js';
4
4
  export { SecureScreen } from '../chunk-4CZJF5PR.js';
5
5
  import { StackedMeter } from '../chunk-BMZKZGJF.js';
@@ -32,8 +32,8 @@ import { PeriodFilter } from '../chunk-WJEGCCPY.js';
32
32
  export { PeriodFilter } from '../chunk-WJEGCCPY.js';
33
33
  import { PrintSheet } from '../chunk-JWYBFODH.js';
34
34
  export { PrintSheet } from '../chunk-JWYBFODH.js';
35
- export { AutocompleteField, toAutocompleteOptions } from '../chunk-TBAIREB3.js';
36
- import '../chunk-NIRR6KSQ.js';
35
+ export { AutocompleteField, toAutocompleteOptions } from '../chunk-PEDU6LQK.js';
36
+ import '../chunk-CDYIUQAD.js';
37
37
  import { ColumnBoard } from '../chunk-VK6KWB65.js';
38
38
  export { ColumnBoard } from '../chunk-VK6KWB65.js';
39
39
  import { DiffView } from '../chunk-4IRLQWQC.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipco/sdui",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "WIPCO 서버 주도 UI(SDUI) 렌더러 — JSON 페이지 스펙을 React 컴포넌트로 렌더링",
5
5
  "keywords": [
6
6
  "sdui",