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";
+4 -1
View File
@@ -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",
+8
View File
@@ -0,0 +1,8 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_21296_8257" style="mask-type: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="#F6F06F"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+202
View File
@@ -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 (
<div className="p-8">
<button
onClick={() => setIsOpen(true)}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Open Create Dialog
</button>
<Create {...args} isOpen={isOpen} onClose={() => setIsOpen(false)}>
{args.children}
</Create>
</div>
);
};
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: (
<div className="space-y-4">
<Input
label="Label"
placeholder="Policy name"
value=""
/>
<p className="text-[12px] text-[var(--color-content-default-tertiary)]">
0/48
</p>
</div>
),
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: (
<div className="space-y-4">
<Input
label="Label"
placeholder="Policy name"
value=""
/>
<p className="text-[12px] text-[var(--color-content-default-tertiary)]">
0/48
</p>
</div>
),
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: (
<div className="space-y-4">
<Input
label="Label"
placeholder="Enter text"
value=""
/>
</div>
),
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: (
<div className="space-y-4">
<p className="text-[var(--color-content-default-primary)]">
Review your policy configuration
</p>
</div>
),
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: (
<div className="space-y-4">
<p className="text-[var(--color-content-default-primary)]">
Modal content without footer
</p>
</div>
),
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: (
<div className="space-y-4">
<Input
label="Label"
placeholder="Policy name"
value=""
/>
<p className="text-[12px] text-[var(--color-content-default-tertiary)]">
0/48
</p>
</div>
),
showBackButton: true,
showNextButton: true,
backButtonText: "Back",
nextButtonText: "Next",
nextButtonDisabled: true,
currentStep: 1,
totalSteps: 3,
};
+193
View File
@@ -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<typeof Create>;
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 {...defaultProps}>Create dialog content</Create>);
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(<Create {...defaultProps} isOpen={false} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("calls onClose when close button is clicked", () => {
const onClose = vi.fn();
render(<Create {...defaultProps} onClose={onClose} />);
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(<Create {...defaultProps} onClose={onClose} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when overlay is clicked", () => {
const onClose = vi.fn();
render(<Create {...defaultProps} onClose={onClose} />);
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(
<Create
{...defaultProps}
showBackButton={true}
showNextButton={true}
onBack={onBack}
onNext={onNext}
backButtonText="Go Back"
nextButtonText="Continue"
/>,
);
expect(screen.getByText("Go Back")).toBeInTheDocument();
expect(screen.getByText("Continue")).toBeInTheDocument();
});
it("calls onBack when back button is clicked", () => {
const onBack = vi.fn();
render(
<Create
{...defaultProps}
showBackButton={true}
onBack={onBack}
backButtonText="Back"
/>,
);
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(
<Create
{...defaultProps}
showNextButton={true}
onNext={onNext}
nextButtonText="Next"
/>,
);
const nextButton = screen.getByText("Next");
fireEvent.click(nextButton);
expect(onNext).toHaveBeenCalledTimes(1);
});
it("disables next button when nextButtonDisabled is true", () => {
render(
<Create
{...defaultProps}
showNextButton={true}
nextButtonText="Next"
nextButtonDisabled={true}
/>,
);
const nextButton = screen.getByText("Next");
expect(nextButton).toBeDisabled();
});
it("renders stepper when currentStep and totalSteps are provided", () => {
render(
<Create
{...defaultProps}
currentStep={2}
totalSteps={5}
/>,
);
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(
<Create
{...defaultProps}
footerContent={<button>Custom Footer</button>}
/>,
);
expect(screen.getByText("Custom Footer")).toBeInTheDocument();
});
it("has proper ARIA attributes", () => {
render(<Create {...defaultProps} ariaLabel="Test create dialog" />);
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(<Create {...defaultProps} />);
expect(document.body.style.overflow).toBe("hidden");
});
it("restores body scroll when closed", () => {
const { rerender } = render(<Create {...defaultProps} />);
expect(document.body.style.overflow).toBe("hidden");
rerender(<Create {...defaultProps} isOpen={false} />);
expect(document.body.style.overflow).toBe("");
});
it("traps focus within create dialog", async () => {
render(
<Create {...defaultProps}>
<Input label="Test Input" />
</Create>,
);
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();
});
});
});