@wipco/sdui 1.4.2 → 1.6.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 (143) hide show
  1. package/dist/{DatePicker-C_TT_SOw.d.ts → DatePicker-DX9juElE.d.ts} +10 -1
  2. package/dist/{chunk-GZBY7I4Y.js → chunk-2KQL6DYG.js} +3 -3
  3. package/dist/{chunk-RYBBPFI7.js → chunk-2QXA52DG.js} +4 -4
  4. package/dist/{chunk-DMLDKW7K.js → chunk-37BNG4DT.js} +1 -1
  5. package/dist/{chunk-5WW4MBRO.js → chunk-3JTBOABA.js} +6 -2
  6. package/dist/{chunk-UV6HHFEW.js → chunk-4OABUPIU.js} +129 -68
  7. package/dist/{chunk-ZORQPB6B.js → chunk-53KS4E5N.js} +318 -359
  8. package/dist/{chunk-I5SD7XRA.js → chunk-6TI4WF5Y.js} +3 -3
  9. package/dist/{chunk-44TDRCKR.js → chunk-6XI2VF2O.js} +5 -5
  10. package/dist/{chunk-HBIIEYKW.js → chunk-7C54CKFZ.js} +4 -4
  11. package/dist/{chunk-IZ3JA2BR.js → chunk-7JP52QQO.js} +3 -3
  12. package/dist/{chunk-I45EUZC6.js → chunk-AY4GM6KQ.js} +4 -2
  13. package/dist/{chunk-SAPBDJJQ.js → chunk-AZSLSAJB.js} +2 -2
  14. package/dist/chunk-BITS7UJY.js +333 -0
  15. package/dist/{chunk-RNOUXUBO.js → chunk-BLFJMMBW.js} +306 -57
  16. package/dist/{chunk-YOZN35HD.js → chunk-CEVZAFPO.js} +3 -3
  17. package/dist/chunk-CI5OZ2QT.js +106 -0
  18. package/dist/{chunk-FNMS7OB7.js → chunk-CSE3PZOZ.js} +5 -5
  19. package/dist/chunk-CSZCWBOE.js +256 -0
  20. package/dist/{chunk-JIV4YHCZ.js → chunk-EJIWOD7W.js} +1 -1
  21. package/dist/{chunk-JZXU22PE.js → chunk-FEKKOBPX.js} +208 -117
  22. package/dist/chunk-GSM664TX.js +38 -0
  23. package/dist/{chunk-JYAR7D5U.js → chunk-HRHKQOOF.js} +2 -2
  24. package/dist/{chunk-CXW6KT6X.js → chunk-HXLQSO66.js} +3 -3
  25. package/dist/{chunk-O4IISNE7.js → chunk-IB2ZPUTF.js} +51 -12
  26. package/dist/{chunk-MPT7WJG2.js → chunk-IQXLNICY.js} +5 -5
  27. package/dist/{chunk-OQ52XMGG.js → chunk-K6RE23XX.js} +4 -4
  28. package/dist/{chunk-VY7MVBSE.js → chunk-KZ6U6FKB.js} +2 -2
  29. package/dist/{chunk-WPSK2CB5.js → chunk-LIZTK3HO.js} +16 -5
  30. package/dist/{chunk-Q4SWUINA.js → chunk-LKQJJSVJ.js} +1 -1
  31. package/dist/{chunk-E6ISNQK3.js → chunk-M6H3SHIS.js} +31 -8
  32. package/dist/chunk-MN4RZ2WP.js +380 -0
  33. package/dist/{chunk-GTGS2QRL.js → chunk-NA232CBX.js} +3 -3
  34. package/dist/{chunk-ZFNFPXPR.js → chunk-NAIWQQPW.js} +2 -2
  35. package/dist/{chunk-YVTV4ZV6.js → chunk-NWYYNE3Q.js} +1 -1
  36. package/dist/{chunk-DKVSM5HX.js → chunk-OUEJWC2Z.js} +6 -6
  37. package/dist/{chunk-DOL57OOP.js → chunk-OYDJHQNZ.js} +3 -3
  38. package/dist/{chunk-YWUDBLO6.js → chunk-QVS6K4FP.js} +2 -2
  39. package/dist/{chunk-N5RKM6IC.js → chunk-RU5U2YMT.js} +4 -4
  40. package/dist/{chunk-A6LAIGUS.js → chunk-V7BWMOHO.js} +7 -4
  41. package/dist/{chunk-2HLXCKK7.js → chunk-VPOOIEKT.js} +2 -2
  42. package/dist/{chunk-2YP5N4VV.js → chunk-VZOQRQAR.js} +1 -1
  43. package/dist/{chunk-GNA6SX2H.js → chunk-W4VA76QO.js} +2 -2
  44. package/dist/{chunk-BEM2YS7U.js → chunk-WEG3V7ZB.js} +3 -3
  45. package/dist/{chunk-XGFDY43I.js → chunk-WTIYILY3.js} +88 -18
  46. package/dist/{chunk-RWSH2MBU.js → chunk-X2NIRVLN.js} +73 -19
  47. package/dist/{chunk-AXGF4M57.js → chunk-XAXROQKB.js} +1 -1
  48. package/dist/{chunk-NIJUVTEW.js → chunk-XG22XKKD.js} +3 -3
  49. package/dist/{chunk-A5XQOEVH.js → chunk-YN7X7BEO.js} +4 -4
  50. package/dist/{chunk-KO6OEY3G.js → chunk-YNEEJCSZ.js} +3 -13
  51. package/dist/{chunk-7P7N43JV.js → chunk-YZTTFEL4.js} +2 -2
  52. package/dist/index.d.ts +494 -89
  53. package/dist/index.js +1098 -124
  54. package/dist/{layout-C3nRXMwb.d.ts → layout-DmRikwZb.d.ts} +5 -4
  55. package/dist/meta.d.ts +1 -1
  56. package/dist/meta.js +286 -22
  57. package/dist/rich/ExcelUploadCard.d.ts +1 -1
  58. package/dist/rich/ExcelUploadCard.js +5 -4
  59. package/dist/rich.d.ts +21 -3
  60. package/dist/rich.js +277 -35
  61. package/dist/styles.css +102 -4
  62. package/dist/{types-COjGKgmd.d.ts → types-CYfIthux.d.ts} +57 -1
  63. package/dist/widgets/ApprovalLineEditor.d.ts +1 -1
  64. package/dist/widgets/ApprovalLineEditor.js +9 -8
  65. package/dist/widgets/ApprovalOverridesEditor.d.ts +1 -1
  66. package/dist/widgets/ApprovalOverridesEditor.js +11 -10
  67. package/dist/widgets/ApprovalStepper.d.ts +29 -4
  68. package/dist/widgets/ApprovalStepper.js +3 -3
  69. package/dist/widgets/AutocompleteField.d.ts +1 -1
  70. package/dist/widgets/AutocompleteField.js +9 -8
  71. package/dist/widgets/ColumnBoard.d.ts +1 -1
  72. package/dist/widgets/ColumnBoard.js +3 -3
  73. package/dist/widgets/DiffView.d.ts +1 -1
  74. package/dist/widgets/DiffView.js +3 -3
  75. package/dist/widgets/Disclosure.d.ts +1 -1
  76. package/dist/widgets/Disclosure.js +3 -3
  77. package/dist/widgets/EventCalendar.d.ts +5 -1
  78. package/dist/widgets/EventCalendar.js +5 -3
  79. package/dist/widgets/FileDropzone.d.ts +1 -1
  80. package/dist/widgets/FileDropzone.js +9 -7
  81. package/dist/widgets/ImageLightbox.d.ts +1 -1
  82. package/dist/widgets/ImageLightbox.js +7 -5
  83. package/dist/widgets/InlineEdit.d.ts +1 -1
  84. package/dist/widgets/InlineEdit.js +3 -3
  85. package/dist/widgets/JsonField.d.ts +1 -1
  86. package/dist/widgets/JsonField.js +3 -3
  87. package/dist/widgets/LiveClock.d.ts +1 -1
  88. package/dist/widgets/LiveClock.js +2 -2
  89. package/dist/widgets/OptionSchemaEditor.d.ts +1 -1
  90. package/dist/widgets/OptionSchemaEditor.js +4 -4
  91. package/dist/widgets/OptionValueFields.d.ts +1 -1
  92. package/dist/widgets/OptionValueFields.js +4 -4
  93. package/dist/widgets/OrgChart.d.ts +1 -1
  94. package/dist/widgets/OrgChart.js +4 -4
  95. package/dist/widgets/OrgChartBoard.d.ts +15 -33
  96. package/dist/widgets/OrgChartBoard.js +10 -8
  97. package/dist/widgets/PeriodFilter.d.ts +1 -1
  98. package/dist/widgets/PeriodFilter.js +6 -5
  99. package/dist/widgets/PermissionMatrixEditor.d.ts +34 -2
  100. package/dist/widgets/PermissionMatrixEditor.js +6 -4
  101. package/dist/widgets/PositionSelect.d.ts +1 -1
  102. package/dist/widgets/PositionSelect.js +5 -4
  103. package/dist/widgets/PrintSheet.d.ts +15 -2
  104. package/dist/widgets/PrintSheet.js +3 -3
  105. package/dist/widgets/ProfileEditDiffCard.d.ts +1 -1
  106. package/dist/widgets/ProfileEditDiffCard.js +5 -4
  107. package/dist/widgets/QRDisplay.d.ts +1 -1
  108. package/dist/widgets/QRDisplay.js +5 -4
  109. package/dist/widgets/RankBoard.d.ts +1 -1
  110. package/dist/widgets/RankBoard.js +8 -6
  111. package/dist/widgets/RecordSheet.d.ts +1 -1
  112. package/dist/widgets/RecordSheet.js +2 -2
  113. package/dist/widgets/RoleMemberPicker.d.ts +1 -1
  114. package/dist/widgets/RoleMemberPicker.js +6 -5
  115. package/dist/widgets/SearchableMultiPicker.d.ts +1 -1
  116. package/dist/widgets/SearchableMultiPicker.js +7 -6
  117. package/dist/widgets/SecureScreen.d.ts +1 -1
  118. package/dist/widgets/SecureScreen.js +3 -3
  119. package/dist/widgets/StackedMeter.d.ts +1 -1
  120. package/dist/widgets/StackedMeter.js +2 -2
  121. package/dist/widgets/StagePeriodEditor.d.ts +1 -1
  122. package/dist/widgets/StagePeriodEditor.js +6 -5
  123. package/dist/widgets/Strip.d.ts +1 -1
  124. package/dist/widgets/Strip.js +2 -2
  125. package/dist/widgets/SummaryTable.d.ts +1 -1
  126. package/dist/widgets/SummaryTable.js +2 -2
  127. package/dist/widgets/TagInput.d.ts +1 -1
  128. package/dist/widgets/TagInput.js +3 -3
  129. package/dist/widgets/TagMultiSelect.d.ts +1 -1
  130. package/dist/widgets/TagMultiSelect.js +5 -4
  131. package/dist/widgets/Timeline.d.ts +1 -1
  132. package/dist/widgets/Timeline.js +4 -4
  133. package/dist/widgets/TreePicker.d.ts +1 -1
  134. package/dist/widgets/TreePicker.js +8 -7
  135. package/dist/widgets/TreeView.d.ts +1 -1
  136. package/dist/widgets/TreeView.js +7 -6
  137. package/dist/widgets/index.d.ts +3 -3
  138. package/dist/widgets/index.js +84 -83
  139. package/package.json +1 -1
  140. package/dist/chunk-LFEJLOFN.js +0 -352
  141. package/dist/chunk-MMIGOPO5.js +0 -111
  142. package/dist/chunk-TFGNKFL2.js +0 -39
  143. package/dist/chunk-XQPTSEJC.js +0 -135
package/dist/index.js CHANGED
@@ -1,23 +1,26 @@
1
- export { loadOptionalModule } from './chunk-YVTV4ZV6.js';
2
- import { useSduiDispatch, SduiDialog, useSduiCan, SduiDispatchContext, SduiPermissionProvider } from './chunk-TFGNKFL2.js';
3
- export { SduiDialog, SduiPermissionProvider, useSduiCan, useSduiDispatch } from './chunk-TFGNKFL2.js';
4
- import { Text, Spacer, Show, Section, Row, PageHead, KeyValueList, Grid, Divider, Col, Actions } from './chunk-WPSK2CB5.js';
5
- export { Actions, Col, Divider, Grid, KeyValueList, PageHead, Row, Section, Show, Spacer, Text } from './chunk-WPSK2CB5.js';
6
- import { ImageViewer } from './chunk-SAPBDJJQ.js';
7
- export { ImageViewer } from './chunk-SAPBDJJQ.js';
8
- import { normalizeSearchFields, searchValuesFromParams, splitMultiValue, includesLooseSearch, cascadingValuePaths, SearchToolbar, searchFieldKeys, Popover } from './chunk-UV6HHFEW.js';
9
- export { SearchToolbar, cascadingValuePaths, cleanSearchValues, includesLooseSearch, joinMultiValue, normalizeSearchText, searchFieldKeys, searchValuesFromParams, splitMultiValue } from './chunk-UV6HHFEW.js';
10
- import { parseYmd, YMD_RE, TimePicker, DateRangePicker, DatePicker } from './chunk-RWSH2MBU.js';
11
- export { DatePicker, DateRangePicker, TimePicker } from './chunk-RWSH2MBU.js';
1
+ export { loadOptionalModule } from './chunk-NWYYNE3Q.js';
2
+ import { useSduiDispatch, SduiDialog, useSduiCan, useSduiRuntime, SduiDirtyRegistryContext, SduiLeaveRegistryContext, SduiFormScopeContext, SduiDispatchContext, SduiRuntimeContext, SduiPermissionProvider } from './chunk-CI5OZ2QT.js';
3
+ export { SduiDialog, SduiPermissionProvider, useSduiCan, useSduiDispatch, useSduiRuntime } from './chunk-CI5OZ2QT.js';
4
+ import { Text, Spacer, Show, Section, Row, PageHead, KeyValueList, Grid, Divider, Col, Actions } from './chunk-LIZTK3HO.js';
5
+ export { Actions, Col, Divider, Grid, KeyValueList, PageHead, Row, Section, Show, Spacer, Text } from './chunk-LIZTK3HO.js';
6
+ import { ImageViewer } from './chunk-AZSLSAJB.js';
7
+ export { ImageViewer } from './chunk-AZSLSAJB.js';
8
+ import { normalizeSearchFields, searchValuesFromParams, splitMultiValue, includesLooseSearch, cascadingValuePaths, SearchToolbar, searchFieldKeys, Popover } from './chunk-4OABUPIU.js';
9
+ export { SearchToolbar, cascadingValuePaths, cleanSearchValues, includesLooseSearch, joinMultiValue, normalizeSearchText, searchFieldKeys, searchValuesFromParams, splitMultiValue } from './chunk-4OABUPIU.js';
10
+ import { parseYmd, YMD_RE, TimePicker, DateRangePicker, DatePicker } from './chunk-X2NIRVLN.js';
11
+ export { DatePicker, DateRangePicker, TimePicker } from './chunk-X2NIRVLN.js';
12
+ import { OverlayContainerContext, OverlayLayer, useAnyModalOpen } from './chunk-BITS7UJY.js';
13
+ export { OverlayContainerContext, OverlayLayer, getOverlayRoot, useAnyModalOpen, useOverlayLayer } from './chunk-BITS7UJY.js';
12
14
  import { HoistedStyle } from './chunk-RIZRM7OO.js';
