Skip to content

Commit ce3c72c

Browse files
committed
feat(frontend): refine main sidebar interactions
1 parent 9a440db commit ce3c72c

7 files changed

Lines changed: 265 additions & 51 deletions

File tree

frontend/src/lib/components/UserAvatar.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
// minimal drops the items that navigate to the desktop app (My Workspace,
1515
// Profile, Security) — used on the mobile surface where those routes render
1616
// the full desktop UI. Theme + Sign Out remain.
17-
minimal = false
17+
minimal = false,
18+
isOpen = $bindable(false),
19+
onOpenChange = null,
1820
} = $props();
1921
2022
// Local state
@@ -124,6 +126,8 @@
124126
triggerAlignment={expanded ? "start" : "center"}
125127
showChevron={false}
126128
triggerTestid="user-avatar-trigger"
129+
{isOpen}
130+
{onOpenChange}
127131
items={[
128132
...((!minimal && authStore.currentUser) ? [{
129133
id: 'my-workspace',

frontend/src/lib/features/notifications/NotificationTray.svelte

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,35 @@
99
import { navigate } from '../../router.js';
1010
import { t } from '../../stores/i18n.svelte.js';
1111
import { createPopover, melt } from '@melt-ui/svelte';
12+
import { toStore } from 'svelte/store';
1213
1314
const AUTO_SEEN_DELAY_MS = 5000;
1415
1516
let {
1617
expanded = false,
17-
label = ''
18+
label = '',
19+
isOpen = $bindable(false),
20+
onOpenChange = null,
1821
} = $props();
1922
2023
let unreadCount = $state(0);
2124
2225
// Portal above the sidebar stacking context.
26+
const controlledOpen = toStore(
27+
() => isOpen,
28+
(value) => {
29+
if (isOpen === value) return;
30+
isOpen = value;
31+
onOpenChange?.(value);
32+
}
33+
);
34+
2335
const {
2436
elements: { trigger, content },
2537
states: { open }
2638
} = createPopover({
2739
forceVisible: true,
40+
open: controlledOpen,
2841
positioning: {
2942
placement: 'right-start'
3043
},
@@ -108,6 +121,7 @@
108121
{#if $open}
109122
<div
110123
use:melt={$content}
124+
tabindex="-1"
111125
class="notification-dropdown z-[60] w-96 rounded shadow-xl max-h-[500px] overflow-hidden"
112126
style="background-color: var(--ds-surface-overlay); border: 1px solid var(--ds-border); color: var(--ds-text);"
113127
in:fly={{ x: -10, duration: 200, easing: quintOut }}

frontend/src/lib/layout/DropdownMenu.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { t } from '../stores/i18n.svelte.js';
66
import { sanitizeHtml } from '../utils/sanitize.ts';
77
import { tick } from 'svelte';
8+
import { toStore } from 'svelte/store';
89
import Checkbox from '../components/Checkbox.svelte';
910
import Input from '../components/Input.svelte';
1011
import ItemTypeIcon from '../components/ItemTypeIcon.svelte';
@@ -31,12 +32,23 @@
3132
disabled = false,
3233
triggerTestid = '',
3334
triggerLabel = '',
35+
isOpen = $bindable(false),
36+
onOpenChange = null,
3437
children = undefined
3538
} = $props();
3639
3740
const isDisabled = $derived(disabled || (items.length === 0 && !children));
3841
3942
// Create popover (replaces createDropdownMenu to avoid typeahead focus-stealing)
43+
const controlledOpen = toStore(
44+
() => isOpen,
45+
(value) => {
46+
if (isOpen === value) return;
47+
isOpen = value;
48+
onOpenChange?.(value);
49+
}
50+
);
51+
4052
const {
4153
elements: { trigger, content },
4254
states: { open }
@@ -47,6 +59,7 @@
4759
placement: /** @type {import('@floating-ui/dom').Placement} */ (placement || 'bottom')
4860
},
4961
portal: 'body',
62+
open: controlledOpen,
5063
// svelte-ignore state_referenced_locally
5164
disabled: isDisabled
5265
}));
@@ -220,6 +233,7 @@
220233
use:melt={$content}
221234
data-menu-container
222235
role="menu"
236+
tabindex="-1"
223237
onkeydown={handleMenuKeydown}
224238
class="{maxWidth} rounded shadow-xl border focus:outline-none z-[60]"
225239
style="background-color: var(--ds-surface-raised); border-color: var(--ds-border); box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 10px 10px -5px rgba(0, 0, 0, 0.15);"

frontend/src/lib/layout/MainSidebar.svelte

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import NavLink from './NavLink.svelte';
1212
import UserAvatar from '../components/UserAvatar.svelte';
1313
import NotificationTray from '../features/notifications/NotificationTray.svelte';
14+
import ScrollableSidebar from './ScrollableSidebar.svelte';
1415
import {
1516
IconSearch, IconSettings, IconPlus, IconGridDots, IconUserScan,
1617
IconFolders, IconLayoutSidebarLeftExpand, IconLayoutSidebarLeftCollapse,
@@ -22,7 +23,9 @@
2223
onShowCommandPalette = () => {},
2324
onShowCreateModal = () => {},
2425
onShowChatPanel = () => {},
25-
onToggleTerminal = () => {}
26+
onToggleTerminal = () => {},
27+
activeSurface = null,
28+
onSurfaceChange = () => {},
2629
} = $props();
2730
2831
const isTauri = getIsTauri();
@@ -126,24 +129,41 @@
126129
function showCreateDropdown() {
127130
onShowCreateModal();
128131
}
132+
133+
function setPopoverSurface(surface, open) {
134+
if (open) {
135+
onSurfaceChange(surface);
136+
} else if (activeSurface === surface) {
137+
onSurfaceChange(null);
138+
}
139+
}
140+
141+
function closePopoverSurface() {
142+
if (activeSurface === 'workspaces' || activeSurface === 'notifications' || activeSurface === 'profile') {
143+
onSurfaceChange(null);
144+
}
145+
}
129146
</script>
130147
131-
<nav class="main-sidebar {$uiStore.navExpanded ? 'w-[200px]' : 'w-16'} shadow-lg border-r flex flex-col py-4 fixed h-full z-40 themed-nav transition-all duration-200 overflow-x-hidden" style="border-color: var(--ds-border);" aria-label={t('aria.mainNavigation')}>
148+
{#snippet sidebarHeader()}
132149
<!-- Logo -->
133150
<Tooltip content="Windshift" placement="right" disabled={$uiStore.navExpanded}>
134151
<a
135152
href="/"
153+
onclick={closePopoverSurface}
136154
class="flex items-center {$uiStore.navExpanded ? 'px-4' : 'justify-center'} w-full h-10 mb-2 hover:opacity-80 transition-opacity cursor-pointer"
137155
>
138-
<img src="windshift-3.svg" alt="Windshift" class="w-8 h-8 flex-shrink-0" />
156+
<img src="/windshift-3.svg" alt="Windshift" class="w-8 h-8 flex-shrink-0" />
139157
{#if $uiStore.navExpanded}
140158
<span class="ml-3 font-semibold text-sm whitespace-nowrap">Windshift</span>
141159
{/if}
142160
</a>
143161
</Tooltip>
162+
{/snippet}
144163
164+
{#snippet sidebarContent()}
145165
<!-- Main Navigation -->
146-
<div class="flex mt-6 flex-col {$uiStore.navExpanded ? 'items-stretch px-2.5' : 'items-center'} space-y-1 flex-1">
166+
<div class="flex flex-col {$uiStore.navExpanded ? 'items-stretch px-2.5' : 'items-center'} space-y-1 py-4">
147167
148168
<!-- Workspaces -->
149169
<Tooltip content={t('nav.workspaces')} placement="right" disabled={$uiStore.navExpanded}>
@@ -154,14 +174,16 @@
154174
triggerGap="gap-3"
155175
triggerText={$uiStore.navExpanded ? t('nav.workspaces') : ''}
156176
triggerLabel={t('nav.workspaces')}
157-
triggerClass="{$uiStore.navExpanded ? 'w-full px-3' : 'w-10'} h-10 rounded flex items-center {$uiStore.navExpanded ? '' : 'justify-center'} cursor-pointer nav-button nav-button-emphasized {isWorkspaceRoute($currentRoute.view) ? 'nav-button-selected' : ''} {!$workspacesStore.loaded ? 'opacity-50 cursor-wait' : ''}"
177+
triggerClass="{$uiStore.navExpanded ? 'w-full px-3' : 'w-10'} h-10 rounded flex items-center {$uiStore.navExpanded ? '' : 'justify-center'} cursor-pointer nav-button nav-button-emphasized {isWorkspaceRoute($currentRoute.view) || activeSurface === 'workspaces' ? 'nav-button-selected' : ''} {!$workspacesStore.loaded ? 'opacity-50 cursor-wait' : ''}"
158178
triggerTestid="workspaces-dropdown-trigger"
159179
items={workspacesDropdownItems}
160180
maxWidth="max-w-xs"
161181
showChevron={false}
162182
placement="right-start"
163183
iconOnly={!$uiStore.navExpanded}
164184
triggerAlignment={$uiStore.navExpanded ? 'start' : 'center'}
185+
isOpen={activeSurface === 'workspaces'}
186+
onOpenChange={(open) => setPopoverSurface('workspaces', open)}
165187
/>
166188
</div>
167189
</Tooltip>
@@ -175,25 +197,31 @@
175197
href={item.href}
176198
isActive={item.activeViews.includes($currentRoute.view)}
177199
expanded={$uiStore.navExpanded}
200+
onclick={closePopoverSurface}
178201
/>
179202
{/each}
180203
181-
<!-- Top Actions Section - "Notch" style centered positioning -->
182-
<div class="flex flex-col items-stretch space-y-2 my-6 py-4">
204+
<!-- Global actions share the same rhythm as navigation, but remain a
205+
distinct task group instead of a second navigation section. -->
206+
<div class="sidebar-quick-actions flex flex-col items-stretch space-y-1 my-3 py-3 border-y">
183207
<NavLink
184208
id="global-create-button"
185209
icon={IconPlus}
186210
label={t('nav.create')}
187211
onclick={showCreateDropdown}
188212
expanded={$uiStore.navExpanded}
189-
variant="primary"
190-
tooltipSuffix=" (C)"
213+
variant="accent"
214+
isActive={activeSurface === 'create'}
215+
shortcut={getShortcutDisplay('global', 'create')}
216+
tooltipSuffix=" ({getShortcutDisplay('global', 'create')})"
191217
/>
192218
<NavLink
193219
icon={IconSearch}
194220
label={t('nav.search')}
195221
onclick={onShowCommandPalette}
196222
expanded={$uiStore.navExpanded}
223+
isActive={activeSurface === 'search'}
224+
shortcut={getShortcutDisplay('global', 'commandPalette')}
197225
tooltipSuffix=" ({getShortcutDisplay('global', 'commandPalette')} or Space Space)"
198226
/>
199227
{#if aiStore.chatAvailable}
@@ -203,6 +231,8 @@
203231
label={t('nav.aiChat')}
204232
onclick={onShowChatPanel}
205233
expanded={$uiStore.navExpanded}
234+
isActive={activeSurface === 'chat'}
235+
shortcut={getShortcutDisplay('global', 'aiChat')}
206236
tooltipSuffix=" ({getShortcutDisplay('global', 'aiChat')})"
207237
/>
208238
{/if}
@@ -217,9 +247,11 @@
217247
{/if}
218248
</div>
219249
</div>
250+
{/snippet}
220251
252+
{#snippet sidebarFooter()}
221253
<!-- Bottom Section -->
222-
<div class="flex flex-col {$uiStore.navExpanded ? 'items-stretch px-3' : 'items-center'} space-y-1 mt-auto">
254+
<div class="flex flex-col {$uiStore.navExpanded ? 'items-stretch px-3' : 'items-center'} space-y-1 pt-2">
223255
<!-- Nav Toggle Button -->
224256
<button
225257
onclick={() => uiStore.toggleNavExpanded()}
@@ -242,24 +274,56 @@
242274
href={item.href}
243275
isActive={item.activeViews.includes($currentRoute.view)}
244276
expanded={$uiStore.navExpanded}
277+
onclick={closePopoverSurface}
245278
/>
246279
{/each}
247280
248281
<!-- Notification Tray -->
249282
<Tooltip content={t('nav.notifications')} placement="right" disabled={$uiStore.navExpanded}>
250-
<NotificationTray expanded={$uiStore.navExpanded} label={t('nav.notifications')} />
283+
<NotificationTray
284+
expanded={$uiStore.navExpanded}
285+
label={t('nav.notifications')}
286+
isOpen={activeSurface === 'notifications'}
287+
onOpenChange={(open) => setPopoverSurface('notifications', open)}
288+
/>
251289
</Tooltip>
252290
253291
<!-- User Profile Avatar -->
254292
<Tooltip content={t('nav.profile')} placement="right" disabled={$uiStore.navExpanded}>
255-
<UserAvatar expanded={$uiStore.navExpanded} label={t('nav.profile')} />
293+
<UserAvatar
294+
expanded={$uiStore.navExpanded}
295+
label={t('nav.profile')}
296+
isOpen={activeSurface === 'profile'}
297+
onOpenChange={(open) => setPopoverSurface('profile', open)}
298+
/>
256299
</Tooltip>
257300
</div>
258-
</nav>
301+
{/snippet}
302+
303+
<ScrollableSidebar
304+
as="nav"
305+
class="main-sidebar {$uiStore.navExpanded ? 'w-[200px]' : 'w-16'} shadow-lg border-r py-4 fixed inset-y-0 z-40 themed-nav transition-all duration-200"
306+
style="border-color: var(--ds-border);"
307+
aria-label={t('aria.mainNavigation')}
308+
header={sidebarHeader}
309+
footer={sidebarFooter}
310+
scrollTestid="main-navigation-scroll"
311+
>
312+
{@render sidebarContent()}
313+
</ScrollableSidebar>
259314
260315
<style>
316+
:global(.main-sidebar) {
317+
height: 100vh;
318+
height: 100dvh;
319+
}
320+
321+
.sidebar-quick-actions {
322+
border-color: color-mix(in srgb, var(--ds-border) 75%, transparent);
323+
}
324+
261325
@media (max-width: 767px) {
262-
.main-sidebar {
326+
:global(.main-sidebar) {
263327
width: 4rem;
264328
}
265329
}

frontend/src/lib/layout/NavLink.svelte

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script>
22
import Tooltip from '../components/Tooltip.svelte';
3+
import { t } from '../stores/i18n.svelte.js';
34
45
let {
56
icon: Icon,
@@ -10,6 +11,7 @@
1011
expanded = false,
1112
variant = 'default',
1213
tooltipSuffix = '',
14+
shortcut = '',
1315
id = undefined
1416
} = $props();
1517
@@ -20,21 +22,37 @@
2022
const variantClasses = $derived(
2123
variant === 'primary'
2224
? 'bg-[var(--ds-interactive)] bg-primary text-white text-sm font-medium transition'
25+
: variant === 'accent'
26+
? `nav-button nav-button-accent ${isActive ? 'nav-button-selected' : ''}`
2327
: `nav-button ${isActive ? 'nav-button-selected' : ''}`
2428
);
29+
30+
const shortcutKeys = $derived(shortcut.trim().split(/\s+/).filter(Boolean));
2531
</script>
2632

2733
<Tooltip content="{label}{tooltipSuffix}" placement="right" disabled={expanded}>
2834
{#if href}
2935
<a
3036
{id}
3137
{href}
38+
{onclick}
3239
class="{baseClasses} {variantClasses}"
3340
aria-label={label}
3441
aria-current={isActive ? 'page' : undefined}
3542
>
3643
<Icon class="w-5 h-5 flex-shrink-0" />
3744
{#if expanded}<span class="ml-3 min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm">{label}</span>{/if}
45+
{#if expanded && shortcut}
46+
<span
47+
class="nav-shortcut"
48+
title={t('commandPalette.pressToOpen', { shortcut })}
49+
aria-label={t('commandPalette.pressToOpen', { shortcut })}
50+
>
51+
{#each shortcutKeys as key, index (`${key}-${index}`)}
52+
<kbd class="nav-shortcut-key">{key}</kbd>
53+
{/each}
54+
</span>
55+
{/if}
3856
</a>
3957
{:else}
4058
<button
@@ -45,6 +63,17 @@
4563
>
4664
<Icon class="w-5 h-5 flex-shrink-0" />
4765
{#if expanded}<span class="ml-3 min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>{/if}
66+
{#if expanded && shortcut}
67+
<span
68+
class="nav-shortcut"
69+
title={t('commandPalette.pressToOpen', { shortcut })}
70+
aria-label={t('commandPalette.pressToOpen', { shortcut })}
71+
>
72+
{#each shortcutKeys as key, index (`${key}-${index}`)}
73+
<kbd class="nav-shortcut-key">{key}</kbd>
74+
{/each}
75+
</span>
76+
{/if}
4877
</button>
4978
{/if}
5079
</Tooltip>

0 commit comments

Comments
 (0)