diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index 3635324..aa1cd99 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -1,20 +1,40 @@ "use client"; import { useState } from "react"; -import TextInput from "../components/TextInput"; -import Checkbox from "../components/Checkbox"; -import CheckboxGroup from "../components/CheckboxGroup"; -import RadioGroup from "../components/RadioGroup"; +import RuleCard from "../components/RuleCard"; +import Image from "next/image"; +import { getAssetPath } from "../../lib/assetUtils"; export default function ComponentsPreview() { - const [defaultInputValue, setDefaultInputValue] = useState(""); - const [activeInputValue, setActiveInputValue] = useState(""); - const [errorInputValue, setErrorInputValue] = useState(""); - const [standardCheckbox, setStandardCheckbox] = useState(false); - const [inverseCheckbox, setInverseCheckbox] = useState(false); - const [checkboxGroupValues, setCheckboxGroupValues] = useState([]); - const [radioValue, setRadioValue] = useState(""); - const [inverseRadioValue, setInverseRadioValue] = useState(""); + const [expandedCard, setExpandedCard] = useState(null); + + const sampleCategories = [ + { + name: "Values", + items: ["Consciousness", "Ecology", "Abundance", "Art", "Decisiveness"], + createUrl: "/create/value", + }, + { + name: "Communication", + items: ["Signal"], + createUrl: "/create/communication", + }, + { + name: "Membership", + items: ["Open Admission"], + createUrl: "/create/membership", + }, + { + name: "Decision-making", + items: ["Lazy Consensus", "Modified Consensus"], + createUrl: "/create/decision-making", + }, + { + name: "Conflict management", + items: ["Code of Conduct", "Restorative Justice"], + createUrl: "/create/conflict-management", + }, + ]; return (
@@ -24,236 +44,165 @@ export default function ComponentsPreview() { Component Preview

- Temporary page for viewing and testing new components + RuleCard component examples - collapsed/expanded states, size variants, and interactions

- {/* Text Input Section */} + {/* Collapsed State - Large */}

- Text Input Component + Collapsed State - Large (L)

-
-
-
-

- States -

-
- setDefaultInputValue(e.target.value)} - /> - setActiveInputValue(e.target.value)} - /> - - setErrorInputValue(e.target.value)} - error - /> -
-
+ console.log("Card clicked: Mutual Aid Mondays")} + /> +
+
+ {/* Collapsed State - Medium */} +
+

+ Collapsed State - Medium (M) +

+
+ console.log("Card clicked: Mutual Aid Mondays")} + /> +
+
+ + {/* Expanded State - Large */} +
+

+ Expanded State - Large (L) +

+
+ { + console.log(`Pill clicked: ${category} - ${item}`); + }} + onCreateClick={(category) => { + console.log(`Create clicked: ${category}`); + }} + onClick={() => console.log("Card clicked: Mutual Aid Mondays")} + /> +
+
+ + {/* Expanded State - Medium */} +
+

+ Expanded State - Medium (M) +

+
+ { + console.log(`Pill clicked: ${category} - ${item}`); + }} + onCreateClick={(category) => { + console.log(`Create clicked: ${category}`); + }} + onClick={() => console.log("Card clicked: Mutual Aid Mondays")} + /> +
+
+ + {/* Different Background Colors */} +
+

+ Different Background Colors +

+
+
+ + } + onClick={() => console.log("Consensus clusters selected")} + /> + + } + onClick={() => console.log("Consensus selected")} + />
- {/* Checkbox Section */} + {/* Logo Fallback */}

- Checkbox Component + Logo Fallback (Community Initials)

-
-
-
-

- Standard Mode -

-
- setStandardCheckbox(checked)} - /> -
-
-
-

- Inverse Mode -

-
- setInverseCheckbox(checked)} - /> -
-
-
-
-
- - {/* Checkbox Group Section */} -
-

- Checkbox Group Component -

- -
-
-
-

- Standard Mode -

-
- setCheckboxGroupValues(value)} - mode="standard" - options={[ - { value: "option1", label: "Checkbox label" }, - { - value: "option2", - label: "Checkbox label", - subtext: "Nunc sed hendrerit consequat.", - }, - ]} - /> -
-
-
-

- Inverse Mode -

