Skip to content
Draft
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
157 changes: 110 additions & 47 deletions apps/ui/src/components/create-site-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { RecommendedPHPVersion } from '@studio/common/types/php-versions';
import { BaseControl, CheckboxControl } from '@wordpress/components';
import { DataForm, useFormValidity } from '@wordpress/dataviews';
import { __, sprintf } from '@wordpress/i18n';
import { chevronDown, chevronRight } from '@wordpress/icons';
import { chevronLeft, chevronDown, chevronRight } from '@wordpress/icons';
import { Button, Icon } from '@wordpress/ui';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BusyOverlay } from '@/components/busy-overlay';
import { LearnHowLink, LearnMoreLink } from '@/components/learn-more';
import { OnboardingFooter } from '@/components/onboarding-footer';
import {
adminEmailField,
adminPasswordField,
Expand All @@ -21,6 +23,7 @@ import {
} from '@/components/site-fields';
import { usePathValidator } from '@/data/queries/use-create-site-helpers';
import { useSites } from '@/data/queries/use-sites';
import { useWordPressVersions } from '@/data/queries/use-wordpress-versions';
import styles from './style.module.css';
import type { SupportedPHPVersion } from '@studio/common/types/php-versions';
import type {
Expand Down Expand Up @@ -302,6 +305,36 @@ export function CreateSiteForm( {
} );
}, [ initialValues ] );

const { data: wpVersions } = useWordPressVersions();

// Land keyboard focus in the Site name field on mount — it's the first
// thing every flow asks for. The onboarding layout's heading-focus
// fallback yields when a page claims focus itself.
const formRef = useRef< HTMLFormElement >( null );
useEffect( () => {
const input = formRef.current?.querySelector< HTMLInputElement >(
'input[type="text"], input:not([type])'
);
input?.focus();
}, [] );

// Drop a wpVersion that isn't in the installable-versions list (e.g. a
// blueprint preferring a release below the minimum supported version) —
// mirrors the desktop renderer, which silently ignores unsupported
// preferred versions. Keyed on the current value as well as the list:
// initial values seed asynchronously, so with a warm versions cache the
// list alone would never change again and a late seed would slip through.
useEffect( () => {
if ( ! wpVersions?.length ) {
return;
}
setData( ( prev ) =>
wpVersions.some( ( version ) => version.value === prev.wpVersion )
? prev
: { ...prev, wpVersion: DEFAULT_WORDPRESS_VERSION }
);
}, [ wpVersions, data.wpVersion ] );

