diff --git a/app/components/controls/Upload/Upload.container.tsx b/app/components/controls/Upload/Upload.container.tsx new file mode 100644 index 0000000..c1beab4 --- /dev/null +++ b/app/components/controls/Upload/Upload.container.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { memo } from "react"; +import UploadView from "./Upload.view"; +import type { UploadProps } from "./Upload.types"; + +const UploadContainer = memo( + ({ + active = true, + label, + showHelpIcon = true, + onClick, + className = "", + }) => { + return ( + + ); + }, +); + +UploadContainer.displayName = "Upload"; + +export default UploadContainer; diff --git a/app/components/controls/Upload/Upload.types.ts b/app/components/controls/Upload/Upload.types.ts new file mode 100644 index 0000000..d9ce7ad --- /dev/null +++ b/app/components/controls/Upload/Upload.types.ts @@ -0,0 +1,34 @@ +export interface UploadProps { + /** + * Whether the upload component is in active state. + * When active, button has white background with black text. + * When inactive, button has dark background with gray text. + * @default true + */ + active?: boolean; + /** + * Label text displayed above the upload component + */ + label?: string; + /** + * Whether to show help icon next to label + * @default true + */ + showHelpIcon?: boolean; + /** + * Callback when upload button is clicked + */ + onClick?: () => void; + /** + * Additional CSS classes + */ + className?: string; +} + +export interface UploadViewProps { + active: boolean; + label?: string; + showHelpIcon: boolean; + onClick?: () => void; + className: string; +} diff --git a/app/components/controls/Upload/Upload.view.tsx b/app/components/controls/Upload/Upload.view.tsx new file mode 100644 index 0000000..92cf5d7 --- /dev/null +++ b/app/components/controls/Upload/Upload.view.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { memo } from "react"; +import InputLabel from "../../utility/InputLabel"; +import type { UploadViewProps } from "./Upload.types"; + +function UploadView({ + active = true, + label, + showHelpIcon = true, + onClick, + className = "", +}: UploadViewProps) { + const isActive = active; + + // Button styles based on active state + const buttonBgClass = isActive + ? "bg-[var(--color-surface-invert-primary,white)]" + : "bg-[var(--color-surface-default-secondary,#141414)]"; + + const buttonTextColor = isActive + ? "text-[color:var(--color-content-invert-primary,black)]" + : "text-[color:var(--color-content-invert-tertiary,#2d2d2d)]"; + + // Description text color based on active state + const descriptionTextColor = isActive + ? "text-[color:var(--color-content-default-primary,white)]" + : "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"; + + // Icon color based on active state + const iconColor = isActive + ? "text-[color:var(--color-content-invert-primary,black)]" + : "text-[color:var(--color-content-invert-tertiary,#2d2d2d)]"; + + return ( +
+ {/* Label using InputLabel component */} + {label && ( + + )} + + {/* Upload container */} +
+ {/* Upload button */} + + + {/* Description text */} +
+

+ Add images, PDFs, and other files to the policy +

+
+
+
+ ); +} + +UploadView.displayName = "UploadView"; + +export default memo(UploadView); diff --git a/app/components/controls/Upload/index.tsx b/app/components/controls/Upload/index.tsx new file mode 100644 index 0000000..2335015 --- /dev/null +++ b/app/components/controls/Upload/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Upload.container"; +export type { UploadProps } from "./Upload.types"; diff --git a/app/create/select/page.tsx b/app/create/select/page.tsx new file mode 100644 index 0000000..6b25d7a --- /dev/null +++ b/app/create/select/page.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; +import HeaderLockup from "../../components/type/HeaderLockup"; +import MultiSelect from "../../components/controls/MultiSelect"; + +/** + * Select page for the create flow + * + * Displays selection options using HeaderLockup and MultiSelect components. + * Responsive layout: two-column at 640px+, single column below 640px. + * Responsive sizing: uses L/M for HeaderLockup and S for MultiSelect based on 640px breakpoint. + */ +export default function SelectPage() { + const isMdOrLarger = useMediaQuery("(min-width: 640px)"); + + // Sample options for MultiSelect components + const [communitySizeOptions, setCommunitySizeOptions] = useState([ + { id: "1", label: "1 member", state: "Unselected" as const }, + { id: "2", label: "2-10 members", state: "Unselected" as const }, + { id: "3", label: "10-24 members", state: "Unselected" as const }, + { id: "4", label: "24-64 members", state: "Unselected" as const }, + { id: "5", label: "64-128 members", state: "Unselected" as const }, + { id: "6", label: "125-1000 members", state: "Unselected" as const }, + { id: "7", label: "1000+ members", state: "Unselected" as const }, + ]); + + const [organizationTypeOptions, setOrganizationTypeOptions] = useState([ + { id: "1", label: "Non-profit", state: "Unselected" as const }, + { id: "2", label: "For-profit", state: "Unselected" as const }, + { id: "3", label: "Community", state: "Unselected" as const }, + { id: "4", label: "Educational", state: "Unselected" as const }, + ]); + + const [governanceStyleOptions, setGovernanceStyleOptions] = useState([ + { id: "1", label: "Democratic", state: "Unselected" as const }, + { id: "2", label: "Consensus", state: "Unselected" as const }, + { id: "3", label: "Hierarchical", state: "Unselected" as const }, + { id: "4", label: "Flat", state: "Unselected" as const }, + ]); + + const handleCommunitySizeClick = (chipId: string) => { + setCommunitySizeOptions((prev) => + prev.map((opt) => + opt.id === chipId + ? { ...opt, state: opt.state === "Selected" ? "Unselected" : "Selected" } + : opt + ) + ); + }; + + const handleOrganizationTypeClick = (chipId: string) => { + setOrganizationTypeOptions((prev) => + prev.map((opt) => + opt.id === chipId + ? { ...opt, state: opt.state === "Selected" ? "Unselected" : "Selected" } + : opt + ) + ); + }; + + const handleGovernanceStyleClick = (chipId: string) => { + setGovernanceStyleOptions((prev) => + prev.map((opt) => + opt.id === chipId + ? { ...opt, state: opt.state === "Selected" ? "Unselected" : "Selected" } + : opt + ) + ); + }; + + return ( +
+ {isMdOrLarger ? ( + // Two-column layout for 640px+ +
+ {/* Left column: HeaderLockup */} +
+ +
+ + {/* Right column: Three MultiSelect components */} +
+ + + +
+
+ ) : ( + // Single column layout below 640px +
+ {/* HeaderLockup */} + + + {/* Three MultiSelect components */} + + + +
+ )} +
+ ); +} diff --git a/app/create/upload/page.tsx b/app/create/upload/page.tsx new file mode 100644 index 0000000..cdef335 --- /dev/null +++ b/app/create/upload/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useMediaQuery } from "../../hooks/useMediaQuery"; +import HeaderLockup from "../../components/type/HeaderLockup"; +import Upload from "../../components/controls/Upload"; + +/** + * Upload page for the create flow + * + * Displays upload functionality using HeaderLockup and Upload components. + * Responsive layout: centered at 640px+, left-aligned below 640px. + * Responsive sizing: uses L/M for HeaderLockup based on 640px breakpoint. + */ +export default function UploadPage() { + const isMdOrLarger = useMediaQuery("(min-width: 640px)"); + + const handleUploadClick = () => { + // Handle upload button click + console.log("Upload clicked"); + }; + + return ( +
+
+ {/* HeaderLockup: Center justification at 640px+, left below 640px */} + + + {/* Upload component: no label in create flow, max width 474px */} +
+ +
+
+
+ ); +} diff --git a/app/hooks/useMediaQuery.ts b/app/hooks/useMediaQuery.ts index 8f6c134..c298c09 100644 --- a/app/hooks/useMediaQuery.ts +++ b/app/hooks/useMediaQuery.ts @@ -42,23 +42,18 @@ export function useMediaQuery( mediaQuery = query; } - // Initialize state with current match if available (SSR safety) - const [matches, setMatches] = useState(() => { - if (typeof window === "undefined") { - return false; - } - return window.matchMedia(mediaQuery).matches; - }); + // Always start with false so server and first client render match (avoids hydration mismatch). + // Real value is set in useEffect after mount. + const [matches, setMatches] = useState(false); useEffect(() => { - // Check if window is available (SSR safety) if (typeof window === "undefined") { return; } const media = window.matchMedia(mediaQuery); + setMatches(media.matches); - // Create listener for changes const listener = (event: MediaQueryListEvent) => { setMatches(event.matches); }; diff --git a/stories/controls/Upload.stories.js b/stories/controls/Upload.stories.js new file mode 100644 index 0000000..e8ef585 --- /dev/null +++ b/stories/controls/Upload.stories.js @@ -0,0 +1,129 @@ +import React from "react"; +import Upload from "../../app/components/controls/Upload"; + +export default { + title: "Components/Controls/Upload", + component: Upload, + parameters: { + layout: "centered", + docs: { + description: { + component: + "An upload component with active/inactive states. Displays a label, upload button with icon, and description text.", + }, + }, + }, + argTypes: { + active: { + control: { type: "boolean" }, + description: "Whether the upload component is in active state", + }, + label: { + control: { type: "text" }, + description: "Label text displayed above the upload component", + }, + showHelpIcon: { + control: { type: "boolean" }, + description: "Whether to show help icon next to label", + }, + onClick: { action: "clicked" }, + }, + tags: ["autodocs"], +}; + +const Template = (args) => ; + +// Default story +export const Default = Template.bind({}); +Default.args = { + label: "Upload", + active: true, + showHelpIcon: true, +}; + +// Active state +export const Active = Template.bind({}); +Active.args = { + label: "Upload", + active: true, + showHelpIcon: true, +}; +Active.parameters = { + docs: { + description: { + story: "Upload component in active state with white button and black text.", + }, + }, +}; + +// Inactive state +export const Inactive = Template.bind({}); +Inactive.args = { + label: "Upload", + active: false, + showHelpIcon: true, +}; +Inactive.parameters = { + docs: { + description: { + story: "Upload component in inactive state with dark button and gray text.", + }, + }, +}; + +// Without help icon +export const WithoutHelpIcon = Template.bind({}); +WithoutHelpIcon.args = { + label: "Upload", + active: true, + showHelpIcon: false, +}; +WithoutHelpIcon.parameters = { + docs: { + description: { + story: "Upload component without help icon.", + }, + }, +}; + +// Without label +export const WithoutLabel = Template.bind({}); +WithoutLabel.args = { + active: true, + showHelpIcon: false, +}; +WithoutLabel.parameters = { + docs: { + description: { + story: "Upload component without label.", + }, + }, +}; + +// Custom label +export const CustomLabel = Template.bind({}); +CustomLabel.args = { + label: "Upload Files", + active: true, + showHelpIcon: true, +}; +CustomLabel.parameters = { + docs: { + description: { + story: "Upload component with custom label text.", + }, + }, +}; + +// All states comparison +export const AllStates = () => ( +
+
+

