Skip to content

Commit 2a9ae0e

Browse files
committed
feat(NcFilePicker): add picker component to select local files
This can be used e.g. to upload files, for example in the forms app but also for the files or photos app. Signed-off-by: Ferdinand Thiessen <[email protected]>
1 parent 8f99087 commit 2a9ae0e

File tree

12 files changed

+527
-0
lines changed

12 files changed

+527
-0
lines changed

l10n/messages.pot

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,15 @@ msgstr ""
381381
msgid "Pick an emoji"
382382
msgstr ""
383383

384+
msgid "Pick file"
385+
msgstr ""
386+
387+
msgid "Pick files"
388+
msgstr ""
389+
390+
msgid "Pick folder"
391+
msgstr ""
392+
384393
msgid "Please choose a date"
385394
msgstr ""
386395

@@ -549,6 +558,18 @@ msgstr ""
549558
msgid "Undo changes"
550559
msgstr ""
551560

561+
msgid "Upload file"
562+
msgstr ""
563+
564+
msgid "Upload files"
565+
msgstr ""
566+
567+
msgid "Upload folder"
568+
msgstr ""
569+
570+
msgid "Uploading …"
571+
msgstr ""
572+
552573
msgid "User status: {status}"
553574
msgstr ""
554575

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<script setup lang="ts">
7+
import type { Slot } from 'vue'
8+
9+
import { computed, nextTick, useTemplateRef } from 'vue'
10+
import IconFolderUpload from 'vue-material-design-icons/FolderUpload.vue'
11+
import IconPlus from 'vue-material-design-icons/Plus.vue'
12+
import IconUpload from 'vue-material-design-icons/Upload.vue'
13+
import { t } from '../../l10n.js'
14+
import NcActionButton from '../NcActionButton/index.js'
15+
import NcActions from '../NcActions/index.js'
16+
import NcLoadingIcon from '../NcLoadingIcon/index.js'
17+
18+
const props = withDefaults(defineProps<{
19+
/**
20+
* File types to accept
21+
*/
22+
accept?: string[]
23+
24+
/**
25+
* Allow picking a directory
26+
*/
27+
directory?: boolean
28+
29+
/**
30+
* Disable picking files and only allow picking a directory
31+
*/
32+
directoryOnly?: boolean
33+
34+
/**
35+
* Disabled state of the picker
36+
*/
37+
disabled?: boolean
38+
39+
/**
40+
* If set then the label is only used for accessibility but not shown visually
41+
*/
42+
iconOnly?: boolean
43+
44+
/**
45+
* Label of the picker
46+
*
47+
* @default 'Pick file' or 'Pick files' depending on `multiple`
48+
*/
49+
label?: string
50+
51+
/**
52+
* If set then the picker will be set into a loading state.
53+
* This means the picker is disabled, a loading spinner is shown and the label is adjusted.
54+
*/
55+
loading?: boolean
56+
57+
/**
58+
* Can the user pick multiple files.
59+
* This is ignored when picking folder (by browser limitations).
60+
*/
61+
multiple?: boolean
62+
63+
/**
64+
* The variant of the button
65+
*/
66+
variant?: 'primary' | 'secondary' | 'tertiary'
67+
}>(), {
68+
accept: undefined,
69+
label: undefined,
70+
variant: 'primary',
71+
})
72+
73+
const emit = defineEmits<{
74+
pick: [files: File[]]
75+
}>()
76+
77+
defineSlots<{
78+
/**
79+
* Custom NcAction* to be shown within the picker menu
80+
*/
81+
actions?: Slot
82+
83+
/**
84+
* Optional custom icon for the picker menu
85+
*/
86+
icon?: Slot
87+
88+
/**
89+
* Optional content to be shown in the picker.
90+
* This can be used e.g. for a progress bar or similar.
91+
*/
92+
default?: Slot
93+
}>()
94+
95+
defineExpose({
96+
reset,
97+
})
98+
99+
const formElement = useTemplateRef('form')
100+
const inputElement = useTemplateRef('input')
101+
102+
/**
103+
* The current label to be used as menu name and accessible name of the picker.
104+
*/
105+
const currentLabel = computed(() => {
106+
if (props.loading) {
107+
return t('Uploading …')
108+
} else if (props.label) {
109+
return props.label
110+
} else if (props.directoryOnly) {
111+
return t('Pick folder')
112+
}
113+
return props.multiple ? t('Pick files') : t('Pick file')
114+
})
115+
116+
/**
117+
* Check whether the current browser supports uploading directories
118+
* Hint: This does not check if the current connection supports this, as some browsers require a secure context!
119+
*/
120+
const canUploadFolders = computed(() => {
121+
return (props.directory || props.directoryOnly) && 'webkitdirectory' in HTMLInputElement.prototype
122+
})
123+
124+
/**
125+
* Trigger file picker
126+
*
127+
* @param uploadFolders - Whether to upload folders or files
128+
*/
129+
function triggerPickFiles(uploadFolders: boolean) {
130+
// Without reset selecting the same file doesn't trigger the change event
131+
reset()
132+
133+
// Only if the browser supports picking folders and the user selected "pick folder" we set the file input to directory picking.
134+
if (canUploadFolders.value) {
135+
inputElement.value!.webkitdirectory = uploadFolders
136+
}
137+
138+
// Wait for the reset and the `webkitdirectory` to be dispatched in DOM
139+
nextTick(() => inputElement.value!.click())
140+
}
141+
142+
/**
143+
* Handle picking some local files
144+
*/
145+
function onPick() {
146+
const files = inputElement.value?.files ? Array.from(inputElement.value.files) : []
147+
emit('pick', files)
148+
}
149+
150+
/**
151+
* Reset the picker state of the currently selected files.
152+
*/
153+
function reset() {
154+
formElement.value!.reset()
155+
}
156+
</script>
157+
158+
<template>
159+
<form
160+
ref="form"
161+
class="vue-file-picker">
162+
<NcActions
163+
:aria-label="currentLabel"
164+
:disabled="disabled || loading"
165+
:menu-name="iconOnly ? undefined : currentLabel"
166+
:force-name="!iconOnly"
167+
:variant>
168+
<template #icon>
169+
<slot v-if="!loading" name="icon">
170+
<IconPlus :size="20" />
171+
</slot>
172+
<NcLoadingIcon v-else />
173+
</template>
174+
175+
<NcActionButton
176+
v-if="!directoryOnly"
177+
close-after-click
178+
@click="triggerPickFiles(false)">
179+
<template #icon>
180+
<IconUpload :size="20" />
181+
</template>
182+
<!-- If this is not the only action in the NcActions this is a menu entry and we need a generic name - otherwise this will be a single button where we need to apply the label -->
183+
{{ canUploadFolders || $slots.actions ? (multiple ? t('Upload files') : t('Upload file')) : currentLabel }}
184+
</NcActionButton>
185+
186+
<NcActionButton
187+
v-if="canUploadFolders"
188+
close-after-click
189+
@click="triggerPickFiles(true)">
190+
<template #icon>
191+
<IconFolderUpload style="color: var(--color-primary-element)" :size="20" />
192+
</template>
193+
<!-- If this is not the only action in the NcActions this is a menu entry and we need a generic name - otherwise this will be a single button where we need to apply the label -->
194+
{{ !directoryOnly || $slots.actions ? t('Upload folder') : currentLabel }}
195+
</NcActionButton>
196+
197+
<!-- App defined upload actions -->
198+
<slot name="actions" />
199+
</NcActions>
200+
201+
<!-- Hidden files picker input - also hidden for accessibility as otherwise such users also loose the ability to pick files -->
202+
<input
203+
ref="input"
204+
:accept="accept?.join(', ')"
205+
aria-hidden="true"
206+
class="hidden-visually"
207+
:multiple
208+
type="file"
209+
@change="onPick">
210+
211+
<slot />
212+
</form>
213+
</template>
214+
215+
<style lang="scss" scoped>
216+
.vue-file-picker {
217+
display: inline-flex;
218+
align-items: center;
219+
height: var(--default-clickable-area);
220+
}
221+
</style>
222+
223+
<docs>
224+
This component allows to pick local files (or directories) which can be used to upload them to Nextcloud or directly process them in the browser.
225+
226+
### Exposed methods
227+
228+
- `function reset(): void`
229+
This method allows to reset the internal state of the file picker to clear the current selection
230+
231+
### Example
232+
233+
```vue
234+
<template>
235+
<div>
236+
<div class="wrapper">
237+
<NcFilePicker ref="picker"
238+
directory
239+
@pick="selectedFiles = $event" />
240+
241+
<NcButton variant="tertiary"
242+
@click="clearPicker">
243+
Clear
244+
</NcButton>
245+
</div>
246+
247+
<h3>Selected files:</h3>
248+
<ul>
249+
<li v-for="file in selectedFiles" key="file.name">
250+
{{ file.webkitRelativePath || file.name }}
251+
</li>
252+
</ul>
253+
</div>
254+
</template>
255+
<script>
256+
export default {
257+
data() {
258+
return {
259+
selectedFiles: [],
260+
}
261+
},
262+
methods: {
263+
/**
264+
* This will clear the selected files from the picker.
265+
*/
266+
clearPicker() {
267+
this.$refs.picker.reset()
268+
},
269+
},
270+
}
271+
</script>
272+
<style scoped>
273+
.wrapper {
274+
display: flex;
275+
gap: 10px;
276+
}
277+
</style>
278+
```
279+
</docs>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
export { default } from './NcFilePicker.vue'

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export { default as NcDialogButton } from './NcDialogButton/index.ts'
5959
export { default as NcEllipsisedOption } from './NcEllipsisedOption/index.js'
6060
export { default as NcEmojiPicker } from './NcEmojiPicker/index.js'
6161
export { default as NcEmptyContent } from './NcEmptyContent/index.ts'
62+
export { default as NcFilePicker } from './NcFilePicker/index.ts'
6263
export { default as NcFormBox } from './NcFormBox/index.ts'
6364
export { default as NcFormBoxButton } from './NcFormBoxButton/index.ts'
6465
export { default as NcFormBoxCopyButton } from './NcFormBoxCopyButton/index.ts'

0 commit comments

Comments
 (0)