Create chip component

This commit is contained in:
adilallo
2026-02-05 09:02:15 -07:00
parent 769fc8e7c6
commit 8ba11070d3
6 changed files with 719 additions and 1 deletions
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { memo, useState, useEffect, useRef } from "react";
import ChipView from "./Chip.view";
import type { ChipProps } from "./Chip.types";
import {
normalizeChipPalette,
normalizeChipSize,
normalizeChipState,
} from "../../../lib/propNormalization";
const ChipContainer = memo<ChipProps>(
({
label,
state: stateProp = "Unselected",
palette: paletteProp = "Default",
size: sizeProp = "S",
className = "",
disabled,
onClick,
onRemove,
onCheck,
onClose,
ariaLabel,
}) => {
const state = normalizeChipState(stateProp);
const palette = normalizeChipPalette(paletteProp);
const size = normalizeChipSize(sizeProp);
const isDisabled = disabled ?? state === "disabled";
const isCustom = state === "custom";
// Manage input value for custom state
const [inputValue, setInputValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
// Focus input when custom state is active
useEffect(() => {
if (isCustom && inputRef.current) {
inputRef.current.focus();
}
}, [isCustom]);
const handleCheck = (value: string, event: React.MouseEvent<HTMLButtonElement>) => {
if (onCheck && value.trim()) {
onCheck(value.trim(), event);
// Reset input after successful check
setInputValue("");
}
};
const handleClose = (event: React.MouseEvent<HTMLButtonElement>) => {
if (onClose) {
onClose(event);
} else if (onRemove) {
// Fallback to onRemove if onClose not provided
onRemove(event);
}
// Reset input value when closing
setInputValue("");
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" && inputValue.trim() && onCheck) {
event.preventDefault();
handleCheck(inputValue.trim(), event as unknown as React.MouseEvent<HTMLButtonElement>);
} else if (event.key === "Escape" && onClose) {
event.preventDefault();
handleClose(event as unknown as React.MouseEvent<HTMLButtonElement>);
}
};
return (
<ChipView
label={label}
state={state}
palette={palette}
size={size}
className={className}
disabled={isDisabled}
onClick={onClick}
onRemove={onRemove}
onCheck={handleCheck}
onClose={handleClose}
inputValue={isCustom ? inputValue : undefined}
onInputChange={isCustom ? setInputValue : undefined}
onInputKeyDown={isCustom ? handleKeyDown : undefined}
inputRef={isCustom ? inputRef : undefined}
ariaLabel={ariaLabel}
/>
);
},
);
ChipContainer.displayName = "Chip";
export default ChipContainer;
+71
View File
@@ -0,0 +1,71 @@
import type {
ChipPaletteValue,
ChipSizeValue,
ChipStateValue,
} from "../../../lib/propNormalization";
export interface ChipProps {
label: string;
/**
* Visual state of the chip, aligned with Figma:
* - "Unselected"
* - "Selected"
* - "Disabled"
* - "Custom" (editable chips with check/close buttons)
*
* Accepts both PascalCase (Figma) and lowercase values.
*/
state?: ChipStateValue;
/**
* Palette of the chip, aligned with Figma:
* - "Default"
* - "Inverse"
*
* Accepts both PascalCase (Figma) and lowercase values.
*/
palette?: ChipPaletteValue;
/**
* Size of the chip, aligned with Figma:
* - "S"
* - "M"
*
* Accepts both uppercase (Figma) and lowercase values.
*/
size?: ChipSizeValue;
className?: string;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
/**
* Optional remove/close handler for chips that can be dismissed.
*/
onRemove?: (event: React.MouseEvent<HTMLButtonElement>) => void;
/**
* Optional check/confirm handler for custom state chips.
* Called with the input value when user confirms the input.
*/
onCheck?: (value: string, event: React.MouseEvent<HTMLButtonElement>) => void;
/**
* Optional callback when custom chip is closed/removed.
*/
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void;
ariaLabel?: string;
}
export interface ChipViewProps {
label: string;
state: "unselected" | "selected" | "disabled" | "custom";
palette: "default" | "inverse";
size: "s" | "m";
className?: string;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onRemove?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onCheck?: (value: string, event: React.MouseEvent<HTMLButtonElement>) => void;
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void;
inputValue?: string;
onInputChange?: (value: string) => void;
onInputKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
inputRef?: React.RefObject<HTMLInputElement>;
ariaLabel?: string;
}
+287
View File
@@ -0,0 +1,287 @@
"use client";
import { memo } from "react";
import type { ChipViewProps } from "./Chip.types";
function ChipView({
label,
state,
palette,
size,
className = "",
disabled = false,
onClick,
onRemove,
onCheck,
onClose,
inputValue,
onInputChange,
onInputKeyDown,
inputRef,
ariaLabel,
}: ChipViewProps) {
const isDisabled = disabled || state === "disabled";
const isSelected = state === "selected";
const isCustom = state === "custom";
const isInverse = palette === "inverse";
const isDefault = palette === "default";
const isSmall = size === "s";
// Size-based styles from Figma tokens
// Custom state has different padding
const sizeClasses = isCustom
? isSmall
? "px-[var(--measures-spacing-100,4px)] py-[3px] text-[length:var(--sizing-300,12px)] leading-[16px]"
: "px-[var(--measures-spacing-150,6px)] py-[10px] text-[length:var(--sizing-400,16px)] leading-[24px]"
: isSmall
? "h-[30px] px-[var(--measures-spacing-200,8px)] gap-[var(--measures-spacing-050,2px)] text-[length:var(--sizing-300,12px)] leading-[14px]"
: "px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-300,12px)] gap-[var(--measures-spacing-150,6px)] text-[length:var(--sizing-400,16px)] leading-[20px]";
// Palette + state styling based on Figma examples
// Use consistent border width to prevent layout shift
const borderWidth = isSmall ? "border-[1.25px]" : "border-2";
let background = "bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
let border =
`${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`;
let textColor =
"text-[color:var(--color-content-default-brand-primary,#fefcc9)]";
if (isDefault) {
if (state === "custom") {
background =
"bg-[var(--color-surface-default-secondary,#141414)]"; // dark background for custom
border = "border-none";
textColor =
"text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
} else if (state === "disabled") {
background =
"bg-[var(--color-surface-default-secondary,#141414)]"; // dark background
border = "border-none";
textColor =
"text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
} else if (isSelected) {
background =
"bg-[var(--color-surface-inverse-brandaccent,#fdfaa8)]"; // yellow selected
border = `${borderWidth} border-[var(--color-border-default-brand-primary,#fdfaa8)]`;
textColor =
"text-[color:var(--color-content-inverse-primary,black)]";
} else {
// Unselected default
background =
"bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
border = `${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`;
textColor =
"text-[color:var(--color-content-default-brand-primary,#fefcc9)]";
}
} else if (isInverse) {
if (state === "disabled") {
background =
"bg-[var(--color-surface-inverse-tertiary,#d2d2d2)]";
border = "border-none";
textColor =
"text-[color:var(--color-content-inverse-primary,black)]";
} else if (isSelected) {
background =
"bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.05))]";
border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`;
textColor =
"text-[color:var(--color-content-inverse-primary,black)]";
} else {
// Unselected / custom inverse
background =
"bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`;
textColor =
"text-[color:var(--color-content-inverse-primary,black)]";
}
}
const baseClasses = `
inline-flex
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,#141414)]
focus-visible:ring-offset-2
focus-visible:ring-offset-transparent
transition-[background,border-color,color,box-shadow,transform]
duration-200
ease-in-out
`
.trim()
.replace(/\s+/g, " ");
const stateClasses = isDisabled
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:scale-[1.02]";
const combinedClasses = [
baseClasses,
sizeClasses,
background,
border,
textColor,
stateClasses,
className,
]
.filter(Boolean)
.join(" ");
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
if (isDisabled) {
event.preventDefault();
return;
}
onClick?.(event);
};
const sharedA11y = {
"aria-label": ariaLabel,
};
// Custom state rendering with check/close buttons
if (isCustom) {
return (
<div
className={combinedClasses}
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
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="Confirm"
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="Type to add"
className="bg-transparent border-none outline-none flex-1 min-w-0 font-inter 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="Close"
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}
disabled={isDisabled}
onClick={handleClick}
{...sharedA11y}
>
<span className="flex items-center justify-center">
{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);
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./Chip.container";
export type { ChipProps } from "./Chip.types";