@wipco/sdui 1.5.0 → 1.6.1

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-7P7N43JV.js → chunk-34BABTH4.js} +2 -2
  3. package/dist/{chunk-DMLDKW7K.js → chunk-37BNG4DT.js} +1 -1
  4. package/dist/{chunk-5WW4MBRO.js → chunk-3JTBOABA.js} +6 -2
  5. package/dist/{chunk-HBIIEYKW.js → chunk-3PCR73OC.js} +4 -4
  6. package/dist/{chunk-VY7MVBSE.js → chunk-3PT5OBOU.js} +2 -2
  7. package/dist/{chunk-YVTV4ZV6.js → chunk-4YPPL7JK.js} +1 -1
  8. package/dist/{chunk-E6ISNQK3.js → chunk-6YBSBC77.js} +31 -8
  9. package/dist/{chunk-YWUDBLO6.js → chunk-7G6J6QFC.js} +2 -2
  10. package/dist/{chunk-UV6HHFEW.js → chunk-A57GKV5F.js} +129 -68
  11. package/dist/{chunk-I45EUZC6.js → chunk-AJ3KDBEE.js} +4 -2
  12. package/dist/chunk-BITS7UJY.js +333 -0
  13. package/dist/{chunk-DOL57OOP.js → chunk-BV37UCDF.js} +3 -3
  14. package/dist/chunk-CSZCWBOE.js +256 -0
  15. package/dist/{chunk-XGFDY43I.js → chunk-CUFGH3LB.js} +88 -18
  16. package/dist/{chunk-A6LAIGUS.js → chunk-EGFDELSF.js} +7 -4
  17. package/dist/{chunk-JIV4YHCZ.js → chunk-EJIWOD7W.js} +1 -1
  18. package/dist/{chunk-CXW6KT6X.js → chunk-ESAFQXEU.js} +3 -3
  19. package/dist/{chunk-N5RKM6IC.js → chunk-EUSC5NMZ.js} +4 -4
  20. package/dist/{chunk-RWSH2MBU.js → chunk-FP32LNFK.js} +73 -19
  21. package/dist/{chunk-44TDRCKR.js → chunk-GKYUDTMI.js} +5 -5
  22. package/dist/chunk-GSM664TX.js +38 -0
  23. package/dist/{chunk-I5SD7XRA.js → chunk-GSXD52XG.js} +3 -3
  24. package/dist/{chunk-O4IISNE7.js → chunk-IB2ZPUTF.js} +51 -12
  25. package/dist/{chunk-YOZN35HD.js → chunk-IIRVHSNK.js} +3 -3
  26. package/dist/{chunk-FNMS7OB7.js → chunk-J7BZF4T4.js} +5 -5
  27. package/dist/{chunk-KO6OEY3G.js → chunk-JHFA5RDN.js} +3 -13
  28. package/dist/{chunk-DKVSM5HX.js → chunk-KDKRFWIF.js} +6 -6
  29. package/dist/chunk-KO3EUNWB.js +380 -0
  30. package/dist/{chunk-JYAR7D5U.js → chunk-NLSR3JYU.js} +2 -2
  31. package/dist/{chunk-ZORQPB6B.js → chunk-O4EMQWCV.js} +325 -360
  32. package/dist/{chunk-2HLXCKK7.js → chunk-OCYJ2ZOV.js} +2 -2
  33. package/dist/{chunk-OQ52XMGG.js → chunk-OKNF2SBB.js} +4 -4
  34. package/dist/{chunk-GNA6SX2H.js → chunk-OQ7A3SFM.js} +2 -2
  35. package/dist/{chunk-A5XQOEVH.js → chunk-PCFH74RD.js} +4 -4
  36. package/dist/{chunk-BEM2YS7U.js → chunk-SGGIWPP6.js} +3 -3
  37. package/dist/{chunk-NIJUVTEW.js → chunk-TB7G5DXQ.js} +3 -3
  38. package/dist/{chunk-GZBY7I4Y.js → chunk-TWUKNUUQ.js} +66 -10
  39. package/dist/{chunk-WPSK2CB5.js → chunk-UC725BGW.js} +16 -5
  40. package/dist/{chunk-ZFNFPXPR.js → chunk-US3K6Q5T.js} +2 -2
  41. package/dist/{chunk-MPT7WJG2.js → chunk-USNPJEXH.js} +5 -5
  42. package/dist/{chunk-SAPBDJJQ.js → chunk-UZZLMPRW.js} +2 -2
  43. package/dist/{chunk-Q4SWUINA.js → chunk-VAXRTBJH.js} +1 -1
  44. package/dist/{chunk-RYBBPFI7.js → chunk-VQKX5PJ4.js} +4 -4
  45. package/dist/chunk-VR5MFUD2.js +106 -0
  46. package/dist/{chunk-JZXU22PE.js → chunk-VSNHDHGS.js} +208 -117
  47. package/dist/{chunk-2YP5N4VV.js → chunk-VZOQRQAR.js} +1 -1
  48. package/dist/{chunk-AXGF4M57.js → chunk-XAXROQKB.js} +1 -1
  49. package/dist/{chunk-IZ3JA2BR.js → chunk-XFKNU6QN.js} +3 -3
  50. package/dist/{chunk-GTGS2QRL.js → chunk-XUVWIRZJ.js} +3 -3
  51. package/dist/{chunk-RNOUXUBO.js → chunk-YYAGE733.js} +323 -59
  52. package/dist/index.d.ts +449 -91
  53. package/dist/index.js +1060 -138
  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 +351 -25
  57. package/dist/rich/ExcelUploadCard.d.ts +1 -1
  58. package/dist/rich/ExcelUploadCard.js +5 -4
  59. package/dist/rich.d.ts +22 -3
  60. package/dist/rich.js +304 -35
  61. package/dist/styles.css +119 -4
  62. package/dist/{types-COjGKgmd.d.ts → types-CMikpoN5.d.ts} +65 -2
  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 +17 -3
  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-4YPPL7JK.js';
2
+ import { useSduiDispatch, SduiDialog, useSduiCan, useSduiRuntime, SduiDirtyRegistryContext, SduiLeaveRegistryContext, SduiFormScopeContext, SduiDispatchContext, SduiRuntimeContext, SduiPermissionProvider } from './chunk-VR5MFUD2.js';
3
+ export { SduiDialog, SduiPermissionProvider, useSduiCan, useSduiDispatch, useSduiRuntime } from './chunk-VR5MFUD2.js';
4
+ import { Text, Spacer, Show, Section, Row, PageHead, KeyValueList, Grid, Divider, Col, Actions } from './chunk-UC725BGW.js';
5
+ export { Actions, Col, Divider, Grid, KeyValueList, PageHead, Row, Section, Show, Spacer, Text } from './chunk-UC725BGW.js';
6
+ import { ImageViewer } from './chunk-UZZLMPRW.js';
7
+ export { ImageViewer } from './chunk-UZZLMPRW.js';
8
+ import { normalizeSearchFields, searchValuesFromParams, splitMultiValue, includesLooseSearch, cascadingValuePaths, SearchToolbar, searchFieldKeys, Popover } from './chunk-A57GKV5F.js';
9
+ export { SearchToolbar, cascadingValuePaths, cleanSearchValues, includesLooseSearch, joinMultiValue, normalizeSearchText, searchFieldKeys, searchValuesFromParams, splitMultiValue } from './chunk-A57GKV5F.js';
10
+ import { parseYmd, YMD_RE, TimePicker, DateRangePicker, DatePicker } from './chunk-FP32LNFK.js';
11
+ export { DatePicker, DateRangePicker, TimePicker } from './chunk-FP32LNFK.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, useSduiAdapter, hostFetchOf, isSafeNavigationTarget, useSduiSandbox, resolveAdapter, assetResolverOf, SduiAdapterContext } from './chunk-KO6OEY3G.js';
15
- export { defaultAdapter, useSduiAssetResolver, useSduiSandbox } from './chunk-KO6OEY3G.js';
16
- import { EmptyState, Button, Checkbox, Tabs, SegmentedControl, Spinner, Select, Pagination, fieldOf, StatCard, Icon, Card, Badge, ProgressBar, Tag, Toast, Textarea, Table, Switch, 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-JHFA5RDN.js';
16
+ export { defaultAdapter, useSduiAssetResolver, useSduiSandbox } from './chunk-JHFA5RDN.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-YYAGE733.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-YYAGE733.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,7 +659,7 @@ 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 };
473
663
  }
