From 1b40dd18f7af7ae330135713bf0f70ee4204ca6e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 20:30:16 -0700 Subject: [PATCH] =?UTF-8?q?fix(chat):=20sidenav=20collapsed-mode=20polish?= =?UTF-8?q?=20=E2=80=94=20vertical=20icons,=20thread=20initials,=20right-c?= =?UTF-8?q?lick=20context=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../content/docs/chat/api/api-docs.json | 44 +++++++++++++++++++ .../chat-overflow-menu.component.ts | 7 +++ .../chat-thread-list.component.spec.ts | 40 +++++++++++++++++ .../chat-thread-list.component.ts | 32 ++++++++++++++ .../src/lib/styles/chat-sidenav.styles.ts | 26 ++++++++++- .../src/lib/styles/chat-thread-list.styles.ts | 13 ++++++ 6 files changed, 161 insertions(+), 1 deletion(-) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 64b18ccf..713f48ef 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -2982,6 +2982,12 @@ "description": "Element the menu anchors against (positions just below its bottom-right corner).", "optional": false }, + { + "name": "anchorPos", + "type": "InputSignal", + "description": "Alternative anchor: explicit viewport coordinates (e.g. cursor position\n from a right-click). Takes precedence over `anchor` when set.", + "optional": false + }, { "name": "closed", "type": "OutputEmitterRef", @@ -3900,6 +3906,12 @@ "description": "", "optional": false }, + { + "name": "menuAnchorPos", + "type": "WritableSignal", + "description": "Cursor-anchored position when the menu was opened via right-click.\n Mutually exclusive with `menuAnchor` — set one, null the other.", + "optional": false + }, { "name": "menuOpenForId", "type": "WritableSignal", @@ -4000,6 +4012,19 @@ } ] }, + { + "name": "initialOf", + "signature": "initialOf(title: string)", + "description": "First grapheme of a title rendered as an uppercase initial for the\n collapsed sidenav. Falls back to \"?\" for empty/whitespace titles.", + "params": [ + { + "name": "title", + "type": "string", + "description": "", + "optional": false + } + ] + }, { "name": "onDragEnd", "signature": "onDragEnd()", @@ -4121,6 +4146,25 @@ } ] }, + { + "name": "onRowContextMenu", + "signature": "onRowContextMenu(threadId: string, event: MouseEvent)", + "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).", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "event", + "type": "MouseEvent", + "description": "", + "optional": false + } + ] + }, { "name": "openMenu", "signature": "openMenu(threadId: string, anchor: HTMLElement)", diff --git a/libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts b/libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts index 2bfa941f..8905cac3 100644 --- a/libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts +++ b/libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts @@ -67,11 +67,18 @@ export class ChatOverflowMenuComponent { readonly items = input([]); /** Element the menu anchors against (positions just below its bottom-right corner). */ readonly anchor = input(null); + /** Alternative anchor: explicit viewport coordinates (e.g. cursor position + * from a right-click). Takes precedence over `anchor` when set. */ + readonly anchorPos = input<{ x: number; y: number } | null>(null); readonly itemSelected = output(); readonly closed = output(); protected readonly position = computed<{ top: number; left: number }>(() => { if (!this.open()) return { top: 0, left: 0 }; + const pos = this.anchorPos(); + if (pos) { + return { top: pos.y + 4, left: Math.max(pos.x, 8) }; + } const el = this.anchor(); if (!el) { const vw = typeof window === 'undefined' ? 0 : window.innerWidth; diff --git a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts index 8f60035e..b4599f9d 100644 --- a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts @@ -708,4 +708,44 @@ describe('ChatThreadListComponent', () => { expect(spy).toHaveBeenCalledWith('p1', null); }); }); + + describe('row context menu', () => { + it('right-click on a row opens the overflow menu and suppresses the native menu', () => { + const fixture = render({ actions: { rename: noop, delete: noop } }); + const wrap = fixture.nativeElement.querySelector('.chat-thread-list__item-wrap') as HTMLElement; + const evt = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 120, clientY: 80 }); + const dispatched = wrap.dispatchEvent(evt); + fixture.detectChanges(); + // preventDefault was called → dispatchEvent returns false. + expect(dispatched).toBe(false); + const items = document.querySelectorAll('.chat-overflow-menu__item'); + const labels = Array.from(items).map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).toContain('Rename'); + expect(labels).toContain('Delete'); + }); + + it('right-click does nothing (but still preventDefaults) when there is no adapter', () => { + const fixture = render({ actions: null }); + const wrap = fixture.nativeElement.querySelector('.chat-thread-list__item-wrap') as HTMLElement; + const evt = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }); + const dispatched = wrap.dispatchEvent(evt); + fixture.detectChanges(); + expect(dispatched).toBe(false); + expect(document.querySelector('.chat-overflow-menu')).toBeNull(); + }); + + it('renders the per-thread initial circle', () => { + const fixture = render({ threads: [{ id: 't1', title: 'Hello world' }] }); + const initial = fixture.nativeElement.querySelector('.chat-thread-list__initial') as HTMLElement; + expect(initial).not.toBeNull(); + expect(initial.textContent?.trim()).toBe('H'); + }); + + it('initialOf falls back to "?" for empty titles', () => { + const fixture = render({ threads: [{ id: 't1', title: '' }] }); + // title falls back to id "t1" via threadLabel, so initial should be "T". + const initial = fixture.nativeElement.querySelector('.chat-thread-list__initial') as HTMLElement; + expect(initial.textContent?.trim()).toBe('T'); + }); + }); }); diff --git a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts index 57fec053..bbcfbcac 100644 --- a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts +++ b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts @@ -99,6 +99,7 @@ export interface ThreadActionAdapter { (dragleave)="onDragLeave($event, thread.id)" (drop)="onDrop($event, thread.id)" (dragend)="onDragEnd()" + (contextmenu)="onRowContextMenu(thread.id, $event)" > @if (templateRef()) { + @if (thread.pinned) {