Skip to content
Merged
12 changes: 12 additions & 0 deletions .changeset/fix-combobox-query-clearing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@cloudoperators/juno-ui-components": patch
---

fix(ui): improve ComboBox search query state management

- Clear search query after option selection to prevent stale search terms
- Clear query and close dropdown when controlled value prop becomes empty
- Add comprehensive tests for controlled and uncontrolled scenarios
- Use ref instead of getElementById for blur to work in shadow DOM

**Breaking change note:** When both `value=""` and `defaultValue` are provided, the ComboBox now shows empty instead of falling back to `defaultValue`. This is the correct behavior since `defaultValue` is documented as uncontrolled-mode only, but consumers relying on the previous fallback behavior should update their code to not pass `value=""` if they want `defaultValue` to apply.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import React, {
HTMLAttributes,
FocusEventHandler,
ChangeEventHandler,
useRef,
} from "react"
import { Combobox, ComboboxInput, ComboboxOptions, ComboboxButton } from "@headlessui/react"
import {
Expand Down Expand Up @@ -213,7 +214,7 @@ export const ComboBox = ({
successtext = "",
truncateOptions = false,
valid = false,
value = "",
value,
valueLabel,
width = "full",
wrapperClassName = "",
Expand All @@ -232,13 +233,23 @@ export const ComboBox = ({
new Map<OptionValuesAndLabelsKey, OptionValuesAndLabelsValue>()
)
const [query, setQuery] = useState("")
const [selectedValue, setSelectedValue] = useState(value)
const [selectedValue, setSelectedValue] = useState(value ?? "")
const [isLoading, setIsLoading] = useState(false)
const [hasError, setHasError] = useState(false)
const [hasFocus, setFocus] = useState(false)
const [isInvalid, setIsInvalid] = useState(false)
const [isValid, setIsValid] = useState(false)

// Determine if component is in controlled mode (value prop was explicitly supplied, even if empty)
// Computed inline - always accurate, zero cost, no ratchet behavior
const isControlled = value !== undefined

// Use a ref to track the previous value to detect when it changes from truthy to falsy
const previousValue = useRef(value ?? "")

// Ref to the input element for programmatic blur (works in shadow DOM unlike getElementById)
const inputRef = useRef<HTMLInputElement>(null)

// Floating UI setup
const { x, y, strategy, refs, context } = useFloating({
open: isOpen,
Expand Down Expand Up @@ -296,9 +307,24 @@ export const ComboBox = ({
)

useEffect(() => {
setSelectedValue(value)
setSelectedValue(value ?? "")
// Clear query and close dropdown when value is explicitly cleared to ""
// Use value === "" instead of !value to distinguish intentional reset from prop removal (undefined)
if (value === "" && previousValue.current) {
clearQueryWithEvent()
// Blur the input to close Headless UI's Combobox properly
// This ensures aria-expanded syncs correctly with the actual open state
// Use ref instead of getElementById to work within shadow DOM
inputRef.current?.blur()
}
previousValue.current = value ?? ""
}, [value])

// Determine the effective value to pass to Headless UI
// In controlled mode (value prop was supplied), honor it even if empty string
// In uncontrolled mode, fall back to defaultValue when selectedValue is empty
const effectiveValue = isControlled ? selectedValue : selectedValue || defaultValue
Comment thread
ArtieReus marked this conversation as resolved.

useEffect(() => {
setHasError(error)
}, [error])
Expand All @@ -319,6 +345,9 @@ export const ComboBox = ({
const stringValue = value || ""
setSelectedValue(stringValue)

// Clear the search query after selection and notify parent
clearQueryWithEvent()

if (stringValue) {
setIsOpen(false)
}
Expand All @@ -331,6 +360,23 @@ export const ComboBox = ({
onInputChange && onInputChange(event)
}

// Helper to clear query and notify parent via onInputChange
// This keeps internal and external state in sync for consumers that maintain their own search state
const clearQueryWithEvent = () => {
setQuery("")
if (inputRef.current && onInputChange) {
// Set the input's value directly
inputRef.current.value = ""
// Create a real Event object which has all standard methods (preventDefault, stopPropagation, etc.)
// Override read-only properties to match React's synthetic event structure
const event = new Event("input", { bubbles: true, cancelable: true })
Object.defineProperty(event, "target", { value: inputRef.current })
Object.defineProperty(event, "currentTarget", { value: inputRef.current })
Object.defineProperty(event, "nativeEvent", { value: event })
onInputChange(event as unknown as ChangeEvent<HTMLInputElement>)
}
}

const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
setFocus(true)

Expand Down Expand Up @@ -406,12 +452,12 @@ export const ComboBox = ({
disabled={disabled || isLoading || hasError}
name={name}
onChange={handleChange}
value={selectedValue || defaultValue}
value={effectiveValue}
as="div"
{...props}
>
{({ open }) => {
// Update our open state when Headless UI updates it
// Synchronize our open state with Headless UI's open state
useEffect(() => {
if (open !== isOpen) {
setIsOpen(open)
Expand Down Expand Up @@ -451,6 +497,7 @@ export const ComboBox = ({
)}

<ComboboxInput<OptionValuesAndLabelsKey>
ref={inputRef}
autoComplete="off"
aria-label={ariaLabel || label}
aria-describedby={helptext ? helptextId : ""}
Expand Down
Loading
Loading