Skip to content

Instantly share code, notes, and snippets.

@kentcdodds
Last active January 17, 2019 08:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save kentcdodds/9d195236d182c3d11637fe3194df2bc6 to your computer and use it in GitHub Desktop.
Save kentcdodds/9d195236d182c3d11637fe3194df2bc6 to your computer and use it in GitHub Desktop.
how?
import 'pp-vx/dist/components/button/button.css'
import React from 'react'
import clsx from 'clsx'
const sizes: {[key: string]: string} = {
md: 'vx_btn--size_md',
sm: 'vx_btn--size_sm',
}
export type OneOrAnother<T1, T2> =
| (T1 & {[K in Exclude<keyof T2, keyof T1>]?: undefined})
| (T2 & {[K in Exclude<keyof T1, keyof T2>]?: undefined})
type CommonProps = {
size?: 'md' | 'sm'
inverse?: boolean
secondary?: boolean
className?: string
}
type ButtonOnlyProps = CommonProps & {
children: React.ReactChild
} & React.ButtonHTMLAttributes<HTMLButtonElement>
type ButtonAsProps = CommonProps & {
as: string | React.ComponentClass | React.FunctionComponent
[key: string]: any
}
function isButtonAs(
props: ButtonProps | ButtonAsProps,
): props is ButtonAsProps {
return (props as ButtonAsProps).as !== undefined
}
type ButtonProps = OneOrAnother<ButtonOnlyProps, ButtonAsProps>
function Button(props: ButtonProps) {
const {size, inverse, secondary, className, ...rest} = props
const finalclassName = clsx(className, 'vx_btn', sizes[size], {
'vx_btn--inverse': inverse,
'vx_btn--secondary': secondary,
})
if (isButtonAs(props)) {
return React.createElement(props.as, {
// TODO: See if there's a way that we can tell TypeScript that whatever
// is passed as "as" must be able to accept a "className" prop. That'd be cool
// @ts-ignore (whatever "as" is, it should expect the "className" prop)
className: finalclassName,
...rest,
})
}
return <button className={finalclassName} {...rest} />
}
export const passing = [
<Button key="k">hi</Button>,
<Button key="k" size="md">hi</Button>,
<Button key="k" inverse secondary>hey</Button>,
<Button key="k" as="a" href="#">hi</Button>,
<Button key="k" as="a" href="#" size="md">hi</Button>,
<Button key="k" as="a" href="#" inverse secondary>hey</Button>,
]
export const failing = [
<Button key="k" href="#">hi</Button>,
<Button key="k" size="blah">hi</Button>,
<Button key="k" inverse={2}>hey</Button>,
<Button key="k" secondary="true">hey</Button>,
// this one would be awesome if there's a way to make this work:
// <Button key="k" as="a" typo="#">hey</Button>,
// maybe I could do this via https://github.com/Microsoft/TypeScript/issues/3960#issuecomment-165330151 ?
]
export {Button}
@ferdaber
Copy link

Try this:

import React from 'react'

declare function clsx(...args: any[]): string

const sizes = {
  md: 'vx_btn--size_md',
  sm: 'vx_btn--size_sm',
}

type CommonProps = {
  size?: keyof typeof sizes
  inverse?: boolean
  secondary?: boolean
  className?: string
}

type ButtonOnlyProps = CommonProps & {
  children: React.ReactChild
} & React.ButtonHTMLAttributes<HTMLButtonElement>

type ButtonAsProps = CommonProps & {
  as: keyof JSX.IntrinsicElements | React.JSXElementConstructor<{ className?: string }>
  [key: string]: any
}

function isButtonAs(props: ButtonOnlyProps | ButtonAsProps): props is ButtonAsProps {
  return (props as ButtonAsProps).as !== undefined
}

function Button(props: ButtonOnlyProps): JSX.Element
function Button(props: ButtonAsProps): JSX.Element
function Button(props: ButtonOnlyProps | ButtonAsProps) {
  const { size, inverse, secondary, className, ...rest } = props
  const finalclassName = clsx(className, 'vx_btn', sizes[size!], {
    'vx_btn--inverse': inverse,
    'vx_btn--secondary': secondary,
  })
  if (isButtonAs(props)) {
    return React.createElement(props.as, {
      // TODO: See if there's a way that we can tell TypeScript that whatever
      // is passed as "as" must be able to accept a "className" prop. That'd be cool
      // @ts-ignore (whatever "as" is, it should expect the "className" prop)
      className: finalclassName,
      ...rest,
    })
  }
  return <button className={finalclassName} {...rest} />
}

