Update select input component
This commit is contained in:
@@ -22,12 +22,10 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||
{
|
||||
id,
|
||||
label,
|
||||
labelVariant = "default",
|
||||
size = "medium",
|
||||
state = "default",
|
||||
state: externalState = "default",
|
||||
disabled = false,
|
||||
error = false,
|
||||
placeholder = "Select an option",
|
||||
placeholder = "Choose an option",
|
||||
className = "",
|
||||
children,
|
||||
value,
|
||||
@@ -45,6 +43,14 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||
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) {
|
||||
@@ -69,7 +75,6 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||
if (onChange) {
|
||||
onChange({ target: { value: optionValue, text: optionText } });
|
||||
}
|
||||
// Return focus to the select button for accessibility
|
||||
if (selectRef.current) {
|
||||
selectRef.current.focus();
|
||||
}
|
||||
@@ -77,6 +82,13 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||
[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) {
|
||||
@@ -99,145 +111,47 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||
[disabled, isOpen],
|
||||
);
|
||||
|
||||
const getSizeStyles = (): string => {
|
||||
const baseStyles = "w-full";
|
||||
// Handle focus to detect mouse vs keyboard
|
||||
const handleFocus = useCallback(() => {
|
||||
if (disabled) return;
|
||||
|
||||
switch (size) {
|
||||
case "small": {
|
||||
const smallHeight =
|
||||
labelVariant === "horizontal" ? "h-[30px]" : "h-[32px]";
|
||||
return `${baseStyles} ${smallHeight} pl-[12px] pr-[36px] py-[8px] text-[10px] leading-[14px]`;
|
||||
}
|
||||
case "medium":
|
||||
return `${baseStyles} h-[36px] pl-[12px] pr-[36px] py-[8px] text-[14px] leading-[20px]`;
|
||||
case "large":
|
||||
return `${baseStyles} h-[40px] pl-[12px] pr-[40px] py-[8px] text-[16px] leading-[24px]`;
|
||||
default:
|
||||
return `${baseStyles} h-[36px] pl-[12px] pr-[36px] py-[8px] text-[14px] leading-[20px]`;
|
||||
const method = wasMouseDownRef.current ? "mouse" : "keyboard";
|
||||
|
||||
if (shouldAutoManageFocus) {
|
||||
setIsFocused(true);
|
||||
setFocusMethod(method);
|
||||
wasMouseDownRef.current = false;
|
||||
}
|
||||
};
|
||||
}, [disabled, shouldAutoManageFocus]);
|
||||
|
||||
const getLabelSizeStyles = (): string => {
|
||||
switch (size) {
|
||||
case "small":
|
||||
return "text-[12px] leading-[14px]";
|
||||
case "medium":
|
||||
return "text-[14px] leading-[16px]";
|
||||
case "large":
|
||||
return "text-[16px] leading-[20px]";
|
||||
default:
|
||||
return "text-[14px] leading-[16px]";
|
||||
// Handle blur
|
||||
const handleBlur = useCallback(() => {
|
||||
if (shouldAutoManageFocus) {
|
||||
setIsFocused(false);
|
||||
setFocusMethod(null);
|
||||
wasMouseDownRef.current = false;
|
||||
}
|
||||
};
|
||||
}, [shouldAutoManageFocus]);
|
||||
|
||||
const getStateStyles = (): {
|
||||
select: string;
|
||||
label: string;
|
||||
} => {
|
||||
if (disabled) {
|
||||
return {
|
||||
select:
|
||||
"bg-[var(--color-content-default-secondary)] border-[var(--color-border-default-tertiary)] cursor-not-allowed opacity-40",
|
||||
label: "text-[var(--color-content-default-secondary)]",
|
||||
};
|
||||
}
|
||||
// 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;
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
select: "border-[var(--color-border-default-utility-negative)]",
|
||||
label: "text-[var(--color-content-default-secondary)]",
|
||||
};
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case "hover":
|
||||
return {
|
||||
select:
|
||||
"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 {
|
||||
select:
|
||||
"border-[var(--color-border-default-utility-info)] shadow-[0_0_5px_3px_#3281F8]",
|
||||
label: "text-[var(--color-content-default-secondary)]",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
select: "border-[var(--color-border-default-tertiary)]",
|
||||
label: "text-[var(--color-content-default-secondary)]",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getBorderRadius = (): string => {
|
||||
switch (size) {
|
||||
case "small":
|
||||
return "rounded-[var(--measures-radius-small)]";
|
||||
case "medium":
|
||||
return "rounded-[var(--measures-radius-medium)]";
|
||||
case "large":
|
||||
return "rounded-[var(--measures-radius-large)]";
|
||||
default:
|
||||
return "rounded-[var(--measures-radius-medium)]";
|
||||
}
|
||||
};
|
||||
|
||||
const sizeStyles = getSizeStyles();
|
||||
const labelSizeStyles = getLabelSizeStyles();
|
||||
const stateStyles = getStateStyles();
|
||||
const borderRadius = getBorderRadius();
|
||||
|
||||
const selectClasses = `
|
||||
${sizeStyles}
|
||||
${stateStyles.select}
|
||||
${borderRadius}
|
||||
bg-[var(--color-background-default-primary)]
|
||||
text-[var(--color-content-default-primary)]
|
||||
border
|
||||
font-inter
|
||||
font-normal
|
||||
appearance-none
|
||||
cursor-pointer
|
||||
transition-all
|
||||
duration-200
|
||||
focus:outline-none
|
||||
focus-visible:border focus-visible:border-[var(--color-border-default-utility-info)] focus-visible:shadow-[0_0_5px_3px_#3281F8]
|
||||
text-left
|
||||
justify-start
|
||||
hover:shadow-[0_0_0_2px_var(--color-border-default-tertiary)]
|
||||
${className}
|
||||
`
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
|
||||
const labelClasses = `
|
||||
${labelSizeStyles}
|
||||
${stateStyles.label}
|
||||
font-inter
|
||||
font-medium
|
||||
block
|
||||
mb-[4px]
|
||||
`
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
|
||||
const containerClasses =
|
||||
labelVariant === "horizontal"
|
||||
? "flex items-center gap-[12px]"
|
||||
: "flex flex-col";
|
||||
|
||||
const chevronClasses = `${
|
||||
size === "large" ? "w-5 h-5" : "w-4 h-4"
|
||||
} text-[var(--color-content-default-primary)] transition-transform duration-200 ${
|
||||
isOpen ? "rotate-180" : ""
|
||||
}`;
|
||||
// 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;
|
||||
|
||||
// Handle options prop
|
||||
if (options && Array.isArray(options)) {
|
||||
const selectedOption = options.find(
|
||||
(option) => option.value === selectedValue,
|
||||
@@ -245,7 +159,6 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||
return selectedOption ? selectedOption.label : placeholder;
|
||||
}
|
||||
|
||||
// Handle children (option elements)
|
||||
const selectedOption = Children.toArray(children).find(
|
||||
(
|
||||
child,
|
||||
@@ -270,11 +183,9 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||
<SelectInputView
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
size={size}
|
||||
state={state}
|
||||
state={actualState}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
labelVariant={labelVariant}
|
||||
className={className}
|
||||
options={options}
|
||||
selectId={selectId}
|
||||
@@ -282,12 +193,12 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||
isOpen={isOpen}
|
||||
selectedValue={selectedValue}
|
||||
displayText={getDisplayText()}
|
||||
selectClasses={selectClasses}
|
||||
labelClasses={labelClasses}
|
||||
containerClasses={containerClasses}
|
||||
chevronClasses={chevronClasses}
|
||||
isFilled={isFilled}
|
||||
onButtonClick={handleSelectClick}
|
||||
onButtonKeyDown={handleKeyDown}
|
||||
onButtonMouseDown={handleMouseDown}
|
||||
onButtonFocus={handleFocus}
|
||||
onButtonBlur={handleBlur}
|
||||
onOptionClick={handleOptionSelect}
|
||||
selectRef={selectRef}
|
||||
menuRef={menuRef}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -6,11 +7,9 @@ import type { SelectOptionData } from "./SelectInput.types";
|
||||
export interface SelectInputViewProps {
|
||||
label?: string;
|
||||
placeholder: string;
|
||||
size: "small" | "medium" | "large";
|
||||
state: "default" | "hover" | "focus";
|
||||
state: "default" | "active" | "hover" | "focus";
|
||||
disabled: boolean;
|
||||
error: boolean;
|
||||
labelVariant: "default" | "horizontal";
|
||||
className: string;
|
||||
options?: SelectOptionData[];
|
||||
children?: ReactNode;
|
||||
@@ -20,13 +19,13 @@ export interface SelectInputViewProps {
|
||||
isOpen: boolean;
|
||||
selectedValue: string;
|
||||
displayText: string;
|
||||
selectClasses: string;
|
||||
labelClasses: string;
|
||||
containerClasses: string;
|
||||
chevronClasses: 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>;
|
||||
@@ -39,10 +38,9 @@ export interface SelectInputViewProps {
|
||||
export function SelectInputView({
|
||||
label,
|
||||
placeholder: _placeholder,
|
||||
size,
|
||||
state,
|
||||
disabled,
|
||||
error: _error,
|
||||
labelVariant: _labelVariant,
|
||||
error,
|
||||
options,
|
||||
children,
|
||||
selectId,
|
||||
@@ -50,12 +48,12 @@ export function SelectInputView({
|
||||
isOpen,
|
||||
selectedValue,
|
||||
displayText,
|
||||
selectClasses,
|
||||
labelClasses,
|
||||
containerClasses,
|
||||
chevronClasses,
|
||||
isFilled,
|
||||
onButtonClick,
|
||||
onButtonKeyDown,
|
||||
onButtonMouseDown,
|
||||
onButtonFocus,
|
||||
onButtonBlur,
|
||||
onOptionClick,
|
||||
selectRef,
|
||||
menuRef,
|
||||
@@ -63,48 +61,132 @@ export function SelectInputView({
|
||||
ariaInvalid,
|
||||
...props
|
||||
}: 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 && (
|
||||
<label
|
||||
id={labelId}
|
||||
htmlFor={selectId}
|
||||
className={`${labelClasses} text-[var(--color-content-default-secondary)]`}
|
||||
>
|
||||
{label}
|
||||
</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={selectClasses}
|
||||
className={buttonClasses}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
onClick={onButtonClick}
|
||||
onKeyDown={onButtonKeyDown}
|
||||
{...props}
|
||||
onMouseDown={onButtonMouseDown}
|
||||
onFocus={onButtonFocus}
|
||||
onBlur={onButtonBlur}
|
||||
>
|
||||
<span className="text-left">{displayText}</span>
|
||||
<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>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-[12px] pointer-events-none">
|
||||
<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>
|
||||
{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
|
||||
@@ -117,7 +199,7 @@ export function SelectInputView({
|
||||
<SelectOption
|
||||
key={option.value}
|
||||
selected={option.value === selectedValue}
|
||||
size={size}
|
||||
size="medium"
|
||||
onClick={() => onOptionClick(option.value, option.label)}
|
||||
>
|
||||
{option.label}
|
||||
@@ -136,7 +218,7 @@ export function SelectInputView({
|
||||
<SelectOption
|
||||
key={optionProps.value}
|
||||
selected={optionProps.value === selectedValue}
|
||||
size={size}
|
||||
size="medium"
|
||||
onClick={() =>
|
||||
onOptionClick(
|
||||
optionProps.value,
|
||||
|
||||
Reference in New Issue
Block a user