Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 35 additions & 32 deletions examples/vite/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { appSettingsStore, useAppSettingsSelector } from './AppSettings';
import { DESKTOP_LAYOUT_BREAKPOINT } from './ChatLayout/constants.ts';
import { ChatSkipNavigation } from './AccessibilityNavigation/ChatSkipNavigation.tsx';
import { ChannelsPanels, ThreadsPanels } from './ChatLayout/Panels.tsx';
import { EmbeddedHostPage } from './ChatLayout/EmbeddedHostPage.tsx';
import { SidebarProvider } from './ChatLayout/SidebarContext.tsx';
import {
ChatViewSelectorWidthSync,
Expand Down Expand Up @@ -508,39 +509,41 @@ const App = () => {
<ChatSkipNavigation />
{/* Publishes window.streamDebug β€” see src/Debug/StreamDebugHandles.tsx */}
<StreamDebugHandles />
<div
className='app-chat-layout'
data-variant={messageUiVariant ?? undefined}
ref={appLayoutRef}
style={initialAppLayoutStyle}
>
<SystemNotification />
<div className='app-chat-layout__body'>
<PanelLayoutStyleSync layoutRef={appLayoutRef} />
<ChatViewSelectorWidthSync
iconOnly={chatView.iconOnly}
layoutRef={appLayoutRef}
/>
<ChatView>
<DialogManagerProvider id={globalDialogManager}>
<ChatStateSync initialChatView={initialChatView} />
<SidebarLayoutSync />
<ChannelsPanels
filters={filters}
iconOnly={chatView.iconOnly}
initialChannelId={initialChannelId ?? undefined}
itemSet={chatViewSelectorItemSet}
options={options}
sort={sort}
/>
<ThreadsPanels
iconOnly={chatView.iconOnly}
itemSet={chatViewSelectorItemSet}
/>
</DialogManagerProvider>
</ChatView>
<EmbeddedHostPage>
<div
className='app-chat-layout'
data-variant={messageUiVariant ?? undefined}
ref={appLayoutRef}
style={initialAppLayoutStyle}
>
<SystemNotification />
<div className='app-chat-layout__body'>
<PanelLayoutStyleSync layoutRef={appLayoutRef} />
<ChatViewSelectorWidthSync
iconOnly={chatView.iconOnly}
layoutRef={appLayoutRef}
/>
<ChatView>
<DialogManagerProvider id={globalDialogManager}>
<ChatStateSync initialChatView={initialChatView} />
<SidebarLayoutSync />
<ChannelsPanels
filters={filters}
iconOnly={chatView.iconOnly}
initialChannelId={initialChannelId ?? undefined}
itemSet={chatViewSelectorItemSet}
options={options}
sort={sort}
/>
<ThreadsPanels
iconOnly={chatView.iconOnly}
itemSet={chatViewSelectorItemSet}
/>
</DialogManagerProvider>
</ChatView>
</div>
</div>
</div>
</EmbeddedHostPage>
</Chat>
</SidebarProvider>
</WithComponents>
Expand Down
63 changes: 63 additions & 0 deletions examples/vite/src/AppSettings/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ export type MessageListSettingsState = {
type: 'standard' | 'virtualized';
};

export type PageLayoutSettingsState = {
/**
* Dev harness: renders the chat as a fixed-height widget between host-page content that
* overflows the viewport, so the window itself scrolls. Reproduces integrations where
* scrolling inside the chat must not move the surrounding page.
*/
embedded: boolean;
};

export type ComposerSettingsState = {
/**
* POC: allow sending a message while its attachments are still uploading.
Expand Down Expand Up @@ -102,6 +111,7 @@ export type AppSettingsState = {
messageActions: MessageActionsSettingsState;
messageList: MessageListSettingsState;
notifications: NotificationsSettingsState;
pageLayout: PageLayoutSettingsState;
panelLayout: PanelLayoutSettingsState;
reactions: ReactionsSettingsState;
theme: ThemeSettingsState;
Expand Down Expand Up @@ -165,6 +175,9 @@ const defaultAppSettingsState: AppSettingsState = {
notifications: {
verticalAlignment: 'bottom',
},
pageLayout: {
embedded: false,
},
panelLayout: {
leftPanel: {
collapsed: false,
Expand Down Expand Up @@ -300,6 +313,45 @@ const getSendMessagesWithPendingUploadsFromUrl = (): boolean | undefined => {
return raw !== '0' && raw !== 'false';
};

const embeddedLayoutUrlParam = 'embedded_layout';

/** Seeded from `?embedded_layout=1`; kept in the URL (not localStorage) so a reload keeps it. */
const getEmbeddedLayoutFromUrl = (): boolean | undefined => {
if (typeof window === 'undefined') return;

const raw = new URLSearchParams(window.location.search).get(embeddedLayoutUrlParam);

if (raw === null) return;

return raw !== '0' && raw !== 'false';
};

const persistEmbeddedLayoutInUrl = (embedded: boolean) => {
if (typeof window === 'undefined') return;

const url = new URL(window.location.href);
const hasParam = url.searchParams.get(embeddedLayoutUrlParam) === '1';

if (hasParam === embedded) return;

if (embedded) url.searchParams.set(embeddedLayoutUrlParam, '1');
else url.searchParams.delete(embeddedLayoutUrlParam);

window.history.replaceState(
window.history.state,
'',
`${url.pathname}${url.search}${url.hash}`,
);
};

/** The root stylesheet switches #root from viewport height to page flow off this attribute. */
const applyEmbeddedLayout = (embedded: boolean) => {
if (typeof document === 'undefined') return;

if (embedded) document.documentElement.setAttribute('data-embedded-layout', '');
else document.documentElement.removeAttribute('data-embedded-layout');
};

const getThemeModeFromUrl = (): ThemeSettingsState['mode'] | undefined => {
if (typeof window === 'undefined') return;

Expand Down Expand Up @@ -373,6 +425,9 @@ const initialAppSettingsState: AppSettingsState = {
// A delay in the URL means the harness is wanted, so it arms the switch too.
slowUploads: (getSlowUploadMsFromUrl() ?? 0) > 0,
},
pageLayout: {
embedded: getEmbeddedLayoutFromUrl() ?? defaultAppSettingsState.pageLayout.embedded,
},
panelLayout: getStoredPanelLayoutSettings() ?? defaultAppSettingsState.panelLayout,
theme: {
...defaultAppSettingsState.theme,
Expand Down Expand Up @@ -403,6 +458,14 @@ appSettingsStore.subscribeWithSelector(
// Apply initial direction on load
applyDirection(initialAppSettingsState.theme.direction);

appSettingsStore.subscribeWithSelector(
({ pageLayout }) => ({ embedded: pageLayout.embedded }),
({ embedded }) => {
persistEmbeddedLayoutInUrl(embedded);
applyEmbeddedLayout(embedded);
},
);

appSettingsStore.subscribeWithSelector(
({ panelLayout }) => panelLayout,
(panelLayout) => {
Expand Down
29 changes: 29 additions & 0 deletions examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type GeneralTabProps = {
export const GeneralTab = ({ close }: GeneralTabProps) => {
const {
messageList,
pageLayout,
theme,
theme: { direction },
} = useAppSettingsState();
Expand Down Expand Up @@ -79,6 +80,34 @@ export const GeneralTab = ({ close }: GeneralTabProps) => {
</Button>
</div>
</div>
<div className='app__settings-modal__field'>
<div className='app__settings-modal__field-label'>Page layout</div>
<div className='app__settings-modal__options-row'>
<Button
aria-pressed={!pageLayout.embedded}
className='app__settings-modal__option-button str-chat__button--outline str-chat__button--secondary str-chat__button--size-sm'
onClick={() =>
appSettingsStore.partialNext({ pageLayout: { embedded: false } })
}
>
Full viewport
</Button>
<Button
aria-pressed={pageLayout.embedded}
className='app__settings-modal__option-button str-chat__button--outline str-chat__button--secondary str-chat__button--size-sm'
onClick={() =>
appSettingsStore.partialNext({ pageLayout: { embedded: true } })
}
>
Embedded in page
</Button>
</div>
<div className='app__settings-modal__field-comment'>
Embedded places the chat between host-page content taller than the viewport,
so the window scrolls too. Use it to check that scrolling inside the chat
(e.g. jumping to unread or quoted messages) leaves the page in place.
</div>
</div>
</SettingsTabBody>
</div>
);
Expand Down
42 changes: 42 additions & 0 deletions examples/vite/src/ChatLayout/EmbeddedHostPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ReactNode } from 'react';

import { type AppSettingsState, useAppSettingsSelector } from '../AppSettings';

const pageLayoutSelector = ({ pageLayout }: AppSettingsState) => ({
embedded: pageLayout.embedded,
});

/**
* With the "Embedded in page" layout setting, renders the chat as a fixed-height widget
* between host-page content taller than the viewport, so the window scrolls as well.
* Any scroll the chat performs on an ancestor (e.g. Element.scrollIntoView) then shows up
* as the host page moving.
*
* The chat wrapper stays mounted in both layouts (it is `display: contents` in the full
* viewport one), so toggling the setting does not remount the chat.
*/
export const EmbeddedHostPage = ({ children }: { children: ReactNode }) => {
const { embedded } = useAppSettingsSelector(pageLayoutSelector);

return (
<>
{embedded && (
<header className='app-embedded-host__content'>
<strong>Host page header</strong>
<p>
The chat below is embedded in a page that scrolls. Scrolling inside the chat
should never move this page. Scroll the page so the chat is only partly in
view, then e.g. jump to the first unread message and check that the page stays
where it was.
</p>
</header>
)}
<div className='app-embedded-host__chat'>{children}</div>
{embedded && (
<footer className='app-embedded-host__content'>
<strong>Host page footer</strong>
</footer>
)}
</>
);
};
35 changes: 35 additions & 0 deletions examples/vite/src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,41 @@ body {
min-height: 0;
}

/* "Embedded in page" layout: #root flows with its content so the window scrolls. */
html[data-embedded-layout] #root {
height: auto;
}

.app-embedded-host__content {
box-sizing: border-box;
min-height: 60vh;
padding: 24px;
font-family: system-ui, sans-serif;
color: #333;
background: repeating-linear-gradient(-45deg, #fdecec 0 16px, #fbe0e0 16px 32px);

p {
max-width: 60ch;
}
}

/* Layout-neutral in the full viewport layout; stays mounted so toggling keeps the chat. */
.app-embedded-host__chat {
display: contents;
}

html[data-embedded-layout] .app-embedded-host__chat {
display: flex;
flex-direction: column;
flex: none;
/* Shorter than the viewport, so the page around the chat stays visible when scrolled to. */
height: 85vh;
height: 85dvh;
min-height: 0;
margin: 0 24px;
outline: 2px solid #e58a8a;
}

@layer stream-overrides {
.app-chat-layout {
--app-left-panel-width: 360px;
Expand Down
17 changes: 17 additions & 0 deletions src/components/MessageComposer/__tests__/MessageInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,23 @@ describe(`MessageInputFlat`, () => {
expect(results).toHaveNoViolations();
});

it('should not scroll ancestors when focusing the textarea because of the `focus` prop', async () => {
const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus');
await renderComponent({
messageInputProps: {
focus: true,
},
});
await waitFor(() => {
expect(screen.getByPlaceholderText(inputPlaceholder)).toHaveFocus();
});
expect(focusSpy).toHaveBeenCalled();
focusSpy.mock.calls.forEach(([options]) => {
expect(options).toEqual({ preventScroll: true });
});
focusSpy.mockRestore();
});

it('should render default file upload icon', async () => {
const { container } = await renderComponent();
const fileUploadIcon = await screen.findByTestId('invoke-attachment-selector-button');
Expand Down
4 changes: 2 additions & 2 deletions src/components/MessageComposer/hooks/useTextareaRef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import type { MessageComposerProps } from '../MessageComposer';
export const useTextareaRef = (props: MessageComposerProps) => {
const { focus } = props;
const textareaRef = useRef<HTMLTextAreaElement>(undefined);
// Focus
// Focus on mount without scrolling the host page to the composer
useEffect(() => {
if (focus && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.focus({ preventScroll: true });
}
}, [focus]);

Expand Down
Loading
Loading