@byline/ui 2.2.10 → 2.3.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 (39) hide show
  1. package/dist/fields/field-renderer.js +2 -1
  2. package/dist/fields/file/file-field.d.ts +4 -2
  3. package/dist/fields/file/file-field.js +182 -81
  4. package/dist/fields/file/file-field.module.js +10 -5
  5. package/dist/fields/file/file-field_module.css +99 -32
  6. package/dist/fields/file/file-upload-field.d.ts +21 -0
  7. package/dist/fields/file/file-upload-field.js +128 -0
  8. package/dist/fields/file/file-upload-field.module.js +15 -0
  9. package/dist/fields/file/file-upload-field_module.css +74 -0
  10. package/dist/fields/image/image-field.js +33 -17
  11. package/dist/fields/image/image-field.module.js +1 -0
  12. package/dist/fields/image/image-field_module.css +23 -10
  13. package/dist/fields/relation/relation-field.js +37 -24
  14. package/dist/fields/relation/relation-field.module.js +1 -1
  15. package/dist/fields/relation/relation-field_module.css +16 -16
  16. package/dist/forms/form-context.d.ts +11 -0
  17. package/dist/forms/form-context.js +47 -3
  18. package/dist/forms/form-renderer.js +5 -3
  19. package/dist/icons/index.d.ts +1 -0
  20. package/dist/icons/index.js +1 -0
  21. package/dist/icons/video-icon.d.ts +6 -0
  22. package/dist/icons/video-icon.js +36 -0
  23. package/dist/react.d.ts +1 -0
  24. package/dist/react.js +1 -0
  25. package/package.json +5 -4
  26. package/src/fields/field-renderer.tsx +1 -0
  27. package/src/fields/file/file-field.module.css +114 -49
  28. package/src/fields/file/file-field.tsx +220 -56
  29. package/src/fields/file/file-upload-field.module.css +101 -0
  30. package/src/fields/file/file-upload-field.tsx +183 -0
  31. package/src/fields/image/image-field.module.css +27 -13
  32. package/src/fields/image/image-field.tsx +35 -12
  33. package/src/fields/relation/relation-field.module.css +21 -21
  34. package/src/fields/relation/relation-field.tsx +24 -20
  35. package/src/forms/form-context.tsx +73 -0
  36. package/src/forms/form-renderer.tsx +9 -2
  37. package/src/icons/index.ts +1 -0
  38. package/src/icons/video-icon.tsx +32 -0
  39. package/src/react.ts +1 -0
