Repository: tailwindlabs/headlessui
Pull request #3677 (2025-04-04 09:37:13)

Add `invalid` prop to `Combobox` component
This PR adds the `invalid` prop to the `Combobox` component. This will also expose the `invalid` value as a render prop to the `Combobox.Input` and `Combobox.Button` components.

It will also expose the `data-invalid` attribute on these components when the `invalid` prop is set to `true`.

```tsx
<Combobox invalid>
 <Combobox.Input />
 <Combobox.Button />
</Combobox>
```

Without `invalid` prop:
<img width="916" alt="image" src="https://github.com/user-attachments/assets/2c199691-7b29-450f-89a5-4b84e6704c6a" />


With invalid prop:
<img width="913" alt="image" src="https://github.com/user-attachments/assets/4bdde518-39b3-4998-b353-604a818a3c99" />

Notice the `data-invalid` prop on the `<input>` and the `<button>`.


Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 08ddd19..55e8fc2 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -15,8 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Fixed
 
 - Use correct `ownerDocument` when using internal `Portal` component ([#3594](https://github.com/tailwindlabs/headlessui/pull/3594))
-- Bump `@tanstack/react-virtual` to be fix warnings in React 19 projects ([#3588](https://github.com/tailwindlabs/headlessui/pull/3588))
+- Bump `@tanstack/react-virtual` to fix warnings in React 19 projects ([#3588](https://github.com/tailwindlabs/headlessui/pull/3588))
 - Fix `aria-invalid` attributes to have a valid `'true'` value ([#3639](https://github.com/tailwindlabs/headlessui/pull/3639))
+- Add missing `invalid` prop to `Combobox` component ([#3677](https://github.com/tailwindlabs/headlessui/pull/3677))
 
 ## [2.2.0] - 2024-10-25
 
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
index cebbb74..d0dce90 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx
@@ -577,6 +577,7 @@ let ComboboxDataContext = createContext<
       value: unknown
       defaultValue: unknown
       disabled: boolean
+      invalid: boolean
       mode: ValueMode
       activeOptionIndex: number | null
       immediate: boolean
@@ -619,6 +620,7 @@ let DEFAULT_COMBOBOX_TAG = Fragment
 type ComboboxRenderPropArg<TValue, TActive = TValue> = {
   open: boolean
   disabled: boolean
+  invalid: boolean
   activeIndex: number | null
   activeOption: TActive | null
   value: TValue
@@ -648,6 +650,7 @@ export type ComboboxProps<
 
     multiple?: TMultiple
     disabled?: boolean
+    invalid?: boolean
     form?: string
     name?: string
     immediate?: boolean
@@ -676,6 +679,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
     form,
     name,
     by,
+    invalid = false,
     disabled = providedDisabled || false,
     onClose,
     __demoMode = false,
@@ -751,6 +755,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
       value,
       defaultValue,
       disabled,
+      invalid,
       mode: multiple ? ValueMode.Multi : ValueMode.Single,
       virtual: virtual ? state.virtual : null,
       get activeOptionIndex() {
@@ -785,7 +790,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
       isSelected,
       isActive,
     }),
-    [value, defaultValue, disabled, multiple, __demoMode, state, virtual]
+    [value, defaultValue, disabled, invalid, multiple, __demoMode, state, virtual]
   )
 
   useIsoMorphicEffect(() => {
@@ -813,6 +818,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
     return {
       open: data.comboboxState === ComboboxState.Open,
       disabled,
+      invalid,
       activeIndex: data.activeOptionIndex,
       activeOption:
         data.activeOptionIndex === null
@@ -822,7 +828,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
             : (data.options[data.activeOptionIndex]?.dataRef.current.value as TValue) ?? null,
       value,
     } satisfies ComboboxRenderPropArg<unknown>
-  }, [data, disabled, value])
+  }, [data, disabled, value, invalid])
 
   let selectActiveOption = useEvent(() => {
     if (data.activeOptionIndex === null) return
@@ -999,6 +1005,7 @@ let DEFAULT_INPUT_TAG = 'input' as const
 type InputRenderPropArg = {
   open: boolean
   disabled: boolean
+  invalid: boolean
   hover: boolean
   focus: boolean
   autofocus: boolean
@@ -1398,11 +1405,12 @@ function InputFn<
     return {
       open: data.comboboxState === ComboboxState.Open,
       disabled,
+      invalid: data.invalid,
       hover,
       focus,
       autofocus: autoFocus,
     } satisfies InputRenderPropArg
-  }, [data, hover, focus, autoFocus, disabled])
+  }, [data, hover, focus, autoFocus, disabled, data.invalid])
 
   let ourProps = mergeProps(
     {
@@ -1463,6 +1471,7 @@ type ButtonRenderPropArg = {
   open: boolean
   active: boolean
   disabled: boolean
+  invalid: boolean
   value: any
   focus: boolean
   hover: boolean
@@ -1587,6 +1596,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
       open: data.comboboxState === ComboboxState.Open,
       active: active || data.comboboxState === ComboboxState.Open,
       disabled,
+      invalid: data.invalid,
       value: data.value,
       hover,
       focus,


Test patch:
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx
index 30ee990..367fa15 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx
@@ -1045,6 +1045,7 @@ describe('Rendering', () => {
             open: false,
             active: false,
             disabled: false,
+            invalid: false,
             value: 'test',
             hover: false,
             focus: false,
@@ -1061,6 +1062,7 @@ describe('Rendering', () => {
             open: true,
             active: true,
             disabled: false,
+            invalid: false,
             value: 'test',
             hover: false,
             focus: false,
@@ -1094,6 +1096,7 @@ describe('Rendering', () => {
             open: false,
             active: false,
             disabled: false,
+            invalid: false,
             value: 'test',
             hover: false,
             focus: false,
@@ -1110,6 +1113,7 @@ describe('Rendering', () => {
             open: true,
             active: true,
             disabled: false,
+            invalid: false,
             value: 'test',
             hover: false,
             focus: false,