From 769fc8e7c6bb7f5a5aef96f451bd496ec56b419a Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:19:27 -0700 Subject: [PATCH 1/9] Update Rule Card component --- app/components-preview/page.tsx | 401 ++++++++---------- .../RuleCard/RuleCard.container.tsx | 20 + app/components/RuleCard/RuleCard.types.ts | 22 + app/components/RuleCard/RuleCard.view.tsx | 195 ++++++++- lib/propNormalization.ts | 15 + next.config.mjs | 8 + stories/RuleCard.stories.js | 166 +++++++- 7 files changed, 584 insertions(+), 243 deletions(-) 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.", }, }, }, -- 2.43.0 From 8ba11070d3881cd8720e9acf1a4ee802e565ed9d Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:02:15 -0700 Subject: [PATCH 2/9] Create chip component --- app/components-preview/page.tsx | 186 +++++++++++++++- app/components/Chip/Chip.container.tsx | 98 +++++++++ app/components/Chip/Chip.types.ts | 71 ++++++ app/components/Chip/Chip.view.tsx | 287 +++++++++++++++++++++++++ app/components/Chip/index.tsx | 3 + lib/propNormalization.ts | 75 +++++++ 6 files changed, 719 insertions(+), 1 deletion(-) create mode 100644 app/components/Chip/Chip.container.tsx create mode 100644 app/components/Chip/Chip.types.ts create mode 100644 app/components/Chip/Chip.view.tsx create mode 100644 app/components/Chip/index.tsx diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index aa1cd99..e74b0a6 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -2,11 +2,32 @@ import { useState } from "react"; import RuleCard from "../components/RuleCard"; +import Chip from "../components/Chip"; import Image from "next/image"; import { getAssetPath } from "../../lib/assetUtils"; +interface ChipData { + id: string; + label: string; + state: "Unselected" | "Selected" | "Custom"; + palette: "Default" | "Inverse"; + size: "S" | "M"; +} + export default function ComponentsPreview() { const [expandedCard, setExpandedCard] = useState(null); + const [chipStates, setChipStates] = useState>({ + "default-s": "Unselected", + "default-m": "Unselected", + "inverse-s": "Unselected", + "inverse-m": "Unselected", + }); + + // Manage custom chips separately + const [customChips, setCustomChips] = useState([ + { id: "custom-1", label: "", state: "Custom", palette: "Default", size: "S" }, + { id: "custom-2", label: "", state: "Custom", palette: "Default", size: "M" }, + ]); const sampleCategories = [ { @@ -44,10 +65,173 @@ export default function ComponentsPreview() { Component Preview

- RuleCard component examples - collapsed/expanded states, size variants, and interactions + RuleCard and Chip component examples - states, palettes, sizes, and interactions

+ {/* Chip Component - Controls */} +
+

+ Chip Component (Controls) +

+
+ {/* Default palette */} +
+

+ Default palette +

