-
Notifications
You must be signed in to change notification settings - Fork 67
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add typings for <Button> #3080
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -30,7 +30,7 @@ describe('<Button />', () => { | |
|
||
it('renders with props iconAfter and size', () => { | ||
const tree = renderer.create(( | ||
<Button iconAfter={Close} size="md">Button</Button> | ||
<Button iconAfter={Close} size="sm">Button</Button> | ||
)).toJSON(); | ||
expect(tree).toMatchSnapshot(); | ||
}); | ||
|
@@ -94,9 +94,21 @@ describe('<Button />', () => { | |
}); | ||
|
||
test('test button as hyperlink', () => { | ||
render(<Button as={Hyperlink} destination="https://www.poop.com/💩">Button</Button>); | ||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
const ref = (_current: HTMLAnchorElement) => {}; // Check typing of a ref - should not show type errors. | ||
render(<Button as={Hyperlink} ref={ref} destination="https://www.poop.com/💩">Button</Button>); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This shows an example of how passing |
||
expect(screen.getByRole('link').getAttribute('href')).toEqual('https://www.poop.com/💩'); | ||
}); | ||
}); | ||
|
||
test('with size="inline"', () => { | ||
const tree = renderer.create(( | ||
<p> | ||
<span className="mr-1">2 items selected.</span> | ||
<Button variant="link" size="inline">Clear</Button> | ||
</p> | ||
)).toJSON(); | ||
expect(tree).toMatchSnapshot(); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,32 +1,57 @@ | ||
import React from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import PropTypes, { type Requireable } from 'prop-types'; | ||
import classNames from 'classnames'; | ||
import BaseButton from 'react-bootstrap/Button'; | ||
import BaseButtonGroup from 'react-bootstrap/ButtonGroup'; | ||
import BaseButtonToolbar from 'react-bootstrap/ButtonToolbar'; | ||
import BaseButton, { type ButtonProps as BaseButtonProps } from 'react-bootstrap/Button'; | ||
import BaseButtonGroup, { type ButtonGroupProps as BaseButtonGroupProps } from 'react-bootstrap/ButtonGroup'; | ||
import BaseButtonToolbar, { type ButtonToolbarProps } from 'react-bootstrap/ButtonToolbar'; | ||
import { type BsPrefixRefForwardingComponent as ComponentWithAsProp } from 'react-bootstrap/esm/helpers'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [question] Given this pattern of importing For example, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, that's a good idea and I'm happy to move it somewhere shared. Do you have a place in mind or want me to make something up? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. First thing that came to my mind was to rename https://github.com/openedx/paragon/tree/master/src/utils/propTypes to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, I didn't rename I took the opportunity to add lots of documentation to explain how these types work (since the Bootstrap types are pretty obscure), and added in some type-only tests to verify that my understanding of them is correct (and support any future refactors). |
||
// @ts-ignore - we're not going to bother adding types for the deprecated button | ||
import ButtonDeprecated from './deprecated'; | ||
|
||
import Icon from '../Icon'; | ||
|
||
const Button = React.forwardRef(({ | ||
interface ButtonProps extends Omit<BaseButtonProps, 'size'> { | ||
/** | ||
* An icon component to render. Example: | ||
* ``` | ||
* import { Close } from '@openedx/paragon/icons'; | ||
* <Button iconBefore={Close}>Close</Button> | ||
* ``` | ||
*/ | ||
iconBefore?: React.ComponentType; | ||
/** | ||
* An icon component to render. Example: | ||
* ``` | ||
* import { Close } from '@openedx/paragon/icons'; | ||
* <Button iconAfter={Close}>Close</Button> | ||
* ``` | ||
*/ | ||
iconAfter?: React.ComponentType; | ||
size?: 'sm' | 'md' | 'lg' | 'inline'; | ||
} | ||
|
||
type ButtonType = ComponentWithAsProp<'button', ButtonProps> & { Deprecated?: any }; | ||
|
||
const Button: ButtonType = React.forwardRef<HTMLButtonElement, ButtonProps>(({ | ||
children, | ||
iconAfter, | ||
iconBefore, | ||
size, | ||
...props | ||
}, ref) => ( | ||
<BaseButton | ||
size={size as 'sm' | 'lg' | undefined} // Bootstrap's <Button> types do not allow 'md' or 'inline', but we do. | ||
{...props} | ||
className={classNames(props.className)} | ||
ref={ref} | ||
> | ||
{iconBefore && <Icon className="btn-icon-before" size={props.size} src={iconBefore} />} | ||
{iconBefore && <Icon className="btn-icon-before" size={size} src={iconBefore} />} | ||
{children} | ||
{iconAfter && <Icon className="btn-icon-after" size={props.size} src={iconAfter} />} | ||
{iconAfter && <Icon className="btn-icon-after" size={size} src={iconAfter} />} | ||
</BaseButton> | ||
)); | ||
|
||
Button.propTypes = { | ||
...Button.propTypes, | ||
/** Specifies class name to apply to the button */ | ||
className: PropTypes.string, | ||
/** Disables the Button, preventing mouse events, even if the underlying component is an `<a>` element */ | ||
|
@@ -52,10 +77,13 @@ Button.propTypes = { | |
variant: PropTypes.string, | ||
/** An icon component to render. | ||
* Example import of a Paragon icon component: `import { Check } from '@openedx/paragon/icons';` */ | ||
iconBefore: PropTypes.oneOfType([PropTypes.elementType, PropTypes.node]), | ||
iconBefore: PropTypes.elementType as Requireable<React.ComponentType>, | ||
/** An icon component to render. | ||
* Example import of a Paragon icon component: `import { Check } from '@openedx/paragon/icons';` */ | ||
iconAfter: PropTypes.oneOfType([PropTypes.elementType, PropTypes.node]), | ||
iconAfter: PropTypes.elementType as Requireable<React.ComponentType>, | ||
// The 'as' type casting above is required for TypeScript checking, because the 'PropTypes.elementType' type normally | ||
// allows strings as a value (for use cases like 'div') but we don't support that for <Icon />/iconBefore/iconAfter. | ||
// The React TypeScript type definitions are more specific (React.ComponentType vs React.ElementType). | ||
}; | ||
|
||
Button.defaultProps = { | ||
|
@@ -69,20 +97,29 @@ Button.defaultProps = { | |
|
||
Button.Deprecated = ButtonDeprecated; | ||
|
||
function ButtonGroup(props) { | ||
return <BaseButtonGroup {...props} />; | ||
} | ||
function ButtonToolbar(props) { | ||
return <BaseButtonToolbar {...props} />; | ||
// We could just re-export 'ButtonGroup' and 'ButtonToolbar', but we currently | ||
// override them to add propTypes validation at runtime, since most Paragon | ||
// consumers aren't using TypeScript yet. We also force ButtonGroup's 'size' | ||
// prop to accept our custom values of 'md' and 'inline' which are used in | ||
// Paragon but not used in the base Bootstrap classes. | ||
|
||
interface ButtonGroupProps extends Omit<BaseButtonGroupProps, 'size'> { | ||
size?: 'sm' | 'md' | 'lg' | 'inline'; | ||
} | ||
|
||
const ButtonGroup: ComponentWithAsProp<'div', ButtonGroupProps> = ( | ||
React.forwardRef<HTMLButtonElement, ButtonGroupProps>(({ size, ...props }, ref) => ( | ||
<BaseButtonGroup size={size as 'sm' | 'lg'} {...props} ref={ref} /> | ||
)) | ||
); | ||
|
||
ButtonGroup.propTypes = { | ||
/** Specifies element type for this component. */ | ||
as: PropTypes.elementType, | ||
/** An ARIA role describing the button group. */ | ||
role: PropTypes.string, | ||
/** Specifies the size for all Buttons in the group. */ | ||
size: PropTypes.oneOf(['sm', 'md', 'lg']), | ||
size: PropTypes.oneOf(['sm', 'md', 'lg', 'inline']), | ||
/** Display as a button toggle group. */ | ||
toggle: PropTypes.bool, | ||
/** Specifies if the set of Buttons should appear vertically stacked. */ | ||
|
@@ -100,6 +137,12 @@ ButtonGroup.defaultProps = { | |
size: 'md', | ||
}; | ||
|
||
const ButtonToolbar: ComponentWithAsProp<'div', ButtonToolbarProps> = ( | ||
React.forwardRef<HTMLButtonElement, ButtonToolbarProps>((props, ref) => ( | ||
<BaseButtonToolbar {...props} ref={ref} /> | ||
)) | ||
); | ||
|
||
ButtonToolbar.propTypes = { | ||
/** An ARIA role describing the button group. */ | ||
role: PropTypes.string, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just changed this to
sm
for some variation - too many of the tests were just using the samemd
size.