@wipco/sdui 1.0.0 → 1.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.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as React from 'react';
2
2
  import React__default, { CSSProperties, ReactNode, ComponentType } from 'react';
3
- import { S as SduiPage, a as SduiAction, b as SduiPropValue, c as SduiRule } from './types-C7y9CpgF.js';
4
- export { d as SduiArgRef, e as SduiCalcRef, f as SduiChild, g as SduiIndexRef, h as SduiItemRef, i as SduiNode, j as SduiPageQuery, k as SduiScalar, l as SduiStateRef } from './types-C7y9CpgF.js';
3
+ import { S as SduiPage, a as SduiAction, b as SduiPropValue, c as SduiRule } from './types-COjGKgmd.js';
4
+ export { d as SduiArgRef, e as SduiCalcRef, f as SduiChild, g as SduiIndexRef, h as SduiItemRef, i as SduiNode, j as SduiPageQuery, k as SduiScalar, l as SduiStateRef } from './types-COjGKgmd.js';
5
5
  import { S as SduiBoxProps, a as SduiContainerLayoutProps, b as SduiColumnsScale, c as SduiGapScale, d as SduiWeightScale, e as SduiTextAlignScale } from './layout-C3nRXMwb.js';
6
6
  export { C as CHILD_LAYOUT_PROPS, L as LAYOUT_CSS, f as SduiAlignScale, g as SduiChildLayoutProps, h as SduiSizeScale, i as SduiSpanScale, j as childLayoutFrom, k as columnsClass, l as containerLayoutStyle, m as joinClass, n as mergeStyle, t as typographyStyle } from './layout-C3nRXMwb.js';
7
7
  import { D as DatePickerProps, a as DateRangePickerProps, T as TimePickerProps } from './DatePicker-C_TT_SOw.js';
package/dist/index.js CHANGED
@@ -2181,6 +2181,9 @@ function nearestArgsFrame(scope) {
2181
2181
  }
2182
2182
  return void 0;
2183
2183
  }
2184
+ function hasExplicitPageHead(body) {
2185
+ return Array.isArray(body) && body.some((child) => isPlainObject(child) && child.component === "PageHead");
2186
+ }
2184
2187
  function isStateRef(v) {
2185
2188
  return isPlainObject(v) && typeof v.$state === "string";
2186
2189
  }
@@ -2503,6 +2506,11 @@ function SduiFragmentBody({
2503
2506
  }
2504
2507
  return /* @__PURE__ */ jsx(SduiFragmentChainContext.Provider, { value: nextChain, children: page.body.map((child, i) => renderChild(child, i, bodyScope, engine)) });
2505
2508
  }
2509
+ function urlSeedSettled(state, seeded, baseline) {
2510
+ return Object.keys(seeded).every(
2511
+ (key) => Object.is(state[key], seeded[key]) || !Object.is(state[key], baseline[key])
2512
+ );
2513
+ }
2506
2514
  function SduiRenderer({
2507
2515
  page,
2508
2516
  registry = defaultRegistry,
@@ -2520,6 +2528,8 @@ function SduiRenderer({
2520
2528
  });
2521
2529
  const [state, setState] = useState(() => ({ ...initialState ?? {} }));
2522
2530
  const urlSeeded = useRef(false);
2531
+ const urlSeedPending = useRef(null);
2532
+ const urlHomePath = useRef(typeof window !== "undefined" ? window.location.pathname : "");
2523
2533
  const [toasts, setToasts] = useState([]);
2524
2534
  const toastSeq = useRef(0);
2525
2535
  const toastTimers = useRef([]);
@@ -2554,10 +2564,21 @@ function SduiRenderer({
2554
2564
  }
2555
2565
  }
2556
2566
  }
2557
- if (Object.keys(seeded).length > 0) setEntries(seeded);
2567
+ if (Object.keys(seeded).length > 0) {
2568
+ const baseline = {};
2569
+ for (const key of Object.keys(seeded)) baseline[key] = stateRef.current[key];
2570
+ urlSeedPending.current = { seeded, baseline };
2571
+ setEntries(seeded);
2572
+ }
2558
2573
  }, [page.urlState, setEntries, syncUrlState]);
