Files
community-rule/app/components/controls/MultiSelect/MultiSelect.view.tsx
T
adilallo ca928e306b Match the 3 Nov color ramps and size-based strokes, and replace leftover palette hex with tokens.
Drop Cookies Settings from the footer, link the Home features that already have pages, and give leftover routes their own descriptions.
2026-11-05 15:37:55 -07:00

187 lines
6.4 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,
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 helperText = 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={helperText}
size={size}
palette={palette}
/>
)}
{!formHeader || !label ? (
countCaption.length > 0 ? (
<p className="w-full text-x-small-paragraph text-[color:var(--color-content-default-tertiary)]">
{countCaption}
</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>
</div>
);
}
MultiSelectView.displayName = "MultiSelectView";
export default memo(MultiSelectView);