;
+}
diff --git a/app/components/Create/Create.view.tsx b/app/components/Create/Create.view.tsx
new file mode 100644
index 0000000..bf89cbc
--- /dev/null
+++ b/app/components/Create/Create.view.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { createPortal } from "react-dom";
+import ContentLockup from "../ContentLockup";
+import ModalFooter from "../ModalFooter";
+import ModalHeader from "../ModalHeader";
+import type { CreateViewProps } from "./Create.types";
+
+export function CreateView({
+ isOpen,
+ onClose,
+ title,
+ description,
+ children,
+ footerContent,
+ showBackButton,
+ showNextButton,
+ onBack,
+ onNext,
+ backButtonText,
+ nextButtonText,
+ nextButtonDisabled,
+ currentStep,
+ totalSteps,
+ className,
+ ariaLabel,
+ ariaLabelledBy,
+ createRef,
+ overlayRef,
+}: CreateViewProps) {
+ if (!isOpen) return null;
+
+ const createContent = (
+ <>
+ {/* Overlay */}
+
+
+ {/* Create Dialog */}
+
+ {/* Header with close buttons */}
+
+
+ {/* Header Lockup Section (Sticky) */}
+ {(title || description) && (
+
+
+
+ )}
+
+ {/* Content Area (Scrollable) */}
+
+ {children}
+
+
+ {/* Footer */}
+
+
+ >
+ );
+
+ // Portal to body
+ if (typeof window !== "undefined") {
+ return createPortal(createContent, document.body);
+ }
+
+ return null;
+}
diff --git a/app/components/Create/index.tsx b/app/components/Create/index.tsx
new file mode 100644
index 0000000..d07af48
--- /dev/null
+++ b/app/components/Create/index.tsx
@@ -0,0 +1,2 @@
+export { default } from "./Create.container";
+export type { CreateProps } from "./Create.types";
diff --git a/app/components/InputWithCounter/InputWithCounter.types.ts b/app/components/InputWithCounter/InputWithCounter.types.ts
new file mode 100644
index 0000000..c04ee47
--- /dev/null
+++ b/app/components/InputWithCounter/InputWithCounter.types.ts
@@ -0,0 +1,10 @@
+export interface InputWithCounterProps {
+ label?: string;
+ placeholder?: string;
+ value: string;
+ onChange: (value: string) => void;
+ maxLength: number;
+ showHelpIcon?: boolean;
+ className?: string;
+ inputClassName?: string;
+}
diff --git a/app/components/InputWithCounter/InputWithCounter.view.tsx b/app/components/InputWithCounter/InputWithCounter.view.tsx
new file mode 100644
index 0000000..2092127
--- /dev/null
+++ b/app/components/InputWithCounter/InputWithCounter.view.tsx
@@ -0,0 +1,75 @@
+import { getAssetPath } from "../../../lib/assetUtils";
+import type { InputWithCounterProps } from "./InputWithCounter.types";
+
+export function InputWithCounterView({
+ label,
+ placeholder,
+ value,
+ onChange,
+ maxLength,
+ showHelpIcon = false,
+ className = "",
+ inputClassName = "",
+}: InputWithCounterProps) {
+ return (
+
+ {/* Label with help icon */}
+ {label && (
+
+
+ {label}
+
+ {showHelpIcon && (
+
+
+
+
+
+
+
+
+ )}
+
+ )}
+
+ {/* Input field */}
+
+ {
+ if (e.target.value.length <= maxLength) {
+ onChange(e.target.value);
+ }
+ }}
+ maxLength={maxLength}
+ className={`w-full h-[36px] px-[12px] py-[8px] bg-[var(--color-surface-default-primary)] border-2 border-[var(--color-border-default-tertiary)] rounded-[var(--measures-radius-medium,8px)] text-[16px] leading-[24px] text-[var(--color-content-default-primary)] placeholder:text-[var(--color-content-default-tertiary)] focus:outline-none ${inputClassName}`}
+ />
+
+
+ {/* Character counter */}
+
+ {value.length}/{maxLength}
+
+
+ );
+}
diff --git a/app/components/InputWithCounter/index.tsx b/app/components/InputWithCounter/index.tsx
new file mode 100644
index 0000000..84207e0
--- /dev/null
+++ b/app/components/InputWithCounter/index.tsx
@@ -0,0 +1,2 @@
+export { InputWithCounterView as default } from "./InputWithCounter.view";
+export type { InputWithCounterProps } from "./InputWithCounter.types";
diff --git a/app/components/ModalFooter/ModalFooter.types.ts b/app/components/ModalFooter/ModalFooter.types.ts
new file mode 100644
index 0000000..02b9898
--- /dev/null
+++ b/app/components/ModalFooter/ModalFooter.types.ts
@@ -0,0 +1,19 @@
+export interface ModalFooterProps {
+ showBackButton?: boolean;
+ showNextButton?: boolean;
+ onBack?: () => void;
+ onNext?: () => void;
+ /**
+ * Custom back button text. If not provided, uses localized "Back" from common.json
+ */
+ backButtonText?: string;
+ /**
+ * Custom next button text. If not provided, uses localized "Next" from common.json
+ */
+ nextButtonText?: string;
+ nextButtonDisabled?: boolean;
+ currentStep?: number;
+ totalSteps?: number;
+ footerContent?: React.ReactNode;
+ className?: string;
+}
diff --git a/app/components/ModalFooter/ModalFooter.view.tsx b/app/components/ModalFooter/ModalFooter.view.tsx
new file mode 100644
index 0000000..f86cc84
--- /dev/null
+++ b/app/components/ModalFooter/ModalFooter.view.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { useTranslation } from "../../contexts/MessagesContext";
+import Button from "../Button";
+import Stepper from "../Stepper";
+import type { ModalFooterProps } from "./ModalFooter.types";
+
+export function ModalFooterView({
+ showBackButton = false,
+ showNextButton = false,
+ onBack,
+ onNext,
+ backButtonText,
+ nextButtonText,
+ nextButtonDisabled = false,
+ currentStep,
+ totalSteps,
+ footerContent,
+ className = "",
+}: ModalFooterProps) {
+ const t = useTranslation("common");
+
+ // Use localized defaults if text not provided
+ const defaultBackText = backButtonText || t("buttons.back");
+ const defaultNextText = nextButtonText || t("buttons.next");
+ return (
+
+ {/* Back Button - Absolutely positioned bottom left */}
+ {showBackButton && (
+
+
+ {defaultBackText}
+
+
+ )}
+
+ {/* Stepper (Centered) */}
+ {currentStep && totalSteps && (
+
+
+
+ )}
+
+ {/* Next Button - Absolutely positioned bottom right */}
+ {showNextButton && (
+
+
+ {defaultNextText}
+
+
+ )}
+
+ {/* Custom Footer Content */}
+ {footerContent}
+
+ );
+}
diff --git a/app/components/ModalFooter/index.tsx b/app/components/ModalFooter/index.tsx
new file mode 100644
index 0000000..b722de4
--- /dev/null
+++ b/app/components/ModalFooter/index.tsx
@@ -0,0 +1,2 @@
+export { ModalFooterView as default } from "./ModalFooter.view";
+export type { ModalFooterProps } from "./ModalFooter.types";
diff --git a/app/components/ModalHeader/ModalHeader.types.ts b/app/components/ModalHeader/ModalHeader.types.ts
new file mode 100644
index 0000000..22b82d3
--- /dev/null
+++ b/app/components/ModalHeader/ModalHeader.types.ts
@@ -0,0 +1,7 @@
+export interface ModalHeaderProps {
+ onClose?: () => void;
+ onMoreOptions?: () => void;
+ showCloseButton?: boolean;
+ showMoreOptionsButton?: boolean;
+ className?: string;
+}
diff --git a/app/components/ModalHeader/ModalHeader.view.tsx b/app/components/ModalHeader/ModalHeader.view.tsx
new file mode 100644
index 0000000..b35aa3e
--- /dev/null
+++ b/app/components/ModalHeader/ModalHeader.view.tsx
@@ -0,0 +1,55 @@
+import { getAssetPath } from "../../../lib/assetUtils";
+import type { ModalHeaderProps } from "./ModalHeader.types";
+
+export function ModalHeaderView({
+ onClose,
+ onMoreOptions,
+ showCloseButton = true,
+ showMoreOptionsButton = true,
+ className = "",
+}: ModalHeaderProps) {
+ return (
+
+ {/* Close Button - Left */}
+ {showCloseButton && (
+
+
+
+ )}
+
+ {/* More Options Button - Right */}
+ {showMoreOptionsButton && (
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app/components/ModalHeader/index.tsx b/app/components/ModalHeader/index.tsx
new file mode 100644
index 0000000..a26925f
--- /dev/null
+++ b/app/components/ModalHeader/index.tsx
@@ -0,0 +1,2 @@
+export { ModalHeaderView as default } from "./ModalHeader.view";
+export type { ModalHeaderProps } from "./ModalHeader.types";
diff --git a/messages/en/common.json b/messages/en/common.json
index 1504b53..e3b8f12 100644
--- a/messages/en/common.json
+++ b/messages/en/common.json
@@ -4,7 +4,10 @@
"createCommunityRule": "Create CommunityRule",
"seeHowItWorks": "See how it works",
"learnMore": "Learn more",
- "askOrganizer": "Ask an organizer"
+ "askOrganizer": "Ask an organizer",
+ "back": "Back",
+ "next": "Next",
+ "finish": "Finish"
},
"ariaLabels": {
"followBluesky": "Follow us on Bluesky",
diff --git a/public/assets/Icon_Help.svg b/public/assets/Icon_Help.svg
new file mode 100644
index 0000000..2162e1c
--- /dev/null
+++ b/public/assets/Icon_Help.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/stories/Create.stories.js b/stories/Create.stories.js
new file mode 100644
index 0000000..87fd449
--- /dev/null
+++ b/stories/Create.stories.js
@@ -0,0 +1,202 @@
+import React, { useState } from "react";
+import Create from "../app/components/Create";
+import Input from "../app/components/Input";
+
+export default {
+ title: "Components/Create",
+ component: Create,
+ parameters: {
+ layout: "fullscreen",
+ docs: {
+ description: {
+ component:
+ "Create dialog component with portal rendering, keyboard navigation, and focus trap. Supports multi-step workflows with stepper integration. Used for the create flow in the application.",
+ },
+ },
+ },
+ argTypes: {
+ isOpen: {
+ control: { type: "boolean" },
+ },
+ title: {
+ control: { type: "text" },
+ },
+ description: {
+ control: { type: "text" },
+ },
+ showBackButton: {
+ control: { type: "boolean" },
+ },
+ showNextButton: {
+ control: { type: "boolean" },
+ },
+ nextButtonDisabled: {
+ control: { type: "boolean" },
+ },
+ currentStep: {
+ control: { type: "number", min: 1, max: 5 },
+ },
+ totalSteps: {
+ control: { type: "number", min: 1, max: 5 },
+ },
+ },
+ tags: ["autodocs"],
+};
+
+const Template = (args) => {
+ const [isOpen, setIsOpen] = useState(args.isOpen || false);
+
+ return (
+
+ setIsOpen(true)}
+ className="px-4 py-2 bg-blue-500 text-white rounded"
+ >
+ Open Create Dialog
+
+ setIsOpen(false)}>
+ {args.children}
+
+
+ );
+};
+
+export const Default = Template.bind({});
+Default.args = {
+ isOpen: true,
+ title: "What do you call your group's new policy?",
+ description:
+ "You can also combine or add new approaches to the list",
+ children: (
+
+ ),
+ showBackButton: true,
+ showNextButton: true,
+ backButtonText: "Back",
+ nextButtonText: "Next",
+ nextButtonDisabled: false,
+};
+
+export const WithStepper = Template.bind({});
+WithStepper.args = {
+ isOpen: true,
+ title: "What do you call your group's new policy?",
+ description:
+ "You can also combine or add new approaches to the list",
+ children: (
+
+ ),
+ showBackButton: true,
+ showNextButton: true,
+ backButtonText: "Back",
+ nextButtonText: "Next",
+ nextButtonDisabled: false,
+ currentStep: 1,
+ totalSteps: 3,
+};
+
+export const Step2 = Template.bind({});
+Step2.args = {
+ isOpen: true,
+ title: "How should conflicts be resolved?",
+ description:
+ "You can also combine or add new approaches to the list",
+ children: (
+
+
+
+ ),
+ showBackButton: true,
+ showNextButton: true,
+ backButtonText: "Back",
+ nextButtonText: "Next",
+ nextButtonDisabled: false,
+ currentStep: 2,
+ totalSteps: 3,
+};
+
+export const Step3 = Template.bind({});
+Step3.args = {
+ isOpen: true,
+ title: "Final step",
+ description: "Review your settings",
+ children: (
+
+
+ Review your policy configuration
+
+
+ ),
+ showBackButton: true,
+ showNextButton: true,
+ backButtonText: "Back",
+ nextButtonText: "Finish",
+ nextButtonDisabled: false,
+ currentStep: 3,
+ totalSteps: 3,
+};
+
+export const WithoutFooter = Template.bind({});
+WithoutFooter.args = {
+ isOpen: true,
+ title: "Simple Create Dialog",
+ description: "This create dialog has no footer buttons",
+ children: (
+
+
+ Modal content without footer
+
+
+ ),
+ showBackButton: false,
+ showNextButton: false,
+};
+
+export const NextButtonDisabled = Template.bind({});
+NextButtonDisabled.args = {
+ isOpen: true,
+ title: "What do you call your group's new policy?",
+ description:
+ "You can also combine or add new approaches to the list",
+ children: (
+
+ ),
+ showBackButton: true,
+ showNextButton: true,
+ backButtonText: "Back",
+ nextButtonText: "Next",
+ nextButtonDisabled: true,
+ currentStep: 1,
+ totalSteps: 3,
+};
diff --git a/tests/components/Create.test.tsx b/tests/components/Create.test.tsx
new file mode 100644
index 0000000..ff2dfaf
--- /dev/null
+++ b/tests/components/Create.test.tsx
@@ -0,0 +1,193 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import "@testing-library/jest-dom/vitest";
+import Create from "../../app/components/Create";
+import Input from "../../app/components/Input";
+
+type CreateProps = React.ComponentProps;
+
+describe("Create", () => {
+ const defaultProps: CreateProps = {
+ isOpen: true,
+ onClose: vi.fn(),
+ title: "Test Create Dialog",
+ description: "Test description",
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders when isOpen is true", () => {
+ render(Create dialog content );
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(screen.getByText("Test Create Dialog")).toBeInTheDocument();
+ expect(screen.getByText("Create dialog content")).toBeInTheDocument();
+ });
+
+ it("does not render when isOpen is false", () => {
+ render( );
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it("calls onClose when close button is clicked", () => {
+ const onClose = vi.fn();
+ render( );
+ const closeButton = screen.getByLabelText("Close dialog");
+ fireEvent.click(closeButton);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onClose when ESC key is pressed", () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onClose when overlay is clicked", () => {
+ const onClose = vi.fn();
+ render( );
+ const overlay = document.querySelector(".fixed.inset-0");
+ if (overlay) {
+ fireEvent.click(overlay);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ }
+ });
+
+ it("renders footer buttons when provided", () => {
+ const onBack = vi.fn();
+ const onNext = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getByText("Go Back")).toBeInTheDocument();
+ expect(screen.getByText("Continue")).toBeInTheDocument();
+ });
+
+ it("calls onBack when back button is clicked", () => {
+ const onBack = vi.fn();
+ render(
+ ,
+ );
+ const backButton = screen.getByText("Back");
+ fireEvent.click(backButton);
+ expect(onBack).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onNext when next button is clicked", () => {
+ const onNext = vi.fn();
+ render(
+ ,
+ );
+ const nextButton = screen.getByText("Next");
+ fireEvent.click(nextButton);
+ expect(onNext).toHaveBeenCalledTimes(1);
+ });
+
+ it("disables next button when nextButtonDisabled is true", () => {
+ render(
+ ,
+ );
+ const nextButton = screen.getByText("Next");
+ expect(nextButton).toBeDisabled();
+ });
+
+ it("renders stepper when currentStep and totalSteps are provided", () => {
+ render(
+ ,
+ );
+ const steppers = screen.getAllByRole("progressbar");
+ // Find the stepper in the footer (not the progress bar if any)
+ const footerStepper = steppers.find((stepper) => {
+ const parent = stepper.closest(".absolute.bottom-0");
+ return parent !== null;
+ });
+ expect(footerStepper).toBeInTheDocument();
+ if (footerStepper) {
+ expect(footerStepper).toHaveAttribute("aria-valuenow", "2");
+ expect(footerStepper).toHaveAttribute("aria-valuemax", "5");
+ }
+ });
+
+ it("renders custom footer content", () => {
+ render(
+ Custom Footer}
+ />,
+ );
+ expect(screen.getByText("Custom Footer")).toBeInTheDocument();
+ });
+
+ it("has proper ARIA attributes", () => {
+ render( );
+ const dialog = screen.getByRole("dialog");
+ expect(dialog).toHaveAttribute("aria-modal", "true");
+ expect(dialog).toHaveAttribute("aria-label", "Test create dialog");
+ });
+
+ it("locks body scroll when open", () => {
+ render( );
+ expect(document.body.style.overflow).toBe("hidden");
+ });
+
+ it("restores body scroll when closed", () => {
+ const { rerender } = render( );
+ expect(document.body.style.overflow).toBe("hidden");
+ rerender( );
+ expect(document.body.style.overflow).toBe("");
+ });
+
+ it("traps focus within create dialog", async () => {
+ render(
+
+
+ ,
+ );
+
+ const closeButton = screen.getByLabelText("Close dialog");
+ const input = screen.getByLabelText("Test Input");
+
+ // Focus should start on first focusable element (close button)
+ await waitFor(() => {
+ expect(closeButton).toHaveFocus();
+ });
+
+ // Tab should move focus to next element
+ fireEvent.keyDown(document, { key: "Tab" });
+ await waitFor(() => {
+ // Should focus on the more options button or input
+ const focusedElement = document.activeElement;
+ expect(focusedElement).toBeInTheDocument();
+ });
+ });
+});