2559
2574
  useEffect(() => {
2560
2575
  if (!syncUrlState || !page.urlState?.length || !urlSeeded.current || typeof window === "undefined") return;
2576
+ if (window.location.pathname !== urlHomePath.current) return;
2577
+ if (urlSeedPending.current) {
2578
+ const { seeded, baseline } = urlSeedPending.current;
2579
+ if (!urlSeedSettled(state, seeded, baseline)) return;
2580
+ urlSeedPending.current = null;
2581
+ }
2561
2582
  const params = new URLSearchParams(window.location.search);
2562
2583
  let changed = false;
2563
2584
  for (const key of page.urlState) {
@@ -3017,7 +3038,7 @@ function SduiRenderer({
3017
3038
  // 컴포넌트(목록 조회 등)도 호스트가 건 잠금 안에서 움직이게 하기 위해서다(adapter.ts 주석).
3018
3039
  /* @__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: [
3019
3040
  /* @__PURE__ */ jsx(HoistedStyle, { id: "wp-sdui-layout", css: LAYOUT_CSS }),
3020
- page.title || page.subtitle ? /* @__PURE__ */ jsx("div", { className: "wp-page-head", children: /* @__PURE__ */ jsxs("div", { className: "wp-page-head__text", children: [
3041
+ !hasExplicitPageHead(page.body) && (page.title || page.subtitle) ? /* @__PURE__ */ jsx("div", { className: "wp-page-head", children: /* @__PURE__ */ jsxs("div", { className: "wp-page-head__text", children: [
3021
3042
  page.title ? /* @__PURE__ */ jsx("h1", { className: "wp-page-head__title", children: page.title }) : null,
3022
3043
  page.subtitle ? /* @__PURE__ */ jsx("div", { className: "wp-page-head__sub", children: page.subtitle }) : null
3023
3044
  ] }) }) : null,
package/dist/meta.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { b as SduiPropValue, i as SduiNode } from './types-C7y9CpgF.js';
1
+ import { b as SduiPropValue, i as SduiNode } from './types-COjGKgmd.js';
2
2
 
3
3
  /**
4
4
  * 시각 빌더용 컴포넌트 메타데이터 스키마.
package/dist/meta.js CHANGED
@@ -3273,6 +3273,26 @@ var ChartMeta = {
3273
3273
  description: "\uB2E8\uC77C \uC2DC\uB9AC\uC988 \uCE74\uB974\uD14C\uC2DC\uC548 \uCC28\uD2B8\uB294 \uCF1C\uB3C4 \uC0DD\uB7B5\uB41C\uB2E4.",
3274
3274
  group: "appearance"
3275
3275
  },
3276
+ {
3277
+ name: "legendPlacement",
3278
+ kind: "enum",
3279
+ label: "\uBC94\uB840 \uC704\uCE58",
3280
+ default: "bottom",
3281
+ description: 'pie/donut \uC804\uC6A9. "\uC6B0\uCE21 \uC218\uCE58 \uD45C"\uB294 \uC810 \uBC94\uB840 \uB300\uC2E0 \uC804\uCCB4 \uD589 + \uC870\uAC01\uBCC4 \uAC12\xB7\uC810\uC720\uC728\uC744 \uCC28\uD2B8 \uC624\uB978\uCABD\uC5D0 \uC801\uB294\uB2E4.',
3282
+ options: [
3283
+ { value: "bottom", label: "\uC544\uB798 \uC810 \uBC94\uB840" },
3284
+ { value: "right", label: "\uC6B0\uCE21 \uC218\uCE58 \uD45C" }
3285
+ ],
3286
+ group: "appearance"
3287
+ },
3288
+ {
3289
+ name: "legendTotal",
3290
+ kind: "number",
3291
+ label: "\uC804\uCCB4 \uAC12 \uC7AC\uC815\uC758",
3292
+ bindable: true,
3293
+ description: '\uC6B0\uCE21 \uC218\uCE58 \uD45C\uC758 "\uC804\uCCB4" \uD589 \uAC12\uC774\uC790 \uC810\uC720\uC728 \uBD84\uBAA8. \uC0DD\uB7B5\uD558\uBA74 \uC870\uAC01 \uAC12\uC758 \uD569 \u2014 \uC870\uAC01\uC774 \uACB9\uCCD0 \uC138\uC5B4\uC9C0\uB294 \uC9D1\uACC4\uB294 \uC5EC\uAE30\uC5D0 \uC2E4\uC81C \uBAA8\uC218\uB97C \uBC14\uC778\uB529\uD55C\uB2E4.',
3294
+ group: "appearance"
3295
+ },
3276
3296
  {
3277
3297
  name: "donutCenter",
3278
3298
  kind: "object",
package/dist/rich.d.ts CHANGED
@@ -39,6 +39,19 @@ interface ChartProps {
39
39
  valueFormat?: ChartValueFormat;
40
40
  /** 범례 표시 여부. 기본 true (단일 시리즈 카르테시안 차트는 생략). */
41
41
  legend?: boolean;
42
+ /**
43
+ * pie/donut 전용: 범례 위치. `'right'`는 아래쪽 점 범례 대신 차트 오른쪽에
44
+ * 전체 행 + 조각별 값·점유율 표를 붙인다({@link SliceLegendTable}).
45
+ * 카르테시안 차트에서는 무시된다. 기본 'bottom'.
46
+ */
47
+ legendPlacement?: 'bottom' | 'right';
48
+ /**
49
+ * 우측 수치 표의 "전체" 값(점유율 분모) 재정의. 생략하면 조각 값의 합.
50
+ *
51
+ * 조각이 겹치는 집계(수리 중인 장비가 대여 상태 어느 한쪽에도 이미 세어진 경우 등)는
52
+ * 합이 실제 모수보다 커진다 — 화면의 다른 총계와 어긋나 보이지 않게 모수를 직접 준다.
53
+ */
54
+ legendTotal?: number;
42
55
  /** donut 중앙 오버레이 텍스트. */
43
56
  donutCenter?: {
44
57
  label?: string;
@@ -47,7 +60,7 @@ interface ChartProps {
47
60
  /** 데이터가 없을 때 보여줄 문구. */
48
61
  emptyText?: string;
49
62
  }
50
- declare function Chart({ type, data, xKey, series, height, valueFormat, legend, donutCenter, emptyText, }: ChartProps): React__default.JSX.Element;
63
+ declare function Chart({ type, data, xKey, series, height, valueFormat, legend, legendPlacement, legendTotal, donutCenter, emptyText, }: ChartProps): React__default.JSX.Element;
51
64
 
52
65
  /**
53
66
  * MapPreview — OpenLayers 기반 위치 미리보기 지도 (ERP `app/components/widgets/MapPreview.tsx` 이식).
package/dist/rich.js CHANGED
@@ -149,6 +149,65 @@ function HtmlLegend({ items }) {
149
149
  }
150
150
  );
151
151
  }
152
+ function SliceLegendTable({
153
+ rows,
154
+ format,
155
+ total
156
+ }) {
157
+ const denom = total ?? rows.reduce((sum, r) => sum + r.value, 0);
158
+ const share = (v) => denom > 0 ? `${(v / denom * 100).toLocaleString("ko-KR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%` : "0.0%";
159
+ const valueStyle = {
160
+ fontSize: 13,
161
+ fontWeight: 600,
162
+ color: "var(--wp-text-strong)",
163
+ fontVariantNumeric: "tabular-nums",
164
+ whiteSpace: "nowrap"
165
+ };
166
+ return /* @__PURE__ */ jsxs(
167
+ "div",
168
+ {
169
+ style: {
170
+ flex: "none",
171
+ minWidth: 148,
172
+ display: "flex",
173
+ flexDirection: "column",
174
+ justifyContent: "center",
175
+ gap: "var(--wp-space-2)"
176
+ },
177
+ children: [
178
+ /* @__PURE__ */ jsxs(
179
+ "div",
180
+ {
181
+ style: {
182
+ display: "flex",
183
+ alignItems: "baseline",
184
+ justifyContent: "space-between",
185
+ gap: "var(--wp-space-4)",
186
+ paddingBottom: "var(--wp-space-2)",
187
+ borderBottom: "1px solid var(--wp-divider)"
188
+ },
189
+ children: [
190
+ /* @__PURE__ */ jsx("span", { className: "wp-caption", children: "\uC804\uCCB4" }),
191
+ /* @__PURE__ */ jsx("span", { style: valueStyle, children: `${format(denom)} (${share(denom)})` })
192
+ ]
193
+ }
194
+ ),
195
+ rows.map((row, i) => /* @__PURE__ */ jsxs(
196
+ "div",
197
+ {
198
+ style: { display: "flex", alignItems: "center", gap: "var(--wp-space-2)" },
199
+ children: [
200
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, style: dotStyle(row.color) }),
201
+ /* @__PURE__ */ jsx("span", { className: "wp-caption", style: { flex: 1, minWidth: 0, overflowWrap: "anywhere" }, children: row.label }),
202
+ /* @__PURE__ */ jsx("span", { style: valueStyle, children: `${format(row.value)} (${share(row.value)})` })
203
+ ]
204
+ },
205
+ `${row.label}-${i}`
206
+ ))
207
+ ]
208
+ }
209
+ );
210
+ }
152
211
  var axisTick = { fill: "var(--wp-text-muted)", fontSize: 12 };
153
212
  function Chart({
154
213
  type,
@@ -158,6 +217,8 @@ function Chart({
158
217
  height = 300,
159
218
  valueFormat = "number",
160
219
  legend = true,
220
+ legendPlacement = "bottom",
221
+ legendTotal,
161
222
  donutCenter,
162
223
  emptyText
163
224
  }) {
@@ -218,9 +279,12 @@ function Chart({
218
279
  label: String(row[nameKey] ?? `\uD56D\uBAA9 ${i + 1}`),
219
280
  color: sliceColor(row, i)
220
281
  }));
221
- return shell(
222
- /* @__PURE__ */ jsxs(Fragment, { children: [
223
- /* @__PURE__ */ jsxs("div", { style: { position: "relative", width: "100%", height }, children: [
282
+ const sideLegend = legend && legendPlacement === "right";
283
+ const chartArea = /* @__PURE__ */ jsxs(
284
+ "div",
285
+ {
286
+ style: sideLegend ? { position: "relative", flex: "1 1 0%", minWidth: 0, height } : { position: "relative", width: "100%", height },
287
+ children: [
224
288
  /* @__PURE__ */ jsx(ResponsiveContainer, { width: "100%", height: "100%", children: /* @__PURE__ */ jsxs(PieChart, { children: [
225
289
  /* @__PURE__ */ jsx(Tooltip, { content: tooltipContent }),
226
290
  /* @__PURE__ */ jsx(
@@ -259,7 +323,32 @@ function Chart({
259
323
  ]
260
324
  }
261
325
  )
262
- ] }),
326
+ ]
327
+ }
328
+ );
329
+ if (sideLegend) {
330
+ const sliceValue = (row) => {
331
+ const n = Number(row[valueKey]);
332
+ return Number.isFinite(n) ? n : 0;
333
+ };
334
+ const denomOverride = Number(legendTotal);
335
+ return shell(
336
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "center", gap: "var(--wp-space-6)" }, children: [
337
+ chartArea,
338
+ /* @__PURE__ */ jsx(
339
+ SliceLegendTable,
340
+ {
341
+ rows: rows.map((row, i) => ({ ...legendItems2[i], value: sliceValue(row) })),
342
+ format,
343
+ total: legendTotal !== void 0 && Number.isFinite(denomOverride) ? denomOverride : void 0
344
+ }
345
+ )
346
+ ] })
347
+ );
348
+ }
349
+ return shell(
350
+ /* @__PURE__ */ jsxs(Fragment, { children: [
351
+ chartArea,
263
352
  legend && /* @__PURE__ */ jsx(HtmlLegend, { items: legendItems2 })
264
353
  ] })
265
354
  );
@@ -397,7 +397,13 @@ interface SduiPageQuery {
397
397
  }
398
398
  interface SduiPage {
399
399
  version: 1;
400
- /** 문서 <title> 및 페이지 헤더에 쓰인다. */
400
+ /**
401
+ * 문서 <title> 및 페이지 헤더에 쓰인다.
402
+ *
403
+ * body **최상위**에 `PageHead` 노드가 있으면 자동 페이지 헤더는 접히고 그쪽이
404
+ * 시각 헤더가 된다 — 제목 줄 우측에 액션(필터·버튼)을 놓으려는 스펙이
405
+ * title(문서 제목)을 포기하지 않아도 되는 자리다.
406
+ */
401
407
  title?: string;
402
408
  subtitle?: string;
403
409
  /** 실시간 채널 구독 — 어댑터의 subscribe로 위임(이벤트 수신 시 조용한 재조회) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipco/sdui",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "WIPCO 서버 주도 UI(SDUI) 렌더러 — JSON 페이지 스펙을 React 컴포넌트로 렌더링",
5
5
  "keywords": [
6
6
  "sdui",
@@ -208,7 +208,7 @@
208
208
  "dev": "tsup --watch",
209
209
  "typecheck": "tsc --noEmit",
210
210
  "check:meta": "node scripts/check-meta.mjs",
211
- "test": "tsx --test test/*.test.ts",
211
+ "test": "tsx --test test/*.test.ts test/*.test.tsx",
212
212
  "prepack": "pnpm run build"
213
213
  },
214
214
  "peerDependencies": {
@@ -232,6 +232,9 @@
232
232
  "devDependencies": {
233
233
  "@types/react": "^19.0.0",
234
234
  "@types/react-dom": "^19.0.0",
235
+ "jsdom": "^26.1.0",
236
+ "react": "^19.2.8",
237
+ "react-dom": "^19.2.8",
235
238
  "tsup": "^8.5.0",
236
239
  "tsx": "^4.20.0",
237
240
  "typescript": "^5.7.0"