Skip to content

Commit c37ed39

Browse files
committed
[LG-5650] feat(chat-layout): add hover expand and collapse functionality (#3278)
* feat(chat-layout): add header to ChatSideNavContent * fix(input-bar): rm redundant z-index and increase disclaimer text spacing * chore(input-bar): changeset * chore(chat-layout): rm icon-button dependency * feat(chat-layout): enhance ChatLayout with side nav hover state and transition effects * test(chat-layout): update stories * docs(chat-layout): README * refactor(input-bar): revert gap changes * refactor(chat-layout): expose shouldRenderExpanded from context and fix overflow
1 parent e9ded93 commit c37ed39

25 files changed

+817
-109
lines changed

.changeset/gold-goats-visit.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@lg-chat/input-bar': patch
3+
---
4+
5+
Remove redundant `z-index: 2;` in `InputBar` content wrapping node.

chat/chat-layout/README.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,25 +165,38 @@ All other props are passed through to the underlying `<div>` element.
165165
Hook that returns the current chat layout context:
166166

167167
```tsx
168-
const { isPinned, togglePin } = useChatLayoutContext();
168+
const {
169+
isPinned,
170+
togglePin,
171+
isSideNavHovered,
172+
setIsSideNavHovered,
173+
shouldRenderExpanded,
174+
} = useChatLayoutContext();
169175
```
170176

171177
**Returns:**
172178

173-
| Property | Type | Description |
174-
| ----------- | ------------ | ---------------------------------------- |
175-
| `isPinned` | `boolean` | Whether the side nav is currently pinned |
176-
| `togglePin` | `() => void` | Function to toggle the pinned state |
179+
| Property | Type | Description |
180+
| ---------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------- |
181+
| `isPinned` | `boolean` | Whether the side nav is currently pinned |
182+
| `togglePin` | `() => void` | Function to toggle the pinned state |
183+
| `isSideNavHovered` | `boolean` | Whether the side nav is currently being hovered |
184+
| `setIsSideNavHovered` | `(isHovered: boolean) => void` | Function to set the hover state of the side nav |
185+
| `shouldRenderExpanded` | `boolean` | Whether the side nav should render in expanded state. This is `true` when the nav is pinned OR hovered. |
177186

178187
## Behavior
179188

180189
### State Management
181190

182-
- `ChatLayout` manages the `isPinned` state internally and provides it to all descendants via `ChatLayoutContext`
191+
- `ChatLayout` manages the `isPinned` and `isSideNavHovered` state internally and provides it to all descendants via `ChatLayoutContext`
192+
- `shouldRenderExpanded` is computed as `isPinned || isSideNavHovered` and provided in the context for convenience
183193
- When `togglePin` is called:
184194
1. The `isPinned` state updates
185195
2. Grid columns resize smoothly via CSS transition
186196
3. The `onTogglePinned` callback fires (if provided) with the new state value
187197
- Descendant components can consume the context to:
188198
- Read the current `isPinned` state
189199
- Call `togglePin()` to toggle the sidebar
200+
- Read the current `isSideNavHovered` state
201+
- Call `setIsSideNavHovered()` to update the hover state
202+
- Use `shouldRenderExpanded` to determine if the side nav should render in expanded state

chat/chat-layout/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
"@leafygreen-ui/compound-component": "workspace:^",
3434
"@leafygreen-ui/emotion": "workspace:^",
3535
"@leafygreen-ui/icon": "workspace:^",
36-
"@leafygreen-ui/icon-button": "workspace:^",
3736
"@leafygreen-ui/lib": "workspace:^",
3837
"@leafygreen-ui/palette": "workspace:^",
3938
"@leafygreen-ui/polymorphic": "workspace:^",

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

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { MessageFeed } from '@lg-chat/message-feed';
1010
import { TitleBar } from '@lg-chat/title-bar';
1111
import { StoryMetaType } from '@lg-tools/storybook-utils';
1212
import { StoryFn, StoryObj } from '@storybook/react';
13+
import { userEvent, within } from '@storybook/test';
1314

1415
import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider';
1516

@@ -106,14 +107,15 @@ const Template: StoryFn<ChatLayoutProps> = props => {
106107
{testMessages.map(msg => (
107108
<Message
108109
key={msg.id}
110+
isSender={msg.isSender}
109111
messageBody={
110112
msg.id === '2'
111113
? `${msg.messageBody} ${chatItems
112114
.find(item => item.id === activeId)
113115
?.name.toLowerCase()}`
114116
: msg.messageBody
115117
}
116-
isSender={msg.isSender}
118+
sourceType="markdown"
117119
/>
118120
))}
119121
</MessageFeed>
@@ -133,3 +135,79 @@ export const LiveExample: StoryObj<ChatLayoutProps> = {
133135
},
134136
},
135137
};
138+
139+
export const PinnedLight: StoryObj<ChatLayoutProps> = {
140+
render: Template,
141+
args: {
142+
darkMode: false,
143+
initialIsPinned: true,
144+
},
145+
};
146+
147+
export const PinnedDark: StoryObj<ChatLayoutProps> = {
148+
render: Template,
149+
args: {
150+
darkMode: true,
151+
initialIsPinned: true,
152+
},
153+
};
154+
155+
export const UnpinnedLight: StoryObj<ChatLayoutProps> = {
156+
render: Template,
157+
args: {
158+
darkMode: false,
159+
initialIsPinned: false,
160+
},
161+
};
162+
163+
export const UnpinnedDark: StoryObj<ChatLayoutProps> = {
164+
render: Template,
165+
args: {
166+
darkMode: true,
167+
initialIsPinned: false,
168+
},
169+
};
170+
171+
export const UnpinnedAndHoveredLight: StoryObj<ChatLayoutProps> = {
172+
render: Template,
173+
args: {
174+
darkMode: false,
175+
initialIsPinned: false,
176+
},
177+
play: async ({ canvasElement }) => {
178+
const canvas = within(canvasElement);
179+
180+
// Find the side nav
181+
const sideNav = canvas.getByLabelText('Side navigation');
182+
183+
// Hover over the side nav
184+
await userEvent.hover(sideNav);
185+
},
186+
parameters: {
187+
chromatic: {
188+
delay: 350,
189+
},
190+
},
191+
};
192+
193+
export const UnpinnedAndHoveredDark: StoryObj<ChatLayoutProps> = {
194+
render: Template,
195+
args: {
196+
darkMode: true,
197+
initialIsPinned: false,
198+
},
199+
play: async ({ canvasElement }) => {
200+
const canvas = within(canvasElement);
201+
202+
// Find the side nav
203+
const sideNav = canvas.getByLabelText('Side navigation');
204+
205+
// Hover over the side nav
206+
await userEvent.hover(sideNav);
207+
},
208+
parameters: {
209+
chromatic: {
210+
delay: 350,
211+
},
212+
},
213+
};

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,5 +135,83 @@ describe('packages/chat-layout', () => {
135135
const element = screen.getByTestId('chat-layout');
136136
expect(element).toHaveClass('custom-class');
137137
});
138+
139+
test('provides isSideNavHovered context with default value', () => {
140+
const TestConsumer = () => {
141+
const { isSideNavHovered } = useChatLayoutContext();
142+
return <div>isSideNavHovered: {isSideNavHovered.toString()}</div>;
143+
};
144+
145+
render(
146+
<ChatLayout>
147+
<TestConsumer />
148+
</ChatLayout>,
149+
);
150+
expect(screen.getByText('isSideNavHovered: false')).toBeInTheDocument();
151+
});
152+
153+
test('setIsSideNavHovered function updates isSideNavHovered state', async () => {
154+
const TestConsumer = () => {
155+
const { isSideNavHovered, setIsSideNavHovered } =
156+
useChatLayoutContext();
157+
return (
158+
<>
159+
<div>isSideNavHovered: {isSideNavHovered.toString()}</div>
160+
<button onClick={() => setIsSideNavHovered(true)}>
161+
Set Hovered
162+
</button>
163+
<button onClick={() => setIsSideNavHovered(false)}>
164+
Set Not Hovered
165+
</button>
166+
</>
167+
);
168+
};
169+
170+
render(
171+
<ChatLayout>
172+
<TestConsumer />
173+
</ChatLayout>,
174+
);
175+
176+
expect(screen.getByText('isSideNavHovered: false')).toBeInTheDocument();
177+
178+
await userEvent.click(
179+
screen.getByRole('button', { name: 'Set Hovered' }),
180+
);
181+
expect(screen.getByText('isSideNavHovered: true')).toBeInTheDocument();
182+
183+
await userEvent.click(
184+
screen.getByRole('button', { name: 'Set Not Hovered' }),
185+
);
186+
expect(screen.getByText('isSideNavHovered: false')).toBeInTheDocument();
187+
});
188+
189+
test('togglePin updates isPinned state correctly when hovered', async () => {
190+
const TestConsumer = () => {
191+
const { isPinned, togglePin, isSideNavHovered } =
192+
useChatLayoutContext();
193+
return (
194+
<>
195+
<div>isPinned: {isPinned.toString()}</div>
196+
<div>isSideNavHovered: {isSideNavHovered.toString()}</div>
197+
<button onClick={togglePin}>Toggle</button>
198+
</>
199+
);
200+
};
201+
202+
render(
203+
<ChatLayout initialIsPinned={false}>
204+
<TestConsumer />
205+
</ChatLayout>,
206+
);
207+
208+
expect(screen.getByText('isPinned: false')).toBeInTheDocument();
209+
expect(screen.getByText('isSideNavHovered: false')).toBeInTheDocument();
210+
211+
await userEvent.click(screen.getByRole('button', { name: 'Toggle' }));
212+
213+
expect(screen.getByText('isPinned: true')).toBeInTheDocument();
214+
expect(screen.getByText('isSideNavHovered: false')).toBeInTheDocument();
215+
});
138216
});
139217
});
Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
import { css, cx } from '@leafygreen-ui/emotion';
2-
import { transitionDuration } from '@leafygreen-ui/tokens';
32

