Skip to content

Commit d11a3ab

Browse files
susnuxShGKme
andcommitted
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. Co-authored-by: Ferdinand Thiessen <opensource@fthiessen.de> Co-authored-by: Grigorii K. Shartsev <me@shgk.me> Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 8c4ab52 commit d11a3ab

File tree

4 files changed

+275
-0
lines changed

4 files changed

+275
-0
lines changed

l10n/messages.pot

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,9 @@ msgstr ""
350350
msgid "Pick an emoji"
351351
msgstr ""
352352

353+
msgid "Pick files"
354+
msgstr ""
355+
353356
msgid "Please choose a date"
354357
msgstr ""
355358

@@ -506,6 +509,12 @@ msgstr ""
506509
msgid "Undo changes"
507510
msgstr ""
508511

512+
msgid "Upload files"
513+
msgstr ""
514+
515+
msgid "Upload folders"
516+
msgstr ""
517+
509518
msgid "User status: {status}"
510519
msgstr ""
511520

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

src/components/NcFilePicker/index.ts

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
@@ -54,6 +54,7 @@ export { default as NcDialogButton } from './NcDialogButton/index.ts'
5454
export { default as NcEllipsisedOption } from './NcEllipsisedOption/index.js'
5555
export { default as NcEmojiPicker } from './NcEmojiPicker/index.js'
5656
export { default as NcEmptyContent } from './NcEmptyContent/index.ts'
57+
export { default as NcFilePicker } from './NcFilePicker/index.ts'
5758
export { default as NcGuestContent } from './NcGuestContent/index.ts'
5859
export { default as NcHeaderButton } from './NcHeaderButton/index.ts'
5960
export { default as NcHeaderMenu } from './NcHeaderMenu/index.ts'

0 commit comments

Comments
 (0)