headlessui icon indicating copy to clipboard operation
headlessui copied to clipboard

Export component prop types

Open valtism opened this issue 3 years ago • 10 comments

What package within Headless UI are you using?

@headlessui/react

What version of that package are you using?

v1.5.0

What browser are you using?

N/A

Reproduction URL

N/A

Describe your issue

In React + Typescript, a common pattern I follow is to wrap a library component for styling or custom behaviour before exposing it to the rest of the codebase. I like to mirror the underlying component props so that Typescript intellisense can provide the same prop hints to for the wrapper component as the underlying library component.

Here is an example of what I want:

// Wrapping a tab for custom styling. TabProps, however, does not exist;
function TemplateLibraryTab({ children, ...props }: TabProps) {
  return (
    <Tab
      {...props}
      className={({ selected }) =>
        clsx("p-2 rounded", selected ? "text-gray-900" : "text-gray-500")
      }
    >
      {children}
    </Tab>
  )
}

HeadlessUI does not seem to expose these props, or at least I could not find them. Would it be possible to export these prop types for this purpose? I think it is a strong use-case.

valtism avatar May 03 '22 05:05 valtism

I should point out that there is a workaround for this. You can use React.ComponentProps to pull out the type from the component.

function TemplateLibraryTab({ children, ...props }: React.ComponentProps<typeof Tab>) {
  return (
    <Tab
      {...props}
      className={({ selected }) =>
        clsx("p-2 rounded", selected ? "text-gray-900" : "text-gray-500")
      }
    >
      {children}
    </Tab>
  )
}

This works in this use-case, but I have run into issues with it in the past. I still think that exporting prop types like other libraries is a good thing to have.

valtism avatar May 03 '22 05:05 valtism

I have been doing React.ComponentProps<typeof Tab> for exactly the same use case (I wrap all the components I import) and it worked very well so far! However v1.6.0 breaks this for me. Would love to know if there is a workaround at least, otherwise will stay with v1.5.

easga avatar May 06 '22 10:05 easga

I have been doing React.ComponentProps for exactly the same use case (I wrap all the components I import) and it worked very well so far! However v1.6.0 breaks this for me. Would love to know if there is a workaround at least, otherwise will stay with v1.5.

Yup for some reason it broke now.

Type '(<TTag extends ElementType<any> = "button">(props: Props<TTag, TabRenderPropArg, TabPropsWeControl>, ref: Ref<HTMLElement>) => ReactElement<...>) & { ...; } & { ...; }' does not satisfy the constraint 'keyof IntrinsicElements | JSXElementConstructor<any>'. Type '(<TTag extends ElementType<any> = "button">(props: Props<TTag, TabRenderPropArg, TabPropsWeControl>, ref: Ref<HTMLElement>) => ReactElement<...>) & { ...; } & { ...; }' is not assignable to type '(props: any) => ReactElement<any, any>'.

Exporting the PropTypes would probably still be the nicer solution.

ZerNico avatar May 09 '22 10:05 ZerNico

Thanks to discussions/601, I got it working like this:

import { ComponentType } from 'react'
import { Dialog as HeadlessDialog } from '@headlessui/react'

type ExtractProps<T> = T extends ComponentType<infer P> ? P : T

function DialogRoot(props: ExtractProps<typeof HeadlessDialog>) {
  // add your own logic here
  return <HeadlessDialog {...props} />
}

You may also need to do something like this if you want the nested components to work too.

export let Dialog = Object.assign(DialogRoot, {
	Backdrop: HeadlessDialog.Backdrop,
	Panel: HeadlessDialog.Panel,
	Overlay: HeadlessDialog.Overlay,
	Title: HeadlessDialog.Title,
	Description: HeadlessDialog.Description,
})

CapitaineToinon avatar May 14 '22 10:05 CapitaineToinon

Broken in 1.6.1 for me too! Would greatly appreciate a fix 🙂

merlinaudio avatar May 18 '22 14:05 merlinaudio

Same here with 1.4.3. Would love a fix thats less confusing.

Bohemus307 avatar May 20 '22 14:05 Bohemus307

Thanks @CapitaineToinon - i was using the original React.ComponentProps<typeof Tab> method and after upgrading to 1.6.5 ran into the length TS error above. Your new workaround worked wonders!

babycourageous avatar Jun 26 '22 17:06 babycourageous

Plus to exporting prop types, thnx @CapitaineToinon for workaround.

shftlvch avatar Jul 18 '22 17:07 shftlvch

+1 to exporting prop types, I'd like to use ComponentProps<typeof Dialog> like I would with all other components

JaapWeijland avatar Sep 08 '22 14:09 JaapWeijland

+1 creating components with any sort of extensibility using headless + typescript has been a headache for me.

erickreutz avatar Sep 15 '22 21:09 erickreutz

Since headless components are declared more as pure functions, it seems that it is now possible to extract the props using the Parameters type:

type MyPopoverButtonProps = Parameters<typeof HeadlessPopover.Button>[0];

Note the [0] at the and, since the function takes two arguments: props and ref.

For more advanced cases you can add generics:

type HeadlessUIProps = keyof JSX.IntrinsicElements | React.JSXElementConstructor<any>;

type MyPopoverProps<T extends HeadlessUIProps> = Parameters<typeof HeadlessPopover<T>>[0];

flammenmensch avatar Oct 04 '22 21:10 flammenmensch

Using Parameters<> works for some of the simple components, but it runs into issues with more complex prop configurations in components like Combobox. For example, Combobox's value prop can either be a single value, or an array of values. The expected TS type for the value is determined by whether the multiple prop is set to true or false.

