Create chip component
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from "./Chip.container";
|
||||
export type { ChipProps } from "./Chip.types";
|
||||
|
||||
Reference in New Issue
Block a user