diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index 35c3ba0..d23e021 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -6,6 +6,9 @@ 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"; export default function ComponentsPreview() { const [alertVisible, setAlertVisible] = useState({ @@ -16,6 +19,10 @@ export default function ComponentsPreview() { banner: true, }); + const [createOpen, setCreateOpen] = useState(false); + const [createStep, setCreateStep] = useState(1); + const [policyName, setPolicyName] = useState(""); + return (
@@ -306,6 +313,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..d240cb2 100644 --- a/app/components/ContentLockup/ContentLockup.container.tsx +++ b/app/components/ContentLockup/ContentLockup.container.tsx @@ -91,6 +91,20 @@ 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", + subtitle: + "font-inter font-normal text-[16px] leading-[24px] tracking-[0] text-[var(--color-content-default-tertiary)] 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..a43ab04 --- /dev/null +++ b/app/components/Create/Create.container.tsx @@ -0,0 +1,140 @@ +"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 */} +