-
- setCheckboxGroupValues(value)} - mode="inverse" - options={[ - { value: "option3", label: "Checkbox label" }, - { - value: "option4", - label: "Checkbox label", - subtext: "Nunc sed hendrerit consequat.", - }, - ]} - /> -
-
-
-
-
- - {/* Radio Group Section */} -
-

- Radio Group Component -

- -
-
-
-

- Standard Mode -

-
- setRadioValue(value)} - mode="standard" - options={[ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - { value: "option3", label: "Option 3" }, - ]} - /> - setRadioValue(value)} - mode="standard" - options={[ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - { value: "option3", label: "Option 3" }, - ]} - /> - -
-
-
-

- Inverse Mode -

-
- setInverseRadioValue(value)} - mode="inverse" - options={[ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - { value: "option3", label: "Option 3" }, - ]} - /> - setInverseRadioValue(value)} - mode="inverse" - options={[ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - { value: "option3", label: "Option 3" }, - ]} - /> - -
-
-
+ console.log("Community Example selected")} + />
diff --git a/app/components/RuleCard/RuleCard.container.tsx b/app/components/RuleCard/RuleCard.container.tsx index fdfaa6f..855bbb7 100644 --- a/app/components/RuleCard/RuleCard.container.tsx +++ b/app/components/RuleCard/RuleCard.container.tsx @@ -3,6 +3,7 @@ import { memo } from "react"; import { RuleCardView } from "./RuleCard.view"; import type { RuleCardProps } from "./RuleCard.types"; +import { normalizeRuleCardSize } from "../../../lib/propNormalization"; declare global { interface Window { @@ -25,7 +26,18 @@ const RuleCardContainer = memo( backgroundColor = "bg-[var(--color-community-teal-100)]", className = "", onClick, + expanded = false, + size: sizeProp, + categories, + onPillClick, + onCreateClick, + logoUrl, + logoAlt, + communityInitials, }) => { + // Normalize size prop + const size = normalizeRuleCardSize(sizeProp, "L"); + const handleClick = () => { // Basic analytics event tracking if (typeof window !== "undefined" && window.gtag) { @@ -62,6 +74,14 @@ const RuleCardContainer = memo( className={className} onClick={handleClick} onKeyDown={handleKeyDown} + expanded={expanded} + size={size} + categories={categories} + onPillClick={onPillClick} + onCreateClick={onCreateClick} + logoUrl={logoUrl} + logoAlt={logoAlt} + communityInitials={communityInitials} /> ); }, diff --git a/app/components/RuleCard/RuleCard.types.ts b/app/components/RuleCard/RuleCard.types.ts index 9776e18..ab5c367 100644 --- a/app/components/RuleCard/RuleCard.types.ts +++ b/app/components/RuleCard/RuleCard.types.ts @@ -1,3 +1,9 @@ +export interface Category { + name: string; + items: string[]; + createUrl?: string; +} + export interface RuleCardProps { title: string; description?: string; @@ -5,6 +11,14 @@ export interface RuleCardProps { backgroundColor?: string; className?: string; onClick?: () => void; + expanded?: boolean; + size?: "L" | "M" | "l" | "m"; + categories?: Category[]; + onPillClick?: (category: string, item: string) => void; + onCreateClick?: (category: string) => void; + logoUrl?: string; + logoAlt?: string; + communityInitials?: string; } export interface RuleCardViewProps { @@ -15,4 +29,12 @@ export interface RuleCardViewProps { className: string; onClick: () => void; onKeyDown: (_event: React.KeyboardEvent) => void; + expanded: boolean; + size: "L" | "M"; + categories?: Category[]; + onPillClick?: (category: string, item: string) => void; + onCreateClick?: (category: string) => void; + logoUrl?: string; + logoAlt?: string; + communityInitials?: string; } diff --git a/app/components/RuleCard/RuleCard.view.tsx b/app/components/RuleCard/RuleCard.view.tsx index 0eacce0..ed9edcf 100644 --- a/app/components/RuleCard/RuleCard.view.tsx +++ b/app/components/RuleCard/RuleCard.view.tsx @@ -1,5 +1,6 @@ "use client"; +import Image from "next/image"; import { useTranslation } from "../../contexts/MessagesContext"; import type { RuleCardViewProps } from "./RuleCard.types"; @@ -11,40 +12,210 @@ export function RuleCardView({ className, onClick, onKeyDown, + expanded, + size, + categories, + onPillClick, + onCreateClick, + logoUrl, + logoAlt, + communityInitials, }: RuleCardViewProps) { const t = useTranslation("ruleCard"); const ariaLabel = t("ariaLabel").replace("{title}", title); + // Size-based styling + const isLarge = size === "L"; + + // Card dimensions - make width flexible for grid layouts, but can be overridden via className + // For standalone/preview use, add fixed width via className + const cardPadding = isLarge ? "p-[24px]" : "p-[16px]"; + const cardGap = expanded + ? "gap-[16px]" + : isLarge ? "gap-[10px]" : "gap-[12px]"; + + // Logo/Icon dimensions + const logoSize = isLarge ? 103 : 56; + const logoContainerClass = isLarge + ? "size-[103px]" + : "size-[56px]"; + + // Title typography + const titleClass = isLarge + ? "font-bricolage-grotesque font-extrabold text-[36px] leading-[44px]" + : "font-bricolage-grotesque font-bold text-[24px] leading-[32px]"; + + // Description typography + const descriptionClass = isLarge + ? "font-inter font-medium text-[18px] leading-[24px]" + : "font-inter font-medium text-[14px] leading-[16px]"; + + // Category label typography + const categoryLabelClass = "font-inter font-normal text-[14px] leading-[20px]"; + + // Pill typography + const pillTextClass = "font-inter font-medium text-[12px] leading-[14px]"; + + // Render logo/icon + const renderLogo = () => { + if (logoUrl) { + // Check if it's a localhost URL or external URL that needs regular img tag + const isLocalhost = logoUrl.startsWith("http://localhost") || logoUrl.startsWith("https://localhost"); + + if (isLocalhost) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {logoAlt +
+ ); + } + + return ( +
+ {logoAlt +
+ ); + } + + if (icon) { + return ( +
+ {icon} +
+ ); + } + + if (communityInitials) { + return ( +
+ + {communityInitials} + +
+ ); + } + + return null; + }; + + // Handle pill click with stopPropagation + const handlePillClick = (e: React.MouseEvent, categoryName: string, item: string) => { + e.stopPropagation(); + if (onPillClick) { + onPillClick(categoryName, item); + } + }; + + // Handle create button click with stopPropagation + const handleCreateClick = (e: React.MouseEvent, categoryName: string) => { + e.stopPropagation(); + if (onCreateClick) { + onCreateClick(categoryName); + } + }; + return (
{/* Header Container */} -
- {/* Icon Container */} - {icon && ( -
- {icon} +
+ {/* Logo/Icon Container */} + {renderLogo() && ( +
+ {renderLogo()}
)} {/* Title Container */} {title && ( -
-

+
+

{title}

)}

- {description && ( -

- {description} -

+ + {expanded ? ( + <> + {/* Categories Section */} + {categories && categories.length > 0 && ( +
+ {categories.map((category, categoryIndex) => ( +
+ {/* Category Label */} +
+

+ {category.name} +

+
+ {/* Pills Container */} +
+ {/* Pills */} + {category.items && category.items.map((item, itemIndex) => ( + + ))} + {/* Add Button */} + +
+
+ ))} +
+ )} + {/* Footer: Description */} + {description && ( +
+

+ {description} +

+
+ )} + + ) : ( + /* Collapsed State: Description */ + description && ( +
+

+ {description} +

+
+ ) )}
); diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index da0d570..b063b73 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -512,3 +512,18 @@ export function normalizeSmallMediumLargeSize( } return defaultValue; } + +/** + * Normalize RuleCard size prop values (L/M -> l/m -> L/M) + */ +export function normalizeRuleCardSize( + value: string | undefined, + defaultValue: "L" = "L" +): "L" | "M" { + if (!value) return defaultValue; + const normalized = value.toUpperCase(); + if (normalized === "L" || normalized === "M") { + return normalized; + } + return defaultValue; +} diff --git a/next.config.mjs b/next.config.mjs index 242f0c1..c9a22d6 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -16,6 +16,14 @@ const nextConfig = { minimumCacheTTL: 60, dangerouslyAllowSVG: true, contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", + remotePatterns: [ + { + protocol: "http", + hostname: "localhost", + port: "", + pathname: "/**", + }, + ], }, // Headers for caching async headers() { diff --git a/stories/RuleCard.stories.js b/stories/RuleCard.stories.js index bf52c35..f79e327 100644 --- a/stories/RuleCard.stories.js +++ b/stories/RuleCard.stories.js @@ -9,7 +9,7 @@ export default { docs: { description: { component: - "An interactive card component that displays governance templates and decision-making patterns. Features hover states, keyboard navigation, analytics tracking, and accessibility support. Use Tab key to test focus indicators and Enter/Space to activate.", + "An interactive card component that displays governance templates and decision-making patterns. Features collapsed/expanded states, size variants (L/M), category sections with pills and + buttons, hover states, keyboard navigation, analytics tracking, and accessibility support. Use Tab key to test focus indicators and Enter/Space to activate.", }, }, }, @@ -33,7 +33,18 @@ export default { ], description: "The background color variant for the card", }, + expanded: { + control: { type: "boolean" }, + description: "Whether the card is in expanded state", + }, + size: { + control: { type: "select" }, + options: ["L", "M", "l", "m"], + description: "Size variant of the card", + }, onClick: { action: "clicked" }, + onPillClick: { action: "pillClicked" }, + onCreateClick: { action: "createClicked" }, }, tags: ["autodocs"], }; @@ -44,6 +55,8 @@ export const Default = { description: "Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.", backgroundColor: "bg-[var(--color-surface-default-brand-lime)]", + expanded: false, + size: "L", icon: ( Sociocracy + ), + }, +}; + +export const SizeMedium = { + args: { + title: "Consensus clusters", + description: + "Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.", + backgroundColor: "bg-[var(--color-surface-default-brand-lime)]", + expanded: false, + size: "M", + icon: ( + Sociocracy + ), + }, +}; + +export const ExpandedMedium = { + args: { + title: "Mutual Aid Mondays", + description: + "Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.", + backgroundColor: "bg-[#b7d9d5]", + expanded: true, + size: "M", + logoUrl: "http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png", + logoAlt: "Mutual Aid Mondays", + categories: [ + { + name: "Values", + items: ["Consciousness", "Ecology", "Abundance", "Art", "Decisiveness"], + }, + { + name: "Communication", + items: ["Signal"], + }, + { + name: "Membership", + items: ["Open Admission"], + }, + { + name: "Decision-making", + items: ["Lazy Consensus", "Modified Consensus"], + }, + { + name: "Conflict management", + items: ["Code of Conduct", "Restorative Justice"], + }, + ], + }, +}; + +export const WithLogoFallback = { + args: { + title: "Community Example", + description: + "This card shows the logo fallback with community initials when no logo is provided.", + backgroundColor: "bg-[var(--color-surface-default-brand-teal)]", + expanded: false, + size: "L", + communityInitials: "CE", + }, +}; + export const AllVariants = { // eslint-disable-next-line no-unused-vars render: (_args) => ( @@ -136,19 +274,37 @@ export const InteractiveStates = { args: { title: "Interactive Demo", description: - "Hover over this card to see the scale and shadow effects. Use Tab to focus and Enter/Space to activate.", + "Hover over this card to see the scale and shadow effects. Use Tab to focus and Enter/Space to activate. Click pills and + buttons to see event handlers.", backgroundColor: "bg-[var(--color-community-teal-100)]", + expanded: true, + size: "L", icon: ( -
- ? +
+ ?
), + categories: [ + { + name: "Values", + items: ["Consciousness", "Ecology", "Abundance"], + }, + { + name: "Communication", + items: ["Signal"], + }, + ], + onPillClick: (category, item) => { + console.log(`Pill clicked: ${category} - ${item}`); + }, + onCreateClick: (category) => { + console.log(`Create clicked: ${category}`); + }, }, parameters: { docs: { description: { story: - "Demonstrates interactive states including hover effects, focus indicators, and keyboard navigation. Test with mouse hover and keyboard Tab/Enter/Space.", + "Demonstrates interactive states including hover effects, focus indicators, keyboard navigation, and pill/+ button interactions. Test with mouse hover, keyboard Tab/Enter/Space, and click pills/+ buttons.", }, }, },