Files
community-rule/app/components/controls/MultiSelect/MultiSelect.view.tsx
T

199 lines
6.8 KiB
TypeScript

"use client";
import { memo } from "react";
import Chip from "../Chip";
import InputLabel from "../../type/InputLabel";
import type { MultiSelectViewProps } from "./MultiSelect.types";
function MultiSelectView({
label,
showHelpIcon,
size,
palette,
options,
onChipClick,
onAddClick,
addButton,
addButtonText,
addButtonAriaLabel,
chipSelectAriaLabel,
chipDeselectAriaLabel,
formHeader = true,
helperText: helperTextProp,
errorMessage,
onCustomChipConfirm,
onCustomChipClose,
maxSelections,
selectionCountText,
limitReachedAnnouncement,
className,
}: MultiSelectViewProps) {
const isSmall = size === "s";
const isInverse = palette === "inverse";
// Size-based spacing
const gapClass = isSmall
? "gap-[var(--space-200)]"
: "gap-[var(--space-300)]";
const chipSize = size;
const selectedCount = options.filter((o) => o.state === "selected").length;
const atLimit =
maxSelections != null && selectedCount >= maxSelections;
const countCaption = selectionCountText?.trim() ?? "";
const headerHelper =
helperTextProp?.trim() ||
(countCaption.length > 0 ? countCaption : false);
return (
<div
className={`flex flex-col ${isSmall ? "gap-[var(--space-200)]" : "gap-[var(--space-300)]"} items-start relative w-full ${className}`}
>
{/* Label using InputLabel component */}
{formHeader && label && (
<InputLabel
label={label}
helpIcon={showHelpIcon}
asterisk={false}
helperText={headerHelper}
size={size}
palette={palette}
/>
)}
{!formHeader || !label ? (
headerHelper && typeof headerHelper === "string" ? (
<p className="w-full text-x-small-paragraph text-[color:var(--color-content-default-tertiary)]">
{headerHelper}
</p>
) : null
) : null}
{limitReachedAnnouncement ? (
<p className="sr-only" aria-live="polite">
{atLimit ? limitReachedAnnouncement : ""}
</p>
) : null}
{/* Chips container */}
<div
className={`flex flex-wrap ${gapClass} items-center relative shrink-0 w-full`}
>
{options.map((option) => {
const isCustom = option.state === "custom";
const isSelected = option.state === "selected";
const chipDisabled = !isCustom && !isSelected && atLimit;
const chipState = chipDisabled
? "disabled"
: option.state || "unselected";
return (
<Chip
key={option.id}
label={isCustom ? "" : option.label}
state={chipState}
palette={palette}
size={chipSize}
disabled={chipDisabled}
ariaLabel={
isCustom || !option.label
? undefined
: (isSelected
? chipDeselectAriaLabel
: chipSelectAriaLabel
).replace("{label}", option.label)
}
onClick={() => {
if (!isCustom && !chipDisabled && 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 — icon-only: bordered circle + brand icon (chips stay yellow). With label: Figma 19688:38288 — brand + icon, primary label text, no fill/border. */}
{addButton && (
<button
type="button"
aria-label={addButtonAriaLabel}
disabled={atLimit}
onClick={(e) => {
e.stopPropagation();
if (atLimit) return;
onAddClick?.();
}}
className={
!addButtonText
? // Circular button with border (Rule style)
`${atLimit ? "cursor-not-allowed opacity-60" : "cursor-pointer hover:opacity-80"} bg-[var(--color-surface-default-transparent)] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary)]" : "border-[var(--color-border-default-control)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--radius-full)] shrink-0 transition-opacity`
: // Text add control (default palette: white label + brand “+”; inverse: inverse primary for both)
`${atLimit ? "cursor-not-allowed opacity-60" : "cursor-pointer hover:opacity-80"} flex items-center justify-center overflow-hidden rounded-[var(--radius-full)] shrink-0 transition-opacity ${
isSmall
? "gap-[var(--space-100)] px-[var(--space-300)] py-[var(--space-200)]"
: "gap-[var(--space-150)] px-[var(--space-400)] py-[var(--space-300)]"
}`
}
>
{/* Plus icon — brand accent; selection chips keep full yellow fill separately */}
<svg
width={isSmall ? "14" : "20"}
height={isSmall ? "14" : "20"}
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`shrink-0 ${
!addButtonText && isInverse
? "text-[var(--color-content-invert-primary)]"
: "text-[var(--color-content-default-brand-primary)]"
}`}
>
<path
d="M7 3V11M3 7H11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{addButtonText && (
<span
className={`font-medium ${isSmall ? "text-[length:var(--sizing-300)] leading-[14px]" : "text-medium-label"} ${
isInverse
? "text-[color:var(--color-content-invert-primary)]"
: "text-[color:var(--color-content-default-primary)]"
}`}
>
{addButtonText}
</span>
)}
</button>
)}
</div>
{errorMessage ? (
<p
className="w-full text-x-small-paragraph text-[color:var(--color-content-default-negative-primary)]"
role="alert"
>
{errorMessage}
</p>
) : null}
</div>
);
}
MultiSelectView.displayName = "MultiSelectView";
export default memo(MultiSelectView);