@@ -0,0 +1,128 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useRef, useState } from "react";
3
+ import { createPendingStoredFileValue } from "@byline/core";
4
+ import classnames from "classnames";
5
+ import { useFormContext } from "../../forms/form-context.js";
6
+ import file_upload_field_module from "./file-upload-field.module.js";
7
+ const FileUploadField = ({ field: _field, collectionPath, fieldPath, onUploaded, accept })=>{
8
+ const inputRef = useRef(null);
9
+ const [status, setStatus] = useState('idle');
10
+ const [errorMessage, setErrorMessage] = useState(null);
11
+ const [isDragOver, setIsDragOver] = useState(false);
12
+ const { addPendingUpload } = useFormContext();
13
+ const handleFileSelected = useCallback((file)=>{
14
+ setStatus('processing');
15
+ setErrorMessage(null);
16
+ const previewUrl = URL.createObjectURL(file);
17
+ const pendingValue = createPendingStoredFileValue(file, previewUrl);
18
+ addPendingUpload(fieldPath, {
19
+ file,
20
+ previewUrl,
21
+ collectionPath
22
+ });
23
+ setStatus('idle');
24
+ onUploaded(pendingValue);
25
+ }, [
26
+ collectionPath,
27
+ fieldPath,
28
+ addPendingUpload,
29
+ onUploaded
30
+ ]);
31
+ const handleFileChange = useCallback((e)=>{
32
+ const file = e.target.files?.[0];
33
+ if (file) handleFileSelected(file);
34
+ e.target.value = '';
35
+ }, [
36
+ handleFileSelected
37
+ ]);
38
+ const handleBrowseClick = useCallback(()=>{
39
+ inputRef.current?.click();
40
+ }, []);
41
+ const handleDragOver = useCallback((e)=>{
42
+ e.preventDefault();
43
+ setIsDragOver(true);
44
+ }, []);
45
+ const handleDragLeave = useCallback((e)=>{
46
+ e.preventDefault();
47
+ setIsDragOver(false);
48
+ }, []);
49
+ const handleDrop = useCallback((e)=>{
50
+ e.preventDefault();
51
+ setIsDragOver(false);
52
+ const file = e.dataTransfer.files?.[0];
53
+ if (file) handleFileSelected(file);
54
+ }, [
55
+ handleFileSelected
56
+ ]);
57
+ const isProcessing = 'processing' === status;
58
+ return /*#__PURE__*/ jsxs("div", {
59
+ className: classnames('byline-field-file-upload', file_upload_field_module.root),
60
+ children: [
61
+ /*#__PURE__*/ jsx("input", {
62
+ ref: inputRef,
63
+ type: "file",
64
+ accept: accept,
65
+ className: classnames('byline-field-file-upload-input', file_upload_field_module.input),
66
+ onChange: handleFileChange,
67
+ disabled: isProcessing,
68
+ "aria-hidden": "true",
69
+ tabIndex: -1
70
+ }),
71
+ /*#__PURE__*/ jsxs("div", {
72
+ role: "button",
73
+ tabIndex: 0,
74
+ "aria-label": "Upload file — drag and drop or click to browse",
75
+ onDragOver: handleDragOver,
76
+ onDragLeave: handleDragLeave,
77
+ onDrop: handleDrop,
78
+ onClick: handleBrowseClick,
79
+ onKeyDown: (e)=>{
80
+ if ('Enter' === e.key || ' ' === e.key) {
81
+ e.preventDefault();
82
+ handleBrowseClick();
83
+ }
84
+ },
85
+ className: classnames('byline-field-file-upload-zone', file_upload_field_module.zone, isDragOver && !isProcessing && [
86
+ 'byline-field-file-upload-zone-active',
87
+ file_upload_field_module["zone-active"]
88
+ ], isProcessing && [
89
+ 'byline-field-file-upload-zone-busy',
90
+ file_upload_field_module["zone-busy"]
91
+ ]),
92
+ children: [
93
+ /*#__PURE__*/ jsx("svg", {
94
+ xmlns: "http://www.w3.org/2000/svg",
95
+ className: classnames('byline-field-file-upload-icon', file_upload_field_module.icon),
96
+ fill: "none",
97
+ viewBox: "0 0 24 24",
98
+ stroke: "currentColor",
99
+ strokeWidth: 1.5,
100
+ "aria-hidden": "true",
101
+ children: /*#__PURE__*/ jsx("path", {
102
+ strokeLinecap: "round",
103
+ strokeLinejoin: "round",
104
+ d: "M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
105
+ })
106
+ }),
107
+ /*#__PURE__*/ jsxs("span", {
108
+ className: classnames('byline-field-file-upload-label', file_upload_field_module.label),
109
+ children: [
110
+ "Drop file here or",
111
+ ' ',
112
+ /*#__PURE__*/ jsx("span", {
113
+ className: classnames('byline-field-file-upload-action', file_upload_field_module.action),
114
+ children: "browse"
115
+ })
116
+ ]
117
+ })
118
+ ]
119
+ }),
120
+ 'error' === status && errorMessage && /*#__PURE__*/ jsx("p", {
121
+ className: classnames('byline-field-file-upload-error', file_upload_field_module.error),
122
+ role: "alert",
123
+ children: errorMessage
124
+ })
125
+ ]
126
+ });
127
+ };
128
+ export { FileUploadField };
@@ -0,0 +1,15 @@
1
+ import "./file-upload-field_module.css";
2
+ const file_upload_field_module = {
3
+ root: "root-Eb8eWY",
4
+ input: "input-xBB8ah",
5
+ zone: "zone-HdQNmA",
6
+ "zone-active": "zone-active-iYZTsU",
7
+ zoneActive: "zone-active-iYZTsU",
8
+ "zone-busy": "zone-busy-GmToil",
9
+ zoneBusy: "zone-busy-GmToil",
10
+ icon: "icon-vXw1Do",
11
+ label: "label-XCy7AX",
12
+ action: "action-LUT6jM",
13
+ error: "error-fFWMW2"
14
+ };
15
+ export default file_upload_field_module;
@@ -0,0 +1,74 @@
1
+ :is(.root-Eb8eWY, .byline-field-file-upload) {
2
+ margin-top: .25rem;
3
+ }
4
+
5
+ :is(.input-xBB8ah, .byline-field-file-upload-input) {
6
+ clip: rect(0, 0, 0, 0);
7
+ white-space: nowrap;
8
+ border: 0;
9
+ width: 1px;
10
+ height: 1px;
11
+ margin: -1px;
12
+ padding: 0;
13
+ position: absolute;
14
+ overflow: hidden;
15
+ }
16
+
17
+ :is(.zone-HdQNmA, .byline-field-file-upload-zone) {
18
+ justify-content: center;
19
+ align-items: center;
20
+ gap: var(--spacing-8);
21
+ border: 2px dashed var(--gray-600);
22
+ border-radius: var(--border-radius-lg);
23
+ color: var(--gray-400);
24
+ text-align: center;
25
+ cursor: pointer;
26
+ -webkit-user-select: none;
27
+ user-select: none;
28
+ flex-direction: column;
29
+ padding: 1.5rem 1rem;
30
+ transition: color .15s, background-color .15s, border-color .15s;
31
+ display: flex;
32
+ }
33
+
34
+ :is(.zone-HdQNmA:hover, .byline-field-file-upload-zone:hover) {
35
+ border-color: var(--primary-500);
36
+ background-color: oklch(from var(--primary-900) l c h / .1);
37
+ }
38
+
39
+ :is(.zone-active-iYZTsU, .byline-field-file-upload-zone-active) {
40
+ border-color: var(--primary-400);
41
+ background-color: oklch(from var(--primary-900) l c h / .2);
42
+ color: var(--primary-300);
43
+ }
44
+
45
+ :is(.zone-busy-GmToil, .byline-field-file-upload-zone-busy) {
46
+ border-color: var(--gray-700);
47
+ background-color: oklch(from var(--canvas-800) l c h / .5);
48
+ color: var(--gray-600);
49
+ cursor: not-allowed;
50
+ }
51
+
52
+ :is(.icon-vXw1Do, .byline-field-file-upload-icon) {
53
+ opacity: .6;
54
+ width: 1.75rem;
55
+ height: 1.75rem;
56
+ }
57
+
58
+ :is(.label-XCy7AX, .byline-field-file-upload-label) {
59
+ font-size: var(--font-size-xs);
60
+ font-weight: var(--font-weight-medium);
61
+ }
62
+
63
+ :is(.action-LUT6jM, .byline-field-file-upload-action) {
64
+ color: var(--primary-400);
65
+ text-underline-offset: 2px;
66
+ text-decoration: underline;
67
+ }
68
+
69
+ :is(.error-fFWMW2, .byline-field-file-upload-error) {
70
+ color: var(--red-400);
71
+ font-size: var(--font-size-xs);
72
+ margin-top: .375rem;
73
+ }
74
+
@@ -2,8 +2,10 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
3
  import { isPendingStoredFileValue } from "@byline/core";
