diff --git a/l10n/messages.pot b/l10n/messages.pot
index 8f83149482..b57e92c9ee 100644
--- a/l10n/messages.pot
+++ b/l10n/messages.pot
@@ -381,6 +381,15 @@ msgstr ""
msgid "Pick an emoji"
msgstr ""
+msgid "Pick file"
+msgstr ""
+
+msgid "Pick files"
+msgstr ""
+
+msgid "Pick folder"
+msgstr ""
+
msgid "Please choose a date"
msgstr ""
@@ -549,6 +558,18 @@ msgstr ""
msgid "Undo changes"
msgstr ""
+msgid "Upload file"
+msgstr ""
+
+msgid "Upload files"
+msgstr ""
+
+msgid "Upload folder"
+msgstr ""
+
+msgid "Uploading …"
+msgstr ""
+
msgid "User status: {status}"
msgstr ""
diff --git a/src/components/NcFilePicker/NcFilePicker.vue b/src/components/NcFilePicker/NcFilePicker.vue
new file mode 100644
index 0000000000..3bf1a0dfef
--- /dev/null
+++ b/src/components/NcFilePicker/NcFilePicker.vue
@@ -0,0 +1,331 @@
+
+
+
+
+
+
+
+
+
+
+
+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.
+
+### Exposed methods
+
+- `function reset(): void`
+ This method allows to reset the internal state of the file picker to clear the current selection
+
+### Example
+
+```vue
+
+
+
+
+
+
+ Clear
+
+
+
+
Selected files:
+
+ -
+ {{ file.webkitRelativePath || file.name }}
+
+
+
+
+
+
+```
+
diff --git a/src/components/NcFilePicker/index.ts b/src/components/NcFilePicker/index.ts
new file mode 100644
index 0000000000..378c1ea178
--- /dev/null
+++ b/src/components/NcFilePicker/index.ts
@@ -0,0 +1,11 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export type {
+ FilePickerItem,
+ FilePickerItemGroup,
+} from './NcFilePicker.vue'
+
+export { default } from './NcFilePicker.vue'
diff --git a/src/components/index.ts b/src/components/index.ts
index 086153520d..284e5e29ca 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -59,6 +59,7 @@ export { default as NcDialogButton } from './NcDialogButton/index.ts'
export { default as NcEllipsisedOption } from './NcEllipsisedOption/index.js'
export { default as NcEmojiPicker } from './NcEmojiPicker/index.js'
export { default as NcEmptyContent } from './NcEmptyContent/index.ts'
+export { default as NcFilePicker } from './NcFilePicker/index.ts'
export { default as NcFormBox } from './NcFormBox/index.ts'
export { default as NcFormBoxButton } from './NcFormBoxButton/index.ts'
export { default as NcFormBoxCopyButton } from './NcFormBoxCopyButton/index.ts'
diff --git a/tests/component/components/NcFilePicker/NcFilePicker.spec.ts b/tests/component/components/NcFilePicker/NcFilePicker.spec.ts
new file mode 100644
index 0000000000..fd1bce9a28
--- /dev/null
+++ b/tests/component/components/NcFilePicker/NcFilePicker.spec.ts
@@ -0,0 +1,201 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect, test } from '@playwright/experimental-ct-vue'
+import { join } from 'node:path'
+import NcFilePicker from '../../../../src/components/NcFilePicker/NcFilePicker.vue'
+import NcFilePickerEmitStory from './NcFilePickerEmit.story.vue'
+
+test('mounting with default props', async ({ mount, page }) => {
+ await mount(NcFilePicker, {
+ props: {},
+ })
+
+ const button = page.getByRole('button', { name: 'Pick file' })
+ await expect(button).toBeVisible()
+ // check that the input is hidden
+ const input = page.locator('input[type="file"]')
+ await expect(input).toHaveAttribute('aria-hidden', 'true')
+})
+
+test('setting the variant prop', { tag: '@visual' }, async ({ mount, page, browserName }) => {
+ if (browserName !== 'chromium') {
+ test.skip()
+ }
+
+ const component = await mount(NcFilePicker, {
+ props: {},
+ })
+
+ const button = page.getByRole('button', { name: 'Pick file' })
+ await expect(button).toHaveScreenshot()
+
+ await component.update({ props: { variant: 'primary' } })
+ await expect(button).toHaveScreenshot()
+
+ await component.update({ props: { variant: 'secondary' } })
+ await expect(button).toHaveScreenshot()
+
+ await component.update({ props: { variant: 'tertiary' } })
+ await expect(button).toHaveScreenshot()
+})
+
+test('label is adjusted to match requested mode', async ({ mount, page }) => {
+ const component = await mount(NcFilePicker, {
+ props: {},
+ })
+
+ await expect(page.getByRole('button', { name: 'Pick file' })).toBeVisible()
+
+ await component.update({ props: { multiple: true } })
+ await expect(page.getByRole('button', { name: 'Pick files' })).toBeVisible()
+
+ await component.update({ props: { directory: true, multiple: false } })
+ await expect(page.getByRole('button', { name: 'Pick file' })).toBeVisible()
+
+ await component.update({ props: { directory: true, multiple: true } })
+ await expect(page.getByRole('button', { name: 'Pick files' })).toBeVisible()
+
+ await component.update({ props: { directory: true, directoryOnly: true, multiple: false } })
+ await expect(page.getByRole('button', { name: 'Pick folder' })).toBeVisible()
+
+ await component.update({ props: { directory: true, directoryOnly: true, multiple: true } })
+ await expect(page.getByRole('button', { name: 'Pick folder' })).toBeVisible() // ignored because browsers only support single folder picking
+})
+
+test('can override labels', async ({ mount, page }) => {
+ const label = 'Custom label'
+ const component = await mount(NcFilePicker, {
+ props: {
+ label,
+ },
+ })
+
+ await expect(page.getByRole('button', { name: label })).toBeVisible()
+
+ await component.update({ props: { label, multiple: true } })
+ await expect(page.getByRole('button', { name: label })).toBeVisible()
+
+ await component.update({ props: { label, directory: true } })
+ await expect(page.getByRole('button', { name: label })).toBeVisible()
+
+ await component.update({ props: { label, directory: true, multiple: true } })
+ await expect(page.getByRole('button', { name: label })).toBeVisible()
+
+ await component.update({ props: { label, directory: true, directoryOnly: true } })
+ await expect(page.getByRole('button', { name: label })).toBeVisible()
+})
+
+test('Shows the menu when picking directories is allowed', async ({ mount, page }) => {
+ await mount(NcFilePicker, {
+ props: {
+ directory: true,
+ },
+ })
+
+ const button = page.getByRole('button', { name: 'Pick file' })
+ await expect(button).toBeVisible()
+ await button.click()
+
+ await expect(page.getByRole('menu', { name: 'Pick file' })).toBeVisible()
+ await expect(page.getByRole('menuitem', { name: 'Upload file' })).toBeVisible()
+ await expect(page.getByRole('menuitem', { name: 'Upload folder' })).toBeVisible()
+})
+
+test.describe('file picking', () => {
+ test.skip(({ browserName }) => browserName === 'webkit', 'WebKit does not support file pickers in Playwright yet')
+
+ test('picking a single file', async ({ mount, page }) => {
+ page.on('filechooser', async (fileChooser) => {
+ expect(fileChooser.isMultiple()).toBe(false)
+ await fileChooser.setFiles({ name: 'testfile.txt', mimeType: 'text/plain', buffer: Buffer.from('Hello World') })
+ })
+
+ const { promise, resolve } = Promise.withResolvers()
+ await mount(NcFilePickerEmitStory, { // we need this story as PlayWright can only proxy simple structures like strings but not complex ones like File objects
+ props: {},
+ on: {
+ pick: resolve,
+ },
+ })
+
+ const button = page.getByRole('button', { name: 'Pick file' })
+ await button.click()
+
+ expect(await promise).toEqual(['testfile.txt'])
+ })
+
+ test('picking multiple files', async ({ mount, page }) => {
+ page.on('filechooser', async (fileChooser) => {
+ expect(fileChooser.isMultiple()).toBe(true)
+ await fileChooser.setFiles([
+ { name: 'a.txt', mimeType: 'text/plain', buffer: Buffer.from('Hello') },
+ { name: 'b.txt', mimeType: 'text/plain', buffer: Buffer.from('Hallo') },
+ { name: 'c.txt', mimeType: 'text/plain', buffer: Buffer.from('Hola') },
+ ])
+ })
+
+ const { promise, resolve } = Promise.withResolvers()
+ await mount(NcFilePickerEmitStory, {
+ props: {
+ multiple: true,
+ },
+ on: {
+ pick: resolve,
+ },
+ })
+
+ const button = page.getByRole('button', { name: 'Pick files' })
+ await button.click()
+
+ expect(await promise).toEqual(['a.txt', 'b.txt', 'c.txt'])
+ })
+
+ test('picking a directory', async ({ mount, page }) => {
+ page.on('filechooser', async (fileChooser) => {
+ expect(fileChooser.isMultiple()).toBe(false)
+ await fileChooser.setFiles(join(import.meta.dirname, 'folder'))
+ })
+
+ const { promise, resolve } = Promise.withResolvers()
+ await mount(NcFilePickerEmitStory, {
+ props: {
+ directory: true,
+ directoryOnly: true,
+ },
+ on: {
+ pick: resolve,
+ },
+ })
+
+ await page.getByRole('button', { name: 'Pick folder' })
+ .click()
+ expect(await promise).toEqual(['folder/.gitkeep'])
+ })
+
+ test('picking a directory when having the menu', async ({ mount, page }) => {
+ page.on('filechooser', async (fileChooser) => {
+ expect(fileChooser.isMultiple()).toBe(false)
+ await fileChooser.setFiles(join(import.meta.dirname, 'folder'))
+ })
+
+ const { promise, resolve } = Promise.withResolvers()
+ await mount(NcFilePickerEmitStory, {
+ props: {
+ directory: true,
+ },
+ on: {
+ pick: resolve,
+ },
+ })
+
+ await page.getByRole('button', { name: 'Pick file' })
+ .click()
+ await page.getByRole('menuitem', { name: 'Upload folder' })
+ .click()
+
+ expect(await promise).toEqual(['folder/.gitkeep'])
+ })
+})
diff --git a/tests/component/components/NcFilePicker/NcFilePickerEmit.story.vue b/tests/component/components/NcFilePicker/NcFilePickerEmit.story.vue
new file mode 100644
index 0000000000..a50147210f
--- /dev/null
+++ b/tests/component/components/NcFilePicker/NcFilePickerEmit.story.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/tests/component/components/NcFilePicker/folder/.gitkeep b/tests/component/components/NcFilePicker/folder/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/mounting-with-default-props-1-chromium-linux.png b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/mounting-with-default-props-1-chromium-linux.png
new file mode 100644
index 0000000000..75e148f399
Binary files /dev/null and b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/mounting-with-default-props-1-chromium-linux.png differ
diff --git a/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-1-chromium-linux.png b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-1-chromium-linux.png
new file mode 100644
index 0000000000..75e148f399
Binary files /dev/null and b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-1-chromium-linux.png differ
diff --git a/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-2-chromium-linux.png b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-2-chromium-linux.png
new file mode 100644
index 0000000000..75e148f399
Binary files /dev/null and b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-2-chromium-linux.png differ
diff --git a/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-3-chromium-linux.png b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-3-chromium-linux.png
new file mode 100644
index 0000000000..859b4eda1f
Binary files /dev/null and b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-3-chromium-linux.png differ
diff --git a/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-4-chromium-linux.png b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-4-chromium-linux.png
new file mode 100644
index 0000000000..8b8e38003b
Binary files /dev/null and b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-4-chromium-linux.png differ