fix(shell): move subnav/tabbar inside <main> and unify pill styles

Subnav and tabbar were rendered outside <main id="app">, causing them
to be hidden behind the fixed header. Move them inside <main>, add
sticky positioning (top: 92px), and consolidate pill CSS into a shared
.rapp-nav-pill class. Also refactors tabbar to use URL subpaths instead
of ?tab= query params.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 01:07:11 -07:00
parent a658b20fb1
commit b60c0f565e
1 changed files with 35 additions and 50 deletions

View File

@ -63,8 +63,12 @@ export interface ShellOptions {
enabledModules?: string[] | null;
/** Whether this space has client-side encryption enabled */
spaceEncrypted?: boolean;
/** Optional tab bar rendered below the subnav. Uses ?tab= query params. */
/** Optional tab bar rendered below the subnav. */
tabs?: Array<{ id: string; label: string; icon?: string }>;
/** Active tab ID (matched from URL path by server). First tab if omitted. */
activeTab?: string;
/** Base path for tab links (default: /{space}/{moduleId}). Set to e.g. "/{space}/rnetwork/crm" for sub-pages. */
tabBasePath?: string;
}
export function renderShell(opts: ShellOptions): string {
@ -169,8 +173,6 @@ export function renderShell(opts: ShellOptions): string {
<button class="rapp-info-btn" id="rapp-info-btn" title="About this rApp"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></button>
</rstack-tab-bar>
</div>
${renderModuleSubNav(moduleId, spaceSlug, visibleModules)}
${opts.tabs ? renderTabBar(opts.tabs) : ''}
<div id="rapp-info-panel" class="rapp-info-panel" style="display:none">
<div class="rapp-info-panel__header">
<span class="rapp-info-panel__title">About</span>
@ -180,6 +182,8 @@ export function renderShell(opts: ShellOptions): string {
</div>
<rstack-user-dashboard space="${escapeAttr(spaceSlug)}" style="display:none"></rstack-user-dashboard>
<main id="app"${moduleId === "rspace" ? ' class="canvas-layout"' : ''}>
${renderModuleSubNav(moduleId, spaceSlug, visibleModules)}
${opts.tabs ? renderTabBar(opts.tabs, opts.activeTab, opts.tabBasePath || `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`) : ''}
${body}
</main>
<rstack-collab-overlay module-id="${escapeAttr(moduleId)}" space="${escapeAttr(spaceSlug)}"></rstack-collab-overlay>
@ -1261,9 +1265,15 @@ const SUBNAV_CSS = `
border-bottom: 1px solid var(--rs-border);
background: var(--rs-bg-surface);
scrollbar-width: none;
position: sticky;
top: 92px;
z-index: 100;
}
.rapp-subnav::-webkit-scrollbar { display: none; }
.rapp-subnav__pill {
@media (max-width: 640px) {
.rapp-subnav { top: 0; }
}
.rapp-nav-pill {
padding: 0.3125rem 0.75rem;
border-radius: 999px;
font-size: 0.8125rem;
@ -1273,8 +1283,8 @@ const SUBNAV_CSS = `
border: 1px solid transparent;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.rapp-subnav__pill:hover { color: var(--rs-text-primary); background: var(--rs-bg-surface-raised); }
.rapp-subnav__pill--active {
.rapp-nav-pill:hover { color: var(--rs-text-primary); background: var(--rs-bg-surface-raised); }
.rapp-nav-pill--active {
color: var(--rs-text-primary);
background: var(--rs-bg-surface-raised);
border-color: var(--rs-border);
@ -1310,77 +1320,52 @@ function renderModuleSubNav(moduleId: string, spaceSlug: string, modules: Module
const base = `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`;
const pills = [
`<a class="rapp-subnav__pill" href="${base}" data-subnav-root>${escapeHtml(mod.name)}</a>`,
`<a class="rapp-nav-pill" href="${base}" data-subnav-root>${escapeHtml(mod.name)}</a>`,
...items.map(it =>
`<a class="rapp-subnav__pill" href="${base}/${escapeAttr(it.path)}">${it.icon ? escapeHtml(it.icon) + ' ' : ''}${escapeHtml(it.label)}</a>`
`<a class="rapp-nav-pill" href="${base}/${escapeAttr(it.path)}">${it.icon ? escapeHtml(it.icon) + ' ' : ''}${escapeHtml(it.label)}</a>`
),
];
return `<nav class="rapp-subnav" id="rapp-subnav">${pills.join('')}</nav>
<script>(function(){var ps=document.querySelectorAll('.rapp-subnav__pill'),p=location.pathname.replace(/\\/$/,'');var matched=false;ps.forEach(function(a){var h=a.getAttribute('href');if(h&&h===p){a.classList.add('rapp-subnav__pill--active');matched=true}else if(h&&p.startsWith(h+'/')&&!a.hasAttribute('data-subnav-root')){a.classList.add('rapp-subnav__pill--active');matched=true}});if(!matched){var root=document.querySelector('[data-subnav-root]');if(root)root.classList.add('rapp-subnav__pill--active')}})()</script>`;
<script>(function(){var ps=document.querySelectorAll('#rapp-subnav .rapp-nav-pill'),p=location.pathname.replace(/\\/$/,'');var matched=false;ps.forEach(function(a){var h=a.getAttribute('href');if(h&&h===p){a.classList.add('rapp-nav-pill--active');matched=true}else if(h&&p.startsWith(h+'/')&&!a.hasAttribute('data-subnav-root')){a.classList.add('rapp-nav-pill--active');matched=true}});if(!matched){var root=document.querySelector('[data-subnav-root]');if(root)root.classList.add('rapp-nav-pill--active')}})()</script>`;
}
// ── rApp tab bar (in-page tabs via ?tab= query params) ──
// ── rApp tab bar (in-page tabs via URL subpaths) ──
const TABBAR_CSS = `
.rapp-tabbar {
display: flex;
gap: 0.375rem;
padding: 0.25rem 1rem;
padding: 0.375rem 1rem;
overflow-x: auto;
border-bottom: 1px solid var(--rs-border);
background: var(--rs-bg-surface);
scrollbar-width: none;
position: sticky;
top: 92px;
z-index: 100;
}
.rapp-tabbar::-webkit-scrollbar { display: none; }
.rapp-tabbar__pill {
padding: 0.25rem 0.625rem;
border-radius: 999px;
font-size: 0.75rem;
white-space: nowrap;
color: var(--rs-text-muted);
text-decoration: none;
border: 1px solid transparent;
cursor: pointer;
background: none;
font-family: inherit;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.rapp-tabbar__pill:hover { color: var(--rs-text-primary); background: var(--rs-bg-surface-raised); }
.rapp-tabbar__pill--active {
color: var(--rs-text-primary);
background: var(--rs-bg-surface-raised);
border-color: var(--rs-border);
font-weight: 500;
@media (max-width: 640px) {
.rapp-tabbar { top: 0; }
}
`;
function renderTabBar(tabs: Array<{ id: string; label: string; icon?: string }>): string {
function renderTabBar(tabs: Array<{ id: string; label: string; icon?: string }>, activeTab: string | undefined, basePath: string): string {
if (tabs.length === 0) return '';
const pills = tabs.map(t =>
`<button class="rapp-tabbar__pill" data-tab-id="${escapeAttr(t.id)}">${t.icon ? escapeHtml(t.icon) + ' ' : ''}${escapeHtml(t.label)}</button>`
).join('');
const active = activeTab || tabs[0]?.id || '';
const base = basePath;
const pills = tabs.map(t => {
const isActive = t.id === active;
return `<a class="rapp-nav-pill${isActive ? ' rapp-nav-pill--active' : ''}" href="${base}/${escapeAttr(t.id)}" data-tab-id="${escapeAttr(t.id)}">${t.icon ? escapeHtml(t.icon) + ' ' : ''}${escapeHtml(t.label)}</a>`;
}).join('');
return `<nav class="rapp-tabbar" id="rapp-tabbar">${pills}</nav>
<script>(function(){
var pills = document.querySelectorAll('.rapp-tabbar__pill');
var params = new URLSearchParams(location.search);
var active = params.get('tab') || pills[0]?.dataset.tabId || '';
pills.forEach(function(btn) {
if (btn.dataset.tabId === active) btn.classList.add('rapp-tabbar__pill--active');
btn.addEventListener('click', function() {
var id = btn.dataset.tabId;
pills.forEach(function(p) { p.classList.remove('rapp-tabbar__pill--active'); });
btn.classList.add('rapp-tabbar__pill--active');
var u = new URL(location.href);
u.searchParams.set('tab', id);
history.replaceState(null, '', u);
document.dispatchEvent(new CustomEvent('rapp-tab-change', { detail: { tab: id } }));
});
});
// Dispatch initial tab on load so components can read it
document.dispatchEvent(new CustomEvent('rapp-tab-change', { detail: { tab: active } }));
document.dispatchEvent(new CustomEvent('rapp-tab-change', { detail: { tab: '${escapeAttr(active)}' } }));
})()</script>`;
}