Give builder fields persistent labels, treat description as optional, and validate save-progress email with a real form.

Name stays required; description is a labelled optional textarea. Email uses native validity and form submit. Drop the dead workshop link and fix a few copy errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-09-10 11:37:41 -06:00
co-authored by Cursor
parent 130663f930
commit 42d83c8b62
33 changed files with 437 additions and 125 deletions
+33 -7
View File
@@ -33,6 +33,7 @@ import {
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
} from "./utils/flowSteps";
import {
CREATE_FLOW_COMMUNITY_SAVE_FORM_ID,
CREATE_FLOW_SYNC_DRAFT_QUERY,
CREATE_FLOW_SYNC_DRAFT_VALUE,
CREATE_ROUTES,
@@ -482,12 +483,28 @@ function CreateFlowLayoutContent({
return () => window.clearTimeout(timeoutId);
}, [currentStep, sessionResolved, sessionUser, openLogin]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
const handleCommunitySaveMagicLinkSubmit = useCallback(async (
formEl?: HTMLFormElement | null,
) => {
setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false);
const raw = state.communitySaveEmail;
const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : "";
if (!isValidCreateFlowSaveEmail(trimmed)) return;
const namedEmail = formEl?.elements.namedItem("email");
const emailField =
namedEmail instanceof HTMLInputElement
? namedEmail
: typeof document === "undefined"
? null
: document.querySelector<HTMLInputElement>(
`input[name="email"][form="${CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}"]`,
);
if (emailField != null && !emailField.checkValidity()) {
emailField.reportValidity();
return;
}
const raw =
emailField != null ? emailField.value : state.communitySaveEmail;
if (typeof raw !== "string" || !isValidCreateFlowSaveEmail(raw)) return;
const trimmed = raw.trim().toLowerCase();
setCommunitySaveMagicLinkSubmitting(true);
try {
@@ -770,6 +787,16 @@ function CreateFlowLayoutContent({
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
}`.trim()}
/>
{currentStep === "community-save" ? (
<form
id={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
className="hidden"
onSubmit={(e) => {
e.preventDefault();
void handleCommunitySaveMagicLinkSubmit(e.currentTarget);
}}
/>
) : null}
<main
className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${mainContentClass} ${mainResponsiveLayout}`}
>
@@ -841,6 +868,8 @@ function CreateFlowLayoutContent({
{footer.saveLater}
</Button>
<Button
type="submit"
form={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
buttonType="filled"
palette="default"
size="xsmall"
@@ -851,9 +880,6 @@ function CreateFlowLayoutContent({
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
void handleCommunitySaveMagicLinkSubmit();
}}
>
{communitySaveMagicLinkSubmitting
? footer.submitEmailSending
@@ -80,6 +80,8 @@ function CustomMethodCardWizardViewComponent({
<TextArea
appearance="default"
formHeader={false}
showHelpIcon={false}
label={copy.step2.title}
placeholder={copy.step2.fieldPlaceholder}
value={policyDescription}
maxLength={maxDescriptionChars}
@@ -1,6 +1,6 @@
"use client";
import { memo } from "react";
import { memo, useId } from "react";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea";
@@ -39,6 +39,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
onProportionBlockTitleChange,
onProportionDefaultChange,
}: CustomMethodCardWizardFieldBodiesViewProps) {
const placeholderFieldId = useId();
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
@@ -53,10 +54,14 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
/>
<div className="flex flex-col gap-2">
<label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]">
<label
htmlFor={placeholderFieldId}
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
>
{copy.text.placeholderLabel}
</label>
<TextArea
id={placeholderFieldId}
formHeader={false}
appearance="embedded"
value={textPlaceholderBody}
@@ -84,6 +89,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
/>
<TextInput
formHeader={false}
label={copy.badges.blockTitleLabel}
placeholder={copy.badges.blockTitlePlaceholder}
value={badgeBlockTitle}
onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
@@ -8,7 +8,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
import Create from "../../../components/modals/Create";
import TextInput from "../../../components/controls/TextInput";
import TextArea from "../../../components/controls/TextArea";
import ContentLockup from "../../../components/type/ContentLockup";
import { useTranslation } from "../../../contexts/MessagesContext";
@@ -95,18 +95,20 @@ export function FinalReviewCommunityContextEditModal({
ariaLabel={tModal("title")}
>
<div className="pb-2">
<TextInput
<TextArea
className="!transition-none"
type="text"
label={tModal("title")}
placeholder={tField("placeholder")}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
}}
inputSize="medium"
size="medium"
formHeader={false}
showHelpIcon={false}
textHint={characterHint}
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
rows={4}
/>
</div>
</Create>
@@ -93,6 +93,7 @@ export function FinalReviewTitleEditModal({
<TextInput
className="!transition-none"
type="text"
label={tModal("title")}
placeholder={tField("placeholder")}
value={draft}
onChange={(e) => {
@@ -100,8 +101,10 @@ export function FinalReviewTitleEditModal({
}}
inputSize="medium"
formHeader={false}
showHelpIcon={false}
textHint={characterHint}
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
required
/>
</div>
</Create>
@@ -115,6 +115,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
messageNamespace="create.community.communityName"
stateField="title"
maxLength={48}
required
/>
);
case "community-structure":
@@ -126,6 +127,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
stateField="communityContext"
maxLength={200}
mainAlign="center"
multiline
/>
);
case "community-size":
@@ -1,6 +1,5 @@
"use client";
import type { ReactNode } from "react";
import NumberedList from "../../../../components/type/NumberedList";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
@@ -31,22 +30,6 @@ export function InformationalScreen() {
},
];
const description: ReactNode = (
<>
{copy.descriptionLead}{" "}
<a
href="#"
className="font-normal text-[var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] cursor-pointer"
onClick={(e) => {
e.preventDefault();
}}
>
{copy.workshopLabel}
</a>{" "}
{copy.descriptionTrail}
</>
);
return (
<CreateFlowStepShell
variant="centeredNarrow"
@@ -57,7 +40,7 @@ export function InformationalScreen() {
>
<CreateFlowHeaderLockup
title={copy.title}
description={description}
description={copy.description}
justification="left"
/>
<NumberedList items={items} size={mdUp ? "M" : "S"} />
@@ -2,6 +2,7 @@
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
import TextInput from "../../../../components/controls/TextInput";
import TextArea from "../../../../components/controls/TextArea";
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
import { useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -12,6 +13,7 @@ import {
type CreateFlowContentTopBelowMd,
} from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../utils/createFlowPaths";
import type { CreateFlowTextStateField } from "../../types";
type Props = {
@@ -21,6 +23,10 @@ type Props = {
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
mainAlign?: "start" | "center";
inputType?: HTMLInputTypeAttribute;
/** Multi-line control (community description). */
multiline?: boolean;
/** Native `required` (community name and save-progress email). */
required?: boolean;
showCharacterCount?: boolean;
headerJustification?: HeaderLockupJustificationValue;
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
@@ -28,7 +34,7 @@ type Props = {
};
/**
* Shared narrow-column + TextInput pattern for Create Community text frames.
* Shared narrow-column + labelled field pattern for Create Community text frames.
*/
export function CreateFlowTextFieldScreen({
messageNamespace,
@@ -36,6 +42,8 @@ export function CreateFlowTextFieldScreen({
maxLength,
mainAlign = "start",
inputType = "text",
multiline = false,
required = false,
showCharacterCount = true,
headerJustification = "left",
contentTopBelowMd = "space-1400",
@@ -69,6 +77,15 @@ export function CreateFlowTextFieldScreen({
const mainItems =
mainAlign === "center" ? "items-center" : "items-start";
const isEmail = inputType === "email";
const persistValue = (next: string) => {
setValue(next);
markCreateFlowInteraction();
updateState({ [stateField]: next } as Record<string, string>);
};
const fieldLabel = t("inputLabel");
return (
<CreateFlowStepShell
variant="centeredNarrow"
@@ -85,22 +102,60 @@ export function CreateFlowTextFieldScreen({
/>
</div>
<div className="w-full">
<TextInput
className="!transition-none"
type={inputType}
placeholder={t("placeholder")}
value={value}
onChange={(e) => {
const v = e.target.value;
setValue(v);
markCreateFlowInteraction();
updateState({ [stateField]: v } as Record<string, string>);
}}
inputSize={mdUp ? "medium" : "small"}
formHeader={false}
textHint={hint}
maxLength={maxLength}
/>
{multiline ? (
<TextArea
className="!transition-none"
label={fieldLabel}
placeholder={t("placeholder")}
value={value}
onChange={(e) => persistValue(e.target.value)}
onBlur={() => {
const trimmed = value.trim();
if (trimmed !== value) persistValue(trimmed);
}}
size={mdUp ? "medium" : "small"}
formHeader={false}
showHelpIcon={false}
textHint={hint}
maxLength={maxLength}
required={required}
rows={4}
/>
) : (
<TextInput
className="!transition-none"
type={inputType}
name={isEmail ? "email" : undefined}
label={fieldLabel}
placeholder={t("placeholder")}
value={value}
onChange={(e) => persistValue(e.target.value)}
onBlur={() => {
const trimmed = isEmail
? value.trim().toLowerCase()
: value.trim();
if (trimmed !== value) persistValue(trimmed);
}}
onKeyDown={
isEmail
? (e) => {
if (e.key !== "Enter") return;
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
: undefined
}
inputSize={mdUp ? "medium" : "small"}
formHeader={false}
showHelpIcon={false}
textHint={hint}
maxLength={maxLength}
required={required || isEmail}
autoComplete={isEmail ? "email" : undefined}
inputMode={isEmail ? "email" : undefined}
form={isEmail ? CREATE_FLOW_COMMUNITY_SAVE_FORM_ID : undefined}
/>
)}
</div>
</div>
</CreateFlowStepShell>
@@ -10,6 +10,10 @@ import {
FIRST_STEP,
} from "./flowSteps";
/** Associates the save-progress email field with the layout footer submit. */
export const CREATE_FLOW_COMMUNITY_SAVE_FORM_ID =
"create-flow-community-save";
export const CREATE_ROUTES = {
root: "/",
createRoot: "/create",
@@ -1,3 +1,6 @@
"use client";
import { useId } from "react";
import type { InputWithCounterProps } from "./InputWithCounter.types";
export function InputWithCounterView({
@@ -10,12 +13,16 @@ export function InputWithCounterView({
className = "",
inputClassName = "",
}: InputWithCounterProps) {
const inputId = useId();
return (
<div className={`space-y-[var(--spacing-scale-008)] ${className}`}>
{/* Label with help icon */}
{label && (
<div className="flex items-center gap-[var(--spacing-scale-002)]">
<label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]">
<label
htmlFor={inputId}
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
>
{label}
</label>
{showHelpIcon && (
@@ -52,6 +59,7 @@ export function InputWithCounterView({
{/* Input field */}
<div className="relative">
<input
id={inputId}
type="text"
placeholder={placeholder}
value={value}
@@ -36,31 +36,39 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
},
ref,
) => {
const labelEl = label ? (
formHeader ? (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
id={labelId}
htmlFor={textareaId}
className={`${labelClasses} font-medium text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
{showHelpIcon && (
<div className="relative shrink-0 size-[12px]">
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt={helpIconAlt}
className="block max-w-none size-full"
/>
</div>
)}
</div>
</div>
) : (
<label id={labelId} htmlFor={textareaId} className="sr-only">
{label}
</label>
)
) : null;
return (
<div className={containerClasses}>
{formHeader && label && (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
id={labelId}
htmlFor={textareaId}
className={`${labelClasses} font-medium text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
{showHelpIcon && (
<div className="relative shrink-0 size-[12px]">
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt={helpIconAlt}
className="block max-w-none size-full"
/>
</div>
)}
</div>
</div>
)}
{labelEl}
<div className={disabled ? "opacity-40" : ""}>
<textarea
ref={ref}
@@ -31,6 +31,11 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
textHint = false,
formHeader = true,
maxLength,
autoComplete,
inputMode,
required = false,
form,
onKeyDown,
...props
},
ref,
@@ -250,6 +255,11 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
maxLength={maxLength}
helpIconAlt={t("helpIconAlt")}
hintDefault={t("hintDefault")}
autoComplete={autoComplete}
inputMode={inputMode}
required={required}
form={form}
onKeyDown={onKeyDown}
{...props}
/>
);
@@ -67,4 +67,9 @@ export interface TextInputViewProps {
maxLength?: number;
helpIconAlt: string;
hintDefault: string;
autoComplete?: string;
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
required?: boolean;
form?: string;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
}
@@ -13,7 +13,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
name,
type,
disabled,
error: _error,
error = false,
className: _className,
containerClasses,
labelClasses,
@@ -31,34 +31,49 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
maxLength,
helpIconAlt,
hintDefault,
autoComplete,
inputMode,
required = false,
form,
onKeyDown,
state: _state,
isFilled: _isFilled,
},
ref,
) => {
const labelEl = label ? (
formHeader ? (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
id={labelId}
htmlFor={inputId}
className={`${labelClasses} font-medium text-[var(--color-content-default-primary)]`}
>
{label}
</label>
{showHelpIcon && (
<div className="relative shrink-0 size-[12px]">
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt={helpIconAlt}
className="block max-w-none size-full"
/>
</div>
)}
</div>
</div>
) : (
<label id={labelId} htmlFor={inputId} className="sr-only">
{label}
</label>
)
) : null;
return (
<div className={containerClasses}>
{formHeader && label && (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
id={labelId}
htmlFor={inputId}
className={`${labelClasses} font-medium text-[var(--color-content-default-primary)]`}
>
{label}
</label>
{showHelpIcon && (
<div className="relative shrink-0 size-[12px]">
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt={helpIconAlt}
className="block max-w-none size-full"
/>
</div>
)}
</div>
</div>
)}
{labelEl}
<div className={inputWrapperClasses}>
<div className={disabled ? "opacity-40" : ""}>
<input
@@ -71,9 +86,16 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={onKeyDown}
onMouseDown={handleMouseDown}
disabled={disabled}
maxLength={maxLength}
autoComplete={autoComplete}
inputMode={inputMode}
required={required}
form={form}
aria-invalid={error || undefined}
aria-required={required || undefined}
className={inputClasses}
style={{ borderRadius }}
/>