4
4
  import classnames from "classnames";
5
- import { useFieldError, useFieldValue, useFormContext, useIsDirty } from "../../forms/form-context.js";
6
- import { ErrorText, HelpText, Label } from "../../uikit.js";
5
+ import { IconButton } from "../../components/button/icon-button.js";
6
+ import { useFieldError, useFieldValue, useFormContext, useIsDirty, useIsFieldUploading } from "../../forms/form-context.js";
7
+ import { CloseIcon } from "../../icons/close-icon.js";
8
+ import { ErrorText, HelpText, Label, LoaderRing } from "../../uikit.js";
7
9
  import { ImageLightbox } from "../../widgets/image-lightbox/image-lightbox.js";
8
10
  import { useFieldChangeHandler } from "../use-field-change-handler.js";
9
11
  import image_field_module from "./image-field.module.js";
@@ -13,6 +15,7 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
13
15
  const fieldError = useFieldError(fieldPath);
14
16
  const isDirty = useIsDirty(fieldPath);
15
17
  const fieldValue = useFieldValue(fieldPath);
18
+ const isUploading = useIsFieldUploading(fieldPath);
16
19
  const { removePendingUpload } = useFormContext();
17
20
  const handleChange = useFieldChangeHandler(field, fieldPath);
18
21
  const incomingValue = isDirty ? fieldValue ?? null : value ?? fieldValue ?? defaultValue ?? null;
