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) {