diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index e74b0a6..2a68e17 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import RuleCard from "../components/RuleCard"; import Chip from "../components/Chip"; +import MultiSelect from "../components/MultiSelect"; import Image from "next/image"; import { getAssetPath } from "../../lib/assetUtils"; @@ -14,6 +15,73 @@ interface ChipData { size: "S" | "M"; } +// MultiSelect example component with state management +function MultiSelectExample({ size }: { size: "S" | "M" }) { + const [options, setOptions] = useState([ + { id: "1", label: "1 member", state: "Unselected" as const }, + { id: "2", label: "2-10 members", state: "Unselected" as const }, + { id: "3", label: "10-24 members", state: "Unselected" as const }, + { id: "4", label: "24-64 members", state: "Unselected" as const }, + { id: "5", label: "64-128 members", state: "Unselected" as const }, + { id: "6", label: "125-1000 members", state: "Unselected" as const }, + { id: "7", label: "1000+ members", state: "Unselected" as const }, + ]); + + const handleChipClick = (chipId: string) => { + setOptions((prev) => + prev.map((opt) => + opt.id === chipId + ? { + ...opt, + state: opt.state === "Selected" ? ("Unselected" as const) : ("Selected" as const), + } + : opt + ) + ); + }; + + const handleAddClick = () => { + const newId = `custom-${Date.now()}`; + setOptions((prev) => [ + ...prev, + { id: newId, label: "", state: "Custom" as const }, + ]); + }; + + const handleCustomConfirm = (chipId: string, value: string) => { + setOptions((prev) => + prev.map((opt) => + opt.id === chipId + ? { ...opt, label: value, state: "Selected" as const } + : opt + ) + ); + }; + + const handleCustomClose = (chipId: string) => { + setOptions((prev) => prev.filter((opt) => opt.id !== chipId)); + }; + + return ( +
+

+ {size === "S" ? "Small (S)" : "Medium (M)"} +

+ +
+ ); +} + export default function ComponentsPreview() { const [expandedCard, setExpandedCard] = useState(null); const [chipStates, setChipStates] = useState>({ @@ -389,6 +457,20 @@ export default function ComponentsPreview() { /> + + {/* MultiSelect Component */} +
+

+ MultiSelect Component (Controls) +

+
+ {/* Small size */} + + + {/* Medium size */} + +
+
); diff --git a/app/components/MultiSelect/MultiSelect.container.tsx b/app/components/MultiSelect/MultiSelect.container.tsx new file mode 100644 index 0000000..086498e --- /dev/null +++ b/app/components/MultiSelect/MultiSelect.container.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { memo } from "react"; +import MultiSelectView from "./MultiSelect.view"; +import type { MultiSelectProps } from "./MultiSelect.types"; +import { normalizeMultiSelectSize } from "../../../lib/propNormalization"; + +const MultiSelectContainer = memo( + ({ + label, + showHelpIcon = true, + size: sizeProp = "M", + options, + onChipClick, + onAddClick, + showAddButton = true, + addButtonText = "Add organization type", + onCustomChipConfirm, + onCustomChipClose, + className = "", + }) => { + const size = normalizeMultiSelectSize(sizeProp); + + return ( + + ); + }, +); + +MultiSelectContainer.displayName = "MultiSelect"; + +export default MultiSelectContainer; diff --git a/app/components/MultiSelect/MultiSelect.types.ts b/app/components/MultiSelect/MultiSelect.types.ts new file mode 100644 index 0000000..38c39a3 --- /dev/null +++ b/app/components/MultiSelect/MultiSelect.types.ts @@ -0,0 +1,68 @@ +import type { ChipStateValue, ChipSizeValue } from "../../../lib/propNormalization"; + +export interface ChipOption { + id: string; + label: string; + state?: ChipStateValue; +} + +export type MultiSelectSizeValue = "S" | "M" | "s" | "m"; + +export interface MultiSelectProps { + /** + * Label for the multi-select component + */ + label?: string; + /** + * Show help icon next to label + */ + showHelpIcon?: boolean; + /** + * Size variant: "S" (small) or "M" (medium) + * Accepts both uppercase (Figma) and lowercase values. + */ + size?: MultiSelectSizeValue; + /** + * Array of chip options to display + */ + options: ChipOption[]; + /** + * Callback when a chip is clicked (toggled) + */ + onChipClick?: (chipId: string) => void; + /** + * Callback when add button is clicked + */ + onAddClick?: () => void; + /** + * Show the add button + */ + showAddButton?: boolean; + /** + * Text for the add button + */ + addButtonText?: string; + /** + * Callback when a custom chip is confirmed (check button clicked) + */ + onCustomChipConfirm?: (chipId: string, value: string) => void; + /** + * Callback when a custom chip is closed/removed + */ + onCustomChipClose?: (chipId: string) => void; + className?: string; +} + +export interface MultiSelectViewProps { + label?: string; + showHelpIcon: boolean; + size: "s" | "m"; + options: ChipOption[]; + onChipClick?: (chipId: string) => void; + onAddClick?: () => void; + showAddButton: boolean; + addButtonText: string; + onCustomChipConfirm?: (chipId: string, value: string) => void; + onCustomChipClose?: (chipId: string) => void; + className: string; +} diff --git a/app/components/MultiSelect/MultiSelect.view.tsx b/app/components/MultiSelect/MultiSelect.view.tsx new file mode 100644 index 0000000..a526e68 --- /dev/null +++ b/app/components/MultiSelect/MultiSelect.view.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { memo } from "react"; +import { getAssetPath, ASSETS } from "../../../lib/assetUtils"; +import Chip from "../Chip"; +import type { MultiSelectViewProps } from "./MultiSelect.types"; + +function MultiSelectView({ + label, + showHelpIcon, + size, + options, + onChipClick, + onAddClick, + showAddButton, + addButtonText, + onCustomChipConfirm, + onCustomChipClose, + className, +}: MultiSelectViewProps) { + const isSmall = size === "s"; + + // Size-based spacing and typography + 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 && ( +
+
+ + {showHelpIcon && ( +
+ Help +
+ )} +
+
+ )} + + {/* Chips container */} +
+ {options.map((option) => ( + { + // Only toggle if not in Custom state + if (option.state !== "Custom" && onChipClick) { + onChipClick(option.id); + } + }} + onCheck={(value, e) => { + e.stopPropagation(); + if (onCustomChipConfirm) { + onCustomChipConfirm(option.id, value); + } + }} + onClose={(e) => { + e.stopPropagation(); + if (onCustomChipClose) { + onCustomChipClose(option.id); + } + }} + /> + ))} + + {/* Add button - Ghost button style */} + {showAddButton && ( + + )} +
+
+ ); +} + +MultiSelectView.displayName = "MultiSelectView"; + +export default memo(MultiSelectView); diff --git a/app/components/MultiSelect/index.tsx b/app/components/MultiSelect/index.tsx new file mode 100644 index 0000000..036a4e4 --- /dev/null +++ b/app/components/MultiSelect/index.tsx @@ -0,0 +1 @@ +export { default } from "./MultiSelect.container"; diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index cbb05e5..ea8600e 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -602,3 +602,19 @@ export function normalizeChipSize( } return defaultValue; } + +/** + * Normalize MultiSelect size prop values (S/M -> s/m) + */ +export function normalizeMultiSelectSize( + value: string | undefined, + defaultValue: "m" = "m", +): "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; +}