13
- export { OverlayContainerContext, OverlayLayer, getOverlayRoot, useOverlayLayer } from './chunk-XQPTSEJC.js';
14
- import { DEV, isSafeNavigationTarget, useSduiAdapter, hostFetchOf, useSduiSandbox, resolveAdapter, assetResolverOf, SduiAdapterContext } from './chunk-KO6OEY3G.js';
15
- export { defaultAdapter, useSduiAssetResolver, useSduiSandbox } from './chunk-KO6OEY3G.js';
16
- import { EmptyState, Button, Checkbox, SegmentedControl, Spinner, Select, Pagination, fieldOf, Icon, Card, Badge, ProgressBar, Tag, Toast, Textarea, Tabs, Table, Switch, StatCard, Input, IconButton, Field, Alert, warnOnce, isPlainObject, CALC_OPS, evaluateCalc, ICON_NODES, toNumber } from './chunk-RNOUXUBO.js';
17
- export { Alert, Avatar, Badge, Breadcrumb, Button, CALC_OPS, 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-RNOUXUBO.js';
18
- import { boxed, LAYOUT_CSS, childLayoutFrom, CHILD_LAYOUT_PROPS } from './chunk-5WW4MBRO.js';
19
- export { CHILD_LAYOUT_PROPS, LAYOUT_CSS, boxed, childLayoutFrom, columnsClass, containerLayoutStyle, joinClass, mergeStyle, typographyStyle } from './chunk-5WW4MBRO.js';
20
- import React9, { createContext, useState, useMemo, useRef, useEffect, useContext, useCallback } from 'react';
15
+ import { useSduiAdapter, hostFetchOf, isSafeNavigationTarget, useSduiSandbox, resolveAdapter, assetResolverOf, SduiAdapterContext } from './chunk-YNEEJCSZ.js';
16
+ export { defaultAdapter, useSduiAssetResolver, useSduiSandbox } from './chunk-YNEEJCSZ.js';
17
+ import { DEV, useIsMobile } from './chunk-GSM664TX.js';
18
+ export { MOBILE_MEDIA_QUERY, useIsMobile } from './chunk-GSM664TX.js';
19
+ import { EmptyState, Button, Checkbox, Select, Tabs, SegmentedControl, Spinner, Pagination, fieldOf, StatCard, isZonedIsoDateTime, formatDateValue, Icon, Card, Badge, ProgressBar, Tag, Toast, Textarea, Table, Switch, Input, IconButton, Field, Alert, warnOnce, isPlainObject, CALC_OPS, evaluateCalc, Dialog, ICON_NODES, toNumber } from './chunk-BLFJMMBW.js';
20
+ export { Alert, Avatar, Badge, Breadcrumb, Button, CALC_OPS, Card, Checkbox, Dialog, EmptyState, Field, Icon, IconButton, Input, Pagination, ProgressBar, Radio, SegmentedControl, Select, SidebarNav, Spinner, StatCard, Switch, Table, Tabs, Tag, Textarea, Toast, Tooltip, formatDateValue, formatDisplayValue, formatNumberValue, isZonedIsoDateTime, parseNumeric } from './chunk-BLFJMMBW.js';
21
+ import { boxed, LAYOUT_CSS, childLayoutFrom, CHILD_LAYOUT_PROPS } from './chunk-3JTBOABA.js';
22
+ export { CHILD_LAYOUT_PROPS, LAYOUT_CSS, boxed, childLayoutFrom, columnsClass, containerLayoutStyle, joinClass, mergeStyle, typographyStyle } from './chunk-3JTBOABA.js';
23
+ import React9, { createContext, useState, useMemo, useEffect, useRef, useContext, useCallback, useSyncExternalStore } from 'react';
21
24
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
22
25
 
23
26
  // src/asset-url.ts
@@ -114,6 +117,119 @@ function resolveAssetsIn(propName, value, resolve, component) {
114
117
  return out ?? record;
115
118
  }
116
119
  }
