mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
feat(web): unified mobile top bar with always-on workspace switcher (IDEA-1835) (#737)
Promote MobileContextBar from a detail-only back+title bar to the persistent mobile top bar shown on every screen inside a workspace. The collection/item name now reads the same everywhere and the workspace switcher (moved out of the bottom-nav sheet) is always visible for at-a-glance context and quick switching. - MobileContextBar: show on all workspace screens (was depth >= 2); gate the back affordance to non-root screens; embed WorkspaceSwitcher on the right, restyled onto --bg-tertiary and sized to fill the 44px bar. - WorkspaceSheet: drop the switcher card + inline list (now in the top bar); the "Workspace" bottom-nav slot keeps its label/avatar and Navigate + Collections. - Collection list: hide the now-duplicate in-page collection-name heading on mobile; the name lives in the bar.
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
<!--
|
||||
MobileContextBar — slim contextual top bar for detail screens (PLAN-1694
|
||||
Phase 2, TASK-1700).
|
||||
MobileContextBar — the unified mobile top bar (PLAN-1694 Phase 2,
|
||||
TASK-1700; generalized to all screens in IDEA-1835).
|
||||
|
||||
With the global mobile <TopBar /> retired inside workspaces, root/tab
|
||||
screens render full-height (no top chrome). Drill-in "detail" screens still
|
||||
need a back affordance + title — that's this bar. Shown only on mobile and
|
||||
only when the path is ≥2 segments deep past the workspace prefix (item
|
||||
detail `/coll/slug`, tag detail `/tags/tag`, playbook editor, …). Root tabs
|
||||
and collection lists (depth 0-1) have their own in-page headers.
|
||||
Originally a detail-only back+title bar. IDEA-1835 promoted it to a
|
||||
persistent top bar shown on EVERY mobile screen inside a workspace so the
|
||||
workspace switcher (moved here from the bottom nav) is always visible and
|
||||
the collection/item name reads the same everywhere. Layout is
|
||||
`[back?] [title] [workspace switcher]`:
|
||||
- back affordance shows only when there's a parent to return to (i.e.
|
||||
not on the workspace home/root, where `segments` is empty);
|
||||
- title is the page's own title (item ref → section → humanized last
|
||||
segment), so collection lists show the collection name and item
|
||||
details show the ref — no per-page in-page header needed;
|
||||
- the workspace switcher (name + chevron) opens a BottomSheet to switch.
|
||||
|
||||
Mounted in the workspace layout next to <BottomNav />. While shown it
|
||||
toggles `body.has-context-bar`, which app.css uses to pad .main-content top.
|
||||
@@ -19,6 +24,7 @@
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { titleStore } from '$lib/stores/title.svelte';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
import WorkspaceSwitcher from '$lib/components/layout/WorkspaceSwitcher.svelte';
|
||||
|
||||
// Count in-app navigations so goBack() knows whether history.back() stays
|
||||
// inside Pad. history.length is unreliable — a deep link opened from
|
||||
@@ -43,14 +49,22 @@
|
||||
let wsUsername = $derived(workspaceStore.current?.owner_username ?? '');
|
||||
let wsPrefix = $derived(wsUsername && wsSlug ? `/${wsUsername}/${wsSlug}` : '');
|
||||
|
||||
// Segments past the workspace prefix. depth ≥ 2 ⇒ a drill-in detail screen.
|
||||
// Segments past the workspace prefix. Empty ⇒ the workspace home/root.
|
||||
let segments = $derived.by(() => {
|
||||
const path = page.url.pathname;
|
||||
if (!wsPrefix || !path.startsWith(`${wsPrefix}/`)) return [];
|
||||
return path.slice(wsPrefix.length + 1).split('/').filter(Boolean);
|
||||
});
|
||||
let isDetail = $derived(segments.length >= 2);
|
||||
let show = $derived(uiStore.isMobile && isDetail);
|
||||
|
||||
// Shown on every mobile screen inside a workspace (IDEA-1835) — the home
|
||||
// (path === wsPrefix) plus any deeper route. The back affordance is gated
|
||||
// separately so the root screen doesn't offer a dead-end "back".
|
||||
let inWorkspace = $derived(
|
||||
!!wsPrefix &&
|
||||
(page.url.pathname === wsPrefix || page.url.pathname.startsWith(`${wsPrefix}/`))
|
||||
);
|
||||
let show = $derived(uiStore.isMobile && inWorkspace);
|
||||
let canGoBack = $derived(segments.length >= 1);
|
||||
|
||||
// Deterministic parent (drop the last path segment) — used as a safe
|
||||
// fallback when there's no in-app history to go back to (deep link).
|
||||
@@ -87,19 +101,23 @@
|
||||
|
||||
{#if show}
|
||||
<header class="context-bar">
|
||||
<button class="cb-back" type="button" onclick={goBack} aria-label="Back">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12.5 4L6.5 10L12.5 16"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if canGoBack}
|
||||
<button class="cb-back" type="button" onclick={goBack} aria-label="Back">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12.5 4L6.5 10L12.5 16"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<h1 class="cb-title">{title}</h1>
|
||||
<span class="cb-spacer" aria-hidden="true"></span>
|
||||
<div class="cb-ws">
|
||||
<WorkspaceSwitcher mobile={true} />
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
@@ -125,8 +143,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -140,16 +158,32 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Balance the back button so the title sits optically centered. */
|
||||
.cb-spacer {
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
/* Workspace switcher, right-anchored. Capped so a long workspace name
|
||||
can't crowd out the title — the switcher's own `.name` ellipsizes. */
|
||||
.cb-ws {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 62%;
|
||||
}
|
||||
/* The embedded <WorkspaceSwitcher> paints its trigger with the same
|
||||
--bg-secondary as this bar, so it'd vanish. Lift it onto --bg-tertiary
|
||||
and size it to fill the bar's height (without growing the bar) so it
|
||||
reads as a substantial control rather than a small floating chip. */
|
||||
.cb-ws :global(.current) {
|
||||
max-width: 100%;
|
||||
min-height: 34px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-tertiary);
|
||||
font-size: 1em;
|
||||
}
|
||||
.cb-ws :global(.current:hover) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
WorkspaceSheet — the mobile "Workspace" slot surface (PLAN-1694, TASK-1701).
|
||||
|
||||
A docked sheet (DockedSheet, anchored above the nav) for moving around the
|
||||
current workspace: a purpose-built workspace switcher card (tap to expand an
|
||||
inline list — no reused WorkspaceSwitcher), a Navigate tile grid
|
||||
(Dashboard/Insights/Roles/Starred/Tags/Settings), and the Collections list.
|
||||
current workspace: a Navigate tile grid (Dashboard/Insights/Roles/Starred/
|
||||
Tags/Settings) and the Collections list.
|
||||
|
||||
The workspace *switcher* used to live here as a card; IDEA-1835 moved it to
|
||||
the persistent top bar (MobileContextBar), so this sheet is now navigation
|
||||
only. The bottom-nav slot keeps its "Workspace" label and avatar.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
@@ -12,8 +15,6 @@
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
import { workspaceRestoreTarget } from '$lib/utils/workspace-route';
|
||||
import { avatarColor, avatarInitial } from '$lib/utils/avatar';
|
||||
import { getPrimaryDestinations, getActiveKey } from '$lib/nav/destinations';
|
||||
import DockedSheet from '$lib/components/layout/DockedSheet.svelte';
|
||||
|
||||
@@ -22,9 +23,7 @@
|
||||
let wsSlug = $derived(workspaceStore.current?.slug);
|
||||
let wsUsername = $derived(workspaceStore.current?.owner_username ?? '');
|
||||
let wsPrefix = $derived(wsUsername && wsSlug ? `/${wsUsername}/${wsSlug}` : '');
|
||||
let wsName = $derived(workspaceStore.current?.name ?? 'Workspace');
|
||||
let isGuest = $derived(workspaceStore.current?.is_guest ?? false);
|
||||
let role = $derived(workspaceStore.currentRole);
|
||||
let activeKey = $derived(getActiveKey(page.url.pathname, wsPrefix));
|
||||
|
||||
// Navigate tiles: every static destination except Activity (its own nav
|
||||
@@ -43,19 +42,7 @@
|
||||
collectionStore.collections.filter((c) => agentSlugs.includes(c.slug))
|
||||
);
|
||||
|
||||
// Inline switcher list expansion. Reassignable $derived (Svelte 5.25+)
|
||||
// instead of $state: the card's onclick toggles it by reassignment, and
|
||||
// it snaps back to collapsed whenever `open` changes. This state lives
|
||||
// here (not in DockedSheet's children, which unmount on close), so plain
|
||||
// $state would survive close/reopen and the list would come back stale —
|
||||
// including via backdrop/swipe dismissal, which no handler in this
|
||||
// component sees (IDEA-1720).
|
||||
let switching = $derived.by(() => {
|
||||
void open;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Close on navigation (the switcher card navigates on select).
|
||||
// Close on navigation (the tiles/collections navigate on select).
|
||||
afterNavigate((nav) => {
|
||||
if (nav.type !== 'enter' && open) onclose();
|
||||
});
|
||||
@@ -65,58 +52,10 @@
|
||||
uiStore.onNavigate();
|
||||
goto(href);
|
||||
}
|
||||
|
||||
function selectWorkspace(ws: { slug: string; owner_username?: string }) {
|
||||
const current = workspaceStore.current;
|
||||
const isCurrent = !!current && ws.slug === current.slug;
|
||||
const target = isCurrent
|
||||
? `/${current.owner_username}/${current.slug}`
|
||||
: workspaceRestoreTarget(ws);
|
||||
onclose();
|
||||
goto(target);
|
||||
}
|
||||
|
||||
function newWorkspace() {
|
||||
onclose();
|
||||
uiStore.openCreateWorkspace();
|
||||
}
|
||||
</script>
|
||||
|
||||
<DockedSheet {open} {onclose} label="Workspace">
|
||||
<div class="ws">
|
||||
<!-- Workspace switcher card -->
|
||||
<button class="ws-card" type="button" onclick={() => (switching = !switching)}>
|
||||
<span class="ws-avatar" style:background={avatarColor(wsName)}>{avatarInitial(wsName)}</span>
|
||||
<span class="ws-meta">
|
||||
<span class="ws-name">{wsName}</span>
|
||||
{#if role}<span class="ws-role">{role}</span>{/if}
|
||||
</span>
|
||||
<span class="ws-switch">Switch <span class="ws-chev" class:up={switching}>⌄</span></span>
|
||||
</button>
|
||||
|
||||
{#if switching}
|
||||
<div class="ws-list">
|
||||
{#each workspaceStore.workspaces as ws (ws.slug)}
|
||||
<button
|
||||
class="ws-row"
|
||||
class:active={ws.slug === wsSlug}
|
||||
type="button"
|
||||
onclick={() => selectWorkspace(ws)}
|
||||
>
|
||||
<span class="ws-row-avatar" style:background={avatarColor(ws.name)}>
|
||||
{avatarInitial(ws.name)}
|
||||
</span>
|
||||
<span class="ws-row-name">{ws.name}</span>
|
||||
{#if ws.slug === wsSlug}<span class="ws-row-check" aria-hidden="true">✓</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="ws-row ws-new" type="button" onclick={newWorkspace}>
|
||||
<span class="ws-row-avatar plus" aria-hidden="true">+</span>
|
||||
<span class="ws-row-name">New workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigate -->
|
||||
<div class="ws-label">Navigate</div>
|
||||
<div class="ws-grid">
|
||||
@@ -171,125 +110,6 @@
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
/* Workspace switcher card */
|
||||
.ws-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.ws-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.ws-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.ws-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 1em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ws-role {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.ws-switch {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 0.8em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ws-chev {
|
||||
display: inline-block;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.ws-chev.up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.ws-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ws-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ws-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.ws-row.active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ws-row-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.ws-row-avatar.plus {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.ws-row-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.ws-row-check {
|
||||
color: var(--accent-blue);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.ws-new .ws-row-name {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ws-label {
|
||||
padding: var(--space-4) var(--space-1) var(--space-2);
|
||||
font-size: 0.7em;
|
||||
|
||||
@@ -2768,6 +2768,13 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* The collection name now lives in the persistent top bar
|
||||
(MobileContextBar, IDEA-1835) — drop the duplicate in-page heading
|
||||
but keep the view controls in the row. */
|
||||
.title-row h1 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
Reference in New Issue
Block a user