Skip to content

Commit 0895ae9

Browse files
authored
Merge pull request #1306 from nextcloud-libraries/feat/sidebar-api-v2
feat(sidebar): provide a proxy for the files sidebar
2 parents 635ae2f + 570a190 commit 0895ae9

5 files changed

Lines changed: 261 additions & 160 deletions

File tree

__tests__/sidebar/sidebarTab.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import type { IFolder, INode, IView } from '../../lib/index.ts'
77

88
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'
9-
import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar'
9+
import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar/SidebarTab.ts'
1010
// missing in JSDom but supported by every browser!
1111
import 'css.escape'
1212

lib/sidebar/Sidebar.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { ISidebarContext, ISidebarTab } from './SidebarTab.ts'
7+
8+
import { getSidebarTabs, registerSidebarTab } from './SidebarTab.ts'
9+
10+
export interface ISidebar {
11+
/**
12+
* If the files sidebar can currently be accessed.
13+
* Registering tabs also works if the sidebar is currently not available.
14+
*/
15+
readonly available: boolean
16+
17+
/**
18+
* The current open state of the sidebar
19+
*/
20+
readonly open: boolean
21+
22+
/**
23+
* Open or close the sidebar
24+
*
25+
* @param open - The new open state
26+
*/
27+
setOpen(open: boolean): void
28+
29+
/**
30+
* Register a new sidebar tab
31+
*
32+
* @param tab - The sidebar tab to register
33+
*/
34+
registerTab(tab: ISidebarTab): void
35+
36+
/**
37+
* Get all registered sidebar tabs.
38+
* If a node is passed only the enabled tabs are retrieved.
39+
*/
40+
getTabs(context?: ISidebarContext): ISidebarTab[]
41+
}
42+
43+
/**
44+
* This is just a proxy allowing an arbitrary `@nextcloud/files` library version to access the defined interface of the sidebar.
45+
* By proxying this instead of providing the implementation here we ensure that if apps use different versions of the library,
46+
* we do not end up with version conflicts between them.
47+
*
48+
* If we add new properties they just will be available in new library versions.
49+
* If we decide to do a breaking change we can either add compatibility wrappers in the implementation in the files app.
50+
*/
51+
class SidebarProxy implements ISidebar {
52+
53+
get available(): boolean {
54+
return !!window.OCA?.Files?.Sidebar
55+
}
56+
57+
get open(): boolean {
58+
return !!window.OCA?.Files?.Sidebar?.state.file
59+
}
60+
61+
setOpen(open: boolean): void {
62+
if (open) {
63+
window.OCA?.Files?.Sidebar?.open()
64+
} else {
65+
window.OCA?.Files?.Sidebar?.close()
66+
}
67+
}
68+
69+
setActiveTab(tabId: string): void {
70+
window.OCA?.Files?.Sidebar?.setActiveTab(tabId)
71+
}
72+
73+
registerTab(tab: ISidebarTab): void {
74+
registerSidebarTab(tab)
75+
}
76+
77+
getTabs(context?: ISidebarContext): ISidebarTab[] {
78+
const tabs = getSidebarTabs()
79+
if (context) {
80+
return tabs.filter((tab) => tab.enabled(context))
81+
}
82+
return tabs
83+
}
84+
85+
}
86+
87+
/**
88+
* Get a reference to the files sidebar.
89+
*/
90+
export function getSidebar(): ISidebar {
91+
return new SidebarProxy()
92+
}

