Skip to content

Commit dc16bb5

Browse files
authored
feat!: externalize sidebar toggle and remove navOpen state from the SDK (#3088)
### 🎯 Goal The SDK currently couples itself to a specific layout pattern (collapsible sidebar) by owning `navOpen` state in `ChatContext`. This creates problems — e.g., the sidebar toggle renders even when no sidebar exists — and prevents apps from fully controlling their own layout. This PR removes all sidebar state management from the SDK and makes the app fully responsible for sidebar visibility. ### 🛠 Implementation details **Sidebar toggle externalized (commit 1-3):** - Removed `ToggleSidebarButton` component from the SDK - Added `SidebarToggle` slot to `ComponentContext` — apps provide their own toggle via `WithComponents` - All 4 headers (ChannelHeader, ChannelListHeader, ThreadHeader, ThreadListHeader) render the slot when provided - Removed `IconSidebar` (moved to vite example), `MenuIcon` prop, `ToggleButtonIcon` prop **navOpen state removed (commit 4):** - Removed `navOpen`, `closeMobileNav`, `openMobileNav` from `ChatContextValue` - Removed `initialNavOpen` from `ChatProps` - Removed `NAV_SIDEBAR_DESKTOP_BREAKPOINT` constant, `useMobileNavigation` hook, `useIsMobileViewport` hook - Removed auto-close-on-channel-selection (mobile) from `setActiveChannel` - Removed auto-close-on-thread-click from `ThreadListItemUI` - Removed `openMobileNav` calls from `ChatViewSelector` buttons - Removed all 7 navOpen-dependent CSS classes and their SCSS rules from 6 files **Vite example updated:** - New `SidebarContext` (`SidebarProvider` + `useSidebar`) replaces SDK nav state - `SidebarToggle` uses app-level sidebar state - Layout panels read `sidebarOpen` from app context - Resize handle toggles app-level sidebar state - CSS rules hide the expand toggle when sidebar is visible (mutual exclusivity) ### 🎨 UI Changes No visual changes in the vite example — sidebar toggle behavior is identical. Apps that don't provide a `SidebarToggle` will have no toggle button in any header. ### ⚠️ Breaking changes | Removed API | Migration | |-------------|-----------| | `ChatContextValue.navOpen` | Own your sidebar state | | `ChatContextValue.closeMobileNav` / `openMobileNav` | Own your toggle functions | | `ChatProps.initialNavOpen` | Move to own state initializer | | `NAV_SIDEBAR_DESKTOP_BREAKPOINT` | Define own constant | | `useMobileNavigation` hook | Implement own click-outside logic | | `ToggleSidebarButton` component | Provide `SidebarToggle` via `WithComponents` | | `MenuIcon` / `ToggleButtonIcon` props | Use `SidebarToggle` slot instead | | 7 navOpen CSS classes | Apply own classes from own state |
1 parent 8317b73 commit dc16bb5

39 files changed

+360
-824
lines changed

examples/vite/src/App.tsx

Lines changed: 73 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import {
1616
import {
1717
Attachment,
1818
type AttachmentProps,
19+
Button,
1920
Chat,
2021
ChatView,
22+
createIcon,
2123
MessageReactions,
2224
type NotificationListProps,
2325
NotificationList,
@@ -27,6 +29,7 @@ import {
2729
type ReactionOptions,
2830
mapEmojiMartData,
2931
useCreateChatClient,
32+
useTranslationContext,
3033
Search,
3134
} from 'stream-chat-react';
3235
import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis';
@@ -37,6 +40,7 @@ import { humanId } from 'human-id';
3740
import { appSettingsStore, useAppSettingsSelector } from './AppSettings';
3841
import { DESKTOP_LAYOUT_BREAKPOINT } from './ChatLayout/constants.ts';
3942
import { ChannelsPanels, ThreadsPanels } from './ChatLayout/Panels.tsx';
43+
import { SidebarProvider, useSidebar } from './ChatLayout/SidebarContext.tsx';
4044
import {
4145
ChatViewSelectorWidthSync,
4246
PanelLayoutStyleSync,
@@ -162,6 +166,36 @@ const ConfigurableNotificationList = (props: NotificationListProps) => {
162166
return <NotificationList {...props} verticalAlignment={verticalAlignment} />;
163167
};
164168

169+
const IconSidebar = createIcon(
170+
'IconSidebar',
171+
<path
172+
d='M6.875 3.75V16.25M3.125 3.75H16.875C17.2202 3.75 17.5 4.02982 17.5 4.375V15.625C17.5 15.9702 17.2202 16.25 16.875 16.25H3.125C2.77982 16.25 2.5 15.9702 2.5 15.625V4.375C2.5 4.02982 2.77982 3.75 3.125 3.75Z'
173+
fill='none'
174+
stroke='currentColor'
175+
strokeLinecap='round'
176+
strokeLinejoin='round'
177+
strokeWidth='1.5'
178+
/>,
179+
);
180+
181+
const SidebarToggle = () => {
182+
const { closeSidebar, openSidebar, sidebarOpen } = useSidebar();
183+
const { t } = useTranslationContext();
184+
return (
185+
<Button
186+
appearance='ghost'
187+
aria-label={sidebarOpen ? t('aria/Collapse sidebar') : t('aria/Expand sidebar')}
188+
circular
189+
className='str-chat__header-sidebar-toggle'
190+
onClick={sidebarOpen ? closeSidebar : openSidebar}
191+
size='md'
192+
variant='secondary'
193+
>
194+
<IconSidebar />
195+
</Button>
196+
);
197+
};
198+
165199
const language = new URLSearchParams(window.location.search).get('language');
166200
const i18nInstance = language ? new Streami18n({ language: language as any }) : undefined;
167201

@@ -195,7 +229,7 @@ const App = () => {
195229
() => appSettingsStore.getLatestValue().panelLayout,
196230
[],
197231
);
198-
const initialNavOpen = useMemo(() => {
232+
const initialSidebarOpen = useMemo(() => {
199233
if (typeof window === 'undefined') return !initialPanelLayout.leftPanel.collapsed;
200234

201235
const isMobile = window.innerWidth < DESKTOP_LAYOUT_BREAKPOINT;
@@ -324,7 +358,7 @@ const App = () => {
324358
<LoadingScreen
325359
initialAppLayoutStyle={initialAppLayoutStyle}
326360
initialChannelSelected={Boolean(initialChannelId)}
327-
initialNavOpen={initialNavOpen}
361+
initialSidebarOpen={initialSidebarOpen}
328362
/>
329363
);
330364
}
@@ -358,46 +392,49 @@ const App = () => {
358392
MessageReactions: CustomMessageReactions,
359393
reactionOptions: newReactionOptions,
360394
Search: CustomChannelSearch,
395+
HeaderEndContent: SidebarToggle,
396+
HeaderStartContent: SidebarToggle,
361397
...messageUiOverrides,
362398
}}
363399
>
364-
<Chat
365-
searchController={searchController}
366-
client={chatClient}
367-
i18nInstance={i18nInstance}
368-
initialNavOpen={initialNavOpen}
369-
isMessageAIGenerated={isMessageAIGenerated}
370-
theme={chatTheme}
371-
>
372-
<div
373-
className='app-chat-layout'
374-
ref={appLayoutRef}
375-
style={initialAppLayoutStyle}
376-
data-variant={messageUiVariant ?? undefined}
400+
<SidebarProvider initialOpen={initialSidebarOpen}>
401+
<Chat
402+
searchController={searchController}
403+
client={chatClient}
404+
i18nInstance={i18nInstance}
405+
isMessageAIGenerated={isMessageAIGenerated}
406+
theme={chatTheme}
377407
>
378-
<PanelLayoutStyleSync layoutRef={appLayoutRef} />
379-
<ChatViewSelectorWidthSync
380-
iconOnly={chatView.iconOnly}
381-
layoutRef={appLayoutRef}
382-
/>
383-
<ChatView>
384-
<ChatStateSync initialChatView={initialChatView} />
385-
<SidebarLayoutSync />
386-
<ChannelsPanels
387-
filters={filters}
388-
iconOnly={chatView.iconOnly}
389-
initialChannelId={initialChannelId ?? undefined}
390-
itemSet={chatViewSelectorItemSet}
391-
options={options}
392-
sort={sort}
393-
/>
394-
<ThreadsPanels
408+
<div
409+
className='app-chat-layout'
410+
ref={appLayoutRef}
411+
style={initialAppLayoutStyle}
412+
data-variant={messageUiVariant ?? undefined}
413+
>
414+
<PanelLayoutStyleSync layoutRef={appLayoutRef} />
415+
<ChatViewSelectorWidthSync
395416
iconOnly={chatView.iconOnly}
396-
itemSet={chatViewSelectorItemSet}
417+
layoutRef={appLayoutRef}
397418
/>
398-
</ChatView>
399-
</div>
400-
</Chat>
419+
<ChatView>
420+
<ChatStateSync initialChatView={initialChatView} />
421+
<SidebarLayoutSync />
422+
<ChannelsPanels
423+
filters={filters}
424+
iconOnly={chatView.iconOnly}
425+
initialChannelId={initialChannelId ?? undefined}
426+
itemSet={chatViewSelectorItemSet}
427+
options={options}
428+
sort={sort}
429+
/>
430+
<ThreadsPanels
431+
iconOnly={chatView.iconOnly}
432+
itemSet={chatViewSelectorItemSet}
433+
/>
434+
</ChatView>
435+
</div>
436+
</Chat>
437+
</SidebarProvider>
401438
</WithComponents>
402439
);
403440
};

examples/vite/src/ChatLayout/Panels.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from 'stream-chat-react';
2424

2525
import { SidebarResizeHandle, ThreadResizeHandle } from './Resize.tsx';
26+
import { useSidebar } from './SidebarContext.tsx';
2627
import { ThreadStateSync } from './Sync.tsx';
2728

2829
const ChannelThreadPanel = () => {
@@ -92,15 +93,16 @@ export const ChannelsPanels = ({
9293
options: ChannelOptions;
9394
sort: ChannelSort;
9495
}) => {
95-
const { channel, navOpen = true } = useChatContext('ChannelsPanels');
96+
const { channel } = useChatContext('ChannelsPanels');
97+
const { sidebarOpen } = useSidebar();
9698
const channelsLayoutRef = useRef<HTMLDivElement | null>(null);
9799

98100
return (
99101
<ChatView.Channels>
100102
<div
101103
className={clsx('app-chat-view__channels-layout', {
102104
'app-chat-view__channels-layout--channel-selected': !!channel?.id,
103-
'app-chat-view__channels-layout--sidebar-collapsed': navOpen === false,
105+
'app-chat-view__channels-layout--sidebar-collapsed': !sidebarOpen,
104106
})}
105107
ref={channelsLayoutRef}
106108
>
@@ -138,7 +140,7 @@ export const ThreadsPanels = ({
138140
iconOnly?: boolean;
139141
itemSet?: ChatViewSelectorEntry[];
140142
}) => {
141-
const { navOpen = true } = useChatContext('ThreadsPanels');
143+
const { sidebarOpen } = useSidebar();
142144
const { activeThread } = useThreadsViewContext();
143145
const threadsLayoutRef = useRef<HTMLDivElement | null>(null);
144146

@@ -148,7 +150,7 @@ export const ThreadsPanels = ({
148150
<div
149151
className={clsx('app-chat-view__threads-layout', {
150152
'app-chat-view__threads-layout--thread-selected': !!activeThread?.id,
151-
'app-chat-view__threads-layout--sidebar-collapsed': navOpen === false,
153+
'app-chat-view__threads-layout--sidebar-collapsed': !sidebarOpen,
152154
})}
153155
ref={threadsLayoutRef}
154156
>

examples/vite/src/ChatLayout/Resize.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
useEffect,
77
useRef,
88
} from 'react';
9-
import { useChatContext } from 'stream-chat-react';
9+
import { useSidebar } from './SidebarContext.tsx';
1010

1111
import {
1212
type LeftPanelLayoutSettingsState,
@@ -197,7 +197,7 @@ const PanelResizeHandle = ({
197197
);
198198

199199
export const SidebarLayoutSync = () => {
200-
const { navOpen = true } = useChatContext();
200+
const { sidebarOpen } = useSidebar();
201201
const { collapsed: leftPanelCollapsed } = useAppSettingsSelector(
202202
(state) => state.panelLayout.leftPanel,
203203
);
@@ -209,7 +209,7 @@ export const SidebarLayoutSync = () => {
209209

210210
if (document.body.classList.contains('app-chat-resizing-sidebar')) return;
211211

212-
const shouldBeCollapsed = !navOpen;
212+
const shouldBeCollapsed = !sidebarOpen;
213213

214214
if (shouldBeCollapsed === leftPanelCollapsed) return;
215215

@@ -220,7 +220,7 @@ export const SidebarLayoutSync = () => {
220220
collapsed: shouldBeCollapsed,
221221
},
222222
}));
223-
}, [leftPanelCollapsed, navOpen]);
223+
}, [leftPanelCollapsed, sidebarOpen]);
224224

225225
return null;
226226
};
@@ -230,7 +230,7 @@ export const SidebarResizeHandle = ({
230230
}: {
231231
layoutRef: RefObject<HTMLDivElement | null>;
232232
}) => {
233-
const { closeMobileNav, openMobileNav } = useChatContext('SidebarResizeHandle');
233+
const { closeSidebar, openSidebar } = useSidebar();
234234
const leftPanel = useAppSettingsSelector((state) => state.panelLayout.leftPanel);
235235
const isSidebarCollapsedRef = useRef(leftPanel.collapsed);
236236
const leftPanelStateRef = useRef(leftPanel);
@@ -301,9 +301,9 @@ export const SidebarResizeHandle = ({
301301
isSidebarCollapsedRef.current = shouldCollapse;
302302

303303
if (shouldCollapse) {
304-
closeMobileNav();
304+
closeSidebar();
305305
} else {
306-
openMobileNav();
306+
openSidebar();
307307
}
308308
}
309309
},
@@ -330,7 +330,7 @@ export const SidebarResizeHandle = ({
330330
pointerId: event.pointerId,
331331
});
332332
},
333-
[closeMobileNav, layoutRef, openMobileNav],
333+
[closeSidebar, layoutRef, openSidebar],
334334
);
335335

336336
return (
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { createContext, useCallback, useContext, useState } from 'react';
2+
import type { PropsWithChildren } from 'react';
3+
4+
type SidebarContextValue = {
5+
closeSidebar: () => void;
6+
openSidebar: () => void;
7+
sidebarOpen: boolean;
8+
};
9+
10+
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
11+
12+
export const useSidebar = () => {
13+
const value = useContext(SidebarContext);
14+
if (!value) throw new Error('useSidebar must be used within a SidebarProvider');
15+
return value;
16+
};
17+
18+
export const SidebarProvider = ({
19+
children,
20+
initialOpen = true,
21+
}: PropsWithChildren<{ initialOpen?: boolean }>) => {
22+
const [sidebarOpen, setSidebarOpen] = useState(initialOpen);
23+
24+
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
25+
const openSidebar = useCallback(() => setSidebarOpen(true), []);
26+
27+
return (
28+
<SidebarContext.Provider value={{ closeSidebar, openSidebar, sidebarOpen }}>
29+
{children}
30+
</SidebarContext.Provider>
31+
);
32+
};

examples/vite/src/LoadingScreen/LoadingScreen.tsx

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ import { LoadingChannel, LoadingChannels } from 'stream-chat-react';
1010
type LoadingScreenProps = {
1111
initialAppLayoutStyle: CSSProperties;
1212
initialChannelSelected: boolean;
13-
initialNavOpen: boolean;
13+
initialSidebarOpen: boolean;
1414
};
1515

1616
const selectorButtonCount = 4;
1717

1818
export const LoadingScreen = ({
1919
initialAppLayoutStyle,
2020
initialChannelSelected,
21-
initialNavOpen,
21+
initialSidebarOpen,
2222
}: LoadingScreenProps) => (
2323
<div className='app-chat-layout' style={initialAppLayoutStyle}>
2424
<div className='str-chat'>
@@ -27,16 +27,11 @@ export const LoadingScreen = ({
2727
<div
2828
className={clsx('app-chat-view__channels-layout', {
2929
'app-chat-view__channels-layout--channel-selected': initialChannelSelected,
30-
'app-chat-view__channels-layout--sidebar-collapsed': !initialNavOpen,
30+
'app-chat-view__channels-layout--sidebar-collapsed': !initialSidebarOpen,
3131
})}
3232
>
3333
<div className='app-chat-sidebar-overlay'>
34-
<div
35-
className={clsx('str-chat__chat-view__selector', {
36-
'str-chat__chat-view__selector--nav-closed': !initialNavOpen,
37-
'str-chat__chat-view__selector--nav-open': initialNavOpen,
38-
})}
39-
>
34+
<div className='str-chat__chat-view__selector'>
4035
{Array.from({ length: selectorButtonCount }).map((_, index) => (
4136
<div
4237
className='str-chat__chat-view__selector-button-container'
@@ -48,11 +43,7 @@ export const LoadingScreen = ({
4843
</div>
4944
))}
5045
</div>
51-
<div
52-
className={clsx('str-chat__channel-list', {
53-
'str-chat__channel-list--open': initialNavOpen,
54-
})}
55-
>
46+
<div className='str-chat__channel-list'>
5647
<LoadingChannels />
5748
</div>
5849
</div>

examples/vite/src/index.scss

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -308,11 +308,22 @@ body {
308308
0s, 0s, 0s, 0s, 0s, 0s, var(--str-chat__channel-list-transition-duration, 180ms);
309309
}
310310

311-
.app-chat-sidebar-overlay > .str-chat__chat-view__selector,
312-
.app-chat-sidebar-overlay
313-
> .str-chat__chat-view__selector.str-chat__chat-view__selector--nav-closed,
314-
.app-chat-sidebar-overlay
315-
> .str-chat__chat-view__selector.str-chat__chat-view__selector--nav-open {
311+
/* Hide expand toggle in content headers when sidebar is visible */
312+
.app-chat-view__channels-layout:not(
313+
.app-chat-view__channels-layout--sidebar-collapsed
314+
)
315+
.str-chat__channel-header
316+
.str-chat__header-sidebar-toggle {
317+
display: none;
318+
}
319+
320+
.app-chat-view__threads-layout:not(.app-chat-view__threads-layout--sidebar-collapsed)
321+
.str-chat__thread-header
322+
.str-chat__header-sidebar-toggle {
323+
display: none;
324+
}
325+
326+
.app-chat-sidebar-overlay > .str-chat__chat-view__selector {
316327
position: static;
317328
inset: auto;
318329
width: var(--str-chat__chat-view-selector-mobile-width);

0 commit comments

Comments
 (0)