Create multi-select component
This commit is contained in:
@@ -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 (
|
||||
<div className="space-y-[var(--spacing-scale-016)]">
|
||||
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
|
||||
{size === "S" ? "Small (S)" : "Medium (M)"}
|
||||
</h3>
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
showHelpIcon={true}
|
||||
size={size}
|
||||
options={options}
|
||||
onChipClick={handleChipClick}
|
||||
onAddClick={handleAddClick}
|
||||
onCustomChipConfirm={handleCustomConfirm}
|
||||
onCustomChipClose={handleCustomClose}
|
||||
addButtonText="Add organization type"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComponentsPreview() {
|
||||
const [expandedCard, setExpandedCard] = useState<string | null>(null);
|
||||
const [chipStates, setChipStates] = useState<Record<string, "Unselected" | "Selected">>({
|
||||
@@ -389,6 +457,20 @@ export default function ComponentsPreview() {
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* MultiSelect Component */}
|
||||
<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)]">
|
||||
MultiSelect Component (Controls)
|
||||
</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)]">
|
||||
{/* Small size */}
|
||||
<MultiSelectExample size="S" />
|
||||
|
||||
{/* Medium size */}
|
||||
<MultiSelectExample size="M" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<MultiSelectProps>(
|
||||
({
|
||||
label,
|
||||
showHelpIcon = true,
|
||||
size: sizeProp = "M",
|
||||
options,
|
||||
onChipClick,
|
||||
onAddClick,
|
||||
showAddButton = true,
|
||||
addButtonText = "Add organization type",
|
||||
onCustomChipConfirm,
|
||||
onCustomChipClose,
|
||||
className = "",
|
||||
}) => {
|
||||
const size = normalizeMultiSelectSize(sizeProp);
|
||||
|
||||
return (
|
||||
<MultiSelectView
|
||||
label={label}
|
||||
showHelpIcon={showHelpIcon}
|
||||
size={size}
|
||||
options={options}
|
||||
onChipClick={onChipClick}
|
||||
onAddClick={onAddClick}
|
||||
showAddButton={showAddButton}
|
||||
addButtonText={addButtonText}
|
||||
onCustomChipConfirm={onCustomChipConfirm}
|
||||
onCustomChipClose={onCustomChipClose}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MultiSelectContainer.displayName = "MultiSelect";
|
||||
|
||||
export default MultiSelectContainer;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Chips container */}
|
||||
<div className={`flex flex-wrap ${gapClass} items-center relative shrink-0 w-full`}>
|
||||
{options.map((option) => (
|
||||
<Chip
|
||||
key={option.id}
|
||||
label={option.state === "Custom" ? "" : option.label}
|
||||
state={option.state || "Unselected"}
|
||||
palette="Default"
|
||||
size={chipSize}
|
||||
onClick={() => {
|
||||
// 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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
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`}
|
||||
>
|
||||
{/* Plus icon */}
|
||||
<svg
|
||||
width={isSmall ? "14" : "20"}
|
||||
height={isSmall ? "14" : "20"}
|
||||
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"
|
||||
>
|
||||
<path
|
||||
d="M7 3V11M3 7H11"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MultiSelectView.displayName = "MultiSelectView";
|
||||
|
||||
export default memo(MultiSelectView);
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./MultiSelect.container";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user