+
+ + setChipStates((prev) => ({ + ...prev, + "default-s": prev["default-s"] === "Selected" ? "Unselected" : "Selected", + })) + } + /> + + setChipStates((prev) => ({ + ...prev, + "default-m": prev["default-m"] === "Selected" ? "Unselected" : "Selected", + })) + } + /> + + {customChips + .filter((chip) => chip.palette === "Default") + .map((chip) => ( + { + e.stopPropagation(); + setCustomChips((prev) => + prev.map((c) => + c.id === chip.id + ? { ...c, label: value, state: "Selected" as const } + : c + ) + ); + }} + onClose={(e) => { + e.stopPropagation(); + setCustomChips((prev) => prev.filter((c) => c.id !== chip.id)); + }} + onClick={(e) => { + e.stopPropagation(); + // Only toggle if the chip is in Selected or Unselected state (not Custom) + if (chip.state === "Selected" || chip.state === "Unselected") { + setCustomChips((prev) => + prev.map((c) => + c.id === chip.id + ? { + ...c, + state: c.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + } + : c + ) + ); + } + }} + /> + ))} + {/* Add new custom chip button - Ghost button style */} + +
+
+ + {/* Inverse palette - on white background */} +
+

+ Inverse palette (on white background) +

+
+
+ + setChipStates((prev) => ({ + ...prev, + "inverse-s": prev["inverse-s"] === "Selected" ? "Unselected" : "Selected", + })) + } + /> + + setChipStates((prev) => ({ + ...prev, + "inverse-m": prev["inverse-m"] === "Selected" ? "Unselected" : "Selected", + })) + } + /> + +
+
+
+
+
+ {/* Collapsed State - Large */}

diff --git a/app/components/Chip/Chip.container.tsx b/app/components/Chip/Chip.container.tsx new file mode 100644 index 0000000..d26acec --- /dev/null +++ b/app/components/Chip/Chip.container.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { memo, useState, useEffect, useRef } from "react"; +import ChipView from "./Chip.view"; +import type { ChipProps } from "./Chip.types"; +import { + normalizeChipPalette, + normalizeChipSize, + normalizeChipState, +} from "../../../lib/propNormalization"; + +const ChipContainer = memo( + ({ + label, + state: stateProp = "Unselected", + palette: paletteProp = "Default", + size: sizeProp = "S", + className = "", + disabled, + onClick, + onRemove, + onCheck, + onClose, + ariaLabel, + }) => { + const state = normalizeChipState(stateProp); + const palette = normalizeChipPalette(paletteProp); + const size = normalizeChipSize(sizeProp); + + const isDisabled = disabled ?? state === "disabled"; + const isCustom = state === "custom"; + + // Manage input value for custom state + const [inputValue, setInputValue] = useState(""); + const inputRef = useRef(null); + + // Focus input when custom state is active + useEffect(() => { + if (isCustom && inputRef.current) { + inputRef.current.focus(); + } + }, [isCustom]); + + const handleCheck = (value: string, event: React.MouseEvent) => { + if (onCheck && value.trim()) { + onCheck(value.trim(), event); + // Reset input after successful check + setInputValue(""); + } + }; + + const handleClose = (event: React.MouseEvent) => { + if (onClose) { + onClose(event); + } else if (onRemove) { + // Fallback to onRemove if onClose not provided + onRemove(event); + } + // Reset input value when closing + setInputValue(""); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && inputValue.trim() && onCheck) { + event.preventDefault(); + handleCheck(inputValue.trim(), event as unknown as React.MouseEvent); + } else if (event.key === "Escape" && onClose) { + event.preventDefault(); + handleClose(event as unknown as React.MouseEvent); + } + }; + + return ( + + ); + }, +); + +ChipContainer.displayName = "Chip"; + +export default ChipContainer; + diff --git a/app/components/Chip/Chip.types.ts b/app/components/Chip/Chip.types.ts new file mode 100644 index 0000000..0a240b3 --- /dev/null +++ b/app/components/Chip/Chip.types.ts @@ -0,0 +1,71 @@ +import type { + ChipPaletteValue, + ChipSizeValue, + ChipStateValue, +} from "../../../lib/propNormalization"; + +export interface ChipProps { + label: string; + /** + * Visual state of the chip, aligned with Figma: + * - "Unselected" + * - "Selected" + * - "Disabled" + * - "Custom" (editable chips with check/close buttons) + * + * Accepts both PascalCase (Figma) and lowercase values. + */ + state?: ChipStateValue; + /** + * Palette of the chip, aligned with Figma: + * - "Default" + * - "Inverse" + * + * Accepts both PascalCase (Figma) and lowercase values. + */ + palette?: ChipPaletteValue; + /** + * Size of the chip, aligned with Figma: + * - "S" + * - "M" + * + * Accepts both uppercase (Figma) and lowercase values. + */ + size?: ChipSizeValue; + className?: string; + disabled?: boolean; + onClick?: (event: React.MouseEvent) => void; + /** + * Optional remove/close handler for chips that can be dismissed. + */ + onRemove?: (event: React.MouseEvent) => void; + /** + * Optional check/confirm handler for custom state chips. + * Called with the input value when user confirms the input. + */ + onCheck?: (value: string, event: React.MouseEvent) => void; + /** + * Optional callback when custom chip is closed/removed. + */ + onClose?: (event: React.MouseEvent) => void; + ariaLabel?: string; +} + +export interface ChipViewProps { + label: string; + state: "unselected" | "selected" | "disabled" | "custom"; + palette: "default" | "inverse"; + size: "s" | "m"; + className?: string; + disabled?: boolean; + onClick?: (event: React.MouseEvent) => void; + onRemove?: (event: React.MouseEvent) => void; + onCheck?: (value: string, event: React.MouseEvent) => void; + onClose?: (event: React.MouseEvent) => void; + inputValue?: string; + onInputChange?: (value: string) => void; + onInputKeyDown?: (event: React.KeyboardEvent) => void; + inputRef?: React.RefObject; + ariaLabel?: string; +} + diff --git a/app/components/Chip/Chip.view.tsx b/app/components/Chip/Chip.view.tsx new file mode 100644 index 0000000..a8a91a1 --- /dev/null +++ b/app/components/Chip/Chip.view.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { memo } from "react"; +import type { ChipViewProps } from "./Chip.types"; + +function ChipView({ + label, + state, + palette, + size, + className = "", + disabled = false, + onClick, + onRemove, + onCheck, + onClose, + inputValue, + onInputChange, + onInputKeyDown, + inputRef, + ariaLabel, +}: ChipViewProps) { + const isDisabled = disabled || state === "disabled"; + const isSelected = state === "selected"; + const isCustom = state === "custom"; + + const isInverse = palette === "inverse"; + const isDefault = palette === "default"; + + const isSmall = size === "s"; + + // Size-based styles from Figma tokens + // Custom state has different padding + const sizeClasses = isCustom + ? isSmall + ? "px-[var(--measures-spacing-100,4px)] py-[3px] text-[length:var(--sizing-300,12px)] leading-[16px]" + : "px-[var(--measures-spacing-150,6px)] py-[10px] text-[length:var(--sizing-400,16px)] leading-[24px]" + : isSmall + ? "h-[30px] px-[var(--measures-spacing-200,8px)] gap-[var(--measures-spacing-050,2px)] text-[length:var(--sizing-300,12px)] leading-[14px]" + : "px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-300,12px)] gap-[var(--measures-spacing-150,6px)] text-[length:var(--sizing-400,16px)] leading-[20px]"; + + // Palette + state styling based on Figma examples + // Use consistent border width to prevent layout shift + const borderWidth = isSmall ? "border-[1.25px]" : "border-2"; + + let background = "bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]"; + let border = + `${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`; + let textColor = + "text-[color:var(--color-content-default-brand-primary,#fefcc9)]"; + + if (isDefault) { + if (state === "custom") { + background = + "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background for custom + border = "border-none"; + textColor = + "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"; + } else if (state === "disabled") { + background = + "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background + border = "border-none"; + textColor = + "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"; + } else if (isSelected) { + background = + "bg-[var(--color-surface-inverse-brandaccent,#fdfaa8)]"; // yellow selected + border = `${borderWidth} border-[var(--color-border-default-brand-primary,#fdfaa8)]`; + textColor = + "text-[color:var(--color-content-inverse-primary,black)]"; + } else { + // Unselected default + background = + "bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]"; + border = `${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`; + textColor = + "text-[color:var(--color-content-default-brand-primary,#fefcc9)]"; + } + } else if (isInverse) { + if (state === "disabled") { + background = + "bg-[var(--color-surface-inverse-tertiary,#d2d2d2)]"; + border = "border-none"; + textColor = + "text-[color:var(--color-content-inverse-primary,black)]"; + } else if (isSelected) { + background = + "bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.05))]"; + border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`; + textColor = + "text-[color:var(--color-content-inverse-primary,black)]"; + } else { + // Unselected / custom inverse + background = + "bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]"; + border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`; + textColor = + "text-[color:var(--color-content-inverse-primary,black)]"; + } + } + + const baseClasses = ` + inline-flex + items-center + justify-center + rounded-[var(--measures-radius-full,9999px)] + overflow-clip + box-border + focus:outline-none + focus-visible:ring-2 + focus-visible:ring-[var(--color-border-default-primary,#141414)] + focus-visible:ring-offset-2 + focus-visible:ring-offset-transparent + transition-[background,border-color,color,box-shadow,transform] + duration-200 + ease-in-out + ` + .trim() + .replace(/\s+/g, " "); + + const stateClasses = isDisabled + ? "cursor-not-allowed opacity-60" + : "cursor-pointer hover:scale-[1.02]"; + + const combinedClasses = [ + baseClasses, + sizeClasses, + background, + border, + textColor, + stateClasses, + className, + ] + .filter(Boolean) + .join(" "); + + const handleClick: React.MouseEventHandler = (event) => { + if (isDisabled) { + event.preventDefault(); + return; + } + onClick?.(event); + }; + + const sharedA11y = { + "aria-label": ariaLabel, + }; + + // Custom state rendering with check/close buttons + if (isCustom) { + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleClick(e as unknown as React.MouseEvent); + } + }} + {...sharedA11y} + > +
+ {/* Check button */} + {onCheck && ( + + )} + + {/* Input field */} +
+ onInputChange?.(e.target.value)} + onKeyDown={onInputKeyDown} + placeholder="Type to add" + className="bg-transparent border-none outline-none flex-1 min-w-0 font-inter font-normal text-[color:var(--color-content-default-tertiary,#b4b4b4)] placeholder:text-[color:var(--color-content-default-tertiary,#b4b4b4)]" + style={{ + fontSize: isSmall ? "var(--sizing-300,12px)" : "var(--sizing-400,16px)", + lineHeight: isSmall ? "16px" : "24px", + }} + onClick={(e) => e.stopPropagation()} + onFocus={(e) => e.stopPropagation()} + /> +
+ + {/* Close button */} + {onClose && ( + + )} +
+
+ ); + } + + // Regular state rendering + return ( + + )} + + ); +} + +ChipView.displayName = "ChipView"; + +export default memo(ChipView); + diff --git a/app/components/Chip/index.tsx b/app/components/Chip/index.tsx new file mode 100644 index 0000000..a6776a8 --- /dev/null +++ b/app/components/Chip/index.tsx @@ -0,0 +1,3 @@ +export { default } from "./Chip.container"; +export type { ChipProps } from "./Chip.types"; + diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index b063b73..cbb05e5 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -527,3 +527,78 @@ export function normalizeRuleCardSize( } return defaultValue; } + +/** + * Type helper for case-insensitive Chip state prop + */ +export type ChipStateValue = + | "unselected" + | "selected" + | "disabled" + | "custom" + | "Unselected" + | "Selected" + | "Disabled" + | "Custom"; + +/** + * Type helper for case-insensitive Chip palette prop + */ +export type ChipPaletteValue = + | "default" + | "inverse" + | "Default" + | "Inverse"; + +/** + * Type helper for case-insensitive Chip size prop + */ +export type ChipSizeValue = "s" | "m" | "S" | "M"; + +/** + * Normalize Chip state prop values (Unselected/Selected/Disabled/Custom) + */ +export function normalizeChipState( + value: string | undefined, + defaultValue: "unselected" = "unselected", +): "unselected" | "selected" | "disabled" | "custom" { + if (!value) return defaultValue; + const normalized = value.toLowerCase(); + const states = ["unselected", "selected", "disabled", "custom"]; + if (states.includes(normalized)) { + return normalized as typeof defaultValue; + } + return defaultValue; +} + +/** + * Normalize Chip palette prop values (Default/Inverse -> default/inverse) + */ +export function normalizeChipPalette( + value: string | undefined, + defaultValue: "default" = "default", +): "default" | "inverse" { + if (!value) return defaultValue; + const normalized = value.toLowerCase(); + const palettes = ["default", "inverse"]; + if (palettes.includes(normalized)) { + return normalized as typeof defaultValue; + } + return defaultValue; +} + +/** + * Normalize Chip size prop values (S/M -> s/m) + */ +export function normalizeChipSize( + value: string | undefined, + defaultValue: "s" = "s", +): "s" | "m" { + if (!value) return defaultValue; + const normalized = value.toLowerCase(); + const sizes = ["s", "m"]; + if (sizes.includes(normalized)) { + return normalized as typeof defaultValue; + } + return defaultValue; +} -- 2.43.0 From 3e935ecd9edbd44516b5eefe096e189bb45316f4 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:07:42 -0700 Subject: [PATCH 3/9] Create multi-select component --- app/components-preview/page.tsx | 82 +++++++++++ .../MultiSelect/MultiSelect.container.tsx | 44 ++++++ .../MultiSelect/MultiSelect.types.ts | 68 +++++++++ .../MultiSelect/MultiSelect.view.tsx | 132 ++++++++++++++++++ app/components/MultiSelect/index.tsx | 1 + lib/propNormalization.ts | 16 +++ 6 files changed, 343 insertions(+) create mode 100644 app/components/MultiSelect/MultiSelect.container.tsx create mode 100644 app/components/MultiSelect/MultiSelect.types.ts create mode 100644 app/components/MultiSelect/MultiSelect.view.tsx create mode 100644 app/components/MultiSelect/index.tsx diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index e74b0a6..2a68e17 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import RuleCard from "../components/RuleCard"; import Chip from "../components/Chip"; +import MultiSelect from "../components/MultiSelect"; import Image from "next/image"; import { getAssetPath } from "../../lib/assetUtils"; @@ -14,6 +15,73 @@ interface ChipData { size: "S" | "M"; } +// MultiSelect example component with state management +function MultiSelectExample({ size }: { size: "S" | "M" }) { + const [options, setOptions] = 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 handleChipClick = (chipId: string) => { + setOptions((prev) => + prev.map((opt) => + opt.id === chipId + ? { + ...opt, + state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + } + : opt + ) + ); + }; + + const handleAddClick = () => { + const newId = `custom-${Date.now()}`; + setOptions((prev) => [ + ...prev, + { id: newId, label: "", state: "Custom" as const }, + ]); + }; + + const handleCustomConfirm = (chipId: string, value: string) => { + setOptions((prev) => + prev.map((opt) => + opt.id === chipId + ? { ...opt, label: value, state: "Selected" as const } + : opt + ) + ); + }; + + const handleCustomClose = (chipId: string) => { + setOptions((prev) => prev.filter((opt) => opt.id !== chipId)); + }; + + return ( +
+

+ {size === "S" ? "Small (S)" : "Medium (M)"} +

+ +
+ ); +} + export default function ComponentsPreview() { const [expandedCard, setExpandedCard] = useState(null); const [chipStates, setChipStates] = useState>({ @@ -389,6 +457,20 @@ export default function ComponentsPreview() { />

+ + {/* MultiSelect Component */} +
+

+ MultiSelect Component (Controls) +

+
+ {/* Small size */} + + + {/* Medium size */} + +
+
); diff --git a/app/components/MultiSelect/MultiSelect.container.tsx b/app/components/MultiSelect/MultiSelect.container.tsx new file mode 100644 index 0000000..086498e --- /dev/null +++ b/app/components/MultiSelect/MultiSelect.container.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { memo } from "react"; +import MultiSelectView from "./MultiSelect.view"; +import type { MultiSelectProps } from "./MultiSelect.types"; +import { normalizeMultiSelectSize } from "../../../lib/propNormalization"; + +const MultiSelectContainer = memo( + ({ + label, + showHelpIcon = true, + size: sizeProp = "M", + options, + onChipClick, + onAddClick, + showAddButton = true, + addButtonText = "Add organization type", + onCustomChipConfirm, + onCustomChipClose, + className = "", + }) => { + const size = normalizeMultiSelectSize(sizeProp); + + return ( + + ); + }, +); + +MultiSelectContainer.displayName = "MultiSelect"; + +export default MultiSelectContainer; diff --git a/app/components/MultiSelect/MultiSelect.types.ts b/app/components/MultiSelect/MultiSelect.types.ts new file mode 100644 index 0000000..38c39a3 --- /dev/null +++ b/app/components/MultiSelect/MultiSelect.types.ts @@ -0,0 +1,68 @@ +import type { ChipStateValue, ChipSizeValue } from "../../../lib/propNormalization"; + +export interface ChipOption { + id: string; + label: string; + state?: ChipStateValue; +} + +export type MultiSelectSizeValue = "S" | "M" | "s" | "m"; + +export interface MultiSelectProps { + /** + * Label for the multi-select component + */ + label?: string; + /** + * Show help icon next to label + */ + showHelpIcon?: boolean; + /** + * Size variant: "S" (small) or "M" (medium) + * Accepts both uppercase (Figma) and lowercase values. + */ + size?: MultiSelectSizeValue; + /** + * Array of chip options to display + */ + options: ChipOption[]; + /** + * Callback when a chip is clicked (toggled) + */ + onChipClick?: (chipId: string) => void; + /** + * Callback when add button is clicked + */ + onAddClick?: () => void; + /** + * Show the add button + */ + showAddButton?: boolean; + /** + * Text for the add button + */ + addButtonText?: string; + /** + * Callback when a custom chip is confirmed (check button clicked) + */ + onCustomChipConfirm?: (chipId: string, value: string) => void; + /** + * Callback when a custom chip is closed/removed + */ + onCustomChipClose?: (chipId: string) => void; + className?: string; +} + +export interface MultiSelectViewProps { + label?: string; + showHelpIcon: boolean; + size: "s" | "m"; + options: ChipOption[]; + onChipClick?: (chipId: string) => void; + onAddClick?: () => void; + showAddButton: boolean; + addButtonText: string; + onCustomChipConfirm?: (chipId: string, value: string) => void; + onCustomChipClose?: (chipId: string) => void; + className: string; +} diff --git a/app/components/MultiSelect/MultiSelect.view.tsx b/app/components/MultiSelect/MultiSelect.view.tsx new file mode 100644 index 0000000..a526e68 --- /dev/null +++ b/app/components/MultiSelect/MultiSelect.view.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { memo } from "react"; +import { getAssetPath, ASSETS } from "../../../lib/assetUtils"; +import Chip from "../Chip"; +import type { MultiSelectViewProps } from "./MultiSelect.types"; + +function MultiSelectView({ + label, + showHelpIcon, + size, + options, + onChipClick, + onAddClick, + showAddButton, + addButtonText, + onCustomChipConfirm, + onCustomChipClose, + className, +}: MultiSelectViewProps) { + const isSmall = size === "s"; + + // Size-based spacing and typography + const gapClass = isSmall + ? "gap-[var(--measures-spacing-200,8px)]" + : "gap-[var(--measures-spacing-300,12px)]"; + + const labelGapClass = isSmall + ? "gap-[var(--measures-spacing-200,4px_8px)]" + : "gap-[4px]"; + + const labelTextSize = isSmall + ? "text-[length:var(--sizing-350,14px)] leading-[20px]" + : "text-[length:var(--sizing-400,16px)] leading-[24px]"; + + const helpIconSize = isSmall ? "size-[12px]" : "size-[16px]"; + + const chipSize = isSmall ? "S" : "M"; + + return ( +
+ {/* Label with help icon */} + {label && ( +
+
+ + {showHelpIcon && ( +
+ Help +
+ )} +
+
+ )} + + {/* Chips container */} +
+ {options.map((option) => ( + { + // Only toggle if not in Custom state + if (option.state !== "Custom" && onChipClick) { + onChipClick(option.id); + } + }} + onCheck={(value, e) => { + e.stopPropagation(); + if (onCustomChipConfirm) { + onCustomChipConfirm(option.id, value); + } + }} + onClose={(e) => { + e.stopPropagation(); + if (onCustomChipClose) { + onCustomChipClose(option.id); + } + }} + /> + ))} + + {/* Add button - Ghost button style */} + {showAddButton && ( + + )} +
+
+ ); +} + +MultiSelectView.displayName = "MultiSelectView"; + +export default memo(MultiSelectView); diff --git a/app/components/MultiSelect/index.tsx b/app/components/MultiSelect/index.tsx new file mode 100644 index 0000000..036a4e4 --- /dev/null +++ b/app/components/MultiSelect/index.tsx @@ -0,0 +1 @@ +export { default } from "./MultiSelect.container"; diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index cbb05e5..ea8600e 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -602,3 +602,19 @@ export function normalizeChipSize( } return defaultValue; } + +/** + * Normalize MultiSelect size prop values (S/M -> s/m) + */ +export function normalizeMultiSelectSize( + value: string | undefined, + defaultValue: "m" = "m", +): "s" | "m" { + if (!value) return defaultValue; + const normalized = value.toLowerCase(); + const sizes = ["s", "m"]; + if (sizes.includes(normalized)) { + return normalized as typeof defaultValue; + } + return defaultValue; +} -- 2.43.0 From 0dedebfaf872de9fb6404a1acf6a9da68d6ab014 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:25:42 -0700 Subject: [PATCH 4/9] Create InputLabel component and add RuleCard chips --- app/components-preview/page.tsx | 367 ++++++++++++++++-- .../InputLabel/InputLabel.container.tsx | 40 ++ app/components/InputLabel/InputLabel.types.ts | 44 +++ app/components/InputLabel/InputLabel.view.tsx | 106 +++++ app/components/InputLabel/index.tsx | 3 + .../MultiSelect/MultiSelect.container.tsx | 5 +- .../MultiSelect/MultiSelect.types.ts | 8 +- .../MultiSelect/MultiSelect.view.tsx | 66 ++-- .../RuleCard/RuleCard.container.tsx | 4 - app/components/RuleCard/RuleCard.types.ts | 13 +- app/components/RuleCard/RuleCard.view.tsx | 77 ++-- lib/propNormalization.ts | 32 ++ 12 files changed, 638 insertions(+), 127 deletions(-) create mode 100644 app/components/InputLabel/InputLabel.container.tsx create mode 100644 app/components/InputLabel/InputLabel.types.ts create mode 100644 app/components/InputLabel/InputLabel.view.tsx create mode 100644 app/components/InputLabel/index.tsx diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index 2a68e17..f7ba62f 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -97,33 +97,360 @@ export default function ComponentsPreview() { { id: "custom-2", label: "", state: "Custom", palette: "Default", size: "M" }, ]); - const sampleCategories = [ + // RuleCard categories with chip options and state management + const [ruleCardCategories, setRuleCardCategories] = useState([ { name: "Values", - items: ["Consciousness", "Ecology", "Abundance", "Art", "Decisiveness"], - createUrl: "/create/value", + chipOptions: [ + { id: "values-1", label: "Consciousness", state: "Unselected" as const }, + { id: "values-2", label: "Ecology", state: "Unselected" as const }, + { id: "values-3", label: "Abundance", state: "Unselected" as const }, + { id: "values-4", label: "Art", state: "Unselected" as const }, + { id: "values-5", label: "Decisiveness", state: "Unselected" as const }, + ], + onChipClick: (categoryName: string, chipId: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.map((opt) => + opt.id === chipId + ? { + ...opt, + state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + } + : opt + ), + } + : cat + ) + ); + }, + onAddClick: (categoryName: string) => { + const newId = `custom-${categoryName}-${Date.now()}`; + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: [ + ...cat.chipOptions, + { id: newId, label: "", state: "Custom" as const }, + ], + } + : cat + ) + ); + }, + onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.map((opt) => + opt.id === chipId + ? { ...opt, label: value, state: "Selected" as const } + : opt + ), + } + : cat + ) + ); + }, + onCustomChipClose: (categoryName: string, chipId: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId), + } + : cat + ) + ); + }, }, { name: "Communication", - items: ["Signal"], - createUrl: "/create/communication", + chipOptions: [ + { id: "comm-1", label: "Signal", state: "Unselected" as const }, + ], + onChipClick: (categoryName: string, chipId: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.map((opt) => + opt.id === chipId + ? { + ...opt, + state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + } + : opt + ), + } + : cat + ) + ); + }, + onAddClick: (categoryName: string) => { + const newId = `custom-${categoryName}-${Date.now()}`; + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: [ + ...cat.chipOptions, + { id: newId, label: "", state: "Custom" as const }, + ], + } + : cat + ) + ); + }, + onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.map((opt) => + opt.id === chipId + ? { ...opt, label: value, state: "Selected" as const } + : opt + ), + } + : cat + ) + ); + }, + onCustomChipClose: (categoryName: string, chipId: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId), + } + : cat + ) + ); + }, }, { name: "Membership", - items: ["Open Admission"], - createUrl: "/create/membership", + chipOptions: [ + { id: "membership-1", label: "Open Admission", state: "Unselected" as const }, + ], + onChipClick: (categoryName: string, chipId: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.map((opt) => + opt.id === chipId + ? { + ...opt, + state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + } + : opt + ), + } + : cat + ) + ); + }, + onAddClick: (categoryName: string) => { + const newId = `custom-${categoryName}-${Date.now()}`; + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: [ + ...cat.chipOptions, + { id: newId, label: "", state: "Custom" as const }, + ], + } + : cat + ) + ); + }, + onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.map((opt) => + opt.id === chipId + ? { ...opt, label: value, state: "Selected" as const } + : opt + ), + } + : cat + ) + ); + }, + onCustomChipClose: (categoryName: string, chipId: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId), + } + : cat + ) + ); + }, }, { name: "Decision-making", - items: ["Lazy Consensus", "Modified Consensus"], - createUrl: "/create/decision-making", + chipOptions: [ + { id: "decision-1", label: "Lazy Consensus", state: "Unselected" as const }, + { id: "decision-2", label: "Modified Consensus", state: "Unselected" as const }, + ], + onChipClick: (categoryName: string, chipId: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.map((opt) => + opt.id === chipId + ? { + ...opt, + state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + } + : opt + ), + } + : cat + ) + ); + }, + onAddClick: (categoryName: string) => { + const newId = `custom-${categoryName}-${Date.now()}`; + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: [ + ...cat.chipOptions, + { id: newId, label: "", state: "Custom" as const }, + ], + } + : cat + ) + ); + }, + onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.map((opt) => + opt.id === chipId + ? { ...opt, label: value, state: "Selected" as const } + : opt + ), + } + : cat + ) + ); + }, + onCustomChipClose: (categoryName: string, chipId: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId), + } + : cat + ) + ); + }, }, { name: "Conflict management", - items: ["Code of Conduct", "Restorative Justice"], - createUrl: "/create/conflict-management", + chipOptions: [ + { id: "conflict-1", label: "Code of Conduct", state: "Unselected" as const }, + { id: "conflict-2", label: "Restorative Justice", state: "Unselected" as const }, + ], + onChipClick: (categoryName: string, chipId: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.map((opt) => + opt.id === chipId + ? { + ...opt, + state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + } + : opt + ), + } + : cat + ) + ); + }, + onAddClick: (categoryName: string) => { + const newId = `custom-${categoryName}-${Date.now()}`; + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: [ + ...cat.chipOptions, + { id: newId, label: "", state: "Custom" as const }, + ], + } + : cat + ) + ); + }, + onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.map((opt) => + opt.id === chipId + ? { ...opt, label: value, state: "Selected" as const } + : opt + ), + } + : cat + ) + ); + }, + onCustomChipClose: (categoryName: string, chipId: string) => { + setRuleCardCategories((prev) => + prev.map((cat) => + cat.name === categoryName + ? { + ...cat, + chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId), + } + : cat + ) + ); + }, }, - ]; + ]); return (
@@ -355,13 +682,7 @@ export default function ComponentsPreview() { className="w-[568px]" logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png" logoAlt="Mutual Aid Mondays" - categories={sampleCategories} - onPillClick={(category, item) => { - console.log(`Pill clicked: ${category} - ${item}`); - }} - onCreateClick={(category) => { - console.log(`Create clicked: ${category}`); - }} + categories={ruleCardCategories} onClick={() => console.log("Card clicked: Mutual Aid Mondays")} />
@@ -382,13 +703,7 @@ export default function ComponentsPreview() { className="w-[398px]" logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png" logoAlt="Mutual Aid Mondays" - categories={sampleCategories} - onPillClick={(category, item) => { - console.log(`Pill clicked: ${category} - ${item}`); - }} - onCreateClick={(category) => { - console.log(`Create clicked: ${category}`); - }} + categories={ruleCardCategories} onClick={() => console.log("Card clicked: Mutual Aid Mondays")} />
diff --git a/app/components/InputLabel/InputLabel.container.tsx b/app/components/InputLabel/InputLabel.container.tsx new file mode 100644 index 0000000..1060ba7 --- /dev/null +++ b/app/components/InputLabel/InputLabel.container.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { memo } from "react"; +import InputLabelView from "./InputLabel.view"; +import type { InputLabelProps } from "./InputLabel.types"; +import { + normalizeInputLabelSize, + normalizeInputLabelPalette, +} from "../../../lib/propNormalization"; + +const InputLabelContainer = memo( + ({ + label, + helpIcon = false, + asterisk = false, + helperText = false, + size: sizeProp = "S", + palette: paletteProp = "Default", + className = "", + }) => { + const size = normalizeInputLabelSize(sizeProp); + const palette = normalizeInputLabelPalette(paletteProp); + + return ( + + ); + }, +); + +InputLabelContainer.displayName = "InputLabel"; + +export default InputLabelContainer; diff --git a/app/components/InputLabel/InputLabel.types.ts b/app/components/InputLabel/InputLabel.types.ts new file mode 100644 index 0000000..7e37ae6 --- /dev/null +++ b/app/components/InputLabel/InputLabel.types.ts @@ -0,0 +1,44 @@ +export type InputLabelSizeValue = "S" | "M" | "s" | "m"; +export type InputLabelPaletteValue = "Default" | "Inverse" | "default" | "inverse"; + +export interface InputLabelProps { + /** + * The label text to display + */ + label: string; + /** + * Show help icon next to label + */ + helpIcon?: boolean; + /** + * Show asterisk (*) to indicate required field + */ + asterisk?: boolean; + /** + * Helper text to display on the right side. + * If boolean true, shows "Optional text". + * If string, shows the provided text. + */ + helperText?: boolean | string; + /** + * Size variant: "S" (small) or "M" (medium) + * Accepts both uppercase (Figma) and lowercase values. + */ + size?: InputLabelSizeValue; + /** + * Palette variant: "Default" or "Inverse" + * Accepts both PascalCase (Figma) and lowercase values. + */ + palette?: InputLabelPaletteValue; + className?: string; +} + +export interface InputLabelViewProps { + label: string; + helpIcon: boolean; + asterisk: boolean; + helperText: boolean | string; + size: "s" | "m"; + palette: "default" | "inverse"; + className: string; +} diff --git a/app/components/InputLabel/InputLabel.view.tsx b/app/components/InputLabel/InputLabel.view.tsx new file mode 100644 index 0000000..9b5ab56 --- /dev/null +++ b/app/components/InputLabel/InputLabel.view.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { memo } from "react"; +import { getAssetPath, ASSETS } from "../../../lib/assetUtils"; +import type { InputLabelViewProps } from "./InputLabel.types"; + +function InputLabelView({ + label, + helpIcon, + asterisk, + helperText, + size, + palette, + className = "", +}: InputLabelViewProps) { + const isSmall = size === "s"; + const isInverse = palette === "inverse"; + + // Size-based typography + const labelTextSize = isSmall + ? "text-[length:var(--sizing-350,14px)] leading-[20px]" + : "text-[length:var(--sizing-400,16px)] leading-[24px]"; + + const helperTextSize = isSmall + ? "text-[length:var(--measures-sizing-250,10px)] leading-[var(--measures-spacing-350,14px)]" + : "text-[length:var(--sizing-300,12px)] leading-[16px]"; + + const asteriskSize = isSmall + ? "text-[length:var(--measures-sizing-250,10px)] leading-[var(--measures-spacing-300,12px)]" + : "text-[length:var(--measures-spacing-300,12px)] leading-[var(--measures-spacing-300,12px)]"; + + // Palette-based colors + const labelColor = isInverse + ? "text-[color:var(--color-content-inverse-secondary,#1f1f1f)]" + : "text-[color:var(--color-content-default-secondary,#d2d2d2)]"; + + const helperTextColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"; + + // Layout: S uses flex-wrap with baseline, M uses flex with center + const containerClass = isSmall + ? "flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative w-full" + : "flex gap-[4px] items-center relative w-full"; + + const labelContainerClass = isSmall + ? "flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0" + : "flex gap-[var(--measures-spacing-100,4px)] items-center relative shrink-0"; + + const helpIconSize = isSmall ? "size-[12px]" : "size-[16px]"; + + // Help icon color filter based on palette + // Default: Light yellow (#f6f06f / rgba(246, 240, 111, 1)) - SVG already has this color + // Inverse: Dark yellow (#8c8505 / rgba(140, 133, 5, 1)) + // For default, no filter needed as SVG already has the correct yellow + // For inverse, darken the yellow + const helpIconFilter = isInverse + ? "brightness(0.57) saturate(100%)" // Dark yellow (#8c8505) - darken the existing yellow + : undefined; // No filter for default - use SVG's native yellow color + + return ( +
+
+
+

+ {label} +

+ {asterisk && ( +

+ * +

+ )} +
+ {helpIcon && ( +
+ Help +
+ )} +
+ {helperText && ( +

+ {typeof helperText === "string" ? helperText : "Optional text"} +

+ )} +
+ ); +} + +InputLabelView.displayName = "InputLabelView"; + +export default memo(InputLabelView); diff --git a/app/components/InputLabel/index.tsx b/app/components/InputLabel/index.tsx new file mode 100644 index 0000000..b2d805a --- /dev/null +++ b/app/components/InputLabel/index.tsx @@ -0,0 +1,3 @@ +import InputLabel from "./InputLabel.container"; + +export default InputLabel; diff --git a/app/components/MultiSelect/MultiSelect.container.tsx b/app/components/MultiSelect/MultiSelect.container.tsx index 086498e..1613576 100644 --- a/app/components/MultiSelect/MultiSelect.container.tsx +++ b/app/components/MultiSelect/MultiSelect.container.tsx @@ -3,13 +3,14 @@ import { memo } from "react"; import MultiSelectView from "./MultiSelect.view"; import type { MultiSelectProps } from "./MultiSelect.types"; -import { normalizeMultiSelectSize } from "../../../lib/propNormalization"; +import { normalizeMultiSelectSize, normalizeChipPalette } from "../../../lib/propNormalization"; const MultiSelectContainer = memo( ({ label, showHelpIcon = true, size: sizeProp = "M", + palette: paletteProp = "Default", options, onChipClick, onAddClick, @@ -20,12 +21,14 @@ const MultiSelectContainer = memo( className = "", }) => { const size = normalizeMultiSelectSize(sizeProp); + const palette = normalizeChipPalette(paletteProp); return ( void; onAddClick?: () => void; diff --git a/app/components/MultiSelect/MultiSelect.view.tsx b/app/components/MultiSelect/MultiSelect.view.tsx index a526e68..b59d8c9 100644 --- a/app/components/MultiSelect/MultiSelect.view.tsx +++ b/app/components/MultiSelect/MultiSelect.view.tsx @@ -1,14 +1,15 @@ "use client"; import { memo } from "react"; -import { getAssetPath, ASSETS } from "../../../lib/assetUtils"; import Chip from "../Chip"; +import InputLabel from "../InputLabel"; import type { MultiSelectViewProps } from "./MultiSelect.types"; function MultiSelectView({ label, showHelpIcon, size, + palette, options, onChipClick, onAddClick, @@ -19,44 +20,27 @@ function MultiSelectView({ className, }: MultiSelectViewProps) { const isSmall = size === "s"; + const isInverse = palette === "inverse"; - // Size-based spacing and typography + // Size-based spacing const gapClass = isSmall ? "gap-[var(--measures-spacing-200,8px)]" : "gap-[var(--measures-spacing-300,12px)]"; - const labelGapClass = isSmall - ? "gap-[var(--measures-spacing-200,4px_8px)]" - : "gap-[4px]"; - - const labelTextSize = isSmall - ? "text-[length:var(--sizing-350,14px)] leading-[20px]" - : "text-[length:var(--sizing-400,16px)] leading-[24px]"; - - const helpIconSize = isSmall ? "size-[12px]" : "size-[16px]"; - const chipSize = isSmall ? "S" : "M"; return (
- {/* Label with help icon */} + {/* Label using InputLabel component */} {label && ( -
-
- - {showHelpIcon && ( -
- Help -
- )} -
-
+ )} {/* Chips container */} @@ -66,7 +50,7 @@ function MultiSelectView({ key={option.id} label={option.state === "Custom" ? "" : option.label} state={option.state || "Unselected"} - palette="Default" + palette={palette === "inverse" ? "Inverse" : "Default"} size={chipSize} onClick={() => { // Only toggle if not in Custom state @@ -89,7 +73,7 @@ function MultiSelectView({ /> ))} - {/* Add button - Ghost button style */} + {/* Add button - Circular button with border (not ghost) when no text, ghost style when text provided */} {showAddButton && ( )}
diff --git a/app/components/RuleCard/RuleCard.container.tsx b/app/components/RuleCard/RuleCard.container.tsx index 855bbb7..84105b6 100644 --- a/app/components/RuleCard/RuleCard.container.tsx +++ b/app/components/RuleCard/RuleCard.container.tsx @@ -29,8 +29,6 @@ const RuleCardContainer = memo( expanded = false, size: sizeProp, categories, - onPillClick, - onCreateClick, logoUrl, logoAlt, communityInitials, @@ -77,8 +75,6 @@ const RuleCardContainer = memo( 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 ab5c367..f7bda7f 100644 --- a/app/components/RuleCard/RuleCard.types.ts +++ b/app/components/RuleCard/RuleCard.types.ts @@ -1,7 +1,12 @@ +import type { ChipOption } from "../MultiSelect/MultiSelect.types"; + export interface Category { name: string; - items: string[]; - createUrl?: string; + chipOptions: ChipOption[]; + onChipClick?: (categoryName: string, chipId: string) => void; + onAddClick?: (categoryName: string) => void; + onCustomChipConfirm?: (categoryName: string, chipId: string, value: string) => void; + onCustomChipClose?: (categoryName: string, chipId: string) => void; } export interface RuleCardProps { @@ -14,8 +19,6 @@ export interface RuleCardProps { 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; @@ -32,8 +35,6 @@ export interface RuleCardViewProps { 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 ed9edcf..2b92295 100644 --- a/app/components/RuleCard/RuleCard.view.tsx +++ b/app/components/RuleCard/RuleCard.view.tsx @@ -2,6 +2,7 @@ import Image from "next/image"; import { useTranslation } from "../../contexts/MessagesContext"; +import MultiSelect from "../MultiSelect"; import type { RuleCardViewProps } from "./RuleCard.types"; export function RuleCardView({ @@ -15,8 +16,6 @@ export function RuleCardView({ expanded, size, categories, - onPillClick, - onCreateClick, logoUrl, logoAlt, communityInitials, @@ -111,21 +110,6 @@ export function RuleCardView({ 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 (
- {/* Categories Section */} + {/* Categories Section - Using MultiSelect */} {categories && categories.length > 0 && (
{categories.map((category, categoryIndex) => ( -
- {/* Category Label */} -
-

- {category.name} -

-
- {/* Pills Container */} -
- {/* Pills */} - {category.items && category.items.map((item, itemIndex) => ( - - ))} - {/* Add Button */} - -
-
+ { + category.onChipClick?.(category.name, chipId); + }} + onAddClick={() => { + category.onAddClick?.(category.name); + }} + onCustomChipConfirm={(chipId, value) => { + category.onCustomChipConfirm?.(category.name, chipId, value); + }} + onCustomChipClose={(chipId) => { + category.onCustomChipClose?.(category.name, chipId); + }} + showAddButton={true} + addButtonText="" // Empty text for icon-only circular button + className="w-full" + /> ))}
)} diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index ea8600e..0c5d6de 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -618,3 +618,35 @@ export function normalizeMultiSelectSize( } return defaultValue; } + +/** + * Normalize InputLabel size prop values (S/M -> s/m) + */ +export function normalizeInputLabelSize( + value: string | undefined, + defaultValue: "s" = "s", +): "s" | "m" { + if (!value) return defaultValue; + const normalized = value.toLowerCase(); + const sizes = ["s", "m"]; + if (sizes.includes(normalized)) { + return normalized as typeof defaultValue; + } + return defaultValue; +} + +/** + * Normalize InputLabel palette prop values (Default/Inverse -> default/inverse) + */ +export function normalizeInputLabelPalette( + value: string | undefined, + defaultValue: "default" = "default", +): "default" | "inverse" { + if (!value) return defaultValue; + const normalized = value.toLowerCase(); + const palettes = ["default", "inverse"]; + if (palettes.includes(normalized)) { + return normalized as typeof defaultValue; + } + return defaultValue; +} -- 2.43.0 From 4c147780acf0c0dd355d3cbfef4e96b39a56425e Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:29:58 -0700 Subject: [PATCH 5/9] Fix rule card header layout --- app/components/RuleCard/RuleCard.view.tsx | 49 ++++++++++++----------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/app/components/RuleCard/RuleCard.view.tsx b/app/components/RuleCard/RuleCard.view.tsx index 2b92295..ce7689b 100644 --- a/app/components/RuleCard/RuleCard.view.tsx +++ b/app/components/RuleCard/RuleCard.view.tsx @@ -21,17 +21,21 @@ export function RuleCardView({ communityInitials, }: RuleCardViewProps) { const t = useTranslation("ruleCard"); - const ariaLabel = t("ariaLabel").replace("{title}", title); + const ariaLabel = t("ariaLabel")?.replace("{title}", 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 + // Card dimensions - fixed width for expanded states (568px for L, 398px for M per Figma) const cardPadding = isLarge ? "p-[24px]" : "p-[16px]"; const cardGap = expanded ? "gap-[16px]" : isLarge ? "gap-[10px]" : "gap-[12px]"; + const cardWidth = expanded + ? isLarge + ? "w-[568px]" + : "w-[398px]" + : ""; // Logo/Icon dimensions const logoSize = isLarge ? 103 : 56; @@ -49,12 +53,6 @@ export function RuleCardView({ ? "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) { @@ -100,7 +98,7 @@ export function RuleCardView({ if (communityInitials) { return (
- + {communityInitials}
@@ -113,7 +111,7 @@ export function RuleCardView({ return (
- {/* Header Container */} -
- {/* Logo/Icon Container */} + {/* Outermost container with bottom border - taller to match Figma */} +
+ {/* Logo/Icon - fixed width/height, vertically centered, does not touch bottom */} {renderLogo() && ( -
+
{renderLogo()}
)} - {/* Title Container */} + {/* 16px spacing */} +
+ {/* Container with no padding and left border - extends full height to touch bottom */} {title && ( -
-

- {title} -

+
+ {/* Inner container for header text with padding */} +
+

+ {title} +

+
)}
@@ -173,8 +176,8 @@ export function RuleCardView({ )} {/* Footer: Description */} {description && ( -
-

+

+

{description}

@@ -184,7 +187,7 @@ export function RuleCardView({ /* Collapsed State: Description */ description && (
-

+

{description}

-- 2.43.0 From fc5933e6ba710d41d7a06ae307c1c38d035a5fd7 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:36:14 -0700 Subject: [PATCH 6/9] Create and adjust rule card tests --- .../MultiSelect/MultiSelect.view.tsx | 1 + stories/RuleCard.stories.js | 117 ++++++++--- tests/components/InputLabel.test.tsx | 97 +++++++++ tests/components/MultiSelect.test.tsx | 189 ++++++++++++++++++ tests/unit/RuleCard.test.jsx | 71 +++++-- 5 files changed, 437 insertions(+), 38 deletions(-) create mode 100644 tests/components/InputLabel.test.tsx create mode 100644 tests/components/MultiSelect.test.tsx diff --git a/app/components/MultiSelect/MultiSelect.view.tsx b/app/components/MultiSelect/MultiSelect.view.tsx index b59d8c9..b1d2197 100644 --- a/app/components/MultiSelect/MultiSelect.view.tsx +++ b/app/components/MultiSelect/MultiSelect.view.tsx @@ -77,6 +77,7 @@ function MultiSelectView({ {showAddButton && (
- +
+ {/* Different Background Colors */} -
-

+
+

Different Background Colors -

+

} onClick={() => console.log("Consensus selected")} - /> -
-
+ /> +
+
{/* Logo Fallback */} @@ -769,8 +769,8 @@ export default function ComponentsPreview() { className="w-[525px]" communityInitials="CE" onClick={() => console.log("Community Example selected")} - /> -
+ /> +
{/* MultiSelect Component */} diff --git a/app/components/RuleCard/RuleCard.types.ts b/app/components/RuleCard/RuleCard.types.ts index f7bda7f..3ebe9ce 100644 --- a/app/components/RuleCard/RuleCard.types.ts +++ b/app/components/RuleCard/RuleCard.types.ts @@ -17,7 +17,7 @@ export interface RuleCardProps { className?: string; onClick?: () => void; expanded?: boolean; - size?: "L" | "M" | "l" | "m"; + size?: "XS" | "S" | "M" | "L" | "xs" | "s" | "m" | "l"; categories?: Category[]; logoUrl?: string; logoAlt?: string; @@ -33,7 +33,7 @@ export interface RuleCardViewProps { onClick: () => void; onKeyDown: (_event: React.KeyboardEvent) => void; expanded: boolean; - size: "L" | "M"; + size: "XS" | "S" | "M" | "L"; categories?: Category[]; logoUrl?: string; logoAlt?: string; diff --git a/app/components/RuleCard/RuleCard.view.tsx b/app/components/RuleCard/RuleCard.view.tsx index ce7689b..4e637e9 100644 --- a/app/components/RuleCard/RuleCard.view.tsx +++ b/app/components/RuleCard/RuleCard.view.tsx @@ -25,33 +25,67 @@ export function RuleCardView({ // Size-based styling const isLarge = size === "L"; + const isMedium = size === "M"; + const isSmall = size === "S"; + const isExtraSmall = size === "XS"; // Card dimensions - fixed width for expanded states (568px for L, 398px for M per Figma) - const cardPadding = isLarge ? "p-[24px]" : "p-[16px]"; + // XS and S don't have fixed widths when expanded + const cardPadding = isLarge || isSmall + ? "p-[24px]" + : isMedium + ? "p-[16px]" + : "pb-[24px] pt-[12px] px-[12px]"; // XS: asymmetric padding const cardGap = expanded ? "gap-[16px]" - : isLarge ? "gap-[10px]" : "gap-[12px]"; + : isLarge + ? "gap-[10px]" + : isMedium + ? "gap-[12px]" + : "gap-[18px]"; // XS and S: 18px gap const cardWidth = expanded ? isLarge ? "w-[568px]" - : "w-[398px]" + : isMedium + ? "w-[398px]" + : "" // XS and S: no fixed width : ""; // Logo/Icon dimensions - const logoSize = isLarge ? 103 : 56; + // For S: 80px container with 12px padding = 56px icon area + // For XS: 40px container with 16px padding = 8px icon area (very small, but matches Figma) + const logoSize = isLarge + ? 103 + : isMedium + ? 56 + : isSmall + ? 56 // S: 80px container - 12px padding * 2 = 56px icon + : 8; // XS: 40px container - 16px padding * 2 = 8px icon const logoContainerClass = isLarge ? "size-[103px]" - : "size-[56px]"; + : isMedium + ? "size-[56px]" + : isSmall + ? "size-[80px]" // S: 80px container + : "size-[40px]"; // XS: 40px container // Title typography const titleClass = isLarge ? "font-bricolage-grotesque font-extrabold text-[36px] leading-[44px]" - : "font-bricolage-grotesque font-bold text-[24px] leading-[32px]"; + : isMedium + ? "font-bricolage-grotesque font-bold text-[24px] leading-[32px]" + : isSmall + ? "font-bricolage-grotesque font-bold text-[28px] leading-[36px]" // S: 28px, bold, Bricolage + : "font-inter font-bold text-[20px] leading-[28px]"; // XS: 20px, bold, Inter // Description typography const descriptionClass = isLarge ? "font-inter font-medium text-[18px] leading-[24px]" - : "font-inter font-medium text-[14px] leading-[16px]"; + : isMedium + ? "font-inter font-medium text-[14px] leading-[16px]" + : isSmall + ? "font-inter font-medium text-[14px] leading-[16px]" // S: 14px, medium, Inter + : "font-inter font-medium text-[12px] leading-[14px]"; // XS: 12px, medium, Inter // Render logo/icon const renderLogo = () => { @@ -59,29 +93,31 @@ export function RuleCardView({ // 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"); + const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity ${isSmall ? "p-[12px]" : isExtraSmall ? "p-[16px]" : ""}`; + if (isLocalhost) { return ( -
+
{/* eslint-disable-next-line @next/next/no-img-element */} {logoAlt
); } return ( -
+
{logoAlt
); @@ -89,16 +125,23 @@ export function RuleCardView({ if (icon) { return ( -
+
{icon}
); } if (communityInitials) { + const initialsSize = isLarge + ? "text-[36px]" + : isMedium + ? "text-[24px]" + : isSmall + ? "text-[20px]" + : "text-[16px]"; return (
- + {communityInitials}
@@ -111,7 +154,7 @@ export function RuleCardView({ return (
{/* Outermost container with bottom border - taller to match Figma */} -
+
{/* Logo/Icon - fixed width/height, vertically centered, does not touch bottom */} {renderLogo() && ( -
+
{renderLogo()}
)} - {/* 16px spacing */} -
+ {/* Spacing between icon and title */} + {!isSmall && !isExtraSmall &&
} {/* Container with no padding and left border - extends full height to touch bottom */} {title && ( -
+
{/* Inner container for header text with padding */} -
+

- {title} -

+ {title} +
)} @@ -175,7 +218,7 @@ export function RuleCardView({
)} {/* Footer: Description */} - {description && ( + {description && (

{description} @@ -188,8 +231,8 @@ export function RuleCardView({ description && (

- {description} -

+ {description} +

) )} diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index 0c5d6de..9ebd610 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -519,10 +519,10 @@ export function normalizeSmallMediumLargeSize( export function normalizeRuleCardSize( value: string | undefined, defaultValue: "L" = "L" -): "L" | "M" { +): "XS" | "S" | "M" | "L" { if (!value) return defaultValue; const normalized = value.toUpperCase(); - if (normalized === "L" || normalized === "M") { + if (normalized === "XS" || normalized === "S" || normalized === "M" || normalized === "L") { return normalized; } return defaultValue; diff --git a/stories/RuleCard.stories.js b/stories/RuleCard.stories.js index a88b827..074fc19 100644 --- a/stories/RuleCard.stories.js +++ b/stories/RuleCard.stories.js @@ -39,7 +39,7 @@ export default { }, size: { control: { type: "select" }, - options: ["L", "M", "l", "m"], + options: ["XS", "S", "M", "L", "xs", "s", "m", "l"], description: "Size variant of the card", }, onClick: { action: "clicked" }, @@ -186,6 +186,44 @@ export const SizeMedium = { }, }; +export const SizeSmall = { + 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: "S", + icon: ( + Sociocracy + ), + }, +}; + +export const SizeExtraSmall = { + 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: "XS", + icon: ( + Sociocracy + ), + }, +}; + export const ExpandedMedium = { args: { title: "Mutual Aid Mondays", -- 2.43.0 From b012c73e65cd481d3dd556d359c4d224dab23353 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:58:15 -0700 Subject: [PATCH 8/9] Update Rule Stack component and tests --- app/components/RuleCard/RuleCard.view.tsx | 120 ++++++----- app/components/RuleStack/RuleStack.view.tsx | 208 ++++++++++++++------ messages/en/pages/home.json | 3 + tests/pages/user-journey.test.jsx | 3 +- tests/unit/RuleCard.test.jsx | 4 +- tests/unit/RuleStack.test.jsx | 36 ++-- 6 files changed, 245 insertions(+), 129 deletions(-) diff --git a/app/components/RuleCard/RuleCard.view.tsx b/app/components/RuleCard/RuleCard.view.tsx index 4e637e9..bdd2625 100644 --- a/app/components/RuleCard/RuleCard.view.tsx +++ b/app/components/RuleCard/RuleCard.view.tsx @@ -29,15 +29,22 @@ export function RuleCardView({ const isSmall = size === "S"; const isExtraSmall = size === "XS"; - // Card dimensions - fixed width for expanded states (568px for L, 398px for M per Figma) - // XS and S don't have fixed widths when expanded - const cardPadding = isLarge || isSmall + // Card dimensions - use CSS classes from className if provided, otherwise use size-based logic + // Check if className already has padding/gap classes + const hasResponsivePadding = className?.includes("p-[") || className?.includes("px-[") || className?.includes("py-[") || className?.includes("pt-[") || className?.includes("pb-["); + const hasResponsiveGap = className?.includes("gap-["); + + const cardPadding = hasResponsivePadding + ? "" // If className has responsive padding, don't add size-based padding + : isLarge || isSmall ? "p-[24px]" : isMedium ? "p-[16px]" : "pb-[24px] pt-[12px] px-[12px]"; // XS: asymmetric padding const cardGap = expanded ? "gap-[16px]" + : hasResponsiveGap + ? "" // If className has responsive gap, don't add size-based gap : isLarge ? "gap-[10px]" : isMedium @@ -51,32 +58,24 @@ export function RuleCardView({ : "" // XS and S: no fixed width : ""; - // Logo/Icon dimensions + // Logo/Icon dimensions - use CSS responsive classes // For S: 80px container with 12px padding = 56px icon area - // For XS: 40px container with 16px padding = 8px icon area (very small, but matches Figma) - const logoSize = isLarge - ? 103 - : isMedium - ? 56 - : isSmall - ? 56 // S: 80px container - 12px padding * 2 = 56px icon - : 8; // XS: 40px container - 16px padding * 2 = 8px icon - const logoContainerClass = isLarge - ? "size-[103px]" - : isMedium - ? "size-[56px]" - : isSmall - ? "size-[80px]" // S: 80px container - : "size-[40px]"; // XS: 40px container + // For XS: 72px container with 16px padding = 40px icon (72 - 16*2 = 40px) + const logoSize = 103; // Use max size, CSS will resize + const logoContainerClass = ` + max-[639px]:size-[72px] + min-[640px]:max-[1023px]:size-[80px] + min-[1024px]:max-[1439px]:size-[56px] + min-[1440px]:size-[103px] + `; - // Title typography - const titleClass = isLarge - ? "font-bricolage-grotesque font-extrabold text-[36px] leading-[44px]" - : isMedium - ? "font-bricolage-grotesque font-bold text-[24px] leading-[32px]" - : isSmall - ? "font-bricolage-grotesque font-bold text-[28px] leading-[36px]" // S: 28px, bold, Bricolage - : "font-inter font-bold text-[20px] leading-[28px]"; // XS: 20px, bold, Inter + // Title typography - use CSS responsive classes + const titleClass = ` + max-[639px]:font-inter max-[639px]:font-bold max-[639px]:text-[20px] max-[639px]:leading-[28px] + min-[640px]:max-[1023px]:font-bricolage-grotesque min-[640px]:max-[1023px]:font-bold min-[640px]:max-[1023px]:text-[28px] min-[640px]:max-[1023px]:leading-[36px] + min-[1024px]:max-[1439px]:font-bricolage-grotesque min-[1024px]:max-[1439px]:font-bold min-[1024px]:max-[1439px]:text-[24px] min-[1024px]:max-[1439px]:leading-[32px] + min-[1440px]:font-bricolage-grotesque min-[1440px]:font-extrabold min-[1440px]:text-[36px] min-[1440px]:leading-[44px] + `; // Description typography const descriptionClass = isLarge @@ -93,7 +92,7 @@ export function RuleCardView({ // 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"); - const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity ${isSmall ? "p-[12px]" : isExtraSmall ? "p-[16px]" : ""}`; + const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity max-[639px]:p-[16px] min-[640px]:max-[1023px]:p-[12px]`; if (isLocalhost) { return ( @@ -104,7 +103,7 @@ export function RuleCardView({ alt={logoAlt || title} width={logoSize} height={logoSize} - className={`${isSmall || isExtraSmall ? "w-full h-full" : "absolute inset-0 w-full h-full"} object-cover rounded-full`} + className="w-full h-full object-cover rounded-full" />
); @@ -117,7 +116,7 @@ export function RuleCardView({ alt={logoAlt || title} width={logoSize} height={logoSize} - className={`${isSmall || isExtraSmall ? "w-full h-full" : "absolute inset-0 w-full h-full"} object-cover rounded-full`} + className="w-full h-full object-cover rounded-full" />
); @@ -125,20 +124,19 @@ export function RuleCardView({ if (icon) { return ( -
+
{icon}
); } if (communityInitials) { - const initialsSize = isLarge - ? "text-[36px]" - : isMedium - ? "text-[24px]" - : isSmall - ? "text-[20px]" - : "text-[16px]"; + const initialsSize = ` + max-[639px]:text-[16px] + min-[640px]:max-[1023px]:text-[20px] + min-[1024px]:max-[1439px]:text-[24px] + min-[1440px]:text-[36px] + `; return (
@@ -152,9 +150,18 @@ export function RuleCardView({ }; + // Border radius - use CSS classes if provided via className, otherwise use size-based logic + const borderRadiusClass = className?.includes("rounded-") + ? "" // If className already has border radius, don't add size-based one + : isExtraSmall + ? "rounded-[var(--measures-radius-200,8px)]" + : isSmall + ? "rounded-[var(--measures-radius-300,12px)]" + : "rounded-[var(--radius-measures-radius-small)]"; + return (
{/* Outermost container with bottom border - taller to match Figma */} -
+
{/* Logo/Icon - fixed width/height, vertically centered, does not touch bottom */} {renderLogo() && ( -
+
{renderLogo()}
)} {/* Spacing between icon and title */} - {!isSmall && !isExtraSmall &&
} +
{/* Container with no padding and left border - extends full height to touch bottom */} {title && ( -
+
{/* Inner container for header text with padding */} -
+

{title}

diff --git a/app/components/RuleStack/RuleStack.view.tsx b/app/components/RuleStack/RuleStack.view.tsx index a735e46..d7533c2 100644 --- a/app/components/RuleStack/RuleStack.view.tsx +++ b/app/components/RuleStack/RuleStack.view.tsx @@ -1,8 +1,11 @@ "use client"; +import { useState, useEffect } from "react"; import Image from "next/image"; import { useTranslation } from "../../contexts/MessagesContext"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; import RuleCard from "../RuleCard"; +import SectionHeader from "../SectionHeader"; import Button from "../Button"; import { getAssetPath } from "../../../lib/assetUtils"; import type { RuleStackViewProps } from "./RuleStack.types"; @@ -12,78 +15,153 @@ export function RuleStackView({ onTemplateClick, }: RuleStackViewProps) { const t = useTranslation("pages.home.ruleStack"); + const [isMounted, setIsMounted] = useState(false); + + // Debug: Log button text to ensure translation works + const buttonText = t("button.seeAllTemplates"); + + // Determine current breakpoint for RuleCard size + // 320-639: XS, 640-767: S, 768-1023: S, 1024-1439: M, 1440+: L + const isMax639 = useMediaQuery("(max-width: 639px)"); + const isMin640Max1023 = useMediaQuery("(min-width: 640px) and (max-width: 1023px)"); + const isMin1024Max1439 = useMediaQuery("(min-width: 1024px) and (max-width: 1439px)"); + const isMin1440 = useMediaQuery("(min-width: 1440px)"); + + // Handle hydration: only use media queries after mount + useEffect(() => { + setIsMounted(true); + }, []); + + // Use CSS classes for responsive sizing to avoid hydration mismatch + // Default to M size for SSR, then let CSS handle the responsive sizing + const cardSize = isMounted + ? isMax639 + ? "XS" + : isMin640Max1023 + ? "S" + : isMin1024Max1439 + ? "M" + : isMin1440 + ? "L" + : "M" + : "M"; + + // Icon sizes: XS=40px, S=56px, M=56px, L=90px + // Use a large default (90px) and let CSS handle responsive sizing + const iconSize = 90; + + // Card data + const cards = [ + { + title: t("cards.consensusClusters.title"), + description: t("cards.consensusClusters.description"), + iconAlt: t("cards.consensusClusters.iconAlt"), + iconPath: "assets/Icon_Sociocracy.svg", + backgroundColor: "bg-[var(--color-surface-default-brand-lime)]", + }, + { + title: t("cards.consensus.title"), + description: t("cards.consensus.description"), + iconAlt: t("cards.consensus.iconAlt"), + iconPath: "assets/Icon_Consensus.svg", + backgroundColor: "bg-[var(--color-surface-default-brand-rust)]", + }, + { + title: t("cards.electedBoard.title"), + description: t("cards.electedBoard.description"), + iconAlt: t("cards.electedBoard.iconAlt"), + iconPath: "assets/Icon_ElectedBoard.svg", + backgroundColor: "bg-[var(--color-surface-default-brand-red)]", + }, + { + title: t("cards.petition.title"), + description: t("cards.petition.description"), + iconAlt: t("cards.petition.iconAlt"), + iconPath: "assets/Icon_Petition.svg", + backgroundColor: "bg-[var(--color-surface-default-brand-teal)]", + }, + ]; return (
-
- - } - backgroundColor="bg-[var(--color-surface-default-brand-lime)]" - onClick={() => onTemplateClick(t("cards.consensusClusters.title"))} - /> - - } - backgroundColor="bg-[var(--color-surface-default-brand-rust)]" - onClick={() => onTemplateClick(t("cards.consensus.title"))} - /> - - } - backgroundColor="bg-[var(--color-surface-default-brand-red)]" - onClick={() => onTemplateClick(t("cards.electedBoard.title"))} - /> - - } - backgroundColor="bg-[var(--color-surface-default-brand-teal)]" - onClick={() => onTemplateClick(t("cards.petition.title"))} - /> + {/* Section Header */} + + + {/* Cards Container */} +
+ {cards.map((card, index) => ( + + } + backgroundColor={card.backgroundColor} + onClick={() => onTemplateClick(card.title)} + /> + ))}
{/* See all templates button */} -
-
diff --git a/messages/en/pages/home.json b/messages/en/pages/home.json index 61e1913..564c934 100644 --- a/messages/en/pages/home.json +++ b/messages/en/pages/home.json @@ -54,6 +54,9 @@ "buttonHref": "#contact" }, "ruleStack": { + "title": "Popular templates", + "subtitle": "These are popular patterns for making decisions in mutual aid and open source communities. You can use them as they are or as a starting place for customizing your own CommunityRule.", + "subtitleLg": "These are popular patterns for making decisions in communities with egalitarian values. You can use them as they are or as a starting place for customizing your own CommunityRule.", "cards": { "consensusClusters": { "title": "Consensus clusters", diff --git a/tests/pages/user-journey.test.jsx b/tests/pages/user-journey.test.jsx index 4233cbd..bbf7c9b 100644 --- a/tests/pages/user-journey.test.jsx +++ b/tests/pages/user-journey.test.jsx @@ -251,7 +251,8 @@ describe("User Journey Integration", () => { // 3. User sees governance options - wait for dynamically imported component await waitFor(() => { - expect(screen.getByText("Consensus clusters")).toBeInTheDocument(); + // Use a more flexible matcher in case text is split across elements + expect(screen.getByText(/Consensus clusters/i)).toBeInTheDocument(); }); // 4. User sees features and benefits - wait for dynamically imported component diff --git a/tests/unit/RuleCard.test.jsx b/tests/unit/RuleCard.test.jsx index e4c8161..f009574 100644 --- a/tests/unit/RuleCard.test.jsx +++ b/tests/unit/RuleCard.test.jsx @@ -147,7 +147,9 @@ describe("RuleCard Component", () => { render(); const heading = screen.getByRole("heading", { level: 3 }); - expect(heading).toHaveClass("font-bricolage-grotesque", "font-extrabold"); + // Check for responsive font classes - at 1440px+ it should have font-bricolage-grotesque and font-extrabold + expect(heading?.className).toMatch(/min-\[1440px\]:font-bricolage-grotesque/); + expect(heading?.className).toMatch(/min-\[1440px\]:font-extrabold/); }); it("renders expanded state with categories", () => { diff --git a/tests/unit/RuleStack.test.jsx b/tests/unit/RuleStack.test.jsx index 7d17a3f..41362dc 100644 --- a/tests/unit/RuleStack.test.jsx +++ b/tests/unit/RuleStack.test.jsx @@ -74,17 +74,17 @@ describe("RuleStack Component", () => { render(); const section = document.querySelector("section"); - expect(section).toHaveClass( - "py-[var(--spacing-scale-032)]", - "px-[var(--spacing-scale-020)]", - ); + // Check for responsive padding classes + expect(section).toHaveClass("px-[20px]", "py-[32px]"); + expect(section?.className).toMatch(/min-\[640px\]:px-\[32px\]/); + expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/); }); test("applies responsive grid layout", () => { render(); const grid = document.querySelector('[class*="flex flex-col gap-[18px]"]'); - expect(grid).toHaveClass("xmd:grid", "xmd:grid-cols-2"); + expect(grid).toHaveClass("min-[768px]:grid", "min-[768px]:grid-cols-2"); }); test("renders RuleCard components with correct props", () => { @@ -124,19 +124,18 @@ describe("RuleStack Component", () => { const section = document.querySelector("section"); expect(section).toBeInTheDocument(); - // Check for proper heading structure in cards + // Check for proper heading structure: 1 from SectionHeader + 4 from RuleCards const headings = screen.getAllByRole("heading"); - expect(headings).toHaveLength(4); // Four rule cards + expect(headings).toHaveLength(5); // One section header + four rule cards }); test("applies responsive spacing", () => { render(); const section = document.querySelector("section"); - expect(section).toHaveClass( - "md:py-[var(--spacing-scale-048)]", - "lg:py-[var(--spacing-scale-064)]", - ); + // Check for responsive padding classes + expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/); + expect(section?.className).toMatch(/min-\[1024px\]:py-\[64px\]/); }); test("renders icons with correct attributes", () => { @@ -147,12 +146,11 @@ describe("RuleStack Component", () => { "src", "/assets/Icon_Sociocracy.svg", ); - expect(sociocracyIcon).toHaveClass( - "md:w-[56px]", - "md:h-[56px]", - "lg:w-[90px]", - "lg:h-[90px]", - ); + // Check for responsive icon size classes + expect(sociocracyIcon?.className).toMatch(/min-\[640px\]:max-\[1023px\]:w-\[56px\]/); + expect(sociocracyIcon?.className).toMatch(/min-\[640px\]:max-\[1023px\]:h-\[56px\]/); + expect(sociocracyIcon?.className).toMatch(/min-\[1440px\]:w-\[90px\]/); + expect(sociocracyIcon?.className).toMatch(/min-\[1440px\]:h-\[90px\]/); }); test("applies different background colors to cards", () => { @@ -174,7 +172,9 @@ describe("RuleStack Component", () => { render(); const button = screen.getByRole("button", { name: "See all templates" }); - expect(button).toHaveClass("bg-transparent", "border-[1.5px]"); + // Button component uses outline variant which has bg-transparent and border + expect(button?.className).toMatch(/bg-transparent/); + expect(button?.className).toMatch(/border/); }); test("applies flex layout for button container", () => { -- 2.43.0 From 794b978aab1d1dbe811220f7fb879412e731e45a Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:45:57 -0700 Subject: [PATCH 9/9] Update and resolve test issues --- app/components-preview/page.tsx | 86 ++++++++++--------- .../AskOrganizer/AskOrganizer.container.tsx | 2 +- app/components/Checkbox/Checkbox.types.ts | 2 +- app/components/Chip/Chip.view.tsx | 4 +- .../MultiSelect/MultiSelect.types.ts | 2 +- app/components/RadioGroup/RadioGroup.types.ts | 2 +- app/components/RuleStack/RuleStack.view.tsx | 1 - .../SelectInput/SelectInput.container.tsx | 3 + .../SelectInput/SelectInput.view.tsx | 2 +- app/components/Switch/Switch.types.ts | 2 +- app/components/Toggle/Toggle.types.ts | 2 +- lib/propNormalization.ts | 4 +- 12 files changed, 60 insertions(+), 52 deletions(-) diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index 7b23a5e..315a6f5 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -17,14 +17,14 @@ interface ChipData { // MultiSelect example component with state management function MultiSelectExample({ size }: { size: "S" | "M" }) { - const [options, setOptions] = 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 [options, setOptions] = useState>([ + { id: "1", label: "1 member", state: "Unselected" }, + { id: "2", label: "2-10 members", state: "Unselected" }, + { id: "3", label: "10-24 members", state: "Unselected" }, + { id: "4", label: "24-64 members", state: "Unselected" }, + { id: "5", label: "64-128 members", state: "Unselected" }, + { id: "6", label: "125-1000 members", state: "Unselected" }, + { id: "7", label: "1000+ members", state: "Unselected" }, ]); const handleChipClick = (chipId: string) => { @@ -33,7 +33,7 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) { opt.id === chipId ? { ...opt, - state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + state: opt.state === "Selected" ? "Unselected" : "Selected", } : opt ) @@ -44,7 +44,7 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) { const newId = `custom-${Date.now()}`; setOptions((prev) => [ ...prev, - { id: newId, label: "", state: "Custom" as const }, + { id: newId, label: "", state: "Custom" }, ]); }; @@ -83,7 +83,6 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) { } export default function ComponentsPreview() { - const [expandedCard, setExpandedCard] = useState(null); const [chipStates, setChipStates] = useState>({ "default-s": "Unselected", "default-m": "Unselected", @@ -98,15 +97,22 @@ export default function ComponentsPreview() { ]); // RuleCard categories with chip options and state management - const [ruleCardCategories, setRuleCardCategories] = useState([ + const [ruleCardCategories, setRuleCardCategories] = useState; + onChipClick?: (categoryName: string, chipId: string) => void; + onAddClick?: (categoryName: string) => void; + onCustomChipConfirm?: (categoryName: string, chipId: string, value: string) => void; + onCustomChipClose?: (categoryName: string, chipId: string) => void; + }>>([ { name: "Values", chipOptions: [ - { id: "values-1", label: "Consciousness", state: "Unselected" as const }, - { id: "values-2", label: "Ecology", state: "Unselected" as const }, - { id: "values-3", label: "Abundance", state: "Unselected" as const }, - { id: "values-4", label: "Art", state: "Unselected" as const }, - { id: "values-5", label: "Decisiveness", state: "Unselected" as const }, + { id: "values-1", label: "Consciousness", state: "Unselected" }, + { id: "values-2", label: "Ecology", state: "Unselected" }, + { id: "values-3", label: "Abundance", state: "Unselected" }, + { id: "values-4", label: "Art", state: "Unselected" }, + { id: "values-5", label: "Decisiveness", state: "Unselected" }, ], onChipClick: (categoryName: string, chipId: string) => { setRuleCardCategories((prev) => @@ -118,7 +124,7 @@ export default function ComponentsPreview() { opt.id === chipId ? { ...opt, - state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + state: opt.state === "Selected" ? "Unselected" : "Selected", } : opt ), @@ -136,7 +142,7 @@ export default function ComponentsPreview() { ...cat, chipOptions: [ ...cat.chipOptions, - { id: newId, label: "", state: "Custom" as const }, + { id: newId, label: "", state: "Custom" }, ], } : cat @@ -151,7 +157,7 @@ export default function ComponentsPreview() { ...cat, chipOptions: cat.chipOptions.map((opt) => opt.id === chipId - ? { ...opt, label: value, state: "Selected" as const } + ? { ...opt, label: value, state: "Selected" } : opt ), } @@ -175,7 +181,7 @@ export default function ComponentsPreview() { { name: "Communication", chipOptions: [ - { id: "comm-1", label: "Signal", state: "Unselected" as const }, + { id: "comm-1", label: "Signal", state: "Unselected" }, ], onChipClick: (categoryName: string, chipId: string) => { setRuleCardCategories((prev) => @@ -187,7 +193,7 @@ export default function ComponentsPreview() { opt.id === chipId ? { ...opt, - state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + state: opt.state === "Selected" ? "Unselected" : "Selected", } : opt ), @@ -205,7 +211,7 @@ export default function ComponentsPreview() { ...cat, chipOptions: [ ...cat.chipOptions, - { id: newId, label: "", state: "Custom" as const }, + { id: newId, label: "", state: "Custom" }, ], } : cat @@ -220,7 +226,7 @@ export default function ComponentsPreview() { ...cat, chipOptions: cat.chipOptions.map((opt) => opt.id === chipId - ? { ...opt, label: value, state: "Selected" as const } + ? { ...opt, label: value, state: "Selected" } : opt ), } @@ -244,7 +250,7 @@ export default function ComponentsPreview() { { name: "Membership", chipOptions: [ - { id: "membership-1", label: "Open Admission", state: "Unselected" as const }, + { id: "membership-1", label: "Open Admission", state: "Unselected" }, ], onChipClick: (categoryName: string, chipId: string) => { setRuleCardCategories((prev) => @@ -256,7 +262,7 @@ export default function ComponentsPreview() { opt.id === chipId ? { ...opt, - state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + state: opt.state === "Selected" ? "Unselected" : "Selected", } : opt ), @@ -274,7 +280,7 @@ export default function ComponentsPreview() { ...cat, chipOptions: [ ...cat.chipOptions, - { id: newId, label: "", state: "Custom" as const }, + { id: newId, label: "", state: "Custom" }, ], } : cat @@ -289,7 +295,7 @@ export default function ComponentsPreview() { ...cat, chipOptions: cat.chipOptions.map((opt) => opt.id === chipId - ? { ...opt, label: value, state: "Selected" as const } + ? { ...opt, label: value, state: "Selected" } : opt ), } @@ -313,8 +319,8 @@ export default function ComponentsPreview() { { name: "Decision-making", chipOptions: [ - { id: "decision-1", label: "Lazy Consensus", state: "Unselected" as const }, - { id: "decision-2", label: "Modified Consensus", state: "Unselected" as const }, + { id: "decision-1", label: "Lazy Consensus", state: "Unselected" }, + { id: "decision-2", label: "Modified Consensus", state: "Unselected" }, ], onChipClick: (categoryName: string, chipId: string) => { setRuleCardCategories((prev) => @@ -326,7 +332,7 @@ export default function ComponentsPreview() { opt.id === chipId ? { ...opt, - state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + state: opt.state === "Selected" ? "Unselected" : "Selected", } : opt ), @@ -344,7 +350,7 @@ export default function ComponentsPreview() { ...cat, chipOptions: [ ...cat.chipOptions, - { id: newId, label: "", state: "Custom" as const }, + { id: newId, label: "", state: "Custom" }, ], } : cat @@ -359,7 +365,7 @@ export default function ComponentsPreview() { ...cat, chipOptions: cat.chipOptions.map((opt) => opt.id === chipId - ? { ...opt, label: value, state: "Selected" as const } + ? { ...opt, label: value, state: "Selected" } : opt ), } @@ -383,8 +389,8 @@ export default function ComponentsPreview() { { name: "Conflict management", chipOptions: [ - { id: "conflict-1", label: "Code of Conduct", state: "Unselected" as const }, - { id: "conflict-2", label: "Restorative Justice", state: "Unselected" as const }, + { id: "conflict-1", label: "Code of Conduct", state: "Unselected" }, + { id: "conflict-2", label: "Restorative Justice", state: "Unselected" }, ], onChipClick: (categoryName: string, chipId: string) => { setRuleCardCategories((prev) => @@ -396,7 +402,7 @@ export default function ComponentsPreview() { opt.id === chipId ? { ...opt, - state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + state: opt.state === "Selected" ? "Unselected" : "Selected", } : opt ), @@ -414,7 +420,7 @@ export default function ComponentsPreview() { ...cat, chipOptions: [ ...cat.chipOptions, - { id: newId, label: "", state: "Custom" as const }, + { id: newId, label: "", state: "Custom" }, ], } : cat @@ -429,7 +435,7 @@ export default function ComponentsPreview() { ...cat, chipOptions: cat.chipOptions.map((opt) => opt.id === chipId - ? { ...opt, label: value, state: "Selected" as const } + ? { ...opt, label: value, state: "Selected" } : opt ), } @@ -520,7 +526,7 @@ export default function ComponentsPreview() { setCustomChips((prev) => prev.map((c) => c.id === chip.id - ? { ...c, label: value, state: "Selected" as const } + ? { ...c, label: value, state: "Selected" } : c ) ); @@ -538,7 +544,7 @@ export default function ComponentsPreview() { c.id === chip.id ? { ...c, - state: c.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + state: c.state === "Selected" ? "Unselected" : "Selected", } : c ) diff --git a/app/components/AskOrganizer/AskOrganizer.container.tsx b/app/components/AskOrganizer/AskOrganizer.container.tsx index 831f4ff..1933187 100644 --- a/app/components/AskOrganizer/AskOrganizer.container.tsx +++ b/app/components/AskOrganizer/AskOrganizer.container.tsx @@ -11,7 +11,7 @@ import type { import { normalizeAskOrganizerVariant } from "../../../lib/propNormalization"; const VARIANT_STYLES: Record< - AskOrganizerVariant, + "centered" | "left-aligned" | "compact" | "inverse", { container: string; buttonContainer: string } > = { centered: { diff --git a/app/components/Checkbox/Checkbox.types.ts b/app/components/Checkbox/Checkbox.types.ts index 8faefb9..c6cd369 100644 --- a/app/components/Checkbox/Checkbox.types.ts +++ b/app/components/Checkbox/Checkbox.types.ts @@ -30,7 +30,7 @@ export interface CheckboxViewProps { labelId: string; checked: boolean; mode: "standard" | "inverse"; - state: "default" | "hover" | "focus"; + state: "default" | "hover" | "focus" | "selected"; disabled: boolean; label?: string; name?: string; diff --git a/app/components/Chip/Chip.view.tsx b/app/components/Chip/Chip.view.tsx index a8a91a1..2402994 100644 --- a/app/components/Chip/Chip.view.tsx +++ b/app/components/Chip/Chip.view.tsx @@ -134,12 +134,12 @@ function ChipView({ .filter(Boolean) .join(" "); - const handleClick: React.MouseEventHandler = (event) => { + const handleClick = (event: React.MouseEvent) => { if (isDisabled) { event.preventDefault(); return; } - onClick?.(event); + onClick?.(event as React.MouseEvent); }; const sharedA11y = { diff --git a/app/components/MultiSelect/MultiSelect.types.ts b/app/components/MultiSelect/MultiSelect.types.ts index d834320..9c035cc 100644 --- a/app/components/MultiSelect/MultiSelect.types.ts +++ b/app/components/MultiSelect/MultiSelect.types.ts @@ -1,4 +1,4 @@ -import type { ChipStateValue, ChipSizeValue, ChipPaletteValue } from "../../../lib/propNormalization"; +import type { ChipStateValue, ChipPaletteValue } from "../../../lib/propNormalization"; export interface ChipOption { id: string; diff --git a/app/components/RadioGroup/RadioGroup.types.ts b/app/components/RadioGroup/RadioGroup.types.ts index db44f87..f1588b5 100644 --- a/app/components/RadioGroup/RadioGroup.types.ts +++ b/app/components/RadioGroup/RadioGroup.types.ts @@ -32,7 +32,7 @@ export interface RadioGroupViewProps { groupId: string; value?: string; mode: "standard" | "inverse"; - state: "default" | "hover" | "focus"; + state: "default" | "hover" | "focus" | "selected"; disabled: boolean; options: RadioOption[]; className: string; diff --git a/app/components/RuleStack/RuleStack.view.tsx b/app/components/RuleStack/RuleStack.view.tsx index d7533c2..788db53 100644 --- a/app/components/RuleStack/RuleStack.view.tsx +++ b/app/components/RuleStack/RuleStack.view.tsx @@ -48,7 +48,6 @@ export function RuleStackView({ // Icon sizes: XS=40px, S=56px, M=56px, L=90px // Use a large default (90px) and let CSS handle responsive sizing - const iconSize = 90; // Card data const cards = [ diff --git a/app/components/SelectInput/SelectInput.container.tsx b/app/components/SelectInput/SelectInput.container.tsx index 6318265..76add96 100644 --- a/app/components/SelectInput/SelectInput.container.tsx +++ b/app/components/SelectInput/SelectInput.container.tsx @@ -42,6 +42,9 @@ const SelectInputContainer = forwardRef( // Note: labelVariant and size are normalized for future use but not yet implemented in the view const _labelVariant = labelVariantProp ? normalizeLabelVariant(labelVariantProp) : undefined; const _size = sizeProp ? normalizeSmallMediumLargeSize(sizeProp) : undefined; + // Mark as intentionally unused for future implementation + void _labelVariant; + void _size; const externalState = normalizeState(externalStateProp); const generatedId = useId(); diff --git a/app/components/SelectInput/SelectInput.view.tsx b/app/components/SelectInput/SelectInput.view.tsx index 26ea67b..ba8b9a7 100644 --- a/app/components/SelectInput/SelectInput.view.tsx +++ b/app/components/SelectInput/SelectInput.view.tsx @@ -7,7 +7,7 @@ import type { SelectOptionData } from "./SelectInput.types"; export interface SelectInputViewProps { label?: string; placeholder: string; - state: "default" | "active" | "hover" | "focus"; + state: "default" | "active" | "hover" | "focus" | "selected"; disabled: boolean; error: boolean; className: string; diff --git a/app/components/Switch/Switch.types.ts b/app/components/Switch/Switch.types.ts index 93569ad..2bccb8b 100644 --- a/app/components/Switch/Switch.types.ts +++ b/app/components/Switch/Switch.types.ts @@ -24,7 +24,7 @@ export interface SwitchProps extends Omit< export interface SwitchViewProps { switchId: string; checked: boolean; - state: "default" | "hover" | "focus"; + state: "default" | "hover" | "focus" | "selected"; label?: string; className: string; switchClasses: string; diff --git a/app/components/Toggle/Toggle.types.ts b/app/components/Toggle/Toggle.types.ts index a0fe919..cd09bf5 100644 --- a/app/components/Toggle/Toggle.types.ts +++ b/app/components/Toggle/Toggle.types.ts @@ -31,7 +31,7 @@ export interface ToggleViewProps { labelId: string; checked: boolean; disabled: boolean; - state: "default" | "hover" | "focus"; + state: "default" | "hover" | "focus" | "selected"; label?: string; showIcon: boolean; showText: boolean; diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index 9ebd610..3a1071b 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -142,13 +142,13 @@ export function normalizeVariant( */ export function normalizeSize( value: string | undefined, - defaultValue: "xsmall" = "xsmall" + defaultValue: "xsmall" | "small" | "medium" | "large" | "xlarge" = "xsmall" ): "xsmall" | "small" | "medium" | "large" | "xlarge" { if (!value) return defaultValue; const normalized = value.toLowerCase(); const sizes = ["xsmall", "small", "medium", "large", "xlarge"]; if (sizes.includes(normalized)) { - return normalized as typeof defaultValue; + return normalized as "xsmall" | "small" | "medium" | "large" | "xlarge"; } return defaultValue; } -- 2.43.0