From 0e7985287fd13f502dce0e929a6bb8ee5748dba9 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:10:14 -0700 Subject: [PATCH] Update select input component --- app/components-preview/page.tsx | 87 +----- .../SelectInput/SelectInput.container.tsx | 193 ++++---------- .../SelectInput/SelectInput.view.tsx | 164 +++++++++--- stories/SelectInput.stories.js | 247 +++++++----------- tests/components/SelectInput.test.tsx | 4 +- 5 files changed, 285 insertions(+), 410 deletions(-) diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index 1d3346d..8998bfc 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -75,55 +75,11 @@ export default function ComponentsPreview() {
-
-

- All Sizes -

-
- setSelectValue(data.target.value)} - options={[ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - { value: "option3", label: "Option 3" }, - ]} - /> - setSelectValue(data.target.value)} - options={[ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - { value: "option3", label: "Option 3" }, - ]} - /> - setSelectValue(data.target.value)} - options={[ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - { value: "option3", label: "Option 3" }, - ]} - /> -
-
-

States

-
+
+ setSelectValue(data.target.value)} + options={[ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + { value: "option3", label: "Option 3" }, + ]} + />
- -
-

- Label Variants -

-
- - -
-
diff --git a/app/components/SelectInput/SelectInput.container.tsx b/app/components/SelectInput/SelectInput.container.tsx index 7a179bc..0449652 100644 --- a/app/components/SelectInput/SelectInput.container.tsx +++ b/app/components/SelectInput/SelectInput.container.tsx @@ -22,12 +22,10 @@ const SelectInputContainer = forwardRef( { 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( const selectRef = useRef(null); const menuRef = useRef(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( 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( [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( [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( return selectedOption ? selectedOption.label : placeholder; } - // Handle children (option elements) const selectedOption = Children.toArray(children).find( ( child, @@ -270,11 +183,9 @@ const SelectInputContainer = forwardRef( ( 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} diff --git a/app/components/SelectInput/SelectInput.view.tsx b/app/components/SelectInput/SelectInput.view.tsx index ad49ad3..a18035e 100644 --- a/app/components/SelectInput/SelectInput.view.tsx +++ b/app/components/SelectInput/SelectInput.view.tsx @@ -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) => void; + onButtonMouseDown?: () => void; + onButtonFocus?: () => void; + onButtonBlur?: () => void; onOptionClick: (_value: string, _text: string) => void; // Refs selectRef: React.RefObject; @@ -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 (
{label && ( - +
+
+ +
+ Help +
+
+
)}
-
- - - -
+ {state === "focus" && ( +