If you use the Parameters approach, TS starts to run into problems because it doesn't know which overload to use for the expected props. The combobox type declarations list the possible permutations, but there is no way to specify which permutation you want to use via Parameters

image

This is because the only available export is typeof ComboboxFn (which includes all possible permutations)

image

This means that if you try to do something like the following:

interface Option { foo: string } 

// accept all but the multiple prop
type ComboboxProps<TOption extends Option> = Omit<
  Parameters<typeof Combobox<TOption, React.ElementType<HTMLDivElement>>>[0],
  'multiple'
>;

type MyComboboxProps<TOption extends Option> = ComboboxProps<TOption> & { myProp: string }

function MyComboboxWrapper<TOption extends Option>({value, onChange}: MyComboboxProps<TOption>) {
  // value and onChange have multiple conflicting implementations 
}

you will get TS errors due to the conflicting implementations.

If someone has discovered a better solution for this situations like this, I would love to hear it. I would still advocate simply exporting the Prop types so we don't need to find complex workarounds for something that should be fairly simple. I haven't seen a reason in this thread for why this couldn't be done, only workarounds which don't seem to be stable as the library is evolving. Scanning through the source, it seems like the prop types are declared for most of the components, so I feel like it should be simple enough to add the export keyword to them. I would be willing to open a PR to do this if the maintainers are not opposed to it. I would like to hear from them first before starting work on it.

Mando75 avatar Oct 13 '22 17:10 Mando75

+1 on this, would be extremely useful for custom-styled components in a design system where we do not want developers to directly use headless-ui components.

itsmingjie avatar Oct 16 '22 18:10 itsmingjie

For Combobox, it's possible to export the props like this:

import type {ComboboxProps as BaseProps} from '@headlessui/react';

// Then
export type ComboboxProps<TValue> = BaseProps<TValue, false, false, 'div'>;

Then you can probably pass generic over the 4 arguments, but trying that out increases the complexity of the component considerably.

Hope this help, and hopefully others can also share how they've done their component!

mcchrish avatar Nov 16 '22 06:11 mcchrish

@RobinMalfait I hate to pester you, but I would love it if we got some more clarity around the plan for this. I can see in a linked issue that you "want to simplify and export the types". Is this something that is currently being worked on, or are you facing blockers here?

Don't want to put pressure on you here, but just knowing what your thoughts are around this and where you are at would be great.

valtism avatar Nov 16 '22 20:11 valtism

+1, this issue has been really bugging me. In my case I just want to use the props Dialog exposes to build my own compound components

agcty avatar Feb 06 '23 17:02 agcty

Related question - I want to get the prop types for Dialog.Panel to use on a wrapper component, but using React.ComponentPropsWithoutRef<typeof Dialog.Panel> like suggested in the React-Typescript cheatsheet produces a {} type.

How should I be getting the type for Dialog.Panel props?

UPDATE: Jumped the gun - apparently using type ExtractProps<T> = T extends ComponentType<infer P> ? P : T works, but className and style is just typed as any in ExtractProps<typeof Dialog.Panel>, so I was misled to believe prop types weren't working.

stephenkoo avatar Feb 10 '23 02:02 stephenkoo

In the next version of Headless UI we're exporting XXXProps types for each component. They are all generic because you can change which tag / component our components render with (as well as some types like Combobox have additional generics for values and other types). Hopefully this solution will work as you expect.

In the meantime you can test out these changes by installing our insiders build:

npm install @headlessui/react@insiders

Thanks for the patience on this while we figured stuff out. ✨

thecrypticace avatar Feb 20 '23 17:02 thecrypticace

Thanks for the update @thecrypticace and thanks to the rest of the team for this feature and all your work on HeadlessUI!

babycourageous avatar Feb 22 '23 19:02 babycourageous

But this one was working before. What I'm doing here is I'm extracting the props from the component itself:

import React, { ComponentType } from 'react';
import { Menu } from '@headlessui/react';
import { Button } from '../Button/Button';

type ExtractProps<T> = T extends ComponentType<infer P> ? P : T;

export type MenuButtonType = ExtractProps<typeof Menu.Button>;

export const MenuButton: React.FC<MenuButtonType> = ({ children, ...props }) => {
  return (
    <Menu.Button as={Button} {...props}>
      {children}
    </Menu.Button>
  );
};

rmlevangelio avatar Mar 01 '23 11:03 rmlevangelio

Hi there, is there any place where we can gran types for Vue?

fprl avatar Aug 14 '23 15:08 fprl

Does anyone have a working example of using the now-exported XXXProps types to make a wrapper component? I'm looking to use a RadioGroup under the hood for another type of component and haven't figured out the right syntax yet. The ExtractProps method doesn't seem to work.

EDIT: Nevermind, the ExtractProps method does seem to work. I ran into the same mistake above where className is typed as any, so thought it wasn't working. But I'd like to use the XXXProps type if possible still.

mattfelten avatar Mar 21 '24 20:03 mattfelten

@mattfelten You can do something like this now:

import { RadioGroup, RadioGroupProps } from "@headlessui/react";

function MyRadioGroup(props: RadioGroupProps<"div", string>) {
  return <RadioGroup {...props} />;
}

valtism avatar Mar 21 '24 22:03 valtism

Thanks @valtism ! That did work. Now that I understand what those two arguments are for, I slightly modified it to not be specific and it seems to still work.

import { RadioGroup, RadioGroupProps } from "@headlessui/react";
import { ComponentPropsWithoutRef, ElementType } from "react";

interface CustomWrapperProps extends RadioGroupProps<ElementType, ComponentPropsWithoutRef<"input">["value"]> { ... }

mattfelten avatar Mar 21 '24 23:03 mattfelten