Merge pull request 'adilallo/feature/PageTemplateImplementations' (#42) from adilallo/feature/PageTemplateImplementations into main

Reviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
2026-04-04 17:05:46 +00:00
210 changed files with 7330 additions and 965 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: CI Pipeline
run-name: "${{ gitea.actor }} triggered CI pipeline"
on:
workflow_dispatch: {} # Manual trigger only - run tests locally before merging
workflow_dispatch: {} # Manual trigger only - run tests locally before merging
# Auto-runs disabled for solo development
# Re-enable when ready for collaborators:
# pull_request:
+313 -140
View File
@@ -1,12 +1,16 @@
"use client";
import { useState } from "react";
import RuleCard from "../components/cards/RuleCard";
import Chip from "../components/controls/Chip";
import MultiSelect from "../components/controls/MultiSelect";
import RuleCard from "../../components/cards/RuleCard";
import Card from "../../components/cards/Card";
import Chip from "../../components/controls/Chip";
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;
@@ -17,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" },
@@ -35,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) => {
@@ -53,8 +60,8 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
prev.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" as const }
: opt
)
: opt,
),
);
};
@@ -83,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: [
@@ -124,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
@@ -145,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
@@ -158,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) => {
@@ -171,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) =>
@@ -193,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
@@ -214,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
@@ -227,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) => {
@@ -240,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,
),
);
},
},
@@ -262,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
@@ -283,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
@@ -296,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) => {
@@ -309,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,
),
);
},
},
@@ -332,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
@@ -353,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
@@ -366,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) => {
@@ -379,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,
),
);
},
},
@@ -402,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
@@ -423,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
@@ -436,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) => {
@@ -449,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,
),
);
},
},
@@ -466,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 and Chip component examples - states, palettes, sizes, and interactions
RuleCard, Card, and Chip component examples - states, palettes,
sizes, and interactions
</p>
</header>
@@ -480,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"
@@ -490,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",
}))
}
/>
@@ -502,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",
}))
}
/>
@@ -527,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,
),
);
}
}}
@@ -560,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"
@@ -591,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"
@@ -605,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",
}))
}
/>
@@ -617,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",
}))
}
/>
@@ -633,6 +737,75 @@ export default function ComponentsPreview() {
</div>
</section>
{/* Card Component - Create flow selection card variants */}
<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)]">
Card 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)]">
<p className="font-inter text-[18px] leading-[24px] text-[var(--color-content-default-secondary)]">
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">
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
Horizontal + Recommended
</h3>
<Card
label="Label"
supportText="Members vote to resolve a dispute democratically."
recommended={true}
selected={false}
orientation="horizontal"
onClick={() => console.warn("Card clicked")}
/>
</div>
<div className="space-y-2">
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
Horizontal + Selected
</h3>
<Card
label="Label"
supportText="Members vote to resolve a dispute democratically."
recommended={false}
selected={true}
orientation="horizontal"
onClick={() => console.warn("Card clicked")}
/>
</div>
<div className="space-y-2">
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
Vertical + Recommended
</h3>
<Card
label="Label"
supportText="Invite-only"
recommended={true}
selected={false}
orientation="vertical"
showInfoIcon={true}
onClick={() => console.warn("Card clicked")}
/>
</div>
<div className="space-y-2">
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
Vertical + Selected
</h3>
<Card
label="Label"
supportText="Invite-only"
recommended={false}
selected={true}
orientation="vertical"
showInfoIcon={true}
onClick={() => console.warn("Card clicked")}
/>
</div>
</div>
</div>
</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)]">
@@ -648,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 */}
@@ -668,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."
@@ -689,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 */}
@@ -710,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
@@ -737,7 +910,7 @@ export default function ComponentsPreview() {
height={103}
/>
}
onClick={() => console.log("Consensus clusters selected")}
onClick={() => console.warn("Consensus clusters selected")}
/>
<RuleCard
title="Consensus"
@@ -754,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 */}
@@ -774,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 */}
@@ -787,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>
+1 -1
View File
@@ -189,7 +189,7 @@ export default async function BlogPostPage({ params }: PageProps) {
url: "https://communityrule.com",
logo: {
"@type": "ImageObject",
url: "https://communityrule.com/assets/Logo.svg",
url: "https://communityrule.com/assets/logo/Logo.svg",
},
},
datePublished: post.frontmatter.date,
+1 -1
View File
@@ -1,5 +1,5 @@
import { getAllBlogPosts } from "../../../lib/content";
import ContentThumbnailTemplate from "../../../components/content/ContentThumbnailTemplate";
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import type { Metadata } from "next";
export const metadata: Metadata = {
+18 -12
View File
@@ -12,12 +12,15 @@ const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
ssr: true,
});
const NumberedCards = dynamic(() => import("../components/sections/NumberedCards"), {
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[300px]" />
),
ssr: true,
});
const NumberedCards = dynamic(
() => import("../components/sections/NumberedCards"),
{
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[300px]" />
),
ssr: true,
},
);
const RuleStack = dynamic(() => import("../components/sections/RuleStack"), {
loading: () => (
@@ -26,12 +29,15 @@ const RuleStack = dynamic(() => import("../components/sections/RuleStack"), {
ssr: true,
});
const FeatureGrid = dynamic(() => import("../components/sections/FeatureGrid"), {
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[500px]" />
),
ssr: true,
});
const FeatureGrid = dynamic(
() => import("../components/sections/FeatureGrid"),
{
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[500px]" />
),
ssr: true,
},
);
const QuoteBlock = dynamic(() => import("../components/sections/QuoteBlock"), {
loading: () => (
@@ -1,4 +1,10 @@
export type ContextMenuItemSizeValue = "small" | "medium" | "large" | "Small" | "Medium" | "Large";
export type ContextMenuItemSizeValue =
| "small"
| "medium"
| "large"
| "Small"
| "Medium"
| "Large";
export interface ContextMenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
@@ -40,7 +40,25 @@ const WebVitalsDashboardContainer = memo(() => {
if (typeof window !== "undefined") {
import("web-vitals").then((webVitals) => {
const { getCLS, getFID, getFCP, getLCP, getTTFB } = webVitals as any;
// web-vitals v4 typings don't expose legacy get* names the same way; runtime bundle still provides them for this dashboard.
const { getCLS, getFID, getFCP, getLCP, getTTFB } =
webVitals as unknown 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) => ({
+55
View File
@@ -0,0 +1,55 @@
"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

+3
View File
@@ -0,0 +1,3 @@
export { default as Icon } from "./Icon";
export type { IconName, IconProps } from "./Icon";
export { default as Logo } from "./logo";
+134
View File
@@ -0,0 +1,134 @@
import { memo } from "react";
import Link from "next/link";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
interface LogoProps {
size?:
| "default"
| "footer"
| "createFlow"
| "topNavFolderTop"
| "topNavHeader";
/**
* Visual style: default (dark on light) or inverse (e.g. black/white on teal).
* @default "default"
*/
palette?: "default" | "inverse";
/**
* Whether to show the "CommunityRule" wordmark.
* @default true
*/
wordmark?: boolean;
}
interface SizeConfig {
containerHeight: string;
gap: string;
textSize: string;
lineHeight: string;
iconSize: string;
}
const Logo = memo<LogoProps>(
({ size = "default", palette = "default", wordmark = true }) => {
// Size configurations
const sizes: Record<string, SizeConfig> = {
default: {
containerHeight: "h-[41px]",
gap: "gap-[8.28px]",
textSize: "text-[21.97px]",
lineHeight: "leading-[27.05px]",
iconSize: "w-[27.05px] h-[27.05px]",
},
footer: {
containerHeight:
"h-[41px] sm:h-[calc(40px*1.37)] lg:h-[calc(40px*2.05)]",
gap: "gap-[8.28px] sm:gap-[calc(8px*1.37)] lg:gap-[calc(8px*2.05)]",
textSize:
"text-[21.97px] sm:text-[calc(21.97px*1.37)] lg:text-[calc(21.97px*2.05)]",
lineHeight:
"leading-[27.05px] sm:leading-[calc(27.05px*1.37)] lg:leading-[calc(27.05px*2.05)]",
iconSize:
"w-[27.05px] h-[27.05px] sm:w-[calc(27.05px*1.37)] sm:h-[calc(27.05px*1.37)] lg:w-[calc(27.05px*2.05)] lg:h-[calc(27.05px*2.05)]",
},
createFlow: {
containerHeight: "h-[30px] md:h-[41px]",
gap: "gap-[6px] md:gap-[8.28px]",
textSize: "text-[16.48px] md:text-[21.97px]",
lineHeight: "leading-[20.28px] md:leading-[27.05px]",
iconSize: "w-[20.28px] h-[20.28px] md:w-[27.05px] md:h-[27.05px]",
},
topNavFolderTop: {
containerHeight:
"h-[14.11px] sm:h-[21.06px] md:h-[32.24px] lg:h-[28px] xl:h-[36px]",
gap: "gap-0 sm:gap-[3.19px] md:gap-[4.89px] lg:gap-[6.55px] xl:gap-[8.64px]",
textSize:
"text-[11.57px] sm:text-[11.69px] md:text-[17.89px] lg:text-[21.97px] xl:text-[29.01px]",
lineHeight:
"leading-[14.24px] sm:leading-[14.39px] md:leading-[22.02px] lg:leading-[27.05px] xl:leading-[35.7px]",
iconSize:
"w-[14.11px] h-[14.11px] sm:w-[14.39px] sm:h-[14.39px] md:w-[22.02px] md:h-[22.02px] lg:w-[27.05px] lg:h-[27.05px] xl:w-[35.7px] xl:h-[35.7px]",
},
topNavHeader: {
containerHeight:
"h-[20.85px] sm:h-[20.85px] md:h-[17.91px] lg:h-[28px] xl:h-[34px]",
gap: "gap-0 sm:gap-[4.21px] md:gap-[6.51px] lg:gap-[6.55px] xl:gap-[8.19px]",
textSize:
"text-[11.57px] sm:text-[11.57px] md:text-[17.89px] lg:text-[21.97px] xl:text-[27.47px]",
lineHeight:
"leading-[14.24px] sm:leading-[14.24px] md:leading-[22.02px] lg:leading-[27.05px] xl:leading-[33.81px]",
iconSize:
"w-[14.24px] h-[14.24px] sm:w-[14.24px] sm:h-[14.24px] md:w-[22.02px] md:h-[22.02px] lg:w-[27.05px] lg:h-[27.05px] xl:w-[33.81px] xl:h-[33.81px]",
},
};
const config = sizes[size || "default"] || sizes.default;
const isInverse = palette === "inverse";
const textColorClass = isInverse
? "text-[var(--color-content-invert-primary)]"
: "text-[var(--color-content-default-primary)]";
const wordmarkVisibilityClass =
size === "topNavFolderTop" || size === "topNavHeader"
? wordmark
? "hidden sm:block"
: "hidden"
: wordmark
? ""
: "hidden";
return (
<Link href="/" className="block" aria-label="CommunityRule Logo">
<div
className={`flex items-center ${config.containerHeight} ${
wordmark ? config.gap : ""
} transition-all duration-200 ease-in-out hover:scale-[1.02] cursor-pointer`}
>
{/* Logo Text - responsive visibility for topNav sizes */}
<div
className={`font-bricolage-grotesque ${textColorClass} ${config.textSize} ${config.lineHeight} font-normal tracking-[0px] transition-colors duration-200 ${wordmarkVisibilityClass}`}
aria-label="CommunityRule"
>
CommunityRule
</div>
{/* Vector Icon */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getAssetPath(ASSETS.LOGO)}
alt="CommunityRule Logo Icon"
width={27.05}
height={27.05}
className={`flex-shrink-0 ${config.iconSize} transition-all duration-200 ${
isInverse ? "brightness-0" : ""
}`}
aria-hidden="true"
/>
</div>
</Link>
);
},
);
Logo.displayName = "Logo";
export default Logo;
+1
View File
@@ -0,0 +1 @@
export { default } from "./Logo";
+6 -9
View File
@@ -109,15 +109,11 @@ const Button = memo<ButtonProps>(
const variant = getVariantFromTypeAndPalette(buttonType, buttonPalette);
const sizeStyles: Record<string, string> = {
xsmall:
"p-[var(--spacing-scale-004)] gap-[var(--spacing-scale-002)]",
small:
"p-[var(--spacing-scale-008)] gap-[var(--spacing-scale-002)]",
xsmall: "p-[var(--spacing-scale-004)] gap-[var(--spacing-scale-002)]",
small: "p-[var(--spacing-scale-008)] gap-[var(--spacing-scale-002)]",
medium: "p-[var(--spacing-scale-010)] gap-[var(--spacing-scale-004)]",
large:
"p-[var(--spacing-scale-012)] gap-[var(--spacing-scale-006)]",
xlarge:
"p-[var(--spacing-scale-016)] gap-[var(--spacing-scale-008)]",
large: "p-[var(--spacing-scale-012)] gap-[var(--spacing-scale-006)]",
xlarge: "p-[var(--spacing-scale-016)] gap-[var(--spacing-scale-008)]",
};
const fontStyles: Record<string, string> = {
@@ -135,7 +131,8 @@ const Button = memo<ButtonProps>(
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-default-primary)] hover:text-[var(--color-content-default-brand-primary)] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus:bg-[var(--color-surface-default-primary)] focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
outline:
"bg-transparent text-[var(--color-content-default-primary)] border-[1.5px] border-[var(--color-border-invert-primary)] hover:bg-transparent hover:text-[var(--color-content-default-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-border-invert-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"outline-inverse": "bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-[var(--color-border-default-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-invert-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-border-default-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-invert-primary)] active:border-[1.5px] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"outline-inverse":
"bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-[var(--color-border-default-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-invert-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-border-default-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-invert-primary)] active:border-[1.5px] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
ghost:
"bg-transparent text-[var(--color-content-default-brand-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-default-primary)] hover:border-transparent hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"ghost-inverse":
@@ -0,0 +1,49 @@
"use client";
import { memo } from "react";
import { CardView } from "./Card.view";
import type { CardProps } from "./Card.types";
const CardContainer = memo<CardProps>(
({
label,
supportText = "",
recommended = false,
selected = false,
orientation = "horizontal",
showInfoIcon = false,
id,
className = "",
onClick,
}) => {
const handleClick = () => {
if (onClick) onClick();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleClick();
}
};
return (
<CardView
label={label}
supportText={supportText}
recommended={recommended}
selected={selected}
orientation={orientation}
showInfoIcon={showInfoIcon}
id={id}
className={className}
onClick={handleClick}
onKeyDown={handleKeyDown}
/>
);
},
);
CardContainer.displayName = "Card";
export default CardContainer;
+25
View File
@@ -0,0 +1,25 @@
export interface CardProps {
label: string;
supportText?: string;
recommended?: boolean;
selected?: boolean;
orientation: "horizontal" | "vertical";
showInfoIcon?: boolean;
/** Optional id for the card root (e.g. data-card-id for focus after modal close). */
id?: string;
className?: string;
onClick?: () => void;
}
export interface CardViewProps {
label: string;
supportText: string;
recommended: boolean;
selected: boolean;
orientation: "horizontal" | "vertical";
showInfoIcon: boolean;
id: string | undefined;
className: string;
onClick: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import Tag from "../../utility/Tag";
import type { CardViewProps } from "./Card.types";
function InfoIcon() {
return (
<span
className="flex h-[var(--spacing-scale-016)] w-[var(--spacing-scale-016)] shrink-0 items-center justify-center rounded-full border border-[var(--color-content-invert-brand-secondary)] bg-transparent font-inter text-[10px] font-medium leading-none text-[var(--color-content-invert-brand-secondary)]"
aria-hidden
>
?
</span>
);
}
function CardTag({
recommended,
selected,
}: {
recommended: boolean;
selected: boolean;
}) {
if (selected) return <Tag variant="selected" />;
if (recommended) return <Tag variant="recommended" />;
return null;
}
export function CardView({
label,
supportText,
recommended,
selected,
orientation,
showInfoIcon,
id: cardId,
className,
onClick,
onKeyDown,
}: CardViewProps) {
const borderClass = "border border-[var(--color-border-default-primary)]";
const selectedBorder = selected
? "outline outline-2 outline-dashed outline-black outline-offset-[-2px]"
: "";
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 (
<div
{...(cardId ? { "data-card-id": cardId } : {})}
role="button"
tabIndex={0}
aria-label={supportText ? `${label}: ${supportText}` : label}
className={baseClasses}
onClick={onClick}
onKeyDown={onKeyDown}
>
<div className="flex flex-col gap-2 items-start w-full">
<CardTag recommended={recommended} selected={selected} />
<span className="font-inter text-base font-semibold leading-6 text-black w-full">
{label}
</span>
{supportText ? (
<p className="font-inter text-sm font-normal leading-5 text-black w-full">
{supportText}
</p>
) : null}
</div>
</div>
);
}
return (
<div
{...(cardId ? { "data-card-id": cardId } : {})}
role="button"
tabIndex={0}
aria-label={supportText ? `${label}: ${supportText}` : label}
className={`${baseClasses} flex flex-row items-center justify-between gap-4`}
onClick={onClick}
onKeyDown={onKeyDown}
>
<div className="min-w-0 flex-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-inter text-base font-semibold leading-6 text-black">
{label}
</span>
{showInfoIcon ? <InfoIcon /> : null}
</div>
{supportText ? (
<p className="font-inter text-sm font-normal leading-5 text-black">
{supportText}
</p>
) : null}
</div>
<div className="shrink-0 w-[6rem]">
<CardTag recommended={recommended} selected={selected} />
</div>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Card.container";
export type { CardProps } from "./Card.types";
+20 -15
View File
@@ -29,7 +29,8 @@ interface NumberCardProps {
const NumberCard = memo<NumberCardProps>(({ number, text, size: sizeProp }) => {
// Base classes common to all sizes
const baseClasses = "bg-[var(--color-surface-inverse-primary)] rounded-[12px] shadow-lg";
const baseClasses =
"bg-[var(--color-surface-inverse-primary)] rounded-[12px] shadow-lg";
// If size prop is provided, use explicit size classes
// Otherwise, use responsive breakpoints for backward compatibility
@@ -40,16 +41,22 @@ const NumberCard = memo<NumberCardProps>(({ number, text, size: sizeProp }) => {
const sizeClasses = {
Small: "flex flex-col items-end justify-center gap-4 p-5 relative",
Medium: "flex flex-row items-center gap-8 p-8 relative",
Large: "flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
XLarge: "flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
Large:
"flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
XLarge:
"flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
};
// Text size classes
const textClasses = {
Small: "font-bricolage-grotesque font-medium text-[24px] leading-[32px] text-[#141414]",
Medium: "font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
Large: "font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
XLarge: "font-bricolage-grotesque font-medium text-[32px] leading-[32px] text-[#141414]",
Small:
"font-bricolage-grotesque font-medium text-[24px] leading-[32px] text-[#141414]",
Medium:
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
Large:
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
XLarge:
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] text-[#141414]",
};
// Section number wrapper classes - Small doesn't need a wrapper
@@ -74,11 +81,9 @@ const NumberCard = memo<NumberCardProps>(({ number, text, size: sizeProp }) => {
<div className={`${baseClasses} ${sizeClasses[size]}`}>
{/* Section Number - Direct child for Small */}
<SectionNumber number={number} />
{/* Card Content */}
<p className={textClasses[size]}>
{text}
</p>
<p className={textClasses[size]}>{text}</p>
</div>
);
}
@@ -92,9 +97,7 @@ const NumberCard = memo<NumberCardProps>(({ number, text, size: sizeProp }) => {
{/* Card Content */}
<div className={contentClasses[size]}>
<p className={textClasses[size]}>
{text}
</p>
<p className={textClasses[size]}>{text}</p>
</div>
</div>
);
@@ -103,7 +106,9 @@ const NumberCard = memo<NumberCardProps>(({ number, text, size: sizeProp }) => {
// Responsive breakpoints for backward compatibility (matches original behavior)
// Maps to: Small (mobile) -> Medium (sm) -> Large (lg) -> XLarge (xl)
return (
<div className={`${baseClasses} flex flex-col gap-4 p-5 sm:flex-row sm:gap-8 sm:p-8 sm:items-center lg:flex-col lg:gap-[22px] lg:items-start lg:justify-end lg:p-8 lg:relative lg:h-[238px]`}>
<div
className={`${baseClasses} flex flex-col gap-4 p-5 sm:flex-row sm:gap-8 sm:p-8 sm:items-center lg:flex-col lg:gap-[22px] lg:items-start lg:justify-end lg:p-8 lg:relative lg:h-[238px]`}
>
{/* Section Number - Responsive positioning */}
<div className="flex justify-end items-end sm:justify-start sm:flex-shrink-0 lg:absolute lg:top-8 lg:right-8">
<SectionNumber number={number} />
@@ -5,7 +5,11 @@ export interface Category {
chipOptions: ChipOption[];
onChipClick?: (categoryName: string, chipId: string) => void;
onAddClick?: (categoryName: string) => void;
onCustomChipConfirm?: (categoryName: string, chipId: string, value: string) => void;
onCustomChipConfirm?: (
categoryName: string,
chipId: string,
value: string,
) => void;
onCustomChipClose?: (categoryName: string, chipId: string) => void;
}
+82 -56
View File
@@ -28,34 +28,39 @@ export function RuleCardView({
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 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
? "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
? "" // 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
? "w-[398px]"
: "" // XS and S: no fixed width
: "";
// Logo/Icon dimensions - use CSS responsive classes
@@ -81,19 +86,21 @@ export function RuleCardView({
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
? "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 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}>
@@ -108,7 +115,7 @@ export function RuleCardView({
</div>
);
}
return (
<div className={containerClass}>
<Image
@@ -121,15 +128,17 @@ export function RuleCardView({
</div>
);
}
if (icon) {
return (
<div className={`${logoContainerClass} flex items-center justify-center max-[639px]:p-[16px] min-[640px]:max-[1023px]:p-[12px]`}>
<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]
@@ -138,26 +147,29 @@ export function RuleCardView({
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)]`}>
<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-")
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)]";
: isExtraSmall
? "rounded-[var(--measures-radius-200,8px)]"
: isSmall
? "rounded-[var(--measures-radius-300,12px)]"
: "rounded-[var(--radius-measures-radius-small)]";
return (
<div
@@ -170,48 +182,60 @@ export function RuleCardView({
onKeyDown={onKeyDown}
>
{/* Outermost container with bottom border - taller to match Figma */}
<div className={`
<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={`
<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>
)}
{/* Spacing between icon and title */}
<div className="
<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={`
<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={`
<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>
`}
>
<h3
className={`${titleClass} text-black overflow-hidden text-ellipsis w-full`}
>
{title}
</h3>
</div>
</div>
)}
@@ -237,7 +261,11 @@ export function RuleCardView({
category.onAddClick?.(category.name);
}}
onCustomChipConfirm={(chipId, value) => {
category.onCustomChipConfirm?.(category.name, chipId, value);
category.onCustomChipConfirm?.(
category.name,
chipId,
value,
);
}}
onCustomChipClose={(chipId) => {
category.onCustomChipClose?.(category.name, chipId);
@@ -250,11 +278,9 @@ export function RuleCardView({
</div>
)}
{/* Footer: Description */}
{description && (
{description && (
<div className="border-t border-black border-solid pt-[16px] relative shrink-0 w-full">
<p className={`${descriptionClass} text-black`}>
{description}
</p>
<p className={`${descriptionClass} text-black`}>{description}</p>
</div>
)}
</>
@@ -263,8 +289,8 @@ export function RuleCardView({
description && (
<div className="flex items-center justify-center relative shrink-0 w-full">
<p className={`${descriptionClass} text-black flex-1`}>
{description}
</p>
{description}
</p>
</div>
)
)}
@@ -1,6 +1,10 @@
import type { BlogPost } from "../../../../lib/content";
export type ContentContainerSizeValue = "xs" | "responsive" | "Xs" | "Responsive";
export type ContentContainerSizeValue =
| "xs"
| "responsive"
| "Xs"
| "Responsive";
export interface ContentContainerProps {
post: BlogPost;
@@ -1,6 +1,10 @@
import type { BlogPost } from "../../../../lib/content";
export type ContentThumbnailTemplateVariantValue = "vertical" | "horizontal" | "Vertical" | "Horizontal";
export type ContentThumbnailTemplateVariantValue =
| "vertical"
| "horizontal"
| "Vertical"
| "Horizontal";
export interface ContentThumbnailTemplateProps {
post: BlogPost;
@@ -4,7 +4,10 @@ import { memo } from "react";
import { useComponentId } from "../../../hooks";
import { CheckboxView } from "./Checkbox.view";
import type { CheckboxProps } from "./Checkbox.types";
import { normalizeMode, normalizeState } from "../../../../lib/propNormalization";
import {
normalizeMode,
normalizeState,
} from "../../../../lib/propNormalization";
const CheckboxContainer = memo<CheckboxProps>(
({
@@ -24,7 +27,7 @@ const CheckboxContainer = memo<CheckboxProps>(
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const mode = normalizeMode(modeProp);
const state = normalizeState(stateProp);
const isInverse = mode === "inverse";
const isStandard = mode === "standard";
@@ -43,7 +46,9 @@ const CheckboxContainer = memo<CheckboxProps>(
transition-all
duration-200
ease-in-out
`.trim().replace(/\s+/g, " ");
`
.trim()
.replace(/\s+/g, " ");
// Get box styles based on state and checked status per Figma designs
const getBoxStyles = (): string => {
@@ -22,8 +22,10 @@ const CheckboxGroupContainer = ({
const groupId = name || `checkbox-group-${generatedId}`;
// Internal state to track checked values (only used if value prop is not provided)
const [internalCheckedValues, setInternalCheckedValues] = useState<string[]>([]);
const [internalCheckedValues, setInternalCheckedValues] = useState<string[]>(
[],
);
// Use controlled value if provided, otherwise use internal state
const checkedValues = value !== undefined ? value : internalCheckedValues;
@@ -23,10 +23,7 @@ export function CheckboxGroupView({
// If there's subtext, render checkbox without label and handle layout separately
if (option.subtext) {
return (
<div
key={option.value}
className="flex gap-[8px] items-start"
>
<div key={option.value} className="flex gap-[8px] items-start">
<Checkbox
checked={isChecked}
mode={mode}
@@ -41,7 +41,10 @@ const ChipContainer = memo<ChipProps>(
}
}, [isCustom]);
const handleCheck = (value: string, event: React.MouseEvent<HTMLButtonElement>) => {
const handleCheck = (
value: string,
event: React.MouseEvent<HTMLButtonElement>,
) => {
if (onCheck && value.trim()) {
onCheck(value.trim(), event);
// Reset input after successful check
@@ -63,7 +66,10 @@ const ChipContainer = memo<ChipProps>(
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>);
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>);
@@ -95,4 +101,3 @@ const ChipContainer = memo<ChipProps>(
ChipContainer.displayName = "Chip";
export default ChipContainer;
@@ -68,4 +68,3 @@ export interface ChipViewProps {
inputRef?: React.RefObject<HTMLInputElement>;
ariaLabel?: string;
}
+24 -31
View File
@@ -42,32 +42,26 @@ function ChipView({
// 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 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
background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background for custom
border = "border-none";
textColor =
"text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
textColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
} else if (state === "disabled") {
background =
"bg-[var(--color-surface-default-secondary,#141414)]"; // dark background
background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background
border = "border-none";
textColor =
"text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
textColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
} else if (isSelected) {
background =
"bg-[var(--color-surface-inverse-brandaccent,#fdfaa8)]"; // yellow selected
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)]";
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
} else {
// Unselected default
background =
@@ -78,24 +72,20 @@ function ChipView({
}
} else if (isInverse) {
if (state === "disabled") {
background =
"bg-[var(--color-surface-inverse-tertiary,#d2d2d2)]";
background = "bg-[var(--color-surface-inverse-tertiary,#d2d2d2)]";
border = "border-none";
textColor =
"text-[color:var(--color-content-inverse-primary,black)]";
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)]";
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)]";
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
}
}
@@ -134,7 +124,9 @@ function ChipView({
.filter(Boolean)
.join(" ");
const handleClick = (event: React.MouseEvent<HTMLButtonElement | HTMLDivElement>) => {
const handleClick = (
event: React.MouseEvent<HTMLButtonElement | HTMLDivElement>,
) => {
if (isDisabled) {
event.preventDefault();
return;
@@ -162,7 +154,9 @@ function ChipView({
}}
{...sharedA11y}
>
<div className={`flex items-center ${isSmall ? "gap-[8px]" : "gap-[12px]"}`}>
<div
className={`flex items-center ${isSmall ? "gap-[8px]" : "gap-[12px]"}`}
>
{/* Check button */}
{onCheck && (
<button
@@ -208,7 +202,9 @@ function ChipView({
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)",
fontSize: isSmall
? "var(--sizing-300,12px)"
: "var(--sizing-400,16px)",
lineHeight: isSmall ? "16px" : "24px",
}}
onClick={(e) => e.stopPropagation()}
@@ -259,9 +255,7 @@ function ChipView({
onClick={handleClick}
{...sharedA11y}
>
<span className="flex items-center justify-center">
{label}
</span>
<span className="flex items-center justify-center">{label}</span>
{onRemove && !isDisabled && (
<button
type="button"
@@ -284,4 +278,3 @@ function ChipView({
ChipView.displayName = "ChipView";
export default memo(ChipView);
-1
View File
@@ -1,3 +1,2 @@
export { default } from "./Chip.container";
export type { ChipProps } from "./Chip.types";
@@ -3,7 +3,10 @@
import { memo } from "react";
import MultiSelectView from "./MultiSelect.view";
import type { MultiSelectProps } from "./MultiSelect.types";
import { normalizeMultiSelectSize, normalizeChipPalette } from "../../../../lib/propNormalization";
import {
normalizeMultiSelectSize,
normalizeChipPalette,
} from "../../../../lib/propNormalization";
const MultiSelectContainer = memo<MultiSelectProps>(
({
@@ -1,4 +1,7 @@
import type { ChipStateValue, ChipPaletteValue } from "../../../../lib/propNormalization";
import type {
ChipStateValue,
ChipPaletteValue,
} from "../../../../lib/propNormalization";
export interface ChipOption {
id: string;
@@ -31,7 +31,9 @@ function MultiSelectView({
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}`}>
<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 */}
{formHeader && label && (
<InputLabel
@@ -45,7 +47,9 @@ function MultiSelectView({
)}
{/* Chips container */}
<div className={`flex flex-wrap ${gapClass} items-center relative shrink-0 w-full`}>
<div
className={`flex flex-wrap ${gapClass} items-center relative shrink-0 w-full`}
>
{options.map((option) => (
<Chip
key={option.id}
@@ -74,7 +78,7 @@ function MultiSelectView({
/>
))}
{/* Add button - Circular button with border (not ghost) when no text, ghost style when text provided */}
{/* Add button — icon-only: bordered circle + brand icon (chips stay yellow). With label: Figma 19688:38288 — brand + icon, primary label text, no fill/border. */}
{addButton && (
<button
type="button"
@@ -87,18 +91,26 @@ function MultiSelectView({
!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`
: // Text add control (default palette: white label + brand “+”; inverse: inverse primary for both)
`flex items-center justify-center overflow-hidden rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity ${
isSmall
? "gap-[var(--measures-spacing-100,4px)] px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-200,8px)]"
: "gap-[var(--measures-spacing-150,6px)] px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)]"
}`
}
>
{/* Plus icon */}
{/* Plus icon — brand accent; selection chips keep full yellow fill separately */}
<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`}
className={`shrink-0 ${
!addButtonText && isInverse
? "text-[var(--color-content-inverse-primary,black)]"
: "text-[var(--color-content-default-brand-primary,#fefcc9)]"
}`}
>
<path
d="M7 3V11M3 7H11"
@@ -108,9 +120,14 @@ function MultiSelectView({
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)]"}`}>
<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-primary,white)]"
}`}
>
{addButtonText}
</span>
)}
@@ -3,7 +3,10 @@
import { memo, useCallback, useId } from "react";
import { RadioButtonView } from "./RadioButton.view";
import type { RadioButtonProps } from "./RadioButton.types";
import { normalizeMode, normalizeState } from "../../../../lib/propNormalization";
import {
normalizeMode,
normalizeState,
} from "../../../../lib/propNormalization";
const RadioButtonContainer = ({
checked = false,
@@ -22,10 +25,10 @@ const RadioButtonContainer = ({
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const mode = normalizeMode(modeProp);
const state = normalizeState(stateProp);
// If state is "selected", it means checked in Figma terms
const normalizedState = state === "selected" || checked ? "selected" : state;
const isInverse = mode === "inverse";
const isStandard = mode === "standard";
@@ -42,7 +45,9 @@ const RadioButtonContainer = ({
duration-200
ease-in-out
p-[4px]
`.trim().replace(/\s+/g, " ");
`
.trim()
.replace(/\s+/g, " ");
// Get box styles based on mode and checked status per Figma designs
const getBoxStyles = (): string => {
@@ -55,12 +60,12 @@ const RadioButtonContainer = ({
const defaultBorder = checked
? "border-[var(--color-border-default-brand-primary,#fdfaa8)]"
: "border-[var(--color-border-default-tertiary,#464646)]";
// When focused and checked, border should be invert tertiary (#2d2d2d) per Figma
const focusBorder = checked
? "focus:border-[var(--color-content-invert-tertiary,#2d2d2d)]"
: "focus:border-[var(--color-border-default-tertiary,#464646)]";
return `${baseBox} bg-[var(--color-surface-default-primary)] border border-solid ${defaultBorder} hover:border-[var(--color-border-default-brand-primary,#fdfaa8)] ${focusBorder} focus:shadow-[0px_0px_0px_2px_var(--color-border-invert-primary,white),0px_0px_0px_4px_var(--color-border-default-primary,#141414)] focus:outline-none`;
}
@@ -73,15 +78,16 @@ const RadioButtonContainer = ({
const defaultBorder = checked
? "border-[var(--color-border-default-brand-primary,#fdfaa8)]"
: "border-[var(--color-border-invert-primary,white)]";
// Hover border: inverse brand primary for both selected and unselected per Figma
const hoverBorder = "hover:border-[var(--color-border-invert-brand-primary,#6c6701)]";
const hoverBorder =
"hover:border-[var(--color-border-invert-brand-primary,#6c6701)]";
// Focus border: when focused and checked, border should be white per Figma
const focusBorder = checked
? "focus:border-[var(--color-border-invert-primary,white)]"
: "focus:border-[var(--color-border-invert-primary,white)]";
return `${baseBox} bg-transparent border border-solid ${defaultBorder} ${hoverBorder} ${focusBorder} focus:shadow-[0px_0px_0px_2px_var(--color-border-default-primary,#141414),0px_0px_0px_4px_var(--color-border-invert-primary,white)] focus:outline-none`;
}
@@ -41,8 +41,8 @@ export function RadioButtonView({
checked && mode === "standard"
? "bg-[var(--color-content-default-brand-primary,#fefcc9)] group-hover:!bg-[#333000]"
: checked && mode === "inverse"
? "bg-[var(--color-content-default-primary,#000000)]"
: "bg-transparent"
? "bg-[var(--color-content-default-primary,#000000)]"
: "bg-transparent"
}`}
/>
</span>
@@ -3,7 +3,10 @@
import { memo, useCallback, useId } from "react";
import { RadioGroupView } from "./RadioGroup.view";
import type { RadioGroupProps } from "./RadioGroup.types";
import { normalizeMode, normalizeState } from "../../../../lib/propNormalization";
import {
normalizeMode,
normalizeState,
} from "../../../../lib/propNormalization";
const RadioGroupContainer = ({
name,
@@ -19,10 +22,11 @@ const RadioGroupContainer = ({
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const mode = normalizeMode(modeProp);
// Normalize state, but handle "With Subtext" separately (it's represented by options with subtext)
const state = typeof stateProp === "string" &&
const state =
typeof stateProp === "string" &&
(stateProp.toLowerCase() === "with subtext" || stateProp === "With Subtext")
? "default" // "With Subtext" is handled via RadioOption.subtext, use default state
: normalizeState(stateProp);
? "default" // "With Subtext" is handled via RadioOption.subtext, use default state
: normalizeState(stateProp);
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const groupId = name || `radio-group-${generatedId}`;
@@ -24,10 +24,7 @@ export function RadioGroupView({
// If there's subtext, render radio button without label and handle layout separately
if (option.subtext) {
return (
<div
key={option.value}
className="flex gap-[8px] items-start"
>
<div key={option.value} className="flex gap-[8px] items-start">
<RadioButton
checked={isSelected}
mode={mode}
@@ -16,7 +16,11 @@ import React, {
import { useClickOutside } from "../../../hooks";
import { SelectInputView } from "./SelectInput.view";
import type { SelectInputProps } from "./SelectInput.types";
import { normalizeState, normalizeSmallMediumLargeSize, normalizeLabelVariant } from "../../../../lib/propNormalization";
import {
normalizeState,
normalizeSmallMediumLargeSize,
normalizeLabelVariant,
} from "../../../../lib/propNormalization";
const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
(
@@ -46,23 +50,28 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
ref,
) => {
// Determine if label should be shown
const shouldShowLabel = showLabel !== undefined ? showLabel : (labelText !== undefined);
const shouldShowLabel =
showLabel !== undefined ? showLabel : labelText !== undefined;
// Normalize state - handle "state5" as disabled
let normalizedState = externalStateProp;
if (normalizedState === "state5" || normalizedState === "State5") {
normalizedState = "default"; // Map to default, disabled prop handles the disabled state
}
const externalState = normalizeState(normalizedState);
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
// 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;
const _labelVariant = labelVariantProp
? normalizeLabelVariant(labelVariantProp)
: undefined;
const _size = sizeProp
? normalizeSmallMediumLargeSize(sizeProp)
: undefined;
// Mark as intentionally unused for future implementation
void _labelVariant;
void _size;
const generatedId = useId();
const selectId = id || `select-input-${generatedId}`;
const labelId = `${selectId}-label`;
@@ -73,11 +82,14 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
// Internal state management: track if focused and how (mouse vs keyboard)
const [isFocused, setIsFocused] = useState(false);
const [focusMethod, setFocusMethod] = useState<"mouse" | "keyboard" | null>(null);
const [focusMethod, setFocusMethod] = useState<"mouse" | "keyboard" | null>(
null,
);
const wasMouseDownRef = useRef(false);
// Determine if we should auto-manage focus (only when state is "default" or undefined)
const shouldAutoManageFocus = externalState === "default" || externalState === undefined;
const shouldAutoManageFocus =
externalState === "default" || externalState === undefined;
// Sync internal state with external value prop
useEffect(() => {
@@ -7,8 +7,18 @@ export interface SelectOptionData {
import type { StateValue } from "../../../../lib/propNormalization";
export type SelectInputLabelVariantValue = "default" | "horizontal" | "Default" | "Horizontal";
export type SelectInputSizeValue = "small" | "medium" | "large" | "Small" | "Medium" | "Large";
export type SelectInputLabelVariantValue =
| "default"
| "horizontal"
| "Default"
| "Horizontal";
export type SelectInputSizeValue =
| "small"
| "medium"
| "large"
| "Small"
| "Medium"
| "Large";
export interface SelectInputProps {
id?: string;
@@ -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">
@@ -1,4 +1,10 @@
export type SelectOptionSizeValue = "small" | "medium" | "large" | "Small" | "Medium" | "Large";
export type SelectOptionSizeValue =
| "small"
| "medium"
| "large"
| "Small"
| "Medium"
| "Large";
export interface SelectOptionProps {
children?: React.ReactNode;
@@ -17,7 +17,7 @@ const SwitchContainer = memo(
className = "",
...rest
} = props;
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const state = normalizeState(stateProp);
@@ -4,7 +4,12 @@ import { memo, forwardRef } from "react";
import { useComponentId, useFormField } from "../../../hooks";
import { TextAreaView } from "./TextArea.view";
import type { TextAreaProps } from "./TextArea.types";
import { normalizeInputState, normalizeSmallMediumLargeSize, normalizeLabelVariant } from "../../../../lib/propNormalization";
import {
normalizeInputState,
normalizeSmallMediumLargeSize,
normalizeLabelVariant,
normalizeTextAreaAppearance,
} from "../../../../lib/propNormalization";
const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
(
@@ -27,6 +32,7 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
textHint = false,
formHeader = true,
showHelpIcon = false,
appearance: appearanceProp = "default",
...props
},
ref,
@@ -35,6 +41,7 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
const size = normalizeSmallMediumLargeSize(sizeProp);
const labelVariant = normalizeLabelVariant(labelVariantProp);
const state = normalizeInputState(stateProp);
const appearance = normalizeTextAreaAppearance(appearanceProp);
// Generate unique ID for accessibility if not provided
const { id: textareaId, labelId } = useComponentId("textarea", id);
@@ -74,11 +81,26 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
},
};
// State styles
// State styles (embedded: Figma 20736-12668 borderless, darker grey block, white text)
const getStateStyles = (): {
textarea: string;
label: string;
} => {
if (appearance === "embedded") {
if (disabled) {
return {
textarea:
"border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] cursor-not-allowed opacity-60",
label: "text-[var(--color-content-default-secondary)]",
};
}
return {
textarea:
"border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-border-default-tertiary)] focus:ring-inset",
label: "text-[var(--color-content-default-secondary)]",
};
}
if (disabled) {
return {
textarea:
@@ -138,8 +160,8 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
: `${currentSize.label} font-inter`;
const textareaClasses = `
w-full border transition-all duration-200 ease-in-out
focus:outline-none focus:ring-0 resize-none
scrollbar-design w-full transition-all duration-200 ease-in-out resize-none
${appearance === "embedded" ? "rounded-[var(--radius-300,12px)]" : "border"}
${currentSize.textarea}
${stateStyles.textarea}
${className}
@@ -180,6 +202,7 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
textHint={textHint}
formHeader={formHeader}
showHelpIcon={showHelpIcon}
appearance={appearance}
{...props}
/>
);
@@ -1,7 +1,23 @@
import type { InputStateValue } from "../../../../lib/propNormalization";
export type TextAreaSizeValue = "small" | "medium" | "large" | "Small" | "Medium" | "Large";
export type TextAreaLabelVariantValue = "default" | "horizontal" | "Default" | "Horizontal";
export type TextAreaSizeValue =
| "small"
| "medium"
| "large"
| "Small"
| "Medium"
| "Large";
export type TextAreaLabelVariantValue =
| "default"
| "horizontal"
| "Default"
| "Horizontal";
export type TextAreaAppearanceValue =
| "default"
| "embedded"
| "Default"
| "Embedded";
export interface TextAreaProps extends Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
@@ -47,6 +63,12 @@ export interface TextAreaProps extends Omit<
* @default false
*/
showHelpIcon?: boolean;
/**
* Visual appearance. "embedded" matches Create modal sections (Figma 20736-12668):
* borderless, darker grey background, white text. "default" is standard bordered input.
* @default "default"
*/
appearance?: TextAreaAppearanceValue;
}
export interface TextAreaViewProps {
@@ -73,4 +95,5 @@ export interface TextAreaViewProps {
textHint?: boolean;
formHeader?: boolean;
showHelpIcon?: boolean;
appearance?: "default" | "embedded";
}
@@ -24,6 +24,12 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
textHint = false,
formHeader = true,
showHelpIcon = false,
appearance: _appearance,
// Component-only props: do not pass to DOM
size: _size,
labelVariant: _labelVariant,
state: _state,
error: _error,
...props
},
ref,
@@ -42,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"
@@ -4,12 +4,16 @@ import { memo, forwardRef, useState, useRef } from "react";
import { useComponentId, useFormField } from "../../../hooks";
import { TextInputView } from "./TextInput.view";
import type { TextInputProps } from "./TextInput.types";
import { normalizeInputState } from "../../../../lib/propNormalization";
import {
normalizeInputState,
normalizeTextInputSize,
} from "../../../../lib/propNormalization";
const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
(
{
state: externalStateProp = "default",
inputSize: inputSizeProp = "medium",
disabled = false,
error = false,
label,
@@ -31,18 +35,22 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const externalState = normalizeInputState(externalStateProp);
const inputSize = normalizeTextInputSize(inputSizeProp);
// Generate unique ID for accessibility if not provided
const { id: inputId, labelId } = useComponentId("text-input", id);
// Internal state management: track if focused and how (mouse vs keyboard)
const [isFocused, setIsFocused] = useState(false);
const [focusMethod, setFocusMethod] = useState<"mouse" | "keyboard" | null>(null);
const [focusMethod, setFocusMethod] = useState<"mouse" | "keyboard" | null>(
null,
);
const wasMouseDownRef = useRef(false);
// Determine if we should auto-manage focus (only when state is "default" or undefined)
// If state is "active", "hover", or "focus", respect it and don't override
const shouldAutoManageFocus = externalState === "default" || externalState === undefined;
const shouldAutoManageFocus =
externalState === "default" || externalState === undefined;
// Determine actual state:
// - Active: when clicked (mouse focus)
@@ -59,13 +67,21 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
// Determine if input is filled (has value)
const isFilled = Boolean(value && value.trim().length > 0);
// Fixed size styles (medium only per Figma designs)
const sizeStyles = {
input: "h-[40px] px-[12px] py-[8px] text-[16px]",
label: "text-[14px] leading-[20px] font-medium",
container: "gap-[8px]",
radius: "var(--measures-radius-200,8px)",
};
// Size styles based on inputSize prop
const sizeStyles =
inputSize === "small"
? {
input: "h-[32px] px-[10px] py-[6px] text-[14px]",
label: "text-[12px] leading-[16px] font-medium",
container: "gap-[6px]",
radius: "var(--measures-radius-200,8px)",
}
: {
input: "h-[40px] px-[12px] py-[8px] text-[16px]",
label: "text-[14px] leading-[20px] font-medium",
container: "gap-[8px]",
radius: "var(--measures-radius-200,8px)",
};
// State styles based on Figma designs
const getStateStyles = (): {
@@ -158,17 +174,20 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
: "text-[var(--color-content-default-tertiary,#b4b4b4)]";
// Form field handlers with disabled state handling
const { handleChange, handleBlur } = useFormField<HTMLInputElement>(disabled, {
onChange,
onBlur: (e) => {
if (shouldAutoManageFocus) {
setIsFocused(false);
setFocusMethod(null);
wasMouseDownRef.current = false;
}
onBlur?.(e);
const { handleChange, handleBlur } = useFormField<HTMLInputElement>(
disabled,
{
onChange,
onBlur: (e) => {
if (shouldAutoManageFocus) {
setIsFocused(false);
setFocusMethod(null);
wasMouseDownRef.current = false;
}
onBlur?.(e);
},
},
});
);
// Handle mouse down to detect mouse clicks
const handleMouseDown = () => {
@@ -180,19 +199,19 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
// Custom focus handler to detect mouse vs keyboard
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
if (disabled) return;
// Detect if focus came from keyboard (Tab) or mouse (click)
// If mouseDown was detected before focus, it's a mouse click (active)
// Otherwise, it's keyboard navigation (focus)
const method = wasMouseDownRef.current ? "mouse" : "keyboard";
if (shouldAutoManageFocus) {
setIsFocused(true);
setFocusMethod(method);
// Reset mouse down flag after focus is processed
wasMouseDownRef.current = false;
}
onFocus?.(e);
};
@@ -1,5 +1,7 @@
import type { InputStateValue } from "../../../../lib/propNormalization";
export type TextInputSizeValue = "small" | "medium" | "Small" | "Medium";
export interface TextInputProps extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"size" | "onChange" | "onFocus" | "onBlur"
@@ -9,6 +11,12 @@ export interface TextInputProps extends Omit<
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
state?: InputStateValue;
/**
* Size variant. Accepts both PascalCase (Figma) and lowercase (codebase).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
* @default "medium"
*/
inputSize?: TextInputSizeValue;
disabled?: boolean;
error?: boolean;
label?: string;
@@ -21,9 +29,10 @@ export interface TextInputProps extends Omit<
showHelpIcon?: boolean;
/**
* Whether to show hint text below input (Figma prop).
* Can be a boolean or a string to display custom text (e.g., character count).
* @default false
*/
textHint?: boolean;
textHint?: boolean | string;
/**
* Whether to show form header (label and help icon) above input (Figma prop).
* @default true
@@ -55,6 +64,6 @@ export interface TextInputViewProps {
isFilled?: boolean;
inputWrapperClasses?: string;
focusRingClasses?: string;
textHint?: boolean;
textHint?: boolean | string;
formHeader?: boolean;
}
@@ -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"
@@ -80,7 +81,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
{textHint && (
<div className="flex items-start relative shrink-0 w-full">
<p className="flex-[1_0_0] font-inter font-normal leading-[16px] min-h-px min-w-px relative text-[color:var(--color-content-default-tertiary,#b4b4b4)] text-[length:var(--sizing-300,12px)]">
Hint text here
{typeof textHint === "string" ? textHint : "Hint text here"}
</p>
</div>
)}
@@ -3,7 +3,10 @@
import { memo, useCallback, useId, forwardRef } from "react";
import { ToggleGroupView } from "./ToggleGroup.view";
import type { ToggleGroupProps } from "./ToggleGroup.types";
import { normalizeToggleState, normalizeToggleGroupPosition } from "../../../../lib/propNormalization";
import {
normalizeToggleState,
normalizeToggleGroupPosition,
} from "../../../../lib/propNormalization";
const ToggleGroupContainer = memo(
forwardRef<HTMLButtonElement, ToggleGroupProps>((props, _ref) => {
@@ -19,7 +22,7 @@ const ToggleGroupContainer = memo(
onBlur,
...rest
} = props;
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const position = normalizeToggleGroupPosition(positionProp);
const state = normalizeToggleState(stateProp);
@@ -1,6 +1,12 @@
import type { StateValue } from "../../../../lib/propNormalization";
export type ToggleGroupPositionValue = "left" | "middle" | "right" | "Left" | "Middle" | "Right";
export type ToggleGroupPositionValue =
| "left"
| "middle"
| "right"
| "Left"
| "Middle"
| "Right";
export interface ToggleGroupProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -0,0 +1,23 @@
"use client";
import { memo } from "react";
import UploadView from "./Upload.view";
import type { UploadProps } from "./Upload.types";
const UploadContainer = memo<UploadProps>(
({ active = true, label, showHelpIcon = true, onClick, className = "" }) => {
return (
<UploadView
active={active}
label={label}
showHelpIcon={showHelpIcon}
onClick={onClick}
className={className}
/>
);
},
);
UploadContainer.displayName = "Upload";
export default UploadContainer;
@@ -0,0 +1,34 @@
export interface UploadProps {
/**
* Whether the upload component is in active state.
* When active, button has white background with black text.
* When inactive, button has dark background with gray text.
* @default true
*/
active?: boolean;
/**
* Label text displayed above the upload component
*/
label?: string;
/**
* Whether to show help icon next to label
* @default true
*/
showHelpIcon?: boolean;
/**
* Callback when upload button is clicked
*/
onClick?: () => void;
/**
* Additional CSS classes
*/
className?: string;
}
export interface UploadViewProps {
active: boolean;
label?: string;
showHelpIcon: boolean;
onClick?: () => void;
className: string;
}
@@ -0,0 +1,119 @@
"use client";
import { memo } from "react";
import InputLabel from "../../utility/InputLabel";
import type { UploadViewProps } from "./Upload.types";
function UploadView({
active = true,
label,
showHelpIcon = true,
onClick,
className = "",
}: UploadViewProps) {
const isActive = active;
// Button styles based on active state
const buttonBgClass = isActive
? "bg-[var(--color-surface-invert-primary,white)]"
: "bg-[var(--color-surface-default-secondary,#141414)]";
const buttonTextColor = isActive
? "text-[color:var(--color-content-invert-primary,black)]"
: "text-[color:var(--color-content-invert-tertiary,#2d2d2d)]";
// Description text color based on active state
const descriptionTextColor = isActive
? "text-[color:var(--color-content-default-primary,white)]"
: "text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
// Icon color based on active state
const iconColor = isActive
? "text-[color:var(--color-content-invert-primary,black)]"
: "text-[color:var(--color-content-invert-tertiary,#2d2d2d)]";
return (
<div
className={`flex flex-col 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="S"
palette="Default"
/>
)}
{/* Upload container */}
<div className="bg-[var(--color-surface-default-secondary,#141414)] flex gap-[24px] items-center justify-center px-[var(--measures-spacing-600,24px)] py-[var(--measures-spacing-1200,48px)] rounded-[var(--measures-radius-200,8px)] shrink-0 w-full">
{/* Upload button */}
<button
type="button"
onClick={onClick}
className={`${buttonBgClass} flex gap-[var(--measures-spacing-150,6px)] items-center justify-center overflow-clip p-[var(--measures-spacing-300,12px)] rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`}
aria-label="Upload"
>
{/* Upload icon */}
<div className={`relative shrink-0 size-[20px] ${iconColor}`}>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-full"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="17 8 12 3 7 8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<line
x1="12"
y1="3"
x2="12"
y2="15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
{/* Button text */}
<div
className={`flex flex-col font-inter font-medium justify-center leading-[0] relative shrink-0 text-[length:var(--sizing-400,16px)] whitespace-nowrap ${buttonTextColor}`}
>
<p className="leading-[20px]">Upload</p>
</div>
</button>
{/* Description text */}
<div
className={`flex flex-[1_0_0] flex-col font-inter font-normal h-[32px] justify-center leading-[0] min-h-px min-w-px relative text-[length:var(--sizing-350,14px)] ${descriptionTextColor}`}
>
<p className="leading-[20px] whitespace-pre-wrap">
Add images, PDFs, and other files to the policy
</p>
</div>
</div>
</div>
);
}
UploadView.displayName = "UploadView";
export default memo(UploadView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Upload.container";
export type { UploadProps } from "./Upload.types";
+15 -3
View File
@@ -1,7 +1,15 @@
import { memo } from "react";
import { normalizeSize } from "../../../lib/propNormalization";
export type AvatarSizeValue = "small" | "medium" | "large" | "xlarge" | "Small" | "Medium" | "Large" | "XLarge";
export type AvatarSizeValue =
| "small"
| "medium"
| "large"
| "xlarge"
| "Small"
| "Medium"
| "Large"
| "XLarge";
interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
@@ -19,7 +27,8 @@ const Avatar = memo<AvatarProps>(
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const size = normalizeSize(sizeProp, "small");
const sizeStyles: Record<string, string> = {
small: "w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)] border-[1.5px] border-[#FFFFFF4D] border-solid",
small:
"w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)] border-[1.5px] border-[#FFFFFF4D] border-solid",
medium: "w-[var(--spacing-scale-018)] h-[var(--spacing-scale-018)]",
large: "w-[var(--spacing-scale-024)] h-[var(--spacing-scale-024)]",
xlarge: "w-[var(--spacing-scale-032)] h-[var(--spacing-scale-032)]",
@@ -27,7 +36,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} />
);
},
);
-115
View File
@@ -1,115 +0,0 @@
import { memo } from "react";
import Link from "next/link";
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
interface LogoProps {
size?:
| "default"
| "footer"
| "createFlow"
| "topNavFolderTop"
| "topNavHeader";
showText?: boolean;
}
interface SizeConfig {
containerHeight: string;
gap: string;
textSize: string;
lineHeight: string;
iconSize: string;
}
const Logo = memo<LogoProps>(({ size = "default", showText = true }) => {
// Size configurations
const sizes: Record<string, SizeConfig> = {
default: {
containerHeight: "h-[41px]",
gap: "gap-[8.28px]",
textSize: "text-[21.97px]",
lineHeight: "leading-[27.05px]",
iconSize: "w-[27.05px] h-[27.05px]",
},
footer: {
containerHeight: "h-[41px] sm:h-[calc(40px*1.37)] lg:h-[calc(40px*2.05)]",
gap: "gap-[8.28px] sm:gap-[calc(8px*1.37)] lg:gap-[calc(8px*2.05)]",
textSize: "text-[21.97px] sm:text-[calc(21.97px*1.37)] lg:text-[calc(21.97px*2.05)]",
lineHeight: "leading-[27.05px] sm:leading-[calc(27.05px*1.37)] lg:leading-[calc(27.05px*2.05)]",
iconSize: "w-[27.05px] h-[27.05px] sm:w-[calc(27.05px*1.37)] sm:h-[calc(27.05px*1.37)] lg:w-[calc(27.05px*2.05)] lg:h-[calc(27.05px*2.05)]",
},
createFlow: {
containerHeight: "h-[30px] md:h-[41px]",
gap: "gap-[6px] md:gap-[8.28px]",
textSize: "text-[16.48px] md:text-[21.97px]",
lineHeight: "leading-[20.28px] md:leading-[27.05px]",
iconSize: "w-[20.28px] h-[20.28px] md:w-[27.05px] md:h-[27.05px]",
},
topNavFolderTop: {
containerHeight: "h-[14.11px] sm:h-[21.06px] md:h-[32.24px] lg:h-[28px] xl:h-[36px]",
gap: "gap-0 sm:gap-[3.19px] md:gap-[4.89px] lg:gap-[6.55px] xl:gap-[8.64px]",
textSize: "text-[11.57px] sm:text-[11.69px] md:text-[17.89px] lg:text-[21.97px] xl:text-[29.01px]",
lineHeight: "leading-[14.24px] sm:leading-[14.39px] md:leading-[22.02px] lg:leading-[27.05px] xl:leading-[35.7px]",
iconSize: "w-[14.11px] h-[14.11px] sm:w-[14.39px] sm:h-[14.39px] md:w-[22.02px] md:h-[22.02px] lg:w-[27.05px] lg:h-[27.05px] xl:w-[35.7px] xl:h-[35.7px]",
},
topNavHeader: {
containerHeight: "h-[20.85px] sm:h-[20.85px] md:h-[17.91px] lg:h-[28px] xl:h-[34px]",
gap: "gap-0 sm:gap-[4.21px] md:gap-[6.51px] lg:gap-[6.55px] xl:gap-[8.19px]",
textSize: "text-[11.57px] sm:text-[11.57px] md:text-[17.89px] lg:text-[21.97px] xl:text-[27.47px]",
lineHeight: "leading-[14.24px] sm:leading-[14.24px] md:leading-[22.02px] lg:leading-[27.05px] xl:leading-[33.81px]",
iconSize: "w-[14.24px] h-[14.24px] sm:w-[14.24px] sm:h-[14.24px] md:w-[22.02px] md:h-[22.02px] lg:w-[27.05px] lg:h-[27.05px] xl:w-[33.81px] xl:h-[33.81px]",
},
};
const config = sizes[size || "default"] || sizes.default;
return (
<Link href="/" className="block" aria-label="CommunityRule Logo">
<div
className={`flex items-center ${config.containerHeight} ${
showText ? config.gap : ""
} transition-all duration-200 ease-in-out hover:scale-[1.02] cursor-pointer`}
>
{/* Logo Text - responsive visibility for topNav sizes */}
<div
className={`font-bricolage-grotesque ${
size === "topNavFolderTop"
? "text-[var(--color-content-inverse-primary)]"
: "text-[var(--color-content-default-primary)]"
} ${config.textSize} ${
config.lineHeight
} font-normal tracking-[0px] transition-colors duration-200 ${
size === "topNavFolderTop" || size === "topNavHeader"
? showText
? "hidden sm:block"
: "hidden"
: showText
? ""
: "hidden"
}`}
aria-label="CommunityRule"
>
CommunityRule
</div>
{/* Vector Icon */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getAssetPath(ASSETS.LOGO)}
alt="CommunityRule Logo Icon"
width={27.05}
height={27.05}
className={`flex-shrink-0 ${
config.iconSize
} transition-all duration-200 ${
size === "topNavFolderTop" ? "filter brightness-0" : ""
}`}
aria-hidden="true"
/>
</div>
</Link>
);
});
Logo.displayName = "Logo";
export default Logo;
@@ -3,7 +3,10 @@
import { memo } from "react";
import { AlertView } from "./Alert.view";
import type { AlertProps } from "./Alert.types";
import { normalizeAlertStatus, normalizeAlertType } from "../../../../lib/propNormalization";
import {
normalizeAlertStatus,
normalizeAlertType,
} from "../../../../lib/propNormalization";
const AlertContainer = memo<AlertProps>(
({
@@ -10,6 +10,7 @@ const CreateContainer = memo<CreateProps>(
onClose,
title,
description,
headerContent,
children,
footerContent,
showBackButton = true,
@@ -113,6 +114,7 @@ const CreateContainer = memo<CreateProps>(
onClose={onClose}
title={title}
description={description}
headerContent={headerContent}
// eslint-disable-next-line react/no-children-prop
children={children}
footerContent={footerContent}
+5 -24
View File
@@ -1,8 +1,11 @@
export interface CreateProps {
isOpen: boolean;
onClose: () => void;
/** Default header: title + description. Omit to use title/description. */
title?: string;
description?: string;
/** Custom header slot. When set, replaces title/description for full control. */
headerContent?: React.ReactNode;
children?: React.ReactNode;
footerContent?: React.ReactNode;
showBackButton?: boolean;
@@ -17,35 +20,12 @@ export interface CreateProps {
className?: string;
ariaLabel?: string;
ariaLabelledBy?: string;
/**
* Whether to enable Create block array content type (Figma prop).
* @default false
*/
/** Figma / design alignment (unused in implementation). */
createBlockArray?: boolean;
/**
* Whether to enable Text input content type (Figma prop).
* @default false
*/
textInput?: boolean;
/**
* Whether to enable Text area content type (Figma prop).
* @default false
*/
textArea?: boolean;
/**
* Whether to enable Multi-select content type (Figma prop).
* @default false
*/
multiSelect?: boolean;
/**
* Whether to enable Upload content type (Figma prop).
* @default false
*/
upload?: boolean;
/**
* Whether to enable Proportion content type (Figma prop).
* @default false
*/
proportion?: boolean;
}
@@ -54,6 +34,7 @@ export interface CreateViewProps {
onClose: () => void;
title?: string;
description?: string;
headerContent?: React.ReactNode;
children?: React.ReactNode;
footerContent?: React.ReactNode;
showBackButton: boolean;
+12 -9
View File
@@ -11,6 +11,7 @@ export function CreateView({
onClose,
title,
description,
headerContent,
children,
footerContent,
showBackButton,
@@ -40,21 +41,23 @@ export function CreateView({
aria-hidden="true"
/>
{/* Create Dialog */}
{/* Create Dialog: max-h ensures modal fits viewport; content scrolls inside */}
<div
ref={createRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={`fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-[var(--color-surface-default-primary)] rounded-[var(--radius-500,20px)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] w-[560px] max-h-[728px] flex flex-col overflow-hidden z-[9999] ${className}`}
className={`fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-[var(--color-surface-default-primary)] rounded-[var(--radius-500,20px)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] w-[560px] max-h-[90vh] flex min-h-0 flex-col overflow-hidden z-[9999] ${className}`}
>
{/* Header with close buttons */}
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
{/* Header Lockup Section (Sticky) */}
{(title || description) && (
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0 sticky top-[48px] z-[2]">
{/* Header: custom headerContent (when provided) or default title/description */}
{headerContent !== undefined ? (
<div className="shrink-0">{headerContent}</div>
) : title || description ? (
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={title}
description={description}
@@ -62,14 +65,14 @@ export function CreateView({
alignment="left"
/>
</div>
)}
) : null}
{/* Content Area (Scrollable) */}
<div className="flex flex-col gap-[var(--spacing-scale-024)] px-[24px] pb-[96px] overflow-x-clip overflow-y-auto relative shrink-0 flex-1">
{/* Content Area (scrollable when content overflows) */}
<div className="scrollbar-design flex min-h-0 flex-1 flex-col gap-[var(--spacing-scale-024)] overflow-x-clip overflow-y-auto px-[24px] pb-6 pt-0">
{children}
</div>
{/* Footer */}
{/* Footer (always visible at bottom of modal) */}
<ModalFooter
showBackButton={showBackButton}
showNextButton={showNextButton}
@@ -6,7 +6,13 @@ import type { TooltipProps } from "./Tooltip.types";
import { normalizeTooltipPosition } from "../../../../lib/propNormalization";
const TooltipContainer = memo<TooltipProps>(
({ children, text, position: positionProp = "top", className = "", disabled = false }) => {
({
children,
text,
position: positionProp = "top",
className = "",
disabled = false,
}) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const position = normalizeTooltipPosition(positionProp);
const [isVisible, setIsVisible] = useState(false);
+4 -2
View File
@@ -3,7 +3,7 @@
import { memo } from "react";
import { useTranslation } from "../../contexts/MessagesContext";
import Link from "next/link";
import Logo from "../icons/Logo";
import Logo from "../asset/logo";
import Separator from "../utility/Separator";
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
@@ -40,7 +40,7 @@ const Footer = memo(() => {
lg:gap-[var(--spacing-measures-spacing-060,60px)]"
>
{/* Logo */}
<Logo size="footer" />
<Logo size="footer" wordmark />
{/* Content section */}
<div className="flex flex-col items-start w-full gap-[var(--spacing-measures-spacing-048,48px)] sm:flex-row sm:justify-between sm:gap-0">
@@ -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"
+1 -1
View File
@@ -25,7 +25,7 @@ const MenuBar = memo<MenuBarProps>(
({ children, className = "", size: sizeProp = "X Small", ...props }) => {
const size = normalizeMenuBarSize(sizeProp);
const t = useTranslation("menuBar");
// Size styles based on Figma specifications
const sizeStyles: Record<
"X Small" | "Small" | "Medium" | "Large" | "X Large",
@@ -32,11 +32,17 @@ const MenuBarItemContainer = memo<MenuBarItemProps>(
"X Small" | "Small" | "Medium" | "Large" | "X Large",
string
> = {
"X Small": reducedPadding ? "px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)]" : "px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)]",
"X Small": reducedPadding
? "px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)]"
: "px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)]",
Small: "px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)]",
Medium: reducedPadding ? "px-[var(--spacing-scale-002)] py-[var(--spacing-scale-008)] h-[32px]" : "px-[var(--spacing-scale-008)] py-[var(--spacing-scale-008)] h-[32px]",
Large: "px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] h-[44px]",
"X Large": "px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] h-[44px]",
Medium: reducedPadding
? "px-[var(--spacing-scale-002)] py-[var(--spacing-scale-008)] h-[32px]"
: "px-[var(--spacing-scale-008)] py-[var(--spacing-scale-008)] h-[32px]",
Large:
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] h-[44px]",
"X Large":
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] h-[44px]",
};
// Text styles based on Figma specifications
@@ -46,41 +52,34 @@ const MenuBarItemContainer = memo<MenuBarItemProps>(
> = {
"X Small":
"font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
Small:
"font-inter text-[12px] leading-[14px] font-medium tracking-[0%]",
Medium:
"font-inter text-[12px] leading-[14px] font-medium tracking-[0%]",
Large:
"font-inter text-[16px] leading-[20px] font-medium tracking-[0%]",
Small: "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]",
Medium: "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]",
Large: "font-inter text-[16px] leading-[20px] font-medium tracking-[0%]",
"X Large":
"font-inter text-[24px] leading-[28px] font-normal tracking-[0%]",
};
// State styles for Default mode (yellow text on dark background)
const defaultModeStyles: Record<
"default" | "hover" | "selected",
string
> = {
default:
"bg-transparent text-[var(--color-content-default-brand-primary,#fefcc9)] hover:bg-[var(--color-gray-800)] hover:text-[var(--color-content-default-brand-primary,#fefcc9)]",
hover:
"bg-[var(--color-gray-800)] text-[var(--color-content-default-brand-primary,#fefcc9)]",
selected:
"border border-[var(--color-border-default-brand-primary,#fdfaa8)] text-[var(--color-content-default-brand-primary,#fefcc9)] bg-transparent hover:bg-[var(--color-gray-800)]",
};
const defaultModeStyles: Record<"default" | "hover" | "selected", string> =
{
default:
"bg-transparent text-[var(--color-content-default-brand-primary,#fefcc9)] hover:bg-[var(--color-gray-800)] hover:text-[var(--color-content-default-brand-primary,#fefcc9)]",
hover:
"bg-[var(--color-gray-800)] text-[var(--color-content-default-brand-primary,#fefcc9)]",
selected:
"border border-[var(--color-border-default-brand-primary,#fdfaa8)] text-[var(--color-content-default-brand-primary,#fefcc9)] bg-transparent hover:bg-[var(--color-gray-800)]",
};
// State styles for Inverse mode (black text on yellow background)
const inverseModeStyles: Record<
"default" | "hover" | "selected",
string
> = {
default:
"bg-transparent text-[var(--color-content-inverse-primary,black)] hover:bg-[var(--color-surface-brand-accent,#4d4a00)] hover:text-[var(--color-content-inverse-primary,black)]",
hover:
"bg-[var(--color-surface-brand-accent,#4d4a00)] text-[var(--color-content-inverse-primary,black)]",
selected:
"border border-[var(--color-border-default-primary,#141414)] text-[var(--color-content-inverse-primary,black)] bg-transparent hover:bg-[var(--color-surface-brand-accent,#4d4a00)]",
};
const inverseModeStyles: Record<"default" | "hover" | "selected", string> =
{
default:
"bg-transparent text-[var(--color-content-inverse-primary,black)] hover:bg-[var(--color-surface-brand-accent,#4d4a00)] hover:text-[var(--color-content-inverse-primary,black)]",
hover:
"bg-[var(--color-surface-brand-accent,#4d4a00)] text-[var(--color-content-inverse-primary,black)]",
selected:
"border border-[var(--color-border-default-primary,#141414)] text-[var(--color-content-inverse-primary,black)] bg-transparent hover:bg-[var(--color-surface-brand-accent,#4d4a00)]",
};
// Get state styles based on mode
const stateStyles =
@@ -5,14 +5,9 @@ export type MenuBarItemSizeValue =
| "Large"
| "X Large";
export type MenuBarItemStateValue =
| "default"
| "hover"
| "selected";
export type MenuBarItemStateValue = "default" | "hover" | "selected";
export type MenuBarItemModeValue =
| "default"
| "inverse";
export type MenuBarItemModeValue = "default" | "inverse";
export interface MenuBarItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href?: string;
@@ -3,7 +3,10 @@
import { memo } from "react";
import NavigationItemView from "./NavigationItem.view";
import type { NavigationItemProps } from "./NavigationItem.types";
import { normalizeNavigationItemVariant, normalizeNavigationItemSize } from "../../../../lib/propNormalization";
import {
normalizeNavigationItemVariant,
normalizeNavigationItemSize,
} from "../../../../lib/propNormalization";
const NavigationItemContainer = memo<NavigationItemProps>(
({
@@ -1,5 +1,9 @@
export type NavigationItemVariantValue = "default" | "Default";
export type NavigationItemSizeValue = "default" | "xsmall" | "Default" | "XSmall";
export type NavigationItemSizeValue =
| "default"
| "xsmall"
| "Default"
| "XSmall";
export interface NavigationItemProps extends Omit<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
@@ -1,13 +1,12 @@
"use client";
import { memo } from "react";
import { usePathname } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useTranslation } from "../../../contexts/MessagesContext";
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,13 +18,9 @@ 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 router = useRouter();
const t = useTranslation("header");
// Schema markup for site navigation
@@ -34,7 +29,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 +51,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 +85,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 +116,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",
@@ -159,7 +165,7 @@ const TopNavContainer = memo<TopNavProps>(
size={buttonSize}
buttonType={buttonType}
palette={palette}
href="/create/informational"
onClick={() => router.push("/create/informational")}
ariaLabel={t("ariaLabels.createNewRule")}
>
{renderAvatarGroup(containerSize, avatarSize)}
@@ -7,7 +7,7 @@ import { getAssetPath } from "../../../../lib/assetUtils";
import MenuBar from "../MenuBar";
import type { TopNavViewProps } from "./TopNav.types";
import Logo from "../../icons/Logo";
import Logo from "../../asset/logo";
function TopNavView({
folderTop,
@@ -44,7 +44,11 @@ function TopNavView({
{/* Header Tab - Yellow tab container with decorative Union images */}
<div className="HeaderTab header-breakpoint-transition relative bg-[var(--color-surface-inverse-brand-primary)] rounded-tl-[var(--radius-measures-radius-medium)] rounded-tr-[var(--radius-measures-radius-medium)] sm:rounded-t-[var(--radius-measures-radius-xlarge)] md:rounded-t-[var(--radius-measures-radius-xlarge)] lg:rounded-t-[var(--radius-measures-radius-xlarge)] xl:rounded-t-[var(--radius-measures-radius-xlarge)] pl-[var(--spacing-scale-012)] pr-[var(--spacing-scale-048)] h-[var(--spacing-scale-040)] sm:pl-[var(--spacing-scale-012)] sm:h-[52px] sm:pr-[var(--spacing-scale-006)] md:h-[52px] md:pl-[var(--spacing-scale-024)] md:pr-[var(--spacing-scale-012)] lg:h-[52px] lg:pl-[var(--spacing-scale-024)] lg:pr-[var(--spacing-scale-048)] xl:h-[64px] xl:pl-[var(--spacing-scale-032)] xl:pr-[var(--spacing-scale-120)] md:gap-[var(--spacing-scale-032)] flex-1 min-w-0 min-w-[197px] sm:min-w-0 sm:mr-[var(--spacing-scale-008)] md:mr-[185px] lg:mr-[var(--spacing-scale-024)] xl:mr-[var(--spacing-scale-032)] flex items-center self-end">
{/* Logo - Consistent left positioning within HeaderTab */}
<Logo size={logoSize} showText={true} />
<Logo
size={logoSize}
wordmark
palette={folderTop ? "inverse" : "default"}
/>
{/* XSmall menu bar - positioned next to logo */}
<div className="block sm:hidden -me-[2px]">
@@ -55,18 +59,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=""
@@ -87,7 +94,9 @@ function TopNavView({
{/* 640-1023px (md: breakpoint): MenuBar Small */}
<div className="hidden md:block lg:hidden">
<MenuBar size="Small">{renderNavigationItems("homeMd")}</MenuBar>
<MenuBar size="Small">
{renderNavigationItems("homeMd")}
</MenuBar>
</div>
{/* 1024-1440px (lg: breakpoint): MenuBar Large */}
@@ -158,7 +167,11 @@ function TopNavView({
aria-label={t("ariaLabels.mainNavigation")}
>
{/* Logo - Consistent left positioning across all breakpoints */}
<Logo size={logoSize} showText={true} />
<Logo
size={logoSize}
wordmark
palette={folderTop ? "inverse" : "default"}
/>
{/* Navigation Links - Consistent center positioning */}
<div className="flex items-center flex-1 justify-end sm:flex-none sm:justify-center">
@@ -190,7 +203,9 @@ function TopNavView({
</div>
<div className="hidden xl:block" data-testid="nav-xl">
<MenuBar size="X Large">{renderNavigationItems("xlarge")}</MenuBar>
<MenuBar size="X Large">
{renderNavigationItems("xlarge")}
</MenuBar>
</div>
</div>
@@ -44,7 +44,9 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
onContactClick,
}) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const variant = normalizeAskOrganizerVariant(variantProp) as AskOrganizerVariant;
const variant = normalizeAskOrganizerVariant(
variantProp,
) as AskOrganizerVariant;
const t = useTranslation();
const defaultButtonText = buttonText ?? t("askOrganizer.buttonText");
const defaultButtonHref = buttonHref ?? t("askOrganizer.buttonHref");
@@ -0,0 +1,16 @@
export interface CommunityRuleDocumentEntry {
title: string;
body: string;
}
export interface CommunityRuleDocumentSection {
categoryName: string;
entries: CommunityRuleDocumentEntry[];
}
export interface CommunityRuleDocumentProps {
sections: CommunityRuleDocumentSection[];
className?: string;
/** When true, wrap in white background with left teal bar (small breakpoint). */
useCardStyle?: boolean;
}
@@ -0,0 +1,65 @@
"use client";
import { memo } from "react";
import type { CommunityRuleDocumentProps } from "./CommunityRuleDocument.types";
const SECTION_GAP = "var(--measures-spacing-1200, 64px)";
const TEAL_BG = "var(--color-teal-teal50, #c9fef9)";
const SECTION_LINE_COLOR = "var(--color-border-default-tertiary, #464646)";
function CommunityRuleDocumentView({
sections,
className = "",
useCardStyle = false,
}: CommunityRuleDocumentProps) {
const rootClass = useCardStyle
? `rounded-[12px] bg-white pl-3 border-l-4 ${className}`
: className;
const rootStyle = useCardStyle ? { borderLeftColor: TEAL_BG } : undefined;
const sectionLineStyle = useCardStyle
? undefined
: { borderLeftColor: SECTION_LINE_COLOR };
return (
<div
className={`flex flex-col min-w-0 ${rootClass}`}
style={{ gap: SECTION_GAP, ...rootStyle }}
>
{sections.map((section, sectionIndex) => (
<div
key={sectionIndex}
className={`flex flex-col min-w-0 ${!useCardStyle ? "border-l pl-3" : ""}`}
style={sectionLineStyle}
>
{/* Section content: line runs full height of this block via border-left */}
<div className="flex flex-1 flex-col gap-4 min-w-0">
<p className="font-inter font-medium text-[16px] leading-[20px] text-[var(--color-content-invert-secondary,#1f1f1f)] shrink-0">
{section.categoryName}
</p>
<div className="flex flex-col min-w-0" style={{ gap: "24px" }}>
{section.entries.map((entry, entryIndex) => (
<div
key={entryIndex}
className="flex flex-col min-w-0"
style={{ gap: "6px" }}
>
<p className="font-inter font-bold text-[20px] leading-[28px] text-[var(--color-content-invert-primary)] shrink-0">
{entry.title}
</p>
<p className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-primary)] shrink-0">
{entry.body}
</p>
</div>
))}
</div>
</div>
</div>
))}
</div>
);
}
CommunityRuleDocumentView.displayName = "CommunityRuleDocumentView";
export default memo(CommunityRuleDocumentView);
@@ -0,0 +1,2 @@
export { default } from "./CommunityRuleDocument.view";
export type { CommunityRuleDocumentProps } from "./CommunityRuleDocument.types";
@@ -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>
+5 -1
View File
@@ -3,7 +3,11 @@
import { memo } from "react";
import { normalizeSectionHeaderVariant } from "../../../lib/propNormalization";
export type SectionHeaderVariantValue = "default" | "multi-line" | "Default" | "Multi-Line";
export type SectionHeaderVariantValue =
| "default"
| "multi-line"
| "Default"
| "Multi-Line";
interface SectionHeaderProps {
title: string;
@@ -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}`}
@@ -3,7 +3,10 @@
import { memo } from "react";
import ContentLockupView from "./ContentLockup.view";
import type { ContentLockupProps, VariantStyle } from "./ContentLockup.types";
import { normalizeContentLockupVariant, normalizeAlignment } from "../../../../lib/propNormalization";
import {
normalizeContentLockupVariant,
normalizeAlignment,
} from "../../../../lib/propNormalization";
const ContentLockupContainer = memo<ContentLockupProps>(
({
@@ -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>
@@ -93,19 +96,32 @@ function ContentLockupView({
<div className="flex justify-start">
{/* Small button for xsm and sm breakpoints */}
<div className="block md:hidden">
<Button buttonType="filled" palette={variant === "hero" ? "default" : "inverse"} size="small">
<Button
buttonType="filled"
palette={variant === "hero" ? "default" : "inverse"}
size="small"
>
{ctaText}
</Button>
</div>
{/* Large button for md and lg breakpoints */}
<div className="hidden md:block xl:hidden">
<Button buttonType="filled" palette={variant === "hero" ? "default" : "inverse"} size="large" className={buttonClassName}>
<Button
buttonType="filled"
palette={variant === "hero" ? "default" : "inverse"}
size="large"
className={buttonClassName}
>
{ctaText}
</Button>
</div>
{/* XLarge button for xl breakpoint */}
<div className="hidden xl:block">
<Button buttonType="filled" palette={variant === "hero" ? "default" : "inverse"} size="xlarge">
<Button
buttonType="filled"
palette={variant === "hero" ? "default" : "inverse"}
size="xlarge"
>
{ctaText}
</Button>
</div>
@@ -0,0 +1,39 @@
"use client";
import { memo } from "react";
import HeaderLockupView from "./HeaderLockup.view";
import type { HeaderLockupProps } from "./HeaderLockup.types";
import {
normalizeHeaderLockupJustification,
normalizeHeaderLockupSize,
normalizeHeaderLockupPalette,
} from "../../../../lib/propNormalization";
const HeaderLockupContainer = memo<HeaderLockupProps>(
({
title,
description,
justification: justificationProp = "left",
size: sizeProp = "L",
palette: paletteProp = "default",
}) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const justification = normalizeHeaderLockupJustification(justificationProp);
const size = normalizeHeaderLockupSize(sizeProp);
const palette = normalizeHeaderLockupPalette(paletteProp);
return (
<HeaderLockupView
title={title}
description={description}
justification={justification}
size={size}
palette={palette}
/>
);
},
);
HeaderLockupContainer.displayName = "HeaderLockup";
export default HeaderLockupContainer;
@@ -0,0 +1,45 @@
export type HeaderLockupJustificationValue =
| "left"
| "center"
| "Left"
| "Center";
export type HeaderLockupSizeValue = "L" | "M" | "l" | "m";
export type HeaderLockupPaletteValue =
| "default"
| "inverse"
| "Default"
| "Inverse";
export interface HeaderLockupProps {
/**
* Title text (required)
*/
title: string;
/**
* Description text (optional)
*/
description?: string;
/**
* Text justification. Accepts both PascalCase (Figma) and lowercase (codebase).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
justification?: HeaderLockupJustificationValue;
/**
* Size variant. Accepts both PascalCase (Figma) and lowercase (codebase).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
size?: HeaderLockupSizeValue;
/**
* Palette. Default = light text (dark bg); Inverse = dark text (light bg).
* Accepts both PascalCase (Figma) and lowercase (codebase).
*/
palette?: HeaderLockupPaletteValue;
}
export interface HeaderLockupViewProps {
title: string;
description?: string;
justification: "left" | "center";
size: "L" | "M";
palette: "default" | "inverse";
}
@@ -0,0 +1,63 @@
"use client";
import { memo } from "react";
import type { HeaderLockupViewProps } from "./HeaderLockup.types";
function HeaderLockupView({
title,
description,
justification,
size,
palette,
}: HeaderLockupViewProps) {
const isL = size === "L";
const isLeft = justification === "left";
const isInverse = palette === "inverse";
const titleColorClass = isInverse
? "text-[var(--color-content-invert-primary)]"
: "text-[var(--color-content-default-primary,white)]";
const descriptionColorClass = isInverse
? "text-[#2d2d2d]"
: "text-[var(--color-content-default-tertiary,#b4b4b4)]";
return (
<div
className={`flex flex-col gap-[var(--measures-spacing-200,8px)] py-[12px] relative ${
isLeft ? "items-start" : "items-center"
}`}
>
{/* Title */}
<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 ${titleColorClass} 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 */}
{description && (
<p
className={`font-inter font-normal max-w-[640px] overflow-hidden relative shrink-0 ${descriptionColorClass} text-ellipsis w-full whitespace-pre-wrap ${
isLeft ? "" : "text-center"
} ${
isL ? "text-[18px] leading-[1.3]" : "text-[14px] leading-[20px]"
}`}
>
{description}
</p>
)}
</div>
);
}
HeaderLockupView.displayName = "HeaderLockupView";
export default memo(HeaderLockupView);
@@ -0,0 +1 @@
export { default } from "./HeaderLockup.container";
@@ -0,0 +1,19 @@
"use client";
import { memo } from "react";
import NumberedListView from "./NumberedList.view";
import type { NumberedListProps } from "./NumberedList.types";
import { normalizeNumberedListSize } from "../../../../lib/propNormalization";
const NumberedListContainer = memo<NumberedListProps>(
({ items, size: sizeProp = "M" }) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const size = normalizeNumberedListSize(sizeProp);
return <NumberedListView items={items} size={size} />;
},
);
NumberedListContainer.displayName = "NumberedList";
export default NumberedListContainer;
@@ -0,0 +1,23 @@
export type NumberedListSizeValue = "M" | "S" | "m" | "s";
export interface NumberedListItem {
title: string;
description: string;
}
export interface NumberedListProps {
/**
* Array of list items, each with title and description
*/
items: NumberedListItem[];
/**
* Size variant. Accepts both PascalCase (Figma) and lowercase (codebase).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
size?: NumberedListSizeValue;
}
export interface NumberedListViewProps {
items: NumberedListItem[];
size: "M" | "S";
}
@@ -0,0 +1,67 @@
"use client";
import { memo } from "react";
import type { NumberedListViewProps } from "./NumberedList.types";
function NumberedListView({ items, size }: NumberedListViewProps) {
const isM = size === "M";
return (
<ol className="flex flex-col gap-[var(--measures-spacing-600,24px)] items-start relative w-full list-none">
{items.map((item, index) => (
<li
key={index}
className="flex gap-[12px] items-center relative shrink-0 w-full"
>
{/* Number Indicator */}
<div
className={`bg-[var(--color-surface-inverse-primary,white)] flex flex-col items-center justify-center px-[11.2px] py-[4px] relative rounded-full shrink-0 ${
isM ? "size-[32px]" : "size-[24px]"
}`}
>
<div
className={`flex flex-col justify-center leading-[0] overflow-hidden relative shrink-0 text-[var(--color-content-inverse-primary,black)] text-ellipsis whitespace-nowrap ${
isM
? "font-inter font-bold text-[20px] leading-[28px]"
: "font-bricolage-grotesque font-bold text-[16px] leading-[22px]"
}`}
>
<span>{index + 1}</span>
</div>
</div>
{/* Content */}
<div className="flex flex-[1_0_0] flex-col gap-[var(--measures-spacing-100,4px)] items-start justify-center min-h-px min-w-px">
{/* Title */}
<div className="flex items-center relative shrink-0 w-full">
<h3
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 ${
isM
? "font-inter font-bold text-[20px] leading-[28px]"
: "font-bricolage-grotesque font-bold text-[16px] leading-[22px]"
}`}
>
{item.title}
</h3>
</div>
{/* Description */}
<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 ${
isM
? "text-[14px] leading-[20px]"
: "text-[12px] leading-[16px]"
}`}
>
{item.description}
</p>
</div>
</li>
))}
</ol>
);
}
NumberedListView.displayName = "NumberedListView";
export default memo(NumberedListView);
@@ -0,0 +1 @@
export { default } from "./NumberedList.container";
+9 -1
View File
@@ -1,7 +1,15 @@
import { memo } from "react";
import { normalizeSize } from "../../../lib/propNormalization";
export type AvatarContainerSizeValue = "small" | "medium" | "large" | "xlarge" | "Small" | "Medium" | "Large" | "XLarge";
export type AvatarContainerSizeValue =
| "small"
| "medium"
| "large"
| "xlarge"
| "Small"
| "Medium"
| "Large"
| "XLarge";
interface AvatarContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
@@ -0,0 +1,85 @@
"use client";
import { memo, useCallback, useState } from "react";
import { CardStackView } from "./CardStack.view";
import type { CardStackProps } from "./CardStack.types";
const DEFAULT_TOGGLE_LABEL = "See all communication approaches";
const DEFAULT_SHOW_LESS_LABEL = "Show less";
const CardStackContainer = memo<CardStackProps>(
({
cards,
selectedId: controlledSelectedId,
selectedIds: controlledSelectedIds,
onCardSelect: controlledOnCardSelect,
expanded: controlledExpanded,
onToggleExpand: controlledOnToggleExpand,
hasMore = true,
toggleLabel = DEFAULT_TOGGLE_LABEL,
showLessLabel = DEFAULT_SHOW_LESS_LABEL,
title = "",
description = "",
layout = "default",
className = "",
}) => {
const [internalExpanded, setInternalExpanded] = useState(false);
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(
[],
);
const expanded =
controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
const handleToggleExpand = useCallback(() => {
if (controlledOnToggleExpand) {
controlledOnToggleExpand();
} else {
setInternalExpanded((prev) => !prev);
}
}, [controlledOnToggleExpand]);
const selectedIds =
controlledSelectedIds !== undefined
? controlledSelectedIds
: controlledSelectedId !== undefined
? controlledSelectedId
? [controlledSelectedId]
: []
: internalSelectedIds;
const handleCardSelect = useCallback(
(id: string) => {
if (controlledOnCardSelect) {
controlledOnCardSelect(id);
} else {
setInternalSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
}
},
[controlledOnCardSelect],
);
return (
<CardStackView
cards={cards}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={hasMore}
toggleLabel={toggleLabel}
showLessLabel={showLessLabel}
title={title}
description={description}
layout={layout}
className={className}
/>
);
},
);
CardStackContainer.displayName = "CardStack";
export default CardStackContainer;
@@ -0,0 +1,38 @@
export interface CardStackItem {
id: string;
label: string;
supportText?: string;
recommended?: boolean;
}
export interface CardStackProps {
cards: CardStackItem[];
selectedId?: string | null;
selectedIds?: string[];
onCardSelect?: (id: string) => void;
expanded?: boolean;
onToggleExpand?: () => void;
hasMore?: boolean;
toggleLabel?: string;
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;
}
export interface CardStackViewProps {
cards: CardStackItem[];
selectedIds: string[];
onCardSelect: (id: string) => void;
expanded: boolean;
onToggleExpand: () => void;
hasMore: boolean;
toggleLabel: string;
showLessLabel: string;
title: string;
description: string;
layout: "default" | "singleStack";
className: string;
}
@@ -0,0 +1,156 @@
"use client";
import HeaderLockup from "../../type/HeaderLockup";
import Card from "../../cards/Card";
import type { CardStackViewProps } from "./CardStack.types";
export function CardStackView({
cards,
selectedIds,
onCardSelect,
expanded,
onToggleExpand,
hasMore,
toggleLabel,
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);
// 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 ? (
<div className="min-w-0">
<HeaderLockup
title={title}
description={description}
justification="center"
size="L"
/>
</div>
) : null}
{expanded ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-6 w-full">
{cards.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>
) : (
<>
{/* Compact under 640: single column, up to 5 recommended cards */}
<div className="flex flex-col gap-6 w-full md:hidden">
{compactCards.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>
{/* Compact 640+: 6-col grid so each card spans 2; second row centered (cols 23 and 45) */}
<div className="hidden md:grid grid-cols-6 gap-x-4 gap-y-6 w-full">
{compactCards.map((item, index) => {
const colClass =
index <= 2
? "md:col-span-2"
: index === 3 && compactCards.length === 4
? "md:col-start-3 md:col-span-2"
: index === 3
? "md:col-start-2 md:col-span-2"
: "md:col-start-4 md:col-span-2";
return (
<div key={item.id} className={colClass}>
<Card
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
onClick={() => onCardSelect(item.id)}
/>
</div>
);
})}
</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>
);
}
@@ -0,0 +1,2 @@
export { default } from "./CardStack.container";
export type { CardStackProps, CardStackItem } from "./CardStack.types";
@@ -5,11 +5,12 @@ import { CreateFlowFooterView } from "./CreateFlowFooter.view";
import type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
const CreateFlowFooterContainer = memo<CreateFlowFooterProps>(
({ secondButton, progressBar = true, className = "" }) => {
({ secondButton, progressBar = true, onBackClick, className = "" }) => {
return (
<CreateFlowFooterView
secondButton={secondButton}
progressBar={progressBar}
onBackClick={onBackClick}
className={className}
/>
);
@@ -13,6 +13,10 @@ export interface CreateFlowFooterProps {
* @default true
*/
progressBar?: boolean;
/**
* Callback function for Back button click
*/
onBackClick?: () => void;
/**
* Additional CSS classes
*/
@@ -5,11 +5,12 @@ import type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
export function CreateFlowFooterView({
secondButton,
progressBar = true,
onBackClick,
className = "",
}: CreateFlowFooterProps) {
return (
<footer
className={`sticky bottom-0 z-50 bg-black w-full ${className}`}
className={`bg-black w-full ${className}`}
role="contentinfo"
aria-label="Create Flow Footer"
>
@@ -28,14 +29,14 @@ export function CreateFlowFooterView({
palette="default"
size="xsmall"
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
onClick={onBackClick}
disabled={!onBackClick}
>
Back
</Button>
{/* Second Button - Right */}
{secondButton && (
<div className="flex-shrink-0">{secondButton}</div>
)}
{secondButton && <div className="flex-shrink-0">{secondButton}</div>}
</div>
</footer>
);
@@ -15,13 +15,14 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
onExport,
onEdit,
onExit,
buttonPalette,
className = "",
}) => {
const router = useRouter();
const handleExit = () => {
const handleExit = (options?: { saveDraft?: boolean }) => {
if (onExit) {
onExit();
onExit(options);
} else {
// Default behavior: navigate to home
router.push("/");
@@ -38,6 +39,7 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
onExport={onExport}
onEdit={onEdit}
onExit={handleExit}
buttonPalette={buttonPalette}
className={className}
/>
);
@@ -1,6 +1,6 @@
/**
* Type definitions for CreateFlowTopNav component
*
*
* Top navigation bar for the create rule flow.
* Includes logo and action buttons (Share, Export, Edit, Exit).
*/
@@ -39,9 +39,15 @@ export interface CreateFlowTopNavProps {
*/
onEdit?: () => void;
/**
* Callback when Exit/Save & Exit button is clicked
* Callback when Exit/Save & Exit button is clicked.
* When user is logged in, called with { saveDraft: true } to stub "Save & Exit".
*/
onExit?: () => void;
onExit?: (options?: { saveDraft?: boolean }) => void;
/**
* Palette for nav buttons (e.g. "inverse" on completed page to match teal background)
* @default "default"
*/
buttonPalette?: "default" | "inverse";
/**
* Additional CSS classes
*/
@@ -1,4 +1,4 @@
import Logo from "../../icons/Logo";
import Logo from "../../asset/logo";
import Button from "../../buttons/Button";
import type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types";
@@ -11,13 +11,14 @@ export function CreateFlowTopNavView({
onExport,
onEdit,
onExit,
buttonPalette = "default",
className = "",
}: CreateFlowTopNavProps) {
const exitButtonText = loggedIn ? "Save & Exit" : "Exit";
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"
>
@@ -27,14 +28,14 @@ export function CreateFlowTopNavView({
aria-label="Create Flow Navigation"
>
{/* Logo - Left */}
<Logo size="createFlow" showText={true} />
<Logo size="createFlow" wordmark palette={buttonPalette} />
{/* Button Group - Right */}
<div className="flex items-center gap-[var(--spacing-scale-012,12px)]">
{hasShare && (
<Button
buttonType="outline"
palette="default"
palette={buttonPalette}
size="xsmall"
onClick={onShare}
ariaLabel="Share"
@@ -47,7 +48,7 @@ export function CreateFlowTopNavView({
{hasExport && (
<Button
buttonType="outline"
palette="default"
palette={buttonPalette}
size="xsmall"
onClick={onExport}
ariaLabel="Export"
@@ -74,7 +75,7 @@ export function CreateFlowTopNavView({
{hasEdit && (
<Button
buttonType="outline"
palette="default"
palette={buttonPalette}
size="xsmall"
onClick={onEdit}
ariaLabel="Edit"
@@ -86,9 +87,9 @@ export function CreateFlowTopNavView({
<Button
buttonType="outline"
palette="default"
palette={buttonPalette}
size="xsmall"
onClick={onExit}
onClick={() => onExit?.({ saveDraft: loggedIn })}
ariaLabel={exitButtonText}
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
>
@@ -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,33 @@
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,76 @@
"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);

Some files were not shown because too many files have changed in this diff Show More