const fields = useMemo< Field< FormData >[] >(
() => [
siteNameField< FormData >(),
Expand All @@ -321,7 +354,7 @@ export function CreateSiteForm( {
},
},
phpVersionField< FormData >(),
wpVersionField< FormData >( DEFAULT_WORDPRESS_VERSION ),
wpVersionField< FormData >( DEFAULT_WORDPRESS_VERSION, wpVersions ),
adminUsernameField< FormData >(),
adminPasswordField< FormData >(),
adminEmailField< FormData >(),
Expand All @@ -335,7 +368,7 @@ export function CreateSiteForm( {
Edit: EnableHttpsControl,
},
],
[ existingDomainNames ]
[ existingDomainNames, wpVersions ]
);

const basicForm = useMemo< Form >(
Expand Down Expand Up @@ -425,70 +458,100 @@ export function CreateSiteForm( {

const advancedErrorCount = countAdvancedErrors( validity, advancedForm );

return (
<form className={ styles.form } onSubmit={ handleSubmit }>
<DataForm< FormData >
data={ data }
fields={ fields }
form={ basicForm }
onChange={ handleChange }
validity={ validity }
/>

// The buttons stay inside the <form> element so the submit button keeps
// its implicit form association while floating in the footer.
const actionButtons = (
<>
<Button
type="button"
variant="unstyled"
variant="minimal"
tone="neutral"
className={ styles.advancedToggle }
onClick={ () => setIsAdvancedOpen( ( value ) => ! value ) }
aria-expanded={ isAdvancedOpen }
onClick={ onCancel }
disabled={ isSubmitting }
>
<Icon icon={ isAdvancedOpen ? chevronDown : chevronRight } />
<span>{ __( 'Advanced settings' ) }</span>
{ ! isAdvancedOpen && advancedErrorCount > 0 && (
<span className={ styles.advancedErrorCount }>
{ advancedErrorCount === 1
? __( '1 error found' )
: /* translators: %d: number of errors */
`${ advancedErrorCount } ${ __( 'errors found' ) }` }
</span>
) }
<Icon icon={ chevronLeft } size={ 16 } />
<span>{ __( 'Back' ) }</span>
</Button>
<Button
type="submit"
variant="solid"
tone="brand"
disabled={ ! canSubmit }
loading={ isSubmitting }
loadingAnnouncement={ __( 'Creating site' ) }
data-testid="create-site-submit"
>
{ submitLabel ?? __( 'Create site' ) }
</Button>
</>
);

{ isAdvancedOpen && (
return (
<form ref={ formRef } className={ styles.form } onSubmit={ handleSubmit }>
{ /* While creating, shield the rest of the window and freeze the
fields (inert) — the submit button's spinner is the progress
indication. */ }
<BusyOverlay active={ !! isSubmitting } />
{ /* The frosted panel wraps only the fields: its backdrop-filter
turns it into a containing block for fixed descendants, so the
fixed OnboardingFooter must stay outside (but inside the form
for the submit button's implicit association). */ }
<div className={ styles.panel } inert={ isSubmitting || undefined }>
<DataForm< FormData >
data={ data }
fields={ fields }
form={ advancedForm }
form={ basicForm }
onChange={ handleChange }
validity={ validity }
/>
) }

{ submitError && <div className={ styles.submitError }>{ submitError }</div> }

<div className={ styles.actions }>
<Button
type="button"
variant="minimal"
variant="unstyled"
tone="neutral"
onClick={ onCancel }
disabled={ isSubmitting }
className={ styles.advancedToggle }
onClick={ () => setIsAdvancedOpen( ( value ) => ! value ) }
aria-expanded={ isAdvancedOpen }
>
{ __( 'Cancel' ) }
<Icon icon={ isAdvancedOpen ? chevronDown : chevronRight } />
<span>{ __( 'Advanced settings' ) }</span>
{ ! isAdvancedOpen && advancedErrorCount > 0 && (
<span className={ styles.advancedErrorCount }>
{ advancedErrorCount === 1
? __( '1 error found' )
: /* translators: %d: number of errors */
`${ advancedErrorCount } ${ __( 'errors found' ) }` }
</span>
) }
</Button>
<Button
type="submit"
variant="solid"
tone="brand"
disabled={ ! canSubmit }
loading={ isSubmitting }
loadingAnnouncement={ __( 'Creating site' ) }
data-testid="create-site-submit"

<div
className={
isAdvancedOpen
? `${ styles.advancedCollapse } ${ styles.advancedCollapseOpen }`
: styles.advancedCollapse
}
inert={ ! isAdvancedOpen || undefined }
>
{ submitLabel ?? __( 'Create site' ) }
</Button>
<div className={ styles.advancedCollapseInner }>
<DataForm< FormData >
data={ data }
fields={ fields }
form={ advancedForm }
onChange={ handleChange }
validity={ validity }
/>
</div>
</div>

{ submitError && (
<div role="alert" className={ styles.submitError }>
{ submitError }
</div>
) }
</div>

<OnboardingFooter>{ actionButtons }</OnboardingFooter>
</form>
);
}
106 changes: 75 additions & 31 deletions apps/ui/src/components/create-site-form/style.module.css
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
.form {
text-align: left;
}

/* Frosted panel around the fields so the fixed dot grid reads as a
backdrop instead of bleeding through the (transparent-filled) inputs —
same treatment as the onboarding home cards. Kept off the <form> itself:
backdrop-filter creates a containing block, which would capture the
fixed footer. */
.panel {
display: flex;
flex-direction: column;
gap: 24px;
text-align: left;
padding: 32px;
border: 1px solid var(--wpds-color-stroke-surface-neutral, #ddd);
border-radius: 12px;
background: color-mix(in srgb, var(--wpds-color-bg-surface-neutral-strong, #fff) 50%, transparent);
backdrop-filter: blur(12px);
}

/* The path trigger is a <button>, but we style it to sit within the
DataForm grid the same way the text/email inputs do — 40px tall, 1px
neutral border, 2px radius, matching the InputBase chrome so the field
/* The path trigger is a <button>, but it mirrors the @wordpress/ui
InputLayout chrome (height, border, radius, type, tokens) so the field
reads as "one more input" alongside its siblings. The actual value is
chosen via the OS folder picker opened by clicking the button. */
.pathTrigger {
Expand All @@ -16,36 +28,38 @@
justify-content: space-between;
gap: 8px;
width: 100%;
min-height: 40px;
padding: 0 4px 0 12px;
border: 1px solid #8a8a8a;
height: 40px;
padding: 0 4px 0 var(--wpds-dimension-padding-md, 12px);
background-color: var(--wpds-color-bg-interactive-neutral-weak);
border: var(--wpds-border-width-xs, 1px) solid var(--wpds-color-stroke-interactive-neutral);
/* Fixed 2px, not the radius-sm token (this app bumps it to 4px): the
sibling DataForm fields are @wordpress/components controls with a
hardcoded 2px radius, and the path field must match them. */
border-radius: 2px;
background: #fff;
color: #1e1e1e;
font: inherit;
font-size: 16px;
color: var(--wpds-color-fg-interactive-neutral);
font-family: var(--wpds-typography-font-family-body);
font-size: var(--wpds-typography-font-size-md, 13px);
line-height: 1;
text-align: left;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
transition: border-color 0.15s;
}

.pathTrigger:hover {
border-color: #1e1e1e;
border-color: var(--wpds-color-stroke-interactive-neutral-active);
}

.pathTrigger:focus-visible {
border-color: #1e1e1e;
box-shadow: 0 0 0 0.5px #1e1e1e;
outline: none;
outline: 2px solid var(--wpds-color-stroke-focus-brand, #3858e9);
outline-offset: -1px;
}

.pathTriggerError {
border-color: #d63638;
border-color: var(--wpds-color-stroke-interactive-error, #d63638);
}

.pathTriggerError:focus-visible {
border-color: #d63638;
box-shadow: 0 0 0 0.5px #d63638;
outline-color: var(--wpds-color-stroke-interactive-error, #d63638);
}

.pathValue {
Expand All @@ -57,19 +71,19 @@
}

.pathValuePlaceholder {
color: #757575;
color: var(--wpds-color-fg-interactive-neutral-disabled);
}

.pathTriggerAction {
flex: 0 0 auto;
padding: 6px 12px;
font-size: 13px;
font-size: var(--wpds-typography-font-size-md, 13px);
font-weight: 500;
color: #1e1e1e;
color: var(--wpds-color-fg-interactive-neutral);
}

.pathErrorHelp {
color: #d63638;
color: var(--wpds-color-fg-content-error, #d63638);
}

.advancedToggle {
Expand All @@ -79,23 +93,53 @@
align-self: flex-start;
}

/* Animated expand/collapse for the Advanced settings — the 0fr -> 1fr grid
row transition slides the content open without measuring heights. The
content stays mounted (inert while closed) so closing animates too. */
.advancedCollapse {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
/* Cancels the panel's 24px flex gap while collapsed — the zero-height
row would otherwise leave a phantom gap under the toggle. */
margin-top: -24px;
transition: grid-template-rows 0.25s ease, opacity 0.2s ease, margin-top 0.25s ease;
}

.advancedCollapseOpen {
grid-template-rows: 1fr;
opacity: 1;
margin-top: 0;
}

.advancedCollapseInner {
overflow: hidden;
min-height: 0;
/* The clip needed for the collapse animation would also shave outset
focus rings off fields at the container edges; the negative margin +
equal padding widens the clip box without moving the content. */
padding: 4px;
margin: -4px;
}

@media (prefers-reduced-motion: reduce) {
.advancedCollapse {
transition: none;
}
}

.advancedErrorCount {
margin-left: 8px;
font-size: 12px;
font-weight: 500;
color: #d63638;
color: var(--wpds-color-fg-content-error, #d63638);
}

.submitError {
padding: 10px 12px;
border-radius: 4px;
background: #fce8e8;
color: #7a1a1a;
background: color-mix(in srgb, var(--wpds-color-fg-content-error, #d63638) 12%, transparent);
color: var(--wpds-color-fg-content-error, #7a1a1a);
font-size: 13px;
}

.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
Loading