feat(web): wire WorkspaceSwitcher into mobile TopBar (TASK-640) (#169)

* feat(web): wire WorkspaceSwitcher into mobile TopBar (TASK-640)

Follow-up to TASK-637: the WorkspaceSwitcher component was built with a
BottomSheet branch on mobile but it was never rendered anywhere — the
TopBar had its own inline horizontal workspace list on both desktop
and mobile.

- Mobile: swap the TopBar's horizontal workspace list + "+" add button
  + "edit/reorder" button for a single <WorkspaceSwitcher /> chip. Tap
  opens the BottomSheet of workspaces + "+ New Workspace". Removes the
  horizontal-scroll discoverability problem when a user has many
  workspaces.
- Desktop: unchanged. Still uses the inline list with drag-to-reorder.
- Users who want to reorder workspaces can do it on desktop; mobile
  drag-reorder is a rarely-used workflow and the edit button added
  visible chrome on cramped mobile chrome.
- WorkspaceSwitcher now calls `uiStore.onNavigate()` on select/create
  so the mobile sidebar closes on workspace switch — preserves the
  previous TopBar link behavior.
- Removed now-unused state + handlers: mobileEditMode, enterEditMode,
  exitEditMode, handleMobileConsider, handleMobileFinalize, the
  reorder-overlay markup and CSS, the currentUsername derived (it was
  already unused).

Parent: PLAN-631.

* fix(web): let callers force WorkspaceSwitcher's mobile branch (Codex review)

Codex flagged a P2: TopBar branches mobile/desktop on uiStore.isMobile
(≤768px) but WorkspaceSwitcher uses its own 639.98px matchMedia. At
640–768px viewports (small tablets), the mobile TopBar would render
the desktop WorkspaceSwitcher dropdown — reintroducing the clipping
this PR was trying to fix.

- Add an optional `mobile?: boolean` prop to WorkspaceSwitcher that
  overrides the internal viewport detection when passed. Auto-detect
  still runs when the prop is omitted (for future callers).
- Mirror the rotation-reopen guard for the prop path: if `mobile`
  flips to false while the sheet is open, close it.
- TopBar passes `mobile={true}` when rendering inside its mobile branch
  so the decision stays consistent with `uiStore.isMobile`.

Per Codex review on PR #169.
This commit is contained in:
xarmian
2026-04-20 17:44:16 -04:00
committed by GitHub
parent 041472496b
commit 2e00a6769a
2 changed files with 61 additions and 213 deletions
+25 -209
View File
@@ -8,6 +8,7 @@
import type { Workspace } from '$lib/types';
import { onMount } from 'svelte';
import PadLogo from '$lib/components/layout/PadLogo.svelte';
import WorkspaceSwitcher from '$lib/components/layout/WorkspaceSwitcher.svelte';
let { mobile = false }: { mobile?: boolean } = $props();
@@ -15,19 +16,17 @@
let currentTheme = $state<'dark' | 'light'>('dark');
let currentSlug = $derived(workspaceStore.current?.slug ?? '');
let currentUsername = $derived(workspaceStore.current?.owner_username ?? '');
// DnD state — local copy of workspaces for reordering
// DnD state — local copy of workspaces for reordering (desktop only;
// mobile uses WorkspaceSwitcher which doesn't expose reorder — users
// who want to reorder can do it on desktop).
let dndWorkspaces: Workspace[] = $state([]);
let isDragging = $state(false);
const flipDurationMs = 150;
// Mobile edit mode
let mobileEditMode = $state(false);
// Sync from store when not actively reordering
$effect(() => {
if (!isDragging && !mobileEditMode) {
if (!isDragging) {
dndWorkspaces = [...workspaceStore.workspaces];
}
});
@@ -44,16 +43,6 @@
await saveOrder();
}
// Mobile edit mode: svelte-dnd-action handlers (touch drag works fine
// when items are buttons, not links)
function handleMobileConsider(e: CustomEvent<DndEvent<Workspace>>) {
dndWorkspaces = e.detail.items;
}
async function handleMobileFinalize(e: CustomEvent<DndEvent<Workspace>>) {
dndWorkspaces = e.detail.items;
}
async function saveOrder() {
const updates = dndWorkspaces.map((ws, i) => ({
slug: ws.slug,
@@ -67,16 +56,6 @@
}
}
function enterEditMode() {
mobileEditMode = true;
dndWorkspaces = [...workspaceStore.workspaces];
}
async function exitEditMode() {
mobileEditMode = false;
await saveOrder();
}
// Color palette for workspace circles
const colors = [
'#4a9eff', '#4ade80', '#a78bfa', '#fbbf24',
@@ -229,47 +208,25 @@
</header>
{:else}
<!-- ── Mobile ─────────────────────────────────────────────────────────── -->
<!--
Mobile TopBar is cramped, and horizontal-scrolling the full workspace
list hides workspaces off-screen. Swap the list + add + reorder
buttons for a single WorkspaceSwitcher trigger that opens a full-
width BottomSheet (see TASK-637). Reorder is available on desktop.
-->
<header class="topbar topbar-mobile">
<div class="topbar-left">
<PadLogo />
</div>
<div class="workspace-list">
{#each workspaceStore.workspaces as ws (ws.id)}
<a
href="/{ws.owner_username}/{ws.slug}"
class="workspace-item"
class:active={ws.slug === currentSlug}
onclick={() => uiStore.onNavigate()}
>
<span
class="workspace-icon"
style="background: {ws.slug === currentSlug ? wsColor(ws.name) : 'transparent'}; color: {ws.slug === currentSlug ? '#fff' : 'var(--text-secondary)'}; border-color: {wsColor(ws.name)}"
>
{wsInitial(ws.name)}
</span>
<span class="workspace-name">{ws.name}</span>
</a>
{/each}
<div class="mobile-switcher-slot">
<!--
Force the mobile branch. TopBar's mobile/desktop decision uses
`uiStore.isMobile` (≤768px) but WorkspaceSwitcher's own query
is ≤639.98px — without this prop, 640768px viewports would
render the desktop dropdown inside a mobile layout.
-->
<WorkspaceSwitcher mobile={true} />
</div>
<button
class="workspace-add"
onclick={() => { uiStore.onNavigate(); uiStore.openCreateWorkspace(); }}
title="New workspace"
>
<span class="add-icon">+</span>
</button>
{#if workspaceStore.workspaces.length > 1}
<button
class="edit-btn"
onclick={enterEditMode}
title="Reorder workspaces"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="4" y1="9" x2="20" y2="9" /><line x1="4" y1="15" x2="20" y2="15" />
<polyline points="8 5 4 9 8 13" /><polyline points="16 11 20 15 16 19" />
</svg>
</button>
{/if}
{#if authStore.user}
<div class="user-menu-container">
<button
@@ -316,36 +273,6 @@
</div>
{/if}
</header>
<!-- Full-screen reorder overlay -->
{#if mobileEditMode}
<div class="reorder-overlay">
<div class="reorder-header">
<h3 class="reorder-title">Reorder Workspaces</h3>
<button class="done-btn" onclick={exitEditMode}>Done</button>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="reorder-list"
use:dndzone={{items: dndWorkspaces, flipDurationMs, type: 'mobile-reorder'}}
onconsider={handleMobileConsider}
onfinalize={handleMobileFinalize}
>
{#each dndWorkspaces as ws (ws.id)}
<div class="reorder-item" class:active={ws.slug === currentSlug}>
<span class="drag-grip"></span>
<span
class="reorder-icon"
style="background: {wsColor(ws.name)}; border-color: {wsColor(ws.name)}"
>
{wsInitial(ws.name)}
</span>
<span class="reorder-name">{ws.name}</span>
</div>
{/each}
</div>
</div>
{/if}
{/if}
<style>
@@ -459,35 +386,14 @@
line-height: 1;
}
/* Edit / Done buttons for mobile reorder */
.edit-btn {
/* Mobile TopBar — slot that hosts the <WorkspaceSwitcher /> chip.
Flex-grows so the switcher trigger stretches to fill the gap between
the absolute-positioned logo and the user avatar. */
.mobile-switcher-slot {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: var(--radius);
color: var(--text-muted);
transition: background 0.15s, color 0.15s;
}
.edit-btn:hover {
background: var(--bg-hover);
color: var(--text-secondary);
}
.done-btn {
flex-shrink: 0;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius);
font-size: 0.78em;
font-weight: 600;
color: var(--accent-blue);
background: color-mix(in srgb, var(--accent-blue) 15%, transparent);
transition: background 0.15s;
}
.done-btn:hover {
background: color-mix(in srgb, var(--accent-blue) 25%, transparent);
}
/* Left side — logo */
@@ -619,94 +525,4 @@
background: color-mix(in srgb, var(--accent-orange) 10%, var(--bg-hover));
}
/* ── Mobile reorder overlay ──────────────────────────────────────────── */
.reorder-overlay {
position: fixed;
inset: 0;
z-index: 40;
background: var(--bg-primary);
display: flex;
flex-direction: column;
animation: overlay-in 0.15s ease-out;
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
.reorder-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-4);
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
.reorder-title {
font-size: 1em;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.reorder-list {
flex: 1;
overflow-y: auto;
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: 4px;
}
.reorder-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-3);
background: var(--bg-secondary);
border-radius: var(--radius);
border: 1px solid var(--border);
cursor: grab;
touch-action: none;
user-select: none;
transition: box-shadow 0.15s, border-color 0.15s;
}
.reorder-item:active {
cursor: grabbing;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
border-color: var(--accent-blue);
}
.reorder-item.active {
border-color: color-mix(in srgb, var(--accent-blue) 40%, var(--border));
}
.drag-grip {
color: var(--text-muted);
font-size: 1em;
line-height: 1;
flex-shrink: 0;
user-select: none;
}
.reorder-icon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8em;
font-weight: 700;
color: #fff;
flex-shrink: 0;
border: 2px solid;
}
.reorder-name {
font-size: 0.92em;
font-weight: 500;
color: var(--text-primary);
flex: 1;
}
</style>
@@ -4,19 +4,35 @@
import { uiStore } from '$lib/stores/ui.svelte';
import BottomSheet from '$lib/components/common/BottomSheet.svelte';
interface Props {
/**
* Force the mobile (BottomSheet) branch regardless of the internal
* viewport detection. Use this when an ancestor component already
* owns the mobile/desktop decision (e.g. TopBar branches on
* `uiStore.isMobile` at ≤768px but this component's own breakpoint
* is ≤639.98px — without the override, 640768px would show the
* desktop dropdown inside a mobile layout).
*/
mobile?: boolean;
}
let { mobile }: Props = $props();
let open = $state(false);
// ── Viewport detection ───────────────────────────────────────────────
// Track mobile viewport so we can swap the absolute-positioned dropdown
// for a full-width BottomSheet that reads better when workspace names
// are long or the list is deep.
let isMobile = $state(false);
// are long or the list is deep. Skipped when the caller passes an
// explicit `mobile` prop.
let detectedMobile = $state(false);
$effect(() => {
if (mobile !== undefined) return;
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(max-width: 639.98px)');
isMobile = mq.matches;
detectedMobile = mq.matches;
const onChange = (e: MediaQueryListEvent) => {
isMobile = e.matches;
detectedMobile = e.matches;
// Close the sheet if the viewport crosses above mobile while it's
// open (e.g. rotation) so returning to mobile doesn't reopen it.
if (!e.matches) {
@@ -27,13 +43,29 @@
return () => mq.removeEventListener('change', onChange);
});
// Also close the sheet if an ancestor-driven `mobile` prop flips off
// while the sheet is open — same rotation-reopen guard as the internal
// detection path.
$effect(() => {
if (mobile === false) {
open = false;
}
});
let isMobile = $derived(mobile ?? detectedMobile);
function select(ws: { slug: string; owner_username?: string }) {
open = false;
// Close the mobile sidebar if open — the TopBar's previous inline
// workspace links did this on click, so preserve the behavior now
// that the switcher is the mobile nav entry point.
uiStore.onNavigate();
goto(`/${ws.owner_username}/${ws.slug}`);
}
function openCreateModal() {
open = false;
uiStore.onNavigate();
uiStore.openCreateWorkspace();
}
</script>