@byline/ui 2.2.9 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) 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/forms/form-renderer_module.css +1 -2
  20. package/dist/icons/index.d.ts +1 -0
  21. package/dist/icons/index.js +1 -0
  22. package/dist/icons/video-icon.d.ts +6 -0
  23. package/dist/icons/video-icon.js +36 -0
  24. package/dist/react.d.ts +1 -0
  25. package/dist/react.js +1 -0
  26. package/package.json +5 -4
  27. package/src/fields/field-renderer.tsx +1 -0
  28. package/src/fields/file/file-field.module.css +114 -49
  29. package/src/fields/file/file-field.tsx +220 -56
  30. package/src/fields/file/file-upload-field.module.css +101 -0
  31. package/src/fields/file/file-upload-field.tsx +183 -0
  32. package/src/fields/image/image-field.module.css +27 -13
  33. package/src/fields/image/image-field.tsx +35 -12
  34. package/src/fields/relation/relation-field.module.css +21 -21
  35. package/src/fields/relation/relation-field.tsx +24 -20
  36. package/src/forms/form-context.tsx +73 -0
  37. package/src/forms/form-renderer.module.css +1 -2
  38. package/src/forms/form-renderer.tsx +9 -2
  39. package/src/icons/index.ts +1 -0
  40. package/src/icons/video-icon.tsx +32 -0
  41. package/src/react.ts +1 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * FileUploadField — drag-and-drop file picker that registers a deferred
