diff --git a/apps/ui/src/components/connect-site-picker/index.tsx b/apps/ui/src/components/connect-site-picker/index.tsx new file mode 100644 index 0000000000..a0c1d42573 --- /dev/null +++ b/apps/ui/src/components/connect-site-picker/index.tsx @@ -0,0 +1,336 @@ +import { Spinner, VisuallyHidden } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { external, search } from '@wordpress/icons'; +import { Badge, Button, Icon } from '@wordpress/ui'; +import { clsx } from 'clsx'; +import { useMemo, useState } from 'react'; +import { useConnector } from '@/data/core'; +import { useUserLocale } from '@/data/queries/use-user-locale'; +import { useOffline } from '@/hooks/use-offline'; +import { getLocalizedLink } from '@/lib/docs-links'; +import { presentRemoteSites, searchRemoteSites, type ConnectSiteGroup } from './site-presentation'; +import styles from './style.module.css'; +import type { SyncSite } from '@/data/core'; + +const createWpcomSiteUrl = new URL( 'https://wordpress.com/setup/new-hosted-site' ); +createWpcomSiteUrl.searchParams.set( 'ref', 'studio' ); +createWpcomSiteUrl.searchParams.set( 'section', 'studio-sync' ); +createWpcomSiteUrl.searchParams.set( 'showDomainStep', 'true' ); + +function getEnvironmentLabel( site: SyncSite ): string { + if ( site.isPressable && site.environmentType === 'development' ) return __( 'Development' ); + if ( site.isPressable && site.environmentType === 'staging' ) return __( 'Staging' ); + if ( site.isStaging ) return __( 'Staging' ); + return __( 'Production' ); +} + +function getEnvironmentIntent( site: SyncSite ) { + if ( site.isPressable && site.environmentType === 'development' ) return 'informational'; + if ( site.isStaging || ( site.isPressable && site.environmentType === 'staging' ) ) + return 'medium'; + return 'stable'; +} + +function getSiteStatus( site: SyncSite, group: ConnectSiteGroup ): string { + if ( group === 'needs-transfer' ) { + return __( 'Enable hosting features on WordPress.com before connecting this site.' ); + } + if ( group === 'needs-upgrade' ) { + return __( 'Upgrade this site to a supported plan before connecting it.' ); + } + if ( site.syncSupport === 'missing-permissions' ) { + return __( "Your account doesn't have permission to manage this site." ); + } + if ( site.syncSupport === 'deleted' ) return __( 'This site has been deleted.' ); + return __( 'This site does not support pulling into Studio.' ); +} + +export function getSiteName( site: SyncSite ): string { + if ( site.name.trim() ) return site.name.trim(); + try { + return new URL( site.url ).hostname; + } catch { + return __( 'WordPress site' ); + } +} + +function RemoteSiteCard( { + site, + group, + isSelected, + onSelect, +}: ReturnType< typeof presentRemoteSites >[ number ] & { + isSelected: boolean; + onSelect: ( id: number ) => void; +} ) { + const connector = useConnector(); + const isAvailable = group === 'available'; + const siteName = getSiteName( site ); + const providerLabel = site.isPressable ? __( 'Pressable' ) : __( 'WP.com' ); + const environmentLabel = getEnvironmentLabel( site ); + const siteStatus = isAvailable ? '' : getSiteStatus( site, group ); + const className = clsx( + styles.siteCard, + isSelected && styles.siteCardSelected, + ! isAvailable && styles.siteCardUnavailable + ); + + return ( +
  • + + { group === 'needs-transfer' && ( + + ) } + { group === 'needs-upgrade' && ( + + ) } +
  • + ); +} + +export type ConnectSitePickerProps = { + sites: SyncSite[] | undefined; + isLoading: boolean; + isFetching: boolean; + error: unknown; + onRefresh: () => void; + selectedId: number | null; + onSelect: ( id: number ) => void; + // Shown when the account has no sites this flow can use. + emptyTitle?: string; + emptyDescription?: string; +}; + +/** + * The list of WordPress.com and Pressable sites a Studio site can be wired to, + * with its search, its grouping into what can and can't be connected, and the + * states around loading them. Shared by onboarding, which uses it to bring a + * live site down into Studio, and by publishing, which uses it to send one up — + * the choice is the same either way, so it should look the same. + */ +export function ConnectSitePicker( { + sites, + isLoading, + isFetching, + error, + onRefresh, + selectedId, + onSelect, + emptyTitle = __( 'No sites found' ), + emptyDescription = __( 'This account has no WordPress.com or Pressable sites to show.' ), +}: ConnectSitePickerProps ) { + const connector = useConnector(); + const locale = useUserLocale(); + const isOffline = useOffline(); + const [ searchQuery, setSearchQuery ] = useState( '' ); + + const presentedSites = useMemo( () => presentRemoteSites( sites ?? [] ), [ sites ] ); + const filteredSites = useMemo( + () => searchRemoteSites( presentedSites, searchQuery ), + [ presentedSites, searchQuery ] + ); + const isSingleSite = presentedSites.length === 1 && searchQuery.trim() === ''; + const isSingleAvailableSite = isSingleSite && presentedSites[ 0 ].group === 'available'; + + if ( isOffline ) { + return ( +
    +

    { __( "You're offline" ) }

    +

    { __( 'Reconnect to load your WordPress.com and Pressable sites.' ) }

    +
    + ); + } + + if ( isLoading ) { + return ( +
    + +

    { __( 'Loading your sites…' ) }

    +
    + ); + } + + if ( error ) { + return ( +
    +

    { __( "We couldn't load your sites" ) }

    +

    { __( 'Check your connection and try again.' ) }

    + +
    + ); + } + + if ( presentedSites.length === 0 ) { + return ( +
    +

    { emptyTitle }

    +

    { emptyDescription }

    + +
    + ); + } + + const sections = [ + { + key: 'available', + title: __( 'Available to connect' ), + description: __( 'Select a site to create its local copy.' ), + sites: filteredSites.filter( ( entry ) => entry.group === 'available' ), + }, + { + key: 'unavailable', + title: __( 'Unavailable' ), + description: __( 'These sites cannot currently be connected to Studio.' ), + sites: filteredSites.filter( ( entry ) => entry.group !== 'available' ), + }, + ]; + + return ( + <> +
    + { ! isSingleSite && ( + + ) } +

    + + + +

    +
    + + { filteredSites.length === 0 ? ( +
    +

    + { sprintf( + // translators: %s is the site search query. + __( 'No sites match “%s”.' ), + searchQuery + ) } +

    +
    + ) : isSingleAvailableSite ? ( + + ) : ( +
    + { sections.map( + ( section ) => + section.sites.length > 0 && ( +
    +
    +

    { section.title }

    +

    { section.description }

    +
    + +
    + ) + ) } +
    + ) } + + ); +} diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.test.ts b/apps/ui/src/components/connect-site-picker/site-presentation.test.ts similarity index 100% rename from apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.test.ts rename to apps/ui/src/components/connect-site-picker/site-presentation.test.ts diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.ts b/apps/ui/src/components/connect-site-picker/site-presentation.ts similarity index 100% rename from apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.ts rename to apps/ui/src/components/connect-site-picker/site-presentation.ts diff --git a/apps/ui/src/components/connect-site-picker/style.module.css b/apps/ui/src/components/connect-site-picker/style.module.css new file mode 100644 index 0000000000..39e8fbe5df --- /dev/null +++ b/apps/ui/src/components/connect-site-picker/style.module.css @@ -0,0 +1,226 @@ +/* The site cards, search, and list states shared by onboarding and the + publish flow. */ + +.state p { + margin: 0; + color: var(--wpds-color-fg-content-neutral-weak); +} + +.state { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + max-width: 520px; + margin-inline: auto; + padding: 0 20px 32px; +} + +.state h2 { + margin: 0; + font-size: var(--wpds-typography-font-size-lg); + font-weight: 500; +} + +.siteControls { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + margin-bottom: 32px; +} + +.search { + display: flex; + align-items: center; + gap: 8px; + width: min(100%, 520px); + padding: 0 12px; + border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral); + border-radius: 6px; + background: var(--wpds-color-bg-surface-neutral); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.search:focus-within { + border-color: var(--wpds-color-stroke-focus-brand); + box-shadow: 0 0 0 1px var(--wpds-color-stroke-focus-brand); +} + +.search input { + width: 100%; + height: 40px; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--wpds-color-fg-content-neutral); + font: inherit; +} + +.search input::placeholder { + color: var(--wpds-color-fg-content-neutral-weak); +} + +.helperLinks { + display: flex; + align-items: center; + gap: 2px; + margin: 0; + color: var(--wpds-color-fg-content-neutral-weak); +} + +.helperLinks button { + padding-inline: 2px; +} + +.sections { + display: flex; + flex-direction: column; + gap: 40px; + text-align: start; +} + +.section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.sectionHeader { + text-align: center; +} + +.sectionHeader h2 { + margin: 0 0 4px; + font-size: var(--wpds-typography-font-size-lg); + font-weight: 500; +} + +.sectionHeader p { + margin: 0; + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-sm); +} + +.siteGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); + gap: 20px; + margin: 0; + padding: 0; + list-style: none; +} + +.singleSiteGrid { + grid-template-columns: minmax(0, 440px); + justify-content: center; +} + +.siteCardWrapper { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; +} + +.siteCard { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + padding: 6px; + border: 0; + border-radius: 12px; + background: transparent; + color: var(--wpds-color-fg-content-neutral); + cursor: pointer; + text-align: start; +} + +.siteCardUnavailable, +.siteCardUnavailable:hover { + cursor: default; +} + +.siteCardSelected .siteThumb { + box-shadow: 0 0 0 1px var(--wpds-color-stroke-interactive-brand); +} + +.siteCard:focus-visible { + outline: 2px solid var(--wpds-color-stroke-focus-brand); + outline-offset: 2px; +} + +.siteThumb { + position: relative; + display: block; + width: 100%; + aspect-ratio: 3 / 2; + overflow: hidden; + border-radius: 8px; + background: var(--wpds-color-bg-surface-neutral-strong); + box-shadow: 0 0 0 var(--wpds-border-width-xs) var(--wpds-color-stroke-surface-neutral); + transition: box-shadow 0.15s ease; +} + +.siteThumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.siteCardUnavailable .siteThumb { + opacity: 0.65; +} + +.siteText { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + padding: 10px 8px 8px; +} + +.siteName { + overflow: hidden; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.siteUrl { + overflow: hidden; + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-sm); + text-overflow: ellipsis; + white-space: nowrap; +} + +.siteStatus { + margin-top: 4px; + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-sm); + line-height: 1.4; +} + +.badges { + position: absolute; + inset-inline-end: 8px; + inset-block-end: 8px; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.siteAction { + align-self: center; + margin-top: 4px; +} + + +@media (max-width: 600px) { + .siteGrid { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/apps/ui/src/components/menu/index.tsx b/apps/ui/src/components/menu/index.tsx index 647d8d9fec..31f5a1c98f 100644 --- a/apps/ui/src/components/menu/index.tsx +++ b/apps/ui/src/components/menu/index.tsx @@ -31,6 +31,8 @@ type PopupProps = { align?: 'start' | 'center' | 'end'; sideOffset?: number; alignOffset?: number; + /** Raises the menu above modal surfaces. Set when the trigger is inside a dialog. */ + aboveOverlays?: boolean; className?: string; onClick?: MouseEventHandler< HTMLElement >; onPointerDown?: PointerEventHandler< HTMLElement >; @@ -47,6 +49,7 @@ export function Popup( { align = 'start', sideOffset = 4, alignOffset, + aboveOverlays, className, onClick, onPointerDown, @@ -58,7 +61,9 @@ export function Popup( { align={ align } sideOffset={ sideOffset } alignOffset={ alignOffset } - className={ styles.positioner } + className={ `${ styles.positioner }${ + aboveOverlays ? ` ${ styles.positionerAboveOverlays }` : '' + }` } > { /* Portals mount into document.body, escaping the app-root ThemeProvider's `data-wpds-density='compact'` wrapper and diff --git a/apps/ui/src/components/menu/style.module.css b/apps/ui/src/components/menu/style.module.css index c7bb94df48..4855365a55 100644 --- a/apps/ui/src/components/menu/style.module.css +++ b/apps/ui/src/components/menu/style.module.css @@ -10,6 +10,12 @@ outline: none; } +/* Above the dialog scrim (700) so a menu opened from inside a dialog isn't + trapped behind it — matches the select tier. */ +.positionerAboveOverlays { + z-index: var(--wp-ui-select-z-index, 750); +} + .popup { min-width: 160px; padding: var(--wpds-dimension-padding-xs); diff --git a/apps/ui/src/components/selective-sync/sync-dialog.tsx b/apps/ui/src/components/selective-sync/sync-dialog.tsx index a47a5219db..2f06ec153e 100644 --- a/apps/ui/src/components/selective-sync/sync-dialog.tsx +++ b/apps/ui/src/components/selective-sync/sync-dialog.tsx @@ -322,7 +322,10 @@ export function SyncDialog( { return ( diff --git a/apps/ui/src/components/site-dropdown/dropdown-trigger.module.css b/apps/ui/src/components/site-dropdown/dropdown-trigger.module.css deleted file mode 100644 index 81c84f9240..0000000000 --- a/apps/ui/src/components/site-dropdown/dropdown-trigger.module.css +++ /dev/null @@ -1,236 +0,0 @@ -.trigger { - --site-dropdown-radius: var(--wpds-border-radius-lg); - --site-menu-active-fill: var(--wpds-color-bg-interactive-neutral-strong); - --site-menu-resting-fill: var(--wpds-color-bg-interactive-neutral-strong-active); - --site-menu-foreground: var(--wpds-color-fg-interactive-neutral-strong); - --wp-ui-button-background-color: var(--site-menu-resting-fill); - --wp-ui-button-background-color-active: var(--site-menu-active-fill); - --wp-ui-button-border-color: transparent; - --wp-ui-button-border-color-active: transparent; - --wp-ui-button-foreground-color: var(--site-menu-foreground); - --wp-ui-button-foreground-color-active: var(--site-menu-foreground); - --wp-ui-button-height: 44px; - --wp-ui-button-padding-block: 0; - --wp-ui-button-padding-inline: 0; - - gap: 12px; - max-width: 100%; - min-height: 44px; - min-width: 0; - align-items: center; - backdrop-filter: blur(20px) saturate(115%); - border-width: 0; - border-radius: var(--site-dropdown-radius); - box-shadow: 0 8px 18px rgb(0 0 0 / 18%); - color: var(--site-menu-foreground); - overflow: hidden; - padding-inline: 0 12px; - white-space: nowrap; -} - -/* Non-floating placements (regular header rows) drop the floating-card - shadow. */ -.trigger.triggerFlat { - box-shadow: none; -} - -.siteIconWrap { - position: relative; - display: inline-flex; - flex: 0 0 auto; - width: 44px; - height: 44px; -} - -.siteIcon { - width: 100%; - height: 100%; - border-radius: 0; -} - -.siteIcon_stopped { - opacity: 0.72; -} - -.identity { - display: inline-flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - gap: 0; - min-width: 0; - max-width: min(420px, 56vw); - padding-inline: 0 var(--wpds-dimension-padding-xs); - text-align: left; -} - -.site { - flex: 0 1 auto; - min-width: 0; - color: var(--site-menu-foreground); - overflow: hidden; - font-weight: 600; - line-height: var(--wpds-typography-line-height-sm); - text-overflow: ellipsis; - white-space: nowrap; -} - -.secondary { - display: inline-flex; - align-items: center; - gap: 5px; - max-width: 100%; - min-width: 0; - color: color-mix(in srgb, var(--site-menu-foreground) 78%, transparent); - font-size: 11px; - font-weight: 350; - line-height: 1.05; -} - -.secondaryLabel { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.secondary_pending { - color: color-mix( - in srgb, - var(--site-menu-foreground) 70%, - var(--wpds-color-fg-interactive-brand) - ); -} - -.secondary_success { - color: color-mix( - in srgb, - var(--site-menu-foreground) 72%, - var(--wpds-color-fg-content-success, var(--site-menu-foreground)) - ); -} - -.secondary_error { - color: color-mix( - in srgb, - var(--site-menu-foreground) 72%, - var(--wpds-color-fg-content-error, var(--site-menu-foreground)) - ); -} - -.statusBadge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 8px; - height: 8px; - border-radius: 999px; - flex-shrink: 0; -} - -.statusBadge_overlay { - position: absolute; - right: -4px; - bottom: 8px; - z-index: 1; - box-shadow: 0 0 0 2px var(--site-menu-resting-fill); -} - -.statusBadge_live { - background: #2563eb; - color: var(--wpds-color-fg-content-inverted, #fff); -} - -.statusBadge:not(.statusBadge_overlay).statusBadge_live { - width: auto; - height: 20px; - gap: 4px; - padding: 0 6px; - background: color-mix(in srgb, #2563eb 9%, transparent); - color: #2563eb; -} - -.statusBadge_stopped { - border-radius: 2px; - background: var(--wpds-color-fg-content-neutral-weak); -} - -.statusBadge_transitioning { - background: #2563eb; - animation: siteDotBlink 1s ease-in-out infinite; -} - -.dot { - width: 100%; - height: 100%; - border-radius: 50%; - flex-shrink: 0; -} - -.dot_running { - background-color: var(--studio-color-status-running); -} - -.dot_stopped { - background-color: var(--wpds-color-fg-content-neutral-weak); -} - -.dot_transitioning { - background-color: #2563eb; -} - -.dot_live { - background-color: #2563eb; -} - -.pauseMark, -.pauseMark::before, -.pauseMark::after { - display: block; - width: 1.5px; - height: 4px; - border-radius: 1px; - background: var(--wpds-color-bg-surface-neutral-strong); -} - -.pauseMark { - position: relative; - background: transparent; -} - -.pauseMark::before, -.pauseMark::after { - position: absolute; - top: 0; - content: ''; -} - -.pauseMark::before { - left: -2px; -} - -.pauseMark::after { - left: 1px; -} - -.statusLabel { - font-size: var(--wpds-typography-font-size-xs); - font-weight: 500; - line-height: 1; -} - -@keyframes siteDotBlink { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.25; - } -} - -.chevron { - flex: 0 0 auto; - color: color-mix(in srgb, var(--site-menu-foreground) 72%, transparent); - fill: currentColor; -} diff --git a/apps/ui/src/components/site-dropdown/dropdown-trigger.tsx b/apps/ui/src/components/site-dropdown/dropdown-trigger.tsx deleted file mode 100644 index 23a8a82f7d..0000000000 --- a/apps/ui/src/components/site-dropdown/dropdown-trigger.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import { chevronDownSmall } from '@wordpress/icons'; -import { Button, Icon, Tooltip } from '@wordpress/ui'; -import { clsx } from 'clsx'; -import { forwardRef } from 'react'; -import { SiteIcon } from '@/components/site-icon'; -import styles from './dropdown-trigger.module.css'; -import type { TriggerSecondaryTone } from './trigger-secondary'; -import type { ComponentProps, ElementRef } from 'react'; - -export type SiteStatus = 'running' | 'stopped' | 'transitioning'; - -type Props = Omit< ComponentProps< typeof Button >, 'children' > & { - siteName: string; - siteUrl: string; - status: SiteStatus; - statusLabel: string; - environment: 'local' | 'live'; - secondaryLabel: string; - secondaryTone?: TriggerSecondaryTone; - showSiteIcon?: boolean; - showStatus?: boolean; - siteIconSeed?: string; - siteIconImage?: string | null; - // The floating-card shadow suits placements where the trigger overlays - // panel content (the chat header); regular header rows pass false. - floating?: boolean; -}; - -export const DropdownTrigger = forwardRef< ElementRef< typeof Button >, Props >( - function DropdownTrigger( - { - siteName, - siteUrl, - status, - statusLabel, - environment, - secondaryLabel, - secondaryTone = 'neutral', - showSiteIcon = false, - showStatus = true, - siteIconSeed, - siteIconImage, - floating = true, - className, - ...props - }, - ref - ) { - // In live mode the local server's running/stopped status is irrelevant - // to what the agent targets; use a dedicated dot color so the trigger - // still reflects the active target. - const isLive = environment === 'live'; - const dotClass = environment === 'live' ? styles.dot_live : styles[ `dot_${ status }` ]; - const statusClass = - environment === 'live' ? styles.statusBadge_live : styles[ `statusBadge_${ status }` ]; - const dotLabel = isLive ? __( 'Live site' ) : statusLabel; - const statusBadge = showStatus ? ( - - { status === 'stopped' && ! isLive ? ( -