diff --git a/.eslintrc.json b/.eslintrc.json
index 64afe8fb6..8a95224bc 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,5 +1,13 @@
{
"extends": [
"@nextcloud/eslint-config/typescript"
+ ],
+ "overrides": [
+ {
+ "files": ["**.spec.*"],
+ "rules": {
+ "no-console": "off"
+ }
+ }
]
}
diff --git a/__tests__/sidebar/sidebarTab.spec.ts b/__tests__/sidebar/sidebarTab.spec.ts
new file mode 100644
index 000000000..503a907d3
--- /dev/null
+++ b/__tests__/sidebar/sidebarTab.spec.ts
@@ -0,0 +1,172 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { IFolder, INode, IView } from '../../lib/index.ts'
+
+import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'
+import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar'
+// missing in JSDom but supported by every browser!
+import 'css.escape'
+
+class SidebarTabMock extends HTMLElement {
+
+ node?: INode
+ folder?: IFolder
+ view?: IView
+
+ public setActive(active: boolean) {
+ console.log('setActive', active)
+ }
+
+}
+
+describe('Sidebar tabs', () => {
+ let getCustomElementsSpy: Mock
+
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ getCustomElementsSpy = vi.spyOn(window.customElements, 'get')
+ .mockImplementation(() => SidebarTabMock)
+ delete window._nc_files_sidebar_tabs
+ })
+
+ it('can register a tab', () => {
+ const tab = getExampleTab()
+
+ registerSidebarTab(tab)
+ expect(window._nc_files_sidebar_tabs).toBeInstanceOf(Map)
+ expect(window._nc_files_sidebar_tabs!.has(tab.id)).toBe(true)
+ expect(window._nc_files_sidebar_tabs!.get(tab.id)).toBe(tab)
+ })
+
+ it('can fetch empty list of sidebar tabs', () => {
+ expect(getSidebarTabs()).toBeInstanceOf(Array)
+ expect(getSidebarTabs()).toHaveLength(0)
+ })
+
+ it('can fetch list of sidebar tabs', () => {
+ registerSidebarTab(getExampleTab())
+ registerSidebarTab({ ...getExampleTab(), id: 'another-example' })
+
+ expect(getSidebarTabs()).toBeInstanceOf(Array)
+ expect(getSidebarTabs()).toHaveLength(2)
+ })
+
+ it('only registeres same id once', () => {
+ const consoleSpy = vi.spyOn(console, 'warn')
+ consoleSpy.mockImplementationOnce(() => {})
+
+ registerSidebarTab(getExampleTab())
+ registerSidebarTab(getExampleTab())
+ expect(consoleSpy).toHaveBeenCalledOnce()
+ expect(getSidebarTabs()).toHaveLength(1)
+ })
+
+ describe('Tab validation', () => {
+ it('fails with an invalid parameter', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab(getExampleTab),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab is not an object]')
+ })
+
+ it('fails with missing id', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), id: undefined }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an id conforming to the HTML id attribute specifications]')
+ })
+
+ it('fails with non conforming id', () => {
+ expect(
+ () => registerSidebarTab({ ...getExampleTab(), id: 'this is invalid' }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an id conforming to the HTML id attribute specifications]')
+ })
+
+ it('fails with missing tagName name', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), tagName: undefined }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have the tagName name set]')
+ })
+
+ it('fails with invalid tagName name', () => {
+ expect(() => registerSidebarTab({ ...getExampleTab(), tagName: 'MyAppSidebarTab' }))
+ .toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs tagName name is invalid]')
+ })
+
+ it('fails with non registered element', () => {
+ getCustomElementsSpy.mockImplementationOnce(() => undefined)
+ expect(() => registerSidebarTab(getExampleTab())).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab element not registered]')
+ })
+
+ it('fails with invalid custom element', () => {
+ getCustomElementsSpy.mockImplementationOnce(() => HTMLElement)
+ expect(() => registerSidebarTab(getExampleTab())).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab elements must have the `setActive` method]')
+ })
+
+ it('fails with missing name', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), displayName: undefined }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a name set]')
+ })
+
+ it('fails with invalid name', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), displayName: 1234 }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a name set]')
+ })
+
+ it('fails with missing icon', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), iconSvgInline: undefined }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]')
+ })
+
+ it('fails with invalid SVG icon', () => {
+ expect(
+ () => registerSidebarTab({ ...getExampleTab(), iconSvgInline: 'icon-group' }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]')
+ })
+
+ it('fails with missing order', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), order: undefined }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a numeric order set]')
+ })
+
+ it('fails with invalid order', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), order: '3' }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a numeric order set]')
+ })
+
+ it('fails with missing "enabled" method', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), enabled: undefined }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an "enabled" method]')
+ })
+ })
+})
+
+/**
+ * Get a very basic mock of a sidebar tab
+ */
+function getExampleTab(): ISidebarTab {
+ return {
+ id: 'example-tab',
+ displayName: 'Example',
+ tagName: 'example_app-files-sidebar-tab',
+ enabled: vi.fn(),
+ iconSvgInline: '',
+ order: 0,
+ }
+}
diff --git a/__tests__/view.spec.ts b/__tests__/view.spec.ts
index 04291c128..969c6a015 100644
--- a/__tests__/view.spec.ts
+++ b/__tests__/view.spec.ts
@@ -182,6 +182,9 @@ describe('View creation', () => {
})
})
+/**
+ * Creates a mock View and its associated Folder for testing purposes.
+ */
export function mockView() {
const folder = new Folder({
source: 'https://example.org/dav/files/admin/',
diff --git a/lib/index.ts b/lib/index.ts
index 6327ae270..69c32d997 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -12,4 +12,5 @@ export * from './navigation/index.ts'
export * from './newMenu/index.ts'
export * from './node/index.ts'
export * from './permissions.ts'
+export * from './sidebar/index.ts'
export * from './utils/index.ts'
diff --git a/lib/sidebar/index.ts b/lib/sidebar/index.ts
new file mode 100644
index 000000000..0ef3afe2f
--- /dev/null
+++ b/lib/sidebar/index.ts
@@ -0,0 +1,163 @@
+/*
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { IView } from '../navigation/view.ts'
+import type { IFolder, INode } from '../node/index.ts'
+
+import isSvg from 'is-svg'
+import logger from '../utils/logger.ts'
+
+export interface ISidebarContext {
+ /**
+ * The active node in the sidebar
+ */
+ node: INode
+
+ /**
+ * The current open folder in the files app
+ */
+ folder: IFolder
+
+ /**
+ * The currently active view
+ */
+ view: IView
+}
+
+/**
+ * This component describes the custom web component that should be registered for a sidebar tab.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components
+ * @see https://vuejs.org/guide/extras/web-components#building-custom-elements-with-vue
+ */
+export interface SidebarComponent extends HTMLElement, ISidebarContext {
+ /**
+ * This method is called by the files app if the sidebar tab state changes.
+ *
+ * @param active - The new active state
+ */
+ setActive(active: boolean): Promise
+}
+
+/**
+ * Implementation of a custom sidebar tab within the files app.
+ */
+export interface ISidebarTab {
+ /**
+ * Unique id of the sidebar tab.
+ * This has to conform to the HTML id attribute specification.
+ */
+ id: string
+
+ /**
+ * The localized name of the sidebar tab.
+ */
+ displayName: string
+
+ /**
+ * The icon, as SVG, of the sidebar tab.
+ */
+ iconSvgInline: string
+
+ /**
+ * The order of this tab.
+ * Use a low number to make this tab ordered in front.
+ */
+ order: number
+
+ /**
+ * The tag name of the web component.
+ * The web component must already be registered under that tag name with `CustomElementRegistry.define()`.
+ *
+ * To avoid name clashes the name has to start with your appid (e.g. `your_app`).
+ * So in addition with the web component naming rules a good name would be `your_app-files-sidebar-tab`.
+ */
+ tagName: string
+
+ /**
+ * Callback to check if the sidebar tab should be shown for the selected node.
+ *
+ * @param context - The current context of the files app
+ */
+ enabled: (context: ISidebarContext) => boolean
+}
+
+/**
+ * Register a new sidebar tab for the files app.
+ *
+ * @param tab - The sidebar tab to register
+ * @throws If the provided tab is not a valid sidebar tab and thus cannot be registered.
+ */
+export function registerSidebarTab(tab: ISidebarTab): void {
+ validateSidebarTab(tab)
+
+ window._nc_files_sidebar_tabs ??= new Map()
+ if (window._nc_files_sidebar_tabs.has(tab.id)) {
+ logger.warn(`Sidebar tab with id "${tab.id}" already registered. Skipping.`)
+ return
+ }
+ window._nc_files_sidebar_tabs.set(tab.id, tab)
+ logger.debug(`New sidebar tab with id "${tab.id}" registered.`)
+}
+
+/**
+ * Get all currently registered sidebar tabs.
+ */
+export function getSidebarTabs(): ISidebarTab[] {
+ if (window._nc_files_sidebar_tabs) {
+ return [...window._nc_files_sidebar_tabs.values()]
+ }
+ return []
+}
+
+/**
+ * Check if a given sidebar tab objects implements all necessary fields.
+ *
+ * @param tab - The sidebar tab to validate
+ */
+function validateSidebarTab(tab: ISidebarTab): void {
+ if (typeof tab !== 'object') {
+ throw new Error('Sidebar tab is not an object')
+ }
+
+ if (!tab.id || (typeof tab.id !== 'string') || tab.id !== CSS.escape(tab.id)) {
+ throw new Error('Sidebar tabs need to have an id conforming to the HTML id attribute specifications')
+ }
+
+ if (!tab.tagName || typeof tab.tagName !== 'string') {
+ throw new Error('Sidebar tabs need to have the tagName name set')
+ }
+
+ if (!tab.tagName.match(/^[a-z][a-z0-9-_]+$/)) {
+ throw new Error('Sidebar tabs tagName name is invalid')
+ }
+
+ if (!tab.displayName || typeof tab.displayName !== 'string') {
+ throw new Error('Sidebar tabs need to have a name set')
+ }
+
+ if (typeof tab.iconSvgInline !== 'string' || !isSvg(tab.iconSvgInline)) {
+ throw new Error('Sidebar tabs need to have an valid SVG icon')
+ }
+
+ if (typeof tab.order !== 'number') {
+ throw new Error('Sidebar tabs need to have a numeric order set')
+ }
+
+ if (typeof tab.enabled !== 'function') {
+ throw new Error('Sidebar tabs need to have an "enabled" method')
+ }
+
+ // now check the custom element constructor
+ const tagConstructor = window.customElements.get(tab.tagName)
+ if (!tagConstructor) {
+ throw new Error('Sidebar tab element not registered')
+ }
+
+ if (!('setActive' in tagConstructor.prototype)) {
+ // we cannot check properties like `node` or `view` because those are not necessarily defined in the prototype.
+ throw new Error('Sidebar tab elements must have the `setActive` method')
+ }
+}
diff --git a/lib/window.d.ts b/lib/window.d.ts
index 32bb6a0cf..84b1c5007 100644
--- a/lib/window.d.ts
+++ b/lib/window.d.ts
@@ -13,9 +13,8 @@ import type {
NewMenu,
} from './index.ts'
-import type {
- DavProperty,
-} from './dav/index.ts'
+import type { DavProperty } from './dav/index.ts'
+import type { ISidebarTab } from './sidebar/index.ts'
export {}
@@ -30,6 +29,7 @@ declare global {
_nc_newfilemenu?: NewMenu
_nc_navigation?: Navigation
_nc_filelist_filters?: Map
+ _nc_files_sidebar_tabs?: Map
_oc_config?: {
forbidden_filenames_characters: string[]
diff --git a/package-lock.json b/package-lock.json
index b4378598d..a2b961597 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"@nextcloud/vite-config": "^2.5.2",
"@types/node": "^25.0.0",
"@vitest/coverage-istanbul": "^4.0.15",
+ "css.escape": "^1.5.1",
"fast-xml-parser": "^5.3.2",
"jsdom": "^27.3.0",
"tslib": "^2.8.1",
@@ -4705,6 +4706,13 @@
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
diff --git a/package.json b/package.json
index e59fb7ee6..5f1f1dcd3 100644
--- a/package.json
+++ b/package.json
@@ -73,6 +73,7 @@
"@nextcloud/vite-config": "^2.5.2",
"@types/node": "^25.0.0",
"@vitest/coverage-istanbul": "^4.0.15",
+ "css.escape": "^1.5.1",
"fast-xml-parser": "^5.3.2",
"jsdom": "^27.3.0",
"tslib": "^2.8.1",