Navigation, state management, create rule button integration

This commit is contained in:
adilallo
2026-03-02 22:40:29 -07:00
parent 3e3d2881f5
commit 3a3e54d455
17 changed files with 370 additions and 139 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import { getAllBlogPosts } from "../../../lib/content"; import { getAllBlogPosts } from "../../../lib/content";
import ContentThumbnailTemplate from "../../../components/content/ContentThumbnailTemplate"; import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -1,7 +1,7 @@
"use client"; "use client";
import { memo } from "react"; import { memo } from "react";
import { usePathname } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useTranslation } from "../../../contexts/MessagesContext"; import { useTranslation } from "../../../contexts/MessagesContext";
import MenuBarItem from "../MenuBarItem"; import MenuBarItem from "../MenuBarItem";
import Button from "../../buttons/Button"; import Button from "../../buttons/Button";
@@ -20,6 +20,7 @@ export const avatarImages = [
const TopNavContainer = memo<TopNavProps>( const TopNavContainer = memo<TopNavProps>(
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => { ({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
const t = useTranslation("header"); const t = useTranslation("header");
// Schema markup for site navigation // Schema markup for site navigation
@@ -164,7 +165,7 @@ const TopNavContainer = memo<TopNavProps>(
size={buttonSize} size={buttonSize}
buttonType={buttonType} buttonType={buttonType}
palette={palette} palette={palette}
href="/create/informational" onClick={() => router.push("/create/informational")}
ariaLabel={t("ariaLabels.createNewRule")} ariaLabel={t("ariaLabels.createNewRule")}
> >
{renderAvatarGroup(containerSize, avatarSize)} {renderAvatarGroup(containerSize, avatarSize)}
@@ -20,9 +20,9 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
}) => { }) => {
const router = useRouter(); const router = useRouter();
const handleExit = () => { const handleExit = (options?: { saveDraft?: boolean }) => {
if (onExit) { if (onExit) {
onExit(); onExit(options);
} else { } else {
// Default behavior: navigate to home // Default behavior: navigate to home
router.push("/"); router.push("/");
@@ -39,9 +39,10 @@ export interface CreateFlowTopNavProps {
*/ */
onEdit?: () => void; onEdit?: () => void;
/** /**
* Callback when Exit/Save & Exit button is clicked * Callback when Exit/Save & Exit button is clicked.
* When user is logged in, called with { saveDraft: true } to stub "Save & Exit".
*/ */
onExit?: () => void; onExit?: (options?: { saveDraft?: boolean }) => void;
/** /**
* Palette for nav buttons (e.g. "inverse" on completed page to match teal background) * Palette for nav buttons (e.g. "inverse" on completed page to match teal background)
* @default "default" * @default "default"
@@ -89,7 +89,7 @@ export function CreateFlowTopNavView({
buttonType="outline" buttonType="outline"
palette={buttonPalette} palette={buttonPalette}
size="xsmall" size="xsmall"
onClick={onExit} onClick={() => onExit?.({ saveDraft: loggedIn })}
ariaLabel={exitButtonText} ariaLabel={exitButtonText}
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]" className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
> >
+2 -17
View File
@@ -2,27 +2,12 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { use } from "react"; import { use } from "react";
import type { CreateFlowStep } from "../types"; import { VALID_STEPS } from "../utils/flowSteps";
interface PageProps { interface PageProps {
params: Promise<{ step: string }>; params: Promise<{ step: string }>;
} }
/**
* Valid step IDs for the create rule flow
*/
const VALID_STEPS: CreateFlowStep[] = [
"informational",
"text",
"select",
"upload",
"review",
"cards",
"right-rail",
"final-review",
"completed",
];
/** /**
* Dynamic route handler for create flow steps * Dynamic route handler for create flow steps
* *
@@ -33,7 +18,7 @@ export default function CreateFlowStepPage({ params }: PageProps) {
const { step } = use(params); const { step } = use(params);
// Validate step exists // Validate step exists
if (!VALID_STEPS.includes(step as CreateFlowStep)) { if (!(VALID_STEPS as readonly string[]).includes(step)) {
notFound(); notFound();
} }
+68 -6
View File
@@ -1,6 +1,13 @@
"use client"; "use client";
import { createContext, useContext, useState, type ReactNode } from "react"; import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import type { import type {
CreateFlowState, CreateFlowState,
CreateFlowContextValue, CreateFlowContextValue,
@@ -9,6 +16,39 @@ import type {
const CreateFlowContext = createContext<CreateFlowContextValue | null>(null); const CreateFlowContext = createContext<CreateFlowContextValue | null>(null);
const STORAGE_KEY = "create-flow-state";
const DRAFT_STORAGE_KEY = "create-flow-draft";
function readStateFromStorage(key: string): CreateFlowState {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(key);
if (!raw) return {};
const parsed = JSON.parse(raw) as CreateFlowState;
return typeof parsed === "object" && parsed !== null ? parsed : {};
} catch {
return {};
}
}
function writeStateToStorage(key: string, value: CreateFlowState): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// Ignore storage errors (e.g. quota, private mode)
}
}
function removeFromStorage(key: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(key);
} catch {
// Ignore
}
}
interface CreateFlowProviderProps { interface CreateFlowProviderProps {
children: ReactNode; children: ReactNode;
initialStep?: CreateFlowStep | null; initialStep?: CreateFlowStep | null;
@@ -17,27 +57,39 @@ interface CreateFlowProviderProps {
/** /**
* Provider component for Create Flow state management * Provider component for Create Flow state management
* *
* This is a basic implementation that will be expanded in CR-56 * Manages flow state with optional localStorage persistence and draft support.
* with full navigation logic, state persistence, and validation.
*/ */
export function CreateFlowProvider({ export function CreateFlowProvider({
children, children,
initialStep = null, initialStep = null,
}: CreateFlowProviderProps) { }: CreateFlowProviderProps) {
const [state, setState] = useState<CreateFlowState>({}); const [state, setState] = useState<CreateFlowState>(() =>
readStateFromStorage(STORAGE_KEY),
);
const [currentStep] = useState<CreateFlowStep | null>(initialStep); const [currentStep] = useState<CreateFlowStep | null>(initialStep);
const updateState = (updates: Partial<CreateFlowState>) => { useEffect(() => {
writeStateToStorage(STORAGE_KEY, state);
}, [state]);
const updateState = useCallback((updates: Partial<CreateFlowState>) => {
setState((prevState) => ({ setState((prevState) => ({
...prevState, ...prevState,
...updates, ...updates,
})); }));
}; }, []);
const clearState = useCallback(() => {
setState({});
removeFromStorage(STORAGE_KEY);
removeFromStorage(DRAFT_STORAGE_KEY);
}, []);
const contextValue: CreateFlowContextValue = { const contextValue: CreateFlowContextValue = {
state, state,
currentStep, currentStep,
updateState, updateState,
clearState,
}; };
return ( return (
@@ -47,6 +99,16 @@ export function CreateFlowProvider({
); );
} }
/** Save current state as draft (e.g. on "Save & Exit"). Stub for CR-57. */
export function saveCreateFlowDraft(state: CreateFlowState): void {
writeStateToStorage(DRAFT_STORAGE_KEY, state);
}
/** Load draft state if present. Caller can merge into initial state when entering flow. */
export function loadCreateFlowDraft(): CreateFlowState {
return readStateFromStorage(DRAFT_STORAGE_KEY);
}
/** /**
* Hook to access Create Flow context * Hook to access Create Flow context
* *
@@ -0,0 +1,81 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useCallback } from "react";
import type { CreateFlowStep } from "../types";
import { getNextStep, getPreviousStep, isValidStep } from "../utils/flowSteps";
/**
* Options passed to navigation handlers (e.g. for blur before navigate)
*/
const blurActiveElement = (): void => {
if (
typeof document !== "undefined" &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
};
/**
* Hook for Create Rule Flow navigation.
*
* Must be used within the create flow (pathname like /create/[step]).
* Uses the current step from the URL and provides type-safe navigation.
*/
export function useCreateFlowNavigation(): {
currentStep: CreateFlowStep | null;
goToNextStep: () => void;
goToPreviousStep: () => void;
goToStep: (_step: CreateFlowStep) => void;
canGoNext: () => boolean;
canGoBack: () => boolean;
nextStep: CreateFlowStep | null;
previousStep: CreateFlowStep | null;
} {
const pathname = usePathname();
const router = useRouter();
const currentStep = (pathname?.split("/").pop() ??
null) as CreateFlowStep | null;
const validStep = isValidStep(currentStep) ? currentStep : null;
const nextStep = getNextStep(validStep);
const previousStep = getPreviousStep(validStep);
const goToNextStep = useCallback(() => {
blurActiveElement();
if (nextStep) {
router.push(`/create/${nextStep}`);
}
}, [router, nextStep]);
const goToPreviousStep = useCallback(() => {
blurActiveElement();
if (previousStep) {
router.push(`/create/${previousStep}`);
}
}, [router, previousStep]);
const goToStep = useCallback(
(step: CreateFlowStep) => {
blurActiveElement();
router.push(`/create/${step}`);
},
[router],
);
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
const canGoBack = useCallback(() => previousStep !== null, [previousStep]);
return {
currentStep: validStep,
goToNextStep,
goToPreviousStep,
goToStep,
canGoNext,
canGoBack,
nextStep,
previousStep,
};
}
+12 -2
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useMediaQuery } from "../../hooks/useMediaQuery";
import HeaderLockup from "../../components/type/HeaderLockup"; import HeaderLockup from "../../components/type/HeaderLockup";
import NumberedList from "../../components/type/NumberedList"; import NumberedList from "../../components/type/NumberedList";
@@ -11,8 +12,17 @@ import NumberedList from "../../components/type/NumberedList";
* Responsive sizing: uses L/M for HeaderLockup and M/S for NumberedList based on 640px breakpoint. * Responsive sizing: uses L/M for HeaderLockup and M/S for NumberedList based on 640px breakpoint.
*/ */
export default function InformationalPage() { export default function InformationalPage() {
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)"); const isMdOrLarger = useMediaQuery("(min-width: 640px)");
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
setIsMounted(true);
}, []);
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
const items = [ const items = [
{ {
title: "Tell us about your organization", title: "Tell us about your organization",
@@ -39,11 +49,11 @@ export default function InformationalPage() {
title="How CommunityRule helps groups like yours" title="How CommunityRule helps groups like yours"
description="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 workshop 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. Alternatively, there is a workshop that your group can use to go through the process it together."
justification="left" justification="left"
size={isMdOrLarger ? "L" : "M"} size={effectiveMdOrLarger ? "L" : "M"}
/> />
{/* NumberedList: M size at 640px+, S size below 640px */} {/* NumberedList: M size at 640px+, S size below 640px */}
<NumberedList items={items} size={isMdOrLarger ? "M" : "S"} /> <NumberedList items={items} size={effectiveMdOrLarger ? "M" : "S"} />
</div> </div>
</div> </div>
); );
+33 -67
View File
@@ -1,12 +1,16 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CreateFlowProvider } from "./context/CreateFlowContext"; import {
CreateFlowProvider,
useCreateFlow,
saveCreateFlowDraft,
} from "./context/CreateFlowContext";
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav"; import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
import CreateFlowFooter from "../components/utility/CreateFlowFooter"; import CreateFlowFooter from "../components/utility/CreateFlowFooter";
import Button from "../components/buttons/Button"; import Button from "../components/buttons/Button";
import type { CreateFlowStep } from "./types";
/** /**
* Layout for the Create Rule Flow * Layout for the Create Rule Flow
@@ -16,77 +20,38 @@ import type { CreateFlowStep } from "./types";
* Includes the create flow-specific TopNav and Footer components. * Includes the create flow-specific TopNav and Footer components.
*/ */
function CreateFlowLayoutContent({ children }: { children: ReactNode }) { function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const {
currentStep,
nextStep,
previousStep,
goToNextStep,
goToPreviousStep,
} = useCreateFlowNavigation();
const { state, clearState } = useCreateFlow();
// Extract current step from pathname const handleExit = (options?: { saveDraft?: boolean }) => {
const currentStep = pathname?.split("/").pop() as CreateFlowStep | undefined; const saveDraft = options?.saveDraft ?? false;
if (!saveDraft && typeof window !== "undefined") {
// Define step order const confirmed = window.confirm(
const stepOrder: CreateFlowStep[] = [ "Leave create flow? Your progress will be lost.",
"informational", );
"text", if (!confirmed) return;
"select",
"upload",
"review",
"cards",
"right-rail",
"final-review",
"completed",
];
// Get next step
const getNextStep = (): CreateFlowStep | null => {
if (!currentStep) return null;
const currentIndex = stepOrder.indexOf(currentStep);
if (currentIndex === -1 || currentIndex === stepOrder.length - 1) {
return null;
} }
return stepOrder[currentIndex + 1]; if (saveDraft) {
}; saveCreateFlowDraft(state);
// Get previous step
const getPreviousStep = (): CreateFlowStep | null => {
if (!currentStep) return null;
const currentIndex = stepOrder.indexOf(currentStep);
if (currentIndex === -1 || currentIndex === 0) {
return null;
}
return stepOrder[currentIndex - 1];
};
const nextStep = getNextStep();
const previousStep = getPreviousStep();
const handleNext = () => {
if (
typeof document !== "undefined" &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
if (nextStep) {
router.push(`/create/${nextStep}`);
}
};
const handleBack = () => {
if (
typeof document !== "undefined" &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
if (previousStep) {
router.push(`/create/${previousStep}`);
} }
clearState();
router.push("/");
}; };
const isCompletedStep = currentStep === "completed"; const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "right-rail";
const useFullHeightMain = isCompletedStep || isRightRailStep;
return ( return (
<div <div
className={`bg-black flex flex-col ${isCompletedStep ? "h-screen overflow-hidden" : "min-h-screen"}`} className={`bg-black flex flex-col ${useFullHeightMain ? "h-screen overflow-hidden" : "min-h-screen"}`}
> >
<CreateFlowTopNav <CreateFlowTopNav
hasShare={isCompletedStep} hasShare={isCompletedStep}
@@ -98,13 +63,14 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
? () => router.push("/create/final-review") ? () => router.push("/create/final-review")
: undefined : undefined
} }
onExit={handleExit}
buttonPalette={isCompletedStep ? "inverse" : undefined} buttonPalette={isCompletedStep ? "inverse" : undefined}
className={ className={
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : undefined isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : undefined
} }
/> />
<main <main
className={`flex-1 flex min-h-0 justify-center ${isCompletedStep ? "items-stretch overflow-hidden" : "items-center overflow-auto"}`} className={`flex-1 flex min-h-0 justify-center ${useFullHeightMain ? "items-stretch overflow-hidden" : "items-center overflow-auto"}`}
> >
{children} {children}
</main> </main>
@@ -117,7 +83,7 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
palette="default" palette="default"
size="xsmall" size="xsmall"
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]" className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
onClick={handleNext} onClick={goToNextStep}
> >
{currentStep === "final-review" {currentStep === "final-review"
? "Finalize CommunityRule" ? "Finalize CommunityRule"
@@ -125,7 +91,7 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
</Button> </Button>
) : null ) : null
} }
onBackClick={previousStep ? handleBack : undefined} onBackClick={previousStep ? goToPreviousStep : undefined}
/> />
)} )}
</div> </div>
+37 -31
View File
@@ -115,35 +115,41 @@ export default function RightRailPage() {
if (showDesktopLayout) { if (showDesktopLayout) {
return ( return (
<div className="w-full flex flex-col items-center px-5 md:px-12"> <div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
<div className="flex gap-12 items-stretch w-full max-w-[1280px] min-w-0"> <div className="flex min-h-0 flex-1 overflow-hidden px-5 md:px-12">
<div className="flex flex-1 flex-col justify-center gap-3 min-w-0"> <div className="grid h-full max-w-[1280px] grid-cols-2 shrink-0 gap-12 min-h-0 min-w-0 w-full">
<DecisionMakingSidebar {/* Left column: sidebar stays put, does not scroll */}
title={SIDEBAR_TITLE} <div className="flex min-w-0 flex-col justify-center overflow-hidden py-8">
description={SIDEBAR_DESCRIPTION} <DecisionMakingSidebar
messageBoxTitle={MESSAGE_BOX_TITLE} title={SIDEBAR_TITLE}
messageBoxItems={MESSAGE_BOX_ITEMS} description={SIDEBAR_DESCRIPTION}
messageBoxCheckedIds={messageBoxCheckedIds} messageBoxTitle={MESSAGE_BOX_TITLE}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange} messageBoxItems={MESSAGE_BOX_ITEMS}
size="L" messageBoxCheckedIds={messageBoxCheckedIds}
justification="left" onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
/> size="L"
</div> justification="left"
<div className="flex flex-1 flex-col gap-6 items-center min-w-0"> />
<CardStack </div>
cards={SAMPLE_CARDS} {/* Right column: card stack — this column scrolls independently */}
selectedIds={selectedIds} <div className="scrollbar-hide relative flex min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto">
onCardSelect={handleCardSelect} <div className="py-8 flex flex-col gap-6 items-center min-w-0">
expanded={expanded} <CardStack
onToggleExpand={handleToggleExpand} cards={SAMPLE_CARDS}
hasMore={true} selectedIds={selectedIds}
toggleLabel="See all decision approaches" onCardSelect={handleCardSelect}
showLessLabel="Show less" expanded={expanded}
title="" onToggleExpand={handleToggleExpand}
description="" hasMore={true}
layout="singleStack" toggleLabel="See all decision approaches"
className="w-full" showLessLabel="Show less"
/> title=""
description=""
layout="singleStack"
className="w-full"
/>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -151,8 +157,8 @@ export default function RightRailPage() {
} }
return ( return (
<div className="w-full flex flex-col items-center px-5"> <div className="w-full h-full min-h-0 overflow-y-auto flex flex-col items-center px-5">
<div className="flex flex-col gap-6 w-full min-w-0"> <div className="flex flex-col gap-6 w-full min-w-0 py-8">
<DecisionMakingSidebar <DecisionMakingSidebar
title={SIDEBAR_TITLE} title={SIDEBAR_TITLE}
description={SIDEBAR_DESCRIPTION} description={SIDEBAR_DESCRIPTION}
+11 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useMediaQuery } from "../../hooks/useMediaQuery";
import HeaderLockup from "../../components/type/HeaderLockup"; import HeaderLockup from "../../components/type/HeaderLockup";
import MultiSelect from "../../components/controls/MultiSelect"; import MultiSelect from "../../components/controls/MultiSelect";
@@ -13,8 +13,17 @@ import MultiSelect from "../../components/controls/MultiSelect";
* Responsive sizing: uses L/M for HeaderLockup and S for MultiSelect based on 640px breakpoint. * Responsive sizing: uses L/M for HeaderLockup and S for MultiSelect based on 640px breakpoint.
*/ */
export default function SelectPage() { export default function SelectPage() {
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)"); const isMdOrLarger = useMediaQuery("(min-width: 640px)");
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
setIsMounted(true);
}, []);
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
// Sample options for MultiSelect components // Sample options for MultiSelect components
const [communitySizeOptions, setCommunitySizeOptions] = useState([ const [communitySizeOptions, setCommunitySizeOptions] = useState([
{ id: "1", label: "1 member", state: "Unselected" as const }, { id: "1", label: "1 member", state: "Unselected" as const },
@@ -81,7 +90,7 @@ export default function SelectPage() {
return ( return (
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[64px]"> <div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[64px]">
{isMdOrLarger ? ( {effectiveMdOrLarger ? (
// Two-column layout for 640px+ // Two-column layout for 640px+
<div className="flex gap-[var(--measures-spacing-1200,48px)] items-center justify-center w-full max-w-[1280px]"> <div className="flex gap-[var(--measures-spacing-1200,48px)] items-center justify-center w-full max-w-[1280px]">
{/* Left column: HeaderLockup */} {/* Left column: HeaderLockup */}
+12 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useMediaQuery } from "../../hooks/useMediaQuery";
import HeaderLockup from "../../components/type/HeaderLockup"; import HeaderLockup from "../../components/type/HeaderLockup";
import TextInput from "../../components/controls/TextInput"; import TextInput from "../../components/controls/TextInput";
@@ -12,9 +12,18 @@ import TextInput from "../../components/controls/TextInput";
* Responsive sizing: uses L/M for HeaderLockup and medium/small for TextInput based on 640px breakpoint. * Responsive sizing: uses L/M for HeaderLockup and medium/small for TextInput based on 640px breakpoint.
*/ */
export default function TextPage() { export default function TextPage() {
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)"); const isMdOrLarger = useMediaQuery("(min-width: 640px)");
const [value, setValue] = useState(""); const [value, setValue] = useState("");
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
setIsMounted(true);
}, []);
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
const maxLength = 48; const maxLength = 48;
const characterCount = value.length; const characterCount = value.length;
@@ -26,7 +35,7 @@ export default function TextPage() {
title="What is your community called?" title="What is your community called?"
description="This will be the name of your community" description="This will be the name of your community"
justification="left" justification="left"
size={isMdOrLarger ? "L" : "M"} size={effectiveMdOrLarger ? "L" : "M"}
/> />
{/* TextInput: medium size at 640px+, small size below 640px */} {/* TextInput: medium size at 640px+, small size below 640px */}
@@ -35,7 +44,7 @@ export default function TextPage() {
placeholder="Enter your community name" placeholder="Enter your community name"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
inputSize={isMdOrLarger ? "medium" : "small"} inputSize={effectiveMdOrLarger ? "medium" : "small"}
formHeader={false} formHeader={false}
textHint={`${characterCount}/${maxLength}`} textHint={`${characterCount}/${maxLength}`}
maxLength={maxLength} maxLength={maxLength}
+2 -1
View File
@@ -36,7 +36,8 @@ export interface CreateFlowContextValue {
state: CreateFlowState; state: CreateFlowState;
currentStep: CreateFlowStep | null; currentStep: CreateFlowStep | null;
updateState: (_updates: Partial<CreateFlowState>) => void; updateState: (_updates: Partial<CreateFlowState>) => void;
// Navigation handlers will be added in CR-56 /** Clear all flow state (e.g. on exit). Also clears persisted draft. */
clearState: () => void;
} }
/** /**
+12 -2
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useMediaQuery } from "../../hooks/useMediaQuery";
import HeaderLockup from "../../components/type/HeaderLockup"; import HeaderLockup from "../../components/type/HeaderLockup";
import Upload from "../../components/controls/Upload"; import Upload from "../../components/controls/Upload";
@@ -12,8 +13,17 @@ import Upload from "../../components/controls/Upload";
* Responsive sizing: uses L/M for HeaderLockup based on 640px breakpoint. * Responsive sizing: uses L/M for HeaderLockup based on 640px breakpoint.
*/ */
export default function UploadPage() { export default function UploadPage() {
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)"); const isMdOrLarger = useMediaQuery("(min-width: 640px)");
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
setIsMounted(true);
}, []);
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
const handleUploadClick = () => { const handleUploadClick = () => {
// TODO: Handle upload button click (e.g. open file picker) // TODO: Handle upload button click (e.g. open file picker)
}; };
@@ -25,8 +35,8 @@ export default function UploadPage() {
<HeaderLockup <HeaderLockup
title="How should conflicts be resolved?" title="How should conflicts be resolved?"
description="This will be the name of your community" description="This will be the name of your community"
justification={isMdOrLarger ? "center" : "left"} justification={effectiveMdOrLarger ? "center" : "left"}
size={isMdOrLarger ? "L" : "M"} size={effectiveMdOrLarger ? "L" : "M"}
/> />
{/* Upload component: no label in create flow, max width 474px */} {/* Upload component: no label in create flow, max width 474px */}
+76
View File
@@ -0,0 +1,76 @@
/**
* Step definitions and helpers for the Create Rule Flow
*
* Single source of truth for step order and navigation helpers.
*/
import type { CreateFlowStep } from "../types";
/**
* Ordered list of steps in the create rule flow
*/
export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
"informational",
"text",
"select",
"upload",
"review",
"cards",
"right-rail",
"final-review",
"completed",
] as const;
/**
* Valid step IDs for the create flow (for validation)
*/
export const VALID_STEPS: readonly CreateFlowStep[] = FLOW_STEP_ORDER;
/**
* First step in the flow (entry point)
*/
export const FIRST_STEP: CreateFlowStep = FLOW_STEP_ORDER[0];
/**
* Returns the next step in the flow, or null if current is last/invalid
*/
export function getNextStep(
currentStep: CreateFlowStep | null | undefined,
): CreateFlowStep | null {
if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index === -1 || index === FLOW_STEP_ORDER.length - 1) return null;
return FLOW_STEP_ORDER[index + 1] as CreateFlowStep;
}
/**
* Returns the previous step in the flow, or null if current is first/invalid
*/
export function getPreviousStep(
currentStep: CreateFlowStep | null | undefined,
): CreateFlowStep | null {
if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index <= 0) return null;
return FLOW_STEP_ORDER[index - 1] as CreateFlowStep;
}
/**
* Returns the index of the step (0-based), or -1 if invalid
*/
export function getStepIndex(step: CreateFlowStep | null | undefined): number {
if (!step) return -1;
return FLOW_STEP_ORDER.indexOf(step);
}
/**
* Whether the given string is a valid create flow step
*/
export function isValidStep(
step: string | null | undefined,
): step is CreateFlowStep {
return (
typeof step === "string" &&
(VALID_STEPS as readonly string[]).includes(step)
);
}
+14
View File
@@ -1,7 +1,21 @@
import React from "react"; import React from "react";
import { vi } from "vitest";
import TopNav from "../../app/components/navigation/TopNav"; import TopNav from "../../app/components/navigation/TopNav";
import { componentTestSuite } from "../utils/componentTestSuite"; import { componentTestSuite } from "../utils/componentTestSuite";
// Mock next/navigation (TopNav uses useRouter for Create Rule button and usePathname for nav state)
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/",
}));
type TopNavProps = React.ComponentProps<typeof TopNav>; type TopNavProps = React.ComponentProps<typeof TopNav>;
// Test folderTop=false variant (standard header) // Test folderTop=false variant (standard header)