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