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
134 changes: 134 additions & 0 deletions apps/ui/src/components/site-preview/address-bar.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/* Segmented address pill: one segment per realm (front end / WP Admin /
database). The active segment carries the realm name; inactive segments
collapse to icon tabs that flip realms. A filled indicator slides
underneath the selected segment (positioned by the component from
measurements). The toolbar's `.header button` rule opts the buttons out
of the window drag region. */
.segments {
position: relative;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 2px;
min-width: 0;
max-width: min(420px, 100%);
height: 32px;
/* 2px + the 1px border = 3px visual inset around the selected fill. */
padding: 2px;
border: 1px solid var(--wpds-color-stroke-surface-neutral);
border-radius: 999px;
background-color: var(--wpds-color-bg-interactive-neutral-weak);
}

/* The selected-segment fill: solid, sliding between segments and stretching
as the active title expands. `left: 0` + translateX keeps the motion on
the compositor where possible. */
.indicator {
position: absolute;
top: 2px;
bottom: 2px;
left: 0;
border-radius: 999px;
/* Neutral mid-gray fill that adapts to both color schemes (a solid
token here reads too heavy: near-black in light, near-white in dark). */
background: color-mix(in srgb, var(--wpds-color-fg-content-neutral) 14%, transparent);
transition:
transform 200ms ease,
width 200ms ease;
}

.segment {
position: relative;
z-index: 1;
box-sizing: border-box;
display: inline-flex;
flex: 0 1 auto;
min-width: 0;
align-items: center;
justify-content: center;
height: 26px;
margin: 0;
padding: 0 10px;
appearance: none;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--wpds-color-fg-content-neutral-weak);
font-family: inherit;
font-size: var(--wpds-typography-font-size-xs);
font-weight: 600;
line-height: 16px;
cursor: pointer;
transition: color 200ms ease;
}

.segment:not([data-active='true']):hover {
color: var(--wpds-color-fg-content-neutral);
}

/* Text on the indicator fill. */
.segment[data-active='true'] {
color: var(--wpds-color-fg-content-neutral);
}

.segment:focus-visible {
outline: 2px solid var(--wpds-color-stroke-focus-brand);
outline-offset: 1px;
}

.segmentIcon {
display: inline-flex;
width: 16px;
height: 16px;
flex-shrink: 0;
align-items: center;
justify-content: center;
}

.segmentIcon svg {
display: block;
width: 100%;
height: 100%;
}

/* Titles render in every segment but collapse when inactive, so selection
morphs the pill instead of popping between layouts. */
.segmentTitle {
min-width: 0;
max-width: 0;
margin-inline: 0;
overflow: hidden;
opacity: 0;
text-overflow: ellipsis;
white-space: nowrap;
transition:
max-width 200ms ease,
margin 200ms ease,
opacity 150ms ease;
}

/* The extra inline-end margin pads the text away from the pill's rounded
right edge; living on the title, it collapses with it. */
.segment[data-active='true'] .segmentTitle {
max-width: 200px;
margin-inline: 6px 3px;
opacity: 1;
}

/* Narrow toolbars: every segment goes icon-only (the active pill keeps its
fill, just not its name) so the tabs never crowd the toolbar's buttons. */
@container studio-preview-toolbar (max-width: 680px) {
.segment[data-active='true'] .segmentTitle {
max-width: 0;
margin-inline: 0;
opacity: 0;
}
}

@media (prefers-reduced-motion: reduce) {
.indicator,
.segment,
.segmentTitle {
transition: none;
}
}
122 changes: 122 additions & 0 deletions apps/ui/src/components/site-preview/address-bar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { Tooltip } from '@wordpress/ui';
import { describe, expect, it, vi } from 'vitest';
import { getPreviewRealm, getRealmNavigationPath, PreviewAddressBar } from './address-bar';
import type { SiteDetails } from '@/data/core';
import type { Mock } from 'vitest';

const SITE_URL = 'http://localhost:8881';

const SITE = {
id: 'site-1',
name: 'Example Site',
path: '/Users/example/Studio/example-site',
port: 8881,
running: true,
phpVersion: '8.3',
} as SiteDetails;