lib/sidebar/SidebarTab.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { IView } from '../navigation/view.ts'
7+
import type { IFolder, INode } from '../node/index.ts'
8+
9+
import isSvg from 'is-svg'
10+
import logger from '../utils/logger.ts'
11+
12+
export interface ISidebarContext {
13+
/**
14+
* The active node in the sidebar
15+
*/
16+
node: INode
17+
18+
/**
19+
* The current open folder in the files app
20+
*/
21+
folder: IFolder
22+
23+
/**
24+
* The currently active view
25+
*/
26+
view: IView
27+
}
28+
29+
/**
30+
* This component describes the custom web component that should be registered for a sidebar tab.
31+
*
32+
* @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components
33+
* @see https://vuejs.org/guide/extras/web-components#building-custom-elements-with-vue
34+
*/
35+
export interface SidebarComponent extends HTMLElement, ISidebarContext {
36+
/**
37+
* This method is called by the files app if the sidebar tab state changes.
38+
*
39+
* @param active - The new active state
40+
*/
41+
setActive(active: boolean): Promise<void>
42+
}
43+
44+
/**
45+
* Implementation of a custom sidebar tab within the files app.
46+
*/
47+
export interface ISidebarTab {
48+
/**
49+
* Unique id of the sidebar tab.
50+
* This has to conform to the HTML id attribute specification.
51+
*/
52+
id: string
53+
54+
/**
55+
* The localized name of the sidebar tab.
56+
*/
57+
displayName: string
58+
59+
/**
60+
* The icon, as SVG, of the sidebar tab.
61+
*/
62+
iconSvgInline: string
63+
64+
/**
65+
* The order of this tab.
66+
* Use a low number to make this tab ordered in front.
67+
*/
68+
order: number
69+
70+
/**
71+
* The tag name of the web component.
72+
* The web component must already be registered under that tag name with `CustomElementRegistry.define()`.
73+
*
74+
* To avoid name clashes the name has to start with your appid (e.g. `your_app`).
75+
* So in addition with the web component naming rules a good name would be `your_app-files-sidebar-tab`.
76+
*/
77+
tagName: string
78+
79+
/**
80+
* Callback to check if the sidebar tab should be shown for the selected node.
81+
*
82+
* @param context - The current context of the files app
83+
*/
84+
enabled: (context: ISidebarContext) => boolean
85+
}
86+
87+
/**
88+
* Register a new sidebar tab for the files app.
89+
*
90+
* @param tab - The sidebar tab to register
91+
* @throws If the provided tab is not a valid sidebar tab and thus cannot be registered.
92+
*/
93+
export function registerSidebarTab(tab: ISidebarTab): void {
94+
validateSidebarTab(tab)
95+
96+
window._nc_files_sidebar_tabs ??= new Map<string, ISidebarTab>()
97+
if (window._nc_files_sidebar_tabs.has(tab.id)) {
98+
logger.warn(`Sidebar tab with id "${tab.id}" already registered. Skipping.`)
99+
return
100+
}
101+
window._nc_files_sidebar_tabs.set(tab.id, tab)
102+
logger.debug(`New sidebar tab with id "${tab.id}" registered.`)
103+
}
104+
105+
/**
106+
* Get all currently registered sidebar tabs.
107+
*/
108+
export function getSidebarTabs(): ISidebarTab[] {
109+
if (window._nc_files_sidebar_tabs) {
110+
return [...window._nc_files_sidebar_tabs.values()]
111+
}
112+
return []
113+
}
114+
115+
/**
116+
* Check if a given sidebar tab objects implements all necessary fields.
117+
*
118+
* @param tab - The sidebar tab to validate
119+
*/
120+
function validateSidebarTab(tab: ISidebarTab): void {
121+
if (typeof tab !== 'object') {
122+
throw new Error('Sidebar tab is not an object')
123+
}
124+
125+
if (!tab.id || (typeof tab.id !== 'string') || tab.id !== CSS.escape(tab.id)) {
126+
throw new Error('Sidebar tabs need to have an id conforming to the HTML id attribute specifications')
127+
}
128+
129+
if (!tab.tagName || typeof tab.tagName !== 'string') {
130+
throw new Error('Sidebar tabs need to have the tagName name set')
131+
}
132+
133+
if (!tab.tagName.match(/^[a-z][a-z0-9-_]+$/)) {
134+
throw new Error('Sidebar tabs tagName name is invalid')
135+
}
136+
137+
if (!tab.displayName || typeof tab.displayName !== 'string') {
138+
throw new Error('Sidebar tabs need to have a name set')
139+
}
140+
141+
if (typeof tab.iconSvgInline !== 'string' || !isSvg(tab.iconSvgInline)) {
142+
throw new Error('Sidebar tabs need to have an valid SVG icon')
143+
}
144+
145+
if (typeof tab.order !== 'number') {
146+
throw new Error('Sidebar tabs need to have a numeric order set')
147+
}
148+
149+
if (typeof tab.enabled !== 'function') {
150+
throw new Error('Sidebar tabs need to have an "enabled" method')
151+
}
152+
153+
// now check the custom element constructor
154+
const tagConstructor = window.customElements.get(tab.tagName)
155+
if (!tagConstructor) {
156+
throw new Error('Sidebar tab element not registered')
157+
}
158+
159+
if (!('setActive' in tagConstructor.prototype)) {
160+
// we cannot check properties like `node` or `view` because those are not necessarily defined in the prototype.
161+
throw new Error('Sidebar tab elements must have the `setActive` method')
162+
}
163+
}

0 commit comments

Comments
 (0)