Skip to content

Commit be16ff5

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 be16ff5

File tree

12 files changed

+540
-0
lines changed

12 files changed

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