export const passing = [
  <Button key="k">hi</Button>,
  <Button key="k" size="md">
    hi
  </Button>,
  <Button key="k" inverse secondary>
    hey
  </Button>,
  <Button key="k" as="a" href="#">
    hi
  </Button>,
  <Button key="k" as="a" href="#" size="md">
    hi
  </Button>,
  <Button key="k" as="a" href="#" inverse secondary>
    hey
  </Button>,
  // would be sweet to be able to do
]
export const failing = [
  <Button key="k" href="#">
    hi
  </Button>,
  <Button key="k" size="blah">
    hi
  </Button>,
  <Button key="k" inverse={2}>
    hey
  </Button>,
  <Button key="k" secondary="true">
    hey
  </Button>,
  // this one would be awesome if there's a way to make this work:
  // <Button key="k" as="a" typo="#">hey</Button>,
]

export { Button }

Tested it with TS 3.2.2 and @types/react v16.7.20 and everything on failing is failing and everything on passing is passing.

@ferdaber
Copy link

Some changes I want to point out:

  • size in CommonProps can just be keyof typeof sizes to only limit it to the properties defined in sizes
  • I changed ButtonAsProps['as'] to satisfy your TODO, and also made it more limited to not just any string
  • I added overload signatures to the Button function

The first two are optional but the third is what should fix your issue.

@kentcdodds
Copy link
Author

Nice! Thanks! This seems to work for me! Now I just gotta figure out why it works 😅

@ferdaber
Copy link

ferdaber commented Jan 16, 2019

Unsure what you were looking for for the commented out test under failing, but typo is allowed there because ButtonAsProps allows any prop to be passed in, if you wanted to limit it, we'd need to convert it to a generic type to grab the allowable props for whatever is passed into as.

@kentcdodds
Copy link
Author

Thanks. I'm pretty sure if I follow this that'll work: microsoft/TypeScript#3960 (comment)

@ferdaber
Copy link

For those who are coming in wondering how to limit the rest of the props in ButtonAsProps, this is one solution:

import React from 'react'

declare function clsx(...args: any[]): string

const sizes = {
  md: 'vx_btn--size_md',
  sm: 'vx_btn--size_sm',
}

type CommonProps = {
  size?: keyof typeof sizes
  inverse?: boolean
  secondary?: boolean
  className?: string
}

type OverrideComponent = React.JSXElementConstructor<{ className?: string }> | keyof JSX.IntrinsicElements

type ButtonOnlyProps = CommonProps & {
  children: React.ReactChild
} & React.ButtonHTMLAttributes<HTMLButtonElement>

type ButtonAsProps<C extends OverrideComponent> = CommonProps & React.ComponentProps<C> & {
  as: C
}

function isButtonAs<C extends OverrideComponent>(props: ButtonOnlyProps | ButtonAsProps<C>): props is ButtonAsProps<C> {
  return (props as ButtonAsProps<C>).as !== undefined
}

function Button(props: ButtonOnlyProps): JSX.Element
function Button<C extends OverrideComponent>(props: ButtonAsProps<C>): JSX.Element
function Button(props: ButtonOnlyProps | ButtonAsProps<any>) {
  const { size, inverse, secondary, className, ...rest } = props
  const finalclassName = clsx(className, 'vx_btn', sizes[size as keyof typeof sizes], {
    'vx_btn--inverse': inverse,
    'vx_btn--secondary': secondary,
  })
  if (isButtonAs(props)) {
    return React.createElement(props.as, {
      // TODO: See if there's a way that we can tell TypeScript that whatever
      // is passed as "as" must be able to accept a "className" prop. That'd be cool
      // @ts-ignore (whatever "as" is, it should expect the "className" prop)
      className: finalclassName,
      ...rest,
    })
  }
  return <button className={finalclassName} {...rest} />
}

export const passing = [
  <Button key="k">hi</Button>,
  <Button key="k" size="md">
    hi
  </Button>,
  <Button key="k" inverse secondary>
    hey
  </Button>,
  <Button key="k" as="a" href="#">
    hi
  </Button>,
  <Button key="k" as="a" href="#" size="md">
    hi
  </Button>,
  <Button key="k" as="a" href="#" inverse secondary>
    hey
  </Button>,
  // would be sweet to be able to do
]
export const failing = [
  <Button key="k" href="#">
    hi
  </Button>,
  <Button key="k" size="blah">
    hi
  </Button>,
  <Button key="k" inverse={2}>
    hey
  </Button>,
  <Button key="k" secondary="true">
    hey
  </Button>,
  // this one would be awesome if there's a way to make this work:
  // <Button key="k" as="a" typo="#">hey</Button>,
]

export { Button }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment