-
+
{!isActive && activePaginator && (
// Expose the active paginator through ChannelListContext (as the SDK's ChannelLists
diff --git a/examples/vite/src/Sidebar/HeaderContent.tsx b/examples/vite/src/Sidebar/HeaderContent.tsx
index 607a0916c..30e977db9 100644
--- a/examples/vite/src/Sidebar/HeaderContent.tsx
+++ b/examples/vite/src/Sidebar/HeaderContent.tsx
@@ -1,4 +1,8 @@
-import { WorkspaceNavigationBackButton } from 'stream-chat-react';
+import {
+ ThreadListHeader,
+ useWorkspaceNavigation,
+ WorkspaceNavigationBackButton,
+} from 'stream-chat-react';
import { SidebarToggle } from './SidebarToggle.tsx';
@@ -10,3 +14,23 @@ export const HeaderStartWithSidebarToggle = () => (
>
);
+
+/** The sidebar toggle while a channel is open beside the channel list. With nothing open,
+ * collapsing the sidebar would leave an empty screen. */
+export const SidebarToggleWhileChannelOpen = () => {
+ const { openChannels } = useWorkspaceNavigation();
+ return openChannels.length > 0 ?
: null;
+};
+
+/** The sidebar toggle while a thread is open beside the thread list. With nothing open,
+ * collapsing the sidebar would leave an empty screen. */
+export const SidebarToggleWhileThreadOpen = () => {
+ const { openThreads } = useWorkspaceNavigation();
+ return openThreads.length > 0 ?
: null;
+};
+
+/** The thread list header, registered as the `ThreadListHeader` override, with the sidebar toggle
+ * at its end. */
+export const ThreadListHeaderWithSidebarToggle = () => (
+
+);
diff --git a/src/components/ChannelList/ChannelListHeader.tsx b/src/components/ChannelList/ChannelListHeader.tsx
index 269beea37..3d80a3347 100644
--- a/src/components/ChannelList/ChannelListHeader.tsx
+++ b/src/components/ChannelList/ChannelListHeader.tsx
@@ -1,21 +1,26 @@
import React from 'react';
-import {
- useComponentContext,
- useTranslationContext,
- useWorkspaceNavigation,
-} from '../../context';
+import { useTranslationContext } from '../../context';
-export const ChannelListHeader = () => {
+export type ChannelListHeaderProps = {
+ /** Rendered at the end of the header, after the title. No default. */
+ EndContent?: React.ComponentType;
+ /** Rendered at the start of the header, before the title. No default. */
+ StartContent?: React.ComponentType;
+};
+
+/** Header of the channel list: the "Chats" title, with optional start and end content. */
+export const ChannelListHeader = ({
+ EndContent,
+ StartContent,
+}: ChannelListHeaderProps) => {
const { t } = useTranslationContext();
- const { HeaderEndContent } = useComponentContext();
- // `HeaderEndContent` renders only while a channel is open in the workspace.
- const hasActiveChannel = useWorkspaceNavigation().openChannels.length > 0;
return (
+ {StartContent &&
}
{t('channelList.header.chats.text', 'Chats')}
- {hasActiveChannel && HeaderEndContent &&
}
+ {EndContent &&
}
);
};
diff --git a/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx b/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx
index 7cbd57394..1d60ef201 100644
--- a/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx
+++ b/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx
@@ -1,58 +1,50 @@
import React from 'react';
import { cleanup, render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
-import { WithComponents, WorkspaceNavigationProvider } from '../../../context';
+import { WorkspaceNavigationProvider } from '../../../context';
import { TranslationProvider } from '../../../context/TranslationContext';
import { mockTranslationContextValue } from '../../../mock-builders';
+import type { ChannelListHeaderProps } from '../ChannelListHeader';
import { ChannelListHeader } from '../ChannelListHeader';
import { mockT } from '../../../mock-builders/translator';
const t = vi.fn(mockT);
-const HeaderEndContent = () =>
;
+const EndContent = () =>
;
+const StartContent = () =>
;
afterEach(cleanup);
-// The header reads `openChannels` from the workspace navigation; drive it through the real
-// provider.
-const renderHeader = ({
- openChannels,
- withHeaderEndContent,
-}: {
- openChannels: unknown[];
- withHeaderEndContent: boolean;
-}) => {
- const inner = (
-
-
-
- );
- return render(
-
- {withHeaderEndContent ? (
- {inner}
- ) : (
- inner
- )}
+// Nothing is open in the workspace navigation: the start and end content must not depend on it.
+const renderHeader = (props: ChannelListHeaderProps = {}) =>
+ render(
+
+
+
+
,
);
-};
describe('ChannelListHeader', () => {
- it('should not render HeaderEndContent when not provided via ComponentContext', () => {
- renderHeader({ openChannels: [{}], withHeaderEndContent: false });
-
- expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument();
- });
-
- it('should render HeaderEndContent when a channel is active', () => {
- renderHeader({ openChannels: [{}], withHeaderEndContent: true });
-
- expect(screen.getByTestId('sidebar-toggle')).toBeInTheDocument();
+ it('should render only the title by default', () => {
+ const { container } = renderHeader();
+
+ expect(screen.queryByTestId('header-start')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('header-end')).not.toBeInTheDocument();
+ expect(
+ container.querySelector('.str-chat__channel-list__header')?.children,
+ ).toHaveLength(1);
});
- it('should not render HeaderEndContent when no channel is active', () => {
- renderHeader({ openChannels: [], withHeaderEndContent: true });
+ it('should render StartContent before the title and EndContent after it, without an open channel', () => {
+ const { container } = renderHeader({ EndContent, StartContent });
- expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument();
+ const children = Array.from(
+ container.querySelector('.str-chat__channel-list__header')?.children ?? [],
+ );
+ expect(
+ children.map((child) => child.getAttribute('data-testid') ?? child.className),
+ ).toEqual(['header-start', 'str-chat__channel-list__header__title', 'header-end']);
});
});
diff --git a/src/components/Threads/ThreadList/ThreadList.tsx b/src/components/Threads/ThreadList/ThreadList.tsx
index 3bbdac179..46204a319 100644
--- a/src/components/Threads/ThreadList/ThreadList.tsx
+++ b/src/components/Threads/ThreadList/ThreadList.tsx
@@ -24,7 +24,7 @@ import {
useTranslationContext,
} from '../../../context';
import { useStateStore } from '../../../store';
-import { ThreadListHeader } from './ThreadListHeader';
+import { ThreadListHeader as DefaultThreadListHeader } from './ThreadListHeader';
const noThreads: Thread[] = [];
@@ -80,6 +80,7 @@ export const ThreadList = ({ virtuosoProps }: ThreadListProps) => {
ListLoadErrorIndicator = DefaultListLoadErrorIndicator,
NotificationList: NotificationListFromContext = NotificationList,
ThreadListEmptyPlaceholder = DefaultThreadListEmptyPlaceholder,
+ ThreadListHeader = DefaultThreadListHeader,
ThreadListItem = DefaultThreadListItem,
ThreadListLoadingIndicator = DefaultThreadListLoadingIndicator,
ThreadListUnseenThreadsBanner = DefaultThreadListUnseenThreadsBanner,
diff --git a/src/components/Threads/ThreadList/ThreadListHeader.tsx b/src/components/Threads/ThreadList/ThreadListHeader.tsx
index cd845a545..57f08358c 100644
--- a/src/components/Threads/ThreadList/ThreadListHeader.tsx
+++ b/src/components/Threads/ThreadList/ThreadListHeader.tsx
@@ -1,21 +1,23 @@
import React from 'react';
-import {
- useComponentContext,
- useTranslationContext,
- useWorkspaceNavigation,
-} from '../../../context';
+import { useTranslationContext } from '../../../context';
-export const ThreadListHeader = () => {
+export type ThreadListHeaderProps = {
+ /** Rendered at the end of the header, after the title. No default. */
+ EndContent?: React.ComponentType;
+ /** Rendered at the start of the header, before the title. No default. */
+ StartContent?: React.ComponentType;
+};
+
+/** Header of the thread list: the "Threads" title, with optional start and end content. */
+export const ThreadListHeader = ({ EndContent, StartContent }: ThreadListHeaderProps) => {
const { t } = useTranslationContext();
- const { HeaderEndContent } = useComponentContext();
- // A thread is "active" when one is open in the workspace.
- const hasActiveThread = useWorkspaceNavigation().openThreads.length > 0;
return (
+ {StartContent &&
}
{t('common.threads.text', 'Threads')}
- {hasActiveThread && HeaderEndContent &&
}
+ {EndContent &&
}
);
};
diff --git a/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx
index ed022c57f..482f245ad 100644
--- a/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx
+++ b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx
@@ -251,6 +251,17 @@ describe('ThreadList', () => {
expect(screen.queryByTestId('loading-channels')).not.toBeInTheDocument();
});
+ it('renders the ThreadListHeader override instead of the default header', () => {
+ mockUseComponentContext.mockReturnValue({
+ ThreadListHeader: () => ,
+ });
+
+ render();
+
+ expect(screen.getByTestId('custom-thread-list-header')).toBeInTheDocument();
+ expect(screen.queryByTestId('thread-list-header')).not.toBeInTheDocument();
+ });
+
it('renders the overrides of the first-load skeleton and the load error', () => {
const lastQueryError = new Error('query failed');
const ListLoadErrorIndicator = vi.fn(() => );
diff --git a/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx
index 8e0c48ef0..0dc3a5ea1 100644
--- a/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx
+++ b/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx
@@ -1,58 +1,50 @@
import React from 'react';
import { cleanup, render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
-import { WithComponents, WorkspaceNavigationProvider } from '../../../../context';
+import { WorkspaceNavigationProvider } from '../../../../context';
import { TranslationProvider } from '../../../../context/TranslationContext';
import { mockTranslationContextValue } from '../../../../mock-builders';
+import type { ThreadListHeaderProps } from '../ThreadListHeader';
import { ThreadListHeader } from '../ThreadListHeader';
import { mockT } from '../../../../mock-builders/translator';
const t = vi.fn(mockT);
-const HeaderEndContent = () => ;
+const EndContent = () => ;
+const StartContent = () => ;
afterEach(cleanup);
-// The header derives "a thread is active" from the workspace navigation's `openThreads`, so the
-// tests drive it through the real provider.
-const renderHeader = ({
- openThreads,
- withHeaderEndContent,
-}: {
- openThreads: unknown[];
- withHeaderEndContent: boolean;
-}) => {
- const inner = (
-
-
-
- );
- return render(
-
- {withHeaderEndContent ? (
- {inner}
- ) : (
- inner
- )}
+// Nothing is open in the workspace navigation: the start and end content must not depend on it.
+const renderHeader = (props: ThreadListHeaderProps = {}) =>
+ render(
+
+
+
+
,
);
-};
describe('ThreadListHeader', () => {
- it('should not render HeaderEndContent when not provided via ComponentContext', () => {
- renderHeader({ openThreads: [{}], withHeaderEndContent: false });
-
- expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument();
- });
-
- it('should render HeaderEndContent when a thread is active', () => {
- renderHeader({ openThreads: [{}], withHeaderEndContent: true });
-
- expect(screen.getByTestId('sidebar-toggle')).toBeInTheDocument();
+ it('should render only the title by default', () => {
+ const { container } = renderHeader();
+
+ expect(screen.queryByTestId('header-start')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('header-end')).not.toBeInTheDocument();
+ expect(
+ container.querySelector('.str-chat__thread-list__header')?.children,
+ ).toHaveLength(1);
});
- it('should not render HeaderEndContent when no thread is active', () => {
- renderHeader({ openThreads: [], withHeaderEndContent: true });
+ it('should render StartContent before the title and EndContent after it, without an open thread', () => {
+ const { container } = renderHeader({ EndContent, StartContent });
- expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument();
+ const children = Array.from(
+ container.querySelector('.str-chat__thread-list__header')?.children ?? [],
+ );
+ expect(
+ children.map((child) => child.getAttribute('data-testid') ?? child.className),
+ ).toEqual(['header-start', 'str-chat__thread-list__header__title', 'header-end']);
});
});
diff --git a/src/components/Threads/ThreadList/index.ts b/src/components/Threads/ThreadList/index.ts
index 71a4c7df4..9c4a467e1 100644
--- a/src/components/Threads/ThreadList/index.ts
+++ b/src/components/Threads/ThreadList/index.ts
@@ -1,4 +1,5 @@
export * from './ThreadList';
+export * from './ThreadListHeader';
export * from './ThreadListItem';
export * from './ThreadListItemUI';
export * from './utils.a11y';
diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx
index 875f72ddb..546608311 100644
--- a/src/context/ComponentContext.tsx
+++ b/src/context/ComponentContext.tsx
@@ -139,7 +139,10 @@ export type ComponentContextValue = {
CalloutDialog?: React.ComponentType;
/** Shown instead of an image that fails to load. */
ImagePlaceholder?: React.ComponentType;
- /** Header of the `ChannelList`. */
+ /**
+ * Header of the `ChannelList`, rendered without props. To add start or end content, register a
+ * component that renders `ChannelListHeader` with `StartContent` / `EndContent`.
+ */
ChannelListHeader?: React.ComponentType;
/** Action buttons within `ChannelListItemUI`. */
ChannelListItemActionButtons?: React.ComponentType;
@@ -333,6 +336,11 @@ export type ComponentContextValue = {
/** The thread's parent message, rendered above the replies in a thread's message list. */
ThreadHead?: React.ComponentType;
ThreadListEmptyPlaceholder?: React.ComponentType;
+ /**
+ * Header of the `ThreadList`, rendered without props. To add start or end content, register a
+ * component that renders `ThreadListHeader` with `StartContent` / `EndContent`.
+ */
+ ThreadListHeader?: React.ComponentType;
ThreadListItem?: React.ComponentType;
ThreadListItemUI?: React.ComponentType;
ThreadListLoadingIndicator?: React.ComponentType;
@@ -350,11 +358,6 @@ export type ComponentContextValue = {
* `UnreadMessagesSeparator`.
*/
UnreadMessagesNotification?: React.ComponentType;
- /**
- * Rendered at the end of `ChannelListHeader` while a channel is open, and of `ThreadListHeader`
- * while a thread is open. No default.
- */
- HeaderEndContent?: React.ComponentType;
/** Separates read messages from unread ones. */
UnreadMessagesSeparator?: React.ComponentType;
/** Uploaded vs total byte size during an attachment upload, in the composer previews. */