Give builder fields persistent labels, treat description as optional, and validate save-progress email with a real form. #77
@@ -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}
|
||||
|
||||
+8
-2
@@ -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">
|
||||
{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) => {
|
||||
const v = e.target.value;
|
||||
setValue(v);
|
||||
markCreateFlowInteraction();
|
||||
updateState({ [stateField]: v } as Record<string, string>);
|
||||
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,9 +36,8 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{formHeader && label && (
|
||||
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
|
||||
@@ -60,7 +59,16 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<label id={labelId} htmlFor={textareaId} className="sr-only">
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{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,12 +31,18 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
|
||||
maxLength,
|
||||
helpIconAlt,
|
||||
hintDefault,
|
||||
autoComplete,
|
||||
inputMode,
|
||||
required = false,
|
||||
form,
|
||||
onKeyDown,
|
||||
state: _state,
|
||||
isFilled: _isFilled,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{formHeader && label && (
|
||||
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
|
||||
@@ -58,7 +64,16 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<label id={labelId} htmlFor={inputId} className="sr-only">
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{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 }}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
/** RFC 5321 max mailbox length (no angle brackets). */
|
||||
export const EMAIL_MAX_LEN = 254;
|
||||
|
||||
/** Pragmatic check for the create-flow “save progress” email field (draft + footer enablement). */
|
||||
/**
|
||||
* Fallback when `HTMLInputElement.checkValidity()` is unavailable (SSR).
|
||||
* Matches the HTML living standard’s type=email pattern closely enough
|
||||
* for the save-progress field; the live input is the source of truth in
|
||||
* the browser.
|
||||
*/
|
||||
const EMAIL_FALLBACK_PATTERN =
|
||||
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
|
||||
/**
|
||||
* Whether a create-flow save-progress email would pass a required
|
||||
* `type="email"` input’s `checkValidity()`. Does not trim: native
|
||||
* validity rejects leading or trailing spaces.
|
||||
*/
|
||||
export function isValidCreateFlowSaveEmail(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
const t = value.trim();
|
||||
if (t.length === 0 || t.length > EMAIL_MAX_LEN) return false;
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
|
||||
if (value.length === 0 || value.length > EMAIL_MAX_LEN) return false;
|
||||
// Native type=email rejects whitespace; jsdom’s checkValidity does not.
|
||||
if (/\s/.test(value)) return false;
|
||||
if (typeof document === "undefined") {
|
||||
return EMAIL_FALLBACK_PATTERN.test(value);
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.type = "email";
|
||||
input.required = true;
|
||||
input.value = value;
|
||||
return input.checkValidity();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "Why does your community exist?",
|
||||
"description": "Edit or change the description to match how you’d like the organization to be described to other users. Try and describe your mission, goals, and scope.",
|
||||
"description": "Optional. Write a short paragraph about your mission, goals, and scope. You can skip this and add it later.",
|
||||
"inputLabel": "Community description",
|
||||
"placeholder": "Describe your community",
|
||||
"characterCountTemplate": "{current}/{max}"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "What is your community called?",
|
||||
"description": "This will be the name of your community",
|
||||
"description": "This will be the name of your community.",
|
||||
"inputLabel": "Community name",
|
||||
"placeholder": "Enter community name",
|
||||
"characterCountTemplate": "{current}/{max}"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "Save your progress",
|
||||
"description": "We need your email to save your CommunityRule progress\nand make it accessible to you later.",
|
||||
"inputLabel": "Email address",
|
||||
"placeholder": "email@domain.com",
|
||||
"characterCountTemplate": "{current}/{max}",
|
||||
"magicLinkSuccessTitle": "Check your email to log in",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"header": {
|
||||
"title": "What kind of community would you like to improve?",
|
||||
"description": "Choose tags the describe your community. You can also combine or add new values to the list."
|
||||
"description": "Choose tags that describe your community. You can also combine or add new values to the list."
|
||||
},
|
||||
"organizationMultiSelect": {
|
||||
"label": "Organization Type",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Add a photo to identify your group",
|
||||
"description": "This photo be used as a profile picture for your group and will be editable later. If possible, try to use a simple logo or graphic.",
|
||||
"description": "This photo will be used as a profile picture for your group and will be editable later. If possible, try to use a simple logo or graphic.",
|
||||
"hintText": "Add image from your device",
|
||||
"signInToUploadNote": "Your photo will upload after you sign in (use Save progress from the next step, or Log in from the header).",
|
||||
"uploadingLabel": "Uploading your photo…",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"title": "How CommunityRule helps groups like yours",
|
||||
"descriptionLead": "This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider. Alternatively, there is a",
|
||||
"workshopLabel": "workshop",
|
||||
"descriptionTrail": "that your group can use to go through the process it together.",
|
||||
"description": "This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider.",
|
||||
"steps": {
|
||||
"0": {
|
||||
"title": "Tell us about your organization",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"2": {
|
||||
"title": "How do you want to describe your new policy?",
|
||||
"description": "This description will show up with the title to offer additional context about what the policy is how it should be applied.",
|
||||
"description": "This description will show up with the title to offer additional context about what the policy is and how it should be applied.",
|
||||
"fieldPlaceholder": "Policy description"
|
||||
},
|
||||
"3": {
|
||||
|
||||
@@ -11,5 +11,28 @@ export const Default = {
|
||||
messageNamespace: "create.community.communityName",
|
||||
stateField: "title",
|
||||
maxLength: 48,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const CommunityDescription = {
|
||||
args: {
|
||||
messageNamespace: "create.community.communityContext",
|
||||
stateField: "communityContext",
|
||||
maxLength: 200,
|
||||
mainAlign: "center",
|
||||
multiline: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const CommunitySave = {
|
||||
args: {
|
||||
messageNamespace: "create.community.communitySave",
|
||||
stateField: "communitySaveEmail",
|
||||
maxLength: 254,
|
||||
mainAlign: "center",
|
||||
inputType: "email",
|
||||
showCharacterCount: false,
|
||||
headerJustification: "center",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,6 +11,9 @@ describe("CommunityStructureSelectScreen", () => {
|
||||
expect(screen.getByText("Organization Type")).toBeInTheDocument();
|
||||
expect(screen.getByText("Scale")).toBeInTheDocument();
|
||||
expect(screen.getByText("Maturity")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Choose tags that describe your community/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1044,8 +1044,9 @@ describe("FinalReviewScreen — edit published title and description", () => {
|
||||
|
||||
fireEvent.click(await screen.findByTestId("rule-title-edit"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByText(/Community name/i)).toBeInTheDocument();
|
||||
const input = within(dialog).getByRole("textbox");
|
||||
const input = within(dialog).getByRole("textbox", {
|
||||
name: /Community name/i,
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "Renamed Commons" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
|
||||
@@ -1076,10 +1077,9 @@ describe("FinalReviewScreen — edit published title and description", () => {
|
||||
|
||||
fireEvent.click(await screen.findByTestId("rule-description-edit"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(
|
||||
within(dialog).getByText(/Community description/i),
|
||||
).toBeInTheDocument();
|
||||
const input = within(dialog).getByRole("textbox");
|
||||
const input = within(dialog).getByRole("textbox", {
|
||||
name: /Community description/i,
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "Updated copy" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
|
||||
|
||||
@@ -22,11 +22,9 @@ describe("InformationalScreen", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders workshop as a link (URL TBD) with underline per Figma", () => {
|
||||
it("does not offer a workshop link until a destination exists", () => {
|
||||
render(<InformationalScreen />);
|
||||
const workshop = screen.getByRole("link", { name: "workshop" });
|
||||
expect(workshop).toHaveAttribute("href", "#");
|
||||
expect(workshop.className).toMatch(/underline/);
|
||||
expect(screen.queryByRole("link", { name: "workshop" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders first numbered list item title", () => {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { describe, vi } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
} from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import {
|
||||
componentTestSuite,
|
||||
type ComponentTestSuiteConfig,
|
||||
@@ -28,4 +33,19 @@ const config: ComponentTestSuiteConfig<Props> = {
|
||||
|
||||
describe("InputWithCounter", () => {
|
||||
componentTestSuite<Props>(config);
|
||||
|
||||
it("associates the visible label with the input", () => {
|
||||
render(
|
||||
<InputWithCounter
|
||||
label="Community name"
|
||||
placeholder="Enter a name"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
maxLength={50}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "Community name" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,16 @@ describe("TextArea appearance", () => {
|
||||
expect(textarea).toHaveClass("border-0");
|
||||
});
|
||||
|
||||
it("keeps a programmatic label when the visible header is off", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<TextArea label="Community description" formHeader={false} value="" />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "Community description" }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelector("label")).toHaveClass("sr-only");
|
||||
});
|
||||
|
||||
it("uses tertiary text in the embedded default state and primary on focus", () => {
|
||||
renderWithProviders(
|
||||
<TextArea label="Notes" value="Some text" appearance="embedded" />,
|
||||
|
||||
@@ -55,4 +55,30 @@ describe("TextInput (size tests)", () => {
|
||||
const input = container.querySelector("input");
|
||||
expect(input).toHaveAttribute("maxLength", "200");
|
||||
});
|
||||
|
||||
it("keeps a programmatic label when the visible header is off", () => {
|
||||
const { getByRole, container } = render(
|
||||
<TextInput label="Community name" formHeader={false} />,
|
||||
);
|
||||
expect(getByRole("textbox", { name: "Community name" })).toBeInTheDocument();
|
||||
expect(container.querySelector("label")).toHaveClass("sr-only");
|
||||
});
|
||||
|
||||
it("forwards autocomplete, inputMode, required, and form", () => {
|
||||
const { container } = render(
|
||||
<TextInput
|
||||
label="Email address"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
required
|
||||
form="create-flow-community-save"
|
||||
/>,
|
||||
);
|
||||
const input = container.querySelector("input");
|
||||
expect(input).toHaveAttribute("autocomplete", "email");
|
||||
expect(input).toHaveAttribute("inputmode", "email");
|
||||
expect(input).toBeRequired();
|
||||
expect(input).toHaveAttribute("form", "create-flow-community-save");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { CreateFlowTextFieldScreen } from "../../app/(app)/create/screens/text/CreateFlowTextFieldScreen";
|
||||
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../app/(app)/create/utils/createFlowPaths";
|
||||
|
||||
describe("CreateFlowTextFieldScreen (community name)", () => {
|
||||
it("renders main heading", () => {
|
||||
@@ -10,6 +11,7 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
|
||||
messageNamespace="create.community.communityName"
|
||||
stateField="title"
|
||||
maxLength={48}
|
||||
required
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
@@ -19,19 +21,63 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders description and text field", () => {
|
||||
it("renders description and a labelled text field", () => {
|
||||
render(
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communityName"
|
||||
stateField="title"
|
||||
maxLength={48}
|
||||
required
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("This will be the name of your community"),
|
||||
screen.getByText("This will be the name of your community."),
|
||||
).toBeInTheDocument();
|
||||
const input = screen.getByRole("textbox", { name: "Community name" });
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute("placeholder", "Enter community name");
|
||||
expect(input).toBeRequired();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateFlowTextFieldScreen (community description)", () => {
|
||||
it("uses a textarea and treats the field as optional", () => {
|
||||
render(
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communityContext"
|
||||
stateField="communityContext"
|
||||
maxLength={200}
|
||||
multiline
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByPlaceholderText("Enter community name"),
|
||||
screen.getByText(/Optional\. Write a short paragraph/i),
|
||||
).toBeInTheDocument();
|
||||
const field = screen.getByRole("textbox", {
|
||||
name: "Community description",
|
||||
});
|
||||
expect(field.tagName).toBe("TEXTAREA");
|
||||
expect(field).not.toBeRequired();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateFlowTextFieldScreen (save progress email)", () => {
|
||||
it("uses a labelled email field with native email attributes", () => {
|
||||
render(
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communitySave"
|
||||
stateField="communitySaveEmail"
|
||||
maxLength={254}
|
||||
inputType="email"
|
||||
showCharacterCount={false}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByRole("textbox", { name: "Email address" });
|
||||
expect(input).toHaveAttribute("type", "email");
|
||||
expect(input).toHaveAttribute("autocomplete", "email");
|
||||
expect(input).toHaveAttribute("inputmode", "email");
|
||||
expect(input).toHaveAttribute("name", "email");
|
||||
expect(input).toHaveAttribute("form", CREATE_FLOW_COMMUNITY_SAVE_FORM_ID);
|
||||
expect(input).toBeRequired();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("CommunityUploadScreen", () => {
|
||||
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/This photo be used as a profile picture for your group/i,
|
||||
/This photo will be used as a profile picture for your group/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CREATE_FLOW_COMMUNITY_SAVE_FORM_ID,
|
||||
CREATE_FLOW_SYNC_DRAFT_QUERY,
|
||||
CREATE_FLOW_SYNC_DRAFT_VALUE,
|
||||
CREATE_ROUTES,
|
||||
@@ -42,4 +43,10 @@ describe("createFlowPaths (CR-92 §2)", () => {
|
||||
expect(CREATE_ROUTES.review).toBe("/create/review");
|
||||
expect(CREATE_ROUTES.completed).toBe("/create/completed");
|
||||
});
|
||||
|
||||
it("exposes a stable form id for the save-progress email step", () => {
|
||||
expect(CREATE_FLOW_COMMUNITY_SAVE_FORM_ID).toBe(
|
||||
"create-flow-community-save",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isValidCreateFlowSaveEmail } from "../../lib/create/isValidCreateFlowSaveEmail";
|
||||
|
||||
describe("isValidCreateFlowSaveEmail", () => {
|
||||
it("rejects empty, whitespace, and non-string values", () => {
|
||||
expect(isValidCreateFlowSaveEmail("")).toBe(false);
|
||||
expect(isValidCreateFlowSaveEmail(" ")).toBe(false);
|
||||
expect(isValidCreateFlowSaveEmail(null)).toBe(false);
|
||||
expect(isValidCreateFlowSaveEmail(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects values the native email control would reject", () => {
|
||||
expect(isValidCreateFlowSaveEmail("not-an-email")).toBe(false);
|
||||
expect(isValidCreateFlowSaveEmail("user@example.com ")).toBe(false);
|
||||
expect(isValidCreateFlowSaveEmail(" user@example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a required type=email value", () => {
|
||||
expect(isValidCreateFlowSaveEmail("user@example.com")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user