Skip to content

Commit 810c172

Browse files
bloveclaude
andauthored
feat(chat,examples-chat): sidenav collapse toggle + remove leftover hamburger (#272)
Replaces the leftover floating "☰" hamburger (vestige of when the sidenav was a palette-toggled drawer) with proper collapse/expand controls inside the sidenav itself. - New chevron toggle button in the sidenav header — flips between expanded (280px) and collapsed (56px icon-strip) modes. - Cmd+B / Ctrl+B keyboard shortcut (VS Code convention) toggles the same. - Demo-shell stores the chosen desktop mode in localStorage so the preference persists across reloads. - Collapsed-mode CSS was already fully wired (icon-strip width, label hiding) — this commit just adds the entry point. - Mobile (<1024px) still uses drawer mode with a small floating hamburger as the open trigger. The hamburger renders only when the drawer is closed and the viewport is narrow. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 63d7e93 commit 810c172

7 files changed

Lines changed: 146 additions & 15 deletions

File tree

apps/website/content/docs/chat/api/api-docs.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3412,6 +3412,12 @@
34123412
"description": "",
34133413
"optional": false
34143414
},
3415+
{
3416+
"name": "modeChange",
3417+
"type": "OutputEmitterRef<ChatSidenavMode>",
3418+
"description": "",
3419+
"optional": false
3420+
},
34153421
{
34163422
"name": "newChat",
34173423
"type": "OutputEmitterRef<void>",
@@ -3450,6 +3456,12 @@
34503456
}
34513457
],
34523458
"methods": [
3459+
{
3460+
"name": "onCollapseToggle",
3461+
"signature": "onCollapseToggle()",
3462+
"description": "",
3463+
"params": []
3464+
},
34533465
{
34543466
"name": "onEscape",
34553467
"signature": "onEscape()",

examples/chat/angular/src/app/shell/demo-shell.component.css

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,14 @@
3838
transition: padding-left 200ms ease;
3939
padding-left: 0;
4040
}
41-
.demo-shell__main--push {
42-
padding-left: 280px;
41+
.demo-shell__main[data-sidenav-mode="expanded"] {
42+
padding-left: var(--ngaf-chat-sidenav-width-expanded, 280px);
43+
}
44+
.demo-shell__main[data-sidenav-mode="collapsed"] {
45+
padding-left: var(--ngaf-chat-sidenav-width-collapsed, 56px);
4346
}
4447
@media (max-width: 1023px) {
45-
.demo-shell__main--push { padding-left: 0; }
48+
.demo-shell__main[data-sidenav-mode] { padding-left: 0; }
4649
}
4750

4851
.demo-shell__interrupt-panel {

examples/chat/angular/src/app/shell/demo-shell.component.html

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
<div class="demo-shell">
2-
<button
3-
type="button"
4-
class="demo-shell__hamburger"
5-
aria-label="Open conversations"
6-
[attr.aria-expanded]="drawerOpen()"
7-
(click)="toggleSidenav()"
8-
></button>
2+
@if (sidenavMode() === 'drawer' && !drawerOpen()) {
3+
<button
4+
type="button"
5+
class="demo-shell__hamburger"
6+
aria-label="Open conversations"
7+
[attr.aria-expanded]="drawerOpen()"
8+
(click)="toggleSidenav()"
9+
></button>
10+
}
911

1012
<chat-sidenav
1113
[threads]="threadsSvc.threads()"
@@ -18,11 +20,12 @@
1820
(threadSelected)="onThreadSelected($event)"
1921
(searchOpened)="paletteOpen.set(true)"
2022
(openChange)="onSidenavOpenChange($event)"
23+
(modeChange)="onSidenavModeChange($event)"
2124
/>
2225

2326
<div
2427
class="demo-shell__main"
25-
[class.demo-shell__main--push]="drawerOpen() && sidenavMode() === 'expanded'"
28+
[attr.data-sidenav-mode]="sidenavMode() !== 'drawer' ? sidenavMode() : null"
2629
>
2730
<router-outlet />
2831
@if (agent.interrupt && agent.interrupt()) {

examples/chat/angular/src/app/shell/demo-shell.component.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,17 @@ export class DemoShell {
167167
typeof window !== 'undefined' ? window.innerWidth : 1440,
168168
);
169169

170-
/** Computed sidenav mode based on viewport width. */
170+
/**
171+
* User's chosen desktop sidenav mode. Persisted across reloads.
172+
* Below 1024px the shell ignores this and forces drawer mode.
173+
*/
174+
private readonly storedDesktopMode = signal<'expanded' | 'collapsed'>(
175+
(this.persistence.read('sidenavMode') as 'expanded' | 'collapsed' | null) ?? 'expanded',
176+
);
177+
178+
/** Computed sidenav mode: viewport forces drawer below 1024px, else user preference. */
171179
protected readonly sidenavMode = computed<ChatSidenavMode>(() =>
172-
this.viewportWidth() >= 1024 ? 'expanded' : 'drawer',
180+
this.viewportWidth() >= 1024 ? this.storedDesktopMode() : 'drawer',
173181
);
174182

175183
/** Client-side title filter over the loaded threads. */
@@ -312,6 +320,13 @@ export class DemoShell {
312320
this.onSidenavOpenChange(!this.drawerOpen());
313321
}
314322

323+
protected onSidenavModeChange(next: ChatSidenavMode): void {
324+
// Drawer is viewport-driven; ignore user attempts to set it directly.
325+
if (next === 'drawer') return;
326+
this.storedDesktopMode.set(next);
327+
this.persistence.write('sidenavMode', next);
328+
}
329+
315330
onTimelineReplay(checkpointId: string): void {
316331
void this.agent.submit(null as never, { checkpointId } as never);
317332
}

examples/chat/angular/src/app/shell/palette-persistence.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface PaletteState {
1010
theme?: string | null;
1111
threadId?: string | null;
1212
drawerOpen?: boolean | null;
13+
sidenavMode?: 'expanded' | 'collapsed' | null;
1314
}
1415

1516
type PaletteKey = keyof PaletteState;

libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,67 @@ describe('ChatSidenavComponent', () => {
120120
expect(lists[1].getAttribute('mode')).toBe('archived');
121121
});
122122

123+
it('renders the collapse chevron in expanded mode with "Collapse sidenav" label', () => {
124+
const fixture = render({ mode: 'expanded' });
125+
const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--collapse') as HTMLButtonElement;
126+
expect(btn).not.toBeNull();
127+
expect(btn.getAttribute('aria-label')).toBe('Collapse sidenav');
128+
});
129+
130+
it('renders the expand chevron in collapsed mode with "Expand sidenav" label', () => {
131+
const fixture = render({ mode: 'collapsed' });
132+
const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--collapse') as HTMLButtonElement;
133+
expect(btn).not.toBeNull();
134+
expect(btn.getAttribute('aria-label')).toBe('Expand sidenav');
135+
});
136+
137+
it('omits the collapse chevron in drawer mode', () => {
138+
const fixture = render({ mode: 'drawer' });
139+
expect(fixture.nativeElement.querySelector('.chat-sidenav__action--collapse')).toBeNull();
140+
});
141+
142+
it('clicking the chevron in expanded mode emits modeChange="collapsed"', () => {
143+
const fixture = render({ mode: 'expanded' });
144+
let last: string | undefined;
145+
fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; });
146+
const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--collapse') as HTMLButtonElement;
147+
btn.click();
148+
expect(last).toBe('collapsed');
149+
});
150+
151+
it('clicking the chevron in collapsed mode emits modeChange="expanded"', () => {
152+
const fixture = render({ mode: 'collapsed' });
153+
let last: string | undefined;
154+
fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; });
155+
const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--collapse') as HTMLButtonElement;
156+
btn.click();
157+
expect(last).toBe('expanded');
158+
});
159+
160+
it('Cmd+B in expanded mode emits modeChange="collapsed"', () => {
161+
const fixture = render({ mode: 'expanded' });
162+
let last: string | undefined;
163+
fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; });
164+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true }));
165+
expect(last).toBe('collapsed');
166+
});
167+
168+
it('Cmd+B in collapsed mode emits modeChange="expanded"', () => {
169+
const fixture = render({ mode: 'collapsed' });
170+
let last: string | undefined;
171+
fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; });
172+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true }));
173+
expect(last).toBe('expanded');
174+
});
175+
176+
it('Cmd+B is a no-op in drawer mode', () => {
177+
const fixture = render({ mode: 'drawer' });
178+
let emits = 0;
179+
fixture.componentInstance.modeChange.subscribe(() => { emits++; });
180+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true }));
181+
expect(emits).toBe(0);
182+
});
183+
123184
it('clicking the archived heading toggles aria-expanded', () => {
124185
const fixture = render({ threads: [{ id: 't1' }] });
125186
fixture.componentRef.setInput('archivedThreads', []);

libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,26 @@ export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer';
7777
</svg>
7878
<span class="chat-sidenav__action-label">Search</span>
7979
</button>
80+
@if (mode() !== 'drawer') {
81+
<button
82+
type="button"
83+
class="chat-sidenav__action chat-sidenav__action--collapse"
84+
(click)="onCollapseToggle()"
85+
[attr.aria-label]="mode() === 'collapsed' ? 'Expand sidenav' : 'Collapse sidenav'"
86+
[attr.title]="(mode() === 'collapsed' ? 'Expand sidenav' : 'Collapse sidenav') + ' (⌘B)'"
87+
>
88+
@if (mode() === 'collapsed') {
89+
<svg class="chat-sidenav__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
90+
<polyline points="9 6 15 12 9 18"/>
91+
</svg>
92+
} @else {
93+
<svg class="chat-sidenav__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
94+
<polyline points="15 6 9 12 15 18"/>
95+
</svg>
96+
}
97+
<span class="chat-sidenav__action-label">{{ mode() === 'collapsed' ? 'Expand' : 'Collapse' }}</span>
98+
</button>
99+
}
80100
</div>
81101
82102
<div class="chat-sidenav__primary">
@@ -152,6 +172,7 @@ export class ChatSidenavComponent {
152172
readonly threadSelected = output<string>();
153173
readonly searchOpened = output<void>();
154174
readonly openChange = output<boolean>();
175+
readonly modeChange = output<ChatSidenavMode>();
155176

156177
protected readonly archivedOpen = signal<boolean>(false);
157178

@@ -161,14 +182,23 @@ export class ChatSidenavComponent {
161182
fromEvent<KeyboardEvent>(window, 'keydown')
162183
.pipe(takeUntilDestroyed(this.destroyRef))
163184
.subscribe((e) => {
164-
if (!((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k')) return;
185+
if (!(e.metaKey || e.ctrlKey)) return;
186+
const key = e.key.toLowerCase();
187+
if (key !== 'k' && key !== 'b') return;
165188
const t = e.target as HTMLElement | null;
166189
if (t) {
167190
const tag = t.tagName;
168191
if (tag === 'INPUT' || tag === 'TEXTAREA' || t.isContentEditable) return;
169192
}
193+
if (key === 'k') {
194+
e.preventDefault();
195+
this.searchOpened.emit();
196+
return;
197+
}
198+
// Cmd/Ctrl+B: toggle expanded ↔ collapsed (no-op in drawer mode).
199+
if (this.mode() === 'drawer') return;
170200
e.preventDefault();
171-
this.searchOpened.emit();
201+
this.modeChange.emit(this.mode() === 'collapsed' ? 'expanded' : 'collapsed');
172202
});
173203
}
174204

@@ -177,4 +207,10 @@ export class ChatSidenavComponent {
177207
this.openChange.emit(false);
178208
}
179209
}
210+
211+
protected onCollapseToggle(): void {
212+
const m = this.mode();
213+
if (m === 'drawer') return;
214+
this.modeChange.emit(m === 'collapsed' ? 'expanded' : 'collapsed');
215+
}
180216
}

0 commit comments

Comments
 (0)