Update select input component

This commit is contained in:
adilallo
2026-02-04 13:10:14 -07:00
parent 255f16477c
commit 0e7985287f
5 changed files with 285 additions and 410 deletions
@@ -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}