@@ -35,22 +38,14 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
35
38
  return /*#__PURE__*/ jsxs("div", {
36
39
  className: `byline-field-image ${field.name}`,
37
40
  children: [
38
- /*#__PURE__*/ jsxs("div", {
41
+ /*#__PURE__*/ jsx("div", {
39
42
  className: classnames('byline-field-image-header', image_field_module.header),
40
- children: [
41
- /*#__PURE__*/ jsx(Label, {
42
- id: htmlId,
43
- htmlFor: htmlId,
44
- label: field.label ?? field.name,
45
- required: !field.optional
46
- }),
47
- !showUploadWidget && collectionPath && /*#__PURE__*/ jsx("button", {
48
- type: "button",
49
- className: classnames('byline-field-image-remove', image_field_module.remove),
50
- onClick: handleRemove,
51
- children: "Remove"
52
- })
53
- ]
43
+ children: /*#__PURE__*/ jsx(Label, {
44
+ id: htmlId,
45
+ htmlFor: htmlId,
46
+ label: field.label ?? field.name,
47
+ required: !field.optional
48
+ })
54
49
  }),
55
50
  showUploadWidget ? collectionPath ? /*#__PURE__*/ jsx(ImageUploadField, {
56
51
  field: field,
@@ -65,6 +60,27 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
65
60
  }) : /*#__PURE__*/ jsxs("div", {
66
61
  className: classnames('byline-field-image-tile', image_field_module.tile),
67
62
  children: [
63
+ isUploading && /*#__PURE__*/ jsx("div", {
64
+ className: classnames('byline-field-image-uploading', image_field_module.uploading),
65
+ "aria-live": "polite",
66
+ "aria-busy": "true",
67
+ children: /*#__PURE__*/ jsx(LoaderRing, {})
68
+ }),
69
+ collectionPath && /*#__PURE__*/ jsx("div", {
70
+ className: classnames('byline-field-image-remove', image_field_module.remove),
71
+ children: /*#__PURE__*/ jsx(IconButton, {
72
+ type: "button",
73
+ intent: "noeffect",
74
+ onClick: handleRemove,
75
+ size: "xs",
76
+ disabled: isUploading,
77
+ "aria-label": "Remove image",
78
+ children: /*#__PURE__*/ jsx(CloseIcon, {
79
+ width: "15px",
80
+ height: "15px"
81
+ })
82
+ })
83
+ }),
68
84
  previewUrl && /*#__PURE__*/ jsxs("div", {
69
85
  className: classnames('byline-field-image-preview-wrap', image_field_module["preview-wrap"]),
70
86
  children: [
@@ -12,6 +12,7 @@ const image_field_module = {
12
12
  "preview-svg": "preview-svg-sCwreb",
13
13
  previewSvg: "preview-svg-sCwreb",
14
14
  pending: "pending-s6X_52",
15
+ uploading: "uploading-nqh2Gh",
15
16
  meta: "meta-uHyKiu",
16
17
  "meta-key": "meta-key-eR6iRU",
17
18
  metaKey: "meta-key-eR6iRU",
@@ -6,18 +6,18 @@
6
6
  }
7
7
 
8
8
  :is(.remove-ib7eKx, .byline-field-image-remove) {
9
- color: var(--red-500);
10
- font-size: var(--font-size-xs);
11
- cursor: pointer;
12
- background: none;
13
- border: none;
14
- padding: 0;
9
+ top: var(--spacing-6);
10
+ right: var(--spacing-6);
11
+ z-index: 1;
12
+ position: absolute;
15
13
  }
16
14
 
17
- :is(.remove-ib7eKx:hover, .byline-field-image-remove:hover) {
18
- color: var(--red-400);
19
- text-underline-offset: 2px;
20
- text-decoration: underline;
15
+ .byline-field-image-remove .byline-button {
16
+ color: var(--gray-900);
17
+ }
18
+
19
+ :is(.dark .byline-field-image-remove .byline-button, [data-theme="dark"] .byline-field-image-remove .byline-button) {
20
+ color: var(--gray-200);
21
21
  }
22
22
 
23
23
  :is(.empty-b8pdoJ, .byline-field-image-empty) {
@@ -33,6 +33,7 @@
33
33
  border-radius: var(--border-radius-md);
34
34
  margin-top: .25rem;
35
35
  display: flex;
36
+ position: relative;
36
37
  }
37
38
 
38
39
  :is(.preview-wrap-Cjr0PD, .byline-field-image-preview-wrap) {
@@ -90,7 +91,19 @@
90
91
  left: .25rem;
91
92
  }
92
93
 
94
+ :is(.uploading-nqh2Gh, .byline-field-image-uploading) {
95
+ z-index: 2;
96
+ background-color: oklch(from var(--gray-950) l c h / .5);
97
+ border-radius: var(--border-radius-md);
98
+ justify-content: center;
99
+ align-items: center;
100
+ display: flex;
101
+ position: absolute;
102
+ inset: 0;
103
+ }
104
+
93
105
  :is(.meta-uHyKiu, .byline-field-image-meta) {
106
+ padding-right: var(--spacing-32);
94
107
  color: var(--gray-200);
95
108
  font-size: var(--font-size-xs);
96
109
  flex-direction: column;
@@ -3,7 +3,9 @@ import { useState } from "react";
3
3
  import { getCollectionAdminConfig, getCollectionDefinition } from "@byline/core";
4
4
  import classnames from "classnames";
5
5
  import { useFieldError, useFieldValue } from "../../forms/form-context.js";
6
- import { Button, ErrorText, Label } from "../../uikit.js";
6
+ import { CloseIcon } from "../../icons/close-icon.js";
7
+ import { EditIcon } from "../../icons/edit-icon.js";
8
+ import { Button, ErrorText, IconButton, Label } from "../../uikit.js";
7
9
  import relation_field_module from "./relation-field.module.js";
8
10
  import { RelationPicker } from "./relation-picker.js";
9
11
  import { RelationSummary } from "./relation-summary.js";
@@ -38,22 +40,14 @@ const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
38
40
  return /*#__PURE__*/ jsxs("div", {
39
41
  className: `byline-field-relation ${field.name}`,
40
42
  children: [
41
- /*#__PURE__*/ jsxs("div", {
43
+ /*#__PURE__*/ jsx("div", {
42
44
  className: classnames('byline-field-relation-header', relation_field_module.header),
43
- children: [
44
- /*#__PURE__*/ jsx(Label, {
45
- id: `${htmlId}-label`,
46
- htmlFor: htmlId,
47
- label: field.label ?? field.name,
48
- required: !field.optional
49
- }),
50
- incomingValue && !isUnknown && /*#__PURE__*/ jsx("button", {
51
- type: "button",
52
- className: classnames('byline-field-relation-remove', relation_field_module.remove),
53
- onClick: handleRemove,
54
- children: "Remove"
55
- })
56
- ]
45
+ children: /*#__PURE__*/ jsx(Label, {
46
+ id: `${htmlId}-label`,
47
+ htmlFor: htmlId,
48
+ label: field.label ?? field.name,
49
+ required: !field.optional
50
+ })
57
51
  }),
58
52
  field.helpText && /*#__PURE__*/ jsx("div", {
59
53
  className: classnames('byline-field-relation-help', relation_field_module.help),
@@ -92,14 +86,33 @@ const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
92
86
  value: incomingValue,
93
87
  cachedRecord: cachedRecord
94
88
  }),
95
- /*#__PURE__*/ jsx(Button, {
96
- id: htmlId,
97
- size: "xs",
98
- variant: "outlined",
99
- intent: "noeffect",
100
- type: "button",
101
- onClick: ()=>setPickerOpen(true),
102
- children: "Change"
89
+ /*#__PURE__*/ jsxs("div", {
90
+ className: classnames('byline-field-relation-actions', relation_field_module.actions),
91
+ children: [
92
+ /*#__PURE__*/ jsx(IconButton, {
93
+ id: htmlId,
94
+ type: "button",
95
+ intent: "noeffect",
96
+ size: "xs",
97
+ "aria-label": `Change ${targetDef.labels.singular}`,
98
+ onClick: ()=>setPickerOpen(true),
99
+ children: /*#__PURE__*/ jsx(EditIcon, {
100
+ width: "15px",
101
+ height: "15px"
102
+ })
103
+ }),
104
+ /*#__PURE__*/ jsx(IconButton, {
105
+ type: "button",
106
+ intent: "noeffect",
107
+ size: "xs",
108
+ "aria-label": `Remove ${targetDef.labels.singular}`,
109
+ onClick: handleRemove,
110
+ children: /*#__PURE__*/ jsx(CloseIcon, {
111
+ width: "15px",
112
+ height: "15px"
113
+ })
114
+ })
115
+ ]
103
116
  })
104
117
  ]
105
118
  }) : /*#__PURE__*/ jsxs(Button, {
@@ -1,13 +1,13 @@
1
1
  import "./relation-field_module.css";
2
2
  const relation_field_module = {
3
3
  header: "header-y7lSnb",
4
- remove: "remove-po_DBR",
5
4
  help: "help-DEGVPH",
6
5
  "error-tile": "error-tile-y2kiiq",
7
6
  errorTile: "error-tile-y2kiiq",
8
7
  "error-text": "error-text-jHQh34",
9
8
  errorText: "error-text-jHQh34",
10
9
  tile: "tile-Y3_yre",
10
+ actions: "actions-Nov8hS",
11
11
  mono: "mono-y8Xo6b"
12
12
  };
13
13
  export default relation_field_module;
@@ -5,21 +5,6 @@
5
5
  display: flex;
6
6
  }
7
7
 
8
- :is(.remove-po_DBR, .byline-field-relation-remove) {
9
- color: var(--red-500);
10
- font-size: var(--font-size-xs);
11
- cursor: pointer;
12
- background: none;
13
- border: none;
14
- padding: 0;
15
- }
16
-
17
- :is(.remove-po_DBR:hover, .byline-field-relation-remove:hover) {
18
- color: var(--red-400);
19
- text-underline-offset: 2px;
20
- text-decoration: underline;
21
- }
22
-
23
8
  :is(.help-DEGVPH, .byline-field-relation-help) {
24
9
  color: var(--gray-400);
25
10
  font-size: var(--font-size-xs);
@@ -45,7 +30,7 @@
45
30
 
46
31
  :is(.tile-Y3_yre, .byline-field-relation-tile) {
47
32
  justify-content: space-between;
48
- align-items: center;
33
+ align-items: flex-start;
49
34
  gap: var(--spacing-8);
50
35
  padding: var(--spacing-8);
51
36
  border: var(--border-width-thin) var(--border-style-solid) var(--primary-500);
@@ -56,6 +41,21 @@
56
41
  display: flex;
57
42
  }
58
43
 
44
+ :is(.actions-Nov8hS, .byline-field-relation-actions) {
45
+ align-items: center;
46
+ gap: var(--spacing-4);
47
+ flex-shrink: 0;
48
+ display: flex;
49
+ }
50
+
51
+ .byline-field-relation-actions .byline-button {
52
+ color: var(--gray-900);
53
+ }
54
+
55
+ :is(.dark .byline-field-relation-actions .byline-button, [data-theme="dark"] .byline-field-relation-actions .byline-button) {
56
+ color: var(--gray-200);
57
+ }
58
+
59
59
  :is(.mono-y8Xo6b, .byline-field-relation-mono) {
60
60
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
61
61
  }
@@ -28,6 +28,7 @@ type FieldListener = (value: any) => void;
28
28
  type ErrorsListener = (errors: FormError[]) => void;
29
29
  type MetaListener = () => void;
30
30
  type SystemPathListener = (value: string | null) => void;
31
+ type FieldUploadingListener = (uploading: boolean) => void;
31
32
  interface FormContextType {
32
33
  setFieldValue: (name: string, value: any) => void;
33
34
  setFieldStore: (name: string, value: any) => void;
@@ -54,6 +55,9 @@ interface FormContextType {
54
55
  getPendingUploads: () => Map<string, PendingUpload>;
55
56
  hasPendingUploads: () => boolean;
56
57
  clearPendingUploads: () => void;
58
+ setFieldUploading: (fieldPath: string, uploading: boolean) => void;
59
+ getIsFieldUploading: (fieldPath: string) => boolean;
60
+ subscribeFieldUploading: (fieldPath: string, listener: FieldUploadingListener) => () => void;
57
61
  getSystemPath: () => string | null;
58
62
  setSystemPath: (value: string | null) => void;
59
63
  subscribeSystemPath: (listener: SystemPathListener) => () => void;
@@ -75,4 +79,11 @@ export declare const useFormMeta: () => {
75
79
  };
76
80
  export declare const useIsDirty: (name: string) => boolean;
77
81
  export declare const useFieldValue: <T = any>(name: string) => T | undefined;
82
+ /**
83
+ * Subscribe to a single field's upload-in-flight state. Returns `true` while
84
+ * the form orchestrator is actively transporting this field's pending upload
85
+ * (between the `setFieldUploading(path, true)` and the matching `false`
86
+ * emitted by the upload executor's progress callback).
87
+ */
88
+ export declare const useIsFieldUploading: (fieldPath: string) => boolean;
78
89
  export {};
@@ -2,7 +2,7 @@
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
4
4
  import { normalizeHooks } from "@byline/core";
5
- import { get, set } from "lodash-es";
5
+ import { get, set as external_lodash_es_set } from "lodash-es";
6
6
  const FormContext = /*#__PURE__*/ createContext(null);
7
7
  const useFormContext = ()=>{
8
8
  const context = useContext(FormContext);
@@ -16,6 +16,8 @@ const FormProvider = ({ children, initialData = {} })=>{
16
16
  const dirtyFields = useRef(new Set());
17
17
  const patchesRef = useRef([]);
18
18
  const pendingUploadsRef = useRef(new Map());
19
+ const uploadingFieldsRef = useRef(new Set());
20
+ const uploadingListenersRef = useRef(new Map());
19
21
  const fieldListeners = useRef(new Map());
20
22
  const errorListeners = useRef(new Set());
21
23
  const metaListeners = useRef(new Set());
@@ -65,7 +67,7 @@ const FormProvider = ({ children, initialData = {} })=>{
65
67
  const newFieldValues = {
66
68
  ...fieldValues.current
67
69
  };
68
- set(newFieldValues, name, value);
70
+ external_lodash_es_set(newFieldValues, name, value);
69
71
  fieldValues.current = newFieldValues;
70
72
  dirtyFields.current.add(name);
71
73
  notifyFieldListeners(name, value);
@@ -180,6 +182,34 @@ const FormProvider = ({ children, initialData = {} })=>{
180
182
  for (const upload of pendingUploadsRef.current.values())URL.revokeObjectURL(upload.previewUrl);
181
183
  pendingUploadsRef.current.clear();
182
184
  }, []);
185
+ const setFieldUploading = useCallback((fieldPath, uploading)=>{
186
+ if (uploading) {
187
+ if (uploadingFieldsRef.current.has(fieldPath)) return;
188
+ uploadingFieldsRef.current.add(fieldPath);
189
+ } else {
190
+ if (!uploadingFieldsRef.current.has(fieldPath)) return;
191
+ uploadingFieldsRef.current.delete(fieldPath);
192
+ }
193
+ uploadingListenersRef.current.get(fieldPath)?.forEach((listener)=>{
194
+ listener(uploading);
195
+ });
196
+ }, []);
197
+ const getIsFieldUploading = useCallback((fieldPath)=>uploadingFieldsRef.current.has(fieldPath), []);
198
+ const subscribeFieldUploading = useCallback((fieldPath, listener)=>{
199
+ let listeners = uploadingListenersRef.current.get(fieldPath);
200
+ if (!listeners) {
201
+ listeners = new Set();
202
+ uploadingListenersRef.current.set(fieldPath, listeners);
203
+ }
204
+ listeners.add(listener);
205
+ return ()=>{
206
+ const set = uploadingListenersRef.current.get(fieldPath);
207
+ if (set) {
208
+ set.delete(listener);
209
+ if (0 === set.size) uploadingListenersRef.current.delete(fieldPath);
210
+ }
211
+ };
212
+ }, []);
183
213
  useEffect(()=>()=>{
184
214
  for (const upload of pendingUploadsRef.current.values())URL.revokeObjectURL(upload.previewUrl);
185
215
  }, []);
@@ -343,6 +373,9 @@ const FormProvider = ({ children, initialData = {} })=>{
343
373
  getPendingUploads,
344
374
  hasPendingUploads,
345
375
  clearPendingUploads,
376
+ setFieldUploading,
377
+ getIsFieldUploading,
378
+ subscribeFieldUploading,
346
379
  getSystemPath,
347
380
  setSystemPath,
348
381
  subscribeSystemPath
@@ -419,4 +452,15 @@ const useFieldValue = (name)=>{
419
452
  ]);
420
453
  return value;
421
454
  };
422
- export { FormProvider, useFieldError, useFieldValue, useFormContext, useFormMeta, useFormStore, useIsDirty, useSystemPath };
455
+ const useIsFieldUploading = (fieldPath)=>{
456
+ const { getIsFieldUploading, subscribeFieldUploading } = useFormContext();
457
+ const [uploading, setUploading] = useState(()=>getIsFieldUploading(fieldPath));
458
+ useEffect(()=>subscribeFieldUploading(fieldPath, (next)=>{
459
+ setUploading(next);
460
+ }), [
461
+ subscribeFieldUploading,
462
+ fieldPath
463
+ ]);
464
+ return uploading;
465
+ };
466
+ export { FormProvider, useFieldError, useFieldValue, useFormContext, useFormMeta, useFormStore, useIsDirty, useIsFieldUploading, useSystemPath };
@@ -14,7 +14,7 @@ import { FormProvider, useFieldValue, useFormContext } from "./form-context.js";
14
14
  import form_renderer_module from "./form-renderer.module.js";
15
15
  import { useNavigationGuardAdapter } from "./navigation-guard.js";
16
16
  import { PathWidget } from "./path-widget.js";
17
- import { executeUploads } from "./upload-executor.js";
17
+ import { executeUploadsWithProgress } from "./upload-executor.js";
18
18
  const FormStatusDisplay = ({ initialData, workflowStatuses, publishedVersion, onUnpublish })=>{
19
19
  const statusCode = initialData?.status;
20
20
  const statusLabel = workflowStatuses?.find((s)=>s.name === statusCode)?.label ?? statusCode;
@@ -139,7 +139,7 @@ function computeStatusTransitions(currentStatus, workflowStatuses, nextStatus) {
139
139
  };
140
140
  }
141
141
  const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, _activeTabBySet, _onTabChange })=>{
142
- const { getFieldValues, runFieldHooks, validateForm, errors: initialErrors, hasChanges: hasChangesFn, resetHasChanges, getPatches, getSystemPath, subscribeErrors, subscribeMeta, setFieldValue, setFieldError, getPendingUploads, clearPendingUploads } = useFormContext();
142
+ const { getFieldValues, runFieldHooks, validateForm, errors: initialErrors, hasChanges: hasChangesFn, resetHasChanges, getPatches, getSystemPath, subscribeErrors, subscribeMeta, setFieldValue, setFieldError, getPendingUploads, clearPendingUploads, setFieldUploading } = useFormContext();
143
143
  const [errors, setErrors] = useState(initialErrors);
144
144
  const [hasChanges, setHasChanges] = useState(hasChangesFn());
145
145
  const [statusBusy, setStatusBusy] = useState(false);
@@ -273,7 +273,9 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
273
273
  if (pendingUploads.size > 0) {
274
274
  setIsUploading(true);
275
275
  try {
276
- const uploadResult = await executeUploads(pendingUploads, uploadField);
276
+ const uploadResult = await executeUploadsWithProgress(pendingUploads, uploadField, ({ fieldPath, status })=>{
277
+ setFieldUploading(fieldPath, 'uploading' === status);
278
+ });
277
279
  if (!uploadResult.allSucceeded) {
278
280
  for (const [fieldPath, errorMessage] of uploadResult.errors.entries())setFieldError(fieldPath, `Upload failed: ${errorMessage}`);
279
281
  console.error('One or more uploads failed:', uploadResult.errors);