Merge pull request 'Rule Card and Rule Stack Update' (#39) from adilallo/component/RuleCardUpdate into main

Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
2026-02-06 00:46:45 +00:00
33 changed files with 2843 additions and 323 deletions
+737 -201
View File
@@ -1,20 +1,462 @@
"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 Chip from "../components/Chip";
import MultiSelect from "../components/MultiSelect";
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";
}
// MultiSelect example component with state management
function MultiSelectExample({ size }: { size: "S" | "M" }) {
const [options, setOptions] = useState<Array<{ id: string; label: string; state: "Unselected" | "Selected" | "Custom" }>>([
{ 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) => {
setOptions((prev) =>
prev.map((opt) =>
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? "Unselected" : "Selected",
}
: opt
)
);
};
const handleAddClick = () => {
const newId = `custom-${Date.now()}`;
setOptions((prev) => [
...prev,
{ id: newId, label: "", state: "Custom" },
]);
};
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 (
<div className="space-y-[var(--spacing-scale-016)]">
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
{size === "S" ? "Small (S)" : "Medium (M)"}
</h3>
<MultiSelect
label="Label"
showHelpIcon={true}
size={size}
options={options}
onChipClick={handleChipClick}
onAddClick={handleAddClick}
onCustomChipConfirm={handleCustomConfirm}
onCustomChipClose={handleCustomClose}
addButtonText="Add organization type"
/>
</div>
);
}
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<string[]>([]);
const [radioValue, setRadioValue] = useState("");
const [inverseRadioValue, setInverseRadioValue] = useState("");
const [chipStates, setChipStates] = useState<Record<string, "Unselected" | "Selected">>({
"default-s": "Unselected",
"default-m": "Unselected",
"inverse-s": "Unselected",
"inverse-m": "Unselected",
});
// Manage custom chips separately
const [customChips, setCustomChips] = useState<ChipData[]>([
{ id: "custom-1", label: "", state: "Custom", palette: "Default", size: "S" },
{ id: "custom-2", label: "", state: "Custom", palette: "Default", size: "M" },
]);
// RuleCard categories with chip options and state management
const [ruleCardCategories, setRuleCardCategories] = useState<Array<{
name: string;
chipOptions: Array<{ id: string; label: string; state: "Unselected" | "Selected" | "Custom" }>;
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" },
{ 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) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? "Unselected" : "Selected",
}
: 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" },
],
}
: 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" }
: 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",
chipOptions: [
{ id: "comm-1", label: "Signal", state: "Unselected" },
],
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" : "Selected",
}
: 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" },
],
}
: 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" }
: 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",
chipOptions: [
{ id: "membership-1", label: "Open Admission", state: "Unselected" },
],
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" : "Selected",
}
: 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" },
],
}
: 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" }
: 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",
chipOptions: [
{ id: "decision-1", label: "Lazy Consensus", state: "Unselected" },
{ id: "decision-2", label: "Modified Consensus", state: "Unselected" },
],
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" : "Selected",
}
: 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" },
],
}
: 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" }
: 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",
chipOptions: [
{ id: "conflict-1", label: "Code of Conduct", state: "Unselected" },
{ id: "conflict-2", label: "Restorative Justice", state: "Unselected" },
],
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" : "Selected",
}
: 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" },
],
}
: 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" }
: 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 (
<div className="min-h-screen bg-[var(--color-surface-default-primary)] p-[var(--spacing-scale-032)]">
@@ -24,86 +466,166 @@ export default function ComponentsPreview() {
Component Preview
</h1>
<p className="font-inter text-[18px] leading-[24px] text-[var(--color-content-default-secondary)]">
Temporary page for viewing and testing new components
RuleCard and Chip component examples - states, palettes, sizes, and interactions
</p>
</header>
{/* Text Input Section */}
{/* Chip Component - Controls */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
Text Input Component
Chip Component (Controls)
</h2>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
{/* Default palette */}
<div className="space-y-[var(--spacing-scale-016)]">
<div>
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]">
States
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
Default palette
</h3>
<div className="space-y-[var(--spacing-scale-016)]">
<TextInput
label="Default Text Input"
placeholder="Enter text"
value={defaultInputValue}
onChange={(e) => setDefaultInputValue(e.target.value)}
/>
<TextInput
label="Interactive Text Input (click = active, tab = focus)"
placeholder="Enter text"
value={activeInputValue}
onChange={(e) => setActiveInputValue(e.target.value)}
/>
<TextInput
label="Disabled Text Input"
placeholder="Enter text"
value=""
disabled
/>
<TextInput
label="Error Text Input"
placeholder="Enter text"
value={errorInputValue}
onChange={(e) => setErrorInputValue(e.target.value)}
error
/>
</div>
<div className="flex flex-wrap items-center gap-[var(--spacing-scale-016)]">
<Chip
label="Small"
state={chipStates["default-s"]}
palette="Default"
size="S"
onClick={() =>
setChipStates((prev) => ({
...prev,
"default-s": prev["default-s"] === "Selected" ? "Unselected" : "Selected",
}))
}
/>
<Chip
label="Medium"
state={chipStates["default-m"]}
palette="Default"
size="M"
onClick={() =>
setChipStates((prev) => ({
...prev,
"default-m": prev["default-m"] === "Selected" ? "Unselected" : "Selected",
}))
}
/>
<Chip
label="Disabled"
state="Disabled"
palette="Default"
size="S"
/>
{customChips
.filter((chip) => chip.palette === "Default")
.map((chip) => (
<Chip
key={chip.id}
label={chip.state === "Custom" ? "" : chip.label}
state={chip.state}
palette={chip.palette}
size={chip.size}
onCheck={(value, e) => {
e.stopPropagation();
setCustomChips((prev) =>
prev.map((c) =>
c.id === chip.id
? { ...c, label: value, state: "Selected" }
: 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" : "Selected",
}
: c
)
);
}
}}
/>
))}
{/* Add new custom chip button - Ghost button style */}
<button
type="button"
onClick={() => {
const newId = `custom-${Date.now()}`;
setCustomChips((prev) => [
...prev,
{ id: newId, label: "", state: "Custom", palette: "Default", size: "S" },
]);
}}
className="flex gap-[var(--measures-spacing-050,2px)] items-center justify-center p-[var(--measures-spacing-200,8px)] rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity"
>
{/* Plus icon */}
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-[var(--color-content-default-brand-primary,#fefcc9)] shrink-0"
>
<path
d="M7 3V11M3 7H11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{/* Text */}
<span className="font-inter font-medium text-[length:var(--sizing-300,12px)] leading-[14px] text-[color:var(--color-content-default-brand-primary,#fefcc9)]">
Add Applicable Scope
</span>
</button>
</div>
</div>
</div>
</section>
{/* Checkbox Section */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
Checkbox Component
</h2>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
<div className="space-y-[var(--spacing-scale-016)]">
<div>
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]">
Standard Mode
</h3>
{/* Inverse palette - on white background */}
<div className="space-y-[var(--spacing-scale-016)]">
<Checkbox
label="Standard Checkbox"
checked={standardCheckbox}
mode="standard"
onChange={({ checked }) => setStandardCheckbox(checked)}
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
Inverse palette (on white background)
</h3>
<div className="!bg-white p-[var(--spacing-scale-032)] rounded-[var(--radius-300,12px)]" style={{ backgroundColor: '#ffffff' }}>
<div className="flex flex-wrap items-center gap-[var(--spacing-scale-016)]">
<Chip
label="Small"
state={chipStates["inverse-s"]}
palette="Inverse"
size="S"
onClick={() =>
setChipStates((prev) => ({
...prev,
"inverse-s": prev["inverse-s"] === "Selected" ? "Unselected" : "Selected",
}))
}
/>
</div>
</div>
<div>
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]">
Inverse Mode
</h3>
<div className="space-y-[var(--spacing-scale-016)]">
<Checkbox
label="Inverse Checkbox"
checked={inverseCheckbox}
mode="inverse"
onChange={({ checked }) => setInverseCheckbox(checked)}
<Chip
label="Medium"
state={chipStates["inverse-m"]}
palette="Inverse"
size="M"
onClick={() =>
setChipStates((prev) => ({
...prev,
"inverse-m": prev["inverse-m"] === "Selected" ? "Unselected" : "Selected",
}))
}
/>
<Chip
label="Disabled"
state="Disabled"
palette="Inverse"
size="S"
/>
</div>
</div>
@@ -111,149 +633,163 @@ export default function ComponentsPreview() {
</div>
</section>
{/* Checkbox Group Section */}
{/* Collapsed State - Large */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
Collapsed State - Large (L)
</h2>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
<RuleCard
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={false}
size="L"
className="w-[525px]"
logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png"
logoAlt="Mutual Aid Mondays"
onClick={() => console.log("Card clicked: Mutual Aid Mondays")}
/>
</div>
</section>
{/* Collapsed State - Medium */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
Collapsed State - Medium (M)
</h2>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
<RuleCard
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={false}
size="M"
className="w-[289px]"
logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png"
logoAlt="Mutual Aid Mondays"
onClick={() => console.log("Card clicked: Mutual Aid Mondays")}
/>
</div>
</section>
{/* Expanded State - Large */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
Checkbox Group Component
Expanded State - Large (L)
</h2>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
<div className="space-y-[var(--spacing-scale-016)]">
<div>
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]">
Standard Mode
</h3>
<div className="space-y-[var(--spacing-scale-016)]">
<CheckboxGroup
name="standard-checkbox-group"
value={checkboxGroupValues}
onChange={({ value }) => setCheckboxGroupValues(value)}
mode="standard"
options={[
{ value: "option1", label: "Checkbox label" },
{
value: "option2",
label: "Checkbox label",
subtext: "Nunc sed hendrerit consequat.",
},
]}
<RuleCard
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="L"
className="w-[568px]"
logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png"
logoAlt="Mutual Aid Mondays"
categories={ruleCardCategories}
onClick={() => console.log("Card clicked: Mutual Aid Mondays")}
/>
</div>
</div>
<div>
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]">
Inverse Mode
</h3>
<div className="space-y-[var(--spacing-scale-016)]">
<CheckboxGroup
name="inverse-checkbox-group"
value={checkboxGroupValues}
onChange={({ value }) => setCheckboxGroupValues(value)}
mode="inverse"
options={[
{ value: "option3", label: "Checkbox label" },
{
value: "option4",
label: "Checkbox label",
subtext: "Nunc sed hendrerit consequat.",
},
]}
/>
</div>
</div>
</div>
</section>
{/* Expanded State - Medium */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
Expanded State - Medium (M)
</h2>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
<RuleCard
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"
className="w-[398px]"
logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png"
logoAlt="Mutual Aid Mondays"
categories={ruleCardCategories}
onClick={() => console.log("Card clicked: Mutual Aid Mondays")}
/>
</div>
</section>
{/* Radio Group Section */}
{/* Different Background Colors */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
Radio Group Component
Different Background Colors
</h2>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
<div className="space-y-[var(--spacing-scale-016)]">
<div>
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]">
Standard Mode
</h3>
<div className="space-y-[var(--spacing-scale-016)]">
<RadioGroup
name="default-radio"
value={radioValue}
onChange={({ value }) => setRadioValue(value)}
mode="standard"
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
<div className="grid grid-cols-1 md:grid-cols-2 gap-[var(--spacing-scale-024)]">
<RuleCard
title="Consensus clusters"
description="Units called Circles have the ability to decide and act on matters in their domains."
backgroundColor="bg-[var(--color-surface-default-brand-lime)]"
expanded={false}
size="L"
className="w-[525px]"
icon={
<Image
src={getAssetPath("assets/Icon_Sociocracy.svg")}
alt="Sociocracy"
width={103}
height={103}
/>
<RadioGroup
name="interactive-radio"
value={radioValue}
onChange={({ value }) => setRadioValue(value)}
mode="standard"
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
}
onClick={() => console.log("Consensus clusters selected")}
/>
<RuleCard
title="Consensus"
description="Decisions that affect the group collectively should involve participation of all participants."
backgroundColor="bg-[var(--color-surface-default-brand-rust)]"
expanded={false}
size="L"
className="w-[525px]"
icon={
<Image
src={getAssetPath("assets/Icon_Consensus.svg")}
alt="Consensus"
width={103}
height={103}
/>
<RadioGroup
name="disabled-radio"
value=""
mode="standard"
disabled
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
}
onClick={() => console.log("Consensus selected")}
/>
</div>
</div>
<div>
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]">
Inverse Mode
</h3>
<div className="space-y-[var(--spacing-scale-016)]">
<RadioGroup
name="inverse-default-radio"
value={inverseRadioValue}
onChange={({ value }) => setInverseRadioValue(value)}
mode="inverse"
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
/>
<RadioGroup
name="inverse-interactive-radio"
value={inverseRadioValue}
onChange={({ value }) => setInverseRadioValue(value)}
mode="inverse"
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
/>
<RadioGroup
name="inverse-disabled-radio"
value=""
mode="inverse"
disabled
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
</section>
{/* Logo Fallback */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
Logo Fallback (Community Initials)
</h2>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
<RuleCard
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"
className="w-[525px]"
communityInitials="CE"
onClick={() => console.log("Community Example selected")}
/>
</div>
</div>
</div>
</section>
{/* MultiSelect Component */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
MultiSelect Component (Controls)
</h2>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
{/* Small size */}
<MultiSelectExample size="S" />
{/* Medium size */}
<MultiSelectExample size="M" />
</div>
</section>
</div>
@@ -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: {
+1 -1
View File
@@ -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;
+98
View File
@@ -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<ChipProps>(
({
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<HTMLInputElement>(null);
// Focus input when custom state is active
useEffect(() => {
if (isCustom && inputRef.current) {
inputRef.current.focus();
}
}, [isCustom]);
const handleCheck = (value: string, event: React.MouseEvent<HTMLButtonElement>) => {
if (onCheck && value.trim()) {
onCheck(value.trim(), event);
// Reset input after successful check
setInputValue("");
}
};
const handleClose = (event: React.MouseEvent<HTMLButtonElement>) => {
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<HTMLInputElement>) => {
if (event.key === "Enter" && inputValue.trim() && onCheck) {
event.preventDefault();
handleCheck(inputValue.trim(), event as unknown as React.MouseEvent<HTMLButtonElement>);
} else if (event.key === "Escape" && onClose) {
event.preventDefault();
handleClose(event as unknown as React.MouseEvent<HTMLButtonElement>);
}
};
return (
<ChipView
label={label}
state={state}
palette={palette}
size={size}
className={className}
disabled={isDisabled}
onClick={onClick}
onRemove={onRemove}
onCheck={handleCheck}
onClose={handleClose}
inputValue={isCustom ? inputValue : undefined}
onInputChange={isCustom ? setInputValue : undefined}
onInputKeyDown={isCustom ? handleKeyDown : undefined}
inputRef={isCustom ? inputRef : undefined}
ariaLabel={ariaLabel}
/>
);
},
);
ChipContainer.displayName = "Chip";
export default ChipContainer;
+71
View File
@@ -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<HTMLButtonElement>) => void;
/**
* Optional remove/close handler for chips that can be dismissed.
*/
onRemove?: (event: React.MouseEvent<HTMLButtonElement>) => 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<HTMLButtonElement>) => void;
/**
* Optional callback when custom chip is closed/removed.
*/
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => 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<HTMLButtonElement>) => void;
onRemove?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onCheck?: (value: string, event: React.MouseEvent<HTMLButtonElement>) => void;
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void;
inputValue?: string;
onInputChange?: (value: string) => void;
onInputKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
inputRef?: React.RefObject<HTMLInputElement>;
ariaLabel?: string;
}
+287
View File
@@ -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 = (event: React.MouseEvent<HTMLButtonElement | HTMLDivElement>) => {
if (isDisabled) {
event.preventDefault();
return;
}
onClick?.(event as React.MouseEvent<HTMLButtonElement>);
};
const sharedA11y = {
"aria-label": ariaLabel,
};
// Custom state rendering with check/close buttons
if (isCustom) {
return (
<div
className={combinedClasses}
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick(e as unknown as React.MouseEvent<HTMLButtonElement>);
}
}}
{...sharedA11y}
>
<div className={`flex items-center ${isSmall ? "gap-[8px]" : "gap-[12px]"}`}>
{/* Check button */}
{onCheck && (
<button
type="button"
className="flex items-center justify-center p-[var(--measures-spacing-150,6px)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.1))] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Confirm"
disabled={!inputValue || !inputValue.trim()}
onClick={(event) => {
event.stopPropagation();
// The container's handleCheck will get the value from state
if (inputValue && inputValue.trim() && onCheck) {
onCheck(inputValue.trim(), event);
}
}}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
>
<path
d="M10 3L4.5 8.5L2 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
{/* Input field */}
<div className="flex items-center flex-1 min-w-0">
<input
ref={inputRef}
type="text"
value={inputValue ?? ""}
onChange={(e) => 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()}
/>
</div>
{/* Close button */}
{onClose && (
<button
type="button"
className="flex items-center justify-center p-[var(--measures-spacing-150,6px)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.1))] transition-colors"
aria-label="Close"
onClick={(event) => {
event.stopPropagation();
onClose(event);
}}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
>
<path
d="M9 3L3 9M3 3L9 9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</div>
</div>
);
}
// Regular state rendering
return (
<button
type="button"
className={combinedClasses}
disabled={isDisabled}
onClick={handleClick}
{...sharedA11y}
>
<span className="flex items-center justify-center">
{label}
</span>
{onRemove && !isDisabled && (
<button
type="button"
className="ml-[var(--measures-spacing-050,2px)] p-[var(--measures-spacing-050,2px)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.05))]"
aria-label={`Remove ${label}`}
onClick={(event) => {
event.stopPropagation();
onRemove(event);
}}
>
<span className="block w-[12px] h-[12px] leading-none text-[10px]">
×
</span>
</button>
)}
</button>
);
}
ChipView.displayName = "ChipView";
export default memo(ChipView);
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./Chip.container";
export type { ChipProps } from "./Chip.types";
@@ -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<InputLabelProps>(
({
label,
helpIcon = false,
asterisk = false,
helperText = false,
size: sizeProp = "S",
palette: paletteProp = "Default",
className = "",
}) => {
const size = normalizeInputLabelSize(sizeProp);
const palette = normalizeInputLabelPalette(paletteProp);
return (
<InputLabelView
label={label}
helpIcon={helpIcon}
asterisk={asterisk}
helperText={helperText}
size={size}
palette={palette}
className={className}
/>
);
},
);
InputLabelContainer.displayName = "InputLabel";
export default InputLabelContainer;
@@ -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;
}
@@ -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 (
<div className={`${containerClass} ${className}`}>
<div className={labelContainerClass}>
<div className="flex gap-px items-start relative shrink-0">
<p
className={`font-inter font-normal ${labelTextSize} ${labelColor} relative shrink-0`}
>
{label}
</p>
{asterisk && (
<p
className={`font-inter font-medium ${asteriskSize} relative shrink-0 text-[color:var(--color-content-default-negative-primary,#ea4845)]`}
>
*
</p>
)}
</div>
{helpIcon && (
<div className={`relative shrink-0 ${helpIconSize}`}>
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt="Help"
className="block max-w-none size-full"
style={
helpIconFilter
? {
filter: helpIconFilter,
}
: undefined
}
/>
</div>
)}
</div>
{helperText && (
<p
className={`flex-[1_0_0] font-inter font-normal ${helperTextSize} min-h-px min-w-px relative ${helperTextColor} text-right`}
>
{typeof helperText === "string" ? helperText : "Optional text"}
</p>
)}
</div>
);
}
InputLabelView.displayName = "InputLabelView";
export default memo(InputLabelView);
+3
View File
@@ -0,0 +1,3 @@
import InputLabel from "./InputLabel.container";
export default InputLabel;
@@ -0,0 +1,47 @@
"use client";
import { memo } from "react";
import MultiSelectView from "./MultiSelect.view";
import type { MultiSelectProps } from "./MultiSelect.types";
import { normalizeMultiSelectSize, normalizeChipPalette } from "../../../lib/propNormalization";
const MultiSelectContainer = memo<MultiSelectProps>(
({
label,
showHelpIcon = true,
size: sizeProp = "M",
palette: paletteProp = "Default",
options,
onChipClick,
onAddClick,
showAddButton = true,
addButtonText = "Add organization type",
onCustomChipConfirm,
onCustomChipClose,
className = "",
}) => {
const size = normalizeMultiSelectSize(sizeProp);
const palette = normalizeChipPalette(paletteProp);
return (
<MultiSelectView
label={label}
showHelpIcon={showHelpIcon}
size={size}
palette={palette}
options={options}
onChipClick={onChipClick}
onAddClick={onAddClick}
showAddButton={showAddButton}
addButtonText={addButtonText}
onCustomChipConfirm={onCustomChipConfirm}
onCustomChipClose={onCustomChipClose}
className={className}
/>
);
},
);
MultiSelectContainer.displayName = "MultiSelect";
export default MultiSelectContainer;
@@ -0,0 +1,74 @@
import type { ChipStateValue, ChipPaletteValue } 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;
/**
* Palette for chips: "Default" or "Inverse"
* Accepts both PascalCase (Figma) and lowercase values.
*/
palette?: ChipPaletteValue;
/**
* 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";
palette: "default" | "inverse";
options: ChipOption[];
onChipClick?: (chipId: string) => void;
onAddClick?: () => void;
showAddButton: boolean;
addButtonText: string;
onCustomChipConfirm?: (chipId: string, value: string) => void;
onCustomChipClose?: (chipId: string) => void;
className: string;
}
@@ -0,0 +1,125 @@
"use client";
import { memo } from "react";
import Chip from "../Chip";
import InputLabel from "../InputLabel";
import type { MultiSelectViewProps } from "./MultiSelect.types";
function MultiSelectView({
label,
showHelpIcon,
size,
palette,
options,
onChipClick,
onAddClick,
showAddButton,
addButtonText,
onCustomChipConfirm,
onCustomChipClose,
className,
}: MultiSelectViewProps) {
const isSmall = size === "s";
const isInverse = palette === "inverse";
// Size-based spacing
const gapClass = isSmall
? "gap-[var(--measures-spacing-200,8px)]"
: "gap-[var(--measures-spacing-300,12px)]";
const chipSize = isSmall ? "S" : "M";
return (
<div className={`flex flex-col ${isSmall ? "gap-[var(--measures-spacing-200,8px)]" : "gap-[var(--measures-spacing-300,12px)]"} items-start relative w-full ${className}`}>
{/* Label using InputLabel component */}
{label && (
<InputLabel
label={label}
helpIcon={showHelpIcon}
asterisk={false}
helperText={false}
size={size === "s" ? "S" : "M"}
palette={palette === "inverse" ? "Inverse" : "Default"}
/>
)}
{/* Chips container */}
<div className={`flex flex-wrap ${gapClass} items-center relative shrink-0 w-full`}>
{options.map((option) => (
<Chip
key={option.id}
label={option.state === "Custom" ? "" : option.label}
state={option.state || "Unselected"}
palette={palette === "inverse" ? "Inverse" : "Default"}
size={chipSize}
onClick={() => {
// 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 - Circular button with border (not ghost) when no text, ghost style when text provided */}
{showAddButton && (
<button
type="button"
aria-label={addButtonText || "Add option"}
onClick={(e) => {
e.stopPropagation();
onAddClick?.();
}}
className={
!addButtonText
? // Circular button with border (RuleCard style)
`bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`
: // Ghost button style (standalone MultiSelect)
`flex ${isSmall ? "gap-[var(--measures-spacing-050,2px)]" : "gap-[var(--measures-spacing-150,6px)]"} items-center justify-center ${isSmall ? "p-[var(--measures-spacing-200,8px)]" : "p-[var(--measures-spacing-300,12px)]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`
}
>
{/* Plus icon */}
<svg
width={isSmall ? "14" : "20"}
height={isSmall ? "14" : "20"}
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`${isInverse ? "text-[var(--color-content-inverse-primary,black)]" : "text-[var(--color-content-default-brand-primary,#fefcc9)]"} shrink-0`}
>
<path
d="M7 3V11M3 7H11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{/* Text - only show if addButtonText is provided */}
{addButtonText && (
<span className={`font-inter font-medium ${isSmall ? "text-[length:var(--sizing-300,12px)] leading-[14px]" : "text-[length:var(--sizing-400,16px)] leading-[20px]"} ${isInverse ? "text-[color:var(--color-content-inverse-primary,black)]" : "text-[color:var(--color-content-default-brand-primary,#fefcc9)]"}`}>
{addButtonText}
</span>
)}
</button>
)}
</div>
</div>
);
}
MultiSelectView.displayName = "MultiSelectView";
export default memo(MultiSelectView);
+1
View File
@@ -0,0 +1 @@
export { default } from "./MultiSelect.container";
@@ -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;
@@ -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,16 @@ const RuleCardContainer = memo<RuleCardProps>(
backgroundColor = "bg-[var(--color-community-teal-100)]",
className = "",
onClick,
expanded = false,
size: sizeProp,
categories,
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 +72,12 @@ const RuleCardContainer = memo<RuleCardProps>(
className={className}
onClick={handleClick}
onKeyDown={handleKeyDown}
expanded={expanded}
size={size}
categories={categories}
logoUrl={logoUrl}
logoAlt={logoAlt}
communityInitials={communityInitials}
/>
);
},
+23
View File
@@ -1,3 +1,14 @@
import type { ChipOption } from "../MultiSelect/MultiSelect.types";
export interface Category {
name: 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 {
title: string;
description?: string;
@@ -5,6 +16,12 @@ export interface RuleCardProps {
backgroundColor?: string;
className?: string;
onClick?: () => void;
expanded?: boolean;
size?: "XS" | "S" | "M" | "L" | "xs" | "s" | "m" | "l";
categories?: Category[];
logoUrl?: string;
logoAlt?: string;
communityInitials?: string;
}
export interface RuleCardViewProps {
@@ -15,4 +32,10 @@ export interface RuleCardViewProps {
className: string;
onClick: () => void;
onKeyDown: (_event: React.KeyboardEvent<HTMLDivElement>) => void;
expanded: boolean;
size: "XS" | "S" | "M" | "L";
categories?: Category[];
logoUrl?: string;
logoAlt?: string;
communityInitials?: string;
}
+234 -12
View File
@@ -1,6 +1,8 @@
"use client";
import Image from "next/image";
import { useTranslation } from "../../contexts/MessagesContext";
import MultiSelect from "../MultiSelect";
import type { RuleCardViewProps } from "./RuleCard.types";
export function RuleCardView({
@@ -11,40 +13,260 @@ export function RuleCardView({
className,
onClick,
onKeyDown,
expanded,
size,
categories,
logoUrl,
logoAlt,
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";
const isMedium = size === "M";
const isSmall = size === "S";
const isExtraSmall = size === "XS";
// 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
? "gap-[12px]"
: "gap-[18px]"; // XS and S: 18px gap
const cardWidth = expanded
? isLarge
? "w-[568px]"
: isMedium
? "w-[398px]"
: "" // XS and S: no fixed width
: "";
// Logo/Icon dimensions - use CSS responsive classes
// For S: 80px container with 12px padding = 56px icon area
// 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 - 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
? "font-inter font-medium text-[18px] leading-[24px]"
: 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 = () => {
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");
const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity max-[639px]:p-[16px] min-[640px]:max-[1023px]:p-[12px]`;
if (isLocalhost) {
return (
<div className={containerClass}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={logoUrl}
alt={logoAlt || title}
width={logoSize}
height={logoSize}
className="w-full h-full object-cover rounded-full"
/>
</div>
);
}
return (
<div className={containerClass}>
<Image
src={logoUrl}
alt={logoAlt || title}
width={logoSize}
height={logoSize}
className="w-full h-full object-cover rounded-full"
/>
</div>
);
}
if (icon) {
return (
<div className={`${logoContainerClass} flex items-center justify-center max-[639px]:p-[16px] min-[640px]:max-[1023px]:p-[12px]`}>
{icon}
</div>
);
}
if (communityInitials) {
const initialsSize = `
max-[639px]:text-[16px]
min-[640px]:max-[1023px]:text-[20px]
min-[1024px]:max-[1439px]:text-[24px]
min-[1440px]:text-[36px]
`;
return (
<div className={`${logoContainerClass} rounded-full bg-[var(--color-surface-default-primary)] flex items-center justify-center`}>
<span className={`${initialsSize} font-bricolage-grotesque font-bold text-[var(--color-content-default-primary,white)]`}>
{communityInitials}
</span>
</div>
);
}
return null;
};
// 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 (
<div
className={`${backgroundColor} rounded-[var(--radius-measures-radius-small)] pt-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] pl-[var(--spacing-scale-012)] pb-[var(--spacing-scale-024)] md:p-[var(--spacing-scale-024)] md:h-[210px] lg:h-[277px] flex flex-col gap-[18px] shadow-lg backdrop-blur-sm transition-all duration-500 ease-in-out hover:shadow-xl hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--color-community-teal-500)] focus:ring-offset-2 cursor-pointer min-h-[44px] min-w-[44px] ${className}`}
className={`${backgroundColor} ${cardPadding} ${cardGap} ${borderRadiusClass} shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] hover:shadow-[0px_0px_64px_0px_rgba(0,0,0,0.15)] transition-shadow duration-200 flex flex-col items-start justify-center relative ${cardWidth || "w-full"} ${className || ""}`}
tabIndex={0}
role="button"
aria-label={ariaLabel}
aria-expanded={expanded}
onClick={onClick}
onKeyDown={onKeyDown}
>
{/* Header Container */}
<div className="grid grid-cols-[auto_1fr] h-[72px] md:h-[80px] lg:h-[138px] border-b border-[var(--color-surface-default-primary)]">
{/* Icon Container */}
{icon && (
<div className="p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-012)] lg:p-[var(--spacing-scale-024)] border-r border-[var(--color-surface-default-primary)] w-fit flex items-center justify-center">
{icon}
{/* Outermost container with bottom border - taller to match Figma */}
<div className={`
border-b border-black border-solid flex items-center relative shrink-0 w-full
max-[639px]:h-[72px]
min-[640px]:max-[1023px]:h-[80px]
min-[1024px]:max-[1439px]:h-[88px]
min-[1440px]:h-[136px]
`}>
{/* Logo/Icon - fixed width/height, vertically centered, does not touch bottom */}
{renderLogo() && (
<div className={`
flex items-center justify-center shrink-0
max-[639px]:w-[72px] max-[639px]:h-[72px] max-[639px]:border-r max-[639px]:border-black max-[639px]:border-solid
min-[640px]:max-[1023px]:w-[80px] min-[640px]:max-[1023px]:h-[80px] min-[640px]:max-[1023px]:border-r min-[640px]:max-[1023px]:border-black min-[640px]:max-[1023px]:border-solid
min-[1024px]:max-[1439px]:w-[56px] min-[1024px]:max-[1439px]:h-[56px]
min-[1440px]:w-[103px] min-[1440px]:h-[103px]
`}>
{renderLogo()}
</div>
)}
{/* Title Container */}
{/* Spacing between icon and title */}
<div className="
max-[1023px]:hidden
min-[1024px]:w-[16px] min-[1024px]:shrink-0
" />
{/* Container with no padding and left border - extends full height to touch bottom */}
{title && (
<div className="pl-[var(--spacing-scale-008)] md:pl-[var(--spacing-scale-012)] lg:pl-[var(--spacing-scale-024)] flex items-center gap-[var(--spacing-scale-004)]">
<h3 className="font-space-grotesk font-bold text-[20px] md:text-[28px] lg:text-[36px] leading-[28px] md:leading-[36px] lg:leading-[44px] text-[--color-content-inverse-primary]">
<div className={`
flex-1 min-w-0 h-full flex
max-[1023px]:border-0
min-[1024px]:border-l min-[1024px]:border-black min-[1024px]:border-solid
`}>
{/* Inner container for header text with padding */}
<div className={`
flex items-center justify-center w-full
max-[639px]:pl-[8px] max-[639px]:py-[8px]
min-[640px]:max-[1023px]:pl-[12px] min-[640px]:max-[1023px]:py-[12px]
min-[1024px]:max-[1439px]:px-[16px] min-[1024px]:max-[1439px]:py-[12px]
min-[1440px]:px-[16px] min-[1440px]:py-[24px]
`}>
<h3 className={`${titleClass} text-black overflow-hidden text-ellipsis w-full`}>
{title}
</h3>
</div>
</div>
)}
</div>
{expanded ? (
<>
{/* Categories Section - Using MultiSelect */}
{categories && categories.length > 0 && (
<div className="flex flex-col gap-[16px] items-start px-[12px] relative shrink-0 w-full">
{categories.map((category, categoryIndex) => (
<MultiSelect
key={categoryIndex}
label={category.name}
showHelpIcon={false}
size="S"
palette="Inverse"
options={category.chipOptions}
onChipClick={(chipId) => {
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"
/>
))}
</div>
)}
{/* Footer: Description */}
{description && (
<p className="font-inter font-medium text-[12px] md:text-[14px] lg:text-[18px] leading-[14px] md:leading-[16px] lg:leading-[24px] text-[var(--color-content-inverse-primary)]">
<div className="border-t border-black border-solid pt-[16px] relative shrink-0 w-full">
<p className={`${descriptionClass} text-black`}>
{description}
</p>
</div>
)}
</>
) : (
/* Collapsed State: Description */
description && (
<div className="flex items-center justify-center relative shrink-0 w-full">
<p className={`${descriptionClass} text-black flex-1`}>
{description}
</p>
</div>
)
)}
</div>
);
+142 -65
View File
@@ -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,152 @@ 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
// 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 (
<section
className={`w-full bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-048)] md:px-[var(--spacing-scale-032)] xmd:py-[var(--spacing-scale-056)] xmd:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] xl:px-[var(--spacing-scale-096)] flex flex-col gap-[var(--spacing-scale-024)] xmd:gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] ${className}`}
className={`
w-full bg-transparent flex flex-col
px-[20px] py-[32px]
min-[640px]:px-[32px] min-[640px]:py-[48px]
min-[768px]:py-[56px]
min-[1024px]:px-[64px] min-[1024px]:py-[64px]
min-[1440px]:px-[96px]
gap-[24px]
min-[640px]:gap-[32px]
min-[1024px]:gap-[40px]
${className}
`}
>
<div className="flex flex-col gap-[18px] xmd:grid xmd:grid-cols-2 lg:gap-[var(--spacing-scale-024)]">
<RuleCard
title={t("cards.consensusClusters.title")}
description={t("cards.consensusClusters.description")}
icon={
<Image
src={getAssetPath("assets/Icon_Sociocracy.svg")}
alt={t("cards.consensusClusters.iconAlt")}
width={40}
height={40}
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
/>
}
backgroundColor="bg-[var(--color-surface-default-brand-lime)]"
onClick={() => onTemplateClick(t("cards.consensusClusters.title"))}
/>
<RuleCard
title={t("cards.consensus.title")}
description={t("cards.consensus.description")}
icon={
<Image
src={getAssetPath("assets/Icon_Consensus.svg")}
alt={t("cards.consensus.iconAlt")}
width={40}
height={40}
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
/>
}
backgroundColor="bg-[var(--color-surface-default-brand-rust)]"
onClick={() => onTemplateClick(t("cards.consensus.title"))}
/>
<RuleCard
title={t("cards.electedBoard.title")}
description={t("cards.electedBoard.description")}
icon={
<Image
src={getAssetPath("assets/Icon_ElectedBoard.svg")}
alt={t("cards.electedBoard.iconAlt")}
width={40}
height={40}
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
/>
}
backgroundColor="bg-[var(--color-surface-default-brand-red)]"
onClick={() => onTemplateClick(t("cards.electedBoard.title"))}
/>
<RuleCard
title={t("cards.petition.title")}
description={t("cards.petition.description")}
icon={
<Image
src={getAssetPath("assets/Icon_Petition.svg")}
alt={t("cards.petition.iconAlt")}
width={40}
height={40}
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
/>
}
backgroundColor="bg-[var(--color-surface-default-brand-teal)]"
onClick={() => onTemplateClick(t("cards.petition.title"))}
/>
{/* Section Header */}
<SectionHeader
title={t("title")}
subtitle={t("subtitle")}
variant="multi-line"
/>
{/* Cards Container */}
<div
className={`
flex flex-col gap-[18px]
min-[768px]:grid min-[768px]:grid-cols-2 min-[768px]:gap-[18px]
min-[1024px]:gap-[24px]
`}
>
{cards.map((card, index) => (
<RuleCard
key={index}
title={card.title}
description={card.description}
size={cardSize}
className="
max-[639px]:rounded-[var(--measures-radius-200,8px)]
min-[640px]:max-[1023px]:rounded-[var(--measures-radius-300,12px)]
min-[1024px]:rounded-[var(--radius-measures-radius-small)]
max-[639px]:pb-[24px] max-[639px]:pt-[12px] max-[639px]:px-[12px]
min-[640px]:max-[1023px]:p-[24px]
min-[1024px]:max-[1439px]:p-[16px]
min-[1440px]:p-[24px]
max-[1023px]:gap-[18px]
min-[1024px]:max-[1439px]:gap-[12px]
min-[1440px]:gap-[10px]
"
icon={
<Image
src={getAssetPath(card.iconPath)}
alt={card.iconAlt}
width={90}
height={90}
className="
max-[639px]:w-[40px] max-[639px]:h-[40px]
min-[640px]:max-[1023px]:w-[56px] min-[640px]:max-[1023px]:h-[56px]
min-[1024px]:max-[1439px]:w-[56px] min-[1024px]:max-[1439px]:h-[56px]
min-[1440px]:w-[90px] min-[1440px]:h-[90px]
"
/>
}
backgroundColor={card.backgroundColor}
onClick={() => onTemplateClick(card.title)}
/>
))}
</div>
{/* See all templates button */}
<div className="flex justify-center">
<Button variant="outline" size="large">
{t("button.seeAllTemplates")}
<div className="
flex justify-center w-full
max-[767px]:mt-[var(--measures-spacing-600,24px)]
min-[768px]:max-[1023px]:mt-[var(--measures-spacing-800,32px)]
min-[1024px]:mt-[var(--measures-spacing-1000,40px)]
">
<Button
variant="outline"
size="large"
>
{buttonText}
</Button>
</div>
</section>
@@ -42,6 +42,9 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
// 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();
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+140 -2
View File
@@ -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;
}
@@ -512,3 +512,141 @@ 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"
): "XS" | "S" | "M" | "L" {
if (!value) return defaultValue;
const normalized = value.toUpperCase();
if (normalized === "XS" || normalized === "S" || normalized === "M" || normalized === "L") {
return normalized;
}
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;
}
/**
* 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;
}
/**
* 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;
}
+3
View File
@@ -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",
+8
View File
@@ -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() {
+266 -5
View File
@@ -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,6 +33,15 @@ 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: ["XS", "S", "M", "L", "xs", "s", "m", "l"],
description: "Size variant of the card",
},
onClick: { action: "clicked" },
},
tags: ["autodocs"],
@@ -44,6 +53,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: (
<Image
src="assets/Icon_Sociocracy.svg"
@@ -56,6 +67,226 @@ export const Default = {
},
};
export const Expanded = {
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: "L",
logoUrl: "http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png",
logoAlt: "Mutual Aid Mondays",
categories: [
{
name: "Values",
chipOptions: [
{ 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, chipId) => {
console.log(`Chip clicked: ${categoryName} - ${chipId}`);
},
onAddClick: (categoryName) => {
console.log(`Add clicked: ${categoryName}`);
},
},
{
name: "Communication",
chipOptions: [
{ id: "comm-1", label: "Signal", state: "Unselected" },
],
onChipClick: (categoryName, chipId) => {
console.log(`Chip clicked: ${categoryName} - ${chipId}`);
},
onAddClick: (categoryName) => {
console.log(`Add clicked: ${categoryName}`);
},
},
{
name: "Membership",
chipOptions: [
{ id: "membership-1", label: "Open Admission", state: "Unselected" },
],
onChipClick: (categoryName, chipId) => {
console.log(`Chip clicked: ${categoryName} - ${chipId}`);
},
onAddClick: (categoryName) => {
console.log(`Add clicked: ${categoryName}`);
},
},
{
name: "Decision-making",
chipOptions: [
{ id: "decision-1", label: "Lazy Consensus", state: "Unselected" },
{ id: "decision-2", label: "Modified Consensus", state: "Unselected" },
],
onChipClick: (categoryName, chipId) => {
console.log(`Chip clicked: ${categoryName} - ${chipId}`);
},
onAddClick: (categoryName) => {
console.log(`Add clicked: ${categoryName}`);
},
},
{
name: "Conflict management",
chipOptions: [
{ id: "conflict-1", label: "Code of Conduct", state: "Unselected" },
{ id: "conflict-2", label: "Restorative Justice", state: "Unselected" },
],
onChipClick: (categoryName, chipId) => {
console.log(`Chip clicked: ${categoryName} - ${chipId}`);
},
onAddClick: (categoryName) => {
console.log(`Add clicked: ${categoryName}`);
},
},
],
},
};
export const SizeLarge = {
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: "L",
icon: (
<Image
src="assets/Icon_Sociocracy.svg"
alt="Sociocracy"
width={103}
height={103}
/>
),
},
};
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: (
<Image
src="assets/Icon_Sociocracy.svg"
alt="Sociocracy"
width={56}
height={56}
/>
),
},
};
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: (
<Image
src="assets/Icon_Sociocracy.svg"
alt="Sociocracy"
width={56}
height={56}
/>
),
},
};
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: (
<Image
src="assets/Icon_Sociocracy.svg"
alt="Sociocracy"
width={8}
height={8}
/>
),
},
};
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",
chipOptions: [
{ 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" },
],
},
{
name: "Communication",
chipOptions: [
{ id: "comm-1", label: "Signal", state: "Unselected" },
],
},
{
name: "Membership",
chipOptions: [
{ id: "membership-1", label: "Open Admission", state: "Unselected" },
],
},
{
name: "Decision-making",
chipOptions: [
{ id: "decision-1", label: "Lazy Consensus", state: "Unselected" },
{ id: "decision-2", label: "Modified Consensus", state: "Unselected" },
],
},
{
name: "Conflict management",
chipOptions: [
{ id: "conflict-1", label: "Code of Conduct", state: "Unselected" },
{ id: "conflict-2", label: "Restorative Justice", state: "Unselected" },
],
},
],
},
};
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 +367,49 @@ 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: (
<div className="w-10 h-10 md:w-14 md:h-14 lg:w-[90px] lg:h-[90px] bg-white rounded-full flex items-center justify-center">
<span className="text-lg font-bold text-gray-800">?</span>
<div className="w-[103px] h-[103px] bg-white rounded-full flex items-center justify-center">
<span className="text-[36px] font-bold text-gray-800">?</span>
</div>
),
categories: [
{
name: "Values",
chipOptions: [
{ id: "values-1", label: "Consciousness", state: "Unselected" },
{ id: "values-2", label: "Ecology", state: "Unselected" },
{ id: "values-3", label: "Abundance", state: "Unselected" },
],
onChipClick: (categoryName, chipId) => {
console.log(`Chip clicked: ${categoryName} - ${chipId}`);
},
onAddClick: (categoryName) => {
console.log(`Add clicked: ${categoryName}`);
},
},
{
name: "Communication",
chipOptions: [
{ id: "comm-1", label: "Signal", state: "Unselected" },
],
onChipClick: (categoryName, chipId) => {
console.log(`Chip clicked: ${categoryName} - ${chipId}`);
},
onAddClick: (categoryName) => {
console.log(`Add clicked: ${categoryName}`);
},
},
],
},
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.",
},
},
},
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders as render } from "../utils/test-utils";
import InputLabel from "../../app/components/InputLabel";
import {
componentTestSuite,
type ComponentTestSuiteConfig,
} from "../utils/componentTestSuite";
type Props = React.ComponentProps<typeof InputLabel>;
const config: ComponentTestSuiteConfig<Props> = {
component: InputLabel,
name: "InputLabel",
props: {
label: "Test Label",
helpIcon: false,
asterisk: false,
helperText: false,
size: "S",
palette: "Default",
} as Props,
requiredProps: ["label"],
optionalProps: {
helpIcon: true,
asterisk: true,
helperText: true,
size: "M",
palette: "Inverse",
},
primaryRole: undefined, // InputLabel is not directly interactive
testCases: {
renders: true,
accessibility: true,
keyboardNavigation: false, // Not directly interactive
disabledState: false,
errorState: false,
},
};
componentTestSuite<Props>(config);
describe("InputLabel behaviour specifics", () => {
it("renders label text", () => {
render(<InputLabel label="Test Label" />);
expect(screen.getByText("Test Label")).toBeInTheDocument();
});
it("shows help icon when helpIcon is true", () => {
render(<InputLabel label="Test Label" helpIcon={true} />);
const helpIcon = screen.getByAltText("Help");
expect(helpIcon).toBeInTheDocument();
});
it("shows asterisk when asterisk is true", () => {
render(<InputLabel label="Test Label" asterisk={true} />);
expect(screen.getByText("*")).toBeInTheDocument();
});
it("shows helper text when helperText is true", () => {
render(<InputLabel label="Test Label" helperText={true} />);
expect(screen.getByText("Optional text")).toBeInTheDocument();
});
it("shows custom helper text when helperText is a string", () => {
render(<InputLabel label="Test Label" helperText="Custom helper" />);
expect(screen.getByText("Custom helper")).toBeInTheDocument();
});
it("applies size S styling", () => {
render(<InputLabel label="Test Label" size="S" />);
const label = screen.getByText("Test Label");
expect(label).toHaveClass("text-[length:var(--sizing-350,14px)]");
});
it("applies size M styling", () => {
render(<InputLabel label="Test Label" size="M" />);
const label = screen.getByText("Test Label");
expect(label).toHaveClass("text-[length:var(--sizing-400,16px)]");
});
it("applies Default palette styling", () => {
render(<InputLabel label="Test Label" palette="Default" />);
const label = screen.getByText("Test Label");
expect(label).toHaveClass(
"text-[color:var(--color-content-default-secondary,#d2d2d2)]",
);
});
it("applies Inverse palette styling", () => {
render(<InputLabel label="Test Label" palette="Inverse" />);
const label = screen.getByText("Test Label");
expect(label).toHaveClass(
"text-[color:var(--color-content-inverse-secondary,#1f1f1f)]",
);
});
});
+189
View File
@@ -0,0 +1,189 @@
import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders as render } from "../utils/test-utils";
import MultiSelect from "../../app/components/MultiSelect";
import {
componentTestSuite,
type ComponentTestSuiteConfig,
} from "../utils/componentTestSuite";
type Props = React.ComponentProps<typeof MultiSelect>;
const defaultChipOptions = [
{ id: "1", label: "Option 1", state: "Unselected" as const },
{ id: "2", label: "Option 2", state: "Selected" as const },
];
const config: ComponentTestSuiteConfig<Props> = {
component: MultiSelect,
name: "MultiSelect",
props: {
label: "Test Label",
showHelpIcon: false,
size: "S",
palette: "Default",
options: defaultChipOptions,
showAddButton: true,
addButtonText: "",
} as Props,
requiredProps: ["options"],
optionalProps: {
label: "Optional Label",
showHelpIcon: true,
size: "M",
palette: "Inverse",
onChipClick: vi.fn(),
onAddClick: vi.fn(),
showAddButton: false,
addButtonText: "Add",
},
primaryRole: undefined, // MultiSelect contains multiple interactive elements
testCases: {
renders: true,
accessibility: true,
keyboardNavigation: false, // Complex component with multiple interactive elements
disabledState: false,
errorState: false,
},
};
componentTestSuite<Props>(config);
describe("MultiSelect behaviour specifics", () => {
it("renders label when provided", () => {
render(<MultiSelect options={defaultChipOptions} label="Test Label" />);
expect(screen.getByText("Test Label")).toBeInTheDocument();
});
it("renders all chip options", () => {
render(<MultiSelect options={defaultChipOptions} />);
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.getByText("Option 2")).toBeInTheDocument();
});
it("calls onChipClick when a chip is clicked", async () => {
const handleChipClick = vi.fn();
render(
<MultiSelect
options={defaultChipOptions}
onChipClick={handleChipClick}
/>,
);
const chip = screen.getByText("Option 1");
await userEvent.click(chip);
expect(handleChipClick).toHaveBeenCalledWith("1");
});
it("calls onAddClick when add button is clicked", async () => {
const handleAddClick = vi.fn();
render(
<MultiSelect
options={defaultChipOptions}
onAddClick={handleAddClick}
showAddButton={true}
addButtonText="Add option"
/>,
);
const addButton = screen.getByRole("button", { name: "Add option" });
await userEvent.click(addButton);
expect(handleAddClick).toHaveBeenCalled();
});
it("calls onAddClick when icon-only add button is clicked", async () => {
const handleAddClick = vi.fn();
render(
<MultiSelect
options={defaultChipOptions}
onAddClick={handleAddClick}
showAddButton={true}
addButtonText=""
/>,
);
const addButton = screen.getByRole("button", { name: "Add option" });
await userEvent.click(addButton);
expect(handleAddClick).toHaveBeenCalled();
});
it("shows help icon when showHelpIcon is true", () => {
render(
<MultiSelect
options={defaultChipOptions}
label="Test Label"
showHelpIcon={true}
/>,
);
const helpIcon = screen.getByAltText("Help");
expect(helpIcon).toBeInTheDocument();
});
it("renders add button text when provided", () => {
render(
<MultiSelect
options={defaultChipOptions}
showAddButton={true}
addButtonText="Add option"
/>,
);
expect(screen.getByText("Add option")).toBeInTheDocument();
});
it("does not render add button when showAddButton is false", () => {
render(
<MultiSelect
options={defaultChipOptions}
showAddButton={false}
/>,
);
expect(screen.queryByRole("button", { name: /add/i })).not.toBeInTheDocument();
});
it("handles custom chip confirm", async () => {
const handleConfirm = vi.fn();
const customOptions = [
{ id: "custom-1", label: "", state: "Custom" as const },
];
render(
<MultiSelect
options={customOptions}
onCustomChipConfirm={handleConfirm}
/>,
);
// Type into the input first (check button is disabled until there's text)
const input = screen.getByPlaceholderText("Type to add");
await userEvent.type(input, "NewOption");
// Now the check button should be enabled
const checkButton = screen.getByRole("button", { name: "Confirm" });
expect(checkButton).not.toBeDisabled();
await userEvent.click(checkButton);
expect(handleConfirm).toHaveBeenCalledWith("custom-1", "NewOption");
});
it("handles custom chip close", async () => {
const handleClose = vi.fn();
const customOptions = [
{ id: "custom-1", label: "", state: "Custom" as const },
];
render(
<MultiSelect
options={customOptions}
onCustomChipClose={handleClose}
/>,
);
const closeButton = screen.getByRole("button", { name: "Close" });
await userEvent.click(closeButton);
expect(handleClose).toHaveBeenCalled();
});
});
+2 -1
View File
@@ -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
+60 -13
View File
@@ -81,9 +81,8 @@ describe("RuleCard Component", () => {
const card = screen.getByRole("button");
expect(card).toHaveClass(
"hover:shadow-xl",
"hover:scale-[1.02]",
"transition-all",
"hover:shadow-[0px_0px_64px_0px_rgba(0,0,0,0.15)]",
"transition-shadow",
);
});
@@ -107,18 +106,19 @@ describe("RuleCard Component", () => {
).not.toBeInTheDocument();
});
it("applies proper responsive sizing", () => {
render(<RuleCard {...defaultProps} />);
it("applies proper sizing for expanded states", () => {
render(<RuleCard {...defaultProps} expanded={true} size="L" />);
const card = screen.getByRole("button");
expect(card).toHaveClass("md:h-[210px]", "lg:h-[277px]");
expect(card).toHaveClass("w-[568px]");
});
it("applies focus styles correctly", () => {
render(<RuleCard {...defaultProps} />);
it("applies proper accessibility attributes", () => {
render(<RuleCard {...defaultProps} expanded={true} />);
const card = screen.getByRole("button");
expect(card).toHaveClass("focus:outline-none", "focus:ring-2");
expect(card).toHaveAttribute("aria-expanded", "true");
expect(card).toHaveAttribute("tabIndex", "0");
});
it("renders without icon when not provided", () => {
@@ -131,7 +131,8 @@ describe("RuleCard Component", () => {
render(<RuleCard {...defaultProps} />);
const card = screen.getByRole("button");
expect(card).toHaveClass("shadow-lg", "backdrop-blur-sm");
expect(card).toHaveClass("shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)]");
expect(card).toHaveClass("rounded-[var(--radius-measures-radius-small)]");
});
it("maintains proper heading structure", () => {
@@ -142,10 +143,56 @@ describe("RuleCard Component", () => {
expect(heading.tagName).toBe("H3");
});
it("applies proper font classes", () => {
render(<RuleCard {...defaultProps} />);
it("applies proper font classes for title", () => {
render(<RuleCard {...defaultProps} size="L" />);
const heading = screen.getByRole("heading", { level: 3 });
expect(heading).toHaveClass("font-space-grotesk", "font-bold");
// 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", () => {
const categories = [
{
name: "Values",
chipOptions: [
{ id: "v1", label: "Consciousness", state: "Unselected" },
],
},
];
render(
<RuleCard
{...defaultProps}
expanded={true}
categories={categories}
/>,
);
expect(screen.getByText("Values")).toBeInTheDocument();
});
it("renders with logo URL", () => {
render(
<RuleCard
{...defaultProps}
logoUrl="http://localhost:3845/assets/test.png"
logoAlt="Test Logo"
/>,
);
const logo = screen.getByAltText("Test Logo");
expect(logo).toBeInTheDocument();
});
it("renders with community initials fallback", () => {
render(
<RuleCard
{...defaultProps}
communityInitials="CE"
/>,
);
expect(screen.getByText("CE")).toBeInTheDocument();
});
});
+18 -18
View File
@@ -74,17 +74,17 @@ describe("RuleStack Component", () => {
render(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
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", () => {