diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index 2a68e17..f7ba62f 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -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 (
@@ -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")} />
@@ -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")} /> diff --git a/app/components/InputLabel/InputLabel.container.tsx b/app/components/InputLabel/InputLabel.container.tsx new file mode 100644 index 0000000..1060ba7 --- /dev/null +++ b/app/components/InputLabel/InputLabel.container.tsx @@ -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( + ({ + label, + helpIcon = false, + asterisk = false, + helperText = false, + size: sizeProp = "S", + palette: paletteProp = "Default", + className = "", + }) => { + const size = normalizeInputLabelSize(sizeProp); + const palette = normalizeInputLabelPalette(paletteProp); + + return ( + + ); + }, +); + +InputLabelContainer.displayName = "InputLabel"; + +export default InputLabelContainer; diff --git a/app/components/InputLabel/InputLabel.types.ts b/app/components/InputLabel/InputLabel.types.ts new file mode 100644 index 0000000..7e37ae6 --- /dev/null +++ b/app/components/InputLabel/InputLabel.types.ts @@ -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; +} diff --git a/app/components/InputLabel/InputLabel.view.tsx b/app/components/InputLabel/InputLabel.view.tsx new file mode 100644 index 0000000..9b5ab56 --- /dev/null +++ b/app/components/InputLabel/InputLabel.view.tsx @@ -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 ( +
+
+
+

+ {label} +

+ {asterisk && ( +

+ * +

+ )} +
+ {helpIcon && ( +
+ Help +
+ )} +
+ {helperText && ( +

+ {typeof helperText === "string" ? helperText : "Optional text"} +

+ )} +
+ ); +} + +InputLabelView.displayName = "InputLabelView"; + +export default memo(InputLabelView); diff --git a/app/components/InputLabel/index.tsx b/app/components/InputLabel/index.tsx new file mode 100644 index 0000000..b2d805a --- /dev/null +++ b/app/components/InputLabel/index.tsx @@ -0,0 +1,3 @@ +import InputLabel from "./InputLabel.container"; + +export default InputLabel; diff --git a/app/components/MultiSelect/MultiSelect.container.tsx b/app/components/MultiSelect/MultiSelect.container.tsx index 086498e..1613576 100644 --- a/app/components/MultiSelect/MultiSelect.container.tsx +++ b/app/components/MultiSelect/MultiSelect.container.tsx @@ -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( ({ label, showHelpIcon = true, size: sizeProp = "M", + palette: paletteProp = "Default", options, onChipClick, onAddClick, @@ -20,12 +21,14 @@ const MultiSelectContainer = memo( className = "", }) => { const size = normalizeMultiSelectSize(sizeProp); + const palette = normalizeChipPalette(paletteProp); return ( void; onAddClick?: () => void; diff --git a/app/components/MultiSelect/MultiSelect.view.tsx b/app/components/MultiSelect/MultiSelect.view.tsx index a526e68..b59d8c9 100644 --- a/app/components/MultiSelect/MultiSelect.view.tsx +++ b/app/components/MultiSelect/MultiSelect.view.tsx @@ -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 (
- {/* Label with help icon */} + {/* Label using InputLabel component */} {label && ( -
-
- - {showHelpIcon && ( -
- Help -
- )} -
-
+ )} {/* 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 && ( )}
diff --git a/app/components/RuleCard/RuleCard.container.tsx b/app/components/RuleCard/RuleCard.container.tsx index 855bbb7..84105b6 100644 --- a/app/components/RuleCard/RuleCard.container.tsx +++ b/app/components/RuleCard/RuleCard.container.tsx @@ -29,8 +29,6 @@ const RuleCardContainer = memo( expanded = false, size: sizeProp, categories, - onPillClick, - onCreateClick, logoUrl, logoAlt, communityInitials, @@ -77,8 +75,6 @@ const RuleCardContainer = memo( expanded={expanded} size={size} categories={categories} - onPillClick={onPillClick} - onCreateClick={onCreateClick} logoUrl={logoUrl} logoAlt={logoAlt} communityInitials={communityInitials} diff --git a/app/components/RuleCard/RuleCard.types.ts b/app/components/RuleCard/RuleCard.types.ts index ab5c367..f7bda7f 100644 --- a/app/components/RuleCard/RuleCard.types.ts +++ b/app/components/RuleCard/RuleCard.types.ts @@ -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; diff --git a/app/components/RuleCard/RuleCard.view.tsx b/app/components/RuleCard/RuleCard.view.tsx index ed9edcf..2b92295 100644 --- a/app/components/RuleCard/RuleCard.view.tsx +++ b/app/components/RuleCard/RuleCard.view.tsx @@ -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, categoryName: string, item: string) => { - e.stopPropagation(); - if (onPillClick) { - onPillClick(categoryName, item); - } - }; - - // Handle create button click with stopPropagation - const handleCreateClick = (e: React.MouseEvent, categoryName: string) => { - e.stopPropagation(); - if (onCreateClick) { - onCreateClick(categoryName); - } - }; return (
- {/* Categories Section */} + {/* Categories Section - Using MultiSelect */} {categories && categories.length > 0 && (
{categories.map((category, categoryIndex) => ( -
- {/* Category Label */} -
-

- {category.name} -

-
- {/* Pills Container */} -
- {/* Pills */} - {category.items && category.items.map((item, itemIndex) => ( - - ))} - {/* Add Button */} - -
-
+ { + 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" + /> ))}
)} diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index ea8600e..0c5d6de 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -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; +}