43
import {
4+
COLLAPSED_SIDE_NAV_WIDTH_WITH_BORDER,
55
gridAreas,
6-
SIDE_NAV_WIDTH_COLLAPSED,
7-
SIDE_NAV_WIDTH_PINNED,
6+
PINNED_SIDE_NAV_WIDTH_WITH_BORDER,
7+
SIDE_NAV_TRANSITION_DURATION,
88
} from '../constants';
99

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;
10+
const baseContainerStyles = css`
11+
overflow: hidden;
1612
height: 100%;
1713
width: 100%;
18-
transition: grid-template-columns ${transitionDuration.default}ms ease-in-out;
14+
max-height: 100vh;
15+
max-width: 100vw;
16+
display: grid;
17+
grid-template-areas: '${gridAreas.sideNav} ${gridAreas.main}';
18+
grid-template-columns: ${PINNED_SIDE_NAV_WIDTH_WITH_BORDER}px auto;
19+
transition: grid-template-columns ${SIDE_NAV_TRANSITION_DURATION}ms
20+
ease-in-out;
21+
`;
22+
23+
const collapsedContainerStyles = css`
24+
grid-template-columns: ${COLLAPSED_SIDE_NAV_WIDTH_WITH_BORDER}px auto;
1925
`;
2026

2127
export const getContainerStyles = ({
@@ -24,4 +30,11 @@ export const getContainerStyles = ({
2430
}: {
2531
className?: string;
2632
isPinned: boolean;
27-
}) => cx(getBaseContainerStyles(isPinned), className);
33+
}) =>
34+
cx(
35+
baseContainerStyles,
36+
{
37+
[collapsedContainerStyles]: !isPinned,
38+
},
39+
className,
40+
);

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useState } from 'react';
1+
import React, { useCallback, useMemo, useState } from 'react';
22

