Right rail template

This commit is contained in:
adilallo
2026-02-28 23:16:10 -07:00
parent f5bfb25f5e
commit 0799636c78
60 changed files with 1255 additions and 305 deletions
+246 -142
View File
@@ -8,6 +8,9 @@ import MultiSelect from "../../components/controls/MultiSelect";
import Image from "next/image";
import { getAssetPath } from "../../../lib/assetUtils";
/** Module-level counter for unique rule card chip IDs (avoids ref in initial state). */
let ruleCardIdCounter = 0;
interface ChipData {
id: string;
label: string;
@@ -18,7 +21,13 @@ interface ChipData {
// 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" }>>([
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" },
@@ -36,17 +45,14 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
...opt,
state: opt.state === "Selected" ? "Unselected" : "Selected",
}
: opt
)
: opt,
),
);
};
const handleAddClick = () => {
const newId = `custom-${Date.now()}`;
setOptions((prev) => [
...prev,
{ id: newId, label: "", state: "Custom" },
]);
setOptions((prev) => [...prev, { id: newId, label: "", state: "Custom" }]);
};
const handleCustomConfirm = (chipId: string, value: string) => {
@@ -54,8 +60,8 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
prev.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" as const }
: opt
)
: opt,
),
);
};
@@ -84,28 +90,52 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
}
export default function ComponentsPreview() {
const [chipStates, setChipStates] = useState<Record<string, "Unselected" | "Selected">>({
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" },
{
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;
}>>([
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: [
@@ -125,17 +155,20 @@ export default function ComponentsPreview() {
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? "Unselected" : "Selected",
state:
opt.state === "Selected"
? "Unselected"
: "Selected",
}
: opt
: opt,
),
}
: cat
)
: cat,
),
);
},
onAddClick: (categoryName: string) => {
const newId = `custom-${categoryName}-${Date.now()}`;
const newId = `custom-${categoryName}-${++ruleCardIdCounter}`;
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
@@ -146,11 +179,15 @@ export default function ComponentsPreview() {
{ id: newId, label: "", state: "Custom" },
],
}
: cat
)
: cat,
),
);
},
onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => {
onCustomChipConfirm: (
categoryName: string,
chipId: string,
value: string,
) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
@@ -159,11 +196,11 @@ export default function ComponentsPreview() {
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" }
: opt
: opt,
),
}
: cat
)
: cat,
),
);
},
onCustomChipClose: (categoryName: string, chipId: string) => {
@@ -172,18 +209,18 @@ export default function ComponentsPreview() {
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId),
chipOptions: cat.chipOptions.filter(
(opt) => opt.id !== chipId,
),
}
: cat
)
: cat,
),
);
},
},
{
name: "Communication",
chipOptions: [
{ id: "comm-1", label: "Signal", state: "Unselected" },
],
chipOptions: [{ id: "comm-1", label: "Signal", state: "Unselected" }],
onChipClick: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
@@ -194,17 +231,20 @@ export default function ComponentsPreview() {
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? "Unselected" : "Selected",
state:
opt.state === "Selected"
? "Unselected"
: "Selected",
}
: opt
: opt,
),
}
: cat
)
: cat,
),
);
},
onAddClick: (categoryName: string) => {
const newId = `custom-${categoryName}-${Date.now()}`;
const newId = `custom-${categoryName}-${++ruleCardIdCounter}`;
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
@@ -215,11 +255,15 @@ export default function ComponentsPreview() {
{ id: newId, label: "", state: "Custom" },
],
}
: cat
)
: cat,
),
);
},
onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => {
onCustomChipConfirm: (
categoryName: string,
chipId: string,
value: string,
) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
@@ -228,11 +272,11 @@ export default function ComponentsPreview() {
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" }
: opt
: opt,
),
}
: cat
)
: cat,
),
);
},
onCustomChipClose: (categoryName: string, chipId: string) => {
@@ -241,10 +285,12 @@ export default function ComponentsPreview() {
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId),
chipOptions: cat.chipOptions.filter(
(opt) => opt.id !== chipId,
),
}
: cat
)
: cat,
),
);
},
},
@@ -263,17 +309,20 @@ export default function ComponentsPreview() {
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? "Unselected" : "Selected",
state:
opt.state === "Selected"
? "Unselected"
: "Selected",
}
: opt
: opt,
),
}
: cat
)
: cat,
),
);
},
onAddClick: (categoryName: string) => {
const newId = `custom-${categoryName}-${Date.now()}`;
const newId = `custom-${categoryName}-${++ruleCardIdCounter}`;
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
@@ -284,11 +333,15 @@ export default function ComponentsPreview() {
{ id: newId, label: "", state: "Custom" },
],
}
: cat
)
: cat,
),
);
},
onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => {
onCustomChipConfirm: (
categoryName: string,
chipId: string,
value: string,
) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
@@ -297,11 +350,11 @@ export default function ComponentsPreview() {
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" }
: opt
: opt,
),
}
: cat
)
: cat,
),
);
},
onCustomChipClose: (categoryName: string, chipId: string) => {
@@ -310,10 +363,12 @@ export default function ComponentsPreview() {
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId),
chipOptions: cat.chipOptions.filter(
(opt) => opt.id !== chipId,
),
}
: cat
)
: cat,
),
);
},
},
@@ -333,17 +388,20 @@ export default function ComponentsPreview() {
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? "Unselected" : "Selected",
state:
opt.state === "Selected"
? "Unselected"
: "Selected",
}
: opt
: opt,
),
}
: cat
)
: cat,
),
);
},
onAddClick: (categoryName: string) => {
const newId = `custom-${categoryName}-${Date.now()}`;
const newId = `custom-${categoryName}-${++ruleCardIdCounter}`;
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
@@ -354,11 +412,15 @@ export default function ComponentsPreview() {
{ id: newId, label: "", state: "Custom" },
],
}
: cat
)
: cat,
),
);
},
onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => {
onCustomChipConfirm: (
categoryName: string,
chipId: string,
value: string,
) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
@@ -367,11 +429,11 @@ export default function ComponentsPreview() {
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" }
: opt
: opt,
),
}
: cat
)
: cat,
),
);
},
onCustomChipClose: (categoryName: string, chipId: string) => {
@@ -380,10 +442,12 @@ export default function ComponentsPreview() {
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId),
chipOptions: cat.chipOptions.filter(
(opt) => opt.id !== chipId,
),
}
: cat
)
: cat,
),
);
},
},
@@ -403,17 +467,20 @@ export default function ComponentsPreview() {
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? "Unselected" : "Selected",
state:
opt.state === "Selected"
? "Unselected"
: "Selected",
}
: opt
: opt,
),
}
: cat
)
: cat,
),
);
},
onAddClick: (categoryName: string) => {
const newId = `custom-${categoryName}-${Date.now()}`;
const newId = `custom-${categoryName}-${++ruleCardIdCounter}`;
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
@@ -424,11 +491,15 @@ export default function ComponentsPreview() {
{ id: newId, label: "", state: "Custom" },
],
}
: cat
)
: cat,
),
);
},
onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => {
onCustomChipConfirm: (
categoryName: string,
chipId: string,
value: string,
) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
@@ -437,11 +508,11 @@ export default function ComponentsPreview() {
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" }
: opt
: opt,
),
}
: cat
)
: cat,
),
);
},
onCustomChipClose: (categoryName: string, chipId: string) => {
@@ -450,10 +521,12 @@ export default function ComponentsPreview() {
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId),
chipOptions: cat.chipOptions.filter(
(opt) => opt.id !== chipId,
),
}
: cat
)
: cat,
),
);
},
},
@@ -467,7 +540,8 @@ export default function ComponentsPreview() {
Component Preview
</h1>
<p className="font-inter text-[18px] leading-[24px] text-[var(--color-content-default-secondary)]">
RuleCard, Card, and Chip component examples - states, palettes, sizes, and interactions
RuleCard, Card, and Chip component examples - states, palettes,
sizes, and interactions
</p>
</header>
@@ -481,7 +555,7 @@ export default function ComponentsPreview() {
<div className="space-y-[var(--spacing-scale-016)]">
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
Default palette
</h3>
</h3>
<div className="flex flex-wrap items-center gap-[var(--spacing-scale-016)]">
<Chip
label="Small"
@@ -491,7 +565,10 @@ export default function ComponentsPreview() {
onClick={() =>
setChipStates((prev) => ({
...prev,
"default-s": prev["default-s"] === "Selected" ? "Unselected" : "Selected",
"default-s":
prev["default-s"] === "Selected"
? "Unselected"
: "Selected",
}))
}
/>
@@ -503,7 +580,10 @@ export default function ComponentsPreview() {
onClick={() =>
setChipStates((prev) => ({
...prev,
"default-m": prev["default-m"] === "Selected" ? "Unselected" : "Selected",
"default-m":
prev["default-m"] === "Selected"
? "Unselected"
: "Selected",
}))
}
/>
@@ -528,27 +608,35 @@ export default function ComponentsPreview() {
prev.map((c) =>
c.id === chip.id
? { ...c, label: value, state: "Selected" }
: c
)
: c,
),
);
}}
onClose={(e) => {
e.stopPropagation();
setCustomChips((prev) => prev.filter((c) => c.id !== chip.id));
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") {
if (
chip.state === "Selected" ||
chip.state === "Unselected"
) {
setCustomChips((prev) =>
prev.map((c) =>
c.id === chip.id
? {
...c,
state: c.state === "Selected" ? "Unselected" : "Selected",
state:
c.state === "Selected"
? "Unselected"
: "Selected",
}
: c
)
: c,
),
);
}
}}
@@ -561,7 +649,13 @@ export default function ComponentsPreview() {
const newId = `custom-${Date.now()}`;
setCustomChips((prev) => [
...prev,
{ id: newId, label: "", state: "Custom", palette: "Default", size: "S" },
{
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"
@@ -592,11 +686,14 @@ export default function ComponentsPreview() {
</div>
{/* Inverse palette - on white background */}
<div className="space-y-[var(--spacing-scale-016)]">
<div className="space-y-[var(--spacing-scale-016)]">
<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="!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"
@@ -606,7 +703,10 @@ export default function ComponentsPreview() {
onClick={() =>
setChipStates((prev) => ({
...prev,
"inverse-s": prev["inverse-s"] === "Selected" ? "Unselected" : "Selected",
"inverse-s":
prev["inverse-s"] === "Selected"
? "Unselected"
: "Selected",
}))
}
/>
@@ -618,7 +718,10 @@ export default function ComponentsPreview() {
onClick={() =>
setChipStates((prev) => ({
...prev,
"inverse-m": prev["inverse-m"] === "Selected" ? "Unselected" : "Selected",
"inverse-m":
prev["inverse-m"] === "Selected"
? "Unselected"
: "Selected",
}))
}
/>
@@ -641,7 +744,8 @@ export default function ComponentsPreview() {
</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)]">
<p className="font-inter text-[18px] leading-[24px] text-[var(--color-content-default-secondary)]">
Horizontal and vertical orientations with recommended and selected states.
Horizontal and vertical orientations with recommended and selected
states.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
<div className="space-y-2">
@@ -654,7 +758,7 @@ export default function ComponentsPreview() {
recommended={true}
selected={false}
orientation="horizontal"
onClick={() => console.log("Card clicked")}
onClick={() => console.warn("Card clicked")}
/>
</div>
<div className="space-y-2">
@@ -667,7 +771,7 @@ export default function ComponentsPreview() {
recommended={false}
selected={true}
orientation="horizontal"
onClick={() => console.log("Card clicked")}
onClick={() => console.warn("Card clicked")}
/>
</div>
<div className="space-y-2">
@@ -681,7 +785,7 @@ export default function ComponentsPreview() {
selected={false}
orientation="vertical"
showInfoIcon={true}
onClick={() => console.log("Card clicked")}
onClick={() => console.warn("Card clicked")}
/>
</div>
<div className="space-y-2">
@@ -695,7 +799,7 @@ export default function ComponentsPreview() {
selected={true}
orientation="vertical"
showInfoIcon={true}
onClick={() => console.log("Card clicked")}
onClick={() => console.warn("Card clicked")}
/>
</div>
</div>
@@ -717,9 +821,9 @@ export default function ComponentsPreview() {
className="w-[525px]"
logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png"
logoAlt="Mutual Aid Mondays"
onClick={() => console.log("Card clicked: Mutual Aid Mondays")}
/>
</div>
onClick={() => console.warn("Card clicked: Mutual Aid Mondays")}
/>
</div>
</section>
{/* Collapsed State - Medium */}
@@ -737,17 +841,17 @@ export default function ComponentsPreview() {
className="w-[289px]"
logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png"
logoAlt="Mutual Aid Mondays"
onClick={() => console.log("Card clicked: Mutual Aid Mondays")}
/>
onClick={() => console.warn("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)]">
<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 - 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)]">
</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."
@@ -758,9 +862,9 @@ export default function ComponentsPreview() {
logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png"
logoAlt="Mutual Aid Mondays"
categories={ruleCardCategories}
onClick={() => console.log("Card clicked: Mutual Aid Mondays")}
/>
</div>
onClick={() => console.warn("Card clicked: Mutual Aid Mondays")}
/>
</div>
</section>
{/* Expanded State - Medium */}
@@ -779,16 +883,16 @@ export default function ComponentsPreview() {
logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png"
logoAlt="Mutual Aid Mondays"
categories={ruleCardCategories}
onClick={() => console.log("Card clicked: Mutual Aid Mondays")}
onClick={() => console.warn("Card clicked: Mutual Aid Mondays")}
/>
</div>
</section>
</div>
</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)]">
<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)]">
Different Background Colors
</h2>
</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="grid grid-cols-1 md:grid-cols-2 gap-[var(--spacing-scale-024)]">
<RuleCard
@@ -806,7 +910,7 @@ export default function ComponentsPreview() {
height={103}
/>
}
onClick={() => console.log("Consensus clusters selected")}
onClick={() => console.warn("Consensus clusters selected")}
/>
<RuleCard
title="Consensus"
@@ -823,10 +927,10 @@ export default function ComponentsPreview() {
height={103}
/>
}
onClick={() => console.log("Consensus selected")}
/>
</div>
</div>
onClick={() => console.warn("Consensus selected")}
/>
</div>
</div>
</section>
{/* Logo Fallback */}
@@ -843,9 +947,9 @@ export default function ComponentsPreview() {
size="L"
className="w-[525px]"
communityInitials="CE"
onClick={() => console.log("Community Example selected")}
/>
</div>
onClick={() => console.warn("Community Example selected")}
/>
</div>
</section>
{/* MultiSelect Component */}
@@ -856,7 +960,7 @@ export default function ComponentsPreview() {
<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>
@@ -40,7 +40,23 @@ const WebVitalsDashboardContainer = memo(() => {
if (typeof window !== "undefined") {
import("web-vitals").then((webVitals) => {
const { getCLS, getFID, getFCP, getLCP, getTTFB } = webVitals as any;
const { getCLS, getFID, getFCP, getLCP, getTTFB } = webVitals as {
getCLS: (
_fn: (_m: { value: number; rating: string }) => void,
) => void;
getFID: (
_fn: (_m: { value: number; rating: string }) => void,
) => void;
getFCP: (
_fn: (_m: { value: number; rating: string }) => void,
) => void;
getLCP: (
_fn: (_m: { value: number; rating: string }) => void,
) => void;
getTTFB: (
_fn: (_m: { value: number; rating: string }) => void,
) => void;
};
getLCP((metric: { value: number; rating: VitalData["rating"] }) => {
setVitals((prev) => ({
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { memo } from "react";
import ExclamationIcon from "./icon/exclamation.svg";
export type IconName = "exclamation";
/** SVG import may be a React component or a module object { default: Component } (e.g. with Turbopack) */
const iconMap: Record<
IconName,
React.ComponentType<React.SVGProps<SVGSVGElement>> | { default: React.ComponentType<React.SVGProps<SVGSVGElement>> }
> = {
exclamation: ExclamationIcon,
};
export interface IconProps {
name: IconName;
className?: string;
/** Width and height (default 24) */
size?: number;
"aria-hidden"?: boolean;
}
function IconComponent({
name,
className = "",
size = 24,
"aria-hidden": ariaHidden = true,
}: IconProps) {
const SvgModule = iconMap[name];
if (!SvgModule) return null;
// Turbopack/bundler may expose SVG as { default: Component } instead of the component directly
const Svg =
typeof SvgModule === "object" && SvgModule !== null && "default" in SvgModule
? (SvgModule as { default: React.ComponentType<React.SVGProps<SVGSVGElement>> }).default
: (SvgModule as React.ComponentType<React.SVGProps<SVGSVGElement>>);
if (typeof Svg !== "function") return null;
return (
<Svg
width={size}
height={size}
className={className}
aria-hidden={ariaHidden}
/>
);
}
export default memo(IconComponent);
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.25 14.0386V5.53857H12.75V14.0386H11.25ZM11.25 18.4616V16.9616H12.75V18.4616H11.25Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 224 B

+2
View File
@@ -0,0 +1,2 @@
export { default as Icon } from "./Icon";
export type { IconName, IconProps } from "./Icon";
+2 -2
View File
@@ -42,7 +42,7 @@ export function CardView({
const selectedBorder = selected
? "outline outline-2 outline-dashed outline-black outline-offset-[-2px]"
: "";
const baseClasses = `rounded-[var(--radius-measures-radius-small)] bg-[#FFFFFF] p-4 transition-all duration-200 cursor-pointer ${borderClass} ${selectedBorder} ${className}`;
const baseClasses = `select-none rounded-[var(--radius-measures-radius-small)] bg-[#FFFFFF] p-4 transition-[border-color,box-shadow,outline] duration-200 cursor-pointer ${borderClass} ${selectedBorder} ${className}`;
if (orientation === "horizontal") {
return (
@@ -93,7 +93,7 @@ export function CardView({
</p>
) : null}
</div>
<div className="shrink-0">
<div className="shrink-0 w-[6rem]">
<CardTag recommended={recommended} selected={selected} />
</div>
</div>
@@ -75,7 +75,8 @@ export function SelectInputView({
}: SelectInputViewProps) {
// Styles based on Figma design
const containerClasses = "flex flex-col gap-[8px]";
const labelClasses = "text-[14px] leading-[20px] font-medium font-inter text-[var(--color-content-default-primary)]";
const labelClasses =
"text-[14px] leading-[20px] font-medium font-inter text-[var(--color-content-default-primary)]";
// Button styles per Figma
const getButtonClasses = (): string => {
@@ -101,7 +102,9 @@ export function SelectInputView({
cursor-pointer
appearance-none
m-0
`.trim().replace(/\s+/g, " ");
`
.trim()
.replace(/\s+/g, " ");
if (disabled) {
return `${baseClasses} bg-[var(--color-surface-default-secondary)] text-[var(--color-content-inverse-tertiary,#2d2d2d)] border-[var(--color-border-default-primary)] cursor-not-allowed opacity-40`;
@@ -142,10 +145,7 @@ export function SelectInputView({
{label && (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
id={labelId}
className={labelClasses}
>
<label id={labelId} className={labelClasses}>
{label}
</label>
{asterisk && (
@@ -155,6 +155,7 @@ export function SelectInputView({
)}
{iconHelp && (
<div className="relative shrink-0 size-[12px]">
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt="Help"
@@ -186,8 +187,10 @@ export function SelectInputView({
onFocus={onButtonFocus}
onBlur={onButtonBlur}
>
<span className={`flex-1 text-left ${iconRight ? "pr-[32px]" : ""} ${textColorClass}`}>
{textData ? displayText : placeholder}
<span
className={`flex-1 text-left ${iconRight ? "pr-[32px]" : ""} ${textColorClass}`}
>
{textData ? displayText : _placeholder}
</span>
{iconRight && (
<div className="flex items-center justify-center shrink-0">
@@ -48,6 +48,7 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
</label>
{showHelpIcon && (
<div className="relative shrink-0 size-[12px]">
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt="Help"
@@ -45,6 +45,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
</label>
{showHelpIcon && (
<div className="relative shrink-0 size-[12px]">
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt="Help"
+4 -1
View File
@@ -27,7 +27,10 @@ const Avatar = memo<AvatarProps>(
const baseStyles = `rounded-[var(--radius-measures-radius-full)] object-cover box-border ${sizeStyles[size]} ${className}`;
return <img src={src} alt={alt} className={baseStyles} {...props} />;
return (
/* eslint-disable-next-line @next/next/no-img-element -- avatar image from URL */
<img src={src} alt={alt} className={baseStyles} {...props} />
);
},
);
+2
View File
@@ -66,6 +66,7 @@ const Footer = memo(() => {
className="flex items-center gap-[var(--spacing-measures-spacing-06,6px)] hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer group"
aria-label={t("social.bluesky.ariaLabel")}
>
{/* eslint-disable-next-line @next/next/no-img-element -- social logo */}
<img
src={getAssetPath(ASSETS.BLUESKY_LOGO)}
alt="Bluesky"
@@ -82,6 +83,7 @@ const Footer = memo(() => {
className="flex items-center gap-[var(--spacing-measures-spacing-06,6px)] hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer group"
aria-label={t("social.gitlab.ariaLabel")}
>
{/* eslint-disable-next-line @next/next/no-img-element -- social icon */}
<img
src={getAssetPath(ASSETS.GITLAB_ICON)}
alt="GitLab"
@@ -7,7 +7,6 @@ import MenuBarItem from "../MenuBarItem";
import Button from "../../buttons/Button";
import AvatarContainer from "../../utility/AvatarContainer";
import Avatar from "../../icons/Avatar";
import Logo from "../../icons/Logo";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import { TopNavView } from "./TopNav.view";
import type { TopNavProps, NavSize } from "./TopNav.types";
@@ -19,12 +18,7 @@ export const avatarImages = [
];
const TopNavContainer = memo<TopNavProps>(
({
folderTop = false,
loggedIn = false,
profile = false,
logIn = true,
}) => {
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
const pathname = usePathname();
const t = useTranslation("header");
@@ -34,7 +28,9 @@ const TopNavContainer = memo<TopNavProps>(
"@type": "WebSite",
name: "CommunityRule",
url: "https://communityrule.com",
...(folderTop && { description: "Build operating manuals for successful communities" }),
...(folderTop && {
description: "Build operating manuals for successful communities",
}),
potentialAction: {
"@type": "SearchAction",
target: "https://communityrule.com/search?q={search_term_string}",
@@ -54,7 +50,10 @@ const TopNavContainer = memo<TopNavProps>(
const renderNavigationItems = (size: NavSize) => {
// Map NavSize to Figma MenuBarItem sizes
const sizeMap: Record<NavSize, "X Small" | "Small" | "Medium" | "Large" | "X Large"> = {
const sizeMap: Record<
NavSize,
"X Small" | "Small" | "Medium" | "Large" | "X Large"
> = {
default: "Small",
xsmall: "X Small",
xsmallUseCases: "X Small",
@@ -85,7 +84,10 @@ const TopNavContainer = memo<TopNavProps>(
mode={mode}
state={pathname === item.href ? "selected" : "default"}
reducedPadding={isUseCases}
ariaLabel={t("ariaLabels.navigateToPage").replace("{text}", item.text)}
ariaLabel={t("ariaLabels.navigateToPage").replace(
"{text}",
item.text,
)}
>
{item.text}
</MenuBarItem>
@@ -113,7 +115,10 @@ const TopNavContainer = memo<TopNavProps>(
const renderLoginButton = (size: NavSize) => {
// Map NavSize to Figma MenuBarItem sizes
const sizeMap: Record<NavSize, "X Small" | "Small" | "Medium" | "Large" | "X Large"> = {
const sizeMap: Record<
NavSize,
"X Small" | "Small" | "Medium" | "Large" | "X Large"
> = {
default: "Small",
xsmall: "X Small",
xsmallUseCases: "X Small",
@@ -55,18 +55,21 @@ function TopNavView({
</div>
{/* Decorative Union images for tab appearance */}
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG, not content */}
<img
src={getAssetPath("assets/Union_xsm.svg")}
alt=""
role="presentation"
className="absolute -bottom-[3px] -right-[52px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] sm:hidden -z-10"
/>
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */}
<img
src={getAssetPath("assets/Union_sm_md_lg.svg")}
alt=""
role="presentation"
className="absolute -bottom-[3.7px] -right-[53px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] hidden sm:block xl:hidden -z-10"
/>
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */}
<img
src={getAssetPath("assets/Union_xlg.svg")}
alt=""
@@ -46,6 +46,7 @@ const HeroBanner = memo<HeroBannerProps>(
{/* Hero Image Container */}
<div className="w-full h-full md:flex-1 rounded-[8px] overflow-hidden relative z-10 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element -- dynamic path from getAssetPath */}
<img
src={getAssetPath("assets/HeroImage.png")}
alt={imageAlt}
@@ -16,19 +16,24 @@ export function RuleStackView({
}: 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 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(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer breakpoint until after mount to avoid hydration mismatch
setIsMounted(true);
}, []);
@@ -38,12 +43,12 @@ export function RuleStackView({
? isMax639
? "XS"
: isMin640Max1023
? "S"
: isMin1024Max1439
? "M"
: isMin1440
? "L"
: "M"
? "S"
: isMin1024Max1439
? "M"
: isMin1440
? "L"
: "M"
: "M";
// Icon sizes: XS=40px, S=56px, M=56px, L=90px
@@ -150,17 +155,15 @@ export function RuleStackView({
</div>
{/* See all templates button */}
<div className="
<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
buttonType="outline"
palette="default"
size="large"
>
"
>
<Button buttonType="outline" palette="default" size="large">
{buttonText}
</Button>
</div>
@@ -26,6 +26,7 @@ const SectionNumber = memo<SectionNumberProps>(({ number }) => {
return (
<div className="relative size-[40px] overflow-visible -rotate-[15deg]">
{/* eslint-disable-next-line @next/next/no-img-element -- dynamic src from getImageSrc */}
<img
src={getImageSrc(number)}
alt={`Section ${number}`}
@@ -60,12 +60,15 @@ function ContentLockupView({
</h1>
) : null}
{variant === "hero" && (
<img
src={getAssetPath("assets/Shapes_1.svg")}
alt=""
className={styles.shape}
role="presentation"
/>
<>
{/* eslint-disable-next-line @next/next/no-img-element -- decorative shape SVG */}
<img
src={getAssetPath("assets/Shapes_1.svg")}
alt=""
className={styles.shape}
role="presentation"
/>
</>
)}
</div>
@@ -20,6 +20,7 @@ const CardStackContainer = memo<CardStackProps>(
showLessLabel = DEFAULT_SHOW_LESS_LABEL,
title = "",
description = "",
layout = "default",
className = "",
}) => {
const [internalExpanded, setInternalExpanded] = useState(false);
@@ -68,6 +69,7 @@ const CardStackContainer = memo<CardStackProps>(
showLessLabel={showLessLabel}
title={title}
description={description}
layout={layout}
className={className}
/>
);
@@ -17,6 +17,8 @@ export interface CardStackProps {
showLessLabel?: string;
title?: string;
description?: string;
/** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */
layout?: "default" | "singleStack";
className?: string;
}
@@ -31,5 +33,6 @@ export interface CardStackViewProps {
showLessLabel: string;
title: string;
description: string;
layout: "default" | "singleStack";
className: string;
}
@@ -15,17 +15,59 @@ export function CardStackView({
showLessLabel,
title,
description,
layout,
className,
}: CardStackViewProps) {
const isSelected = (id: string) => selectedIds.includes(id);
// Compact: recommended only (up to 5). Expanded: all cards.
const compactCards = cards
.filter((c) => c.recommended ?? false)
.slice(0, 5);
const compactCards = cards.filter((c) => c.recommended ?? false).slice(0, 5);
// Single stack: always one column; expand reveals more in same stack (scrollable)
if (layout === "singleStack") {
const displayedCards = expanded ? cards : compactCards;
return (
<div className={`flex w-full flex-col gap-6 min-w-0 ${className}`}>
{title || description ? (
<div className="min-w-0 shrink-0">
<HeaderLockup
title={title}
description={description}
justification="center"
size="L"
/>
</div>
) : null}
<div className="flex flex-col gap-[8px] w-full min-w-0">
{displayedCards.map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="vertical"
showInfoIcon={true}
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
{hasMore ? (
<button
type="button"
onClick={onToggleExpand}
className="font-inter text-base font-normal leading-6 text-[var(--color-gray-000)] underline hover:opacity-90 focus:outline-none self-center cursor-pointer"
>
{expanded ? showLessLabel : toggleLabel}
</button>
) : null}
</div>
);
}
return (
<div className={`flex w-full flex-col gap-6 min-w-0 ${className}`}>
{(title || description) ? (
{title || description ? (
<div className="min-w-0">
<HeaderLockup
title={title}
@@ -17,7 +17,7 @@ export function CreateFlowTopNavView({
return (
<header
className={`bg-black w-full border-b border-[var(--color-border-default-tertiary)] ${className}`}
className={`bg-black w-full ${className}`}
role="banner"
aria-label="Create Rule Flow Navigation"
>
@@ -0,0 +1,44 @@
"use client";
import { memo } from "react";
import DecisionMakingSidebarView from "./DecisionMakingSidebar.view";
import type { DecisionMakingSidebarProps } from "./DecisionMakingSidebar.types";
import {
normalizeHeaderLockupJustification,
normalizeHeaderLockupSize,
} from "../../../../lib/propNormalization";
const DecisionMakingSidebarContainer = memo<DecisionMakingSidebarProps>(
({
title,
description,
messageBoxTitle,
messageBoxItems,
messageBoxCheckedIds,
onMessageBoxCheckboxChange,
size: sizeProp = "L",
justification: justificationProp = "left",
className = "",
}) => {
const size = normalizeHeaderLockupSize(sizeProp);
const justification = normalizeHeaderLockupJustification(justificationProp);
return (
<DecisionMakingSidebarView
title={title}
description={description}
messageBoxTitle={messageBoxTitle}
messageBoxItems={messageBoxItems}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={onMessageBoxCheckboxChange}
size={size}
justification={justification}
className={className}
/>
);
},
);
DecisionMakingSidebarContainer.displayName = "DecisionMakingSidebar";
export default DecisionMakingSidebarContainer;
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import type {
HeaderLockupJustificationValue,
HeaderLockupSizeValue,
} from "../../type/HeaderLockup/HeaderLockup.types";
import type { InfoMessageBoxItem } from "../InfoMessageBox/InfoMessageBox.types";
export interface DecisionMakingSidebarProps {
title: string;
/** Description text or ReactNode (e.g. with underlined "add") */
description?: string | ReactNode;
messageBoxTitle: string;
messageBoxItems: InfoMessageBoxItem[];
messageBoxCheckedIds?: string[];
onMessageBoxCheckboxChange?: (id: string, checked: boolean) => void;
size?: HeaderLockupSizeValue;
justification?: HeaderLockupJustificationValue;
className?: string;
}
export interface DecisionMakingSidebarViewProps {
title: string;
description: string | ReactNode | undefined;
messageBoxTitle: string;
messageBoxItems: InfoMessageBoxItem[];
messageBoxCheckedIds: string[] | undefined;
onMessageBoxCheckboxChange: ((id: string, checked: boolean) => void) | undefined;
size: "L" | "M";
justification: "left" | "center";
className: string;
}
@@ -0,0 +1,78 @@
"use client";
import { memo } from "react";
import HeaderLockup from "../../type/HeaderLockup";
import InfoMessageBox from "../InfoMessageBox";
import type { DecisionMakingSidebarViewProps } from "./DecisionMakingSidebar.types";
function DecisionMakingSidebarView({
title,
description,
messageBoxTitle,
messageBoxItems,
messageBoxCheckedIds,
onMessageBoxCheckboxChange,
size,
justification,
className,
}: DecisionMakingSidebarViewProps) {
const isL = size === "L";
const isLeft = justification === "left";
const isStringDescription = typeof description === "string";
return (
<div className={`flex flex-col gap-3 w-full min-w-0 ${className}`}>
{isStringDescription ? (
<HeaderLockup
title={title}
description={description as string}
justification={justification}
size={size}
/>
) : (
<div
className={`flex flex-col gap-[var(--measures-spacing-200,8px)] py-[12px] relative ${
isLeft ? "items-start" : "items-center"
}`}
>
<div className="flex items-center relative shrink-0 w-full">
<h1
className={`flex-[1_0_0] min-h-px min-w-px overflow-hidden relative text-[var(--color-content-default-primary,white)] text-ellipsis whitespace-pre-wrap ${
isLeft ? "text-left" : "text-center"
} ${
isL
? "font-bricolage-grotesque font-extrabold text-[36px] leading-[44px]"
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px]"
}`}
>
{title}
</h1>
</div>
{description != null && (
<p
className={`font-inter font-normal max-w-[640px] overflow-hidden relative shrink-0 text-[var(--color-content-default-tertiary,#b4b4b4)] text-ellipsis w-full whitespace-pre-wrap ${
isLeft ? "" : "text-center"
} ${
isL
? "text-[18px] leading-[1.3]"
: "text-[14px] leading-[20px]"
}`}
>
{description}
</p>
)}
</div>
)}
<InfoMessageBox
title={messageBoxTitle}
items={messageBoxItems}
checkedIds={messageBoxCheckedIds}
onCheckboxChange={onMessageBoxCheckboxChange ?? undefined}
/>
</div>
);
}
DecisionMakingSidebarView.displayName = "DecisionMakingSidebarView";
export default memo(DecisionMakingSidebarView);
@@ -0,0 +1,2 @@
export { default } from "./DecisionMakingSidebar.container";
export type { DecisionMakingSidebarProps } from "./DecisionMakingSidebar.types";
@@ -0,0 +1,56 @@
"use client";
import { memo, useCallback, useState } from "react";
import InfoMessageBoxView from "./InfoMessageBox.view";
import type { InfoMessageBoxProps } from "./InfoMessageBox.types";
const InfoMessageBoxContainer = memo<InfoMessageBoxProps>(
({
title,
items,
icon,
checkedIds: controlledCheckedIds,
onCheckboxChange,
className = "",
}) => {
const [internalCheckedIds, setInternalCheckedIds] = useState<string[]>([]);
const checkedIds =
controlledCheckedIds !== undefined
? controlledCheckedIds
: internalCheckedIds;
const handleGroupChange = useCallback(
(newValue: string[]) => {
if (controlledCheckedIds === undefined) {
setInternalCheckedIds(newValue);
}
if (!onCheckboxChange) return;
const prevSet = new Set(checkedIds);
const newSet = new Set(newValue);
items.forEach((item) => {
const nowChecked = newSet.has(item.id);
const wasChecked = prevSet.has(item.id);
if (nowChecked !== wasChecked) {
onCheckboxChange(item.id, nowChecked);
}
});
},
[checkedIds, controlledCheckedIds, items, onCheckboxChange],
);
return (
<InfoMessageBoxView
title={title}
items={items}
icon={icon}
checkedIds={checkedIds}
onGroupChange={handleGroupChange}
className={className}
/>
);
},
);
InfoMessageBoxContainer.displayName = "InfoMessageBox";
export default InfoMessageBoxContainer;
@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
export interface InfoMessageBoxItem {
id: string;
label: string;
}
export interface InfoMessageBoxProps {
/** Heading text for the message box */
title: string;
/** Checkbox items (id used as value for CheckboxGroup) */
items: InfoMessageBoxItem[];
/** Optional icon (e.g. exclamation); default exclamation icon used if not provided */
icon?: ReactNode;
/** Controlled checked ids; if undefined, uncontrolled */
checkedIds?: string[];
/** Callback when a checkbox is toggled */
onCheckboxChange?: (id: string, checked: boolean) => void;
className?: string;
}
export interface InfoMessageBoxViewProps {
title: string;
items: InfoMessageBoxItem[];
icon?: ReactNode;
checkedIds: string[];
onGroupChange: (value: string[]) => void;
className: string;
}
@@ -0,0 +1,77 @@
"use client";
import { memo } from "react";
import CheckboxGroup from "../../controls/CheckboxGroup";
import type { InfoMessageBoxViewProps } from "./InfoMessageBox.types";
/** Exclamation icon per Figma 19751:35053 vertical bar + dot inside circle; circle bg white 10% opacity, no border */
function ExclamationIconInline() {
const fillColor = "var(--color-content-default-primary, white)";
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
aria-hidden
>
<circle cx="12" cy="12" r="10" fill="rgba(255,255,255,0.1)" />
<path
d="M11.25 14.0386V5.53857H12.75V14.0386H11.25ZM11.25 18.4616V16.9616H12.75V18.4616H11.25Z"
fill={fillColor}
/>
</svg>
);
}
function InfoMessageBoxView({
title,
items,
icon,
checkedIds,
onGroupChange,
className,
}: InfoMessageBoxViewProps) {
const options = items.map((item) => ({
value: item.id,
label: item.label,
}));
const handleChange = (data: { value: string[] }) => {
onGroupChange(data.value);
};
return (
<div
className={`flex flex-col gap-[12px] p-[var(--spacing-measures-spacing-500,20px)] rounded-[var(--measures-radius-300,12px)] border-l-2 border-solid border-[var(--color-border-default-secondary,#1f1f1f)] bg-[var(--color-content-inverse-secondary,#1f1f1f)] w-full min-w-0 ${className}`}
role="region"
aria-label={title}
>
<div className="flex items-center gap-[var(--measures-spacing-200,8px)] min-w-0">
<div
className="relative shrink-0 size-6 flex items-center justify-center"
data-name="Asset / Icon / exclamation"
>
{icon ?? <ExclamationIconInline />}
</div>
<p className="font-inter font-medium text-[14px] leading-[16px] text-[var(--color-content-default-primary,white)] min-w-0">
{title}
</p>
</div>
<div className="flex flex-col gap-[12px] [&_label]:gap-[6px] [&_label_span]:text-[12px] [&_label_span]:leading-[16px] [&_label_span]:opacity-80 pl-8">
<CheckboxGroup
mode="standard"
value={checkedIds}
onChange={handleChange}
options={options}
aria-label={title}
className="flex flex-col gap-[12px] !space-y-0"
/>
</div>
</div>
);
}
export default memo(InfoMessageBoxView);
@@ -0,0 +1,2 @@
export { default } from "./InfoMessageBox.container";
export type { InfoMessageBoxProps, InfoMessageBoxItem } from "./InfoMessageBox.types";
@@ -75,6 +75,7 @@ function InputLabelView({
</div>
{helpIcon && (
<div className={`relative shrink-0 ${helpIconSize}`}>
{/* eslint-disable-next-line @next/next/no-img-element -- icon from asset path */}
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt="Help"
@@ -19,6 +19,7 @@ export function ModalHeaderView({
className="absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full left-[24px] top-[12px] flex items-center justify-center cursor-pointer"
aria-label="Close dialog"
>
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath("assets/Icon_Close.svg")}
alt=""
+3 -7
View File
@@ -10,16 +10,12 @@ import type { TagViewProps } from "./Tag.types";
*/
export function TagView({ variant, children, className }: TagViewProps) {
const isRecommended = variant === "recommended";
const bgClass = isRecommended
? "bg-[#F6EEA7]"
: "bg-[#3F3F3F]";
const textClass = isRecommended
? "text-[#3F3F3F]"
: "text-[#FFFFFF]";
const bgClass = isRecommended ? "bg-[#F6EEA7]" : "bg-[#3F3F3F]";
const textClass = isRecommended ? "text-[#3F3F3F]" : "text-[#FFFFFF]";
return (
<span
className={`inline-flex items-center justify-center rounded px-2 py-0.5 font-inter text-[10px] font-medium uppercase leading-3 ${bgClass} ${textClass} ${className}`}
className={`inline-flex w-[6rem] min-w-[6rem] items-center justify-center rounded px-2 py-0.5 font-inter text-[10px] font-medium uppercase leading-3 ${bgClass} ${textClass} ${className}`}
role="status"
>
{children}
+2 -2
View File
@@ -39,11 +39,11 @@ export function useMessages(): Messages {
*/
function getTranslationValue(messages: Messages, key: string): string {
const keys = key.split(".");
let value: any = messages;
let value: unknown = messages;
for (const k of keys) {
if (value && typeof value === "object" && k in value) {
value = value[k as keyof typeof value];
value = (value as Record<string, unknown>)[k];
} else {
return key; // Fallback to key if path not found
}
+14 -11
View File
@@ -52,8 +52,7 @@ const ADD_PLATFORM_MODALS: Record<
},
[VIDEO_MEETINGS_CARD_ID]: {
title: "Video Meetings",
description:
"Synchronous video calls for remote face-to-face interaction.",
description: "Synchronous video calls for remote face-to-face interaction.",
nextButtonText: "Add Platform",
},
};
@@ -93,12 +92,12 @@ const ADD_PLATFORM_SECTION_DEFAULTS: Record<
*/
function CreateModalSection({
title,
value,
value: _value,
onChange,
}: {
title: string;
value: string;
onChange: (value: string) => void;
onChange: (_value: string) => void;
}) {
return (
<div className="flex flex-col gap-2">
@@ -115,7 +114,7 @@ function CreateModalSection({
</div>
<TextArea
formHeader={false}
value={value}
value={_value}
onChange={(e) => onChange(e.target.value)}
size="large"
rows={6}
@@ -126,9 +125,15 @@ function CreateModalSection({
}
/** Body for any "Add platform" modal: three editable sections (TextArea only). */
function AddPlatformModalContent({ platformCardId }: { platformCardId: string }) {
function AddPlatformModalContent({
platformCardId,
}: {
platformCardId: string;
}) {
const defaults = ADD_PLATFORM_SECTION_DEFAULTS[platformCardId];
const [sectionValues, setSectionValues] = useState<Record<SectionKey, string>>(
const [sectionValues, setSectionValues] = useState<
Record<SectionKey, string>
>(
defaults ?? {
"Core Principle & Scope": "",
"Logistics, Admin & Norms": "",
@@ -168,15 +173,13 @@ const SAMPLE_CARDS = [
{
id: SIGNAL_CARD_ID,
label: "Signal",
supportText:
"Encrypted messaging for high-security, private coordination.",
supportText: "Encrypted messaging for high-security, private coordination.",
recommended: true,
},
{
id: VIDEO_MEETINGS_CARD_ID,
label: "Video Meetings",
supportText:
"Synchronous video calls for remote face-to-face interaction.",
supportText: "Synchronous video calls for remote face-to-face interaction.",
recommended: true,
},
{
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import DecisionMakingSidebar from "../../components/utility/DecisionMakingSidebar";
import CardStack from "../../components/utility/CardStack";
import type { InfoMessageBoxItem } from "../../components/utility/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../components/utility/CardStack/CardStack.types";
const SIDEBAR_TITLE = "How should conflicts be resolved?";
const SIDEBAR_DESCRIPTION = (
<>
You can also combine or <span className="underline">add</span> new
approaches to the list
</>
);
const MESSAGE_BOX_TITLE =
"Consider defining approaches to steward key resources:";
const MESSAGE_BOX_ITEMS: InfoMessageBoxItem[] = [
{ id: "amend", label: "Amend your CommunityRule" },
{ id: "finances", label: "Steward finances" },
{ id: "project", label: "Project level decisions" },
{ id: "discipline", label: "Discipline and member termination" },
];
const SAMPLE_CARDS: CardStackItem[] = [
{
id: "mediation",
label: "Mediation",
supportText:
"Collaborative work to reach a resolution that all parties can agree upon.",
recommended: true,
},
{
id: "facilitation",
label: "Facilitated dialogue",
supportText:
"Structured sessions where parties collaboratively resolve disputes.",
recommended: true,
},
{
id: "invite-only",
label: "Invite-only",
supportText: "Private discussions with selected participants.",
recommended: true,
},
{
id: "arbitration",
label: "Arbitration",
supportText: "Arbitrators are chosen specifically for a particular case.",
recommended: true,
},
{
id: "direct-dialogue",
label: "Direct dialogue",
supportText:
"Encouraging direct, respectful dialogue between those involved.",
recommended: true,
},
// Extra cards to test scrolling (only visible when "See all" is expanded)
{ id: "label-1", label: "Label", supportText: "", recommended: false },
{ id: "label-2", label: "Label", supportText: "", recommended: false },
{ id: "label-3", label: "Label", supportText: "", recommended: false },
{ id: "label-4", label: "Label", supportText: "", recommended: false },
{ id: "label-5", label: "Label", supportText: "", recommended: false },
{ id: "label-6", label: "Label", supportText: "", recommended: false },
{ id: "label-7", label: "Label", supportText: "", recommended: false },
{ id: "label-8", label: "Label", supportText: "", recommended: false },
{ id: "label-9", label: "Label", supportText: "", recommended: false },
{ id: "label-10", label: "Label", supportText: "", recommended: false },
];
/**
* Right Rail step of the create flow.
* Two-column layout (sidebar + card stack) at 640+, single column at 320-639.
*/
export default function RightRailPage() {
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
[],
);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [expanded, setExpanded] = useState(false);
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
setIsMounted(true);
}, []);
const showDesktopLayout = !isMounted || isMdOrLarger;
const handleMessageBoxCheckboxChange = useCallback(
(id: string, checked: boolean) => {
setMessageBoxCheckedIds((prev) =>
checked ? [...prev, id] : prev.filter((x) => x !== id),
);
},
[],
);
const handleCardSelect = useCallback((id: string) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
}, []);
const handleToggleExpand = useCallback(() => {
setExpanded((prev) => !prev);
}, []);
if (showDesktopLayout) {
return (
<div className="w-full flex flex-col items-center px-5 md:px-12">
<div className="flex gap-12 items-stretch w-full max-w-[1280px] min-w-0">
<div className="flex flex-1 flex-col justify-center gap-3 min-w-0">
<DecisionMakingSidebar
title={SIDEBAR_TITLE}
description={SIDEBAR_DESCRIPTION}
messageBoxTitle={MESSAGE_BOX_TITLE}
messageBoxItems={MESSAGE_BOX_ITEMS}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
size="L"
justification="left"
/>
</div>
<div className="flex flex-1 flex-col gap-6 items-center min-w-0">
<CardStack
cards={SAMPLE_CARDS}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel="See all decision approaches"
showLessLabel="Show less"
title=""
description=""
layout="singleStack"
className="w-full"
/>
</div>
</div>
</div>
);
}
return (
<div className="w-full flex flex-col items-center px-5">
<div className="flex flex-col gap-6 w-full min-w-0">
<DecisionMakingSidebar
title={SIDEBAR_TITLE}
description={SIDEBAR_DESCRIPTION}
messageBoxTitle={MESSAGE_BOX_TITLE}
messageBoxItems={MESSAGE_BOX_ITEMS}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
size="M"
justification="center"
/>
<div className="flex flex-col gap-6 items-center w-full">
<CardStack
cards={SAMPLE_CARDS}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel="See all decision approaches"
showLessLabel="Show less"
title=""
description=""
layout="singleStack"
className="w-full"
/>
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -35,7 +35,7 @@ export interface CreateFlowState {
export interface CreateFlowContextValue {
state: CreateFlowState;
currentStep: CreateFlowStep | null;
updateState: (updates: Partial<CreateFlowState>) => void;
updateState: (_updates: Partial<CreateFlowState>) => void;
// Navigation handlers will be added in CR-56
}
@@ -55,7 +55,7 @@ export interface PageTemplateProps {
export interface NavigationHandlers {
goToNextStep: () => void;
goToPreviousStep: () => void;
goToStep: (step: CreateFlowStep) => void;
goToStep: (_step: CreateFlowStep) => void;
canGoNext: () => boolean;
canGoBack: () => boolean;
}
+2 -3
View File
@@ -6,7 +6,7 @@ import Upload from "../../components/controls/Upload";
/**
* Upload page for the create flow
*
*
* Displays upload functionality using HeaderLockup and Upload components.
* Responsive layout: centered at 640px+, left-aligned below 640px.
* Responsive sizing: uses L/M for HeaderLockup based on 640px breakpoint.
@@ -15,8 +15,7 @@ export default function UploadPage() {
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
const handleUploadClick = () => {
// Handle upload button click
console.log("Upload clicked");
// TODO: Handle upload button click (e.g. open file picker)
};
return (
+2 -1
View File
@@ -52,6 +52,7 @@ export function useMediaQuery(
}
const media = window.matchMedia(mediaQuery);
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync with external system (matchMedia)
setMatches(media.matches);
const listener = (event: MediaQueryListEvent) => {
@@ -67,7 +68,7 @@ export function useMediaQuery(
media.addListener(listener);
return () => media.removeListener(listener);
}
}, [query, direction]);
}, [mediaQuery]);
return matches;
}
+4 -4
View File
@@ -13,11 +13,11 @@ type Messages = typeof messages;
*/
export function getTranslation(messages: Messages, key: string): string {
const keys = key.split(".");
let value: any = messages;
let value: unknown = messages;
for (const k of keys) {
if (value && typeof value === "object" && k in value) {
value = value[k as keyof typeof value];
value = (value as Record<string, unknown>)[k];
} else {
// Fallback to key if path not found
return key;
@@ -42,11 +42,11 @@ export function getNested<T extends keyof Messages>(
}
const keys = key.split(".");
let value: any = namespaceObj;
let value: unknown = namespaceObj;
for (const k of keys) {
if (value && typeof value === "object" && k in value) {
value = value[k];
value = (value as Record<string, unknown>)[k];
} else {
return key;
}
+9 -1
View File
@@ -5,7 +5,15 @@ import ContentBanner from "../../app/components/sections/ContentBanner";
import type { BlogPost } from "../../lib/content";
vi.mock("next/link", () => ({
default: ({ children, href, ...props }: any) => (
default: ({
children,
href,
...props
}: {
children?: React.ReactNode;
href?: string;
[key: string]: unknown;
}) => (
<a href={href} {...props}>
{children}
</a>
+3 -1
View File
@@ -44,7 +44,9 @@ describe("CreateFlowFooter (behavioral tests)", () => {
it("renders progress bar when progressBar is true", () => {
render(<CreateFlowFooter progressBar={true} />);
const footer = screen.getByRole("contentinfo", { name: "Create Flow Footer" });
const footer = screen.getByRole("contentinfo", {
name: "Create Flow Footer",
});
expect(footer).toBeInTheDocument();
});
+3 -1
View File
@@ -80,7 +80,9 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
it("does not render Share button when hasShare is false", () => {
render(<CreateFlowTopNav hasShare={false} />);
expect(screen.queryByRole("button", { name: "Share" })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Share" }),
).not.toBeInTheDocument();
});
it("renders Export button when hasExport is true", () => {
+2 -10
View File
@@ -45,9 +45,7 @@ describe("HeaderLockup (behavioral tests)", () => {
});
it("renders description when provided", () => {
render(
<HeaderLockup title="Test Title" description="Test description" />,
);
render(<HeaderLockup title="Test Title" description="Test description" />);
expect(screen.getByText("Test description")).toBeInTheDocument();
});
@@ -71,13 +69,7 @@ describe("HeaderLockup (behavioral tests)", () => {
});
it("accepts PascalCase props", () => {
render(
<HeaderLockup
title="Test Title"
justification="Left"
size="L"
/>,
);
render(<HeaderLockup title="Test Title" justification="Left" size="L" />);
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
});
});
+1 -1
View File
@@ -47,7 +47,7 @@ describe("Logo (behavioral tests)", () => {
it("hides text when showText is false", () => {
const { container } = render(<Logo showText={false} />);
const textElement = container.querySelector('.hidden');
const textElement = container.querySelector(".hidden");
expect(textElement).toBeInTheDocument();
expect(screen.getByAltText("CommunityRule Logo Icon")).toBeInTheDocument();
});
+6 -12
View File
@@ -135,13 +135,10 @@ describe("MultiSelect behaviour specifics", () => {
});
it("does not render add button when addButton is false", () => {
render(
<MultiSelect
options={defaultChipOptions}
addButton={false}
/>,
);
expect(screen.queryByRole("button", { name: /add/i })).not.toBeInTheDocument();
render(<MultiSelect options={defaultChipOptions} addButton={false} />);
expect(
screen.queryByRole("button", { name: /add/i }),
).not.toBeInTheDocument();
});
it("handles custom chip confirm", async () => {
@@ -163,7 +160,7 @@ describe("MultiSelect behaviour specifics", () => {
// 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");
@@ -175,10 +172,7 @@ describe("MultiSelect behaviour specifics", () => {
{ id: "custom-1", label: "", state: "Custom" as const },
];
render(
<MultiSelect
options={customOptions}
onCustomChipClose={handleClose}
/>,
<MultiSelect options={customOptions} onCustomChipClose={handleClose} />,
);
const closeButton = screen.getByRole("button", { name: "Close" });
+9 -1
View File
@@ -5,7 +5,15 @@ import RelatedArticles from "../../app/components/sections/RelatedArticles";
import type { BlogPost } from "../../lib/content";
vi.mock("next/link", () => ({
default: ({ children, href, ...props }: any) => (
default: ({
children,
href,
...props
}: {
children?: React.ReactNode;
href?: string;
[key: string]: unknown;
}) => (
<a href={href} {...props}>
{children}
</a>
+6 -2
View File
@@ -13,7 +13,9 @@ describe("ReviewPage", () => {
it("renders HeaderLockup with expected title", () => {
render(<ReviewPage />);
expect(
screen.getByRole("heading", { name: "Your community is added - congrats!" }),
screen.getByRole("heading", {
name: "Your community is added - congrats!",
}),
).toBeInTheDocument();
});
@@ -44,6 +46,8 @@ describe("ReviewPage", () => {
render(<ReviewPage />);
const buttons = screen.getAllByRole("button");
expect(buttons.length).toBeGreaterThanOrEqual(1);
expect(buttons.some((el) => el.textContent?.includes("Mutual Aid Mondays"))).toBe(true);
expect(
buttons.some((el) => el.textContent?.includes("Mutual Aid Mondays")),
).toBe(true);
});
});
+2 -4
View File
@@ -1,6 +1,6 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { render } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import TextInput from "../../app/components/controls/TextInput";
import { componentTestSuite } from "../utils/componentTestSuite";
@@ -34,9 +34,7 @@ componentTestSuite<TextInputProps>({
describe("TextInput (size tests)", () => {
it("renders with medium size by default", () => {
const { container } = render(
<TextInput label="Test" inputSize="medium" />,
);
const { container } = render(<TextInput label="Test" inputSize="medium" />);
const input = container.querySelector("input");
expect(input).toHaveClass("h-[40px]");
});
+21 -7
View File
@@ -33,13 +33,17 @@ describe("Upload (behavioral tests)", () => {
it("renders with active state by default", () => {
render(<Upload label="Upload" />);
const button = screen.getByRole("button", { name: /upload/i });
expect(button).toHaveClass("bg-[var(--color-surface-invert-primary,white)]");
expect(button).toHaveClass(
"bg-[var(--color-surface-invert-primary,white)]",
);
});
it("renders with inactive state when active is false", () => {
render(<Upload label="Upload" active={false} />);
const button = screen.getByRole("button", { name: /upload/i });
expect(button).toHaveClass("bg-[var(--color-surface-default-secondary,#141414)]");
expect(button).toHaveClass(
"bg-[var(--color-surface-default-secondary,#141414)]",
);
});
it("displays label when provided", () => {
@@ -76,20 +80,30 @@ describe("Upload (behavioral tests)", () => {
it("displays description text", () => {
render(<Upload label="Upload" />);
expect(screen.getByText(/Add images, PDFs, and other files to the policy/i)).toBeInTheDocument();
expect(
screen.getByText(/Add images, PDFs, and other files to the policy/i),
).toBeInTheDocument();
});
it("applies active state styles correctly", () => {
render(<Upload label="Upload" active={true} />);
const descriptionText = screen.getByText(/Add images, PDFs, and other files to the policy/i);
const descriptionText = screen.getByText(
/Add images, PDFs, and other files to the policy/i,
);
const descriptionContainer = descriptionText.parentElement;
expect(descriptionContainer).toHaveClass("text-[color:var(--color-content-default-primary,white)]");
expect(descriptionContainer).toHaveClass(
"text-[color:var(--color-content-default-primary,white)]",
);
});
it("applies inactive state styles correctly", () => {
render(<Upload label="Upload" active={false} />);
const descriptionText = screen.getByText(/Add images, PDFs, and other files to the policy/i);
const descriptionText = screen.getByText(
/Add images, PDFs, and other files to the policy/i,
);
const descriptionContainer = descriptionText.parentElement;
expect(descriptionContainer).toHaveClass("text-[color:var(--color-content-default-tertiary,#b4b4b4)]");
expect(descriptionContainer).toHaveClass(
"text-[color:var(--color-content-default-tertiary,#b4b4b4)]",
);
});
});
+5 -1
View File
@@ -74,7 +74,11 @@ test.describe("Performance Monitoring", () => {
const metrics: { lcp?: number; fid?: number; cls?: number } = {};
for (const entry of entries) {
const e = entry as any;
const e = entry as PerformanceEntry & {
startTime?: number;
processingStart?: number;
value?: number;
};
if (
e.name === "LCP" ||
e.entryType === "largest-contentful-paint"
+2 -2
View File
@@ -1,7 +1,7 @@
import { test, expect } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
test.describe("Visual Regression Tests", () => {
async function settle(page: any) {
async function settle(page: Page) {
await page.evaluate(() => {
window.scrollTo(0, window.scrollY); // ensure a frame boundary
void document.body.getBoundingClientRect();
+6 -2
View File
@@ -32,7 +32,9 @@ describe("Create flow cards page", () => {
render(<CardsPage />);
expect(
screen.getByText("How should this community communicate with each-other?"),
screen.getByText(
"How should this community communicate with each-other?",
),
).toBeInTheDocument();
});
@@ -58,7 +60,9 @@ describe("Create flow cards page", () => {
});
await user.click(toggle);
expect(screen.getByRole("button", { name: "Show less" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Show less" }),
).toBeInTheDocument();
expect(
screen.getByText(
"What method should this community use to communicate with eachother?",
+145
View File
@@ -0,0 +1,145 @@
import {
renderWithProviders as render,
screen,
cleanup,
within,
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { describe, test, expect, afterEach } from "vitest";
import RightRailPage from "../../app/create/right-rail/page";
afterEach(() => {
cleanup();
});
describe("Create flow right-rail page", () => {
test("renders without error", () => {
render(<RightRailPage />);
expect(
screen.getByRole("heading", {
name: "How should conflicts be resolved?",
}),
).toBeInTheDocument();
});
test("renders sidebar description with add link", () => {
render(<RightRailPage />);
const description = screen.getByText((content, element) => {
if (element?.tagName !== "P") return false;
const text = element.textContent ?? "";
return (
text.includes("You can also combine or") &&
text.includes("add") &&
text.includes("new approaches to the list")
);
});
expect(description).toBeInTheDocument();
});
test("renders message box with title and checkboxes", () => {
render(<RightRailPage />);
const region = screen.getByRole("region", {
name: "Consider defining approaches to steward key resources:",
});
expect(region).toBeInTheDocument();
expect(
within(region).getByRole("checkbox", {
name: "Amend your CommunityRule",
}),
).toBeInTheDocument();
expect(
within(region).getByRole("checkbox", { name: "Steward finances" }),
).toBeInTheDocument();
expect(
within(region).getByRole("checkbox", { name: "Project level decisions" }),
).toBeInTheDocument();
expect(
within(region).getByRole("checkbox", {
name: "Discipline and member termination",
}),
).toBeInTheDocument();
});
test("renders card stack with See all decision approaches toggle", () => {
render(<RightRailPage />);
expect(
screen.getByRole("button", { name: "See all decision approaches" }),
).toBeInTheDocument();
});
test("renders recommended approach cards", () => {
render(<RightRailPage />);
expect(
screen.getByRole("button", {
name: /Mediation: Collaborative work to reach a resolution/,
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: /Facilitated dialogue: Structured sessions/,
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: /Invite-only: Private discussions with selected participants/,
}),
).toBeInTheDocument();
});
test("toggle expands and shows Show less", async () => {
const user = userEvent.setup();
render(<RightRailPage />);
const toggle = screen.getByRole("button", {
name: "See all decision approaches",
});
await user.click(toggle);
expect(
screen.getByRole("button", { name: "Show less" }),
).toBeInTheDocument();
});
test("expanded view shows Label cards", async () => {
const user = userEvent.setup();
render(<RightRailPage />);
const toggle = screen.getByRole("button", {
name: "See all decision approaches",
});
await user.click(toggle);
const labelButtons = screen.getAllByRole("button", { name: /^Label/ });
expect(labelButtons.length).toBeGreaterThanOrEqual(1);
});
test("clicking a card toggles selection", async () => {
const user = userEvent.setup();
render(<RightRailPage />);
const mediationCard = screen.getByRole("button", {
name: /Mediation: Collaborative work to reach a resolution/,
});
await user.click(mediationCard);
expect(screen.getByText("SELECTED")).toBeInTheDocument();
});
test("message box checkboxes are interactive", async () => {
const user = userEvent.setup();
render(<RightRailPage />);
const amendCheckbox = screen.getByRole("checkbox", {
name: "Amend your CommunityRule",
});
expect(amendCheckbox).not.toBeChecked();
await user.click(amendCheckbox);
expect(amendCheckbox).toBeChecked();
});
});
+4 -4
View File
@@ -15,10 +15,8 @@ vi.mock("next/dynamic", () => {
default: (importFn, options) => {
// In tests, resolve the dynamic import immediately and return the component
let Component = null;
let resolved = false;
importFn().then((mod) => {
Component = mod.default || mod;
resolved = true;
});
// Return a synchronous wrapper that uses the mocked component
return (props) => {
@@ -261,10 +259,12 @@ describe("User Journey Integration", () => {
},
{ timeout: 3000 },
);
} catch (error) {
} catch {
// Dynamic import may not resolve in test environment - this is a known limitation
// The component functionality is tested in RuleStack.test.jsx
console.warn("Dynamic import for RuleStack did not resolve in test environment");
console.warn(
"Dynamic import for RuleStack did not resolve in test environment",
);
}
// 4. User sees features and benefits - wait for dynamically imported component
+5 -5
View File
@@ -89,10 +89,7 @@ describe("Card Component", () => {
render(<Card {...defaultProps} />);
const card = screen.getByRole("button");
expect(card).toHaveAttribute(
"aria-label",
"Label: Support text here",
);
expect(card).toHaveAttribute("aria-label", "Label: Support text here");
expect(card).toHaveAttribute("tabIndex", "0");
});
@@ -100,6 +97,9 @@ describe("Card Component", () => {
render(<Card label="Label only" orientation="horizontal" />);
expect(screen.getByText("Label only")).toBeInTheDocument();
expect(screen.getByRole("button")).toHaveAttribute("aria-label", "Label only");
expect(screen.getByRole("button")).toHaveAttribute(
"aria-label",
"Label only",
);
});
});
+22 -7
View File
@@ -9,9 +9,24 @@ import { vi, describe, test, expect, afterEach } from "vitest";
import CardStack from "../../app/components/utility/CardStack";
const SAMPLE_CARDS = [
{ id: "1", label: "Option A", supportText: "Description A", recommended: true },
{ id: "2", label: "Option B", supportText: "Description B", recommended: false },
{ id: "3", label: "Option C", supportText: "Description C", recommended: true },
{
id: "1",
label: "Option A",
supportText: "Description A",
recommended: true,
},
{
id: "2",
label: "Option B",
supportText: "Description B",
recommended: false,
},
{
id: "3",
label: "Option C",
supportText: "Description C",
recommended: true,
},
];
afterEach(() => {
@@ -62,7 +77,9 @@ describe("CardStack Component", () => {
render(<CardStack cards={SAMPLE_CARDS} hasMore={false} />);
expect(
screen.queryByRole("button", { name: "See all communication approaches" }),
screen.queryByRole("button", {
name: "See all communication approaches",
}),
).not.toBeInTheDocument();
});
@@ -82,9 +99,7 @@ describe("CardStack Component", () => {
test("calls onCardSelect when a card is clicked", () => {
const onCardSelect = vi.fn();
render(
<CardStack cards={SAMPLE_CARDS} onCardSelect={onCardSelect} />,
);
render(<CardStack cards={SAMPLE_CARDS} onCardSelect={onCardSelect} />);
const cardButtons = screen.getAllByRole("button", {
name: "Option A: Description A",
+11 -4
View File
@@ -39,7 +39,16 @@ describe("NumberCard Component", () => {
const card = screen
.getByText("Test Card Text")
.closest("div").parentElement;
expect(card).toHaveClass("flex", "flex-col", "sm:flex-row", "sm:items-center", "lg:flex-col", "lg:items-start", "lg:justify-end", "lg:relative");
expect(card).toHaveClass(
"flex",
"flex-col",
"sm:flex-row",
"sm:items-center",
"lg:flex-col",
"lg:items-start",
"lg:justify-end",
"lg:relative",
);
});
it("applies proper responsive spacing when size is not specified", () => {
@@ -194,9 +203,7 @@ describe("NumberCard Component", () => {
render(<NumberCard {...defaultProps} size="Small" />);
// For Small size, text is directly in card div (no wrapper), so use closest("div")
const card = screen
.getByText("Test Card Text")
.closest("div");
const card = screen.getByText("Test Card Text").closest("div");
expect(card).toHaveClass(
"flex",
"flex-col",
+5 -12
View File
@@ -148,7 +148,9 @@ describe("RuleCard Component", () => {
const heading = screen.getByRole("heading", { level: 3 });
// 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-bricolage-grotesque/,
);
expect(heading?.className).toMatch(/min-\[1440px\]:font-extrabold/);
});
@@ -162,11 +164,7 @@ describe("RuleCard Component", () => {
},
];
render(
<RuleCard
{...defaultProps}
expanded={true}
categories={categories}
/>,
<RuleCard {...defaultProps} expanded={true} categories={categories} />,
);
expect(screen.getByText("Values")).toBeInTheDocument();
@@ -186,12 +184,7 @@ describe("RuleCard Component", () => {
});
it("renders with community initials fallback", () => {
render(
<RuleCard
{...defaultProps}
communityInitials="CE"
/>,
);
render(<RuleCard {...defaultProps} communityInitials="CE" />);
expect(screen.getByText("CE")).toBeInTheDocument();
});
+6 -2
View File
@@ -147,8 +147,12 @@ describe("RuleStack Component", () => {
"/assets/Icon_Sociocracy.svg",
);
// 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-\[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\]/);
});
+9 -4
View File
@@ -1,4 +1,5 @@
import "@testing-library/jest-dom/vitest";
import type React from "react";
import { afterAll, afterEach, beforeAll, vi } from "vitest";
import { cleanup } from "@testing-library/react";
import { server } from "./tests/msw/server";
@@ -11,15 +12,19 @@ import { server } from "./tests/msw/server";
vi.mock("next/dynamic", () => {
const React = require("react");
return {
default: (importFn: () => Promise<any>, options?: any) => {
default: (
importFn: () => Promise<{ default?: React.ComponentType }>,
options?: { loading?: () => React.ReactNode },
) => {
// In tests, return a component that immediately resolves and renders
return function DynamicComponent(props: any) {
const [Component, setComponent] = React.useState(null);
return function DynamicComponent(props: Record<string, unknown>) {
const [Component, setComponent] =
React.useState<React.ComponentType | null>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
importFn()
.then((mod: any) => {
.then((mod: { default?: React.ComponentType }) => {
setComponent(mod.default || mod);
setLoading(false);
})