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": "../",

This comment has been minimized.

Copy link
@bradenmacdonald

bradenmacdonald May 29, 2024

Author Contributor

Not sure why, but tsc was complaining that the new bootstrap.ts file was outside of rootDir for the www project (which it is, but so are files like Button/index.tsx which it wasn't complaining about). For now, setting the root dir one level higher is an easy way to ignore the error, which seems like some sort of bug anyways. But debugging these things are complex because of the ways that we have several different projects sharing one repo.

"resolveJsonModule": true,
"noImplicitAny": false,
"paths": {
Expand Down

0 comments on commit 66f85cb

Please sign in to comment.