Create InputLabel component and add RuleCard chips
This commit is contained in:
+341
-26
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user