Skip to content

Commit aa3b990

Browse files
committed
[LG-5646] feat(chat-layout): implement ChatSideNav component with Header and Content subcomponents (#3271)
* chore(chat-layout): add deps for ChatSideNav * feat(chat-layout): implement ChatSideNav and subcomponents * test(chat-layout): update stories * docs(chat-layout): README * fix(chat-layout): apply focus styles on :focus-visible * refactor(chat-layout): PR feedback * refactor(chat-layout): ChatSideNav only renders header and content
1 parent 681d5e7 commit aa3b990

20 files changed

+606
-40
lines changed

chat/chat-layout/README.md

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,23 +31,29 @@ npm install @lg-chat/chat-layout
3131
This package exports:
3232

3333
- `ChatLayout`: The grid container and context provider
34-
- `ChatMain`: Content area component that positions itself in the grid
34+
- `ChatMain`: The primary content area of the chat interface, automatically positioned within the grid layout.
35+
- `ChatSideNav`: A compound component representing the side navigation, exposing subcomponents such as `ChatSideNav.Header` and `ChatSideNav.Content` for flexible composition.
3536
- `useChatLayoutContext`: Hook for accessing layout state
3637

3738
## Examples
3839

3940
### Basic
4041

4142
```tsx
42-
import { ChatLayout, ChatMain } from '@lg-chat/chat-layout';
43+
import { ChatLayout, ChatMain, ChatSideNav } from '@lg-chat/chat-layout';
4344

4445
function MyChatApp() {
46+
const handleNewChat = () => {
47+
console.log('Start new chat');
48+
};
49+
4550
return (
4651
<ChatLayout>
47-
{/* ChatSideNav will go here */}
48-
<ChatMain>
49-
<div>Your chat content</div>
50-
</ChatMain>
52+
<ChatSideNav>
53+
<ChatSideNav.Header onClickNewChat={handleNewChat} />
54+
<ChatSideNav.Content>{/* Your side nav content */}</ChatSideNav.Content>
55+
</ChatSideNav>
56+
<ChatMain>{/* Main chat content here */}</ChatMain>
5157
</ChatLayout>
5258
);
5359
}
@@ -56,19 +62,24 @@ function MyChatApp() {
5662
### With Initial State and Toggle Pinned Callback
5763

5864
```tsx
59-
import { ChatLayout, ChatMain } from '@lg-chat/chat-layout';
65+
import { ChatLayout, ChatMain, ChatSideNav } from '@lg-chat/chat-layout';
6066

6167
function MyChatApp() {
68+
const handleNewChat = () => {
69+
console.log('Start new chat');
70+
};
71+
6272
const handleTogglePinned = (isPinned: boolean) => {
6373
console.log('Side nav is now:', isPinned ? 'pinned' : 'collapsed');
6474
};
6575

6676
return (
6777
<ChatLayout initialIsPinned={false} onTogglePinned={handleTogglePinned}>
68-
{/* ChatSideNav will go here */}
69-
<ChatMain>
70-
<div>Your chat content</div>
71-
</ChatMain>
78+
<ChatSideNav>
79+
<ChatSideNav.Header onClickNewChat={handleNewChat} />
80+
<ChatSideNav.Content>{/* Your side nav content */}</ChatSideNav.Content>
81+
</ChatSideNav>
82+
<ChatMain>{/* Main chat content here */}</ChatMain>
7283
</ChatLayout>
7384
);
7485
}
@@ -97,6 +108,29 @@ All other props are passed through to the underlying `<div>` element.
97108

98109
**Note:** `ChatMain` must be used as a direct child of `ChatLayout` to work correctly within the grid system.
99110

111+
### ChatSideNav
112+
113+
| Prop | Type | Description | Default |
114+
| ------------------------ | ------------------------- | -------------------------------------------------------------- | ------- |
115+
| `children` | `ReactNode` | Should include `ChatSideNav.Header` and `ChatSideNav.Content`. | - |
116+
| `className` _(optional)_ | `string` | Root class name | - |
117+
| `...` | `HTMLElementProps<'nav'>` | Props spread on the root `<nav>` element | - |
118+
119+
### ChatSideNav.Header
120+
121+
| Prop | Type | Description | Default |
122+
| ----------------------------- | -------------------------------------- | ------------------------------------------- | ------- |
123+
| `onClickNewChat` _(optional)_ | `MouseEventHandler<HTMLButtonElement>` | Fired when the "New Chat" button is clicked | - |
124+
| `className` _(optional)_ | `string` | Header class name | - |
125+
| `...` | `HTMLElementProps<'div'>` | Props spread on the header container | - |
126+
127+
### ChatSideNav.Content
128+
129+
| Prop | Type | Description | Default |
130+
| ------------------------ | ------------------------- | ------------------------------------- | ------- |
131+
| `className` _(optional)_ | `string` | Content class name | - |
132+
| `...` | `HTMLElementProps<'div'>` | Props spread on the content container | - |
133+
100134
## Context API
101135

102136
### useChatLayoutContext

chat/chat-layout/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,19 @@
2828
"access": "public"
2929
},
3030
"dependencies": {
31+
"@leafygreen-ui/avatar": "workspace:^",
32+
"@leafygreen-ui/button": "workspace:^",
33+
"@leafygreen-ui/compound-component": "workspace:^",
3134
"@leafygreen-ui/emotion": "workspace:^",
35+
"@leafygreen-ui/icon": "workspace:^",
36+
"@leafygreen-ui/icon-button": "workspace:^",
3237
"@leafygreen-ui/lib": "workspace:^",
3338
"@leafygreen-ui/tokens": "workspace:^",
39+
"@leafygreen-ui/typography": "workspace:^",
3440
"@lg-tools/test-harnesses": "workspace:^"
3541
},
3642
"peerDependencies": {
43+
"@leafygreen-ui/leafygreen-provider": ">=3.2.0",
3744
"@lg-chat/leafygreen-chat-provider": "workspace:^"
3845
},
3946
"devDependencies": {

chat/chat-layout/src/ChatLayout.stories.tsx

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,27 @@ import { TitleBar } from '@lg-chat/title-bar';
1111
import { StoryMetaType } from '@lg-tools/storybook-utils';
1212
import { StoryFn, StoryObj } from '@storybook/react';
1313

14-
import { css } from '@leafygreen-ui/emotion';
14+
import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider';
1515

16-
import { ChatLayout, type ChatLayoutProps, ChatMain } from '.';
16+
import { ChatLayout, type ChatLayoutProps, ChatMain, ChatSideNav } from '.';
17+
18+
const testMessages = [
19+
{
20+
id: '1',
21+
messageBody: 'Hello! How can I help you today?',
22+
isSender: false,
23+
},
24+
{
25+
id: '2',
26+
messageBody: 'I need help with my database query.',
27+
},
28+
{
29+
id: '3',
30+
messageBody:
31+
'Sure! I can help with that. What specific issue are you encountering?',
32+
isSender: false,
33+
},
34+
];
1735

1836
const meta: StoryMetaType<typeof ChatLayout> = {
1937
title: 'Composition/Chat/ChatLayout',
@@ -22,49 +40,33 @@ const meta: StoryMetaType<typeof ChatLayout> = {
2240
default: 'LiveExample',
2341
},
2442
decorators: [
25-
Story => (
43+
(Story, context) => (
2644
<div
2745
style={{
2846
margin: '-100px',
2947
height: '100vh',
3048
width: '100vw',
3149
}}
3250
>
33-
<Story />
51+
<LeafyGreenProvider darkMode={context?.args.darkMode}>
52+
<Story />
53+
</LeafyGreenProvider>
3454
</div>
3555
),
3656
],
3757
};
3858
export default meta;
3959

40-
const sideNavPlaceholderStyles = css`
41-
background-color: rgba(0, 0, 0, 0.05);
42-
padding: 16px;
43-
min-width: 200px;
44-
`;
45-
46-
const testMessages = [
47-
{
48-
id: '1',
49-
messageBody: 'Hello! How can I help you today?',
50-
isSender: false,
51-
},
52-
{
53-
id: '2',
54-
messageBody: 'I need help with my database query.',
55-
},
56-
{
57-
id: '3',
58-
messageBody:
59-
'Sure! I can help with that. What specific issue are you encountering?',
60-
isSender: false,
61-
},
62-
];
63-
6460
const Template: StoryFn<ChatLayoutProps> = props => (
6561
<LeafyGreenChatProvider variant={Variant.Compact}>
6662
<ChatLayout {...props}>
67-
<div className={sideNavPlaceholderStyles}>ChatSideNav Placeholder</div>
63+
<ChatSideNav>
64+
<ChatSideNav.Header
65+
// eslint-disable-next-line no-console
66+
onClickNewChat={() => console.log('Clicked new chat')}
67+
/>
68+
<ChatSideNav.Content>Content</ChatSideNav.Content>
69+
</ChatSideNav>
6870
<ChatMain>
6971
<TitleBar title="Chat Assistant" />
7072
<ChatWindow>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import React from 'react';
2+
import {
3+
LeafyGreenChatProvider,
4+
Variant,
5+
} from '@lg-chat/leafygreen-chat-provider';
6+
import { render, screen } from '@testing-library/react';
7+
import userEvent from '@testing-library/user-event';
8+
9+
import { ChatSideNav } from '.';
10+
11+
const Providers = ({ children }: { children: React.ReactNode }) => (
12+
<LeafyGreenChatProvider variant={Variant.Compact}>
13+
{children}
14+
</LeafyGreenChatProvider>
15+
);
16+
17+
describe('ChatSideNav', () => {
18+
beforeAll(() => {
19+
global.ResizeObserver = jest.fn().mockImplementation(() => ({
20+
observe: jest.fn(),
21+
unobserve: jest.fn(),
22+
disconnect: jest.fn(),
23+
}));
24+
});
25+
26+
afterEach(() => {
27+
jest.clearAllMocks();
28+
});
29+
30+
test('Header shows "New Chat" button when onClickNewChat provided', async () => {
31+
const onClickNewChat = jest.fn();
32+
33+
render(
34+
<Providers>
35+
<ChatSideNav>
36+
<ChatSideNav.Header onClickNewChat={onClickNewChat} />
37+
<ChatSideNav.Content />
38+
</ChatSideNav>
39+
</Providers>,
40+
);
41+
42+
const button = screen.getByRole('button', { name: /new chat/i });
43+
expect(button).toBeInTheDocument();
44+
45+
await userEvent.click(button);
46+
expect(onClickNewChat).toHaveBeenCalledTimes(1);
47+
});
48+
49+
test('Header does not render "New Chat" button when onClickNewChat is absent', () => {
50+
render(
51+
<Providers>
52+
<ChatSideNav>
53+
<ChatSideNav.Header />
54+
<ChatSideNav.Content />
55+
</ChatSideNav>
56+
</Providers>,
57+
);
58+
59+
expect(
60+
screen.queryByRole('button', { name: /new chat/i }),
61+
).not.toBeInTheDocument();
62+
});
63+
64+
test('does not render children that are not ChatSideNavHeader or ChatSideNavContent', () => {
65+
render(
66+
<Providers>
67+
<ChatSideNav>
68+
<ChatSideNav.Header />
69+
<ChatSideNav.Content />
70+
<div data-testid="arbitrary-child">Should not render</div>
71+
<span data-testid="another-arbitrary-child">
72+
Also should not render
73+
</span>
74+
</ChatSideNav>
75+
</Providers>,
76+
);
77+
78+
expect(screen.queryByTestId('arbitrary-child')).not.toBeInTheDocument();
79+
expect(
80+
screen.queryByTestId('another-arbitrary-child'),
81+
).not.toBeInTheDocument();
82+
});
83+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { css, cx } from '@leafygreen-ui/emotion';
2+
import { Theme } from '@leafygreen-ui/lib';
3+
import { color, InteractionState, Variant } from '@leafygreen-ui/tokens';
4+
5+
import { gridAreas } from '../constants';
6+
7+
const getBaseContainerStyles = (theme: Theme) => css`
8+
grid-area: ${gridAreas.sideNav};
9+
background: ${color[theme].background[Variant.Secondary][
10+
InteractionState.Default
11+
]};
12+
border-right: 1px solid
13+
${color[theme].border[Variant.Secondary][InteractionState.Default]};
14+
display: flex;
15+
flex-direction: column;
16+
height: 100%;
17+
overflow: hidden;
18+
`;
19+
20+
export const getContainerStyles = ({
21+
className,
22+
theme,
23+
}: {
24+
className?: string;
25+
theme: Theme;
26+
}) => cx(getBaseContainerStyles(theme), className);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React, { forwardRef } from 'react';
2+
3+
import {
4+
CompoundComponent,
5+
findChild,
6+
} from '@leafygreen-ui/compound-component';
7+
import LeafyGreenProvider, {
8+
useDarkMode,
9+
} from '@leafygreen-ui/leafygreen-provider';
10+
11+
import { getContainerStyles } from './ChatSideNav.styles';
12+
import {
13+
type ChatSideNavProps,
14+
ChatSideNavSubcomponentProperty,
15+
} from './ChatSideNav.types';
16+
import { ChatSideNavContent } from './ChatSideNavContent';
17+
import { ChatSideNavFooter } from './ChatSideNavFooter';
18+
import { ChatSideNavHeader } from './ChatSideNavHeader';
19+
20+
export const ChatSideNav = CompoundComponent(
21+
// eslint-disable-next-line react/display-name
22+
forwardRef<HTMLElement, ChatSideNavProps>(
23+
({ children, className, darkMode: darkModeProp, ...rest }, ref) => {
24+
const { darkMode, theme } = useDarkMode(darkModeProp);
25+
// Find subcomponents
26+
const header = findChild(
27+
children,
28+
ChatSideNavSubcomponentProperty.Header,
29+
);
30+
const content = findChild(
31+
children,
32+
ChatSideNavSubcomponentProperty.Content,
33+
);
34+
35+
return (
36+
<LeafyGreenProvider darkMode={darkMode}>
37+
<nav
38+
ref={ref}
39+
className={getContainerStyles({ className, theme })}
40+
aria-label="Side navigation"
41+
{...rest}
42+
>
43+
{header}
44+
{content}
45+
<ChatSideNavFooter />
46+
</nav>
47+
</LeafyGreenProvider>
48+
);
49+
},
50+
),
51+
{
52+
displayName: 'ChatSideNav',
53+
Header: ChatSideNavHeader,
54+
Content: ChatSideNavContent,
55+
},
56+
);

0 commit comments

Comments
 (0)