331 lines
9.7 KiB
TypeScript
331 lines
9.7 KiB
TypeScript
"use client";
|
||
|
||
import { memo, type CSSProperties } from "react";
|
||
import type { ChipViewProps } from "./Chip.types";
|
||
|
||
function chipSurfaceStyle(
|
||
state: ChipViewProps["state"],
|
||
palette: ChipViewProps["palette"],
|
||
size: ChipViewProps["size"],
|
||
): CSSProperties {
|
||
const borderWidth = size === "s" ? "1.25px" : "2px";
|
||
const reset: CSSProperties = {
|
||
appearance: "none",
|
||
WebkitAppearance: "none",
|
||
backgroundImage: "none",
|
||
};
|
||
|
||
const paint = (
|
||
backgroundColor: string,
|
||
color: string,
|
||
borderColor?: string,
|
||
): CSSProperties => ({
|
||
...reset,
|
||
backgroundColor,
|
||
color,
|
||
WebkitTextFillColor: color,
|
||
...(borderColor
|
||
? { borderWidth, borderStyle: "solid", borderColor }
|
||
: { borderWidth: 0, borderStyle: "none", borderColor: "transparent" }),
|
||
});
|
||
|
||
if (palette === "inverse") {
|
||
if (state === "disabled") {
|
||
return paint(
|
||
"var(--color-surface-inverse-tertiary)",
|
||
"var(--color-content-inverse-primary)",
|
||
);
|
||
}
|
||
if (state === "selected") {
|
||
return paint(
|
||
"var(--color-surface-default-semi-opaque)",
|
||
"var(--color-content-inverse-primary)",
|
||
"var(--color-border-default-primary)",
|
||
);
|
||
}
|
||
return paint(
|
||
"transparent",
|
||
"var(--color-content-inverse-primary)",
|
||
"var(--color-border-default-primary)",
|
||
);
|
||
}
|
||
|
||
if (state === "custom") {
|
||
return paint(
|
||
"var(--color-surface-default-secondary)",
|
||
"var(--color-content-default-tertiary)",
|
||
);
|
||
}
|
||
if (state === "disabled") {
|
||
return paint(
|
||
"var(--color-surface-default-secondary)",
|
||
"var(--color-content-invert-tertiary)",
|
||
);
|
||
}
|
||
if (state === "selected") {
|
||
return paint(
|
||
"var(--color-surface-invert-brand-primary, #fefcc9)",
|
||
"var(--color-content-invert-primary, #000)",
|
||
"var(--color-border-default-brand-primary, #fdfaa8)",
|
||
);
|
||
}
|
||
return paint(
|
||
"transparent",
|
||
"var(--color-content-default-brand-primary, #fefcc9)",
|
||
"var(--color-border-default-tertiary, #464646)",
|
||
);
|
||
}
|
||
|
||
function ChipView({
|
||
label,
|
||
state,
|
||
palette,
|
||
size,
|
||
className = "",
|
||
disabled = false,
|
||
onClick,
|
||
onRemove,
|
||
onCheck,
|
||
onClose,
|
||
inputValue,
|
||
onInputChange,
|
||
onInputKeyDown,
|
||
inputRef,
|
||
ariaLabel,
|
||
confirmAriaLabel,
|
||
typeToAddPlaceholder,
|
||
closeAriaLabel,
|
||
}: ChipViewProps) {
|
||
// The container is the source of truth for `disabled`. `state="disabled"`
|
||
// is the non-interactive visual only — toggle groups use selected/unselected.
|
||
const isDisabled = disabled ?? false;
|
||
const isSelected = state === "selected";
|
||
const isCustom = state === "custom";
|
||
const isToggle = !isCustom;
|
||
|
||
const isSmall = size === "s";
|
||
const surfaceStyle = chipSurfaceStyle(state, palette, size);
|
||
|
||
// Size-based styles from Figma tokens
|
||
// Custom state has different padding
|
||
const sizeClasses = isCustom
|
||
? isSmall
|
||
? "px-[var(--measures-spacing-100,4px)] py-[3px] text-x-small-paragraph"
|
||
: "px-[var(--measures-spacing-150,6px)] py-[10px] text-medium-paragraph"
|
||
: isSmall
|
||
? "h-[30px] px-[var(--measures-spacing-200,8px)] gap-[var(--measures-spacing-050,2px)] text-x-small-label"
|
||
: "px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-300,12px)] gap-[var(--measures-spacing-150,6px)] text-medium-label";
|
||
|
||
const baseClasses = `
|
||
appearance-none
|
||
inline-flex
|
||
max-w-full
|
||
items-center
|
||
justify-center
|
||
rounded-[var(--measures-radius-full,9999px)]
|
||
overflow-clip
|
||
box-border
|
||
focus:outline-none
|
||
focus-visible:ring-2
|
||
focus-visible:ring-[var(--color-border-default-primary)]
|
||
focus-visible:ring-offset-2
|
||
focus-visible:ring-offset-transparent
|
||
transition-[background,border-color,color,box-shadow]
|
||
duration-200
|
||
ease-in-out
|
||
`
|
||
.trim()
|
||
.replace(/\s+/g, " ");
|
||
|
||
const stateClasses = isDisabled
|
||
? "cursor-not-allowed opacity-60"
|
||
: "cursor-pointer";
|
||
|
||
const combinedClasses = [baseClasses, sizeClasses, stateClasses, className]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
|
||
const handleClick = (
|
||
event: React.MouseEvent<HTMLButtonElement | HTMLDivElement>,
|
||
) => {
|
||
if (isDisabled) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
onClick?.(event as React.MouseEvent<HTMLButtonElement>);
|
||
};
|
||
|
||
const sharedA11y = {
|
||
"aria-label": ariaLabel,
|
||
...(isToggle ? { "aria-pressed": isSelected } : {}),
|
||
};
|
||
|
||
// Custom state rendering with check/close buttons
|
||
if (isCustom) {
|
||
return (
|
||
<div
|
||
className={combinedClasses}
|
||
style={surfaceStyle}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={handleClick}
|
||
onKeyDown={(e) => {
|
||
if (e.target instanceof HTMLInputElement) return;
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
handleClick(e as unknown as React.MouseEvent<HTMLButtonElement>);
|
||
}
|
||
}}
|
||
{...sharedA11y}
|
||
>
|
||
<div
|
||
className={`flex items-center ${isSmall ? "gap-[8px]" : "gap-[12px]"}`}
|
||
>
|
||
{/* Check button */}
|
||
{onCheck && (
|
||
<button
|
||
type="button"
|
||
className="flex items-center justify-center p-[var(--measures-spacing-150,6px)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.1))] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
aria-label={confirmAriaLabel}
|
||
disabled={!inputValue || !inputValue.trim()}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
// The container's handleCheck will get the value from state
|
||
if (inputValue && inputValue.trim() && onCheck) {
|
||
onCheck(inputValue.trim(), event);
|
||
}
|
||
}}
|
||
>
|
||
<svg
|
||
width="12"
|
||
height="12"
|
||
viewBox="0 0 12 12"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
|
||
>
|
||
<path
|
||
d="M10 3L4.5 8.5L2 6"
|
||
stroke="currentColor"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
|
||
{/* Input field */}
|
||
<div className="flex items-center flex-1 min-w-0">
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={inputValue ?? ""}
|
||
onChange={(e) => onInputChange?.(e.target.value)}
|
||
onKeyDown={onInputKeyDown}
|
||
placeholder={typeToAddPlaceholder}
|
||
className="bg-transparent border-none outline-none flex-1 min-w-0 font-normal text-[color:var(--color-content-default-tertiary,#b4b4b4)] placeholder:text-[color:var(--color-content-default-tertiary,#b4b4b4)]"
|
||
style={{
|
||
fontSize: isSmall
|
||
? "var(--sizing-300,12px)"
|
||
: "var(--sizing-400,16px)",
|
||
lineHeight: isSmall ? "16px" : "24px",
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onFocus={(e) => e.stopPropagation()}
|
||
/>
|
||
</div>
|
||
|
||
{/* Close button */}
|
||
{onClose && (
|
||
<button
|
||
type="button"
|
||
className="flex items-center justify-center p-[var(--measures-spacing-150,6px)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.1))] transition-colors"
|
||
aria-label={closeAriaLabel}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onClose(event);
|
||
}}
|
||
>
|
||
<svg
|
||
width="12"
|
||
height="12"
|
||
viewBox="0 0 12 12"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
|
||
>
|
||
<path
|
||
d="M9 3L3 9M3 3L9 9"
|
||
stroke="currentColor"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Regular state rendering
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={combinedClasses}
|
||
style={surfaceStyle}
|
||
disabled={isDisabled}
|
||
onClick={handleClick}
|
||
{...sharedA11y}
|
||
>
|
||
{isSelected ? (
|
||
<svg
|
||
aria-hidden
|
||
viewBox="0 0 12 12"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
className={`shrink-0 ${isSmall ? "size-[12px]" : "size-[16px]"}`}
|
||
>
|
||
<path
|
||
d="M10 3L4.5 8.5L2 6"
|
||
stroke="currentColor"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
) : null}
|
||
<span
|
||
className="min-w-0 truncate"
|
||
style={{
|
||
color: surfaceStyle.color,
|
||
WebkitTextFillColor: surfaceStyle.WebkitTextFillColor,
|
||
}}
|
||
>
|
||
{label}
|
||
</span>
|
||
{onRemove && !isDisabled && (
|
||
<button
|
||
type="button"
|
||
className="ml-[var(--measures-spacing-050,2px)] p-[var(--measures-spacing-050,2px)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.05))]"
|
||
aria-label={`Remove ${label}`}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onRemove(event);
|
||
}}
|
||
>
|
||
<span className="block w-[12px] h-[12px] leading-none text-[10px]">
|
||
×
|
||
</span>
|
||
</button>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
ChipView.displayName = "ChipView";
|
||
|
||
export default memo(ChipView);
|