Implement create component

This commit is contained in:
adilallo
2026-02-02 12:53:52 -07:00
parent b98b9dded3
commit a8eb9e192b
21 changed files with 1061 additions and 7 deletions
+108
View File
@@ -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 (
<div className="min-h-screen bg-[var(--color-surface-default-primary)] p-[var(--spacing-scale-032)]">
<div className="max-w-[1200px] mx-auto space-y-[var(--spacing-scale-064)]">
@@ -306,6 +314,106 @@ export default function ComponentsPreview() {
</div>
</div>
</section>
{/* Create Component Section */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
Create Component
</h2>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
<div className="space-y-[var(--spacing-scale-016)]">
<Button
variant="primary"
size="medium"
onClick={() => setCreateOpen(true)}
>
Open Create Dialog
</Button>
<div className="space-y-[var(--spacing-scale-008)]">
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)]">
Step {createStep} of 3
</p>
<Button
variant="secondary"
size="small"
onClick={() => setCreateStep((prev) => Math.max(1, prev - 1))}
disabled={createStep === 1}
>
Previous Step
</Button>
<Button
variant="secondary"
size="small"
onClick={() => setCreateStep((prev) => Math.min(3, prev + 1))}
disabled={createStep === 3}
>
Next Step
</Button>
</div>
</div>
</div>
<Create
isOpen={createOpen}
onClose={() => 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}
>
<div className="space-y-[var(--spacing-scale-024)]">
{createStep === 1 && (
<InputWithCounter
label="Label"
placeholder="Policy name"
value={policyName}
onChange={setPolicyName}
maxLength={48}
showHelpIcon
/>
)}
{createStep === 2 && (
<div className="space-y-[var(--spacing-scale-008)]">
<Input
label="Conflict Resolution Method"
placeholder="Enter method"
value=""
/>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-primary)]">
Select how conflicts should be resolved in your group.
</p>
</div>
)}
{createStep === 3 && (
<div className="space-y-[var(--spacing-scale-016)]">
<p className="font-inter text-[16px] leading-[24px] text-[var(--color-content-default-primary)]">
Review your policy configuration before finalizing.
</p>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-200,8px)] p-[var(--spacing-scale-016)]">
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)]">
Policy details will appear here
</p>
</div>
</div>
)}
</div>
</Create>
</section>
</div>
</div>
);
@@ -91,6 +91,18 @@ const ContentLockupContainer = memo<ContentLockupProps>(
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;
@@ -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";
@@ -20,16 +20,20 @@ function ContentLockupView({
}: ContentLockupViewProps) {
return (
<div className={styles.container}>
{variant === "ask" || variant === "ask-inverse" ? (
/* Simplified structure for ask variant */
{variant === "ask" || variant === "ask-inverse" || variant === "modal" ? (
/* Simplified structure for ask and modal variants */
<div
className={`${styles.titleGroup} ${
alignment === "left" ? "text-left" : "text-center"
alignment === "left" || variant === "modal"
? "text-left"
: "text-center"
}`}
>
<div
className={`${styles.titleContainer} ${
alignment === "left" ? "justify-start" : "justify-center"
alignment === "left" || variant === "modal"
? "justify-start"
: "justify-center"
}`}
>
{title ? (
@@ -39,6 +43,9 @@ function ContentLockupView({
) : null}
</div>
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
{variant === "modal" && description && (
<p className={styles.description}>{description}</p>
)}
</div>
) : (
/* Full structure for other variants */
+141
View File
@@ -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<CreateProps>(
({
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<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const previousActiveElementRef = useRef<HTMLElement | null>(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 (
<CreateView
isOpen={isOpen}
onClose={onClose}
title={title}
description={description}
children={children}
footerContent={footerContent}
showBackButton={showBackButton}
showNextButton={showNextButton}
onBack={onBack}
onNext={onNext}
backButtonText={backButtonText}
nextButtonText={nextButtonText}
nextButtonDisabled={nextButtonDisabled}
currentStep={currentStep}
totalSteps={totalSteps}
className={className}
ariaLabel={ariaLabel}
ariaLabelledBy={ariaLabelledBy}
createRef={createRef}
overlayRef={overlayRef}
/>
);
},
);
CreateContainer.displayName = "Create";
export default CreateContainer;
+43
View File
@@ -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<HTMLDivElement>;
overlayRef: React.RefObject<HTMLDivElement>;
}
+95
View File
@@ -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 */}
<div
ref={overlayRef}
className="fixed inset-0 bg-black/50 z-[9998]"
onClick={onClose}
aria-hidden="true"
/>
{/* Create Dialog */}
<div
ref={createRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={`fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-[var(--color-surface-default-primary)] rounded-[var(--radius-500,20px)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] w-[560px] max-h-[728px] flex flex-col overflow-hidden z-[9999] ${className}`}
>
{/* Header with close buttons */}
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
{/* Header Lockup Section (Sticky) */}
{(title || description) && (
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0 sticky top-[48px] z-[2]">
<ContentLockup
title={title}
description={description}
variant="modal"
alignment="left"
/>
</div>
)}
{/* Content Area (Scrollable) */}
<div className="flex flex-col gap-[var(--spacing-scale-024)] px-[24px] pb-[96px] overflow-x-clip overflow-y-auto relative shrink-0 flex-1">
{children}
</div>
{/* Footer */}
<ModalFooter
showBackButton={showBackButton}
showNextButton={showNextButton}
onBack={onBack}
onNext={onNext}
backButtonText={backButtonText}
nextButtonText={nextButtonText}
nextButtonDisabled={nextButtonDisabled}
currentStep={currentStep}
totalSteps={totalSteps}
footerContent={footerContent}
/>
</div>
</>
);
// Portal to body
if (typeof window !== "undefined") {
return createPortal(createContent, document.body);
}
return null;
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Create.container";
export type { CreateProps } from "./Create.types";
@@ -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;
}
@@ -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 (
<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="font-inter text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]">
{label}
</label>
{showHelpIcon && (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0 w-[12px] h-[12px]"
>
<mask
id="mask0_21296_8257"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="12"
height="12"
>
<rect width="12" height="12" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_21296_8257)">
<path
d="M5.99449 8.80766C6.13725 8.80766 6.25784 8.75838 6.35624 8.6598C6.45463 8.56123 6.50383 8.44055 6.50383 8.29779C6.50383 8.15502 6.45454 8.03444 6.35596 7.93605C6.25739 7.83765 6.13672 7.78845 5.99395 7.78845C5.85118 7.78845 5.7306 7.83774 5.63221 7.93631C5.53381 8.03489 5.48461 8.15556 5.48461 8.29833C5.48461 8.44109 5.5339 8.56168 5.63248 8.66008C5.73105 8.75847 5.85172 8.80766 5.99449 8.80766ZM5.64038 7.01729H6.34421C6.35062 6.77114 6.38668 6.5745 6.45239 6.42739C6.51809 6.28028 6.67754 6.08525 6.93075 5.8423C7.15062 5.62243 7.31905 5.41938 7.43604 5.23316C7.55302 5.04695 7.61151 4.82703 7.61151 4.57343C7.61151 4.14307 7.45687 3.80689 7.14759 3.5649C6.8383 3.32292 6.47243 3.20193 6.04999 3.20193C5.63269 3.20193 5.28734 3.3133 5.01394 3.53606C4.74055 3.75881 4.54552 4.02115 4.42885 4.32306L5.07114 4.58075C5.13204 4.41473 5.2362 4.25303 5.38364 4.09566C5.53108 3.93829 5.74999 3.8596 6.04038 3.8596C6.33589 3.8596 6.55432 3.94053 6.69566 4.1024C6.83701 4.26426 6.90769 4.4423 6.90769 4.63654C6.90769 4.80641 6.85929 4.96185 6.76249 5.10288C6.6657 5.2439 6.5423 5.38012 6.3923 5.51154C6.0641 5.80769 5.85673 6.0439 5.77019 6.22019C5.68365 6.39647 5.64038 6.66217 5.64038 7.01729ZM6.00082 10.75C5.34386 10.75 4.72634 10.6253 4.14828 10.376C3.5702 10.1266 3.06736 9.78827 2.63975 9.36085C2.21213 8.93343 1.8736 8.43081 1.62416 7.85299C1.37472 7.27518 1.25 6.65779 1.25 6.00083C1.25 5.34386 1.37467 4.72634 1.624 4.14828C1.87333 3.5702 2.21171 3.06736 2.63913 2.63975C3.06655 2.21213 3.56917 1.8736 4.14699 1.62416C4.7248 1.37472 5.34218 1.25 5.99915 1.25C6.65612 1.25 7.27363 1.37467 7.8517 1.624C8.42978 1.87333 8.93262 2.21171 9.36023 2.63913C9.78784 3.06655 10.1264 3.56917 10.3758 4.14699C10.6253 4.7248 10.75 5.34218 10.75 5.99915C10.75 6.65612 10.6253 7.27363 10.376 7.8517C10.1266 8.42978 9.78827 8.93262 9.36085 9.36023C8.93343 9.78784 8.43081 10.1264 7.85299 10.3758C7.27518 10.6253 6.65779 10.75 6.00082 10.75ZM5.99999 9.99999C7.11665 9.99999 8.06249 9.61249 8.83749 8.83749C9.61249 8.06249 9.99999 7.11665 9.99999 5.99999C9.99999 4.88332 9.61249 3.93749 8.83749 3.16249C8.06249 2.38749 7.11665 1.99999 5.99999 1.99999C4.88332 1.99999 3.93749 2.38749 3.16249 3.16249C2.38749 3.93749 1.99999 4.88332 1.99999 5.99999C1.99999 7.11665 2.38749 8.06249 3.16249 8.83749C3.93749 9.61249 4.88332 9.99999 5.99999 9.99999Z"
fill="var(--color-content-brand-darker-accent-2)"
/>
</g>
</svg>
)}
</div>
)}
{/* Input field */}
<div className="relative">
<input
type="text"
placeholder={placeholder}
value={value}
onChange={(e) => {
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}`}
/>
</div>
{/* Character counter */}
<p className="font-inter text-[12px] leading-[16px] text-[var(--color-content-default-tertiary)]">
{value.length}/{maxLength}
</p>
</div>
);
}
@@ -0,0 +1,2 @@
export { InputWithCounterView as default } from "./InputWithCounter.view";
export type { InputWithCounterProps } from "./InputWithCounter.types";
@@ -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;
}
@@ -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 (
<div
className={`h-[64px] bg-[var(--color-surface-default-primary)] rounded-bl-[var(--radius-300,12px)] rounded-br-[var(--radius-300,12px)] shrink-0 relative ${className}`}
>
{/* Back Button - Absolutely positioned bottom left */}
{showBackButton && (
<div className="absolute left-[16px] top-[12px]">
<Button
variant="outlined"
size="medium"
onClick={onBack}
>
{defaultBackText}
</Button>
</div>
)}
{/* Stepper (Centered) */}
{currentStep && totalSteps && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Stepper active={currentStep} totalSteps={totalSteps} />
</div>
)}
{/* Next Button - Absolutely positioned bottom right */}
{showNextButton && (
<div className="absolute right-[16px] top-[12px]">
<Button
variant="default"
size="medium"
onClick={onNext}
disabled={nextButtonDisabled}
>
{defaultNextText}
</Button>
</div>
)}
{/* Custom Footer Content */}
{footerContent}
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { ModalFooterView as default } from "./ModalFooter.view";
export type { ModalFooterProps } from "./ModalFooter.types";
@@ -0,0 +1,7 @@
export interface ModalHeaderProps {
onClose?: () => void;
onMoreOptions?: () => void;
showCloseButton?: boolean;
showMoreOptionsButton?: boolean;
className?: string;
}
@@ -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 (
<div
className={`border-b border-[var(--color-border-default-secondary)] h-[48px] shrink-0 sticky top-0 bg-[var(--color-surface-default-primary)] z-[2] ${className}`}
>
{/* Close Button - Left */}
{showCloseButton && (
<button
onClick={onClose}
className="absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full left-[24px] top-[12px] flex items-center justify-center cursor-pointer"
aria-label="Close dialog"
>
<img
src={getAssetPath("assets/Icon_Close.svg")}
alt=""
className="w-[16px] h-[16px]"
style={{
filter: "brightness(0) invert(1)",
}}
/>
</button>
)}
{/* More Options Button - Right */}
{showMoreOptionsButton && (
<button
onClick={onMoreOptions}
className="absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full right-[24px] top-[12px] flex items-center justify-center cursor-pointer"
aria-label="More options"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="4" cy="8" r="1.5" fill="white" />
<circle cx="8" cy="8" r="1.5" fill="white" />
<circle cx="12" cy="8" r="1.5" fill="white" />
</svg>
</button>
)}
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { ModalHeaderView as default } from "./ModalHeader.view";
export type { ModalHeaderProps } from "./ModalHeader.types";