From ac897d831092a48fe99d3c861a2e9ff73077b1d1 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Wed, 30 Sep 2026 16:29:44 +0200 Subject: [PATCH 1/9] fix(ui): improve ComboBox search query state management Clear search query after option selection and when controlled value becomes empty to prevent stale search terms from persisting. This ensures proper synchronization between internal query state and parent component filter state, improving the user experience when working with controlled ComboBox components. Changes: - Clear search query after option selection - Clear query and close dropdown when value prop becomes empty - Add comprehensive tests for controlled/uncontrolled scenarios Signed-off-by: Arturo Reuschenbach Puncernau Signed-off-by: Arturo Reuschenbach Puncernau --- .changeset/fix-combobox-query-clearing.md | 9 ++ .../ComboBox/ComboBox.component.tsx | 8 ++ .../src/components/ComboBox/ComboBox.test.tsx | 95 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 .changeset/fix-combobox-query-clearing.md diff --git a/.changeset/fix-combobox-query-clearing.md b/.changeset/fix-combobox-query-clearing.md new file mode 100644 index 0000000000..1f65dd58ea --- /dev/null +++ b/.changeset/fix-combobox-query-clearing.md @@ -0,0 +1,9 @@ +--- +"@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 diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index a53f14582b..a5561a6ac5 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -297,6 +297,11 @@ export const ComboBox = ({ useEffect(() => { setSelectedValue(value) + // Clear query and close dropdown when value is cleared externally + if (!value) { + setQuery("") + setIsOpen(false) + } }, [value]) useEffect(() => { @@ -319,6 +324,9 @@ export const ComboBox = ({ const stringValue = value || "" setSelectedValue(stringValue) + // Clear the search query after selection + setQuery("") + if (stringValue) { setIsOpen(false) } diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx index d55d81d812..baf1029749 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx @@ -583,4 +583,99 @@ describe("ComboBox", () => { expect(screen.getByRole("combobox")).toBeInTheDocument() expect(screen.getByRole("combobox")).toHaveAttribute("data-lolo", "1234") }) + + test("clears internal state when controlled value prop becomes empty", async () => { + const { rerender } = render( + + + + + + + ) + + const input = screen.getByRole("combobox") + // When controlled with value="option1", it shows the value + expect(input).toHaveValue("option1") + + // Rerender updates the same component instance with new props (simulating what supernova does) + // Update value prop to empty (simulating what supernova does after selection) + rerender( + + + + + + + ) + + // Should clear the value + await waitFor(() => { + expect(input).toHaveValue("") + }) + }) + + test("updates displayed value when controlled value prop changes to a new value", async () => { + const { rerender } = render( + + + + + + + ) + + const input = screen.getByRole("combobox") + expect(input).toHaveValue("option1") + + // Rerender updates the same component instance with new props + // Update value prop to a different value + rerender( + + + + + + + ) + + // Should update to the new value + await waitFor(() => { + expect(input).toHaveValue("option2") + }) + }) + + test("uncontrolled combobox: select option and it remains visible in the display", async () => { + const user = userEvent.setup() + const mockOnChange = vi.fn() + + render( + + + + + + + + ) + + const input = screen.getByRole("combobox") + expect(input).toHaveValue("") + + // Click to open dropdown + await user.click(input) + await waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument()) + + // Select an option + await user.click(screen.getByRole("option", { name: "Option 2" })) + + // onChange should be called + expect(mockOnChange).toHaveBeenCalledWith("option2") + + // Dropdown should close + await waitFor(() => expect(screen.queryByRole("listbox")).not.toBeInTheDocument()) + + // The selected value should remain visible in uncontrolled mode (displays the label) + expect(input).toHaveValue("Option 2") + }) }) From 26260edb0a33545cdf937b773191296b43367d48 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Thu, 8 Oct 2026 14:00:09 +0200 Subject: [PATCH 2/9] fix(ui): address Copilot review feedback for ComboBox controlled value handling - Honor explicitly cleared controlled values over defaultValue by tracking controlled mode and using effectiveValue calculation - Remove problematic setIsOpen(false) that caused aria-expanded desync with Headless UI internal state - Only clear query when value transitions from truthy to falsy (not on init) - Add comprehensive tests for controlled/uncontrolled query reset behavior Signed-off-by: Arturo Reuschenbach Puncernau --- .../ComboBox/ComboBox.component.tsx | 27 +++- .../src/components/ComboBox/ComboBox.test.tsx | 144 ++++++++++++++++++ 2 files changed, 166 insertions(+), 5 deletions(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index a5561a6ac5..7b56e2f74d 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -17,6 +17,7 @@ import React, { HTMLAttributes, FocusEventHandler, ChangeEventHandler, + useRef, } from "react" import { Combobox, ComboboxInput, ComboboxOptions, ComboboxButton } from "@headlessui/react" import { @@ -239,6 +240,12 @@ export const ComboBox = ({ const [isInvalid, setIsInvalid] = useState(false) const [isValid, setIsValid] = useState(false) + // Track if component has ever received a controlled value prop (including empty string after a value) + const [isControlled, setIsControlled] = useState(value !== "") + + // Use a ref to track the previous value to detect when it changes from truthy to falsy + const previousValue = useRef(value) + // Floating UI setup const { x, y, strategy, refs, context } = useFloating({ open: isOpen, @@ -296,14 +303,24 @@ export const ComboBox = ({ ) useEffect(() => { + // If we receive a non-empty value, mark as controlled + if (value !== "") { + setIsControlled(true) + } setSelectedValue(value) - // Clear query and close dropdown when value is cleared externally - if (!value) { + // Clear query when value transitions from truthy to falsy (explicit clearing) + if (!value && previousValue.current) { setQuery("") - setIsOpen(false) } + previousValue.current = value }, [value]) + // Determine the effective value to pass to Headless UI + // In controlled mode (component received a non-empty value at some point), + // honor the current value even if it's empty string + // In uncontrolled mode, fall back to defaultValue when selectedValue is empty + const effectiveValue = isControlled ? selectedValue : selectedValue || defaultValue + useEffect(() => { setHasError(error) }, [error]) @@ -414,12 +431,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) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx index baf1029749..b0ebb2c21b 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx @@ -678,4 +678,148 @@ describe("ComboBox", () => { // The selected value should remain visible in uncontrolled mode (displays the label) expect(input).toHaveValue("Option 2") }) + + test("uncontrolled combobox: query resets after selecting a filtered option", async () => { + const user = userEvent.setup() + + render( + + + Apple + Banana + Cherry + + + ) + + const input = screen.getByRole("combobox") + + // Type a search that excludes Apple and Cherry (only Banana contains "ban") + await user.type(input, "ban") + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeInTheDocument() + expect(screen.queryByRole("option", { name: "Apple" })).not.toBeInTheDocument() + expect(screen.getByRole("option", { name: "Banana" })).toBeInTheDocument() + expect(screen.queryByRole("option", { name: "Cherry" })).not.toBeInTheDocument() + }) + + // Select Banana - this triggers setQuery("") in handleChange + await user.click(screen.getByRole("option", { name: "Banana" })) + + // Dropdown should close and show selected value + await waitFor(() => { + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + expect(input).toHaveValue("Banana") + }) + + // Clear input value and click to reopen dropdown + // If setQuery("") wasn't called in handleChange, the old filter "ban" would still be active + await user.clear(input) + await user.click(input) + + // All options should be visible because query was reset to "" when Banana was selected + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeInTheDocument() + expect(screen.getByRole("option", { name: "Apple" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "Banana" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "Cherry" })).toBeInTheDocument() + }) + }) + + test("honors explicitly cleared controlled value over defaultValue", async () => { + const { rerender } = render( + + + + + + + ) + + const input = screen.getByRole("combobox") + expect(input).toHaveValue("option1") + + // Explicitly clear the controlled value while defaultValue is still nonempty + rerender( + + + + + + + ) + + // Should show empty value, not fall back to defaultValue + await waitFor(() => { + expect(input).toHaveValue("") + }) + }) + + test("resets query and restores all options when controlled value is cleared after filtering", async () => { + const user = userEvent.setup() + const { rerender } = render( + + + Apple + Banana + Cherry + + + ) + + const input = screen.getByRole("combobox") + const toggle = screen.getByRole("button") + + // Type a query that filters to only one option + await user.type(input, "ban") + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeInTheDocument() + expect(screen.queryByRole("option", { name: "Apple" })).not.toBeInTheDocument() + expect(screen.getByRole("option", { name: "Banana" })).toBeInTheDocument() + expect(screen.queryByRole("option", { name: "Cherry" })).not.toBeInTheDocument() + }) + + // Select the filtered option + await user.click(screen.getByRole("option", { name: "Banana" })) + + // Simulate controlled component setting the value + rerender( + + + Apple + Banana + Cherry + + + ) + + await waitFor(() => { + expect(input).toHaveValue("Banana") + }) + + // Now clear the controlled value (simulating what supernova does) + rerender( + + + Apple + Banana + Cherry + + + ) + + // Value should be cleared and query should be reset + await waitFor(() => { + expect(input).toHaveValue("") + }) + + // Reopen the dropdown using the toggle button and verify all options are present (query was reset) + await user.click(toggle) + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeInTheDocument() + expect(screen.getByRole("option", { name: "Apple" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "Banana" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "Cherry" })).toBeInTheDocument() + }) + }) }) From a79fa911af9bcf8fe4cca0a7e4646c0d00768d0e Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Thu, 8 Oct 2026 14:20:30 +0200 Subject: [PATCH 3/9] fix(ui): detect controlled mode from value prop presence, not emptiness - Change isControlled detection from `value !== ""` to `value !== undefined` - Remove default value for `value` prop to preserve undefined vs empty distinction - This ensures `` shows empty, not default - Add test for initially empty controlled value scenario Signed-off-by: Arturo Reuschenbach Puncernau --- .../ComboBox/ComboBox.component.tsx | 21 +++++++++---------- .../src/components/ComboBox/ComboBox.test.tsx | 15 +++++++++++++ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index 7b56e2f74d..f2495ae3d7 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -214,7 +214,7 @@ export const ComboBox = ({ successtext = "", truncateOptions = false, valid = false, - value = "", + value, valueLabel, width = "full", wrapperClassName = "", @@ -233,18 +233,18 @@ export const ComboBox = ({ new Map() ) 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) - // Track if component has ever received a controlled value prop (including empty string after a value) - const [isControlled, setIsControlled] = useState(value !== "") + // Track if component is in controlled mode (value prop was explicitly supplied, even if empty) + const [isControlled, setIsControlled] = useState(value !== undefined) // Use a ref to track the previous value to detect when it changes from truthy to falsy - const previousValue = useRef(value) + const previousValue = useRef(value ?? "") // Floating UI setup const { x, y, strategy, refs, context } = useFloating({ @@ -303,21 +303,20 @@ export const ComboBox = ({ ) useEffect(() => { - // If we receive a non-empty value, mark as controlled - if (value !== "") { + // If value prop is supplied (not undefined), mark as controlled + if (value !== undefined) { setIsControlled(true) } - setSelectedValue(value) + setSelectedValue(value ?? "") // Clear query when value transitions from truthy to falsy (explicit clearing) if (!value && previousValue.current) { setQuery("") } - previousValue.current = value + previousValue.current = value ?? "" }, [value]) // Determine the effective value to pass to Headless UI - // In controlled mode (component received a non-empty value at some point), - // honor the current value even if it's empty string + // 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 diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx index b0ebb2c21b..f75a40f3e4 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx @@ -755,6 +755,21 @@ describe("ComboBox", () => { }) }) + test("honors initially empty controlled value over defaultValue", () => { + render( + + + + + + + ) + + const input = screen.getByRole("combobox") + // Should show empty value because value="" was explicitly supplied, not defaultValue + expect(input).toHaveValue("") + }) + test("resets query and restores all options when controlled value is cleared after filtering", async () => { const user = userEvent.setup() const { rerender } = render( From ec8485ef81047ce03abf2fbcdf3560b42ee03882 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Thu, 8 Oct 2026 14:28:13 +0200 Subject: [PATCH 4/9] fix(ui): close dropdown when controlled value is cleared while menu is open - Blur the input element to properly close Headless UI's Combobox - This ensures aria-expanded syncs correctly with actual open state - Add test verifying dropdown closes on external value clear Signed-off-by: Arturo Reuschenbach Puncernau --- .../ComboBox/ComboBox.component.tsx | 10 ++++- .../src/components/ComboBox/ComboBox.test.tsx | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index f2495ae3d7..61b083752b 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -308,12 +308,18 @@ export const ComboBox = ({ setIsControlled(true) } setSelectedValue(value ?? "") - // Clear query when value transitions from truthy to falsy (explicit clearing) + // Clear query and close dropdown when value transitions from truthy to falsy (explicit clearing) if (!value && previousValue.current) { setQuery("") + // Blur the input to close Headless UI's Combobox properly + // This ensures aria-expanded syncs correctly with the actual open state + const inputElement = document.getElementById(theId) + if (inputElement) { + inputElement.blur() + } } previousValue.current = value ?? "" - }, [value]) + }, [value, theId]) // Determine the effective value to pass to Headless UI // In controlled mode (value prop was supplied), honor it even if empty string diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx index f75a40f3e4..72840c3156 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx @@ -770,6 +770,43 @@ describe("ComboBox", () => { expect(input).toHaveValue("") }) + test("closes dropdown when controlled value is cleared while menu is open", async () => { + const user = userEvent.setup() + const { rerender } = render( + + + + + + + ) + + const input = screen.getByRole("combobox") + + // Open the dropdown while value is set + await user.click(input) + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeInTheDocument() + }) + + // Clear the controlled value while menu is open + rerender( + + + + + + + ) + + // Dropdown should close and aria-expanded should be false + await waitFor(() => { + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + expect(input).toHaveAttribute("aria-expanded", "false") + expect(input).toHaveValue("") + }) + }) + test("resets query and restores all options when controlled value is cleared after filtering", async () => { const user = userEvent.setup() const { rerender } = render( From 95bc5dbc618bc09b23a44fb701756348025e0fa0 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Thu, 8 Oct 2026 15:48:54 +0200 Subject: [PATCH 5/9] fix(ui): use ref instead document.getElementById Signed-off-by: Arturo Reuschenbach Puncernau --- .../src/components/ComboBox/ComboBox.component.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index 61b083752b..112680d56e 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -246,6 +246,9 @@ export const ComboBox = ({ // 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(null) + // Floating UI setup const { x, y, strategy, refs, context } = useFloating({ open: isOpen, @@ -313,13 +316,11 @@ export const ComboBox = ({ setQuery("") // Blur the input to close Headless UI's Combobox properly // This ensures aria-expanded syncs correctly with the actual open state - const inputElement = document.getElementById(theId) - if (inputElement) { - inputElement.blur() - } + // Use ref instead of getElementById to work within shadow DOM + inputRef.current?.blur() } previousValue.current = value ?? "" - }, [value, theId]) + }, [value]) // Determine the effective value to pass to Headless UI // In controlled mode (value prop was supplied), honor it even if empty string @@ -481,6 +482,7 @@ export const ComboBox = ({ )} + ref={inputRef} autoComplete="off" aria-label={ariaLabel || label} aria-describedby={helptext ? helptextId : ""} From 9ecdb0df53e94378515d260c0aaf5b21ae81be57 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Thu, 8 Oct 2026 15:55:02 +0200 Subject: [PATCH 6/9] fix(ui): change changeset Signed-off-by: Arturo Reuschenbach Puncernau --- .changeset/fix-combobox-query-clearing.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/fix-combobox-query-clearing.md b/.changeset/fix-combobox-query-clearing.md index 1f65dd58ea..bb74fcf529 100644 --- a/.changeset/fix-combobox-query-clearing.md +++ b/.changeset/fix-combobox-query-clearing.md @@ -7,3 +7,6 @@ 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. From d5ef6d30ee81bb757c52551c725a73d0f259d727 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Fri, 9 Oct 2026 08:46:50 +0200 Subject: [PATCH 7/9] chore(ui): combobox clearQueryWithEvent() now calls onInputChange with a proper event object Signed-off-by: Arturo Reuschenbach Puncernau --- .../ComboBox/ComboBox.component.tsx | 23 ++++++++++-- .../src/components/ComboBox/ComboBox.test.tsx | 37 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index 112680d56e..536cca5cba 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -313,7 +313,7 @@ export const ComboBox = ({ setSelectedValue(value ?? "") // Clear query and close dropdown when value transitions from truthy to falsy (explicit clearing) if (!value && previousValue.current) { - setQuery("") + 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 @@ -347,8 +347,8 @@ export const ComboBox = ({ const stringValue = value || "" setSelectedValue(stringValue) - // Clear the search query after selection - setQuery("") + // Clear the search query after selection and notify parent + clearQueryWithEvent() if (stringValue) { setIsOpen(false) @@ -362,6 +362,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) + } + } + const handleFocus = (event: FocusEvent) => { setFocus(true) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx index 72840c3156..0d2b5484c3 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx @@ -874,4 +874,41 @@ describe("ComboBox", () => { expect(screen.getByRole("option", { name: "Cherry" })).toBeInTheDocument() }) }) + + test("fires onInputChange with empty value when query is cleared after selection", async () => { + // Track values at call time, not by reference + const capturedValues: string[] = [] + const handleInputChange = vi.fn((e: React.ChangeEvent) => { + capturedValues.push(e.target.value) + }) + const handleChange = vi.fn() + + await waitFor(() => + render( + + + Apple + Banana + + + ) + ) + + const user = userEvent.setup() + const toggle = screen.getByRole("button") + + // Open dropdown using toggle button + await waitFor(() => user.click(toggle)) + expect(screen.getByRole("listbox")).toBeInTheDocument() + + // Select an option (this triggers onChange and should also trigger onInputChange with empty value) + await waitFor(async () => { + await user.click(screen.getByRole("option", { name: "Apple" })) + expect(handleChange).toHaveBeenCalled() + }) + + // onInputChange should be fired with empty value when query is cleared after selection + expect(handleInputChange).toHaveBeenCalled() + expect(capturedValues).toContain("") + }) }) From a8a9685a0266303eec7f22ccef9ca74a55ef009e Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Fri, 9 Oct 2026 08:52:03 +0200 Subject: [PATCH 8/9] fix(ui): use strict equality check for ComboBox controlled value reset Signed-off-by: Arturo Reuschenbach Puncernau --- .../src/components/ComboBox/ComboBox.component.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index 536cca5cba..367183b351 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -311,8 +311,9 @@ export const ComboBox = ({ setIsControlled(true) } setSelectedValue(value ?? "") - // Clear query and close dropdown when value transitions from truthy to falsy (explicit clearing) - if (!value && previousValue.current) { + // 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 From 86dcdb483c4f470dbb3603da6a6d635f90fac7df Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Fri, 9 Oct 2026 08:55:56 +0200 Subject: [PATCH 9/9] fix(ui): compute isControlled inline instead of using state Signed-off-by: Arturo Reuschenbach Puncernau --- .../src/components/ComboBox/ComboBox.component.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index 367183b351..f3f79a6043 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -240,8 +240,9 @@ export const ComboBox = ({ const [isInvalid, setIsInvalid] = useState(false) const [isValid, setIsValid] = useState(false) - // Track if component is in controlled mode (value prop was explicitly supplied, even if empty) - const [isControlled, setIsControlled] = useState(value !== undefined) + // 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 ?? "") @@ -306,10 +307,6 @@ export const ComboBox = ({ ) useEffect(() => { - // If value prop is supplied (not undefined), mark as controlled - if (value !== undefined) { - setIsControlled(true) - } 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)