Skip to content
15 changes: 6 additions & 9 deletions apps/ui/src/components/sidebar-header/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe( 'SidebarHeader', () => {
} );

it( 'starts the add-site workflow from the plus button', () => {
render( <SidebarHeader onToggleSidebar={ vi.fn() } /> );
render( <SidebarHeader /> );

fireEvent.click( screen.getByRole( 'button', { name: 'Add site' } ) );

Expand All @@ -36,17 +36,14 @@ describe( 'SidebarHeader', () => {
expect( screen.queryByText( 'Import from…' ) ).not.toBeInTheDocument();
} );

it( 'hides the sidebar from the header toggle', () => {
const onToggleSidebar = vi.fn();
render( <SidebarHeader onToggleSidebar={ onToggleSidebar } /> );
it( 'has no sidebar toggle — it lives in the sidebar footer', () => {
render( <SidebarHeader /> );

fireEvent.click( screen.getByRole( 'button', { name: 'Hide sidebar' } ) );

expect( onToggleSidebar ).toHaveBeenCalledTimes( 1 );
expect( screen.queryByRole( 'button', { name: 'Hide sidebar' } ) ).not.toBeInTheDocument();
} );

it( 'opens the app menu when the host has no native menu bar', () => {
render( <SidebarHeader onToggleSidebar={ vi.fn() } /> );
render( <SidebarHeader /> );

fireEvent.click( screen.getByRole( 'button', { name: 'Menu' } ) );

Expand All @@ -56,7 +53,7 @@ describe( 'SidebarHeader', () => {
it( 'hides the app menu button when the host has a native menu bar', () => {
useConnectorMock.mockReturnValue( { showsAppMenuButton: false } );

render( <SidebarHeader onToggleSidebar={ vi.fn() } /> );
render( <SidebarHeader /> );

expect( screen.queryByRole( 'button', { name: 'Menu' } ) ).not.toBeInTheDocument();
} );
Expand Down
15 changes: 1 addition & 14 deletions apps/ui/src/components/sidebar-header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,10 @@ import { menu, plus } from '@wordpress/icons';
import { IconButton } from '@wordpress/ui';
import { useConnector } from '@/data/core';
import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space';
import { drawerIcon } from '@/lib/icons';
import styles from './style.module.css';
import type { MouseEvent } from 'react';

type Props = {
onToggleSidebar: () => void;
};

export function SidebarHeader( { onToggleSidebar }: Props ) {
export function SidebarHeader() {
const reserveTrafficLightSpace = useTrafficLightSpace();
const navigate = useNavigate();
const connector = useConnector();
Expand Down Expand Up @@ -42,14 +37,6 @@ export function SidebarHeader( { onToggleSidebar }: Props ) {
label={ __( 'Add site' ) }
onClick={ () => void navigate( { to: '/onboarding' } ) }
/>
<IconButton
variant="minimal"
tone="neutral"
size="small"
icon={ drawerIcon }
label={ __( 'Hide sidebar' ) }
onClick={ onToggleSidebar }
/>
</div>
</div>
);
Expand Down
8 changes: 4 additions & 4 deletions apps/ui/src/components/sidebar-layout/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@ import { SidebarLayout } from './index';
import type { ReactNode } from 'react';

vi.mock( '@/components/sidebar-header', () => ( {
SidebarHeader: ( { onToggleSidebar }: { onToggleSidebar: () => void } ) => (
<button onClick={ onToggleSidebar }>Hide sidebar</button>
),
SidebarHeader: () => null,
} ) );

vi.mock( '@/components/site-list', () => ( {
SiteList: () => <nav aria-label="Sites" />,
} ) );

vi.mock( '@/components/user-menu', () => ( {
UserMenu: () => null,
UserMenu: ( { onToggleSidebar }: { onToggleSidebar: () => void } ) => (
<button onClick={ onToggleSidebar }>Hide sidebar</button>
),
} ) );

vi.mock( '@/data/core', () => ( {
Expand Down
4 changes: 2 additions & 2 deletions apps/ui/src/components/sidebar-layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ export function SidebarLayout( { children }: { children: ReactNode } ) {
scope. */ }
<ThemeProvider color={ { bg: chromeBg } }>
<div className={ styles.sidebarThemeScope }>
<SidebarHeader onToggleSidebar={ toggleSidebar } />
<SidebarHeader />
<SiteList />
<div className={ styles.sidebarFooter }>
{ ! collapsed ? <AppToasts className={ styles.sidebarToasts } /> : null }
<UserMenu />
<UserMenu onToggleSidebar={ toggleSidebar } />
</div>
</div>
</ThemeProvider>
Expand Down
10 changes: 4 additions & 6 deletions apps/ui/src/components/sidebar-layout/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,12 @@
background-color: var(--wpds-color-fg-interactive-brand);
}

/* Pinned to the bottom-left of the main area, mirroring the sidebar footer.
The button carries its own `padding-sm` top/bottom for its hit area; the
matching negative `bottom` cancels that lower padding so the glyph sits flush
with the main area's bottom edge instead of floating a padding's-worth above
it. */
/* Bottom-left of the main area. The 46px min-height matches the session
view's `panelFooterControls` so the toggle lines up vertically with the
chat-history/new-chat buttons. */
.floatingToggle {
position: absolute;
bottom: calc(-1 * var(--wpds-dimension-padding-sm));
bottom: 0;
left: var(--wpds-dimension-padding-xl);
display: flex;
align-items: center;
Expand Down
16 changes: 15 additions & 1 deletion apps/ui/src/components/user-menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ import { useUserLocale } from '@/data/queries/use-user-locale';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useOffline } from '@/hooks/use-offline';
import { getLocalizedLink, REPORT_ISSUE_URL } from '@/lib/docs-links';
import { drawerIcon } from '@/lib/icons';
import styles from './style.module.css';

const WPCOM_PROFILE_URL = 'https://wordpress.com/me';

export function UserMenu() {
type Props = {
onToggleSidebar: () => void;
};

export function UserMenu( { onToggleSidebar }: Props ) {
const connector = useConnector();
const { data: user } = useAuthUser();
const login = useLogin();
Expand Down Expand Up @@ -88,6 +93,15 @@ export function UserMenu() {
onClick={ () => void navigate( { to: '/settings' } ) }
/>
) : null }
<IconButton
variant="minimal"
tone="neutral"
size="small"
className={ styles.sidebarToggle }
icon={ drawerIcon }
label={ __( 'Hide sidebar' ) }
onClick={ onToggleSidebar }
/>
</div>
</div>
);
Expand Down
7 changes: 7 additions & 0 deletions apps/ui/src/components/user-menu/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,20 @@
}

.userName,
.sidebarToggle,
.settingsButton {
color: var(--wpds-color-fg-content-neutral-weak);
}

.sidebarToggle {
margin-inline-end: calc(-1 * var(--wpds-dimension-padding-sm));
}

.row:hover .userName,
.row:hover .sidebarToggle,
.row:hover .settingsButton,
.row:focus-within .userName,
.row:focus-within .sidebarToggle,
.row:focus-within .settingsButton {
color: var(--wpds-color-fg-content-neutral);
}
Expand Down
36 changes: 29 additions & 7 deletions apps/ui/src/ui-classic/components/session-view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type ReactNode,
type Ref,
} from 'react';
import { PreviewToggleButton } from '@/components/preview-toggle-button';
import { ProgressiveBlur } from '@/components/progressive-blur';
import { SiteDropdown } from '@/components/site-dropdown';
import { SiteIcon } from '@/components/site-icon';
Expand Down Expand Up @@ -53,11 +54,8 @@ function SessionHeader( { summary }: SessionHeaderProps ) {
return null;
}

const toggleSpacerClass = sidebarCollapsed
? reserveTrafficLightSpace
? styles.toggleSpacer
: styles.toggleSpacerFlush
: null;
const toggleSpacerClass =
sidebarCollapsed && reserveTrafficLightSpace ? styles.toggleSpacer : null;

return (
<div className={ styles.header }>
Expand Down Expand Up @@ -88,17 +86,26 @@ interface SessionFrameProps {
header?: ReactNode;
composer?: ReactNode;
footer?: ReactNode;
footerEnd?: ReactNode;
scrollRef?: Ref< HTMLDivElement >;
children?: ReactNode;
}

// Lays out the chat column as fixed chrome over a full-height conversation
// scroller. The site preview panel lives in the dashboard layout's
// PreviewSplitFrame, which keeps it mounted across routes.
function SessionFrame( { header, composer, footer, scrollRef, children }: SessionFrameProps ) {
function SessionFrame( {
header,
composer,
footer,
footerEnd,
scrollRef,
children,
}: SessionFrameProps ) {
const rootRef = useRef< HTMLDivElement >( null );
const headerRef = useRef< HTMLDivElement >( null );
const composerRef = useRef< HTMLDivElement >( null );
const sidebarCollapsed = useSidebarCollapsed();

useLayoutEffect( () => {
const root = rootRef.current;
Expand Down Expand Up @@ -151,7 +158,21 @@ function SessionFrame( { header, composer, footer, scrollRef, children }: Sessio
>
{ composer }
</div>
{ footer ? <div className={ styles.panelFooterControls }>{ footer }</div> : null }
{ footer ? (
<div
className={ clsx(
styles.panelFooterControls,
sidebarCollapsed && styles.panelFooterControlsCollapsed
) }
>
{ footer }
</div>
) : null }
{ footerEnd ? (
<div className={ clsx( styles.panelFooterControls, styles.panelFooterControlsEnd ) }>
{ footerEnd }
</div>
) : null }
</div>
);
}
Expand Down Expand Up @@ -349,6 +370,7 @@ function SessionViewContent( { sessionId }: { sessionId: string } ) {
/>
) : null
}
footerEnd={ canTogglePreview ? <PreviewToggleButton /> : null }
>
{ isEmpty ? <EmptyBackground /> : null }
{ isEmpty && ownerSite ? (
Expand Down
31 changes: 17 additions & 14 deletions apps/ui/src/ui-classic/components/session-view/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,24 +49,14 @@
-webkit-app-region: drag;
}

/* Reserve a no-drag area at the left so the floating sidebar-toggle button
(collapsed state) doesn't sit on top of the header's drag region. */
/* Clears the macOS traffic lights when the collapsed sidebar puts this
header at the window's left edge, matching the sidebar header's
reservation. */
.toggleSpacer {
display: block;
flex-shrink: 0;
width: 118px;
width: calc(94px - var(--wpds-dimension-padding-lg) - var(--wpds-dimension-padding-sm));
align-self: stretch;
-webkit-app-region: no-drag;
}

/* No traffic lights to clear (non-macOS, the browser, or macOS fullscreen): a
minimal spacer so the toggle still clears the header's drag region. */
.toggleSpacerFlush {
display: block;
flex-shrink: 0;
width: calc(var(--wpds-dimension-padding-xl) + 24px);
align-self: stretch;
-webkit-app-region: no-drag;
}

.headerSite {
Expand Down Expand Up @@ -192,6 +182,19 @@
-webkit-app-region: no-drag;
}

.panelFooterControlsCollapsed {
left: calc(
var(--wpds-dimension-padding-xl) + 24px + var(--classic-panel-control-gap)
);
}

/* Applied on top of .panelFooterControls to anchor the strip to the right
edge instead (hosts the preview panel toggle). */
.panelFooterControlsEnd {
left: auto;
right: var(--classic-panel-control-left);
}

.classicComposerFooter {
display: flex;
justify-content: flex-start;
Expand Down
Loading