33
import { getContainerStyles } from './ChatLayout.styles';
44
import { ChatLayoutProps } from './ChatLayout.types';
@@ -17,15 +17,35 @@ export function ChatLayout({
1717
...rest
1818
}: ChatLayoutProps) {
1919
const [isPinned, setIsPinned] = useState(initialIsPinned);
20+
const [isSideNavHovered, setIsSideNavHovered] = useState(false);
2021

2122
const togglePin = useCallback(() => {
2223
const newValue = !isPinned;
2324
setIsPinned(newValue);
2425
onTogglePinned?.(newValue);
2526
}, [isPinned, onTogglePinned]);
2627

28+
const shouldRenderExpanded = isPinned || isSideNavHovered;
29+
30+
const value = useMemo(
31+
() => ({
32+
isPinned,
33+
togglePin,
34+
isSideNavHovered,
35+
setIsSideNavHovered,
36+
shouldRenderExpanded,
37+
}),
38+
[
39+
isPinned,
40+
togglePin,
41+
isSideNavHovered,
42+
setIsSideNavHovered,
43+
shouldRenderExpanded,
44+
],
45+
);
46+
2747
return (
28-
<ChatLayoutContext.Provider value={{ isPinned, togglePin }}>
48+
<ChatLayoutContext.Provider value={value}>
2949
<div className={getContainerStyles({ className, isPinned })} {...rest}>
3050
{children}
3151
</div>

chat/chat-layout/src/ChatLayout/ChatLayoutContext.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,30 @@ export interface ChatLayoutContextProps {
1010
* Function to toggle the pinned state of the side nav
1111
*/
1212
togglePin: () => void;
13+
14+
/**
15+
* Whether the side nav is currently being hovered
16+
*/
17+
isSideNavHovered: boolean;
18+
19+
/**
20+
* Function to set the hover state of the side nav
21+
*/
22+
setIsSideNavHovered: (isHovered: boolean) => void;
23+
24+
/**
25+
* Whether the side nav should render in expanded state.
26+
* This is true when the nav is pinned OR when it's being hovered.
27+
*/
28+
shouldRenderExpanded: boolean;
1329
}
1430

1531
export const ChatLayoutContext = createContext<ChatLayoutContextProps>({
1632
isPinned: true,
1733
togglePin: () => {},
34+
isSideNavHovered: false,
35+
setIsSideNavHovered: () => {},
36+
shouldRenderExpanded: true,
1837
});
1938

2039
export const useChatLayoutContext = () => useContext(ChatLayoutContext);

chat/chat-layout/src/ChatMain/ChatMain.styles.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { css, cx } from '@leafygreen-ui/emotion';
2+
import { spacing } from '@leafygreen-ui/tokens';
23

34
import { gridAreas } from '../constants';
45

6+
const CHAT_WINDOW_MAX_WIDTH = 800;
7+
58
const baseContainerStyles = css`
69
grid-area: ${gridAreas.main};
710
display: flex;
@@ -12,3 +15,12 @@ const baseContainerStyles = css`
1215

1316
export const getContainerStyles = ({ className }: { className?: string }) =>
1417
cx(baseContainerStyles, className);
18+
19+
export const chatWindowWrapperStyles = css`
20+
height: 100%;
21+
width: 100%;
22+
max-width: ${CHAT_WINDOW_MAX_WIDTH}px;
23+
padding: 0 ${spacing[800]}px;
24+
display: flex;
25+
align-self: center;
26+
`;

0 commit comments

Comments
 (0)