diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 98caeccb71..0baafb9351 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve `Combobox` re-opening keyboard issue on mobile ([#1732](https://github.com/tailwindlabs/headlessui/pull/1732)) - Ensure `Disclosure.Panel` is properly linked ([#1747](https://github.com/tailwindlabs/headlessui/pull/1747)) - Only select the active option when using "singular" mode when pressing `` in the `Combobox` component ([#1750](https://github.com/tailwindlabs/headlessui/pull/1750)) +- Improve the types of the `Combobox` component ([#1761](https://github.com/tailwindlabs/headlessui/pull/1761)) ## Changed diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index 2d11699c3c..45f1f9a1d2 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -280,7 +280,7 @@ describe('Rendering', () => { let [value, setValue] = useState({ id: 2, name: 'Bob' }) return ( - + setValue(value)} by="id"> Trigger alice @@ -322,7 +322,7 @@ describe('Rendering', () => { let [value, setValue] = useState([{ id: 2, name: 'Bob' }]) return ( - + setValue(value)} by="id" multiple> Trigger alice @@ -2231,7 +2231,7 @@ describe('Keyboard interactions', () => { suppressConsoleLogs(async () => { let handleChange = jest.fn() function Example() { - let [value, setValue] = useState('bob') + let [value, setValue] = useState('bob') let [, setQuery] = useState('') return ( @@ -5095,7 +5095,7 @@ describe('Multi-select', () => { let [value, setValue] = useState(['bob', 'charlie']) return ( - + setValue(value)} multiple> {}} /> Trigger @@ -5131,7 +5131,7 @@ describe('Multi-select', () => { let [value, setValue] = useState(['bob', 'charlie']) return ( - + setValue(value)} multiple> {}} /> Trigger @@ -5160,7 +5160,7 @@ describe('Multi-select', () => { let [value, setValue] = useState(['bob', 'charlie']) return ( - + setValue(value)} multiple> {}} /> Trigger @@ -5193,7 +5193,7 @@ describe('Multi-select', () => { let [value, setValue] = useState(['bob', 'charlie']) return ( - + setValue(value)} multiple> {}} /> Trigger diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 120b6f7c5c..2ba11f2315 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -15,7 +15,7 @@ import React, { MutableRefObject, Ref, } from 'react' -import { Props } from '../../types' +import { ByComparator, EnsureArray, Expand, Props } from '../../types' import { useComputed } from '../../hooks/use-computed' import { useDisposables } from '../../hooks/use-disposables' @@ -303,26 +303,79 @@ interface ComboboxRenderPropArg { value: T } -let ComboboxRoot = forwardRefWithAs(function Combobox< - TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG, - TType = string, - TActualType = TType extends (infer U)[] ? U : TType ->( - props: Props< - TTag, - ComboboxRenderPropArg, - 'value' | 'defaultValue' | 'onChange' | 'by' | 'disabled' | 'name' | 'nullable' | 'multiple' - > & { - value?: TType - defaultValue?: TType - onChange?(value: TType): void - by?: (keyof TActualType & string) | ((a: TActualType, z: TActualType) => boolean) - disabled?: boolean - __demoMode?: boolean - name?: string - nullable?: boolean - multiple?: boolean - }, +type O = 'value' | 'defaultValue' | 'nullable' | 'multiple' | 'onChange' | 'by' + +type ComboboxValueProps< + TValue, + TNullable extends boolean | undefined, + TMultiple extends boolean | undefined, + TTag extends ElementType +> = Extract< + | ({ + value?: EnsureArray + defaultValue?: EnsureArray + nullable: true // We ignore `nullable` in multiple mode + multiple: true + onChange?(value: EnsureArray): void + by?: ByComparator + } & Props>, O>) + | ({ + value?: TValue | null + defaultValue?: TValue | null + nullable: true + multiple?: false + onChange?(value: TValue | null): void + by?: ByComparator + } & Expand, O>>) + | ({ + value?: EnsureArray + defaultValue?: EnsureArray + nullable?: false + multiple: true + onChange?(value: EnsureArray): void + by?: ByComparator ? U : TValue> + } & Expand>, O>>) + | ({ + value?: TValue + nullable?: false + multiple?: false + defaultValue?: TValue + onChange?(value: TValue): void + by?: ByComparator + } & Props, O>), + { nullable?: TNullable; multiple?: TMultiple } +> + +type ComboboxProps< + TValue, + TNullable extends boolean | undefined, + TMultiple extends boolean | undefined, + TTag extends ElementType +> = ComboboxValueProps & { + disabled?: boolean + __demoMode?: boolean + name?: string +} + +function ComboboxFn( + props: ComboboxProps, + ref: Ref +): JSX.Element +function ComboboxFn( + props: ComboboxProps, + ref: Ref +): JSX.Element +function ComboboxFn( + props: ComboboxProps, + ref: Ref +): JSX.Element +function ComboboxFn( + props: ComboboxProps, + ref: Ref +): JSX.Element + +function ComboboxFn( + props: ComboboxProps, ref: Ref ) { let { @@ -330,14 +383,18 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< defaultValue, onChange: controlledOnChange, name, - by = (a, z) => a === z, + by = (a: any, z: any) => a === z, disabled = false, __demoMode = false, nullable = false, multiple = false, ...theirProps } = props - let [value, theirOnChange] = useControllable(controlledValue, controlledOnChange, defaultValue) + let [value, theirOnChange] = useControllable( + controlledValue, + controlledOnChange, + defaultValue + ) let [state, dispatch] = useReducer(stateReducer, { dataRef: createRef(), @@ -345,7 +402,7 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< options: [], activeOptionIndex: null, activationTrigger: ActivationTrigger.Other, - } as StateDefinition) + } as StateDefinition) let defaultToFirstOption = useRef(false) @@ -358,19 +415,19 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< let compare = useEvent( typeof by === 'string' - ? (a: TActualType, z: TActualType) => { - let property = by as unknown as keyof TActualType + ? (a, z) => { + let property = by as unknown as keyof TValue return a[property] === z[property] } : by ) - let isSelected: (value: TActualType) => boolean = useCallback( + let isSelected: (value: unknown) => boolean = useCallback( (compareValue) => match(data.mode, { [ValueMode.Multi]: () => - (value as unknown as TActualType[]).some((option) => compare(option, compareValue)), - [ValueMode.Single]: () => compare(value as unknown as TActualType, compareValue), + (value as EnsureArray).some((option) => compare(option, compareValue)), + [ValueMode.Single]: () => compare(value as TValue, compareValue), }), [value] ) @@ -422,7 +479,7 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< data.comboboxState === ComboboxState.Open ) - let slot = useMemo>( + let slot = useMemo>( () => ({ open: data.comboboxState === ComboboxState.Open, disabled, @@ -430,7 +487,7 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< activeOption: data.activeOptionIndex === null ? null - : (data.options[data.activeOptionIndex].dataRef.current.value as TType), + : (data.options[data.activeOptionIndex].dataRef.current.value as TValue), value, }), [data, disabled, value] @@ -482,19 +539,19 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< let onChange = useEvent((value: unknown) => { return match(data.mode, { [ValueMode.Single]() { - return theirOnChange?.(value as TType) + return theirOnChange?.(value as TValue) }, [ValueMode.Multi]() { - let copy = (data.value as TActualType[]).slice() + let copy = (data.value as TValue[]).slice() - let idx = copy.findIndex((item) => compare(item, value as TActualType)) + let idx = copy.findIndex((item) => compare(item, value as TValue)) if (idx === -1) { - copy.push(value as TActualType) + copy.push(value as TValue) } else { copy.splice(idx, 1) } - return theirOnChange?.(copy as unknown as TType) + return theirOnChange?.(copy as unknown as TValue[]) }, }) }) @@ -550,7 +607,8 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< ) -}) +} +let ComboboxRoot = forwardRefWithAs(ComboboxFn) // --- diff --git a/packages/@headlessui-react/src/components/transitions/transition.tsx b/packages/@headlessui-react/src/components/transitions/transition.tsx index 5227b8f340..82fdb89ee5 100644 --- a/packages/@headlessui-react/src/components/transitions/transition.tsx +++ b/packages/@headlessui-react/src/components/transitions/transition.tsx @@ -12,7 +12,7 @@ import React, { MutableRefObject, Ref, } from 'react' -import { Props } from '../../types' +import { Props, ReactTag } from '../../types' import { Features, forwardRefWithAs, @@ -68,7 +68,7 @@ export interface TransitionEvents { afterLeave?: () => void } -type TransitionChildProps = Props & +type TransitionChildProps = Props & PropsForFeatures & TransitionClasses & TransitionEvents & { appear?: boolean } diff --git a/packages/@headlessui-react/src/types.ts b/packages/@headlessui-react/src/types.ts index 2cc70bb918..3b246f09ae 100644 --- a/packages/@headlessui-react/src/types.ts +++ b/packages/@headlessui-react/src/types.ts @@ -1,4 +1,6 @@ -import { ReactNode, ReactElement } from 'react' +import { ReactNode, ReactElement, JSXElementConstructor } from 'react' + +export type ReactTag = keyof JSX.IntrinsicElements | JSXElementConstructor // A unique placeholder we can use as a default. This is nice because we can use this instead of // defaulting to null / never / ... and possibly collide with actual data. @@ -8,38 +10,48 @@ export type __ = typeof __ export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never -export type PropsOf = TTag extends React.ElementType +export type PropsOf = TTag extends React.ElementType ? React.ComponentProps : never type PropsWeControl = 'as' | 'children' | 'refName' | 'className' // Resolve the props of the component, but ensure to omit certain props that we control -type CleanProps = TOmitableProps extends __ +type CleanProps< + TTag extends ReactTag, + TOmitableProps extends PropertyKey = __ +> = TOmitableProps extends __ ? Omit, PropsWeControl> : Omit, TOmitableProps | PropsWeControl> // Add certain props that we control -type OurProps = { +type OurProps = { as?: TTag children?: ReactNode | ((bag: TSlot) => ReactElement) refName?: string } +type HasProperty = T extends never + ? never + : K extends keyof T + ? true + : never + // Conditionally override the `className`, to also allow for a function -// if and only if the PropsOf already define `className`. +// if and only if the PropsOf already defines `className`. // This will allow us to have a TS error on as={Fragment} -type ClassNameOverride = PropsOf extends { className?: any } - ? { className?: string | ((bag: TSlot) => string) } - : {} +type ClassNameOverride = + // Order is important here, because `never extends true` is `true`... + true extends HasProperty, 'className'> + ? { className?: PropsOf['className'] | ((bag: TSlot) => string) } + : {} // Provide clean TypeScript props, which exposes some of our custom API's. -export type Props = CleanProps< - TTag, - TOmitableProps -> & - OurProps & - ClassNameOverride +export type Props< + TTag extends ReactTag, + TSlot = {}, + TOmitableProps extends PropertyKey = __ +> = CleanProps & OurProps & ClassNameOverride type Without = { [P in Exclude]?: never } export type XOR = T | U extends __ @@ -51,3 +63,6 @@ export type XOR = T | U extends __ : T | U extends object ? (Without & U) | (Without & T) : T | U + +export type ByComparator = (keyof T & string) | ((a: T, b: T) => boolean) +export type EnsureArray = T extends any[] ? T : Expand[] diff --git a/packages/playground-react/pages/combobox/multi-select.tsx b/packages/playground-react/pages/combobox/multi-select.tsx index 8d80313222..cf8bc60923 100644 --- a/packages/playground-react/pages/combobox/multi-select.tsx +++ b/packages/playground-react/pages/combobox/multi-select.tsx @@ -39,7 +39,12 @@ function MultiPeopleList() { console.log([...new FormData(e.currentTarget).entries()]) }} > - + setActivePersons(people)} + name="people" + multiple + > Assigned to