120
+
121
+ // src/josa.ts
122
+ var HANGUL_FIRST = 44032;
123
+ var HANGUL_LAST = 55203;
124
+ var JONG_RIEUL = 8;
125
+ var DIGIT_JONG = {
126
+ "0": 21,
127
+ "1": JONG_RIEUL,
128
+ "2": 0,
129
+ "3": 16,
130
+ "4": 0,
131
+ "5": 0,
132
+ "6": 1,
133
+ "7": JONG_RIEUL,
134
+ "8": JONG_RIEUL,
135
+ "9": 0
136
+ };
137
+ function finalConsonant(word) {
138
+ let text = word.trim();
139
+ for (; ; ) {
140
+ const next = text.replace(/\s*[([([][^()[\]()[]]*[)\])]]$/u, "").replace(/["'”’」』]+$/u, "").trim();
141
+ if (next === text) break;
142
+ text = next;
143
+ }
144
+ const last = text.slice(-1);
145
+ if (!last) return null;
146
+ const code = last.charCodeAt(0);
147
+ if (code >= HANGUL_FIRST && code <= HANGUL_LAST) return (code - HANGUL_FIRST) % 28;
148
+ if (last in DIGIT_JONG) return DIGIT_JONG[last];
149
+ return null;
150
+ }
151
+ function josaOf(word, pair) {
152
+ const [withFinal, withoutFinal] = pair.split("/");
153
+ const jong = finalConsonant(word);
154
+ if (jong === null) return `${withFinal}(${withoutFinal})`;
155
+ if (pair === "\uC73C\uB85C/\uB85C") return jong === 0 || jong === JONG_RIEUL ? withoutFinal : withFinal;
156
+ return jong === 0 ? withoutFinal : withFinal;
157
+ }
158
+ function josa(word, pair) {
159
+ return `${word}${josaOf(word, pair)}`;
160
+ }
161
+
162
+ // src/toast-store.ts
163
+ var TOAST_LIFETIME_MS = 4e3;
164
+ var TOAST_UNSHOWN_TTL_MS = 1e4;
165
+ var entries = [];
166
+ var hosts = [];
167
+ var nextToastId = 0;
168
+ var nextHostId = 0;
169
+ var version = { entries, hosts };
170
+ var listeners = /* @__PURE__ */ new Set();
171
+ function later(fn, ms) {
172
+ const timer = setTimeout(fn, ms);
173
+ timer.unref?.();
174
+ }
175
+ function emit() {
176
+ version = { entries, hosts };
177
+ listeners.forEach((fn) => fn());
178
+ }
179
+ function subscribeToasts(fn) {
180
+ listeners.add(fn);
181
+ return () => {
182
+ listeners.delete(fn);
183
+ };
184
+ }
185
+ function toastSnapshot() {
186
+ return version;
187
+ }
188
+ var EMPTY_SNAPSHOT = { entries: [], hosts: [] };
189
+ function toastServerSnapshot() {
190
+ return EMPTY_SNAPSHOT;
191
+ }
192
+ function allocateToastHostId() {
193
+ nextHostId += 1;
194
+ return nextHostId;
195
+ }
196
+ function registerToastHost(id, channel) {
197
+ hosts = [...hosts.filter((h) => h.id !== id), { id, channel }];
198
+ emit();
199
+ return () => {
200
+ hosts = hosts.filter((h) => h.id !== id);
201
+ emit();
202
+ };
203
+ }
204
+ function pushToastEntry(owner, channel, toast) {
205
+ nextToastId += 1;
206
+ const id = nextToastId;
207
+ entries = [...entries, { ...toast, id, owner, channel, shown: false }];
208
+ emit();
209
+ later(() => {
210
+ const entry = entries.find((e) => e.id === id);
211
+ if (entry && !entry.shown) closeToastEntry(id);
212
+ }, TOAST_UNSHOWN_TTL_MS);
213
+ return id;
214
+ }
215
+ function closeToastEntry(id) {
216
+ if (!entries.some((e) => e.id === id)) return;
217
+ entries = entries.filter((e) => e.id !== id);
218
+ emit();
219
+ }
220
+ function markToastsShown(ids) {
221
+ const fresh = ids.filter((id) => entries.some((e) => e.id === id && !e.shown));
222
+ if (fresh.length === 0) return;
223
+ entries = entries.map((e) => fresh.includes(e.id) ? { ...e, shown: true } : e);
224
+ for (const id of fresh) later(() => closeToastEntry(id), TOAST_LIFETIME_MS);
225
+ }
226
+ function toastHostOf(entry, liveHosts) {
227
+ if (liveHosts.some((h) => h.id === entry.owner)) return entry.owner;
228
+ for (let i = liveHosts.length - 1; i >= 0; i -= 1) {
229
+ if (liveHosts[i].channel === entry.channel) return liveHosts[i].id;
230
+ }
231
+ return void 0;
232
+ }
117
233
  function SpecErrorCard({ children }) {
118
234
  return /* @__PURE__ */ jsx(
119
235
  "div",
@@ -361,13 +477,85 @@ function cellRenderer(cell, key, dispatch) {
361
477
  return void 0;
362
478
  }
363
479
  }
480
+
481
+ // src/search/sort.ts
482
+ var SORT_PARAM = "sort";
483
+ var ORDER_PARAM = "order";
484
+ function sortFromParams(params) {
485
+ const key = params.get(SORT_PARAM);
486
+ if (!key) return null;
487
+ return { key, dir: params.get(ORDER_PARAM) === "desc" ? "desc" : "asc" };
488
+ }
489
+ function nextSort(current, key) {
490
+ if (!current || current.key !== key) return { key, dir: "asc" };
491
+ return current.dir === "asc" ? { key, dir: "desc" } : null;
492
+ }
493
+ function sortEchoOf(body) {
494
+ if (!body || typeof body !== "object") return null;
495
+ const sort = body.sort;
496
+ if (!sort || typeof sort !== "object") return null;
497
+ const { field, order } = sort;
498
+ if (typeof field !== "string" || !field) return null;
499
+ return { field, order: order === "desc" ? "desc" : "asc" };
500
+ }
501
+ var NONE = Object.freeze([]);
502
+ function endpointPath(endpoint) {
503
+ const cut = endpoint.indexOf("?");
504
+ return cut >= 0 ? endpoint.slice(0, cut) : endpoint;
505
+ }
506
+ function createServerSortMemory() {
507
+ const entries2 = /* @__PURE__ */ new Map();
508
+ const entry = (endpoint) => {
509
+ const path = endpointPath(endpoint);
510
+ let found = entries2.get(path);
511
+ if (!found) {
512
+ found = { supported: /* @__PURE__ */ new Set(), unsupported: [], all: false };
513
+ entries2.set(path, found);
514
+ }
515
+ return found;
516
+ };
517
+ const unsortable = (endpoint) => {
518
+ const e = entries2.get(endpointPath(endpoint));
519
+ if (!e) return NONE;
520
+ return e.all ? "all" : e.unsupported.length > 0 ? e.unsupported : NONE;
521
+ };
522
+ return {
523
+ record(endpoint, requested, echo) {
524
+ const e = entry(endpoint);
525
+ if (echo) {
526
+ e.supported.add(echo.field);
527
+ e.all = false;
528
+ if (echo.field === requested.key) return true;
529
+ }
530
+ if (e.supported.size === 0) e.all = true;
531
+ else if (!e.unsupported.includes(requested.key)) e.unsupported = [...e.unsupported, requested.key];
532
+ return false;
533
+ },
534
+ unsortable,
535
+ allows(endpoint, key) {
536
+ const verdict = unsortable(endpoint);
537
+ return verdict !== "all" && !verdict.includes(key);
538
+ }
539
+ };
540
+ }
541
+ var serverSortMemory = createServerSortMemory();
542
+ function withoutSort(params) {
543
+ const next = new URLSearchParams(params);
544
+ next.delete(SORT_PARAM);
545
+ next.delete(ORDER_PARAM);
546
+ return next;
547
+ }
364
548
  function searchUrl(endpoint, params) {
365
549
  const query = params.toString();
366
550
  return `${endpoint}${query ? endpoint.includes("?") ? "&" : "?" : ""}${query}`;
367
551
  }
368
552
  function toPage(body) {
369
553
  const rows = Array.isArray(body?.data) ? body.data : [];
370
- return { data: rows, total: typeof body?.total === "number" ? body.total : rows.length };
554
+ return {
555
+ data: rows,
556
+ total: typeof body?.total === "number" ? body.total : rows.length,
557
+ sort: sortEchoOf(body)
558
+ };
371
559
  }
372
560
  var defaultFetcher = async (endpoint, params, signal) => {
373
561
  const response = await fetch(searchUrl(endpoint, params), {
@@ -398,19 +586,19 @@ function SduiSearchDataProvider({
398
586
  return /* @__PURE__ */ jsx(SearchDataContext.Provider, { value: fetcher, children });
399
587
  }
400
588
  function createSearchInvalidator() {
401
- const listeners = /* @__PURE__ */ new Set();
589
+ const listeners2 = /* @__PURE__ */ new Set();
402
590
  return {
403
591
  invalidate(prefix = "", exact = false) {
404
- for (const entry of [...listeners]) {
592
+ for (const entry of [...listeners2]) {
405
593
  const hit = prefix === "" ? true : exact ? entry.endpoint === prefix : entry.endpoint.startsWith(prefix);
406
594
  if (hit) entry.reload();
407
595
  }
408
596
  },
409
597
  subscribe(endpoint, reload) {
410
598
  const entry = { endpoint, reload };
411
- listeners.add(entry);
599
+ listeners2.add(entry);
412
600
  return () => {
413
- listeners.delete(entry);
601
+ listeners2.delete(entry);
414
602
  };
415
603
  }
416
604
  };
@@ -436,6 +624,7 @@ function useRemoteSearchPage(endpoint, params) {
436
624
  const fetcher = useSearchFetcher();
437
625
  const invalidator = useContext(SearchInvalidationContext);
438
626
  const [page, setPage] = useState(EMPTY_PAGE);
627
+ const [pageQuery, setPageQuery] = useState(null);
439
628
  const [isLoading, setIsLoading] = useState(true);
440
629
  const [error, setError] = useState(null);
441
630
  const [reloadKey, setReloadKey] = useState(0);
@@ -454,6 +643,7 @@ function useRemoteSearchPage(endpoint, params) {
454
643
  (next) => {
455
644
  if (!alive) return;
456
645
  setPage(next);
646
+ setPageQuery(query);
457
647
  setError(null);
458
648
  setIsLoading(false);
459
649
  },
@@ -469,10 +659,87 @@ function useRemoteSearchPage(endpoint, params) {
469
659
  };
470
660
  }, [endpoint, query, reloadKey]);
471
661
  const reload = useCallback(() => setReloadKey((value) => value + 1), []);
472
- return { page, isLoading, error, reload };
662
+ return { page, pageQuery, isLoading, error, reload };
663
+ }
664
+ function useRemoteSearchSummary(endpoint, params) {
665
+ const adapter = useSduiAdapter();
666
+ const invalidator = useContext(SearchInvalidationContext);
667
+ const [values, setValues] = useState(null);
668
+ const [isLoading, setIsLoading] = useState(endpoint !== null);
669
+ const [error, setError] = useState(null);
670
+ const [reloadKey, setReloadKey] = useState(0);
671
+ useEffect(() => {
672
+ if (!invalidator || endpoint === null) return void 0;
673
+ return invalidator.subscribe(endpoint, () => setReloadKey((value) => value + 1));
674
+ }, [invalidator, endpoint]);
675
+ const query = params.toString();
676
+ const hostFetchRef = useRef(hostFetchOf(adapter));
677
+ hostFetchRef.current = hostFetchOf(adapter);
678
+ useEffect(() => {
679
+ if (endpoint === null) return void 0;
680
+ const controller = new AbortController();
681
+ let alive = true;
682
+ const url = searchUrl(endpoint, new URLSearchParams(query));
683
+ setIsLoading(true);
684
+ const hostFetch = hostFetchRef.current;
685
+ const request = hostFetch ? hostFetch(url, { method: "GET", signal: controller.signal }) : fetch(url, { headers: { accept: "application/json" }, credentials: "same-origin", signal: controller.signal }).then(
686
+ async (response) => {
687
+ const body = await response.json().catch(() => null);
688
+ if (!response.ok) throw new Error(body?.message ?? `\uD569\uACC4\uB97C \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4 (${response.status})`);
689
+ return body;
690
+ }
691
+ );
692
+ request.then(
693
+ (body) => {
694
+ if (!alive) return;
695
+ setValues(body && typeof body === "object" && !Array.isArray(body) ? body : null);
696
+ setError(null);
697
+ setIsLoading(false);
698
+ },
699
+ (cause) => {
700
+ if (!alive || controller.signal.aborted) return;
701
+ setError(cause instanceof Error ? cause : new Error("\uD569\uACC4\uB97C \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4"));
702
+ setIsLoading(false);
703
+ }
704
+ );
705
+ return () => {
706
+ alive = false;
707
+ controller.abort();
708
+ };
709
+ }, [endpoint, query, reloadKey]);
710
+ return { values, isLoading, error };
711
+ }
712
+ function summaryParams(params) {
713
+ const next = new URLSearchParams(params);
714
+ next.delete("page");
715
+ next.delete("limit");
716
+ next.delete("sort");
717
+ next.delete("order");
718
+ return next;
719
+ }
720
+ function plainValue(value) {
721
+ if (value === null || value === void 0 || value === "") return "\u2014";
722
+ if (typeof value === "number") return value.toLocaleString("ko-KR");
723
+ if (typeof value === "string" && /^-?\d+(\.\d+)?$/.test(value)) return Number(value).toLocaleString("ko-KR");
724
+ return String(value);
725
+ }
726
+ function SearchSummaryCards({ items, values, dispatch }) {
727
+ const list = Array.isArray(items) ? items.filter((item) => item && typeof item.key === "string") : [];
728
+ if (list.length === 0) return null;
729
+ const columns = Math.min(Math.max(list.length, 2), 4);
730
+ const row = values ?? {};
731
+ return /* @__PURE__ */ jsx("div", { className: `wp-grid wp-grid--${columns}`, "aria-label": "\uAC80\uC0C9 \uACB0\uACFC \uD569\uACC4", children: list.map((item) => {
732
+ const render = item.cell ? cellRenderer(item.cell, item.key, dispatch) : void 0;
733
+ const raw = values ? fieldOf(row, item.key) : void 0;
734
+ const value = values && render && raw !== void 0 && raw !== null ? render(row) : plainValue(raw);
735
+ return /* @__PURE__ */ jsx(StatCard, { size: "sm", label: item.label, icon: item.icon, value, unit: item.unit }, item.key);
736
+ }) });
473
737
  }
474
738
  var OVERSCAN = 10;
475
739
  var CLIP_CSS = `.wp-table--clip tbody td{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}`;
740
+ function isBlankCell(value) {
741
+ return value === null || value === void 0 || value === "";
742
+ }
476
743
  function ResultTable({
477
744
  columns,
478
745
  rows,
@@ -483,7 +750,8 @@ function ResultTable({
483
750
  empty,
484
751
  virtual = false,
485
752
  height = 560,
486
- rowHeight = 41
753
+ rowHeight = 41,
754
+ stackable = true
487
755
  }) {
488
756
  const scrollRef = useRef(null);
489
757
  const firstRowRef = useRef(null);
@@ -507,13 +775,14 @@ function ResultTable({
507
775
  const table = /* @__PURE__ */ jsxs(
508
776
  "table",
509
777
  {
510
- className: `wp-table wp-table--hover${virtual ? " wp-table--sticky wp-table--clip" : ""}`,
778
+ className: `wp-table wp-table--hover${virtual ? " wp-table--sticky wp-table--clip" : stackable ? " wp-table--stackable" : ""}`,
511
779
  style: virtual ? { tableLayout: "fixed" } : void 0,
512
780
  children: [
513
781
  /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { children: columns.map((column) => /* @__PURE__ */ jsx(
514
782
  "th",
515
783
  {
516
784
  style: { width: column.width, textAlign: column.align === "right" ? "right" : void 0 },
785
+ "data-mobile": column.mobile,
517
786
  children: column.sortable && onSort ? (
518
787
  // 인라인 style로는 :hover를 표현할 수 없다 — 색 피드백은 .wp-table__sort:hover가 낸다
519
788
  /* @__PURE__ */ jsxs("button", { type: "button", className: "wp-table__sort", onClick: () => onSort(column.key), children: [
@@ -547,10 +816,20 @@ function ResultTable({
547
816
  if (column.align === "right") cls.push("wp-table__num");
548
817
  if (column.mono) cls.push("wp-table__mono");
549
818
  if (column.strong) cls.push("wp-table__strong");
550
- return /* @__PURE__ */ jsx("td", { className: cls.join(" ") || void 0, children: column.render ? column.render(row, index) : (
819
+ const value = column.render ? column.render(row, index) : (
551
820
  // 평면 키 우선, 없으면 점 경로 — 셀 렌더러(cells.tsx)와 같은 조회 규칙이다
552
821
  fieldOf(row, column.key)
553
- ) }, column.key);
822
+ );
823
+ return /* @__PURE__ */ jsx(
824
+ "td",
825
+ {
826
+ className: cls.join(" ") || void 0,
827
+ "data-label": labelOf(column),
828
+ "data-mobile": column.mobile,
829
+ children: isBlankCell(value) ? /* @__PURE__ */ jsx("span", { style: { color: "var(--wp-text-subtle)" }, children: "\u2014" }) : value
830
+ },
831
+ column.key
832
+ );
554
833
  })
555
834
  },
556
835
  row.id ?? index
@@ -561,14 +840,33 @@ function ResultTable({
561
840
  ]
562
841
  }
563
842
  );
564
- if (!virtual) return table;
843
+ if (!virtual) {
844
+ return /* @__PURE__ */ jsx("div", { className: "wp-table-scroll", children: table });
845
+ }
565
846
  return /* @__PURE__ */ jsxs("div", { ref: scrollRef, onScroll: handleScroll, style: { height, overflowY: "auto" }, children: [
566
847
  /* @__PURE__ */ jsx(HoistedStyle, { id: "wp-table-clip", css: CLIP_CSS }),
567
848
  table
568
849
  ] });
569
850
  }
570
- function ResultEmptyState({ title }) {
571
- 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." });
851
+ function labelOf(column) {
852
+ if (typeof column.label === "string") return column.label;
853
+ return typeof column.header === "string" ? column.header : "";
854
+ }
855
+ function ResultEmptyState({ title, filtered = false, emptyTitle, emptyText, scoped = false, onReset }) {
856
+ const name = String(title ?? "").trim() || "\uD56D\uBAA9";
857
+ if (filtered) {
858
+ return /* @__PURE__ */ jsx(
859
+ EmptyState,
860
+ {
861
+ icon: "scan-search",
862
+ title: `\uC870\uAC74\uC5D0 \uB9DE\uB294 ${name}${josaOf(name, "\uC774/\uAC00")} \uC5C6\uC2B5\uB2C8\uB2E4`,
863
+ tight: true,
864
+ actions: onReset ? /* @__PURE__ */ jsx(Button, { size: "sm", variant: "secondary", icon: "refresh-cw", onClick: onReset, children: "\uC870\uAC74 \uCD08\uAE30\uD654" }) : void 0,
865
+ children: "\uAC80\uC0C9\uC5B4\uB098 \uC870\uAC74\uC744 \uBC14\uAFD4 \uBCF4\uC138\uC694."
866
+ }
867
+ );
868
+ }
869
+ return /* @__PURE__ */ jsx(EmptyState, { title: emptyTitle || `${scoped ? "" : "\uC544\uC9C1 "}${name}${josaOf(name, "\uC774/\uAC00")} \uC5C6\uC2B5\uB2C8\uB2E4`, tight: true, children: emptyText || void 0 });
572
870
  }
573
871
  var EMPTY_FILTERS = [];
574
872
  var EMPTY_FIELDS = [];
@@ -577,6 +875,29 @@ var DEFAULT_VIEWS = [
577
875
  { value: "list", icon: "list", label: "\uBAA9\uB85D" },
578
876
  { value: "grid", icon: "layout-grid", label: "\uCE74\uB4DC" }
579
877
  ];
878
+ var VIEW_STORAGE_PREFIX = "wp-sdui:view:";
879
+ var viewMemory = /* @__PURE__ */ new Map();
880
+ function readStoredView(title) {
881
+ const inMemory = viewMemory.get(title);
882
+ if (inMemory) return inMemory;
883
+ try {
884
+ if (typeof window === "undefined") return null;
885
+ const stored = window.sessionStorage.getItem(VIEW_STORAGE_PREFIX + title);
886
+ return stored === "grid" || stored === "list" ? stored : null;
887
+ } catch {
888
+ return null;
889
+ }
890
+ }
891
+ function writeStoredView(title, view) {
892
+ viewMemory.set(title, view);
893
+ try {
894
+ if (typeof window !== "undefined") window.sessionStorage.setItem(VIEW_STORAGE_PREFIX + title, view);
895
+ } catch {
896
+ }
897
+ }
898
+ function looksLikeLocator(value) {
899
+ return /^(https?:)?\/\//.test(value) || value.startsWith("/") || value.startsWith("data:");
900
+ }
580
901
  function normalizePageSizes(sizes) {
581
902
  if (!Array.isArray(sizes)) return void 0;
582
903
  const clean = [...new Set(sizes.map(Number).filter((n) => Number.isFinite(n) && n > 0).map(Math.floor))].sort(
@@ -606,6 +927,8 @@ function SearchResults({
606
927
  gridConfig,
607
928
  leftFilters = EMPTY_FILTERS,
608
929
  rightFilters = EMPTY_FILTERS,
930
+ tabs,
931
+ summary,
609
932
  initialView = "list",
610
933
  views,
611
934
  pageSizes,
@@ -623,9 +946,15 @@ function SearchResults({
623
946
  hideHeader = false,
624
947
  advancedSearchFields: rawAdvancedSearchFields = EMPTY_FIELDS,
625
948
  searchPlaceholder,
626
- virtual
949
+ virtual,
950
+ serverUnsortable,
951
+ primaryKey,
952
+ emptyTitle,
953
+ emptyText,
954
+ serverFiltered = false
627
955
  }) {
628
956
  const hasActions = actions.length > 0;
957
+ const isMobile = useIsMobile();
629
958
  const advancedSearchFields = useMemo(
630
959
  () => normalizeSearchFields(rawAdvancedSearchFields),
631
960
  [rawAdvancedSearchFields]
@@ -641,12 +970,18 @@ function SearchResults({
641
970
  );
642
971
  const applyViewConstraints = (candidate) => viewItems.some((v) => v.value === candidate) ? candidate : viewItems[0].value;
643
972
  const appliedSearch = searchParams.get("search") ?? "";
644
- const [view, setView] = useState(() => applyViewConstraints(initialView));
973
+ const [chosenView, setChosenView] = useState(() => readStoredView(title));
645
974
  const [search, setSearch] = useState(appliedSearch);
646
- const [sort, setSort] = useState(null);
647
975
  const [selected, setSelected] = useState(/* @__PURE__ */ new Set());
648
976
  const selectedRowsRef = useRef(/* @__PURE__ */ new Map());
649
- const activeView = applyViewConstraints(view);
977
+ const hasGridView = viewItems.some((v) => v.value === "grid");
978
+ const defaultView = isMobile && hasGridView ? "grid" : initialView;
979
+ const activeView = applyViewConstraints(chosenView ?? defaultView);
980
+ const chooseView = (next) => {
981
+ const view = applyViewConstraints(next);
982
+ writeStoredView(title, view);
983
+ setChosenView(view);
984
+ };
650
985
  useEffect(() => {
651
986
  setSearch(appliedSearch);
652
987
  }, [appliedSearch]);
@@ -663,29 +998,43 @@ function SearchResults({
663
998
  onParamsChange(next);
664
999
  };
665
1000
  const activeFilters = useMemo(() => {
666
- const entries = [];
1001
+ const entries2 = [];
667
1002
  for (const filter of [...leftFilters, ...rightFilters]) {
668
1003
  const values = splitMultiValue(searchParams.get(filter.key) ?? "");
669
- if (values.length > 0) entries.push({ key: filter.key, values });
1004
+ if (values.length > 0) entries2.push({ key: filter.key, values });
1005
+ }
1006
+ if (tabs && typeof tabs.key === "string") {
1007
+ const values = splitMultiValue(searchParams.get(tabs.key) ?? "");
1008
+ if (values.length > 0) entries2.push({ key: tabs.key, values });
670
1009
  }
671
- return entries;
672
- }, [leftFilters, rightFilters, searchParams]);
1010
+ return entries2;
1011
+ }, [leftFilters, rightFilters, tabs, searchParams]);
673
1012
  const hasAllRows = item.data.length >= item.total;
1013
+ const sortAllowed = (key) => hasAllRows || !(serverUnsortable === "all" || Array.isArray(serverUnsortable) && serverUnsortable.includes(key));
1014
+ const requestedSort = sortFromParams(searchParams);
1015
+ const sortKey = requestedSort && sortAllowed(requestedSort.key) ? requestedSort.key : null;
1016
+ const sortDir = requestedSort?.dir ?? "asc";
1017
+ const sort = useMemo(() => sortKey ? { key: sortKey, dir: sortDir } : null, [sortKey, sortDir]);
674
1018
  const processed = useMemo(() => {
675
1019
  const cell = (row, key) => fieldOf(row, key);
676
1020
  let rows = item.data;
677
- if (hasAllRows) {
1021
+ if (hasAllRows && !serverFiltered) {
1022
+ const source = item.data;
1023
+ const known = (key) => source.some((row) => cell(row, key) !== void 0);
678
1024
  if (appliedSearch) {
679
1025
  rows = rows.filter((row) => includesLooseSearch(localSearchText(row), appliedSearch));
680
1026
  }
681
1027
  for (const { key, values } of activeFilters) {
1028
+ if (!known(key)) continue;
682
1029
  rows = rows.filter((row) => values.includes(String(cell(row, key))));
683
1030
  }
684
1031
  for (const field of advancedSearchFields) {
685
1032
  if (field.type === "select") {
1033
+ if (!known(field.key)) continue;
686
1034
  const wanted = splitMultiValue(advancedValues[field.key] ?? "");
687
1035
  if (wanted.length > 0) rows = rows.filter((row) => wanted.includes(String(cell(row, field.key))));
688
1036
  } else if (field.type === "cascading") {
1037
+ if (!known(field.key)) continue;
689
1038
  const wanted = splitMultiValue(advancedValues[field.key] ?? "");
690
1039
  if (wanted.length > 0) {
691
1040
  const lineages = cascadingValuePaths(field.options);
@@ -696,24 +1045,29 @@ function SearchResults({
696
1045
  });
697
1046
  }
698
1047
  } else if (field.type === "dateRange") {
1048
+ if (!known(field.key)) continue;
699
1049
  const from = advancedValues[field.fromKey ?? `${field.key}From`];
700
1050
  const to = advancedValues[field.toKey ?? `${field.key}To`];
701
- const datePart = (v) => String(v ?? "").slice(0, 10);
1051
+ const datePart = (v) => isZonedIsoDateTime(v) ? formatDateValue(v, "date") : String(v ?? "").slice(0, 10);
702
1052
  if (from) rows = rows.filter((row) => datePart(cell(row, field.key)) >= from.slice(0, 10));
703
1053
  if (to) rows = rows.filter((row) => datePart(cell(row, field.key)) <= to.slice(0, 10));
704
1054
  } else if (field.type === "numberRange") {
1055
+ if (!known(field.key)) continue;
705
1056
  const min = numericValue(advancedValues[field.fromKey ?? `${field.key}Min`]);
706
1057
  const max = numericValue(advancedValues[field.toKey ?? `${field.key}Max`]);
707
1058
  if (min !== null) rows = rows.filter((row) => (numericValue(cell(row, field.key)) ?? -Infinity) >= min);
708
1059
  if (max !== null) rows = rows.filter((row) => (numericValue(cell(row, field.key)) ?? Infinity) <= max);
709
1060
  } else {
710
1061
  const keyword = advancedValues[field.key];
711
- const baseKey = field.key.endsWith("Contains") ? field.key.slice(0, -"Contains".length) : field.key;
712
- if (keyword) rows = rows.filter((row) => includesLooseSearch(String(cell(row, baseKey) ?? ""), keyword));
1062
+ const suffix = ["_contains", "Contains"].find((s) => field.key.endsWith(s));
1063
+ const baseKey = suffix ? field.key.slice(0, -suffix.length) : field.key;
1064
+ if (keyword && known(baseKey)) {
1065
+ rows = rows.filter((row) => includesLooseSearch(String(cell(row, baseKey) ?? ""), keyword));
1066
+ }
713
1067
  }
714
1068
  }
715
1069
  }
716
- if (sort) {
1070
+ if (sort && hasAllRows) {
717
1071
  const column = columns.find((c) => c.key === sort.key);
718
1072
  const dirFactor = sort.dir === "asc" ? 1 : -1;
719
1073
  rows = [...rows].sort((a, b) => {
@@ -728,7 +1082,7 @@ function SearchResults({
728
1082
  });
729
1083
  }
730
1084
  return rows;
731
- }, [item.data, hasAllRows, appliedSearch, activeFilters, advancedSearchFields, advancedValues, sort, columns]);
1085
+ }, [item.data, hasAllRows, serverFiltered, appliedSearch, activeFilters, advancedSearchFields, advancedValues, sort, columns]);
732
1086
  const clientPaginated = hasAllRows;
733
1087
  const totalCount = clientPaginated ? processed.length : item.total;
734
1088
  const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));
@@ -760,7 +1114,50 @@ function SearchResults({
760
1114
  });
761
1115
  };
762
1116
  const selectedItems = Array.from(selected).map((id) => selectedRowsRef.current.get(id)).filter((row) => row !== void 0);
1117
+ const paramsKey = searchParams.toString();
1118
+ const seenRef = useRef(null);
1119
+ const answersRef = useRef(/* @__PURE__ */ new Map());
1120
+ useEffect(() => {
1121
+ const settle = (key, data) => {
1122
+ const answers = answersRef.current;
1123
+ const nextIds = new Set(data.map((row) => row.id));
1124
+ const prev = answers.get(key);
1125
+ answers.delete(key);
1126
+ answers.set(key, nextIds);
1127
+ if (answers.size > 64) answers.delete(answers.keys().next().value);
1128
+ if (!prev) return;
1129
+ const vanished = [...prev].filter((id) => !nextIds.has(id));
1130
+ if (vanished.length === 0) return;
1131
+ vanished.forEach((id) => selectedRowsRef.current.delete(id));
1132
+ setSelected((current) => {
1133
+ let changed = false;
1134
+ const next = new Set(current);
1135
+ for (const id of vanished) if (next.delete(id)) changed = true;
1136
+ return changed ? next : current;
1137
+ });
1138
+ };
1139
+ const seen = seenRef.current;
1140
+ seenRef.current = { key: paramsKey, data: item.data };
1141
+ if (!seen) {
1142
+ settle(paramsKey, item.data);
1143
+ return;
1144
+ }
1145
+ if (seen.data === item.data) return;
1146
+ settle(paramsKey, item.data);
1147
+ }, [paramsKey, item.data]);
763
1148
  const ratioSum = columns.reduce((sum, c) => sum + (c.ratio ?? 0), 0);
1149
+ const titleColumnKey = columns.find((c) => c.mobile === "title")?.key ?? (primaryKey && columns.some((c) => c.key === primaryKey) ? primaryKey : void 0) ?? columns.find((c) => c.strong && c.mobile !== "hide")?.key;
1150
+ const rowLabelOf = (row) => {
1151
+ const keys = [primaryKey, titleColumnKey, ...columns.map((c) => c.key)].filter(
1152
+ (key) => typeof key === "string" && key.length > 0
1153
+ );
1154
+ for (const key of keys) {
1155
+ const value = fieldOf(row, key);
1156
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
1157
+ if (typeof value === "string" && value.trim() && !looksLikeLocator(value.trim())) return value.trim();
1158
+ }
1159
+ return "\uD589";
1160
+ };
764
1161
  const tableColumns = [
765
1162
  ...hasActions ? [
766
1163
  {
@@ -775,6 +1172,7 @@ function SearchResults({
775
1172
  }
776
1173
  ),
777
1174
  width: "36px",
1175
+ label: "",
778
1176
  render: (row) => (
779
1177
  // 셀 어디를 눌러도 행 클릭(onRowClick)으로 번지지 않게 래퍼에서 전파를 끊는다
780
1178
  /* @__PURE__ */ jsx("span", { onClick: (e) => e.stopPropagation(), children: /* @__PURE__ */ jsx(
@@ -782,7 +1180,7 @@ function SearchResults({
782
1180
  {
783
1181
  checked: selected.has(row.id),
784
1182
  onChange: (e) => toggleSelect(row, e.target.checked),
785
- "aria-label": `${row.id} \uC120\uD0DD`
1183
+ "aria-label": `${rowLabelOf(row)} \uC120\uD0DD`
786
1184
  }
787
1185
  ) })
788
1186
  )
@@ -791,7 +1189,9 @@ function SearchResults({
791
1189
  ...columns.map((column) => ({
792
1190
  key: column.key,
793
1191
  header: column.title,
794
- sortable: column.sortable,
1192
+ label: column.title,
1193
+ mobile: column.mobile ?? (column.key === titleColumnKey ? "title" : void 0),
1194
+ sortable: column.sortable && sortAllowed(column.key),
795
1195
  align: column.textAlign === "right" ? "right" : "left",
796
1196
  mono: column.mono,
797
1197
  strong: column.strong,
@@ -800,10 +1200,62 @@ function SearchResults({
800
1200
  }))
801
1201
  ];
802
1202
  const handleSort = (key) => {
803
- setSort((prev) => prev?.key === key ? prev.dir === "asc" ? { key, dir: "desc" } : null : { key, dir: "asc" });
1203
+ const next = nextSort(sort, key);
1204
+ updateParams({ [SORT_PARAM]: next?.key ?? null, [ORDER_PARAM]: next?.dir ?? null, page: null });
1205
+ };
1206
+ const sortChoices = columns.filter((column) => column.sortable && sortAllowed(column.key));
1207
+ const mobileSort = isMobile && sortChoices.length > 0 ? /* @__PURE__ */ jsx(
1208
+ Select,
1209
+ {
1210
+ size: "sm",
1211
+ "aria-label": `${title} \uC815\uB82C`,
1212
+ value: sort ? `${sort.key}:${sort.dir}` : "",
1213
+ options: [
1214
+ { value: "", label: "\uAE30\uBCF8 \uC815\uB82C" },
1215
+ ...sortChoices.flatMap((column) => [
1216
+ { value: `${column.key}:asc`, label: `${column.title} \uC624\uB984\uCC28\uC21C` },
1217
+ { value: `${column.key}:desc`, label: `${column.title} \uB0B4\uB9BC\uCC28\uC21C` }
1218
+ ])
1219
+ ],
1220
+ onChange: (e) => {
1221
+ const cut = e.target.value.lastIndexOf(":");
1222
+ const key = cut > 0 ? e.target.value.slice(0, cut) : "";
1223
+ const dir = e.target.value.slice(cut + 1) === "desc" ? "desc" : "asc";
1224
+ updateParams({ [SORT_PARAM]: key || null, [ORDER_PARAM]: key ? dir : null, page: null });
1225
+ }
1226
+ }
1227
+ ) : null;
1228
+ const chipKeys = [...leftFilters, ...rightFilters].map((filter) => filter.key);
1229
+ const filtered = Boolean(appliedSearch) || Object.values(advancedValues).some((value) => Boolean(value)) || chipKeys.some((key) => Boolean(searchParams.get(key)));
1230
+ const scoped = Boolean(tabs && typeof tabs.key === "string" && searchParams.get(tabs.key));
1231
+ const resetConditions = () => {
1232
+ const patch = { search: null, page: null };
1233
+ for (const key of searchFieldKeys(advancedSearchFields)) patch[key] = null;
1234
+ for (const key of chipKeys) patch[key] = null;
1235
+ updateParams(patch);
804
1236
  };
805
- const empty = /* @__PURE__ */ jsx(ResultEmptyState, { title });
1237
+ const empty = /* @__PURE__ */ jsx(
1238
+ ResultEmptyState,
1239
+ {
1240
+ title,
1241
+ filtered,
1242
+ scoped,
1243
+ emptyTitle,
1244
+ emptyText,
1245
+ onReset: resetConditions
1246
+ }
1247
+ );
806
1248
  return /* @__PURE__ */ jsxs("div", { className: ["wp-col", className].filter(Boolean).join(" "), style, "aria-label": title, children: [
1249
+ summary ?? null,
1250
+ tabs && Array.isArray(tabs.items) && tabs.items.length > 0 ? /* @__PURE__ */ jsx(
1251
+ Tabs,
1252
+ {
1253
+ "aria-label": `${title} \uAD6C\uBD84`,
1254
+ items: tabs.items.map((tab) => ({ value: tab.value ?? "", label: tab.label, icon: tab.icon })),
1255
+ value: searchParams.get(tabs.key) ?? "",
1256
+ onChange: (value) => updateParams({ [tabs.key]: value || null, page: null })
1257
+ }
1258
+ ) : null,
807
1259
  !hideHeader ? /* @__PURE__ */ jsxs("div", { className: "wp-row wp-row--between", style: { flexWrap: "wrap" }, children: [
808
1260
  /* @__PURE__ */ jsxs("span", { className: "wp-caption", children: [
809
1261
  "\uCD1D ",
@@ -832,9 +1284,10 @@ function SearchResults({
832
1284
  {
833
1285
  items: viewItems,
834
1286
  value: activeView,
835
- onChange: (next) => setView(applyViewConstraints(next))
1287
+ onChange: (next) => chooseView(next)
836
1288
  }
837
1289
  ) : null,
1290
+ mobileSort,
838
1291
  headerActions
839
1292
  ] })
840
1293
  ] }) : null,
@@ -915,7 +1368,7 @@ function SearchResults({
915
1368
  virtual: virtualListActive
916
1369
  }
917
1370
  ) : visibleItems.length === 0 ? empty : /* @__PURE__ */ jsx("div", { className: "wp-grid wp-grid--3", children: visibleItems.map((row) => /* @__PURE__ */ jsx(GridCard, { item: row, config: gridConfig, onClick: onRowClick }, row.id)) }),
918
- virtualListActive && clientPaginated ? null : /* @__PURE__ */ jsxs("div", { className: "wp-row wp-row--between", children: [
1371
+ virtualListActive && clientPaginated || totalCount === 0 && !isLoading ? null : /* @__PURE__ */ jsxs("div", { className: "wp-row wp-row--between", children: [
919
1372
  /* @__PURE__ */ jsxs("div", { className: "wp-row", style: { flex: "none" }, children: [
920
1373
  /* @__PURE__ */ jsx("span", { className: "wp-caption", style: { whiteSpace: "nowrap" }, children: "\uD398\uC774\uC9C0\uB2F9" }),
921
1374
  /* @__PURE__ */ jsx(
@@ -957,6 +1410,9 @@ function formatCell(value) {
957
1410
  if (value instanceof Date) return formatDateCellValue(value);
958
1411
  return value;
959
1412
  }
1413
+ function blankAsDash(value) {
1414
+ return value === null || value === void 0 || value === "" ? /* @__PURE__ */ jsx("span", { style: { color: "var(--wp-text-subtle)" }, children: "\u2014" }) : value;
1415
+ }
960
1416
  function resolveRowPart(part, item) {
961
1417
  return typeof part === "function" ? part(item) : part;
962
1418
  }
@@ -972,7 +1428,7 @@ function GridCard({
972
1428
  return /* @__PURE__ */ jsxs("div", { className: "wp-row", children: [
973
1429
  resolveRowPart(row.icon, item),
974
1430
  row.label != null ? /* @__PURE__ */ jsx("span", { className: "wp-caption", children: resolveRowPart(row.label, item) }) : null,
975
- /* @__PURE__ */ jsx("span", { className: "wp-body-sm", children: resolveRowPart(row.value, item) })
1431
+ /* @__PURE__ */ jsx("span", { className: "wp-body-sm", children: blankAsDash(resolveRowPart(row.value, item)) })
976
1432
  ] }, idx);
977
1433
  }) });
978
1434
  const note = config.note?.(item);
@@ -1041,6 +1497,22 @@ function FilterMultiSelect({
1041
1497
  function toList(action) {
1042
1498
  return Array.isArray(action) ? action : [action];
1043
1499
  }
1500
+ function conditionParams(params) {
1501
+ const next = new URLSearchParams(params);
1502
+ next.delete("page");
1503
+ next.delete("limit");
1504
+ return next.toString();
1505
+ }
1506
+ function useQueryStateMirror(dispatch, key, params) {
1507
+ const value = conditionParams(params);
1508
+ const lastRef = useRef(null);
1509
+ useEffect(() => {
1510
+ if (typeof key !== "string" || key === "") return;
1511
+ if (lastRef.current === value) return;
1512
+ lastRef.current = value;
1513
+ dispatch({ action: "setState", key, value });
1514
+ }, [dispatch, key, value]);
1515
+ }
1044
1516
  function mapColumns(columns, dispatch) {
1045
1517
  return (columns ?? []).map((column) => ({
1046
1518
  key: column.key,
@@ -1050,6 +1522,7 @@ function mapColumns(columns, dispatch) {
1050
1522
  ratio: column.ratio,
1051
1523
  mono: column.mono,
1052
1524
  strong: column.strong,
1525
+ mobile: column.mobile === "title" || column.mobile === "hide" ? column.mobile : void 0,
1053
1526
  render: cellRenderer(column.cell, column.key, dispatch)
1054
1527
  }));
1055
1528
  }
@@ -1140,6 +1613,7 @@ function SduiSearchResultsImpl({
1140
1613
  fields,
1141
1614
  leftFilters,
1142
1615
  rightFilters,
1616
+ tabs,
1143
1617
  gridCard,
1144
1618
  actions,
1145
1619
  initialView,
@@ -1150,11 +1624,15 @@ function SduiSearchResultsImpl({
1150
1624
  searchPlaceholder,
1151
1625
  rowClick,
1152
1626
  headerActions,
1153
- virtual
1627
+ virtual,
1628
+ emptyTitle,
1629
+ emptyText,
1630
+ queryStateKey
1154
1631
  }) {
1155
1632
  const dispatch = useSduiDispatch();
1156
1633
  const [params, setParams] = useState(() => new URLSearchParams());
1157
1634
  const rows = Array.isArray(data) ? data : [];
1635
+ useQueryStateMirror(dispatch, queryStateKey, params);
1158
1636
  return /* @__PURE__ */ jsx(
1159
1637
  SearchResults,
1160
1638
  {
@@ -1164,6 +1642,7 @@ function SduiSearchResultsImpl({
1164
1642
  gridConfig: mapGridCard(gridCard, dispatch),
1165
1643
  leftFilters,
1166
1644
  rightFilters,
1645
+ tabs,
1167
1646
  actions: mapActions(actions, dispatch),
1168
1647
  initialView,
1169
1648
  views: mapViews(views, gridCard, disableGrid, disableList),
@@ -1174,6 +1653,9 @@ function SduiSearchResultsImpl({
1174
1653
  searchPlaceholder,
1175
1654
  headerActions,
1176
1655
  virtual,
1656
+ primaryKey: gridCard?.titleKey,
1657
+ emptyTitle,
1658
+ emptyText,
1177
1659
  onRowClick: rowClick ? (row) => toList(rowClick).forEach((a) => dispatch(a, row)) : void 0,
1178
1660
  searchParams: params,
1179
1661
  onParamsChange: setParams
@@ -1187,6 +1669,7 @@ function RemoteSearchResultsImpl({
1187
1669
  fields,
1188
1670
  leftFilters,
1189
1671
  rightFilters,
1672
+ tabs,
1190
1673
  gridCard,
1191
1674
  actions,
1192
1675
  initialView,
@@ -1200,7 +1683,11 @@ function RemoteSearchResultsImpl({
1200
1683
  virtual,
1201
1684
  pageSize,
1202
1685
  searchParams: controlledParams,
1203
- onParamsChange
1686
+ onParamsChange,
1687
+ summary,
1688
+ emptyTitle,
1689
+ emptyText,
1690
+ queryStateKey
1204
1691
  }) {
1205
1692
  const dispatch = useSduiDispatch();
1206
1693
  const defaultPageSize = normalizePageSizes(pageSizes)?.[0] ?? 10;
@@ -1220,7 +1707,22 @@ function RemoteSearchResultsImpl({
1220
1707
  if (!next.has("limit")) next.set("limit", String(defaultPageSize));
1221
1708
  return next;
1222
1709
  }, [params, defaultPageSize]);
1223
- const { page, isLoading, error, reload } = useRemoteSearchPage(endpoint, query);
1710
+ const { page, pageQuery, isLoading, error, reload } = useRemoteSearchPage(endpoint, query);
1711
+ const requestedSort = sortFromParams(params);
1712
+ const queryString = query.toString();
1713
+ const settledForQuery = pageQuery === queryString;
1714
+ useEffect(() => {
1715
+ if (!requestedSort || !settledForQuery || page.data.length >= page.total) return;
1716
+ if (!serverSortMemory.record(endpoint, requestedSort, page.sort ?? null)) {
1717
+ handleParamsChange(withoutSort(params));
1718
+ }
1719
+ }, [page, settledForQuery, endpoint, requestedSort?.key, requestedSort?.dir]);
1720
+ const summaryQuery = useMemo(() => summaryParams(params), [params]);
1721
+ useQueryStateMirror(dispatch, queryStateKey, params);
1722
+ const summaryState = useRemoteSearchSummary(
1723
+ summary && typeof summary.endpoint === "string" ? summary.endpoint : null,
1724
+ summaryQuery
1725
+ );
1224
1726
  if (error && page.data.length === 0) {
1225
1727
  return /* @__PURE__ */ jsx(
1226
1728
  EmptyState,
@@ -1241,6 +1743,7 @@ function RemoteSearchResultsImpl({
1241
1743
  gridConfig: mapGridCard(gridCard, dispatch),
1242
1744
  leftFilters,
1243
1745
  rightFilters,
1746
+ tabs,
1244
1747
  actions: mapActions(actions, dispatch),
1245
1748
  initialView,
1246
1749
  views: mapViews(views, gridCard, disableGrid, disableList),
@@ -1252,6 +1755,12 @@ function RemoteSearchResultsImpl({
1252
1755
  headerActions,
1253
1756
  virtual,
1254
1757
  isLoading,
1758
+ serverUnsortable: serverSortMemory.unsortable(endpoint),
1759
+ serverFiltered: true,
1760
+ primaryKey: gridCard?.titleKey,
1761
+ emptyTitle,
1762
+ emptyText,
1763
+ summary: summary ? /* @__PURE__ */ jsx(SearchSummaryCards, { items: summary.items, values: summaryState.values, dispatch }) : void 0,
1255
1764
  onRowClick: rowClick ? (row) => toList(rowClick).forEach((a) => dispatch(a, row)) : void 0,
1256
1765
  searchParams: params,
1257
1766
  onParamsChange: handleParamsChange
@@ -1260,12 +1769,6 @@ function RemoteSearchResultsImpl({
1260
1769
  }
1261
1770
  var SduiSearchResults = boxed(SduiSearchResultsImpl, "SduiSearchResults");
1262
1771
  var RemoteSearchResults = boxed(RemoteSearchResultsImpl, "RemoteSearchResults");
1263
- var SduiRuntimeContext = createContext(null);
1264
- function useSduiRuntime() {
1265
- const runtime = useContext(SduiRuntimeContext);
1266
- if (!runtime) throw new Error("useSduiRuntime\uC740 SduiRenderer \uB0B4\uBD80\uC5D0\uC11C\uB9CC \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.");
1267
- return runtime;
1268
- }
1269
1772
  function prefillParams(search, hash) {
1270
1773
  const params = new URLSearchParams(search);
1271
1774
  const q = hash.indexOf("?");
@@ -1302,21 +1805,21 @@ function SduiForm({
1302
1805
  return JSON.stringify(keys.map((key) => [key, state[key]]));
1303
1806
  };
1304
1807
  useEffect(() => {
1305
- const entries = {};
1808
+ const entries2 = {};
1306
1809
  for (const [field, value] of Object.entries(initial ?? {})) {
1307
- if (runtime.state[`${id}.${field}`] === void 0) entries[`${id}.${field}`] = value;
1810
+ if (runtime.state[`${id}.${field}`] === void 0) entries2[`${id}.${field}`] = value;
1308
1811
  }
1309
1812
  if (initialFromUrl && typeof window !== "undefined") {
1310
1813
  const params = prefillParams(window.location.search, window.location.hash);
1311
1814
  for (const [field, param] of Object.entries(initialFromUrl)) {
1312
1815
  const raw = params.get(param);
1313
1816
  if (raw !== null && runtime.state[`${id}.${field}`] === void 0) {
1314
- entries[`${id}.${field}`] = raw;
1817
+ entries2[`${id}.${field}`] = raw;
1315
1818
  }
1316
1819
  }
1317
1820
  }
1318
- if (Object.keys(entries).length > 0) runtime.setEntries(entries);
1319
- initialSnapshot.current = sliceOf({ ...runtime.state, ...entries });
1821
+ if (Object.keys(entries2).length > 0) runtime.setEntries(entries2);
1822
+ initialSnapshot.current = sliceOf({ ...runtime.state, ...entries2 });
1320
1823
  }, [id]);
1321
1824
  useEffect(() => {
1322
1825
  return () => {
@@ -1337,6 +1840,25 @@ function SduiForm({
1337
1840
  }
1338
1841
  }, [slice, id, runtime]);
1339
1842
  const dirty = Boolean(runtime.state[`${id}.$dirty`]);
1843
+ const latestState = useRef(runtime.state);
1844
+ latestState.current = runtime.state;
1845
+ const dirtyRegistry = useContext(SduiDirtyRegistryContext);
1846
+ useEffect(
1847
+ () => dirtyRegistry?.register(
1848
+ () => initialSnapshot.current !== null && sliceOf(latestState.current) !== initialSnapshot.current
1849
+ ),
1850
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1851
+ [dirtyRegistry, id]
1852
+ );
1853
+ const leaveRegistry = useContext(SduiLeaveRegistryContext);
1854
+ useEffect(() => {
1855
+ if (!guard || !leaveRegistry) return;
1856
+ return leaveRegistry.register(
1857
+ id,
1858
+ () => initialSnapshot.current !== null && sliceOf(latestState.current) !== initialSnapshot.current
1859
+ );
1860
+ }, [guard, leaveRegistry, id]);
1861
+ const scope = useMemo(() => ({ id, rules: JSON.parse(rulesJson) }), [id, rulesJson]);
1340
1862
  useEffect(() => {
1341
1863
  if (!guard || !dirty) return;
1342
1864
  const onBeforeUnload = (event) => {
@@ -1351,12 +1873,13 @@ function SduiForm({
1351
1873
  {
1352
1874
  className: ["wp-col", className].filter(Boolean).join(" "),
1353
1875
  style,
1876
+ "data-sdui-form": id,
1354
1877
  onSubmit: (event) => {
1355
1878
  event.preventDefault();
1356
1879
  onSubmit?.(event);
1357
1880
  },
1358
1881
  noValidate: true,
1359
- children
1882
+ children: /* @__PURE__ */ jsx(SduiFormScopeContext.Provider, { value: scope, children })
1360
1883
  }
1361
1884
  );
1362
1885
  }
@@ -1698,6 +2221,9 @@ function withProps(Component, defaults) {
1698
2221
  function deferChainEntry(container, key) {
1699
2222
  return typeof container.action === "string" && (key === "onSuccess" || key === "onError");
1700
2223
  }
2224
+ function deferEachTarget(container, key) {
2225
+ return container.action === "request" && container.each !== void 0 && (key === "endpoint" || key === "body");
2226
+ }
1701
2227
  function failureDetailOf(error) {
1702
2228
  const message = error instanceof Error ? error.message : "\uC694\uCCAD\uC774 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4";
1703
2229
  const carrier = error;
@@ -1717,12 +2243,14 @@ function runSuccessChain(kind, id, run) {
1717
2243
  );
1718
2244
  }
1719
2245
  }
2246
+ var EMPTY_ENDPOINT_MESSAGE = "request \uC561\uC158\uC758 endpoint\uAC00 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4 \u2014 $state \uCC38\uC870\uC5D0 \uC544\uC9C1 \uAC12\uC774 \uC5C6\uB294\uC9C0 \uD655\uC778\uD558\uC138\uC694";
1720
2247
  async function runRequestAction(action, deps) {
2248
+ if (action.each !== void 0) return runRequestEach(action, deps);
1721
2249
  const pendingKey = typeof action.pendingKey === "string" && action.pendingKey !== "" ? action.pendingKey : null;
1722
2250
  let pendingOn = false;
1723
2251
  try {
1724
2252
  if (typeof action.endpoint !== "string" || action.endpoint === "") {
1725
- throw new Error("request \uC561\uC158\uC758 endpoint\uAC00 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4 \u2014 $state \uCC38\uC870\uC5D0 \uC544\uC9C1 \uAC12\uC774 \uC5C6\uB294\uC9C0 \uD655\uC778\uD558\uC138\uC694");
2253
+ throw new Error(EMPTY_ENDPOINT_MESSAGE);
1726
2254
  }
1727
2255
  if (pendingKey) {
1728
2256
  pendingOn = true;
@@ -1756,6 +2284,91 @@ async function runRequestAction(action, deps) {
1756
2284
  if (pendingKey && pendingOn) deps.dispatch({ action: "setState", key: pendingKey, value: false });
1757
2285
  }
1758
2286
  }
2287
+ var REQUEST_EACH_CONCURRENCY = 3;
2288
+ async function mapWithLimit(items, limit, fn) {
2289
+ const results = new Array(items.length);
2290
+ let next = 0;
2291
+ const worker = async () => {
2292
+ while (next < items.length) {
2293
+ const index = next;
2294
+ next += 1;
2295
+ results[index] = await fn(items[index], index);
2296
+ }
2297
+ };
2298
+ const width = Math.max(1, Math.min(limit, items.length));
2299
+ await Promise.all(Array.from({ length: width }, () => worker()));
2300
+ return results;
2301
+ }
2302
+ function eachFailureMessage(total, failed, first) {
2303
+ const head = `${total}\uAC74 \uC911 ${failed}\uAC74 \uC2E4\uD328`;
2304
+ return first ? `${head} \u2014 ${first}` : head;
2305
+ }
2306
+ async function runRequestEach(action, deps) {
2307
+ const list = action.each;
2308
+ if (!Array.isArray(list) || list.length === 0) {
2309
+ warnOnce(
2310
+ "request:each:empty",
2311
+ "[sdui] request \uC561\uC158\uC758 each \uAC00 \uBC30\uC5F4\uC774 \uC544\uB2C8\uAC70\uB098 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4 \u2014 \uC694\uCCAD\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4 (\uBC84\uD2BC\uC744 \uC120\uD0DD \uC218\uB85C \uC7A0\uADF8\uC138\uC694)",
2312
+ "warn"
2313
+ );
2314
+ return;
2315
+ }
2316
+ const pendingKey = typeof action.pendingKey === "string" && action.pendingKey !== "" ? action.pendingKey : null;
2317
+ const resolveFor = deps.resolveFor ?? ((value) => value);
2318
+ const method = action.method ?? "POST";
2319
+ if (pendingKey) deps.dispatch({ action: "setState", key: pendingKey, value: true });
2320
+ try {
2321
+ const outcomes = await mapWithLimit(list, REQUEST_EACH_CONCURRENCY, async (item, index) => {
2322
+ try {
2323
+ const endpoint = resolveFor(action.endpoint, item, index);
2324
+ if (typeof endpoint !== "string" || endpoint === "") throw new Error(EMPTY_ENDPOINT_MESSAGE);
2325
+ const body = action.body === void 0 ? void 0 : resolveFor(action.body, item, index);
2326
+ const value = await deps.fetch(endpoint, { method, ...body === void 0 ? {} : { body } });
2327
+ return { ok: true, value };
2328
+ } catch (error) {
2329
+ return { ok: false, error };
2330
+ }
2331
+ });
2332
+ const errors = [];
2333
+ outcomes.forEach((outcome, index) => {
2334
+ if (!outcome.ok) errors.push({ index, item: list[index], ...failureDetailOf(outcome.error) });
2335
+ });
2336
+ const succeeded = outcomes.length - errors.length;
2337
+ const invalidate = typeof action.invalidate === "string" && action.invalidate !== "" ? action.invalidate : null;
2338
+ if (errors.length === 0) {
2339
+ runSuccessChain("request", `each:${list.length}`, () => {
2340
+ if (invalidate) deps.dispatch({ action: "invalidate", prefix: invalidate });
2341
+ const responses = outcomes.map((outcome) => outcome.ok ? outcome.value : null);
2342
+ (action.onSuccess ?? []).forEach((a) => deps.dispatch(a, responses));
2343
+ });
2344
+ return;
2345
+ }
2346
+ if (succeeded > 0 && invalidate) {
2347
+ runSuccessChain("request", `each:${list.length}`, () => deps.dispatch({ action: "invalidate", prefix: invalidate }));
2348
+ }
2349
+ const failure = {
2350
+ failed: errors.length,
2351
+ succeeded,
2352
+ total: list.length,
2353
+ message: eachFailureMessage(list.length, errors.length, errors[0]?.message),
2354
+ errors
2355
+ };
2356
+ if (typeof action.errorKey === "string" && action.errorKey !== "") {
2357
+ deps.dispatch({ action: "setState", key: action.errorKey, value: failure });
2358
+ }
2359
+ if (action.onError?.length) {
2360
+ action.onError.forEach((a) => deps.dispatch(a, failure));
2361
+ } else {
2362
+ deps.toast({
2363
+ tone: "danger",
2364
+ title: `${list.length}\uAC74 \uC911 ${errors.length}\uAC74 \uC2E4\uD328`,
2365
+ message: errors[0]?.message
2366
+ });
2367
+ }
2368
+ } finally {
2369
+ if (pendingKey) deps.dispatch({ action: "setState", key: pendingKey, value: false });
2370
+ }
2371
+ }
1759
2372
 
1760
2373
  // src/state-path.ts
1761
2374
  var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
@@ -2192,6 +2805,33 @@ function nearestArgsFrame(scope) {
2192
2805
  }
2193
2806
  return void 0;
2194
2807
  }
2808
+ function toastText(value) {
2809
+ if (typeof value === "string") return value;
2810
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
2811
+ if (isPlainObject(value) && typeof value.message === "string") return value.message;
2812
+ return void 0;
2813
+ }
2814
+ var DEFAULT_LEAVE_MESSAGE = "\uC800\uC7A5\uD558\uC9C0 \uC54A\uC740 \uBCC0\uACBD \uC0AC\uD56D\uC774 \uC788\uC2B5\uB2C8\uB2E4. \uC774 \uD654\uBA74\uC744 \uB5A0\uB098\uBA74 \uC0AC\uB77C\uC9D1\uB2C8\uB2E4.";
2815
+ function leaveMessageOf(value) {
2816
+ if (typeof value === "string") return value.trim() === "" ? null : value;
2817
+ return value ? DEFAULT_LEAVE_MESSAGE : null;
2818
+ }
2819
+ function titleText(value) {
2820
+ if (typeof value === "string") return value.trim() === "" ? null : value;
2821
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
2822
+ return null;
2823
+ }
2824
+ function collectFormValues(state, formId) {
2825
+ const prefix = `${formId}.`;
2826
+ const values = {};
2827
+ for (const [key, value] of Object.entries(state)) {
2828
+ if (key.startsWith(prefix) && !key.slice(prefix.length).startsWith("$") && value !== void 0) {
2829
+ values[key.slice(prefix.length)] = value;
2830
+ }
2831
+ }
2832
+ return values;
2833
+ }
2834
+ var TOAST_MODAL_CSS = ".wp-toast-region.wp-toast-region--modal{top:16px;bottom:auto}@media screen and (max-width:767.98px){.wp-toast-region.wp-toast-region--modal{top:calc(12px + env(safe-area-inset-top));left:12px;right:12px}}";
2195
2835
  function hasExplicitPageHead(body) {
2196
2836
  return Array.isArray(body) && body.some((child) => isPlainObject(child) && child.component === "PageHead");
2197
2837
  }
@@ -2269,45 +2909,76 @@ function toIndex(v) {
2269
2909
  function inRange(index, length) {
2270
2910
  return index !== null && index >= 0 && index < length;
2271
2911
  }
2272
- function validateForm(values, config, customValidators) {
2273
- const errors = {};
2274
- if (!config) return errors;
2912
+ var CHOICE_KINDS = /* @__PURE__ */ new Set([
2913
+ "Select",
2914
+ "AutocompleteField",
2915
+ "PositionSelect",
2916
+ "TagMultiSelect",
2917
+ "TreePicker",
2918
+ "SearchableMultiPicker",
2919
+ "RoleMemberPicker",
2920
+ "SegmentedControl",
2921
+ "Radio",
2922
+ "DatePicker",
2923
+ "DateRangePicker",
2924
+ "TimePicker"
2925
+ ]);
2926
+ function requiredVerb(kind) {
2927
+ if (kind && CHOICE_KINDS.has(kind)) return "\uC120\uD0DD\uD558\uC138\uC694";
2928
+ if (kind === "FileDropzone") return "\uCCA8\uBD80\uD558\uC138\uC694";
2929
+ if (kind === "Checkbox" || kind === "Switch") return "\uD655\uC778\uD558\uC138\uC694";
2930
+ return "\uC785\uB825\uD558\uC138\uC694";
2931
+ }
2932
+ function validateForm(values, config, customValidators, fieldInfo) {
2933
+ const out = { errors: {}, failed: {} };
2934
+ if (!config) return out;
2935
+ const labelOf2 = (field) => config.labels[field] ?? fieldInfo?.get(field)?.label;
2275
2936
  for (const [field, rules] of Object.entries(config.rules)) {
2276
2937
  const value = values[field];
2277
- const label = config.labels[field] ?? field;
2938
+ const context = {
2939
+ label: labelOf2(field),
2940
+ kind: fieldInfo?.get(field)?.kind,
2941
+ labelOf: labelOf2
2942
+ };
2278
2943
  for (const rule of rules) {
2279
- const message = ruleError(rule, value, values, label, customValidators);
2944
+ const message = ruleError(rule, value, values, context, customValidators);
2280
2945
  if (message) {
2281
- errors[field] = message;
2946
+ out.errors[field] = message;
2947
+ out.failed[field] = rule.rule;
2282
2948
  break;
2283
2949
  }
2284
2950
  }
2285
2951
  }
2286
- return errors;
2952
+ return out;
2287
2953
  }
2288
- function ruleError(rule, value, values, label, customValidators) {
2954
+ function ruleError(rule, value, values, { label, kind, labelOf: labelOf2 }, customValidators) {
2289
2955
  const empty = value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0 || // DateRangePicker의 {} 같은 "값 없는 객체"도 미입력으로 본다
2290
2956
  typeof value === "object" && !Array.isArray(value) && Object.values(value).every((v) => v === void 0 || v === null || v === "");
2957
+ const subject = (pair) => label ? `${josa(label, pair)} ` : "";
2291
2958
  switch (rule.rule) {
2292
2959
  case "required":
2293
- return empty || value === false ? rule.message ?? `${label}\uC744(\uB97C) \uC785\uB825\uD558\uC138\uC694` : null;
2960
+ return empty || value === false ? rule.message ?? (label ? `${josa(label, "\uC744/\uB97C")} ${requiredVerb(kind)}` : "\uD544\uC218 \uD56D\uBAA9\uC785\uB2C8\uB2E4") : null;
2294
2961
  case "min":
2295
- return !empty && toNumber(value) < rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value} \uC774\uC0C1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` : null;
2962
+ return !empty && toNumber(value) < rule.value ? rule.message ?? `${subject("\uC740/\uB294")}${rule.value} \uC774\uC0C1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` : null;
2296
2963
  case "max":
2297
- return !empty && toNumber(value) > rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value} \uC774\uD558\uC5EC\uC57C \uD569\uB2C8\uB2E4` : null;
2964
+ return !empty && toNumber(value) > rule.value ? rule.message ?? `${subject("\uC740/\uB294")}${rule.value} \uC774\uD558\uC5EC\uC57C \uD569\uB2C8\uB2E4` : null;
2298
2965
  case "minLength":
2299
- return !empty && String(value).length < rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value}\uC790 \uC774\uC0C1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` : null;
2966
+ return !empty && String(value).length < rule.value ? rule.message ?? `${subject("\uC740/\uB294")}${rule.value}\uC790 \uC774\uC0C1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` : null;
2300
2967
  case "maxLength":
2301
- return !empty && String(value).length > rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value}\uC790 \uC774\uD558\uC5EC\uC57C \uD569\uB2C8\uB2E4` : null;
2968
+ return !empty && String(value).length > rule.value ? rule.message ?? `${subject("\uC740/\uB294")}${rule.value}\uC790 \uC774\uD558\uC5EC\uC57C \uD569\uB2C8\uB2E4` : null;
2302
2969
  case "pattern":
2303
- return !empty && !new RegExp(rule.value).test(String(value)) ? rule.message ?? `${label} \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4` : null;
2970
+ return !empty && !new RegExp(rule.value).test(String(value)) ? rule.message ?? (label ? `${label} \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4` : "\uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4") : null;
2304
2971
  case "notBeforeField": {
2305
2972
  const other = values[rule.field];
2306
2973
  if (empty || other === void 0 || other === null || other === "") return null;
2307
2974
  const a = Number(String(value).replace(/,/g, ""));
2308
2975
  const b = Number(String(other).replace(/,/g, ""));
2309
2976
  const before = Number.isFinite(a) && Number.isFinite(b) && String(value).trim() !== "" ? a < b : String(value) < String(other);
2310
- return before ? rule.message ?? `${label}\uC774(\uAC00) \uC21C\uC11C\uC5D0 \uB9DE\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4` : null;
2977
+ if (!before) return null;
2978
+ if (rule.message) return rule.message;
2979
+ const otherLabel = labelOf2(rule.field);
2980
+ if (label && otherLabel) return `${josa(label, "\uC740/\uB294")} ${otherLabel}\uBCF4\uB2E4 \uC55E\uC124 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4`;
2981
+ return `${subject("\uC774/\uAC00")}\uC21C\uC11C\uC5D0 \uB9DE\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4`;
2311
2982
  }
2312
2983
  case "custom": {
2313
2984
  const validator = customValidators?.[rule.name];
@@ -2320,6 +2991,122 @@ function ruleError(rule, value, values, label, customValidators) {
2320
2991
  }
2321
2992
  }
2322
2993
  }
2994
+ function invalidSummary(result, labelOf2, order = []) {
2995
+ const rank = (field) => {
2996
+ const index = order.indexOf(field);
2997
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index;
2998
+ };
2999
+ const fields = Object.keys(result.errors).sort((a, b) => rank(a) - rank(b));
3000
+ const count = fields.length;
3001
+ const allRequired = fields.every((field) => result.failed[field] === "required");
3002
+ const labels = fields.map(labelOf2).filter((l) => typeof l === "string" && l !== "");
3003
+ if (count === 1 && labels.length === 1) return { title: result.errors[fields[0]] };
3004
+ const title = allRequired ? `\uD544\uC218 \uD56D\uBAA9 ${count}\uAC1C\uB97C \uCC44\uC6CC \uC8FC\uC138\uC694` : `\uC785\uB825\uAC12 ${count}\uAC1C\uB97C \uD655\uC778\uD574 \uC8FC\uC138\uC694`;
3005
+ if (labels.length === 0) return { title, message: count === 1 ? void 0 : result.errors[fields[0]] };
3006
+ const shown = labels.slice(0, 4).join(", ");
3007
+ const rest = labels.length - 4;
3008
+ return { title, message: rest > 0 ? `${shown} \uC678 ${rest}\uAC1C` : shown };
3009
+ }
3010
+ var FOCUSABLE_IN_FIELD = 'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])';
3011
+ function formElementOf(formId) {
3012
+ if (typeof document === "undefined") return void 0;
3013
+ return Array.from(document.querySelectorAll("form[data-sdui-form]")).find(
3014
+ (el) => el.getAttribute("data-sdui-form") === formId && el.isConnected
3015
+ );
3016
+ }
3017
+ function fieldOrderOf(formId) {
3018
+ const form = formElementOf(formId);
3019
+ if (!form) return [];
3020
+ return Array.from(form.querySelectorAll("[data-sdui-field]")).map(
3021
+ (el) => el.getAttribute("data-sdui-field") ?? ""
3022
+ );
3023
+ }
3024
+ function focusFirstInvalid(formId, fields) {
3025
+ const form = formElementOf(formId);
3026
+ if (!form) return;
3027
+ const wanted = new Set(fields);
3028
+ const candidates = form.querySelectorAll('[data-sdui-field], [aria-invalid]:not([aria-invalid="false"])');
3029
+ const hit = Array.from(candidates).find((el) => {
3030
+ const field = el.getAttribute("data-sdui-field");
3031
+ return field !== null ? wanted.has(field) : true;
3032
+ });
3033
+ if (!hit) return;
3034
+ const target = hit.matches(FOCUSABLE_IN_FIELD) ? hit : hit.querySelector(FOCUSABLE_IN_FIELD);
3035
+ hit.scrollIntoView?.({ block: "center", inline: "nearest" });
3036
+ target?.focus({ preventScroll: true });
3037
+ }
3038
+ var INVALID_PROP_COMPONENTS = /* @__PURE__ */ new Set([
3039
+ "Input",
3040
+ "Textarea",
3041
+ "Select",
3042
+ "DatePicker",
3043
+ "DateRangePicker",
3044
+ "TimePicker",
3045
+ "AutocompleteField",
3046
+ "JsonField",
3047
+ "PositionSelect"
3048
+ ]);
3049
+ var BINDING_PROPS = ["value", "checked", "values"];
3050
+ function formKeyOf(value, formId) {
3051
+ if (!isStateRef(value)) return null;
3052
+ const prefix = `${formId}.`;
3053
+ if (!value.$state.startsWith(prefix)) return null;
3054
+ const key = value.$state.slice(prefix.length);
3055
+ return key === "" || key.startsWith("$") ? null : key;
3056
+ }
3057
+ function boundInputOf(node, formId, depth = 0) {
3058
+ if (!Array.isArray(node.children)) return null;
3059
+ for (const child of node.children) {
3060
+ if (!isPlainObject(child)) continue;
3061
+ const childNode = child;
3062
+ const props = isPlainObject(childNode.props) ? childNode.props : {};
3063
+ for (const name of BINDING_PROPS) {
3064
+ const key = formKeyOf(props[name], formId);
3065
+ if (key) return { key, kind: String(childNode.component) };
3066
+ }
3067
+ const component = childNode.component;
3068
+ if (component === "Field" || component === "Form" || component === "ForEach" || component === "Fragment") continue;
3069
+ if (depth < 4) {
3070
+ const found = boundInputOf(childNode, formId, depth + 1);
3071
+ if (found) return found;
3072
+ }
3073
+ }
3074
+ return null;
3075
+ }
3076
+ function hasRequiredRule(rules) {
3077
+ return Array.isArray(rules) && rules.some((rule) => isPlainObject(rule) && rule.rule === "required");
3078
+ }
3079
+ function applyFormBinding(node, props, form, engine) {
3080
+ const raw = isPlainObject(node.props) ? node.props : {};
3081
+ const errorOf = (key) => {
3082
+ const errors = engine.resolveValue({ $state: `${form.id}.$errors` }, EMPTY_SCOPE);
3083
+ const message = isPlainObject(errors) ? errors[key] : void 0;
3084
+ return typeof message === "string" && message !== "" ? message : void 0;
3085
+ };
3086
+ if (node.component === "Field") {
3087
+ const bound = boundInputOf(node, form.id);
3088
+ if (!bound) return;
3089
+ if (!Object.hasOwn(raw, "required") && hasRequiredRule(form.rules[bound.key])) props.required = true;
3090
+ if (!Object.hasOwn(raw, "error")) {
3091
+ const message = errorOf(bound.key);
3092
+ if (message) props.error = message;
3093
+ }
3094
+ props["data-sdui-field"] = bound.key;
3095
+ engine.noteField(form.id, bound.key, {
3096
+ label: typeof props.label === "string" && props.label !== "" ? props.label : void 0,
3097
+ kind: bound.kind
3098
+ });
3099
+ return;
3100
+ }
3101
+ if (typeof node.component !== "string" || !INVALID_PROP_COMPONENTS.has(node.component)) return;
3102
+ if (Object.hasOwn(raw, "invalid")) return;
3103
+ for (const name of BINDING_PROPS) {
3104
+ const key = formKeyOf(raw[name], form.id);
3105
+ if (!key) continue;
3106
+ if (errorOf(key)) props.invalid = true;
3107
+ return;
3108
+ }
3109
+ }
2323
3110
  var EMPTY_SCOPE = [];
2324
3111
  var DENIED_PROPS2 = /* @__PURE__ */ new Set([
2325
3112
  "dangerouslySetInnerHTML",
@@ -2352,6 +3139,7 @@ function SduiNodeRenderer({
2352
3139
  engine
2353
3140
  }) {
2354
3141
  const { registry, resolveValue, dispatch, resolveAsset } = engine;
3142
+ const formScope = React9.useContext(SduiFormScopeContext);
2355
3143
  const slots = isPlainObject(node.slots) ? node.slots : void 0;
2356
3144
  const childList = childrenOf(node);
2357
3145
  if (node.component === "ForEach") {
@@ -2416,6 +3204,10 @@ function SduiNodeRenderer({
2416
3204
  props[name] = resolved;
2417
3205
  }
2418
3206
  }
3207
+ if (formScope) applyFormBinding(node, props, formScope, engine);
3208
+ if (typeof node.component === "string" && INVALID_PROP_COMPONENTS.has(node.component) && props.invalid !== void 0 && typeof props.invalid !== "boolean") {
3209
+ props.invalid = Boolean(props.invalid);
3210
+ }
2419
3211
  const childLayout = childLayoutFrom(props);
2420
3212
  if (childLayout) {
2421
3213
  for (const name of CHILD_LAYOUT_PROPS) delete props[name];
@@ -2512,14 +3304,30 @@ function SduiFragmentBody({
2512
3304
  return null;
2513
3305
  }
2514
3306
  const page = spec;
2515
- if (page.data || page.urlState || page.subscribe) {
3307
+ if (page.data || page.urlState || page.subscribe || page.leaveGuard !== void 0 || page.titleFrom !== void 0) {
2516
3308
  warnOnce(
2517
3309
  `fragment:page-features:${id}`,
2518
- `[sdui] Fragment \uC2A4\uD399 ${id}\uC758 data/urlState/subscribe\uB294 \uBB34\uC2DC\uB429\uB2C8\uB2E4 \u2014 \uC870\uAC01\uC740 body\uB9CC \uB80C\uB354\uD569\uB2C8\uB2E4`
3310
+ `[sdui] Fragment \uC2A4\uD399 ${id}\uC758 data/urlState/subscribe/leaveGuard/titleFrom\uC740 \uBB34\uC2DC\uB429\uB2C8\uB2E4 \u2014 \uC870\uAC01\uC740 body\uB9CC \uB80C\uB354\uD569\uB2C8\uB2E4`
2519
3311
  );
2520
3312
  }
2521
3313
  return /* @__PURE__ */ jsx(SduiFragmentChainContext.Provider, { value: nextChain, children: page.body.map((child, i) => renderChild(child, i, bodyScope, engine)) });
2522
3314
  }
3315
+ function SduiToastRegion({ hostId }) {
3316
+ const board = useSyncExternalStore(subscribeToasts, toastSnapshot, toastServerSnapshot);
3317
+ const toasts = useMemo(
3318
+ () => board.entries.filter((entry) => toastHostOf(entry, board.hosts) === hostId),
3319
+ [board, hostId]
3320
+ );
3321
+ useEffect(() => {
3322
+ if (toasts.length > 0) markToastsShown(toasts.map((t) => t.id));
3323
+ }, [toasts]);
3324
+ const modalOpen = useAnyModalOpen();
3325
+ if (toasts.length === 0) return null;
3326
+ return /* @__PURE__ */ jsxs("div", { className: modalOpen ? "wp-toast-region wp-toast-region--modal" : "wp-toast-region", children: [
3327
+ /* @__PURE__ */ jsx(HoistedStyle, { id: "wp-sdui-toast-modal", css: TOAST_MODAL_CSS }),
3328
+ toasts.map((t) => /* @__PURE__ */ jsx(Toast, { tone: t.tone, title: t.title, onClose: () => closeToastEntry(t.id), children: t.message }, t.id))
3329
+ ] });
3330
+ }
2523
3331
  function urlSeedSettled(state, seeded, baseline) {
2524
3332
  return Object.keys(seeded).every(
2525
3333
  (key) => Object.is(state[key], seeded[key]) || !Object.is(state[key], baseline[key])
@@ -2544,24 +3352,43 @@ function SduiRenderer({
2544
3352
  const urlSeeded = useRef(false);
2545
3353
  const urlSeedPending = useRef(null);
2546
3354
  const urlHomePath = useRef(typeof window !== "undefined" ? window.location.pathname : "");
2547
- const [toasts, setToasts] = useState([]);
2548
- const toastSeq = useRef(0);
2549
- const toastTimers = useRef([]);
2550
3355
  const stateRef = useRef(state);
2551
3356
  stateRef.current = state;
2552
3357
  const formsRef = useRef(/* @__PURE__ */ new Map());
2553
- useEffect(() => {
2554
- const timers = toastTimers.current;
2555
- return () => timers.forEach(clearTimeout);
3358
+ const fieldInfoRef = useRef(/* @__PURE__ */ new Map());
3359
+ const noteField = useCallback((formId, key, info) => {
3360
+ let fields = fieldInfoRef.current.get(formId);
3361
+ if (!fields) {
3362
+ fields = /* @__PURE__ */ new Map();
3363
+ fieldInfoRef.current.set(formId, fields);
3364
+ }
3365
+ fields.set(key, info);
2556
3366
  }, []);
3367
+ const [toastHostId] = useState(allocateToastHostId);
3368
+ const toastChannel = React9.useContext(OverlayContainerContext);
3369
+ useEffect(() => registerToastHost(toastHostId, toastChannel), [toastHostId, toastChannel]);
2557
3370
  useEffect(() => {
2558
3371
  if (!DEV) return;
2559
3372
  const issues = validateSpec(page, new Set(Object.keys(registry)));
2560
3373
  issues.forEach((issue) => console.warn(`[sdui:spec] ${issue.path} \u2014 ${issue.message}`));
2561
3374
  }, [page, registry]);
2562
- const setEntries = useCallback((entries) => {
2563
- stateRef.current = { ...stateRef.current, ...entries };
2564
- setState((prev) => ({ ...prev, ...entries }));
3375
+ const leaveFormsRef = useRef(/* @__PURE__ */ new Map());
3376
+ const leaveRegistry = useMemo(
3377
+ () => ({
3378
+ register: (formId, isDirty) => {
3379
+ leaveFormsRef.current.set(formId, isDirty);
3380
+ return () => {
3381
+ if (leaveFormsRef.current.get(formId) === isDirty) leaveFormsRef.current.delete(formId);
3382
+ };
3383
+ }
3384
+ }),
3385
+ []
3386
+ );
3387
+ const leavingRef = useRef(false);
3388
+ const setEntries = useCallback((entries2) => {
3389
+ stateRef.current = { ...stateRef.current, ...entries2 };
3390
+ leavingRef.current = false;
3391
+ setState((prev) => ({ ...prev, ...entries2 }));
2565
3392
  }, []);
2566
3393
  useEffect(() => {
2567
3394
  if (!syncUrlState || urlSeeded.current || !page.urlState?.length || typeof window === "undefined") return;
@@ -2585,6 +3412,16 @@ function SduiRenderer({
2585
3412
  setEntries(seeded);
2586
3413
  }
2587
3414
  }, [page.urlState, setEntries, syncUrlState]);
3415
+ const [urlResync, setUrlResync] = useState(0);
3416
+ const hasUrlState = Boolean(page.urlState?.length);
3417
+ useEffect(() => {
3418
+ if (!syncUrlState || !hasUrlState || typeof window === "undefined") return;
3419
+ const onPop = () => {
3420
+ if (window.location.pathname === urlHomePath.current) setUrlResync((n) => n + 1);
3421
+ };
3422
+ window.addEventListener("popstate", onPop);
3423
+ return () => window.removeEventListener("popstate", onPop);
3424
+ }, [syncUrlState, hasUrlState]);
2588
3425
  useEffect(() => {
2589
3426
  if (!syncUrlState || !page.urlState?.length || !urlSeeded.current || typeof window === "undefined") return;
2590
3427
  if (window.location.pathname !== urlHomePath.current) return;
@@ -2620,7 +3457,7 @@ function SduiRenderer({
2620
3457
  );
2621
3458
  }
2622
3459
  }
2623
- }, [state, page.urlState, syncUrlState]);
3460
+ }, [state, page.urlState, syncUrlState, urlResync]);
2624
3461
  const subscribeChannels = page.subscribe?.join(",") ?? "";
2625
3462
  const hasSubscribe = typeof host.subscribe === "function";
2626
3463
  useEffect(() => {
@@ -2641,16 +3478,11 @@ function SduiRenderer({
2641
3478
  unsubscribe();
2642
3479
  };
2643
3480
  }, [subscribeChannels, hasSubscribe]);
2644
- const closeToast = useCallback((id) => {
2645
- setToasts((prev) => prev.filter((t) => t.id !== id));
2646
- }, []);
2647
3481
  const pushToast = useCallback(
2648
3482
  (toast) => {
2649
- const id = ++toastSeq.current;
2650
- setToasts((prev) => [...prev, { ...toast, id }]);
2651
- toastTimers.current.push(setTimeout(() => closeToast(id), 4e3));
3483
+ pushToastEntry(toastHostId, toastChannel, toast);
2652
3484
  },
2653
- [closeToast]
3485
+ [toastHostId, toastChannel]
2654
3486
  );
2655
3487
  const lookupState = useCallback((key) => {
2656
3488
  const current = stateRef.current;
@@ -2711,7 +3543,7 @@ function SduiRenderer({
2711
3543
  if (isPlainObject(value)) {
2712
3544
  const out = {};
2713
3545
  for (const [k, v] of Object.entries(value)) {
2714
- out[k] = deferChainEntry(value, k) || deferRunEntry(k, v) ? v : resolveValue(v, scope);
3546
+ out[k] = deferChainEntry(value, k) || deferRunEntry(k, v) || deferEachTarget(value, k) ? v : resolveValue(v, scope);
2715
3547
  }
2716
3548
  return out;
2717
3549
  }
@@ -2719,6 +3551,76 @@ function SduiRenderer({
2719
3551
  },
2720
3552
  [lookupState]
2721
3553
  );
3554
+ const leaveMessageNow = useCallback(() => {
3555
+ if (leavingRef.current) return null;
3556
+ if (page.leaveGuard !== void 0) {
3557
+ const message = leaveMessageOf(resolveValue(page.leaveGuard, EMPTY_SCOPE));
3558
+ if (message) return message;
3559
+ }
3560
+ for (const [formId, isDirty] of leaveFormsRef.current) {
3561
+ if (stateRef.current[`${formId}.$resetDirty`] === true) continue;
3562
+ if (isDirty()) return DEFAULT_LEAVE_MESSAGE;
3563
+ }
3564
+ return null;
3565
+ }, [page.leaveGuard, resolveValue]);
3566
+ const leaveSentRef = useRef(null);
3567
+ const unloadHandlerRef = useRef(null);
3568
+ const syncLeaveGuard = useCallback(() => {
3569
+ const message = leaveMessageNow();
3570
+ if (message === leaveSentRef.current) return message;
3571
+ leaveSentRef.current = message;
3572
+ hostRef.current.setLeaveGuard?.(message);
3573
+ if (typeof window === "undefined") return message;
3574
+ if (message !== null && !unloadHandlerRef.current) {
3575
+ const handler = (event) => {
3576
+ event.preventDefault();
3577
+ event.returnValue = "";
3578
+ };
3579
+ window.addEventListener("beforeunload", handler);
3580
+ unloadHandlerRef.current = handler;
3581
+ } else if (message === null && unloadHandlerRef.current) {
3582
+ window.removeEventListener("beforeunload", unloadHandlerRef.current);
3583
+ unloadHandlerRef.current = null;
3584
+ }
3585
+ return message;
3586
+ }, [leaveMessageNow]);
3587
+ useEffect(() => {
3588
+ syncLeaveGuard();
3589
+ });
3590
+ useEffect(
3591
+ () => () => {
3592
+ if (leaveSentRef.current !== null) {
3593
+ leaveSentRef.current = null;
3594
+ hostRef.current.setLeaveGuard?.(null);
3595
+ }
3596
+ if (unloadHandlerRef.current && typeof window !== "undefined") {
3597
+ window.removeEventListener("beforeunload", unloadHandlerRef.current);
3598
+ unloadHandlerRef.current = null;
3599
+ }
3600
+ },
3601
+ []
3602
+ );
3603
+ const [pendingLeave, setPendingLeave] = useState(null);
3604
+ const titleFrom = page.titleFrom;
3605
+ const resolvedTitle = useMemo(
3606
+ () => titleFrom === void 0 ? null : titleText(resolveValue(titleFrom, EMPTY_SCOPE)),
3607
+ // state 가 바뀌어야 다시 푼다 — resolveValue 는 ref 를 읽어 정체성이 안 바뀐다.
3608
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3609
+ [titleFrom, resolveValue, state]
3610
+ );
3611
+ const effectiveTitle = resolvedTitle ?? page.title;
3612
+ useEffect(() => {
3613
+ if (titleFrom === void 0) return;
3614
+ hostRef.current.setTitle?.(effectiveTitle ?? null);
3615
+ }, [titleFrom, effectiveTitle]);
3616
+ useEffect(
3617
+ () => () => {
3618
+ if (titleFrom !== void 0) hostRef.current.setTitle?.(null);
3619
+ },
3620
+ // 마운트 1회 계약 — 라우트가 바뀌면 호스트가 key 로 갈아 끼운다.
3621
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3622
+ []
3623
+ );
2722
3624
  const dataPlan = useMemo(
2723
3625
  () => planPageData(page.data, (value) => resolveValue(value, EMPTY_SCOPE)),
2724
3626
  // state를 의존성에 두어야 $state를 참조하는 경로가 값 변화를 따라간다.
@@ -2808,6 +3710,34 @@ function SduiRenderer({
2808
3710
  revertUnsettledStamps(fetchedRef.current, runnable, epochs, settled);
2809
3711
  };
2810
3712
  }, [dataPlanKey, dataEpochKey, setEntries]);
3713
+ const revalidateField = useCallback(
3714
+ (key) => {
3715
+ for (const [formId, config] of formsRef.current) {
3716
+ const prefix = `${formId}.`;
3717
+ if (!key.startsWith(prefix)) continue;
3718
+ const field = key.slice(prefix.length);
3719
+ if (field === "" || field.startsWith("$")) continue;
3720
+ const errors = stateRef.current[`${formId}.$errors`];
3721
+ if (!isPlainObject(errors) || !Object.hasOwn(errors, field)) continue;
3722
+ const next = { ...errors };
3723
+ try {
3724
+ const result = validateForm(
3725
+ collectFormValues(stateRef.current, formId),
3726
+ config,
3727
+ customValidators,
3728
+ fieldInfoRef.current.get(formId)
3729
+ );
3730
+ const message = result.errors[field];
3731
+ if (message) next[field] = message;
3732
+ else delete next[field];
3733
+ } catch {
3734
+ delete next[field];
3735
+ }
3736
+ setEntries({ [`${formId}.$errors`]: next });
3737
+ }
3738
+ },
3739
+ [customValidators, setEntries]
3740
+ );
2811
3741
  const dispatchRef = useRef(() => {
2812
3742
  });
2813
3743
  const dispatch = useCallback(
@@ -2824,17 +3754,24 @@ function SduiRenderer({
2824
3754
  eventScope
2825
3755
  );
2826
3756
  switch (action.action) {
2827
- case "navigate":
3757
+ case "navigate": {
2828
3758
  if (!isSafeNavigationTarget(action.to)) {
2829
3759
  warnOnce(`navigate:${String(action.to)}`, `[sdui] \uD5C8\uC6A9\uB418\uC9C0 \uC54A\uB294 navigate \uB300\uC0C1: ${String(action.to)}`);
2830
3760
  break;
2831
3761
  }
3762
+ const leaveMessage = syncLeaveGuard();
3763
+ if (leaveMessage !== null && typeof hostRef.current.setLeaveGuard !== "function") {
3764
+ setPendingLeave({ to: action.to, message: leaveMessage });
3765
+ break;
3766
+ }
2832
3767
  hostRef.current.navigate(action.to);
2833
3768
  break;
3769
+ }
2834
3770
  case "setState": {
2835
3771
  const nextValue = action.fromEvent ? eventValue(arg) : action.value;
2836
3772
  if (!Array.isArray(action.path) || action.path.length === 0) {
2837
3773
  setEntries({ [action.key]: nextValue });
3774
+ revalidateField(action.key);
2838
3775
  break;
2839
3776
  }
2840
3777
  const written = setAtPath(
@@ -2850,6 +3787,7 @@ function SduiRenderer({
2850
3787
  break;
2851
3788
  }
2852
3789
  setEntries({ [action.key]: written.next });
3790
+ revalidateField(action.key);
2853
3791
  break;
2854
3792
  }
2855
3793
  case "open":
@@ -2859,7 +3797,7 @@ function SduiRenderer({
2859
3797
  setEntries({ [action.key]: false });
2860
3798
  break;
2861
3799
  case "toast":
2862
- pushToast({ tone: action.tone ?? "info", title: action.title, message: action.message });
3800
+ pushToast({ tone: action.tone ?? "info", title: toastText(action.title), message: toastText(action.message) });
2863
3801
  break;
2864
3802
  case "arrayAppend": {
2865
3803
  const current = stateRef.current[action.key];
@@ -2926,16 +3864,21 @@ function SduiRenderer({
2926
3864
  let phase = "validate";
2927
3865
  let pendingOn = false;
2928
3866
  try {
2929
- const prefix = `${formId}.`;
2930
- const values = {};
2931
- for (const [key, value] of Object.entries(stateRef.current)) {
2932
- if (key.startsWith(prefix) && !key.slice(prefix.length).startsWith("$") && value !== void 0) {
2933
- values[key.slice(prefix.length)] = value;
2934
- }
2935
- }
2936
- const errors = validateForm(values, formsRef.current.get(formId), customValidators);
3867
+ const values = collectFormValues(stateRef.current, formId);
3868
+ const config = formsRef.current.get(formId);
3869
+ const fieldInfo = fieldInfoRef.current.get(formId);
3870
+ const result = validateForm(values, config, customValidators, fieldInfo);
3871
+ const errors = result.errors;
2937
3872
  if (Object.keys(errors).length > 0) {
2938
3873
  setEntries({ [`${formId}.$errors`]: errors, [`${formId}.$submitting`]: false });
3874
+ const summary = invalidSummary(
3875
+ result,
3876
+ (field) => config?.labels[field] ?? fieldInfo?.get(field)?.label,
3877
+ fieldOrderOf(formId)
3878
+ );
3879
+ pushToast({ tone: "warning", title: summary.title, message: summary.message });
3880
+ const fields = Object.keys(errors);
3881
+ setTimeout(() => focusFirstInvalid(formId, fields), 0);
2939
3882
  return;
2940
3883
  }
2941
3884
  setEntries({ [`${formId}.$errors`]: {}, [`${formId}.$submitting`]: true });
@@ -2980,7 +3923,10 @@ function SduiRenderer({
2980
3923
  // scope 를 잇는다 — request 의 onSuccess/onError 도 같은 자리의 텍스트다.
2981
3924
  // {"$arg"} 체인 항목은 dispatch 진입부가 이 스코프로 펼친다(submitForm과 동일).
2982
3925
  dispatch: (a, value) => dispatchRef.current(a, value, scope),
2983
- toast: pushToast
3926
+ toast: pushToast,
3927
+ // each: 원소를 행 프레임으로 얹어 endpoint·body 를 원소마다 푼다 — 같은 자리의
3928
+ // $state·$arg·$event 는 그대로 보이고, $item/$index 만 이 원소를 가리킨다.
3929
+ resolveFor: (value, item, index) => resolveValue(value, [...eventScope, { item, index }])
2984
3930
  });
2985
3931
  break;
2986
3932
  case "invalidate": {
@@ -3027,12 +3973,12 @@ function SduiRenderer({
3027
3973
  break;
3028
3974
  }
3029
3975
  },
3030
- [pushToast, onCustomAction, customValidators, setEntries, resolveValue]
3976
+ [pushToast, onCustomAction, customValidators, setEntries, resolveValue, revalidateField, syncLeaveGuard]
3031
3977
  );
3032
3978
  dispatchRef.current = dispatch;
3033
3979
  const engine = useMemo(
3034
- () => ({ registry, resolveValue, dispatch, resolveAsset: assetResolverOf(host) }),
3035
- [registry, resolveValue, dispatch, host]
3980
+ () => ({ registry, resolveValue, dispatch, resolveAsset: assetResolverOf(host), noteField }),
3981
+ [registry, resolveValue, dispatch, host, noteField]
3036
3982
  );
3037
3983
  const runtime = useMemo(
3038
3984
  () => ({
@@ -3050,15 +3996,43 @@ function SduiRenderer({
3050
3996
  return (
3051
3997
  // 해석된 어댑터를 트리에 흘려보낸다 — dispatch를 거치지 않고 스스로 요청을 내는
3052
3998
  // 컴포넌트(목록 조회 등)도 호스트가 건 잠금 안에서 움직이게 하기 위해서다(adapter.ts 주석).
3053
- /* @__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: [
3999
+ /* @__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__ */ jsx(SduiLeaveRegistryContext.Provider, { value: leaveRegistry, children: /* @__PURE__ */ jsxs("div", { className: "sdui-root", children: [
3054
4000
  /* @__PURE__ */ jsx(HoistedStyle, { id: "wp-sdui-layout", css: LAYOUT_CSS }),
3055
- !hasExplicitPageHead(page.body) && (page.title || page.subtitle) ? /* @__PURE__ */ jsx("div", { className: "wp-page-head", children: /* @__PURE__ */ jsxs("div", { className: "wp-page-head__text", children: [
3056
- page.title ? /* @__PURE__ */ jsx("h1", { className: "wp-page-head__title", children: page.title }) : null,
4001
+ !hasExplicitPageHead(page.body) && (effectiveTitle || page.subtitle) ? /* @__PURE__ */ jsx("div", { className: "wp-page-head", children: /* @__PURE__ */ jsxs("div", { className: "wp-page-head__text", children: [
4002
+ effectiveTitle ? /* @__PURE__ */ jsx("h1", { className: "wp-page-head__title", children: effectiveTitle }) : null,
3057
4003
  page.subtitle ? /* @__PURE__ */ jsx("div", { className: "wp-page-head__sub", children: page.subtitle }) : null
3058
4004
  ] }) }) : null,
3059
4005
  /* @__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" }) }),
3060
- 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
3061
- ] }) }) }) }) })
4006
+ /* @__PURE__ */ jsx(SduiToastRegion, { hostId: toastHostId }),
4007
+ pendingLeave ? /* @__PURE__ */ jsx(OverlayLayer, { onClose: () => setPendingLeave(null), children: /* @__PURE__ */ jsx(
4008
+ Dialog,
4009
+ {
4010
+ size: "sm",
4011
+ icon: "triangle-alert",
4012
+ tone: "danger",
4013
+ title: "\uC800\uC7A5\uD558\uC9C0 \uC54A\uACE0 \uB098\uAC08\uAE4C\uC694?",
4014
+ onClose: () => setPendingLeave(null),
4015
+ footer: /* @__PURE__ */ jsxs(Fragment, { children: [
4016
+ /* @__PURE__ */ jsx(Button, { variant: "secondary", "data-autofocus": "", onClick: () => setPendingLeave(null), children: "\uACC4\uC18D \uC791\uC131" }),
4017
+ /* @__PURE__ */ jsx(
4018
+ Button,
4019
+ {
4020
+ variant: "danger",
4021
+ onClick: () => {
4022
+ const { to } = pendingLeave;
4023
+ setPendingLeave(null);
4024
+ leavingRef.current = true;
4025
+ syncLeaveGuard();
4026
+ hostRef.current.navigate(to);
4027
+ },
4028
+ children: "\uB098\uAC00\uAE30"
4029
+ }
4030
+ )
4031
+ ] }),
4032
+ children: pendingLeave.message
4033
+ }
4034
+ ) }) : null
4035
+ ] }) }) }) }) }) })
3062
4036
  );
3063
4037
  }
3064
4038
 
@@ -3119,4 +4093,4 @@ async function resolvePatternSpec(id, load) {
3119
4093
  // src/icon-names.ts
3120
4094
  var ICON_NAMES = Object.keys(ICON_NODES);
3121
4095
 
3122
- export { Can, ICON_NAMES, NodeErrorBoundary, RemoteSearchResults, ResultTable, SduiForm, SduiRenderer, SduiSearchDataProvider, SduiSearchInvalidationProvider, SduiSearchResults, SearchResults, SortHandle, SortableList, cellRenderer, createRegistry, createSearchInvalidator, defaultRegistry, imageRenderer, isValidSpecId, mapActions, mapCardRows, mapColumns, mapGridCard, mapViews, matchesCondition, normalizePageSizes, patternSpecCandidates, resolvePatternSpec, sortableDropIndex, sortableMove, useRemoteSearchPage, useSduiRuntime, useSearchFetcher, validateSpec, withProps };
4096
+ export { Can, ICON_NAMES, NodeErrorBoundary, ORDER_PARAM, RemoteSearchResults, ResultEmptyState, ResultTable, SORT_PARAM, SduiForm, SduiRenderer, SduiSearchDataProvider, SduiSearchInvalidationProvider, SduiSearchResults, SearchResults, SearchSummaryCards, SortHandle, SortableList, cellRenderer, conditionParams, createRegistry, createSearchInvalidator, createServerSortMemory, defaultRegistry, imageRenderer, isValidSpecId, mapActions, mapCardRows, mapColumns, mapGridCard, mapViews, matchesCondition, nextSort, normalizePageSizes, patternSpecCandidates, resolvePatternSpec, serverSortMemory, sortEchoOf, sortFromParams, sortableDropIndex, sortableMove, summaryParams, useRemoteSearchPage, useRemoteSearchSummary, useSearchFetcher, validateSpec, withProps, withoutSort };