Start organizational migration

This commit is contained in:
adilallo
2026-02-05 18:21:56 -07:00
parent 69074b23f3
commit db3c0274f6
161 changed files with 145 additions and 145 deletions
@@ -0,0 +1,134 @@
"use client";
import { memo } from "react";
import { useComponentId } from "../../../hooks";
import { CheckboxView } from "./Checkbox.view";
import type { CheckboxProps } from "./Checkbox.types";
import { normalizeMode, normalizeState } from "../../../../lib/propNormalization";
const CheckboxContainer = memo<CheckboxProps>(
({
checked = false,
mode: modeProp = "standard",
state: stateProp = "default",
disabled = false,
label,
className = "",
onChange,
id,
name,
value,
ariaLabel,
...props
}) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const mode = normalizeMode(modeProp);
const state = normalizeState(stateProp);
const isInverse = mode === "inverse";
const isStandard = mode === "standard";
// Generate unique ID for accessibility if not provided
const { id: checkboxId, labelId } = useComponentId("checkbox", id);
// Base box styles per Figma
const baseBox = `
flex
items-center
justify-center
shrink-0
w-[24px]
h-[24px]
rounded-[4px]
transition-all
duration-200
ease-in-out
`.trim().replace(/\s+/g, " ");
// Get box styles based on state and checked status per Figma designs
const getBoxStyles = (): string => {
// Standard mode styles
if (isStandard) {
// Default state: tertiary border, with hover and focus states via CSS
// Hover changes border to brand primary color
// Focus removes border and shows shadow (double ring: 2px white inner, 4px dark outer)
return `${baseBox} bg-[var(--color-surface-default-primary)] border border-solid border-[var(--color-border-default-tertiary,#464646)] hover:border-[var(--color-border-default-brand-primary,#fdfaa8)] focus:border-transparent focus:shadow-[0px_0px_0px_2px_var(--color-border-invert-primary,white),0px_0px_0px_4px_var(--color-border-default-primary,#141414)] focus:outline-none`;
}
// Inverse mode styles per Figma
if (isInverse) {
// Inverse: transparent background, white border
// Hover changes border to brand primary color
// Focus shows shadow (2px dark inner, 4px white outer) - note: reversed from standard
return `${baseBox} bg-transparent border border-solid border-[var(--color-border-invert-primary,white)] hover:border-[var(--color-border-default-brand-primary,#fdfaa8)] focus:shadow-[0px_0px_0px_2px_var(--color-border-default-primary,#141414),0px_0px_0px_4px_var(--color-border-invert-primary,white)] focus:outline-none`;
}
return baseBox;
};
const combinedBoxStyles = getBoxStyles();
// Checkmark color per Figma
const checkGlyphColor = checked
? isStandard
? "var(--color-content-default-brand-primary, #fefcc9)" // Light yellow/cream for standard mode
: "var(--color-content-inverse-primary, #000000)" // Black for inverse mode
: "transparent";
// Label color
const labelColor = isInverse
? "var(--color-content-inverse-primary)"
: "var(--color-content-default-primary)";
const handleToggle = (e: React.MouseEvent | React.KeyboardEvent) => {
if (disabled) return;
onChange?.({
checked: !checked,
value,
event: e,
});
};
const accessibilityProps = {
role: "checkbox" as const,
"aria-checked": checked,
...(disabled && { "aria-disabled": true, tabIndex: -1 }),
...(!disabled && { tabIndex: 0 }),
...(ariaLabel && { "aria-label": ariaLabel }),
...(label && !ariaLabel && { "aria-labelledby": labelId }),
id: checkboxId,
...props,
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleToggle(e);
}
};
return (
<CheckboxView
labelId={labelId}
checked={checked}
mode={mode}
state={state}
disabled={disabled}
label={label}
name={name}
value={value}
className={className}
combinedBoxStyles={combinedBoxStyles}
checkGlyphColor={checkGlyphColor}
labelColor={labelColor}
accessibilityProps={accessibilityProps}
onToggle={handleToggle}
onKeyDown={handleKeyDown}
/>
);
},
);
CheckboxContainer.displayName = "Checkbox";
export default CheckboxContainer;
@@ -0,0 +1,45 @@
import type { ModeValue, StateValue } from "../../../../lib/propNormalization";
export interface CheckboxProps {
checked?: boolean;
/**
* Mode variant. Accepts both "standard"/"Standard" and "inverse"/"Inverse" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
mode?: ModeValue;
/**
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
state?: StateValue;
disabled?: boolean;
label?: string;
className?: string;
onChange?: (_data: {
checked: boolean;
value?: string;
event: React.MouseEvent | React.KeyboardEvent;
}) => void;
id?: string;
name?: string;
value?: string;
ariaLabel?: string;
}
export interface CheckboxViewProps {
labelId: string;
checked: boolean;
mode: "standard" | "inverse";
state: "default" | "hover" | "focus" | "selected";
disabled: boolean;
label?: string;
name?: string;
value?: string;
className: string;
combinedBoxStyles: string;
checkGlyphColor: string;
labelColor: string;
accessibilityProps: React.HTMLAttributes<HTMLSpanElement>;
onToggle: (_e: React.MouseEvent | React.KeyboardEvent) => void;
onKeyDown: (_e: React.KeyboardEvent<HTMLSpanElement>) => void;
}
@@ -0,0 +1,74 @@
import type { CheckboxViewProps } from "./Checkbox.types";
export function CheckboxView({
labelId,
checked,
disabled,
label,
name,
value,
className,
combinedBoxStyles,
checkGlyphColor,
labelColor,
accessibilityProps,
onToggle,
onKeyDown,
}: CheckboxViewProps) {
return (
<label
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${
disabled ? "opacity-60 cursor-not-allowed" : ""
} ${className}`}
onMouseDown={(e) => e.preventDefault()}
>
<span
{...accessibilityProps}
onClick={onToggle}
onKeyDown={onKeyDown}
className={`${combinedBoxStyles} p-[4px] ${disabled ? "" : "cursor-pointer"}`}
>
{/* Checkmark SVG per Figma - 16px size */}
<svg
width="16"
height="16"
viewBox="0 0 12 12"
aria-hidden="true"
focusable="false"
className="block"
>
<polyline
points="2.5 6 5 8.5 10 3.5"
stroke={checkGlyphColor}
strokeWidth="1.25"
fill="none"
strokeLinecap="square"
strokeLinejoin="miter"
vectorEffect="non-scaling-stroke"
/>
</svg>
</span>
{label && (
<span
id={labelId}
className="font-inter text-[14px] leading-[18px]"
style={{ color: labelColor }}
>
{label}
</span>
)}
{/* Hidden native input for form compatibility */}
<input
type="checkbox"
name={name}
value={value}
checked={checked}
onChange={() => {}}
tabIndex={-1}
aria-hidden="true"
className="sr-only"
readOnly
/>
</label>
);
}
@@ -0,0 +1,2 @@
export { default } from "./Checkbox.container";
export type { CheckboxProps } from "./Checkbox.types";
@@ -0,0 +1,66 @@
"use client";
import { memo, useCallback, useId, useState } from "react";
import { CheckboxGroupView } from "./CheckboxGroup.view";
import type { CheckboxGroupProps } from "./CheckboxGroup.types";
import { normalizeMode } from "../../../../lib/propNormalization";
const CheckboxGroupContainer = ({
name,
value,
onChange,
mode: modeProp = "standard",
disabled = false,
options = [],
className = "",
...props
}: CheckboxGroupProps) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const mode = normalizeMode(modeProp);
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const groupId = name || `checkbox-group-${generatedId}`;
// Internal state to track checked values (only used if value prop is not provided)
const [internalCheckedValues, setInternalCheckedValues] = useState<string[]>([]);
// Use controlled value if provided, otherwise use internal state
const checkedValues = value !== undefined ? value : internalCheckedValues;
const handleOptionChange = useCallback(
(optionValue: string, checked: boolean) => {
if (disabled) return;
const newCheckedValues = checked
? [...checkedValues, optionValue]
: checkedValues.filter((v) => v !== optionValue);
// Only update internal state if uncontrolled
if (value === undefined) {
setInternalCheckedValues(newCheckedValues);
}
if (onChange) {
onChange({ value: newCheckedValues });
}
},
[disabled, checkedValues, onChange, value],
);
return (
<CheckboxGroupView
groupId={groupId}
value={checkedValues}
mode={mode}
disabled={disabled}
options={options}
className={className}
ariaLabel={props["aria-label"]}
onOptionChange={handleOptionChange}
/>
);
};
CheckboxGroupContainer.displayName = "CheckboxGroup";
export default memo(CheckboxGroupContainer);
@@ -0,0 +1,34 @@
export interface CheckboxOption {
value: string;
label: string;
subtext?: string;
ariaLabel?: string;
}
import type { ModeValue } from "../../../../lib/propNormalization";
export interface CheckboxGroupProps {
name?: string;
value?: string[];
onChange?: (_data: { value: string[] }) => void;
/**
* Mode variant. Accepts both "standard"/"Standard" and "inverse"/"Inverse" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
mode?: ModeValue;
disabled?: boolean;
options?: CheckboxOption[];
className?: string;
"aria-label"?: string;
}
export interface CheckboxGroupViewProps {
groupId: string;
value: string[];
mode: "standard" | "inverse";
disabled: boolean;
options: CheckboxOption[];
className: string;
ariaLabel?: string;
onOptionChange: (_optionValue: string, _checked: boolean) => void;
}
@@ -0,0 +1,84 @@
import Checkbox from "../Checkbox";
import type { CheckboxGroupViewProps } from "./CheckboxGroup.types";
export function CheckboxGroupView({
groupId,
value,
mode,
disabled,
options,
className,
ariaLabel,
onOptionChange,
}: CheckboxGroupViewProps) {
return (
<div
className={`space-y-[8px] ${className}`}
role="group"
aria-label={ariaLabel}
>
{options.map((option) => {
const isChecked = value.includes(option.value);
// If there's subtext, render checkbox without label and handle layout separately
if (option.subtext) {
return (
<div
key={option.value}
className="flex gap-[8px] items-start"
>
<Checkbox
checked={isChecked}
mode={mode}
disabled={disabled}
name={groupId}
value={option.value}
ariaLabel={option.ariaLabel || option.label}
onChange={({ checked }) => {
onOptionChange(option.value, checked);
}}
/>
<div className="flex flex-col gap-[4px] flex-1">
<span
className={`font-inter text-[14px] leading-[20px] ${
mode === "inverse"
? "text-[var(--color-content-inverse-primary)]"
: "text-[var(--color-content-default-primary)]"
}`}
>
{option.label}
</span>
<span
className={`font-inter text-[14px] leading-[20px] ${
mode === "inverse"
? "text-[var(--color-content-inverse-secondary,#1f1f1f)]"
: "text-[var(--color-content-default-tertiary,#b4b4b4)]"
}`}
>
{option.subtext}
</span>
</div>
</div>
);
}
// If no subtext, use Checkbox's built-in label
return (
<Checkbox
key={option.value}
checked={isChecked}
mode={mode}
disabled={disabled}
label={option.label}
name={groupId}
value={option.value}
ariaLabel={option.ariaLabel}
onChange={({ checked }) => {
onOptionChange(option.value, checked);
}}
/>
);
})}
</div>
);
}
@@ -0,0 +1 @@
export { default } from "./CheckboxGroup.container";
@@ -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;
}
+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 = (event: React.MouseEvent<HTMLButtonElement | HTMLDivElement>) => {
if (isDisabled) {
event.preventDefault();
return;
}
onClick?.(event as React.MouseEvent<HTMLButtonElement>);
};
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";
@@ -0,0 +1,10 @@
export interface InputWithCounterProps {
label?: string;
placeholder?: string;
value: string;
onChange: (value: string) => void;
maxLength: number;
showHelpIcon?: boolean;
className?: string;
inputClassName?: string;
}
@@ -0,0 +1,74 @@
import type { InputWithCounterProps } from "./InputWithCounter.types";
export function InputWithCounterView({
label,
placeholder,
value,
onChange,
maxLength,
showHelpIcon = false,
className = "",
inputClassName = "",
}: InputWithCounterProps) {
return (
<div className={`space-y-[var(--spacing-scale-008)] ${className}`}>
{/* Label with help icon */}
{label && (
<div className="flex items-center gap-[var(--spacing-scale-002)]">
<label className="font-inter text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]">
{label}
</label>
{showHelpIcon && (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0 w-[12px] h-[12px]"
>
<mask
id="mask0_21296_8257"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="12"
height="12"
>
<rect width="12" height="12" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_21296_8257)">
<path
d="M5.99449 8.80766C6.13725 8.80766 6.25784 8.75838 6.35624 8.6598C6.45463 8.56123 6.50383 8.44055 6.50383 8.29779C6.50383 8.15502 6.45454 8.03444 6.35596 7.93605C6.25739 7.83765 6.13672 7.78845 5.99395 7.78845C5.85118 7.78845 5.7306 7.83774 5.63221 7.93631C5.53381 8.03489 5.48461 8.15556 5.48461 8.29833C5.48461 8.44109 5.5339 8.56168 5.63248 8.66008C5.73105 8.75847 5.85172 8.80766 5.99449 8.80766ZM5.64038 7.01729H6.34421C6.35062 6.77114 6.38668 6.5745 6.45239 6.42739C6.51809 6.28028 6.67754 6.08525 6.93075 5.8423C7.15062 5.62243 7.31905 5.41938 7.43604 5.23316C7.55302 5.04695 7.61151 4.82703 7.61151 4.57343C7.61151 4.14307 7.45687 3.80689 7.14759 3.5649C6.8383 3.32292 6.47243 3.20193 6.04999 3.20193C5.63269 3.20193 5.28734 3.3133 5.01394 3.53606C4.74055 3.75881 4.54552 4.02115 4.42885 4.32306L5.07114 4.58075C5.13204 4.41473 5.2362 4.25303 5.38364 4.09566C5.53108 3.93829 5.74999 3.8596 6.04038 3.8596C6.33589 3.8596 6.55432 3.94053 6.69566 4.1024C6.83701 4.26426 6.90769 4.4423 6.90769 4.63654C6.90769 4.80641 6.85929 4.96185 6.76249 5.10288C6.6657 5.2439 6.5423 5.38012 6.3923 5.51154C6.0641 5.80769 5.85673 6.0439 5.77019 6.22019C5.68365 6.39647 5.64038 6.66217 5.64038 7.01729ZM6.00082 10.75C5.34386 10.75 4.72634 10.6253 4.14828 10.376C3.5702 10.1266 3.06736 9.78827 2.63975 9.36085C2.21213 8.93343 1.8736 8.43081 1.62416 7.85299C1.37472 7.27518 1.25 6.65779 1.25 6.00083C1.25 5.34386 1.37467 4.72634 1.624 4.14828C1.87333 3.5702 2.21171 3.06736 2.63913 2.63975C3.06655 2.21213 3.56917 1.8736 4.14699 1.62416C4.7248 1.37472 5.34218 1.25 5.99915 1.25C6.65612 1.25 7.27363 1.37467 7.8517 1.624C8.42978 1.87333 8.93262 2.21171 9.36023 2.63913C9.78784 3.06655 10.1264 3.56917 10.3758 4.14699C10.6253 4.7248 10.75 5.34218 10.75 5.99915C10.75 6.65612 10.6253 7.27363 10.376 7.8517C10.1266 8.42978 9.78827 8.93262 9.36085 9.36023C8.93343 9.78784 8.43081 10.1264 7.85299 10.3758C7.27518 10.6253 6.65779 10.75 6.00082 10.75ZM5.99999 9.99999C7.11665 9.99999 8.06249 9.61249 8.83749 8.83749C9.61249 8.06249 9.99999 7.11665 9.99999 5.99999C9.99999 4.88332 9.61249 3.93749 8.83749 3.16249C8.06249 2.38749 7.11665 1.99999 5.99999 1.99999C4.88332 1.99999 3.93749 2.38749 3.16249 3.16249C2.38749 3.93749 1.99999 4.88332 1.99999 5.99999C1.99999 7.11665 2.38749 8.06249 3.16249 8.83749C3.93749 9.61249 4.88332 9.99999 5.99999 9.99999Z"
fill="var(--color-content-brand-darker-accent-2)"
/>
</g>
</svg>
)}
</div>
)}
{/* Input field */}
<div className="relative">
<input
type="text"
placeholder={placeholder}
value={value}
onChange={(e) => {
if (e.target.value.length <= maxLength) {
onChange(e.target.value);
}
}}
maxLength={maxLength}
className={`w-full h-[36px] px-[12px] py-[8px] bg-[var(--color-surface-default-primary)] border-2 border-[var(--color-border-default-tertiary)] rounded-[var(--measures-radius-medium,8px)] text-[16px] leading-[24px] text-[var(--color-content-default-primary)] placeholder:text-[var(--color-content-default-tertiary)] focus:outline-none ${inputClassName}`}
/>
</div>
{/* Character counter */}
<p className="font-inter text-[12px] leading-[16px] text-[var(--color-content-default-tertiary)]">
{value.length}/{maxLength}
</p>
</div>
);
}
@@ -0,0 +1,2 @@
export { InputWithCounterView as default } from "./InputWithCounter.view";
export type { InputWithCounterProps } from "./InputWithCounter.types";
@@ -0,0 +1,47 @@
"use client";
import { memo } from "react";
import MultiSelectView from "./MultiSelect.view";
import type { MultiSelectProps } from "./MultiSelect.types";
import { normalizeMultiSelectSize, normalizeChipPalette } from "../../../../lib/propNormalization";
const MultiSelectContainer = memo<MultiSelectProps>(
({
label,
showHelpIcon = true,
size: sizeProp = "M",
palette: paletteProp = "Default",
options,
onChipClick,
onAddClick,
showAddButton = true,
addButtonText = "Add organization type",
onCustomChipConfirm,
onCustomChipClose,
className = "",
}) => {
const size = normalizeMultiSelectSize(sizeProp);
const palette = normalizeChipPalette(paletteProp);
return (
<MultiSelectView
label={label}
showHelpIcon={showHelpIcon}
size={size}
palette={palette}
options={options}
onChipClick={onChipClick}
onAddClick={onAddClick}
showAddButton={showAddButton}
addButtonText={addButtonText}
onCustomChipConfirm={onCustomChipConfirm}
onCustomChipClose={onCustomChipClose}
className={className}
/>
);
},
);
MultiSelectContainer.displayName = "MultiSelect";
export default MultiSelectContainer;
@@ -0,0 +1,74 @@
import type { ChipStateValue, ChipPaletteValue } from "../../../../lib/propNormalization";
export interface ChipOption {
id: string;
label: string;
state?: ChipStateValue;
}
export type MultiSelectSizeValue = "S" | "M" | "s" | "m";
export interface MultiSelectProps {
/**
* Label for the multi-select component
*/
label?: string;
/**
* Show help icon next to label
*/
showHelpIcon?: boolean;
/**
* Size variant: "S" (small) or "M" (medium)
* Accepts both uppercase (Figma) and lowercase values.
*/
size?: MultiSelectSizeValue;
/**
* Palette for chips: "Default" or "Inverse"
* Accepts both PascalCase (Figma) and lowercase values.
*/
palette?: ChipPaletteValue;
/**
* Array of chip options to display
*/
options: ChipOption[];
/**
* Callback when a chip is clicked (toggled)
*/
onChipClick?: (chipId: string) => void;
/**
* Callback when add button is clicked
*/
onAddClick?: () => void;
/**
* Show the add button
*/
showAddButton?: boolean;
/**
* Text for the add button
*/
addButtonText?: string;
/**
* Callback when a custom chip is confirmed (check button clicked)
*/
onCustomChipConfirm?: (chipId: string, value: string) => void;
/**
* Callback when a custom chip is closed/removed
*/
onCustomChipClose?: (chipId: string) => void;
className?: string;
}
export interface MultiSelectViewProps {
label?: string;
showHelpIcon: boolean;
size: "s" | "m";
palette: "default" | "inverse";
options: ChipOption[];
onChipClick?: (chipId: string) => void;
onAddClick?: () => void;
showAddButton: boolean;
addButtonText: string;
onCustomChipConfirm?: (chipId: string, value: string) => void;
onCustomChipClose?: (chipId: string) => void;
className: string;
}
@@ -0,0 +1,125 @@
"use client";
import { memo } from "react";
import Chip from "../Chip";
import InputLabel from "../../InputLabel";
import type { MultiSelectViewProps } from "./MultiSelect.types";
function MultiSelectView({
label,
showHelpIcon,
size,
palette,
options,
onChipClick,
onAddClick,
showAddButton,
addButtonText,
onCustomChipConfirm,
onCustomChipClose,
className,
}: MultiSelectViewProps) {
const isSmall = size === "s";
const isInverse = palette === "inverse";
// Size-based spacing
const gapClass = isSmall
? "gap-[var(--measures-spacing-200,8px)]"
: "gap-[var(--measures-spacing-300,12px)]";
const chipSize = isSmall ? "S" : "M";
return (
<div className={`flex flex-col ${isSmall ? "gap-[var(--measures-spacing-200,8px)]" : "gap-[var(--measures-spacing-300,12px)]"} items-start relative w-full ${className}`}>
{/* Label using InputLabel component */}
{label && (
<InputLabel
label={label}
helpIcon={showHelpIcon}
asterisk={false}
helperText={false}
size={size === "s" ? "S" : "M"}
palette={palette === "inverse" ? "Inverse" : "Default"}
/>
)}
{/* Chips container */}
<div className={`flex flex-wrap ${gapClass} items-center relative shrink-0 w-full`}>
{options.map((option) => (
<Chip
key={option.id}
label={option.state === "Custom" ? "" : option.label}
state={option.state || "Unselected"}
palette={palette === "inverse" ? "Inverse" : "Default"}
size={chipSize}
onClick={() => {
// Only toggle if not in Custom state
if (option.state !== "Custom" && onChipClick) {
onChipClick(option.id);
}
}}
onCheck={(value, e) => {
e.stopPropagation();
if (onCustomChipConfirm) {
onCustomChipConfirm(option.id, value);
}
}}
onClose={(e) => {
e.stopPropagation();
if (onCustomChipClose) {
onCustomChipClose(option.id);
}
}}
/>
))}
{/* Add button - Circular button with border (not ghost) when no text, ghost style when text provided */}
{showAddButton && (
<button
type="button"
aria-label={addButtonText || "Add option"}
onClick={(e) => {
e.stopPropagation();
onAddClick?.();
}}
className={
!addButtonText
? // Circular button with border (RuleCard style)
`bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`
: // Ghost button style (standalone MultiSelect)
`flex ${isSmall ? "gap-[var(--measures-spacing-050,2px)]" : "gap-[var(--measures-spacing-150,6px)]"} items-center justify-center ${isSmall ? "p-[var(--measures-spacing-200,8px)]" : "p-[var(--measures-spacing-300,12px)]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`
}
>
{/* Plus icon */}
<svg
width={isSmall ? "14" : "20"}
height={isSmall ? "14" : "20"}
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`${isInverse ? "text-[var(--color-content-inverse-primary,black)]" : "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 - only show if addButtonText is provided */}
{addButtonText && (
<span className={`font-inter font-medium ${isSmall ? "text-[length:var(--sizing-300,12px)] leading-[14px]" : "text-[length:var(--sizing-400,16px)] leading-[20px]"} ${isInverse ? "text-[color:var(--color-content-inverse-primary,black)]" : "text-[color:var(--color-content-default-brand-primary,#fefcc9)]"}`}>
{addButtonText}
</span>
)}
</button>
)}
</div>
</div>
);
}
MultiSelectView.displayName = "MultiSelectView";
export default memo(MultiSelectView);
@@ -0,0 +1 @@
export { default } from "./MultiSelect.container";
@@ -0,0 +1,142 @@
"use client";
import { memo, useCallback, useId } from "react";
import { RadioButtonView } from "./RadioButton.view";
import type { RadioButtonProps } from "./RadioButton.types";
import { normalizeMode, normalizeState } from "../../../../lib/propNormalization";
const RadioButtonContainer = ({
checked = false,
mode: modeProp = "standard",
state: stateProp = "default", // This state prop is now only for static display in Storybook/Preview
indicator: _indicator = true, // From Figma: whether to show the indicator dot (currently not used in view)
disabled = false,
label,
onChange,
id,
name,
value,
ariaLabel,
className = "",
}: RadioButtonProps) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const mode = normalizeMode(modeProp);
const state = normalizeState(stateProp);
// If state is "selected", it means checked in Figma terms
const normalizedState = state === "selected" || checked ? "selected" : state;
const isInverse = mode === "inverse";
const isStandard = mode === "standard";
// Base box styles per Figma - 24px size, circular
const baseBox = `
flex
items-center
justify-center
shrink-0
w-[24px]
h-[24px]
rounded-full
transition-all
duration-200
ease-in-out
p-[4px]
`.trim().replace(/\s+/g, " ");
// Get box styles based on mode and checked status per Figma designs
const getBoxStyles = (): string => {
// Standard mode styles
if (isStandard) {
// Default state: tertiary border (or brand primary when checked), with hover and focus states via CSS
// Hover changes border to brand primary color
// Focus shows shadow (double ring: 2px white inner, 4px dark outer)
// When checked, border is brand primary (but changes to invert tertiary on focus)
const defaultBorder = checked
? "border-[var(--color-border-default-brand-primary,#fdfaa8)]"
: "border-[var(--color-border-default-tertiary,#464646)]";
// When focused and checked, border should be invert tertiary (#2d2d2d) per Figma
const focusBorder = checked
? "focus:border-[var(--color-content-invert-tertiary,#2d2d2d)]"
: "focus:border-[var(--color-border-default-tertiary,#464646)]";
return `${baseBox} bg-[var(--color-surface-default-primary)] border border-solid ${defaultBorder} hover:border-[var(--color-border-default-brand-primary,#fdfaa8)] ${focusBorder} focus:shadow-[0px_0px_0px_2px_var(--color-border-invert-primary,white),0px_0px_0px_4px_var(--color-border-default-primary,#141414)] focus:outline-none`;
}
// Inverse mode styles
if (isInverse) {
// Default state: white border (or brand primary when checked), transparent background
// Hover changes border to inverse brand primary color (#6c6701) for both selected and unselected
// Focus shows shadow (double ring: 2px dark inner, 4px white outer)
// When checked, border is brand primary (but changes to white on focus)
const defaultBorder = checked
? "border-[var(--color-border-default-brand-primary,#fdfaa8)]"
: "border-[var(--color-border-invert-primary,white)]";
// Hover border: inverse brand primary for both selected and unselected per Figma
const hoverBorder = "hover:border-[var(--color-border-invert-brand-primary,#6c6701)]";
// Focus border: when focused and checked, border should be white per Figma
const focusBorder = checked
? "focus:border-[var(--color-border-invert-primary,white)]"
: "focus:border-[var(--color-border-invert-primary,white)]";
return `${baseBox} bg-transparent border border-solid ${defaultBorder} ${hoverBorder} ${focusBorder} focus:shadow-[0px_0px_0px_2px_var(--color-border-default-primary,#141414),0px_0px_0px_4px_var(--color-border-invert-primary,white)] focus:outline-none`;
}
return baseBox;
};
const combinedBoxStyles = getBoxStyles();
// Label color
const labelColor = isInverse
? "var(--color-content-inverse-primary)"
: "var(--color-content-default-primary)";
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const radioId = id || `radio-${generatedId}`;
const handleToggle = useCallback(
(_e: React.MouseEvent | React.KeyboardEvent) => {
if (!disabled && onChange) {
// Always call onChange when clicked, even if already checked
// The parent (RadioGroup) will handle the logic
onChange({ checked: true, value });
}
},
[disabled, onChange, value],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleToggle(e);
}
};
return (
<RadioButtonView
radioId={radioId}
checked={checked}
mode={mode}
state={normalizedState} // Normalized state (handles "selected" from Figma)
disabled={disabled}
label={label}
name={name}
value={value}
ariaLabel={ariaLabel}
className={className}
combinedBoxStyles={combinedBoxStyles}
labelColor={labelColor}
onToggle={handleToggle}
onKeyDown={handleKeyDown}
/>
);
};
RadioButtonContainer.displayName = "RadioButton";
export default memo(RadioButtonContainer);
@@ -0,0 +1,45 @@
import type { ModeValue, StateValue } from "../../../../lib/propNormalization";
export interface RadioButtonProps {
checked?: boolean;
/**
* Mode variant. Accepts both "standard"/"Standard" and "inverse"/"Inverse" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
mode?: ModeValue;
/**
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus", "selected"/"Selected" (case-insensitive).
* Note: "selected" state is represented by the `checked` prop in practice.
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
state?: StateValue;
/**
* Whether to show the indicator dot. From Figma specification.
*/
indicator?: boolean;
disabled?: boolean;
label?: string;
onChange?: (_data: { checked: boolean; value?: string }) => void;
id?: string;
name?: string;
value?: string;
ariaLabel?: string;
className?: string;
}
export interface RadioButtonViewProps {
radioId: string;
checked: boolean;
mode: "standard" | "inverse";
state: "default" | "hover" | "focus" | "selected";
disabled: boolean;
label?: string;
name?: string;
value?: string;
ariaLabel?: string;
className: string;
combinedBoxStyles: string;
labelColor: string;
onToggle: (_e: React.MouseEvent | React.KeyboardEvent) => void;
onKeyDown: (_e: React.KeyboardEvent<HTMLSpanElement>) => void;
}
@@ -0,0 +1,72 @@
import type { RadioButtonViewProps } from "./RadioButton.types";
export function RadioButtonView({
radioId,
checked,
mode,
disabled,
label,
name,
value,
ariaLabel,
className,
combinedBoxStyles,
labelColor,
onToggle,
onKeyDown,
}: RadioButtonViewProps) {
return (
<label
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${
disabled ? "opacity-60 cursor-not-allowed" : ""
} ${className}`}
onMouseDown={(e) => e.preventDefault()}
onClick={onToggle}
>
<span
onKeyDown={onKeyDown}
className={`group ${combinedBoxStyles} ${disabled ? "" : "cursor-pointer"}`}
tabIndex={disabled ? -1 : 0}
role="radio"
aria-checked={checked}
{...(disabled && { "aria-disabled": true })}
{...(ariaLabel && { "aria-label": ariaLabel })}
{...(label && !ariaLabel && { "aria-labelledby": `${radioId}-label` })}
id={radioId}
>
{/* Radio dot - 16px size per Figma */}
{/* Selected hover state: darker dot color (#333000) per Figma */}
<div
className={`w-[16px] h-[16px] rounded-full transition-all duration-200 ${
checked && mode === "standard"
? "bg-[var(--color-content-default-brand-primary,#fefcc9)] group-hover:!bg-[#333000]"
: checked && mode === "inverse"
? "bg-[var(--color-content-default-primary,#000000)]"
: "bg-transparent"
}`}
/>
</span>
{label && (
<span
id={`${radioId}-label`}
className="font-inter text-[14px] leading-[18px]"
style={{ color: labelColor }}
>
{label}
</span>
)}
{/* Hidden input for form submission */}
<input
type="radio"
name={name}
value={value}
checked={checked}
onChange={() => {}}
disabled={disabled}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
/>
</label>
);
}
@@ -0,0 +1,2 @@
export { default } from "./RadioButton.container";
export type { RadioButtonProps } from "./RadioButton.types";
@@ -0,0 +1,56 @@
"use client";
import { memo, useCallback, useId } from "react";
import { RadioGroupView } from "./RadioGroup.view";
import type { RadioGroupProps } from "./RadioGroup.types";
import { normalizeMode, normalizeState } from "../../../../lib/propNormalization";
const RadioGroupContainer = ({
name,
value,
onChange,
mode: modeProp = "standard",
state: stateProp = "default",
disabled = false,
options = [],
className = "",
...props
}: RadioGroupProps) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const mode = normalizeMode(modeProp);
// Normalize state, but handle "With Subtext" separately (it's represented by options with subtext)
const state = typeof stateProp === "string" &&
(stateProp.toLowerCase() === "with subtext" || stateProp === "With Subtext")
? "default" // "With Subtext" is handled via RadioOption.subtext, use default state
: normalizeState(stateProp);
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const groupId = name || `radio-group-${generatedId}`;
const handleChange = useCallback(
(optionValue: string) => {
if (!disabled && onChange) {
onChange({ value: optionValue });
}
},
[disabled, onChange],
);
return (
<RadioGroupView
groupId={groupId}
value={value}
mode={mode}
state={state}
disabled={disabled}
options={options}
className={className}
ariaLabel={props["aria-label"]}
onOptionChange={handleChange}
/>
);
};
RadioGroupContainer.displayName = "RadioGroup";
export default memo(RadioGroupContainer);
@@ -0,0 +1,41 @@
export interface RadioOption {
value: string;
label: string;
subtext?: string;
ariaLabel?: string;
}
import type { ModeValue, StateValue } from "../../../../lib/propNormalization";
export interface RadioGroupProps {
name?: string;
value?: string;
onChange?: (_data: { value: string }) => void;
/**
* Mode variant. Accepts both "standard"/"Standard" and "inverse"/"Inverse" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
mode?: ModeValue;
/**
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
* Figma also supports "With Subtext" state, which is handled via RadioOption.subtext.
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
state?: StateValue | "With Subtext" | "with subtext";
disabled?: boolean;
options?: RadioOption[];
className?: string;
"aria-label"?: string;
}
export interface RadioGroupViewProps {
groupId: string;
value?: string;
mode: "standard" | "inverse";
state: "default" | "hover" | "focus" | "selected";
disabled: boolean;
options: RadioOption[];
className: string;
ariaLabel?: string;
onOptionChange: (_optionValue: string) => void;
}
@@ -0,0 +1,91 @@
import RadioButton from "../RadioButton";
import type { RadioGroupViewProps } from "./RadioGroup.types";
export function RadioGroupView({
groupId,
value,
mode,
state,
disabled,
options,
className,
ariaLabel,
onOptionChange,
}: RadioGroupViewProps) {
return (
<div
className={`space-y-[8px] ${className}`}
role="radiogroup"
aria-label={ariaLabel}
>
{options.map((option) => {
const isSelected = value === option.value;
// If there's subtext, render radio button without label and handle layout separately
if (option.subtext) {
return (
<div
key={option.value}
className="flex gap-[8px] items-start"
>
<RadioButton
checked={isSelected}
mode={mode}
state={state}
disabled={disabled}
name={groupId}
value={option.value}
ariaLabel={option.ariaLabel || option.label}
onChange={({ checked }) => {
if (checked) {
onOptionChange(option.value);
}
}}
/>
<div className="flex flex-col gap-[4px] flex-1">
<span
className={`font-inter text-[14px] leading-[20px] ${
mode === "inverse"
? "text-[var(--color-content-inverse-primary)]"
: "text-[var(--color-content-default-primary)]"
}`}
>
{option.label}
</span>
<span
className={`font-inter text-[14px] leading-[20px] ${
mode === "inverse"
? "text-[var(--color-content-inverse-secondary,#1f1f1f)]"
: "text-[var(--color-content-default-secondary,#b4b4b4)]"
}`}
>
{option.subtext}
</span>
</div>
</div>
);
}
// If no subtext, use RadioButton's built-in label
return (
<RadioButton
key={option.value}
checked={isSelected}
mode={mode}
state={state}
disabled={disabled}
label={option.label}
name={groupId}
value={option.value}
ariaLabel={option.ariaLabel}
onChange={({ checked }) => {
if (checked) {
onOptionChange(option.value);
}
}}
/>
);
})}
</div>
);
}
@@ -0,0 +1,2 @@
export { default } from "./RadioGroup.container";
export type { RadioGroupProps, RadioOption } from "./RadioGroup.types";
@@ -0,0 +1,227 @@
"use client";
import React, {
Children,
type ReactElement,
type ReactNode,
forwardRef,
useId,
useState,
useRef,
useCallback,
memo,
useImperativeHandle,
useEffect,
} from "react";
import { useClickOutside } from "../../../hooks";
import { SelectInputView } from "./SelectInput.view";
import type { SelectInputProps } from "./SelectInput.types";
import { normalizeState, normalizeSmallMediumLargeSize, normalizeLabelVariant } from "../../../../lib/propNormalization";
const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
(
{
id,
label,
labelVariant: labelVariantProp,
size: sizeProp,
state: externalStateProp = "default",
disabled = false,
error = false,
placeholder = "Choose an option",
className = "",
children,
value,
onChange,
options,
...props
},
ref,
) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
// Note: labelVariant and size are normalized for future use but not yet implemented in the view
const _labelVariant = labelVariantProp ? normalizeLabelVariant(labelVariantProp) : undefined;
const _size = sizeProp ? normalizeSmallMediumLargeSize(sizeProp) : undefined;
// Mark as intentionally unused for future implementation
void _labelVariant;
void _size;
const externalState = normalizeState(externalStateProp);
const generatedId = useId();
const selectId = id || `select-input-${generatedId}`;
const labelId = `${selectId}-label`;
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState(value || "");
const selectRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Internal state management: track if focused and how (mouse vs keyboard)
const [isFocused, setIsFocused] = useState(false);
const [focusMethod, setFocusMethod] = useState<"mouse" | "keyboard" | null>(null);
const wasMouseDownRef = useRef(false);
// Determine if we should auto-manage focus (only when state is "default" or undefined)
const shouldAutoManageFocus = externalState === "default" || externalState === undefined;
// Sync internal state with external value prop
useEffect(() => {
if (value !== undefined && value !== selectedValue) {
setSelectedValue(value);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useImperativeHandle(
ref,
() => selectRef.current as HTMLButtonElement | null,
);
// Handle click outside to close menu
useClickOutside([menuRef, selectRef], () => setIsOpen(false), isOpen);
// Handle option selection
const handleOptionSelect = useCallback(
(optionValue: string, optionText: string) => {
setSelectedValue(optionValue);
setIsOpen(false);
if (onChange) {
onChange({ target: { value: optionValue, text: optionText } });
}
if (selectRef.current) {
selectRef.current.focus();
}
},
[onChange],
);
// Handle mouse down to detect mouse clicks
const handleMouseDown = useCallback(() => {
if (!disabled && shouldAutoManageFocus) {
wasMouseDownRef.current = true;
}
}, [disabled, shouldAutoManageFocus]);
// Handle select button click
const handleSelectClick = useCallback(() => {
if (!disabled) {
setIsOpen(!isOpen);
}
}, [disabled, isOpen]);
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsOpen(!isOpen);
} else if (e.key === "Escape") {
setIsOpen(false);
}
},
[disabled, isOpen],
);
// Handle focus to detect mouse vs keyboard
const handleFocus = useCallback(() => {
if (disabled) return;
const method = wasMouseDownRef.current ? "mouse" : "keyboard";
if (shouldAutoManageFocus) {
setIsFocused(true);
setFocusMethod(method);
wasMouseDownRef.current = false;
}
}, [disabled, shouldAutoManageFocus]);
// Handle blur
const handleBlur = useCallback(() => {
if (shouldAutoManageFocus) {
setIsFocused(false);
setFocusMethod(null);
wasMouseDownRef.current = false;
}
}, [shouldAutoManageFocus]);
// Determine actual state:
// - Active: when clicked (mouse focus) or when dropdown is open
// - Focus: when tabbed (keyboard focus)
// - Default: when not focused
const actualState = shouldAutoManageFocus
? isOpen || isFocused
? focusMethod === "mouse" || isOpen
? "active"
: "focus"
: "default"
: externalState;
// Determine if select is filled (has selected value)
const isFilled = Boolean(selectedValue && selectedValue.trim().length > 0);
// Get display text for selected value
const getDisplayText = (): string => {
if (!selectedValue) return placeholder;
if (options && Array.isArray(options)) {
const selectedOption = options.find(
(option) => option.value === selectedValue,
);
return selectedOption ? selectedOption.label : placeholder;
}
const selectedOption = Children.toArray(children).find(
(
child,
): child is ReactElement<{
value: string;
children: ReactNode;
}> => {
if (!React.isValidElement(child)) return false;
const props = child.props as {
value?: string;
children?: ReactNode;
};
return props.value === selectedValue;
},
);
return selectedOption
? String(selectedOption.props.children)
: placeholder;
};
return (
<SelectInputView
label={label}
placeholder={placeholder}
state={actualState}
disabled={disabled}
error={error}
className={className}
options={options}
selectId={selectId}
labelId={labelId}
isOpen={isOpen}
selectedValue={selectedValue}
displayText={getDisplayText()}
isFilled={isFilled}
onButtonClick={handleSelectClick}
onButtonKeyDown={handleKeyDown}
onButtonMouseDown={handleMouseDown}
onButtonFocus={handleFocus}
onButtonBlur={handleBlur}
onOptionClick={handleOptionSelect}
selectRef={selectRef}
menuRef={menuRef}
ariaLabelledby={label ? labelId : undefined}
ariaInvalid={error}
{...props}
/>
);
},
);
SelectInputContainer.displayName = "SelectInput";
export default memo(SelectInputContainer);
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
export interface SelectOptionData {
value: string;
label: string;
}
import type { StateValue } from "../../../../lib/propNormalization";
export type SelectInputLabelVariantValue = "default" | "horizontal" | "Default" | "Horizontal";
export type SelectInputSizeValue = "small" | "medium" | "large" | "Small" | "Medium" | "Large";
export interface SelectInputProps {
id?: string;
label?: string;
/**
* Label variant. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
labelVariant?: SelectInputLabelVariantValue;
/**
* Select input size. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
size?: SelectInputSizeValue;
/**
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
state?: StateValue;
disabled?: boolean;
error?: boolean;
placeholder?: string;
className?: string;
children?: ReactNode;
value?: string;
onChange?: (_data: { target: { value: string; text: string } }) => void;
options?: SelectOptionData[];
}
@@ -0,0 +1,240 @@
import React, { Children, type ReactNode } from "react";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import SelectDropdown from "../../SelectDropdown";
import SelectOption from "../../SelectOption";
import type { SelectOptionData } from "./SelectInput.types";
export interface SelectInputViewProps {
label?: string;
placeholder: string;
state: "default" | "active" | "hover" | "focus" | "selected";
disabled: boolean;
error: boolean;
className: string;
options?: SelectOptionData[];
children?: ReactNode;
// Computed props from container
selectId: string;
labelId: string;
isOpen: boolean;
selectedValue: string;
displayText: string;
isFilled: boolean;
// Callbacks
onButtonClick: () => void;
onButtonKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
onButtonMouseDown?: () => void;
onButtonFocus?: () => void;
onButtonBlur?: () => void;
onOptionClick: (_value: string, _text: string) => void;
// Refs
selectRef: React.RefObject<HTMLButtonElement>;
menuRef: React.RefObject<HTMLDivElement>;
// Additional props
ariaLabelledby?: string;
ariaInvalid?: boolean;
}
export function SelectInputView({
label,
placeholder: _placeholder,
state,
disabled,
error,
options,
children,
selectId,
labelId,
isOpen,
selectedValue,
displayText,
isFilled,
onButtonClick,
onButtonKeyDown,
onButtonMouseDown,
onButtonFocus,
onButtonBlur,
onOptionClick,
selectRef,
menuRef,
ariaLabelledby,
ariaInvalid,
}: SelectInputViewProps) {
// Styles based on Figma design
const containerClasses = "flex flex-col gap-[8px]";
const labelClasses = "text-[14px] leading-[20px] font-medium font-inter text-[var(--color-content-default-primary)]";
// Button styles per Figma
const getButtonClasses = (): string => {
const baseClasses = `
w-full
h-[40px]
px-[12px]
py-[8px]
text-[16px]
font-medium
leading-[20px]
rounded-[8px]
border
border-solid
flex
items-center
justify-between
gap-[12px]
transition-all
duration-200
focus:outline-none
focus:ring-0
cursor-pointer
appearance-none
m-0
`.trim().replace(/\s+/g, " ");
if (disabled) {
return `${baseClasses} bg-[var(--color-surface-default-secondary)] text-[var(--color-content-inverse-tertiary,#2d2d2d)] border-[var(--color-border-default-primary)] cursor-not-allowed opacity-40`;
}
if (error) {
return `${baseClasses} bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-2 border-[var(--color-border-default-utility-negative)]`;
}
if (state === "focus") {
// Focus state: secondary background, tertiary border, with focus ring
return `${baseClasses} bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border border-solid border-[var(--color-border-default-tertiary)]`;
}
if (state === "active" || isOpen) {
// Active state per Figma: secondary background, tertiary border
return `${baseClasses} bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border-[var(--color-border-default-tertiary)]`;
}
// Default state per Figma: secondary background, primary border (subtle)
return `${baseClasses} bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border-[var(--color-border-default-primary)]`;
};
const buttonClasses = getButtonClasses();
// Text color based on filled state
const textColorClass = isFilled
? "text-[var(--color-content-default-primary)]"
: "text-[var(--color-content-default-tertiary,#b4b4b4)]";
// Chevron icon
const chevronClasses = `w-5 h-5 text-[var(--color-content-default-primary)] transition-transform duration-200 ${
isOpen ? "rotate-180" : ""
}`;
return (
<div className={containerClasses}>
{label && (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
id={labelId}
className={labelClasses}
>
{label}
</label>
<div className="relative shrink-0 size-[12px]">
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt="Help"
className="block max-w-none size-full"
/>
</div>
</div>
</div>
)}
<div className="relative">
<button
ref={selectRef}
id={selectId}
disabled={disabled}
className={buttonClasses}
aria-labelledby={ariaLabelledby}
aria-invalid={ariaInvalid}
aria-expanded={isOpen}
aria-haspopup="listbox"
onClick={onButtonClick}
onKeyDown={onButtonKeyDown}
onMouseDown={onButtonMouseDown}
onFocus={onButtonFocus}
onBlur={onButtonBlur}
>
<span className={`flex-1 text-left pr-[32px] ${textColorClass}`}>
{displayText}
</span>
<div className="flex items-center justify-center shrink-0">
<svg
className={chevronClasses}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</button>
{state === "focus" && (
<div
className="absolute border-2 border-solid border-[var(--color-border-inverse-primary)] inset-0 rounded-[8px] shadow-[0px_0px_0px_2px_var(--color-border-default-primary)] pointer-events-none z-10"
aria-hidden="true"
/>
)}
{isOpen && (
<div
ref={menuRef}
className="absolute top-full left-0 right-0 z-50 mt-1"
>
<SelectDropdown>
{options && Array.isArray(options)
? options.map((option) => (
<SelectOption
key={option.value}
selected={option.value === selectedValue}
size="medium"
onClick={() => onOptionClick(option.value, option.label)}
>
{option.label}
</SelectOption>
))
: Children.map(children, (child) => {
if (
React.isValidElement(child) &&
child.type === "option"
) {
const optionProps = child.props as {
value: string;
children: ReactNode;
};
return (
<SelectOption
key={optionProps.value}
selected={optionProps.value === selectedValue}
size="medium"
onClick={() =>
onOptionClick(
optionProps.value,
String(optionProps.children),
)
}
>
{optionProps.children}
</SelectOption>
);
}
return child;
})}
</SelectDropdown>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,2 @@
export { default } from "./SelectInput.container";
export type { SelectInputProps, SelectOptionData } from "./SelectInput.types";
@@ -0,0 +1,167 @@
"use client";
import { memo, useCallback, useId, forwardRef } from "react";
import { SwitchView } from "./Switch.view";
import type { SwitchProps } from "./Switch.types";
import { normalizeState } from "../../../../lib/propNormalization";
const SwitchContainer = memo(
forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
const {
checked = false,
onChange,
onFocus,
onBlur,
state: stateProp = "default",
label,
className = "",
...rest
} = props;
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const state = normalizeState(stateProp);
const switchId = useId();
const handleClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
if (onChange) {
onChange(e);
}
},
[onChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (onChange) {
onChange(e);
}
}
},
[onChange],
);
const handleFocus = useCallback(
(e: React.FocusEvent<HTMLButtonElement>) => {
if (onFocus) {
onFocus(e);
}
},
[onFocus],
);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLButtonElement>) => {
if (onBlur) {
onBlur(e);
}
},
[onBlur],
);
// Switch track styles based on checked state
const getTrackStyles = useCallback(() => {
return checked
? "bg-[var(--color-surface-inverse-tertiary)]"
: "bg-[var(--color-surface-default-tertiary)]";
}, [checked]);
// Switch thumb styles based on checked state
const getThumbStyles = useCallback(() => {
return "bg-[var(--color-gray-000)]";
}, []);
// Focus styles
const getFocusStyles = useCallback(() => {
if (state === "focus") {
return "shadow-[0_0_5px_3px_#3281F8] rounded-full";
}
return "";
}, [state]);
const trackStyles = getTrackStyles();
const thumbStyles = getThumbStyles();
const focusStyles = getFocusStyles();
const switchClasses = `
relative
inline-flex
items-center
cursor-pointer
transition-all
duration-200
focus:outline-none
focus-visible:shadow-[0_0_5px_3px_#3281F8]
focus-visible:rounded-full
${focusStyles}
${className}
`
.trim()
.replace(/\s+/g, " ");
const trackClasses = `
${trackStyles}
w-[40px]
h-[24px]
rounded-full
transition-all
duration-200
flex
items-center
${checked ? "justify-end" : "justify-start"}
p-[2px]
`
.trim()
.replace(/\s+/g, " ");
const thumbClasses = `
${thumbStyles}
w-[var(--measures-sizing-020)]
h-[var(--measures-sizing-020)]
rounded-[var(--measures-radius-xlarge)]
transition-all
duration-200
shadow-sm
`
.trim()
.replace(/\s+/g, " ");
const labelClasses = `
ml-[var(--measures-spacing-008)]
font-inter
font-normal
text-[14px]
leading-[20px]
text-[var(--color-content-default-primary)]
`
.trim()
.replace(/\s+/g, " ");
return (
<SwitchView
ref={ref}
switchId={switchId}
checked={checked}
state={state}
label={label}
className={className}
switchClasses={switchClasses}
trackClasses={trackClasses}
thumbClasses={thumbClasses}
labelClasses={labelClasses}
onClick={handleClick}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
{...rest}
/>
);
}),
);
SwitchContainer.displayName = "Switch";
export default SwitchContainer;
@@ -0,0 +1,38 @@
import type { StateValue } from "../../../../lib/propNormalization";
export interface SwitchProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onChange"
> {
checked?: boolean;
onChange?: (
_e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>,
) => void;
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
/**
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
state?: StateValue;
label?: string;
className?: string;
}
export interface SwitchViewProps {
switchId: string;
checked: boolean;
state: "default" | "hover" | "focus" | "selected";
label?: string;
className: string;
switchClasses: string;
trackClasses: string;
thumbClasses: string;
labelClasses: string;
onClick: (_e: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
onFocus: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur: (_e: React.FocusEvent<HTMLButtonElement>) => void;
}
@@ -0,0 +1,48 @@
import { forwardRef } from "react";
import type { SwitchViewProps } from "./Switch.types";
export const SwitchView = forwardRef<HTMLButtonElement, SwitchViewProps>(
(
{
switchId,
checked,
label,
switchClasses,
trackClasses,
thumbClasses,
labelClasses,
onClick,
onKeyDown,
onFocus,
onBlur,
...rest
},
ref,
) => {
return (
<div className="flex items-center">
<button
ref={ref}
id={switchId}
type="button"
role="switch"
aria-checked={checked}
aria-label={label || "Toggle switch"}
onClick={onClick}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
className={switchClasses}
{...rest}
>
<div className={trackClasses}>
<div className={thumbClasses} />
</div>
</button>
{label && <span className={labelClasses}>{label}</span>}
</div>
);
},
);
SwitchView.displayName = "SwitchView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Switch.container";
export type { SwitchProps } from "./Switch.types";
@@ -0,0 +1,185 @@
"use client";
import { memo, forwardRef } from "react";
import { useComponentId, useFormField } from "../../../hooks";
import { TextAreaView } from "./TextArea.view";
import type { TextAreaProps } from "./TextArea.types";
import { normalizeInputState, normalizeSmallMediumLargeSize, normalizeLabelVariant } from "../../../../lib/propNormalization";
const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
(
{
size: sizeProp = "medium",
labelVariant: labelVariantProp = "default",
state: stateProp = "default",
disabled = false,
error = false,
label,
placeholder,
value,
onChange,
onFocus,
onBlur,
id,
name,
className = "",
rows,
...props
},
ref,
) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const size = normalizeSmallMediumLargeSize(sizeProp);
const labelVariant = normalizeLabelVariant(labelVariantProp);
const state = normalizeInputState(stateProp);
// Generate unique ID for accessibility if not provided
const { id: textareaId, labelId } = useComponentId("textarea", id);
// Size variants with specific heights and radius for TextArea
const sizeStyles: Record<
string,
{
textarea: string;
label: string;
container: string;
radius: string;
}
> = {
small: {
textarea:
labelVariant === "horizontal"
? "h-[60px] px-[12px] py-[8px] text-[10px]"
: "h-[60px] px-[12px] py-[8px] text-[10px]",
label: "text-[12px] leading-[14px] font-medium",
container: "gap-[4px]",
radius: "var(--measures-radius-xsmall)",
},
medium: {
textarea:
labelVariant === "horizontal"
? "h-[110px] px-[12px] py-[8px] text-[14px] leading-[20px]"
: "h-[100px] px-[12px] py-[8px] text-[14px] leading-[20px]",
label: "text-[14px] leading-[16px] font-medium",
container: "gap-[8px]",
radius: "var(--measures-radius-xsmall)",
},
large: {
textarea: "h-[150px] px-[12px] py-[8px] text-[16px] leading-[24px]",
label: "text-[16px] leading-[20px] font-medium",
container: "gap-[12px]",
radius: "var(--measures-radius-small)",
},
};
// State styles
const getStateStyles = (): {
textarea: string;
label: string;
} => {
if (disabled) {
return {
textarea:
"bg-[var(--color-content-default-secondary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] cursor-not-allowed",
label: "text-[var(--color-content-default-secondary)]",
};
}
if (error) {
return {
textarea:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-utility-negative)]",
label: "text-[var(--color-content-default-secondary)]",
};
}
switch (state) {
case "active":
return {
textarea:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)]",
label: "text-[var(--color-content-default-secondary)]",
};
case "hover":
return {
textarea:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] shadow-[0_0_0_2px_var(--color-border-default-tertiary)]",
label: "text-[var(--color-content-default-secondary)]",
};
case "focus":
return {
textarea:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-utility-info)] shadow-[0_0_5px_3px_#3281F8]",
label: "text-[var(--color-content-default-secondary)]",
};
default:
return {
textarea:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] hover:shadow-[0_0_0_2px_var(--color-border-default-tertiary)]",
label: "text-[var(--color-content-default-secondary)]",
};
}
};
const stateStyles = getStateStyles();
const currentSize = sizeStyles[size];
// Container classes based on label variant
const containerClasses =
labelVariant === "horizontal"
? `flex items-center gap-[12px]`
: `flex flex-col ${currentSize.container}`;
const labelClasses =
labelVariant === "horizontal"
? `${currentSize.label} font-inter min-w-fit`
: `${currentSize.label} font-inter`;
const textareaClasses = `
w-full border transition-all duration-200 ease-in-out
focus:outline-none focus:ring-0 resize-none
${currentSize.textarea}
${stateStyles.textarea}
${className}
`.trim();
// Form field handlers with disabled state handling
const { handleChange, handleFocus, handleBlur } =
useFormField<HTMLTextAreaElement>(disabled, {
onChange,
onFocus,
onBlur,
});
return (
<TextAreaView
ref={ref}
textareaId={textareaId}
labelId={labelId}
size={size}
labelVariant={labelVariant}
state={state}
disabled={disabled}
error={error}
label={label}
placeholder={placeholder}
value={value}
name={name}
className={className}
rows={rows}
containerClasses={containerClasses}
labelClasses={labelClasses}
textareaClasses={textareaClasses}
borderRadius={currentSize.radius}
handleChange={handleChange}
handleFocus={handleFocus}
handleBlur={handleBlur}
aria-invalid={error}
{...props}
/>
);
},
);
TextAreaContainer.displayName = "TextArea";
export default memo(TextAreaContainer);
@@ -0,0 +1,58 @@
import type { InputStateValue } from "../../../../lib/propNormalization";
export type TextAreaSizeValue = "small" | "medium" | "large" | "Small" | "Medium" | "Large";
export type TextAreaLabelVariantValue = "default" | "horizontal" | "Default" | "Horizontal";
export interface TextAreaProps extends Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"size" | "onChange" | "onFocus" | "onBlur"
> {
/**
* Text area size. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
size?: TextAreaSizeValue;
/**
* Label variant. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
labelVariant?: TextAreaLabelVariantValue;
/**
* Visual state. Accepts "default"/"Default", "active"/"Active", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
state?: InputStateValue;
disabled?: boolean;
error?: boolean;
label?: string;
placeholder?: string;
value?: string;
onChange?: (_e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onFocus?: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
className?: string;
rows?: number;
}
export interface TextAreaViewProps {
textareaId: string;
labelId: string;
size: "small" | "medium" | "large";
labelVariant: "default" | "horizontal";
state: "default" | "active" | "hover" | "focus";
disabled: boolean;
error: boolean;
label?: string;
placeholder?: string;
value?: string;
name?: string;
className: string;
rows?: number;
containerClasses: string;
labelClasses: string;
textareaClasses: string;
borderRadius: string;
handleChange: (_e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleFocus: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
handleBlur: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
}
@@ -0,0 +1,62 @@
import { forwardRef } from "react";
import type { TextAreaViewProps } from "./TextArea.types";
export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
(
{
textareaId,
labelId,
label,
placeholder,
value,
name,
disabled,
className: _className,
rows,
containerClasses,
labelClasses,
textareaClasses,
borderRadius,
handleChange,
handleFocus,
handleBlur,
...props
},
ref,
) => {
return (
<div className={containerClasses}>
{label && (
<label
id={labelId}
htmlFor={textareaId}
className={`${labelClasses} font-inter font-medium text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
)}
<div className={disabled ? "opacity-40" : ""}>
<textarea
ref={ref}
id={textareaId}
name={name}
value={value}
placeholder={placeholder}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
rows={rows}
className={textareaClasses}
style={{ borderRadius }}
aria-disabled={disabled}
aria-invalid={props["aria-invalid"]}
{...props}
/>
</div>
</div>
);
},
);
TextAreaView.displayName = "TextAreaView";
@@ -0,0 +1,2 @@
export { default } from "./TextArea.container";
export type { TextAreaProps } from "./TextArea.types";
@@ -0,0 +1,231 @@
"use client";
import { memo, forwardRef, useState, useRef } from "react";
import { useComponentId, useFormField } from "../../../hooks";
import { TextInputView } from "./TextInput.view";
import type { TextInputProps } from "./TextInput.types";
import { normalizeInputState } from "../../../../lib/propNormalization";
const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
(
{
state: externalStateProp = "default",
disabled = false,
error = false,
label,
placeholder,
value,
onChange,
onFocus,
onBlur,
id,
name,
type = "text",
className = "",
showHelpIcon = true,
...props
},
ref,
) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const externalState = normalizeInputState(externalStateProp);
// Generate unique ID for accessibility if not provided
const { id: inputId, labelId } = useComponentId("text-input", id);
// Internal state management: track if focused and how (mouse vs keyboard)
const [isFocused, setIsFocused] = useState(false);
const [focusMethod, setFocusMethod] = useState<"mouse" | "keyboard" | null>(null);
const wasMouseDownRef = useRef(false);
// Determine if we should auto-manage focus (only when state is "default" or undefined)
// If state is "active", "hover", or "focus", respect it and don't override
const shouldAutoManageFocus = externalState === "default" || externalState === undefined;
// Determine actual state:
// - Active: when clicked (mouse focus)
// - Focus: when tabbed (keyboard focus)
// - Default: when not focused
const actualState = shouldAutoManageFocus
? isFocused
? focusMethod === "mouse"
? "active"
: "focus"
: "default"
: externalState;
// Determine if input is filled (has value)
const isFilled = Boolean(value && value.trim().length > 0);
// Fixed size styles (medium only per Figma designs)
const sizeStyles = {
input: "h-[40px] px-[12px] py-[8px] text-[16px]",
label: "text-[14px] leading-[20px] font-medium",
container: "gap-[8px]",
radius: "var(--measures-radius-200,8px)",
};
// State styles based on Figma designs
const getStateStyles = (): {
input: string;
label: string;
inputWrapper: string;
focusRing: string;
} => {
if (disabled) {
return {
input:
"bg-[var(--color-surface-default-secondary)] text-[var(--color-content-inverse-tertiary,#2d2d2d)] border border-solid border-[var(--color-border-default-primary)] cursor-not-allowed",
label: "text-[var(--color-content-default-secondary)]",
inputWrapper: "relative",
focusRing: "",
};
}
if (error) {
const filledStyles = isFilled
? "font-medium leading-[20px]"
: "font-normal leading-[24px]";
return {
input: `bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-2 border-solid border-[var(--color-border-default-utility-negative)] ${filledStyles}`,
label: "text-[var(--color-content-default-secondary)]",
inputWrapper: "relative",
focusRing: "",
};
}
switch (actualState) {
case "active": {
const filledStyles = isFilled
? "font-medium leading-[20px]"
: "font-normal leading-[24px]";
return {
input: `bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-2 border-solid border-[var(--color-border-default-tertiary)] ${filledStyles}`,
label: "text-[var(--color-content-default-secondary)]",
inputWrapper: "relative",
focusRing: "",
};
}
case "focus": {
const filledStyles = isFilled
? "font-medium leading-[20px]"
: "font-normal leading-[24px]";
return {
input: `bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border border-solid border-[var(--color-border-default-tertiary)] ${filledStyles}`,
label: "text-[var(--color-content-default-secondary)]",
inputWrapper: "relative",
focusRing:
"absolute border-2 border-solid border-[var(--color-border-inverse-primary)] inset-0 rounded-[var(--measures-radius-200,8px)] shadow-[0px_0px_0px_2px_var(--color-border-default-primary)] pointer-events-none",
};
}
default: {
const filledStyles = isFilled
? "font-medium leading-[20px]"
: "font-normal leading-[24px]";
// Default state uses primary border (matches Figma - border color same as background, so border is subtle)
return {
input: `bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border border-solid border-[var(--color-border-default-primary)] ${filledStyles}`,
label: "text-[var(--color-content-default-secondary)]",
inputWrapper: "relative",
focusRing: "",
};
}
}
};
const stateStyles = getStateStyles();
// Container classes (default label variant only)
const containerClasses = `flex flex-col ${sizeStyles.container}`;
const labelClasses = `${sizeStyles.label} font-inter`;
// Base classes without border (border is added in state styles)
const inputClasses = `
w-full transition-all duration-200 ease-in-out
focus:outline-none focus:ring-0
placeholder:text-[var(--color-content-default-tertiary,#b4b4b4)]
${sizeStyles.input}
${stateStyles.input}
${className}
`.trim();
// Text color for filled text (placeholder color is handled above)
const textColorClass = isFilled
? "text-[var(--color-content-default-primary)]"
: "text-[var(--color-content-default-tertiary,#b4b4b4)]";
// Form field handlers with disabled state handling
const { handleChange, handleBlur } = useFormField<HTMLInputElement>(disabled, {
onChange,
onBlur: (e) => {
if (shouldAutoManageFocus) {
setIsFocused(false);
setFocusMethod(null);
wasMouseDownRef.current = false;
}
onBlur?.(e);
},
});
// Handle mouse down to detect mouse clicks
const handleMouseDown = () => {
if (!disabled && shouldAutoManageFocus) {
wasMouseDownRef.current = true;
}
};
// Custom focus handler to detect mouse vs keyboard
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
if (disabled) return;
// Detect if focus came from keyboard (Tab) or mouse (click)
// If mouseDown was detected before focus, it's a mouse click (active)
// Otherwise, it's keyboard navigation (focus)
const method = wasMouseDownRef.current ? "mouse" : "keyboard";
if (shouldAutoManageFocus) {
setIsFocused(true);
setFocusMethod(method);
// Reset mouse down flag after focus is processed
wasMouseDownRef.current = false;
}
onFocus?.(e);
};
return (
<TextInputView
ref={ref}
inputId={inputId}
labelId={labelId}
state={actualState}
disabled={disabled}
error={error}
label={label}
placeholder={placeholder}
value={value}
name={name}
type={type}
className={className}
containerClasses={containerClasses}
labelClasses={labelClasses}
inputClasses={`${inputClasses} ${textColorClass}`}
borderRadius={sizeStyles.radius}
handleChange={handleChange}
handleFocus={handleFocus}
handleBlur={handleBlur}
handleMouseDown={handleMouseDown}
showHelpIcon={showHelpIcon}
isFilled={isFilled}
inputWrapperClasses={stateStyles.inputWrapper}
focusRingClasses={stateStyles.focusRing}
{...props}
/>
);
},
);
TextInputContainer.displayName = "TextInput";
export default memo(TextInputContainer);
@@ -0,0 +1,48 @@
import type { InputStateValue } from "../../../../lib/propNormalization";
export interface TextInputProps extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"size" | "onChange" | "onFocus" | "onBlur"
> {
/**
* Visual state. Accepts "default"/"Default", "active"/"Active", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
state?: InputStateValue;
disabled?: boolean;
error?: boolean;
label?: string;
placeholder?: string;
value?: string;
onChange?: (_e: React.ChangeEvent<HTMLInputElement>) => void;
onFocus?: (_e: React.FocusEvent<HTMLInputElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLInputElement>) => void;
className?: string;
showHelpIcon?: boolean;
}
export interface TextInputViewProps {
inputId: string;
labelId: string;
state: "default" | "active" | "hover" | "focus";
disabled: boolean;
error: boolean;
label?: string;
placeholder?: string;
value?: string;
name?: string;
type: string;
className: string;
containerClasses: string;
labelClasses: string;
inputClasses: string;
borderRadius: string;
handleChange: (_e: React.ChangeEvent<HTMLInputElement>) => void;
handleFocus: (_e: React.FocusEvent<HTMLInputElement>) => void;
handleBlur: (_e: React.FocusEvent<HTMLInputElement>) => void;
handleMouseDown?: () => void;
showHelpIcon?: boolean;
isFilled?: boolean;
inputWrapperClasses?: string;
focusRingClasses?: string;
}
@@ -0,0 +1,83 @@
import { forwardRef } from "react";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import type { TextInputViewProps } from "./TextInput.types";
export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
(
{
inputId,
labelId,
label,
placeholder,
value,
name,
type,
disabled,
error: _error,
className: _className,
containerClasses,
labelClasses,
inputClasses,
borderRadius,
handleChange,
handleFocus,
handleBlur,
handleMouseDown,
showHelpIcon = true,
inputWrapperClasses = "relative",
focusRingClasses = "",
},
ref,
) => {
return (
<div className={containerClasses}>
{label && (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
id={labelId}
htmlFor={inputId}
className={`${labelClasses} font-inter font-medium text-[var(--color-content-default-primary)]`}
>
{label}
</label>
{showHelpIcon && (
<div className="relative shrink-0 size-[12px]">
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt="Help"
className="block max-w-none size-full"
/>
</div>
)}
</div>
</div>
)}
<div className={inputWrapperClasses}>
<div className={disabled ? "opacity-40" : ""}>
<input
ref={ref}
id={inputId}
name={name}
type={type}
value={value}
placeholder={placeholder}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseDown={handleMouseDown}
disabled={disabled}
className={inputClasses}
style={{ borderRadius }}
/>
</div>
{focusRingClasses && (
<div className={focusRingClasses} aria-hidden="true" />
)}
</div>
</div>
);
},
);
TextInputView.displayName = "TextInputView";
@@ -0,0 +1,2 @@
export { default } from "./TextInput.container";
export type { TextInputProps } from "./TextInput.types";
@@ -0,0 +1,199 @@
"use client";
import { memo, useCallback, useId, forwardRef } from "react";
import { ToggleView } from "./Toggle.view";
import type { ToggleProps } from "./Toggle.types";
import { normalizeState } from "../../../../lib/propNormalization";
const ToggleContainer = forwardRef<HTMLButtonElement, ToggleProps>(
(
{
label,
checked = false,
onChange,
onFocus,
onBlur,
disabled = false,
state: stateProp = "default",
showIcon = false,
showText = false,
icon = "I",
text = "Toggle",
className = "",
...props
},
ref,
) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const state = normalizeState(stateProp);
const toggleId = useId();
const labelId = useId();
// Size styles - single size with specific dimensions
const sizeStyles = {
toggle: "h-[var(--measures-sizing-032)] px-[16px] py-[8px] gap-[4px]",
label: "text-[12px] leading-[16px]",
};
// State styles
const getStateStyles = (): {
toggle: string;
label: string;
} => {
if (disabled) {
return {
toggle:
"bg-[var(--color-surface-default-tertiary)] text-[var(--color-content-default-tertiary)] cursor-not-allowed",
label: "text-[var(--color-content-default-secondary)]",
};
}
if (checked) {
switch (state) {
case "hover":
return {
toggle:
"bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)]",
label: "text-[var(--color-content-default-secondary)]",
};
case "focus":
return {
toggle:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] shadow-[0_0_5px_1px_#3281F8]",
label: "text-[var(--color-content-default-secondary)]",
};
default:
return {
toggle:
"bg-[var(--color-magenta-magenta100)] text-[var(--color-content-default-primary)] shadow-[0_0_0_1px_var(--color-border-default-brand-primary)]",
label: "text-[var(--color-content-default-secondary)]",
};
}
} else {
switch (state) {
case "hover":
return {
toggle:
"bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)]",
label: "text-[var(--color-content-default-secondary)]",
};
case "focus":
return {
toggle:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] shadow-[0_0_5px_1px_#3281F8]",
label: "text-[var(--color-content-default-secondary)]",
};
default:
return {
toggle:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)]",
label: "text-[var(--color-content-default-secondary)]",
};
}
}
};
const stateStyles = getStateStyles();
const currentSize = sizeStyles;
// Container classes
const containerClasses = "flex flex-col gap-[4px]";
const labelClasses = `${currentSize.label} font-inter font-medium`;
const toggleClasses = `
${currentSize.toggle}
${stateStyles.toggle}
rounded-full
font-inter
font-normal
text-[12px]
leading-[16px]
cursor-pointer
transition-all
duration-200
focus:outline-none
focus-visible:shadow-[0_0_5px_1px_#3281F8]
${!checked ? "hover:!bg-[var(--color-surface-default-secondary)]" : ""}
flex
items-center
justify-center
gap-[4px]
${className}
`
.trim()
.replace(/\s+/g, " ");
const handleChange = useCallback(
(
e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>,
) => {
if (!disabled && onChange) {
onChange(e);
}
},
[disabled, onChange],
);
const handleFocus = useCallback(
(e: React.FocusEvent<HTMLButtonElement>) => {
if (!disabled && onFocus) {
onFocus(e);
}
},
[disabled, onFocus],
);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLButtonElement>) => {
if (!disabled && onBlur) {
onBlur(e);
}
},
[disabled, onBlur],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLButtonElement>) => {
if (!disabled && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
if (onChange) {
onChange(e);
}
}
},
[disabled, onChange],
);
return (
<ToggleView
ref={ref}
toggleId={toggleId}
labelId={labelId}
checked={checked}
disabled={disabled}
state={state}
label={label}
showIcon={showIcon}
showText={showText}
icon={icon}
text={text}
className={className}
containerClasses={containerClasses}
labelClasses={labelClasses}
toggleClasses={toggleClasses}
onClick={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
/>
);
},
);
ToggleContainer.displayName = "Toggle";
export default memo(ToggleContainer);
@@ -0,0 +1,52 @@
import type { StateValue } from "../../../../lib/propNormalization";
export interface ToggleProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onChange"
> {
label?: string;
checked?: boolean;
onChange?: (
_e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>,
) => void;
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
disabled?: boolean;
/**
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
state?: StateValue;
showIcon?: boolean;
showText?: boolean;
icon?: string;
text?: string;
className?: string;
}
export interface ToggleViewProps {
toggleId: string;
labelId: string;
checked: boolean;
disabled: boolean;
state: "default" | "hover" | "focus" | "selected";
label?: string;
showIcon: boolean;
showText: boolean;
icon: string;
text: string;
className: string;
containerClasses: string;
labelClasses: string;
toggleClasses: string;
onClick: (
_e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>,
) => void;
onKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
onFocus: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur: (_e: React.FocusEvent<HTMLButtonElement>) => void;
}
@@ -0,0 +1,63 @@
import { forwardRef } from "react";
import type { ToggleViewProps } from "./Toggle.types";
export const ToggleView = forwardRef<HTMLButtonElement, ToggleViewProps>(
(
{
toggleId,
labelId,
checked,
disabled,
label,
showIcon,
showText,
icon,
text,
containerClasses,
labelClasses,
toggleClasses,
onClick,
onKeyDown,
onFocus,
onBlur,
...rest
},
ref,
) => {
return (
<div className={containerClasses}>
{label && (
<label
id={labelId}
htmlFor={toggleId}
className={`${labelClasses} text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
)}
<div className={disabled ? "opacity-40" : ""}>
<button
ref={ref}
id={toggleId}
type="button"
role="switch"
aria-checked={checked}
aria-labelledby={label ? labelId : undefined}
disabled={disabled}
onClick={onClick}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
className={toggleClasses}
{...rest}
>
{showIcon && <span className="italic">{icon}</span>}
{showText && <span>{text}</span>}
</button>
</div>
</div>
);
},
);
ToggleView.displayName = "ToggleView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Toggle.container";
export type { ToggleProps } from "./Toggle.types";
@@ -0,0 +1,147 @@
"use client";
import { memo, useCallback, useId, forwardRef } from "react";
import { ToggleGroupView } from "./ToggleGroup.view";
import type { ToggleGroupProps } from "./ToggleGroup.types";
import { normalizeToggleState, normalizeToggleGroupPosition } from "../../../../lib/propNormalization";
const ToggleGroupContainer = memo(
forwardRef<HTMLButtonElement, ToggleGroupProps>((props, _ref) => {
const {
children,
className = "",
position: positionProp = "left",
state: stateProp = "default",
showText = true,
ariaLabel,
onChange,
onFocus,
onBlur,
...rest
} = props;
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const position = normalizeToggleGroupPosition(positionProp);
const state = normalizeToggleState(stateProp);
const groupId = useId();
// Position-based styling for border radius
const getPositionStyles = useCallback((pos: string): string => {
switch (pos) {
case "left":
return "rounded-l-[var(--measures-radius-medium)] rounded-r-none";
case "middle":
return "rounded-none";
case "right":
return "rounded-r-[var(--measures-radius-medium)] rounded-l-none";
default:
return "rounded-[var(--measures-radius-medium)]";
}
}, []);
// State-based styling
const getStateStyles = useCallback((state: string): string => {
switch (state) {
case "hover":
return "bg-[var(--color-magenta-magenta100)] text-[var(--color-content-default-primary)]";
case "focus":
return "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] shadow-[0_0_5px_1px_#3281F8]";
case "selected":
return "bg-[var(--color-magenta-magenta100)] text-[var(--color-content-default-primary)] shadow-[inset_0_0_0_1px_var(--color-border-default-secondary)]";
case "default":
default:
return "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)]";
}
}, []);
const positionStyles = getPositionStyles(position);
const stateStyles = getStateStyles(state);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
if (onChange) {
onChange(e);
}
},
[onChange],
);
const handleFocus = useCallback(
(e: React.FocusEvent<HTMLButtonElement>) => {
if (onFocus) {
onFocus(e);
}
},
[onFocus],
);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLButtonElement>) => {
if (onBlur) {
onBlur(e);
}
},
[onBlur],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (onChange) {
onChange(e);
}
}
},
[onChange],
);
const toggleClasses = `
${positionStyles}
${stateStyles}
py-[var(--measures-spacing-008)]
px-[var(--measures-spacing-008)]
gap-[var(--measures-spacing-008)]
font-inter
font-medium
text-[12px]
leading-[12px]
cursor-pointer
transition-all
duration-200
focus:outline-none
focus-visible:shadow-[0_0_5px_1px_#3281F8]
hover:bg-[var(--color-magenta-magenta100)]
flex
items-center
justify-center
${className}
`
.trim()
.replace(/\s+/g, " ");
return (
<ToggleGroupView
groupId={groupId}
className={className}
position={position}
state={state}
showText={showText}
ariaLabel={ariaLabel}
toggleClasses={toggleClasses}
onClick={handleClick}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
{...rest}
>
{children}
</ToggleGroupView>
);
}),
);
ToggleGroupContainer.displayName = "ToggleGroup";
export default ToggleGroupContainer;
@@ -0,0 +1,45 @@
import type { StateValue } from "../../../../lib/propNormalization";
export type ToggleGroupPositionValue = "left" | "middle" | "right" | "Left" | "Middle" | "Right";
export interface ToggleGroupProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onChange"
> {
children?: React.ReactNode;
className?: string;
/**
* Toggle group position. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
position?: ToggleGroupPositionValue;
/**
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus", "selected"/"Selected" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
state?: StateValue | "selected" | "Selected";
showText?: boolean;
ariaLabel?: string;
onChange?: (
_e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>,
) => void;
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
}
export interface ToggleGroupViewProps {
groupId: string;
children?: React.ReactNode;
className: string;
position: "left" | "middle" | "right";
state: "default" | "hover" | "focus" | "selected";
showText: boolean;
ariaLabel?: string;
toggleClasses: string;
onClick: (_e: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
onFocus: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur: (_e: React.FocusEvent<HTMLButtonElement>) => void;
}
@@ -0,0 +1,34 @@
import type { ToggleGroupViewProps } from "./ToggleGroup.types";
export function ToggleGroupView({
groupId,
children,
className: _className,
position: _position,
state: _state,
showText,
ariaLabel,
toggleClasses,
onClick,
onKeyDown,
onFocus,
onBlur,
...rest
}: ToggleGroupViewProps) {
return (
<button
id={groupId}
type="button"
role="button"
aria-label={ariaLabel || (showText ? undefined : "Toggle option")}
onClick={onClick}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
className={toggleClasses}
{...rest}
>
{showText ? children : children || "☰"}
</button>
);
}
@@ -0,0 +1,2 @@
export { default } from "./ToggleGroup.container";
export type { ToggleGroupProps } from "./ToggleGroup.types";