@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.
- package/dist/fields/field-renderer.js +2 -1
- package/dist/fields/file/file-field.d.ts +4 -2
- package/dist/fields/file/file-field.js +182 -81
- package/dist/fields/file/file-field.module.js +10 -5
- package/dist/fields/file/file-field_module.css +99 -32
- package/dist/fields/file/file-upload-field.d.ts +21 -0
- package/dist/fields/file/file-upload-field.js +128 -0
- package/dist/fields/file/file-upload-field.module.js +15 -0
- package/dist/fields/file/file-upload-field_module.css +74 -0
- package/dist/fields/image/image-field.js +33 -17
- package/dist/fields/image/image-field.module.js +1 -0
- package/dist/fields/image/image-field_module.css +23 -10
- package/dist/fields/relation/relation-field.js +37 -24
- package/dist/fields/relation/relation-field.module.js +1 -1
- package/dist/fields/relation/relation-field_module.css +16 -16
- package/dist/forms/form-context.d.ts +11 -0
- package/dist/forms/form-context.js +47 -3
- package/dist/forms/form-renderer.js +5 -3
- package/dist/forms/form-renderer_module.css +1 -2
- package/dist/icons/index.d.ts +1 -0
- package/dist/icons/index.js +1 -0
- package/dist/icons/video-icon.d.ts +6 -0
- package/dist/icons/video-icon.js +36 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +1 -0
- package/package.json +5 -4
- package/src/fields/field-renderer.tsx +1 -0
- package/src/fields/file/file-field.module.css +114 -49
- package/src/fields/file/file-field.tsx +220 -56
- package/src/fields/file/file-upload-field.module.css +101 -0
- package/src/fields/file/file-upload-field.tsx +183 -0
- package/src/fields/image/image-field.module.css +27 -13
- package/src/fields/image/image-field.tsx +35 -12
- package/src/fields/relation/relation-field.module.css +21 -21
- package/src/fields/relation/relation-field.tsx +24 -20
- package/src/forms/form-context.tsx +73 -0
- package/src/forms/form-renderer.module.css +1 -2
- package/src/forms/form-renderer.tsx +9 -2
- package/src/icons/index.ts +1 -0
- package/src/icons/video-icon.tsx +32 -0
- 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
|
|
7
|
-
* .byline-field-image-remove — remove-button
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
38
|
-
:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 {
|
|
19
|
-
import {
|
|
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
|
|
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 +
|
|
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:
|
|
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 {
|
|
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
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
}
|