Skip to content

Applications - add tabs for multiple views #2506

Open

Description

@RTechSn

Is your feature request related to a problem? Please describe.

Right now if you create an "app" from your table which has multiple views attached, the views are displayed ontop one another. It makes the app look confusing, if there is much data in them: you have to fully scroll past one view to see another.

Image

Describe the solution you'd like

Display each view on a separate "tab" (not browser tab, just an UI element).

Describe alternatives you've considered

Some template editor would be even better. A good example is FeePBX User Conlrol Panel.

Image

Additional context

No response

Activity

  1. added
    enhancementNew feature or request
    0. Needs triagePending approval or rejection. This issue is pending approval.
    on Apr 21, 2026
  2. Aveyron-RetD commented on Apr 22, 2026

    @Aveyron-RetD

    similar topic as #1913

  3. RTechSn commented on Apr 22, 2026

    @RTechSn
    Author

    #1913 fits "Describe alternatives you've considered", but demands much more development time than suggested in "Describe the solution you'd like"

  4. added
    help wantedExtra attention is needed
    and removed
    0. Needs triagePending approval or rejection. This issue is pending approval.
    on Apr 22, 2026
  5. blizzz commented on Apr 22, 2026

    @blizzz
    Member

    Thanks for your suggestion on the tabbed layout!

  6. Aveyron-RetD commented on Jul 16, 2026

    @Aveyron-RetD

    I put together two quick design concepts and tested both live in the browser — this is pure DOM/CSS injected on top of the existing render, not a real Vue component yet, just a way to compare directions before committing to one.

    Tab layout — tablesLayoutTabs()
    Image

    Card layout — tablesLayoutCards()
    Image

    You can try both yourself: open a context/application page with 2+ tables or views attached, paste the script below into your browser's dev console, and run tablesLayoutTabs(), tablesLayoutCards(), or tablesLayoutReset() to switch between them or revert to the current layout.

    /**
     * Nextcloud Tables — Context page layout prototype
     * Targets: src/pages/Context.vue  →  .main-context-view .resources > div > .resource
     *
     * HOW TO USE
     * 1. Open a Tables "Application"/context page that has 2+ tables or views on it.
     * 2. Open DevTools console, paste this whole file, hit enter once. Nothing
     *    happens yet — it just registers three functions on window.
     * 3. Try each layout:
     *      tablesLayoutTabs()      // tab strip, one resource visible at a time
     *      tablesLayoutCards()     // card picker grid, click a card to open it
     *      tablesLayoutReset()     // back to stock stacked layout
     *    You can call them repeatedly / switch between them freely.
     *
     * Nothing here touches Vue state — it's pure DOM/CSS manipulation layered on
     * top of the rendered output, so a Vue re-render (e.g. switching context via
     * the nav, or a row/column mutating) will blow it away. Re-run the function
     * you want after that happens. This is meant purely to compare the two
     * visual directions before either gets built as a real Vue component.
     */
    ;(() => {
    
    	const MARKER_ATTR = 'data-proto-nav'
    
    	function getResources() {
    		const container = document.querySelector('.main-context-view .resources')
    		if (!container) {
    			console.warn('[tables-layout-prototype] Could not find .main-context-view .resources — are you on a context/application page with resources on it?')
    			return null
    		}
    		const resources = Array.from(container.querySelectorAll(':scope > div > .resource'))
    		if (resources.length === 0) {
    			console.warn('[tables-layout-prototype] Found the container but no .resource elements inside it.')
    			return null
    		}
    		return { container, resources }
    	}
    
    	function getMeta(resourceEl) {
    		const h1 = resourceEl.querySelector('.row.first-row h1')
    		const title = h1 ? h1.textContent.trim() : '(untitled)'
    		const descEl = resourceEl.querySelector('.element-description')
    		const description = descEl ? descEl.textContent.trim() : ''
    		return { title, description }
    	}
    
    	const HIDDEN_ATTR = 'data-proto-hidden'
    
    	function clearInjectedNav(container) {
    		container.querySelectorAll(`[${MARKER_ATTR}]`).forEach(el => el.remove())
    	}
    
    	function hideEl(el) {
    		if (!el || el.hasAttribute(HIDDEN_ATTR)) return
    		el.setAttribute(HIDDEN_ATTR, '1')
    		el.dataset.protoPrevDisplay = el.style.display || ''
    		el.style.display = 'none'
    	}
    
    	function restoreHidden(root) {
    		root.querySelectorAll(`[${HIDDEN_ATTR}]`).forEach(el => {
    			el.style.display = el.dataset.protoPrevDisplay || ''
    			el.removeAttribute(HIDDEN_ATTR)
    			delete el.dataset.protoPrevDisplay
    		})
    	}
    
    	function resetVisibility(resources) {
    		resources.forEach(el => {
    			el.style.display = ''
    			el.style.margin = ''
    			el.classList.remove('proto-active-resource')
    		})
    	}
    
    	window.tablesLayoutReset = function tablesLayoutReset() {
    		const found = getResources()
    		if (!found) return
    		clearInjectedNav(found.container)
    		restoreHidden(found.container)
    		resetVisibility(found.resources)
    		console.info('[tables-layout-prototype] Reset to stock stacked layout.')
    	}
    
    	window.tablesLayoutTabs = function tablesLayoutTabs() {
    		const found = getResources()
    		if (!found) return
    		const { container, resources } = found
    
    		clearInjectedNav(container)
    		restoreHidden(container)
    		resetVisibility(resources)
    
    		// title is now conveyed by the tab label itself, so drop the in-resource
    		// title bar and keep only the description + the actual table/view
    		resources.forEach(el => hideEl(el.querySelector('.row.first-row')))
    
    		const nav = document.createElement('div')
    		nav.setAttribute(MARKER_ATTR, 'tabs')
    		nav.style.cssText = `
    			position: sticky;
    			top: 0;
    			z-index: 20;
    			display: flex;
    			gap: 4px;
    			padding: 8px 20px 0;
    			background: var(--color-main-background);
    			border-bottom: 1px solid var(--color-border);
    			overflow-x: auto;
    		`
    
    		const items = resources.map((el, i) => getMeta(el))
    
    		function activate(index) {
    			resources.forEach((el, i) => {
    				el.style.display = i === index ? '' : 'none'
    			})
    			Array.from(nav.children).forEach((btn, i) => {
    				const active = i === index
    				btn.style.borderBottomColor = active ? 'var(--color-primary-element)' : 'transparent'
    				btn.style.color = active ? 'var(--color-main-text)' : 'var(--color-text-maxcontrast)'
    				btn.style.fontWeight = active ? '600' : '400'
    			})
    		}
    
    		items.forEach((meta, i) => {
    			const btn = document.createElement('button')
    			btn.type = 'button'
    			btn.textContent = meta.title
    			btn.title = meta.description || meta.title
    			btn.style.cssText = `
    				appearance: none;
    				background: transparent;
    				border: none;
    				border-bottom: 2px solid transparent;
    				padding: 10px 14px;
    				font-size: 14px;
    				cursor: pointer;
    				white-space: nowrap;
    				border-radius: var(--border-radius) var(--border-radius) 0 0;
    			`
    			btn.addEventListener('mouseenter', () => {
    				if (btn.style.borderBottomColor === 'transparent') btn.style.background = 'var(--color-background-hover)'
    			})
    			btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent' })
    			btn.addEventListener('click', () => activate(i))
    			nav.appendChild(btn)
    		})
    
    		container.insertBefore(nav, container.firstChild)
    		activate(0)
    		console.info(`[tables-layout-prototype] Tab layout applied — ${items.length} tab(s).`)
    	}
    
    	window.tablesLayoutCards = function tablesLayoutCards() {
    		const found = getResources()
    		if (!found) return
    		const { container, resources } = found
    
    		clearInjectedNav(container)
    		restoreHidden(container)
    		resetVisibility(resources)
    
    		// title + description are both already shown on the card, so the
    		// opened resource should just be the table/view itself
    		resources.forEach(el => {
    			hideEl(el.querySelector('.row.first-row'))
    			hideEl(el.querySelector('.element-description'))
    		})
    
    		const grid = document.createElement('div')
    		grid.setAttribute(MARKER_ATTR, 'cards')
    		grid.style.cssText = `
    			position: sticky;
    			top: 0;
    			z-index: 20;
    			display: grid;
    			grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    			gap: 12px;
    			padding: 16px 20px;
    			background: var(--color-main-background);
    			border-bottom: 1px solid var(--color-border);
    		`
    
    		const items = resources.map(el => getMeta(el))
    
    		function activate(index) {
    			resources.forEach((el, i) => {
    				el.style.display = i === index ? '' : 'none'
    			})
    			Array.from(grid.children).forEach((card, i) => {
    				card.style.borderColor = i === index ? 'var(--color-primary-element)' : 'var(--color-border)'
    				card.style.background = i === index ? 'var(--color-primary-element-light)' : 'var(--color-main-background)'
    			})
    			// scroll the opened resource into view since the grid pushes it down
    			resources[index].scrollIntoView({ behavior: 'smooth', block: 'nearest' })
    		}
    
    		items.forEach((meta, i) => {
    			const card = document.createElement('div')
    			card.style.cssText = `
    				border: 2px solid var(--color-border);
    				border-radius: var(--border-radius-large, 12px);
    				padding: 14px;
    				cursor: pointer;
    				background: var(--color-main-background);
    				transition: border-color .1s ease, background .1s ease;
    			`
    			const title = document.createElement('div')
    			title.textContent = meta.title
    			title.style.cssText = 'font-weight: 600; font-size: 15px; margin-bottom: 4px;'
    			const desc = document.createElement('div')
    			desc.textContent = meta.description
    				? (meta.description.length > 80 ? meta.description.slice(0, 80) + '…' : meta.description)
    				: ''
    			desc.style.cssText = 'font-size: 12px; color: var(--color-text-maxcontrast); line-height: 1.3;'
    			card.appendChild(title)
    			card.appendChild(desc)
    			card.addEventListener('click', () => activate(i))
    			grid.appendChild(card)
    		})
    
    		container.insertBefore(grid, container.firstChild)
    		activate(0)
    		console.info(`[tables-layout-prototype] Card layout applied — ${items.length} card(s). Click a card to switch.`)
    	}
    
    	console.info('[tables-layout-prototype] Ready. Run tablesLayoutTabs(), tablesLayoutCards(), or tablesLayoutReset().')
    })()

    I'm leaning toward the card layout — it scales better once there are more than a couple of resources on a context, since the tab bar can get crowded while cards give each resource more room to show a meaningful preview of its title/description.

    Before I put together a Vibe coded PR: I would go for this to be a straight drop-in replacement for the current stacked layout (no setting, no toggle, just the new default).

    If cards is the preferred direction, I think it'd be worth reusing an existing card component/style already used in the tables app Nextcloud rather than inventing a new one — for example something close to the style used for adding a new table

    Image

    Happy to open a PR once there's agreement on direction and that this will be purely vibe coded.

  7. Aveyron-RetD commented on Jul 22, 2026

    @Aveyron-RetD
  8. taiebot commented on Sep 21, 2026

    @taiebot
    Contributor

    Ok this is not a mockup but a real implementation.
    I am struggling with vue, scrolling and positioning.
    Will try to submit this shortly

    Image Image
  9. taiebot commented on Sep 30, 2026

    @taiebot
    Contributor

    Mm will try to submit something at the end of the week end. I went against my own advice to implement this as a new default and now i am trying to add this with an option. But this adds migration steps and stuff 😢 . @enjeck Could i submit something as a draft if i do not manage to get the migration working ?

  10. taiebot commented on Oct 1, 2026

    @taiebot
    Contributor

    well here we go #3054

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions