Skip to content

Commit e45c7d9

Browse files
authored
fix(chat): sidenav collapsed-mode polish — vertical icons, thread initials, right-click context menu (#287)
Three small fixes to the chat sidenav when in `collapsed` (56px) mode: 1. Top-bar icons stack vertically. The New-thread `[+]` and collapse/expand chevron previously sat side-by-side inside a 56px column via `justify-content: space-between`, producing awkward proportions. The collapsed-mode rule now switches the top bar to `flex-direction: column` with even gap. 2. Thread rows show a first-letter initial. In collapsed mode the title label is hidden and there was nothing in its place — the strip looked empty aside from the active-row highlight. Each row now renders a 28px circular initial (uppercase, surrogate-pair-safe via `Array.from`), hidden in expanded/drawer mode and revealed in collapsed mode. The row button carries a `title` attribute so the full thread label appears as a native tooltip on hover. 3. Right-click context menu on rows. The kebab is hidden in collapsed mode (28px target in a 56px column was cramped). Right-clicking any thread row now opens the same overflow menu anchored at the cursor — and suppresses the OS context menu — in expanded, collapsed and drawer modes alike. `chat-overflow-menu` gained an `anchorPos: {x, y}` input that takes precedence over the element `anchor` when set; the thread-list keeps the two anchor sources mutually exclusive via paired signals. Tests cover both the contextmenu open path and the no-adapter preventDefault path, plus initial rendering and the empty-title "?" fallback.
1 parent 6ce0471 commit e45c7d9

6 files changed

Lines changed: 161 additions & 1 deletion

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2982,6 +2982,12 @@
29822982
"description": "Element the menu anchors against (positions just below its bottom-right corner).",
29832983
"optional": false
29842984
},
2985+
{
2986+
"name": "anchorPos",
2987+
"type": "InputSignal<object | null>",
2988+
"description": "Alternative anchor: explicit viewport coordinates (e.g. cursor position\n from a right-click). Takes precedence over `anchor` when set.",
2989+
"optional": false
2990+
},
29852991
{
29862992
"name": "closed",
29872993
"type": "OutputEmitterRef<void>",
@@ -3900,6 +3906,12 @@
39003906
"description": "",
39013907
"optional": false
39023908
},
3909+
{
3910+
"name": "menuAnchorPos",
3911+
"type": "WritableSignal<object | null>",
3912+
"description": "Cursor-anchored position when the menu was opened via right-click.\n Mutually exclusive with `menuAnchor` — set one, null the other.",
3913+
"optional": false
3914+
},
39033915
{
39043916
"name": "menuOpenForId",
39053917
"type": "WritableSignal<string | null>",
@@ -4000,6 +4012,19 @@
40004012
}
40014013
]
40024014
},
4015+
{
4016+
"name": "initialOf",
4017+
"signature": "initialOf(title: string)",
4018+
"description": "First grapheme of a title rendered as an uppercase initial for the\n collapsed sidenav. Falls back to \"?\" for empty/whitespace titles.",
4019+
"params": [
4020+
{
4021+
"name": "title",
4022+
"type": "string",
4023+
"description": "",
4024+
"optional": false
4025+
}
4026+
]
4027+
},
40034028
{
40044029
"name": "onDragEnd",
40054030
"signature": "onDragEnd()",
@@ -4121,6 +4146,25 @@
41214146
}
41224147
]
41234148
},
4149+
{
4150+
"name": "onRowContextMenu",
4151+
"signature": "onRowContextMenu(threadId: string, event: MouseEvent)",
4152+
"description": "Right-click on a row opens the same overflow menu anchored at the\n cursor. Always prevents the native context menu — including when the\n adapter exposes no row actions (in which case we open nothing rather\n than confusing the user with the OS menu on what looks like a custom\n list).",
4153+
"params": [
4154+
{
4155+
"name": "threadId",
4156+
"type": "string",
4157+
"description": "",
4158+
"optional": false
4159+
},
4160+
{
4161+
"name": "event",
4162+
"type": "MouseEvent",
4163+
"description": "",
4164+
"optional": false
4165+
}
4166+
]
4167+
},
41244168
{
41254169
"name": "openMenu",
41264170
"signature": "openMenu(threadId: string, anchor: HTMLElement)",

libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,18 @@ export class ChatOverflowMenuComponent {
6767
readonly items = input<OverflowMenuItem[]>([]);
6868
/** Element the menu anchors against (positions just below its bottom-right corner). */
6969
readonly anchor = input<HTMLElement | null>(null);
70+
/** Alternative anchor: explicit viewport coordinates (e.g. cursor position
71+
* from a right-click). Takes precedence over `anchor` when set. */
72+
readonly anchorPos = input<{ x: number; y: number } | null>(null);
7073
readonly itemSelected = output<string>();
7174
readonly closed = output<void>();
7275

7376
protected readonly position = computed<{ top: number; left: number }>(() => {
7477
if (!this.open()) return { top: 0, left: 0 };
78+
const pos = this.anchorPos();
79+
if (pos) {
80+
return { top: pos.y + 4, left: Math.max(pos.x, 8) };
81+
}
7582
const el = this.anchor();
7683
if (!el) {
7784
const vw = typeof window === 'undefined' ? 0 : window.innerWidth;

libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,4 +708,44 @@ describe('ChatThreadListComponent', () => {
708708
expect(spy).toHaveBeenCalledWith('p1', null);
709709
});
710710
});
711+
712+
describe('row context menu', () => {
713+
it('right-click on a row opens the overflow menu and suppresses the native menu', () => {
714+
const fixture = render({ actions: { rename: noop, delete: noop } });
715+
const wrap = fixture.nativeElement.querySelector('.chat-thread-list__item-wrap') as HTMLElement;
716+
const evt = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 120, clientY: 80 });
717+
const dispatched = wrap.dispatchEvent(evt);
718+
fixture.detectChanges();
719+
// preventDefault was called → dispatchEvent returns false.
720+
expect(dispatched).toBe(false);
721+
const items = document.querySelectorAll('.chat-overflow-menu__item');
722+
const labels = Array.from(items).map((el) => (el as HTMLElement).textContent?.trim());
723+
expect(labels).toContain('Rename');
724+
expect(labels).toContain('Delete');
725+
});
726+
727+
it('right-click does nothing (but still preventDefaults) when there is no adapter', () => {
728+
const fixture = render({ actions: null });
729+
const wrap = fixture.nativeElement.querySelector('.chat-thread-list__item-wrap') as HTMLElement;
730+
const evt = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 });
731+
const dispatched = wrap.dispatchEvent(evt);
732+
fixture.detectChanges();
733+
expect(dispatched).toBe(false);
734+
expect(document.querySelector('.chat-overflow-menu')).toBeNull();
735+
});
736+
737+
it('renders the per-thread initial circle', () => {
738+
const fixture = render({ threads: [{ id: 't1', title: 'Hello world' }] });
739+
const initial = fixture.nativeElement.querySelector('.chat-thread-list__initial') as HTMLElement;
740+
expect(initial).not.toBeNull();
741+
expect(initial.textContent?.trim()).toBe('H');
742+
});
743+
744+
it('initialOf falls back to "?" for empty titles', () => {
745+
const fixture = render({ threads: [{ id: 't1', title: '' }] });
746+
// title falls back to id "t1" via threadLabel, so initial should be "T".
747+
const initial = fixture.nativeElement.querySelector('.chat-thread-list__initial') as HTMLElement;
748+
expect(initial.textContent?.trim()).toBe('T');
749+
});
750+
});
711751
});

libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export interface ThreadActionAdapter {
9999
(dragleave)="onDragLeave($event, thread.id)"
100100
(drop)="onDrop($event, thread.id)"
101101
(dragend)="onDragEnd()"
102+
(contextmenu)="onRowContextMenu(thread.id, $event)"
102103
>
103104
@if (templateRef()) {
104105
<ng-container
@@ -129,10 +130,12 @@ export interface ThreadActionAdapter {
129130
<button
130131
type="button"
131132
class="chat-thread-list__item"
133+
[attr.title]="threadLabel(thread)"
132134
[attr.data-active]="thread.id === activeThreadId() ? 'true' : null"
133135
[attr.aria-current]="thread.id === activeThreadId() ? 'true' : null"
134136
(click)="selectThread(thread.id)"
135137
>
138+
<span class="chat-thread-list__initial" aria-hidden="true">{{ initialOf(threadLabel(thread)) }}</span>
136139
<span class="chat-thread-list__item-title">
137140
@if (thread.pinned) {
138141
<svg class="chat-thread-list__item-pin" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
@@ -166,6 +169,7 @@ export interface ThreadActionAdapter {
166169
[open]="menuOpenForId() !== null"
167170
[items]="currentMenuItems()"
168171
[anchor]="menuAnchor()"
172+
[anchorPos]="menuAnchorPos()"
169173
(itemSelected)="onMenuAction($event)"
170174
(closed)="menuOpenForId.set(null)"
171175
/>
@@ -174,6 +178,7 @@ export interface ThreadActionAdapter {
174178
[open]="moveMenuOpenForId() !== null"
175179
[items]="moveMenuItems()"
176180
[anchor]="menuAnchor()"
181+
[anchorPos]="menuAnchorPos()"
177182
(itemSelected)="onMoveMenuAction($event)"
178183
(closed)="moveMenuOpenForId.set(null)"
179184
/>
@@ -206,6 +211,9 @@ export class ChatThreadListComponent {
206211
protected readonly editingValue = signal<string>('');
207212
protected readonly menuOpenForId = signal<string | null>(null);
208213
protected readonly menuAnchor = signal<HTMLElement | null>(null);
214+
/** Cursor-anchored position when the menu was opened via right-click.
215+
* Mutually exclusive with `menuAnchor` — set one, null the other. */
216+
protected readonly menuAnchorPos = signal<{ x: number; y: number } | null>(null);
209217
protected readonly confirmDeleteId = signal<string | null>(null);
210218

211219
protected readonly moveMenuOpenForId = signal<string | null>(null);
@@ -333,9 +341,33 @@ export class ChatThreadListComponent {
333341

334342
protected openMenu(threadId: string, anchor: HTMLElement): void {
335343
this.menuAnchor.set(anchor);
344+
this.menuAnchorPos.set(null);
336345
this.menuOpenForId.set(threadId);
337346
}
338347

348+
/** Right-click on a row opens the same overflow menu anchored at the
349+
* cursor. Always prevents the native context menu — including when the
350+
* adapter exposes no row actions (in which case we open nothing rather
351+
* than confusing the user with the OS menu on what looks like a custom
352+
* list). */
353+
protected onRowContextMenu(threadId: string, event: MouseEvent): void {
354+
event.preventDefault();
355+
if (!this.showKebab()) return;
356+
if (this.editingThreadId() !== null) return;
357+
this.menuAnchor.set(null);
358+
this.menuAnchorPos.set({ x: event.clientX, y: event.clientY });
359+
this.menuOpenForId.set(threadId);
360+
}
361+
362+
/** First grapheme of a title rendered as an uppercase initial for the
363+
* collapsed sidenav. Falls back to "?" for empty/whitespace titles. */
364+
protected initialOf(title: string): string {
365+
const trimmed = (title ?? '').trim();
366+
if (!trimmed) return '?';
367+
const first = Array.from(trimmed)[0];
368+
return first.toUpperCase ? first.toUpperCase() : first;
369+
}
370+
339371
protected onMenuAction(id: string): void {
340372
const threadId = this.menuOpenForId();
341373
this.menuOpenForId.set(null);

libs/chat/src/lib/styles/chat-sidenav.styles.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,11 @@ export const CHAT_SIDENAV_STYLES = `
8383
border-bottom: 1px solid var(--ngaf-chat-separator);
8484
}
8585
:host([data-mode="collapsed"]) .chat-sidenav__topbar {
86+
flex-direction: column;
87+
align-items: center;
8688
justify-content: center;
87-
padding: var(--ngaf-chat-space-2);
89+
gap: 4px;
90+
padding: var(--ngaf-chat-space-2) 0;
8891
}
8992
.chat-sidenav__topbar .chat-sidenav__action {
9093
width: 36px;
@@ -206,4 +209,25 @@ export const CHAT_SIDENAV_STYLES = `
206209
:host([data-mode="collapsed"]) .chat-sidenav__archived { display: none; }
207210
.chat-sidenav__projects { flex-shrink: 0; }
208211
:host([data-mode="collapsed"]) .chat-sidenav__projects { display: none; }
212+
213+
/* Collapsed-mode thread row presentation: hide labels, kebab and grip;
214+
* surface the per-thread initial circle so the strip reads as a list of
215+
* threads rather than an empty column. */
216+
:host([data-mode="collapsed"]) .chat-thread-list__initial {
217+
display: inline-flex;
218+
}
219+
:host([data-mode="collapsed"]) .chat-thread-list__item-title { display: none; }
220+
:host([data-mode="collapsed"]) .chat-thread-list__item-time { display: none; }
221+
:host([data-mode="collapsed"]) .chat-thread-list__kebab { display: none; }
222+
:host([data-mode="collapsed"]) .chat-thread-list__grip { display: none; }
223+
:host([data-mode="collapsed"]) .chat-thread-list__item-wrap {
224+
justify-content: center;
225+
}
226+
:host([data-mode="collapsed"]) .chat-thread-list__item {
227+
padding: 6px;
228+
justify-content: center;
229+
align-items: center;
230+
flex-direction: row;
231+
}
232+
:host([data-mode="collapsed"]) .chat-thread-list__new { display: none; }
209233
`;

libs/chat/src/lib/styles/chat-thread-list.styles.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ export const CHAT_THREAD_LIST_STYLES = `
8989
outline: 2px solid var(--ngaf-chat-primary);
9090
outline-offset: 2px;
9191
}
92+
.chat-thread-list__initial {
93+
display: none;
94+
width: 28px;
95+
height: 28px;
96+
border-radius: 50%;
97+
align-items: center;
98+
justify-content: center;
99+
background: var(--ngaf-chat-surface-alt);
100+
color: var(--ngaf-chat-text);
101+
font-weight: 500;
102+
font-size: 13px;
103+
flex-shrink: 0;
104+
}
92105
.chat-thread-list__item-pin {
93106
width: 11px;
94107
height: 11px;

0 commit comments

Comments
 (0)