From 2e00a6769a8ce9b6a53561479de7e0e66158e489 Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 20 Apr 2026 17:44:16 -0400 Subject: [PATCH] feat(web): wire WorkspaceSwitcher into mobile TopBar (TASK-640) (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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. --- web/src/lib/components/layout/TopBar.svelte | 234 ++---------------- .../layout/WorkspaceSwitcher.svelte | 40 ++- 2 files changed, 61 insertions(+), 213 deletions(-) diff --git a/web/src/lib/components/layout/TopBar.svelte b/web/src/lib/components/layout/TopBar.svelte index 2cbf7193..fe509378 100644 --- a/web/src/lib/components/layout/TopBar.svelte +++ b/web/src/lib/components/layout/TopBar.svelte @@ -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>) { - dndWorkspaces = e.detail.items; - } - - async function handleMobileFinalize(e: CustomEvent>) { - 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 @@ {:else} +
-
- {#each workspaceStore.workspaces as ws (ws.id)} - uiStore.onNavigate()} - > - - {wsInitial(ws.name)} - - {ws.name} - - {/each} +
+ +
- - {#if workspaceStore.workspaces.length > 1} - - {/if} {#if authStore.user}
- - - {#if mobileEditMode} -
-
-

Reorder Workspaces

- -
- -
- {#each dndWorkspaces as ws (ws.id)} -
- - - {wsInitial(ws.name)} - - {ws.name} -
- {/each} -
-
- {/if} {/if} diff --git a/web/src/lib/components/layout/WorkspaceSwitcher.svelte b/web/src/lib/components/layout/WorkspaceSwitcher.svelte index 3a2f95fd..8c1a1727 100644 --- a/web/src/lib/components/layout/WorkspaceSwitcher.svelte +++ b/web/src/lib/components/layout/WorkspaceSwitcher.svelte @@ -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, 640–768px 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(); }