Skip to content

Commit 9b2a57d

Browse files
authored
feat(chat-layout): new @lg-chat/chat-layout package with ChatLayout and ChatLayoutContext (#3251)
1 parent ccb9e11 commit 9b2a57d

File tree

16 files changed

+509
-1
lines changed

16 files changed

+509
-1
lines changed

.changeset/chat-layout.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@lg-chat/chat-layout': minor
3+
---
4+
5+
Initial release of `ChatLayout`

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ import Button from '@leafygreen-ui/button';
149149
| [@lg-charts/legend](./charts/legend) | [![version](https://img.shields.io/npm/v/@lg-charts/legend)](https://www.npmjs.com/package/@lg-charts/legend) | ![downloads](https://img.shields.io/npm/dm/@lg-charts/legend?color=white) | [Live Example](http://mongodb.design/component/legend/live-example) |
150150
| [@lg-charts/series-provider](./charts/series-provider) | [![version](https://img.shields.io/npm/v/@lg-charts/series-provider)](https://www.npmjs.com/package/@lg-charts/series-provider) | ![downloads](https://img.shields.io/npm/dm/@lg-charts/series-provider?color=white) | [Live Example](http://mongodb.design/component/series-provider/live-example) |
151151
| [@lg-chat/avatar](./chat/avatar) | [![version](https://img.shields.io/npm/v/@lg-chat/avatar)](https://www.npmjs.com/package/@lg-chat/avatar) | ![downloads](https://img.shields.io/npm/dm/@lg-chat/avatar?color=white) | [Live Example](http://mongodb.design/component/avatar/live-example) |
152+
| [@lg-chat/chat-layout](./chat/chat-layout) | [![version](https://img.shields.io/npm/v/@lg-chat/chat-layout)](https://www.npmjs.com/package/@lg-chat/chat-layout) | ![downloads](https://img.shields.io/npm/dm/@lg-chat/chat-layout?color=white) | [Live Example](http://mongodb.design/component/chat-layout/live-example) |
152153
| [@lg-chat/chat-window](./chat/chat-window) | [![version](https://img.shields.io/npm/v/@lg-chat/chat-window)](https://www.npmjs.com/package/@lg-chat/chat-window) | ![downloads](https://img.shields.io/npm/dm/@lg-chat/chat-window?color=white) | [Live Example](http://mongodb.design/component/chat-window/live-example) |
153154
| [@lg-chat/fixed-chat-window](./chat/fixed-chat-window) | [![version](https://img.shields.io/npm/v/@lg-chat/fixed-chat-window)](https://www.npmjs.com/package/@lg-chat/fixed-chat-window) | ![downloads](https://img.shields.io/npm/dm/@lg-chat/fixed-chat-window?color=white) | [Live Example](http://mongodb.design/component/fixed-chat-window/live-example) |
154155
| [@lg-chat/input-bar](./chat/input-bar) | [![version](https://img.shields.io/npm/v/@lg-chat/input-bar)](https://www.npmjs.com/package/@lg-chat/input-bar) | ![downloads](https://img.shields.io/npm/dm/@lg-chat/input-bar?color=white) | [Live Example](http://mongodb.design/component/input-bar/live-example) |

chat/chat-layout/README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Chat Layout
2+
3+
![npm (scoped)](https://img.shields.io/npm/v/@lg-chat/chat-layout.svg)
4+
5+
#### [View on MongoDB.design](https://www.mongodb.design/component/chat-layout/live-example/)
6+
7+
## Installation
8+
9+
### PNPM
10+
11+
```shell
12+
pnpm add @lg-chat/chat-layout
13+
```
14+
15+
### Yarn
16+
17+
```shell
18+
yarn add @lg-chat/chat-layout
19+
```
20+
21+
### NPM
22+
23+
```shell
24+
npm install @lg-chat/chat-layout
25+
```
26+
27+
## Overview
28+
29+
`@lg-chat/chat-layout` provides a CSS Grid-based layout system for building full-screen chat interfaces with a collapsible side nav.
30+
31+
## Examples
32+
33+
```tsx
34+
import { ChatLayout } from '@lg-chat/chat-layout';
35+
36+
function MyChatApp() {
37+
const handleToggle = (isPinned: boolean) => {
38+
console.log('Side nav is now:', isPinned ? 'pinned' : 'collapsed');
39+
};
40+
41+
return (
42+
<ChatLayout initialIsPinned={true} onTogglePinned={handleToggle}>
43+
{/* ChatSideNav and ChatMain components will go here */}
44+
</ChatLayout>
45+
);
46+
}
47+
```
48+
49+
## Properties
50+
51+
### ChatLayout
52+
53+
| Prop | Type | Description | Default |
54+
| ------------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------- | ------- |
55+
| `children` | `ReactNode` | The content to render inside the grid layout (`ChatSideNav` and `ChatMain` components) | - |
56+
| `className` _(optional)_ | `string` | Custom CSS class to apply to the grid container | - |
57+
| `initialIsPinned` _(optional)_ | `boolean` | Initial state for whether the side nav is pinned (expanded) | `true` |
58+
| `onTogglePinned` _(optional)_ | `(isPinned: boolean) => void` | Callback fired when the side nav is toggled. Receives the new `isPinned` state as an argument | - |
59+
60+
All other props are passed through to the underlying `<div>` element.
61+
62+
## Context API
63+
64+
### useChatLayoutContext
65+
66+
Hook that returns the current chat layout context:
67+
68+
```tsx
69+
const { isPinned, togglePin } = useChatLayoutContext();
70+
```
71+
72+
**Returns:**
73+
74+
| Property | Type | Description |
75+
| ----------- | ------------ | ---------------------------------------- |
76+
| `isPinned` | `boolean` | Whether the side nav is currently pinned |
77+
| `togglePin` | `() => void` | Function to toggle the pinned state |
78+
79+
## Behavior
80+
81+
### State Management
82+
83+
- `ChatLayout` manages the `isPinned` state internally and provides it to all descendants via `ChatLayoutContext`
84+
- When `togglePin` is called:
85+
1. The `isPinned` state updates
86+
2. Grid columns resize smoothly via CSS transition
87+
3. The `onTogglePinned` callback fires (if provided) with the new state value
88+
- Descendant components can consume the context to:
89+
- Read the current `isPinned` state
90+
- Call `togglePin()` to toggle the sidebar

chat/chat-layout/package.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
2+
{
3+
"name": "@lg-chat/chat-layout",
4+
"version": "0.0.1",
5+
"description": "LeafyGreen UI Kit Chat Layout",
6+
"main": "./dist/umd/index.js",
7+
"module": "./dist/esm/index.js",
8+
"types": "./dist/types/index.d.ts",
9+
"license": "Apache-2.0",
10+
"exports": {
11+
".": {
12+
"require": "./dist/umd/index.js",
13+
"import": "./dist/esm/index.js",
14+
"types": "./dist/types/index.d.ts"
15+
},
16+
"./testing": {
17+
"require": "./dist/umd/testing/index.js",
18+
"import": "./dist/esm/testing/index.js",
19+
"types": "./dist/types/testing/index.d.ts"
20+
}
21+
},
22+
"scripts": {
23+
"build": "lg-build bundle",
24+
"tsc": "lg-build tsc",
25+
"docs": "lg-build docs"
26+
},
27+
"publishConfig": {
28+
"access": "public"
29+
},
30+
"dependencies": {
31+
"@leafygreen-ui/emotion": "workspace:^",
32+
"@leafygreen-ui/lib": "workspace:^",
33+
"@leafygreen-ui/tokens": "workspace:^",
34+
"@lg-tools/test-harnesses": "workspace:^"
35+
},
36+
"homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/chat/chat-layout",
37+
"repository": {
38+
"type": "git",
39+
"url": "https://github.com/mongodb/leafygreen-ui"
40+
},
41+
"bugs": {
42+
"url": "https://jira.mongodb.org/projects/LG/summary"
43+
}
44+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from 'react';
2+
import { StoryMetaType } from '@lg-tools/storybook-utils';
3+
import { StoryFn, StoryObj } from '@storybook/react';
4+
5+
import { ChatLayout, type ChatLayoutProps } from '.';
6+
7+
const meta: StoryMetaType<typeof ChatLayout> = {
8+
title: 'Composition/Chat/ChatLayout',
9+
component: ChatLayout,
10+
parameters: {
11+
default: 'LiveExample',
12+
},
13+
};
14+
export default meta;
15+
16+
const Template: StoryFn<ChatLayoutProps> = props => <ChatLayout {...props} />;
17+
18+
export const LiveExample: StoryObj<ChatLayoutProps> = {
19+
render: Template,
20+
parameters: {
21+
chromatic: {
22+
disableSnapshot: true,
23+
},
24+
},
25+
};
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
5+
import { ChatLayout, useChatLayoutContext } from '.';
6+
7+
describe('packages/chat-layout', () => {
8+
describe('ChatLayout', () => {
9+
test('renders children', () => {
10+
render(
11+
<ChatLayout>
12+
<div>Test Content</div>
13+
</ChatLayout>,
14+
);
15+
expect(screen.getByText('Test Content')).toBeInTheDocument();
16+
});
17+
18+
test('provides isPinned context with default value', () => {
19+
const TestConsumer = () => {
20+
const { isPinned } = useChatLayoutContext();
21+
return <div>isPinned: {isPinned.toString()}</div>;
22+
};
23+
24+
render(
25+
<ChatLayout>
26+
<TestConsumer />
27+
</ChatLayout>,
28+
);
29+
expect(screen.getByText('isPinned: true')).toBeInTheDocument();
30+
});
31+
32+
test('accepts initialIsPinned prop', () => {
33+
const TestConsumer = () => {
34+
const { isPinned } = useChatLayoutContext();
35+
return <div>isPinned: {isPinned.toString()}</div>;
36+
};
37+
38+
render(
39+
<ChatLayout initialIsPinned={false}>
40+
<TestConsumer />
41+
</ChatLayout>,
42+
);
43+
expect(screen.getByText('isPinned: false')).toBeInTheDocument();
44+
});
45+
46+
test('togglePin function updates isPinned state', async () => {
47+
const TestConsumer = () => {
48+
const { isPinned, togglePin } = useChatLayoutContext();
49+
return (
50+
<>
51+
<div>isPinned: {isPinned.toString()}</div>
52+
<button onClick={togglePin}>Toggle</button>
53+
</>
54+
);
55+
};
56+
57+
render(
58+
<ChatLayout>
59+
<TestConsumer />
60+
</ChatLayout>,
61+
);
62+
63+
expect(screen.getByText('isPinned: true')).toBeInTheDocument();
64+
65+
await userEvent.click(screen.getByRole('button', { name: 'Toggle' }));
66+
67+
expect(screen.getByText('isPinned: false')).toBeInTheDocument();
68+
69+
await userEvent.click(screen.getByRole('button', { name: 'Toggle' }));
70+
71+
expect(screen.getByText('isPinned: true')).toBeInTheDocument();
72+
});
73+
74+
test('forwards HTML attributes to the div wrapper', () => {
75+
render(
76+
<ChatLayout data-testid="chat-layout">
77+
<div>Content</div>
78+
</ChatLayout>,
79+
);
80+
expect(screen.getByTestId('chat-layout')).toBeInTheDocument();
81+
});
82+
83+
test('calls onTogglePinned callback when togglePin is called', async () => {
84+
const onTogglePinned = jest.fn();
85+
86+
const TestConsumer = () => {
87+
const { togglePin } = useChatLayoutContext();
88+
return <button onClick={togglePin}>Toggle</button>;
89+
};
90+
91+
render(
92+
<ChatLayout onTogglePinned={onTogglePinned}>
93+
<TestConsumer />
94+
</ChatLayout>,
95+
);
96+
97+
const toggleButton = screen.getByRole('button', { name: 'Toggle' });
98+
99+
await userEvent.click(toggleButton);
100+
101+
expect(onTogglePinned).toHaveBeenCalledTimes(1);
102+
expect(onTogglePinned).toHaveBeenCalledWith(false);
103+
104+
await userEvent.click(toggleButton);
105+
106+
expect(onTogglePinned).toHaveBeenCalledTimes(2);
107+
expect(onTogglePinned).toHaveBeenCalledWith(true);
108+
});
109+
110+
test('onTogglePinned receives correct isPinned value based on initialIsPinned', async () => {
111+
const onTogglePinned = jest.fn();
112+
113+
const TestConsumer = () => {
114+
const { togglePin } = useChatLayoutContext();
115+
return <button onClick={togglePin}>Toggle</button>;
116+
};
117+
118+
render(
119+
<ChatLayout initialIsPinned={false} onTogglePinned={onTogglePinned}>
120+
<TestConsumer />
121+
</ChatLayout>,
122+
);
123+
124+
await userEvent.click(screen.getByRole('button', { name: 'Toggle' }));
125+
126+
expect(onTogglePinned).toHaveBeenCalledWith(true);
127+
});
128+
129+
test('applies custom className', () => {
130+
render(
131+
<ChatLayout data-testid="chat-layout" className="custom-class">
132+
<div>Content</div>
133+
</ChatLayout>,
134+
);
135+
const element = screen.getByTestId('chat-layout');
136+
expect(element).toHaveClass('custom-class');
137+
});
138+
});
139+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { css, cx } from '@leafygreen-ui/emotion';
2+
import { transitionDuration } from '@leafygreen-ui/tokens';
3+
4+
import {
5+
gridAreas,
6+
SIDE_NAV_WIDTH_COLLAPSED,
7+
SIDE_NAV_WIDTH_PINNED,
8+
} from '../constants';
9+
10+
const getBaseContainerStyles = (isPinned: boolean) => css`
11+
display: grid;
12+
grid-template-areas: '${gridAreas.sideNav} ${gridAreas.main}';
13+
grid-template-columns: ${isPinned
14+
? `${SIDE_NAV_WIDTH_PINNED}px`
15+
: `${SIDE_NAV_WIDTH_COLLAPSED}px`} 1fr;
16+
height: 100%;
17+
width: 100%;
18+
transition: grid-template-columns ${transitionDuration.default}ms ease-in-out;
19+
`;
20+
21+
export const getContainerStyles = ({
22+
className,
23+
isPinned,
24+
}: {
25+
className?: string;
26+
isPinned: boolean;
27+
}) => cx(getBaseContainerStyles(isPinned), className);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React, { useCallback, useState } from 'react';
2+
3+
import { getContainerStyles } from './ChatLayout.styles';
4+
import { ChatLayoutProps } from './ChatLayout.types';
5+
import { ChatLayoutContext } from './ChatLayoutContext';
6+
7+
/**
8+
* ChatLayout is a context provider that manages the pinned state of the side nav
9+
* and provides it to all child components.
10+
*
11+
* Context is primarily used by ChatSideNav and ChatMain.
12+
*/
13+
export function ChatLayout({
14+
children,
15+
className,
16+
initialIsPinned = true,
17+
onTogglePinned,
18+
...rest
19+
}: ChatLayoutProps) {
20+
const [isPinned, setIsPinned] = useState(initialIsPinned);
21+
22+
const togglePin = useCallback(() => {
23+
const newValue = !isPinned;
24+
setIsPinned(newValue);
25+
onTogglePinned?.(newValue);
26+
}, [isPinned, onTogglePinned]);
27+
28+
return (
29+
<ChatLayoutContext.Provider value={{ isPinned, togglePin }}>
30+
<div className={getContainerStyles({ className, isPinned })} {...rest}>
31+
{children}
32+
</div>
33+
</ChatLayoutContext.Provider>
34+
);
35+
}
36+
37+
ChatLayout.displayName = 'ChatLayout';

0 commit comments

Comments
 (0)