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 && (
+
+
})
+
+ )}
+
+
+ )}
+
+ {/* 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;
+}