From cefee4a07e40783c593d459966f420514a179b93 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 17:26:33 -0700 Subject: [PATCH] =?UTF-8?q?feat(chat,examples-chat):=20sidenav=20polish=20?= =?UTF-8?q?=E2=80=94=20header=20trim,=20dark=20mode=20contrast,=20mobile?= =?UTF-8?q?=20drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes identified during visual review of #272: 1. Sidenav header trim. The expanded sidenav previously stacked three full-width rows (New chat, Search, Collapse) which felt heavy. Introduce a new .chat-sidenav__topbar row that hosts the New chat and Collapse/Expand buttons as icon-only controls with space-between, and keep the Search button as the sole full-width row in .chat-sidenav__actions. Labels on topbar buttons are hidden in every mode (display: none scoped to .chat-sidenav__topbar) so the topbar stays icon-only in expanded mode and centers in collapsed mode. 2. GenUI dark mode contrast. examples/chat/angular sets a dark body background via styles.css but never opted the chat lib into dark tokens, so --ngaf-chat-text stayed at rgb(28, 28, 28) and headings inside (including A2UI surface titles) inherited near-black on a dark background. Setting data-ngaf-chat-theme="dark" on activates the dark token block at libs/chat/src/lib/styles/chat.css so text becomes rgb(245, 245, 245) and headings are legible. 3. Mobile drawer fixes. Drawer mode incorrectly applied position: relative to the host (originally added in #272 to avoid reserving space when closed), which caused the host to stretch to its parent's full width and made the inner .chat-sidenav fill the entire viewport. The host is now position: fixed top/left/bottom with width set from --ngaf-chat-sidenav-width-drawer (280px), and the inner wrapper handles the translate-X open/close transition. A new icon-only Close (X) button is rendered in the topbar exclusively in drawer mode (aria-label "Close conversations"), wired to emit openChange(false). The dark-mode fix from item 2 also resolves the drawer rendering in light mode. Tests: - libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts: 5 new specs covering the topbar structure, search-row isolation, and the drawer-mode Close button. --- examples/chat/angular/src/index.html | 2 +- .../chat-sidenav.component.spec.ts | 33 +++++++++++++ .../chat-sidenav/chat-sidenav.component.ts | 46 +++++++++++++------ .../src/lib/styles/chat-sidenav.styles.ts | 44 +++++++++++++----- 4 files changed, 98 insertions(+), 27 deletions(-) diff --git a/examples/chat/angular/src/index.html b/examples/chat/angular/src/index.html index 5075242f..30b61a4c 100644 --- a/examples/chat/angular/src/index.html +++ b/examples/chat/angular/src/index.html @@ -1,5 +1,5 @@ - + NGAF chat — canonical demo diff --git a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts index 9e53dc6b..3047a3b7 100644 --- a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts @@ -139,6 +139,39 @@ describe('ChatSidenavComponent', () => { expect(fixture.nativeElement.querySelector('.chat-sidenav__action--collapse')).toBeNull(); }); + it('renders a topbar containing the new-chat and collapse buttons in expanded mode', () => { + const fixture = render({ mode: 'expanded' }); + const topbar = fixture.nativeElement.querySelector('.chat-sidenav__topbar') as HTMLElement; + expect(topbar).not.toBeNull(); + expect(topbar.querySelector('.chat-sidenav__action--new')).not.toBeNull(); + expect(topbar.querySelector('.chat-sidenav__action--collapse')).not.toBeNull(); + }); + + it('search button is the only action in .chat-sidenav__actions row', () => { + const fixture = render({ mode: 'expanded' }); + const actions = fixture.nativeElement.querySelector('.chat-sidenav__actions') as HTMLElement; + const buttons = actions.querySelectorAll('button'); + expect(buttons.length).toBe(1); + expect(buttons[0].classList.contains('chat-sidenav__action--search')).toBe(true); + }); + + it('drawer mode: renders a close button in the topbar that emits openChange(false)', () => { + const fixture = render({ mode: 'drawer', open: true }); + const topbar = fixture.nativeElement.querySelector('.chat-sidenav__topbar') as HTMLElement; + const close = topbar.querySelector('.chat-sidenav__action--close') as HTMLButtonElement; + expect(close).not.toBeNull(); + expect(close.getAttribute('aria-label')).toBe('Close conversations'); + let lastOpen: boolean | undefined; + fixture.componentInstance.openChange.subscribe((v: boolean) => { lastOpen = v; }); + close.click(); + expect(lastOpen).toBe(false); + }); + + it('non-drawer modes: no close button is rendered', () => { + expect(render({ mode: 'expanded' }).nativeElement.querySelector('.chat-sidenav__action--close')).toBeNull(); + expect(render({ mode: 'collapsed' }).nativeElement.querySelector('.chat-sidenav__action--close')).toBeNull(); + }); + it('clicking the chevron in expanded mode emits modeChange="collapsed"', () => { const fixture = render({ mode: 'expanded' }); let last: string | undefined; diff --git a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts index d439e14d..18999da7 100644 --- a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts +++ b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts @@ -50,7 +50,7 @@ export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer'; -
+
- @if (mode() !== 'drawer') { } + @if (mode() === 'drawer') { + + } +
+ +
+
diff --git a/libs/chat/src/lib/styles/chat-sidenav.styles.ts b/libs/chat/src/lib/styles/chat-sidenav.styles.ts index 45fff77d..65879817 100644 --- a/libs/chat/src/lib/styles/chat-sidenav.styles.ts +++ b/libs/chat/src/lib/styles/chat-sidenav.styles.ts @@ -33,7 +33,12 @@ export const CHAT_SIDENAV_STYLES = ` width: 100%; } :host([data-mode="drawer"]) { - position: relative; + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: var(--ngaf-chat-sidenav-width-drawer); + z-index: 1001; } .chat-sidenav { display: flex; @@ -54,28 +59,43 @@ export const CHAT_SIDENAV_STYLES = ` cursor: pointer; } :host([data-mode="drawer"]) .chat-sidenav { - position: fixed; - top: 0; - bottom: 0; - left: 0; - width: var(--ngaf-chat-sidenav-width-drawer); - z-index: 1001; + width: 100%; + height: 100%; transition: transform 200ms ease; transform: translateX(-100%); } :host([data-mode="drawer"][data-open="true"]) .chat-sidenav { transform: translateX(0); } - @media (max-width: 767px) { - :host([data-mode="drawer"]) .chat-sidenav { - width: 100%; - } - } .chat-sidenav__header { flex-shrink: 0; padding: var(--ngaf-chat-space-3); border-bottom: 1px solid var(--ngaf-chat-separator); } + .chat-sidenav__topbar { + flex-shrink: 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 4px; + padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-3); + border-bottom: 1px solid var(--ngaf-chat-separator); + } + :host([data-mode="collapsed"]) .chat-sidenav__topbar { + justify-content: center; + padding: var(--ngaf-chat-space-2); + } + .chat-sidenav__topbar .chat-sidenav__action { + width: 36px; + height: 36px; + padding: 0; + justify-content: center; + flex: 0 0 auto; + } + .chat-sidenav__topbar .chat-sidenav__action-label { + display: none; + } .chat-sidenav__actions { flex-shrink: 0; display: flex;