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
9 changes: 5 additions & 4 deletions apps/ui/src/components/open-in-menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { chevronDown, Icon } from '@wordpress/icons';
import { Button, Tooltip } from '@wordpress/ui';
import { useState } from 'react';
import * as Menu from '@/components/menu';
import splitStyles from '@/components/split-button/style.module.css';
import styles from './style.module.css';
import { useOpenInDestinations } from './use-open-in-destinations';
import type { OpenInDestination } from './use-open-in-destinations';
Expand Down Expand Up @@ -71,15 +72,15 @@ export function OpenInMenu( {

return (
<Menu.Root>
<div className={ styles.splitTrigger }>
<div className={ splitStyles.splitTrigger }>
<Tooltip.Root>
<Tooltip.Trigger
render={
<Button
variant="minimal"
tone="neutral"
size="small"
className={ styles.splitAction }
className={ splitStyles.splitAction }
aria-label={ actionLabel }
disabled={ lastUsedDestination.disabled }
onClick={ () => lastUsedDestination.open() }
Expand All @@ -101,7 +102,7 @@ export function OpenInMenu( {
variant="minimal"
tone="neutral"
size="small"
className={ styles.splitMenuButton }
className={ splitStyles.splitMenuButton }
aria-label={ __( 'Open in…' ) }
/>
}
Expand All @@ -112,7 +113,7 @@ export function OpenInMenu( {
<Icon
icon={ chevronDown }
size={ 12 }
className={ styles.chevron }
className={ splitStyles.chevron }
data-keep-size
/>
</Tooltip.Trigger>
Expand Down
38 changes: 0 additions & 38 deletions apps/ui/src/components/open-in-menu/style.module.css
Original file line number Diff line number Diff line change
@@ -1,40 +1,3 @@
.splitTrigger {
display: inline-flex;
align-items: center;
gap: 1px;
flex: 0 0 auto;
border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral-weak);
/* The buttons' own radius plus the border width, so the halves' rounded
outer corners nest flush against the frame. */
border-radius: calc(var(--wpds-border-radius-sm) + var(--wpds-border-width-xs));
-webkit-app-region: no-drag;
}

/* Size the halves so the pair reads as one compact button: normal padding
on the outer edges, almost none along the shared seam, and a very narrow
chevron tab. Squaring off the shared corners makes the hover fills read
as one control split in two. */
.splitAction {
--wp-ui-button-min-width: unset;

padding-inline: var(--wpds-dimension-padding-xs) 1px;
border-start-end-radius: 0;
border-end-end-radius: 0;
}

.splitMenuButton {
--wp-ui-button-min-width: unset;

padding-inline: 1px 3px;
border-start-start-radius: 0;
border-end-start-radius: 0;
}

.chevron {
color: var(--wpds-color-fg-content-neutral-weak);
fill: currentColor;
}

.popup {
min-width: 180px;
}
Expand All @@ -52,7 +15,6 @@
fill attribute and would otherwise render with the black SVG default. The
brand logos declare their own fill (`currentColor` or `none` for the
stroke-drawn ones), so scope the rule to leave them alone. */
.splitAction svg:not([fill]),
.itemIcon svg:not([fill]) {
fill: currentColor;
}
19 changes: 19 additions & 0 deletions apps/ui/src/components/site-preview/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,25 @@ describe( 'SitePreview', () => {
expect( screen.getByRole( 'button', { name: 'Annotate' } ) ).toBeInTheDocument();
} );

it( 'shows a single annotate toggle while no notes are pending', () => {
useConnectorMock.mockReturnValue( {
startSite: vi.fn().mockResolvedValue( undefined ),
capabilities: { ...CAPABILITIES, annotatePreview: true },
} as never );

renderPreview(
<SitePreview site={ createSite( { running: true } ) } path="/" reloadNonce={ 0 } />
);

// One command means no collapsed variant: a second control would be a
// duplicate of this one at every width, and a menu wrapping it would be
// a single-item dropdown.
expect( screen.getAllByRole( 'button', { name: 'Annotate' } ) ).toHaveLength( 1 );
expect(
screen.queryByRole( 'button', { name: 'Annotation options' } )
).not.toBeInTheDocument();
} );

it( 'hides the Annotate control when agentic features are off', () => {
useConnectorMock.mockReturnValue( {
startSite: vi.fn().mockResolvedValue( undefined ),
Expand Down
159 changes: 133 additions & 26 deletions apps/ui/src/components/site-preview/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { __, sprintf } from '@wordpress/i18n';
import { chevronLeft, chevronRight, moreVertical, pencil } from '@wordpress/icons';
import {
chevronDown,
chevronLeft,
chevronRight,
Icon,
moreVertical,
pencil,
} from '@wordpress/icons';
import { ariaKeyShortcut, displayShortcut, isAppleOS, isKeyboardEvent } from '@wordpress/keycodes';
import { Button, IconButton } from '@wordpress/ui';
import { Button, IconButton, Tooltip } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DotGrid } from '@/components/dot-grid';
import * as Menu from '@/components/menu';
import { OpenInMenu } from '@/components/open-in-menu';
import splitStyles from '@/components/split-button/style.module.css';
import { useConnector } from '@/data/core';
import { useAgenticFeatures } from '@/data/queries/use-agentic-features';
import { useIsSiteStarting, useStartSite } from '@/data/queries/use-sites';
Expand Down Expand Up @@ -525,6 +533,123 @@ function PreviewOverflowMenu( {
);
}

// Annotation commands. With nothing pending there's only one command, so the
// toolbar shows a bare toggle at every width. Once notes are waiting there are
// two, and a narrow toolbar can't fit them inline — `style.module.css` swaps
// the inline pair for a split button, so only one layout is ever in the a11y
// tree.
function PreviewAnnotationControls( {
isPicking,
annotationCount,
disabled,
onCommand,
}: {
isPicking: boolean;
annotationCount: number;
disabled: boolean;
onCommand: ( type: InspectorCommand[ 'type' ] ) => void;
} ) {
const toggleLabel = isPicking ? __( 'Stop annotating' ) : __( 'Annotate' );
const submitLabel = __( 'Send annotations to chat' );
const hasPending = annotationCount > 0;
return (
<>
<div
className={ clsx( styles.annotationControls, hasPending && styles.annotationControlsWide ) }
>
<IconButton
variant="minimal"
tone="neutral"
size="small"
icon={ pencil }
label={ toggleLabel }
disabled={ disabled }
aria-pressed={ isPicking }
onClick={ () => onCommand( 'toggle-picking' ) }
/>
{ hasPending ? (
<Button
variant="solid"
tone="brand"
size="small"
disabled={ disabled }
aria-label={ submitLabel }
onClick={ () => onCommand( 'submit' ) }
>
{ __( 'Send to chat' ) }
</Button>
) : null }
</div>
{ hasPending ? (
<div className={ styles.annotationMenu }>
{ /* Two commands to offer, so it becomes a split button matching the
"Open in…" control beside it: the pencil still toggles directly,
the chevron opens the pair. Modal for the same reason as the
overflow menu — the webview swallows outside clicks, so the
backdrop is what dismisses it. */ }
<Menu.Root>
<div className={ splitStyles.splitTrigger }>
<Tooltip.Root>
<Tooltip.Trigger
render={
<Button
variant="minimal"
tone="neutral"
size="small"
className={ splitStyles.splitAction }
aria-label={ toggleLabel }
aria-pressed={ isPicking }
disabled={ disabled }
onClick={ () => onCommand( 'toggle-picking' ) }
/>
}
>
<Icon icon={ pencil } size={ 18 } />
</Tooltip.Trigger>
<Tooltip.Popup positioner={ <Tooltip.Positioner side="bottom" /> }>
{ toggleLabel }
</Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root>
<Menu.Trigger
render={
<Tooltip.Trigger
render={
<Button
variant="minimal"
tone="neutral"
size="small"
className={ splitStyles.splitMenuButton }
aria-label={ __( 'Annotation options' ) }
disabled={ disabled }
/>
}
>
<Icon
icon={ chevronDown }
size={ 12 }
className={ splitStyles.chevron }
data-keep-size
/>
</Tooltip.Trigger>
}
/>
<Tooltip.Popup positioner={ <Tooltip.Positioner side="bottom" /> }>
{ __( 'Annotation options' ) }
</Tooltip.Popup>
</Tooltip.Root>
</div>
<Menu.Popup side="bottom" align="end">
<Menu.Item onClick={ () => onCommand( 'toggle-picking' ) }>{ toggleLabel }</Menu.Item>
<Menu.Item onClick={ () => onCommand( 'submit' ) }>{ submitLabel }</Menu.Item>
</Menu.Popup>
</Menu.Root>
</div>
) : null }
</>
);
}

function areBrowserStatesEqual( a: BrowserNavigationState, b: BrowserNavigationState ) {
return (
a.canGoBack === b.canGoBack &&
Expand Down Expand Up @@ -924,30 +1049,12 @@ export function SitePreview( {
</div>
<div className={ clsx( styles.headerSide, styles.headerSideEnd ) }>
{ canPreview && chatEnabled && connector.capabilities.annotatePreview ? (
<div className={ styles.annotationControls }>
<IconButton
variant="minimal"
tone="neutral"
size="small"
icon={ pencil }
label={ inspectorState.isPicking ? __( 'Stop annotating' ) : __( 'Annotate' ) }
disabled={ ! canAnnotate }
aria-pressed={ inspectorState.isPicking }
onClick={ () => sendInspectorCommand( 'toggle-picking' ) }
/>
{ inspectorState.annotationCount > 0 ? (
<Button
variant="solid"
tone="brand"
size="small"
disabled={ ! canAnnotate }
aria-label={ __( 'Submit annotations' ) }
onClick={ () => sendInspectorCommand( 'submit' ) }
>
{ __( 'Submit' ) }
</Button>
) : null }
</div>
<PreviewAnnotationControls
isPicking={ inspectorState.isPicking }
annotationCount={ inspectorState.annotationCount }
disabled={ ! canAnnotate }
onCommand={ sendInspectorCommand }
/>
) : null }
<OpenInMenu key={ site.id } site={ site } browserPath={ getSafePath( path ) } />
{ canPreview ? (
Expand Down
Loading