diff --git a/package-lock.json b/package-lock.json
index 88eeab573a..d72a0fb427 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -82,8 +82,8 @@
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^29.5.10",
- "@types/react": "17.0.0",
- "@types/react-dom": "17.0.11",
+ "@types/react": "^17.0.80",
+ "@types/react-dom": "^17.0.11",
"@types/react-responsive": "^8.0.8",
"@types/react-table": "^7.7.19",
"@types/react-test-renderer": "^18.0.0",
@@ -8438,17 +8438,20 @@
}
},
"node_modules/@types/react": {
- "version": "17.0.0",
- "license": "MIT",
+ "version": "17.0.80",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.80.tgz",
+ "integrity": "sha512-LrgHIu2lEtIo8M7d1FcI3BdwXWoRQwMoXOZ7+dPTW0lYREjmlHl3P0U1VD0i/9tppOuv8/sam7sOjx34TxSFbA==",
"dependencies": {
"@types/prop-types": "*",
+ "@types/scheduler": "^0.16",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "17.0.11",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz",
+ "integrity": "sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/react": "*"
}
diff --git a/package.json b/package.json
index d5054c9267..a3bf75281d 100644
--- a/package.json
+++ b/package.json
@@ -123,8 +123,8 @@
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^29.5.10",
- "@types/react": "17.0.0",
- "@types/react-dom": "17.0.11",
+ "@types/react": "^17.0.80",
+ "@types/react-dom": "^17.0.11",
"@types/react-responsive": "^8.0.8",
"@types/react-table": "^7.7.19",
"@types/react-test-renderer": "^18.0.0",
diff --git a/src/Button/Button.test.jsx b/src/Button/Button.test.tsx
similarity index 84%
rename from src/Button/Button.test.jsx
rename to src/Button/Button.test.tsx
index ebe19e0b55..8a3abc3af8 100644
--- a/src/Button/Button.test.jsx
+++ b/src/Button/Button.test.tsx
@@ -30,7 +30,7 @@ describe('', () => {
it('renders with props iconAfter and size', () => {
const tree = renderer.create((
-
+
)).toJSON();
expect(tree).toMatchSnapshot();
});
@@ -94,9 +94,21 @@ describe('', () => {
});
test('test button as hyperlink', () => {
- render();
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const ref = (_current: HTMLAnchorElement) => {}; // Check typing of a ref - should not show type errors.
+ render();
expect(screen.getByRole('link').getAttribute('href')).toEqual('https://www.poop.com/💩');
});
});
+
+ test('with size="inline"', () => {
+ const tree = renderer.create((
+
+ 2 items selected.
+
+
+ )).toJSON();
+ expect(tree).toMatchSnapshot();
+ });
});
});
diff --git a/src/Button/ButtonGroup.test.jsx b/src/Button/ButtonGroup.test.tsx
similarity index 100%
rename from src/Button/ButtonGroup.test.jsx
rename to src/Button/ButtonGroup.test.tsx
diff --git a/src/Button/ButtonToolbar.test.jsx b/src/Button/ButtonToolbar.test.tsx
similarity index 100%
rename from src/Button/ButtonToolbar.test.jsx
rename to src/Button/ButtonToolbar.test.tsx
diff --git a/src/Button/__snapshots__/Button.test.jsx.snap b/src/Button/__snapshots__/Button.test.tsx.snap
similarity index 92%
rename from src/Button/__snapshots__/Button.test.jsx.snap
rename to src/Button/__snapshots__/Button.test.tsx.snap
index 863a1f933e..2cfb8b2f61 100644
--- a/src/Button/__snapshots__/Button.test.jsx.snap
+++ b/src/Button/__snapshots__/Button.test.tsx.snap
@@ -55,13 +55,13 @@ exports[` correct rendering renders with props iconAfter 1`] = `
exports[` correct rendering renders with props iconAfter and size 1`] = `
`;
+
+exports[` correct rendering with size="inline" 1`] = `
+
+
+ 2 items selected.
+
+
+
+`;
diff --git a/src/Button/__snapshots__/ButtonGroup.test.jsx.snap b/src/Button/__snapshots__/ButtonGroup.test.tsx.snap
similarity index 100%
rename from src/Button/__snapshots__/ButtonGroup.test.jsx.snap
rename to src/Button/__snapshots__/ButtonGroup.test.tsx.snap
diff --git a/src/Button/__snapshots__/ButtonToolbar.test.jsx.snap b/src/Button/__snapshots__/ButtonToolbar.test.tsx.snap
similarity index 100%
rename from src/Button/__snapshots__/ButtonToolbar.test.jsx.snap
rename to src/Button/__snapshots__/ButtonToolbar.test.tsx.snap
diff --git a/src/Button/index.jsx b/src/Button/index.tsx
similarity index 51%
rename from src/Button/index.jsx
rename to src/Button/index.tsx
index 9ac374aa65..80c3bc3350 100644
--- a/src/Button/index.jsx
+++ b/src/Button/index.tsx
@@ -1,31 +1,55 @@
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 { ComponentWithAsProp } from '../utils/types/bootstrap';
import Icon from '../Icon';
-const Button = React.forwardRef(({
+interface ButtonProps extends Omit {
+ /**
+ * An icon component to render. Example:
+ * ```
+ * import { Close } from '@openedx/paragon/icons';
+ *
+ * ```
+ */
+ iconBefore?: React.ComponentType;
+ /**
+ * An icon component to render. Example:
+ * ```
+ * import { Close } from '@openedx/paragon/icons';
+ *
+ * ```
+ */
+ iconAfter?: React.ComponentType;
+ size?: 'sm' | 'md' | 'lg' | 'inline';
+}
+
+type ButtonType = ComponentWithAsProp<'button', ButtonProps> & { Deprecated?: any };
+
+const Button: ButtonType = React.forwardRef(({
children,
iconAfter,
iconBefore,
+ size,
...props
}, ref) => (
types do not allow 'md' or 'inline', but we do.
{...props}
className={classNames(props.className)}
ref={ref}
>
- {iconBefore && }
+ {iconBefore && }
{children}
- {iconAfter && }
+ {iconAfter && }
));
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 `` element */
@@ -51,10 +75,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,
/** 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,
+ // 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 /iconBefore/iconAfter.
+ // The React TypeScript type definitions are more specific (React.ComponentType vs React.ElementType).
};
Button.defaultProps = {
@@ -66,20 +93,29 @@ Button.defaultProps = {
disabled: false,
};
-function ButtonGroup(props) {
- return ;
-}
-function ButtonToolbar(props) {
- return ;
+// 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 {
+ size?: 'sm' | 'md' | 'lg' | 'inline';
}
+const ButtonGroup: ComponentWithAsProp<'div', ButtonGroupProps> = (
+ React.forwardRef(({ size, ...props }, 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. */
@@ -97,6 +133,12 @@ ButtonGroup.defaultProps = {
size: 'md',
};
+const ButtonToolbar: ComponentWithAsProp<'div', ButtonToolbarProps> = (
+ React.forwardRef((props, ref) => (
+
+ ))
+);
+
ButtonToolbar.propTypes = {
/** An ARIA role describing the button group. */
role: PropTypes.string,
diff --git a/src/Chip/ChipIcon.tsx b/src/Chip/ChipIcon.tsx
index a32692c5ce..87e08aa256 100644
--- a/src/Chip/ChipIcon.tsx
+++ b/src/Chip/ChipIcon.tsx
@@ -8,7 +8,7 @@ import { STYLE_VARIANTS } from './constants';
export interface ChipIconProps {
className: string,
- src: React.ReactElement | Function,
+ src: React.ComponentType,
onClick?: KeyboardEventHandler & MouseEventHandler,
alt?: string,
variant: string,
diff --git a/src/Chip/index.tsx b/src/Chip/index.tsx
index 23abfde5c7..0f78ab2059 100644
--- a/src/Chip/index.tsx
+++ b/src/Chip/index.tsx
@@ -1,5 +1,5 @@
import React, { ForwardedRef, KeyboardEventHandler, MouseEventHandler } from 'react';
-import PropTypes from 'prop-types';
+import PropTypes, { type Requireable } from 'prop-types';
import classNames from 'classnames';
// @ts-ignore
import { requiredWhen } from '../utils/propTypes';
@@ -15,9 +15,9 @@ export interface IChip {
onClick?: KeyboardEventHandler & MouseEventHandler,
className?: string,
variant?: string,
- iconBefore?: React.ReactElement | Function,
+ iconBefore?: React.ComponentType,
iconBeforeAlt?: string,
- iconAfter?: React.ReactElement | Function,
+ iconAfter?: React.ComponentType,
iconAfterAlt?: string,
onIconBeforeClick?: KeyboardEventHandler & MouseEventHandler,
onIconAfterClick?: KeyboardEventHandler & MouseEventHandler,
@@ -111,7 +111,7 @@ Chip.propTypes = {
*
* `import { Check } from '@openedx/paragon/icons';`
*/
- iconBefore: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ iconBefore: PropTypes.elementType as Requireable,
/** Specifies icon alt text. */
iconBeforeAlt: requiredWhen(PropTypes.string, ['iconBefore', 'onIconBeforeClick']),
/** A click handler for the `Chip` icon before. */
@@ -122,7 +122,7 @@ Chip.propTypes = {
*
* `import { Check } from '@openedx/paragon/icons';`
*/
- iconAfter: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ iconAfter: PropTypes.elementType as Requireable,
/** Specifies icon alt text. */
iconAfterAlt: requiredWhen(PropTypes.string, ['iconAfter', 'onIconAfterClick']),
/** A click handler for the `Chip` icon after. */
diff --git a/src/Icon/index.d.ts b/src/Icon/index.d.ts
index 45505bb49a..b9d6f5d746 100644
--- a/src/Icon/index.d.ts
+++ b/src/Icon/index.d.ts
@@ -1,13 +1,15 @@
import React from 'react';
export interface IconProps extends React.ComponentPropsWithoutRef<'span'> {
- src?: React.ReactElement | Function;
+ // Note: React.ComponentType is what we want here. React.ElementType would allow some element type strings like "div",
+ // but we only want to allow components like 'Add' (a specific icon component function/class)
+ src?: React.ComponentType;
svgAttrs?: {
'aria-label'?: string;
'aria-labelledby'?: string;
};
id?: string | null;
- size?: 'xs' | 'sm' | 'md' | 'lg';
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'inline';
className?: string | string[];
hidden?: boolean;
screenReaderText?: React.ReactNode;
diff --git a/src/Icon/index.jsx b/src/Icon/index.jsx
index 6f0a7a3cf3..89403430f2 100644
--- a/src/Icon/index.jsx
+++ b/src/Icon/index.jsx
@@ -74,7 +74,7 @@ Icon.propTypes = {
* An icon component to render.
* Example import of a Paragon icon component: `import { Check } from '@openedx/paragon/icons';`
*/
- src: PropTypes.oneOfType([PropTypes.element, PropTypes.elementType]),
+ src: PropTypes.elementType,
/** HTML element attributes to pass through to the underlying svg element */
svgAttrs: PropTypes.shape({
'aria-label': PropTypes.string,
diff --git a/src/index.d.ts b/src/index.d.ts
index e4050de325..8cd1eaea8b 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -5,6 +5,7 @@
// Things that have types
// // // // // // // // // // // // // // // // // // // // // // // // // // //
export { default as Bubble } from './Bubble';
+export { default as Button, ButtonGroup, ButtonToolbar } from './Button';
export { default as Chip, CHIP_PGN_CLASS } from './Chip';
export { default as ChipCarousel } from './ChipCarousel';
export { default as Hyperlink, HYPER_LINK_EXTERNAL_LINK_ALT_TEXT, HYPER_LINK_EXTERNAL_LINK_TITLE } from './Hyperlink';
@@ -21,7 +22,6 @@ export const Avatar: any; // from './Avatar';
export const AvatarButton: any; // from './AvatarButton';
export const Badge: any; // from './Badge';
export const Breadcrumb: any; // from './Breadcrumb';
-export const Button: any, ButtonGroup: any, ButtonToolbar: any; // from './Button';
export const
Card: any,
CardColumns: any,
diff --git a/src/index.js b/src/index.js
index 1b28bb4c94..0e24d8d85f 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,10 +1,11 @@
-// To keep this file in sync with the .d.ts file, it's in the same order
-// and each line number is the same
+// Keep this file in sync with the .d.ts file (manually). It's in the same order
+// and each line number is the same, to make it easier.
// // // // // // // // // // // // // // // // // // // // // // // // // // //
// Things that have types
// // // // // // // // // // // // // // // // // // // // // // // // // // //
export { default as Bubble } from './Bubble';
+export { default as Button, ButtonGroup, ButtonToolbar } from './Button';
export { default as Chip, CHIP_PGN_CLASS } from './Chip';
export { default as ChipCarousel } from './ChipCarousel';
export { default as Hyperlink, HYPER_LINK_EXTERNAL_LINK_ALT_TEXT, HYPER_LINK_EXTERNAL_LINK_TITLE } from './Hyperlink';
@@ -21,7 +22,6 @@ export { default as Avatar } from './Avatar';
export { default as AvatarButton } from './AvatarButton';
export { default as Badge } from './Badge';
export { default as Breadcrumb } from './Breadcrumb';
-export { default as Button, ButtonGroup, ButtonToolbar } from './Button';
export {
default as Card,
CardColumns,
diff --git a/src/utils/types/bootstrap.test.tsx b/src/utils/types/bootstrap.test.tsx
new file mode 100644
index 0000000000..0346c5d2b4
--- /dev/null
+++ b/src/utils/types/bootstrap.test.tsx
@@ -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 extends BsPropsWithAs {
+ otherProp?: number;
+ }
+
+ it('defines optional bsPrefix, className, and as but no other props', () => {
+ const checkProps = (_props: Props) => {};
+ // 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:
});
+ });
+});
+
+describe('ComponentWithAsProp', () => {
+ interface MyProps extends BsPropsWithAs {
+ customProp?: string;
+ }
+ const MyComponent: ComponentWithAsProp<'div', MyProps> = (
+ React.forwardRef(
+ ({ as: Inner = 'div', ...props }, ref) => ,
+ )
+ );
+
+ // eslint-disable-next-line react/function-component-definition
+ const CustomComponent: React.FC<{ requiredProp: string }> = () => ;
+
+ it('is defined to wrap a 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
= () => {};
+ const divRef: React.RefObject = { current: null };
+ const valid = ;
+ });
+
+ it('is defined to wrap a by default, and rejects unrelated props', () => {
+ const btnRef: React.RefObject
= { current: null };
+ // @ts-expect-error because the ref is to a