Start organizational migration
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user