3
+ * upload in form context.
4
+ *
5
+ * Override handles:
6
+ * .byline-field-file-upload — root wrapper
7
+ * .byline-field-file-upload-input — visually-hidden file input
8
+ * .byline-field-file-upload-zone — clickable / drop target
9
+ * .byline-field-file-upload-zone-active — drag-hovered state
10
+ * .byline-field-file-upload-zone-busy — processing state
11
+ * .byline-field-file-upload-icon — upload icon svg
12
+ * .byline-field-file-upload-label — primary text inside the zone
13
+ * .byline-field-file-upload-action — "browse" link inside the label
14
+ * .byline-field-file-upload-error — error message paragraph
15
+ */
16
+
17
+ .root,
18
+ :global(.byline-field-file-upload) {
19
+ margin-top: 0.25rem;
20
+ }
21
+
22
+ .input,
23
+ :global(.byline-field-file-upload-input) {
24
+ position: absolute;
25
+ width: 1px;
26
+ height: 1px;
27
+ padding: 0;
28
+ margin: -1px;
29
+ overflow: hidden;
30
+ clip: rect(0, 0, 0, 0);
31
+ white-space: nowrap;
32
+ border: 0;
33
+ }
34
+
35
+ .zone,
36
+ :global(.byline-field-file-upload-zone) {
37
+ display: flex;
38
+ flex-direction: column;
39
+ align-items: center;
40
+ justify-content: center;
41
+ gap: var(--spacing-8);
42
+ padding: 1.5rem 1rem;
43
+ border: 2px dashed var(--gray-600);
44
+ border-radius: var(--border-radius-lg);
45
+ color: var(--gray-400);
46
+ text-align: center;
47
+ cursor: pointer;
48
+ user-select: none;
49
+ transition:
50
+ color 150ms ease,
51
+ background-color 150ms ease,
52
+ border-color 150ms ease;
53
+ }
54
+
55
+ .zone:hover,
56
+ :global(.byline-field-file-upload-zone):hover {
57
+ border-color: var(--primary-500);
58
+ background-color: oklch(from var(--primary-900) l c h / 0.1);
59
+ }
60
+
61
+ .zone-active,
62
+ :global(.byline-field-file-upload-zone-active) {
63
+ border-color: var(--primary-400);
64
+ background-color: oklch(from var(--primary-900) l c h / 0.2);
65
+ color: var(--primary-300);
66
+ }
67
+
68
+ .zone-busy,
69
+ :global(.byline-field-file-upload-zone-busy) {
70
+ border-color: var(--gray-700);
71
+ background-color: oklch(from var(--canvas-800) l c h / 0.5);
72
+ color: var(--gray-600);
73
+ cursor: not-allowed;
74
+ }
75
+
76
+ .icon,
77
+ :global(.byline-field-file-upload-icon) {
78
+ width: 1.75rem;
79
+ height: 1.75rem;
80
+ opacity: 0.6;
81
+ }
82
+
83
+ .label,
84
+ :global(.byline-field-file-upload-label) {
85
+ font-size: var(--font-size-xs);
86
+ font-weight: var(--font-weight-medium);
87
+ }
88
+
89
+ .action,
90
+ :global(.byline-field-file-upload-action) {
91
+ color: var(--primary-400);
92
+ text-decoration: underline;
93
+ text-underline-offset: 2px;
94
+ }
95
+
96
+ .error,
97
+ :global(.byline-field-file-upload-error) {
98
+ margin-top: 0.375rem;
99
+ color: var(--red-400);
100
+ font-size: var(--font-size-xs);
101
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * FileUploadField
11
+ *
12
+ * Generic drag-and-drop / click-to-browse file picker that prepares a file for
13
+ * upload. Mirrors `ImageUploadField` but without image-specific validation or
14
+ * dimension extraction. The actual upload is deferred until form submission —
15
+ * this component stores the file in the form context's pending uploads and
16
+ * emits a placeholder StoredFileValue with a blob URL (used by the form
17
+ * orchestrator for cleanup; not shown to the user).
18
+ */
19
+
20
+ import type { ChangeEvent, DragEvent } from 'react'
21
+ import { useCallback, useRef, useState } from 'react'
22
+
23
+ import {
24
+ createPendingStoredFileValue,
25
+ type FileField as FieldType,
26
+ type PendingStoredFileValue,
27
+ type StoredFileValue,
28
+ } from '@byline/core'
29
+ import cx from 'classnames'
30
+
31
+ import { useFormContext } from '../../forms/form-context'
32
+ import styles from './file-upload-field.module.css'
33
+
34
+ interface FileUploadFieldProps {
35
+ field: FieldType
36
+ /** Collection path used to build the upload URL (e.g. `'media'`). */
37
+ collectionPath: string
38
+ /** Field path in the form (e.g. `'attachment'` or `'content.0.attachment'`). */
39
+ fieldPath: string
40
+ /** Called with the PendingStoredFileValue for immediate UI update. */
41
+ onUploaded: (value: StoredFileValue | PendingStoredFileValue) => void
42
+ /** Optional `accept` MIME-type / extension string for the native file input. */
43
+ accept?: string
44
+ }
45
+
46
+ type SelectionStatus = 'idle' | 'processing' | 'error'
47
+
48
+ export const FileUploadField = ({
49
+ field: _field,
50
+ collectionPath,
51
+ fieldPath,
52
+ onUploaded,
53
+ accept,
54
+ }: FileUploadFieldProps) => {
55
+ const inputRef = useRef<HTMLInputElement>(null)
56
+ const [status, setStatus] = useState<SelectionStatus>('idle')
57
+ const [errorMessage, setErrorMessage] = useState<string | null>(null)
58
+ const [isDragOver, setIsDragOver] = useState(false)
59
+ const { addPendingUpload } = useFormContext()
60
+
61
+ const handleFileSelected = useCallback(
62
+ (file: File) => {
63
+ setStatus('processing')
64
+ setErrorMessage(null)
65
+
66
+ // Blob URL is created so the form orchestrator can revoke it on cleanup
67
+ // alongside image-field uploads; it isn't surfaced in the UI here.
68
+ const previewUrl = URL.createObjectURL(file)
69
+
70
+ const pendingValue = createPendingStoredFileValue(file, previewUrl)
71
+
72
+ addPendingUpload(fieldPath, {
73
+ file,
74
+ previewUrl,
75
+ collectionPath,
76
+ })
77
+
78
+ setStatus('idle')
79
+ onUploaded(pendingValue)
80
+ },
81
+ [collectionPath, fieldPath, addPendingUpload, onUploaded]
82
+ )
83
+
84
+ const handleFileChange = useCallback(
85
+ (e: ChangeEvent<HTMLInputElement>) => {
86
+ const file = e.target.files?.[0]
87
+ if (file) handleFileSelected(file)
88
+ // Reset so re-selecting the same file fires the event again.
89
+ e.target.value = ''
90
+ },
91
+ [handleFileSelected]
92
+ )
93
+
94
+ const handleBrowseClick = useCallback(() => {
95
+ inputRef.current?.click()
96
+ }, [])
97
+
98
+ const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
99
+ e.preventDefault()
100
+ setIsDragOver(true)
101
+ }, [])
102
+
103
+ const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
104
+ e.preventDefault()
105
+ setIsDragOver(false)
106
+ }, [])
107
+
108
+ const handleDrop = useCallback(
109
+ (e: DragEvent<HTMLDivElement>) => {
110
+ e.preventDefault()
111
+ setIsDragOver(false)
112
+ const file = e.dataTransfer.files?.[0]
113
+ if (file) handleFileSelected(file)
114
+ },
115
+ [handleFileSelected]
116
+ )
117
+
118
+ const isProcessing = status === 'processing'
119
+
120
+ return (
121
+ <div className={cx('byline-field-file-upload', styles.root)}>
122
+ <input
123
+ ref={inputRef}
124
+ type="file"
125
+ accept={accept}
126
+ className={cx('byline-field-file-upload-input', styles.input)}
127
+ onChange={handleFileChange}
128
+ disabled={isProcessing}
129
+ aria-hidden="true"
130
+ tabIndex={-1}
131
+ />
132
+
133
+ <div
134
+ role="button"
135
+ tabIndex={0}
136
+ aria-label="Upload file — drag and drop or click to browse"
137
+ onDragOver={handleDragOver}
138
+ onDragLeave={handleDragLeave}
139
+ onDrop={handleDrop}
140
+ onClick={handleBrowseClick}
141
+ onKeyDown={(e) => {
142
+ if (e.key === 'Enter' || e.key === ' ') {
143
+ e.preventDefault()
144
+ handleBrowseClick()
145
+ }
146
+ }}
147
+ className={cx(
148
+ 'byline-field-file-upload-zone',
149
+ styles.zone,
150
+ isDragOver &&
151
+ !isProcessing && ['byline-field-file-upload-zone-active', styles['zone-active']],
152
+ isProcessing && ['byline-field-file-upload-zone-busy', styles['zone-busy']]
153
+ )}
154
+ >
155
+ <svg
156
+ xmlns="http://www.w3.org/2000/svg"
157
+ className={cx('byline-field-file-upload-icon', styles.icon)}
158
+ fill="none"
159
+ viewBox="0 0 24 24"
160
+ stroke="currentColor"
161
+ strokeWidth={1.5}
162
+ aria-hidden="true"
163
+ >
164
+ <path
165
+ strokeLinecap="round"
166
+ strokeLinejoin="round"
167
+ 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"
168
+ />
169
+ </svg>
170
+ <span className={cx('byline-field-file-upload-label', styles.label)}>
171
+ Drop file here or{' '}
172
+ <span className={cx('byline-field-file-upload-action', styles.action)}>browse</span>
173
+ </span>
174
+ </div>
175
+
176
+ {status === 'error' && errorMessage && (
177
+ <p className={cx('byline-field-file-upload-error', styles.error)} role="alert">
178
+ {errorMessage}
179
+ </p>
180
+ )}
181
+ </div>
182
+ )
183
+ }
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Override handles:
5
5
  * .byline-field-image — wrapper div
