@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
@@ -49,6 +49,7 @@ export * from './stopwatch-icon.js';
49
49
  export * from './success-icon.js';
50
50
  export * from './user-icon.js';
51
51
  export * from './users-icon.js';
52
+ export * from './video-icon.js';
52
53
  export * from './wallet-icon.js';
53
54
  export * from './warning-icon.js';
54
55
  export * from './x-icon.js';
@@ -49,6 +49,7 @@ export * from "./stopwatch-icon.js";
49
49
  export * from "./success-icon.js";
50
50
  export * from "./user-icon.js";
51
51
  export * from "./users-icon.js";
52
+ export * from "./video-icon.js";
52
53
  export * from "./wallet-icon.js";
53
54
  export * from "./warning-icon.js";
54
55
  export * from "./x-icon.js";
@@ -0,0 +1,6 @@
1
+ import type React from 'react';
2
+ import type { IconProps } from './types/icon.js';
3
+ export declare const VideoIcon: {
4
+ ({ className, svgClassName, ...rest }: IconProps): React.JSX.Element;
5
+ displayName: string;
6
+ };
@@ -0,0 +1,36 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import classnames from "classnames";
3
+ import { IconElement } from "./icon-element.js";
4
+ import icons_module from "./icons.module.js";
5
+ const VideoIcon = ({ className, svgClassName, ...rest })=>{
6
+ const applied = classnames(icons_module["fill-none"], icons_module["stroke-current"], svgClassName);
7
+ return /*#__PURE__*/ jsx(IconElement, {
8
+ className: classnames('video-icon', className),
9
+ ...rest,
10
+ children: /*#__PURE__*/ jsxs("svg", {
11
+ className: applied,
12
+ xmlns: "http://www.w3.org/2000/svg",
13
+ focusable: "false",
14
+ "aria-hidden": "true",
15
+ viewBox: "0 0 24 24",
16
+ strokeWidth: "1.5",
17
+ strokeLinecap: "round",
18
+ strokeLinejoin: "round",
19
+ children: [
20
+ /*#__PURE__*/ jsx("path", {
21
+ stroke: "none",
22
+ d: "M0 0h24v24H0z",
23
+ fill: "none"
24
+ }),
25
+ /*#__PURE__*/ jsx("path", {
26
+ d: "M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4z"
27
+ }),
28
+ /*#__PURE__*/ jsx("path", {
29
+ d: "M3 6m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"
30
+ })
31
+ ]
32
+ })
33
+ });
34
+ };
35
+ VideoIcon.displayName = 'VideoIcon';
36
+ export { VideoIcon };
package/dist/react.d.ts CHANGED
@@ -45,6 +45,7 @@ export * from './fields/draggable-context-menu.js';
45
45
  export * from './fields/field-helpers.js';
46
46
  export * from './fields/field-renderer.js';
47
47
  export * from './fields/file/file-field.js';
48
+ export * from './fields/file/file-upload-field.js';
48
49
  export * from './fields/group/group-field.js';
49
50
  export * from './fields/image/image-field.js';
50
51
  export * from './fields/image/image-upload-field.js';
package/dist/react.js CHANGED
@@ -26,6 +26,7 @@ export * from "./fields/draggable-context-menu.js";
26
26
  export * from "./fields/field-helpers.js";
27
27
  export * from "./fields/field-renderer.js";
28
28
  export * from "./fields/file/file-field.js";
29
+ export * from "./fields/file/file-upload-field.js";
29
30
  export * from "./fields/group/group-field.js";
30
31
  export * from "./fields/image/image-field.js";
31
32
  export * from "./fields/image/image-upload-field.js";
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "2.2.10",
6
+ "version": "2.3.1",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -31,6 +31,7 @@
31
31
  ],
