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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/ui/src/components/fullscreen-chrome/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { __ } from '@wordpress/i18n';
import { close } from '@wordpress/icons';
import { IconButton } from '@wordpress/ui';
import styles from './style.module.css';

interface FullscreenChromeProps {
/**
* When provided, renders a close button in the top-right corner so the user
* can leave the fullscreen view. Omit to render just the window drag edges.
*/
onClose?: () => void;
/** Accessible label for the close button. Defaults to "Close". */
closeLabel?: string;
/** Disables the close button, e.g. while a submit is in flight. */
closeDisabled?: boolean;
}

/**
* Window chrome for fullscreen views that fill the window with no native title
* bar (site creation, settings). Renders invisible drag strips along the top,
* left, and bottom edges so the window can still be moved, plus an optional
* close button pinned top-right. The right edge is left free for a scroll bar,
* and the close button stays clickable over the drag strips via the global
* no-drag rule in index.css.
*/
export function FullscreenChrome( { onClose, closeLabel, closeDisabled }: FullscreenChromeProps ) {
return (
<>
<div aria-hidden="true">
<div className={ `${ styles.dragEdge } ${ styles.dragEdgeTop }` } />
<div className={ `${ styles.dragEdge } ${ styles.dragEdgeLeft }` } />
<div className={ `${ styles.dragEdge } ${ styles.dragEdgeBottom }` } />
</div>
{ onClose && (
<IconButton
Comment on lines +26 to +35

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new component is similar to onboarding-layout. We can consider unifying them, or using this new one in the Onboarding layout.

export function OnboardingLayout( {
children,
onClose,
closeDisabled = false,
width = 'default',
contentRef,
background,
}: OnboardingLayoutProps ) {
return (
<Stack align="flex-start" justify="center" className={ styles.root }>
{ background }
<div aria-hidden="true">
<div className={ `${ styles.dragEdge } ${ styles.dragEdgeTop }` } />
<div className={ `${ styles.dragEdge } ${ styles.dragEdgeLeft }` } />
<div className={ `${ styles.dragEdge } ${ styles.dragEdgeBottom }` } />
</div>
{ onClose && (

className={ styles.close }
variant="minimal"
tone="neutral"
size="default"
icon={ close }
label={ closeLabel ?? __( 'Close' ) }
onClick={ onClose }
disabled={ closeDisabled }
/>
) }
</>
);
}
46 changes: 46 additions & 0 deletions apps/ui/src/components/fullscreen-chrome/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/* Fullscreen views fill the window with no title bar; invisible strips along
the top, left, and bottom edges act as the window drag region. A full-
background region would also work, but it would swallow the mouse events any
interactive background (e.g. the onboarding dot grid) depends on. The right
edge is left free for the scroll bar, and interactive elements overlapping a
strip (the close button) stay clickable via the global no-drag rule. */
.dragEdge {
position: fixed;
pointer-events: none;
-webkit-app-region: drag;
}

.dragEdgeTop {
top: 0;
left: 0;
right: 0;
height: 36px;
}

.dragEdgeLeft {
top: 36px;
left: 0;
bottom: 0;
width: 16px;
}

.dragEdgeBottom {
left: 16px;
right: 0;
bottom: 0;
height: 16px;
}

.close {
/* Fixed so it stays in the corner while the page scrolls; above the
footer scrim/actions (z-index 5/6). */
position: fixed;
top: 16px;
right: 16px;
z-index: 7;
}

.close svg {
width: 32px;
height: 32px;
}
23 changes: 3 additions & 20 deletions apps/ui/src/components/onboarding-layout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { __ } from '@wordpress/i18n';
import { close } from '@wordpress/icons';
import { IconButton, Stack } from '@wordpress/ui';
import { Stack } from '@wordpress/ui';
import { FullscreenChrome } from '@/components/fullscreen-chrome';
import styles from './style.module.css';
import type { ReactNode, Ref } from 'react';

Expand Down Expand Up @@ -29,23 +28,7 @@ export function OnboardingLayout( {
return (
<Stack align="flex-start" justify="center" className={ styles.root }>
{ background }
<div aria-hidden="true">
<div className={ `${ styles.dragEdge } ${ styles.dragEdgeTop }` } />
<div className={ `${ styles.dragEdge } ${ styles.dragEdgeLeft }` } />
<div className={ `${ styles.dragEdge } ${ styles.dragEdgeBottom }` } />
</div>
{ onClose && (
<IconButton
className={ styles.close }
variant="minimal"
tone="neutral"
size="default"
icon={ close }
label={ __( 'Close' ) }
onClick={ onClose }
disabled={ closeDisabled }
/>
) }
<FullscreenChrome onClose={ onClose } closeDisabled={ closeDisabled } />
<div
ref={ contentRef }
className={ `${ styles.content } ${ width === 'wide' ? styles.contentWide : '' } ${
Expand Down
39 changes: 0 additions & 39 deletions apps/ui/src/components/onboarding-layout/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,42 +19,3 @@
.contentWithClose {
padding-block-start: calc(var(--wpds-dimension-padding-3xl) + 32px);
}

.dragEdge {
position: fixed;
pointer-events: none;
-webkit-app-region: drag;
}

.dragEdgeTop {
top: 0;
left: 0;
right: 0;
height: 36px;
}

.dragEdgeLeft {
top: 36px;
left: 0;
bottom: 0;
width: 16px;
}

.dragEdgeBottom {
left: 16px;
right: 0;
bottom: 0;
height: 16px;
}

.close {
position: fixed;
top: 16px;
right: 16px;
z-index: 7;
}

.close svg {
width: 32px;
height: 32px;
}
24 changes: 20 additions & 4 deletions apps/ui/src/components/settings-view/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useConnector } from '@/data/core';
import { persister } from '@/data/core/query-client';
import { useInstalledApps } from '@/data/queries/use-installed-apps';
import { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-user-preferences';
import { SettingsCloseContext } from '@/hooks/use-settings-close';
import { SettingsView, isSettingsTab } from './index';
import type { ButtonHTMLAttributes, ReactNode } from 'react';

Expand All @@ -30,6 +31,9 @@ vi.mock( '@wordpress/ui', () => ( {
void size;
return <button { ...props }>{ loading ? loadingAnnouncement : children }</button>;
},
IconButton: ( { label, onClick }: { label: string; onClick?: () => void } ) => (
<button type="button" aria-label={ label } onClick={ onClick } />
),
SelectControl: ( {
items = [],
label,
Expand Down Expand Up @@ -105,10 +109,6 @@ vi.mock( '@/data/queries/use-user-preferences', () => ( {
useUserPreferences: vi.fn(),
} ) );

vi.mock( '@/hooks/use-sidebar-collapsed', () => ( {
useSidebarCollapsed: () => false,
} ) );

// The mocked Tabs render every panel unconditionally; the usage panel has its
// own test file.
vi.mock( './usage-panel', () => ( {
Expand Down Expand Up @@ -277,4 +277,20 @@ describe( 'SettingsView', () => {
screen.getByText( 'An error occurred while saving settings. Please try again.' )
).toBeInTheDocument();
} );

it( 'shows a close button only inside the settings overlay', () => {
const onClose = vi.fn();
const { rerender } = render( <SettingsView activeTab="preferences" onTabChange={ vi.fn() } /> );

expect( screen.queryByRole( 'button', { name: 'Close settings' } ) ).not.toBeInTheDocument();

rerender(
<SettingsCloseContext.Provider value={ onClose }>
<SettingsView activeTab="preferences" onTabChange={ vi.fn() } />
</SettingsCloseContext.Provider>
);
fireEvent.click( screen.getByRole( 'button', { name: 'Close settings' } ) );

expect( onClose ).toHaveBeenCalled();
} );
} );
31 changes: 20 additions & 11 deletions apps/ui/src/components/settings-view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import { supportedLocaleNames } from '@studio/common/lib/locale';
import { SUPPORTED_EDITORS, supportedEditorConfig } from '@studio/common/lib/user-settings/editor';
import { SUPPORTED_TERMINALS, terminalConfig } from '@studio/common/lib/user-settings/terminal';
import { __, sprintf } from '@wordpress/i18n';
import { file, Icon } from '@wordpress/icons';
import { Button, SelectControl } from '@wordpress/ui';
import { close, file, Icon } from '@wordpress/icons';
import { Button, IconButton, SelectControl } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useCallback, useEffect, useState } from 'react';
import * as Tabs from '@/components/tabs';
import { useConnector } from '@/data/core';
import { persister } from '@/data/core/query-client';
import { useInstalledApps } from '@/data/queries/use-installed-apps';
import { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-user-preferences';
import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed';
import { useSettingsClose } from '@/hooks/use-settings-close';
import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space';
import { AccountSection } from './account-section';
import { KeyboardPanel } from './keyboard-panel';
Expand Down Expand Up @@ -95,19 +95,16 @@ const LOCALE_ELEMENTS: { value: SupportedLocale; label: string }[] = Object.entr
).map( ( [ value, label ] ) => ( { value: value as SupportedLocale, label } ) );

function SettingsHeader() {
// Settings renders fullscreen, so the sidebar (and its floating toggle) is
// covered — only the macOS traffic lights still need clearing.
const connector = useConnector();
const sidebarCollapsed = useSidebarCollapsed();
const reserveTrafficLightSpace = useTrafficLightSpace();
const toggleSpacerClass = sidebarCollapsed
? reserveTrafficLightSpace
? styles.toggleSpacer
: styles.toggleSpacerFlush
: null;
const onClose = useSettingsClose();
return (
<div className={ styles.header }>
{ toggleSpacerClass ? (
{ reserveTrafficLightSpace ? (
<div className={ styles.headerStart }>
<span className={ toggleSpacerClass } aria-hidden="true" />
<span className={ styles.toggleSpacer } aria-hidden="true" />
</div>
) : null }
<div className={ styles.headerTabs }>
Expand All @@ -122,6 +119,18 @@ function SettingsHeader() {
) }
</Tabs.List>
</div>
{ onClose ? (
<div className={ styles.headerEnd }>
<IconButton
variant="minimal"
tone="neutral"
size="small"
icon={ close }
label={ __( 'Close settings' ) }
onClick={ onClose }
/>
</div>
) : null }
</div>
);
}
Expand Down
14 changes: 6 additions & 8 deletions apps/ui/src/components/settings-view/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,12 @@
-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;
.headerEnd {
grid-column: 3;
display: flex;
align-items: center;
justify-self: end;
padding-inline-end: var(--wpds-dimension-padding-lg);
}

.scroll {
Expand Down
15 changes: 15 additions & 0 deletions apps/ui/src/hooks/use-settings-close.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createContext, useContext } from 'react';

/**
* The settings layout renders the settings view as a fullscreen overlay and
* owns the "close" action (navigate back to where the user was). The view
* renders the close button inside its own toolbar, so the handler is shared
* down through this context rather than threaded as props through the route
* components. `null` when the view is rendered outside the settings overlay,
* where no close button should appear.
*/
export const SettingsCloseContext = createContext< ( () => void ) | null >( null );

export function useSettingsClose(): ( () => void ) | null {
return useContext( SettingsCloseContext );
}
Loading