diff --git a/README.md b/README.md
index 52c49c3..3dbe44e 100644
--- a/README.md
+++ b/README.md
@@ -23,3 +23,48 @@ There are three entry points provided:
- The main entry point `@nextcloud/sharing` provides general utils for file sharing
- The _public_ entry point `@nextcloud/sharing/public` provides utils for handling public file shares
- The _ui_ entry point `@nextcloud/sharing/ui` provides API bindings to interact with the files sharing interface in the files app.
+- The _dialog_ entry point `@nextcloud/sharing/dialog` provides the sharing dialog for the unified sharing API (**experimental**, see below).
+
+### Sharing dialog (experimental)
+
+> [!WARNING]
+> This entry point is experimental. It requires the unified sharing API
+> (Nextcloud 35 or later) and its API may change in any minor release.
+> Some inline validation requires a not-yet-released `@nextcloud/vue` version
+> and degrades gracefully on older ones.
+
+Open the dialog for a node:
+
+```ts
+import { openSharingDialog, isSharingDialogAvailable } from '@nextcloud/sharing/dialog'
+
+// Safe to call directly: shows an error toast if the API is unavailable
+await openSharingDialog(node)
+
+// Or gate your entry point (e.g. a share button) on availability:
+if (isSharingDialogAvailable()) {
+ // show the share action
+}
+```
+
+For programmatic control, work with a `Share` instance directly. Instances are
+created by `createShare()` (a new draft) or `getShare()` (an existing share) —
+the `Share` class is exported as a type only and is never constructed manually.
+Every mutation round-trips to the backend and updates the reactive instance:
+
+```ts
+import { createShare, getShare, searchRecipients } from '@nextcloud/sharing/dialog'
+import type { Share } from '@nextcloud/sharing/dialog'
+
+const share = await createShare()
+await share.addNode(node)
+await share.selectPreset(presetClass)
+await share.setProperty(propertyClass, value)
+await share.showDialog(node) // open the dialog bound to this share
+
+const existing: Share = await getShare(shareId)
+const recipients = await searchRecipients('alice')
+```
+
+The entry point also exports the `SharingDialog` component for embedding and all
+of the sharing API's request/response types.
diff --git a/lib/assets.d.ts b/lib/assets.d.ts
new file mode 100644
index 0000000..57d871c
--- /dev/null
+++ b/lib/assets.d.ts
@@ -0,0 +1,9 @@
+/*!
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+declare module '*?raw' {
+ const content: string
+ export default content
+}
diff --git a/lib/dialog/SharingDialog.vue b/lib/dialog/SharingDialog.vue
new file mode 100644
index 0000000..cfbabab
--- /dev/null
+++ b/lib/dialog/SharingDialog.vue
@@ -0,0 +1,258 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ dialogTitle }}
+
+
+ {{ nodeName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/dialog/api/share.spec.ts b/lib/dialog/api/share.spec.ts
new file mode 100644
index 0000000..2c372de
--- /dev/null
+++ b/lib/dialog/api/share.spec.ts
@@ -0,0 +1,199 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import type { INode } from '@nextcloud/files'
+import type { SharingShare } from '../types/api.ts'
+import type { Share } from './share.ts'
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { SOURCE_TYPE_NODE } from '../constants.ts'
+import { createShare, getShare, searchRecipients } from './share.ts'
+import * as client from './sharing.ts'
+
+vi.mock('./sharing.ts', () => ({
+ createShare: vi.fn(),
+ getShare: vi.fn(),
+ addShareSource: vi.fn(),
+ removeShareSource: vi.fn(),
+ addShareRecipient: vi.fn(),
+ removeShareRecipient: vi.fn(),
+ updateShareRecipientSecret: vi.fn(),
+ updateShareProperty: vi.fn(),
+ updateSharePermission: vi.fn(),
+ selectSharePermissionPreset: vi.fn(),
+ updateShareState: vi.fn(),
+ searchRecipients: vi.fn(),
+ deleteShare: vi.fn(),
+}))
+
+const mocked = vi.mocked(client)
+
+/**
+ * Build a minimal share schema for testing.
+ *
+ * @param overrides Fields to override on the default schema
+ */
+function share(overrides: Partial = {}): SharingShare {
+ return {
+ id: 'abc',
+ owner: { user_id: 'alice', instance: null, display_name: 'Alice', icon: { svg: '' } },
+ last_updated: 0,
+ state: 'draft',
+ sources: [],
+ recipients: [],
+ properties: [],
+ permissions: [],
+ permission_preset: null,
+ ...overrides,
+ }
+}
+
+/**
+ * Obtain a Share instance wrapping the given schema, via the public factory.
+ *
+ * @param data The schema the instance should wrap
+ */
+async function makeShare(data: SharingShare = share()): Promise {
+ mocked.getShare.mockResolvedValueOnce(data)
+ return getShare(data.id)
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('Share', () => {
+ it('createShare() wraps the created draft', async () => {
+ mocked.createShare.mockResolvedValue(share({ id: 'new' }))
+ const instance = await createShare()
+ expect(mocked.createShare).toHaveBeenCalledOnce()
+ expect(instance.id).toBe('new')
+ expect(instance.state).toBe('draft')
+ })
+
+ it('getShare() fetches an existing share', async () => {
+ mocked.getShare.mockResolvedValue(share({ id: 'x' }))
+ const instance = await getShare('x', 'secret', { password: 'p' })
+ expect(mocked.getShare).toHaveBeenCalledWith('x', 'secret', { password: 'p' })
+ expect(instance.id).toBe('x')
+ })
+
+ it('exposes the schema through getters', async () => {
+ const data = share({
+ state: 'active',
+ permission_preset: 'preset-a',
+ sources: [{ class: 'S', value: '1', display_name: 'file', icon: null }],
+ })
+ const instance = await makeShare(data)
+ expect(instance.data).toBe(data)
+ expect(instance.state).toBe('active')
+ expect(instance.permissionPreset).toBe('preset-a')
+ expect(instance.sources).toHaveLength(1)
+ })
+
+ it('mutations pass the id to the client and re-sync the data', async () => {
+ const instance = await makeShare(share({ id: 'abc' }))
+ const updated = share({ id: 'abc', state: 'active' })
+ mocked.updateShareState.mockResolvedValue(updated)
+
+ const result = await instance.setState('active')
+
+ expect(mocked.updateShareState).toHaveBeenCalledWith('abc', 'active')
+ expect(result).toBe(instance) // returns this for chaining
+ expect(instance.data).toBe(updated) // internal data replaced
+ expect(instance.state).toBe('active')
+ })
+
+ it('setProperty forwards the class and value', async () => {
+ const instance = await makeShare()
+ mocked.updateShareProperty.mockResolvedValue(share())
+ await instance.setProperty('P', 'v')
+ expect(mocked.updateShareProperty).toHaveBeenCalledWith('abc', 'P', 'v')
+ })
+
+ it('setPermission forwards the class and enabled flag', async () => {
+ const instance = await makeShare()
+ mocked.updateSharePermission.mockResolvedValue(share())
+ await instance.setPermission('C', false)
+ expect(mocked.updateSharePermission).toHaveBeenCalledWith('abc', 'C', false)
+ })
+
+ it('addSource forwards the class and value', async () => {
+ const instance = await makeShare()
+ mocked.addShareSource.mockResolvedValue(share())
+ await instance.addSource('S', '1')
+ expect(mocked.addShareSource).toHaveBeenCalledWith('abc', 'S', '1')
+ })
+
+ it('addNode maps the node to the node source type', async () => {
+ const instance = await makeShare()
+ mocked.addShareSource.mockResolvedValue(share())
+ await instance.addNode({ fileid: 42 } as unknown as INode)
+ expect(mocked.addShareSource).toHaveBeenCalledWith('abc', SOURCE_TYPE_NODE, '42')
+ })
+
+ it('removeSource forwards the class and value', async () => {
+ const instance = await makeShare()
+ mocked.removeShareSource.mockResolvedValue(share())
+ await instance.removeSource('S', '1')
+ expect(mocked.removeShareSource).toHaveBeenCalledWith('abc', 'S', '1')
+ })
+
+ it('addRecipient forwards class, value and instance', async () => {
+ const instance = await makeShare()
+ mocked.addShareRecipient.mockResolvedValue(share())
+ await instance.addRecipient('R', 'bob', 'https://remote.example')
+ expect(mocked.addShareRecipient).toHaveBeenCalledWith('abc', 'R', 'bob', 'https://remote.example')
+ })
+
+ it('removeRecipient forwards class, value and instance', async () => {
+ const instance = await makeShare()
+ mocked.removeShareRecipient.mockResolvedValue(share())
+ await instance.removeRecipient('R', 'bob')
+ expect(mocked.removeShareRecipient).toHaveBeenCalledWith('abc', 'R', 'bob', undefined)
+ })
+
+ it('setRecipientSecret forwards the secret', async () => {
+ const instance = await makeShare()
+ mocked.updateShareRecipientSecret.mockResolvedValue(share())
+ await instance.setRecipientSecret('R', 'bob', 'sEcret')
+ expect(mocked.updateShareRecipientSecret).toHaveBeenCalledWith('abc', 'R', 'bob', 'sEcret', undefined)
+ })
+
+ it('selectPreset forwards the preset class', async () => {
+ const instance = await makeShare()
+ mocked.selectSharePermissionPreset.mockResolvedValue(share())
+ await instance.selectPreset('preset-a')
+ expect(mocked.selectSharePermissionPreset).toHaveBeenCalledWith('abc', 'preset-a')
+ })
+
+ it('refresh re-fetches the share by id', async () => {
+ const instance = await makeShare(share({ id: 'abc' }))
+ const fresh = share({ id: 'abc', state: 'active' })
+ mocked.getShare.mockResolvedValueOnce(fresh)
+ await instance.refresh()
+ expect(mocked.getShare).toHaveBeenLastCalledWith('abc')
+ expect(instance.data).toBe(fresh)
+ })
+
+ it('activate() sets the state to active', async () => {
+ const instance = await makeShare()
+ mocked.updateShareState.mockResolvedValue(share({ state: 'active' }))
+ await instance.activate()
+ expect(mocked.updateShareState).toHaveBeenCalledWith('abc', 'active')
+ })
+
+ it('delete() removes the share and returns nothing', async () => {
+ const instance = await makeShare()
+ mocked.deleteShare.mockResolvedValue()
+ await expect(instance.delete()).resolves.toBeUndefined()
+ expect(mocked.deleteShare).toHaveBeenCalledWith('abc')
+ })
+
+ it('searchRecipients delegates to the client', async () => {
+ mocked.searchRecipients.mockResolvedValue([])
+ await searchRecipients('bob', 'UserType', 5, 2)
+ expect(mocked.searchRecipients).toHaveBeenCalledWith('bob', 'UserType', 5, 2)
+ })
+})
diff --git a/lib/dialog/api/share.ts b/lib/dialog/api/share.ts
new file mode 100644
index 0000000..e032a85
--- /dev/null
+++ b/lib/dialog/api/share.ts
@@ -0,0 +1,251 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import type { INode } from '@nextcloud/files'
+import type { ShallowRef } from 'vue'
+import type {
+ SharingPermission,
+ SharingProperty,
+ SharingRecipient,
+ SharingShare,
+ SharingSource,
+ SharingState,
+} from '../types/api.ts'
+
+import { shallowRef } from 'vue'
+import { SOURCE_TYPE_NODE } from '../constants.ts'
+import * as client from './sharing.ts'
+
+// Recipient search is not bound to a share; re-export the low-level helper as is.
+export { searchRecipients } from './sharing.ts'
+
+/**
+ * A share from the unified sharing API.
+ *
+ * The server is the source of truth: every mutation round-trips to the backend
+ * and the returned schema replaces the instance's data. The data is held in a
+ * `shallowRef`, so Vue components that read {@link Share.prototype.data} (or any
+ * of the convenience getters) re-render when a mutation resolves.
+ *
+ * Obtain one with {@link createShare} (a new draft) or {@link getShare} (fetch
+ * an existing share). The class is exported as a type only; instances are always
+ * produced by these helpers, never constructed directly.
+ */
+class Share {
+ readonly #data: ShallowRef
+
+ constructor(data: SharingShare) {
+ this.#data = shallowRef(data)
+ }
+
+ /** The full share schema (reactive). */
+ get data(): SharingShare {
+ return this.#data.value
+ }
+
+ /** The share id. */
+ get id(): string {
+ return this.#data.value.id
+ }
+
+ /** The share state (draft / active / deleted). */
+ get state(): SharingState {
+ return this.#data.value.state
+ }
+
+ /** The share sources. */
+ get sources(): SharingSource[] {
+ return this.#data.value.sources
+ }
+
+ /** The share recipients. */
+ get recipients(): SharingRecipient[] {
+ return this.#data.value.recipients
+ }
+
+ /** The share properties. */
+ get properties(): SharingProperty[] {
+ return this.#data.value.properties
+ }
+
+ /** The share permissions. */
+ get permissions(): SharingPermission[] {
+ return this.#data.value.permissions
+ }
+
+ /** The class of the preset matching the enabled permissions, null when custom. */
+ get permissionPreset(): string | null {
+ return this.#data.value.permission_preset
+ }
+
+ /**
+ * Replace the instance data with a fresh schema from the backend.
+ *
+ * @param data The updated share schema
+ */
+ #sync(data: SharingShare): this {
+ this.#data.value = data
+ return this
+ }
+
+ /**
+ * Add a source to the share.
+ *
+ * @param sourceClass The source type class
+ * @param sourceValue The source value
+ */
+ async addSource(sourceClass: string, sourceValue: string): Promise {
+ return this.#sync(await client.addShareSource(this.id, sourceClass, sourceValue))
+ }
+
+ /**
+ * Add a file or folder as the share's source.
+ *
+ * @param node The node to share
+ */
+ async addNode(node: INode): Promise {
+ return this.addSource(SOURCE_TYPE_NODE, node.fileid!.toString())
+ }
+
+ /**
+ * Remove a source from the share.
+ *
+ * @param sourceClass The source type class
+ * @param sourceValue The source value
+ */
+ async removeSource(sourceClass: string, sourceValue: string): Promise {
+ return this.#sync(await client.removeShareSource(this.id, sourceClass, sourceValue))
+ }
+
+ /**
+ * Add a recipient to the share.
+ *
+ * @param recipientClass The recipient type class
+ * @param recipientValue The recipient value
+ * @param instance The recipient's instance (federated shares)
+ */
+ async addRecipient(recipientClass: string, recipientValue: string, instance?: string): Promise {
+ return this.#sync(await client.addShareRecipient(this.id, recipientClass, recipientValue, instance))
+ }
+
+ /**
+ * Remove a recipient from the share.
+ *
+ * @param recipientClass The recipient type class
+ * @param recipientValue The recipient value
+ * @param instance The recipient's instance (federated shares)
+ */
+ async removeRecipient(recipientClass: string, recipientValue: string, instance?: string): Promise {
+ return this.#sync(await client.removeShareRecipient(this.id, recipientClass, recipientValue, instance))
+ }
+
+ /**
+ * Update the secret of a recipient.
+ *
+ * @param recipientClass The recipient type class
+ * @param recipientValue The recipient value
+ * @param secret The new secret
+ * @param instance The recipient's instance (federated shares)
+ */
+ async setRecipientSecret(recipientClass: string, recipientValue: string, secret: string, instance?: string): Promise {
+ return this.#sync(await client.updateShareRecipientSecret(this.id, recipientClass, recipientValue, secret, instance))
+ }
+
+ /**
+ * Set a property value. Pass null to unset it.
+ *
+ * @param propertyClass The property type class
+ * @param value The new value, or null to unset
+ */
+ async setProperty(propertyClass: string, value: string | null): Promise {
+ return this.#sync(await client.updateShareProperty(this.id, propertyClass, value))
+ }
+
+ /**
+ * Enable or disable a single permission.
+ *
+ * @param permissionClass The permission type class
+ * @param enabled The new enabled state
+ */
+ async setPermission(permissionClass: string, enabled: boolean): Promise {
+ return this.#sync(await client.updateSharePermission(this.id, permissionClass, enabled))
+ }
+
+ /**
+ * Apply a permission preset. The backend enables the preset's permissions
+ * and disables the rest.
+ *
+ * @param presetClass The preset class to apply
+ */
+ async selectPreset(presetClass: string): Promise {
+ return this.#sync(await client.selectSharePermissionPreset(this.id, presetClass))
+ }
+
+ /**
+ * Set the share state (draft → active → deleted).
+ *
+ * @param state The new state
+ */
+ async setState(state: SharingState): Promise {
+ return this.#sync(await client.updateShareState(this.id, state))
+ }
+
+ /**
+ * Activate the share (make the draft live).
+ */
+ async activate(): Promise {
+ return this.setState('active')
+ }
+
+ /**
+ * Reload the share from the backend.
+ */
+ async refresh(): Promise {
+ return this.#sync(await client.getShare(this.id))
+ }
+
+ /**
+ * Delete the share permanently.
+ */
+ async delete(): Promise {
+ await client.deleteShare(this.id)
+ }
+
+ /**
+ * Open the sharing dialog bound to this share.
+ * Resolves once the dialog is closed.
+ *
+ * @param node The node backing the share, used for the dialog title
+ */
+ async showDialog(node?: INode): Promise {
+ const [{ spawnDialog }, { default: SharingDialog }] = await Promise.all([
+ import('@nextcloud/vue/functions/dialog'),
+ import('../SharingDialog.vue'),
+ ])
+ return spawnDialog(SharingDialog, { share: this, node })
+ }
+}
+
+/**
+ * Create a new draft share.
+ */
+export async function createShare(): Promise {
+ return new Share(await client.createShare())
+}
+
+/**
+ * Fetch an existing share by id.
+ *
+ * @param id The share id
+ * @param secret A recipient secret granting access, if any
+ * @param args Extra arguments forwarded to the backend (e.g. a password)
+ */
+export async function getShare(id: string, secret?: string | null, args?: Record): Promise {
+ return new Share(await client.getShare(id, secret, args))
+}
+
+// Exported as a type only: instances are produced by the helpers above, the
+// class itself is never constructable by consumers.
+export type { Share }
diff --git a/lib/dialog/api/sharing.ts b/lib/dialog/api/sharing.ts
new file mode 100644
index 0000000..eabd249
--- /dev/null
+++ b/lib/dialog/api/sharing.ts
@@ -0,0 +1,208 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import type { OCSResponse } from '@nextcloud/typings/ocs'
+import type { SharingRecipient, SharingShare, SharingState } from '../types/api.ts'
+
+import axios from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+
+/**
+ *
+ * @param path
+ */
+function sharingUrl(path: string): string {
+ return generateOcsUrl('/apps/sharing/api/v1' + path)
+}
+
+/**
+ * Unwrap the payload from an OCS response envelope.
+ *
+ * @param response The axios response holding an OCS envelope
+ * @param response.data
+ */
+function unwrapOcs(response: { data: OCSResponse }): T {
+ return response.data.ocs.data
+}
+
+/**
+ * Create a new share draft.
+ */
+export async function createShare(): Promise {
+ const response = await axios.post(sharingUrl('/share'))
+ return unwrapOcs(response)
+}
+
+/**
+ * Get a share by ID.
+ * Uses POST because the endpoint accepts a request body for arguments.
+ *
+ * @param shareId
+ * @param secret
+ * @param args
+ */
+export async function getShare(shareId: string, secret?: string | null, args: Record = {}): Promise {
+ const response = await axios.post(sharingUrl(`/share/${shareId}`), { secret: secret ?? null, arguments: args })
+ return unwrapOcs(response)
+}
+
+/**
+ * Add a source to a share.
+ *
+ * @param shareId
+ * @param sourceClass
+ * @param sourceValue
+ */
+export async function addShareSource(shareId: string, sourceClass: string, sourceValue: string): Promise {
+ const response = await axios.post(sharingUrl(`/share/${shareId}/source`), {
+ class: sourceClass,
+ value: sourceValue,
+ })
+ return unwrapOcs(response)
+}
+
+/**
+ * Remove a source from a share.
+ *
+ * @param shareId
+ * @param sourceClass
+ * @param sourceValue
+ */
+export async function removeShareSource(shareId: string, sourceClass: string, sourceValue: string): Promise {
+ const response = await axios.delete(sharingUrl(`/share/${shareId}/source`), {
+ params: { class: sourceClass, value: sourceValue },
+ })
+ return unwrapOcs(response)
+}
+
+/**
+ * Add a recipient to a share.
+ *
+ * @param shareId
+ * @param recipientClass
+ * @param recipientValue
+ * @param instance
+ */
+export async function addShareRecipient(shareId: string, recipientClass: string, recipientValue: string, instance?: string): Promise {
+ const response = await axios.post(sharingUrl(`/share/${shareId}/recipient`), {
+ class: recipientClass,
+ value: recipientValue,
+ instance: instance ?? null,
+ })
+ return unwrapOcs(response)
+}
+
+/**
+ * Remove a recipient from a share.
+ *
+ * @param shareId
+ * @param recipientClass
+ * @param recipientValue
+ * @param instance
+ */
+export async function removeShareRecipient(shareId: string, recipientClass: string, recipientValue: string, instance?: string): Promise {
+ const response = await axios.delete(sharingUrl(`/share/${shareId}/recipient`), {
+ params: { class: recipientClass, value: recipientValue, instance: instance ?? undefined },
+ })
+ return unwrapOcs(response)
+}
+
+/**
+ * Update the secret of a share recipient.
+ *
+ * @param shareId
+ * @param recipientClass
+ * @param recipientValue
+ * @param secret
+ * @param instance
+ */
+export async function updateShareRecipientSecret(shareId: string, recipientClass: string, recipientValue: string, secret: string, instance?: string): Promise {
+ const response = await axios.put(sharingUrl(`/share/${shareId}/recipient/secret`), {
+ class: recipientClass,
+ value: recipientValue,
+ instance: instance ?? null,
+ secret,
+ })
+ return unwrapOcs(response)
+}
+
+/**
+ * Update a property value on a share.
+ *
+ * @param shareId
+ * @param propertyClass
+ * @param value
+ */
+export async function updateShareProperty(shareId: string, propertyClass: string, value: string | null): Promise {
+ const response = await axios.put(sharingUrl(`/share/${shareId}/property`), {
+ class: propertyClass,
+ value,
+ })
+ return unwrapOcs(response)
+}
+
+/**
+ * Update a permission on a share.
+ *
+ * @param shareId
+ * @param permissionClass
+ * @param enabled
+ */
+export async function updateSharePermission(shareId: string, permissionClass: string, enabled: boolean): Promise {
+ const response = await axios.put(sharingUrl(`/share/${shareId}/permission`), {
+ class: permissionClass,
+ enabled,
+ })
+ return unwrapOcs(response)
+}
+
+/**
+ * Select a permission preset on a share.
+ * The backend enables the permissions belonging to the preset and disables the rest.
+ *
+ * @param shareId Id of the share
+ * @param presetClass Class of the preset to apply
+ */
+export async function selectSharePermissionPreset(shareId: string, presetClass: string): Promise {
+ const response = await axios.put(sharingUrl(`/share/${shareId}/permission/preset`), {
+ permissionPresetClass: presetClass,
+ })
+ return unwrapOcs(response)
+}
+
+/**
+ * Update the state of a share (draft → active → deleted).
+ *
+ * @param shareId
+ * @param state
+ */
+export async function updateShareState(shareId: string, state: SharingState): Promise {
+ const response = await axios.put(sharingUrl(`/share/${shareId}/state`), { state })
+ return unwrapOcs(response)
+}
+
+/**
+ * Search for recipients by query.
+ *
+ * @param query
+ * @param recipientTypeClass
+ * @param limit
+ * @param offset
+ */
+export async function searchRecipients(query: string, recipientTypeClass?: string, limit = 10, offset = 0): Promise {
+ const response = await axios.get(sharingUrl('/recipients'), {
+ params: { query, recipientTypeClass, limit, offset },
+ })
+ return unwrapOcs(response)
+}
+
+/**
+ * Delete a share permanently.
+ *
+ * @param shareId
+ */
+export async function deleteShare(shareId: string): Promise {
+ await axios.delete(sharingUrl(`/share/${shareId}`))
+}
diff --git a/lib/dialog/components/InlineToggleField.spec.ts b/lib/dialog/components/InlineToggleField.spec.ts
new file mode 100644
index 0000000..4f13e9b
--- /dev/null
+++ b/lib/dialog/components/InlineToggleField.spec.ts
@@ -0,0 +1,117 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import type { VueWrapper } from '@vue/test-utils'
+
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import { h, nextTick } from 'vue'
+import InlineToggleField from './InlineToggleField.vue'
+
+const INACTIVE = '.inline-toggle-field__slot--inactive'
+const SLOT = '.inline-toggle-field__slot'
+const TOGGLE_INPUT = '.inline-toggle-field__toggle input'
+
+/**
+ * Mount with a focusable input in the default slot.
+ *
+ * @param props Component props
+ */
+function mountField(props: { modelValue: boolean, label?: string, longText?: boolean }): VueWrapper {
+ return mount(InlineToggleField, {
+ props: { label: 'Note', ...props },
+ slots: {
+ default: (slotProps: { inputId: string }) => h('input', { id: slotProps.inputId, class: 'slot-input' }),
+ },
+ attachTo: document.body,
+ })
+}
+
+describe('InlineToggleField', () => {
+ it('renders the slot content and the toggle switch', () => {
+ const wrapper = mountField({ modelValue: true })
+ expect(wrapper.find('.slot-input').exists()).toBe(true)
+ expect(wrapper.find(TOGGLE_INPUT).exists()).toBe(true)
+ })
+
+ it('marks the slot inactive when disabled', () => {
+ const wrapper = mountField({ modelValue: false })
+ expect(wrapper.find(INACTIVE).exists()).toBe(true)
+ })
+
+ it('does not mark the slot inactive when enabled', () => {
+ const wrapper = mountField({ modelValue: true })
+ expect(wrapper.find(INACTIVE).exists()).toBe(false)
+ })
+
+ it('emits update:modelValue(true) when toggled on', async () => {
+ const wrapper = mountField({ modelValue: false })
+ await wrapper.find(TOGGLE_INPUT).setValue(true)
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
+ })
+
+ it('emits update:modelValue(false) when toggled off', async () => {
+ const wrapper = mountField({ modelValue: true })
+ await wrapper.find(TOGGLE_INPUT).setValue(false)
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
+ })
+
+ it('enables by clicking the inactive slot', async () => {
+ const wrapper = mountField({ modelValue: false })
+ await wrapper.find(SLOT).trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
+ })
+
+ it('does not emit when clicking the active slot', async () => {
+ const wrapper = mountField({ modelValue: true })
+ await wrapper.find(SLOT).trigger('click')
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ })
+
+ it('enables when clicking a field element inside the inactive slot', async () => {
+ const wrapper = mountField({ modelValue: false })
+ // A click on the field bubbles to the slot handler and enables it
+ await wrapper.find('.slot-input').trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
+ })
+
+ // Note: with a *disabled* inner field, the click reaching the slot relies on
+ // the `pointer-events: none` reroute, which happy-dom does not simulate (a
+ // disabled control fires no click). That path is covered by browser tests.
+
+ it('does not emit when clicking a field element inside the active slot', async () => {
+ const wrapper = mountField({ modelValue: true })
+ await wrapper.find('.slot-input').trigger('click')
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ })
+
+ it('focuses the first focusable slot element after enabling', async () => {
+ const wrapper = mountField({ modelValue: false })
+ await wrapper.find(TOGGLE_INPUT).setValue(true)
+ await nextTick()
+ expect(document.activeElement).toBe(wrapper.find('.slot-input').element)
+ })
+
+ it('does not throw when enabling with no focusable slot element', async () => {
+ const wrapper = mount(InlineToggleField, {
+ props: { label: 'Note', modelValue: false },
+ slots: { default: () => h('span', 'just text') },
+ attachTo: document.body,
+ })
+ await expect(wrapper.find(TOGGLE_INPUT).setValue(true)).resolves.not.toThrow()
+ })
+
+ it('adds the long-text class on the toggle when longText is set', () => {
+ const wrapper = mountField({ modelValue: true, longText: true })
+ expect(wrapper.find('.inline-toggle-field__toggle--long-text').exists()).toBe(true)
+ })
+
+ it('exposes the group aria-label and wires the slot input id', () => {
+ const wrapper = mountField({ modelValue: true, label: 'Expiration' })
+ expect(wrapper.find('[role="group"]').attributes('aria-label')).toBe('Expiration')
+ const slotId = wrapper.find('.slot-input').attributes('id')
+ expect(slotId).toBeTruthy()
+ expect(wrapper.find(TOGGLE_INPUT).attributes('aria-controls')).toBe(slotId)
+ })
+})
diff --git a/lib/dialog/components/InlineToggleField.vue b/lib/dialog/components/InlineToggleField.vue
new file mode 100644
index 0000000..3f295b9
--- /dev/null
+++ b/lib/dialog/components/InlineToggleField.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/dialog/components/PropertyField.spec.ts b/lib/dialog/components/PropertyField.spec.ts
new file mode 100644
index 0000000..9b8c8eb
--- /dev/null
+++ b/lib/dialog/components/PropertyField.spec.ts
@@ -0,0 +1,225 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import type { OCSResponse } from '@nextcloud/typings/ocs'
+import type { VueWrapper } from '@vue/test-utils'
+import type { Share } from '../api/share.ts'
+import type { SharingProperty } from '../types/api.ts'
+
+import { mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import PropertyField from './PropertyField.vue'
+
+// PropertyField only calls share.setProperty(); a duck-typed Share is enough.
+const mockedUpdate = vi.fn().mockResolvedValue(undefined)
+const shareMock = { setProperty: mockedUpdate } as unknown as Share
+
+const PROPERTY_CLASS = 'OCP\\Sharing\\Property\\LabelSharePropertyType'
+
+function property(overrides: Partial = {}): SharingProperty {
+ return {
+ class: PROPERTY_CLASS,
+ display_name: 'Label',
+ hint: null,
+ priority: 0,
+ required: false,
+ value: null,
+ type: 'string',
+ min_length: 3,
+ max_length: 64,
+ ...overrides,
+ }
+}
+
+function mountField(overrides: Partial = {}): VueWrapper {
+ return mount(PropertyField, {
+ props: { property: property(overrides), share: shareMock },
+ attachTo: document.body,
+ })
+}
+
+/**
+ * Build an axios-like OCS error carrying a backend message.
+ *
+ * @param message The OCS `data` message
+ */
+function ocsError(message: string): { response: { data: OCSResponse } } {
+ return {
+ response: {
+ data: {
+ ocs: {
+ data: message,
+ meta: {
+ status: 'failure',
+ statuscode: 400,
+ message: undefined,
+ totalitems: undefined,
+ itemsperpage: undefined,
+ },
+ },
+ },
+ },
+ }
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ mockedUpdate.mockClear()
+ mockedUpdate.mockResolvedValue(undefined)
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('PropertyField rendering', () => {
+ it('renders a text field for a short string property', () => {
+ const wrapper = mountField({ max_length: 64 })
+ expect(wrapper.find('input[type="text"]').exists()).toBe(true)
+ expect(wrapper.find('textarea').exists()).toBe(false)
+ })
+
+ it('renders a textarea for a long string property', () => {
+ const wrapper = mountField({ max_length: 500 })
+ expect(wrapper.find('textarea').exists()).toBe(true)
+ })
+
+ it('renders a switch for a boolean property', () => {
+ const wrapper = mountField({ type: 'boolean', min_length: null, max_length: null })
+ // TODO: assert on input[role="switch"] once @nextcloud/vue > 9.9.0 is released
+ expect(wrapper.find('.property-field__input-boolean input[type="checkbox"]').exists()).toBe(true)
+ })
+
+ it('renders a datetime input for a date property (full UTC, not midnight)', () => {
+ const wrapper = mountField({ type: 'date', min_length: null, max_length: null })
+ expect(wrapper.find('input[type="datetime-local"]').exists()).toBe(true)
+ })
+
+ it('renders a password field for a password property', () => {
+ const wrapper = mountField({ type: 'password', min_length: null, max_length: null })
+ expect(wrapper.find('.property-field__input-password input').exists()).toBe(true)
+ })
+
+ it('shows a hint note under the field when the backend provides one', () => {
+ const wrapper = mountField({ hint: 'Recipients will be notified' })
+ const hint = wrapper.find('.property-field__hint')
+ expect(hint.exists()).toBe(true)
+ expect(hint.text()).toContain('Recipients will be notified')
+ })
+
+ it('renders no hint note when the property has none', () => {
+ const wrapper = mountField({ hint: null })
+ expect(wrapper.find('.property-field__hint').exists()).toBe(false)
+ })
+
+ it('applies native constraint attributes from the property', () => {
+ const wrapper = mountField({ required: true, min_length: 3, max_length: 64 })
+ const input = wrapper.find('input[type="text"]').element as HTMLInputElement
+ expect(input.required).toBe(true)
+ expect(input.minLength).toBe(3)
+ expect(input.maxLength).toBe(64)
+ })
+})
+
+describe('PropertyField persistence', () => {
+ it('does not dispatch before the debounce elapses', async () => {
+ const wrapper = mountField()
+ const input = wrapper.find('input[type="text"]')
+ vi.spyOn(input.element as HTMLInputElement, 'checkValidity').mockReturnValue(true)
+
+ await input.setValue('Hello')
+ expect(mockedUpdate).not.toHaveBeenCalled()
+ })
+
+ it('dispatches once after typing settles', async () => {
+ const wrapper = mountField()
+ const input = wrapper.find('input[type="text"]')
+ vi.spyOn(input.element as HTMLInputElement, 'checkValidity').mockReturnValue(true)
+
+ await input.setValue('Hello')
+ await vi.advanceTimersByTimeAsync(500)
+
+ expect(mockedUpdate).toHaveBeenCalledTimes(1)
+ expect(mockedUpdate).toHaveBeenCalledWith(PROPERTY_CLASS, 'Hello')
+ })
+
+ it('debounces rapid edits into a single dispatch with the latest value', async () => {
+ const wrapper = mountField()
+ const input = wrapper.find('input[type="text"]')
+ vi.spyOn(input.element as HTMLInputElement, 'checkValidity').mockReturnValue(true)
+
+ await input.setValue('H')
+ await input.setValue('He')
+ await input.setValue('Hey')
+ await vi.advanceTimersByTimeAsync(500)
+
+ expect(mockedUpdate).toHaveBeenCalledTimes(1)
+ expect(mockedUpdate).toHaveBeenCalledWith(PROPERTY_CLASS, 'Hey')
+ })
+
+ it('skips the request and reports validity when the input is invalid', async () => {
+ const wrapper = mountField()
+ const input = wrapper.find('input[type="text"]')
+ const el = input.element as HTMLInputElement
+ vi.spyOn(el, 'checkValidity').mockReturnValue(false)
+ const reportValidity = vi.spyOn(el, 'reportValidity').mockReturnValue(false)
+
+ await input.setValue('He')
+ await vi.advanceTimersByTimeAsync(500)
+
+ expect(mockedUpdate).not.toHaveBeenCalled()
+ expect(reportValidity).toHaveBeenCalled()
+ })
+
+ it('surfaces a backend rejection via setCustomValidity, not a thrown error', async () => {
+ const wrapper = mountField()
+ const input = wrapper.find('input[type="text"]')
+ const el = input.element as HTMLInputElement
+ vi.spyOn(el, 'checkValidity').mockReturnValue(true)
+ const setCustomValidity = vi.spyOn(el, 'setCustomValidity')
+ const reportValidity = vi.spyOn(el, 'reportValidity').mockReturnValue(false)
+ mockedUpdate.mockRejectedValueOnce(ocsError('Need at least 3 characters: He'))
+
+ await input.setValue('Hey')
+ await vi.advanceTimersByTimeAsync(500)
+
+ expect(setCustomValidity).toHaveBeenCalledWith('Need at least 3 characters: He')
+ expect(reportValidity).toHaveBeenCalled()
+ })
+
+ it('clears a previous custom validity on the next edit', async () => {
+ const wrapper = mountField()
+ const input = wrapper.find('input[type="text"]')
+ const el = input.element as HTMLInputElement
+ vi.spyOn(el, 'checkValidity').mockReturnValue(true)
+ const setCustomValidity = vi.spyOn(el, 'setCustomValidity')
+
+ await input.setValue('Hey')
+ expect(setCustomValidity).toHaveBeenCalledWith('')
+ })
+
+ it('persists an empty optional value as null to unset it', async () => {
+ const wrapper = mountField({ required: false })
+ const input = wrapper.find('input[type="text"]')
+ vi.spyOn(input.element as HTMLInputElement, 'checkValidity').mockReturnValue(true)
+
+ await input.setValue('Hello')
+ await input.setValue('')
+ await vi.advanceTimersByTimeAsync(500)
+
+ expect(mockedUpdate).toHaveBeenCalledTimes(1)
+ expect(mockedUpdate).toHaveBeenCalledWith(PROPERTY_CLASS, null)
+ })
+
+ it('dispatches string booleans for a boolean property', async () => {
+ const wrapper = mountField({ type: 'boolean', min_length: null, max_length: null })
+ const checkbox = wrapper.find('input[type="checkbox"]')
+ vi.spyOn(checkbox.element as HTMLInputElement, 'checkValidity').mockReturnValue(true)
+
+ await checkbox.setValue(true)
+ await vi.advanceTimersByTimeAsync(500)
+
+ expect(mockedUpdate).toHaveBeenCalledWith(PROPERTY_CLASS, 'true')
+ })
+})
diff --git a/lib/dialog/components/PropertyField.vue b/lib/dialog/components/PropertyField.vue
new file mode 100644
index 0000000..c6f17d4
--- /dev/null
+++ b/lib/dialog/components/PropertyField.vue
@@ -0,0 +1,258 @@
+
+
+