From 76a8ab7929e6875808a1e7046bd5768e4878ccdc Mon Sep 17 00:00:00 2001 From: glorydavid03023 Date: Wed, 29 Apr 2026 14:44:18 +0900 Subject: [PATCH] fix-trigger-hour-dropdown --- src/components/ui/input-select.tsx | 404 ++++++++++++++++------------- 1 file changed, 223 insertions(+), 181 deletions(-) diff --git a/src/components/ui/input-select.tsx b/src/components/ui/input-select.tsx index d8fec3558..8f1c6240f 100644 --- a/src/components/ui/input-select.tsx +++ b/src/components/ui/input-select.tsx @@ -1,83 +1,100 @@ -import * as React from "react" -import { Check, ChevronDown, CircleAlert } from "lucide-react" -import { cn } from "@/lib/utils" -import { TooltipSimple } from "@/components/ui/tooltip" - -export type InputSelectSize = "default" | "sm" -export type InputSelectState = "error" | "success" +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { TooltipSimple } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { Check, ChevronDown, CircleAlert } from 'lucide-react'; +import * as React from 'react'; + +export type InputSelectSize = 'default' | 'sm'; +export type InputSelectState = 'error' | 'success'; type InputSelectOption = { - value: string - label: string -} + value: string; + label: string; +}; type InputSelectProps = { - value: string - onChange: (value: string) => void - options: InputSelectOption[] - placeholder?: string - title?: string - tooltip?: string - note?: string - errorNote?: string - required?: boolean - disabled?: boolean - size?: InputSelectSize - state?: InputSelectState - leadingIcon?: React.ReactNode - className?: string - maxDropdownHeight?: number + value: string; + onChange: (value: string) => void; + options: InputSelectOption[]; + placeholder?: string; + title?: string; + tooltip?: string; + note?: string; + errorNote?: string; + required?: boolean; + disabled?: boolean; + size?: InputSelectSize; + state?: InputSelectState; + leadingIcon?: React.ReactNode; + className?: string; + maxDropdownHeight?: number; /** * Called when an option is selected from the dropdown */ - onOptionSelect?: (option: InputSelectOption) => void + onOptionSelect?: (option: InputSelectOption) => void; /** * Custom validation function for the input value * Returns true if valid, false if invalid * If returns false, component will show error state */ - validateInput?: (value: string) => boolean + validateInput?: (value: string) => boolean; /** * Called when input loses focus or dropdown closes * Use this to transform/normalize the input value * Return the normalized value, or undefined to keep as-is * Return false to indicate validation failure (will show error state) */ - onInputCommit?: (value: string) => string | false | void -} + onInputCommit?: (value: string) => string | false | void; +}; const sizeClasses: Record = { - default: "h-10 text-body-sm", - sm: "h-8 text-body-sm", -} - -function resolveStateClasses(state: InputSelectState | undefined, disabled: boolean) { + default: 'h-10 text-body-sm', + sm: 'h-8 text-body-sm', +}; + +function resolveStateClasses( + state: InputSelectState | undefined, + disabled: boolean +) { if (disabled) { return { - wrapper: "opacity-50 cursor-not-allowed", - container: "border-transparent bg-input-bg-default", - note: "text-text-label", - } + wrapper: 'opacity-50 cursor-not-allowed', + container: 'border-transparent bg-input-bg-default', + note: 'text-text-label', + }; } - if (state === "error") { + if (state === 'error') { return { - wrapper: "", - container: "border-input-border-cuation bg-input-bg-default", - note: "text-text-cuation", - } + wrapper: '', + container: 'border-input-border-cuation bg-input-bg-default', + note: 'text-text-cuation', + }; } - if (state === "success") { + if (state === 'success') { return { - wrapper: "", - container: "border-input-border-success bg-input-bg-confirm", - note: "text-text-success", - } + wrapper: '', + container: 'border-input-border-success bg-input-bg-confirm', + note: 'text-text-success', + }; } return { - wrapper: "", - container: "border-transparent bg-input-bg-default", - note: "text-text-label", - } + wrapper: '', + container: 'border-transparent bg-input-bg-default', + note: 'text-text-label', + }; } const InputSelect = React.forwardRef( @@ -93,7 +110,7 @@ const InputSelect = React.forwardRef( errorNote, required = false, disabled = false, - size = "default", + size = 'default', state, leadingIcon, className, @@ -104,207 +121,227 @@ const InputSelect = React.forwardRef( }, ref ) => { - const [isOpen, setIsOpen] = React.useState(false) + const [isOpen, setIsOpen] = React.useState(false); const [inputValue, setInputValue] = React.useState(() => { - const option = options.find((opt) => opt.value === value) - return option ? option.label : value - }) - const [hasError, setHasError] = React.useState(false) - const containerRef = React.useRef(null) - const inputRef = React.useRef(null) - const dropdownRef = React.useRef(null) - const isCommittingRef = React.useRef(false) - const optionSelectedRef = React.useRef(false) - const inputValueRef = React.useRef(inputValue) + const option = options.find((opt) => opt.value === value); + return option ? option.label : value; + }); + const [hasError, setHasError] = React.useState(false); + const containerRef = React.useRef(null); + const inputRef = React.useRef(null); + const dropdownRef = React.useRef(null); + const isCommittingRef = React.useRef(false); + const optionSelectedRef = React.useRef(false); + const inputValueRef = React.useRef(inputValue); + const interactingWithDropdownRef = React.useRef(false); // Merge refs - React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement) + React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); // Keep inputValueRef in sync with inputValue React.useEffect(() => { - inputValueRef.current = inputValue - }, [inputValue]) + inputValueRef.current = inputValue; + }, [inputValue]); // Sync input value with external value React.useEffect(() => { - const option = options.find((opt) => opt.value === value) - setInputValue(option ? option.label : value) - setHasError(false) // Clear error when external value changes - }, [value, options]) + const option = options.find((opt) => opt.value === value); + setInputValue(option ? option.label : value); + setHasError(false); // Clear error when external value changes + }, [value, options]); // Commit value function - validates and saves input const commitValue = React.useCallback(() => { // Skip commit if an option was just selected (it handles its own update) if (optionSelectedRef.current) { - optionSelectedRef.current = false - return + optionSelectedRef.current = false; + return; } // Prevent double commits - if (isCommittingRef.current) return - isCommittingRef.current = true + if (isCommittingRef.current) return; + isCommittingRef.current = true; // Use ref to get the latest inputValue (avoids stale closure issue) - const currentInputValue = inputValueRef.current - let finalValue = currentInputValue + const currentInputValue = inputValueRef.current; + let finalValue = currentInputValue; // Run custom commit handler if provided if (onInputCommit) { - const result = onInputCommit(currentInputValue) + const result = onInputCommit(currentInputValue); if (result === false) { // Validation failed - show error state - setHasError(true) - isCommittingRef.current = false - return + setHasError(true); + isCommittingRef.current = false; + return; } - if (typeof result === "string") { - finalValue = result - setInputValue(result) + if (typeof result === 'string') { + finalValue = result; + setInputValue(result); } } // Validate if validator is provided if (validateInput && !validateInput(finalValue)) { - setHasError(true) - isCommittingRef.current = false - return + setHasError(true); + isCommittingRef.current = false; + return; } // Validation passed - clear error and update value - setHasError(false) + setHasError(false); if (finalValue !== value) { - onChange(finalValue) + onChange(finalValue); } - isCommittingRef.current = false - }, [value, onInputCommit, validateInput, onChange]) + isCommittingRef.current = false; + }, [value, onInputCommit, validateInput, onChange]); // Handle click outside to close dropdown React.useEffect(() => { - if (!isOpen) return + if (!isOpen) return; const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Node + const target = event.target as Node; if ( containerRef.current && !containerRef.current.contains(target) && dropdownRef.current && !dropdownRef.current.contains(target) ) { - commitValue() - setIsOpen(false) + commitValue(); + setIsOpen(false); } - } + }; const handleEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setInputValue(value) // Reset to original value - setIsOpen(false) - inputRef.current?.blur() + if (event.key === 'Escape') { + setInputValue(value); // Reset to original value + setIsOpen(false); + inputRef.current?.blur(); } - } + }; // Use capture phase to handle events before they bubble - document.addEventListener("mousedown", handleClickOutside, true) - document.addEventListener("keydown", handleEscape, true) + document.addEventListener('mousedown', handleClickOutside, true); + document.addEventListener('keydown', handleEscape, true); return () => { - document.removeEventListener("mousedown", handleClickOutside, true) - document.removeEventListener("keydown", handleEscape, true) - } - }, [isOpen, value, commitValue]) + document.removeEventListener('mousedown', handleClickOutside, true); + document.removeEventListener('keydown', handleEscape, true); + }; + }, [isOpen, value, commitValue]); + + // Reset dropdown interaction flag on mouse release. + React.useEffect(() => { + if (!isOpen) return; + const handleMouseUp = () => { + interactingWithDropdownRef.current = false; + }; + document.addEventListener('mouseup', handleMouseUp, true); + return () => { + document.removeEventListener('mouseup', handleMouseUp, true); + }; + }, [isOpen]); // Handle scroll within dropdown - prevent parent scroll only when necessary - const handleDropdownWheel = React.useCallback((e: React.WheelEvent) => { - const target = e.currentTarget - const { scrollTop, scrollHeight, clientHeight } = target - const hasScrollableContent = scrollHeight > clientHeight - - if (!hasScrollableContent) { - // No scrollable content, prevent all scroll and stop propagation - e.preventDefault() - e.stopPropagation() - return - } + const handleDropdownWheel = React.useCallback( + (e: React.WheelEvent) => { + const target = e.currentTarget; + const { scrollTop, scrollHeight, clientHeight } = target; + const hasScrollableContent = scrollHeight > clientHeight; + + if (!hasScrollableContent) { + // No scrollable content, prevent all scroll and stop propagation + e.preventDefault(); + e.stopPropagation(); + return; + } - const isAtTop = scrollTop <= 0 - const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1 // -1 for rounding + const isAtTop = scrollTop <= 0; + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1; // -1 for rounding - // If at boundary and trying to scroll past it, prevent default to stop parent from scrolling - if ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0)) { - e.preventDefault() - } + // If at boundary and trying to scroll past it, prevent default to stop parent from scrolling + if ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0)) { + e.preventDefault(); + } - // Always stop propagation to prevent parent dialog from scrolling - e.stopPropagation() - }, []) + // Always stop propagation to prevent parent dialog from scrolling + e.stopPropagation(); + }, + [] + ); const handleInputChange = (e: React.ChangeEvent) => { - setInputValue(e.target.value) + setInputValue(e.target.value); // Clear error when user starts typing again if (hasError) { - setHasError(false) + setHasError(false); } - } + }; const handleInputFocus = () => { if (!disabled) { - setIsOpen(true) + setIsOpen(true); } - } + }; const handleInputBlur = () => { // Use setTimeout to allow option clicks to process first // If user clicks an option, the option handler will update the value // If user clicks outside, we commit the current input value setTimeout(() => { - commitValue() - setIsOpen(false) - }, 150) - } + if (interactingWithDropdownRef.current) { + return; + } + commitValue(); + setIsOpen(false); + }, 150); + }; const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault() - commitValue() - setIsOpen(false) - inputRef.current?.blur() - } else if (e.key === "ArrowDown" && !isOpen) { - e.preventDefault() - setIsOpen(true) + if (e.key === 'Enter') { + e.preventDefault(); + commitValue(); + setIsOpen(false); + inputRef.current?.blur(); + } else if (e.key === 'ArrowDown' && !isOpen) { + e.preventDefault(); + setIsOpen(true); } - } + }; const handleOptionClick = (option: InputSelectOption) => { // Mark that an option was selected so blur handler skips commit - optionSelectedRef.current = true - setInputValue(option.label) - inputValueRef.current = option.label // Update ref immediately - setHasError(false) // Clear error when selecting an option - onChange(option.value) - onOptionSelect?.(option) - setIsOpen(false) - } + optionSelectedRef.current = true; + setInputValue(option.label); + inputValueRef.current = option.label; // Update ref immediately + setHasError(false); // Clear error when selecting an option + onChange(option.value); + onOptionSelect?.(option); + setIsOpen(false); + }; const handleContainerClick = () => { if (!disabled) { - inputRef.current?.focus() - setIsOpen(true) + inputRef.current?.focus(); + setIsOpen(true); } - } + }; // Use internal error state if no external state is provided - const effectiveState = hasError ? "error" : state - const stateCls = resolveStateClasses(effectiveState, disabled) + const effectiveState = hasError ? 'error' : state; + const stateCls = resolveStateClasses(effectiveState, disabled); // Determine which note to show - const displayNote = effectiveState === "error" && errorNote ? errorNote : note + const displayNote = + effectiveState === 'error' && errorNote ? errorNote : note; // Find the currently selected option - const selectedOption = options.find((opt) => opt.value === value) + const selectedOption = options.find((opt) => opt.value === value); return ( -
+
{title && (
{title} @@ -322,20 +359,22 @@ const InputSelect = React.forwardRef( ref={containerRef} onClick={handleContainerClick} className={cn( - "relative flex w-full items-center rounded-lg border border-solid shadow-sm outline-none transition-all px-3 gap-2 text-text-body cursor-text", + 'relative flex w-full cursor-text items-center gap-2 rounded-lg border border-solid px-3 text-text-body shadow-sm outline-none transition-all', sizeClasses[size], stateCls.container, !disabled && - state !== "error" && - state !== "success" && [ - "hover:bg-input-bg-hover hover:ring-1 hover:ring-input-border-hover hover:ring-offset-0", - isOpen && - "bg-input-bg-input ring-1 ring-input-border-focus ring-offset-0", - ] + state !== 'error' && + state !== 'success' && [ + 'hover:bg-input-bg-hover hover:ring-1 hover:ring-input-border-hover hover:ring-offset-0', + isOpen && + 'bg-input-bg-input ring-1 ring-input-border-focus ring-offset-0', + ] )} > {leadingIcon && ( - {leadingIcon} + + {leadingIcon} + )} ( onKeyDown={handleInputKeyDown} placeholder={placeholder} disabled={disabled} - className="flex-1 bg-transparent outline-none placeholder:text-text-label/20 min-w-0" + className="placeholder:text-text-label/20 min-w-0 flex-1 bg-transparent outline-none" />
@@ -361,10 +400,13 @@ const InputSelect = React.forwardRef( {isOpen && (
{ + interactingWithDropdownRef.current = true; + }} + className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-lg border border-solid border-transparent bg-input-bg-default shadow-md" >
@@ -373,9 +415,9 @@ const InputSelect = React.forwardRef( key={option.value} onClick={() => handleOptionClick(option)} className={cn( - "relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-2 pr-8 text-sm outline-none hover:bg-menutabs-fill-hover transition-colors", + 'relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-2 pr-8 text-sm outline-none transition-colors hover:bg-menutabs-fill-hover', selectedOption?.value === option.value && - "bg-menutabs-fill-hover" + 'bg-menutabs-fill-hover' )} > {option.label} @@ -391,13 +433,13 @@ const InputSelect = React.forwardRef( )} {displayNote && ( -
{displayNote}
+
{displayNote}
)}
- ) + ); } -) +); -InputSelect.displayName = "InputSelect" +InputSelect.displayName = 'InputSelect'; -export { InputSelect, type InputSelectOption } +export { InputSelect, type InputSelectOption };