474
664
  function useRemoteSearchSummary(endpoint, params) {
475
665
  const adapter = useSduiAdapter();
@@ -523,6 +713,8 @@ function summaryParams(params) {
523
713
  const next = new URLSearchParams(params);
524
714
  next.delete("page");
525
715
  next.delete("limit");
716
+ next.delete("sort");
717
+ next.delete("order");
526
718
  return next;
527
719
  }
528
720
  function plainValue(value) {
@@ -545,6 +737,9 @@ function SearchSummaryCards({ items, values, dispatch }) {
545
737
  }
546
738
  var OVERSCAN = 10;
547
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
+ }
548
743
  function ResultTable({
549
744
  columns,
550
745
  rows,
@@ -552,10 +747,12 @@ function ResultTable({
552
747
  onSort,
553
748
  onRowClick,
554
749
  selectedIds,
750
+ pickedIds,
555
751
  empty,
556
752
  virtual = false,
557
753
  height = 560,
558
- rowHeight = 41
754
+ rowHeight = 41,
755
+ stackable = true
559
756
  }) {
560
757
  const scrollRef = useRef(null);
561
758
  const firstRowRef = useRef(null);
@@ -571,6 +768,7 @@ function ResultTable({
571
768
  });
572
769
  if (!rows.length && empty) return /* @__PURE__ */ jsx(Fragment, { children: empty });
573
770
  const selectedSet = selectedIds && selectedIds.length > 0 ? new Set(selectedIds) : null;
771
+ const pickedSet = pickedIds && pickedIds.length > 0 ? new Set(pickedIds) : null;
574
772
  const unit = measured ?? rowHeight;
575
773
  const start = virtual ? Math.max(0, Math.floor(scrollTop / unit) - OVERSCAN) : 0;
576
774
  const end = virtual ? Math.min(rows.length, Math.ceil((scrollTop + height) / unit) + OVERSCAN) : rows.length;
@@ -579,13 +777,14 @@ function ResultTable({
579
777
  const table = /* @__PURE__ */ jsxs(
580
778
  "table",
581
779
  {
582
- className: `wp-table wp-table--hover${virtual ? " wp-table--sticky wp-table--clip" : ""}`,
780
+ className: `wp-table wp-table--hover${virtual ? " wp-table--sticky wp-table--clip" : stackable ? " wp-table--stackable" : ""}`,
583
781
  style: virtual ? { tableLayout: "fixed" } : void 0,
584
782
  children: [
585
783
  /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { children: columns.map((column) => /* @__PURE__ */ jsx(
586
784
  "th",
587
785
  {
588
786
  style: { width: column.width, textAlign: column.align === "right" ? "right" : void 0 },
787
+ "data-mobile": column.mobile,
589
788
  children: column.sortable && onSort ? (
590
789
  // 인라인 style로는 :hover를 표현할 수 없다 — 색 피드백은 .wp-table__sort:hover가 낸다
591
790
  /* @__PURE__ */ jsxs("button", { type: "button", className: "wp-table__sort", onClick: () => onSort(column.key), children: [
@@ -607,11 +806,14 @@ function ResultTable({
607
806
  rows.slice(start, end).map((row, offset) => {
608
807
  const index = start + offset;
609
808
  const selected = selectedSet ? selectedSet.has(row.id) : false;
809
+ const picked = pickedSet ? pickedSet.has(String(row.id)) : false;
610
810
  return /* @__PURE__ */ jsx(
611
811
  "tr",
612
812
  {
613
813
  ref: offset === 0 ? firstRowRef : void 0,
614
814
  "aria-selected": selected || void 0,
815
+ "aria-current": picked ? "true" : void 0,
816
+ className: picked ? "wp-row--selected" : void 0,
615
817
  onClick: onRowClick ? () => onRowClick(row) : void 0,
616
818
  style: onRowClick ? { cursor: "pointer" } : void 0,
617
819
  children: columns.map((column) => {
@@ -619,10 +821,20 @@ function ResultTable({
619
821
  if (column.align === "right") cls.push("wp-table__num");
620
822
  if (column.mono) cls.push("wp-table__mono");
621
823
  if (column.strong) cls.push("wp-table__strong");
622
- return /* @__PURE__ */ jsx("td", { className: cls.join(" ") || void 0, children: column.render ? column.render(row, index) : (
824
+ const value = column.render ? column.render(row, index) : (
623
825
  // 평면 키 우선, 없으면 점 경로 — 셀 렌더러(cells.tsx)와 같은 조회 규칙이다
624
826
  fieldOf(row, column.key)
625
- ) }, column.key);
827
+ );
828
+ return /* @__PURE__ */ jsx(
829
+ "td",
830
+ {
831
+ className: cls.join(" ") || void 0,
832
+ "data-label": labelOf(column),
833
+ "data-mobile": column.mobile,
834
+ children: isBlankCell(value) ? /* @__PURE__ */ jsx("span", { style: { color: "var(--wp-text-subtle)" }, children: "\u2014" }) : value
835
+ },
836
+ column.key
837
+ );
626
838
  })
627
839
  },
628
840
  row.id ?? index
@@ -633,14 +845,42 @@ function ResultTable({
633
845
  ]
634
846
  }
635
847
  );
636
- if (!virtual) return table;
848
+ if (!virtual) {
849
+ return /* @__PURE__ */ jsx("div", { className: "wp-table-scroll", children: table });
850
+ }
637
851
  return /* @__PURE__ */ jsxs("div", { ref: scrollRef, onScroll: handleScroll, style: { height, overflowY: "auto" }, children: [
638
852
  /* @__PURE__ */ jsx(HoistedStyle, { id: "wp-table-clip", css: CLIP_CSS }),
639
853
  table
640
854
  ] });
641
855
  }
642
- function ResultEmptyState({ title }) {
643
- 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." });
856
+ function labelOf(column) {
857
+ if (typeof column.label === "string") return column.label;
858
+ return typeof column.header === "string" ? column.header : "";
859
+ }
860
+ function ResultEmptyState({ title, filtered = false, emptyTitle, emptyText, scoped = false, onReset }) {
861
+ const name = String(title ?? "").trim() || "\uD56D\uBAA9";
862
+ if (filtered) {
863
+ return /* @__PURE__ */ jsx(
864
+ EmptyState,
865
+ {
866
+ icon: "scan-search",
867
+ title: `\uC870\uAC74\uC5D0 \uB9DE\uB294 ${name}${josaOf(name, "\uC774/\uAC00")} \uC5C6\uC2B5\uB2C8\uB2E4`,
868
+ tight: true,
869
+ actions: onReset ? /* @__PURE__ */ jsx(Button, { size: "sm", variant: "secondary", icon: "refresh-cw", onClick: onReset, children: "\uC870\uAC74 \uCD08\uAE30\uD654" }) : void 0,
870
+ children: "\uAC80\uC0C9\uC5B4\uB098 \uC870\uAC74\uC744 \uBC14\uAFD4 \uBCF4\uC138\uC694."
871
+ }
872
+ );
873
+ }
874
+ return /* @__PURE__ */ jsx(EmptyState, { title: emptyTitle || `${scoped ? "" : "\uC544\uC9C1 "}${name}${josaOf(name, "\uC774/\uAC00")} \uC5C6\uC2B5\uB2C8\uB2E4`, tight: true, children: emptyText || void 0 });
875
+ }
876
+ function normalizeSelectedIds(ids) {
877
+ if (!Array.isArray(ids)) return [];
878
+ const out = [];
879
+ for (const id of ids) {
880
+ if (typeof id === "string" && id !== "") out.push(id);
881
+ else if (typeof id === "number" && Number.isFinite(id)) out.push(String(id));
882
+ }
883
+ return out;
644
884
  }
645
885
  var EMPTY_FILTERS = [];
646
886
  var EMPTY_FIELDS = [];
@@ -649,6 +889,29 @@ var DEFAULT_VIEWS = [
649
889
  { value: "list", icon: "list", label: "\uBAA9\uB85D" },
650
890
  { value: "grid", icon: "layout-grid", label: "\uCE74\uB4DC" }
651
891
  ];
892
+ var VIEW_STORAGE_PREFIX = "wp-sdui:view:";
893
+ var viewMemory = /* @__PURE__ */ new Map();
894
+ function readStoredView(title) {
895
+ const inMemory = viewMemory.get(title);
896
+ if (inMemory) return inMemory;
897
+ try {
898
+ if (typeof window === "undefined") return null;
899
+ const stored = window.sessionStorage.getItem(VIEW_STORAGE_PREFIX + title);
900
+ return stored === "grid" || stored === "list" ? stored : null;
901
+ } catch {
902
+ return null;
903
+ }
904
+ }
905
+ function writeStoredView(title, view) {
906
+ viewMemory.set(title, view);
907
+ try {
908
+ if (typeof window !== "undefined") window.sessionStorage.setItem(VIEW_STORAGE_PREFIX + title, view);
909
+ } catch {
910
+ }
911
+ }
912
+ function looksLikeLocator(value) {
913
+ return /^(https?:)?\/\//.test(value) || value.startsWith("/") || value.startsWith("data:");
914
+ }
652
915
  function normalizePageSizes(sizes) {
653
916
  if (!Array.isArray(sizes)) return void 0;
654
917
  const clean = [...new Set(sizes.map(Number).filter((n) => Number.isFinite(n) && n > 0).map(Math.floor))].sort(
@@ -697,9 +960,18 @@ function SearchResults({
697
960
  hideHeader = false,
698
961
  advancedSearchFields: rawAdvancedSearchFields = EMPTY_FIELDS,
699
962
  searchPlaceholder,
700
- virtual
963
+ virtual,
964
+ serverUnsortable,
965
+ primaryKey,
966
+ emptyTitle,
967
+ emptyText,
968
+ serverFiltered = false,
969
+ selectedIds
701
970
  }) {
702
971
  const hasActions = actions.length > 0;
972
+ const isMobile = useIsMobile();
973
+ const pickedIds = normalizeSelectedIds(selectedIds);
974
+ const pickedSet = pickedIds.length > 0 ? new Set(pickedIds) : null;
703
975
  const advancedSearchFields = useMemo(
704
976
  () => normalizeSearchFields(rawAdvancedSearchFields),
705
977
  [rawAdvancedSearchFields]
@@ -715,12 +987,18 @@ function SearchResults({
715
987
  );
716
988
  const applyViewConstraints = (candidate) => viewItems.some((v) => v.value === candidate) ? candidate : viewItems[0].value;
717
989
  const appliedSearch = searchParams.get("search") ?? "";
718
- const [view, setView] = useState(() => applyViewConstraints(initialView));
990
+ const [chosenView, setChosenView] = useState(() => readStoredView(title));
719
991
  const [search, setSearch] = useState(appliedSearch);
720
- const [sort, setSort] = useState(null);
721
992
  const [selected, setSelected] = useState(/* @__PURE__ */ new Set());
722
993
  const selectedRowsRef = useRef(/* @__PURE__ */ new Map());
723
- const activeView = applyViewConstraints(view);
994
+ const hasGridView = viewItems.some((v) => v.value === "grid");
995
+ const defaultView = isMobile && hasGridView ? "grid" : initialView;
996
+ const activeView = applyViewConstraints(chosenView ?? defaultView);
997
+ const chooseView = (next) => {
998
+ const view = applyViewConstraints(next);
999
+ writeStoredView(title, view);
1000
+ setChosenView(view);
1001
+ };
724
1002
  useEffect(() => {
725
1003
  setSearch(appliedSearch);
726
1004
  }, [appliedSearch]);
@@ -737,33 +1015,43 @@ function SearchResults({
737
1015
  onParamsChange(next);
738
1016
  };
739
1017
  const activeFilters = useMemo(() => {
740
- const entries = [];
1018
+ const entries2 = [];
741
1019
  for (const filter of [...leftFilters, ...rightFilters]) {
742
1020
  const values = splitMultiValue(searchParams.get(filter.key) ?? "");
743
- if (values.length > 0) entries.push({ key: filter.key, values });
1021
+ if (values.length > 0) entries2.push({ key: filter.key, values });
744
1022
  }
745
1023
  if (tabs && typeof tabs.key === "string") {
746
1024
  const values = splitMultiValue(searchParams.get(tabs.key) ?? "");
747
- if (values.length > 0) entries.push({ key: tabs.key, values });
1025
+ if (values.length > 0) entries2.push({ key: tabs.key, values });
748
1026
  }
749
- return entries;
1027
+ return entries2;
750
1028
  }, [leftFilters, rightFilters, tabs, searchParams]);
751
1029
  const hasAllRows = item.data.length >= item.total;
1030
+ const sortAllowed = (key) => hasAllRows || !(serverUnsortable === "all" || Array.isArray(serverUnsortable) && serverUnsortable.includes(key));
1031
+ const requestedSort = sortFromParams(searchParams);
1032
+ const sortKey = requestedSort && sortAllowed(requestedSort.key) ? requestedSort.key : null;
1033
+ const sortDir = requestedSort?.dir ?? "asc";
1034
+ const sort = useMemo(() => sortKey ? { key: sortKey, dir: sortDir } : null, [sortKey, sortDir]);
752
1035
  const processed = useMemo(() => {
753
1036
  const cell = (row, key) => fieldOf(row, key);
754
1037
  let rows = item.data;
755
- if (hasAllRows) {
1038
+ if (hasAllRows && !serverFiltered) {
1039
+ const source = item.data;
1040
+ const known = (key) => source.some((row) => cell(row, key) !== void 0);
756
1041
  if (appliedSearch) {
757
1042
  rows = rows.filter((row) => includesLooseSearch(localSearchText(row), appliedSearch));
758
1043
  }
759
1044
  for (const { key, values } of activeFilters) {
1045
+ if (!known(key)) continue;
760
1046
  rows = rows.filter((row) => values.includes(String(cell(row, key))));
761
1047
  }
762
1048
  for (const field of advancedSearchFields) {
763
1049
  if (field.type === "select") {
1050
+ if (!known(field.key)) continue;
764
1051
  const wanted = splitMultiValue(advancedValues[field.key] ?? "");
765
1052
  if (wanted.length > 0) rows = rows.filter((row) => wanted.includes(String(cell(row, field.key))));
766
1053
  } else if (field.type === "cascading") {
1054
+ if (!known(field.key)) continue;
767
1055
  const wanted = splitMultiValue(advancedValues[field.key] ?? "");
768
1056
  if (wanted.length > 0) {
769
1057
  const lineages = cascadingValuePaths(field.options);
@@ -774,12 +1062,14 @@ function SearchResults({
774
1062
  });
775
1063
  }
776
1064
  } else if (field.type === "dateRange") {
1065
+ if (!known(field.key)) continue;
777
1066
  const from = advancedValues[field.fromKey ?? `${field.key}From`];
778
1067
  const to = advancedValues[field.toKey ?? `${field.key}To`];
779
- const datePart = (v) => String(v ?? "").slice(0, 10);
1068
+ const datePart = (v) => isZonedIsoDateTime(v) ? formatDateValue(v, "date") : String(v ?? "").slice(0, 10);
780
1069
  if (from) rows = rows.filter((row) => datePart(cell(row, field.key)) >= from.slice(0, 10));
781
1070
  if (to) rows = rows.filter((row) => datePart(cell(row, field.key)) <= to.slice(0, 10));
782
1071
  } else if (field.type === "numberRange") {
1072
+ if (!known(field.key)) continue;
783
1073
  const min = numericValue(advancedValues[field.fromKey ?? `${field.key}Min`]);
784
1074
  const max = numericValue(advancedValues[field.toKey ?? `${field.key}Max`]);
785
1075
  if (min !== null) rows = rows.filter((row) => (numericValue(cell(row, field.key)) ?? -Infinity) >= min);
@@ -788,11 +1078,13 @@ function SearchResults({
788
1078
  const keyword = advancedValues[field.key];
789
1079
  const suffix = ["_contains", "Contains"].find((s) => field.key.endsWith(s));
790
1080
  const baseKey = suffix ? field.key.slice(0, -suffix.length) : field.key;
791
- if (keyword) rows = rows.filter((row) => includesLooseSearch(String(cell(row, baseKey) ?? ""), keyword));
1081
+ if (keyword && known(baseKey)) {
1082
+ rows = rows.filter((row) => includesLooseSearch(String(cell(row, baseKey) ?? ""), keyword));
1083
+ }
792
1084
  }
793
1085
  }
794
1086
  }
795
- if (sort) {
1087
+ if (sort && hasAllRows) {
796
1088
  const column = columns.find((c) => c.key === sort.key);
797
1089
  const dirFactor = sort.dir === "asc" ? 1 : -1;
798
1090
  rows = [...rows].sort((a, b) => {
@@ -807,7 +1099,7 @@ function SearchResults({
807
1099
  });
808
1100
  }
809
1101
  return rows;
810
- }, [item.data, hasAllRows, appliedSearch, activeFilters, advancedSearchFields, advancedValues, sort, columns]);
1102
+ }, [item.data, hasAllRows, serverFiltered, appliedSearch, activeFilters, advancedSearchFields, advancedValues, sort, columns]);
811
1103
  const clientPaginated = hasAllRows;
812
1104
  const totalCount = clientPaginated ? processed.length : item.total;
813
1105
  const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));
@@ -839,7 +1131,50 @@ function SearchResults({
839
1131
  });
840
1132
  };
841
1133
  const selectedItems = Array.from(selected).map((id) => selectedRowsRef.current.get(id)).filter((row) => row !== void 0);
1134
+ const paramsKey = searchParams.toString();
1135
+ const seenRef = useRef(null);
1136
+ const answersRef = useRef(/* @__PURE__ */ new Map());
1137
+ useEffect(() => {
1138
+ const settle = (key, data) => {
1139
+ const answers = answersRef.current;
1140
+ const nextIds = new Set(data.map((row) => row.id));
1141
+ const prev = answers.get(key);
1142
+ answers.delete(key);
1143
+ answers.set(key, nextIds);
1144
+ if (answers.size > 64) answers.delete(answers.keys().next().value);
1145
+ if (!prev) return;
1146
+ const vanished = [...prev].filter((id) => !nextIds.has(id));
1147
+ if (vanished.length === 0) return;
1148
+ vanished.forEach((id) => selectedRowsRef.current.delete(id));
1149
+ setSelected((current) => {
1150
+ let changed = false;
1151
+ const next = new Set(current);
1152
+ for (const id of vanished) if (next.delete(id)) changed = true;
1153
+ return changed ? next : current;
1154
+ });
1155
+ };
1156
+ const seen = seenRef.current;
1157
+ seenRef.current = { key: paramsKey, data: item.data };
1158
+ if (!seen) {
1159
+ settle(paramsKey, item.data);
1160
+ return;
1161
+ }
1162
+ if (seen.data === item.data) return;
1163
+ settle(paramsKey, item.data);
1164
+ }, [paramsKey, item.data]);
842
1165
  const ratioSum = columns.reduce((sum, c) => sum + (c.ratio ?? 0), 0);
1166
+ 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;
1167
+ const rowLabelOf = (row) => {
1168
+ const keys = [primaryKey, titleColumnKey, ...columns.map((c) => c.key)].filter(
1169
+ (key) => typeof key === "string" && key.length > 0
1170
+ );
1171
+ for (const key of keys) {
1172
+ const value = fieldOf(row, key);
1173
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
1174
+ if (typeof value === "string" && value.trim() && !looksLikeLocator(value.trim())) return value.trim();
1175
+ }
1176
+ return "\uD589";
1177
+ };
843
1178
  const tableColumns = [
844
1179
  ...hasActions ? [
845
1180
  {
@@ -854,6 +1189,7 @@ function SearchResults({
854
1189
  }
855
1190
  ),
856
1191
  width: "36px",
1192
+ label: "",
857
1193
  render: (row) => (
858
1194
  // 셀 어디를 눌러도 행 클릭(onRowClick)으로 번지지 않게 래퍼에서 전파를 끊는다
859
1195
  /* @__PURE__ */ jsx("span", { onClick: (e) => e.stopPropagation(), children: /* @__PURE__ */ jsx(
@@ -861,7 +1197,7 @@ function SearchResults({
861
1197
  {
862
1198
  checked: selected.has(row.id),
863
1199
  onChange: (e) => toggleSelect(row, e.target.checked),
864
- "aria-label": `${row.id} \uC120\uD0DD`
1200
+ "aria-label": `${rowLabelOf(row)} \uC120\uD0DD`
865
1201
  }
866
1202
  ) })
867
1203
  )
@@ -870,7 +1206,9 @@ function SearchResults({
870
1206
  ...columns.map((column) => ({
871
1207
  key: column.key,
872
1208
  header: column.title,
873
- sortable: column.sortable,
1209
+ label: column.title,
1210
+ mobile: column.mobile ?? (column.key === titleColumnKey ? "title" : void 0),
1211
+ sortable: column.sortable && sortAllowed(column.key),
874
1212
  align: column.textAlign === "right" ? "right" : "left",
875
1213
  mono: column.mono,
876
1214
  strong: column.strong,
@@ -879,9 +1217,51 @@ function SearchResults({
879
1217
  }))
880
1218
  ];
881
1219
  const handleSort = (key) => {
882
- setSort((prev) => prev?.key === key ? prev.dir === "asc" ? { key, dir: "desc" } : null : { key, dir: "asc" });
1220
+ const next = nextSort(sort, key);
1221
+ updateParams({ [SORT_PARAM]: next?.key ?? null, [ORDER_PARAM]: next?.dir ?? null, page: null });
883
1222
  };
884
- const empty = /* @__PURE__ */ jsx(ResultEmptyState, { title });
1223
+ const sortChoices = columns.filter((column) => column.sortable && sortAllowed(column.key));
1224
+ const mobileSort = isMobile && sortChoices.length > 0 ? /* @__PURE__ */ jsx(
1225
+ Select,
1226
+ {
1227
+ size: "sm",
1228
+ "aria-label": `${title} \uC815\uB82C`,
1229
+ value: sort ? `${sort.key}:${sort.dir}` : "",
1230
+ options: [
1231
+ { value: "", label: "\uAE30\uBCF8 \uC815\uB82C" },
1232
+ ...sortChoices.flatMap((column) => [
1233
+ { value: `${column.key}:asc`, label: `${column.title} \uC624\uB984\uCC28\uC21C` },
1234
+ { value: `${column.key}:desc`, label: `${column.title} \uB0B4\uB9BC\uCC28\uC21C` }
1235
+ ])
1236
+ ],
1237
+ onChange: (e) => {
1238
+ const cut = e.target.value.lastIndexOf(":");
1239
+ const key = cut > 0 ? e.target.value.slice(0, cut) : "";
1240
+ const dir = e.target.value.slice(cut + 1) === "desc" ? "desc" : "asc";
1241
+ updateParams({ [SORT_PARAM]: key || null, [ORDER_PARAM]: key ? dir : null, page: null });
1242
+ }
1243
+ }
1244
+ ) : null;
1245
+ const chipKeys = [...leftFilters, ...rightFilters].map((filter) => filter.key);
1246
+ const filtered = Boolean(appliedSearch) || Object.values(advancedValues).some((value) => Boolean(value)) || chipKeys.some((key) => Boolean(searchParams.get(key)));
1247
+ const scoped = Boolean(tabs && typeof tabs.key === "string" && searchParams.get(tabs.key));
1248
+ const resetConditions = () => {
1249
+ const patch = { search: null, page: null };
1250
+ for (const key of searchFieldKeys(advancedSearchFields)) patch[key] = null;
1251
+ for (const key of chipKeys) patch[key] = null;
1252
+ updateParams(patch);
1253
+ };
1254
+ const empty = /* @__PURE__ */ jsx(
1255
+ ResultEmptyState,
1256
+ {
1257
+ title,
1258
+ filtered,
1259
+ scoped,
1260
+ emptyTitle,
1261
+ emptyText,
1262
+ onReset: resetConditions
1263
+ }
1264
+ );
885
1265
  return /* @__PURE__ */ jsxs("div", { className: ["wp-col", className].filter(Boolean).join(" "), style, "aria-label": title, children: [
886
1266
  summary ?? null,
887
1267
  tabs && Array.isArray(tabs.items) && tabs.items.length > 0 ? /* @__PURE__ */ jsx(
@@ -921,9 +1301,10 @@ function SearchResults({
921
1301
  {
922
1302
  items: viewItems,
923
1303
  value: activeView,
924
- onChange: (next) => setView(applyViewConstraints(next))
1304
+ onChange: (next) => chooseView(next)
925
1305
  }
926
1306
  ) : null,
1307
+ mobileSort,
927
1308
  headerActions
928
1309
  ] })
929
1310
  ] }) : null,
@@ -1000,11 +1381,21 @@ function SearchResults({
1000
1381
  onSort: handleSort,
1001
1382
  onRowClick,
1002
1383
  selectedIds: hasActions ? Array.from(selected) : void 0,
1384
+ pickedIds: pickedSet ? pickedIds : void 0,
1003
1385
  empty,
1004
1386
  virtual: virtualListActive
1005
1387
  }
1006
- ) : 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)) }),
1007
- virtualListActive && clientPaginated ? null : /* @__PURE__ */ jsxs("div", { className: "wp-row wp-row--between", children: [
1388
+ ) : visibleItems.length === 0 ? empty : /* @__PURE__ */ jsx("div", { className: "wp-grid wp-grid--3", children: visibleItems.map((row) => /* @__PURE__ */ jsx(
1389
+ GridCard,
1390
+ {
1391
+ item: row,
1392
+ config: gridConfig,
1393
+ onClick: onRowClick,
1394
+ picked: pickedSet ? pickedSet.has(String(row.id)) : false
1395
+ },
1396
+ row.id
1397
+ )) }),
1398
+ virtualListActive && clientPaginated || totalCount === 0 && !isLoading ? null : /* @__PURE__ */ jsxs("div", { className: "wp-row wp-row--between", children: [
1008
1399
  /* @__PURE__ */ jsxs("div", { className: "wp-row", style: { flex: "none" }, children: [
1009
1400
  /* @__PURE__ */ jsx("span", { className: "wp-caption", style: { whiteSpace: "nowrap" }, children: "\uD398\uC774\uC9C0\uB2F9" }),
1010
1401
  /* @__PURE__ */ jsx(
@@ -1046,13 +1437,17 @@ function formatCell(value) {
1046
1437
  if (value instanceof Date) return formatDateCellValue(value);
1047
1438
  return value;
1048
1439
  }
1440
+ function blankAsDash(value) {
1441
+ return value === null || value === void 0 || value === "" ? /* @__PURE__ */ jsx("span", { style: { color: "var(--wp-text-subtle)" }, children: "\u2014" }) : value;
1442
+ }
1049
1443
  function resolveRowPart(part, item) {
1050
1444
  return typeof part === "function" ? part(item) : part;
1051
1445
  }
1052
1446
  function GridCard({
1053
1447
  item,
1054
1448
  config,
1055
- onClick
1449
+ onClick,
1450
+ picked = false
1056
1451
  }) {
1057
1452
  if (!config) return null;
1058
1453
  const badge = config.badge?.(item) ?? null;
@@ -1061,7 +1456,7 @@ function GridCard({
1061
1456
  return /* @__PURE__ */ jsxs("div", { className: "wp-row", children: [
1062
1457
  resolveRowPart(row.icon, item),
1063
1458
  row.label != null ? /* @__PURE__ */ jsx("span", { className: "wp-caption", children: resolveRowPart(row.label, item) }) : null,
1064
- /* @__PURE__ */ jsx("span", { className: "wp-body-sm", children: resolveRowPart(row.value, item) })
1459
+ /* @__PURE__ */ jsx("span", { className: "wp-body-sm", children: blankAsDash(resolveRowPart(row.value, item)) })
1065
1460
  ] }, idx);
1066
1461
  }) });
1067
1462
  const note = config.note?.(item);
@@ -1069,6 +1464,7 @@ function GridCard({
1069
1464
  Card,
1070
1465
  {
1071
1466
  interactive: Boolean(onClick),
1467
+ ...picked ? { selected: true, className: "is-selected", "aria-current": "true" } : {},
1072
1468
  cover: config.cover?.(item),
1073
1469
  title: config.title(item),
1074
1470
  subtitle: config.subtitle?.(item),
@@ -1130,6 +1526,22 @@ function FilterMultiSelect({
1130
1526
  function toList(action) {
1131
1527
  return Array.isArray(action) ? action : [action];
1132
1528
  }
1529
+ function conditionParams(params) {
1530
+ const next = new URLSearchParams(params);
1531
+ next.delete("page");
1532
+ next.delete("limit");
1533
+ return next.toString();
1534
+ }
1535
+ function useQueryStateMirror(dispatch, key, params) {
1536
+ const value = conditionParams(params);
1537
+ const lastRef = useRef(null);
1538
+ useEffect(() => {
1539
+ if (typeof key !== "string" || key === "") return;
1540
+ if (lastRef.current === value) return;
1541
+ lastRef.current = value;
1542
+ dispatch({ action: "setState", key, value });
1543
+ }, [dispatch, key, value]);
1544
+ }
1133
1545
  function mapColumns(columns, dispatch) {
1134
1546
  return (columns ?? []).map((column) => ({
1135
1547
  key: column.key,
@@ -1139,6 +1551,7 @@ function mapColumns(columns, dispatch) {
1139
1551
  ratio: column.ratio,
1140
1552
  mono: column.mono,
1141
1553
  strong: column.strong,
1554
+ mobile: column.mobile === "title" || column.mobile === "hide" ? column.mobile : void 0,
1142
1555
  render: cellRenderer(column.cell, column.key, dispatch)
1143
1556
  }));
1144
1557
  }
@@ -1240,11 +1653,16 @@ function SduiSearchResultsImpl({
1240
1653
  searchPlaceholder,
1241
1654
  rowClick,
1242
1655
  headerActions,
1243
- virtual
1656
+ virtual,
1657
+ emptyTitle,
1658
+ emptyText,
1659
+ queryStateKey,
1660
+ selectedIds
1244
1661
  }) {
1245
1662
  const dispatch = useSduiDispatch();
1246
1663
  const [params, setParams] = useState(() => new URLSearchParams());
1247
1664
  const rows = Array.isArray(data) ? data : [];
1665
+ useQueryStateMirror(dispatch, queryStateKey, params);
1248
1666
  return /* @__PURE__ */ jsx(
1249
1667
  SearchResults,
1250
1668
  {
@@ -1265,6 +1683,10 @@ function SduiSearchResultsImpl({
1265
1683
  searchPlaceholder,
1266
1684
  headerActions,
1267
1685
  virtual,
1686
+ primaryKey: gridCard?.titleKey,
1687
+ emptyTitle,
1688
+ emptyText,
1689
+ selectedIds,
1268
1690
  onRowClick: rowClick ? (row) => toList(rowClick).forEach((a) => dispatch(a, row)) : void 0,
1269
1691
  searchParams: params,
1270
1692
  onParamsChange: setParams
@@ -1293,7 +1715,11 @@ function RemoteSearchResultsImpl({
1293
1715
  pageSize,
1294
1716
  searchParams: controlledParams,
1295
1717
  onParamsChange,
1296
- summary
1718
+ summary,
1719
+ emptyTitle,
1720
+ emptyText,
1721
+ queryStateKey,
1722
+ selectedIds
1297
1723
  }) {
1298
1724
  const dispatch = useSduiDispatch();
1299
1725
  const defaultPageSize = normalizePageSizes(pageSizes)?.[0] ?? 10;
@@ -1313,8 +1739,18 @@ function RemoteSearchResultsImpl({
1313
1739
  if (!next.has("limit")) next.set("limit", String(defaultPageSize));
1314
1740
  return next;
1315
1741
  }, [params, defaultPageSize]);
1316
- const { page, isLoading, error, reload } = useRemoteSearchPage(endpoint, query);
1742
+ const { page, pageQuery, isLoading, error, reload } = useRemoteSearchPage(endpoint, query);
1743
+ const requestedSort = sortFromParams(params);
1744
+ const queryString = query.toString();
1745
+ const settledForQuery = pageQuery === queryString;
1746
+ useEffect(() => {
1747
+ if (!requestedSort || !settledForQuery || page.data.length >= page.total) return;
1748
+ if (!serverSortMemory.record(endpoint, requestedSort, page.sort ?? null)) {
1749
+ handleParamsChange(withoutSort(params));
1750
+ }
1751
+ }, [page, settledForQuery, endpoint, requestedSort?.key, requestedSort?.dir]);
1317
1752
  const summaryQuery = useMemo(() => summaryParams(params), [params]);
1753
+ useQueryStateMirror(dispatch, queryStateKey, params);
1318
1754
  const summaryState = useRemoteSearchSummary(
1319
1755
  summary && typeof summary.endpoint === "string" ? summary.endpoint : null,
1320
1756
  summaryQuery
@@ -1351,6 +1787,12 @@ function RemoteSearchResultsImpl({
1351
1787
  headerActions,
1352
1788
  virtual,
1353
1789
  isLoading,
1790
+ serverUnsortable: serverSortMemory.unsortable(endpoint),
1791
+ serverFiltered: true,
1792
+ primaryKey: gridCard?.titleKey,
1793
+ emptyTitle,
1794
+ emptyText,
1795
+ selectedIds,
1354
1796
  summary: summary ? /* @__PURE__ */ jsx(SearchSummaryCards, { items: summary.items, values: summaryState.values, dispatch }) : void 0,
1355
1797
  onRowClick: rowClick ? (row) => toList(rowClick).forEach((a) => dispatch(a, row)) : void 0,
1356
1798
  searchParams: params,
@@ -1360,12 +1802,6 @@ function RemoteSearchResultsImpl({
1360
1802
  }
1361
1803
  var SduiSearchResults = boxed(SduiSearchResultsImpl, "SduiSearchResults");
1362
1804
  var RemoteSearchResults = boxed(RemoteSearchResultsImpl, "RemoteSearchResults");
1363
- var SduiRuntimeContext = createContext(null);
1364
- function useSduiRuntime() {
1365
- const runtime = useContext(SduiRuntimeContext);
1366
- if (!runtime) throw new Error("useSduiRuntime\uC740 SduiRenderer \uB0B4\uBD80\uC5D0\uC11C\uB9CC \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.");
1367
- return runtime;
1368
- }
1369
1805
  function prefillParams(search, hash) {
1370
1806
  const params = new URLSearchParams(search);
1371
1807
  const q = hash.indexOf("?");
@@ -1402,21 +1838,21 @@ function SduiForm({
1402
1838
  return JSON.stringify(keys.map((key) => [key, state[key]]));
1403
1839
  };
1404
1840
  useEffect(() => {
1405
- const entries = {};
1841
+ const entries2 = {};
1406
1842
  for (const [field, value] of Object.entries(initial ?? {})) {
1407
- if (runtime.state[`${id}.${field}`] === void 0) entries[`${id}.${field}`] = value;
1843
+ if (runtime.state[`${id}.${field}`] === void 0) entries2[`${id}.${field}`] = value;
1408
1844
  }
1409
1845
  if (initialFromUrl && typeof window !== "undefined") {
1410
1846
  const params = prefillParams(window.location.search, window.location.hash);
1411
1847
  for (const [field, param] of Object.entries(initialFromUrl)) {
1412
1848
  const raw = params.get(param);
1413
1849
  if (raw !== null && runtime.state[`${id}.${field}`] === void 0) {
1414
- entries[`${id}.${field}`] = raw;
1850
+ entries2[`${id}.${field}`] = raw;
1415
1851
  }
1416
1852
  }
1417
1853
  }
1418
- if (Object.keys(entries).length > 0) runtime.setEntries(entries);
1419
- initialSnapshot.current = sliceOf({ ...runtime.state, ...entries });
1854
+ if (Object.keys(entries2).length > 0) runtime.setEntries(entries2);
1855
+ initialSnapshot.current = sliceOf({ ...runtime.state, ...entries2 });
1420
1856
  }, [id]);
1421
1857
  useEffect(() => {
1422
1858
  return () => {
@@ -1437,6 +1873,25 @@ function SduiForm({
1437
1873
  }
1438
1874
  }, [slice, id, runtime]);
1439
1875
  const dirty = Boolean(runtime.state[`${id}.$dirty`]);
1876
+ const latestState = useRef(runtime.state);
1877
+ latestState.current = runtime.state;
1878
+ const dirtyRegistry = useContext(SduiDirtyRegistryContext);
1879
+ useEffect(
1880
+ () => dirtyRegistry?.register(
1881
+ () => initialSnapshot.current !== null && sliceOf(latestState.current) !== initialSnapshot.current
1882
+ ),
1883
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1884
+ [dirtyRegistry, id]
1885
+ );
1886
+ const leaveRegistry = useContext(SduiLeaveRegistryContext);
1887
+ useEffect(() => {
1888
+ if (!guard || !leaveRegistry) return;
1889
+ return leaveRegistry.register(
1890
+ id,
1891
+ () => initialSnapshot.current !== null && sliceOf(latestState.current) !== initialSnapshot.current
1892
+ );
1893
+ }, [guard, leaveRegistry, id]);
1894
+ const scope = useMemo(() => ({ id, rules: JSON.parse(rulesJson) }), [id, rulesJson]);
1440
1895
  useEffect(() => {
1441
1896
  if (!guard || !dirty) return;
1442
1897
  const onBeforeUnload = (event) => {
@@ -1451,12 +1906,13 @@ function SduiForm({
1451
1906
  {
1452
1907
  className: ["wp-col", className].filter(Boolean).join(" "),
1453
1908
  style,
1909
+ "data-sdui-form": id,
1454
1910
  onSubmit: (event) => {
1455
1911
  event.preventDefault();
1456
1912
  onSubmit?.(event);
1457
1913
  },
1458
1914
  noValidate: true,
1459
- children
1915
+ children: /* @__PURE__ */ jsx(SduiFormScopeContext.Provider, { value: scope, children })
1460
1916
  }
1461
1917
  );
1462
1918
  }
@@ -1798,6 +2254,9 @@ function withProps(Component, defaults) {
1798
2254
  function deferChainEntry(container, key) {
1799
2255
  return typeof container.action === "string" && (key === "onSuccess" || key === "onError");
1800
2256
  }
2257
+ function deferEachTarget(container, key) {
2258
+ return container.action === "request" && container.each !== void 0 && (key === "endpoint" || key === "body");
2259
+ }
1801
2260
  function failureDetailOf(error) {
1802
2261
  const message = error instanceof Error ? error.message : "\uC694\uCCAD\uC774 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4";
1803
2262
  const carrier = error;
@@ -1817,12 +2276,14 @@ function runSuccessChain(kind, id, run) {
1817
2276
  );
1818
2277
  }
1819
2278
  }
2279
+ 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";
1820
2280
  async function runRequestAction(action, deps) {
2281
+ if (action.each !== void 0) return runRequestEach(action, deps);
1821
2282
  const pendingKey = typeof action.pendingKey === "string" && action.pendingKey !== "" ? action.pendingKey : null;
1822
2283
  let pendingOn = false;
1823
2284
  try {
1824
2285
  if (typeof action.endpoint !== "string" || action.endpoint === "") {
1825
- 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");
2286
+ throw new Error(EMPTY_ENDPOINT_MESSAGE);
1826
2287
  }
1827
2288
  if (pendingKey) {
1828
2289
  pendingOn = true;
@@ -1856,6 +2317,91 @@ async function runRequestAction(action, deps) {
1856
2317
  if (pendingKey && pendingOn) deps.dispatch({ action: "setState", key: pendingKey, value: false });
1857
2318
  }
1858
2319
  }
2320
+ var REQUEST_EACH_CONCURRENCY = 3;
2321
+ async function mapWithLimit(items, limit, fn) {
2322
+ const results = new Array(items.length);
2323
+ let next = 0;
2324
+ const worker = async () => {
2325
+ while (next < items.length) {
2326
+ const index = next;
2327
+ next += 1;
2328
+ results[index] = await fn(items[index], index);
2329
+ }
2330
+ };
2331
+ const width = Math.max(1, Math.min(limit, items.length));
2332
+ await Promise.all(Array.from({ length: width }, () => worker()));
2333
+ return results;
2334
+ }
2335
+ function eachFailureMessage(total, failed, first) {
2336
+ const head = `${total}\uAC74 \uC911 ${failed}\uAC74 \uC2E4\uD328`;
2337
+ return first ? `${head} \u2014 ${first}` : head;
2338
+ }
2339
+ async function runRequestEach(action, deps) {
2340
+ const list = action.each;
2341
+ if (!Array.isArray(list) || list.length === 0) {
2342
+ warnOnce(
2343
+ "request:each:empty",
2344
+ "[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)",
2345
+ "warn"
2346
+ );
2347
+ return;
2348
+ }
2349
+ const pendingKey = typeof action.pendingKey === "string" && action.pendingKey !== "" ? action.pendingKey : null;
2350
+ const resolveFor = deps.resolveFor ?? ((value) => value);
2351
+ const method = action.method ?? "POST";
2352
+ if (pendingKey) deps.dispatch({ action: "setState", key: pendingKey, value: true });
2353
+ try {
2354
+ const outcomes = await mapWithLimit(list, REQUEST_EACH_CONCURRENCY, async (item, index) => {
2355
+ try {
2356
+ const endpoint = resolveFor(action.endpoint, item, index);
2357
+ if (typeof endpoint !== "string" || endpoint === "") throw new Error(EMPTY_ENDPOINT_MESSAGE);
2358
+ const body = action.body === void 0 ? void 0 : resolveFor(action.body, item, index);
2359
+ const value = await deps.fetch(endpoint, { method, ...body === void 0 ? {} : { body } });
2360
+ return { ok: true, value };
2361
+ } catch (error) {
2362
+ return { ok: false, error };
2363
+ }
2364
+ });
2365
+ const errors = [];
2366
+ outcomes.forEach((outcome, index) => {
2367
+ if (!outcome.ok) errors.push({ index, item: list[index], ...failureDetailOf(outcome.error) });
2368
+ });
2369
+ const succeeded = outcomes.length - errors.length;
2370
+ const invalidate = typeof action.invalidate === "string" && action.invalidate !== "" ? action.invalidate : null;
2371
+ if (errors.length === 0) {
2372
+ runSuccessChain("request", `each:${list.length}`, () => {
2373
+ if (invalidate) deps.dispatch({ action: "invalidate", prefix: invalidate });
2374
+ const responses = outcomes.map((outcome) => outcome.ok ? outcome.value : null);
2375
+ (action.onSuccess ?? []).forEach((a) => deps.dispatch(a, responses));
2376
+ });
2377
+ return;
2378
+ }
2379
+ if (succeeded > 0 && invalidate) {
2380
+ runSuccessChain("request", `each:${list.length}`, () => deps.dispatch({ action: "invalidate", prefix: invalidate }));
2381
+ }
2382
+ const failure = {
2383
+ failed: errors.length,
2384
+ succeeded,
2385
+ total: list.length,
2386
+ message: eachFailureMessage(list.length, errors.length, errors[0]?.message),
2387
+ errors
2388
+ };
2389
+ if (typeof action.errorKey === "string" && action.errorKey !== "") {
2390
+ deps.dispatch({ action: "setState", key: action.errorKey, value: failure });
2391
+ }
2392
+ if (action.onError?.length) {
2393
+ action.onError.forEach((a) => deps.dispatch(a, failure));
2394
+ } else {
2395
+ deps.toast({
2396
+ tone: "danger",
2397
+ title: `${list.length}\uAC74 \uC911 ${errors.length}\uAC74 \uC2E4\uD328`,
2398
+ message: errors[0]?.message
2399
+ });
2400
+ }
2401
+ } finally {
2402
+ if (pendingKey) deps.dispatch({ action: "setState", key: pendingKey, value: false });
2403
+ }
2404
+ }
1859
2405
 
1860
2406
  // src/state-path.ts
1861
2407
  var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
@@ -2292,6 +2838,33 @@ function nearestArgsFrame(scope) {
2292
2838
  }
2293
2839
  return void 0;
2294
2840
  }
2841
+ function toastText(value) {
2842
+ if (typeof value === "string") return value;
2843
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
2844
+ if (isPlainObject(value) && typeof value.message === "string") return value.message;
2845
+ return void 0;
2846
+ }
2847
+ 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.";
2848
+ function leaveMessageOf(value) {
2849
+ if (typeof value === "string") return value.trim() === "" ? null : value;
2850
+ return value ? DEFAULT_LEAVE_MESSAGE : null;
2851
+ }
2852
+ function titleText(value) {
2853
+ if (typeof value === "string") return value.trim() === "" ? null : value;
2854
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
2855
+ return null;
2856
+ }
2857
+ function collectFormValues(state, formId) {
2858
+ const prefix = `${formId}.`;
2859
+ const values = {};
2860
+ for (const [key, value] of Object.entries(state)) {
2861
+ if (key.startsWith(prefix) && !key.slice(prefix.length).startsWith("$") && value !== void 0) {
2862
+ values[key.slice(prefix.length)] = value;
2863
+ }
2864
+ }
2865
+ return values;
2866
+ }
2867
+ 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}}";
2295
2868
  function hasExplicitPageHead(body) {
2296
2869
  return Array.isArray(body) && body.some((child) => isPlainObject(child) && child.component === "PageHead");
2297
2870
  }
@@ -2369,45 +2942,76 @@ function toIndex(v) {
2369
2942
  function inRange(index, length) {
2370
2943
  return index !== null && index >= 0 && index < length;
2371
2944
  }
2372
- function validateForm(values, config, customValidators) {
2373
- const errors = {};
2374
- if (!config) return errors;
2945
+ var CHOICE_KINDS = /* @__PURE__ */ new Set([
2946
+ "Select",
2947
+ "AutocompleteField",
2948
+ "PositionSelect",
2949
+ "TagMultiSelect",
2950
+ "TreePicker",
2951
+ "SearchableMultiPicker",
2952
+ "RoleMemberPicker",
2953
+ "SegmentedControl",
2954
+ "Radio",
2955
+ "DatePicker",
2956
+ "DateRangePicker",
2957
+ "TimePicker"
2958
+ ]);
2959
+ function requiredVerb(kind) {
2960
+ if (kind && CHOICE_KINDS.has(kind)) return "\uC120\uD0DD\uD558\uC138\uC694";
2961
+ if (kind === "FileDropzone") return "\uCCA8\uBD80\uD558\uC138\uC694";
2962
+ if (kind === "Checkbox" || kind === "Switch") return "\uD655\uC778\uD558\uC138\uC694";
2963
+ return "\uC785\uB825\uD558\uC138\uC694";
2964
+ }
2965
+ function validateForm(values, config, customValidators, fieldInfo) {
2966
+ const out = { errors: {}, failed: {} };
2967
+ if (!config) return out;
2968
+ const labelOf2 = (field) => config.labels[field] ?? fieldInfo?.get(field)?.label;
2375
2969
  for (const [field, rules] of Object.entries(config.rules)) {
2376
2970
  const value = values[field];
2377
- const label = config.labels[field] ?? field;
2971
+ const context = {
2972
+ label: labelOf2(field),
2973
+ kind: fieldInfo?.get(field)?.kind,
2974
+ labelOf: labelOf2
2975
+ };
2378
2976
  for (const rule of rules) {
2379
- const message = ruleError(rule, value, values, label, customValidators);
2977
+ const message = ruleError(rule, value, values, context, customValidators);
2380
2978
  if (message) {
2381
- errors[field] = message;
2979
+ out.errors[field] = message;
2980
+ out.failed[field] = rule.rule;
2382
2981
  break;
2383
2982
  }
2384
2983
  }
2385
2984
  }
2386
- return errors;
2985
+ return out;
2387
2986
  }
2388
- function ruleError(rule, value, values, label, customValidators) {
2987
+ function ruleError(rule, value, values, { label, kind, labelOf: labelOf2 }, customValidators) {
2389
2988
  const empty = value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0 || // DateRangePicker의 {} 같은 "값 없는 객체"도 미입력으로 본다
2390
2989
  typeof value === "object" && !Array.isArray(value) && Object.values(value).every((v) => v === void 0 || v === null || v === "");
2990
+ const subject = (pair) => label ? `${josa(label, pair)} ` : "";
2391
2991
  switch (rule.rule) {
2392
2992
  case "required":
2393
- return empty || value === false ? rule.message ?? `${label}\uC744(\uB97C) \uC785\uB825\uD558\uC138\uC694` : null;
2993
+ return empty || value === false ? rule.message ?? (label ? `${josa(label, "\uC744/\uB97C")} ${requiredVerb(kind)}` : "\uD544\uC218 \uD56D\uBAA9\uC785\uB2C8\uB2E4") : null;
2394
2994
  case "min":
2395
- return !empty && toNumber(value) < rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value} \uC774\uC0C1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` : null;
2995
+ return !empty && toNumber(value) < rule.value ? rule.message ?? `${subject("\uC740/\uB294")}${rule.value} \uC774\uC0C1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` : null;
2396
2996
  case "max":
2397
- return !empty && toNumber(value) > rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value} \uC774\uD558\uC5EC\uC57C \uD569\uB2C8\uB2E4` : null;
2997
+ return !empty && toNumber(value) > rule.value ? rule.message ?? `${subject("\uC740/\uB294")}${rule.value} \uC774\uD558\uC5EC\uC57C \uD569\uB2C8\uB2E4` : null;
2398
2998
  case "minLength":
2399
- return !empty && String(value).length < rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value}\uC790 \uC774\uC0C1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` : null;
2999
+ return !empty && String(value).length < rule.value ? rule.message ?? `${subject("\uC740/\uB294")}${rule.value}\uC790 \uC774\uC0C1\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` : null;
2400
3000
  case "maxLength":
2401
- return !empty && String(value).length > rule.value ? rule.message ?? `${label}\uC740(\uB294) ${rule.value}\uC790 \uC774\uD558\uC5EC\uC57C \uD569\uB2C8\uB2E4` : null;
3001
+ return !empty && String(value).length > rule.value ? rule.message ?? `${subject("\uC740/\uB294")}${rule.value}\uC790 \uC774\uD558\uC5EC\uC57C \uD569\uB2C8\uB2E4` : null;
2402
3002
  case "pattern":
2403
- return !empty && !new RegExp(rule.value).test(String(value)) ? rule.message ?? `${label} \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4` : null;
3003
+ 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;
2404
3004
  case "notBeforeField": {
2405
3005
  const other = values[rule.field];
2406
3006
  if (empty || other === void 0 || other === null || other === "") return null;
2407
3007
  const a = Number(String(value).replace(/,/g, ""));
2408
3008
  const b = Number(String(other).replace(/,/g, ""));
2409
3009
  const before = Number.isFinite(a) && Number.isFinite(b) && String(value).trim() !== "" ? a < b : String(value) < String(other);
2410
- return before ? rule.message ?? `${label}\uC774(\uAC00) \uC21C\uC11C\uC5D0 \uB9DE\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4` : null;
3010
+ if (!before) return null;
3011
+ if (rule.message) return rule.message;
3012
+ const otherLabel = labelOf2(rule.field);
3013
+ if (label && otherLabel) return `${josa(label, "\uC740/\uB294")} ${otherLabel}\uBCF4\uB2E4 \uC55E\uC124 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4`;
3014
+ return `${subject("\uC774/\uAC00")}\uC21C\uC11C\uC5D0 \uB9DE\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4`;
2411
3015
  }
2412
3016
  case "custom": {
2413
3017
  const validator = customValidators?.[rule.name];
@@ -2420,6 +3024,122 @@ function ruleError(rule, value, values, label, customValidators) {
2420
3024
  }
2421
3025
  }
2422
3026
  }
3027
+ function invalidSummary(result, labelOf2, order = []) {
3028
+ const rank = (field) => {
3029
+ const index = order.indexOf(field);
3030
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index;
3031
+ };
3032
+ const fields = Object.keys(result.errors).sort((a, b) => rank(a) - rank(b));
3033
+ const count = fields.length;
3034
+ const allRequired = fields.every((field) => result.failed[field] === "required");
3035
+ const labels = fields.map(labelOf2).filter((l) => typeof l === "string" && l !== "");
3036
+ if (count === 1 && labels.length === 1) return { title: result.errors[fields[0]] };
3037
+ 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`;
3038
+ if (labels.length === 0) return { title, message: count === 1 ? void 0 : result.errors[fields[0]] };
3039
+ const shown = labels.slice(0, 4).join(", ");
3040
+ const rest = labels.length - 4;
3041
+ return { title, message: rest > 0 ? `${shown} \uC678 ${rest}\uAC1C` : shown };
3042
+ }
3043
+ var FOCUSABLE_IN_FIELD = 'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])';
3044
+ function formElementOf(formId) {
3045
+ if (typeof document === "undefined") return void 0;
3046
+ return Array.from(document.querySelectorAll("form[data-sdui-form]")).find(
3047
+ (el) => el.getAttribute("data-sdui-form") === formId && el.isConnected
3048
+ );
3049
+ }
3050
+ function fieldOrderOf(formId) {
3051
+ const form = formElementOf(formId);
3052
+ if (!form) return [];
3053
+ return Array.from(form.querySelectorAll("[data-sdui-field]")).map(
3054
+ (el) => el.getAttribute("data-sdui-field") ?? ""
3055
+ );
3056
+ }
3057
+ function focusFirstInvalid(formId, fields) {
3058
+ const form = formElementOf(formId);
3059
+ if (!form) return;
3060
+ const wanted = new Set(fields);
3061
+ const candidates = form.querySelectorAll('[data-sdui-field], [aria-invalid]:not([aria-invalid="false"])');
3062
+ const hit = Array.from(candidates).find((el) => {
3063
+ const field = el.getAttribute("data-sdui-field");
3064
+ return field !== null ? wanted.has(field) : true;
3065
+ });
3066
+ if (!hit) return;
3067
+ const target = hit.matches(FOCUSABLE_IN_FIELD) ? hit : hit.querySelector(FOCUSABLE_IN_FIELD);
3068
+ hit.scrollIntoView?.({ block: "center", inline: "nearest" });
3069
+ target?.focus({ preventScroll: true });
3070
+ }
3071
+ var INVALID_PROP_COMPONENTS = /* @__PURE__ */ new Set([
3072
+ "Input",
3073
+ "Textarea",
3074
+ "Select",
3075
+ "DatePicker",
3076
+ "DateRangePicker",
3077
+ "TimePicker",
3078
+ "AutocompleteField",
3079
+ "JsonField",
3080
+ "PositionSelect"
3081
+ ]);
3082
+ var BINDING_PROPS = ["value", "checked", "values"];
3083
+ function formKeyOf(value, formId) {
3084
+ if (!isStateRef(value)) return null;
3085
+ const prefix = `${formId}.`;
3086
+ if (!value.$state.startsWith(prefix)) return null;
3087
+ const key = value.$state.slice(prefix.length);
3088
+ return key === "" || key.startsWith("$") ? null : key;
3089
+ }
3090
+ function boundInputOf(node, formId, depth = 0) {
3091
+ if (!Array.isArray(node.children)) return null;
3092
+ for (const child of node.children) {
3093
+ if (!isPlainObject(child)) continue;
3094
+ const childNode = child;
3095
+ const props = isPlainObject(childNode.props) ? childNode.props : {};
3096
+ for (const name of BINDING_PROPS) {
3097
+ const key = formKeyOf(props[name], formId);
3098
+ if (key) return { key, kind: String(childNode.component) };
3099
+ }
3100
+ const component = childNode.component;
3101
+ if (component === "Field" || component === "Form" || component === "ForEach" || component === "Fragment") continue;
3102
+ if (depth < 4) {
3103
+ const found = boundInputOf(childNode, formId, depth + 1);
3104
+ if (found) return found;
3105
+ }
3106
+ }
3107
+ return null;
3108
+ }
3109
+ function hasRequiredRule(rules) {
3110
+ return Array.isArray(rules) && rules.some((rule) => isPlainObject(rule) && rule.rule === "required");
3111
+ }
3112
+ function applyFormBinding(node, props, form, engine) {
3113
+ const raw = isPlainObject(node.props) ? node.props : {};
3114
+ const errorOf = (key) => {
3115
+ const errors = engine.resolveValue({ $state: `${form.id}.$errors` }, EMPTY_SCOPE);
3116
+ const message = isPlainObject(errors) ? errors[key] : void 0;
3117
+ return typeof message === "string" && message !== "" ? message : void 0;
3118
+ };
3119
+ if (node.component === "Field") {
3120
+ const bound = boundInputOf(node, form.id);
3121
+ if (!bound) return;
3122
+ if (!Object.hasOwn(raw, "required") && hasRequiredRule(form.rules[bound.key])) props.required = true;
3123
+ if (!Object.hasOwn(raw, "error")) {
3124
+ const message = errorOf(bound.key);
3125
+ if (message) props.error = message;
3126
+ }
3127
+ props["data-sdui-field"] = bound.key;
3128
+ engine.noteField(form.id, bound.key, {
3129
+ label: typeof props.label === "string" && props.label !== "" ? props.label : void 0,
3130
+ kind: bound.kind
3131
+ });
3132
+ return;
3133
+ }
3134
+ if (typeof node.component !== "string" || !INVALID_PROP_COMPONENTS.has(node.component)) return;
3135
+ if (Object.hasOwn(raw, "invalid")) return;
3136
+ for (const name of BINDING_PROPS) {
3137
+ const key = formKeyOf(raw[name], form.id);
3138
+ if (!key) continue;
3139
+ if (errorOf(key)) props.invalid = true;
3140
+ return;
3141
+ }
3142
+ }
2423
3143
  var EMPTY_SCOPE = [];
2424
3144
  var DENIED_PROPS2 = /* @__PURE__ */ new Set([
2425
3145
  "dangerouslySetInnerHTML",
@@ -2452,6 +3172,7 @@ function SduiNodeRenderer({
2452
3172
  engine
2453
3173
  }) {
2454
3174
  const { registry, resolveValue, dispatch, resolveAsset } = engine;
3175
+ const formScope = React9.useContext(SduiFormScopeContext);
2455
3176
  const slots = isPlainObject(node.slots) ? node.slots : void 0;
2456
3177
  const childList = childrenOf(node);
2457
3178
  if (node.component === "ForEach") {
@@ -2516,6 +3237,10 @@ function SduiNodeRenderer({
2516
3237
  props[name] = resolved;
2517
3238
  }
2518
3239
  }
3240
+ if (formScope) applyFormBinding(node, props, formScope, engine);
3241
+ if (typeof node.component === "string" && INVALID_PROP_COMPONENTS.has(node.component) && props.invalid !== void 0 && typeof props.invalid !== "boolean") {
3242
+ props.invalid = Boolean(props.invalid);
3243
+ }
2519
3244
  const childLayout = childLayoutFrom(props);
2520
3245
  if (childLayout) {
2521
3246
  for (const name of CHILD_LAYOUT_PROPS) delete props[name];
@@ -2612,19 +3337,53 @@ function SduiFragmentBody({
2612
3337
  return null;
2613
3338
  }
2614
3339
  const page = spec;
2615
- if (page.data || page.urlState || page.subscribe) {
3340
+ if (page.data || page.urlState || page.subscribe || page.leaveGuard !== void 0 || page.titleFrom !== void 0) {
2616
3341
  warnOnce(
2617
3342
  `fragment:page-features:${id}`,
2618
- `[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`
3343
+ `[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`
2619
3344
  );
2620
3345
  }
2621
3346
  return /* @__PURE__ */ jsx(SduiFragmentChainContext.Provider, { value: nextChain, children: page.body.map((child, i) => renderChild(child, i, bodyScope, engine)) });
2622
3347
  }
3348
+ function SduiToastRegion({ hostId }) {
3349
+ const board = useSyncExternalStore(subscribeToasts, toastSnapshot, toastServerSnapshot);
3350
+ const toasts = useMemo(
3351
+ () => board.entries.filter((entry) => toastHostOf(entry, board.hosts) === hostId),
3352
+ [board, hostId]
3353
+ );
3354
+ useEffect(() => {
3355
+ if (toasts.length > 0) markToastsShown(toasts.map((t) => t.id));
3356
+ }, [toasts]);
3357
+ const modalOpen = useAnyModalOpen();
3358
+ if (toasts.length === 0) return null;
3359
+ return /* @__PURE__ */ jsxs("div", { className: modalOpen ? "wp-toast-region wp-toast-region--modal" : "wp-toast-region", children: [
3360
+ /* @__PURE__ */ jsx(HoistedStyle, { id: "wp-sdui-toast-modal", css: TOAST_MODAL_CSS }),
3361
+ toasts.map((t) => /* @__PURE__ */ jsx(Toast, { tone: t.tone, title: t.title, onClose: () => closeToastEntry(t.id), children: t.message }, t.id))
3362
+ ] });
3363
+ }
2623
3364
  function urlSeedSettled(state, seeded, baseline) {
2624
3365
  return Object.keys(seeded).every(
2625
3366
  (key) => Object.is(state[key], seeded[key]) || !Object.is(state[key], baseline[key])
2626
3367
  );
2627
3368
  }
3369
+ function encodeUrlStateValue(value) {
3370
+ if (value === void 0 || value === null || value === "") return null;
3371
+ if (typeof value !== "string") return JSON.stringify(value);
3372
+ if (/^["[{]/.test(value)) return JSON.stringify(value);
3373
+ try {
3374
+ JSON.parse(value);
3375
+ return JSON.stringify(value);
3376
+ } catch {
3377
+ return value;
3378
+ }
3379
+ }
3380
+ function decodeUrlStateValue(raw) {
3381
+ try {
3382
+ return JSON.parse(raw);
3383
+ } catch {
3384
+ return raw;
3385
+ }
3386
+ }
2628
3387
  function SduiRenderer({
2629
3388
  page,
2630
3389
  registry = defaultRegistry,
@@ -2644,24 +3403,43 @@ function SduiRenderer({
2644
3403
  const urlSeeded = useRef(false);
2645
3404
  const urlSeedPending = useRef(null);
2646
3405
  const urlHomePath = useRef(typeof window !== "undefined" ? window.location.pathname : "");
2647
- const [toasts, setToasts] = useState([]);
2648
- const toastSeq = useRef(0);
2649
- const toastTimers = useRef([]);
2650
3406
  const stateRef = useRef(state);
2651
3407
  stateRef.current = state;
2652
3408
  const formsRef = useRef(/* @__PURE__ */ new Map());
2653
- useEffect(() => {
2654
- const timers = toastTimers.current;
2655
- return () => timers.forEach(clearTimeout);
3409
+ const fieldInfoRef = useRef(/* @__PURE__ */ new Map());
3410
+ const noteField = useCallback((formId, key, info) => {
3411
+ let fields = fieldInfoRef.current.get(formId);
3412
+ if (!fields) {
3413
+ fields = /* @__PURE__ */ new Map();
3414
+ fieldInfoRef.current.set(formId, fields);
3415
+ }
3416
+ fields.set(key, info);
2656
3417
  }, []);
3418
+ const [toastHostId] = useState(allocateToastHostId);
3419
+ const toastChannel = React9.useContext(OverlayContainerContext);
3420
+ useEffect(() => registerToastHost(toastHostId, toastChannel), [toastHostId, toastChannel]);
2657
3421
  useEffect(() => {
2658
3422
  if (!DEV) return;
2659
3423
  const issues = validateSpec(page, new Set(Object.keys(registry)));
2660
3424
  issues.forEach((issue) => console.warn(`[sdui:spec] ${issue.path} \u2014 ${issue.message}`));
2661
3425
  }, [page, registry]);
2662
- const setEntries = useCallback((entries) => {
2663
- stateRef.current = { ...stateRef.current, ...entries };
2664
- setState((prev) => ({ ...prev, ...entries }));
3426
+ const leaveFormsRef = useRef(/* @__PURE__ */ new Map());
3427
+ const leaveRegistry = useMemo(
3428
+ () => ({
3429
+ register: (formId, isDirty) => {
3430
+ leaveFormsRef.current.set(formId, isDirty);
3431
+ return () => {
3432
+ if (leaveFormsRef.current.get(formId) === isDirty) leaveFormsRef.current.delete(formId);
3433
+ };
3434
+ }
3435
+ }),
3436
+ []
3437
+ );
3438
+ const leavingRef = useRef(false);
3439
+ const setEntries = useCallback((entries2) => {
3440
+ stateRef.current = { ...stateRef.current, ...entries2 };
3441
+ leavingRef.current = false;
3442
+ setState((prev) => ({ ...prev, ...entries2 }));
2665
3443
  }, []);
2666
3444
  useEffect(() => {
2667
3445
  if (!syncUrlState || urlSeeded.current || !page.urlState?.length || typeof window === "undefined") return;
@@ -2670,13 +3448,7 @@ function SduiRenderer({
2670
3448
  const seeded = {};
2671
3449
  for (const key of page.urlState) {
2672
3450
  const raw = params.get(key);
2673
- if (raw !== null) {
2674
- try {
2675
- seeded[key] = JSON.parse(raw);
2676
- } catch {
2677
- seeded[key] = raw;
2678
- }
2679
- }
3451
+ if (raw !== null) seeded[key] = decodeUrlStateValue(raw);
2680
3452
  }
2681
3453
  if (Object.keys(seeded).length > 0) {
2682
3454
  const baseline = {};
@@ -2685,6 +3457,16 @@ function SduiRenderer({
2685
3457
  setEntries(seeded);
2686
3458
  }
2687
3459
  }, [page.urlState, setEntries, syncUrlState]);
3460
+ const [urlResync, setUrlResync] = useState(0);
3461
+ const hasUrlState = Boolean(page.urlState?.length);
3462
+ useEffect(() => {
3463
+ if (!syncUrlState || !hasUrlState || typeof window === "undefined") return;
3464
+ const onPop = () => {
3465
+ if (window.location.pathname === urlHomePath.current) setUrlResync((n) => n + 1);
3466
+ };
3467
+ window.addEventListener("popstate", onPop);
3468
+ return () => window.removeEventListener("popstate", onPop);
3469
+ }, [syncUrlState, hasUrlState]);
2688
3470
  useEffect(() => {
2689
3471
  if (!syncUrlState || !page.urlState?.length || !urlSeeded.current || typeof window === "undefined") return;
2690
3472
  if (window.location.pathname !== urlHomePath.current) return;
@@ -2696,16 +3478,19 @@ function SduiRenderer({
2696
3478
  const params = new URLSearchParams(window.location.search);
2697
3479
  let changed = false;
2698
3480
  for (const key of page.urlState) {
2699
- const value = state[key];
2700
- const serialized = value === void 0 || value === null || value === "" ? null : JSON.stringify(value);
3481
+ const serialized = encodeUrlStateValue(state[key]);
3482
+ const current = params.get(key);
2701
3483
  if (serialized === null) {
2702
- if (params.has(key)) {
3484
+ if (current !== null) {
2703
3485
  params.delete(key);
2704
3486
  changed = true;
2705
3487
  }
2706
- } else if (params.get(key) !== serialized) {
2707
- params.set(key, serialized);
2708
- changed = true;
3488
+ } else {
3489
+ const sameValue = current !== null && (current === serialized || encodeUrlStateValue(decodeUrlStateValue(current)) === serialized);
3490
+ if (!sameValue) {
3491
+ params.set(key, serialized);
3492
+ changed = true;
3493
+ }
2709
3494
  }
2710
3495
  }
2711
3496
  if (changed) {
@@ -2720,7 +3505,7 @@ function SduiRenderer({
2720
3505
  );
2721
3506
  }
2722
3507
  }
2723
- }, [state, page.urlState, syncUrlState]);
3508
+ }, [state, page.urlState, syncUrlState, urlResync]);
2724
3509
  const subscribeChannels = page.subscribe?.join(",") ?? "";
2725
3510
  const hasSubscribe = typeof host.subscribe === "function";
2726
3511
  useEffect(() => {
@@ -2741,16 +3526,11 @@ function SduiRenderer({
2741
3526
  unsubscribe();
2742
3527
  };
2743
3528
  }, [subscribeChannels, hasSubscribe]);
2744
- const closeToast = useCallback((id) => {
2745
- setToasts((prev) => prev.filter((t) => t.id !== id));
2746
- }, []);
2747
3529
  const pushToast = useCallback(
2748
3530
  (toast) => {
2749
- const id = ++toastSeq.current;
2750
- setToasts((prev) => [...prev, { ...toast, id }]);
2751
- toastTimers.current.push(setTimeout(() => closeToast(id), 4e3));
3531
+ pushToastEntry(toastHostId, toastChannel, toast);
2752
3532
  },
2753
- [closeToast]
3533
+ [toastHostId, toastChannel]
2754
3534
  );
2755
3535
  const lookupState = useCallback((key) => {
2756
3536
  const current = stateRef.current;
@@ -2811,7 +3591,7 @@ function SduiRenderer({
2811
3591
  if (isPlainObject(value)) {
2812
3592
  const out = {};
2813
3593
  for (const [k, v] of Object.entries(value)) {
2814
- out[k] = deferChainEntry(value, k) || deferRunEntry(k, v) ? v : resolveValue(v, scope);
3594
+ out[k] = deferChainEntry(value, k) || deferRunEntry(k, v) || deferEachTarget(value, k) ? v : resolveValue(v, scope);
2815
3595
  }
2816
3596
  return out;
2817
3597
  }
@@ -2819,6 +3599,76 @@ function SduiRenderer({
2819
3599
  },
2820
3600
  [lookupState]
2821
3601
  );
3602
+ const leaveMessageNow = useCallback(() => {
3603
+ if (leavingRef.current) return null;
3604
+ if (page.leaveGuard !== void 0) {
3605
+ const message = leaveMessageOf(resolveValue(page.leaveGuard, EMPTY_SCOPE));
3606
+ if (message) return message;
3607
+ }
3608
+ for (const [formId, isDirty] of leaveFormsRef.current) {
3609
+ if (stateRef.current[`${formId}.$resetDirty`] === true) continue;
3610
+ if (isDirty()) return DEFAULT_LEAVE_MESSAGE;
3611
+ }
3612
+ return null;
3613
+ }, [page.leaveGuard, resolveValue]);
3614
+ const leaveSentRef = useRef(null);
3615
+ const unloadHandlerRef = useRef(null);
3616
+ const syncLeaveGuard = useCallback(() => {
3617
+ const message = leaveMessageNow();
3618
+ if (message === leaveSentRef.current) return message;
3619
+ leaveSentRef.current = message;
3620
+ hostRef.current.setLeaveGuard?.(message);
3621
+ if (typeof window === "undefined") return message;
3622
+ if (message !== null && !unloadHandlerRef.current) {
3623
+ const handler = (event) => {
3624
+ event.preventDefault();
3625
+ event.returnValue = "";
3626
+ };
3627
+ window.addEventListener("beforeunload", handler);
3628
+ unloadHandlerRef.current = handler;
3629
+ } else if (message === null && unloadHandlerRef.current) {
3630
+ window.removeEventListener("beforeunload", unloadHandlerRef.current);
3631
+ unloadHandlerRef.current = null;
3632
+ }
3633
+ return message;
3634
+ }, [leaveMessageNow]);
3635
+ useEffect(() => {
3636
+ syncLeaveGuard();
3637
+ });
3638
+ useEffect(
3639
+ () => () => {
3640
+ if (leaveSentRef.current !== null) {
3641
+ leaveSentRef.current = null;
3642
+ hostRef.current.setLeaveGuard?.(null);
3643
+ }
3644
+ if (unloadHandlerRef.current && typeof window !== "undefined") {
3645
+ window.removeEventListener("beforeunload", unloadHandlerRef.current);
3646
+ unloadHandlerRef.current = null;
3647
+ }
3648
+ },
3649
+ []
3650
+ );
3651
+ const [pendingLeave, setPendingLeave] = useState(null);
3652
+ const titleFrom = page.titleFrom;
3653
+ const resolvedTitle = useMemo(
3654
+ () => titleFrom === void 0 ? null : titleText(resolveValue(titleFrom, EMPTY_SCOPE)),
3655
+ // state 가 바뀌어야 다시 푼다 — resolveValue 는 ref 를 읽어 정체성이 안 바뀐다.
3656
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3657
+ [titleFrom, resolveValue, state]
3658
+ );
3659
+ const effectiveTitle = resolvedTitle ?? page.title;
3660
+ useEffect(() => {
3661
+ if (titleFrom === void 0) return;
3662
+ hostRef.current.setTitle?.(effectiveTitle ?? null);
3663
+ }, [titleFrom, effectiveTitle]);
3664
+ useEffect(
3665
+ () => () => {
3666
+ if (titleFrom !== void 0) hostRef.current.setTitle?.(null);
3667
+ },
3668
+ // 마운트 1회 계약 — 라우트가 바뀌면 호스트가 key 로 갈아 끼운다.
3669
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3670
+ []
3671
+ );
2822
3672
  const dataPlan = useMemo(
2823
3673
  () => planPageData(page.data, (value) => resolveValue(value, EMPTY_SCOPE)),
2824
3674
  // state를 의존성에 두어야 $state를 참조하는 경로가 값 변화를 따라간다.
@@ -2908,6 +3758,34 @@ function SduiRenderer({
2908
3758
  revertUnsettledStamps(fetchedRef.current, runnable, epochs, settled);
2909
3759
  };
2910
3760
  }, [dataPlanKey, dataEpochKey, setEntries]);
3761
+ const revalidateField = useCallback(
3762
+ (key) => {
3763
+ for (const [formId, config] of formsRef.current) {
3764
+ const prefix = `${formId}.`;
3765
+ if (!key.startsWith(prefix)) continue;
3766
+ const field = key.slice(prefix.length);
3767
+ if (field === "" || field.startsWith("$")) continue;
3768
+ const errors = stateRef.current[`${formId}.$errors`];
3769
+ if (!isPlainObject(errors) || !Object.hasOwn(errors, field)) continue;
3770
+ const next = { ...errors };
3771
+ try {
3772
+ const result = validateForm(
3773
+ collectFormValues(stateRef.current, formId),
3774
+ config,
3775
+ customValidators,
3776
+ fieldInfoRef.current.get(formId)
3777
+ );
3778
+ const message = result.errors[field];
3779
+ if (message) next[field] = message;
3780
+ else delete next[field];
3781
+ } catch {
3782
+ delete next[field];
3783
+ }
3784
+ setEntries({ [`${formId}.$errors`]: next });
3785
+ }
3786
+ },
3787
+ [customValidators, setEntries]
3788
+ );
2911
3789
  const dispatchRef = useRef(() => {
2912
3790
  });
2913
3791
  const dispatch = useCallback(
@@ -2924,17 +3802,24 @@ function SduiRenderer({
2924
3802
  eventScope
2925
3803
  );
2926
3804
  switch (action.action) {
2927
- case "navigate":
3805
+ case "navigate": {
2928
3806
  if (!isSafeNavigationTarget(action.to)) {
2929
3807
  warnOnce(`navigate:${String(action.to)}`, `[sdui] \uD5C8\uC6A9\uB418\uC9C0 \uC54A\uB294 navigate \uB300\uC0C1: ${String(action.to)}`);
2930
3808
  break;
2931
3809
  }
3810
+ const leaveMessage = syncLeaveGuard();
3811
+ if (leaveMessage !== null && typeof hostRef.current.setLeaveGuard !== "function") {
3812
+ setPendingLeave({ to: action.to, message: leaveMessage });
3813
+ break;
3814
+ }
2932
3815
  hostRef.current.navigate(action.to);
2933
3816
  break;
3817
+ }
2934
3818
  case "setState": {
2935
3819
  const nextValue = action.fromEvent ? eventValue(arg) : action.value;
2936
3820
  if (!Array.isArray(action.path) || action.path.length === 0) {
2937
3821
  setEntries({ [action.key]: nextValue });
3822
+ revalidateField(action.key);
2938
3823
  break;
2939
3824
  }
2940
3825
  const written = setAtPath(
@@ -2950,6 +3835,7 @@ function SduiRenderer({
2950
3835
  break;
2951
3836
  }
2952
3837
  setEntries({ [action.key]: written.next });
3838
+ revalidateField(action.key);
2953
3839
  break;
2954
3840
  }
2955
3841
  case "open":
@@ -2959,7 +3845,7 @@ function SduiRenderer({
2959
3845
  setEntries({ [action.key]: false });
2960
3846
  break;
2961
3847
  case "toast":
2962
- pushToast({ tone: action.tone ?? "info", title: action.title, message: action.message });
3848
+ pushToast({ tone: action.tone ?? "info", title: toastText(action.title), message: toastText(action.message) });
2963
3849
  break;
2964
3850
  case "arrayAppend": {
2965
3851
  const current = stateRef.current[action.key];
@@ -3026,16 +3912,21 @@ function SduiRenderer({
3026
3912
  let phase = "validate";
3027
3913
  let pendingOn = false;
3028
3914
  try {
3029
- const prefix = `${formId}.`;
3030
- const values = {};
3031
- for (const [key, value] of Object.entries(stateRef.current)) {
3032
- if (key.startsWith(prefix) && !key.slice(prefix.length).startsWith("$") && value !== void 0) {
3033
- values[key.slice(prefix.length)] = value;
3034
- }
3035
- }
3036
- const errors = validateForm(values, formsRef.current.get(formId), customValidators);
3915
+ const values = collectFormValues(stateRef.current, formId);
3916
+ const config = formsRef.current.get(formId);
3917
+ const fieldInfo = fieldInfoRef.current.get(formId);
3918
+ const result = validateForm(values, config, customValidators, fieldInfo);
3919
+ const errors = result.errors;
3037
3920
  if (Object.keys(errors).length > 0) {
3038
3921
  setEntries({ [`${formId}.$errors`]: errors, [`${formId}.$submitting`]: false });
3922
+ const summary = invalidSummary(
3923
+ result,
3924
+ (field) => config?.labels[field] ?? fieldInfo?.get(field)?.label,
3925
+ fieldOrderOf(formId)
3926
+ );
3927
+ pushToast({ tone: "warning", title: summary.title, message: summary.message });
3928
+ const fields = Object.keys(errors);
3929
+ setTimeout(() => focusFirstInvalid(formId, fields), 0);
3039
3930
  return;
3040
3931
  }
3041
3932
  setEntries({ [`${formId}.$errors`]: {}, [`${formId}.$submitting`]: true });
@@ -3080,7 +3971,10 @@ function SduiRenderer({
3080
3971
  // scope 를 잇는다 — request 의 onSuccess/onError 도 같은 자리의 텍스트다.
3081
3972
  // {"$arg"} 체인 항목은 dispatch 진입부가 이 스코프로 펼친다(submitForm과 동일).
3082
3973
  dispatch: (a, value) => dispatchRef.current(a, value, scope),
3083
- toast: pushToast
3974
+ toast: pushToast,
3975
+ // each: 원소를 행 프레임으로 얹어 endpoint·body 를 원소마다 푼다 — 같은 자리의
3976
+ // $state·$arg·$event 는 그대로 보이고, $item/$index 만 이 원소를 가리킨다.
3977
+ resolveFor: (value, item, index) => resolveValue(value, [...eventScope, { item, index }])
3084
3978
  });
3085
3979
  break;
3086
3980
  case "invalidate": {
@@ -3127,12 +4021,12 @@ function SduiRenderer({
3127
4021
  break;
3128
4022
  }
3129
4023
  },
3130
- [pushToast, onCustomAction, customValidators, setEntries, resolveValue]
4024
+ [pushToast, onCustomAction, customValidators, setEntries, resolveValue, revalidateField, syncLeaveGuard]
3131
4025
  );
3132
4026
  dispatchRef.current = dispatch;
3133
4027
  const engine = useMemo(
3134
- () => ({ registry, resolveValue, dispatch, resolveAsset: assetResolverOf(host) }),
3135
- [registry, resolveValue, dispatch, host]
4028
+ () => ({ registry, resolveValue, dispatch, resolveAsset: assetResolverOf(host), noteField }),
4029
+ [registry, resolveValue, dispatch, host, noteField]
3136
4030
  );
3137
4031
  const runtime = useMemo(
3138
4032
  () => ({
@@ -3150,15 +4044,43 @@ function SduiRenderer({
3150
4044
  return (
3151
4045
  // 해석된 어댑터를 트리에 흘려보낸다 — dispatch를 거치지 않고 스스로 요청을 내는
3152
4046
  // 컴포넌트(목록 조회 등)도 호스트가 건 잠금 안에서 움직이게 하기 위해서다(adapter.ts 주석).
3153
- /* @__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: [
4047
+ /* @__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: [
3154
4048
  /* @__PURE__ */ jsx(HoistedStyle, { id: "wp-sdui-layout", css: LAYOUT_CSS }),
3155
- !hasExplicitPageHead(page.body) && (page.title || page.subtitle) ? /* @__PURE__ */ jsx("div", { className: "wp-page-head", children: /* @__PURE__ */ jsxs("div", { className: "wp-page-head__text", children: [
3156
- page.title ? /* @__PURE__ */ jsx("h1", { className: "wp-page-head__title", children: page.title }) : null,
4049
+ !hasExplicitPageHead(page.body) && (effectiveTitle || page.subtitle) ? /* @__PURE__ */ jsx("div", { className: "wp-page-head", children: /* @__PURE__ */ jsxs("div", { className: "wp-page-head__text", children: [
4050
+ effectiveTitle ? /* @__PURE__ */ jsx("h1", { className: "wp-page-head__title", children: effectiveTitle }) : null,
3157
4051
  page.subtitle ? /* @__PURE__ */ jsx("div", { className: "wp-page-head__sub", children: page.subtitle }) : null
3158
4052
  ] }) }) : null,
3159
4053
  /* @__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" }) }),
3160
- 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
3161
- ] }) }) }) }) })
4054
+ /* @__PURE__ */ jsx(SduiToastRegion, { hostId: toastHostId }),
4055
+ pendingLeave ? /* @__PURE__ */ jsx(OverlayLayer, { onClose: () => setPendingLeave(null), children: /* @__PURE__ */ jsx(
4056
+ Dialog,
4057
+ {
4058
+ size: "sm",
4059
+ icon: "triangle-alert",
4060
+ tone: "danger",
4061
+ title: "\uC800\uC7A5\uD558\uC9C0 \uC54A\uACE0 \uB098\uAC08\uAE4C\uC694?",
4062
+ onClose: () => setPendingLeave(null),
4063
+ footer: /* @__PURE__ */ jsxs(Fragment, { children: [
4064
+ /* @__PURE__ */ jsx(Button, { variant: "secondary", "data-autofocus": "", onClick: () => setPendingLeave(null), children: "\uACC4\uC18D \uC791\uC131" }),
4065
+ /* @__PURE__ */ jsx(
4066
+ Button,
4067
+ {
4068
+ variant: "danger",
4069
+ onClick: () => {
4070
+ const { to } = pendingLeave;
4071
+ setPendingLeave(null);
4072
+ leavingRef.current = true;
4073
+ syncLeaveGuard();
4074
+ hostRef.current.navigate(to);
4075
+ },
4076
+ children: "\uB098\uAC00\uAE30"
4077
+ }
4078
+ )
4079
+ ] }),
4080
+ children: pendingLeave.message
4081
+ }
4082
+ ) }) : null
4083
+ ] }) }) }) }) }) })
3162
4084
  );
3163
4085
  }
3164
4086
 
@@ -3219,4 +4141,4 @@ async function resolvePatternSpec(id, load) {
3219
4141
  // src/icon-names.ts
3220
4142
  var ICON_NAMES = Object.keys(ICON_NODES);
3221
4143
 
3222
- export { Can, ICON_NAMES, NodeErrorBoundary, RemoteSearchResults, ResultTable, SduiForm, SduiRenderer, SduiSearchDataProvider, SduiSearchInvalidationProvider, SduiSearchResults, SearchResults, SearchSummaryCards, SortHandle, SortableList, cellRenderer, createRegistry, createSearchInvalidator, defaultRegistry, imageRenderer, isValidSpecId, mapActions, mapCardRows, mapColumns, mapGridCard, mapViews, matchesCondition, normalizePageSizes, patternSpecCandidates, resolvePatternSpec, sortableDropIndex, sortableMove, summaryParams, useRemoteSearchPage, useRemoteSearchSummary, useSduiRuntime, useSearchFetcher, validateSpec, withProps };
4144
+ 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 };