6
- * .byline-field-image-header — label + remove button row
7
- * .byline-field-image-remove — remove-button text-link
6
+ * .byline-field-image-header — label row
7
+ * .byline-field-image-remove — remove-button (icon, top-right of tile)
8
8
  * .byline-field-image-empty — empty-state hint text
9
9
  * .byline-field-image-tile — bordered preview/metadata tile
10
10
  * .byline-field-image-preview-wrap — preview-image positioned wrapper
@@ -26,19 +26,19 @@
26
26
 
27
27
  .remove,
28
28
  :global(.byline-field-image-remove) {
29
- background: none;
30
- border: none;
31
- padding: 0;
32
- color: var(--red-500);
33
- font-size: var(--font-size-xs);
34
- cursor: pointer;
29
+ position: absolute;
30
+ top: var(--spacing-6);
31
+ right: var(--spacing-6);
32
+ z-index: 1;
35
33
  }
36
34
 
37
- .remove:hover,
38
- :global(.byline-field-image-remove):hover {
39
- color: var(--red-400);
40
- text-decoration: underline;
41
- text-underline-offset: 2px;
35
+ :global(.byline-field-image-remove .byline-button) {
36
+ color: var(--gray-900);
37
+ }
38
+
39
+ :global(.dark .byline-field-image-remove .byline-button),
40
+ :global([data-theme="dark"] .byline-field-image-remove .byline-button) {
41
+ color: var(--gray-200);
42
42
  }
43
43
 
44
44
  .empty,
@@ -50,6 +50,7 @@
50
50
 
51
51
  .tile,
52
52
  :global(.byline-field-image-tile) {
53
+ position: relative;
53
54
  display: flex;
54
55
  gap: var(--spacing-16);
55
56
  margin-top: 0.25rem;
@@ -121,11 +122,24 @@
121
122
  border-radius: var(--border-radius-sm);
122
123
  }
123
124
 
125
+ .uploading,
126
+ :global(.byline-field-image-uploading) {
127
+ position: absolute;
128
+ inset: 0;
129
+ z-index: 2;
130
+ display: flex;
131
+ align-items: center;
132
+ justify-content: center;
133
+ background-color: oklch(from var(--gray-950) l c h / 0.5);
134
+ border-radius: var(--border-radius-md);
135
+ }
136
+
124
137
  .meta,
125
138
  :global(.byline-field-image-meta) {
126
139
  display: flex;
127
140
  flex-direction: column;
128
141
  gap: 0.125rem;
142
+ padding-right: var(--spacing-32);
129
143
  color: var(--gray-200);
130
144
  font-size: var(--font-size-xs);
131
145
  }
@@ -15,8 +15,16 @@ import {
15
15
  } from '@byline/core'
16
16
  import cx from 'classnames'
17
17
 
18
- import { useFieldError, useFieldValue, useFormContext, useIsDirty } from '../../forms/form-context'
19
- import { ErrorText, HelpText, Label } from '../../uikit.js'
18
+ import { IconButton } from '../../components/button/icon-button.js'
19
+ import {
20
+ useFieldError,
21
+ useFieldValue,
22
+ useFormContext,
23
+ useIsDirty,
24
+ useIsFieldUploading,
25
+ } from '../../forms/form-context'
26
+ import { CloseIcon } from '../../icons/close-icon.js'
27
+ import { ErrorText, HelpText, Label, LoaderRing } from '../../uikit.js'
20
28
  import { ImageLightbox } from '../../widgets/image-lightbox/image-lightbox.js'
21
29
  import { useFieldChangeHandler } from '../use-field-change-handler'
22
30
  import styles from './image-field.module.css'
@@ -46,6 +54,7 @@ export const ImageField = ({
46
54
  const fieldError = useFieldError(fieldPath)
47
55
  const isDirty = useIsDirty(fieldPath)
48
56
  const fieldValue = useFieldValue<StoredFileValue | null | undefined>(fieldPath)
57
+ const isUploading = useIsFieldUploading(fieldPath)
49
58
  const { removePendingUpload } = useFormContext()
50
59
 
51
60
  // Re-use the standard field change handler so patches are emitted correctly.
@@ -103,16 +112,6 @@ export const ImageField = ({
103
112
  label={field.label ?? field.name}
104
113
  required={!field.optional}
105
114
  />
106
- {/* Remove button — shown when an image is set (including pending) */}
107
- {!showUploadWidget && collectionPath && (
108
- <button
109
- type="button"
110
- className={cx('byline-field-image-remove', styles.remove)}
111
- onClick={handleRemove}
112
- >
113
- Remove
114
- </button>
115
- )}
116
115
  </div>
117
116
 
118
117
  {showUploadWidget ? (
@@ -130,6 +129,30 @@ export const ImageField = ({
130
129
  )
131
130
  ) : (
132
131
  <div className={cx('byline-field-image-tile', styles.tile)}>
132
+ {isUploading && (
133
+ <div
134
+ className={cx('byline-field-image-uploading', styles.uploading)}
135
+ aria-live="polite"
136
+ aria-busy="true"
137
+ >
138
+ <LoaderRing />
139
+ </div>
140
+ )}
141
+ {/* Remove button — shown when an image is set (including pending) */}
142
+ {collectionPath && (
143
+ <div className={cx('byline-field-image-remove', styles.remove)}>
144
+ <IconButton
145
+ type="button"
146
+ intent="noeffect"
147
+ onClick={handleRemove}
148
+ size="xs"
149
+ disabled={isUploading}
150
+ aria-label="Remove image"
151
+ >
152
+ <CloseIcon width="15px" height="15px" />
153
+ </IconButton>
154
+ </div>
155
+ )}
133
156
  {/* Preview */}
134
157
  {previewUrl && (
135
158
  <div className={cx('byline-field-image-preview-wrap', styles['preview-wrap'])}>
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * Override handles:
5
5
  * .byline-field-relation — outer wrapper
6
- * .byline-field-relation-header — label + remove-button row
7
- * .byline-field-relation-remove — text-link remove button
6
+ * .byline-field-relation-header — label row
8
7
  * .byline-field-relation-help — help text below the label
9
8
  * .byline-field-relation-error-tile — wrapper shown when target collection is unknown
10
9
  * .byline-field-relation-error-text — secondary error message below the title
11
- * .byline-field-relation-tile — bordered selected-value + change-button container
10
+ * .byline-field-relation-tile — bordered selected-value + actions container
11
+ * .byline-field-relation-actions — top-right icon-button group (edit + remove)
12
12
  * .byline-field-relation-mono — monospace `<code>` for field/collection names
13
13
  */
14
14
 
@@ -20,23 +20,6 @@
20
20
  margin-bottom: 0.25rem;
21
21
  }
22
22
 
23
- .remove,
24
- :global(.byline-field-relation-remove) {
25
- background: none;
26
- border: none;
27
- padding: 0;
28
- color: var(--red-500);
29
- font-size: var(--font-size-xs);
30
- cursor: pointer;
31
- }
32
-
33
- .remove:hover,
34
- :global(.byline-field-relation-remove):hover {
35
- color: var(--red-400);
36
- text-decoration: underline;
37
- text-underline-offset: 2px;
38
- }
39
-
40
23
  .help,
41
24
  :global(.byline-field-relation-help) {
42
25
  margin-bottom: 0.25rem;
@@ -66,7 +49,7 @@
66
49
  .tile,
67
50
  :global(.byline-field-relation-tile) {
68
51
  display: flex;
69
- align-items: center;
52
+ align-items: flex-start;
70
53
  justify-content: space-between;
71
54
  gap: var(--spacing-8);
72
55
  margin-top: 0.25rem;
@@ -77,6 +60,23 @@
77
60
  font-size: var(--font-size-xs);
78
61
  }
79
62
 
63
+ .actions,
64
+ :global(.byline-field-relation-actions) {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: var(--spacing-4);
68
+ flex-shrink: 0;
69
+ }
70
+
71
+ :global(.byline-field-relation-actions .byline-button) {
72
+ color: var(--gray-900);
73
+ }
74
+
75
+ :global(.dark .byline-field-relation-actions .byline-button),
76
+ :global([data-theme="dark"] .byline-field-relation-actions .byline-button) {
77
+ color: var(--gray-200);
78
+ }
79
+
80
80
  .mono,
81
81
  :global(.byline-field-relation-mono) {
82
82
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
@@ -18,7 +18,9 @@ import { getCollectionAdminConfig, getCollectionDefinition } from '@byline/core'
18
18
  import cx from 'classnames'
19
19
 
20
20
  import { useFieldError, useFieldValue } from '../../forms/form-context'
21
- import { Button, ErrorText, Label } from '../../uikit.js'
21
+ import { CloseIcon } from '../../icons/close-icon.js'
22
+ import { EditIcon } from '../../icons/edit-icon.js'
23
+ import { Button, ErrorText, IconButton, Label } from '../../uikit.js'
22
24
  import styles from './relation-field.module.css'
23
25
  import { RelationPicker } from './relation-picker'
24
26
  import { RelationSummary } from './relation-summary'
@@ -128,15 +130,6 @@ export const RelationField = ({
128
130
  label={field.label ?? field.name}
129
131
  required={!field.optional}
130
132
  />
131
- {incomingValue && !isUnknown && (
132
- <button
133
- type="button"
134
- className={cx('byline-field-relation-remove', styles.remove)}
135
- onClick={handleRemove}
136
- >
137
- Remove
138
- </button>
139
- )}
140
133
  </div>
141
134
  {field.helpText && (
142
135
  <div className={cx('byline-field-relation-help', styles.help)}>{field.helpText}</div>
@@ -161,16 +154,27 @@ export const RelationField = ({
161
154
  value={incomingValue}
162
155
  cachedRecord={cachedRecord}
163
156
  />
164
- <Button
165
- id={htmlId}
166
- size="xs"
167
- variant="outlined"
168
- intent="noeffect"
169
- type="button"
170
- onClick={() => setPickerOpen(true)}
171
- >
172
- Change
173
- </Button>
157
+ <div className={cx('byline-field-relation-actions', styles.actions)}>
158
+ <IconButton
159
+ id={htmlId}
160
+ type="button"
161
+ intent="noeffect"
162
+ size="xs"
163
+ aria-label={`Change ${targetDef.labels.singular}`}
164
+ onClick={() => setPickerOpen(true)}
165
+ >
166
+ <EditIcon width="15px" height="15px" />
167
+ </IconButton>
168
+ <IconButton
169
+ type="button"
170
+ intent="noeffect"
171
+ size="xs"
172
+ aria-label={`Remove ${targetDef.labels.singular}`}
173
+ onClick={handleRemove}
174
+ >
175
+ <CloseIcon width="15px" height="15px" />
176
+ </IconButton>
177
+ </div>
174
178
  </div>
175
179
  ) : (
176
180
  <Button
@@ -38,6 +38,7 @@ type FieldListener = (value: any) => void
38
38
  type ErrorsListener = (errors: FormError[]) => void
39
39
  type MetaListener = () => void
40
40
  type SystemPathListener = (value: string | null) => void
41
+ type FieldUploadingListener = (uploading: boolean) => void
41
42
 
42
43
  interface FormContextType {
43
44
  setFieldValue: (name: string, value: any) => void
@@ -66,6 +67,12 @@ interface FormContextType {
66
67
  getPendingUploads: () => Map<string, PendingUpload>
67
68
  hasPendingUploads: () => boolean
68
69
  clearPendingUploads: () => void
70
+ // Per-field upload-in-flight tracking. Mirrors the pending-uploads map but
71
+ // for the window during which the upload-executor is actively transporting
72
+ // a given fieldPath, so widgets can render a localised spinner/overlay.
73
+ setFieldUploading: (fieldPath: string, uploading: boolean) => void
74
+ getIsFieldUploading: (fieldPath: string) => boolean
75
+ subscribeFieldUploading: (fieldPath: string, listener: FieldUploadingListener) => () => void
69
76
  // System-managed `path` slot (persisted in `byline_document_paths`),
70
77
  // edited by the path widget. `null` means the widget will fall back
71
78
  // to live-derived preview / the server-side default; a non-null value
@@ -100,6 +107,8 @@ export const FormProvider = ({
100
107
  const dirtyFields = useRef<Set<string>>(new Set())
101
108
  const patchesRef = useRef<DocumentPatch[]>([])
102
109
  const pendingUploadsRef = useRef<Map<string, PendingUpload>>(new Map())
110
+ const uploadingFieldsRef = useRef<Set<string>>(new Set())
111
+ const uploadingListenersRef = useRef<Map<string, Set<FieldUploadingListener>>>(new Map())
103
112
 
104
113
  const fieldListeners = useRef<Map<string, Set<FieldListener>>>(new Map())
105
114
  const errorListeners = useRef<Set<ErrorsListener>>(new Set())
@@ -340,6 +349,48 @@ export const FormProvider = ({
340
349
  pendingUploadsRef.current.clear()
341
350
  }, [])
342
351
 
352
+ // ---------------------------------------------------------------------------
353
+ // Per-field upload-in-flight tracking
354
+ // ---------------------------------------------------------------------------
355
+
356
+ const setFieldUploading = useCallback((fieldPath: string, uploading: boolean) => {
357
+ if (uploading) {
358
+ if (uploadingFieldsRef.current.has(fieldPath)) return
359
+ uploadingFieldsRef.current.add(fieldPath)
360
+ } else {
361
+ if (!uploadingFieldsRef.current.has(fieldPath)) return
362
+ uploadingFieldsRef.current.delete(fieldPath)
363
+ }
364
+ uploadingListenersRef.current.get(fieldPath)?.forEach((listener) => {
365
+ listener(uploading)
366
+ })
367
+ }, [])
368
+
369
+ const getIsFieldUploading = useCallback((fieldPath: string) => {
370
+ return uploadingFieldsRef.current.has(fieldPath)
371
+ }, [])
372
+
373
+ const subscribeFieldUploading = useCallback(
374
+ (fieldPath: string, listener: FieldUploadingListener) => {
375
+ let listeners = uploadingListenersRef.current.get(fieldPath)
376
+ if (!listeners) {
377
+ listeners = new Set()
378
+ uploadingListenersRef.current.set(fieldPath, listeners)
379
+ }
380
+ listeners.add(listener)
381
+ return () => {
382
+ const set = uploadingListenersRef.current.get(fieldPath)
383
+ if (set) {
384
+ set.delete(listener)
385
+ if (set.size === 0) {
386
+ uploadingListenersRef.current.delete(fieldPath)
387
+ }
388
+ }
389
+ }
390
+ },
391
+ []
392
+ )
393
+
343
394
  // Cleanup blob URLs on unmount
344
395
  useEffect(() => {
345
396
  return () => {
@@ -534,6 +585,9 @@ export const FormProvider = ({
534
585
  getPendingUploads,
535
586
  hasPendingUploads,
536
587
  clearPendingUploads,
588
+ setFieldUploading,
589
+ getIsFieldUploading,
590
+ subscribeFieldUploading,
537
591
  getSystemPath,
538
592
  setSystemPath,
539
593
  subscribeSystemPath,
@@ -629,3 +683,22 @@ export const useFieldValue = <T = any>(name: string): T | undefined => {
629
683
 
630
684
  return value
631
685
  }
686
+
687
+ /**
688
+ * Subscribe to a single field's upload-in-flight state. Returns `true` while
689
+ * the form orchestrator is actively transporting this field's pending upload
690
+ * (between the `setFieldUploading(path, true)` and the matching `false`
691
+ * emitted by the upload executor's progress callback).
692
+ */
693
+ export const useIsFieldUploading = (fieldPath: string): boolean => {
694
+ const { getIsFieldUploading, subscribeFieldUploading } = useFormContext()
695
+ const [uploading, setUploading] = useState<boolean>(() => getIsFieldUploading(fieldPath))
696
+
697
+ useEffect(() => {
698
+ return subscribeFieldUploading(fieldPath, (next) => {
699
+ setUploading(next)
700
+ })
701
+ }, [subscribeFieldUploading, fieldPath])
702
+
703
+ return uploading
704
+ }
@@ -261,8 +261,7 @@
261
261
  padding-right: 12px;
262
262
  padding-top: 0.25rem;
263
263
  padding-left: var(--spacing-16);
264
- border-left: var(--border-width-thin) var(--border-style-solid)
265
- var(--gray-100);
264
+ border-left: var(--border-width-thin) var(--border-style-solid) var(--gray-100);
266
265
  position: sticky;
267
266
  top: 95px;
268
267
  }