@wipco/sdui 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { includesLooseSearch } from './chunk-BD75U73S.js';
2
- import { useSduiAdapter, hostFetchOf } from './chunk-DVOPZFQS.js';
2
+ import { useSduiAdapter, hostFetchOf } from './chunk-DKPNFEVU.js';
3
3
  import { Icon, Button, Input, Spinner } from './chunk-WIDDHR7L.js';
4
4
  import { boxed } from './chunk-SAN4IX6N.js';
5
5
  import { useId, useRef, useMemo, useState, useEffect } from 'react';
@@ -96,5 +96,10 @@ function hostFetchOf(adapter) {
96
96
  if (!hostFetch || hostFetch === defaultAdapter.fetch) return null;
97
97
  return hostFetch;
98
98
  }
99
+ function hostRawFetchOf(adapter) {
100
+ const hostRawFetch = adapter?.rawFetch;
101
+ if (!hostRawFetch || hostRawFetch === defaultAdapter.rawFetch) return null;
102
+ return hostRawFetch;
103
+ }
99
104
 
100
- export { DEV, SduiAdapterContext, assetResolverOf, defaultAdapter, hostFetchOf, isSafeNavigationTarget, resolveAdapter, useSduiAdapter, useSduiAssetResolver, useSduiSandbox };
105
+ export { DEV, SduiAdapterContext, assetResolverOf, defaultAdapter, hostFetchOf, hostRawFetchOf, isSafeNavigationTarget, resolveAdapter, useSduiAdapter, useSduiAssetResolver, useSduiSandbox };
@@ -0,0 +1,400 @@
1
+ import { ImageLightbox } from './chunk-XPBZMECW.js';
2
+ import { useSduiSandbox, useSduiAdapter, hostRawFetchOf, defaultAdapter, useSduiAssetResolver } from './chunk-DKPNFEVU.js';
3
+ import { Spinner, Icon, IconButton } from './chunk-WIDDHR7L.js';
4
+ import { boxed } from './chunk-SAN4IX6N.js';
5
+ import { useState, useRef, useEffect, useCallback } from 'react';
6
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
7
+
8
+ var THUMB_SIZE = 72;
9
+ function matchesAccept(file, accept) {
10
+ const rules = accept.split(",").map((rule) => rule.trim().toLowerCase()).filter(Boolean);
11
+ if (rules.length === 0) return true;
12
+ const type = file.type.toLowerCase();
13
+ const name = file.name.toLowerCase();
14
+ return rules.some((rule) => {
15
+ if (rule.startsWith(".")) return name.endsWith(rule);
16
+ if (rule.endsWith("/*")) return type.startsWith(rule.slice(0, -1));
17
+ return type === rule;
18
+ });
19
+ }
20
+ function localId() {
21
+ return `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
22
+ }
23
+ function unwrapUploaded(payload) {
24
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return null;
25
+ const body = payload;
26
+ if ("id" in body || "url" in body) return body;
27
+ const objectKeys = Object.keys(body).filter(
28
+ (key) => typeof body[key] === "object" && body[key] !== null && !Array.isArray(body[key])
29
+ );
30
+ if (objectKeys.length !== 1) return body;
31
+ return body[objectKeys[0]];
32
+ }
33
+ function FileDropzoneImpl({
34
+ upload,
35
+ fileField = "file",
36
+ localFallbackOn503 = false,
37
+ endpoint,
38
+ value,
39
+ onChange,
40
+ onBusyChange,
41
+ maxCount,
42
+ accept = "image/*",
43
+ disabled = false,
44
+ hint,
45
+ deleteEndpoint,
46
+ onRemove
47
+ }) {
48
+ const sandbox = useSduiSandbox();
49
+ const adapter = useSduiAdapter();
50
+ const hostRaw = hostRawFetchOf(adapter);
51
+ const rawFetch = hostRaw ?? defaultAdapter.rawFetch;
52
+ const canUpload = hostRaw !== null || upload !== void 0;
53
+ const resolveAsset = useSduiAssetResolver();
54
+ const [internalItems, setInternalItems] = useState(value ?? []);
55
+ const items = value ?? internalItems;
56
+ const itemsRef = useRef(items);
57
+ itemsRef.current = items;
58
+ const [busy, setBusy] = useState(false);
59
+ const [progress, setProgress] = useState(null);
60
+ const [warnings, setWarnings] = useState([]);
61
+ const [localMode, setLocalMode] = useState(!endpoint);
62
+ const [dragOver, setDragOver] = useState(false);
63
+ const [hovering, setHovering] = useState(false);
64
+ const [hoverItemId, setHoverItemId] = useState(null);
65
+ const inputRef = useRef(null);
66
+ const dragDepthRef = useRef(0);
67
+ const createdUrlsRef = useRef(/* @__PURE__ */ new Set());
68
+ useEffect(() => {
69
+ const created = createdUrlsRef.current;
70
+ return () => created.forEach((url) => URL.revokeObjectURL(url));
71
+ }, []);
72
+ const atCapacity = maxCount !== void 0 && items.length >= maxCount;
73
+ const canAdd = !disabled && !busy && !atCapacity;
74
+ const commit = useCallback(
75
+ (next) => {
76
+ itemsRef.current = next;
77
+ setInternalItems(next);
78
+ onChange?.(next);
79
+ },
80
+ [onChange]
81
+ );
82
+ const runQueue = useCallback(
83
+ async (queue, baseWarnings) => {
84
+ setBusy(true);
85
+ onBusyChange?.(true);
86
+ const failures = [];
87
+ let local = localMode || !endpoint || sandbox || !canUpload;
88
+ try {
89
+ for (let index = 0; index < queue.length; index += 1) {
90
+ const file = queue[index];
91
+ setProgress({ current: index + 1, total: queue.length });
92
+ if (!local && upload) {
93
+ try {
94
+ const item = await upload(file);
95
+ if (item && typeof item.id === "string" && typeof item.url === "string") {
96
+ commit([...itemsRef.current, { name: file.name, ...item }]);
97
+ } else {
98
+ failures.push(`${file.name} (\uC5C5\uB85C\uB354\uAC00 \uD56D\uBAA9\uC744 \uC8FC\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4)`);
99
+ }
100
+ } catch (error) {
101
+ failures.push(`${file.name} (${error instanceof Error ? error.message : "\uC5C5\uB85C\uB4DC \uC2E4\uD328"})`);
102
+ }
103
+ continue;
104
+ }
105
+ if (!local && endpoint) {
106
+ try {
107
+ const formData = new FormData();
108
+ formData.append(fileField, file, file.name);
109
+ const res = await rawFetch(endpoint, { method: "POST", body: formData });
110
+ if (res.status === 503 && localFallbackOn503) {
111
+ local = true;
112
+ setLocalMode(true);
113
+ } else if (!res.ok) {
114
+ failures.push(`${file.name} (${res.status})`);
115
+ continue;
116
+ } else {
117
+ const json = await res.json().catch(() => null);
118
+ const body = unwrapUploaded(json);
119
+ const id = body?.id;
120
+ const url2 = body?.url;
121
+ if ((typeof id === "string" || typeof id === "number") && typeof url2 === "string") {
122
+ commit([...itemsRef.current, { id: String(id), url: url2, name: file.name }]);
123
+ } else {
124
+ failures.push(`${file.name} (\uC751\uB2F5 \uD615\uC2DD \uC624\uB958)`);
125
+ }
126
+ continue;
127
+ }
128
+ } catch {
129
+ failures.push(`${file.name} (\uB124\uD2B8\uC6CC\uD06C \uC624\uB958)`);
130
+ continue;
131
+ }
132
+ }
133
+ const url = URL.createObjectURL(file);
134
+ createdUrlsRef.current.add(url);
135
+ commit([...itemsRef.current, { id: localId(), url, name: file.name }]);
136
+ }
137
+ } finally {
138
+ setProgress(null);
139
+ setBusy(false);
140
+ onBusyChange?.(false);
141
+ }
142
+ if (failures.length > 0) {
143
+ setWarnings([...baseWarnings, `\uC5C5\uB85C\uB4DC \uC2E4\uD328: ${failures.join(", ")}`]);
144
+ }
145
+ },
146
+ [commit, endpoint, localMode, onBusyChange, sandbox, upload, rawFetch, canUpload, fileField, localFallbackOn503]
147
+ );
148
+ const handleFiles = useCallback(
149
+ (fileList) => {
150
+ const files = Array.from(fileList);
151
+ if (files.length === 0) return;
152
+ const nextWarnings = [];
153
+ const matched = files.filter((file) => matchesAccept(file, accept));
154
+ const rejectedCount = files.length - matched.length;
155
+ if (rejectedCount > 0) {
156
+ nextWarnings.push(
157
+ `\uD5C8\uC6A9\uB418\uC9C0 \uC54A\uB294 \uD615\uC2DD\uC774\uB77C ${rejectedCount}\uAC1C \uD30C\uC77C\uC744 \uC81C\uC678\uD588\uC2B5\uB2C8\uB2E4 (\uD5C8\uC6A9: ${accept})`
158
+ );
159
+ }
160
+ let queue = matched;
161
+ if (maxCount !== void 0) {
162
+ const remaining = Math.max(0, maxCount - itemsRef.current.length);
163
+ if (queue.length > remaining) {
164
+ nextWarnings.push(
165
+ `\uCD5C\uB300 ${maxCount}\uAC1C\uAE4C\uC9C0 \uCCA8\uBD80\uD560 \uC218 \uC788\uC5B4 ${queue.length - remaining}\uAC1C\uB97C \uC81C\uC678\uD588\uC2B5\uB2C8\uB2E4`
166
+ );
167
+ queue = queue.slice(0, remaining);
168
+ }
169
+ }
170
+ setWarnings(nextWarnings);
171
+ if (queue.length === 0) return;
172
+ void runQueue(queue, nextWarnings);
173
+ },
174
+ [accept, maxCount, runQueue]
175
+ );
176
+ const removeItem = useCallback(
177
+ (item) => {
178
+ const forget = () => {
179
+ commit(itemsRef.current.filter((current) => current.id !== item.id));
180
+ if (createdUrlsRef.current.has(item.url)) {
181
+ URL.revokeObjectURL(item.url);
182
+ createdUrlsRef.current.delete(item.url);
183
+ }
184
+ };
185
+ if (deleteEndpoint && !item.id.startsWith("local-")) {
186
+ void (async () => {
187
+ try {
188
+ const res = await rawFetch(`${deleteEndpoint}/${item.id}`, { method: "DELETE" });
189
+ if (!res.ok && res.status !== 404) {
190
+ setWarnings([`${item.name ?? "\uCCA8\uBD80"} \uC0AD\uC81C\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4 (${res.status})`]);
191
+ return;
192
+ }
193
+ } catch {
194
+ setWarnings([`${item.name ?? "\uCCA8\uBD80"} \uC0AD\uC81C\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4 \u2014 \uC5F0\uACB0\uC744 \uD655\uC778\uD558\uC138\uC694`]);
195
+ return;
196
+ }
197
+ forget();
198
+ onRemove?.();
199
+ })();
200
+ return;
201
+ }
202
+ forget();
203
+ },
204
+ [commit, deleteEndpoint, onRemove]
205
+ );
206
+ const openPicker = () => {
207
+ if (canAdd) inputRef.current?.click();
208
+ };
209
+ const showLocalCaption = localMode || items.some((item) => item.id.startsWith("local-"));
210
+ const dropTinted = canAdd && (dragOver || hovering);
211
+ return /* @__PURE__ */ jsxs("div", { className: "wp-col", style: { gap: "var(--wp-space-2)" }, children: [
212
+ /* @__PURE__ */ jsx(
213
+ "input",
214
+ {
215
+ ref: inputRef,
216
+ type: "file",
217
+ accept,
218
+ multiple: maxCount !== 1,
219
+ hidden: true,
220
+ disabled: !canAdd,
221
+ onChange: (event) => {
222
+ const selected = event.currentTarget.files;
223
+ if (selected && selected.length > 0) handleFiles(selected);
224
+ event.currentTarget.value = "";
225
+ }
226
+ }
227
+ ),
228
+ /* @__PURE__ */ jsx(
229
+ "div",
230
+ {
231
+ role: "button",
232
+ tabIndex: canAdd ? 0 : -1,
233
+ "aria-disabled": !canAdd,
234
+ "aria-label": "\uC774\uBBF8\uC9C0 \uCCA8\uBD80",
235
+ onClick: openPicker,
236
+ onKeyDown: (event) => {
237
+ if (canAdd && (event.key === "Enter" || event.key === " ")) {
238
+ event.preventDefault();
239
+ openPicker();
240
+ }
241
+ },
242
+ onMouseEnter: () => setHovering(true),
243
+ onMouseLeave: () => setHovering(false),
244
+ onDragEnter: (event) => {
245
+ event.preventDefault();
246
+ if (!canAdd) return;
247
+ dragDepthRef.current += 1;
248
+ setDragOver(true);
249
+ },
250
+ onDragOver: (event) => event.preventDefault(),
251
+ onDragLeave: () => {
252
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
253
+ if (dragDepthRef.current === 0) setDragOver(false);
254
+ },
255
+ onDrop: (event) => {
256
+ event.preventDefault();
257
+ dragDepthRef.current = 0;
258
+ setDragOver(false);
259
+ if (!canAdd) return;
260
+ handleFiles(event.dataTransfer.files);
261
+ },
262
+ style: {
263
+ display: "flex",
264
+ flexDirection: "column",
265
+ alignItems: "center",
266
+ justifyContent: "center",
267
+ gap: "var(--wp-space-2)",
268
+ padding: "var(--wp-space-6)",
269
+ border: `var(--wp-border-w) dashed ${dragOver ? "var(--wp-border-brand)" : "var(--wp-border-subtle)"}`,
270
+ borderRadius: "var(--wp-radius-md)",
271
+ background: dropTinted ? "var(--wp-surface-brand-tint)" : "var(--wp-surface-card)",
272
+ cursor: canAdd ? "pointer" : "not-allowed",
273
+ opacity: disabled ? 0.55 : 1,
274
+ transition: "var(--wp-transition-surface)",
275
+ textAlign: "center"
276
+ },
277
+ title: disabled ? "\uBE44\uD65C\uC131\uD654\uB41C \uC0C1\uD0DC\uC785\uB2C8\uB2E4" : atCapacity ? `\uCD5C\uB300 ${maxCount}\uAC1C\uAE4C\uC9C0 \uCCA8\uBD80\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4` : void 0,
278
+ children: busy && progress ? /* @__PURE__ */ jsx(Spinner, { label: `(${progress.current}/${progress.total}) \uC5C5\uB85C\uB4DC \uC911` }) : /* @__PURE__ */ jsxs(Fragment, { children: [
279
+ /* @__PURE__ */ jsx(
280
+ Icon,
281
+ {
282
+ name: "cloud-upload",
283
+ size: 24,
284
+ style: { color: dropTinted ? "var(--wp-text-brand)" : "var(--wp-text-muted)" }
285
+ }
286
+ ),
287
+ /* @__PURE__ */ jsx("span", { className: "wp-body-sm", style: { color: "var(--wp-text-default)" }, children: atCapacity ? `\uCD5C\uB300 ${maxCount}\uAC1C\uAE4C\uC9C0 \uCCA8\uBD80\uD588\uC2B5\uB2C8\uB2E4` : "\uD074\uB9AD\uD558\uAC70\uB098 \uC774\uBBF8\uC9C0\uB97C \uB04C\uC5B4\uB2E4 \uB193\uC73C\uC138\uC694" }),
288
+ hint ? /* @__PURE__ */ jsx("span", { className: "wp-caption", children: hint }) : null
289
+ ] })
290
+ }
291
+ ),
292
+ warnings.map((warning) => /* @__PURE__ */ jsxs(
293
+ "span",
294
+ {
295
+ className: "wp-caption",
296
+ style: {
297
+ color: "var(--wp-text-warning)",
298
+ display: "inline-flex",
299
+ alignItems: "center",
300
+ gap: "var(--wp-space-1)"
301
+ },
302
+ children: [
303
+ /* @__PURE__ */ jsx(Icon, { name: "triangle-alert", size: 14 }),
304
+ warning
305
+ ]
306
+ },
307
+ warning
308
+ )),
309
+ showLocalCaption ? /* @__PURE__ */ jsxs(
310
+ "span",
311
+ {
312
+ className: "wp-caption",
313
+ style: { display: "inline-flex", alignItems: "center", gap: "var(--wp-space-1)" },
314
+ children: [
315
+ /* @__PURE__ */ jsx(Icon, { name: "unplug", size: 14 }),
316
+ "\uBC31\uC5D4\uB4DC \uBBF8\uC5F0\uB3D9 \u2014 \uC784\uC2DC \uBBF8\uB9AC\uBCF4\uAE30"
317
+ ]
318
+ }
319
+ ) : null,
320
+ items.length > 0 ? /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: "var(--wp-space-2)" }, children: items.map((item) => {
321
+ const thumbSrc = resolveAsset(item.url, {
322
+ component: "FileDropzone",
323
+ prop: "src",
324
+ kind: "attribute"
325
+ });
326
+ return /* @__PURE__ */ jsxs(
327
+ "div",
328
+ {
329
+ style: { position: "relative", width: THUMB_SIZE, height: THUMB_SIZE },
330
+ onMouseEnter: () => setHoverItemId(item.id),
331
+ onMouseLeave: () => setHoverItemId((current) => current === item.id ? null : current),
332
+ children: [
333
+ thumbSrc === null ? /* @__PURE__ */ jsx(
334
+ "span",
335
+ {
336
+ "aria-label": item.name ?? "\uCCA8\uBD80 \uBBF8\uB9AC\uBCF4\uAE30 \uC5C6\uC74C",
337
+ role: "img",
338
+ style: {
339
+ display: "block",
340
+ width: THUMB_SIZE,
341
+ height: THUMB_SIZE,
342
+ borderRadius: "var(--wp-radius-md)",
343
+ border: "var(--wp-border-w) solid var(--wp-border-subtle)",
344
+ background: "var(--wp-surface-sunken)"
345
+ }
346
+ }
347
+ ) : /* @__PURE__ */ jsx(
348
+ ImageLightbox,
349
+ {
350
+ src: thumbSrc,
351
+ alt: item.name,
352
+ thumbStyle: {
353
+ width: THUMB_SIZE,
354
+ height: THUMB_SIZE,
355
+ objectFit: "cover",
356
+ display: "block",
357
+ borderRadius: "var(--wp-radius-md)",
358
+ border: "var(--wp-border-w) solid var(--wp-border-subtle)"
359
+ }
360
+ }
361
+ ),
362
+ /* @__PURE__ */ jsx(
363
+ "span",
364
+ {
365
+ style: {
366
+ position: "absolute",
367
+ top: "var(--wp-space-1)",
368
+ right: "var(--wp-space-1)",
369
+ background: "var(--wp-surface-card)",
370
+ borderRadius: "var(--wp-radius-sm)",
371
+ boxShadow: "var(--wp-shadow-sm)",
372
+ opacity: hoverItemId === item.id ? 1 : 0,
373
+ transition: "opacity var(--wp-duration-fast) var(--wp-ease-out)"
374
+ },
375
+ children: /* @__PURE__ */ jsx(
376
+ IconButton,
377
+ {
378
+ icon: "trash-2",
379
+ label: `\uC0AD\uC81C${item.name ? ` \u2014 ${item.name}` : ""}`,
380
+ variant: "danger",
381
+ size: "xs",
382
+ onClick: (event) => {
383
+ event.stopPropagation();
384
+ removeItem(item);
385
+ },
386
+ onFocus: () => setHoverItemId(item.id)
387
+ }
388
+ )
389
+ }
390
+ )
391
+ ]
392
+ },
393
+ item.id
394
+ );
395
+ }) }) : null
396
+ ] });
397
+ }
398
+ var FileDropzone = boxed(FileDropzoneImpl, "FileDropzone");
399
+
400
+ export { FileDropzone, unwrapUploaded };