diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index aa1cd99..e74b0a6 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -2,11 +2,32 @@ import { useState } from "react"; import RuleCard from "../components/RuleCard"; +import Chip from "../components/Chip"; import Image from "next/image"; 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() { const [expandedCard, setExpandedCard] = useState(null); + const [chipStates, setChipStates] = useState>({ + "default-s": "Unselected", + "default-m": "Unselected", + "inverse-s": "Unselected", + "inverse-m": "Unselected", + }); + + // Manage custom chips separately + const [customChips, setCustomChips] = useState([ + { id: "custom-1", label: "", state: "Custom", palette: "Default", size: "S" }, + { id: "custom-2", label: "", state: "Custom", palette: "Default", size: "M" }, + ]); const sampleCategories = [ { @@ -44,10 +65,173 @@ export default function ComponentsPreview() { Component Preview

- RuleCard component examples - collapsed/expanded states, size variants, and interactions + RuleCard and Chip component examples - states, palettes, sizes, and interactions

+ {/* Chip Component - Controls */} +
+

+ Chip Component (Controls) +

+
+ {/* Default palette */} +
+

+ Default palette +

+
+ + setChipStates((prev) => ({ + ...prev, + "default-s": prev["default-s"] === "Selected" ? "Unselected" : "Selected", + })) + } + /> + + setChipStates((prev) => ({ + ...prev, + "default-m": prev["default-m"] === "Selected" ? "Unselected" : "Selected", + })) + } + /> + + {customChips + .filter((chip) => chip.palette === "Default") + .map((chip) => ( + { + 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 */} + +
+
+ + {/* Inverse palette - on white background */} +
+

+ Inverse palette (on white background) +

+
+
+ + setChipStates((prev) => ({ + ...prev, + "inverse-s": prev["inverse-s"] === "Selected" ? "Unselected" : "Selected", + })) + } + /> + + setChipStates((prev) => ({ + ...prev, + "inverse-m": prev["inverse-m"] === "Selected" ? "Unselected" : "Selected", + })) + } + /> + +
+
+
+
+
+ {/* Collapsed State - Large */}

diff --git a/app/components/Chip/Chip.container.tsx b/app/components/Chip/Chip.container.tsx new file mode 100644 index 0000000..d26acec --- /dev/null +++ b/app/components/Chip/Chip.container.tsx @@ -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( + ({ + 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(null); + + // Focus input when custom state is active + useEffect(() => { + if (isCustom && inputRef.current) { + inputRef.current.focus(); + } + }, [isCustom]); + + const handleCheck = (value: string, event: React.MouseEvent) => { + if (onCheck && value.trim()) { + onCheck(value.trim(), event); + // Reset input after successful check + setInputValue(""); + } + }; + + const handleClose = (event: React.MouseEvent) => { + 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) => { + if (event.key === "Enter" && inputValue.trim() && onCheck) { + event.preventDefault(); + handleCheck(inputValue.trim(), event as unknown as React.MouseEvent); + } else if (event.key === "Escape" && onClose) { + event.preventDefault(); + handleClose(event as unknown as React.MouseEvent); + } + }; + + return ( + + ); + }, +); + +ChipContainer.displayName = "Chip"; + +export default ChipContainer; + diff --git a/app/components/Chip/Chip.types.ts b/app/components/Chip/Chip.types.ts new file mode 100644 index 0000000..0a240b3 --- /dev/null +++ b/app/components/Chip/Chip.types.ts @@ -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) => void; + /** + * Optional remove/close handler for chips that can be dismissed. + */ + onRemove?: (event: React.MouseEvent) => 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) => void; + /** + * Optional callback when custom chip is closed/removed. + */ + onClose?: (event: React.MouseEvent) => 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) => void; + onRemove?: (event: React.MouseEvent) => void; + onCheck?: (value: string, event: React.MouseEvent) => void; + onClose?: (event: React.MouseEvent) => void; + inputValue?: string; + onInputChange?: (value: string) => void; + onInputKeyDown?: (event: React.KeyboardEvent) => void; + inputRef?: React.RefObject; + ariaLabel?: string; +} + diff --git a/app/components/Chip/Chip.view.tsx b/app/components/Chip/Chip.view.tsx new file mode 100644 index 0000000..a8a91a1 --- /dev/null +++ b/app/components/Chip/Chip.view.tsx @@ -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 = (event) => { + if (isDisabled) { + event.preventDefault(); + return; + } + onClick?.(event); + }; + + const sharedA11y = { + "aria-label": ariaLabel, + }; + + // Custom state rendering with check/close buttons + if (isCustom) { + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleClick(e as unknown as React.MouseEvent); + } + }} + {...sharedA11y} + > +
+ {/* Check button */} + {onCheck && ( + + )} + + {/* Input field */} +
+ 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()} + /> +
+ + {/* Close button */} + {onClose && ( + + )} +
+
+ ); + } + + // Regular state rendering + return ( + + )} + + ); +} + +ChipView.displayName = "ChipView"; + +export default memo(ChipView); + diff --git a/app/components/Chip/index.tsx b/app/components/Chip/index.tsx new file mode 100644 index 0000000..a6776a8 --- /dev/null +++ b/app/components/Chip/index.tsx @@ -0,0 +1,3 @@ +export { default } from "./Chip.container"; +export type { ChipProps } from "./Chip.types"; + diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index b063b73..cbb05e5 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -527,3 +527,78 @@ export function normalizeRuleCardSize( } 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; +}