Upload States

+
+ + +
+
+
+); diff --git a/tests/components/Upload.test.tsx b/tests/components/Upload.test.tsx new file mode 100644 index 0000000..e146d13 --- /dev/null +++ b/tests/components/Upload.test.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom/vitest"; +import Upload from "../../app/components/controls/Upload"; +import { componentTestSuite } from "../utils/componentTestSuite"; + +type UploadProps = React.ComponentProps; + +componentTestSuite({ + component: Upload, + name: "Upload", + props: { + label: "Upload", + active: true, + } as UploadProps, + requiredProps: [], + optionalProps: { + label: "Upload", + active: true, + showHelpIcon: true, + }, + primaryRole: "button", + testCases: { + renders: true, + accessibility: true, + keyboardNavigation: true, + }, +}); + +describe("Upload (behavioral tests)", () => { + it("renders with active state by default", () => { + render(); + const button = screen.getByRole("button", { name: /upload/i }); + expect(button).toHaveClass("bg-[var(--color-surface-invert-primary,white)]"); + }); + + it("renders with inactive state when active is false", () => { + render(); + const button = screen.getByRole("button", { name: /upload/i }); + expect(button).toHaveClass("bg-[var(--color-surface-default-secondary,#141414)]"); + }); + + it("displays label when provided", () => { + render(); + expect(screen.getByText("Upload files")).toBeInTheDocument(); + }); + + it("does not display label when not provided", () => { + const { container } = render(); + const label = container.querySelector('[data-name="utility/Input label"]'); + expect(label).not.toBeInTheDocument(); + }); + + it("shows help icon when showHelpIcon is true", () => { + render(); + const helpIcon = screen.getByAltText("Help"); + expect(helpIcon).toBeInTheDocument(); + }); + + it("hides help icon when showHelpIcon is false", () => { + render(); + const helpIcon = screen.queryByAltText("Help"); + expect(helpIcon).not.toBeInTheDocument(); + }); + + it("calls onClick when upload button is clicked", async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + render(); + const button = screen.getByRole("button", { name: /upload/i }); + await user.click(button); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("displays description text", () => { + render(); + expect(screen.getByText(/Add images, PDFs, and other files to the policy/i)).toBeInTheDocument(); + }); + + it("applies active state styles correctly", () => { + render(); + const descriptionText = screen.getByText(/Add images, PDFs, and other files to the policy/i); + const descriptionContainer = descriptionText.parentElement; + expect(descriptionContainer).toHaveClass("text-[color:var(--color-content-default-primary,white)]"); + }); + + it("applies inactive state styles correctly", () => { + render(); + const descriptionText = screen.getByText(/Add images, PDFs, and other files to the policy/i); + const descriptionContainer = descriptionText.parentElement; + expect(descriptionContainer).toHaveClass("text-[color:var(--color-content-default-tertiary,#b4b4b4)]"); + }); +});