Create InputLabel component and add RuleCard chips

This commit is contained in:
adilallo
2026-02-05 09:25:42 -07:00
parent 3e935ecd9e
commit 0dedebfaf8
12 changed files with 638 additions and 127 deletions
+341 -26
View File
@@ -97,33 +97,360 @@ export default function ComponentsPreview() {
{ id: "custom-2", label: "", state: "Custom", palette: "Default", size: "M" },
]);
const sampleCategories = [
// RuleCard categories with chip options and state management
const [ruleCardCategories, setRuleCardCategories] = useState([
{
name: "Values",
items: ["Consciousness", "Ecology", "Abundance", "Art", "Decisiveness"],
createUrl: "/create/value",
chipOptions: [
{ id: "values-1", label: "Consciousness", state: "Unselected" as const },
{ id: "values-2", label: "Ecology", state: "Unselected" as const },
{ id: "values-3", label: "Abundance", state: "Unselected" as const },
{ id: "values-4", label: "Art", state: "Unselected" as const },
{ id: "values-5", label: "Decisiveness", state: "Unselected" as const },
],
onChipClick: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const),
}
: opt
),
}
: cat
)
);
},
onAddClick: (categoryName: string) => {
const newId = `custom-${categoryName}-${Date.now()}`;
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: [
...cat.chipOptions,
{ id: newId, label: "", state: "Custom" as const },
],
}
: cat
)
);
},
onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" as const }
: opt
),
}
: cat
)
);
},
onCustomChipClose: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId),
}
: cat
)
);
},
},
{
name: "Communication",
items: ["Signal"],
createUrl: "/create/communication",
chipOptions: [
{ id: "comm-1", label: "Signal", state: "Unselected" as const },
],
onChipClick: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const),
}
: opt
),
}
: cat
)
);
},
onAddClick: (categoryName: string) => {
const newId = `custom-${categoryName}-${Date.now()}`;
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: [
...cat.chipOptions,
{ id: newId, label: "", state: "Custom" as const },
],
}
: cat
)
);
},
onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" as const }
: opt
),
}
: cat
)
);
},
onCustomChipClose: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId),
}
: cat
)
);
},
},
{
name: "Membership",
items: ["Open Admission"],
createUrl: "/create/membership",
chipOptions: [
{ id: "membership-1", label: "Open Admission", state: "Unselected" as const },
],
onChipClick: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const),
}
: opt
),
}
: cat
)
);
},
onAddClick: (categoryName: string) => {
const newId = `custom-${categoryName}-${Date.now()}`;
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: [
...cat.chipOptions,
{ id: newId, label: "", state: "Custom" as const },
],
}
: cat
)
);
},
onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" as const }
: opt
),
}
: cat
)
);
},
onCustomChipClose: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId),
}
: cat
)
);
},
},
{
name: "Decision-making",
items: ["Lazy Consensus", "Modified Consensus"],
createUrl: "/create/decision-making",
chipOptions: [
{ id: "decision-1", label: "Lazy Consensus", state: "Unselected" as const },
{ id: "decision-2", label: "Modified Consensus", state: "Unselected" as const },
],
onChipClick: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const),
}
: opt
),
}
: cat
)
);
},
onAddClick: (categoryName: string) => {
const newId = `custom-${categoryName}-${Date.now()}`;
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: [
...cat.chipOptions,
{ id: newId, label: "", state: "Custom" as const },
],
}
: cat
)
);
},
onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" as const }
: opt
),
}
: cat
)
);
},
onCustomChipClose: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId),
}
: cat
)
);
},
},
{
name: "Conflict management",
items: ["Code of Conduct", "Restorative Justice"],
createUrl: "/create/conflict-management",
chipOptions: [
{ id: "conflict-1", label: "Code of Conduct", state: "Unselected" as const },
{ id: "conflict-2", label: "Restorative Justice", state: "Unselected" as const },
],
onChipClick: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const),
}
: opt
),
}
: cat
)
);
},
onAddClick: (categoryName: string) => {
const newId = `custom-${categoryName}-${Date.now()}`;
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: [
...cat.chipOptions,
{ id: newId, label: "", state: "Custom" as const },
],
}
: cat
)
);
},
onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" as const }
: opt
),
}
: cat
)
);
},
onCustomChipClose: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
cat.name === categoryName
? {
...cat,
chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId),
}
: cat
)
);
},
},
];
]);
return (
<div className="min-h-screen bg-[var(--color-surface-default-primary)] p-[var(--spacing-scale-032)]">
@@ -355,13 +682,7 @@ export default function ComponentsPreview() {
className="w-[568px]"
logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png"
logoAlt="Mutual Aid Mondays"
categories={sampleCategories}
onPillClick={(category, item) => {
console.log(`Pill clicked: ${category} - ${item}`);
}}
onCreateClick={(category) => {
console.log(`Create clicked: ${category}`);
}}
categories={ruleCardCategories}
onClick={() => console.log("Card clicked: Mutual Aid Mondays")}
/>
</div>
@@ -382,13 +703,7 @@ export default function ComponentsPreview() {
className="w-[398px]"
logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png"
logoAlt="Mutual Aid Mondays"
categories={sampleCategories}
onPillClick={(category, item) => {
console.log(`Pill clicked: ${category} - ${item}`);
}}
onCreateClick={(category) => {
console.log(`Create clicked: ${category}`);
}}
categories={ruleCardCategories}
onClick={() => console.log("Card clicked: Mutual Aid Mondays")}
/>
</div>
@@ -0,0 +1,40 @@
"use client";
import { memo } from "react";
import InputLabelView from "./InputLabel.view";
import type { InputLabelProps } from "./InputLabel.types";
import {
normalizeInputLabelSize,
normalizeInputLabelPalette,
} from "../../../lib/propNormalization";
const InputLabelContainer = memo<InputLabelProps>(
({
label,
helpIcon = false,
asterisk = false,
helperText = false,
size: sizeProp = "S",
palette: paletteProp = "Default",
className = "",
}) => {
const size = normalizeInputLabelSize(sizeProp);
const palette = normalizeInputLabelPalette(paletteProp);
return (
<InputLabelView
label={label}
helpIcon={helpIcon}
asterisk={asterisk}
helperText={helperText}
size={size}
palette={palette}
className={className}
/>
);
},
);
InputLabelContainer.displayName = "InputLabel";
export default InputLabelContainer;
@@ -0,0 +1,44 @@
export type InputLabelSizeValue = "S" | "M" | "s" | "m";
export type InputLabelPaletteValue = "Default" | "Inverse" | "default" | "inverse";
export interface InputLabelProps {
/**
* The label text to display
*/
label: string;
/**
* Show help icon next to label
*/
helpIcon?: boolean;
/**
* Show asterisk (*) to indicate required field
*/
asterisk?: boolean;
/**
* Helper text to display on the right side.
* If boolean true, shows "Optional text".
* If string, shows the provided text.
*/
helperText?: boolean | string;
/**
* Size variant: "S" (small) or "M" (medium)
* Accepts both uppercase (Figma) and lowercase values.
*/
size?: InputLabelSizeValue;
/**
* Palette variant: "Default" or "Inverse"
* Accepts both PascalCase (Figma) and lowercase values.
*/
palette?: InputLabelPaletteValue;
className?: string;
}
export interface InputLabelViewProps {
label: string;
helpIcon: boolean;
asterisk: boolean;
helperText: boolean | string;
size: "s" | "m";
palette: "default" | "inverse";
className: string;
}
@@ -0,0 +1,106 @@
"use client";
import { memo } from "react";
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
import type { InputLabelViewProps } from "./InputLabel.types";
function InputLabelView({
label,
helpIcon,
asterisk,
helperText,
size,
palette,
className = "",
}: InputLabelViewProps) {
const isSmall = size === "s";
const isInverse = palette === "inverse";
// Size-based typography
const labelTextSize = isSmall
? "text-[length:var(--sizing-350,14px)] leading-[20px]"
: "text-[length:var(--sizing-400,16px)] leading-[24px]";
const helperTextSize = isSmall
? "text-[length:var(--measures-sizing-250,10px)] leading-[var(--measures-spacing-350,14px)]"
: "text-[length:var(--sizing-300,12px)] leading-[16px]";
const asteriskSize = isSmall
? "text-[length:var(--measures-sizing-250,10px)] leading-[var(--measures-spacing-300,12px)]"
: "text-[length:var(--measures-spacing-300,12px)] leading-[var(--measures-spacing-300,12px)]";
// Palette-based colors
const labelColor = isInverse
? "text-[color:var(--color-content-inverse-secondary,#1f1f1f)]"
: "text-[color:var(--color-content-default-secondary,#d2d2d2)]";
const helperTextColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
// Layout: S uses flex-wrap with baseline, M uses flex with center
const containerClass = isSmall
? "flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative w-full"
: "flex gap-[4px] items-center relative w-full";
const labelContainerClass = isSmall
? "flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0"
: "flex gap-[var(--measures-spacing-100,4px)] items-center relative shrink-0";
const helpIconSize = isSmall ? "size-[12px]" : "size-[16px]";
// Help icon color filter based on palette
// Default: Light yellow (#f6f06f / rgba(246, 240, 111, 1)) - SVG already has this color
// Inverse: Dark yellow (#8c8505 / rgba(140, 133, 5, 1))
// For default, no filter needed as SVG already has the correct yellow
// For inverse, darken the yellow
const helpIconFilter = isInverse
? "brightness(0.57) saturate(100%)" // Dark yellow (#8c8505) - darken the existing yellow
: undefined; // No filter for default - use SVG's native yellow color
return (
<div className={`${containerClass} ${className}`}>
<div className={labelContainerClass}>
<div className="flex gap-px items-start relative shrink-0">
<p
className={`font-inter font-normal ${labelTextSize} ${labelColor} relative shrink-0`}
>
{label}
</p>
{asterisk && (
<p
className={`font-inter font-medium ${asteriskSize} relative shrink-0 text-[color:var(--color-content-default-negative-primary,#ea4845)]`}
>
*
</p>
)}
</div>
{helpIcon && (
<div className={`relative shrink-0 ${helpIconSize}`}>
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt="Help"
className="block max-w-none size-full"
style={
helpIconFilter
? {
filter: helpIconFilter,
}
: undefined
}
/>
</div>
)}
</div>
{helperText && (
<p
className={`flex-[1_0_0] font-inter font-normal ${helperTextSize} min-h-px min-w-px relative ${helperTextColor} text-right`}
>
{typeof helperText === "string" ? helperText : "Optional text"}
</p>
)}
</div>
);
}
InputLabelView.displayName = "InputLabelView";
export default memo(InputLabelView);
+3
View File
@@ -0,0 +1,3 @@
import InputLabel from "./InputLabel.container";
export default InputLabel;
@@ -3,13 +3,14 @@
import { memo } from "react";
import MultiSelectView from "./MultiSelect.view";
import type { MultiSelectProps } from "./MultiSelect.types";
import { normalizeMultiSelectSize } from "../../../lib/propNormalization";
import { normalizeMultiSelectSize, normalizeChipPalette } from "../../../lib/propNormalization";
const MultiSelectContainer = memo<MultiSelectProps>(
({
label,
showHelpIcon = true,
size: sizeProp = "M",
palette: paletteProp = "Default",
options,
onChipClick,
onAddClick,
@@ -20,12 +21,14 @@ const MultiSelectContainer = memo<MultiSelectProps>(
className = "",
}) => {
const size = normalizeMultiSelectSize(sizeProp);
const palette = normalizeChipPalette(paletteProp);
return (
<MultiSelectView
label={label}
showHelpIcon={showHelpIcon}
size={size}
palette={palette}
options={options}
onChipClick={onChipClick}
onAddClick={onAddClick}
@@ -1,4 +1,4 @@
import type { ChipStateValue, ChipSizeValue } from "../../../lib/propNormalization";
import type { ChipStateValue, ChipSizeValue, ChipPaletteValue } from "../../../lib/propNormalization";
export interface ChipOption {
id: string;
@@ -22,6 +22,11 @@ export interface MultiSelectProps {
* Accepts both uppercase (Figma) and lowercase values.
*/
size?: MultiSelectSizeValue;
/**
* Palette for chips: "Default" or "Inverse"
* Accepts both PascalCase (Figma) and lowercase values.
*/
palette?: ChipPaletteValue;
/**
* Array of chip options to display
*/
@@ -57,6 +62,7 @@ export interface MultiSelectViewProps {
label?: string;
showHelpIcon: boolean;
size: "s" | "m";
palette: "default" | "inverse";
options: ChipOption[];
onChipClick?: (chipId: string) => void;
onAddClick?: () => void;
+29 -37
View File
@@ -1,14 +1,15 @@
"use client";
import { memo } from "react";
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
import Chip from "../Chip";
import InputLabel from "../InputLabel";
import type { MultiSelectViewProps } from "./MultiSelect.types";
function MultiSelectView({
label,
showHelpIcon,
size,
palette,
options,
onChipClick,
onAddClick,
@@ -19,44 +20,27 @@ function MultiSelectView({
className,
}: MultiSelectViewProps) {
const isSmall = size === "s";
const isInverse = palette === "inverse";
// Size-based spacing and typography
// Size-based spacing
const gapClass = isSmall
? "gap-[var(--measures-spacing-200,8px)]"
: "gap-[var(--measures-spacing-300,12px)]";
const labelGapClass = isSmall
? "gap-[var(--measures-spacing-200,4px_8px)]"
: "gap-[4px]";
const labelTextSize = isSmall
? "text-[length:var(--sizing-350,14px)] leading-[20px]"
: "text-[length:var(--sizing-400,16px)] leading-[24px]";
const helpIconSize = isSmall ? "size-[12px]" : "size-[16px]";
const chipSize = isSmall ? "S" : "M";
return (
<div className={`flex flex-col ${isSmall ? "gap-[var(--measures-spacing-200,8px)]" : "gap-[var(--measures-spacing-300,12px)]"} items-start relative w-full ${className}`}>
{/* Label with help icon */}
{/* Label using InputLabel component */}
{label && (
<div className={`flex flex-wrap ${labelGapClass} items-baseline ${isSmall ? "pr-[var(--measures-spacing-100,4px)]" : ""} relative shrink-0 w-full`}>
<div className={`flex ${isSmall ? "gap-[var(--measures-spacing-050,2px)]" : "gap-[var(--measures-spacing-100,4px)]"} items-center relative shrink-0`}>
<label className={`font-inter font-normal ${labelTextSize} text-[color:var(--color-content-default-primary,white)]`}>
{label}
</label>
{showHelpIcon && (
<div className={`relative shrink-0 ${helpIconSize}`}>
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt="Help"
className="block max-w-none size-full"
/>
</div>
)}
</div>
</div>
<InputLabel
label={label}
helpIcon={showHelpIcon}
asterisk={false}
helperText={false}
size={size === "s" ? "S" : "M"}
palette={palette === "inverse" ? "Inverse" : "Default"}
/>
)}
{/* Chips container */}
@@ -66,7 +50,7 @@ function MultiSelectView({
key={option.id}
label={option.state === "Custom" ? "" : option.label}
state={option.state || "Unselected"}
palette="Default"
palette={palette === "inverse" ? "Inverse" : "Default"}
size={chipSize}
onClick={() => {
// Only toggle if not in Custom state
@@ -89,7 +73,7 @@ function MultiSelectView({
/>
))}
{/* Add button - Ghost button style */}
{/* Add button - Circular button with border (not ghost) when no text, ghost style when text provided */}
{showAddButton && (
<button
type="button"
@@ -97,7 +81,13 @@ function MultiSelectView({
e.stopPropagation();
onAddClick?.();
}}
className={`flex ${isSmall ? "gap-[var(--measures-spacing-050,2px)]" : "gap-[var(--measures-spacing-150,6px)]"} items-center justify-center ${isSmall ? "p-[var(--measures-spacing-200,8px)]" : "p-[var(--measures-spacing-300,12px)]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`}
className={
!addButtonText
? // Circular button with border (RuleCard style)
`bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`
: // Ghost button style (standalone MultiSelect)
`flex ${isSmall ? "gap-[var(--measures-spacing-050,2px)]" : "gap-[var(--measures-spacing-150,6px)]"} items-center justify-center ${isSmall ? "p-[var(--measures-spacing-200,8px)]" : "p-[var(--measures-spacing-300,12px)]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`
}
>
{/* Plus icon */}
<svg
@@ -106,7 +96,7 @@ function MultiSelectView({
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-[var(--color-content-default-brand-primary,#fefcc9)] shrink-0"
className={`${isInverse ? "text-[var(--color-content-inverse-primary,black)]" : "text-[var(--color-content-default-brand-primary,#fefcc9)]"} shrink-0`}
>
<path
d="M7 3V11M3 7H11"
@@ -116,10 +106,12 @@ function MultiSelectView({
strokeLinejoin="round"
/>
</svg>
{/* Text */}
<span className={`font-inter font-medium ${isSmall ? "text-[length:var(--sizing-300,12px)] leading-[14px]" : "text-[length:var(--sizing-400,16px)] leading-[20px]"} text-[color:var(--color-content-default-brand-primary,#fefcc9)]`}>
{addButtonText}
</span>
{/* Text - only show if addButtonText is provided */}
{addButtonText && (
<span className={`font-inter font-medium ${isSmall ? "text-[length:var(--sizing-300,12px)] leading-[14px]" : "text-[length:var(--sizing-400,16px)] leading-[20px]"} ${isInverse ? "text-[color:var(--color-content-inverse-primary,black)]" : "text-[color:var(--color-content-default-brand-primary,#fefcc9)]"}`}>
{addButtonText}
</span>
)}
</button>
)}
</div>
@@ -29,8 +29,6 @@ const RuleCardContainer = memo<RuleCardProps>(
expanded = false,
size: sizeProp,
categories,
onPillClick,
onCreateClick,
logoUrl,
logoAlt,
communityInitials,
@@ -77,8 +75,6 @@ const RuleCardContainer = memo<RuleCardProps>(
expanded={expanded}
size={size}
categories={categories}
onPillClick={onPillClick}
onCreateClick={onCreateClick}
logoUrl={logoUrl}
logoAlt={logoAlt}
communityInitials={communityInitials}
+7 -6
View File
@@ -1,7 +1,12 @@
import type { ChipOption } from "../MultiSelect/MultiSelect.types";
export interface Category {
name: string;
items: string[];
createUrl?: string;
chipOptions: ChipOption[];
onChipClick?: (categoryName: string, chipId: string) => void;
onAddClick?: (categoryName: string) => void;
onCustomChipConfirm?: (categoryName: string, chipId: string, value: string) => void;
onCustomChipClose?: (categoryName: string, chipId: string) => void;
}
export interface RuleCardProps {
@@ -14,8 +19,6 @@ export interface RuleCardProps {
expanded?: boolean;
size?: "L" | "M" | "l" | "m";
categories?: Category[];
onPillClick?: (category: string, item: string) => void;
onCreateClick?: (category: string) => void;
logoUrl?: string;
logoAlt?: string;
communityInitials?: string;
@@ -32,8 +35,6 @@ export interface RuleCardViewProps {
expanded: boolean;
size: "L" | "M";
categories?: Category[];
onPillClick?: (category: string, item: string) => void;
onCreateClick?: (category: string) => void;
logoUrl?: string;
logoAlt?: string;
communityInitials?: string;
+25 -52
View File
@@ -2,6 +2,7 @@
import Image from "next/image";
import { useTranslation } from "../../contexts/MessagesContext";
import MultiSelect from "../MultiSelect";
import type { RuleCardViewProps } from "./RuleCard.types";
export function RuleCardView({
@@ -15,8 +16,6 @@ export function RuleCardView({
expanded,
size,
categories,
onPillClick,
onCreateClick,
logoUrl,
logoAlt,
communityInitials,
@@ -111,21 +110,6 @@ export function RuleCardView({
return null;
};
// Handle pill click with stopPropagation
const handlePillClick = (e: React.MouseEvent<HTMLButtonElement>, categoryName: string, item: string) => {
e.stopPropagation();
if (onPillClick) {
onPillClick(categoryName, item);
}
};
// Handle create button click with stopPropagation
const handleCreateClick = (e: React.MouseEvent<HTMLButtonElement>, categoryName: string) => {
e.stopPropagation();
if (onCreateClick) {
onCreateClick(categoryName);
}
};
return (
<div
@@ -157,44 +141,33 @@ export function RuleCardView({
{expanded ? (
<>
{/* Categories Section */}
{/* Categories Section - Using MultiSelect */}
{categories && categories.length > 0 && (
<div className="flex flex-col gap-[16px] items-start px-[12px] relative shrink-0 w-full">
{categories.map((category, categoryIndex) => (
<div key={categoryIndex} className="flex flex-col gap-[var(--spacing-scale-008)] items-start relative shrink-0 w-full">
{/* Category Label */}
<div className="flex items-baseline gap-[var(--spacing-scale-008)] pr-[var(--spacing-scale-004)] relative shrink-0 w-full">
<h4 className={`${categoryLabelClass} text-[var(--color-content-inverse-primary)]`}>
{category.name}
</h4>
</div>
{/* Pills Container */}
<div className="flex flex-wrap gap-[var(--spacing-scale-008)] items-center relative shrink-0 w-full">
{/* Pills */}
{category.items && category.items.map((item, itemIndex) => (
<button
key={itemIndex}
type="button"
className="bg-transparent border-[1.25px] border-[var(--color-content-inverse-primary)] h-[30px] px-[var(--spacing-scale-008)] rounded-[var(--radius-measures-radius-full)] flex items-center justify-center shrink-0 cursor-pointer"
onClick={(e) => handlePillClick(e, category.name, item)}
aria-label={`Edit ${item}`}
>
<span className={`${pillTextClass} text-[var(--color-content-inverse-primary)]`}>
{item}
</span>
</button>
))}
{/* Add Button */}
<button
type="button"
className="bg-transparent border-[1.25px] border-[var(--color-content-inverse-primary)] size-[30px] rounded-[var(--radius-measures-radius-full)] flex items-center justify-center shrink-0 cursor-pointer"
onClick={(e) => handleCreateClick(e, category.name)}
aria-label={`Add new ${category.name}`}
>
<span className="text-[var(--color-content-inverse-primary)] text-[14px] leading-[14px]">+</span>
</button>
</div>
</div>
<MultiSelect
key={categoryIndex}
label={category.name}
showHelpIcon={false}
size="S"
palette="Inverse"
options={category.chipOptions}
onChipClick={(chipId) => {
category.onChipClick?.(category.name, chipId);
}}
onAddClick={() => {
category.onAddClick?.(category.name);
}}
onCustomChipConfirm={(chipId, value) => {
category.onCustomChipConfirm?.(category.name, chipId, value);
}}
onCustomChipClose={(chipId) => {
category.onCustomChipClose?.(category.name, chipId);
}}
showAddButton={true}
addButtonText="" // Empty text for icon-only circular button
className="w-full"
/>
))}
</div>
)}
+32
View File
@@ -618,3 +618,35 @@ export function normalizeMultiSelectSize(
}
return defaultValue;
}
/**
* Normalize InputLabel size prop values (S/M -> s/m)
*/
export function normalizeInputLabelSize(
value: string | undefined,
defaultValue: "s" = "s",
): "s" | "m" {
if (!value) return defaultValue;
const normalized = value.toLowerCase();
const sizes = ["s", "m"];
if (sizes.includes(normalized)) {
return normalized as typeof defaultValue;
}
return defaultValue;
}
/**
* Normalize InputLabel palette prop values (Default/Inverse -> default/inverse)
*/
export function normalizeInputLabelPalette(
value: string | undefined,
defaultValue: "default" = "default",
): "default" | "inverse" {
if (!value) return defaultValue;
const normalized = value.toLowerCase();
const palettes = ["default", "inverse"];
if (palettes.includes(normalized)) {
return normalized as typeof defaultValue;
}
return defaultValue;
}