@octane-xplat/files 0.9.0 → 0.10.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/README.md +39 -23
- package/package.json +11 -4
- package/src/FileInput.linux.tsrx +1 -0
- package/src/FileInput.macos.tsrx +167 -0
- package/src/FileInput.tsrx +168 -0
- package/src/FileInput.web.test.tsx +99 -0
- package/src/FileInput.web.tsrx +240 -0
- package/src/file-input-utils.test.ts +22 -0
- package/src/file-input-utils.ts +113 -0
- package/src/files.macos.ts +46 -5
- package/src/files.ts +26 -18
- package/src/files.web.ts +24 -12
- package/src/index.macos.ts +9 -0
- package/src/index.ts +9 -0
- package/src/props.ts +52 -0
- package/src/types.ts +2 -1
- package/types/index.d.ts +15 -0
- package/types/index.macos.d.ts +14 -0
package/README.md
CHANGED
|
@@ -1,32 +1,48 @@
|
|
|
1
1
|
# `@octane-xplat/files`
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
Cross-platform file selection and file entry for Octane apps.
|
|
4
|
+
|
|
5
|
+
Install `@octane-xplat/files` and `@octane-xplat/ui`. `FileInput` uses the
|
|
6
|
+
platform picker by default: the browser file input on web, the NativeScript
|
|
7
|
+
document picker on iOS/Android, and AppKit `NSOpenPanel` on macOS. No
|
|
8
|
+
app-registered picker is needed. The optional `pick` prop can replace the
|
|
9
|
+
default for a custom source such as cloud storage.
|
|
10
|
+
|
|
11
|
+
```tsrx
|
|
12
|
+
import { useState } from 'octane'
|
|
13
|
+
import { FileInput } from '@octane-xplat/files'
|
|
14
|
+
import type { FileInputFile } from '@octane-xplat/files'
|
|
15
|
+
|
|
16
|
+
export function Attachments() @{
|
|
17
|
+
const [files, setFiles] = useState<FileInputFile[] | null>([])
|
|
18
|
+
|
|
19
|
+
<FileInput
|
|
20
|
+
label="Attachments"
|
|
21
|
+
isMultiple
|
|
22
|
+
value={files}
|
|
23
|
+
onChange={(next) => setFiles(Array.isArray(next) ? next : next ? [next] : [])}
|
|
24
|
+
/>
|
|
25
|
+
}
|
|
12
26
|
```
|
|
13
27
|
|
|
28
|
+
The portable value is `{ name, uri, size?, mimeType? }`. On web, the selected
|
|
29
|
+
browser `File` is also available as `file`; its `uri` is a temporary object
|
|
30
|
+
URL. Native platforms return a local path or content URI, which apps should
|
|
31
|
+
retain as an opaque reference and pass to `files.readText` where supported.
|
|
32
|
+
AppKit provides native picking, but its current `readText` and `writeText`
|
|
33
|
+
methods are unsupported. Web file references should be released with
|
|
34
|
+
`files.release` when no longer needed.
|
|
35
|
+
|
|
14
36
|
```ts
|
|
15
37
|
import { files } from '@octane-xplat/files'
|
|
16
38
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
39
|
+
// Run in a .web.ts file: AppKit currently does not support readText.
|
|
40
|
+
const file = await files.pick('text/plain')
|
|
41
|
+
if (file) {
|
|
42
|
+
try {
|
|
43
|
+
console.log(await files.readText(file))
|
|
44
|
+
} finally {
|
|
45
|
+
files.release(file)
|
|
46
|
+
}
|
|
21
47
|
}
|
|
22
|
-
|
|
23
|
-
// web: triggers a download; native: writes into the app documents folder
|
|
24
|
-
const out = await files.writeText('packing-list.txt', text)
|
|
25
48
|
```
|
|
26
|
-
|
|
27
|
-
`FileRef.uri` is opaque — a blob/object URL on web, a filesystem path or
|
|
28
|
-
`content://` URI on native. Pass it back to `files.readText`; do not parse
|
|
29
|
-
it. `release(ref)` cleans up native temp picks and is a no-op elsewhere.
|
|
30
|
-
|
|
31
|
-
Guide: [Using device features](../../docs/platform-services.md);
|
|
32
|
-
per-target availability: [platform notes](../../docs/platform-notes.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/files",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Native + web file pick and read/write for Octane xplat — matching web + NativeScript implementations",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"android",
|
|
@@ -22,12 +22,17 @@
|
|
|
22
22
|
"directory": "packages/files"
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
|
-
"src"
|
|
25
|
+
"src",
|
|
26
|
+
"types"
|
|
26
27
|
],
|
|
27
28
|
"type": "module",
|
|
28
29
|
"exports": {
|
|
29
30
|
".": {
|
|
30
|
-
"macos":
|
|
31
|
+
"macos": {
|
|
32
|
+
"types": "./types/index.macos.d.ts",
|
|
33
|
+
"default": "./src/index.macos.ts"
|
|
34
|
+
},
|
|
35
|
+
"types": "./types/index.d.ts",
|
|
31
36
|
"default": "./src/index.ts"
|
|
32
37
|
},
|
|
33
38
|
"./*": "./src/*"
|
|
@@ -37,17 +42,19 @@
|
|
|
37
42
|
},
|
|
38
43
|
"dependencies": {
|
|
39
44
|
"@nativescript-community/ui-document-picker": "1.1.29",
|
|
40
|
-
"@octane-xplat/platform": "0.
|
|
45
|
+
"@octane-xplat/platform": "0.10.0"
|
|
41
46
|
},
|
|
42
47
|
"devDependencies": {
|
|
43
48
|
"@nativescript-community/octane": "0.2.4",
|
|
44
49
|
"@nativescript/core": "9.1.2",
|
|
45
50
|
"@nativescript/types": "9.0.0",
|
|
51
|
+
"@octane-xplat/ui": "0.10.0",
|
|
46
52
|
"octane": "0.6.3"
|
|
47
53
|
},
|
|
48
54
|
"peerDependencies": {
|
|
49
55
|
"@nativescript-community/octane": ">=0.2.1 <1",
|
|
50
56
|
"@nativescript/core": ">=9.1.0 <10",
|
|
57
|
+
"@octane-xplat/ui": ">=0.6.0 <1",
|
|
51
58
|
"octane": ">=0.6.3 <1"
|
|
52
59
|
},
|
|
53
60
|
"peerDependenciesMeta": {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FileInput } from './FileInput.web.tsrx'
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/** @jsxImportSource @octane-xplat/macos-renderer */
|
|
2
|
+
import { useRef, useImperativeHandle, useId, useState } from 'octane'
|
|
3
|
+
import { Field as FormField, Icon, Pressable, useFieldControl } from '@octane-xplat/ui'
|
|
4
|
+
import type { FileInputFile, FileInputProps } from './props'
|
|
5
|
+
import { files as fileService } from './files'
|
|
6
|
+
import { cx, formatFileSize, normalizePicked, validateFiles } from './file-input-utils'
|
|
7
|
+
|
|
8
|
+
const STATUS_TEXT = { error: '⚠', warning: '⚠', success: '✓' } as const
|
|
9
|
+
|
|
10
|
+
function FileInputControl(inputProps: FileInputProps) @{
|
|
11
|
+
const hostRef = useRef<any>(null);
|
|
12
|
+
const { props } = useFieldControl(inputProps)
|
|
13
|
+
const generatedId = useId()
|
|
14
|
+
const inputId = props.id ?? generatedId
|
|
15
|
+
|
|
16
|
+
const [busy, setBusy] = useState(false)
|
|
17
|
+
const [validationErrors, setValidationErrors] = useState<string[]>([])
|
|
18
|
+
|
|
19
|
+
const files: FileInputFile[] = props.value == null ? [] : Array.isArray(props.value) ? props.value : [props.value]
|
|
20
|
+
const isDisabled = !!props.isDisabled || !!props.isReadOnly
|
|
21
|
+
const isBusy = !!props.isLoading || busy
|
|
22
|
+
const statusType = validationErrors.length ? 'error' : props.status?.type
|
|
23
|
+
const statusMessage = validationErrors.length ? validationErrors.join(' ') : props.status?.message
|
|
24
|
+
|
|
25
|
+
// `onChange` mirrors `value`'s shape: array when `isMultiple`, single ref
|
|
26
|
+
// or null otherwise.
|
|
27
|
+
const emitValue = (next: FileInputFile[]): FileInputFile | FileInputFile[] | null =>
|
|
28
|
+
props.isMultiple ? next : (next[0] ?? null)
|
|
29
|
+
|
|
30
|
+
const fireChange = (next: FileInputFile[]) => {
|
|
31
|
+
const emitted = emitValue(next)
|
|
32
|
+
props.onChange?.(emitted)
|
|
33
|
+
if (props.changeAction) {
|
|
34
|
+
setBusy(true)
|
|
35
|
+
Promise.resolve(props.changeAction(emitted)).finally(() => setBusy(false))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const acceptRefs = (picked: FileInputFile[]) => {
|
|
40
|
+
if (isDisabled || isBusy || !picked.length) return
|
|
41
|
+
const merged = props.isMultiple ? [...files, ...picked] : picked
|
|
42
|
+
const { valid, errors } = validateFiles(merged, {
|
|
43
|
+
accept: props.accept,
|
|
44
|
+
maxSize: props.maxSize,
|
|
45
|
+
maxFiles: props.maxFiles,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
setValidationErrors(errors)
|
|
49
|
+
const next = !props.isMultiple && errors.length ? files : valid
|
|
50
|
+
if (next.length !== files.length || next.some((file, index) => file.uri !== files[index]?.uri)) {
|
|
51
|
+
fireChange(next)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const removeAt = (index: number) => {
|
|
56
|
+
if (isDisabled || isBusy) return
|
|
57
|
+
setValidationErrors([])
|
|
58
|
+
fireChange(files.filter((_, i) => i !== index))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const pick = () => {
|
|
62
|
+
if (isDisabled || isBusy) return
|
|
63
|
+
const options = { accept: props.accept, multiple: props.isMultiple }
|
|
64
|
+
const picked = props.pick
|
|
65
|
+
? props.pick(options)
|
|
66
|
+
: props.isMultiple
|
|
67
|
+
? fileService.pickMultiple(props.accept)
|
|
68
|
+
: fileService.pick(props.accept)
|
|
69
|
+
|
|
70
|
+
Promise.resolve(picked)
|
|
71
|
+
.then((result) => acceptRefs(normalizePicked(result)))
|
|
72
|
+
.catch((error) => setValidationErrors([String(error?.message ?? error)]))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const dropzone = props.mode === 'dropzone'
|
|
76
|
+
const statusText = statusType ? STATUS_TEXT[statusType] : null
|
|
77
|
+
|
|
78
|
+
useImperativeHandle(props.ref, () => ({ open: pick, native: hostRef.current }), undefined);
|
|
79
|
+
|
|
80
|
+
<flexboxlayout
|
|
81
|
+
flexDirection="column"
|
|
82
|
+
className={cx(
|
|
83
|
+
'vx-fileinput',
|
|
84
|
+
dropzone && 'vx-fileinput--dropzone',
|
|
85
|
+
isDisabled && 'vx-fileinput--disabled',
|
|
86
|
+
isBusy && 'vx-fileinput--loading',
|
|
87
|
+
statusType && `vx-fileinput--status-${statusType}`,
|
|
88
|
+
props.size && `vx-fileinput--size-${props.size}`,
|
|
89
|
+
props.className,
|
|
90
|
+
)}
|
|
91
|
+
style={props.style}
|
|
92
|
+
>
|
|
93
|
+
{files.length ? (
|
|
94
|
+
<flexboxlayout flexDirection="column" className="vx-fileinput-list">
|
|
95
|
+
{files.map((f, i) => (
|
|
96
|
+
<flexboxlayout key={`${f.uri}-${i}`} flexDirection="row" alignItems="center" className="vx-fileinput-item">
|
|
97
|
+
<Icon name="xplat-upload" size={14} />
|
|
98
|
+
<label className="vx-fileinput-itemname" text={f.name} />
|
|
99
|
+
{f.size != null ? <label className="vx-fileinput-itemsize" text={formatFileSize(f.size)} /> : null}
|
|
100
|
+
{!isDisabled && !isBusy
|
|
101
|
+
? <Pressable className="vx-fileinput-itemremove" accessibilityLabel={`Remove ${f.name}`} onPress={() => removeAt(i)}><label text="×" /></Pressable>
|
|
102
|
+
: null}
|
|
103
|
+
</flexboxlayout>
|
|
104
|
+
))}
|
|
105
|
+
</flexboxlayout>
|
|
106
|
+
) : null}
|
|
107
|
+
<Pressable
|
|
108
|
+
ref={(v: any) => { hostRef.current = v; }}
|
|
109
|
+
id={inputId}
|
|
110
|
+
className="vx-fileinput-trigger"
|
|
111
|
+
disabled={isDisabled || isBusy}
|
|
112
|
+
accessibilityLabel={props.accessibilityLabel ?? props.ariaLabel ?? props.fieldLabel ?? props.label}
|
|
113
|
+
accessibilityHint={props.accessibilityHint}
|
|
114
|
+
accessibilityState={{ disabled: isDisabled, busy: isBusy } as any}
|
|
115
|
+
onPress={pick}
|
|
116
|
+
>
|
|
117
|
+
<flexboxlayout flexDirection="row" alignItems="center">
|
|
118
|
+
<Icon name="xplat-upload" size={14} />
|
|
119
|
+
<label text={files.length ? 'Choose more files' : 'Choose files'} />
|
|
120
|
+
</flexboxlayout>
|
|
121
|
+
</Pressable>
|
|
122
|
+
{files.length > 0 && !isDisabled && !isBusy
|
|
123
|
+
? <Pressable className="vx-fileinput-clear" accessibilityLabel={`Clear ${props.fieldLabel ?? props.label ?? 'files'}`} onPress={() => { setValidationErrors([]); fireChange([]) }}><label text="×" /></Pressable>
|
|
124
|
+
: null}
|
|
125
|
+
{statusText != null
|
|
126
|
+
? <label className={cx('vx-field-icon', `vx-field-icon--${statusType}`)} text={statusText} accessibilityLabel={props.statusVariant === 'tooltip' ? statusMessage : undefined} />
|
|
127
|
+
: null}
|
|
128
|
+
{statusMessage && props.statusVariant !== 'tooltip'
|
|
129
|
+
? <label className={cx('vx-field-status', `vx-field-status--${statusType}`)} text={statusMessage} />
|
|
130
|
+
: null}
|
|
131
|
+
</flexboxlayout>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* File entry — delegates selection to this package's AppKit files service.
|
|
136
|
+
* Values are portable `{name, uri, size?, mimeType?}` refs. Drag/drop is not
|
|
137
|
+
* implemented on this leaf.
|
|
138
|
+
*/
|
|
139
|
+
export function FileInput(props: FileInputProps) @{
|
|
140
|
+
const { inField } = useFieldControl(props)
|
|
141
|
+
const cid = useId()
|
|
142
|
+
const control = <FileInputControl {...props} />
|
|
143
|
+
|
|
144
|
+
<>
|
|
145
|
+
{inField ? control : (
|
|
146
|
+
<FormField
|
|
147
|
+
inputID={props.id ?? cid}
|
|
148
|
+
label={props.label}
|
|
149
|
+
isLabelHidden={props.isLabelHidden}
|
|
150
|
+
description={props.description}
|
|
151
|
+
isDisabled={props.isDisabled}
|
|
152
|
+
disabledMessage={props.disabledMessage}
|
|
153
|
+
isReadOnly={props.isReadOnly}
|
|
154
|
+
isLoading={props.isLoading}
|
|
155
|
+
isRequired={props.isRequired}
|
|
156
|
+
isOptional={props.isOptional}
|
|
157
|
+
size={props.size}
|
|
158
|
+
status={props.status}
|
|
159
|
+
statusVariant={props.statusVariant}
|
|
160
|
+
labelTooltip={props.labelTooltip}
|
|
161
|
+
width={props.width}
|
|
162
|
+
>
|
|
163
|
+
{control}
|
|
164
|
+
</FormField>
|
|
165
|
+
)}
|
|
166
|
+
</>
|
|
167
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
+
import { useRef, useImperativeHandle, useId, useState } from 'octane'
|
|
3
|
+
import { Field as FormField, Icon, Pressable, useFieldControl } from '@octane-xplat/ui'
|
|
4
|
+
import type { FileInputFile, FileInputProps } from './props'
|
|
5
|
+
import { files as fileService } from './files'
|
|
6
|
+
import { cx, formatFileSize, normalizePicked, validateFiles } from './file-input-utils'
|
|
7
|
+
|
|
8
|
+
const STATUS_TEXT = { error: '⚠', warning: '⚠', success: '✓' } as const
|
|
9
|
+
|
|
10
|
+
function FileInputControl(inputProps: FileInputProps) @{
|
|
11
|
+
const hostRef = useRef<any>(null);
|
|
12
|
+
const { props } = useFieldControl(inputProps)
|
|
13
|
+
const generatedId = useId()
|
|
14
|
+
const inputId = props.id ?? generatedId
|
|
15
|
+
|
|
16
|
+
const [busy, setBusy] = useState(false)
|
|
17
|
+
const [validationErrors, setValidationErrors] = useState<string[]>([])
|
|
18
|
+
|
|
19
|
+
const files: FileInputFile[] = props.value == null ? [] : Array.isArray(props.value) ? props.value : [props.value]
|
|
20
|
+
const isDisabled = !!props.isDisabled || !!props.isReadOnly
|
|
21
|
+
const isBusy = !!props.isLoading || busy
|
|
22
|
+
const statusType = validationErrors.length ? 'error' : props.status?.type
|
|
23
|
+
const statusMessage = validationErrors.length ? validationErrors.join(' ') : props.status?.message
|
|
24
|
+
|
|
25
|
+
// `onChange` mirrors `value`'s shape: array when `isMultiple`, single ref
|
|
26
|
+
// or null otherwise.
|
|
27
|
+
const emitValue = (next: FileInputFile[]): FileInputFile | FileInputFile[] | null =>
|
|
28
|
+
props.isMultiple ? next : (next[0] ?? null)
|
|
29
|
+
|
|
30
|
+
const fireChange = (next: FileInputFile[]) => {
|
|
31
|
+
const emitted = emitValue(next)
|
|
32
|
+
props.onChange?.(emitted)
|
|
33
|
+
if (props.changeAction) {
|
|
34
|
+
setBusy(true)
|
|
35
|
+
Promise.resolve(props.changeAction(emitted)).finally(() => setBusy(false))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const acceptRefs = (picked: FileInputFile[]) => {
|
|
40
|
+
if (isDisabled || isBusy || !picked.length) return
|
|
41
|
+
const merged = props.isMultiple ? [...files, ...picked] : picked
|
|
42
|
+
const { valid, errors } = validateFiles(merged, {
|
|
43
|
+
accept: props.accept,
|
|
44
|
+
maxSize: props.maxSize,
|
|
45
|
+
maxFiles: props.maxFiles,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
setValidationErrors(errors)
|
|
49
|
+
const next = !props.isMultiple && errors.length ? files : valid
|
|
50
|
+
if (next.length !== files.length || next.some((file, index) => file.uri !== files[index]?.uri)) {
|
|
51
|
+
fireChange(next)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const removeAt = (index: number) => {
|
|
56
|
+
if (isDisabled || isBusy) return
|
|
57
|
+
setValidationErrors([])
|
|
58
|
+
fireChange(files.filter((_, i) => i !== index))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const pick = () => {
|
|
62
|
+
if (isDisabled || isBusy) return
|
|
63
|
+
const options = { accept: props.accept, multiple: props.isMultiple }
|
|
64
|
+
const picked = props.pick
|
|
65
|
+
? props.pick(options)
|
|
66
|
+
: props.isMultiple
|
|
67
|
+
? fileService.pickMultiple(props.accept)
|
|
68
|
+
: fileService.pick(props.accept)
|
|
69
|
+
|
|
70
|
+
Promise.resolve(picked)
|
|
71
|
+
.then((result) => acceptRefs(normalizePicked(result)))
|
|
72
|
+
.catch((error) => setValidationErrors([String(error?.message ?? error)]))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const dropzone = props.mode === 'dropzone'
|
|
76
|
+
const statusText = statusType ? STATUS_TEXT[statusType] : null
|
|
77
|
+
|
|
78
|
+
useImperativeHandle(props.ref, () => ({ open: pick, native: hostRef.current }), undefined);
|
|
79
|
+
|
|
80
|
+
<flexboxlayout
|
|
81
|
+
flexDirection="column"
|
|
82
|
+
className={cx(
|
|
83
|
+
'vx-fileinput',
|
|
84
|
+
dropzone && 'vx-fileinput--dropzone',
|
|
85
|
+
isDisabled && 'vx-fileinput--disabled',
|
|
86
|
+
isBusy && 'vx-fileinput--loading',
|
|
87
|
+
statusType && `vx-fileinput--status-${statusType}`,
|
|
88
|
+
props.size && `vx-fileinput--size-${props.size}`,
|
|
89
|
+
props.className,
|
|
90
|
+
)}
|
|
91
|
+
style={props.style}
|
|
92
|
+
>
|
|
93
|
+
{files.length ? (
|
|
94
|
+
<flexboxlayout flexDirection="column" className="vx-fileinput-list">
|
|
95
|
+
{files.map((f, i) => (
|
|
96
|
+
<flexboxlayout key={`${f.uri}-${i}`} flexDirection="row" alignItems="center" className="vx-fileinput-item">
|
|
97
|
+
<Icon name="xplat-upload" size={14} />
|
|
98
|
+
<label className="vx-fileinput-itemname" text={f.name} />
|
|
99
|
+
{f.size != null ? <label className="vx-fileinput-itemsize" text={formatFileSize(f.size)} /> : null}
|
|
100
|
+
{!isDisabled && !isBusy
|
|
101
|
+
? <Pressable className="vx-fileinput-itemremove" accessibilityLabel={`Remove ${f.name}`} onPress={() => removeAt(i)}><label text="×" /></Pressable>
|
|
102
|
+
: null}
|
|
103
|
+
</flexboxlayout>
|
|
104
|
+
))}
|
|
105
|
+
</flexboxlayout>
|
|
106
|
+
) : null}
|
|
107
|
+
<Pressable
|
|
108
|
+
ref={(v: any) => { hostRef.current = v; }}
|
|
109
|
+
id={inputId}
|
|
110
|
+
className="vx-fileinput-trigger"
|
|
111
|
+
disabled={isDisabled || isBusy}
|
|
112
|
+
accessibilityLabel={props.accessibilityLabel ?? props.ariaLabel ?? props.fieldLabel ?? props.label}
|
|
113
|
+
accessibilityHint={props.accessibilityHint}
|
|
114
|
+
accessibilityState={{ disabled: isDisabled, busy: isBusy } as any}
|
|
115
|
+
onPress={pick}
|
|
116
|
+
>
|
|
117
|
+
<flexboxlayout flexDirection="row" alignItems="center">
|
|
118
|
+
<Icon name="xplat-upload" size={14} />
|
|
119
|
+
<label text={files.length ? 'Choose more files' : 'Choose files'} />
|
|
120
|
+
</flexboxlayout>
|
|
121
|
+
</Pressable>
|
|
122
|
+
{files.length > 0 && !isDisabled && !isBusy
|
|
123
|
+
? <Pressable className="vx-fileinput-clear" accessibilityLabel={`Clear ${props.fieldLabel ?? props.label ?? 'files'}`} onPress={() => { setValidationErrors([]); fireChange([]) }}><label text="×" /></Pressable>
|
|
124
|
+
: null}
|
|
125
|
+
{statusText != null
|
|
126
|
+
? <label className={cx('vx-field-icon', `vx-field-icon--${statusType}`)} text={statusText} accessibilityLabel={props.statusVariant === 'tooltip' ? statusMessage : undefined} />
|
|
127
|
+
: null}
|
|
128
|
+
{statusMessage && props.statusVariant !== 'tooltip'
|
|
129
|
+
? <label className={cx('vx-field-status', `vx-field-status--${statusType}`)} text={statusMessage} />
|
|
130
|
+
: null}
|
|
131
|
+
</flexboxlayout>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* File entry — `compact`/`dropzone` modes render the same tappable surface
|
|
136
|
+
* on iOS/Android (drag/drop has no touch equivalent). Values are
|
|
137
|
+
* `FileInputFile` `{name, uri, size?, mimeType?}`; native picking uses the
|
|
138
|
+
* platform file service exported by this package.
|
|
139
|
+
*/
|
|
140
|
+
export function FileInput(props: FileInputProps) @{
|
|
141
|
+
const { inField } = useFieldControl(props)
|
|
142
|
+
const cid = useId()
|
|
143
|
+
const control = <FileInputControl {...props} />
|
|
144
|
+
|
|
145
|
+
<>
|
|
146
|
+
{inField ? control : (
|
|
147
|
+
<FormField
|
|
148
|
+
inputID={props.id ?? cid}
|
|
149
|
+
label={props.label}
|
|
150
|
+
isLabelHidden={props.isLabelHidden}
|
|
151
|
+
description={props.description}
|
|
152
|
+
isDisabled={props.isDisabled}
|
|
153
|
+
disabledMessage={props.disabledMessage}
|
|
154
|
+
isReadOnly={props.isReadOnly}
|
|
155
|
+
isLoading={props.isLoading}
|
|
156
|
+
isRequired={props.isRequired}
|
|
157
|
+
isOptional={props.isOptional}
|
|
158
|
+
size={props.size}
|
|
159
|
+
status={props.status}
|
|
160
|
+
statusVariant={props.statusVariant}
|
|
161
|
+
labelTooltip={props.labelTooltip}
|
|
162
|
+
width={props.width}
|
|
163
|
+
>
|
|
164
|
+
{control}
|
|
165
|
+
</FormField>
|
|
166
|
+
)}
|
|
167
|
+
</>
|
|
168
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { act, createRoot } from 'octane'
|
|
4
|
+
import { FileInput } from './FileInput.web.tsrx'
|
|
5
|
+
|
|
6
|
+
vi.mock('@octane-xplat/ui', () => ({
|
|
7
|
+
Field: ({ children }: any) => children,
|
|
8
|
+
Icon: () => null,
|
|
9
|
+
Spinner: () => null,
|
|
10
|
+
Tooltip: ({ trigger }: any) => trigger,
|
|
11
|
+
useFieldControl: (props: any) => ({ props, inField: false }),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
const roots: any[] = []
|
|
15
|
+
function mount(jsx: any) {
|
|
16
|
+
const el = document.createElement('div')
|
|
17
|
+
document.body.append(el)
|
|
18
|
+
const root = createRoot(el)
|
|
19
|
+
roots.push(root)
|
|
20
|
+
act(() => root.render(jsx))
|
|
21
|
+
return el
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const root of roots.splice(0)) {
|
|
26
|
+
act(() => root.unmount())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
document.body.innerHTML = ''
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('FileInput (web)', () => {
|
|
33
|
+
it('validates and emits portable refs from a custom source', async () => {
|
|
34
|
+
const change = vi.fn()
|
|
35
|
+
const el = mount(
|
|
36
|
+
<FileInput
|
|
37
|
+
value={null}
|
|
38
|
+
onChange={change}
|
|
39
|
+
isMultiple={true}
|
|
40
|
+
accept=".txt"
|
|
41
|
+
pick={() =>
|
|
42
|
+
Promise.resolve([
|
|
43
|
+
{ name: 'ok.txt', uri: 'blob:1', size: 10 },
|
|
44
|
+
{ name: 'no.exe', uri: 'blob:2', size: 10 },
|
|
45
|
+
])
|
|
46
|
+
}
|
|
47
|
+
/>,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
await act(async () => {
|
|
51
|
+
el.querySelector('.vx-fileinput-trigger')!.dispatchEvent(
|
|
52
|
+
new MouseEvent('click', { bubbles: true }),
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
expect(change).toHaveBeenCalledWith([{ name: 'ok.txt', uri: 'blob:1', size: 10 }])
|
|
57
|
+
expect(el.querySelector('.vx-field-status')?.textContent).toContain('no.exe')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('replaces a single value even when the list length stays one', async () => {
|
|
61
|
+
const change = vi.fn()
|
|
62
|
+
const el = mount(
|
|
63
|
+
<FileInput
|
|
64
|
+
value={{ name: 'old.txt', uri: 'blob:old' }}
|
|
65
|
+
onChange={change}
|
|
66
|
+
pick={() => Promise.resolve({ name: 'new.txt', uri: 'blob:new' })}
|
|
67
|
+
/>,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
await act(async () => {
|
|
71
|
+
el.querySelector('.vx-fileinput-trigger')!.dispatchEvent(
|
|
72
|
+
new MouseEvent('click', { bubbles: true }),
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(change).toHaveBeenCalledWith({ name: 'new.txt', uri: 'blob:new' })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('retains the current file when a replacement fails validation', async () => {
|
|
80
|
+
const change = vi.fn()
|
|
81
|
+
const el = mount(
|
|
82
|
+
<FileInput
|
|
83
|
+
value={{ name: 'old.txt', uri: 'blob:old' }}
|
|
84
|
+
onChange={change}
|
|
85
|
+
accept=".txt"
|
|
86
|
+
pick={() => Promise.resolve({ name: 'new.exe', uri: 'blob:new' })}
|
|
87
|
+
/>,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
await act(async () => {
|
|
91
|
+
el.querySelector('.vx-fileinput-trigger')!.dispatchEvent(
|
|
92
|
+
new MouseEvent('click', { bubbles: true }),
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(change).not.toHaveBeenCalled()
|
|
97
|
+
expect(el.querySelector('.vx-field-status')?.textContent).toContain('new.exe')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { useImperativeHandle, useId, useRef, useState } from 'octane'
|
|
2
|
+
import { Field as FormField, Icon, Spinner as ActivityIndicator, Tooltip, useFieldControl } from '@octane-xplat/ui'
|
|
3
|
+
import type { FileInputFile, FileInputProps } from './props'
|
|
4
|
+
import { cx, formatFileSize, normalizePicked, validateFiles } from './file-input-utils'
|
|
5
|
+
|
|
6
|
+
const STATUS_GLYPHS = { error: '⚠', warning: '⚠', success: '✓' } as const
|
|
7
|
+
|
|
8
|
+
function FileInputControl(inputProps: FileInputProps) @{
|
|
9
|
+
const { props } = useFieldControl(inputProps)
|
|
10
|
+
const generatedId = useId()
|
|
11
|
+
const inputId = props.id ?? generatedId
|
|
12
|
+
const fileInput = useRef<HTMLInputElement | null>(null)
|
|
13
|
+
|
|
14
|
+
const [dragging, setDragging] = useState(false)
|
|
15
|
+
const [busy, setBusy] = useState(false)
|
|
16
|
+
const [validationErrors, setValidationErrors] = useState<string[]>([])
|
|
17
|
+
|
|
18
|
+
const files: FileInputFile[] = props.value == null ? [] : Array.isArray(props.value) ? props.value : [props.value]
|
|
19
|
+
const isDisabled = !!props.isDisabled || !!props.isReadOnly
|
|
20
|
+
const isBusy = !!props.isLoading || busy
|
|
21
|
+
const canClear = files.length > 0 && !isDisabled && !isBusy
|
|
22
|
+
const showsDisabledReason = isDisabled && !!props.disabledMessage
|
|
23
|
+
const statusType = validationErrors.length ? 'error' : props.status?.type
|
|
24
|
+
const statusMessage = validationErrors.length ? validationErrors.join(' ') : props.status?.message
|
|
25
|
+
|
|
26
|
+
// `onChange` mirrors `value`'s shape: array when `isMultiple`, single ref
|
|
27
|
+
// or null otherwise.
|
|
28
|
+
const emitValue = (next: FileInputFile[]): FileInputFile | FileInputFile[] | null =>
|
|
29
|
+
props.isMultiple ? next : (next[0] ?? null)
|
|
30
|
+
|
|
31
|
+
const fireChange = (next: FileInputFile[]) => {
|
|
32
|
+
const emitted = emitValue(next)
|
|
33
|
+
props.onChange?.(emitted)
|
|
34
|
+
if (props.changeAction) {
|
|
35
|
+
setBusy(true)
|
|
36
|
+
Promise.resolve(props.changeAction(emitted)).finally(() => setBusy(false))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const acceptRefs = (picked: FileInputFile[]) => {
|
|
41
|
+
if (isDisabled || isBusy || !picked.length) return
|
|
42
|
+
const merged = props.isMultiple ? [...files, ...picked] : picked
|
|
43
|
+
const { valid, errors } = validateFiles(merged, {
|
|
44
|
+
accept: props.accept,
|
|
45
|
+
maxSize: props.maxSize,
|
|
46
|
+
maxFiles: props.maxFiles,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
setValidationErrors(errors)
|
|
50
|
+
const next = !props.isMultiple && errors.length ? files : valid
|
|
51
|
+
if (next.length !== files.length || next.some((file, index) => file.uri !== files[index]?.uri)) {
|
|
52
|
+
fireChange(next)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const removeAt = (index: number) => {
|
|
57
|
+
if (isDisabled || isBusy) return
|
|
58
|
+
setValidationErrors([])
|
|
59
|
+
fireChange(files.filter((_, i) => i !== index))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const pick = () => {
|
|
63
|
+
if (isDisabled || isBusy) return
|
|
64
|
+
// An optional custom picker can provide cloud or app-specific sources.
|
|
65
|
+
if (props.pick) {
|
|
66
|
+
Promise.resolve(props.pick({ accept: props.accept, multiple: props.isMultiple }))
|
|
67
|
+
.then((r) => acceptRefs(normalizePicked(r)))
|
|
68
|
+
.catch((error) => setValidationErrors([String(error?.message ?? error)]))
|
|
69
|
+
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fileInput.current?.click()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const handleFileSelect = (e: any) => {
|
|
77
|
+
const list = e.target.files as any
|
|
78
|
+
const picked: FileInputFile[] = []
|
|
79
|
+
if (list) {
|
|
80
|
+
for (let i = 0; i < list.length; i++) {
|
|
81
|
+
const f = list[i]
|
|
82
|
+
picked.push({ name: f.name, uri: URL.createObjectURL(f), size: f.size, mimeType: f.type, file: f })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
e.target.value = ''
|
|
87
|
+
acceptRefs(picked)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const handleDrop = (e: any) => {
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
e.stopPropagation()
|
|
93
|
+
setDragging(false)
|
|
94
|
+
if (isDisabled || isBusy) return
|
|
95
|
+
const list = e.dataTransfer?.files
|
|
96
|
+
if (!list?.length) return
|
|
97
|
+
const picked: FileInputFile[] = []
|
|
98
|
+
for (let i = 0; i < list.length; i++) {
|
|
99
|
+
const f = list[i]
|
|
100
|
+
picked.push({ name: f.name, uri: URL.createObjectURL(f), size: f.size, mimeType: f.type, file: f })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
acceptRefs(picked)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const dropzone = props.mode === 'dropzone'
|
|
107
|
+
const itemContent = (
|
|
108
|
+
<>
|
|
109
|
+
{files.length ? (
|
|
110
|
+
<ul className="vx-fileinput-list">
|
|
111
|
+
{files.map((f: FileInputFile, i: number) => (
|
|
112
|
+
<li key={`${f.uri}-${i}`} className="vx-fileinput-item">
|
|
113
|
+
<Icon name="xplat-upload" size={14} />
|
|
114
|
+
<span className="vx-fileinput-itemname">{f.name}</span>
|
|
115
|
+
{f.size != null ? <span className="vx-fileinput-itemsize">{formatFileSize(f.size)}</span> : null}
|
|
116
|
+
{!isDisabled && !isBusy
|
|
117
|
+
? <button type="button" className="vx-fileinput-itemremove" aria-label={`Remove ${f.name}`} onClick={() => removeAt(i)}>{'×'}</button>
|
|
118
|
+
: null}
|
|
119
|
+
</li>
|
|
120
|
+
))}
|
|
121
|
+
</ul>
|
|
122
|
+
) : null}
|
|
123
|
+
{dropzone ? (
|
|
124
|
+
<div className="vx-fileinput-dropzone-hint">
|
|
125
|
+
<Icon name="xplat-upload" size={24} />
|
|
126
|
+
<span>{isDisabled ? 'Upload disabled' : 'Drag & drop files here or click to browse'}</span>
|
|
127
|
+
{props.accept ? <span className="vx-fileinput-accept">{props.accept}</span> : null}
|
|
128
|
+
</div>
|
|
129
|
+
) : (
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
id={inputId}
|
|
133
|
+
className="vx-fileinput-trigger"
|
|
134
|
+
disabled={isDisabled && !showsDisabledReason || undefined}
|
|
135
|
+
aria-disabled={showsDisabledReason || undefined}
|
|
136
|
+
aria-describedby={props.ariaDescribedBy}
|
|
137
|
+
aria-required={props.isRequired && !props.isOptional || undefined}
|
|
138
|
+
aria-invalid={statusType === 'error' || undefined}
|
|
139
|
+
aria-busy={isBusy || undefined}
|
|
140
|
+
onClick={pick}
|
|
141
|
+
>
|
|
142
|
+
<Icon name="xplat-upload" size={14} /> {files.length ? 'Choose more files' : 'Choose files'}
|
|
143
|
+
</button>
|
|
144
|
+
)}
|
|
145
|
+
{dropzone ? (
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
id={inputId}
|
|
149
|
+
className="vx-fileinput-trigger vx-fileinput-trigger--hidden"
|
|
150
|
+
disabled={isDisabled && !showsDisabledReason || undefined}
|
|
151
|
+
aria-disabled={showsDisabledReason || undefined}
|
|
152
|
+
aria-describedby={props.ariaDescribedBy}
|
|
153
|
+
aria-required={props.isRequired && !props.isOptional || undefined}
|
|
154
|
+
aria-invalid={statusType === 'error' || undefined}
|
|
155
|
+
aria-busy={isBusy || undefined}
|
|
156
|
+
onClick={pick}
|
|
157
|
+
>
|
|
158
|
+
Browse files
|
|
159
|
+
</button>
|
|
160
|
+
) : null}
|
|
161
|
+
{canClear ? <button type="button" className="vx-fileinput-clear" aria-label={`Clear ${props.fieldLabel ?? props.label ?? 'files'}`} onClick={() => { setValidationErrors([]); fireChange([]) }}>{'×'}</button> : null}
|
|
162
|
+
{isBusy ? <ActivityIndicator busy className="vx-fileinput-spinner" /> : null}
|
|
163
|
+
{statusType ? (
|
|
164
|
+
props.statusVariant === 'tooltip' && statusMessage
|
|
165
|
+
? <Tooltip trigger={<span className={cx('vx-field-icon', `vx-field-icon--${statusType}`)}>{STATUS_GLYPHS[statusType]}</span>} content={statusMessage} />
|
|
166
|
+
: <span className={cx('vx-field-icon', `vx-field-icon--${statusType}`)}>{STATUS_GLYPHS[statusType]}</span>
|
|
167
|
+
) : null}
|
|
168
|
+
{statusMessage && props.statusVariant !== 'tooltip'
|
|
169
|
+
? <span className={cx('vx-field-status', `vx-field-status--${statusType}`)} role={statusType === 'error' ? 'alert' : 'status'}>{statusMessage}</span>
|
|
170
|
+
: null}
|
|
171
|
+
<input
|
|
172
|
+
ref={(el: any) => { fileInput.current = el; }}
|
|
173
|
+
type="file"
|
|
174
|
+
className="vx-fileinput-nativeinput"
|
|
175
|
+
accept={props.accept}
|
|
176
|
+
multiple={props.isMultiple || undefined}
|
|
177
|
+
disabled={isDisabled || undefined}
|
|
178
|
+
aria-hidden="true"
|
|
179
|
+
tabIndex={-1}
|
|
180
|
+
onChange={handleFileSelect}
|
|
181
|
+
/>
|
|
182
|
+
</>
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
useImperativeHandle(props.ref, () => ({ open: pick, native: fileInput.current }), undefined);
|
|
186
|
+
|
|
187
|
+
<div
|
|
188
|
+
className={cx(
|
|
189
|
+
'vx-fileinput',
|
|
190
|
+
dropzone && 'vx-fileinput--dropzone',
|
|
191
|
+
isDisabled && 'vx-fileinput--disabled',
|
|
192
|
+
isBusy && 'vx-fileinput--loading',
|
|
193
|
+
dragging && 'vx-fileinput--dragging',
|
|
194
|
+
statusType && `vx-fileinput--status-${statusType}`,
|
|
195
|
+
props.size && `vx-fileinput--size-${props.size}`,
|
|
196
|
+
props.className,
|
|
197
|
+
)}
|
|
198
|
+
style={props.style}
|
|
199
|
+
onDragOver={(e: any) => { e.preventDefault(); if (!isDisabled && !isBusy) setDragging(true) }}
|
|
200
|
+
onDragEnter={(e: any) => { e.preventDefault() }}
|
|
201
|
+
onDragLeave={() => setDragging(false)}
|
|
202
|
+
onDrop={handleDrop}
|
|
203
|
+
>
|
|
204
|
+
{itemContent}
|
|
205
|
+
</div>
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* File entry — `compact` (button + list) or `dropzone` mode. Values are
|
|
210
|
+
* `{name, uri, size?, mimeType?}` portable refs. On web the hidden browser
|
|
211
|
+
* input returns the `File` alongside its object URL. Drag/drop targets the
|
|
212
|
+
* control wrapper in both modes.
|
|
213
|
+
*/
|
|
214
|
+
export function FileInput(props: FileInputProps) @{
|
|
215
|
+
const { inField } = useFieldControl(props)
|
|
216
|
+
const cid = useId()
|
|
217
|
+
const control = <FileInputControl {...props} />
|
|
218
|
+
if (inField) return control
|
|
219
|
+
return (
|
|
220
|
+
<FormField
|
|
221
|
+
inputID={props.id ?? cid}
|
|
222
|
+
label={props.label}
|
|
223
|
+
isLabelHidden={props.isLabelHidden}
|
|
224
|
+
description={props.description}
|
|
225
|
+
isDisabled={props.isDisabled}
|
|
226
|
+
disabledMessage={props.disabledMessage}
|
|
227
|
+
isReadOnly={props.isReadOnly}
|
|
228
|
+
isLoading={props.isLoading}
|
|
229
|
+
isRequired={props.isRequired}
|
|
230
|
+
isOptional={props.isOptional}
|
|
231
|
+
size={props.size}
|
|
232
|
+
status={props.status}
|
|
233
|
+
statusVariant={props.statusVariant}
|
|
234
|
+
labelTooltip={props.labelTooltip}
|
|
235
|
+
width={props.width}
|
|
236
|
+
>
|
|
237
|
+
{control}
|
|
238
|
+
</FormField>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { fileAccepts, validateFiles } from './file-input-utils'
|
|
3
|
+
|
|
4
|
+
describe('file validation', () => {
|
|
5
|
+
it('matches accept tokens', () => {
|
|
6
|
+
expect(fileAccepts({ name: 'a.png', uri: 'x' }, '.png')).toBe(true)
|
|
7
|
+
expect(fileAccepts({ name: 'a.jpg', uri: 'x' }, '.png')).toBe(false)
|
|
8
|
+
expect(fileAccepts({ name: 'a.png', uri: 'x', mimeType: 'image/png' }, 'image/*')).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('enforces maxSize and maxFiles', () => {
|
|
12
|
+
const files = [
|
|
13
|
+
{ name: 'a', uri: 'a', size: 10 },
|
|
14
|
+
{ name: 'b', uri: 'b', size: 99999 },
|
|
15
|
+
{ name: 'c', uri: 'c' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const result = validateFiles(files, { maxSize: 100, maxFiles: 1 })
|
|
19
|
+
expect(result.valid.map((file) => file.name)).toEqual(['a'])
|
|
20
|
+
expect(result.errors).toHaveLength(2)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared formatting and validation helpers for this package's FileInput.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FileInputFile } from './props'
|
|
6
|
+
|
|
7
|
+
export function cx(...values: Array<string | false | null | undefined>): string {
|
|
8
|
+
return values.filter(Boolean).join(' ')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function normalizePicked(result: FileInputFile[] | FileInputFile | null): FileInputFile[] {
|
|
12
|
+
if (result == null) {
|
|
13
|
+
return []
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return Array.isArray(result) ? result : [result]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Validation — `accept` tokens (`.ext`, `type/subtype`, `type/*`, `*/*`),
|
|
21
|
+
// `maxSize` bytes, `maxFiles` count. Refs without `size`/`mimeType` skip the
|
|
22
|
+
// checks that need them.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface FileValidationOptions {
|
|
26
|
+
accept?: string
|
|
27
|
+
maxSize?: number
|
|
28
|
+
maxFiles?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FileValidationResult {
|
|
32
|
+
valid: FileInputFile[]
|
|
33
|
+
errors: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function fileAccepts(file: FileInputFile, accept: string | undefined): boolean {
|
|
37
|
+
if (!accept) {
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const tokens = accept
|
|
42
|
+
.split(',')
|
|
43
|
+
.map((t) => t.trim().toLowerCase())
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
|
|
46
|
+
if (!tokens.length) {
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const name = file.name.toLowerCase()
|
|
51
|
+
const mime = (file.mimeType ?? '').toLowerCase()
|
|
52
|
+
return tokens.some((token) => {
|
|
53
|
+
if (token === '*/*') {
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (token.startsWith('.')) {
|
|
58
|
+
return name.endsWith(token)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (token.endsWith('/*')) {
|
|
62
|
+
return mime.startsWith(token.slice(0, -1))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return mime === token
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatFileSize(bytes: number): string {
|
|
70
|
+
if (bytes < 1024) {
|
|
71
|
+
return `${bytes} B`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (bytes < 1024 * 1024) {
|
|
75
|
+
return `${(bytes / 1024).toFixed(1)} KB`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (bytes < 1024 * 1024 * 1024) {
|
|
79
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function validateFiles(
|
|
86
|
+
files: FileInputFile[],
|
|
87
|
+
opts: FileValidationOptions,
|
|
88
|
+
): FileValidationResult {
|
|
89
|
+
const errors: string[] = []
|
|
90
|
+
const valid: FileInputFile[] = []
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
if (!fileAccepts(file, opts.accept)) {
|
|
93
|
+
errors.push(`${file.name}: file type not accepted`)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (opts.maxSize != null && file.size != null && file.size > opts.maxSize) {
|
|
98
|
+
errors.push(`${file.name}: file exceeds ${formatFileSize(opts.maxSize)}`)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
valid.push(file)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (opts.maxFiles != null && valid.length > opts.maxFiles) {
|
|
106
|
+
const extra = valid.splice(opts.maxFiles)
|
|
107
|
+
for (const file of extra) {
|
|
108
|
+
errors.push(`${file.name}: exceeds the ${opts.maxFiles}-file limit`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { valid, errors }
|
|
113
|
+
}
|
package/src/files.macos.ts
CHANGED
|
@@ -1,12 +1,53 @@
|
|
|
1
|
-
// Files — AppKit host leaf.
|
|
1
|
+
// Files — AppKit host leaf.
|
|
2
2
|
import type { FileRef } from './types'
|
|
3
3
|
|
|
4
|
+
function openPanel(accept = '*/*', multiple = false): FileRef[] | null {
|
|
5
|
+
const appKit = globalThis as any
|
|
6
|
+
const Panel = appKit.NSOpenPanel
|
|
7
|
+
if (!Panel?.openPanel) {
|
|
8
|
+
throw new Error('AppKit NSOpenPanel is unavailable in this macOS host')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const panel = Panel.openPanel()
|
|
12
|
+
panel.canChooseFiles = true
|
|
13
|
+
panel.canChooseDirectories = false
|
|
14
|
+
panel.allowsMultipleSelection = multiple
|
|
15
|
+
const extensions = accept
|
|
16
|
+
.split(',')
|
|
17
|
+
.map((value) => value.trim())
|
|
18
|
+
.filter((value) => value.startsWith('.'))
|
|
19
|
+
.map((value) => value.slice(1))
|
|
20
|
+
|
|
21
|
+
if (extensions.length) {
|
|
22
|
+
panel.allowedFileTypes = extensions
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (panel.runModal() !== (appKit.NSModalResponseOK ?? 1)) {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const urls: any[] = []
|
|
30
|
+
if (multiple) {
|
|
31
|
+
const selected = panel.URLs
|
|
32
|
+
for (let index = 0; index < Number(selected?.count ?? 0); index++) {
|
|
33
|
+
urls.push(selected.objectAtIndex(index))
|
|
34
|
+
}
|
|
35
|
+
} else if (panel.URL) {
|
|
36
|
+
urls.push(panel.URL)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return urls.map((url) => ({
|
|
40
|
+
name: String(url.lastPathComponent ?? ''),
|
|
41
|
+
uri: String(url.absoluteString ?? url.path ?? ''),
|
|
42
|
+
}))
|
|
43
|
+
}
|
|
44
|
+
|
|
4
45
|
export const files = {
|
|
5
|
-
async pick(): Promise<FileRef | null> {
|
|
6
|
-
|
|
46
|
+
async pick(accept = '*/*'): Promise<FileRef | null> {
|
|
47
|
+
return openPanel(accept)?.[0] ?? null
|
|
7
48
|
},
|
|
8
|
-
async pickMultiple(): Promise<FileRef[]> {
|
|
9
|
-
|
|
49
|
+
async pickMultiple(accept = '*/*'): Promise<FileRef[]> {
|
|
50
|
+
return openPanel(accept, true) ?? []
|
|
10
51
|
},
|
|
11
52
|
async readText(_file: FileRef): Promise<string> {
|
|
12
53
|
throw new Error('unsupported: AppKit file access is not wired')
|
package/src/files.ts
CHANGED
|
@@ -86,26 +86,34 @@ async function readContentUri(uri: string): Promise<string> {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
async function pickFiles(
|
|
90
|
+
accept: string,
|
|
91
|
+
multiple: boolean,
|
|
92
|
+
opts?: { startingFolder?: string },
|
|
93
|
+
): Promise<FileRef[]> {
|
|
94
|
+
const { extensions, mimeTypes } = pickerTypes(accept)
|
|
95
|
+
const result = await openFilePicker({
|
|
96
|
+
extensions,
|
|
97
|
+
mimeTypes,
|
|
98
|
+
multipleSelection: multiple,
|
|
99
|
+
permissions: { read: true, persistable: true },
|
|
100
|
+
startingFolder: opts?.startingFolder,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return (result.files ?? []).map((uri, index) => ({
|
|
104
|
+
name: androidDisplayName(uri, index === 0 ? result.android : undefined) ?? fallbackName(uri),
|
|
105
|
+
uri,
|
|
106
|
+
}))
|
|
107
|
+
}
|
|
108
|
+
|
|
89
109
|
export const files = {
|
|
90
|
-
/** Opens the platform document picker and returns
|
|
110
|
+
/** Opens the platform document picker and returns selected opaque URIs. */
|
|
111
|
+
async pickMultiple(accept = '*/*', opts?: { startingFolder?: string }): Promise<FileRef[]> {
|
|
112
|
+
return pickFiles(accept, true, opts)
|
|
113
|
+
},
|
|
114
|
+
/** Opens the platform document picker and returns its first opaque URI. */
|
|
91
115
|
async pick(accept = '*/*', opts?: { startingFolder?: string }): Promise<FileRef | null> {
|
|
92
|
-
|
|
93
|
-
const result = await openFilePicker({
|
|
94
|
-
extensions,
|
|
95
|
-
mimeTypes,
|
|
96
|
-
multipleSelection: false,
|
|
97
|
-
permissions: { read: true, persistable: true },
|
|
98
|
-
startingFolder: opts?.startingFolder,
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
const uri = result.files?.[0]
|
|
102
|
-
|
|
103
|
-
if (!uri) {
|
|
104
|
-
return null
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const name = androidDisplayName(uri, result.android) ?? fallbackName(uri)
|
|
108
|
-
return { name, uri }
|
|
116
|
+
return (await pickFiles(accept, false, opts))[0] ?? null
|
|
109
117
|
},
|
|
110
118
|
async readText(ref: FileRef): Promise<string> {
|
|
111
119
|
if (ref.uri.startsWith('content://')) {
|
package/src/files.web.ts
CHANGED
|
@@ -3,25 +3,37 @@
|
|
|
3
3
|
import { desktopHost } from '@octane-xplat/platform/host/web'
|
|
4
4
|
import type { FileRef } from './types'
|
|
5
5
|
|
|
6
|
+
function chooseFiles(accept: string, multiple: boolean): Promise<FileRef[]> {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const input = document.createElement('input')
|
|
9
|
+
input.type = 'file'
|
|
10
|
+
input.accept = accept
|
|
11
|
+
input.multiple = multiple
|
|
12
|
+
input.onchange = () => {
|
|
13
|
+
const refs: FileRef[] = []
|
|
14
|
+
for (const file of Array.from(input.files ?? [])) {
|
|
15
|
+
refs.push({ name: file.name, uri: URL.createObjectURL(file) })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
resolve(refs)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
input.oncancel = () => resolve([])
|
|
22
|
+
input.click()
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
6
26
|
export const files = {
|
|
27
|
+
async pickMultiple(accept = '*/*', _opts?: { startingFolder?: string }): Promise<FileRef[]> {
|
|
28
|
+
return chooseFiles(accept, true)
|
|
29
|
+
},
|
|
7
30
|
async pick(accept = '*/*', opts?: { startingFolder?: string }): Promise<FileRef | null> {
|
|
8
31
|
const host = desktopHost()
|
|
9
32
|
if (host && (await host.supports('files', 'pick'))) {
|
|
10
33
|
return host.files.pick(accept, { startingFolder: opts?.startingFolder })
|
|
11
34
|
}
|
|
12
35
|
|
|
13
|
-
return
|
|
14
|
-
const input = document.createElement('input')
|
|
15
|
-
input.type = 'file'
|
|
16
|
-
input.accept = accept
|
|
17
|
-
input.onchange = () => {
|
|
18
|
-
const f = input.files?.[0]
|
|
19
|
-
resolve(f ? { name: f.name, uri: URL.createObjectURL(f) } : null)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
input.oncancel = () => resolve(null)
|
|
23
|
-
input.click()
|
|
24
|
-
})
|
|
36
|
+
return (await chooseFiles(accept, false))[0] ?? null
|
|
25
37
|
},
|
|
26
38
|
async readText(ref: FileRef): Promise<string> {
|
|
27
39
|
const host = desktopHost()
|
package/src/index.macos.ts
CHANGED
|
@@ -1,2 +1,11 @@
|
|
|
1
1
|
export * from './types'
|
|
2
2
|
export { files } from './files.macos'
|
|
3
|
+
export { FileInput } from './FileInput.macos.tsrx'
|
|
4
|
+
export type {
|
|
5
|
+
FileInputFile,
|
|
6
|
+
FileInputHandle,
|
|
7
|
+
FileInputPick,
|
|
8
|
+
FileInputProps,
|
|
9
|
+
FileInputStatus,
|
|
10
|
+
FileInputStatusType,
|
|
11
|
+
} from './props'
|
package/src/index.ts
CHANGED
package/src/props.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Octane } from 'octane/jsx-runtime'
|
|
2
|
+
|
|
3
|
+
import type { FieldControlProps, FieldStatus, FieldStatusType } from '@octane-xplat/ui'
|
|
4
|
+
import type { FileRef } from './types'
|
|
5
|
+
|
|
6
|
+
/** A portable selected file. The browser File is only present on web. */
|
|
7
|
+
export interface FileInputFile extends FileRef {
|
|
8
|
+
size?: number
|
|
9
|
+
mimeType?: string
|
|
10
|
+
file?: any
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type FileInputStatus = FieldStatus
|
|
14
|
+
export type FileInputStatusType = FieldStatusType
|
|
15
|
+
|
|
16
|
+
export type FileInputPick = (options: {
|
|
17
|
+
accept?: string
|
|
18
|
+
multiple?: boolean
|
|
19
|
+
}) => Promise<FileInputFile[] | FileInputFile | null>
|
|
20
|
+
|
|
21
|
+
export interface FileInputHandle {
|
|
22
|
+
open(): void
|
|
23
|
+
native: any
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FileInputProps extends FieldControlProps {
|
|
27
|
+
className?: any
|
|
28
|
+
style?: any
|
|
29
|
+
id?: string
|
|
30
|
+
accessibilityLabel?: string
|
|
31
|
+
accessibilityHint?: string
|
|
32
|
+
/** `input` is a compact field row; `dropzone` a larger target that
|
|
33
|
+
* accepts drag/drop on pointer platforms. @default 'input' */
|
|
34
|
+
mode?: 'input' | 'dropzone'
|
|
35
|
+
value: FileInputFile | FileInputFile[] | null
|
|
36
|
+
onChange: (value: FileInputFile | FileInputFile[] | null) => void
|
|
37
|
+
changeAction?: (value: FileInputFile | FileInputFile[] | null) => void | Promise<void>
|
|
38
|
+
/** `accept`-style filter: `.ext`, `type/subtype`, wildcard subtype, any. */
|
|
39
|
+
accept?: string
|
|
40
|
+
/** When true, `value` and `onChange` use file arrays. */
|
|
41
|
+
isMultiple?: boolean
|
|
42
|
+
/** Max bytes per file; skipped for refs without a `size`. */
|
|
43
|
+
maxSize?: number
|
|
44
|
+
maxFiles?: number
|
|
45
|
+
placeholder?: string
|
|
46
|
+
/** Optional per-instance picker override for cloud or app-specific sources. */
|
|
47
|
+
pick?: FileInputPick
|
|
48
|
+
ref?: Octane.Ref<FileInputHandle>
|
|
49
|
+
ios?: any
|
|
50
|
+
android?: any
|
|
51
|
+
web?: any
|
|
52
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -2,7 +2,8 @@ export interface FileRef {
|
|
|
2
2
|
name: string
|
|
3
3
|
/**
|
|
4
4
|
* Opaque reference: blob/object URL on web, filesystem path on native.
|
|
5
|
-
* `files.writeText()` downloads on web and writes
|
|
5
|
+
* `files.writeText()` downloads on web and writes where implemented.
|
|
6
|
+
* Picked mobile files may use `content://` URIs.
|
|
6
7
|
*/
|
|
7
8
|
uri: string
|
|
8
9
|
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { UniversalComponent } from 'octane/universal'
|
|
2
|
+
import type { FileInputProps } from '../src/props.js'
|
|
3
|
+
|
|
4
|
+
export declare const FileInput: UniversalComponent<FileInputProps>
|
|
5
|
+
export type {
|
|
6
|
+
FileInputFile,
|
|
7
|
+
FileInputHandle,
|
|
8
|
+
FileInputPick,
|
|
9
|
+
FileInputProps,
|
|
10
|
+
FileInputStatus,
|
|
11
|
+
FileInputStatusType,
|
|
12
|
+
} from '../src/props.js'
|
|
13
|
+
|
|
14
|
+
export { files } from '../src/files.js'
|
|
15
|
+
export type { FileRef } from '../src/types.js'
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FileInputProps } from '../src/props.js'
|
|
2
|
+
|
|
3
|
+
export declare function FileInput(props: FileInputProps): unknown
|
|
4
|
+
export type {
|
|
5
|
+
FileInputFile,
|
|
6
|
+
FileInputHandle,
|
|
7
|
+
FileInputPick,
|
|
8
|
+
FileInputProps,
|
|
9
|
+
FileInputStatus,
|
|
10
|
+
FileInputStatusType,
|
|
11
|
+
} from '../src/props.js'
|
|
12
|
+
|
|
13
|
+
export { files } from '../src/files.macos.js'
|
|
14
|
+
export type { FileRef } from '../src/types.js'
|