From a8eb9e192b5a876b5f872c4428b61eab8ae3620f Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:53:52 -0700 Subject: [PATCH 1/2] Implement create component --- app/components-preview/page.tsx | 108 ++++++++++ .../ContentLockup/ContentLockup.container.tsx | 12 ++ .../ContentLockup/ContentLockup.types.ts | 4 +- .../ContentLockup/ContentLockup.view.tsx | 15 +- app/components/Create/Create.container.tsx | 141 ++++++++++++ app/components/Create/Create.types.ts | 43 ++++ app/components/Create/Create.view.tsx | 95 ++++++++ app/components/Create/index.tsx | 2 + .../InputWithCounter.types.ts | 10 + .../InputWithCounter.view.tsx | 75 +++++++ app/components/InputWithCounter/index.tsx | 2 + .../ModalFooter/ModalFooter.types.ts | 19 ++ .../ModalFooter/ModalFooter.view.tsx | 68 ++++++ app/components/ModalFooter/index.tsx | 2 + .../ModalHeader/ModalHeader.types.ts | 7 + .../ModalHeader/ModalHeader.view.tsx | 55 +++++ app/components/ModalHeader/index.tsx | 2 + messages/en/common.json | 5 +- public/assets/Icon_Help.svg | 8 + stories/Create.stories.js | 202 ++++++++++++++++++ tests/components/Create.test.tsx | 193 +++++++++++++++++ 21 files changed, 1061 insertions(+), 7 deletions(-) create mode 100644 app/components/Create/Create.container.tsx create mode 100644 app/components/Create/Create.types.ts create mode 100644 app/components/Create/Create.view.tsx create mode 100644 app/components/Create/index.tsx create mode 100644 app/components/InputWithCounter/InputWithCounter.types.ts create mode 100644 app/components/InputWithCounter/InputWithCounter.view.tsx create mode 100644 app/components/InputWithCounter/index.tsx create mode 100644 app/components/ModalFooter/ModalFooter.types.ts create mode 100644 app/components/ModalFooter/ModalFooter.view.tsx create mode 100644 app/components/ModalFooter/index.tsx create mode 100644 app/components/ModalHeader/ModalHeader.types.ts create mode 100644 app/components/ModalHeader/ModalHeader.view.tsx create mode 100644 app/components/ModalHeader/index.tsx create mode 100644 public/assets/Icon_Help.svg create mode 100644 stories/Create.stories.js create mode 100644 tests/components/Create.test.tsx diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index 35c3ba0..cccd8ff 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -6,6 +6,10 @@ import Alert from "../components/Alert"; import Button from "../components/Button"; import Stepper from "../components/Stepper"; import Progress from "../components/Progress"; +import Create from "../components/Create"; +import Input from "../components/Input"; +import InputWithCounter from "../components/InputWithCounter"; +import { getAssetPath } from "../../lib/assetUtils"; export default function ComponentsPreview() { const [alertVisible, setAlertVisible] = useState({ @@ -16,6 +20,10 @@ export default function ComponentsPreview() { banner: true, }); + const [createOpen, setCreateOpen] = useState(false); + const [createStep, setCreateStep] = useState(1); + const [policyName, setPolicyName] = useState(""); + return (
@@ -306,6 +314,106 @@ export default function ComponentsPreview() {
+ + {/* Create Component Section */} +
+

+ Create Component +

+ +
+
+ + +
+

+ Step {createStep} of 3 +

+ + +
+
+
+ + setCreateOpen(false)} + title={ + createStep === 1 + ? "What do you call your group's new policy?" + : createStep === 2 + ? "How should conflicts be resolved?" + : "Review your policy" + } + description="You can also combine or add new approaches to the list" + showBackButton={true} + showNextButton={true} + onBack={() => setCreateStep((prev) => Math.max(1, prev - 1))} + onNext={() => setCreateStep((prev) => Math.min(3, prev + 1))} + backButtonText="Back" + nextButtonText={createStep === 3 ? "Finish" : "Next"} + nextButtonDisabled={createStep === 1 && !policyName.trim()} + currentStep={createStep} + totalSteps={3} + > +
+ {createStep === 1 && ( + + )} + {createStep === 2 && ( +
+ +

+ Select how conflicts should be resolved in your group. +

+
+ )} + {createStep === 3 && ( +
+

+ Review your policy configuration before finalizing. +

+
+

+ Policy details will appear here +

+
+
+ )} +
+
+
); diff --git a/app/components/ContentLockup/ContentLockup.container.tsx b/app/components/ContentLockup/ContentLockup.container.tsx index 2e1ad5f..637da77 100644 --- a/app/components/ContentLockup/ContentLockup.container.tsx +++ b/app/components/ContentLockup/ContentLockup.container.tsx @@ -91,6 +91,18 @@ const ContentLockupContainer = memo( shape: "w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]", }, + modal: { + container: + "flex flex-col gap-[var(--spacing-scale-008)] items-start justify-center py-[12px] relative w-full", + textContainer: "flex flex-col gap-[var(--spacing-scale-008)] w-full", + titleGroup: "flex flex-col gap-[var(--spacing-scale-008)] w-full", + titleContainer: "flex items-center justify-start w-full", + title: + "font-bricolage-grotesque font-bold text-[28px] leading-[36px] tracking-[0] text-[var(--color-content-default-primary)] text-left", + description: + "font-inter font-normal text-[16px] leading-[24px] tracking-[0] text-[var(--color-content-default-tertiary)] text-left", + shape: "w-[16px] h-[16px]", + }, }; const styles = variantStyles[variant] || variantStyles.hero; diff --git a/app/components/ContentLockup/ContentLockup.types.ts b/app/components/ContentLockup/ContentLockup.types.ts index be1c2d9..61a59f8 100644 --- a/app/components/ContentLockup/ContentLockup.types.ts +++ b/app/components/ContentLockup/ContentLockup.types.ts @@ -5,7 +5,7 @@ export interface ContentLockupProps { ctaText?: string; ctaHref?: string; buttonClassName?: string; - variant?: "hero" | "feature" | "learn" | "ask" | "ask-inverse"; + variant?: "hero" | "feature" | "learn" | "ask" | "ask-inverse" | "modal"; linkText?: string; linkHref?: string; alignment?: "center" | "left"; @@ -34,7 +34,7 @@ export interface ContentLockupViewProps { ctaText?: string; ctaHref?: string; buttonClassName: string; - variant: "hero" | "feature" | "learn" | "ask" | "ask-inverse"; + variant: "hero" | "feature" | "learn" | "ask" | "ask-inverse" | "modal"; linkText?: string; linkHref?: string; alignment: "center" | "left"; diff --git a/app/components/ContentLockup/ContentLockup.view.tsx b/app/components/ContentLockup/ContentLockup.view.tsx index 6f13269..7334b60 100644 --- a/app/components/ContentLockup/ContentLockup.view.tsx +++ b/app/components/ContentLockup/ContentLockup.view.tsx @@ -20,16 +20,20 @@ function ContentLockupView({ }: ContentLockupViewProps) { return (
- {variant === "ask" || variant === "ask-inverse" ? ( - /* Simplified structure for ask variant */ + {variant === "ask" || variant === "ask-inverse" || variant === "modal" ? ( + /* Simplified structure for ask and modal variants */
{title ? ( @@ -39,6 +43,9 @@ function ContentLockupView({ ) : null}
{subtitle ?

{subtitle}

: null} + {variant === "modal" && description && ( +

{description}

+ )}
) : ( /* Full structure for other variants */ diff --git a/app/components/Create/Create.container.tsx b/app/components/Create/Create.container.tsx new file mode 100644 index 0000000..eb995ca --- /dev/null +++ b/app/components/Create/Create.container.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { memo, useEffect, useRef } from "react"; +import { CreateView } from "./Create.view"; +import type { CreateProps } from "./Create.types"; + +const CreateContainer = memo( + ({ + isOpen, + onClose, + title, + description, + children, + footerContent, + showBackButton = true, + showNextButton = true, + onBack, + onNext, + backButtonText = "Back", + nextButtonText = "Next", + nextButtonDisabled = false, + currentStep, + totalSteps, + className = "", + ariaLabel, + ariaLabelledBy, + }) => { + const createRef = useRef(null); + const overlayRef = useRef(null); + const previousActiveElementRef = useRef(null); + + // Handle ESC key to close + useEffect(() => { + if (!isOpen) return; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("keydown", handleEscape); + }; + }, [isOpen, onClose]); + + // Focus trap and body scroll lock + useEffect(() => { + if (!isOpen) return; + + // Store previous active element + previousActiveElementRef.current = + document.activeElement as HTMLElement; + + // Lock body scroll + document.body.style.overflow = "hidden"; + + // Focus the first focusable element in the create dialog + if (createRef.current) { + const focusableElements = createRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + const firstElement = focusableElements[0] as HTMLElement; + if (firstElement) { + firstElement.focus(); + } else { + // Fallback: make create dialog focusable and focus it + createRef.current.setAttribute("tabindex", "-1"); + createRef.current.focus(); + } + } + + // Focus trap + const handleTab = (e: KeyboardEvent) => { + if (e.key !== "Tab" || !createRef.current) return; + + const focusableElements = createRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + const firstElement = focusableElements[0] as HTMLElement; + const lastElement = focusableElements[ + focusableElements.length - 1 + ] as HTMLElement; + + if (e.shiftKey) { + // Shift + Tab + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement?.focus(); + } + } else { + // Tab + if (document.activeElement === lastElement) { + e.preventDefault(); + firstElement?.focus(); + } + } + }; + + document.addEventListener("keydown", handleTab); + + return () => { + document.body.style.overflow = ""; + document.removeEventListener("keydown", handleTab); + // Restore focus to previous element + previousActiveElementRef.current?.focus(); + }; + }, [isOpen]); + + + return ( + + ); + }, +); + +CreateContainer.displayName = "Create"; + +export default CreateContainer; diff --git a/app/components/Create/Create.types.ts b/app/components/Create/Create.types.ts new file mode 100644 index 0000000..f0db0f3 --- /dev/null +++ b/app/components/Create/Create.types.ts @@ -0,0 +1,43 @@ +export interface CreateProps { + isOpen: boolean; + onClose: () => void; + title?: string; + description?: string; + children?: React.ReactNode; + footerContent?: React.ReactNode; + showBackButton?: boolean; + showNextButton?: boolean; + onBack?: () => void; + onNext?: () => void; + backButtonText?: string; + nextButtonText?: string; + nextButtonDisabled?: boolean; + currentStep?: number; + totalSteps?: number; + className?: string; + ariaLabel?: string; + ariaLabelledBy?: string; +} + +export interface CreateViewProps { + isOpen: boolean; + onClose: () => void; + title?: string; + description?: string; + children?: React.ReactNode; + footerContent?: React.ReactNode; + showBackButton: boolean; + showNextButton: boolean; + onBack?: () => void; + onNext?: () => void; + backButtonText: string; + nextButtonText: string; + nextButtonDisabled: boolean; + currentStep?: number; + totalSteps?: number; + className: string; + ariaLabel?: string; + ariaLabelledBy?: string; + createRef: React.RefObject; + overlayRef: React.RefObject; +} 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 */} + diff --git a/app/components/Stepper/Stepper.types.ts b/app/components/Stepper/Stepper.types.ts index 7c635b4..f82acab 100644 --- a/app/components/Stepper/Stepper.types.ts +++ b/app/components/Stepper/Stepper.types.ts @@ -1,4 +1,4 @@ -export type StepperActive = 1 | 2 | 3 | 4 | 5; +export type StepperActive = number; export interface StepperProps { active?: StepperActive; diff --git a/eslint.config.mjs b/eslint.config.mjs index 7ab6f77..4036877 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -176,6 +176,14 @@ const eslintConfig = [ "react-hooks/exhaustive-deps": "off", }, }, + // Type definition files - interface properties are used in implementation files + { + files: ["**/*.types.ts"], + rules: { + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": "off", + }, + }, ]; export default eslintConfig; diff --git a/stories/Create.stories.js b/stories/Create.stories.js index 87fd449..cea920c 100644 --- a/stories/Create.stories.js +++ b/stories/Create.stories.js @@ -65,15 +65,10 @@ 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", + description: "You can also combine or add new approaches to the list", children: (
- +

0/48

@@ -90,15 +85,10 @@ 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", + description: "You can also combine or add new approaches to the list", children: (
- +

0/48

@@ -117,15 +107,10 @@ 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", + description: "You can also combine or add new approaches to the list", children: (
- +
), showBackButton: true, @@ -178,15 +163,10 @@ 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", + description: "You can also combine or add new approaches to the list", children: (
- +

0/48

diff --git a/tests/components/Create.test.tsx b/tests/components/Create.test.tsx index ff2dfaf..e1f7f0e 100644 --- a/tests/components/Create.test.tsx +++ b/tests/components/Create.test.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, fireEvent, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom/vitest"; +import { renderWithProviders } from "../utils/test-utils"; import Create from "../../app/components/Create"; import Input from "../../app/components/Input"; @@ -20,20 +21,22 @@ describe("Create", () => { }); it("renders when isOpen is true", () => { - render(Create dialog content); + renderWithProviders( + 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(); + renderWithProviders(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); it("calls onClose when close button is clicked", () => { const onClose = vi.fn(); - render(); + renderWithProviders(); const closeButton = screen.getByLabelText("Close dialog"); fireEvent.click(closeButton); expect(onClose).toHaveBeenCalledTimes(1); @@ -41,14 +44,14 @@ describe("Create", () => { it("calls onClose when ESC key is pressed", () => { const onClose = vi.fn(); - render(); + renderWithProviders(); fireEvent.keyDown(document, { key: "Escape" }); expect(onClose).toHaveBeenCalledTimes(1); }); it("calls onClose when overlay is clicked", () => { const onClose = vi.fn(); - render(); + renderWithProviders(); const overlay = document.querySelector(".fixed.inset-0"); if (overlay) { fireEvent.click(overlay); @@ -59,7 +62,7 @@ describe("Create", () => { it("renders footer buttons when provided", () => { const onBack = vi.fn(); const onNext = vi.fn(); - render( + renderWithProviders( { it("calls onBack when back button is clicked", () => { const onBack = vi.fn(); - render( + renderWithProviders( { it("calls onNext when next button is clicked", () => { const onNext = vi.fn(); - render( + renderWithProviders( { }); it("disables next button when nextButtonDisabled is true", () => { - render( + renderWithProviders( { }); it("renders stepper when currentStep and totalSteps are provided", () => { - render( - , + renderWithProviders( + , ); - 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; + // Find the stepper by its aria-label + const stepper = screen.getByRole("progressbar", { + name: "Step 2 of 5", }); - expect(footerStepper).toBeInTheDocument(); - if (footerStepper) { - expect(footerStepper).toHaveAttribute("aria-valuenow", "2"); - expect(footerStepper).toHaveAttribute("aria-valuemax", "5"); - } + expect(stepper).toBeInTheDocument(); + expect(stepper).toHaveAttribute("aria-valuenow", "2"); + expect(stepper).toHaveAttribute("aria-valuemax", "5"); }); it("renders custom footer content", () => { - render( + renderWithProviders( Custom Footer} @@ -149,33 +144,35 @@ describe("Create", () => { }); it("has proper ARIA attributes", () => { - render(); + renderWithProviders( + , + ); 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(); + renderWithProviders(); expect(document.body.style.overflow).toBe("hidden"); }); it("restores body scroll when closed", () => { - const { rerender } = render(); + const { rerender } = renderWithProviders(); expect(document.body.style.overflow).toBe("hidden"); rerender(); expect(document.body.style.overflow).toBe(""); }); it("traps focus within create dialog", async () => { - render( + renderWithProviders( , ); const closeButton = screen.getByLabelText("Close dialog"); - const input = screen.getByLabelText("Test Input"); + screen.getByLabelText("Test Input"); // Verify input is rendered // Focus should start on first focusable element (close button) await waitFor(() => {