Create chip component
This commit is contained in:
@@ -2,11 +2,32 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import RuleCard from "../components/RuleCard";
|
import RuleCard from "../components/RuleCard";
|
||||||
|
import Chip from "../components/Chip";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { getAssetPath } from "../../lib/assetUtils";
|
import { getAssetPath } from "../../lib/assetUtils";
|
||||||
|
|
||||||
|
interface ChipData {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
state: "Unselected" | "Selected" | "Custom";
|
||||||
|
palette: "Default" | "Inverse";
|
||||||
|
size: "S" | "M";
|
||||||
|
}
|
||||||
|
|
||||||
export default function ComponentsPreview() {
|
export default function ComponentsPreview() {
|
||||||
const [expandedCard, setExpandedCard] = useState<string | null>(null);
|
const [expandedCard, setExpandedCard] = useState<string | null>(null);
|
||||||
|
const [chipStates, setChipStates] = useState<Record<string, "Unselected" | "Selected">>({
|
||||||
|
"default-s": "Unselected",
|
||||||
|
"default-m": "Unselected",
|
||||||
|
"inverse-s": "Unselected",
|
||||||
|
"inverse-m": "Unselected",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manage custom chips separately
|
||||||
|
const [customChips, setCustomChips] = useState<ChipData[]>([
|
||||||
|
{ id: "custom-1", label: "", state: "Custom", palette: "Default", size: "S" },
|
||||||
|
{ id: "custom-2", label: "", state: "Custom", palette: "Default", size: "M" },
|
||||||
|
]);
|
||||||
|
|
||||||
const sampleCategories = [
|
const sampleCategories = [
|
||||||
{
|
{
|
||||||
@@ -44,10 +65,173 @@ export default function ComponentsPreview() {
|
|||||||
Component Preview
|
Component Preview
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-inter text-[18px] leading-[24px] text-[var(--color-content-default-secondary)]">
|
<p className="font-inter text-[18px] leading-[24px] text-[var(--color-content-default-secondary)]">
|
||||||
RuleCard component examples - collapsed/expanded states, size variants, and interactions
|
RuleCard and Chip component examples - states, palettes, sizes, and interactions
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Chip Component - Controls */}
|
||||||
|
<section className="space-y-[var(--spacing-scale-024)]">
|
||||||
|
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
|
||||||
|
Chip Component (Controls)
|
||||||
|
</h2>
|
||||||
|
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
|
||||||
|
{/* Default palette */}
|
||||||
|
<div className="space-y-[var(--spacing-scale-016)]">
|
||||||
|
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
|
||||||
|
Default palette
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap items-center gap-[var(--spacing-scale-016)]">
|
||||||
|
<Chip
|
||||||
|
label="Small"
|
||||||
|
state={chipStates["default-s"]}
|
||||||
|
palette="Default"
|
||||||
|
size="S"
|
||||||
|
onClick={() =>
|
||||||
|
setChipStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
"default-s": prev["default-s"] === "Selected" ? "Unselected" : "Selected",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label="Medium"
|
||||||
|
state={chipStates["default-m"]}
|
||||||
|
palette="Default"
|
||||||
|
size="M"
|
||||||
|
onClick={() =>
|
||||||
|
setChipStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
"default-m": prev["default-m"] === "Selected" ? "Unselected" : "Selected",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label="Disabled"
|
||||||
|
state="Disabled"
|
||||||
|
palette="Default"
|
||||||
|
size="S"
|
||||||
|
/>
|
||||||
|
{customChips
|
||||||
|
.filter((chip) => chip.palette === "Default")
|
||||||
|
.map((chip) => (
|
||||||
|
<Chip
|
||||||
|
key={chip.id}
|
||||||
|
label={chip.state === "Custom" ? "" : chip.label}
|
||||||
|
state={chip.state}
|
||||||
|
palette={chip.palette}
|
||||||
|
size={chip.size}
|
||||||
|
onCheck={(value, e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCustomChips((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.id === chip.id
|
||||||
|
? { ...c, label: value, state: "Selected" as const }
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onClose={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCustomChips((prev) => prev.filter((c) => c.id !== chip.id));
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Only toggle if the chip is in Selected or Unselected state (not Custom)
|
||||||
|
if (chip.state === "Selected" || chip.state === "Unselected") {
|
||||||
|
setCustomChips((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.id === chip.id
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
state: c.state === "Selected" ? ("Unselected" as const) : ("Selected" as const),
|
||||||
|
}
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Add new custom chip button - Ghost button style */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newId = `custom-${Date.now()}`;
|
||||||
|
setCustomChips((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: newId, label: "", state: "Custom", palette: "Default", size: "S" },
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
className="flex gap-[var(--measures-spacing-050,2px)] items-center justify-center p-[var(--measures-spacing-200,8px)] rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
{/* Plus icon */}
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="text-[var(--color-content-default-brand-primary,#fefcc9)] shrink-0"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M7 3V11M3 7H11"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* Text */}
|
||||||
|
<span className="font-inter font-medium text-[length:var(--sizing-300,12px)] leading-[14px] text-[color:var(--color-content-default-brand-primary,#fefcc9)]">
|
||||||
|
Add Applicable Scope
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inverse palette - on white background */}
|
||||||
|
<div className="space-y-[var(--spacing-scale-016)]">
|
||||||
|
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
|
||||||
|
Inverse palette (on white background)
|
||||||
|
</h3>
|
||||||
|
<div className="!bg-white p-[var(--spacing-scale-032)] rounded-[var(--radius-300,12px)]" style={{ backgroundColor: '#ffffff' }}>
|
||||||
|
<div className="flex flex-wrap items-center gap-[var(--spacing-scale-016)]">
|
||||||
|
<Chip
|
||||||
|
label="Small"
|
||||||
|
state={chipStates["inverse-s"]}
|
||||||
|
palette="Inverse"
|
||||||
|
size="S"
|
||||||
|
onClick={() =>
|
||||||
|
setChipStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
"inverse-s": prev["inverse-s"] === "Selected" ? "Unselected" : "Selected",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label="Medium"
|
||||||
|
state={chipStates["inverse-m"]}
|
||||||
|
palette="Inverse"
|
||||||
|
size="M"
|
||||||
|
onClick={() =>
|
||||||
|
setChipStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
"inverse-m": prev["inverse-m"] === "Selected" ? "Unselected" : "Selected",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label="Disabled"
|
||||||
|
state="Disabled"
|
||||||
|
palette="Inverse"
|
||||||
|
size="S"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Collapsed State - Large */}
|
{/* Collapsed State - Large */}
|
||||||
<section className="space-y-[var(--spacing-scale-024)]">
|
<section className="space-y-[var(--spacing-scale-024)]">
|
||||||
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
|
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -527,3 +527,78 @@ export function normalizeRuleCardSize(
|
|||||||
}
|
}
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type helper for case-insensitive Chip state prop
|
||||||
|
*/
|
||||||
|
export type ChipStateValue =
|
||||||
|
| "unselected"
|
||||||
|
| "selected"
|
||||||
|
| "disabled"
|
||||||
|
| "custom"
|
||||||
|
| "Unselected"
|
||||||
|
| "Selected"
|
||||||
|
| "Disabled"
|
||||||
|
| "Custom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type helper for case-insensitive Chip palette prop
|
||||||
|
*/
|
||||||
|
export type ChipPaletteValue =
|
||||||
|
| "default"
|
||||||
|
| "inverse"
|
||||||
|
| "Default"
|
||||||
|
| "Inverse";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type helper for case-insensitive Chip size prop
|
||||||
|
*/
|
||||||
|
export type ChipSizeValue = "s" | "m" | "S" | "M";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize Chip state prop values (Unselected/Selected/Disabled/Custom)
|
||||||
|
*/
|
||||||
|
export function normalizeChipState(
|
||||||
|
value: string | undefined,
|
||||||
|
defaultValue: "unselected" = "unselected",
|
||||||
|
): "unselected" | "selected" | "disabled" | "custom" {
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
const states = ["unselected", "selected", "disabled", "custom"];
|
||||||
|
if (states.includes(normalized)) {
|
||||||
|
return normalized as typeof defaultValue;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize Chip palette prop values (Default/Inverse -> default/inverse)
|
||||||
|
*/
|
||||||
|
export function normalizeChipPalette(
|
||||||
|
value: string | undefined,
|
||||||
|
defaultValue: "default" = "default",
|
||||||
|
): "default" | "inverse" {
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
const palettes = ["default", "inverse"];
|
||||||
|
if (palettes.includes(normalized)) {
|
||||||
|
return normalized as typeof defaultValue;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize Chip size prop values (S/M -> s/m)
|
||||||
|
*/
|
||||||
|
export function normalizeChipSize(
|
||||||
|
value: string | undefined,
|
||||||
|
defaultValue: "s" = "s",
|
||||||
|
): "s" | "m" {
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
const sizes = ["s", "m"];
|
||||||
|
if (sizes.includes(normalized)) {
|
||||||
|
return normalized as typeof defaultValue;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user