Implement create component
This commit is contained in:
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ModalHeaderView as default } from "./ModalHeader.view";
|
||||
export type { ModalHeaderProps } from "./ModalHeader.types";
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user