function autoLoginPath( target: string ) {
return `/studio-auto-login?redirect_to=${ encodeURIComponent( `${ SITE_URL }${ target }` ) }`;
}

function renderAddressBar( {
onSwitchRealm = vi.fn(),
path = '/',
showDatabaseTab = true,
}: {
onSwitchRealm?: Mock;
path?: string;
showDatabaseTab?: boolean;
} = {} ) {
render(
<Tooltip.Provider>
<PreviewAddressBar
site={ SITE }
path={ path }
showDatabaseTab={ showDatabaseTab }
onSwitchRealm={ onSwitchRealm }
/>
</Tooltip.Provider>
);
return { onSwitchRealm };
}

describe( 'getPreviewRealm', () => {
it( 'classifies front-end paths', () => {
expect( getPreviewRealm( '/' ) ).toBe( 'frontend' );
expect( getPreviewRealm( '/about/?preview=1' ) ).toBe( 'frontend' );
} );

it( 'classifies wp-admin paths', () => {
expect( getPreviewRealm( '/wp-admin/' ) ).toBe( 'admin' );
expect( getPreviewRealm( '/wp-admin/site-editor.php?path=%2Fpatterns' ) ).toBe( 'admin' );
} );

it( 'classifies phpMyAdmin paths', () => {
expect( getPreviewRealm( '/phpmyadmin/index.php?route=/database/structure' ) ).toBe(
'database'
);
} );

it( 'classifies auto-login hops by their redirect target', () => {
expect( getPreviewRealm( autoLoginPath( '/wp-admin/plugins.php' ) ) ).toBe( 'admin' );
expect( getPreviewRealm( autoLoginPath( '/phpmyadmin/index.php' ) ) ).toBe( 'database' );
expect( getPreviewRealm( autoLoginPath( '/about/' ) ) ).toBe( 'frontend' );
} );
} );

describe( 'getRealmNavigationPath', () => {
it( 'passes non-admin paths through untouched', () => {
expect( getRealmNavigationPath( '/about/', SITE_URL ) ).toBe( '/about/' );
expect( getRealmNavigationPath( '/phpmyadmin/index.php', SITE_URL ) ).toBe(
'/phpmyadmin/index.php'
);
} );

it( 'routes wp-admin paths through auto-login', () => {
expect( getRealmNavigationPath( '/wp-admin/plugins.php', SITE_URL ) ).toBe(
autoLoginPath( '/wp-admin/plugins.php' )
);
} );
} );

describe( 'PreviewAddressBar', () => {
it( 'shows one segment per realm and switches realms from inactive segments', () => {
const { onSwitchRealm } = renderAddressBar( { path: '/' } );

// The front end is active (it carries the site name); the other two
// realms render as labelled icon segments.
fireEvent.click( screen.getByRole( 'button', { name: 'View WP Admin' } ) );
expect( onSwitchRealm ).toHaveBeenCalledWith( 'admin' );

fireEvent.click( screen.getByRole( 'button', { name: 'View database' } ) );
expect( onSwitchRealm ).toHaveBeenCalledWith( 'database' );

expect(
screen.queryByRole( 'button', { name: 'View site front end' } )
).not.toBeInTheDocument();
} );

it( 'does not switch realms from the active segment', () => {
const { onSwitchRealm } = renderAddressBar( { path: '/' } );

fireEvent.click( screen.getByText( 'Example Site' ) );
expect( onSwitchRealm ).not.toHaveBeenCalled();
} );

it( 'hides the database segment when the database tab is turned off', () => {
renderAddressBar( { path: '/', showDatabaseTab: false } );

expect( screen.getByRole( 'button', { name: 'View WP Admin' } ) ).toBeInTheDocument();
expect( screen.queryByRole( 'button', { name: 'View database' } ) ).not.toBeInTheDocument();
} );

it( 'marks the segment matching the current path as active', () => {
renderAddressBar( { path: '/wp-admin/plugins.php' } );

expect( screen.getByRole( 'button', { name: 'View site front end' } ) ).toBeInTheDocument();
expect( screen.getByRole( 'button', { name: 'View database' } ) ).toBeInTheDocument();
expect( screen.queryByRole( 'button', { name: 'View WP Admin' } ) ).not.toBeInTheDocument();
} );
} );
Loading