Skip to content

Commit

Permalink
feat: re-export bootstrap helpers as ComponentWithAsProp, BsPropsWithAs
Browse files Browse the repository at this point in the history
  • Loading branch information
bradenmacdonald committed May 29, 2024
1 parent 1637767 commit 66f85cb
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 3 deletions.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import classNames from 'classnames';
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';
import type { ComponentWithAsProp } from '../utils/types/bootstrap';
// @ts-ignore - we're not going to bother adding types for the deprecated button
import ButtonDeprecated from './deprecated';

Expand Down
86 changes: 86 additions & 0 deletions src/utils/types/bootstrap.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import React from 'react';
import type { BsPropsWithAs, ComponentWithAsProp } from './bootstrap';

// Note: these are type-only tests. They don't actually do much at runtime; the important checks are at transpile time.

describe('BsPropsWithAs', () => {
interface Props<As extends React.ElementType = 'table'> extends BsPropsWithAs<As> {
otherProp?: number;
}

it('defines optional bsPrefix, className, and as but no other props', () => {
const checkProps = <As extends React.ElementType = 'table'>(_props: Props<As>) => {};
// These are all valid props per the prop definition:
checkProps({ });
checkProps({ bsPrefix: 'bs' });
checkProps({ className: 'foo bar' });
checkProps({ as: 'tr' });
checkProps({ className: 'foo bar', as: 'button', otherProp: 15 });
// But these are all invalid:
// @ts-expect-error
checkProps({ newProp: 10 });
// @ts-expect-error
checkProps({ onClick: () => {} });
// @ts-expect-error
checkProps({ id: 'id' });
// @ts-expect-error
checkProps({ children: <tr /> });
});
});

describe('ComponentWithAsProp', () => {
interface MyProps extends BsPropsWithAs {
customProp?: string;
}
const MyComponent: ComponentWithAsProp<'div', MyProps> = (
React.forwardRef<HTMLDivElement, MyProps>(
({ as: Inner = 'div', ...props }, ref) => <Inner {...props} ref={ref} />,
)
);

// eslint-disable-next-line react/function-component-definition
const CustomComponent: React.FC<{ requiredProp: string }> = () => <span />;

it('is defined to wrap a <div> by default, and accepts related props', () => {
// This is valid - by default it is a DIV so accepts props and ref related to DIV:
const divClick: React.MouseEventHandler<HTMLDivElement> = () => {};
const divRef: React.RefObject<HTMLDivElement> = { current: null };
const valid = <MyComponent ref={divRef} onClick={divClick} customProp="foo" />;
});

it('is defined to wrap a <div> by default, and rejects unrelated props', () => {
const btnRef: React.RefObject<HTMLButtonElement> = { current: null };
// @ts-expect-error because the ref is to a <button> ref, but this is wrapping a <div>
const invalidRef = <MyComponent ref={btnRef} customProp="foo" />;

const btnClick: React.MouseEventHandler<HTMLButtonElement> = () => {};
// @ts-expect-error because the handler is for a <button> event, but this is wrapping a <div>
const invalidClick = <MyComponent onClick={btnClick} />;
});

it('can be changed to wrap a <canvas>, and accepts related props', () => {
const canvasClick: React.MouseEventHandler<HTMLCanvasElement> = () => {};
const canvasRef: React.RefObject<HTMLCanvasElement> = { current: null };
const valid = <MyComponent as="canvas" ref={canvasRef} onClick={canvasClick} customProp="foo" />;
});

it('can be changed to wrap a <canvas>, and rejects unrelated props', () => {
const btnRef: React.RefObject<HTMLButtonElement> = { current: null };
// @ts-expect-error because the ref is to a <button> ref, but this is wrapping an <canvas>
const invalidRef = <MyComponent as="canvas" ref={btnRef} customProp="foo" />;

const btnClick: React.MouseEventHandler<HTMLButtonElement> = () => {};
// @ts-expect-error because the handler is for a <button> event, but this is wrapping an <canvas>
const invalidClick = <MyComponent as="canvas" onClick={btnClick} />;
});

it('can be changed to wrap a custom component, and accepts related props', () => {
const valid = <MyComponent as={CustomComponent} requiredProp="hello" />;
});

it('can be changed to wrap a custom component, and rejects unrelated props', () => {
// @ts-expect-error The onClick prop has not been declared for our custom component.
const valid = <MyComponent as={CustomComponent} requiredProp="hello" onClick={() => {}} />;
});
});
43 changes: 43 additions & 0 deletions src/utils/types/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Types related to bootstrap components
*/
import React from 'react';

import type { BsPrefixProps, BsPrefixRefForwardingComponent } from 'react-bootstrap/esm/helpers';

/**
* Type helper for defining props of a component that wraps a bootstrap
* component. This type defines three props:
* 1. `className`: this component accepts additional CSS classes.
* 2. `bsPrefix`: locally change the class name prefix used for this component.
* 3. `as`: optionally specify which HTML element or Component is used, e.g. `"div"`
*
* This type assumes no `children` are allowed, but you can extend it to allow children.
*/
export type BsPropsWithAs<As extends React.ElementType = React.ElementType> = BsPrefixProps<As>;

/**
* This is a helper that can be used to define the type of a Paragon component
* that accepts an `as` prop.
*
* It:
* - assumes you are using `forwardRef`, and sets the type of the `ref` prop
* to match the type of the component passed in the `as` prop.
* - assumes you are passing all unused props to the component, so adds any
* props from the `as` component type to the props you specify as `Props`.
*
* Example;
* ```
* interface MyProps extends BsPropsWithAs {
* customProp?: string;
* }
* export const MyComponent: ComponentWithAsProp<'div', MyProps> = (
* React.forwardRef<HTMLDivElement, MyProps>(
* ({ as: Inner = 'div', ...props }, ref) => <Inner {...props} ref={ref} />,
* )
* );
* ```
* Note that you need to define the default (e.g. `'div'`) in three different places.
*/
export type ComponentWithAsProp<DefaultElementType extends React.ElementType, Props>
= BsPrefixRefForwardingComponent<DefaultElementType, Props>;
2 changes: 1 addition & 1 deletion www/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"rootDir": "../",
"resolveJsonModule": true,
"noImplicitAny": false,
"paths": {
Expand Down

0 comments on commit 66f85cb

Please sign in to comment.