32
32
  "exports": {
33
33
  "./react": {
34
+ "development": "./src/react.ts",
34
35
  "types": "./dist/react.d.ts",
35
36
  "import": "./dist/react.js",
36
37
  "main": "./dist/react.js",
@@ -65,9 +66,9 @@
65
66
  "react-diff-viewer-continued": "^4.2.2",
66
67
  "zod": "^4.4.3",
67
68
  "zod-form-data": "^3.0.1",
68
- "@byline/core": "2.2.10",
69
- "@byline/admin": "2.2.10",
70
- "@byline/client": "2.2.10"
69
+ "@byline/admin": "2.3.1",
70
+ "@byline/core": "2.3.1",
71
+ "@byline/client": "2.3.1"
71
72
  },
72
73
  "peerDependencies": {
73
74
  "react": "^19.0.0",
@@ -209,6 +209,7 @@ export const FieldRenderer = ({
209
209
  defaultValue={defaultValue}
210
210
  onChange={handleChange}
211
211
  path={path}
212
+ collectionPath={collectionPath}
212
213
  />
213
214
  )
214
215
  case 'image':
@@ -1,75 +1,135 @@
1
1
  /**
2
- * FileField — placeholder file widget showing stored file metadata.
2
+ * FileField — metadata tile + remove/upload affordances for arbitrary file
3
+ * uploads (non-image). Mirrors `image-field.module.css` structurally; the
4
+ * image-only preview / SVG-shape / pending-badge rules are dropped.
3
5
  *
4
6
  * Override handles:
5
- * .byline-field-file — wrapper div
6
- * .byline-field-file-dirty dirty-state border on the wrapper
7
- * .byline-field-file-header label/help row
8
- * .byline-field-file-label primary label text
9
- * .byline-field-file-help secondary help text
10
- * .byline-field-file-action action button (e.g. "Upload (coming soon)")
11
- * .byline-field-file-empty empty-state hint text
12
- * .byline-field-file-meta metadata list
13
- * .byline-field-file-meta-key metadata field-name span
7
+ * .byline-field-file — wrapper div
8
+ * .byline-field-file-header label row
9
+ * .byline-field-file-actions top-right icon-button group (download + remove)
10
+ * .byline-field-file-empty empty-state hint text
11
+ * .byline-field-file-tile bordered metadata tile
12
+ * .byline-field-file-uploading centered overlay shown while uploading
13
+ * .byline-field-file-icon-wrap left-aligned document-icon wrapper
14
+ * .byline-field-file-icon the document glyph itself
15
+ * .byline-field-file-pending yellow "pending upload" pill
16
+ * .byline-field-file-meta — metadata list
17
+ * .byline-field-file-meta-key — metadata field-name span
18
+ * .byline-field-file-meta-pending — yellow text inside pending status
14
19
  */
15
20
 
16
- .dirty,
17
- :global(.byline-field-file-dirty) {
18
- padding: var(--spacing-12);
19
- border: var(--border-width-thin) var(--border-style-solid) var(--blue-300);
20
- border-radius: var(--border-radius-md);
21
- }
22
-
23
21
  .header,
24
22
  :global(.byline-field-file-header) {
25
23
  display: flex;
26
24
  align-items: baseline;
27
- justify-content: space-between;
25
+ gap: var(--spacing-8);
28
26
  margin-bottom: 0.25rem;
29
27
  }
30
28
 
31
- .label,
32
- :global(.byline-field-file-label) {
33
- color: var(--gray-100);
34
- font-size: var(--font-size-sm);
35
- font-weight: var(--font-weight-medium);
29
+ .actions,
30
+ :global(.byline-field-file-actions) {
31
+ position: absolute;
32
+ top: var(--spacing-6);
33
+ right: var(--spacing-6);
34
+ z-index: 1;
35
+ display: flex;
36
+ align-items: center;
37
+ gap: var(--spacing-4);
36
38
  }
37
39
 
38
- .help,
39
- :global(.byline-field-file-help) {
40
- margin-top: 0.125rem;
41
- color: var(--gray-400);
42
- font-size: var(--font-size-xs);
40
+ :global(.byline-field-file-actions .byline-button) {
41
+ color: var(--gray-900);
43
42
  }
44
43
 
45
- .action,
46
- :global(.byline-field-file-action) {
47
- background: none;
48
- border: none;
49
- padding: 0;
50
- color: var(--blue-300);
44
+ :global(.dark .byline-field-file-actions .byline-button),
45
+ :global([data-theme="dark"] .byline-field-file-actions .byline-button) {
46
+ color: var(--gray-200);
47
+ }
48
+
49
+ .empty,
50
+ :global(.byline-field-file-empty) {
51
+ color: var(--gray-500);
51
52
  font-size: var(--font-size-xs);
53
+ font-style: italic;
54
+ }
55
+
56
+ .tile,
57
+ :global(.byline-field-file-tile) {
58
+ position: relative;
59
+ display: flex;
60
+ gap: var(--spacing-16);
61
+ margin-top: 0.25rem;
62
+ padding: var(--spacing-8);
63
+ border: var(--border-width-thin) var(--border-style-solid) var(--primary-500);
64
+ border-radius: var(--border-radius-md);
65
+ }
66
+
67
+ .uploading,
68
+ :global(.byline-field-file-uploading) {
69
+ position: absolute;
70
+ inset: 0;
71
+ z-index: 2;
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ background-color: oklch(from var(--gray-950) l c h / 0.5);
76
+ border-radius: var(--border-radius-md);
77
+ }
78
+
79
+ .icon-wrap,
80
+ :global(.byline-field-file-icon-wrap) {
81
+ position: relative;
82
+ flex-shrink: 0;
83
+ align-self: flex-start;
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ width: 4rem;
88
+ height: 4rem;
89
+ border: var(--border-width-thin) var(--border-style-solid) var(--gray-600);
90
+ border-radius: var(--border-radius-sm);
91
+ color: var(--gray-300);
92
+ }
93
+
94
+ /* Interactive affordance when the wrap is rendered as a link
95
+ (file is stored — clicking opens the asset in a new tab). */
96
+ a.icon-wrap,
97
+ :global(a.byline-field-file-icon-wrap) {
52
98
  cursor: pointer;
99
+ text-decoration: none;
100
+ transition:
101
+ border-color 120ms ease,
102
+ color 120ms ease;
53
103
  }
54
104
 
55
- .action:hover,
56
- :global(.byline-field-file-action):hover {
57
- color: var(--blue-200);
58
- text-decoration: underline;
59
- text-underline-offset: 2px;
105
+ a.icon-wrap:hover,
106
+ :global(a.byline-field-file-icon-wrap):hover {
107
+ border-color: var(--primary-500);
108
+ color: var(--primary-400);
60
109
  }
61
110
 
62
- .action:disabled,
63
- :global(.byline-field-file-action):disabled {
64
- cursor: not-allowed;
65
- opacity: 0.6;
111
+ a.icon-wrap:focus-visible,
112
+ :global(a.byline-field-file-icon-wrap):focus-visible {
113
+ outline: 2px solid var(--primary-500);
114
+ outline-offset: 2px;
66
115
  }
67
116
 
68
- .empty,
69
- :global(.byline-field-file-empty) {
70
- color: var(--gray-500);
71
- font-size: var(--font-size-xs);
72
- font-style: italic;
117
+ .icon,
118
+ :global(.byline-field-file-icon) {
119
+ opacity: 0.85;
120
+ }
121
+
122
+ .pending,
123
+ :global(.byline-field-file-pending) {
124
+ position: absolute;
125
+ top: 0.25rem;
126
+ left: 0.25rem;
127
+ padding: 0.125rem 0.375rem;
128
+ background-color: oklch(from var(--yellow-600) l c h / 0.9);
129
+ color: var(--yellow-100);
130
+ font-size: 0.6rem;
131
+ font-weight: var(--font-weight-medium);
132
+ border-radius: var(--border-radius-sm);
73
133
  }
74
134
 
75
135
  .meta,
@@ -77,7 +137,7 @@
77
137
  display: flex;
78
138
  flex-direction: column;
79
139
  gap: 0.125rem;
80
- margin-top: 0.25rem;
140
+ padding-right: var(--spacing-32);
81
141
  color: var(--gray-200);
82
142
  font-size: var(--font-size-xs);
83
143
  }
@@ -86,3 +146,8 @@
86
146
  :global(.byline-field-file-meta-key) {
87
147
  font-weight: var(--font-weight-semibold);
88
148
  }
149
+
150
+ .meta-pending,
151
+ :global(.byline-field-file-meta-pending) {
152
+ color: var(--yellow-400);
153
+ }
@@ -6,17 +6,53 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- import type { FileField as FieldType, StoredFileValue } from '@byline/core'
9
+ import {
10
+ type FileField as FieldType,
11
+ isPendingStoredFileValue,
12
+ type StoredFileValue,
13
+ } from '@byline/core'
10
14
  import cx from 'classnames'
11
15
 
12
- import { useFieldError, useFieldValue, useIsDirty } from '../../forms/form-context'
13
- import { ErrorText } from '../../uikit.js'
16
+ import { IconButton } from '../../components/button/icon-button.js'
17
+ import {
18
+ useFieldError,
19
+ useFieldValue,
20
+ useFormContext,
21
+ useIsDirty,
22
+ useIsFieldUploading,
23
+ } from '../../forms/form-context'
24
+ import { CloseIcon } from '../../icons/close-icon.js'
25
+ import { DocumentIcon } from '../../icons/document-icon.js'
26
+ import { DownloadIcon } from '../../icons/download-icon.js'
27
+ import { VideoIcon } from '../../icons/video-icon.js'
28
+ import { ErrorText, HelpText, Label, LoaderRing } from '../../uikit.js'
29
+ import { useFieldChangeHandler } from '../use-field-change-handler'
14
30
  import styles from './file-field.module.css'
31
+ import { FileUploadField } from './file-upload-field'
32
+
33
+ /**
34
+ * Trigger a download via a temporary anchor. Mirrors the helper in
35
+ * `image-lightbox.tsx`: same-origin URLs respect the `download` attribute and
36
+ * save with the suggested filename; cross-origin URLs without CORS headers
37
+ * fall through to navigation in a new tab, where the user can right-click
38
+ * Save As.
39
+ */
40
+ function triggerDownload(url: string, filename?: string) {
41
+ if (typeof document === 'undefined') return
42
+ const a = document.createElement('a')
43
+ a.href = url
44
+ if (filename) a.download = filename
45
+ a.target = '_blank'
46
+ a.rel = 'noreferrer'
47
+ document.body.appendChild(a)
48
+ a.click()
49
+ document.body.removeChild(a)
50
+ }
15
51
 
16
52
  interface FileFieldProps {
17
53
  field: FieldType
18
- // Stored value is currently a plain object with file metadata
19
- // coming from the seed data / storage layer.
54
+ /** Collection path required to call the /upload endpoint. */
55
+ collectionPath?: string
20
56
  value?: StoredFileValue | null
21
57
  defaultValue?: StoredFileValue | null
22
58
  onChange?: (value: StoredFileValue | null) => void
@@ -25,6 +61,7 @@ interface FileFieldProps {
25
61
 
26
62
  export const FileField = ({
27
63
  field,
64
+ collectionPath,
28
65
  value,
29
66
  defaultValue,
30
67
  onChange: _onChange,
@@ -34,73 +71,200 @@ export const FileField = ({
34
71
  const fieldError = useFieldError(fieldPath)
35
72
  const isDirty = useIsDirty(fieldPath)
36
73
  const fieldValue = useFieldValue<StoredFileValue | null | undefined>(fieldPath)
37
- const incomingValue = value ?? fieldValue ?? defaultValue ?? null
74
+ const isUploading = useIsFieldUploading(fieldPath)
75
+ const { removePendingUpload } = useFormContext()
76
+
77
+ const handleChange = useFieldChangeHandler(field, fieldPath)
78
+
79
+ // Mirror the image-field rule: once the field has been touched, the form
80
+ // value is authoritative (even when null, so a click-to-remove sticks);
81
+ // otherwise fall back to props.
82
+ const incomingValue = isDirty
83
+ ? (fieldValue ?? null)
84
+ : (value ?? fieldValue ?? defaultValue ?? null)
38
85
 
39
- const isPlaceholderStoredFileValue = (v: unknown): boolean => {
86
+ const isPending = isPendingStoredFileValue(incomingValue)
87
+
88
+ // Legacy placeholder shape — kept for backwards compatibility with older
89
+ // seed data, matching the image-field check.
90
+ const isOldPlaceholder = (v: unknown): boolean => {
40
91
  if (!v || typeof v !== 'object') return false
41
92
  const maybe = v as Partial<StoredFileValue>
42
93
  return maybe.storageProvider === 'placeholder' && maybe.storagePath === 'pending'
43
94
  }
44
95
 
45
- const effectiveValue: StoredFileValue | null = isPlaceholderStoredFileValue(incomingValue)
46
- ? null
47
- : incomingValue
96
+ const showUploadWidget = incomingValue == null || isOldPlaceholder(incomingValue)
97
+
98
+ const handleRemove = () => {
99
+ if (isPending) {
100
+ removePendingUpload(fieldPath)
101
+ }
102
+ handleChange(null)
103
+ }
104
+
105
+ // MIME-driven glyph dispatch. Until a dedicated VideoField primitive lands,
106
+ // the FileField is the canonical home for video uploads — the schema's
107
+ // `upload.allowedMimeTypes` decides what gets in, and we swap the glyph
108
+ // here based on the resolved MIME so the tile reads as "video" rather
109
+ // than "generic document".
110
+ const isVideo = incomingValue?.mimeType?.startsWith('video/') === true
111
+ const FileGlyph = isVideo ? VideoIcon : DocumentIcon
112
+
113
+ const htmlId = fieldPath
48
114
 
49
115
  return (
50
- <div
51
- className={cx(
52
- 'byline-field-file',
53
- field.name,
54
- isDirty && ['byline-field-file-dirty', styles.dirty]
55
- )}
56
- >
116
+ <div className={`byline-field-file ${field.name}`}>
57
117
  <div className={cx('byline-field-file-header', styles.header)}>
58
- <div>
59
- <div className={cx('byline-field-file-label', styles.label)}>
60
- {field.label ?? field.name}
61
- {field.optional ? '' : ' *'}
62
- </div>
63
- {field.helpText && (
64
- <div className={cx('byline-field-file-help', styles.help)}>{field.helpText}</div>
65
- )}
66
- </div>
67
- {/* Placeholder action area for future upload UI */}
68
- <button type="button" className={cx('byline-field-file-action', styles.action)} disabled>
69
- Upload (coming soon)
70
- </button>
118
+ <Label
119
+ id={htmlId}
120
+ htmlFor={htmlId}
121
+ label={field.label ?? field.name}
122
+ required={!field.optional}
123
+ />
71
124
  </div>
72
125
 
73
- {effectiveValue == null ? (
74
- <div className={cx('byline-field-file-empty', styles.empty)}>No file selected</div>
126
+ {showUploadWidget ? (
127
+ collectionPath ? (
128
+ <FileUploadField
129
+ field={field}
130
+ collectionPath={collectionPath}
131
+ fieldPath={fieldPath}
132
+ onUploaded={(uploaded) => {
133
+ handleChange(uploaded)
134
+ }}
135
+ />
136
+ ) : (
137
+ <div className={cx('byline-field-file-empty', styles.empty)}>No file selected</div>
138
+ )
75
139
  ) : (
76
- <div className={cx('byline-field-file-meta', styles.meta)}>
77
- <div>
78
- <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>Filename:</span>{' '}
79
- {effectiveValue.filename}
80
- </div>
81
- <div>
82
- <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>Original:</span>{' '}
83
- {effectiveValue.originalFilename}
84
- </div>
85
- <div>
86
- <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>Type:</span>{' '}
87
- {effectiveValue.mimeType}
88
- </div>
89
- <div>
90
- <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>Size:</span>{' '}
91
- {effectiveValue.fileSize}
92
- </div>
93
- <div>
94
- <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>Storage:</span>{' '}
95
- {effectiveValue.storageProvider}
96
- </div>
97
- <div>
98
- <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>Path:</span>{' '}
99
- {effectiveValue.storagePath}
140
+ <div className={cx('byline-field-file-tile', styles.tile)}>
141
+ {isUploading && (
142
+ <div
143
+ className={cx('byline-field-file-uploading', styles.uploading)}
144
+ aria-live="polite"
145
+ aria-busy="true"
146
+ >
147
+ <LoaderRing />
148
+ </div>
149
+ )}
150
+ {collectionPath && (
151
+ <div className={cx('byline-field-file-actions', styles.actions)}>
152
+ {!isPending && incomingValue?.storageUrl && (
153
+ <IconButton
154
+ type="button"
155
+ intent="noeffect"
156
+ onClick={() =>
157
+ triggerDownload(
158
+ incomingValue.storageUrl as string,
159
+ incomingValue.originalFilename ?? incomingValue.filename
160
+ )
161
+ }
162
+ size="xs"
163
+ disabled={isUploading}
164
+ aria-label="Download file"
165
+ >
166
+ <DownloadIcon width="15px" height="15px" />
167
+ </IconButton>
168
+ )}
169
+ <IconButton
170
+ type="button"
171
+ intent="noeffect"
172
+ onClick={handleRemove}
173
+ size="xs"
174
+ disabled={isUploading}
175
+ aria-label="Remove file"
176
+ >
177
+ <CloseIcon width="15px" height="15px" />
178
+ </IconButton>
179
+ </div>
180
+ )}
181
+ {/* Document icon + (optional) pending badge — mirrors the
182
+ image-field's preview-wrap so the file tile has the same
183
+ visual hierarchy: glyph on the left, metadata on the right.
184
+ When the file is stored (non-pending and resolvable storageUrl),
185
+ the wrap is rendered as an anchor that opens the asset in a new
186
+ tab — browser-native viewer dispatch (PDFs render inline,
187
+ non-renderable types fall through to download). */}
188
+ {!isPending && incomingValue?.storageUrl ? (
189
+ <a
190
+ href={incomingValue.storageUrl}
191
+ target="_blank"
192
+ rel="noreferrer"
193
+ aria-label={`Open ${incomingValue.originalFilename ?? incomingValue.filename} in a new tab`}
194
+ className={cx('byline-field-file-icon-wrap', styles['icon-wrap'])}
195
+ >
196
+ <FileGlyph
197
+ width="48px"
198
+ height="48px"
199
+ className={cx('byline-field-file-icon', styles.icon)}
200
+ />
201
+ </a>
202
+ ) : (
203
+ <div className={cx('byline-field-file-icon-wrap', styles['icon-wrap'])}>
204
+ <FileGlyph
205
+ width="48px"
206
+ height="48px"
207
+ className={cx('byline-field-file-icon', styles.icon)}
208
+ />
209
+ {isPending && (
210
+ <div className={cx('byline-field-file-pending', styles.pending)}>
211
+ Pending upload
212
+ </div>
213
+ )}
214
+ </div>
215
+ )}
216
+ <div className={cx('byline-field-file-meta', styles.meta)}>
217
+ <div>
218
+ <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
219
+ Filename:
220
+ </span>{' '}
221
+ {incomingValue?.filename}
222
+ </div>
223
+ <div>
224
+ <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
225
+ Original:
226
+ </span>{' '}
227
+ {incomingValue?.originalFilename}
228
+ </div>
229
+ <div>
230
+ <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>Type:</span>{' '}
231
+ {incomingValue?.mimeType}
232
+ </div>
233
+ <div>
234
+ <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>Size:</span>{' '}
235
+ {incomingValue?.fileSize}
236
+ </div>
237
+ {isPending ? (
238
+ <div>
239
+ <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
240
+ Status:
241
+ </span>{' '}
242
+ <span className={cx('byline-field-file-meta-pending', styles['meta-pending'])}>
243
+ Will upload on save
244
+ </span>
245
+ </div>
246
+ ) : (
247
+ <>
248
+ <div>
249
+ <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
250
+ Storage:
251
+ </span>{' '}
252
+ {incomingValue?.storageProvider}
253
+ </div>
254
+ <div>
255
+ <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
256
+ Path:
257
+ </span>{' '}
258
+ {incomingValue?.storagePath}
259
+ </div>
260
+ </>
261
+ )}
100
262
  </div>
101
263
  </div>
102
264
  )}
103
265
 
266
+ {field.helpText && <HelpText text={field.helpText} />}
267
+
104
268
  {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
105
269
  </div>
106
270
  )