199 lines
6.8 KiB
TypeScript
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-right 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);
|